/** * Phase 4 — close event recording tests. * * Follows the same injectable-parameter pattern as closeTransition.test.js: * _TicketModel — controls the DB layer (updateOne / findOne / find) * _recordAction — captures recording calls without any module mocking * * No vi.mock needed; all dependencies injected directly. * * Covers: * (a) staff force-close — finalizeForceClose, closerId present in pendingCloses * (b) system auto-close — reconcileDeletedTicketChannels, channel absent * (c) no-op close — transitioned=false → no event */ import { describe, it, expect, vi } from 'vitest'; import { finalizeForceClose } from '../handlers/commands/close.js'; import { reconcileDeletedTicketChannels, checkAutoClose } from '../services/tickets.js'; // --------------------------------------------------------------------------- // Shared factories // --------------------------------------------------------------------------- function makeOpenTicket(overrides = {}) { return { gmailThreadId: 'discord-test-001', discordThreadId: 'chan-test-001', claimerId: 'claimer-001', claimedBy: 'ClaimerName', status: 'open', createdAt: new Date('2026-01-01'), escalationTier: 0, priority: 'normal', game: 'TestGame', senderEmail: 'user@example.com', creatorId: 'creator-001', ...overrides }; } function makeClosedTicket(openTicket) { return { ...openTicket, status: 'closed', closedAt: new Date() }; } /** * Minimal mock model for reconcile tests (only needs find / updateOne / findOne). * * @param {object[]} openTickets — rows returned by find() * @param {object} closedTicket — doc returned by findOne after transition * @param {number} modifiedCount — 1 = transition succeeded, 0 = no-op */ function makeReconcileModel(openTickets, closedTicket, modifiedCount = 1) { const chain = { sort: vi.fn().mockReturnThis(), limit: vi.fn().mockReturnThis(), lean: vi.fn().mockResolvedValue(openTickets) }; return { find: vi.fn().mockReturnValue(chain), findOne: vi.fn().mockReturnValue({ lean: vi.fn().mockResolvedValue(closedTicket) }), updateOne: vi.fn().mockResolvedValue({ modifiedCount }) }; } /** * Minimal mock model for finalizeForceClose. * findOne is called twice: * 1st — freshTicket lookup in finalizeForceClose itself * 2nd — post-transition fetch inside attemptCloseTransition */ function makeForceCloseModel(freshTicket, closedTicket, modifiedCount = 1) { return { find: vi.fn(), findOne: vi.fn() .mockReturnValueOnce({ lean: vi.fn().mockResolvedValue(freshTicket) }) .mockReturnValueOnce({ lean: vi.fn().mockResolvedValue(closedTicket) }), updateOne: vi.fn().mockResolvedValue({ modifiedCount }) }; } function makeGuild(id = 'guild-001') { return { id, channels: { cache: { get: vi.fn().mockReturnValue(null) }, fetch: vi.fn().mockResolvedValue(null) } }; } function makeClient(guild) { return { guilds: { cache: { get: vi.fn().mockReturnValue(null), first: vi.fn().mockReturnValue(guild) } } }; } /** Channel mock with send() so enqueueSend doesn't reject immediately. */ function makeChannelRef(id = 'chan-test-001', guildId = 'guild-001') { return { id, name: `ticket-${id}`, guild: { id: guildId }, send: vi.fn().mockResolvedValue({ id: 'sent-msg' }), delete: vi.fn().mockResolvedValue(undefined), messages: undefined // triggers transcript error (caught internally) }; } function makeClientRef() { return { channels: { fetch: vi.fn().mockResolvedValue(null) } }; } /** * Minimal mock model for checkAutoClose tests. * Mirrors makeReconcileModel — same shape, renamed for clarity. */ function makeAutoCloseModel(staleTickets, closedTicket, modifiedCount = 1) { const chain = { sort: vi.fn().mockReturnThis(), limit: vi.fn().mockReturnThis(), lean: vi.fn().mockResolvedValue(staleTickets) }; return { find: vi.fn().mockReturnValue(chain), findOne: vi.fn().mockReturnValue({ lean: vi.fn().mockResolvedValue(closedTicket) }), updateOne: vi.fn().mockResolvedValue({ modifiedCount }) }; } // =========================================================================== // (a) Staff force-close // =========================================================================== describe('finalizeForceClose — staff close', () => { it('emits one "close" event with closerType "staff", correct staffId / resolverId / wasClaimed', async () => { const open = makeOpenTicket({ gmailThreadId: 'discord-test-001', discordThreadId: 'chan-staff-001' }); const closed = makeClosedTicket(open); const model = makeForceCloseModel(open, closed, 1); const mockRecord = vi.fn(); const pc = new Map([['chan-staff-001', { closerId: 'staff-user-001', username: 'Staff#0001' }]]); await finalizeForceClose(makeChannelRef('chan-staff-001', 'guild-001'), makeClientRef(), model, mockRecord, pc); expect(mockRecord).toHaveBeenCalledTimes(1); const [staffId, type, payload] = mockRecord.mock.calls[0]; expect(staffId).toBe('staff-user-001'); expect(type).toBe('close'); expect(payload.closerType).toBe('staff'); expect(payload.resolverId).toBe('claimer-001'); expect(payload.wasClaimed).toBe(true); expect(payload.guildId).toBe('guild-001'); expect(payload.ticket).toBe(closed); }); it('uses closerType "system" and staffId "system" when no closerId in pendingCloses', async () => { const open = makeOpenTicket({ gmailThreadId: 'discord-test-002', discordThreadId: 'chan-sys-002' }); const closed = makeClosedTicket(open); const model = makeForceCloseModel(open, closed, 1); const mockRecord = vi.fn(); const pc = new Map(); // no entry for this channel await finalizeForceClose(makeChannelRef('chan-sys-002', 'guild-001'), makeClientRef(), model, mockRecord, pc); expect(mockRecord).toHaveBeenCalledTimes(1); const [staffId, , payload] = mockRecord.mock.calls[0]; expect(staffId).toBe('system'); expect(payload.closerType).toBe('system'); }); }); // =========================================================================== // (b) System auto-close: reconcileDeletedTicketChannels // =========================================================================== describe('reconcileDeletedTicketChannels — system close', () => { it('emits one "close" event with closerType "system", correct resolverId and wasClaimed', async () => { const open = makeOpenTicket(); const closed = makeClosedTicket(open); const model = makeReconcileModel([open], closed, 1); const mockRecord = vi.fn(); await reconcileDeletedTicketChannels(makeClient(makeGuild('guild-001')), model, mockRecord); expect(mockRecord).toHaveBeenCalledTimes(1); const [staffId, type, payload] = mockRecord.mock.calls[0]; expect(staffId).toBe('system'); expect(type).toBe('close'); expect(payload.closerType).toBe('system'); expect(payload.resolverId).toBe('claimer-001'); expect(payload.wasClaimed).toBe(true); expect(payload.guildId).toBe('guild-001'); expect(payload.ticket).toBe(closed); }); it('reflects wasClaimed=false and resolverId=null for an unclaimed ticket', async () => { const open = makeOpenTicket({ claimerId: null, claimedBy: null }); const closed = makeClosedTicket(open); const model = makeReconcileModel([open], closed, 1); const mockRecord = vi.fn(); await reconcileDeletedTicketChannels(makeClient(makeGuild('guild-001')), model, mockRecord); expect(mockRecord).toHaveBeenCalledTimes(1); const [, , payload] = mockRecord.mock.calls[0]; expect(payload.resolverId).toBeNull(); expect(payload.wasClaimed).toBe(false); }); }); // =========================================================================== // (c) No-op close — idempotency // =========================================================================== describe('no-op close — idempotency', () => { it('emits no event when the ticket channel still exists (reconcile skips close path)', async () => { const open = makeOpenTicket(); const model = { find: vi.fn().mockReturnValue({ sort: vi.fn().mockReturnThis(), limit: vi.fn().mockReturnThis(), lean: vi.fn().mockResolvedValue([open]) }), findOne: vi.fn(), updateOne: vi.fn() }; const mockRecord = vi.fn(); const guild = { id: 'guild-001', channels: { cache: { get: vi.fn().mockReturnValue({ id: open.discordThreadId }) }, // channel IS present fetch: vi.fn() } }; await reconcileDeletedTicketChannels(makeClient(guild), model, mockRecord); expect(mockRecord).not.toHaveBeenCalled(); expect(model.updateOne).not.toHaveBeenCalled(); }); it('emits no event when attemptCloseTransition reports transitioned=false (ticket was already closed)', async () => { const open = makeOpenTicket(); const model = makeReconcileModel([open], null, 0); // modifiedCount 0 → no transition const mockRecord = vi.fn(); await reconcileDeletedTicketChannels(makeClient(makeGuild('guild-001')), model, mockRecord); expect(mockRecord).not.toHaveBeenCalled(); }); }); // =========================================================================== // (d) System auto-close: checkAutoClose // =========================================================================== const TEST_CONFIG = { AUTO_CLOSE_ENABLED: true, AUTO_CLOSE_AFTER_HOURS: 72, DISCORD_AUTO_CLOSE_MESSAGE: 'closing' }; describe('checkAutoClose — system close', () => { it('emits one "close" event with closerType "system", staffId "system", correct resolverId and wasClaimed', async () => { const open = makeOpenTicket(); const closed = makeClosedTicket(open); const model = makeAutoCloseModel([open], closed, 1); const mockRecord = vi.fn(); const deps = { config: TEST_CONFIG, withRetry: fn => fn(), enqueueSend: vi.fn().mockResolvedValue(undefined), scheduleDelete: vi.fn() }; const channel = { id: open.discordThreadId, send: vi.fn() }; const guild = { id: 'guild-001', channels: { fetch: vi.fn().mockResolvedValue(channel) } }; const client = { guilds: { cache: { first: vi.fn().mockReturnValue(guild) } } }; await checkAutoClose(client, vi.fn().mockResolvedValue(undefined), model, mockRecord, deps); expect(mockRecord).toHaveBeenCalledTimes(1); const [staffId, type, payload] = mockRecord.mock.calls[0]; expect(staffId).toBe('system'); expect(type).toBe('close'); expect(payload.closerType).toBe('system'); expect(payload.resolverId).toBe('claimer-001'); expect(payload.wasClaimed).toBe(true); expect(payload.guildId).toBe('guild-001'); expect(payload.ticket).toBe(closed); }); it('emits no event when attemptCloseTransition reports transitioned=false', async () => { const open = makeOpenTicket(); const model = makeAutoCloseModel([open], null, 0); // modifiedCount 0 → no transition const mockRecord = vi.fn(); const deps = { config: TEST_CONFIG, withRetry: fn => fn(), enqueueSend: vi.fn().mockResolvedValue(undefined), scheduleDelete: vi.fn() }; const channel = { id: open.discordThreadId, send: vi.fn() }; const guild = { id: 'guild-001', channels: { fetch: vi.fn().mockResolvedValue(channel) } }; const client = { guilds: { cache: { first: vi.fn().mockReturnValue(guild) } } }; await checkAutoClose(client, vi.fn().mockResolvedValue(undefined), model, mockRecord, deps); expect(mockRecord).not.toHaveBeenCalled(); }); });