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:
169
tests/gmailPollEvents.test.js
Normal file
169
tests/gmailPollEvents.test.js
Normal file
@@ -0,0 +1,169 @@
|
||||
/**
|
||||
* Phase 7 — gmail-poll email ticket persistence + reopen event recording.
|
||||
*
|
||||
* Uses the injectable-parameter pattern: persistEmailTicket accepts
|
||||
* _Ticket (model) and _recordAction as the 4th and 5th parameters.
|
||||
* No vi.mock needed; dependencies are injected directly.
|
||||
*
|
||||
* Covers:
|
||||
* (a) game persisted: findOneAndUpdate $set includes game from detectGame
|
||||
* (b) reopen event: staffId='system', resolverId = prior claimerId, guildId correct
|
||||
* (c) payload.ticket: the returned doc is passed verbatim for denormalization
|
||||
* (d) no reopen: wasReopened=false → _recordAction not called
|
||||
* (e) null claimerId: resolverId=null for unclaimed-ticket reopen
|
||||
*/
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
|
||||
import { persistEmailTicket } from '../gmail-poll.js';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Shared factories
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function makeFields(overrides = {}) {
|
||||
return {
|
||||
threadId: 'gmail-thread-001',
|
||||
discordThreadId: 'chan-001',
|
||||
senderEmail: 'user@example.com',
|
||||
subject: 'Help needed',
|
||||
createdAt: new Date('2026-01-01'),
|
||||
ticketNumber: 1,
|
||||
priority: 'normal',
|
||||
parentCategoryId: 'cat-001',
|
||||
game: 'TestGame',
|
||||
...overrides
|
||||
};
|
||||
}
|
||||
|
||||
function makeReturnedDoc(overrides = {}) {
|
||||
return {
|
||||
gmailThreadId: 'gmail-thread-001',
|
||||
senderEmail: 'user@example.com',
|
||||
claimerId: 'claimer-001',
|
||||
escalationTier: 0,
|
||||
priority: 'normal',
|
||||
game: 'TestGame',
|
||||
...overrides
|
||||
};
|
||||
}
|
||||
|
||||
function makeTicketModel(returnedDoc) {
|
||||
return {
|
||||
findOneAndUpdate: vi.fn().mockResolvedValue(returnedDoc)
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// (a) game included in the $set
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('persistEmailTicket — game persisted in $set', () => {
|
||||
it('includes game in the findOneAndUpdate $set', async () => {
|
||||
const model = makeTicketModel(makeReturnedDoc());
|
||||
await persistEmailTicket(makeFields({ game: 'Minecraft' }), 'guild-001', false, model, vi.fn());
|
||||
|
||||
const [, update] = model.findOneAndUpdate.mock.calls[0];
|
||||
expect(update.$set.game).toBe('Minecraft');
|
||||
});
|
||||
|
||||
it('passes null game when no game is detected', async () => {
|
||||
const model = makeTicketModel(makeReturnedDoc({ game: null }));
|
||||
await persistEmailTicket(makeFields({ game: null }), 'guild-001', false, model, vi.fn());
|
||||
|
||||
const [, update] = model.findOneAndUpdate.mock.calls[0];
|
||||
expect(update.$set.game).toBeNull();
|
||||
});
|
||||
|
||||
it('uses gmailThreadId as the findOneAndUpdate filter', async () => {
|
||||
const model = makeTicketModel(makeReturnedDoc());
|
||||
await persistEmailTicket(makeFields({ threadId: 'thread-xyz' }), 'guild-001', false, model, vi.fn());
|
||||
|
||||
const [filter] = model.findOneAndUpdate.mock.calls[0];
|
||||
expect(filter.gmailThreadId).toBe('thread-xyz');
|
||||
});
|
||||
|
||||
it('sets status:"open" in the $set', async () => {
|
||||
const model = makeTicketModel(makeReturnedDoc());
|
||||
await persistEmailTicket(makeFields(), 'guild-001', false, model, vi.fn());
|
||||
|
||||
const [, update] = model.findOneAndUpdate.mock.calls[0];
|
||||
expect(update.$set.status).toBe('open');
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// (b) Reopen event: staffId='system', resolverId=prior claimerId, guildId correct
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('persistEmailTicket — reopen event recording', () => {
|
||||
it('calls _recordAction once with staffId=system and type=reopen', async () => {
|
||||
const mockRecord = vi.fn();
|
||||
const doc = makeReturnedDoc({ claimerId: 'prev-claimer' });
|
||||
await persistEmailTicket(makeFields(), 'guild-001', true, makeTicketModel(doc), mockRecord);
|
||||
|
||||
expect(mockRecord).toHaveBeenCalledTimes(1);
|
||||
const [staffId, type] = mockRecord.mock.calls[0];
|
||||
expect(staffId).toBe('system');
|
||||
expect(type).toBe('reopen');
|
||||
});
|
||||
|
||||
it('sets resolverId = the claimerId from the returned doc', async () => {
|
||||
const mockRecord = vi.fn();
|
||||
const doc = makeReturnedDoc({ claimerId: 'prev-claimer-123' });
|
||||
await persistEmailTicket(makeFields(), 'guild-001', true, makeTicketModel(doc), mockRecord);
|
||||
|
||||
const [, , payload] = mockRecord.mock.calls[0];
|
||||
expect(payload.resolverId).toBe('prev-claimer-123');
|
||||
});
|
||||
|
||||
it('includes guildId in the payload', async () => {
|
||||
const mockRecord = vi.fn();
|
||||
const doc = makeReturnedDoc();
|
||||
await persistEmailTicket(makeFields(), 'guild-999', true, makeTicketModel(doc), mockRecord);
|
||||
|
||||
const [, , payload] = mockRecord.mock.calls[0];
|
||||
expect(payload.guildId).toBe('guild-999');
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// (c) payload.ticket is the returned doc (so denormalization gets all fields)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('persistEmailTicket — returned doc passed as payload.ticket', () => {
|
||||
it('sets payload.ticket to the doc returned by findOneAndUpdate', async () => {
|
||||
const mockRecord = vi.fn();
|
||||
const doc = makeReturnedDoc({ game: 'SomeGame', senderEmail: 'a@b.com' });
|
||||
await persistEmailTicket(makeFields(), 'guild-001', true, makeTicketModel(doc), mockRecord);
|
||||
|
||||
const [, , payload] = mockRecord.mock.calls[0];
|
||||
expect(payload.ticket).toBe(doc);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// (d) No reopen event when wasReopened=false
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('persistEmailTicket — no reopen on brand-new ticket', () => {
|
||||
it('does not call _recordAction when wasReopened=false', async () => {
|
||||
const mockRecord = vi.fn();
|
||||
await persistEmailTicket(makeFields(), 'guild-001', false, makeTicketModel(makeReturnedDoc()), mockRecord);
|
||||
expect(mockRecord).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// (e) resolverId=null when prior claimerId is null (unclaimed ticket reopened)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('persistEmailTicket — reopen of unclaimed ticket', () => {
|
||||
it('sets resolverId=null when the prior claimerId is null', async () => {
|
||||
const mockRecord = vi.fn();
|
||||
const doc = makeReturnedDoc({ claimerId: null });
|
||||
await persistEmailTicket(makeFields(), 'guild-001', true, makeTicketModel(doc), mockRecord);
|
||||
|
||||
const [, , payload] = mockRecord.mock.calls[0];
|
||||
expect(payload.resolverId).toBeNull();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user