/** * Phase 10 — /stats command handler tests. * Injectable deps — no vi.mock. */ import { describe, it, expect, vi } from 'vitest'; import { MessageFlags } from 'discord.js'; import { handleStats, handleStatsAutocomplete } from '../handlers/commands/stats.js'; const MS_PER_DAY = 24 * 60 * 60 * 1000; const NOW_MS = 1_700_000_000_000; // fixed epoch for deterministic cutoff assertions // --------------------------------------------------------------------------- // Factories // --------------------------------------------------------------------------- function makeInteraction({ userId = 'caller-001', memberUserId, memberUsername, periodStr, source } = {}) { return { user: { id: userId, username: 'testuser' }, guildId: 'guild-001', options: { getUser: (name) => { if (name === 'member' && memberUserId) { return { id: memberUserId, username: memberUsername || 'member-user' }; } return null; }, getString: (name) => { if (name === 'period') return periodStr || null; if (name === 'source') return source || null; return null; } }, reply: vi.fn().mockResolvedValue(undefined) }; } function makeStaffAction(events = []) { return { find: vi.fn().mockReturnValue({ lean: () => Promise.resolve(events) }) }; } function captureStaffAction() { let capturedFilter; return { sa: { find: (filter) => { capturedFilter = filter; return { lean: () => Promise.resolve([]) }; } }, getFilter: () => capturedFilter }; } function deps(overrides = {}) { return { StaffAction: makeStaffAction(), now: () => NOW_MS, adminIds: [], ...overrides }; } // --------------------------------------------------------------------------- // handleStatsAutocomplete // --------------------------------------------------------------------------- describe('handleStatsAutocomplete', () => { function makeAutoInteraction(focusedValue = '') { return { options: { getFocused: () => focusedValue }, respond: vi.fn().mockResolvedValue(undefined) }; } it('returns all 5 presets when focused input is empty', async () => { const i = makeAutoInteraction(''); await handleStatsAutocomplete(i); const [[suggestions]] = i.respond.mock.calls; expect(suggestions).toHaveLength(5); const values = suggestions.map(s => s.value); expect(values).toContain('7 days'); expect(values).toContain('30 days'); expect(values).toContain('3 months'); expect(values).toContain('6 months'); expect(values).toContain('1 year'); }); it('filters to presets matching the typed substring', async () => { const i = makeAutoInteraction('days'); await handleStatsAutocomplete(i); const [[suggestions]] = i.respond.mock.calls; const values = suggestions.map(s => s.value); expect(values).toContain('7 days'); expect(values).toContain('30 days'); expect(values).not.toContain('3 months'); expect(values).not.toContain('1 year'); }); it('echoes typed input as first suggestion when it does not exactly match a preset', async () => { const i = makeAutoInteraction('14d'); await handleStatsAutocomplete(i); const [[suggestions]] = i.respond.mock.calls; expect(suggestions[0].value).toBe('14d'); expect(suggestions[0].name).toBe('14d'); }); it('does not duplicate a preset when typed input exactly matches one', async () => { const i = makeAutoInteraction('30 days'); await handleStatsAutocomplete(i); const [[suggestions]] = i.respond.mock.calls; expect(suggestions.filter(s => s.value === '30 days')).toHaveLength(1); }); it('calls interaction.respond exactly once', async () => { const i = makeAutoInteraction(''); await handleStatsAutocomplete(i); expect(i.respond).toHaveBeenCalledTimes(1); }); }); // --------------------------------------------------------------------------- // handleStats — gating (STATS_ADMIN_IDS) // --------------------------------------------------------------------------- describe('handleStats — gating', () => { it('caller views own stats when no member option is provided', async () => { const interaction = makeInteraction({ userId: 'caller-001' }); await handleStats(interaction, deps()); expect(interaction.reply).toHaveBeenCalledTimes(1); const [[replyArg]] = interaction.reply.mock.calls; expect(replyArg.content).toBeUndefined(); expect(replyArg.embeds).toBeDefined(); }); it('admin can view another member\'s stats', async () => { const interaction = makeInteraction({ userId: 'admin-001', memberUserId: 'other-002' }); await handleStats(interaction, deps({ adminIds: ['admin-001'] })); const [[replyArg]] = interaction.reply.mock.calls; expect(replyArg.embeds).toBeDefined(); expect(replyArg.content).toBeUndefined(); }); it('non-admin is blocked with the exact error message when member option is set', async () => { const interaction = makeInteraction({ userId: 'plain-001', memberUserId: 'other-002' }); await handleStats(interaction, deps({ adminIds: [] })); const [[replyArg]] = interaction.reply.mock.calls; expect(replyArg.content).toBe('You can only view your own stats.'); expect(replyArg.flags).toBe(MessageFlags.Ephemeral); expect(replyArg.embeds).toBeUndefined(); }); it('non-admin can view their own stats (no member option)', async () => { const interaction = makeInteraction({ userId: 'plain-001' }); await handleStats(interaction, deps({ adminIds: [] })); const [[replyArg]] = interaction.reply.mock.calls; expect(replyArg.content).toBeUndefined(); expect(replyArg.embeds).toBeDefined(); }); it('StaffAction is never queried when the non-admin gate fires', async () => { const sa = makeStaffAction(); const interaction = makeInteraction({ userId: 'plain-001', memberUserId: 'other-002' }); await handleStats(interaction, deps({ StaffAction: sa, adminIds: [] })); expect(sa.find).not.toHaveBeenCalled(); }); }); // --------------------------------------------------------------------------- // handleStats — period default (no option → 30 days) // --------------------------------------------------------------------------- describe('handleStats — period default', () => { it('uses a 30-day cutoff when no period option is given', async () => { const { sa, getFilter } = captureStaffAction(); const interaction = makeInteraction(); await handleStats(interaction, { StaffAction: sa, now: () => NOW_MS, adminIds: [] }); const expectedCutoff = new Date(NOW_MS - 30 * MS_PER_DAY); expect(getFilter().createdAt.$gte.getTime()).toBe(expectedCutoff.getTime()); }); it('embed title includes "30 days" when no period option', async () => { const interaction = makeInteraction(); await handleStats(interaction, deps()); const [[replyArg]] = interaction.reply.mock.calls; expect(replyArg.embeds[0].data.title).toContain('30 days'); }); it('embed title includes the user-supplied period label', async () => { const interaction = makeInteraction({ periodStr: '7 days' }); await handleStats(interaction, deps()); const [[replyArg]] = interaction.reply.mock.calls; expect(replyArg.embeds[0].data.title).toContain('7 days'); }); }); // --------------------------------------------------------------------------- // handleStats — source filter passthrough // --------------------------------------------------------------------------- describe('handleStats — source filter', () => { it('source="email" counts only email events', async () => { const events = [ { staffId: 'caller-001', type: 'claim', tier: 0, ticketType: 'email', resolverId: null, toId: null, fromId: null }, { staffId: 'caller-001', type: 'claim', tier: 0, ticketType: 'discord', resolverId: null, toId: null, fromId: null } ]; const sa = { find: () => ({ lean: () => Promise.resolve(events) }) }; const interaction = makeInteraction({ source: 'email' }); await handleStats(interaction, { StaffAction: sa, now: () => NOW_MS, adminIds: [] }); const [[replyArg]] = interaction.reply.mock.calls; const claimsField = replyArg.embeds[0].data.fields.find(f => f.name === 'Claims'); // Only 1 email claim should be counted expect(claimsField.value).toMatch(/^1/); }); it('source label appears in embed description', async () => { const interaction = makeInteraction({ source: 'discord' }); await handleStats(interaction, deps()); const [[replyArg]] = interaction.reply.mock.calls; expect(replyArg.embeds[0].data.description).toContain('discord'); }); it('omitted source defaults to "all" and shows "all sources" in description', async () => { const interaction = makeInteraction(); await handleStats(interaction, deps()); const [[replyArg]] = interaction.reply.mock.calls; expect(replyArg.embeds[0].data.description).toContain('all sources'); }); }); // --------------------------------------------------------------------------- // handleStats — StaffAction query filter shape // --------------------------------------------------------------------------- describe('handleStats — query filter shape', () => { it('$or includes all 4 target fields', async () => { const { sa, getFilter } = captureStaffAction(); const interaction = makeInteraction({ userId: 'user-001' }); await handleStats(interaction, { StaffAction: sa, now: () => NOW_MS, adminIds: [] }); expect(getFilter().$or).toHaveLength(4); expect(getFilter().$or).toContainEqual({ staffId: 'user-001' }); expect(getFilter().$or).toContainEqual({ resolverId: 'user-001' }); expect(getFilter().$or).toContainEqual({ toId: 'user-001' }); expect(getFilter().$or).toContainEqual({ fromId: 'user-001' }); }); it('createdAt.$gte is a Date instance', async () => { const { sa, getFilter } = captureStaffAction(); const interaction = makeInteraction(); await handleStats(interaction, { StaffAction: sa, now: () => NOW_MS, adminIds: [] }); expect(getFilter().createdAt.$gte).toBeInstanceOf(Date); }); it('admin querying another member uses member id (not admin id) in $or', async () => { const { sa, getFilter } = captureStaffAction(); const interaction = makeInteraction({ userId: 'admin-001', memberUserId: 'other-002' }); await handleStats(interaction, { StaffAction: sa, now: () => NOW_MS, adminIds: ['admin-001'] }); expect(getFilter().$or).toContainEqual({ staffId: 'other-002' }); expect(getFilter().$or).not.toContainEqual({ staffId: 'admin-001' }); }); }); // --------------------------------------------------------------------------- // handleStats — embed output reflects shapeStats with tier labels // --------------------------------------------------------------------------- describe('handleStats — embed content', () => { it('reply is ephemeral', async () => { const interaction = makeInteraction(); await handleStats(interaction, deps()); const [[replyArg]] = interaction.reply.mock.calls; expect(replyArg.flags).toBe(MessageFlags.Ephemeral); }); it('embed title contains the target username', async () => { const interaction = makeInteraction({ userId: 'caller-001' }); // username = 'testuser' await handleStats(interaction, deps()); const [[replyArg]] = interaction.reply.mock.calls; expect(replyArg.embeds[0].data.title).toContain('testuser'); }); it('escalations field maps tier 1 → "Tier 2" and tier 2 → "Tier 3"', async () => { const events = [ { staffId: 'caller-001', type: 'escalate', tier: 1, ticketType: 'email', resolverId: null, toId: null, fromId: null }, { staffId: 'caller-001', type: 'escalate', tier: 2, ticketType: 'email', resolverId: null, toId: null, fromId: null } ]; const sa = { find: () => ({ lean: () => Promise.resolve(events) }) }; const interaction = makeInteraction(); await handleStats(interaction, { StaffAction: sa, now: () => NOW_MS, adminIds: [] }); const [[replyArg]] = interaction.reply.mock.calls; const field = replyArg.embeds[0].data.fields.find(f => f.name === 'Escalations'); expect(field.value).toContain('Tier 2'); expect(field.value).toContain('Tier 3'); }); it('de-escalations field maps tier 1 → "Tier 2" and tier 2 → "Tier 3"', async () => { const events = [ { staffId: 'caller-001', type: 'deescalate', tier: 1, ticketType: 'email', resolverId: null, toId: null, fromId: null }, { staffId: 'caller-001', type: 'deescalate', tier: 2, ticketType: 'email', resolverId: null, toId: null, fromId: null } ]; const sa = { find: () => ({ lean: () => Promise.resolve(events) }) }; const interaction = makeInteraction(); await handleStats(interaction, { StaffAction: sa, now: () => NOW_MS, adminIds: [] }); const [[replyArg]] = interaction.reply.mock.calls; const field = replyArg.embeds[0].data.fields.find(f => f.name === 'De-escalations'); expect(field.value).toContain('Tier 2'); expect(field.value).toContain('Tier 3'); }); it('claims-while-escalated sub-breakdown uses tier labels in Claims field', async () => { const events = [ { staffId: 'caller-001', type: 'claim', tier: 1, ticketType: 'email', resolverId: null, toId: null, fromId: null }, { staffId: 'caller-001', type: 'claim', tier: 2, ticketType: 'email', resolverId: null, toId: null, fromId: null } ]; const sa = { find: () => ({ lean: () => Promise.resolve(events) }) }; const interaction = makeInteraction(); await handleStats(interaction, { StaffAction: sa, now: () => NOW_MS, adminIds: [] }); const [[replyArg]] = interaction.reply.mock.calls; const field = replyArg.embeds[0].data.fields.find(f => f.name === 'Claims'); expect(field.value).toContain('Tier 2'); expect(field.value).toContain('Tier 3'); }); it('empty stats returns a valid zero-filled embed without throwing', async () => { const interaction = makeInteraction(); await handleStats(interaction, deps()); const [[replyArg]] = interaction.reply.mock.calls; expect(replyArg.embeds).toHaveLength(1); const claimsField = replyArg.embeds[0].data.fields.find(f => f.name === 'Claims'); expect(claimsField.value).toMatch(/^0/); const escalField = replyArg.embeds[0].data.fields.find(f => f.name === 'Escalations'); expect(escalField.value).toBe('0'); }); it('Email / Discord split field reflects bySource from shapeStats', async () => { const events = [ { staffId: 'caller-001', type: 'claim', tier: 0, ticketType: 'email', resolverId: null, toId: null, fromId: null }, { staffId: 'caller-001', type: 'claim', tier: 0, ticketType: 'discord', resolverId: null, toId: null, fromId: null } ]; const sa = { find: () => ({ lean: () => Promise.resolve(events) }) }; const interaction = makeInteraction(); await handleStats(interaction, { StaffAction: sa, now: () => NOW_MS, adminIds: [] }); const [[replyArg]] = interaction.reply.mock.calls; const splitField = replyArg.embeds[0].data.fields.find(f => f.name === 'Email / Discord split'); expect(splitField.value).toContain('Email'); expect(splitField.value).toContain('Discord'); }); });