UNPKG

@accounter/server

Version:
193 lines (161 loc) • 6.03 kB
import { beforeEach, describe, expect, it, vi } from 'vitest'; import { cleanupExpiredInvitations, type Logger } from '../cleanup-expired-invitations.js'; import type { DBProvider } from '../../modules/app-providers/db.provider.js'; import type { Auth0ManagementProvider } from '../../modules/auth/providers/auth0-management.provider.js'; type QueryResult<T = unknown> = { rows: T[]; rowCount?: number; }; describe('cleanupExpiredInvitations', () => { let mockQuery: ReturnType<typeof vi.fn<(query: string, values?: unknown[]) => Promise<QueryResult>>>; let mockRelease: ReturnType<typeof vi.fn>; let mockConnect: ReturnType<typeof vi.fn>; let mockDeleteUser: ReturnType<typeof vi.fn<(auth0UserId: string) => Promise<void>>>; let db: DBProvider; let auth0: Auth0ManagementProvider; let logger: Logger; beforeEach(() => { mockQuery = vi.fn(); mockRelease = vi.fn(); mockConnect = vi.fn().mockResolvedValue({ query: mockQuery, release: mockRelease, }); db = { pool: { connect: mockConnect, }, query: mockQuery, } as unknown as DBProvider; mockDeleteUser = vi.fn().mockResolvedValue(undefined); auth0 = { deleteUser: mockDeleteUser, } as unknown as Auth0ManagementProvider; logger = { info: vi.fn(), error: vi.fn(), }; }); it('deletes expired invitations, orphaned business users, and writes audit logs', async () => { mockQuery .mockResolvedValueOnce({ rows: [ { id: 'inv-1', auth0_user_id: 'auth0|123', auth0_user_created: true, user_id: 'user-1', }, ], }) .mockResolvedValue({ rows: [], rowCount: 1 }); const result = await cleanupExpiredInvitations(db, auth0, logger); expect(result).toEqual({ deleted: 1, errors: 0 }); expect(auth0.deleteUser).toHaveBeenCalledWith('auth0|123'); const queries = mockQuery.mock.calls.map(([query]) => query); expect(queries.some(query => query.includes('DELETE FROM accounter_schema.business_users'))).toBe( true, ); expect(queries.some(query => query.includes('DELETE FROM accounter_schema.invitations'))).toBe( true, ); expect(queries.some(query => query.includes('INSERT INTO accounter_schema.audit_logs'))).toBe( true, ); expect(mockQuery).toHaveBeenCalledWith( expect.stringContaining('DELETE FROM accounter_schema.business_users'), ['user-1'], ); expect(mockRelease).toHaveBeenCalledTimes(1); }); it('does not attempt to delete Auth0 user when invitation has no Auth0 account', async () => { mockQuery .mockResolvedValueOnce({ rows: [ { id: 'inv-2', auth0_user_id: null, auth0_user_created: false, user_id: 'user-2', }, ], }) .mockResolvedValue({ rows: [], rowCount: 1 }); const result = await cleanupExpiredInvitations(db, auth0, logger); expect(result).toEqual({ deleted: 1, errors: 0 }); expect(auth0.deleteUser).not.toHaveBeenCalled(); }); it('does not delete accepted invitations because selector excludes accepted rows', async () => { mockQuery.mockResolvedValueOnce({ rows: [] }); const result = await cleanupExpiredInvitations(db, auth0, logger); expect(result).toEqual({ deleted: 0, errors: 0 }); expect(auth0.deleteUser).not.toHaveBeenCalled(); const [selectorQuery] = mockQuery.mock.calls[0]; expect(selectorQuery).toContain('WHERE accepted_at IS NULL'); }); it('does not delete non-expired invitations because selector only fetches expired rows', async () => { mockQuery.mockResolvedValueOnce({ rows: [] }); const result = await cleanupExpiredInvitations(db, auth0, logger); expect(result).toEqual({ deleted: 0, errors: 0 }); expect(auth0.deleteUser).not.toHaveBeenCalled(); const [selectorQuery] = mockQuery.mock.calls[0]; expect(selectorQuery).toContain('AND expires_at < NOW()'); }); it('logs Auth0/API failures and continues processing remaining invitations', async () => { mockDeleteUser.mockRejectedValueOnce(new Error('Auth0 rate limit')).mockResolvedValueOnce(undefined); mockQuery .mockResolvedValueOnce({ rows: [ { id: 'inv-fail', auth0_user_id: 'auth0|fail', auth0_user_created: true, user_id: 'user-fail', }, { id: 'inv-ok', auth0_user_id: 'auth0|ok', auth0_user_created: true, user_id: 'user-ok', }, ], }) .mockResolvedValue({ rows: [], rowCount: 1 }); const result = await cleanupExpiredInvitations(db, auth0, logger); expect(result).toEqual({ deleted: 1, errors: 1 }); expect(logger.error).toHaveBeenCalledTimes(1); expect(logger.error).toHaveBeenCalledWith('Failed to cleanup invitation', expect.objectContaining({ invitationId: 'inv-fail' }), ); expect(mockQuery).toHaveBeenCalledWith( 'DELETE FROM accounter_schema.invitations WHERE id = $1', ['inv-ok'], ); }); it('creates one audit log entry per cleaned invitation', async () => { mockQuery .mockResolvedValueOnce({ rows: [ { id: 'inv-1', auth0_user_id: 'auth0|1', auth0_user_created: true, user_id: 'user-1', }, { id: 'inv-2', auth0_user_id: 'auth0|2', auth0_user_created: true, user_id: 'user-2', }, ], }) .mockResolvedValue({ rows: [], rowCount: 1 }); const result = await cleanupExpiredInvitations(db, auth0, logger); expect(result).toEqual({ deleted: 2, errors: 0 }); const auditInsertCalls = mockQuery.mock.calls.filter(([query]) => query.includes('INSERT INTO accounter_schema.audit_logs'), ); expect(auditInsertCalls).toHaveLength(2); }); });