Event-sourced tracking of staff/ticket lifecycle actions, plus a /stats command. Foundation for a future tickets-website analytics dashboard. Data: - StaffAction model (event log) + Ticket.game / Ticket.closedAt - STATS_ADMIN_IDS config (who may view others' stats) Recording (fire-and-forget, idempotent on real state transitions): - claim, response (channel reply + /response send), escalate, de-escalate, transfer, close (4 sites), reopen — each denormalizes ticketType, tier, priority, game, requester (senderEmail / creatorId), guildId - close events carry closerType / resolverId (claimer credit) / wasClaimed; transfer carries fromId / toId; reopen stamps resolverId - conditional close transition helper (atomic open->closed + closedAt) shared by all four close paths Query + command: - pure period parser (presets + free-text) and stats shaper (per-metric keys) - command-aware autocomplete dispatch - /stats: period (autocomplete) + member (admin-gated) + source (all/email/ discord), ManageMessages + staff-role gated, ephemeral, tier-labeled embed 288+ unit tests; timing/busiest-times data is collected but displayed later.
351 lines
15 KiB
JavaScript
351 lines
15 KiB
JavaScript
/**
|
|
* 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');
|
|
});
|
|
});
|