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:
166
tests/transferEvents.test.js
Normal file
166
tests/transferEvents.test.js
Normal file
@@ -0,0 +1,166 @@
|
||||
/**
|
||||
* Phase 5b — transfer event recording tests.
|
||||
*
|
||||
* Follows the same injectable-parameter pattern as claimEvents.test.js:
|
||||
* _TicketModel — controls the DB layer (updateOne)
|
||||
* _recordAction — captures recording calls without any module mocking
|
||||
*
|
||||
* No vi.mock needed; all dependencies injected directly.
|
||||
*
|
||||
* Tests applyTransfer directly (same pattern as testing applyClaim directly).
|
||||
*
|
||||
* Covers:
|
||||
* (a) transfer to different member — one 'transfer' event with fromId/toId
|
||||
* (b) transfer to current claimer — fromId === toId → no event
|
||||
* (c) fromId/toId/staffId/guildId correctness
|
||||
*/
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
|
||||
import { applyTransfer } from '../handlers/commands/index.js';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Shared factories
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function makeTicket(overrides = {}) {
|
||||
return {
|
||||
gmailThreadId: 'discord-test-001',
|
||||
escalationTier: 0,
|
||||
escalated: false,
|
||||
claimerId: 'prev-claimer-001',
|
||||
claimedBy: 'PrevClaimerName',
|
||||
priority: 'normal',
|
||||
game: 'TestGame',
|
||||
senderEmail: 'user@example.com',
|
||||
creatorId: 'creator-001',
|
||||
ticketNumber: 42,
|
||||
...overrides
|
||||
};
|
||||
}
|
||||
|
||||
function makeGuildMember(userId = 'new-claimer-002') {
|
||||
return {
|
||||
id: userId,
|
||||
displayName: 'New Claimer',
|
||||
user: {
|
||||
id: userId,
|
||||
username: 'newclaimer',
|
||||
tag: 'newclaimer#0002',
|
||||
toString: () => `<@${userId}>`
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function makeInteraction(userId = 'staff-001') {
|
||||
return {
|
||||
user: {
|
||||
id: userId,
|
||||
username: 'staffuser',
|
||||
tag: 'staffuser#0001',
|
||||
toString: () => `<@${userId}>`
|
||||
},
|
||||
member: { displayName: 'Staff Member' },
|
||||
guild: {
|
||||
id: 'guild-001',
|
||||
members: {
|
||||
fetch: vi.fn().mockRejectedValue(new Error('no member in test env'))
|
||||
}
|
||||
},
|
||||
channel: {
|
||||
id: 'chan-001',
|
||||
name: 'ticket-chan-001',
|
||||
isThread: vi.fn().mockReturnValue(true),
|
||||
send: vi.fn().mockResolvedValue({ id: 'sent-msg-001' })
|
||||
},
|
||||
editReply: vi.fn().mockResolvedValue(undefined),
|
||||
client: {
|
||||
channels: { fetch: vi.fn().mockResolvedValue(null) }
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// (a) Transfer to a different member — one event
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('applyTransfer — different member emits one event', () => {
|
||||
it('emits exactly one "transfer" event', async () => {
|
||||
const ticket = makeTicket({ claimerId: 'prev-claimer-001' });
|
||||
const guildMember = makeGuildMember('new-claimer-002');
|
||||
const mockUpdateOne = vi.fn().mockResolvedValue({ modifiedCount: 1 });
|
||||
const mockRecord = vi.fn();
|
||||
const interaction = makeInteraction('staff-001');
|
||||
|
||||
await applyTransfer(interaction, ticket, guildMember, 'reason', { updateOne: mockUpdateOne }, mockRecord);
|
||||
|
||||
expect(mockRecord).toHaveBeenCalledTimes(1);
|
||||
const [staffId, type] = mockRecord.mock.calls[0];
|
||||
expect(staffId).toBe('staff-001');
|
||||
expect(type).toBe('transfer');
|
||||
});
|
||||
|
||||
it('passes correct fromId, toId, guildId, and ticket', async () => {
|
||||
const ticket = makeTicket({ claimerId: 'prev-claimer-001' });
|
||||
const guildMember = makeGuildMember('new-claimer-002');
|
||||
const mockUpdateOne = vi.fn().mockResolvedValue({ modifiedCount: 1 });
|
||||
const mockRecord = vi.fn();
|
||||
const interaction = makeInteraction('staff-001');
|
||||
|
||||
await applyTransfer(interaction, ticket, guildMember, 'reason', { updateOne: mockUpdateOne }, mockRecord);
|
||||
|
||||
const [, , payload] = mockRecord.mock.calls[0];
|
||||
expect(payload.fromId).toBe('prev-claimer-001');
|
||||
expect(payload.toId).toBe('new-claimer-002');
|
||||
expect(payload.guildId).toBe('guild-001');
|
||||
expect(payload.ticket).toBe(ticket);
|
||||
});
|
||||
|
||||
it('fromId is the pre-write claimerId (captured before the DB write)', async () => {
|
||||
const ticket = makeTicket({ claimerId: 'original-claimer' });
|
||||
const guildMember = makeGuildMember('new-staff-003');
|
||||
const mockUpdateOne = vi.fn().mockResolvedValue({ modifiedCount: 1 });
|
||||
const mockRecord = vi.fn();
|
||||
const interaction = makeInteraction('staff-001');
|
||||
|
||||
await applyTransfer(interaction, ticket, guildMember, 'reason', { updateOne: mockUpdateOne }, mockRecord);
|
||||
|
||||
const [, , payload] = mockRecord.mock.calls[0];
|
||||
expect(payload.fromId).toBe('original-claimer');
|
||||
expect(payload.toId).toBe('new-staff-003');
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// (b) Transfer to the current claimer — no event
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('applyTransfer — transfer to current claimer emits no event', () => {
|
||||
it('emits no event when fromId === toId (transferring to existing claimer)', async () => {
|
||||
const ticket = makeTicket({ claimerId: 'same-claimer-001' });
|
||||
const guildMember = makeGuildMember('same-claimer-001');
|
||||
const mockUpdateOne = vi.fn().mockResolvedValue({ modifiedCount: 1 });
|
||||
const mockRecord = vi.fn();
|
||||
const interaction = makeInteraction('staff-001');
|
||||
|
||||
await applyTransfer(interaction, ticket, guildMember, 'reason', { updateOne: mockUpdateOne }, mockRecord);
|
||||
|
||||
expect(mockRecord).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('emits one event when the ticket is unclaimed (fromId null) and toId is non-null — null !== toId satisfies the fromId !== toId gate', async () => {
|
||||
// null !== 'new-claimer' so this IS a real transfer — event IS emitted
|
||||
const ticket = makeTicket({ claimerId: null });
|
||||
const guildMember = makeGuildMember('new-claimer-002');
|
||||
const mockUpdateOne = vi.fn().mockResolvedValue({ modifiedCount: 1 });
|
||||
const mockRecord = vi.fn();
|
||||
const interaction = makeInteraction('staff-001');
|
||||
|
||||
await applyTransfer(interaction, ticket, guildMember, 'reason', { updateOne: mockUpdateOne }, mockRecord);
|
||||
|
||||
// null !== 'new-claimer-002' → event IS emitted
|
||||
expect(mockRecord).toHaveBeenCalledTimes(1);
|
||||
const [, , payload] = mockRecord.mock.calls[0];
|
||||
expect(payload.fromId).toBeNull();
|
||||
expect(payload.toId).toBe('new-claimer-002');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user