diff --git a/docs/changelog/2026-08-24-e2e-admin-login-lock.md b/docs/changelog/2026-08-24-e2e-admin-login-lock.md new file mode 100644 index 00000000..e0303c91 --- /dev/null +++ b/docs/changelog/2026-08-24-e2e-admin-login-lock.md @@ -0,0 +1 @@ +**E2E-ADMIN-LOGIN-LOCK — unificar logins do usuário de teste no helper com advisory lock (2026-08-24):** fechamento do débito restante do S10 (#767, orig. do simplify do S10 #94). O e2e full mantinha logins do usuário compartilhado `dev@payloadcms.com` fora do ponto único de serialização: o shadow local `adminHeaders` sem lock em `frontend.e2e.spec.ts` (11 call sites, 10 `seedTestUser` por teste) e o `cleanupTestUser` que ainda **deletava** o usuário no `afterAll` do `admin.e2e.spec.ts` no meio da suíte paralela — janela de usuário ausente contra seeds concorrentes e sessão read-modify-write perdida (token perdedor → 403). A entrega removeu o shadow e roteou os 11 logins pelo helper `tests/helpers/adminApi.ts` (`adminHeaders(request, baseURL)`) com `pg_advisory_lock(727001)` — o mesmo lock do `login()` UI — e removeu `cleanupTestUser` (helper morto + afterAll), convergindo o registro do OPS73-FOLLOWUP que já o declarava removido. Verificação local: admin.e2e (5/5) e frontend.e2e (20/20) verdes; corrida exercitada em run com cadeia completa (falhas colaterais da família campaign isoladas como pré-existentes via `git stash` em main limpo — 5 reprodutíveis + 5 irmãos 1×). Dívida capturada: **#882** (`E2E-FLAKE-CAST-ROTATIVA-POS-OPS83`, depends #767) — cast rotativa de flakes da família campaign. diff --git a/docs/plans/e2e-admin-login-lock-impl.md b/docs/plans/e2e-admin-login-lock-impl.md new file mode 100644 index 00000000..e634a041 --- /dev/null +++ b/docs/plans/e2e-admin-login-lock-impl.md @@ -0,0 +1,92 @@ +# Impl: E2E-ADMIN-LOGIN-LOCK — Unificar os logins do usuário de teste no helper `adminHeaders` com advisory lock + +Status: rascunho +Atualizado em: 2026-08-24 +Issue: #767 +Intenção: body da Issue #767 (dívida restante do S10, fechamento parcial no #805) +Appetite restante: herdado (dívida de teste) — ~0,25 dia, mudança mecânica em 2 arquivos + +## Leitura da intenção + +- **Outcome:** todos os logins do usuário de teste (`dev@payloadcms.com`) no e2e — REST e UI — passam pelo mesmo ponto de serialização (`adminHeaders` com `pg_advisory_lock(727001)`), e o usuário compartilhado nunca é apagado no meio da suíte paralela. A corrida read-modify-write de sessão (token perdedor → 403 em toda requisição admin) deixa de existir entre os specs restantes. +- **O que NÃO negociar:** nenhum spec deixa de ser autossuficiente; nenhum projeto/worker é serializado (a serialização é por-lock, não por topologia); o login UI do admin (`login.ts`) continua testando o formulário genuinamente (não vira API login). +- **O que reavaliar:** o changelog `docs/changelog/2026-08-23-ops73-followup.md` registra `cleanupTestUser` como já removido — mas o código o mantém vivo (`seedUser.ts:42-54`, chamado em `admin.e2e.spec.ts:20`). Esta entrega converge código e registro documental. + +## Abordagem recomendada + +**Remover o shadow local de `adminHeaders` em `frontend.e2e.spec.ts` e rotear os 11 call sites pelo helper com lock; remover `cleanupTestUser` (helper + afterAll do admin).** + +```mermaid +flowchart LR + subgraph helpers["tests/helpers (owners)"] + advisoryLock["advisoryLock.ts
ADMIN_LOGIN_LOCK_KEY = 727_001
withAdvisoryLock()"] + adminApi["adminApi.ts
adminHeaders(request, baseURL) + lock"] + seedUser["seedUser.ts
seedTestUser() create-if-missing
cleanupTestUser() → REMOVIDO"] + end + subgraph specs + frontend["frontend.e2e.spec.ts
shadow adminHeaders (613-620) → REMOVIDO
11 call sites → adminHeaders(request, baseURL)"] + admin["admin.e2e.spec.ts
login() UI + lock (mantém)
afterAll cleanupTestUser → REMOVIDO"] + locked["campaignNewsletter / campaignHomePixel
já via adminHeaders + lock (intocados)"] + end + advisoryLock --> adminApi + adminApi --> frontend + adminApi --> locked + seedUser --> frontend + seedUser --> admin +``` + +**Opções consideradas:** A (remover o shadow e importar o helper com lock + remover cleanup) | B (envolver o shadow existente com `withAdvisoryLock`, mantendo o bloco de login no spec) | C (lock inline em cada call site do frontend) | D (remover só o cleanup, deixar o shadow) | E (manter cleanup, mas envolver o delete em lock + re-seed) + +**Recomendação:** A — porque o helper `adminHeaders` já é o owner do concern (mesma chave do `login()` UI, throw em login não-ok, unlock no `finally`); o shadow de `frontend.e2e.spec.ts:613-620` é o mesmo bloco pré-lock que `adminApi.ts` refatorou no #805 — mantê-lo seria twin com conhecimento vazando em 2 módulos. E remover `cleanupTestUser`: com credenciais constantes + `seedTestUser` create-if-missing, o delete não cumpre nenhum propósito (o usuário é idêntico a cada run, sem estado por-teste) e só cria a janela de ausência que a intenção quer fechar — além de convergir o registro do OPS73-FOLLOWUP. + +**Rejeitadas:** B (mantém o bloco de login duplicado e o conhecimento espalhado — depth check: o módulo profundo já existe e é `adminApi.ts`; DRY); C (espalha `withAdvisoryLock` em 11 pontos — pass-through raso que o helper já encapsula); D (deixa a corrida que a intenção pede para fechar); E (o lock não fecha a janela: o `seedTestUser` concorrente roda fora do lock, e o delete invalida tokens vivos de workers em voo; adicionar lock a um delete que não precisa existir é custo puro). + +### Componentes / mudanças + +- **`tests/e2e/frontend.e2e.spec.ts`**: importar `adminHeaders` de `../helpers/adminApi`; remover o shadow local (linhas 613-620); trocar os 11 call sites `adminHeaders(request)` → `adminHeaders(request, baseURL)` (linhas 608, 827, 985, 1018, 1099, 1152, 1216, 1306, 1366, 1437, 1505 — `baseURL` já está no escopo do describe, linha 593); remover `testUser` do import da linha 3 (fica órfão — só o shadow o usava). `APIRequestContext` permanece (outros helpers locais: `createTag`, `resetSocialFeedSettings`, `bustSocialFeed`, `cleanupS2Fixtures`, …). +- **`tests/e2e/admin.e2e.spec.ts`**: remover `cleanupTestUser` do import (linha 3) e o `test.afterAll` (linhas 19-21). Resto intocado — REST já via `adminHeaders` (51, 148) e UI via `login()` com lock (16). +- **`tests/helpers/seedUser.ts`**: remover `cleanupTestUser` (linhas 39-54). `seedTestUser` fica como está. +- **Migration:** sem migration (mudança de helpers de teste). +- **Access / Consent:** n/a (teste). +- **UI:** n/a (teste). + +## Fases verificáveis + +1. **Fase 1 — Unificar os logins do frontend** (`tests/e2e/frontend.e2e.spec.ts`): novo import `adminHeaders`; remover shadow; atualizar os 11 call sites; limpar import `testUser`. → `pnpm lint`, `pnpm typecheck` (e `pnpm knip` — pega o export morto se algo escapar). +2. **Fase 2 — Remover `cleanupTestUser`** (`admin.e2e.spec.ts` + `seedUser.ts`): remover afterAll/import e a função. → `pnpm lint`, `pnpm knip`. +3. **Fase 3 — Gates e verificação da serialização:** `pnpm gate:fast`; e2e alvo `pnpm test:e2e --no-deps --project=frontend` e `pnpm test:e2e --no-deps --project=admin` (ou por arquivo); rodada full local com `PLAYWRIGHT_WORKERS=4` (a corrida só se demonstra com projetos em paralelo — mesma topologia do deploy `verify`); registrar entrada em `docs/changelog/-e2e-admin-login-lock.md` (converge a discrepância do OPS73-FOLLOWUP); push via `pnpm push`. + +## Rabbit holes / Não escopo (engenharia) + +- Auth `campaignUser` (`campaignE2EFixtures`, `campaignHttpTest.ts:56`) — coleção de auth separada, sem sessão read-modify-write compartilhada; fora do escopo. +- `playwright.config.ts` (fullyParallel, workers, cadeia de projetos) — é a topologia que expõe a corrida; NÃO serializar projetos nem subir workers. +- `seedTestUser()` espalhado (10 no frontend + admin/newsletter/pixel): manter — create-if-missing é idempotente com credenciais constantes (o perdedor do unique-conflict procede); NÃO envolver em lock (não é write read-modify-write). Gatilho de revisitação: se o usuário de teste ganhar credenciais não-constantes, virar fixture de projeto em vez de seed por teste. +- NÃO trocar o login UI do admin por API login (o spec testa o formulário). +- NÃO promover o advisory lock a mecanismo de produção (comentário do próprio `advisoryLock.ts`). + +## Riscos e mitigação + +- **Corrida do afterAll cleanup vs seeds concorrentes:** eliminada pela remoção do delete. Gate de review: nunca reintroduzir delete do usuário compartilhado (o changelog já documenta a remoção como intenção). +- **Janela de usuário ausente:** some junto (sem delete, sem janela; create-if-missing é idempotente). +- **Custo de serialização:** 11 logins do frontend agora passam pelo lock; cada lock é ~ms e o describe já é `serial` — impacto negligenciável contra o custo de 403 retries. +- **Mudança de semântica de falha:** o shadow usava `expect(login.ok()).toBeTruthy()` (asserção Playwright); o helper lança `Error` — ambos rejeitam a promise, então `.catch(() => undefined)` (linha 985) e os `try/finally` dos specs seguem válidos. +- **Import órfão `testUser`:** removido na Fase 1; knip/lint pegam se escapar. +- **Flake pré-existente não relacionado no verify full:** classe conhecida (timeouts de `locator.check`, C142) — não bloqueia esta entrega; o PR roda o conjunto curado. + +## Aceite de engenharia + +- [ ] Aceite de produto da intenção ainda coberto (todos os logins do usuário de teste serializam no mesmo lock; cleanup não apaga o usuário compartilhado) +- [ ] Invariantes AGENTS/engineering-standards (reusa o owner existente `adminApi.ts` — sem twin; zero schema/access/UI tocados) +- [ ] Testes de domínio previstos: e2e alvo (frontend + admin) e full-4-workers local / verify CI (sem unit/int — mudança confinada a helpers de teste) + +--- + +## Self-score decision-quality (gate ≥4) + +1. **Decisões caras têm rejeitadas?** Sim — D1 (remover shadow vs envolver vs lock nos call sites) e D2 (remover cleanup vs lock+re-seed vs manter) com opções e motivos registrados. 1/1 +2. **Abordagem cabe no appetite?** Sim — ~0,25 dia, mecânico, 2 arquivos, reusa 100% da infra do #805. 1/1 +3. **Rabbit holes nomeados?** Sim — auth campaignUser, topologia do config, seedTestUser sem lock (com gatilho de revisitação), login UI genuíno. 1/1 +4. **Depth check: reusa shells/helpers existentes?** Sim — zero helper novo; `adminHeaders`/`advisoryLock` já são o módulo profundo; removemos o twin (shadow) em vez de criar mais um. 1/1 +5. **Intenção permanece satisfeita?** Sim — a engenharia (lock no helper + sem delete) é exatamente a ação pedida no body da Issue, sem reescrever o outcome. 1/1 + +**Score: 5/5.** Ponto único de fragilidade: D2 depende de "nenhum outro consumidor de `cleanupTestUser`" — verificado por grep (único call site: `admin.e2e.spec.ts:20`) e coberto pelo `pnpm knip` da Fase 2. diff --git a/docs/plans/e2e-flake-cast-rotativa-pos-ops83-impl.md b/docs/plans/e2e-flake-cast-rotativa-pos-ops83-impl.md new file mode 100644 index 00000000..8cd3cc75 --- /dev/null +++ b/docs/plans/e2e-flake-cast-rotativa-pos-ops83-impl.md @@ -0,0 +1,43 @@ +# Plano curto: E2E-FLAKE-CAST-ROTATIVA — flakes reprodutíveis da família campaign pós-OPS83 + +Status: rascunho · Issue: (registrada via agent:register) · Débito capturado na entrega #767 (2026-08-24) + +## Problema + +O e2e full (deploy `verify`) e runs locais encontram uma cast rotativa de flakes na família `campaign`: no rerun do #767, 5 testes falharam consistentemente em **main limpo** (não relacionados àquela entrega) e 5 irmãos flakearam 1×: + +**Reprodutíveis em main limpo:** +- `campaignPeople.e2e.spec.ts:180` (C131) — `campaignPeople.e2e.spec.ts:212`: opção `Todas as zonas` do aggregate Salvador não aparece (stream/aggregate). +- `campaignMunicipalities.e2e.spec.ts:387` (B176) — filtro por Dobradinha/Liderança/Partido no omnibox; tocado 2× pelo OPS83 (#824) e ainda vermelho. +- `campaignColumnPicker.e2e.spec.ts:146` (B197) — assessor ganha o picker com e-mail oculto. +- `campaignConcepts.e2e.spec.ts:15` — staff chega na página de conceitos via tooltip/popover. +- `campaignActivity.e2e.spec.ts:51` — cria compromisso com demandas vinculadas pelo overlay. + +**Flakes 1× (passaram no rerun):** `campaignHomeActions:435`, `campaignHomePixel:78`, `campaignMunicipalities:650`, `campaignNearestMunicipality:178`, `campaignPeopleHttp:88/115`. + +## Já resolvido — não reabrir + +- C142 / `campaignMunicipalities:115` (`locator.check` timeouts) — curado pelo #808 (`e2e-full-flake-c142mun`, 21/21 no battle-test). +- Discrepância do `cleanupTestUser`/shadow `adminHeaders` — curada pela própria #767. + +## Abordagem + +Linha spec-only (padrão provado pela casa de flakes): settle/poll com `waitForStreamSettled`/`expect.poll`/`expectPostResponse`, reuso do owner existente. Sem migration, sem mudança de app. + +Fases por ROI: +1. B176 `campaignMunicipalities:387` (pista mais quente — 2× tocado pelo OPS83 e ainda vermelho) +2. C131 `campaignPeople:180` (aggregate/stream) +3. `campaignConcepts:15` +4. `campaignActivity:51` +5. B197 `campaignColumnPicker:146` +6. Re-rodar família + aceite de 2 runs limpos + +## Explicitamente fora + +- `seedTestUser` fora do lock (create-if-missing idempotente) — gatilho: usuário de teste ganhar credenciais não-constantes → virar fixture de projeto (anotado no `e2e-admin-login-lock-impl.md`). +- #763 (C113) e #790 (OPS46-S3) — pais/superfícies distintos. + +## Riscos + +- Cast rotativa pode girar entre o plano e a execução — fase 6 (re-run da família) é o aceite. +- Cada fase é independente; a cast não bloqueia entregas individuais (PR roda blast radius, full fica no verify manual). diff --git a/tests/e2e/admin.e2e.spec.ts b/tests/e2e/admin.e2e.spec.ts index 3dae3bba..9ce1a1e1 100644 --- a/tests/e2e/admin.e2e.spec.ts +++ b/tests/e2e/admin.e2e.spec.ts @@ -1,6 +1,6 @@ import { adminHeaders } from '../helpers/adminApi' import { login } from '../helpers/login' -import { cleanupTestUser, seedTestUser, testUser } from '../helpers/seedUser' +import { seedTestUser, testUser } from '../helpers/seedUser' import { instagramStubUrlFor } from '../helpers/socialStub' import { expect, test } from './fixtures/e2eTest' @@ -16,10 +16,6 @@ test.describe('Admin Panel', () => { await login({ page, serverURL: baseURL, user: testUser }) }) - test.afterAll(async () => { - await cleanupTestUser() - }) - test('can navigate to dashboard', async ({ page }) => { await page.goto(`${baseURL}/admin`) await expect(page).toHaveURL(`${baseURL}/admin`) diff --git a/tests/e2e/frontend.e2e.spec.ts b/tests/e2e/frontend.e2e.spec.ts index 35cf09a1..f46eecbf 100644 --- a/tests/e2e/frontend.e2e.spec.ts +++ b/tests/e2e/frontend.e2e.spec.ts @@ -1,6 +1,7 @@ import type { APIRequestContext, Locator, Page } from '@playwright/test' -import { seedTestUser, testUser } from '../helpers/seedUser' +import { adminHeaders } from '../helpers/adminApi' +import { seedTestUser } from '../helpers/seedUser' import { instagramStubUrlFor, youtubeStubUrlFor } from '../helpers/socialStub' import { expect, test } from './fixtures/e2eTest' @@ -605,20 +606,11 @@ test.describe('Campaign home content section', () => { // Start every run with the social feed unconfigured so the empty-state // test stays deterministic even when a previous S2 run left settings or a // persisted snapshot behind (same reasoning as the `posts` bust below). - const headers = await adminHeaders(request) + const headers = await adminHeaders(request, baseURL) await resetSocialFeedSettings(request, headers) await bustSocialFeed(request) }) - const adminHeaders = async (request: APIRequestContext): Promise> => { - const login = await request.post(`${baseURL}/api/users/login`, { - data: { email: testUser.email, password: testUser.password }, - }) - expect(login.ok()).toBeTruthy() - const { token } = await login.json() - return { cookie: `payload-token=${token}` } - } - const createTag = async ( request: APIRequestContext, headers: Record, @@ -824,7 +816,7 @@ test.describe('Campaign home content section', () => { request, }) => { await seedTestUser() - const headers = await adminHeaders(request) + const headers = await adminHeaders(request, baseURL) const visibleTag = await createTag(request, headers, categoryName, categorySlug) const hiddenTag = await createTag(request, headers, hiddenTagName, hiddenTagSlug, true) @@ -982,7 +974,7 @@ test.describe('Campaign home content section', () => { await page.goto('about:blank') await expect(page).toHaveURL(/about:blank/) } finally { - const headers2 = await adminHeaders(request).catch(() => undefined) + const headers2 = await adminHeaders(request, baseURL).catch(() => undefined) if (headers2) { for (const id of createdPosts) { await request @@ -1015,7 +1007,7 @@ test.describe('Campaign home content section', () => { request, }) => { await seedTestUser() - const headers = await adminHeaders(request) + const headers = await adminHeaders(request, baseURL) await setYouTubeStubState(request, 'ok') const mixTag = await createTag(request, headers, `E2e Mix ${runSuffix}`, `e2e-mix-${runSuffix}`) @@ -1096,7 +1088,7 @@ test.describe('Campaign home content section', () => { request, }) => { await seedTestUser() - const headers = await adminHeaders(request) + const headers = await adminHeaders(request, baseURL) await setYouTubeStubState(request, 'fail') const fallbackTag = await createTag( @@ -1149,7 +1141,7 @@ test.describe('Campaign home content section', () => { test('keeps the last snapshot while the API is down', async ({ page, request }) => { await seedTestUser() - const headers = await adminHeaders(request) + const headers = await adminHeaders(request, baseURL) await setYouTubeStubState(request, 'ok') const baseSettings = { enabled: true, @@ -1213,7 +1205,7 @@ test.describe('Campaign home content section', () => { request, }) => { await seedTestUser() - const headers = await adminHeaders(request) + const headers = await adminHeaders(request, baseURL) await setYouTubeStubState(request, 'ok') await setInstagramStubState(request, 'ok') @@ -1303,7 +1295,7 @@ test.describe('Campaign home content section', () => { request, }) => { await seedTestUser() - const headers = await adminHeaders(request) + const headers = await adminHeaders(request, baseURL) await setInstagramStubState(request, 'fail') const fallbackTag = await createTag( @@ -1363,7 +1355,7 @@ test.describe('Campaign home content section', () => { test('keeps the last Instagram snapshot while the API is down', async ({ page, request }) => { await seedTestUser() - const headers = await adminHeaders(request) + const headers = await adminHeaders(request, baseURL) await setInstagramStubState(request, 'ok') const baseSettings = { enabled: true, @@ -1434,7 +1426,7 @@ test.describe('Campaign home content section', () => { request, }) => { await seedTestUser() - const headers = await adminHeaders(request) + const headers = await adminHeaders(request, baseURL) await setYouTubeStubState(request, 'ok') await setInstagramStubState(request, 'ok') @@ -1502,7 +1494,7 @@ test.describe('Campaign home content section', () => { context, }) => { await seedTestUser() - const headers = await adminHeaders(request) + const headers = await adminHeaders(request, baseURL) await setYouTubeStubState(request, 'ok') await setInstagramStubState(request, 'ok') diff --git a/tests/helpers/seedUser.ts b/tests/helpers/seedUser.ts index e251aedd..f3059de5 100644 --- a/tests/helpers/seedUser.ts +++ b/tests/helpers/seedUser.ts @@ -35,20 +35,3 @@ export async function seedTestUser(): Promise { // A parallel worker already seeded the shared user; nothing to reset. } } - -/** - * Cleans up test user after tests - */ -export async function cleanupTestUser(): Promise { - assertTestDatabase(process.env.DATABASE_URL) - const payload = await getPayload({ config }) - - await payload.delete({ - collection: 'users', - where: { - email: { - equals: testUser.email, - }, - }, - }) -}