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,
- },
- },
- })
-}