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.
189 lines
7.0 KiB
JavaScript
189 lines
7.0 KiB
JavaScript
/**
|
|
* Phase 5b — escalate / de-escalate 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.
|
|
*
|
|
* Covers:
|
|
* (a) escalate real — modifiedCount 1 → one 'escalate' event with new tier
|
|
* (b) deescalate real — modifiedCount 1 → one 'deescalate' event with new tier
|
|
* (c) no-op write — modifiedCount 0 → no event for either direction
|
|
*/
|
|
import { describe, it, expect, vi } from 'vitest';
|
|
|
|
import { runEscalation, runDeescalation } from '../handlers/commands/escalation.js';
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Shared factories
|
|
// ---------------------------------------------------------------------------
|
|
|
|
function makeTicket(overrides = {}) {
|
|
return {
|
|
gmailThreadId: 'discord-test-001',
|
|
escalationTier: 0,
|
|
escalated: false,
|
|
claimerId: 'claimer-001',
|
|
claimedBy: 'ClaimerName',
|
|
priority: 'normal',
|
|
game: 'TestGame',
|
|
senderEmail: 'user@example.com',
|
|
creatorId: 'creator-001',
|
|
ticketNumber: 42,
|
|
welcomeMessageId: null,
|
|
...overrides
|
|
};
|
|
}
|
|
|
|
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) Real escalate — modifiedCount 1
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe('runEscalation — real escalate emits one event', () => {
|
|
it('emits exactly one "escalate" event with the correct staffId', async () => {
|
|
const ticket = makeTicket({ escalationTier: 0 });
|
|
const mockUpdateOne = vi.fn().mockResolvedValue({ modifiedCount: 1 });
|
|
const mockRecord = vi.fn();
|
|
const interaction = makeInteraction('staff-001');
|
|
|
|
await runEscalation(interaction, ticket, 1, { updateOne: mockUpdateOne }, mockRecord);
|
|
|
|
expect(mockRecord).toHaveBeenCalledTimes(1);
|
|
const [staffId, type] = mockRecord.mock.calls[0];
|
|
expect(staffId).toBe('staff-001');
|
|
expect(type).toBe('escalate');
|
|
});
|
|
|
|
it('passes the ticket with the new tier', async () => {
|
|
const ticket = makeTicket({ escalationTier: 0 });
|
|
const mockUpdateOne = vi.fn().mockResolvedValue({ modifiedCount: 1 });
|
|
const mockRecord = vi.fn();
|
|
const interaction = makeInteraction('staff-001');
|
|
|
|
await runEscalation(interaction, ticket, 1, { updateOne: mockUpdateOne }, mockRecord);
|
|
|
|
const [, , payload] = mockRecord.mock.calls[0];
|
|
expect(payload.ticket.escalationTier).toBe(1);
|
|
expect(payload.guildId).toBe('guild-001');
|
|
});
|
|
|
|
it('uses conditional filter escalationTier $ne nextTier', async () => {
|
|
const ticket = makeTicket({ escalationTier: 0 });
|
|
const mockUpdateOne = vi.fn().mockResolvedValue({ modifiedCount: 1 });
|
|
const interaction = makeInteraction('staff-001');
|
|
|
|
await runEscalation(interaction, ticket, 1, { updateOne: mockUpdateOne }, vi.fn());
|
|
|
|
const [filter] = mockUpdateOne.mock.calls[0];
|
|
expect(filter).toMatchObject({
|
|
gmailThreadId: 'discord-test-001',
|
|
escalationTier: { $ne: 1 }
|
|
});
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// (b) Real deescalate — modifiedCount 1
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe('runDeescalation — real deescalate emits one event', () => {
|
|
it('emits exactly one "deescalate" event with the correct staffId', async () => {
|
|
const ticket = makeTicket({ escalationTier: 1, escalated: true });
|
|
const mockUpdateOne = vi.fn().mockResolvedValue({ modifiedCount: 1 });
|
|
const mockRecord = vi.fn();
|
|
const interaction = makeInteraction('staff-001');
|
|
|
|
await runDeescalation(interaction, ticket, { updateOne: mockUpdateOne }, mockRecord);
|
|
|
|
expect(mockRecord).toHaveBeenCalledTimes(1);
|
|
const [staffId, type] = mockRecord.mock.calls[0];
|
|
expect(staffId).toBe('staff-001');
|
|
expect(type).toBe('deescalate');
|
|
});
|
|
|
|
it('passes the ticket with the new (lower) tier', async () => {
|
|
const ticket = makeTicket({ escalationTier: 2, escalated: true });
|
|
const mockUpdateOne = vi.fn().mockResolvedValue({ modifiedCount: 1 });
|
|
const mockRecord = vi.fn();
|
|
const interaction = makeInteraction('staff-001');
|
|
|
|
await runDeescalation(interaction, ticket, { updateOne: mockUpdateOne }, mockRecord);
|
|
|
|
const [, , payload] = mockRecord.mock.calls[0];
|
|
expect(payload.ticket.escalationTier).toBe(1);
|
|
expect(payload.guildId).toBe('guild-001');
|
|
});
|
|
|
|
it('uses conditional filter escalationTier $ne newTier', async () => {
|
|
const ticket = makeTicket({ escalationTier: 1, escalated: true });
|
|
const mockUpdateOne = vi.fn().mockResolvedValue({ modifiedCount: 1 });
|
|
const interaction = makeInteraction('staff-001');
|
|
|
|
await runDeescalation(interaction, ticket, { updateOne: mockUpdateOne }, vi.fn());
|
|
|
|
const [filter] = mockUpdateOne.mock.calls[0];
|
|
expect(filter).toMatchObject({
|
|
gmailThreadId: 'discord-test-001',
|
|
escalationTier: { $ne: 0 }
|
|
});
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// (c) No-op write — modifiedCount 0 → no event for either direction
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe('no-op tier write emits no event', () => {
|
|
it('escalate: emits no event when modifiedCount is 0', async () => {
|
|
const ticket = makeTicket({ escalationTier: 1 });
|
|
const mockUpdateOne = vi.fn().mockResolvedValue({ modifiedCount: 0 });
|
|
const mockRecord = vi.fn();
|
|
const interaction = makeInteraction('staff-001');
|
|
|
|
await runEscalation(interaction, ticket, 1, { updateOne: mockUpdateOne }, mockRecord);
|
|
|
|
expect(mockRecord).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('deescalate: emits no event when modifiedCount is 0', async () => {
|
|
const ticket = makeTicket({ escalationTier: 1, escalated: true });
|
|
const mockUpdateOne = vi.fn().mockResolvedValue({ modifiedCount: 0 });
|
|
const mockRecord = vi.fn();
|
|
const interaction = makeInteraction('staff-001');
|
|
|
|
await runDeescalation(interaction, ticket, { updateOne: mockUpdateOne }, mockRecord);
|
|
|
|
expect(mockRecord).not.toHaveBeenCalled();
|
|
});
|
|
});
|