mirror of
https://github.com/fsolla/teqo.git
synced 2026-11-13 15:13:07 +00:00
266 lines
9 KiB
TypeScript
266 lines
9 KiB
TypeScript
import type { Payload } from 'payload'
|
|
|
|
/**
|
|
* Municipality residue purge for the test fixtures — shared by the int
|
|
* (`campaignFixtures.ts`) and e2e (`campaignE2EFixtures.ts`) suites.
|
|
*
|
|
* Deliberately VITEST-FREE: the e2e fixture imports this module inside the
|
|
* Playwright process, and a vitest import there crashes at load time
|
|
* (`TypeError: Cannot redefine property: Symbol($$jest-matchers-object)` from
|
|
* `@vitest/expect` — OPS45/CI 2026-08-13).
|
|
*/
|
|
|
|
type RelationshipValue = number | { id: number }
|
|
|
|
/**
|
|
* Id of a relationship field at any depth.
|
|
*
|
|
* Specs use this instead of `relationshipId` from `src/utilities/relationship`
|
|
* so an assertion never runs through the production helper it is checking, and
|
|
* instead of `uniqueRelationshipIds`, which dedups — hiding a duplicated link.
|
|
*/
|
|
export function relationId(value: RelationshipValue): number
|
|
export function relationId(value: RelationshipValue | null | undefined): number | undefined
|
|
export function relationId(value: RelationshipValue | null | undefined): number | undefined {
|
|
if (value === null || value === undefined) return undefined
|
|
return typeof value === 'number' ? value : value.id
|
|
}
|
|
|
|
/** Order-preserving, non-deduping ids of a `hasMany` relationship field. */
|
|
export const relationIds = (values: readonly RelationshipValue[] | null | undefined): number[] =>
|
|
(values ?? []).map((value) => relationId(value))
|
|
|
|
/**
|
|
* D10 F1 / OPS45: delete CONTACT rows an aborted run orphaned (no other join
|
|
* references them). Production `removeSupporterData` only anonymizes, so its
|
|
* reference rule is a subset of what a DELETE needs: every join that can hold
|
|
* a ficha must be checked — leadership/signature/subscription/supporter, the
|
|
* C99 account link (`campaignUser.contact`) and the `stateDeputy` FK
|
|
* (`ON DELETE RESTRICT`, so an unchecked deputy would make the delete throw).
|
|
* Reimplemented here so the fixture never runs production code under test.
|
|
*/
|
|
const deleteOrphanedResidueContacts = async (
|
|
payload: Payload,
|
|
contactIDs: Array<number | undefined>,
|
|
): Promise<void> => {
|
|
const uniqueIDs = [...new Set(contactIDs.filter((id): id is number => id !== undefined))]
|
|
if (uniqueIDs.length === 0) return
|
|
const [leadershipRefs, signatureRefs, subscriptionRefs, supporterRefs, accountRefs, deputyRefs] =
|
|
await Promise.all([
|
|
payload.find({
|
|
collection: 'leadership',
|
|
where: { contact: { in: uniqueIDs } },
|
|
depth: 0,
|
|
pagination: false,
|
|
}),
|
|
payload.find({
|
|
collection: 'signature',
|
|
where: { contact: { in: uniqueIDs } },
|
|
depth: 0,
|
|
pagination: false,
|
|
}),
|
|
payload.find({
|
|
collection: 'subscription',
|
|
where: { contact: { in: uniqueIDs } },
|
|
depth: 0,
|
|
pagination: false,
|
|
}),
|
|
payload.find({
|
|
collection: 'supporter',
|
|
where: { contact: { in: uniqueIDs } },
|
|
depth: 0,
|
|
pagination: false,
|
|
}),
|
|
payload.find({
|
|
collection: 'campaignUser',
|
|
where: { contact: { in: uniqueIDs } },
|
|
depth: 0,
|
|
pagination: false,
|
|
}),
|
|
payload.find({
|
|
collection: 'stateDeputy',
|
|
where: { contact: { in: uniqueIDs } },
|
|
depth: 0,
|
|
pagination: false,
|
|
}),
|
|
])
|
|
const referenced = new Set<number>()
|
|
for (const doc of [
|
|
...leadershipRefs.docs,
|
|
...signatureRefs.docs,
|
|
...subscriptionRefs.docs,
|
|
...supporterRefs.docs,
|
|
...accountRefs.docs,
|
|
...deputyRefs.docs,
|
|
]) {
|
|
const contactID = relationId(doc.contact)
|
|
if (contactID !== undefined) referenced.add(contactID)
|
|
}
|
|
const orphanContactIDs = uniqueIDs.filter((id) => !referenced.has(id))
|
|
if (orphanContactIDs.length > 0) {
|
|
await payload.delete({
|
|
collection: 'contact',
|
|
where: { id: { in: orphanContactIDs } },
|
|
depth: 0,
|
|
})
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Delete campaign rows attached to a freshly claimed municipality. The allocator
|
|
* guarantees no OTHER live test owns this municipality, so anything found here is
|
|
* residue from an aborted previous run that would poison count assertions.
|
|
* Also deletes the CONTACT rows those residue supporters orphaned (D10 F1) and
|
|
* the dobradinha residue (OPS45): deputies linked through
|
|
* `municipality.stateDeputies`, their orphaned fichas, and the field itself.
|
|
*/
|
|
export const purgeMunicipalityResidue = async (
|
|
payload: Payload,
|
|
municipalityID: number,
|
|
): Promise<void> => {
|
|
const [pledges, updates, demands, leaderships, supporters, activities, feeds] = await Promise.all(
|
|
[
|
|
payload.find({
|
|
collection: 'votePledge',
|
|
where: { municipality: { equals: municipalityID } },
|
|
depth: 0,
|
|
pagination: false,
|
|
select: {},
|
|
}),
|
|
payload.find({
|
|
collection: 'municipalityUpdate',
|
|
where: { municipality: { equals: municipalityID } },
|
|
depth: 0,
|
|
pagination: false,
|
|
select: {},
|
|
}),
|
|
payload.find({
|
|
collection: 'campaignDemand',
|
|
where: { municipality: { equals: municipalityID } },
|
|
depth: 0,
|
|
pagination: false,
|
|
select: {},
|
|
}),
|
|
payload.find({
|
|
collection: 'leadership',
|
|
where: { municipalities: { in: [municipalityID] } },
|
|
depth: 0,
|
|
pagination: false,
|
|
select: {},
|
|
}),
|
|
payload.find({
|
|
collection: 'supporter',
|
|
where: { municipality: { equals: municipalityID } },
|
|
depth: 0,
|
|
pagination: false,
|
|
}),
|
|
payload.find({
|
|
collection: 'activity',
|
|
where: { municipality: { equals: municipalityID } },
|
|
depth: 0,
|
|
pagination: false,
|
|
select: {},
|
|
}),
|
|
payload.find({
|
|
collection: 'calendarFeed',
|
|
where: { filterMunicipality: { equals: municipalityID } },
|
|
depth: 0,
|
|
pagination: false,
|
|
select: {},
|
|
}),
|
|
],
|
|
)
|
|
|
|
// Residue invites reference residue leaderships (FK), so they must be
|
|
// found and deleted before the leaderships — otherwise the leadership
|
|
// delete aborts and the residue poisons every later claim of this municipality.
|
|
const leadershipIDs = leaderships.docs.map((doc) => doc.id)
|
|
const invites =
|
|
leadershipIDs.length > 0
|
|
? await payload.find({
|
|
collection: 'campaignInvite',
|
|
where: { leadership: { in: leadershipIDs } },
|
|
depth: 0,
|
|
pagination: false,
|
|
select: {},
|
|
})
|
|
: { docs: [] }
|
|
|
|
const deletions: Array<{
|
|
collection:
|
|
| 'campaignInvite'
|
|
| 'votePledge'
|
|
| 'municipalityUpdate'
|
|
| 'campaignDemand'
|
|
| 'leadership'
|
|
| 'supporter'
|
|
| 'activity'
|
|
| 'calendarFeed'
|
|
ids: number[]
|
|
}> = [
|
|
{ collection: 'campaignInvite', ids: invites.docs.map((doc) => doc.id) },
|
|
{ collection: 'votePledge', ids: pledges.docs.map((doc) => doc.id) },
|
|
{ collection: 'municipalityUpdate', ids: updates.docs.map((doc) => doc.id) },
|
|
{ collection: 'campaignDemand', ids: demands.docs.map((doc) => doc.id) },
|
|
{ collection: 'activity', ids: activities.docs.map((doc) => doc.id) },
|
|
{ collection: 'calendarFeed', ids: feeds.docs.map((doc) => doc.id) },
|
|
{ collection: 'leadership', ids: leadershipIDs },
|
|
{ collection: 'supporter', ids: supporters.docs.map((doc) => doc.id) },
|
|
]
|
|
for (const { collection, ids } of deletions) {
|
|
if (ids.length === 0) continue
|
|
await payload.delete({
|
|
collection,
|
|
where: { id: { in: ids } },
|
|
depth: 0,
|
|
})
|
|
}
|
|
|
|
// D10 F1: an aborted run leaves residue supporters AND their contact rows.
|
|
// The supporters are gone above; delete the contacts they orphaned (no other
|
|
// join references them) so the residue stops accumulating.
|
|
await deleteOrphanedResidueContacts(
|
|
payload,
|
|
supporters.docs.map((doc) => relationId(doc.contact)),
|
|
)
|
|
|
|
// OPS45 — dobradinha residue: deputies linked through the freshly claimed
|
|
// municipality's `stateDeputies` belong to the crashed run that claimed it
|
|
// (the allocator contract rules out a live owner). Delete the deputies,
|
|
// the fichas they orphaned, and clear the field so a later claim starts
|
|
// clean. The deputy contacts are captured BEFORE the delete.
|
|
const claimed = await payload.findByID({
|
|
collection: 'municipality',
|
|
id: municipalityID,
|
|
depth: 0,
|
|
select: { stateDeputies: true },
|
|
overrideAccess: true,
|
|
})
|
|
const residueDeputyIDs = relationIds(claimed?.stateDeputies ?? [])
|
|
if (residueDeputyIDs.length > 0) {
|
|
const deputies = await payload.find({
|
|
collection: 'stateDeputy',
|
|
where: { id: { in: residueDeputyIDs } },
|
|
depth: 0,
|
|
pagination: false,
|
|
select: { contact: true },
|
|
overrideAccess: true,
|
|
})
|
|
await payload.delete({
|
|
collection: 'stateDeputy',
|
|
where: { id: { in: residueDeputyIDs } },
|
|
depth: 0,
|
|
})
|
|
await deleteOrphanedResidueContacts(
|
|
payload,
|
|
deputies.docs.map((doc) => relationId(doc.contact)),
|
|
)
|
|
await payload.update({
|
|
collection: 'municipality',
|
|
id: municipalityID,
|
|
data: { stateDeputies: [] },
|
|
depth: 0,
|
|
overrideAccess: true,
|
|
})
|
|
}
|
|
}
|