mirror of
https://github.com/fsolla/teqo.git
synced 2026-11-13 15:13:07 +00:00
1168 lines
38 KiB
TypeScript
1168 lines
38 KiB
TypeScript
import { createHash, randomUUID } from 'node:crypto'
|
|
|
|
import { sql } from '@payloadcms/db-postgres'
|
|
import type { Payload, Where } from 'payload'
|
|
import { afterEach, beforeEach } from 'vitest'
|
|
|
|
import { getMunicipalityCatalogEntry, municipalityCatalog } from '@/lib/municipalityCatalog'
|
|
import type {
|
|
CampaignDemand,
|
|
CampaignInvite,
|
|
CampaignUser,
|
|
Consent,
|
|
Contact,
|
|
Leadership,
|
|
Municipality,
|
|
MunicipalityUpdate,
|
|
Organization,
|
|
StateDeputy,
|
|
Supporter,
|
|
User,
|
|
VotePledge,
|
|
} from '@/payload-types'
|
|
import { withPayloadTransaction } from '@/utilities/payloadTransaction'
|
|
import { claimMunicipalityIndex, releaseMunicipalityClaims } from './campaignMunicipalityAllocator'
|
|
import { purgeMunicipalityResidue, relationId, relationIds } from './campaignResidue'
|
|
|
|
/**
|
|
* Campaign fixtures for the Município model. Municipalities are SEEDED reference rows
|
|
* (435, created by migration): tests never create or delete them — fixtures
|
|
* hand out seeded municipalities and reset the operational fields they touched.
|
|
*/
|
|
|
|
type CampaignCollection =
|
|
| 'users'
|
|
| 'campaignInvite'
|
|
| 'municipalityUpdate'
|
|
| 'votePledge'
|
|
| 'campaignDemand'
|
|
| 'allocationDecision'
|
|
| 'activity'
|
|
| 'leadership'
|
|
| 'supporter'
|
|
| 'organization'
|
|
| 'stateDeputy'
|
|
| 'contact'
|
|
| 'campaignUser'
|
|
| 'consent'
|
|
| 'calendarFeed'
|
|
| 'supporterImportBatch'
|
|
|
|
type OwnedIDs = Record<CampaignCollection, Set<number>>
|
|
|
|
type CampaignUserInput = Partial<
|
|
Pick<
|
|
CampaignUser,
|
|
'name' | 'role' | 'phone' | 'email' | 'username' | 'password' | 'visibility' | 'editing'
|
|
>
|
|
>
|
|
type AdminUserInput = {
|
|
email?: string
|
|
password?: string
|
|
roles?: User['roles']
|
|
}
|
|
type EditorUserInput = {
|
|
email?: string
|
|
password?: string
|
|
}
|
|
type ConsentInput = Partial<Pick<Consent, 'key' | 'text'>>
|
|
type ContactInput = Partial<
|
|
Pick<Contact, 'name' | 'email' | 'gender' | 'state' | 'city' | 'postalCode'> & {
|
|
phones: Array<{ value: string }>
|
|
}
|
|
>
|
|
type LeadershipInput = Partial<
|
|
Pick<
|
|
Leadership,
|
|
| 'contact'
|
|
| 'municipalities'
|
|
| 'organizations'
|
|
| 'exclusive'
|
|
| 'supportStatus'
|
|
| 'user'
|
|
| 'consent'
|
|
| 'consentContentHash'
|
|
| 'consentedAt'
|
|
| 'notes'
|
|
| 'createdBy'
|
|
>
|
|
> &
|
|
Pick<Leadership, 'contact'> & { municipalities: Array<number | { id: number }> }
|
|
type MunicipalityUpdateInput = Partial<
|
|
Pick<
|
|
MunicipalityUpdate,
|
|
'polarity' | 'urgent' | 'adversarySignal' | 'activeVolunteers' | 'newSupports' | 'body'
|
|
>
|
|
> &
|
|
Pick<MunicipalityUpdate, 'municipality' | 'author'>
|
|
type VotePledgeInput = Partial<
|
|
Pick<VotePledge, 'declaredVotes' | 'estimatedVotes' | 'estimateNote'>
|
|
> &
|
|
Pick<VotePledge, 'leadership' | 'municipality'>
|
|
type OrganizationInput = Partial<
|
|
Pick<Organization, 'name' | 'slug' | 'kind' | 'notes' | 'municipalities'>
|
|
>
|
|
type StateDeputyInput = Partial<
|
|
Pick<StateDeputy, 'contact' | 'slug' | 'party' | 'ballotName' | 'notes'>
|
|
> & {
|
|
name?: string
|
|
}
|
|
type CampaignDemandInput = Partial<
|
|
Pick<
|
|
CampaignDemand,
|
|
| 'title'
|
|
| 'slug'
|
|
| 'kind'
|
|
| 'description'
|
|
| 'status'
|
|
| 'leadership'
|
|
| 'createdBy'
|
|
| 'responsibles'
|
|
>
|
|
> &
|
|
Pick<CampaignDemand, 'municipality'>
|
|
type CampaignInviteInput = Partial<
|
|
Pick<CampaignInvite, 'tokenHash' | 'kind' | 'expiresAt' | 'usedAt' | 'revokedAt'>
|
|
> &
|
|
Pick<CampaignInvite, 'leadership' | 'createdBy'>
|
|
|
|
const processRunID = randomUUID()
|
|
let builderCounter = 0
|
|
|
|
const emptyOwnedIDs = (): OwnedIDs => ({
|
|
users: new Set(),
|
|
campaignInvite: new Set(),
|
|
municipalityUpdate: new Set(),
|
|
votePledge: new Set(),
|
|
campaignDemand: new Set(),
|
|
allocationDecision: new Set(),
|
|
activity: new Set(),
|
|
leadership: new Set(),
|
|
supporter: new Set(),
|
|
organization: new Set(),
|
|
stateDeputy: new Set(),
|
|
contact: new Set(),
|
|
campaignUser: new Set(),
|
|
consent: new Set(),
|
|
calendarFeed: new Set(),
|
|
supporterImportBatch: new Set(),
|
|
})
|
|
|
|
/**
|
|
* Consent rows shared across spec files (stable keys the app resolves at
|
|
* runtime). They are leased via `testDatabaseLease.ts`, never owned by a
|
|
* fixture instance — deleting them at cleanup would steal them from
|
|
* concurrently running spec files.
|
|
*/
|
|
const leasedConsentKeys = new Set([
|
|
'lideranca-autopreenchimento',
|
|
'apoiador-cadastro',
|
|
'apoiador-intencao-voto',
|
|
'whatsapp-inscricao',
|
|
])
|
|
|
|
const defaultConsentText = (text: string): Consent['text'] => ({
|
|
root: {
|
|
type: 'root',
|
|
children: [
|
|
{
|
|
type: 'paragraph',
|
|
children: [{ type: 'text', text, version: 1 }],
|
|
direction: null,
|
|
format: '',
|
|
indent: 0,
|
|
version: 1,
|
|
},
|
|
],
|
|
direction: null,
|
|
format: '',
|
|
indent: 0,
|
|
version: 1,
|
|
},
|
|
})
|
|
|
|
// `relationId`/`relationIds`/`purgeMunicipalityResidue` live in
|
|
// `campaignResidue.ts` (vitest-free — the e2e fixture imports it inside the
|
|
// Playwright process) and are re-exported here for the existing importers.
|
|
export { purgeMunicipalityResidue, relationId, relationIds } from './campaignResidue'
|
|
|
|
const bulkErrors = (result: unknown): unknown[] => {
|
|
if (
|
|
typeof result === 'object' &&
|
|
result !== null &&
|
|
'errors' in result &&
|
|
Array.isArray(result.errors)
|
|
) {
|
|
return result.errors
|
|
}
|
|
return []
|
|
}
|
|
|
|
const combineErrors = (primary: unknown, cleanup: unknown): AggregateError =>
|
|
new AggregateError([primary, cleanup], 'Campaign fixture callback and cleanup both failed.')
|
|
|
|
/**
|
|
* Cross-process municipality allocator: seeded municipalities are shared reference rows, so
|
|
* concurrently running spec files must never operate on the same municipality. The allocator
|
|
* (`campaignMunicipalityAllocator.ts`) hands out a catalog index per claim — unique across
|
|
* live runs even after the Postgres sequence wraps (see its module docs for the wrap
|
|
* mechanism). Claims are released by `cleanup()` so later runs reuse the slots.
|
|
*/
|
|
|
|
export class CampaignFixtures {
|
|
readonly payload: Payload
|
|
readonly runID: string
|
|
|
|
private cleaned = false
|
|
private counter = 0
|
|
private municipalityCursor = 0
|
|
private claimedAnyMunicipality = false
|
|
private readonly markers = new Set<string>()
|
|
private readonly owned = emptyOwnedIDs()
|
|
private readonly touchedMunicipalities = new Set<number>()
|
|
private readonly ownedLockedDocuments = new Set<number>()
|
|
private readonly ownedPreferences = new Set<number>()
|
|
private readonly ownedNotifications = new Set<number>()
|
|
private readonly ownedPushSubscriptions = new Set<number>()
|
|
|
|
constructor(private readonly rootPayload: Payload) {
|
|
builderCounter += 1
|
|
this.runID = `${processRunID}-${builderCounter}`
|
|
// OWNERSHIP CONTRACT: rows created through `fixtures.payload` (the proxy
|
|
// below) are auto-owned at cleanup — calling `fixtures.own()` for them is
|
|
// redundant (kept harmless and idempotent for older specs). Rows made
|
|
// through actions, raw payload handles, or the `rootPayload` are NOT
|
|
// tracked — own those explicitly. Creates whose key is a LEASED consent
|
|
// key (`leasedConsentKeys`) are deliberately never owned: the lease
|
|
// system (testDatabaseLease.ts) owns their lifecycle, and owning them
|
|
// here would let this spec's cleanup rob a parallel spec of the row.
|
|
// Swapping/defineProperty-ing `payload.create` survives too — used by the
|
|
// vi.spyOn(payload, 'create') specs.
|
|
const trackedCreate = async (args: Parameters<Payload['create']>[0]) => {
|
|
const document = await rootPayload.create(args)
|
|
const collection = args.collection
|
|
const isLeasedConsent =
|
|
collection === 'consent' &&
|
|
typeof args.data === 'object' &&
|
|
args.data !== null &&
|
|
'key' in args.data &&
|
|
typeof args.data.key === 'string' &&
|
|
leasedConsentKeys.has(args.data.key)
|
|
if (collection in this.owned && typeof document.id === 'number' && !isLeasedConsent) {
|
|
this.own(collection as CampaignCollection, document.id)
|
|
}
|
|
return document
|
|
}
|
|
let currentCreate: unknown = trackedCreate
|
|
this.payload = new Proxy(Object.create(rootPayload) as Payload, {
|
|
get: (_target, property) => {
|
|
if (property === 'create') return currentCreate
|
|
const value = Reflect.get(rootPayload, property)
|
|
return typeof value === 'function' ? value.bind(rootPayload) : value
|
|
},
|
|
set: (_target, property, value) => {
|
|
if (property === 'create') {
|
|
currentCreate = value
|
|
return true
|
|
}
|
|
return Reflect.set(rootPayload, property, value)
|
|
},
|
|
defineProperty: (_target, property, descriptor) => {
|
|
if (property === 'create') {
|
|
currentCreate = descriptor.value
|
|
return true
|
|
}
|
|
return Reflect.defineProperty(rootPayload, property, descriptor)
|
|
},
|
|
deleteProperty: (_target, property) => {
|
|
if (property === 'create') {
|
|
currentCreate = trackedCreate
|
|
return true
|
|
}
|
|
return Reflect.deleteProperty(rootPayload, property)
|
|
},
|
|
getOwnPropertyDescriptor: (_target, property) => {
|
|
if (property === 'create') {
|
|
return {
|
|
configurable: true,
|
|
enumerable: true,
|
|
value: currentCreate,
|
|
writable: true,
|
|
}
|
|
}
|
|
return Reflect.getOwnPropertyDescriptor(rootPayload, property)
|
|
},
|
|
}) as Payload
|
|
}
|
|
|
|
value(prefix: string): string {
|
|
this.counter += 1
|
|
const value = `${prefix}-${this.runID}-${this.counter}`
|
|
this.markers.add(value)
|
|
return value
|
|
}
|
|
|
|
/**
|
|
* Schema-valid unique person name. The contact/leadership name schemas
|
|
* accept letters only (with single spaces/hyphens between words), so the
|
|
* regular `value()` marker — uuid digits plus hyphens glued to them —
|
|
* fails zod validation when a test feeds it through a real action. Digits
|
|
* are transliterated to letters to keep per-run uniqueness, and the name
|
|
* registers as a marker so cleanup sweeps still recognize the row.
|
|
*/
|
|
personName(prefix: string): string {
|
|
this.counter += 1
|
|
const letterized = `${this.runID}${this.counter}`
|
|
.replaceAll('-', '')
|
|
.replaceAll(/[0-9]/g, (digit) => 'abcdefghij'[Number(digit)]!)
|
|
const name = `${prefix} ${letterized}`
|
|
this.markers.add(name)
|
|
return name
|
|
}
|
|
|
|
phone(): string {
|
|
const digest = createHash('sha256').update(this.value('phone')).digest('hex')
|
|
const subscriber = (BigInt(`0x${digest.slice(0, 12)}`) % 90_000_000n) + 10_000_000n
|
|
const phone = `719${subscriber}`
|
|
this.markers.add(phone)
|
|
return phone
|
|
}
|
|
|
|
id(value: number | { id: number }): number {
|
|
return relationId(value)
|
|
}
|
|
|
|
own(collection: CampaignCollection, value: number | { id: number }): void {
|
|
this.owned[collection].add(relationId(value))
|
|
}
|
|
|
|
ownedIDs(collection: CampaignCollection): number[] {
|
|
return [...this.owned[collection]]
|
|
}
|
|
|
|
private hasMarker(...values: (string | null | undefined)[]): boolean {
|
|
return values.some(
|
|
(value) =>
|
|
value !== null &&
|
|
value !== undefined &&
|
|
[...this.markers].some((marker) => value.includes(marker)),
|
|
)
|
|
}
|
|
|
|
/**
|
|
* A seeded municipality for this test — globally unique across concurrently running
|
|
* spec files (Postgres sequence), so parallel tests never share one. Rows
|
|
* left behind by a previously crashed run are purged on claim (allocation
|
|
* uniqueness makes this safe: nothing live can reference this municipality).
|
|
*/
|
|
async getMunicipality(slug?: string): Promise<Municipality> {
|
|
let requestedSlug = slug
|
|
if (!requestedSlug) {
|
|
const index = await claimMunicipalityIndex(
|
|
this.rootPayload,
|
|
municipalityCatalog.length,
|
|
this.runID,
|
|
)
|
|
this.claimedAnyMunicipality = true
|
|
requestedSlug = municipalityCatalog[index]!.slug
|
|
this.municipalityCursor += 1
|
|
}
|
|
|
|
const result = await this.rootPayload.find({
|
|
collection: 'municipality',
|
|
where: { slug: { equals: requestedSlug } },
|
|
depth: 0,
|
|
limit: 1,
|
|
pagination: false,
|
|
})
|
|
const municipality = result.docs[0]
|
|
if (!municipality) {
|
|
throw new Error(
|
|
`Seeded municipality "${requestedSlug}" not found — run migrations on the test database.`,
|
|
)
|
|
}
|
|
if (!slug) await purgeMunicipalityResidue(this.rootPayload, municipality.id)
|
|
this.touchedMunicipalities.add(municipality.id)
|
|
return municipality
|
|
}
|
|
|
|
/** Mark a municipality mutated by the test so cleanup resets its operational fields. */
|
|
touchMunicipality(value: number | { id: number }): void {
|
|
this.touchedMunicipalities.add(relationId(value))
|
|
}
|
|
|
|
private async discoverMarkedRoots(): Promise<void> {
|
|
const roots = await Promise.all([
|
|
this.rootPayload.find({
|
|
collection: 'users',
|
|
where: { email: { contains: this.runID } },
|
|
depth: 0,
|
|
pagination: false,
|
|
}),
|
|
this.rootPayload.find({
|
|
collection: 'campaignUser',
|
|
where: {
|
|
or: [
|
|
{ name: { contains: this.runID } },
|
|
{ email: { contains: this.runID } },
|
|
{ username: { contains: this.runID } },
|
|
{ phone: { contains: this.runID } },
|
|
],
|
|
},
|
|
depth: 0,
|
|
pagination: false,
|
|
}),
|
|
this.rootPayload.find({
|
|
collection: 'contact',
|
|
where: {
|
|
or: [
|
|
{ name: { contains: this.runID } },
|
|
{ email: { contains: this.runID } },
|
|
{ 'phones.value': { contains: this.runID } },
|
|
],
|
|
},
|
|
depth: 0,
|
|
pagination: false,
|
|
}),
|
|
this.rootPayload.find({
|
|
collection: 'organization',
|
|
where: {
|
|
or: [{ name: { contains: this.runID } }, { slug: { contains: this.runID } }],
|
|
},
|
|
depth: 0,
|
|
pagination: false,
|
|
}),
|
|
this.rootPayload.find({
|
|
collection: 'activity',
|
|
where: {
|
|
or: [{ title: { contains: this.runID } }, { slug: { contains: this.runID } }],
|
|
},
|
|
depth: 0,
|
|
pagination: false,
|
|
}),
|
|
this.rootPayload.find({
|
|
collection: 'campaignDemand',
|
|
where: {
|
|
or: [{ title: { contains: this.runID } }, { slug: { contains: this.runID } }],
|
|
},
|
|
depth: 0,
|
|
pagination: false,
|
|
}),
|
|
this.rootPayload.find({
|
|
collection: 'consent',
|
|
where: { key: { contains: this.runID } },
|
|
depth: 0,
|
|
pagination: false,
|
|
}),
|
|
])
|
|
|
|
for (const user of roots[0].docs) this.own('users', user)
|
|
for (const user of roots[1].docs) this.own('campaignUser', user)
|
|
for (const contact of roots[2].docs) this.own('contact', contact)
|
|
for (const organization of roots[3].docs) this.own('organization', organization)
|
|
for (const activity of roots[4].docs) this.own('activity', activity)
|
|
for (const demand of roots[5].docs) this.own('campaignDemand', demand)
|
|
for (const consent of roots[6].docs) this.own('consent', consent)
|
|
}
|
|
|
|
async createCampaignUser(
|
|
role: CampaignUser['role'] = 'leader',
|
|
input: CampaignUserInput = {},
|
|
): Promise<CampaignUser> {
|
|
const user = await this.rootPayload.create({
|
|
collection: 'campaignUser',
|
|
data: {
|
|
name: this.value(`Usuário ${role}`),
|
|
email: `${this.value(role)}@example.com`,
|
|
password: this.value('password'),
|
|
role,
|
|
...input,
|
|
},
|
|
depth: 0,
|
|
})
|
|
this.own('campaignUser', user)
|
|
return user
|
|
}
|
|
|
|
async createAdminUser(input: AdminUserInput = {}): Promise<User> {
|
|
const user = await this.rootPayload.create({
|
|
collection: 'users',
|
|
data: {
|
|
email: `${this.value('admin')}@example.com`,
|
|
password: this.value('password'),
|
|
roles: ['admin'],
|
|
...input,
|
|
},
|
|
depth: 0,
|
|
})
|
|
this.own('users', user)
|
|
return user
|
|
}
|
|
|
|
async createEditorUser(input: EditorUserInput = {}): Promise<User> {
|
|
return this.createAdminUser({
|
|
email: `${this.value('editor')}@example.com`,
|
|
roles: ['editor'],
|
|
...input,
|
|
})
|
|
}
|
|
|
|
/**
|
|
* Creates an OWNED consent row (deleted at cleanup) — even for a leased
|
|
* shared key, since calling this helper is an explicit ownership request.
|
|
* For the shared stable keys, prefer `ensureLeasedConsent` /
|
|
* `withLeasedConsent` (testDatabaseLease.ts) so parallel spec files are not
|
|
* robbed of the row at cleanup.
|
|
*/
|
|
async createConsent(input: ConsentInput = {}): Promise<Consent> {
|
|
const marker = this.value('consent')
|
|
const consent = await this.rootPayload.create({
|
|
collection: 'consent',
|
|
data: {
|
|
key: marker,
|
|
text: defaultConsentText(marker),
|
|
...input,
|
|
},
|
|
depth: 0,
|
|
})
|
|
this.own('consent', consent)
|
|
return consent
|
|
}
|
|
|
|
async createContact(input: ContactInput = {}): Promise<Contact & { phone: string }> {
|
|
const phone = input.phones?.[0]?.value ?? this.phone()
|
|
const contact = await this.rootPayload.create({
|
|
collection: 'contact',
|
|
data: {
|
|
name: this.value('Contato'),
|
|
phones: [{ value: phone }],
|
|
state: 'BA',
|
|
city: 'Salvador',
|
|
...input,
|
|
},
|
|
depth: 0,
|
|
})
|
|
this.own('contact', contact)
|
|
// The fixture contract is "one primary phone": every fixture contact carries
|
|
// one — narrow so specs can rely on it (C112 keeps the array under the hood).
|
|
const primary = contact.phones?.[0]?.value
|
|
if (typeof primary !== 'string') {
|
|
throw new Error('Fixture contacts must always carry a phone.')
|
|
}
|
|
return { ...contact, phone: primary }
|
|
}
|
|
|
|
/** Assign advisors to a seeded municipality (tracked for reset on cleanup). */
|
|
async assignMunicipalityAdvisors(
|
|
municipality: number | { id: number },
|
|
advisors: Array<number | { id: number }>,
|
|
): Promise<Municipality> {
|
|
const municipalityID = relationId(municipality)
|
|
this.touchedMunicipalities.add(municipalityID)
|
|
return this.rootPayload.update({
|
|
collection: 'municipality',
|
|
id: municipalityID,
|
|
data: { advisors: relationIds(advisors) },
|
|
depth: 0,
|
|
})
|
|
}
|
|
|
|
async createLeadership(input: LeadershipInput): Promise<Leadership> {
|
|
const leadership = await this.rootPayload.create({
|
|
collection: 'leadership',
|
|
data: {
|
|
supportStatus: 'engajado',
|
|
...input,
|
|
contact: relationId(input.contact),
|
|
municipalities: relationIds(input.municipalities),
|
|
...(input.organizations
|
|
? { organizations: input.organizations.map((value) => relationId(value)) }
|
|
: {}),
|
|
...(input.user ? { user: relationId(input.user) } : {}),
|
|
...(input.createdBy ? { createdBy: relationId(input.createdBy) } : {}),
|
|
...(input.consent ? { consent: relationId(input.consent) } : {}),
|
|
},
|
|
depth: 0,
|
|
})
|
|
this.own('leadership', leadership)
|
|
for (const municipality of input.municipalities)
|
|
this.touchedMunicipalities.add(relationId(municipality))
|
|
return leadership
|
|
}
|
|
|
|
async createVotePledge(input: VotePledgeInput): Promise<VotePledge> {
|
|
const pledge = await this.rootPayload.create({
|
|
collection: 'votePledge',
|
|
data: {
|
|
declaredVotes: 100,
|
|
...input,
|
|
leadership: relationId(input.leadership),
|
|
municipality: relationId(input.municipality),
|
|
},
|
|
depth: 0,
|
|
})
|
|
this.own('votePledge', pledge)
|
|
this.touchedMunicipalities.add(relationId(input.municipality))
|
|
return pledge
|
|
}
|
|
|
|
async createOrganization(input: OrganizationInput = {}): Promise<Organization> {
|
|
const name = input.name ?? this.value('Organização')
|
|
const organization = await this.rootPayload.create({
|
|
collection: 'organization',
|
|
data: {
|
|
kind: 'sindicato',
|
|
...input,
|
|
name,
|
|
slug: input.slug ?? this.value('organizacao'),
|
|
},
|
|
depth: 0,
|
|
})
|
|
this.own('organization', organization)
|
|
return organization
|
|
}
|
|
|
|
async createStateDeputy(input: StateDeputyInput = {}): Promise<StateDeputy> {
|
|
const { contact: inputContact, name, ...stateDeputyInput } = input
|
|
const contact =
|
|
inputContact ?? (await this.createContact({ name: name ?? this.value('Deputado') }))
|
|
const stateDeputy = await this.rootPayload.create({
|
|
collection: 'stateDeputy',
|
|
data: {
|
|
...stateDeputyInput,
|
|
contact: relationId(contact),
|
|
slug: stateDeputyInput.slug ?? this.value('deputado'),
|
|
},
|
|
depth: 0,
|
|
})
|
|
this.own('stateDeputy', stateDeputy)
|
|
return stateDeputy
|
|
}
|
|
|
|
async createCampaignDemand(input: CampaignDemandInput): Promise<CampaignDemand> {
|
|
const title = input.title ?? this.value('Demanda')
|
|
const demand = await this.rootPayload.create({
|
|
collection: 'campaignDemand',
|
|
data: {
|
|
kind: 'material',
|
|
status: 'aberta',
|
|
...input,
|
|
title,
|
|
slug: input.slug ?? this.value('demanda'),
|
|
municipality: relationId(input.municipality),
|
|
...(input.leadership ? { leadership: relationId(input.leadership) } : {}),
|
|
...(input.createdBy ? { createdBy: relationId(input.createdBy) } : {}),
|
|
...(input.responsibles ? { responsibles: input.responsibles } : {}),
|
|
},
|
|
depth: 0,
|
|
})
|
|
this.own('campaignDemand', demand)
|
|
this.touchedMunicipalities.add(relationId(input.municipality))
|
|
return demand
|
|
}
|
|
|
|
async createSupporter(
|
|
input: Partial<
|
|
Pick<
|
|
Supporter,
|
|
| 'contact'
|
|
| 'municipality'
|
|
| 'voteIntention'
|
|
| 'source'
|
|
| 'consent'
|
|
| 'consentContentHash'
|
|
| 'consentedAt'
|
|
| 'notes'
|
|
| 'consentNote'
|
|
| 'createdBy'
|
|
>
|
|
> &
|
|
Pick<Supporter, 'contact'>,
|
|
): Promise<Supporter> {
|
|
const supporter = await this.rootPayload.create({
|
|
collection: 'supporter',
|
|
data: {
|
|
source: 'manual',
|
|
...input,
|
|
contact: relationId(input.contact),
|
|
...(input.municipality ? { municipality: relationId(input.municipality) } : {}),
|
|
...(input.createdBy ? { createdBy: relationId(input.createdBy) } : {}),
|
|
...(input.consent ? { consent: relationId(input.consent) } : {}),
|
|
},
|
|
depth: 0,
|
|
})
|
|
this.own('supporter', supporter)
|
|
return supporter
|
|
}
|
|
|
|
async createMunicipalityUpdate(input: MunicipalityUpdateInput): Promise<MunicipalityUpdate> {
|
|
const update = await this.rootPayload.create({
|
|
collection: 'municipalityUpdate',
|
|
data: {
|
|
polarity: 'neutra',
|
|
body: this.value('Atualização'),
|
|
urgent: false,
|
|
adversarySignal: false,
|
|
...input,
|
|
municipality: relationId(input.municipality),
|
|
author: relationId(input.author),
|
|
},
|
|
depth: 0,
|
|
})
|
|
this.own('municipalityUpdate', update)
|
|
this.touchedMunicipalities.add(relationId(input.municipality))
|
|
return update
|
|
}
|
|
|
|
async createInvite(input: CampaignInviteInput): Promise<CampaignInvite> {
|
|
const invite = await this.rootPayload.create({
|
|
collection: 'campaignInvite',
|
|
data: {
|
|
tokenHash: createHash('sha256').update(this.value('invite')).digest('hex'),
|
|
kind: 'autopreenchimento',
|
|
expiresAt: new Date('2099-01-01T00:00:00.000Z').toISOString(),
|
|
...input,
|
|
leadership: relationId(input.leadership),
|
|
createdBy: relationId(input.createdBy),
|
|
},
|
|
depth: 0,
|
|
})
|
|
this.own('campaignInvite', invite)
|
|
return invite
|
|
}
|
|
|
|
private async discoverDependents(): Promise<void> {
|
|
await this.discoverMarkedRoots()
|
|
const userIDs = [...this.owned.campaignUser]
|
|
const leadershipConditions: Where[] = []
|
|
if (userIDs.length > 0) leadershipConditions.push({ createdBy: { in: userIDs } })
|
|
if (userIDs.length > 0) leadershipConditions.push({ user: { in: userIDs } })
|
|
if (this.owned.leadership.size > 0) {
|
|
leadershipConditions.push({ id: { in: [...this.owned.leadership] } })
|
|
}
|
|
|
|
if (leadershipConditions.length > 0) {
|
|
const leaderships = await this.rootPayload.find({
|
|
collection: 'leadership',
|
|
where: { or: leadershipConditions },
|
|
depth: 0,
|
|
pagination: false,
|
|
})
|
|
const candidateContactIDs = new Set<number>()
|
|
const candidateUserIDs = new Set<number>()
|
|
for (const leadership of leaderships.docs) {
|
|
this.own('leadership', leadership)
|
|
candidateContactIDs.add(relationId(leadership.contact))
|
|
if (leadership.user) candidateUserIDs.add(relationId(leadership.user))
|
|
}
|
|
if (candidateContactIDs.size > 0) {
|
|
const contacts = await this.rootPayload.find({
|
|
collection: 'contact',
|
|
where: { id: { in: [...candidateContactIDs] } },
|
|
depth: 0,
|
|
pagination: false,
|
|
})
|
|
for (const contact of contacts.docs) {
|
|
if (
|
|
this.owned.contact.has(contact.id) ||
|
|
this.hasMarker(contact.name, contact.email, contact.phones?.[0]?.value ?? null)
|
|
) {
|
|
this.own('contact', contact)
|
|
}
|
|
}
|
|
}
|
|
if (candidateUserIDs.size > 0) {
|
|
const users = await this.rootPayload.find({
|
|
collection: 'campaignUser',
|
|
where: { id: { in: [...candidateUserIDs] } },
|
|
depth: 0,
|
|
pagination: false,
|
|
})
|
|
for (const user of users.docs) {
|
|
if (
|
|
this.owned.campaignUser.has(user.id) ||
|
|
this.hasMarker(user.name, user.email, user.username, user.phone)
|
|
) {
|
|
this.own('campaignUser', user)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
const leadershipIDs = [...this.owned.leadership]
|
|
if (leadershipIDs.length > 0) {
|
|
const [invites, pledges, demands] = await Promise.all([
|
|
this.rootPayload.find({
|
|
collection: 'campaignInvite',
|
|
where: { leadership: { in: leadershipIDs } },
|
|
depth: 0,
|
|
pagination: false,
|
|
}),
|
|
this.rootPayload.find({
|
|
collection: 'votePledge',
|
|
where: { leadership: { in: leadershipIDs } },
|
|
depth: 0,
|
|
pagination: false,
|
|
}),
|
|
this.rootPayload.find({
|
|
collection: 'campaignDemand',
|
|
where: { leadership: { in: leadershipIDs } },
|
|
depth: 0,
|
|
pagination: false,
|
|
}),
|
|
])
|
|
for (const invite of invites.docs) this.own('campaignInvite', invite)
|
|
for (const pledge of pledges.docs) this.own('votePledge', pledge)
|
|
for (const demand of demands.docs) this.own('campaignDemand', demand)
|
|
}
|
|
|
|
if (userIDs.length > 0) {
|
|
const importBatches = await this.rootPayload.find({
|
|
collection: 'supporterImportBatch',
|
|
where: { actor: { in: userIDs } },
|
|
depth: 0,
|
|
pagination: false,
|
|
})
|
|
for (const batch of importBatches.docs) this.own('supporterImportBatch', batch.id)
|
|
const [updates, notifications, pushSubscriptions] = await Promise.all([
|
|
this.rootPayload.find({
|
|
collection: 'municipalityUpdate',
|
|
where: { author: { in: userIDs } },
|
|
depth: 0,
|
|
pagination: false,
|
|
}),
|
|
this.rootPayload.find({
|
|
collection: 'notification',
|
|
where: { recipient: { in: userIDs } },
|
|
depth: 0,
|
|
pagination: false,
|
|
}),
|
|
this.rootPayload.find({
|
|
collection: 'pushSubscription',
|
|
where: { user: { in: userIDs } },
|
|
depth: 0,
|
|
pagination: false,
|
|
}),
|
|
])
|
|
for (const update of updates.docs) {
|
|
this.own('municipalityUpdate', update)
|
|
this.touchedMunicipalities.add(relationId(update.municipality))
|
|
}
|
|
for (const notification of notifications.docs) {
|
|
this.ownedNotifications.add(notification.id)
|
|
}
|
|
for (const subscription of pushSubscriptions.docs) {
|
|
this.ownedPushSubscriptions.add(subscription.id)
|
|
}
|
|
}
|
|
|
|
const contactIDs = [...this.owned.contact]
|
|
if (contactIDs.length > 0 || this.owned.supporter.size > 0) {
|
|
const supporterConditions: Where[] = []
|
|
if (contactIDs.length > 0) supporterConditions.push({ contact: { in: contactIDs } })
|
|
if (this.owned.supporter.size > 0) {
|
|
supporterConditions.push({ id: { in: [...this.owned.supporter] } })
|
|
}
|
|
const supporters = await this.rootPayload.find({
|
|
collection: 'supporter',
|
|
where: { or: supporterConditions },
|
|
depth: 0,
|
|
pagination: false,
|
|
})
|
|
for (const supporter of supporters.docs) {
|
|
this.own('supporter', supporter)
|
|
this.own('contact', relationId(supporter.contact))
|
|
}
|
|
}
|
|
|
|
const lockedDocumentConditions = [
|
|
['activity_id', this.owned.activity],
|
|
['allocation_decision_id', this.owned.allocationDecision],
|
|
['campaign_invite_id', this.owned.campaignInvite],
|
|
['campaign_user_id', this.owned.campaignUser],
|
|
['campaign_demand_id', this.owned.campaignDemand],
|
|
['consent_id', this.owned.consent],
|
|
['contact_id', this.owned.contact],
|
|
['organization_id', this.owned.organization],
|
|
['leadership_id', this.owned.leadership],
|
|
['supporter_id', this.owned.supporter],
|
|
['municipality_update_id', this.owned.municipalityUpdate],
|
|
['vote_pledge_id', this.owned.votePledge],
|
|
['users_id', this.owned.users],
|
|
]
|
|
.filter((entry): entry is [string, Set<number>] => (entry[1] as Set<number>).size > 0)
|
|
.map(([column, ids]) => `"${column}" IN (${[...ids].map((id) => Number(id)).join(', ')})`)
|
|
if (lockedDocumentConditions.length > 0) {
|
|
const result = await this.rootPayload.db.drizzle.execute(
|
|
sql.raw(`
|
|
SELECT DISTINCT "parent_id"
|
|
FROM "payload_locked_documents_rels"
|
|
WHERE ${lockedDocumentConditions.join(' OR ')}
|
|
`),
|
|
)
|
|
for (const row of result.rows) {
|
|
const parentID = Number(row.parent_id)
|
|
if (Number.isInteger(parentID)) this.ownedLockedDocuments.add(parentID)
|
|
}
|
|
}
|
|
|
|
if (userIDs.length > 0) {
|
|
const preferences = await this.rootPayload.db.drizzle.execute(sql`
|
|
SELECT DISTINCT "parent_id"
|
|
FROM "payload_preferences_rels"
|
|
WHERE "campaign_user_id" IN (${sql.join(
|
|
userIDs.map((id) => sql`${id}`),
|
|
sql`, `,
|
|
)})
|
|
`)
|
|
for (const row of preferences.rows) {
|
|
const preferenceID = Number(row.parent_id)
|
|
if (Number.isInteger(preferenceID)) this.ownedPreferences.add(preferenceID)
|
|
}
|
|
}
|
|
}
|
|
|
|
private async deleteNotificationOwned(req: { transactionID: number | string }): Promise<void> {
|
|
if (this.ownedNotifications.size > 0) {
|
|
await this.rootPayload.delete({
|
|
collection: 'notification',
|
|
where: { id: { in: [...this.ownedNotifications] } },
|
|
depth: 0,
|
|
overrideAccess: true,
|
|
req,
|
|
})
|
|
this.ownedNotifications.clear()
|
|
}
|
|
if (this.ownedPushSubscriptions.size > 0) {
|
|
await this.rootPayload.delete({
|
|
collection: 'pushSubscription',
|
|
where: { id: { in: [...this.ownedPushSubscriptions] } },
|
|
depth: 0,
|
|
overrideAccess: true,
|
|
req,
|
|
})
|
|
this.ownedPushSubscriptions.clear()
|
|
}
|
|
}
|
|
|
|
private async deleteOwned(
|
|
collection: CampaignCollection,
|
|
req: { transactionID: number | string },
|
|
): Promise<void> {
|
|
const ids = [...this.owned[collection]]
|
|
if (ids.length === 0) return
|
|
const result = await this.rootPayload.delete({
|
|
collection,
|
|
where: { id: { in: ids } },
|
|
depth: 0,
|
|
req,
|
|
})
|
|
const errors = bulkErrors(result)
|
|
if (errors.length > 0) {
|
|
throw new AggregateError(errors, `Failed to clean owned ${collection} fixture rows.`)
|
|
}
|
|
}
|
|
|
|
/** Seeded municipalities are never deleted — touched ones get their fields reset. */
|
|
private async resetTouchedMunicipalities(req: { transactionID: number | string }): Promise<void> {
|
|
if (this.touchedMunicipalities.size === 0) return
|
|
|
|
// `name` is reset from the catalog, not left as the spec wrote it: specs that
|
|
// exercise search and sorting rename rows, and since B34+ the chips resolve
|
|
// labels through `municipalityCatalog`, so a name left diverged makes the UI
|
|
// and the fixture disagree in a LATER run — which is how it surfaced (an e2e
|
|
// chip rendering the catalog name against a fixture holding the renamed one).
|
|
const touched = await this.rootPayload.find({
|
|
collection: 'municipality',
|
|
where: { id: { in: [...this.touchedMunicipalities] } },
|
|
depth: 0,
|
|
limit: 0,
|
|
pagination: false,
|
|
select: { slug: true },
|
|
req,
|
|
})
|
|
const catalogNameById = new Map(
|
|
touched.docs.map((municipality) => [
|
|
municipality.id,
|
|
getMunicipalityCatalogEntry(municipality.slug)?.name,
|
|
]),
|
|
)
|
|
|
|
for (const municipalityID of this.touchedMunicipalities) {
|
|
const catalogName = catalogNameById.get(municipalityID)
|
|
await this.rootPayload.update({
|
|
collection: 'municipality',
|
|
id: municipalityID,
|
|
data: {
|
|
...(catalogName ? { name: catalogName } : {}),
|
|
advisors: [],
|
|
priority: 'normal',
|
|
expectedVotes: { pessimistic: null, central: null, optimistic: null },
|
|
politicalTrend: { status: null, note: null, recordedBy: null, recordedAt: null },
|
|
engagementLevel: null,
|
|
levelNote: null,
|
|
strengths: [],
|
|
risks: [],
|
|
stateDeputies: [],
|
|
dobradinhaNotes: null,
|
|
nextSteps: null,
|
|
lastUpdateAt: null,
|
|
},
|
|
depth: 0,
|
|
req,
|
|
})
|
|
}
|
|
}
|
|
|
|
async cleanup(): Promise<void> {
|
|
if (this.cleaned) return
|
|
await this.discoverDependents()
|
|
await withPayloadTransaction(this.rootPayload, async ({ req }) => {
|
|
for (const collection of [
|
|
'campaignInvite',
|
|
'votePledge',
|
|
'campaignDemand',
|
|
'allocationDecision',
|
|
'municipalityUpdate',
|
|
'activity',
|
|
'leadership',
|
|
'supporter',
|
|
'organization',
|
|
'stateDeputy',
|
|
'contact',
|
|
'calendarFeed',
|
|
] as const) {
|
|
await this.deleteOwned(collection, req)
|
|
}
|
|
await this.deleteNotificationOwned(req)
|
|
for (const collection of [
|
|
'supporterImportBatch',
|
|
'campaignUser',
|
|
'consent',
|
|
'users',
|
|
] as const) {
|
|
await this.deleteOwned(collection, req)
|
|
}
|
|
await this.resetTouchedMunicipalities(req)
|
|
if (this.ownedPreferences.size > 0) {
|
|
const transaction = this.rootPayload.db.sessions?.[String(req.transactionID)]?.db as
|
|
| { execute: (query: ReturnType<typeof sql.raw>) => Promise<unknown> }
|
|
| undefined
|
|
if (!transaction) throw new Error('Campaign fixture cleanup transaction is unavailable.')
|
|
await transaction.execute(
|
|
sql.raw(`
|
|
DELETE FROM "payload_preferences"
|
|
WHERE "id" IN (${[...this.ownedPreferences].join(', ')})
|
|
`),
|
|
)
|
|
}
|
|
if (this.ownedLockedDocuments.size > 0) {
|
|
const transaction = this.rootPayload.db.sessions?.[String(req.transactionID)]?.db as
|
|
| { execute: (query: ReturnType<typeof sql.raw>) => Promise<unknown> }
|
|
| undefined
|
|
if (!transaction) throw new Error('Campaign fixture cleanup transaction is unavailable.')
|
|
await transaction.execute(
|
|
sql.raw(`
|
|
DELETE FROM "payload_locked_documents"
|
|
WHERE "id" IN (${[...this.ownedLockedDocuments].join(', ')})
|
|
`),
|
|
)
|
|
}
|
|
})
|
|
// Release the allocator claim only AFTER the run's rows are purged: a
|
|
// concurrent run that claims this slot in the meantime would purge-on-claim
|
|
// the rows still live here (the release→purge window is the corruption the
|
|
// registry exists to prevent).
|
|
if (this.claimedAnyMunicipality) {
|
|
await releaseMunicipalityClaims(this.rootPayload, this.runID)
|
|
}
|
|
this.cleaned = true
|
|
}
|
|
|
|
async expectNoOwnedRows(): Promise<void> {
|
|
for (const collection of Object.keys(this.owned) as CampaignCollection[]) {
|
|
const ids = [...this.owned[collection]]
|
|
if (ids.length === 0) continue
|
|
const remaining = await this.rootPayload.find({
|
|
collection,
|
|
where: { id: { in: ids } },
|
|
depth: 0,
|
|
limit: 1,
|
|
})
|
|
if (remaining.docs.length > 0) {
|
|
throw new Error(`Owned ${collection} fixture rows remain after cleanup.`)
|
|
}
|
|
}
|
|
if (this.ownedPreferences.size > 0) {
|
|
const remainingPreferences = await this.rootPayload.db.drizzle.execute(
|
|
sql.raw(`
|
|
SELECT "id"
|
|
FROM "payload_preferences"
|
|
WHERE "id" IN (${[...this.ownedPreferences].join(', ')})
|
|
LIMIT 1
|
|
`),
|
|
)
|
|
if (remainingPreferences.rows.length > 0) {
|
|
throw new Error('Owned Payload preference fixture rows remain after cleanup.')
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
export const createCampaignFixtures = (payload: Payload): CampaignFixtures =>
|
|
new CampaignFixtures(payload)
|
|
|
|
export const installCampaignFixtures = ({
|
|
getPayload,
|
|
setPayload,
|
|
}: {
|
|
getPayload: () => Payload
|
|
setPayload: (payload: Payload) => void
|
|
}): (() => CampaignFixtures) => {
|
|
let rootPayload: Payload | undefined
|
|
let fixtures: CampaignFixtures | undefined
|
|
|
|
beforeEach(() => {
|
|
rootPayload ??= getPayload()
|
|
fixtures = createCampaignFixtures(rootPayload)
|
|
setPayload(fixtures.payload)
|
|
})
|
|
|
|
afterEach(async () => {
|
|
if (!fixtures || !rootPayload) return
|
|
try {
|
|
await fixtures.cleanup()
|
|
await fixtures.expectNoOwnedRows()
|
|
} finally {
|
|
setPayload(rootPayload)
|
|
fixtures = undefined
|
|
}
|
|
})
|
|
|
|
return () => {
|
|
if (!fixtures) throw new Error('Campaign fixtures are only available during a test.')
|
|
return fixtures
|
|
}
|
|
}
|
|
|
|
export const withCampaignFixtures = async <Result>(
|
|
payload: Payload,
|
|
operation: (fixtures: CampaignFixtures) => Promise<Result>,
|
|
): Promise<Result> => {
|
|
const fixtures = createCampaignFixtures(payload)
|
|
let operationError: unknown
|
|
try {
|
|
return await operation(fixtures)
|
|
} catch (error) {
|
|
operationError = error
|
|
throw error
|
|
} finally {
|
|
try {
|
|
await fixtures.cleanup()
|
|
await fixtures.expectNoOwnedRows()
|
|
} catch (cleanupError) {
|
|
if (operationError !== undefined) throw combineErrors(operationError, cleanupError)
|
|
throw cleanupError
|
|
}
|
|
}
|
|
}
|