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:
310
tests/closeEvents.test.js
Normal file
310
tests/closeEvents.test.js
Normal file
@@ -0,0 +1,310 @@
|
||||
/**
|
||||
* Phase 4 — close event recording tests.
|
||||
*
|
||||
* Follows the same injectable-parameter pattern as closeTransition.test.js:
|
||||
* _TicketModel — controls the DB layer (updateOne / findOne / find)
|
||||
* _recordAction — captures recording calls without any module mocking
|
||||
*
|
||||
* No vi.mock needed; all dependencies injected directly.
|
||||
*
|
||||
* Covers:
|
||||
* (a) staff force-close — finalizeForceClose, closerId present in pendingCloses
|
||||
* (b) system auto-close — reconcileDeletedTicketChannels, channel absent
|
||||
* (c) no-op close — transitioned=false → no event
|
||||
*/
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
|
||||
import { finalizeForceClose } from '../handlers/commands/close.js';
|
||||
import { reconcileDeletedTicketChannels, checkAutoClose } from '../services/tickets.js';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Shared factories
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function makeOpenTicket(overrides = {}) {
|
||||
return {
|
||||
gmailThreadId: 'discord-test-001',
|
||||
discordThreadId: 'chan-test-001',
|
||||
claimerId: 'claimer-001',
|
||||
claimedBy: 'ClaimerName',
|
||||
status: 'open',
|
||||
createdAt: new Date('2026-01-01'),
|
||||
escalationTier: 0,
|
||||
priority: 'normal',
|
||||
game: 'TestGame',
|
||||
senderEmail: 'user@example.com',
|
||||
creatorId: 'creator-001',
|
||||
...overrides
|
||||
};
|
||||
}
|
||||
|
||||
function makeClosedTicket(openTicket) {
|
||||
return { ...openTicket, status: 'closed', closedAt: new Date() };
|
||||
}
|
||||
|
||||
/**
|
||||
* Minimal mock model for reconcile tests (only needs find / updateOne / findOne).
|
||||
*
|
||||
* @param {object[]} openTickets — rows returned by find()
|
||||
* @param {object} closedTicket — doc returned by findOne after transition
|
||||
* @param {number} modifiedCount — 1 = transition succeeded, 0 = no-op
|
||||
*/
|
||||
function makeReconcileModel(openTickets, closedTicket, modifiedCount = 1) {
|
||||
const chain = {
|
||||
sort: vi.fn().mockReturnThis(),
|
||||
limit: vi.fn().mockReturnThis(),
|
||||
lean: vi.fn().mockResolvedValue(openTickets)
|
||||
};
|
||||
return {
|
||||
find: vi.fn().mockReturnValue(chain),
|
||||
findOne: vi.fn().mockReturnValue({ lean: vi.fn().mockResolvedValue(closedTicket) }),
|
||||
updateOne: vi.fn().mockResolvedValue({ modifiedCount })
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Minimal mock model for finalizeForceClose.
|
||||
* findOne is called twice:
|
||||
* 1st — freshTicket lookup in finalizeForceClose itself
|
||||
* 2nd — post-transition fetch inside attemptCloseTransition
|
||||
*/
|
||||
function makeForceCloseModel(freshTicket, closedTicket, modifiedCount = 1) {
|
||||
return {
|
||||
find: vi.fn(),
|
||||
findOne: vi.fn()
|
||||
.mockReturnValueOnce({ lean: vi.fn().mockResolvedValue(freshTicket) })
|
||||
.mockReturnValueOnce({ lean: vi.fn().mockResolvedValue(closedTicket) }),
|
||||
updateOne: vi.fn().mockResolvedValue({ modifiedCount })
|
||||
};
|
||||
}
|
||||
|
||||
function makeGuild(id = 'guild-001') {
|
||||
return {
|
||||
id,
|
||||
channels: {
|
||||
cache: { get: vi.fn().mockReturnValue(null) },
|
||||
fetch: vi.fn().mockResolvedValue(null)
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function makeClient(guild) {
|
||||
return {
|
||||
guilds: {
|
||||
cache: {
|
||||
get: vi.fn().mockReturnValue(null),
|
||||
first: vi.fn().mockReturnValue(guild)
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/** Channel mock with send() so enqueueSend doesn't reject immediately. */
|
||||
function makeChannelRef(id = 'chan-test-001', guildId = 'guild-001') {
|
||||
return {
|
||||
id,
|
||||
name: `ticket-${id}`,
|
||||
guild: { id: guildId },
|
||||
send: vi.fn().mockResolvedValue({ id: 'sent-msg' }),
|
||||
delete: vi.fn().mockResolvedValue(undefined),
|
||||
messages: undefined // triggers transcript error (caught internally)
|
||||
};
|
||||
}
|
||||
|
||||
function makeClientRef() {
|
||||
return {
|
||||
channels: { fetch: vi.fn().mockResolvedValue(null) }
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Minimal mock model for checkAutoClose tests.
|
||||
* Mirrors makeReconcileModel — same shape, renamed for clarity.
|
||||
*/
|
||||
function makeAutoCloseModel(staleTickets, closedTicket, modifiedCount = 1) {
|
||||
const chain = {
|
||||
sort: vi.fn().mockReturnThis(),
|
||||
limit: vi.fn().mockReturnThis(),
|
||||
lean: vi.fn().mockResolvedValue(staleTickets)
|
||||
};
|
||||
return {
|
||||
find: vi.fn().mockReturnValue(chain),
|
||||
findOne: vi.fn().mockReturnValue({ lean: vi.fn().mockResolvedValue(closedTicket) }),
|
||||
updateOne: vi.fn().mockResolvedValue({ modifiedCount })
|
||||
};
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// (a) Staff force-close
|
||||
// ===========================================================================
|
||||
|
||||
describe('finalizeForceClose — staff close', () => {
|
||||
it('emits one "close" event with closerType "staff", correct staffId / resolverId / wasClaimed', async () => {
|
||||
const open = makeOpenTicket({ gmailThreadId: 'discord-test-001', discordThreadId: 'chan-staff-001' });
|
||||
const closed = makeClosedTicket(open);
|
||||
const model = makeForceCloseModel(open, closed, 1);
|
||||
const mockRecord = vi.fn();
|
||||
const pc = new Map([['chan-staff-001', { closerId: 'staff-user-001', username: 'Staff#0001' }]]);
|
||||
|
||||
await finalizeForceClose(makeChannelRef('chan-staff-001', 'guild-001'), makeClientRef(), model, mockRecord, pc);
|
||||
|
||||
expect(mockRecord).toHaveBeenCalledTimes(1);
|
||||
const [staffId, type, payload] = mockRecord.mock.calls[0];
|
||||
expect(staffId).toBe('staff-user-001');
|
||||
expect(type).toBe('close');
|
||||
expect(payload.closerType).toBe('staff');
|
||||
expect(payload.resolverId).toBe('claimer-001');
|
||||
expect(payload.wasClaimed).toBe(true);
|
||||
expect(payload.guildId).toBe('guild-001');
|
||||
expect(payload.ticket).toBe(closed);
|
||||
});
|
||||
|
||||
it('uses closerType "system" and staffId "system" when no closerId in pendingCloses', async () => {
|
||||
const open = makeOpenTicket({ gmailThreadId: 'discord-test-002', discordThreadId: 'chan-sys-002' });
|
||||
const closed = makeClosedTicket(open);
|
||||
const model = makeForceCloseModel(open, closed, 1);
|
||||
const mockRecord = vi.fn();
|
||||
const pc = new Map(); // no entry for this channel
|
||||
|
||||
await finalizeForceClose(makeChannelRef('chan-sys-002', 'guild-001'), makeClientRef(), model, mockRecord, pc);
|
||||
|
||||
expect(mockRecord).toHaveBeenCalledTimes(1);
|
||||
const [staffId, , payload] = mockRecord.mock.calls[0];
|
||||
expect(staffId).toBe('system');
|
||||
expect(payload.closerType).toBe('system');
|
||||
});
|
||||
});
|
||||
|
||||
// ===========================================================================
|
||||
// (b) System auto-close: reconcileDeletedTicketChannels
|
||||
// ===========================================================================
|
||||
|
||||
describe('reconcileDeletedTicketChannels — system close', () => {
|
||||
it('emits one "close" event with closerType "system", correct resolverId and wasClaimed', async () => {
|
||||
const open = makeOpenTicket();
|
||||
const closed = makeClosedTicket(open);
|
||||
const model = makeReconcileModel([open], closed, 1);
|
||||
const mockRecord = vi.fn();
|
||||
|
||||
await reconcileDeletedTicketChannels(makeClient(makeGuild('guild-001')), model, mockRecord);
|
||||
|
||||
expect(mockRecord).toHaveBeenCalledTimes(1);
|
||||
const [staffId, type, payload] = mockRecord.mock.calls[0];
|
||||
expect(staffId).toBe('system');
|
||||
expect(type).toBe('close');
|
||||
expect(payload.closerType).toBe('system');
|
||||
expect(payload.resolverId).toBe('claimer-001');
|
||||
expect(payload.wasClaimed).toBe(true);
|
||||
expect(payload.guildId).toBe('guild-001');
|
||||
expect(payload.ticket).toBe(closed);
|
||||
});
|
||||
|
||||
it('reflects wasClaimed=false and resolverId=null for an unclaimed ticket', async () => {
|
||||
const open = makeOpenTicket({ claimerId: null, claimedBy: null });
|
||||
const closed = makeClosedTicket(open);
|
||||
const model = makeReconcileModel([open], closed, 1);
|
||||
const mockRecord = vi.fn();
|
||||
|
||||
await reconcileDeletedTicketChannels(makeClient(makeGuild('guild-001')), model, mockRecord);
|
||||
|
||||
expect(mockRecord).toHaveBeenCalledTimes(1);
|
||||
const [, , payload] = mockRecord.mock.calls[0];
|
||||
expect(payload.resolverId).toBeNull();
|
||||
expect(payload.wasClaimed).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
// ===========================================================================
|
||||
// (c) No-op close — idempotency
|
||||
// ===========================================================================
|
||||
|
||||
describe('no-op close — idempotency', () => {
|
||||
it('emits no event when the ticket channel still exists (reconcile skips close path)', async () => {
|
||||
const open = makeOpenTicket();
|
||||
const model = {
|
||||
find: vi.fn().mockReturnValue({ sort: vi.fn().mockReturnThis(), limit: vi.fn().mockReturnThis(), lean: vi.fn().mockResolvedValue([open]) }),
|
||||
findOne: vi.fn(),
|
||||
updateOne: vi.fn()
|
||||
};
|
||||
const mockRecord = vi.fn();
|
||||
|
||||
const guild = {
|
||||
id: 'guild-001',
|
||||
channels: {
|
||||
cache: { get: vi.fn().mockReturnValue({ id: open.discordThreadId }) }, // channel IS present
|
||||
fetch: vi.fn()
|
||||
}
|
||||
};
|
||||
|
||||
await reconcileDeletedTicketChannels(makeClient(guild), model, mockRecord);
|
||||
|
||||
expect(mockRecord).not.toHaveBeenCalled();
|
||||
expect(model.updateOne).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('emits no event when attemptCloseTransition reports transitioned=false (ticket was already closed)', async () => {
|
||||
const open = makeOpenTicket();
|
||||
const model = makeReconcileModel([open], null, 0); // modifiedCount 0 → no transition
|
||||
const mockRecord = vi.fn();
|
||||
|
||||
await reconcileDeletedTicketChannels(makeClient(makeGuild('guild-001')), model, mockRecord);
|
||||
|
||||
expect(mockRecord).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
// ===========================================================================
|
||||
// (d) System auto-close: checkAutoClose
|
||||
// ===========================================================================
|
||||
|
||||
const TEST_CONFIG = { AUTO_CLOSE_ENABLED: true, AUTO_CLOSE_AFTER_HOURS: 72, DISCORD_AUTO_CLOSE_MESSAGE: 'closing' };
|
||||
|
||||
describe('checkAutoClose — system close', () => {
|
||||
it('emits one "close" event with closerType "system", staffId "system", correct resolverId and wasClaimed', async () => {
|
||||
const open = makeOpenTicket();
|
||||
const closed = makeClosedTicket(open);
|
||||
const model = makeAutoCloseModel([open], closed, 1);
|
||||
const mockRecord = vi.fn();
|
||||
const deps = {
|
||||
config: TEST_CONFIG,
|
||||
withRetry: fn => fn(),
|
||||
enqueueSend: vi.fn().mockResolvedValue(undefined),
|
||||
scheduleDelete: vi.fn()
|
||||
};
|
||||
const channel = { id: open.discordThreadId, send: vi.fn() };
|
||||
const guild = { id: 'guild-001', channels: { fetch: vi.fn().mockResolvedValue(channel) } };
|
||||
const client = { guilds: { cache: { first: vi.fn().mockReturnValue(guild) } } };
|
||||
|
||||
await checkAutoClose(client, vi.fn().mockResolvedValue(undefined), model, mockRecord, deps);
|
||||
|
||||
expect(mockRecord).toHaveBeenCalledTimes(1);
|
||||
const [staffId, type, payload] = mockRecord.mock.calls[0];
|
||||
expect(staffId).toBe('system');
|
||||
expect(type).toBe('close');
|
||||
expect(payload.closerType).toBe('system');
|
||||
expect(payload.resolverId).toBe('claimer-001');
|
||||
expect(payload.wasClaimed).toBe(true);
|
||||
expect(payload.guildId).toBe('guild-001');
|
||||
expect(payload.ticket).toBe(closed);
|
||||
});
|
||||
|
||||
it('emits no event when attemptCloseTransition reports transitioned=false', async () => {
|
||||
const open = makeOpenTicket();
|
||||
const model = makeAutoCloseModel([open], null, 0); // modifiedCount 0 → no transition
|
||||
const mockRecord = vi.fn();
|
||||
const deps = {
|
||||
config: TEST_CONFIG,
|
||||
withRetry: fn => fn(),
|
||||
enqueueSend: vi.fn().mockResolvedValue(undefined),
|
||||
scheduleDelete: vi.fn()
|
||||
};
|
||||
const channel = { id: open.discordThreadId, send: vi.fn() };
|
||||
const guild = { id: 'guild-001', channels: { fetch: vi.fn().mockResolvedValue(channel) } };
|
||||
const client = { guilds: { cache: { first: vi.fn().mockReturnValue(guild) } } };
|
||||
|
||||
await checkAutoClose(client, vi.fn().mockResolvedValue(undefined), model, mockRecord, deps);
|
||||
|
||||
expect(mockRecord).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user