/** * Phase 5a — claim event recording tests. * * Follows the same injectable-parameter pattern as closeTransition.test.js: * _TicketModel — controls the DB layer (updateOne) * _recordAction — captures recording calls without any module mocking * * No vi.mock needed; all dependencies injected directly. * * Covers: * (a) fresh claim — modifiedCount 1 → exactly one 'claim' event * (b) no-op re-claim — modifiedCount 0 (same user) → no event * (c) conditional filter — filter must exclude tickets already claimed by actor * (d) tier captured — escalationTier from ticket at claim time */ import { describe, it, expect, vi } from 'vitest'; import { applyClaim } from '../handlers/buttons.js'; // --------------------------------------------------------------------------- // Shared factories // --------------------------------------------------------------------------- function makeInteraction(userId = 'staff-001') { return { user: { id: userId, username: 'staffuser', toString: () => `<@${userId}>` }, member: { displayName: 'Staff Member' }, guild: { id: 'guild-001' }, channel: { id: 'chan-001' }, update: vi.fn().mockResolvedValue(undefined), followUp: vi.fn().mockResolvedValue(undefined) }; } function makeGuild() { return { members: { fetch: vi.fn().mockRejectedValue(new Error('no member in test env')) } }; } function makeTicket(overrides = {}) { return { gmailThreadId: 'discord-test-001', escalationTier: 0, claimerId: null, claimedBy: null, priority: 'normal', game: 'TestGame', senderEmail: 'user@example.com', creatorId: 'creator-001', ticketNumber: 42, ...overrides }; } function makeBtn() { const btn = {}; const chain = () => btn; btn.setCustomId = chain; btn.setLabel = chain; btn.setEmoji = chain; btn.setStyle = chain; btn.setDisabled = chain; return btn; } // --------------------------------------------------------------------------- // (a) Fresh claim — real transition // --------------------------------------------------------------------------- describe('applyClaim — fresh claim emits one event', () => { it('emits exactly one "claim" event with the correct staffId', async () => { const ticket = makeTicket({ escalationTier: 0, claimerId: null }); const mockUpdateOne = vi.fn().mockResolvedValue({ modifiedCount: 1 }); const mockRecord = vi.fn(); const interaction = makeInteraction('staff-001'); await applyClaim(interaction, ticket, {}, makeBtn(), makeBtn(), 'Staff Member', makeGuild(), { updateOne: mockUpdateOne }, mockRecord); expect(mockRecord).toHaveBeenCalledTimes(1); const [staffId, type] = mockRecord.mock.calls[0]; expect(staffId).toBe('staff-001'); expect(type).toBe('claim'); }); it('passes the ticket doc so the recorder can denormalize fields', async () => { const ticket = makeTicket({ escalationTier: 0, claimerId: null }); const mockUpdateOne = vi.fn().mockResolvedValue({ modifiedCount: 1 }); const mockRecord = vi.fn(); const interaction = makeInteraction('staff-001'); await applyClaim(interaction, ticket, {}, makeBtn(), makeBtn(), 'Staff Member', makeGuild(), { updateOne: mockUpdateOne }, mockRecord); const [, , payload] = mockRecord.mock.calls[0]; expect(payload.ticket).toBe(ticket); expect(payload.guildId).toBe('guild-001'); }); }); // --------------------------------------------------------------------------- // (b) No-op re-claim — same user double-click, modifiedCount 0 // --------------------------------------------------------------------------- describe('applyClaim — no-op re-claim emits no event', () => { it('emits no event when modifiedCount is 0', async () => { const ticket = makeTicket({ escalationTier: 0, claimerId: 'staff-001' }); const mockUpdateOne = vi.fn().mockResolvedValue({ modifiedCount: 0 }); const mockRecord = vi.fn(); const interaction = makeInteraction('staff-001'); await applyClaim(interaction, ticket, {}, makeBtn(), makeBtn(), 'Staff Member', makeGuild(), { updateOne: mockUpdateOne }, mockRecord); expect(mockRecord).not.toHaveBeenCalled(); }); }); // --------------------------------------------------------------------------- // (c) Conditional filter — DB write must exclude same-user claims // --------------------------------------------------------------------------- describe('applyClaim — conditional filter', () => { it('includes claimerId $ne the acting user in the updateOne filter', async () => { const ticket = makeTicket({ claimerId: null }); const mockUpdateOne = vi.fn().mockResolvedValue({ modifiedCount: 0 }); const interaction = makeInteraction('staff-001'); await applyClaim(interaction, ticket, {}, makeBtn(), makeBtn(), 'Staff Member', makeGuild(), { updateOne: mockUpdateOne }, vi.fn()); const [filter] = mockUpdateOne.mock.calls[0]; expect(filter).toMatchObject({ gmailThreadId: 'discord-test-001', claimerId: { $ne: 'staff-001' } }); }); }); // --------------------------------------------------------------------------- // (d) Tier captured at claim time // --------------------------------------------------------------------------- describe('applyClaim — tier captured at claim time', () => { it('passes the ticket with escalationTier=1 when the ticket is escalated', async () => { const ticket = makeTicket({ escalationTier: 1, claimerId: null }); const mockUpdateOne = vi.fn().mockResolvedValue({ modifiedCount: 1 }); const mockRecord = vi.fn(); const interaction = makeInteraction('staff-001'); await applyClaim(interaction, ticket, {}, makeBtn(), makeBtn(), 'Staff Member', makeGuild(), { updateOne: mockUpdateOne }, mockRecord); const [, , payload] = mockRecord.mock.calls[0]; expect(payload.ticket.escalationTier).toBe(1); }); it('passes the ticket with escalationTier=0 for a non-escalated ticket', async () => { const ticket = makeTicket({ escalationTier: 0, claimerId: null }); const mockUpdateOne = vi.fn().mockResolvedValue({ modifiedCount: 1 }); const mockRecord = vi.fn(); const interaction = makeInteraction('staff-001'); await applyClaim(interaction, ticket, {}, makeBtn(), makeBtn(), 'Staff Member', makeGuild(), { updateOne: mockUpdateOne }, mockRecord); const [, , payload] = mockRecord.mock.calls[0]; expect(payload.ticket.escalationTier).toBe(0); }); });