import { describe, it, expect, vi } from 'vitest'; // Stub debugLog so the import chain doesn't pull in discord.js / config. vi.mock('../services/debugLog.js', () => ({ logError: vi.fn() })); import { recordAction, denormalizeTicket, deriveTicketType } from '../services/staffStats.js'; // --------------------------------------------------------------------------- // deriveTicketType // --------------------------------------------------------------------------- describe('deriveTicketType', () => { it('returns "discord" for discord- prefix', () => { expect(deriveTicketType('discord-abc123')).toBe('discord'); }); it('returns "discord" for discord-msg- prefix', () => { expect(deriveTicketType('discord-msg-abc123')).toBe('discord'); }); it('returns "email" for a Gmail thread ID', () => { expect(deriveTicketType('18f3a2b1c0d4e5f6')).toBe('email'); }); it('returns "email" for null / undefined / empty gmailThreadId', () => { expect(deriveTicketType(null)).toBe('email'); expect(deriveTicketType(undefined)).toBe('email'); expect(deriveTicketType('')).toBe('email'); }); }); // --------------------------------------------------------------------------- // denormalizeTicket — field extraction // --------------------------------------------------------------------------- describe('denormalizeTicket', () => { const emailTicket = { gmailThreadId: '18f3a2b1c0d4e5f6', escalationTier: 1, priority: 'high', game: 'Minecraft', senderEmail: 'user@example.com', creatorId: '111222333444555666', claimerId: '999888777666555444' }; const discordTicket = { gmailThreadId: 'discord-msg-xyz789', escalationTier: 0, priority: 'normal', game: null, senderEmail: 'noreply@discord', creatorId: '777666555444333222' }; it('derives ticketType "email" for a Gmail thread', () => { expect(denormalizeTicket(emailTicket).ticketType).toBe('email'); }); it('derives ticketType "discord" for a discord-msg- thread', () => { expect(denormalizeTicket(discordTicket).ticketType).toBe('discord'); }); it('copies all standard event fields from the ticket', () => { const f = denormalizeTicket(emailTicket); expect(f.tier).toBe(1); expect(f.priority).toBe('high'); expect(f.game).toBe('Minecraft'); expect(f.senderEmail).toBe('user@example.com'); expect(f.creatorId).toBe('111222333444555666'); expect(f.gmailThreadId).toBe('18f3a2b1c0d4e5f6'); }); it('defaults tier to 0 when escalationTier is absent', () => { expect(denormalizeTicket({ gmailThreadId: 'abc' }).tier).toBe(0); }); it('does NOT include guildId (must come from call site)', () => { const f = denormalizeTicket(emailTicket); expect(Object.prototype.hasOwnProperty.call(f, 'guildId')).toBe(false); }); it('returns {} for a null ticket', () => { expect(denormalizeTicket(null)).toEqual({}); }); }); // --------------------------------------------------------------------------- // recordAction — payload merging / override precedence // --------------------------------------------------------------------------- describe('recordAction payload merging', () => { const ticket = { gmailThreadId: '18f3a2b1c0d4e5f6', escalationTier: 2, priority: 'medium', game: 'Rust', senderEmail: 'player@example.com', creatorId: '100200300400500600' }; it('payload fields override denormalized ticket fields', () => { const { ticket: t, ...overrides } = { ticket, guildId: '555666777888999000', game: 'OverriddenGame', priority: 'low' }; const merged = { ...denormalizeTicket(t), ...overrides }; expect(merged.game).toBe('OverriddenGame'); expect(merged.priority).toBe('low'); expect(merged.guildId).toBe('555666777888999000'); }); it('guildId (call-site only) passes through from payload', () => { const { ticket: t, ...rest } = { ticket, guildId: '123456789012345678' }; const merged = { ...denormalizeTicket(t), ...rest }; expect(merged.guildId).toBe('123456789012345678'); }); it('close-only fields pass through from payload', () => { const { ticket: t, ...rest } = { ticket, closerType: 'staff', resolverId: '123456789012345678', wasClaimed: true }; const merged = { ...denormalizeTicket(t), ...rest }; expect(merged.closerType).toBe('staff'); expect(merged.resolverId).toBe('123456789012345678'); expect(merged.wasClaimed).toBe(true); }); it('transfer-only fields (fromId/toId) pass through from payload', () => { const { ticket: t, ...rest } = { ticket, fromId: '111111111111111111', toId: '222222222222222222' }; const merged = { ...denormalizeTicket(t), ...rest }; expect(merged.fromId).toBe('111111111111111111'); expect(merged.toId).toBe('222222222222222222'); }); }); // --------------------------------------------------------------------------- // recordAction — fire-and-forget discipline // // In the test environment there is no real MongoDB connection and the // StaffAction model schema is not registered, so mongoose.model('StaffAction') // throws MissingSchemaError synchronously. This is exactly the kind of error // recordAction must swallow — it proves the outer try/catch works. The async // .catch() path is exercised transitively: any callers in later phases that // succeed with a real DB connection will hit that path. // --------------------------------------------------------------------------- describe('recordAction fire-and-forget', () => { it('returns undefined (callers do not await)', () => { expect(recordAction('staff1', 'claim', {})).toBeUndefined(); }); it('does not throw when called with null / undefined / empty payload', () => { expect(() => recordAction('staff1', 'reopen', undefined)).not.toThrow(); expect(() => recordAction('staff1', 'reopen', null)).not.toThrow(); expect(() => recordAction('staff1', 'reopen', {})).not.toThrow(); }); it('does not throw even when the model layer errors (model not registered)', () => { // mongoose.model('StaffAction') throws MissingSchemaError synchronously in // this environment — recordAction must absorb it and never rethrow. expect(() => recordAction('staff1', 'close', { ticket: { gmailThreadId: 'discord-abc', escalationTier: 0, priority: 'normal', senderEmail: 'a@b.com', creatorId: '123' }, guildId: '999', closerType: 'staff', resolverId: '123456789012345678', wasClaimed: true })).not.toThrow(); }); });