teqo-backup/tests/int/campaignMunicipalityUpdate.int.spec.ts

539 lines
18 KiB
TypeScript

// @vitest-environment node
import {
getPayload,
type Payload,
type PayloadRequest,
type RequiredDataFromCollectionSlug,
} from 'payload'
import { beforeAll, describe, expect, it, vi } from 'vitest'
import { createMunicipalityUpdateRecord } from '@/app/(campaign)/campanha/actions/municipalityUpdate'
import { MunicipalityUpdate } from '@/collections/MunicipalityUpdate'
import type { CampaignUser } from '@/payload-types'
import config from '@/payload.config'
import { acquireTextAdvisoryLocks } from '@/utilities/postgresTransactionLocks'
import {
getTestTransactionBackendPID,
waitForAdvisoryLockWaiter,
} from '../helpers/testDatabaseLease'
import { installCampaignFixtures } from '../helpers/campaignFixtures'
import { stub } from '../helpers/stub'
/** Create data with server-managed fields (e.g. `author`) intentionally omitted. */
type MunicipalityUpdateCreateData = RequiredDataFromCollectionSlug<'municipalityUpdate'>
let payload: Payload
const campaignFixtures = installCampaignFixtures({
getPayload: () => payload,
setPayload: (nextPayload) => {
payload = nextPayload
},
})
// Builds the Contact → Leadership graph that controls update visibility and authorship.
const createUpdateAccessLeadershipGraph = async (
coordinator: CampaignUser,
leader: CampaignUser,
municipality: number,
supportStatus: 'engajado' | 'a_abordar',
) => {
const contact = await campaignFixtures().createContact({
name: campaignFixtures().value('Liderança atualização'),
})
return campaignFixtures().createLeadership({
contact,
municipalities: [municipality],
user: leader,
supportStatus,
createdBy: coordinator,
})
}
const listMunicipalityUpdates = (user: CampaignUser, municipalityID: number) =>
payload.find({
collection: 'municipalityUpdate',
where: { municipality: { equals: municipalityID } },
depth: 0,
pagination: false,
sort: '-createdAt',
user,
overrideAccess: false,
})
describe('campaign municipality update domain', () => {
beforeAll(async () => {
payload = await getPayload({ config: await config })
})
it('enforces body and polarity validation through the Local API', async () => {
const coordinator = await campaignFixtures().createCampaignUser('coordinator')
const municipality = await campaignFixtures().getMunicipality()
await expect(
payload.create({
collection: 'municipalityUpdate',
data: stub<MunicipalityUpdateCreateData>({
municipality: municipality.id,
// @ts-expect-error Testing invalid polarity value
polarity: 'invalid',
body: 'Somente uma resposta',
}),
user: coordinator,
overrideAccess: false,
}),
).rejects.toThrow('polaridade')
await expect(
payload.create({
collection: 'municipalityUpdate',
data: stub<MunicipalityUpdateCreateData>({
municipality: municipality.id,
polarity: 'ruim',
urgent: true,
body: ' ',
}),
user: coordinator,
overrideAccess: false,
}),
).rejects.toThrow('texto')
})
it('declares immutable app records and server-managed authorship', async () => {
const coordinator = await campaignFixtures().createCampaignUser('coordinator')
const updateAccess =
typeof MunicipalityUpdate.access?.update === 'function'
? await MunicipalityUpdate.access.update({
id: 1,
data: undefined,
req: stub<PayloadRequest>({ user: coordinator }),
})
: MunicipalityUpdate.access?.update
const deleteAccess =
typeof MunicipalityUpdate.access?.delete === 'function'
? await MunicipalityUpdate.access.delete({
id: 1,
data: undefined,
req: stub<PayloadRequest>({ user: coordinator }),
})
: MunicipalityUpdate.access?.delete
expect(updateAccess).toBe(false)
expect(deleteAccess).toBe(false)
expect(MunicipalityUpdate.fields).toEqual(
expect.arrayContaining([
expect.objectContaining({ name: 'author', relationTo: 'campaignUser', required: true }),
]),
)
})
it('lets the coordinator create and read every update', async () => {
const coordinator = await campaignFixtures().createCampaignUser('coordinator')
const municipality = await campaignFixtures().getMunicipality()
// Forged `author` goes through a widened variable: the schema must strip it.
const forgedInput = {
municipality: municipality.id,
polarity: 'neutra' as const,
body: 'Visitas concluídas',
activeVolunteers: 7,
newSupports: 12,
author: 999,
}
const created = await createMunicipalityUpdateRecord(payload, coordinator, forgedInput)
expect(created.author).toBe(coordinator.id)
const visible = await listMunicipalityUpdates(coordinator, municipality.id)
expect(visible.docs.map(({ id }) => id)).toContain(created.id)
})
it('accepts an adversary update with body', async () => {
const coordinator = await campaignFixtures().createCampaignUser('coordinator')
const municipality = await campaignFixtures().getMunicipality()
const created = await createMunicipalityUpdateRecord(payload, coordinator, {
municipality: municipality.id,
polarity: 'ruim',
adversarySignal: true,
body: 'Visita adversária confirmada.',
})
expect(created.adversarySignal).toBe(true)
expect(created.polarity).toBe('ruim')
expect(created.body).toBe('Visita adversária confirmada.')
})
it('limits advisors to administered municipalities for create and read', async () => {
const advisor = await campaignFixtures().createCampaignUser('advisor')
const otherAdvisor = await campaignFixtures().createCampaignUser('advisor')
const assigned = await campaignFixtures().getMunicipality()
const other = await campaignFixtures().getMunicipality()
await campaignFixtures().assignMunicipalityAdvisors(assigned, [advisor])
await campaignFixtures().assignMunicipalityAdvisors(other, [otherAdvisor])
const created = await createMunicipalityUpdateRecord(payload, advisor, {
municipality: assigned.id,
polarity: 'neutra',
body: 'Atualização permitida',
})
expect((await listMunicipalityUpdates(advisor, assigned.id)).docs).toEqual(
expect.arrayContaining([expect.objectContaining({ id: created.id })]),
)
await expect(
createMunicipalityUpdateRecord(payload, advisor, {
municipality: other.id,
polarity: 'neutra',
body: 'Atualização fora do escopo',
}),
).rejects.toThrow()
await createMunicipalityUpdateRecord(payload, otherAdvisor, {
municipality: other.id,
polarity: 'neutra',
body: 'Atualização alheia',
})
const foreign = await listMunicipalityUpdates(advisor, other.id)
expect(foreign.docs).toHaveLength(0)
})
it('denies leaders from creating municipality updates', async () => {
const coordinator = await campaignFixtures().createCampaignUser('coordinator')
const leader = await campaignFixtures().createCampaignUser('leader')
const municipality = await campaignFixtures().getMunicipality()
await createUpdateAccessLeadershipGraph(coordinator, leader, municipality.id, 'engajado')
await expect(
createMunicipalityUpdateRecord(payload, leader, {
municipality: municipality.id,
polarity: 'ruim',
urgent: true,
body: 'Pedido da própria liderança',
}),
).rejects.toThrow()
})
it('denies leaders read access to municipality updates', async () => {
const coordinator = await campaignFixtures().createCampaignUser('coordinator')
const leader = await campaignFixtures().createCampaignUser('leader')
const municipality = await campaignFixtures().getMunicipality()
await createUpdateAccessLeadershipGraph(coordinator, leader, municipality.id, 'engajado')
const created = await createMunicipalityUpdateRecord(payload, coordinator, {
municipality: municipality.id,
polarity: 'neutra',
body: 'Registro do staff',
})
await expect(
payload.findByID({
collection: 'municipalityUpdate',
id: created.id,
depth: 0,
user: leader,
overrideAccess: false,
}),
).rejects.toThrow(/permissão/i)
await expect(listMunicipalityUpdates(leader, municipality.id)).rejects.toThrow(/permissão/i)
})
it('denies unlinked leader creation', async () => {
const unlinked = await campaignFixtures().createCampaignUser('leader')
const municipality = await campaignFixtures().getMunicipality()
await expect(
createMunicipalityUpdateRecord(payload, unlinked, {
municipality: municipality.id,
polarity: 'neutra',
body: 'Sem vínculo',
}),
).rejects.toThrow()
})
it('denies anonymous create and read operations', async () => {
const municipality = await campaignFixtures().getMunicipality()
await expect(
payload.create({
collection: 'municipalityUpdate',
data: stub<MunicipalityUpdateCreateData>({
municipality: municipality.id,
polarity: 'neutra',
body: 'Tentativa anônima',
}),
overrideAccess: false,
}),
).rejects.toThrow()
await expect(
payload.find({
collection: 'municipalityUpdate',
depth: 0,
overrideAccess: false,
}),
).rejects.toThrow()
})
it('denies actual Local API update and delete operations for every campaign role', async () => {
const coordinator = await campaignFixtures().createCampaignUser('coordinator')
const advisor = await campaignFixtures().createCampaignUser('advisor')
const leader = await campaignFixtures().createCampaignUser('leader')
const municipality = await campaignFixtures().getMunicipality()
await campaignFixtures().assignMunicipalityAdvisors(municipality, [advisor])
await createUpdateAccessLeadershipGraph(coordinator, leader, municipality.id, 'engajado')
const created = await createMunicipalityUpdateRecord(payload, coordinator, {
municipality: municipality.id,
polarity: 'neutra',
body: 'Registro imutável',
})
for (const actor of [coordinator, advisor, leader]) {
await expect(
payload.update({
collection: 'municipalityUpdate',
id: created.id,
data: { body: 'Tentativa de edição' },
user: actor,
overrideAccess: false,
}),
).rejects.toThrow()
await expect(
payload.delete({
collection: 'municipalityUpdate',
id: created.id,
user: actor,
overrideAccess: false,
}),
).rejects.toThrow()
}
})
it('recomputes the latest timestamp after deterministic concurrent creates', async () => {
const coordinator = await campaignFixtures().createCampaignUser('coordinator')
const municipality = await campaignFixtures().getMunicipality()
const created = await Promise.all(
Array.from({ length: 8 }, (_, index) =>
createMunicipalityUpdateRecord(payload, coordinator, {
municipality: municipality.id,
polarity: 'neutra',
body: `Registro concorrente ${index}`,
}),
),
)
const latestCreatedAt = created
.map(({ createdAt }) => createdAt)
.sort((left, right) => right.localeCompare(left))[0]
const persistedMunicipality = await payload.findByID({
collection: 'municipality',
id: municipality.id,
depth: 0,
})
expect(persistedMunicipality.lastUpdateAt).toBe(latestCreatedAt)
})
it('waits on the exact municipality-updates namespace before recomputing derived state', async () => {
const coordinator = await campaignFixtures().createCampaignUser('coordinator')
const municipality = await campaignFixtures().getMunicipality()
const holderID = await payload.db.beginTransaction()
if (holderID === null) throw new Error('Expected a PostgreSQL transaction.')
await acquireTextAdvisoryLocks(payload, { transactionID: holderID }, [
`municipality-updates:${municipality.id}`,
])
const originalBegin = payload.db.beginTransaction.bind(payload.db)
let resolveWaiterPID!: (pid: number) => void
const waiterPID = new Promise<number>((resolve) => {
resolveWaiterPID = resolve
})
const beginSpy = vi.spyOn(payload.db, 'beginTransaction').mockImplementation(async () => {
const transactionID = await originalBegin()
if (transactionID === null) throw new Error('Expected update transaction.')
resolveWaiterPID(await getTestTransactionBackendPID(payload, transactionID))
return transactionID
})
const pending = createMunicipalityUpdateRecord(payload, coordinator, {
municipality: municipality.id,
polarity: 'neutra',
body: 'Registro bloqueado até a liberação',
})
try {
const expectedPID = await waiterPID
await expect(
waitForAdvisoryLockWaiter(payload, {
key: `municipality-updates:${municipality.id}`,
mode: 'ExclusiveLock',
waiterPID: expectedPID,
}),
).resolves.toMatchObject({ granted: false, pid: expectedPID })
await payload.db.rollbackTransaction(holderID)
const created = await pending
await expect(
payload.findByID({ collection: 'municipality', id: municipality.id, depth: 0 }),
).resolves.toMatchObject({ lastUpdateAt: created.createdAt })
} catch (error) {
await payload.db.rollbackTransaction(holderID).catch(() => undefined)
throw error
} finally {
beginSpy.mockRestore()
}
})
it('recomputes after deterministic concurrent deletes of the latest records', async () => {
const coordinator = await campaignFixtures().createCampaignUser('coordinator')
const municipality = await campaignFixtures().getMunicipality()
const created = []
for (let index = 0; index < 4; index += 1) {
created.push(
await createMunicipalityUpdateRecord(payload, coordinator, {
municipality: municipality.id,
polarity: 'neutra',
body: `Registro para exclusão concorrente ${index}`,
}),
)
}
const [latest, secondLatest, expectedLatest] = created.sort((left, right) =>
right.createdAt.localeCompare(left.createdAt),
)
await Promise.all([
payload.delete({
collection: 'municipalityUpdate',
id: latest.id,
depth: 0,
}),
payload.delete({
collection: 'municipalityUpdate',
id: secondLatest.id,
depth: 0,
}),
])
const persistedMunicipality = await payload.findByID({
collection: 'municipality',
id: municipality.id,
depth: 0,
})
expect(persistedMunicipality.lastUpdateAt).toBe(expectedLatest.createdAt)
})
it('recomputes both municipalities after an admin reassigns an update', async () => {
const coordinator = await campaignFixtures().createCampaignUser('coordinator')
const oldMunicipality = await campaignFixtures().getMunicipality()
const newMunicipality = await campaignFixtures().getMunicipality()
const oldRemaining = await createMunicipalityUpdateRecord(payload, coordinator, {
municipality: oldMunicipality.id,
polarity: 'neutra',
body: 'Permanece no município original',
})
const moved = await createMunicipalityUpdateRecord(payload, coordinator, {
municipality: oldMunicipality.id,
polarity: 'neutra',
body: 'Será transferido',
})
await payload.update({
collection: 'municipalityUpdate',
id: moved.id,
data: { municipality: newMunicipality.id },
depth: 0,
})
const [persistedOld, persistedNew] = await Promise.all([
payload.findByID({
collection: 'municipality',
id: oldMunicipality.id,
depth: 0,
}),
payload.findByID({
collection: 'municipality',
id: newMunicipality.id,
depth: 0,
}),
])
expect(persistedOld.lastUpdateAt).toBe(oldRemaining.createdAt)
expect(persistedNew.lastUpdateAt).toBe(moved.createdAt)
})
it('updates lastUpdateAt on create and recomputes it after admin deletion', async () => {
const coordinator = await campaignFixtures().createCampaignUser('coordinator')
const municipality = await campaignFixtures().getMunicipality()
const first = await createMunicipalityUpdateRecord(payload, coordinator, {
municipality: municipality.id,
polarity: 'neutra',
body: 'Primeiro registro',
})
await new Promise((resolve) => setTimeout(resolve, 5))
const second = await createMunicipalityUpdateRecord(payload, coordinator, {
municipality: municipality.id,
polarity: 'neutra',
body: 'Segundo registro',
})
const afterCreate = await payload.findByID({
collection: 'municipality',
id: municipality.id,
depth: 0,
})
expect(afterCreate.lastUpdateAt).toBe(second.createdAt)
await payload.delete({
collection: 'municipalityUpdate',
id: second.id,
depth: 0,
})
const afterLatestDelete = await payload.findByID({
collection: 'municipality',
id: municipality.id,
depth: 0,
})
expect(afterLatestDelete.lastUpdateAt).toBe(first.createdAt)
await payload.delete({
collection: 'municipalityUpdate',
id: first.id,
depth: 0,
})
const afterAllDelete = await payload.findByID({
collection: 'municipality',
id: municipality.id,
depth: 0,
})
expect(afterAllDelete.lastUpdateAt).toBeNull()
})
it('rolls back the update record when the derived municipality write fails', async () => {
const coordinator = await campaignFixtures().createCampaignUser('coordinator')
const municipality = await campaignFixtures().getMunicipality()
const originalUpdate = payload.update.bind(payload)
const updateSpy = vi.spyOn(payload, 'update').mockImplementation(async (args) => {
if (
args.collection === 'municipality' &&
args.data &&
'lastUpdateAt' in args.data &&
args.data.lastUpdateAt
) {
throw new Error('falha forçada no campo derivado')
}
return originalUpdate(args)
})
try {
await expect(
createMunicipalityUpdateRecord(payload, coordinator, {
municipality: municipality.id,
polarity: 'neutra',
body: 'Este registro deve reverter',
}),
).rejects.toThrow('falha forçada')
} finally {
updateSpy.mockRestore()
}
const persisted = await payload.find({
collection: 'municipalityUpdate',
where: { municipality: { equals: municipality.id } },
depth: 0,
pagination: false,
})
expect(persisted.totalDocs).toBe(0)
})
})