mirror of
https://github.com/fsolla/teqo.git
synced 2026-11-12 22:13:05 +00:00
fix(e2e): serializa o beforeEach UI login do admin com o advisory lock 727001 (Closes #805)
This commit is contained in:
parent
368f138282
commit
cd26fb8ce2
7 changed files with 137 additions and 40 deletions
|
|
@ -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 `<form action={submitAction}>`: 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.
|
||||
|
|
|
|||
1
docs/changelog/2026-08-23-e2e-admin-login-lock.md
Normal file
1
docs/changelog/2026-08-23-e2e-admin-login-lock.md
Normal file
|
|
@ -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).
|
||||
74
docs/plans/admin-login-lock-e2e-beforeeach-ui-login-impl.md
Normal file
74
docs/plans/admin-login-lock-e2e-beforeeach-ui-login-impl.md
Normal file
|
|
@ -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<br/>ADMIN_LOGIN_LOCK_KEY=727001<br/>withAdvisoryLock()]
|
||||
login.ts[login.ts<br/>UI form + withAdvisoryLock]
|
||||
adminApi.ts[adminApi.ts<br/>adminHeaders usa withAdvisoryLock]
|
||||
end
|
||||
subgraph spec
|
||||
admin.e2e.spec.ts[beforeEach login() UI<br/>+: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<T>(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
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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<Record<string, string>> => {
|
||||
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<Record<string, string>> =>
|
||||
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()
|
||||
}
|
||||
}
|
||||
})
|
||||
|
|
|
|||
35
tests/helpers/advisoryLock.ts
Normal file
35
tests/helpers/advisoryLock.ts
Normal file
|
|
@ -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 <T>(key: number, fn: () => Promise<T>): Promise<T> => {
|
||||
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(() => {})
|
||||
}
|
||||
}
|
||||
|
|
@ -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<void> {
|
||||
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()
|
||||
})
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue