import { describe, it, expect, vi, beforeEach } from 'vitest'; // Import the real module — no module-level mocks needed. // attemptCloseTransition accepts an optional 4th arg (_TicketModel) so tests // can inject a mock without mocking the whole db-connection chain. import { attemptCloseTransition } from '../services/tickets.js'; describe('attemptCloseTransition', () => { let mockUpdateOne, mockFindOne, mockTicket; beforeEach(() => { mockUpdateOne = vi.fn(); mockFindOne = vi.fn(); mockTicket = { updateOne: mockUpdateOne, findOne: mockFindOne }; }); it('returns transitioned=true and the fetched ticket when an open ticket is closed', async () => { const closedTicket = { gmailThreadId: 'thread-open', status: 'closed', closedAt: new Date() }; mockUpdateOne.mockResolvedValue({ modifiedCount: 1 }); mockFindOne.mockReturnValue({ lean: vi.fn().mockResolvedValue(closedTicket) }); const result = await attemptCloseTransition('thread-open', {}, {}, mockTicket); expect(result.transitioned).toBe(true); expect(result.ticket).toBe(closedTicket); }); it('gates the update on status:"open" so only open tickets are closed', async () => { mockUpdateOne.mockResolvedValue({ modifiedCount: 1 }); mockFindOne.mockReturnValue({ lean: vi.fn().mockResolvedValue({}) }); await attemptCloseTransition('thread-open', {}, {}, mockTicket); expect(mockUpdateOne).toHaveBeenCalledWith( { gmailThreadId: 'thread-open', status: 'open' }, expect.anything() ); }); it('includes a closedAt Date in the $set', async () => { mockUpdateOne.mockResolvedValue({ modifiedCount: 1 }); mockFindOne.mockReturnValue({ lean: vi.fn().mockResolvedValue({}) }); await attemptCloseTransition('thread-open', {}, {}, mockTicket); const [, update] = mockUpdateOne.mock.calls[0]; expect(update.$set.closedAt).toBeInstanceOf(Date); }); it('returns transitioned=false and null ticket when the ticket is already closed', async () => { mockUpdateOne.mockResolvedValue({ modifiedCount: 0 }); const result = await attemptCloseTransition('thread-closed', {}, {}, mockTicket); expect(result.transitioned).toBe(false); expect(result.ticket).toBeNull(); }); it('does not call findOne when no transition occurred', async () => { mockUpdateOne.mockResolvedValue({ modifiedCount: 0 }); await attemptCloseTransition('thread-closed', {}, {}, mockTicket); expect(mockFindOne).not.toHaveBeenCalled(); }); it('is a no-op on a second call — idempotency seam later phases rely on', async () => { mockUpdateOne.mockResolvedValueOnce({ modifiedCount: 1 }); mockFindOne.mockReturnValueOnce({ lean: vi.fn().mockResolvedValue({ gmailThreadId: 'thread-x' }) }); const first = await attemptCloseTransition('thread-x', {}, {}, mockTicket); expect(first.transitioned).toBe(true); mockUpdateOne.mockResolvedValueOnce({ modifiedCount: 0 }); const second = await attemptCloseTransition('thread-x', {}, {}, mockTicket); expect(second.transitioned).toBe(false); expect(second.ticket).toBeNull(); }); it('folds extraSet fields into the $set alongside status and closedAt', async () => { mockUpdateOne.mockResolvedValue({ modifiedCount: 1 }); mockFindOne.mockReturnValue({ lean: vi.fn().mockResolvedValue({}) }); await attemptCloseTransition('thread-x', { discordThreadId: null, pendingDelete: true }, {}, mockTicket); const [, update] = mockUpdateOne.mock.calls[0]; expect(update.$set.status).toBe('closed'); expect(update.$set.discordThreadId).toBeNull(); expect(update.$set.pendingDelete).toBe(true); }); it('includes $unset in the update when extraUnset is non-empty', async () => { mockUpdateOne.mockResolvedValue({ modifiedCount: 1 }); mockFindOne.mockReturnValue({ lean: vi.fn().mockResolvedValue({}) }); await attemptCloseTransition('thread-x', {}, { welcomeMessageId: '' }, mockTicket); const [, update] = mockUpdateOne.mock.calls[0]; expect(update.$unset).toEqual({ welcomeMessageId: '' }); }); it('omits $unset from the update when extraUnset is empty', async () => { mockUpdateOne.mockResolvedValue({ modifiedCount: 1 }); mockFindOne.mockReturnValue({ lean: vi.fn().mockResolvedValue({}) }); await attemptCloseTransition('thread-x', {}, {}, mockTicket); const [, update] = mockUpdateOne.mock.calls[0]; expect(update.$unset).toBeUndefined(); }); });