# Instructions

- Following Playwright test failed.
- Explain why, be concise, respect Playwright best practices.
- Provide a snippet of code with the fix, if possible.

# Test info

- Name: 60-invitations.spec.ts >> invitations: existing account continues login, explicitly accepts, discovers and selects staff store
- Location: e2e-scheduling-real/60-invitations.spec.ts:95:5

# Error details

```
Error: expect(received).toMatchObject(expected)

- Expected  - 1
+ Received  + 0

  Object {
    "role": "sales",
-   "status": "active",
    "store_id": "2411257c-52d5-3a6e-932a-e14cbd30c516",
  }
```

```
Error: page.waitForResponse: Test ended.
```

# Page snapshot

```yaml
- generic [active] [ref=e1]:
  - generic [ref=e3]:
    - link "Alqove" [ref=e4] [cursor=pointer]:
      - /url: /
      - img "Alqove" [ref=e5]
    - generic [ref=e7]:
      - heading "Store invitation" [level=1] [ref=e8]
      - paragraph [ref=e9]: Signed in as e2e-0d4345c5b94c18e8.invite-recipient@example.test. Accept only if this is the account invited to your store.
      - button "Please wait…" [disabled]
      - button "Cancel invitation" [disabled]
  - button "Open Next.js Dev Tools" [ref=e15] [cursor=pointer]:
    - img [ref=e16]
  - alert [ref=e19]: Store invitation | Alqove
```

# Test source

