mirror of
https://github.com/fsolla/teqo.git
synced 2026-11-12 22:13:05 +00:00
fix(OPS83-recurr): cast rotativa do verify #16 — serializar admin atrás do frontend (stub IG compartilhado) + notificação pula recipient sumido em vez de abortar transação
This commit is contained in:
parent
6f6e9fb4b4
commit
3b32dbc814
8 changed files with 163 additions and 14 deletions
1
docs/changelog/2026-08-24-ops83-recurr.md
Normal file
1
docs/changelog/2026-08-24-ops83-recurr.md
Normal file
|
|
@ -0,0 +1 @@
|
|||
**OPS83-recurr — deploy verify run #16 falhou de novo: cast rotativa + bug real de notificação (2026-08-24):** o run #16 (HEAD pós-OPS83) mostrou a cast rotativa que a intenção antecipou: 1 failed (admin:134, 3 retries) + 7 flaky, incluindo 3 classes já tocadas na OPS83 e classes novas. Fixes: (1) **admin:134 (hard fail)** — corrida cross-project real: os specs IG do `frontend` e o painel de sync IG do `admin` compartilham o stub do Instagram e o global `social-feed-settings`; em prod mode os projects rodavam concorrentes (sem dependências), então um teste IG do frontend virava o stub/global enquanto o admin:134 seedava e sincronizava — fix config-only: o project `admin` volta a depender de `frontend` **também em prod mode** (serializa os únicos dois projects que compartilham estado IG); (2) **newsletter:136** — o mesmo bug de regex `/#novidades$/` do :222 que ficou no sibling de captura (miss de família) — `/#novidades/` sem `$`; (3) **B176** — settle do stream também DEPOIS de cada clique de option (não só após remove-chip), o clique de Liderança corria o stream da navegação de facet-add; (4) **advisor-scopes Média** — poll de presença do campo `Média` antes do fill (o painel de pledges streama); (5) **B34** — gate `S:` após o `networkidle` da lista de lideranças (o chip da célula cai depois); (6) **C143 revoke** — o save de responsáveis é server-action otimista: agora espera o POST aterrissar via `expectPostResponse` antes de trocar para o peer, e a asserção de 404 navega via `about:blank` (quebra a cache RSC do router, que servia a demanda acessível após o cookie swap); (7) **bug real de app descoberto (fix com unit pin):** `createCampaignNotifications` insería notificações para recipient IDs já deletados por escritor paralelo (FK `notification_recipient_id_campaign_user_id_fk`) — e um segundo caso com `recipient_id` null — abortando a transação hospedeira inteira (`notifyMunicipalityUpdateCreated` crashou sob 4 workers; mesmo hazard em produção ao remover assessor durante fan-out). Fix: revalidação de existência dos recipients (`payload.find` em `campaignUser`, `overrideAccess: true`, mesmo `req`) + filtro de ids não-finitos antes do fan-out — recipient sumido é pulado, nunca um kill de transação. Sem mudança de schema/contrato público/acesso/Consent; 4 unit pins novos (`campaignNotificationRecipientGuard.unit.spec.ts`). Verificação: unit 2447, int 830, famílias afetadas verdes em dev (2× grupo C106/C143/AgendaFeed, 2× B176/advisors/newsletter), admin isolado verde em `E2E_PROD=1`; lint/tsc/format/knip/cycles verdes.
|
||||
|
|
@ -182,7 +182,13 @@ export default defineConfig({
|
|||
{
|
||||
name: 'admin',
|
||||
testMatch: /admin\.e2e\.spec\.ts/,
|
||||
dependencies: isProdMode ? [] : ['frontend'],
|
||||
// Serialize behind `frontend` in BOTH modes: admin's Instagram sync
|
||||
// panel and the frontend IG feed specs share the instagram stub and the
|
||||
// `social-feed-settings` global (single server process). Drop the
|
||||
// dependency in prod and the two projects run concurrently, letting a
|
||||
// frontend IG test flip the stub/global while admin:134 seeds and syncs
|
||||
// (OPS83 run #16: hard-failed all 3 retries on the shared stub state).
|
||||
dependencies: ['frontend'],
|
||||
fullyParallel: false,
|
||||
use: { ...devices['Desktop Chrome'], channel: 'chromium' },
|
||||
},
|
||||
|
|
|
|||
|
|
@ -67,11 +67,32 @@ export const createCampaignNotifications = async (
|
|||
input: Omit<CreateCampaignNotificationInput, 'recipientID'>,
|
||||
req?: NotificationWriteRequest,
|
||||
): Promise<void> => {
|
||||
const uniqueRecipients = [...new Set(recipientIDs)]
|
||||
if (uniqueRecipients.length === 0) return
|
||||
const candidates = [...new Set(recipientIDs)].filter((id) => Number.isFinite(id))
|
||||
if (candidates.length === 0) return
|
||||
|
||||
// A resolved recipient can be concurrently deleted (a staff member removed
|
||||
// from a município's `advisors`, or a parallel writer's cleanup) between the
|
||||
// recipient resolution and this insert. Inserting for a stale id aborts the
|
||||
// WHOLE host transaction with an FK violation — observed crashing
|
||||
// `notifyMunicipalityUpdateCreated` under the parallel e2e suite (OPS83 run
|
||||
// #16) and the same hazard exists in production when an advisor is removed
|
||||
// while a notification fan-out is in flight. Re-validate existence before
|
||||
// the fan-out so a vanished recipient is skipped, never a transaction kill.
|
||||
const existing = await payload.find({
|
||||
collection: 'campaignUser',
|
||||
where: { id: { in: candidates } },
|
||||
depth: 0,
|
||||
limit: 500,
|
||||
pagination: false,
|
||||
overrideAccess: true,
|
||||
req,
|
||||
})
|
||||
const existingIDs = new Set(existing.docs.map((doc) => doc.id))
|
||||
const recipients = candidates.filter((id) => existingIDs.has(id))
|
||||
if (recipients.length === 0) return
|
||||
|
||||
await Promise.all(
|
||||
uniqueRecipients.map((recipientID) =>
|
||||
recipients.map((recipientID) =>
|
||||
createCampaignNotification(
|
||||
payload,
|
||||
{
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import {
|
||||
campaignPageChrome,
|
||||
expect,
|
||||
expectPostResponse,
|
||||
test,
|
||||
type CampaignE2EFixture,
|
||||
} from './fixtures/campaignE2EFixtures.js'
|
||||
|
|
@ -92,16 +93,23 @@ test.describe('C143 — demand visibility (explicit responsibles)', () => {
|
|||
await campaign.sessionFor(context, responsible)
|
||||
await page.goto(demandURL)
|
||||
await page.getByRole('button', { name: `Remover ${peer.name}` }).click()
|
||||
await page.getByRole('button', { name: 'Salvar responsáveis' }).click()
|
||||
// The responsibles save is an optimistic server-action write — wait for the
|
||||
// POST to land BEFORE swapping to the peer, or the peer's navigation can
|
||||
// race the revoke (OPS83 run #16: the accessible demand still served).
|
||||
await Promise.all([
|
||||
expectPostResponse(page, demandURL),
|
||||
page.getByRole('button', { name: 'Salvar responsáveis' }).click(),
|
||||
])
|
||||
await expect(page.getByRole('button', { name: `Remover ${peer.name}` })).toHaveCount(0)
|
||||
|
||||
// The peer already rendered THIS URL (granted, line 88); the Next router
|
||||
// cache serves the previous RSC payload on a plain goto even after the
|
||||
// cookie swap (OPS83 run #15: the accessible demand showed instead of the
|
||||
// 404). The route ignores unknown query params — bust the cache so the
|
||||
// revoked access is observed from the server.
|
||||
// The peer already rendered THIS URL (granted, line 88), so the Next RSC
|
||||
// cache can serve the previous payload on a plain goto even after the
|
||||
// cookie swap (OPS83 run #15/#16). Break the router context through a
|
||||
// blank page so the 404 boundary is fetched fresh from the server.
|
||||
await campaign.sessionFor(context, peer)
|
||||
await page.goto(`${demandURL}?e2e=${Date.now()}`)
|
||||
await page.goto('about:blank')
|
||||
await expect(page).toHaveURL(/about:blank/)
|
||||
await page.goto(demandURL)
|
||||
await expect(page.getByText('This page could not be found.')).toBeVisible()
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -81,6 +81,10 @@ test.describe('campaign leaderships list', () => {
|
|||
await page.goto(`${campaign.baseURL}/campanha/liderancas?q=${encodeURIComponent(contactName)}`)
|
||||
await expect(campaignPageChrome(page, 'Lideranças')).toBeVisible()
|
||||
await page.waitForLoadState('networkidle')
|
||||
// The cell chips stream with the list's RSC chunk; under 4-worker load the
|
||||
// chip link can land after `networkidle` settles (OPS83 run #16: B34
|
||||
// flaked on the initial chip). Let the stream commit before asserting.
|
||||
await page.waitForFunction(() => document.querySelectorAll('div[id^="S:"]').length === 0)
|
||||
|
||||
// The chip is still a link to the município — editing did not cost the
|
||||
// navigation the column had before.
|
||||
|
|
|
|||
|
|
@ -443,6 +443,7 @@ test.describe('Municípios — jornadas por papel', () => {
|
|||
// Dobradinha by contact name → chip + only municipality A remains.
|
||||
await omnibox.fill(deputyName)
|
||||
await listbox.getByRole('option', { name: new RegExp(`^${deputyName} \\(BR176\\)$`) }).click()
|
||||
await settleMunicipalityStream(page)
|
||||
await expect(removeChip(`Dobradinha: ${deputyName} (BR176)`)).toBeVisible()
|
||||
await expect(tableRows).toHaveCount(1)
|
||||
await expect(page.getByRole('link', { name: municipalityA.name, exact: true })).toBeVisible()
|
||||
|
|
@ -457,6 +458,7 @@ test.describe('Municípios — jornadas por papel', () => {
|
|||
// Partido by acronym → the same single municipality (its only dobradinha).
|
||||
await omnibox.fill('BR176')
|
||||
await listbox.getByRole('option', { name: 'BR176', exact: true }).click()
|
||||
await settleMunicipalityStream(page)
|
||||
await expect(removeChip('Partido: BR176')).toBeVisible()
|
||||
await expect(tableRows).toHaveCount(1)
|
||||
await expect(page.getByRole('link', { name: municipalityA.name, exact: true })).toBeVisible()
|
||||
|
|
@ -468,6 +470,7 @@ test.describe('Municípios — jornadas por papel', () => {
|
|||
// Liderança by contact name → municipality B only.
|
||||
await omnibox.fill(leadership.contactName)
|
||||
await listbox.getByRole('option', { name: new RegExp(`^${leadership.contactName}$`) }).click()
|
||||
await settleMunicipalityStream(page)
|
||||
await expect(removeChip(`Liderança: ${leadership.contactName}`)).toBeVisible()
|
||||
await expect(tableRows).toHaveCount(1)
|
||||
await expect(page.getByRole('link', { name: municipalityB.name, exact: true })).toBeVisible()
|
||||
|
|
@ -732,7 +735,12 @@ test.describe('Municípios — jornadas por papel', () => {
|
|||
|
||||
// Advisor records an internal estimate on the overview pledges panel.
|
||||
await page.goto(`${campaign.baseURL}/campanha/municipios/${administered.slug}`)
|
||||
await page.getByLabel('Média', { exact: true }).fill('90')
|
||||
// The overview pledges panel streams under load; the `Média` estimate
|
||||
// field can land after `goto` settles (OPS83 run #16: fill timed out).
|
||||
await settleMunicipalityStream(page)
|
||||
const estimateInput = page.getByLabel('Média', { exact: true })
|
||||
await expect.poll(() => estimateInput.count(), { timeout: 20_000 }).toBeGreaterThan(0)
|
||||
await estimateInput.fill('90')
|
||||
await page.getByLabel('Justificativa').fill('Histórico da região indica menos.')
|
||||
await page.getByRole('button', { name: 'Salvar estimativa' }).click()
|
||||
await expect(page.getByText('Média: 90')).toBeVisible()
|
||||
|
|
|
|||
|
|
@ -144,9 +144,11 @@ test.describe('Campaign home novidades capture', () => {
|
|||
section.getByRole('heading', { name: 'Receba as novidades da campanha' }),
|
||||
).toBeVisible()
|
||||
|
||||
// The hero CTA is the shortcut into the section (smooth anchor).
|
||||
// The hero CTA is the shortcut into the section (smooth anchor). The URL
|
||||
// legitimately carries the `?e2e=…` cache-buster query (OPS83), so assert
|
||||
// only the hash — no trailing `$`.
|
||||
await page.locator('[data-cta="secondary"]').click()
|
||||
await expect(page).toHaveURL(/#novidades$/)
|
||||
await expect(page).toHaveURL(/#novidades/)
|
||||
await expect(section).toBeInViewport()
|
||||
|
||||
// The toggle is pre-selected: the default level is "time".
|
||||
|
|
|
|||
99
tests/unit/campaignNotificationRecipientGuard.unit.spec.ts
Normal file
99
tests/unit/campaignNotificationRecipientGuard.unit.spec.ts
Normal file
|
|
@ -0,0 +1,99 @@
|
|||
import type { Payload } from 'payload'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { stub } from '../helpers/stub'
|
||||
|
||||
/**
|
||||
* OPS83 — the notification fan-out must skip recipients whose `campaignUser`
|
||||
* no longer resolves, instead of inserting and crashing the host transaction
|
||||
* with an FK violation. Observed aborting `notifyMunicipalityUpdateCreated`
|
||||
* under the parallel e2e suite (run #16) — a deleted advisor left the insert
|
||||
* referencing a vanished row; the same hazard exists in production when a
|
||||
* staff member is removed mid-fan-out.
|
||||
*/
|
||||
|
||||
const { sendMock, createMock, findMock } = vi.hoisted(() => ({
|
||||
sendMock: vi.fn(),
|
||||
createMock: vi.fn(async (args: { data?: Record<string, unknown> }) => ({
|
||||
id: 0,
|
||||
doc: args.data,
|
||||
})),
|
||||
findMock: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/utilities/notification/sendCampaignPush', () => ({
|
||||
sendCampaignPushForNotification: sendMock,
|
||||
}))
|
||||
|
||||
vi.mock('@/utilities/payloadTransaction', () => ({
|
||||
onPayloadTransactionCommit: vi.fn(),
|
||||
}))
|
||||
|
||||
import { createCampaignNotifications } from '@/utilities/notification/createCampaignNotification'
|
||||
|
||||
describe('createCampaignNotifications (stale-recipient guard, OPS83)', () => {
|
||||
const payload = stub<Payload>({
|
||||
find: findMock as unknown as Payload['find'],
|
||||
create: createMock as unknown as Payload['create'],
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('skips recipients that no longer resolve to a campaignUser', async () => {
|
||||
findMock.mockResolvedValue({ docs: [{ id: 7 }, { id: 9 }] })
|
||||
|
||||
await createCampaignNotifications(payload, [7, 9, 999], {
|
||||
type: 'municipality_update',
|
||||
payload: { title: 't', detail: 'd', href: '/x' },
|
||||
})
|
||||
|
||||
expect(findMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
collection: 'campaignUser',
|
||||
where: { id: { in: [7, 9, 999] } },
|
||||
}),
|
||||
)
|
||||
// 999 did not resolve — never inserted.
|
||||
expect(createMock).not.toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
data: expect.objectContaining({ recipient: 999 }),
|
||||
}),
|
||||
)
|
||||
expect(createMock).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('drops non-finite recipient ids from the candidate set', async () => {
|
||||
findMock.mockResolvedValue({ docs: [{ id: 5 }] })
|
||||
|
||||
await createCampaignNotifications(payload, [5, Number.NaN], {
|
||||
type: 'new_supporter',
|
||||
payload: { title: 't', detail: 'd', href: '/x' },
|
||||
})
|
||||
|
||||
expect(findMock).toHaveBeenCalledWith(expect.objectContaining({ where: { id: { in: [5] } } }))
|
||||
expect(createMock).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('is a no-op when no recipient resolves', async () => {
|
||||
findMock.mockResolvedValue({ docs: [] })
|
||||
|
||||
await createCampaignNotifications(payload, [404], {
|
||||
type: 'activity_attention',
|
||||
payload: { title: 't', detail: 'd', href: '/x' },
|
||||
})
|
||||
|
||||
expect(createMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('is a no-op for an empty candidate set without querying', async () => {
|
||||
await createCampaignNotifications(payload, [], {
|
||||
type: 'municipality_update',
|
||||
payload: { title: 't', detail: 'd', href: '/x' },
|
||||
})
|
||||
|
||||
expect(findMock).not.toHaveBeenCalled()
|
||||
expect(createMock).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
Loading…
Reference in a new issue