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:
245
tests/responseEvents.test.js
Normal file
245
tests/responseEvents.test.js
Normal file
@@ -0,0 +1,245 @@
|
||||
/**
|
||||
* Phase 6 — staff response event recording tests.
|
||||
*
|
||||
* Follows the same injectable-parameter pattern as claimEvents.test.js:
|
||||
* _TicketModel — controls Ticket DB layer (findOne, updateOne)
|
||||
* _TagModel — controls Tag DB layer (findOne, updateOne) for /response send
|
||||
* _recordAction — captures recording calls without any module mocking
|
||||
* _isStaff — controls staff check result (messages.js path only)
|
||||
*
|
||||
* No vi.mock needed; all dependencies injected directly.
|
||||
*
|
||||
* Covers:
|
||||
* (a) handleDiscordReply — staff message in a discord ticket → one 'response' event
|
||||
* (b) handleDiscordReply — staff message in an email ticket → one 'response' event
|
||||
* (c) handleDiscordReply — bot message → no event
|
||||
* (d) handleDiscordReply — non-staff message → no event
|
||||
* (e) handleResponseSend — /response send in a ticket → one 'response' event
|
||||
* (f) handleResponseSend — no ticket found → no event
|
||||
* (g) handleResponseSend — tag not found → no event
|
||||
*/
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
|
||||
import { handleDiscordReply } from '../handlers/messages.js';
|
||||
import { handleResponseSend } from '../handlers/commands/response.js';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Shared factories — handleDiscordReply
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function makeMessage(overrides = {}) {
|
||||
return {
|
||||
author: { bot: false, id: 'staff-001' },
|
||||
interaction: null,
|
||||
channel: { id: 'chan-001', name: 'ticket-chan-001' },
|
||||
guild: {
|
||||
id: 'guild-001',
|
||||
members: {
|
||||
cache: { get: vi.fn().mockReturnValue(null) },
|
||||
fetch: vi.fn().mockRejectedValue(new Error('no members in test env'))
|
||||
}
|
||||
},
|
||||
content: 'Hello customer',
|
||||
id: 'msg-001',
|
||||
...overrides
|
||||
};
|
||||
}
|
||||
|
||||
function makeTicket(overrides = {}) {
|
||||
return {
|
||||
gmailThreadId: 'discord-test-001',
|
||||
escalationTier: 0,
|
||||
claimerId: null,
|
||||
priority: 'normal',
|
||||
game: 'TestGame',
|
||||
senderEmail: 'user@example.com',
|
||||
creatorId: 'creator-001',
|
||||
...overrides
|
||||
};
|
||||
}
|
||||
|
||||
function makeMessageTicketModel(ticket) {
|
||||
return {
|
||||
findOne: vi.fn().mockReturnValue({ lean: vi.fn().mockResolvedValue(ticket) }),
|
||||
updateOne: vi.fn().mockResolvedValue({ modifiedCount: 0 })
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// (a) + (b) Staff message records one 'response' — discord + email tickets
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('handleDiscordReply — staff message records response', () => {
|
||||
it('records one "response" event for a discord ticket', async () => {
|
||||
const ticket = makeTicket({ gmailThreadId: 'discord-test-001' });
|
||||
const mockModel = makeMessageTicketModel(ticket);
|
||||
const mockRecord = vi.fn();
|
||||
const stubStaff = vi.fn().mockReturnValue(true);
|
||||
|
||||
await handleDiscordReply(makeMessage(), mockModel, mockRecord, stubStaff);
|
||||
|
||||
expect(mockRecord).toHaveBeenCalledTimes(1);
|
||||
const [staffId, type, payload] = mockRecord.mock.calls[0];
|
||||
expect(staffId).toBe('staff-001');
|
||||
expect(type).toBe('response');
|
||||
expect(payload.ticket).toBe(ticket);
|
||||
expect(payload.guildId).toBe('guild-001');
|
||||
});
|
||||
|
||||
it('records one "response" event for an email ticket (before the discord early-return)', async () => {
|
||||
const ticket = makeTicket({ gmailThreadId: '18f3a2b1c0d4e5f6' });
|
||||
const mockModel = makeMessageTicketModel(ticket);
|
||||
const mockRecord = vi.fn();
|
||||
const stubStaff = vi.fn().mockReturnValue(true);
|
||||
|
||||
// Gmail relay will fail but that's caught internally — record already fired.
|
||||
await handleDiscordReply(makeMessage(), mockModel, mockRecord, stubStaff);
|
||||
|
||||
expect(mockRecord).toHaveBeenCalledTimes(1);
|
||||
const [staffId, type, payload] = mockRecord.mock.calls[0];
|
||||
expect(staffId).toBe('staff-001');
|
||||
expect(type).toBe('response');
|
||||
expect(payload.ticket).toBe(ticket);
|
||||
expect(payload.guildId).toBe('guild-001');
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// (c) Bot message → no event
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('handleDiscordReply — bot message records nothing', () => {
|
||||
it('records nothing when author.bot is true', async () => {
|
||||
const mockModel = makeMessageTicketModel(makeTicket());
|
||||
const mockRecord = vi.fn();
|
||||
const stubStaff = vi.fn().mockReturnValue(true);
|
||||
const m = makeMessage({ author: { bot: true, id: 'bot-001' } });
|
||||
|
||||
await handleDiscordReply(m, mockModel, mockRecord, stubStaff);
|
||||
|
||||
expect(mockRecord).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// (d) Non-staff message → no event
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('handleDiscordReply — non-staff message records nothing', () => {
|
||||
it('records nothing when isStaff returns false', async () => {
|
||||
const ticket = makeTicket({ gmailThreadId: 'discord-test-001' });
|
||||
const mockModel = makeMessageTicketModel(ticket);
|
||||
const mockRecord = vi.fn();
|
||||
const stubStaff = vi.fn().mockReturnValue(false);
|
||||
|
||||
await handleDiscordReply(makeMessage(), mockModel, mockRecord, stubStaff);
|
||||
|
||||
expect(mockRecord).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('records nothing when the message is not in a ticket channel', async () => {
|
||||
const mockModel = makeMessageTicketModel(null); // no ticket
|
||||
const mockRecord = vi.fn();
|
||||
const stubStaff = vi.fn().mockReturnValue(true);
|
||||
|
||||
await handleDiscordReply(makeMessage(), mockModel, mockRecord, stubStaff);
|
||||
|
||||
expect(mockRecord).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Shared factories — handleResponseSend
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function makeInteraction(overrides = {}) {
|
||||
return {
|
||||
user: {
|
||||
id: 'staff-001',
|
||||
username: 'staffuser',
|
||||
toString: () => '<@staff-001>'
|
||||
},
|
||||
member: { displayName: 'Staff Member' },
|
||||
guild: { id: 'guild-001', name: 'Test Guild', memberCount: 10 },
|
||||
channel: { id: 'chan-001' },
|
||||
options: { getString: vi.fn().mockReturnValue('my-tag') },
|
||||
reply: vi.fn().mockResolvedValue(undefined),
|
||||
...overrides
|
||||
};
|
||||
}
|
||||
|
||||
function makeTagModel(tag) {
|
||||
return {
|
||||
findOne: vi.fn().mockReturnValue({ lean: vi.fn().mockResolvedValue(tag) }),
|
||||
updateOne: vi.fn().mockResolvedValue({ modifiedCount: 1 })
|
||||
};
|
||||
}
|
||||
|
||||
function makeResponseTicketModel(ticket) {
|
||||
return { findOne: vi.fn().mockReturnValue({ lean: vi.fn().mockResolvedValue(ticket) }) };
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// (e) /response send in a ticket channel → one event
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('handleResponseSend — records one "response" event', () => {
|
||||
it('records staffId, guildId, and ticket when ticket is found', async () => {
|
||||
const ticket = makeTicket();
|
||||
const tag = { name: 'my-tag', content: 'Hello {ticket.user}', useCount: 0 };
|
||||
const mockRecord = vi.fn();
|
||||
|
||||
await handleResponseSend(
|
||||
makeInteraction(),
|
||||
makeTagModel(tag),
|
||||
makeResponseTicketModel(ticket),
|
||||
mockRecord
|
||||
);
|
||||
|
||||
expect(mockRecord).toHaveBeenCalledTimes(1);
|
||||
const [staffId, type, payload] = mockRecord.mock.calls[0];
|
||||
expect(staffId).toBe('staff-001');
|
||||
expect(type).toBe('response');
|
||||
expect(payload.ticket).toBe(ticket);
|
||||
expect(payload.guildId).toBe('guild-001');
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// (f) No ticket found → no event
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('handleResponseSend — no ticket records nothing', () => {
|
||||
it('records nothing when no ticket exists for the channel', async () => {
|
||||
const tag = { name: 'my-tag', content: 'Hello', useCount: 0 };
|
||||
const mockRecord = vi.fn();
|
||||
|
||||
await handleResponseSend(
|
||||
makeInteraction(),
|
||||
makeTagModel(tag),
|
||||
makeResponseTicketModel(null),
|
||||
mockRecord
|
||||
);
|
||||
|
||||
expect(mockRecord).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// (g) Tag not found → no event
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('handleResponseSend — tag not found records nothing', () => {
|
||||
it('records nothing when the tag does not exist', async () => {
|
||||
const mockRecord = vi.fn();
|
||||
|
||||
await handleResponseSend(
|
||||
makeInteraction(),
|
||||
makeTagModel(null),
|
||||
makeResponseTicketModel(makeTicket()),
|
||||
mockRecord
|
||||
);
|
||||
|
||||
expect(mockRecord).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user