// @vitest-environment node import { getPayload, type Payload } from 'payload' import { beforeAll, describe, expect, it, vi } from 'vitest' import config from '@/payload.config' import { contactPhoneLockKeys, CONTACT_PHONE_CONFLICT_MESSAGE, } from '@/utilities/contactPhoneInvariant' import { acquireTextAdvisoryLocks, getPostgresTransactionDatabase, type PostgresTransactionDatabase, } from '@/utilities/postgresTransactionLocks' import { getTestTransactionBackendPID, waitForAdvisoryLockWaiter, } from '../helpers/testDatabaseLease' let payload: Payload const beginTransaction = async (): Promise<{ req: { transactionID: number | string } transaction: PostgresTransactionDatabase transactionID: number | string }> => { const transactionID = await payload.db.beginTransaction() if (transactionID === null) throw new Error('Expected a PostgreSQL transaction.') const req = { transactionID } const transaction = await getPostgresTransactionDatabase(payload, req) return { req, transaction, transactionID } } const rollback = async (...transactionIDs: Array): Promise => { await Promise.all( transactionIDs.map((transactionID) => payload.db.rollbackTransaction(transactionID)), ) } describe('PostgreSQL transaction advisory locks', () => { beforeAll(async () => { payload = await getPayload({ config: await config }) }) it('builds one canonical sorted contact-phone namespace for creates and changes', () => { expect(contactPhoneLockKeys(['71999990002', '71999990001', '71999990002'])).toEqual([ 'contact-phone:71999990001', 'contact-phone:71999990002', ]) expect(CONTACT_PHONE_CONFLICT_MESSAGE).toBe( 'Já existe outro contato com este celular.', ) }) it('uses only the exact Payload transaction session and fails closed without it', async () => { const sessionDatabase = { execute: vi.fn().mockResolvedValue(undefined) } const unrelatedDatabase = { execute: vi.fn().mockResolvedValue(undefined) } const fixture = { db: { name: 'postgres', sessions: { '41': { db: sessionDatabase }, '42': { db: unrelatedDatabase }, }, }, } await expect( getPostgresTransactionDatabase(fixture as never, { transactionID: Promise.resolve(41), }), ).resolves.toBe(sessionDatabase) await acquireTextAdvisoryLocks(fixture as never, { transactionID: 41 }, [ 'domain:2', 'domain:1', 'domain:1', ]) expect(sessionDatabase.execute).toHaveBeenCalledTimes(2) expect( sessionDatabase.execute.mock.calls.map( ([query]) => (query as { queryChunks: unknown[] }).queryChunks[1], ), ).toEqual(['domain:1', 'domain:2']) expect(unrelatedDatabase.execute).not.toHaveBeenCalled() await expect(getPostgresTransactionDatabase(fixture as never, {})).rejects.toThrow( 'A transação PostgreSQL não está disponível.', ) await expect( acquireTextAdvisoryLocks(fixture as never, {}, ['domain:1']), ).rejects.toThrow('A transação PostgreSQL não está disponível.') await expect( getPostgresTransactionDatabase(fixture as never, { transactionID: 99 }), ).rejects.toThrow('A sessão PostgreSQL da transação não está disponível.') }) it('rejects invalid text keys before acquiring any lock', async () => { const database = { execute: vi.fn().mockResolvedValue(undefined) } const fixture = { db: { name: 'postgres', sessions: { '51': { db: database } }, }, } await expect( acquireTextAdvisoryLocks(fixture as never, { transactionID: 51 }, [ 'valid:key', ' ', ]), ).rejects.toThrow('A chave do bloqueio PostgreSQL deve ser um texto não vazio.') expect(database.execute).not.toHaveBeenCalled() }) it('serializes the same namespace and exposes its stable text hash in pg_locks', async () => { const first = await beginTransaction() const second = await beginTransaction() const key = `phase2c:same:${Date.now()}` try { await acquireTextAdvisoryLocks(payload, first.req, [key]) const waiterPID = await getTestTransactionBackendPID(payload, second.transactionID) const pending = acquireTextAdvisoryLocks(payload, second.req, [key]) const waiting = await waitForAdvisoryLockWaiter(payload, { key, mode: 'ExclusiveLock', waiterPID, }) expect(waiting).toMatchObject({ classID: waiting.expectedClassID, granted: false, objectID: waiting.expectedObjectID, objectSubID: 1, pid: waiterPID, }) await payload.db.rollbackTransaction(first.transactionID) await pending await payload.db.rollbackTransaction(second.transactionID) } catch (error) { await rollback(first.transactionID, second.transactionID).catch(() => undefined) throw error } }) it('allows different domains with the same entity ID to overlap', async () => { const first = await beginTransaction() const second = await beginTransaction() const entityID = Date.now() try { await acquireTextAdvisoryLocks(payload, first.req, [`vote-estimate:${entityID}`]) await expect( acquireTextAdvisoryLocks(payload, second.req, [`nucleus-updates:${entityID}`]), ).resolves.toBeUndefined() } finally { await rollback(first.transactionID, second.transactionID) } }) it('sorts and deduplicates multiple keys so opposite input orders cannot deadlock', async () => { const first = await beginTransaction() const second = await beginTransaction() const prefix = `phase2c:order:${Date.now()}` const low = `${prefix}:a` const high = `${prefix}:b` try { await acquireTextAdvisoryLocks(payload, first.req, [high, low, high]) const waiterPID = await getTestTransactionBackendPID(payload, second.transactionID) const pending = acquireTextAdvisoryLocks(payload, second.req, [low, high, low]) await expect( waitForAdvisoryLockWaiter(payload, { key: low, mode: 'ExclusiveLock', waiterPID, }), ).resolves.toMatchObject({ granted: false, pid: waiterPID }) await payload.db.rollbackTransaction(first.transactionID) await pending await payload.db.rollbackTransaction(second.transactionID) } catch (error) { await rollback(first.transactionID, second.transactionID).catch(() => undefined) throw error } }) })