```ts
  11  | 
  12  | type InviteActor = { id: string; email: string; password: string };
  13  | type Invite = { id: string; store_id: string; invited_user_id: string; token: string };
  14  | type Invites = {
  15  |   actors: { recipient: InviteActor; rejected: InviteActor };
  16  |   invitations: { accepted: Invite; wrong: Invite; revoked: Invite };
  17  | };
  18  | type Readback = {
  19  |   invitations: { id: string; store_id: string; invited_user_id: string; accepted_at: string | null; revoked_at: string | null }[];
  20  |   memberships: { id: string; user_id: string; store_id: string; role: string; status: string }[];
  21  | };
  22  | let invites: Invites;
  23  | 
  24  | function fixtureCommand(command: 'seed' | 'readback'): string {
  25  |   // Pass only the runner's existing allowlisted isolated environment, not the
  26  |   // Playwright worker's ambient environment. PHP independently checks actual PDO.
  27  |   const env = JSON.parse(fs.readFileSync(path.join(runDir, 'environment.json'), 'utf8'));
  28  |   if (env.QA_RUN_DIR !== runDir || env.QA_MARKER !== fixture.marker) throw new Error('Invitation environment identity mismatch');
  29  |   try {
  30  |     return execFileSync('/opt/homebrew/bin/php', [path.join(__dirname, 'invite-fixtures.php'), command], {
  31  |       env, cwd: __dirname, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'],
  32  |     });
  33  |   } catch {
  34  |     // Never rethrow child-process buffers: exception context may contain secrets.
  35  |     throw new Error(`Guarded invitation fixture ${command} failed`);
  36  |   }
  37  | }
  38  | 
  39  | const readback = (): Readback => JSON.parse(fixtureCommand('readback'));
  40  | const isAccept = (response: Response) => new URL(response.url()).pathname === '/v1/invitations/accept' && response.request().method() === 'POST';
  41  | const isDiscovery = (response: Response) => new URL(response.url()).pathname === '/v1/me/memberships' && response.request().method() === 'GET';
  42  | 
  43  | async function landing(page: Page, invite: Invite) {
  44  |   // Deliver the real notification token into the browser's URL before app code
  45  |   // runs. The harmless navigation marker keeps the secret out of Next's HTTP
  46  |   // access log (a literal token-bearing GET would be logged by next dev).
  47  |   // Only fixture delivery is synthetic: capture/scrub, persistence, login,
  48  |   // acceptance, discovery and navigation all run the unmodified application.
  49  |   await page.addInitScript(({ token }) => {
  50  |     if (location.pathname === '/invitations/accept' && location.search === '?qa_invitation_landing=1') {
  51  |       history.replaceState(history.state, '', '/invitations/accept?token=' + encodeURIComponent(token));
  52  |     }
  53  |   }, { token: invite.token });
  54  |   await page.goto('/invitations/accept?qa_invitation_landing=1');
  55  |   await expect(page.getByRole('heading', { name: 'Join your store', exact: true })).toBeVisible();
  56  |   // Assertions contain no raw token even if capture/scrub regresses.
  57  |   expect(await page.evaluate(() => location.pathname === '/invitations/accept' && location.search === '' && location.hash === '')).toBe(true);
  58  |   expect(await page.evaluate(() => !localStorage.getItem('auth_token'))).toBe(true);
  59  |   expect(await page.evaluate(() => !!sessionStorage.getItem('alqove.pending-invitation'))).toBe(true);
  60  |   await expect(page.getByRole('link', { name: 'Sign in', exact: true })).toHaveAttribute('href', '/login?next=/invitations/accept');
  61  | }
  62  | 
  63  | async function continueLogin(page: Page, actor: InviteActor) {
  64  |   await page.getByRole('link', { name: 'Sign in', exact: true }).click();
  65  |   expect(await page.evaluate(() => new URLSearchParams(location.search).get('next') === '/invitations/accept' && !location.search.includes('token'))).toBe(true);
  66  |   try {
  67  |     await page.getByLabel('Email', { exact: true }).fill(actor.email);
  68  |     await page.getByLabel('Password', { exact: true }).fill(actor.password);
  69  |   } catch {
  70  |     throw new Error('Invitation login fields unavailable (credential details suppressed)');
  71  |   }
  72  |   const discovered = page.waitForResponse(isDiscovery);
  73  |   await page.getByRole('button', { name: 'Sign In', exact: true }).click();
  74  |   const discovery = await discovered;
  75  |   expect(discovery.status()).toBe(200);
  76  |   const body = await discovery.json();
  77  |   await page.waitForURL(url => url.pathname === '/invitations/accept' && url.search === '');
  78  |   await expect(page.getByRole('heading', { name: 'Store invitation', exact: true })).toBeVisible();
  79  |   await expect(page.getByText(`Signed in as ${actor.email}.`, { exact: false })).toBeVisible();
  80  |   return body.data as { id: string; store_id: string; role: string; status: string }[];
  81  | }
  82  | 
  83  | function assertUnaccepted(state: Readback, invite: Invite, actor: InviteActor) {
  84  |   const row = state.invitations.find(row => row.id === invite.id);
  85  |   expect(row).toMatchObject({ store_id: invite.store_id, invited_user_id: invite.invited_user_id, accepted_at: null });
  86  |   expect(state.memberships.filter(row => row.user_id === actor.id && row.store_id === invite.store_id)).toEqual([]);
  87  | }
  88  | 
  89  | test.beforeAll(() => {
  90  |   fixtureCommand('seed');
  91  |   invites = JSON.parse(fs.readFileSync(path.join(runDir, 'manifest.json'), 'utf8')).inviteFixtures;
  92  |   if (!invites) throw new Error('Invitation fixture manifest missing');
  93  | });
  94  | 
  95  | test('invitations: existing account continues login, explicitly accepts, discovers and selects staff store', async ({ page }) => {
  96  |   const invite = invites.invitations.accepted;
  97  |   const actor = invites.actors.recipient;
  98  |   let acceptCount = 0;
  99  |   page.on('request', request => {
  100 |     if (new URL(request.url()).pathname === '/v1/invitations/accept' && request.method() === 'POST') acceptCount++;
  101 |   });
  102 |   assertUnaccepted(readback(), invite, actor);
  103 |   await landing(page, invite);
  104 |   const before = await continueLogin(page, actor);
  105 |   expect(before.map(row => row.store_id)).toEqual([fixture.stores.a.id]);
  106 |   expect(acceptCount, 'Login must not implicitly accept an invitation').toBe(0);
  107 |   assertUnaccepted(readback(), invite, actor);
  108 | 
  109 |   // Register both real response observers before the explicit UI command.
  110 |   const accepted = page.waitForResponse(isAccept);
> 111 |   const discovered = page.waitForResponse(isDiscovery);
      |                           ^ Error: page.waitForResponse: Test ended.
  112 |   await page.getByRole('button', { name: 'Accept invitation', exact: true }).click();
  113 |   const acceptance = await accepted;
  114 |   expect(acceptance.status()).toBe(201);
  115 |   const membership = (await acceptance.json()).data;
  116 |   expect(membership).toMatchObject({ store_id: invite.store_id, role: 'sales', status: 'active' });
  117 |   const discovery = await discovered;
  118 |   expect(discovery.status()).toBe(200);
  119 |   const memberships = (await discovery.json()).data as { id: string; store_id: string }[];
  120 |   expect(memberships.map(row => row.store_id).sort()).toEqual([fixture.stores.a.id, fixture.stores.b.id].sort());
  121 |   expect(memberships.find(row => row.id === membership.id)?.store_id).toBe(invite.store_id);
  122 |   await page.waitForURL(url => url.pathname === '/staff');
  123 |   await expect(page.getByRole('heading', { name: 'Your staff workspace', exact: true })).toBeVisible();
  124 |   const store = page.getByRole('combobox', { name: 'Store', exact: true });
  125 |   await expect(store).toHaveValue(invite.store_id);
  126 |   expect(await page.evaluate(id => localStorage.getItem(`selected_store:${id}`), actor.id)).toBe(invite.store_id);
  127 |   expect(await page.evaluate(() => sessionStorage.getItem('alqove.pending-invitation') === null)).toBe(true);
  128 |   const state = readback();
  129 |   const row = state.invitations.find(row => row.id === invite.id)!;
  130 |   expect(row.accepted_at).not.toBeNull();
  131 |   expect(row.revoked_at).toBeNull();
  132 |   const exactMembers = state.memberships.filter(row => row.user_id === actor.id && row.store_id === invite.store_id);
  133 |   expect(exactMembers).toEqual([{ id: membership.id, user_id: actor.id, store_id: invite.store_id, role: 'sales', status: 'active' }]);
  134 |   await page.reload();
  135 |   await expect(page.getByRole('heading', { name: 'Your staff workspace', exact: true })).toBeVisible();
  136 |   await expect(store).toHaveValue(invite.store_id);
  137 |   expect(acceptCount).toBe(1);
  138 |   await evidence('invitations-accepted', { invitation: row, membership: exactMembers[0], discovered_store_ids: memberships.map(row => row.store_id), selected_store_id: invite.store_id, accept_request_count: acceptCount, persisted_after_reload: true, token_cleared: true, token_delivery: 'pre-hydration browser URL; no token-bearing HTTP GET' });
  139 | });
  140 | 
  141 | test('invitations: wrong account fails closed without membership or acceptance', async ({ page }) => {
  142 |   const invite = invites.invitations.wrong;
  143 |   const actor = fixture.actors.outsider as InviteActor;
  144 |   await landing(page, invite);
  145 |   expect(await continueLogin(page, actor)).toEqual([]);
  146 |   const accepted = page.waitForResponse(isAccept);
  147 |   await page.getByRole('button', { name: 'Accept invitation', exact: true }).click();
  148 |   const response = await accepted;
  149 |   expect(response.status()).toBe(422);
  150 |   expect((await response.json()).errors?.token).toContain('This invitation is for a different account.');
  151 |   await expect(page.getByRole('alert')).toContainText('Could not accept this invitation.');
  152 |   await expect(page.getByRole('button', { name: 'Accept invitation', exact: true })).toBeEnabled();
  153 |   expect(new URL(page.url()).pathname).toBe('/invitations/accept');
  154 |   const state = readback();
  155 |   assertUnaccepted(state, invite, actor);
  156 |   assertUnaccepted(state, invite, invites.actors.rejected);
  157 |   expect(await page.evaluate(id => localStorage.getItem(`selected_store:${id}`), actor.id)).toBeNull();
  158 |   await page.goto('/staff');
  159 |   await expect(page.getByText('No active store memberships. Ask your store manager for an invitation.', { exact: true })).toBeVisible();
  160 |   await expect(page.getByRole('navigation', { name: 'Staff', exact: true })).toHaveCount(0);
  161 |   await evidence('invitations-wrong-account', { invitation_id: invite.id, wrong_account_id: actor.id, status: response.status(), database: state, staff_access_denied: true });
  162 | });
  163 | 
  164 | test('invitations: revoked offer fails closed for the invited existing account', async ({ page, request }) => {
  165 |   const invite = invites.invitations.revoked;
  166 |   const actor = invites.actors.rejected;
  167 |   // Revoke via the real API, not a fixture status mutation.
  168 |   await api(request, 'owner', 'DELETE', `/invitations/${invite.id}`, undefined, 204, 'b');
  169 |   const revoked = readback().invitations.find(row => row.id === invite.id)!;
  170 |   expect(revoked.revoked_at).not.toBeNull();
  171 |   expect(revoked.accepted_at).toBeNull();
  172 |   await landing(page, invite);
  173 |   expect(await continueLogin(page, actor)).toEqual([]);
  174 |   const accepted = page.waitForResponse(isAccept);
  175 |   await page.getByRole('button', { name: 'Accept invitation', exact: true }).click();
  176 |   const response = await accepted;
  177 |   expect(response.status()).toBe(422);
  178 |   expect((await response.json()).errors?.token).toContain('This invitation is invalid or has expired.');
  179 |   await expect(page.getByRole('alert')).toContainText('Could not accept this invitation.');
  180 |   expect(new URL(page.url()).pathname).toBe('/invitations/accept');
  181 |   const state = readback();
  182 |   assertUnaccepted(state, invite, actor);
  183 |   expect(state.invitations.find(row => row.id === invite.id)?.revoked_at).toBe(revoked.revoked_at);
  184 |   expect(await page.evaluate(id => localStorage.getItem(`selected_store:${id}`), actor.id)).toBeNull();
  185 |   await page.goto('/staff');
  186 |   await expect(page.getByText('No active store memberships. Ask your store manager for an invitation.', { exact: true })).toBeVisible();
  187 |   await expect(page.getByRole('navigation', { name: 'Staff', exact: true })).toHaveCount(0);
  188 |   await evidence('invitations-revoked', { invitation_id: invite.id, status: response.status(), database: state, staff_access_denied: true });
  189 | });
  190 | 
```