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:
110
tests/closeTransition.test.js
Normal file
110
tests/closeTransition.test.js
Normal file
@@ -0,0 +1,110 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
|
||||
// Import the real module — no module-level mocks needed.
|
||||
// attemptCloseTransition accepts an optional 4th arg (_TicketModel) so tests
|
||||
// can inject a mock without mocking the whole db-connection chain.
|
||||
import { attemptCloseTransition } from '../services/tickets.js';
|
||||
|
||||
describe('attemptCloseTransition', () => {
|
||||
let mockUpdateOne, mockFindOne, mockTicket;
|
||||
|
||||
beforeEach(() => {
|
||||
mockUpdateOne = vi.fn();
|
||||
mockFindOne = vi.fn();
|
||||
mockTicket = { updateOne: mockUpdateOne, findOne: mockFindOne };
|
||||
});
|
||||
|
||||
it('returns transitioned=true and the fetched ticket when an open ticket is closed', async () => {
|
||||
const closedTicket = { gmailThreadId: 'thread-open', status: 'closed', closedAt: new Date() };
|
||||
mockUpdateOne.mockResolvedValue({ modifiedCount: 1 });
|
||||
mockFindOne.mockReturnValue({ lean: vi.fn().mockResolvedValue(closedTicket) });
|
||||
|
||||
const result = await attemptCloseTransition('thread-open', {}, {}, mockTicket);
|
||||
|
||||
expect(result.transitioned).toBe(true);
|
||||
expect(result.ticket).toBe(closedTicket);
|
||||
});
|
||||
|
||||
it('gates the update on status:"open" so only open tickets are closed', async () => {
|
||||
mockUpdateOne.mockResolvedValue({ modifiedCount: 1 });
|
||||
mockFindOne.mockReturnValue({ lean: vi.fn().mockResolvedValue({}) });
|
||||
|
||||
await attemptCloseTransition('thread-open', {}, {}, mockTicket);
|
||||
|
||||
expect(mockUpdateOne).toHaveBeenCalledWith(
|
||||
{ gmailThreadId: 'thread-open', status: 'open' },
|
||||
expect.anything()
|
||||
);
|
||||
});
|
||||
|
||||
it('includes a closedAt Date in the $set', async () => {
|
||||
mockUpdateOne.mockResolvedValue({ modifiedCount: 1 });
|
||||
mockFindOne.mockReturnValue({ lean: vi.fn().mockResolvedValue({}) });
|
||||
|
||||
await attemptCloseTransition('thread-open', {}, {}, mockTicket);
|
||||
|
||||
const [, update] = mockUpdateOne.mock.calls[0];
|
||||
expect(update.$set.closedAt).toBeInstanceOf(Date);
|
||||
});
|
||||
|
||||
it('returns transitioned=false and null ticket when the ticket is already closed', async () => {
|
||||
mockUpdateOne.mockResolvedValue({ modifiedCount: 0 });
|
||||
|
||||
const result = await attemptCloseTransition('thread-closed', {}, {}, mockTicket);
|
||||
|
||||
expect(result.transitioned).toBe(false);
|
||||
expect(result.ticket).toBeNull();
|
||||
});
|
||||
|
||||
it('does not call findOne when no transition occurred', async () => {
|
||||
mockUpdateOne.mockResolvedValue({ modifiedCount: 0 });
|
||||
|
||||
await attemptCloseTransition('thread-closed', {}, {}, mockTicket);
|
||||
|
||||
expect(mockFindOne).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('is a no-op on a second call — idempotency seam later phases rely on', async () => {
|
||||
mockUpdateOne.mockResolvedValueOnce({ modifiedCount: 1 });
|
||||
mockFindOne.mockReturnValueOnce({ lean: vi.fn().mockResolvedValue({ gmailThreadId: 'thread-x' }) });
|
||||
const first = await attemptCloseTransition('thread-x', {}, {}, mockTicket);
|
||||
expect(first.transitioned).toBe(true);
|
||||
|
||||
mockUpdateOne.mockResolvedValueOnce({ modifiedCount: 0 });
|
||||
const second = await attemptCloseTransition('thread-x', {}, {}, mockTicket);
|
||||
expect(second.transitioned).toBe(false);
|
||||
expect(second.ticket).toBeNull();
|
||||
});
|
||||
|
||||
it('folds extraSet fields into the $set alongside status and closedAt', async () => {
|
||||
mockUpdateOne.mockResolvedValue({ modifiedCount: 1 });
|
||||
mockFindOne.mockReturnValue({ lean: vi.fn().mockResolvedValue({}) });
|
||||
|
||||
await attemptCloseTransition('thread-x', { discordThreadId: null, pendingDelete: true }, {}, mockTicket);
|
||||
|
||||
const [, update] = mockUpdateOne.mock.calls[0];
|
||||
expect(update.$set.status).toBe('closed');
|
||||
expect(update.$set.discordThreadId).toBeNull();
|
||||
expect(update.$set.pendingDelete).toBe(true);
|
||||
});
|
||||
|
||||
it('includes $unset in the update when extraUnset is non-empty', async () => {
|
||||
mockUpdateOne.mockResolvedValue({ modifiedCount: 1 });
|
||||
mockFindOne.mockReturnValue({ lean: vi.fn().mockResolvedValue({}) });
|
||||
|
||||
await attemptCloseTransition('thread-x', {}, { welcomeMessageId: '' }, mockTicket);
|
||||
|
||||
const [, update] = mockUpdateOne.mock.calls[0];
|
||||
expect(update.$unset).toEqual({ welcomeMessageId: '' });
|
||||
});
|
||||
|
||||
it('omits $unset from the update when extraUnset is empty', async () => {
|
||||
mockUpdateOne.mockResolvedValue({ modifiedCount: 1 });
|
||||
mockFindOne.mockReturnValue({ lean: vi.fn().mockResolvedValue({}) });
|
||||
|
||||
await attemptCloseTransition('thread-x', {}, {}, mockTicket);
|
||||
|
||||
const [, update] = mockUpdateOne.mock.calls[0];
|
||||
expect(update.$unset).toBeUndefined();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user