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.
188 lines
6.6 KiB
JavaScript
188 lines
6.6 KiB
JavaScript
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();
|
|
});
|
|
});
|