# 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: tests/ui/visitor-token.spec.ts >> donor records are premium >> an anonymous visitor gets the premium notice on Individual Donors
- Location: tests/ui/visitor-token.spec.ts:103:7

# Error details

```
Error: apiRequestContext.get: connect ECONNREFUSED 10.1.105.22:80
Call log:
  - → GET http://10.1.105.22/api/token
    - user-agent: Playwright/1.64.1 (x64; ubuntu 22.04) node/20.9 CI/1
    - accept: */*
    - accept-encoding: gzip,deflate,br

```

# Test source

```ts
  1   | import { test, expect, request as playwrightRequest, type APIRequestContext } from '@playwright/test';
  2   | 
  3   | // Visitor-token gate on /api/graphql (electinfo/sites#277, njs/visitor_token.js):
  4   | // GET /api/token hands every visitor a short-lived token in an HttpOnly cookie;
  5   | // /api/graphql refuses calls without one (401) and lets anon and member tokens
  6   | // send only the site's own persisted documents (403 otherwise). Skipped on a
  7   | // target without the gate (the CI test pod has no keys: /api/token 503).
  8   | 
  9   | // A persisted document (block.ts, precinct.ts and tract.ts send it).
  10  | const LISTED = 'query StateCounties($state: String!) { counties(where: { state: { eq: $state } }, sort: [{ name: ASC }], limit: 500) { id name state state_fips county_fips } }';
  11  | const UNLISTED = '{ counties(limit: 1) { id name } }';
  12  | 
  13  | /** A request context with an empty cookie jar, so a token from one probe does not leak into the next. */
  14  | async function freshContext(baseURL: string | undefined): Promise<APIRequestContext> {
  15  |   return playwrightRequest.newContext({ baseURL });
  16  | }
  17  | 
  18  | async function hasGate(baseURL: string | undefined): Promise<boolean> {
  19  |   const probe = await freshContext(baseURL);
  20  |   try {
> 21  |     return (await probe.get('/api/token')).status() === 204;
      |                         ^ Error: apiRequestContext.get: connect ECONNREFUSED 10.1.105.22:80
  22  |   } finally {
  23  |     await probe.dispose();
  24  |   }
  25  | }
  26  | 
  27  | // js/auth.js requests /api/token before every /api/graphql call that has no
  28  | // token, on every page that loads data. It must never be an error: 204 where the
  29  | // gate runs (preview and production since the prod cutover), 200 from the old stub.
  30  | test('GET /api/token is never an error, with or without the gate', async ({ baseURL }) => {
  31  |   const probe = await freshContext(baseURL);
  32  |   try {
  33  |     const res = await probe.get('/api/token');
  34  |     expect([200, 204]).toContain(res.status());
  35  |     expect(res.headers()['cache-control']).toContain('no-store');
  36  |   } finally {
  37  |     await probe.dispose();
  38  |   }
  39  | });
  40  | 
  41  | test.describe('visitor-token gate', () => {
  42  |   test('GET /api/token sets an HttpOnly token and a readable expiry', async ({ request }) => {
  43  |     const res = await request.get('/api/token');
  44  |     // Without the gate the path falls through to the site (404 or a page); a 5xx is a broken gate, not a skip.
  45  |     test.skip(res.status() !== 204 && res.status() < 500, 'this target has no visitor-token gate');
  46  |     expect(res.status()).toBe(204);
  47  |     expect(res.headers()['cache-control']).toContain('no-store');
  48  |     const cookies = res.headersArray().filter(h => h.name.toLowerCase() === 'set-cookie').map(h => h.value);
  49  |     const token = cookies.find(c => c.startsWith('ei_vt='));
  50  |     const exp = cookies.find(c => c.startsWith('ei_vt_exp='));
  51  |     expect(token).toMatch(/^ei_vt=[\w-]+\.[\w-]+\.[\w-]+; Path=\/api\/; Max-Age=\d+; HttpOnly; SameSite=Strict/);
  52  |     // <exp>.<tier>, then the entitlement key since the identity bar (sites#305): one t/f per restricted section.
  53  |     expect(exp).toMatch(/^ei_vt_exp=\d+\.(anon|member|premium)(\.[tf]+)?; Path=\/;/);
  54  |     expect(exp).not.toContain('HttpOnly');
  55  |   });
  56  | 
  57  |   test('without a token, /api/graphql answers 401', async ({ request, baseURL }) => {
  58  |     test.skip(!(await hasGate(baseURL)), 'this target has no visitor-token gate');
  59  |     const res = await request.post('/api/graphql', { data: { query: LISTED, variables: { state: 'VT' } } });
  60  |     expect(res.status()).toBe(401);
  61  | 
  62  |     const forged = await request.post('/api/graphql', {
  63  |       data: { query: LISTED, variables: { state: 'VT' } },
  64  |       headers: { Cookie: 'ei_vt=eyJhbGciOiJIUzI1NiJ9.eyJ0aWVyIjoicHJlbWl1bSJ9.AAAA' },
  65  |     });
  66  |     expect(forged.status()).toBe(401);
  67  |   });
  68  | 
  69  |   test('an anon token runs persisted documents and nothing else', async ({ request, baseURL }) => {
  70  |     test.skip(!(await hasGate(baseURL)), 'this target has no visitor-token gate');
  71  |     // The token goes into this context's cookie jar (Path=/api/), as in a browser.
  72  |     expect((await request.get('/api/token')).status()).toBe(204);
  73  | 
  74  |     const listed = await request.post('/api/graphql', { data: { query: LISTED, variables: { state: 'VT' } } });
  75  |     expect(listed.status()).toBe(200);
  76  |     const body = await listed.json() as { data?: { counties?: Array<{ state: string }> } };
  77  |     expect(body.data?.counties?.length).toBeGreaterThan(0);
  78  | 
  79  |     // Whitespace does not matter: the gate hashes the collapsed document.
  80  |     const reflowed = await request.post('/api/graphql', {
  81  |       data: { query: LISTED.replace(/ \{ /g, ' {\n    '), variables: { state: 'VT' } },
  82  |     });
  83  |     expect(reflowed.status()).toBe(200);
  84  | 
  85  |     const unlisted = await request.post('/api/graphql', { data: { query: UNLISTED } });
  86  |     expect(unlisted.status()).toBe(403);
  87  |     expect(await unlisted.json()).toEqual({ error: 'query not allowed' });
  88  | 
  89  |     const batch = await request.post('/api/graphql', {
  90  |       data: [{ query: LISTED, variables: { state: 'VT' } }, { query: UNLISTED }],
  91  |     });
  92  |     expect(batch.status(), 'a batch is refused when any document is unlisted').toBe(403);
  93  |   });
  94  | });
  95  | 
  96  | // Donor records are premium (sites#277): an anonymous visitor sees the premium
  97  | // notice instead of donors; the employer's totals stay public.
  98  | test.describe('donor records are premium', () => {
  99  |   test.beforeEach(async ({ baseURL }) => {
  100 |     test.skip(!(await hasGate(baseURL)), 'this target has no visitor-token gate');
  101 |   });
  102 | 
  103 |   test('an anonymous visitor gets the premium notice on Individual Donors', async ({ page }) => {
  104 |     await page.goto('/individuals/');
  105 |     await expect(page.locator('.premium-required')).toBeVisible({ timeout: 30_000 });
  106 |     await expect(page.locator('.entity-search-result')).toHaveCount(0);
  107 |   });
  108 | 
  109 |   test('an anonymous visitor sees employer totals but not donor names', async ({ page }) => {
  110 |     await page.goto('/employers/5f9a1727-2225-3699-685b-8fb15c17c8f3/');
  111 |     await expect(page.locator('#activity')).toBeVisible({ timeout: 30_000 });
  112 |     await expect(page.locator('#donors .premium-required')).toBeVisible();
  113 |     await expect(page.locator('#donors a[href^="/individuals/"]')).toHaveCount(0);
  114 |   });
  115 | });
  116 | 
```