diff --git a/docs/CHANGELOG-AGENTS.md b/docs/CHANGELOG-AGENTS.md index 09cb8f3f..d3c3b362 100644 --- a/docs/CHANGELOG-AGENTS.md +++ b/docs/CHANGELOG-AGENTS.md @@ -4,6 +4,8 @@ Conteúdo movido do `AGENTS.md` em 2026-07-30 (fatiamento do paradigma de agente --- +**E2E-ADMIN-LOGIN-LOCK — beforeEach UI login do admin ignora o advisory lock do usuário compartilhado (2026-08-23):** o fix do #760 (`adminHeaders` com `pg_advisory_lock(727001)`) serializou os logins REST do usuário `dev@payloadcms.com`, mas o `beforeEach` de `tests/e2e/admin.e2e.spec.ts` seguia logando pela UI (form, helper `login()`) sem lock — mesma fila de sessões read-modify-write do Payload, aberta a colisão worker(UI) × worker(adminHeaders lock) → 403 no e2e full (4 workers). Fix: novo `tests/helpers/advisoryLock.ts` exporta `ADMIN_LOGIN_LOCK_KEY = 727_001` e `withAdvisoryLock` (guarda `assertTestDatabase`, lock/unlock com `finally` defensivo); `adminHeaders` (adminApi.ts) e `login()` (login.ts) passam a compartilhar a mesma chave — UI e API serializam entre si — e os dois logins REST inline não-lockados do spec (social-feed seeder e sync) agora usam `adminHeaders`. Sem swap UI→API, sem serialização de projeto/workers, mudança de schema ou UI. Verificação: typecheck/lint/format verdes, e2e admin (5) + campaignHomePixel/campaignNewsletter (7) locais verdes; `gate:push` na entrega; PR em main com auto-merge. Débito registrado: #811 (shadow local de `adminHeaders` em frontend.e2e.spec.ts). + **C140 — Formulários de campanha não apagam mais o digitado em erro (React 19 form reset) (2026-08-23):** fix manual dispatch em 18 formulários de `/campanha` que usavam `
`: campos não controlados voltavam ao defaultValue após qualquer action assentada (sucesso ou erro) do React 19. O fix segue o precedente C139 — `onSubmit` + `preventDefault` + `startTransition` — que mantém `isPending` correto sem reentrar no plumbing de form action. `WizardStepFormChrome` foi alterado uma vez para cobrir 3 wizard steps; os demais 15 forms receberam dispatch manual arquivo a arquivo. `AdvisorPasswordResetButton` (2 micro-forms sem campo digitável) ficou como está. Unit spec novo `campaignWizardStepFormChrome.unit.spec.tsx` cobre o chrome. **S12 — âncora `#novidades` quebra no mobile — deep link não leva ao formulário (2026-08-23):** o deep link `jorgesolla1313.com.br/#novidades` (usado para direcionar tráfego orgânico/pago/WhatsApp para a captura) abria a home de campanha no topo no celular: a home é dona do container de scroll interno (`h-dvh overflow-y-auto`, body `overflow-hidden`), então a navegação nativa por fragmento não aterrissa no alvo, e no mobile o reflow do `h-dvh` + imagens eager do hero deslocam o layout após o scroll inicial. Fix contido: novo componente cliente `CampaignHashScroll` (renderiza null, montado no layout da home) que (1) no load direto com hash retenta o scroll até a seção entrar no viewport (rAF + timeouts, parando na primeira interação do usuário — wheel/touch/pointer/teclas de scroll) e (2) ouve `hashchange` para âncoras in-page (CTA do hero, `#bandeiras`, back/forward) sem tocar em `CampaignHero` (continua server component). `el.scrollIntoView()` sem `behavior` herda `scroll-smooth`/`motion-reduce:scroll-auto` do container — `prefers-reduced-motion` respeitado com zero JS. Sem mudança de visual, schema, formulário S9 ou modelo de scroll de outras rotas. E2E novos: describe mobile 390×844 (load direto, CTA, reduced-motion via `emulateMedia`) + load direto desktop (antes sem cobertura), com o teste de load direto desktop em describe próprio para não criar corrida no `beforeAll` de consent do describe de captura (purge+create não seguro sob workers paralelos). Verificação: lint/typecheck/unit + e2e `campaignNewsletter` e `campaignHomePixel` verdes locais; `gate:push` na entrega; PR em main com auto-merge. diff --git a/docs/changelog/2026-08-23-e2e-admin-login-lock.md b/docs/changelog/2026-08-23-e2e-admin-login-lock.md new file mode 100644 index 00000000..45065856 --- /dev/null +++ b/docs/changelog/2026-08-23-e2e-admin-login-lock.md @@ -0,0 +1 @@ +**E2E-ADMIN-LOGIN-LOCK — beforeEach UI login do admin ignora o advisory lock do usuário compartilhado (2026-08-23):** o fix do #760 (`adminHeaders` com `pg_advisory_lock(727001)`) serializou os logins REST do usuário `dev@payloadcms.com`, mas o `beforeEach` de `tests/e2e/admin.e2e.spec.ts` seguia logando pela UI (form, helper `login()`) sem lock — mesma fila de sessões read-modify-write do Payload, aberta a colisão worker(UI) × worker(adminHeaders lock) → 403 no e2e full (4 workers). Fix: novo `tests/helpers/advisoryLock.ts` exporta `ADMIN_LOGIN_LOCK_KEY = 727_001` e `withAdvisoryLock` (guarda `assertTestDatabase`, lock/unlock com `finally` defensivo); `adminHeaders` (adminApi.ts) e `login()` (login.ts) passam a compartilhar a mesma chave — UI e API serializam entre si — e os dois logins REST inline não-lockados do spec (social-feed seeder e sync) agora usam `adminHeaders`. Sem swap UI→API, sem serialização de projeto/workers, mudança de schema ou UI. Verificação: typecheck/lint/format verdes, e2e admin (5) + campaignHomePixel/campaignNewsletter (7) locais verdes; `gate:push` na entrega; PR em main com auto-merge. Débito registrado: #811 (shadow local de `adminHeaders` em frontend.e2e.spec.ts). diff --git a/docs/plans/admin-login-lock-e2e-beforeeach-ui-login-impl.md b/docs/plans/admin-login-lock-e2e-beforeeach-ui-login-impl.md new file mode 100644 index 00000000..a1fcf72e --- /dev/null +++ b/docs/plans/admin-login-lock-e2e-beforeeach-ui-login-impl.md @@ -0,0 +1,74 @@ +# Impl: E2E-ADMIN-LOGIN-LOCK — Serializar o login de UI do beforeEach do admin e2e + +Status: aprovado (pendente gate humano) +Atualizado em: 2026-08-23 +Issue: #805 +Intenção: body da Issue #805 (dívida capturada no #760) +Appetite restante: ~0,25 dia + +## Leitura da intenção + +- **Outcome:** O `beforeEach` (UI form login) de `tests/e2e/admin.e2e.spec.ts` deve serializar com o mesmo advisory lock `727001` que `adminHeaders` (REST) já usa, para eliminar a janela de colisão worker(admin UI) × worker(adminHeaders lock) que pode perder sessão → 403 no e2e full. +- **O que NÃO negociar:** não trocar o login UI por API login (o spec testa genuinamente o formulário); não serializar o projeto inteiro/workers; não tocar o retry `campaignHomePixel` 403→relogin. +- **O que reavaliar:** além do `beforeEach`, o spec tem dois logins REST inline não-lockados (linhas 50 e 152) — mesma classe de corrida; rotear por `adminHeaders` fecha o spec inteiro. + +## Abordagem recomendada + +**Extrair um helper `withAdvisoryLock` + chave compartilhada `727001`; serializar o `login()` UI; e rotear os logins REST inline do spec por `adminHeaders`.** + +```mermaid +flowchart LR + subgraph helpers + advisoryLock.ts[advisoryLock.ts
ADMIN_LOGIN_LOCK_KEY=727001
withAdvisoryLock()] + login.ts[login.ts
UI form + withAdvisoryLock] + adminApi.ts[adminApi.ts
adminHeaders usa withAdvisoryLock] + end + subgraph spec + admin.e2e.spec.ts[beforeEach login() UI
+:50,:152 REST via adminHeaders] + end + advisoryLock.ts --> login.ts + advisoryLock.ts --> adminApi.ts + login.ts --> admin.e2e.spec.ts + adminApi.ts --> admin.e2e.spec.ts +``` + +**Opções consideradas:** A (helper + chave única + owner `login()` + rotear inline do spec) | B (duplicar bloco pg dentro de `login()`) | C (envolver `login()` no `beforeEach`) | D (não serializar inline REST do spec) + +**Recomendação:** A — porque a chave precisa ser a mesma nos dois caminhos (UI × API fecham a corrida entre si); o helper evita twin do boilerplate pg; o lock dentro de `login()` deixa o owner do concern coberto para futuros chamadores; e os dois logins REST inline do spec são a mesma classe de corrida. + +**Rejeitadas:** B (cria twin; o padrão já vive em `adminHeaders`); C (empurra responsabilidade para o spec, owner descobreto); D (mantém janela aberta dentro do próprio spec). + +### Componentes / mudanças + +- **`tests/helpers/advisoryLock.ts`** (novo): `ADMIN_LOGIN_LOCK_KEY = 727_001` + `withAdvisoryLock(key, fn)` — abre `Client({ connectionString: process.env.DATABASE_URL })`, `pg_advisory_lock($1)` antes, unlock+end no `finally` (`.catch` no unlock, espelhando `adminApi.ts:37-40`). +- **`tests/helpers/adminApi.ts`**: refatorar `adminHeaders` para usar `withAdvisoryLock(ADMIN_LOGIN_LOCK_KEY, ...)`; remover const local `:8` e o client inline. +- **`tests/helpers/login.ts`**: envolver a submissão do formulário com `withAdvisoryLock(ADMIN_LOGIN_LOCK_KEY, ...)` em `try/finally` (lock antes do submit, release após dashboard renderizado, garantido mesmo se asserção falhar). +- **`tests/e2e/admin.e2e.spec.ts`**: substituir os dois `request.post(.../api/users/login)` inline (linhas 50-55, 152-157) por `adminHeaders(request, baseURL)`; importar de `../helpers/adminApi`. +- **Migration:** sem migration. +- **Access / Consent:** n/a (teste). +- **UI:** n/a (teste). + +## Fases verificáveis + +1. **Fase 1 — Helper:** criar `advisoryLock.ts`; refatorar `adminApi.ts`. → `pnpm typecheck`, `pnpm lint`. +2. **Fase 2 — Serializar UI login:** envolver `login.ts` com `withAdvisoryLock`. → `pnpm typecheck`, `pnpm lint`. +3. **Fase 3 — Inline REST do spec:** rotear por `adminHeaders`; remover bloco inline órfão. → `pnpm lint`. +4. **Fase 4 — Verificação (OPS72):** rodar admin e2e isolado e o full com 4 workers; confirmar ausência de 403 por sessão perdida. + +## Rabbit holes / Não escopo (engenharia) + +- NÃO trocar UI login por API login; NÃO serializar projeto/workers; NÃO tocar `campaignHomePixel` retry. +- **Fora:** `frontend.e2e.spec.ts:613` (shadow local não-lockado de `adminHeaders`) — próprio plano, não agravado por este fix. +- Sem migration/schema/UI; só `tests/helpers/*` e `tests/e2e/admin.e2e.spec.ts`. + +## Riscos e mitigação + +- **Release do lock:** lock fica preso até o dashboard renderizar; `finally` garante release em falha de asserção (evita travar o próximo worker). +- **Timing novo:** `login()` abre conexão pg por login; impacto mínimo (dezenas por run). +- **`withAdvisoryLock` é só p/ serialização de teste `_test`** — nunca promover a lock de produção. + +## Aceite de engenharia + +- [x] Aceite de produto da intenção ainda coberto (UI login serializado na mesma chave) +- [x] Invariantes AGENTS/engineering-standards (reusa padrão existente; sem twin) +- [x] Testes de domínio: verificação via e2e admin isolado + full-4-workers diff --git a/tests/e2e/admin.e2e.spec.ts b/tests/e2e/admin.e2e.spec.ts index eeb06e7b..3dae3bba 100644 --- a/tests/e2e/admin.e2e.spec.ts +++ b/tests/e2e/admin.e2e.spec.ts @@ -1,3 +1,4 @@ +import { adminHeaders } from '../helpers/adminApi' import { login } from '../helpers/login' import { cleanupTestUser, seedTestUser, testUser } from '../helpers/seedUser' import { instagramStubUrlFor } from '../helpers/socialStub' @@ -47,12 +48,7 @@ test.describe('Admin Panel', () => { // Seed the social-feed global with an Instagram snapshot so the exclusion // picker (S3) has posts to show. The snapshot is plain data — the picker // lists it and writes `excludedItems` (the same mechanism YouTube uses). - 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() - const headers = { cookie: `payload-token=${token}` } + const headers = await adminHeaders(request, baseURL) const snapshot = { username: 'depjorgesolla', @@ -149,12 +145,7 @@ test.describe('Admin Panel', () => { } await setStub('ok') - 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() - const headers = { cookie: `payload-token=${token}` } + const headers = await adminHeaders(request, baseURL) const seed = await request.post(`${baseURL}/api/globals/social-feed-settings`, { headers, diff --git a/tests/helpers/adminApi.ts b/tests/helpers/adminApi.ts index 311c21c1..33df2a0e 100644 --- a/tests/helpers/adminApi.ts +++ b/tests/helpers/adminApi.ts @@ -1,31 +1,20 @@ import type { APIRequestContext } from '@playwright/test' -import { Client } from 'pg' +import { ADMIN_LOGIN_LOCK_KEY, withAdvisoryLock } from './advisoryLock' import { testUser } from './seedUser' -// Arbitrary fixed key — every spec logs the same test user in through this -// helper, so one serialization point covers the whole suite. -const ADMIN_LOGIN_LOCK_KEY = 727_001 - /** * Logs the shared test user in and returns the `payload-token` cookie. * - * Serialized by a Postgres advisory lock: Payload keeps auth sessions as a - * read-modify-write array on the user document (the campaign WebAuthn note - * documents the exact class), so two parallel workers logging the same - * `dev@payloadcms.com` in concurrently can lose one session row — the loser's - * token then resolves to nobody and every admin request answers 403. The - * lock makes the read-modify-write exclusive; a waiting worker logs in after - * the winner, keeping every session. + * Serialized by the Postgres advisory lock from `advisoryLock.ts` (see the + * helper for the read-modify-write session class). The UI `login()` helper + * shares the same lock key, so both login paths serialize against each other. */ export const adminHeaders = async ( request: APIRequestContext, baseURL: string, -): Promise> => { - const client = new Client({ connectionString: process.env.DATABASE_URL }) - await client.connect() - try { - await client.query('SELECT pg_advisory_lock($1)', [ADMIN_LOGIN_LOCK_KEY]) +): Promise> => + withAdvisoryLock(ADMIN_LOGIN_LOCK_KEY, async () => { const login = await request.post(`${baseURL}/api/users/login`, { data: { email: testUser.email, password: testUser.password }, }) @@ -34,8 +23,4 @@ export const adminHeaders = async ( } const { token } = await login.json() return { cookie: `payload-token=${token}` } - } finally { - await client.query('SELECT pg_advisory_unlock($1)', [ADMIN_LOGIN_LOCK_KEY]).catch(() => {}) - await client.end() - } -} + }) diff --git a/tests/helpers/advisoryLock.ts b/tests/helpers/advisoryLock.ts new file mode 100644 index 00000000..31dac07d --- /dev/null +++ b/tests/helpers/advisoryLock.ts @@ -0,0 +1,35 @@ +import { Client } from 'pg' + +import { assertTestDatabase } from './assertTestDatabase' + +/* + * Arbitrary fixed key — every spec logs the shared test user + * (`dev@payloadcms.com`) through this serialization point, so one key covers + * the whole suite: the REST `adminHeaders` helper and the UI `login()` form + * both acquire it. Single shared namespace on purpose (Postgres advisory + * locks are global per database); if a second lock is ever needed, register + * its key here instead of inventing an integer ad hoc. + */ +export const ADMIN_LOGIN_LOCK_KEY = 727_001 + +/** + * Runs `fn` while holding a Postgres advisory lock. + * + * The class it serializes: Payload keeps auth sessions as a read-modify-write + * array on the user document, so two parallel workers logging the same user + * in concurrently can lose one session row — the loser's token then resolves + * to nobody and every admin request answers 403. Test-only serialization; + * never promote this to a production lock mechanism. + */ +export const withAdvisoryLock = async (key: number, fn: () => Promise): Promise => { + assertTestDatabase(process.env.DATABASE_URL) + const client = new Client({ connectionString: process.env.DATABASE_URL }) + await client.connect() + try { + await client.query('SELECT pg_advisory_lock($1)', [key]) + return await fn() + } finally { + await client.query('SELECT pg_advisory_unlock($1)', [key]).catch(() => {}) + await client.end().catch(() => {}) + } +} diff --git a/tests/helpers/login.ts b/tests/helpers/login.ts index 959d5fd2..d063116a 100644 --- a/tests/helpers/login.ts +++ b/tests/helpers/login.ts @@ -1,6 +1,8 @@ import type { Page } from '@playwright/test' import { expect } from '@playwright/test' +import { ADMIN_LOGIN_LOCK_KEY, withAdvisoryLock } from './advisoryLock' + export interface LoginOptions { page: Page serverURL?: string @@ -12,6 +14,11 @@ export interface LoginOptions { /** * Logs the user into the admin panel via the login page. + * + * Serialized by the Postgres advisory lock from `advisoryLock.ts` (same key + * the REST `adminHeaders` helper uses) — the UI form mutates the same + * read-modify-write session array, so a parallel worker logging via + * `adminHeaders` concurrently could lose one session row → 403. */ export async function login({ page, @@ -20,12 +27,14 @@ export async function login({ }: LoginOptions): Promise { await page.goto(`${serverURL}/admin/login`) - await page.fill('#field-email', user.email) - await page.fill('#field-password', user.password) - await page.click('button[type="submit"]') + await withAdvisoryLock(ADMIN_LOGIN_LOCK_KEY, async () => { + await page.fill('#field-email', user.email) + await page.fill('#field-password', user.password) + await page.click('button[type="submit"]') - await page.waitForURL(`${serverURL}/admin`) + await page.waitForURL(`${serverURL}/admin`) - const dashboardArtifact = page.getByRole('heading', { name: 'Coleções', exact: true }) - await expect(dashboardArtifact).toBeVisible() + const dashboardArtifact = page.getByRole('heading', { name: 'Coleções', exact: true }) + await expect(dashboardArtifact).toBeVisible() + }) }