Add per-staff metrics: StaffAction event log + /stats command
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.
This commit is contained in:
350
tests/statsHandler.test.js
Normal file
350
tests/statsHandler.test.js
Normal file
@@ -0,0 +1,350 @@
|
||||
/**
|
||||
* 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');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user