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:
66
tests/autocompleteDispatch.test.js
Normal file
66
tests/autocompleteDispatch.test.js
Normal file
@@ -0,0 +1,66 @@
|
||||
/**
|
||||
* Phase 9 — command-aware autocomplete dispatcher tests.
|
||||
*
|
||||
* Uses the injectable _handlers parameter to test the dispatch seam without
|
||||
* requiring real Tag/Ticket DB access.
|
||||
*
|
||||
* Covers:
|
||||
* (a) commandName 'response' → response handler called
|
||||
* (b) unknown commandName → no handler called (no-op)
|
||||
* (c) handler receives the interaction object unchanged
|
||||
*/
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
|
||||
import { handleAutocomplete } from '../handlers/commands/index.js';
|
||||
|
||||
function makeInteraction(commandName) {
|
||||
return {
|
||||
commandName,
|
||||
options: {
|
||||
getSubcommand: vi.fn().mockReturnValue('send'),
|
||||
getFocused: vi.fn().mockReturnValue('')
|
||||
},
|
||||
respond: vi.fn().mockResolvedValue(undefined)
|
||||
};
|
||||
}
|
||||
|
||||
describe('autocomplete dispatcher', () => {
|
||||
it('routes commandName "response" to the response handler', async () => {
|
||||
const responseHandler = vi.fn().mockResolvedValue(undefined);
|
||||
const interaction = makeInteraction('response');
|
||||
|
||||
await handleAutocomplete(interaction, { response: responseHandler });
|
||||
|
||||
expect(responseHandler).toHaveBeenCalledTimes(1);
|
||||
expect(responseHandler).toHaveBeenCalledWith(interaction);
|
||||
});
|
||||
|
||||
it('routes commandName "stats" to the stats handler', async () => {
|
||||
const statsHandler = vi.fn().mockResolvedValue(undefined);
|
||||
const interaction = makeInteraction('stats');
|
||||
|
||||
await handleAutocomplete(interaction, { stats: statsHandler });
|
||||
|
||||
expect(statsHandler).toHaveBeenCalledTimes(1);
|
||||
expect(statsHandler).toHaveBeenCalledWith(interaction);
|
||||
});
|
||||
|
||||
it('no-ops for an unknown commandName', async () => {
|
||||
const responseHandler = vi.fn().mockResolvedValue(undefined);
|
||||
const interaction = makeInteraction('unknown-command');
|
||||
|
||||
await handleAutocomplete(interaction, { response: responseHandler });
|
||||
|
||||
expect(responseHandler).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('passes the interaction object through to the handler unchanged', async () => {
|
||||
let received = null;
|
||||
const handler = vi.fn().mockImplementation(async i => { received = i; });
|
||||
const interaction = makeInteraction('response');
|
||||
|
||||
await handleAutocomplete(interaction, { response: handler });
|
||||
|
||||
expect(received).toBe(interaction);
|
||||
});
|
||||
});
|
||||
171
tests/claimEvents.test.js
Normal file
171
tests/claimEvents.test.js
Normal file
@@ -0,0 +1,171 @@
|
||||
/**
|
||||
* Phase 5a — claim event recording tests.
|
||||
*
|
||||
* Follows the same injectable-parameter pattern as closeTransition.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) fresh claim — modifiedCount 1 → exactly one 'claim' event
|
||||
* (b) no-op re-claim — modifiedCount 0 (same user) → no event
|
||||
* (c) conditional filter — filter must exclude tickets already claimed by actor
|
||||
* (d) tier captured — escalationTier from ticket at claim time
|
||||
*/
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
|
||||
import { applyClaim } from '../handlers/buttons.js';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Shared factories
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function makeInteraction(userId = 'staff-001') {
|
||||
return {
|
||||
user: {
|
||||
id: userId,
|
||||
username: 'staffuser',
|
||||
toString: () => `<@${userId}>`
|
||||
},
|
||||
member: { displayName: 'Staff Member' },
|
||||
guild: { id: 'guild-001' },
|
||||
channel: { id: 'chan-001' },
|
||||
update: vi.fn().mockResolvedValue(undefined),
|
||||
followUp: vi.fn().mockResolvedValue(undefined)
|
||||
};
|
||||
}
|
||||
|
||||
function makeGuild() {
|
||||
return {
|
||||
members: {
|
||||
fetch: vi.fn().mockRejectedValue(new Error('no member in test env'))
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function makeTicket(overrides = {}) {
|
||||
return {
|
||||
gmailThreadId: 'discord-test-001',
|
||||
escalationTier: 0,
|
||||
claimerId: null,
|
||||
claimedBy: null,
|
||||
priority: 'normal',
|
||||
game: 'TestGame',
|
||||
senderEmail: 'user@example.com',
|
||||
creatorId: 'creator-001',
|
||||
ticketNumber: 42,
|
||||
...overrides
|
||||
};
|
||||
}
|
||||
|
||||
function makeBtn() {
|
||||
const btn = {};
|
||||
const chain = () => btn;
|
||||
btn.setCustomId = chain;
|
||||
btn.setLabel = chain;
|
||||
btn.setEmoji = chain;
|
||||
btn.setStyle = chain;
|
||||
btn.setDisabled = chain;
|
||||
return btn;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// (a) Fresh claim — real transition
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('applyClaim — fresh claim emits one event', () => {
|
||||
it('emits exactly one "claim" event with the correct staffId', async () => {
|
||||
const ticket = makeTicket({ escalationTier: 0, claimerId: null });
|
||||
const mockUpdateOne = vi.fn().mockResolvedValue({ modifiedCount: 1 });
|
||||
const mockRecord = vi.fn();
|
||||
const interaction = makeInteraction('staff-001');
|
||||
|
||||
await applyClaim(interaction, ticket, {}, makeBtn(), makeBtn(), 'Staff Member', makeGuild(), { updateOne: mockUpdateOne }, mockRecord);
|
||||
|
||||
expect(mockRecord).toHaveBeenCalledTimes(1);
|
||||
const [staffId, type] = mockRecord.mock.calls[0];
|
||||
expect(staffId).toBe('staff-001');
|
||||
expect(type).toBe('claim');
|
||||
});
|
||||
|
||||
it('passes the ticket doc so the recorder can denormalize fields', async () => {
|
||||
const ticket = makeTicket({ escalationTier: 0, claimerId: null });
|
||||
const mockUpdateOne = vi.fn().mockResolvedValue({ modifiedCount: 1 });
|
||||
const mockRecord = vi.fn();
|
||||
const interaction = makeInteraction('staff-001');
|
||||
|
||||
await applyClaim(interaction, ticket, {}, makeBtn(), makeBtn(), 'Staff Member', makeGuild(), { updateOne: mockUpdateOne }, mockRecord);
|
||||
|
||||
const [, , payload] = mockRecord.mock.calls[0];
|
||||
expect(payload.ticket).toBe(ticket);
|
||||
expect(payload.guildId).toBe('guild-001');
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// (b) No-op re-claim — same user double-click, modifiedCount 0
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('applyClaim — no-op re-claim emits no event', () => {
|
||||
it('emits no event when modifiedCount is 0', async () => {
|
||||
const ticket = makeTicket({ escalationTier: 0, claimerId: 'staff-001' });
|
||||
const mockUpdateOne = vi.fn().mockResolvedValue({ modifiedCount: 0 });
|
||||
const mockRecord = vi.fn();
|
||||
const interaction = makeInteraction('staff-001');
|
||||
|
||||
await applyClaim(interaction, ticket, {}, makeBtn(), makeBtn(), 'Staff Member', makeGuild(), { updateOne: mockUpdateOne }, mockRecord);
|
||||
|
||||
expect(mockRecord).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// (c) Conditional filter — DB write must exclude same-user claims
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('applyClaim — conditional filter', () => {
|
||||
it('includes claimerId $ne the acting user in the updateOne filter', async () => {
|
||||
const ticket = makeTicket({ claimerId: null });
|
||||
const mockUpdateOne = vi.fn().mockResolvedValue({ modifiedCount: 0 });
|
||||
const interaction = makeInteraction('staff-001');
|
||||
|
||||
await applyClaim(interaction, ticket, {}, makeBtn(), makeBtn(), 'Staff Member', makeGuild(), { updateOne: mockUpdateOne }, vi.fn());
|
||||
|
||||
const [filter] = mockUpdateOne.mock.calls[0];
|
||||
expect(filter).toMatchObject({
|
||||
gmailThreadId: 'discord-test-001',
|
||||
claimerId: { $ne: 'staff-001' }
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// (d) Tier captured at claim time
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('applyClaim — tier captured at claim time', () => {
|
||||
it('passes the ticket with escalationTier=1 when the ticket is escalated', async () => {
|
||||
const ticket = makeTicket({ escalationTier: 1, claimerId: null });
|
||||
const mockUpdateOne = vi.fn().mockResolvedValue({ modifiedCount: 1 });
|
||||
const mockRecord = vi.fn();
|
||||
const interaction = makeInteraction('staff-001');
|
||||
|
||||
await applyClaim(interaction, ticket, {}, makeBtn(), makeBtn(), 'Staff Member', makeGuild(), { updateOne: mockUpdateOne }, mockRecord);
|
||||
|
||||
const [, , payload] = mockRecord.mock.calls[0];
|
||||
expect(payload.ticket.escalationTier).toBe(1);
|
||||
});
|
||||
|
||||
it('passes the ticket with escalationTier=0 for a non-escalated ticket', async () => {
|
||||
const ticket = makeTicket({ escalationTier: 0, claimerId: null });
|
||||
const mockUpdateOne = vi.fn().mockResolvedValue({ modifiedCount: 1 });
|
||||
const mockRecord = vi.fn();
|
||||
const interaction = makeInteraction('staff-001');
|
||||
|
||||
await applyClaim(interaction, ticket, {}, makeBtn(), makeBtn(), 'Staff Member', makeGuild(), { updateOne: mockUpdateOne }, mockRecord);
|
||||
|
||||
const [, , payload] = mockRecord.mock.calls[0];
|
||||
expect(payload.ticket.escalationTier).toBe(0);
|
||||
});
|
||||
});
|
||||
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();
|
||||
});
|
||||
});
|
||||
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();
|
||||
});
|
||||
});
|
||||
@@ -19,6 +19,10 @@ describe('ALLOWED_CONFIG_KEYS', () => {
|
||||
}
|
||||
});
|
||||
|
||||
it('includes STATS_ADMIN_IDS', () => {
|
||||
expect(ALLOWED_CONFIG_KEYS.has('STATS_ADMIN_IDS')).toBe(true);
|
||||
});
|
||||
|
||||
it('does not contain stale removed keys', () => {
|
||||
for (const k of ['BOSSCORD_API_KEY', 'SURGE_ENABLED']) {
|
||||
expect(ALLOWED_CONFIG_KEYS.has(k)).toBe(false);
|
||||
@@ -192,9 +196,8 @@ describe('discord_id validator', () => {
|
||||
|
||||
describe('discord_id_list validator', () => {
|
||||
// ADDITIONAL_STAFF_ROLES (a real comma-list) ends in _ROLES, not _IDS, so it
|
||||
// hits the string fallback. discord_id_list only fires for `*_IDS` keys, so
|
||||
// exercise it with a hypothetical name.
|
||||
const v = getValidator('STAFF_USER_IDS');
|
||||
// hits the string fallback. discord_id_list only fires for `*_IDS` keys.
|
||||
const v = getValidator('STATS_ADMIN_IDS');
|
||||
|
||||
it('infers type discord_id_list for *_IDS keys', () => {
|
||||
expect(v.type).toBe('discord_id_list');
|
||||
@@ -221,6 +224,40 @@ describe('discord_id_list validator', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('STATS_ADMIN_IDS parsing (config.js pattern)', () => {
|
||||
// Tests the .split(',').map(r=>r.trim()).filter(Boolean) idiom used for all
|
||||
// list env vars in config.js — exercised here as a pure expression.
|
||||
function parseIdList(v) {
|
||||
return (v || '').split(',').map(r => r.trim()).filter(Boolean);
|
||||
}
|
||||
|
||||
it('returns [] for empty string', () => {
|
||||
expect(parseIdList('')).toEqual([]);
|
||||
});
|
||||
|
||||
it('returns [] for undefined', () => {
|
||||
expect(parseIdList(undefined)).toEqual([]);
|
||||
});
|
||||
|
||||
it('returns a single-element array for one ID', () => {
|
||||
expect(parseIdList('321754640431710226')).toEqual(['321754640431710226']);
|
||||
});
|
||||
|
||||
it('returns multiple IDs for comma-separated input', () => {
|
||||
expect(parseIdList('321754640431710226,691678135527276614,224692549225283584'))
|
||||
.toEqual(['321754640431710226', '691678135527276614', '224692549225283584']);
|
||||
});
|
||||
|
||||
it('trims whitespace around each ID', () => {
|
||||
expect(parseIdList(' 321754640431710226 , 691678135527276614 '))
|
||||
.toEqual(['321754640431710226', '691678135527276614']);
|
||||
});
|
||||
|
||||
it('drops empty segments from trailing commas', () => {
|
||||
expect(parseIdList('321754640431710226,')).toEqual(['321754640431710226']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('string validator (fallback)', () => {
|
||||
const v = getValidator('TICKET_CATEGORY_NAME');
|
||||
|
||||
|
||||
188
tests/escalateEvents.test.js
Normal file
188
tests/escalateEvents.test.js
Normal file
@@ -0,0 +1,188 @@
|
||||
/**
|
||||
* 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();
|
||||
});
|
||||
});
|
||||
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();
|
||||
});
|
||||
});
|
||||
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();
|
||||
});
|
||||
});
|
||||
187
tests/staffStats.test.js
Normal file
187
tests/staffStats.test.js
Normal file
@@ -0,0 +1,187 @@
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
|
||||
// Stub debugLog so the import chain doesn't pull in discord.js / config.
|
||||
vi.mock('../services/debugLog.js', () => ({
|
||||
logError: vi.fn()
|
||||
}));
|
||||
|
||||
import { recordAction, denormalizeTicket, deriveTicketType } from '../services/staffStats.js';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// deriveTicketType
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('deriveTicketType', () => {
|
||||
it('returns "discord" for discord- prefix', () => {
|
||||
expect(deriveTicketType('discord-abc123')).toBe('discord');
|
||||
});
|
||||
|
||||
it('returns "discord" for discord-msg- prefix', () => {
|
||||
expect(deriveTicketType('discord-msg-abc123')).toBe('discord');
|
||||
});
|
||||
|
||||
it('returns "email" for a Gmail thread ID', () => {
|
||||
expect(deriveTicketType('18f3a2b1c0d4e5f6')).toBe('email');
|
||||
});
|
||||
|
||||
it('returns "email" for null / undefined / empty gmailThreadId', () => {
|
||||
expect(deriveTicketType(null)).toBe('email');
|
||||
expect(deriveTicketType(undefined)).toBe('email');
|
||||
expect(deriveTicketType('')).toBe('email');
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// denormalizeTicket — field extraction
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('denormalizeTicket', () => {
|
||||
const emailTicket = {
|
||||
gmailThreadId: '18f3a2b1c0d4e5f6',
|
||||
escalationTier: 1,
|
||||
priority: 'high',
|
||||
game: 'Minecraft',
|
||||
senderEmail: 'user@example.com',
|
||||
creatorId: '111222333444555666',
|
||||
claimerId: '999888777666555444'
|
||||
};
|
||||
|
||||
const discordTicket = {
|
||||
gmailThreadId: 'discord-msg-xyz789',
|
||||
escalationTier: 0,
|
||||
priority: 'normal',
|
||||
game: null,
|
||||
senderEmail: 'noreply@discord',
|
||||
creatorId: '777666555444333222'
|
||||
};
|
||||
|
||||
it('derives ticketType "email" for a Gmail thread', () => {
|
||||
expect(denormalizeTicket(emailTicket).ticketType).toBe('email');
|
||||
});
|
||||
|
||||
it('derives ticketType "discord" for a discord-msg- thread', () => {
|
||||
expect(denormalizeTicket(discordTicket).ticketType).toBe('discord');
|
||||
});
|
||||
|
||||
it('copies all standard event fields from the ticket', () => {
|
||||
const f = denormalizeTicket(emailTicket);
|
||||
expect(f.tier).toBe(1);
|
||||
expect(f.priority).toBe('high');
|
||||
expect(f.game).toBe('Minecraft');
|
||||
expect(f.senderEmail).toBe('user@example.com');
|
||||
expect(f.creatorId).toBe('111222333444555666');
|
||||
expect(f.gmailThreadId).toBe('18f3a2b1c0d4e5f6');
|
||||
});
|
||||
|
||||
it('defaults tier to 0 when escalationTier is absent', () => {
|
||||
expect(denormalizeTicket({ gmailThreadId: 'abc' }).tier).toBe(0);
|
||||
});
|
||||
|
||||
it('does NOT include guildId (must come from call site)', () => {
|
||||
const f = denormalizeTicket(emailTicket);
|
||||
expect(Object.prototype.hasOwnProperty.call(f, 'guildId')).toBe(false);
|
||||
});
|
||||
|
||||
it('returns {} for a null ticket', () => {
|
||||
expect(denormalizeTicket(null)).toEqual({});
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// recordAction — payload merging / override precedence
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('recordAction payload merging', () => {
|
||||
const ticket = {
|
||||
gmailThreadId: '18f3a2b1c0d4e5f6',
|
||||
escalationTier: 2,
|
||||
priority: 'medium',
|
||||
game: 'Rust',
|
||||
senderEmail: 'player@example.com',
|
||||
creatorId: '100200300400500600'
|
||||
};
|
||||
|
||||
it('payload fields override denormalized ticket fields', () => {
|
||||
const { ticket: t, ...overrides } = {
|
||||
ticket,
|
||||
guildId: '555666777888999000',
|
||||
game: 'OverriddenGame',
|
||||
priority: 'low'
|
||||
};
|
||||
const merged = { ...denormalizeTicket(t), ...overrides };
|
||||
expect(merged.game).toBe('OverriddenGame');
|
||||
expect(merged.priority).toBe('low');
|
||||
expect(merged.guildId).toBe('555666777888999000');
|
||||
});
|
||||
|
||||
it('guildId (call-site only) passes through from payload', () => {
|
||||
const { ticket: t, ...rest } = { ticket, guildId: '123456789012345678' };
|
||||
const merged = { ...denormalizeTicket(t), ...rest };
|
||||
expect(merged.guildId).toBe('123456789012345678');
|
||||
});
|
||||
|
||||
it('close-only fields pass through from payload', () => {
|
||||
const { ticket: t, ...rest } = {
|
||||
ticket,
|
||||
closerType: 'staff',
|
||||
resolverId: '123456789012345678',
|
||||
wasClaimed: true
|
||||
};
|
||||
const merged = { ...denormalizeTicket(t), ...rest };
|
||||
expect(merged.closerType).toBe('staff');
|
||||
expect(merged.resolverId).toBe('123456789012345678');
|
||||
expect(merged.wasClaimed).toBe(true);
|
||||
});
|
||||
|
||||
it('transfer-only fields (fromId/toId) pass through from payload', () => {
|
||||
const { ticket: t, ...rest } = {
|
||||
ticket,
|
||||
fromId: '111111111111111111',
|
||||
toId: '222222222222222222'
|
||||
};
|
||||
const merged = { ...denormalizeTicket(t), ...rest };
|
||||
expect(merged.fromId).toBe('111111111111111111');
|
||||
expect(merged.toId).toBe('222222222222222222');
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// recordAction — fire-and-forget discipline
|
||||
//
|
||||
// In the test environment there is no real MongoDB connection and the
|
||||
// StaffAction model schema is not registered, so mongoose.model('StaffAction')
|
||||
// throws MissingSchemaError synchronously. This is exactly the kind of error
|
||||
// recordAction must swallow — it proves the outer try/catch works. The async
|
||||
// .catch() path is exercised transitively: any callers in later phases that
|
||||
// succeed with a real DB connection will hit that path.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('recordAction fire-and-forget', () => {
|
||||
it('returns undefined (callers do not await)', () => {
|
||||
expect(recordAction('staff1', 'claim', {})).toBeUndefined();
|
||||
});
|
||||
|
||||
it('does not throw when called with null / undefined / empty payload', () => {
|
||||
expect(() => recordAction('staff1', 'reopen', undefined)).not.toThrow();
|
||||
expect(() => recordAction('staff1', 'reopen', null)).not.toThrow();
|
||||
expect(() => recordAction('staff1', 'reopen', {})).not.toThrow();
|
||||
});
|
||||
|
||||
it('does not throw even when the model layer errors (model not registered)', () => {
|
||||
// mongoose.model('StaffAction') throws MissingSchemaError synchronously in
|
||||
// this environment — recordAction must absorb it and never rethrow.
|
||||
expect(() => recordAction('staff1', 'close', {
|
||||
ticket: {
|
||||
gmailThreadId: 'discord-abc',
|
||||
escalationTier: 0,
|
||||
priority: 'normal',
|
||||
senderEmail: 'a@b.com',
|
||||
creatorId: '123'
|
||||
},
|
||||
guildId: '999',
|
||||
closerType: 'staff',
|
||||
resolverId: '123456789012345678',
|
||||
wasClaimed: true
|
||||
})).not.toThrow();
|
||||
});
|
||||
});
|
||||
350
tests/statsHandler.test.js
Normal file
350
tests/statsHandler.test.js
Normal file
@@ -0,0 +1,350 @@
|
||||
/**
|
||||
* Phase 10 — /stats command handler tests.
|
||||
* Injectable deps — no vi.mock.
|
||||
*/
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { MessageFlags } from 'discord.js';
|
||||
import { handleStats, handleStatsAutocomplete } from '../handlers/commands/stats.js';
|
||||
|
||||
const MS_PER_DAY = 24 * 60 * 60 * 1000;
|
||||
const NOW_MS = 1_700_000_000_000; // fixed epoch for deterministic cutoff assertions
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Factories
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function makeInteraction({ userId = 'caller-001', memberUserId, memberUsername, periodStr, source } = {}) {
|
||||
return {
|
||||
user: { id: userId, username: 'testuser' },
|
||||
guildId: 'guild-001',
|
||||
options: {
|
||||
getUser: (name) => {
|
||||
if (name === 'member' && memberUserId) {
|
||||
return { id: memberUserId, username: memberUsername || 'member-user' };
|
||||
}
|
||||
return null;
|
||||
},
|
||||
getString: (name) => {
|
||||
if (name === 'period') return periodStr || null;
|
||||
if (name === 'source') return source || null;
|
||||
return null;
|
||||
}
|
||||
},
|
||||
reply: vi.fn().mockResolvedValue(undefined)
|
||||
};
|
||||
}
|
||||
|
||||
function makeStaffAction(events = []) {
|
||||
return {
|
||||
find: vi.fn().mockReturnValue({ lean: () => Promise.resolve(events) })
|
||||
};
|
||||
}
|
||||
|
||||
function captureStaffAction() {
|
||||
let capturedFilter;
|
||||
return {
|
||||
sa: {
|
||||
find: (filter) => {
|
||||
capturedFilter = filter;
|
||||
return { lean: () => Promise.resolve([]) };
|
||||
}
|
||||
},
|
||||
getFilter: () => capturedFilter
|
||||
};
|
||||
}
|
||||
|
||||
function deps(overrides = {}) {
|
||||
return {
|
||||
StaffAction: makeStaffAction(),
|
||||
now: () => NOW_MS,
|
||||
adminIds: [],
|
||||
...overrides
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// handleStatsAutocomplete
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('handleStatsAutocomplete', () => {
|
||||
function makeAutoInteraction(focusedValue = '') {
|
||||
return {
|
||||
options: { getFocused: () => focusedValue },
|
||||
respond: vi.fn().mockResolvedValue(undefined)
|
||||
};
|
||||
}
|
||||
|
||||
it('returns all 5 presets when focused input is empty', async () => {
|
||||
const i = makeAutoInteraction('');
|
||||
await handleStatsAutocomplete(i);
|
||||
const [[suggestions]] = i.respond.mock.calls;
|
||||
expect(suggestions).toHaveLength(5);
|
||||
const values = suggestions.map(s => s.value);
|
||||
expect(values).toContain('7 days');
|
||||
expect(values).toContain('30 days');
|
||||
expect(values).toContain('3 months');
|
||||
expect(values).toContain('6 months');
|
||||
expect(values).toContain('1 year');
|
||||
});
|
||||
|
||||
it('filters to presets matching the typed substring', async () => {
|
||||
const i = makeAutoInteraction('days');
|
||||
await handleStatsAutocomplete(i);
|
||||
const [[suggestions]] = i.respond.mock.calls;
|
||||
const values = suggestions.map(s => s.value);
|
||||
expect(values).toContain('7 days');
|
||||
expect(values).toContain('30 days');
|
||||
expect(values).not.toContain('3 months');
|
||||
expect(values).not.toContain('1 year');
|
||||
});
|
||||
|
||||
it('echoes typed input as first suggestion when it does not exactly match a preset', async () => {
|
||||
const i = makeAutoInteraction('14d');
|
||||
await handleStatsAutocomplete(i);
|
||||
const [[suggestions]] = i.respond.mock.calls;
|
||||
expect(suggestions[0].value).toBe('14d');
|
||||
expect(suggestions[0].name).toBe('14d');
|
||||
});
|
||||
|
||||
it('does not duplicate a preset when typed input exactly matches one', async () => {
|
||||
const i = makeAutoInteraction('30 days');
|
||||
await handleStatsAutocomplete(i);
|
||||
const [[suggestions]] = i.respond.mock.calls;
|
||||
expect(suggestions.filter(s => s.value === '30 days')).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('calls interaction.respond exactly once', async () => {
|
||||
const i = makeAutoInteraction('');
|
||||
await handleStatsAutocomplete(i);
|
||||
expect(i.respond).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// handleStats — gating (STATS_ADMIN_IDS)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('handleStats — gating', () => {
|
||||
it('caller views own stats when no member option is provided', async () => {
|
||||
const interaction = makeInteraction({ userId: 'caller-001' });
|
||||
await handleStats(interaction, deps());
|
||||
expect(interaction.reply).toHaveBeenCalledTimes(1);
|
||||
const [[replyArg]] = interaction.reply.mock.calls;
|
||||
expect(replyArg.content).toBeUndefined();
|
||||
expect(replyArg.embeds).toBeDefined();
|
||||
});
|
||||
|
||||
it('admin can view another member\'s stats', async () => {
|
||||
const interaction = makeInteraction({ userId: 'admin-001', memberUserId: 'other-002' });
|
||||
await handleStats(interaction, deps({ adminIds: ['admin-001'] }));
|
||||
const [[replyArg]] = interaction.reply.mock.calls;
|
||||
expect(replyArg.embeds).toBeDefined();
|
||||
expect(replyArg.content).toBeUndefined();
|
||||
});
|
||||
|
||||
it('non-admin is blocked with the exact error message when member option is set', async () => {
|
||||
const interaction = makeInteraction({ userId: 'plain-001', memberUserId: 'other-002' });
|
||||
await handleStats(interaction, deps({ adminIds: [] }));
|
||||
const [[replyArg]] = interaction.reply.mock.calls;
|
||||
expect(replyArg.content).toBe('You can only view your own stats.');
|
||||
expect(replyArg.flags).toBe(MessageFlags.Ephemeral);
|
||||
expect(replyArg.embeds).toBeUndefined();
|
||||
});
|
||||
|
||||
it('non-admin can view their own stats (no member option)', async () => {
|
||||
const interaction = makeInteraction({ userId: 'plain-001' });
|
||||
await handleStats(interaction, deps({ adminIds: [] }));
|
||||
const [[replyArg]] = interaction.reply.mock.calls;
|
||||
expect(replyArg.content).toBeUndefined();
|
||||
expect(replyArg.embeds).toBeDefined();
|
||||
});
|
||||
|
||||
it('StaffAction is never queried when the non-admin gate fires', async () => {
|
||||
const sa = makeStaffAction();
|
||||
const interaction = makeInteraction({ userId: 'plain-001', memberUserId: 'other-002' });
|
||||
await handleStats(interaction, deps({ StaffAction: sa, adminIds: [] }));
|
||||
expect(sa.find).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// handleStats — period default (no option → 30 days)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('handleStats — period default', () => {
|
||||
it('uses a 30-day cutoff when no period option is given', async () => {
|
||||
const { sa, getFilter } = captureStaffAction();
|
||||
const interaction = makeInteraction();
|
||||
await handleStats(interaction, { StaffAction: sa, now: () => NOW_MS, adminIds: [] });
|
||||
const expectedCutoff = new Date(NOW_MS - 30 * MS_PER_DAY);
|
||||
expect(getFilter().createdAt.$gte.getTime()).toBe(expectedCutoff.getTime());
|
||||
});
|
||||
|
||||
it('embed title includes "30 days" when no period option', async () => {
|
||||
const interaction = makeInteraction();
|
||||
await handleStats(interaction, deps());
|
||||
const [[replyArg]] = interaction.reply.mock.calls;
|
||||
expect(replyArg.embeds[0].data.title).toContain('30 days');
|
||||
});
|
||||
|
||||
it('embed title includes the user-supplied period label', async () => {
|
||||
const interaction = makeInteraction({ periodStr: '7 days' });
|
||||
await handleStats(interaction, deps());
|
||||
const [[replyArg]] = interaction.reply.mock.calls;
|
||||
expect(replyArg.embeds[0].data.title).toContain('7 days');
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// handleStats — source filter passthrough
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('handleStats — source filter', () => {
|
||||
it('source="email" counts only email events', async () => {
|
||||
const events = [
|
||||
{ staffId: 'caller-001', type: 'claim', tier: 0, ticketType: 'email', resolverId: null, toId: null, fromId: null },
|
||||
{ staffId: 'caller-001', type: 'claim', tier: 0, ticketType: 'discord', resolverId: null, toId: null, fromId: null }
|
||||
];
|
||||
const sa = { find: () => ({ lean: () => Promise.resolve(events) }) };
|
||||
const interaction = makeInteraction({ source: 'email' });
|
||||
await handleStats(interaction, { StaffAction: sa, now: () => NOW_MS, adminIds: [] });
|
||||
const [[replyArg]] = interaction.reply.mock.calls;
|
||||
const claimsField = replyArg.embeds[0].data.fields.find(f => f.name === 'Claims');
|
||||
// Only 1 email claim should be counted
|
||||
expect(claimsField.value).toMatch(/^1/);
|
||||
});
|
||||
|
||||
it('source label appears in embed description', async () => {
|
||||
const interaction = makeInteraction({ source: 'discord' });
|
||||
await handleStats(interaction, deps());
|
||||
const [[replyArg]] = interaction.reply.mock.calls;
|
||||
expect(replyArg.embeds[0].data.description).toContain('discord');
|
||||
});
|
||||
|
||||
it('omitted source defaults to "all" and shows "all sources" in description', async () => {
|
||||
const interaction = makeInteraction();
|
||||
await handleStats(interaction, deps());
|
||||
const [[replyArg]] = interaction.reply.mock.calls;
|
||||
expect(replyArg.embeds[0].data.description).toContain('all sources');
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// handleStats — StaffAction query filter shape
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('handleStats — query filter shape', () => {
|
||||
it('$or includes all 4 target fields', async () => {
|
||||
const { sa, getFilter } = captureStaffAction();
|
||||
const interaction = makeInteraction({ userId: 'user-001' });
|
||||
await handleStats(interaction, { StaffAction: sa, now: () => NOW_MS, adminIds: [] });
|
||||
|
||||
expect(getFilter().$or).toHaveLength(4);
|
||||
expect(getFilter().$or).toContainEqual({ staffId: 'user-001' });
|
||||
expect(getFilter().$or).toContainEqual({ resolverId: 'user-001' });
|
||||
expect(getFilter().$or).toContainEqual({ toId: 'user-001' });
|
||||
expect(getFilter().$or).toContainEqual({ fromId: 'user-001' });
|
||||
});
|
||||
|
||||
it('createdAt.$gte is a Date instance', async () => {
|
||||
const { sa, getFilter } = captureStaffAction();
|
||||
const interaction = makeInteraction();
|
||||
await handleStats(interaction, { StaffAction: sa, now: () => NOW_MS, adminIds: [] });
|
||||
expect(getFilter().createdAt.$gte).toBeInstanceOf(Date);
|
||||
});
|
||||
|
||||
it('admin querying another member uses member id (not admin id) in $or', async () => {
|
||||
const { sa, getFilter } = captureStaffAction();
|
||||
const interaction = makeInteraction({ userId: 'admin-001', memberUserId: 'other-002' });
|
||||
await handleStats(interaction, { StaffAction: sa, now: () => NOW_MS, adminIds: ['admin-001'] });
|
||||
expect(getFilter().$or).toContainEqual({ staffId: 'other-002' });
|
||||
expect(getFilter().$or).not.toContainEqual({ staffId: 'admin-001' });
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// handleStats — embed output reflects shapeStats with tier labels
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('handleStats — embed content', () => {
|
||||
it('reply is ephemeral', async () => {
|
||||
const interaction = makeInteraction();
|
||||
await handleStats(interaction, deps());
|
||||
const [[replyArg]] = interaction.reply.mock.calls;
|
||||
expect(replyArg.flags).toBe(MessageFlags.Ephemeral);
|
||||
});
|
||||
|
||||
it('embed title contains the target username', async () => {
|
||||
const interaction = makeInteraction({ userId: 'caller-001' }); // username = 'testuser'
|
||||
await handleStats(interaction, deps());
|
||||
const [[replyArg]] = interaction.reply.mock.calls;
|
||||
expect(replyArg.embeds[0].data.title).toContain('testuser');
|
||||
});
|
||||
|
||||
it('escalations field maps tier 1 → "Tier 2" and tier 2 → "Tier 3"', async () => {
|
||||
const events = [
|
||||
{ staffId: 'caller-001', type: 'escalate', tier: 1, ticketType: 'email', resolverId: null, toId: null, fromId: null },
|
||||
{ staffId: 'caller-001', type: 'escalate', tier: 2, ticketType: 'email', resolverId: null, toId: null, fromId: null }
|
||||
];
|
||||
const sa = { find: () => ({ lean: () => Promise.resolve(events) }) };
|
||||
const interaction = makeInteraction();
|
||||
await handleStats(interaction, { StaffAction: sa, now: () => NOW_MS, adminIds: [] });
|
||||
const [[replyArg]] = interaction.reply.mock.calls;
|
||||
const field = replyArg.embeds[0].data.fields.find(f => f.name === 'Escalations');
|
||||
expect(field.value).toContain('Tier 2');
|
||||
expect(field.value).toContain('Tier 3');
|
||||
});
|
||||
|
||||
it('de-escalations field maps tier 1 → "Tier 2" and tier 2 → "Tier 3"', async () => {
|
||||
const events = [
|
||||
{ staffId: 'caller-001', type: 'deescalate', tier: 1, ticketType: 'email', resolverId: null, toId: null, fromId: null },
|
||||
{ staffId: 'caller-001', type: 'deescalate', tier: 2, ticketType: 'email', resolverId: null, toId: null, fromId: null }
|
||||
];
|
||||
const sa = { find: () => ({ lean: () => Promise.resolve(events) }) };
|
||||
const interaction = makeInteraction();
|
||||
await handleStats(interaction, { StaffAction: sa, now: () => NOW_MS, adminIds: [] });
|
||||
const [[replyArg]] = interaction.reply.mock.calls;
|
||||
const field = replyArg.embeds[0].data.fields.find(f => f.name === 'De-escalations');
|
||||
expect(field.value).toContain('Tier 2');
|
||||
expect(field.value).toContain('Tier 3');
|
||||
});
|
||||
|
||||
it('claims-while-escalated sub-breakdown uses tier labels in Claims field', async () => {
|
||||
const events = [
|
||||
{ staffId: 'caller-001', type: 'claim', tier: 1, ticketType: 'email', resolverId: null, toId: null, fromId: null },
|
||||
{ staffId: 'caller-001', type: 'claim', tier: 2, ticketType: 'email', resolverId: null, toId: null, fromId: null }
|
||||
];
|
||||
const sa = { find: () => ({ lean: () => Promise.resolve(events) }) };
|
||||
const interaction = makeInteraction();
|
||||
await handleStats(interaction, { StaffAction: sa, now: () => NOW_MS, adminIds: [] });
|
||||
const [[replyArg]] = interaction.reply.mock.calls;
|
||||
const field = replyArg.embeds[0].data.fields.find(f => f.name === 'Claims');
|
||||
expect(field.value).toContain('Tier 2');
|
||||
expect(field.value).toContain('Tier 3');
|
||||
});
|
||||
|
||||
it('empty stats returns a valid zero-filled embed without throwing', async () => {
|
||||
const interaction = makeInteraction();
|
||||
await handleStats(interaction, deps());
|
||||
const [[replyArg]] = interaction.reply.mock.calls;
|
||||
expect(replyArg.embeds).toHaveLength(1);
|
||||
const claimsField = replyArg.embeds[0].data.fields.find(f => f.name === 'Claims');
|
||||
expect(claimsField.value).toMatch(/^0/);
|
||||
const escalField = replyArg.embeds[0].data.fields.find(f => f.name === 'Escalations');
|
||||
expect(escalField.value).toBe('0');
|
||||
});
|
||||
|
||||
it('Email / Discord split field reflects bySource from shapeStats', async () => {
|
||||
const events = [
|
||||
{ staffId: 'caller-001', type: 'claim', tier: 0, ticketType: 'email', resolverId: null, toId: null, fromId: null },
|
||||
{ staffId: 'caller-001', type: 'claim', tier: 0, ticketType: 'discord', resolverId: null, toId: null, fromId: null }
|
||||
];
|
||||
const sa = { find: () => ({ lean: () => Promise.resolve(events) }) };
|
||||
const interaction = makeInteraction();
|
||||
await handleStats(interaction, { StaffAction: sa, now: () => NOW_MS, adminIds: [] });
|
||||
const [[replyArg]] = interaction.reply.mock.calls;
|
||||
const splitField = replyArg.embeds[0].data.fields.find(f => f.name === 'Email / Discord split');
|
||||
expect(splitField.value).toContain('Email');
|
||||
expect(splitField.value).toContain('Discord');
|
||||
});
|
||||
});
|
||||
716
tests/statsShaping.test.js
Normal file
716
tests/statsShaping.test.js
Normal file
@@ -0,0 +1,716 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { parsePeriod, shapeStats } from '../services/statsShaping.js';
|
||||
|
||||
const MS_PER_DAY = 24 * 60 * 60 * 1000;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// parsePeriod — presets (autocomplete suggestions)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('parsePeriod — presets', () => {
|
||||
it('"7 days" → 7 days', () => {
|
||||
const r = parsePeriod('7 days');
|
||||
expect(r.value).toBe(7);
|
||||
expect(r.unit).toBe('days');
|
||||
expect(r.durationMs).toBe(7 * MS_PER_DAY);
|
||||
expect(r.label).toBe('7 days');
|
||||
});
|
||||
|
||||
it('"30 days" → 30 days', () => {
|
||||
const r = parsePeriod('30 days');
|
||||
expect(r.value).toBe(30);
|
||||
expect(r.unit).toBe('days');
|
||||
expect(r.durationMs).toBe(30 * MS_PER_DAY);
|
||||
expect(r.label).toBe('30 days');
|
||||
});
|
||||
|
||||
it('"3 months" → 3 × 30 days', () => {
|
||||
const r = parsePeriod('3 months');
|
||||
expect(r.value).toBe(3);
|
||||
expect(r.unit).toBe('months');
|
||||
expect(r.durationMs).toBe(3 * 30 * MS_PER_DAY);
|
||||
expect(r.label).toBe('3 months');
|
||||
});
|
||||
|
||||
it('"6 months" → 6 × 30 days', () => {
|
||||
const r = parsePeriod('6 months');
|
||||
expect(r.durationMs).toBe(6 * 30 * MS_PER_DAY);
|
||||
});
|
||||
|
||||
it('"1 year" → 365 days', () => {
|
||||
const r = parsePeriod('1 year');
|
||||
expect(r.value).toBe(1);
|
||||
expect(r.unit).toBe('years');
|
||||
expect(r.durationMs).toBe(365 * MS_PER_DAY);
|
||||
expect(r.label).toBe('1 year');
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// parsePeriod — day unit variants
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('parsePeriod — day variants', () => {
|
||||
it('<n>d', () => {
|
||||
const r = parsePeriod('14d');
|
||||
expect(r.value).toBe(14);
|
||||
expect(r.unit).toBe('days');
|
||||
expect(r.durationMs).toBe(14 * MS_PER_DAY);
|
||||
});
|
||||
|
||||
it('<n>day (singular, no space)', () => {
|
||||
const r = parsePeriod('1day');
|
||||
expect(r.value).toBe(1);
|
||||
expect(r.unit).toBe('days');
|
||||
expect(r.label).toBe('1 day');
|
||||
});
|
||||
|
||||
it('<n> day (singular, with space)', () => {
|
||||
const r = parsePeriod('1 day');
|
||||
expect(r.value).toBe(1);
|
||||
expect(r.label).toBe('1 day');
|
||||
});
|
||||
|
||||
it('<n> days', () => {
|
||||
const r = parsePeriod('10 days');
|
||||
expect(r.durationMs).toBe(10 * MS_PER_DAY);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// parsePeriod — week unit variants
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('parsePeriod — week variants', () => {
|
||||
it('<n>w', () => {
|
||||
const r = parsePeriod('2w');
|
||||
expect(r.value).toBe(2);
|
||||
expect(r.unit).toBe('weeks');
|
||||
expect(r.durationMs).toBe(2 * 7 * MS_PER_DAY);
|
||||
expect(r.label).toBe('2 weeks');
|
||||
});
|
||||
|
||||
it('<n> week (singular)', () => {
|
||||
const r = parsePeriod('1 week');
|
||||
expect(r.value).toBe(1);
|
||||
expect(r.label).toBe('1 week');
|
||||
});
|
||||
|
||||
it('<n>weeks (no space)', () => {
|
||||
const r = parsePeriod('4weeks');
|
||||
expect(r.value).toBe(4);
|
||||
expect(r.unit).toBe('weeks');
|
||||
});
|
||||
|
||||
it('<n> weeks', () => {
|
||||
const r = parsePeriod('4 weeks');
|
||||
expect(r.durationMs).toBe(4 * 7 * MS_PER_DAY);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// parsePeriod — month unit variants
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('parsePeriod — month variants', () => {
|
||||
it('<n>m', () => {
|
||||
const r = parsePeriod('3m');
|
||||
expect(r.unit).toBe('months');
|
||||
expect(r.durationMs).toBe(3 * 30 * MS_PER_DAY);
|
||||
});
|
||||
|
||||
it('<n>mo', () => {
|
||||
const r = parsePeriod('6mo');
|
||||
expect(r.unit).toBe('months');
|
||||
expect(r.durationMs).toBe(6 * 30 * MS_PER_DAY);
|
||||
});
|
||||
|
||||
it('<n> month (singular)', () => {
|
||||
const r = parsePeriod('1 month');
|
||||
expect(r.value).toBe(1);
|
||||
expect(r.label).toBe('1 month');
|
||||
});
|
||||
|
||||
it('<n> months', () => {
|
||||
const r = parsePeriod('12 months');
|
||||
expect(r.value).toBe(12);
|
||||
expect(r.unit).toBe('months');
|
||||
expect(r.durationMs).toBe(12 * 30 * MS_PER_DAY);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// parsePeriod — year unit variants
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('parsePeriod — year variants', () => {
|
||||
it('<n>y', () => {
|
||||
const r = parsePeriod('1y');
|
||||
expect(r.unit).toBe('years');
|
||||
expect(r.durationMs).toBe(365 * MS_PER_DAY);
|
||||
});
|
||||
|
||||
it('<n> year (singular)', () => {
|
||||
const r = parsePeriod('1 year');
|
||||
expect(r.unit).toBe('years');
|
||||
expect(r.label).toBe('1 year');
|
||||
});
|
||||
|
||||
it('<n> years', () => {
|
||||
const r = parsePeriod('2 years');
|
||||
expect(r.value).toBe(2);
|
||||
expect(r.label).toBe('2 years');
|
||||
expect(r.durationMs).toBe(2 * 365 * MS_PER_DAY);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// parsePeriod — bare number = days
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('parsePeriod — bare number defaults to days', () => {
|
||||
it('"30" → 30 days', () => {
|
||||
const r = parsePeriod('30');
|
||||
expect(r.value).toBe(30);
|
||||
expect(r.unit).toBe('days');
|
||||
expect(r.durationMs).toBe(30 * MS_PER_DAY);
|
||||
});
|
||||
|
||||
it('"7" → 7 days', () => {
|
||||
const r = parsePeriod('7');
|
||||
expect(r.unit).toBe('days');
|
||||
expect(r.durationMs).toBe(7 * MS_PER_DAY);
|
||||
});
|
||||
|
||||
it('"365" → 365 days (not 1 year)', () => {
|
||||
const r = parsePeriod('365');
|
||||
expect(r.unit).toBe('days');
|
||||
expect(r.value).toBe(365);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// parsePeriod — case & whitespace tolerance
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('parsePeriod — case and whitespace tolerance', () => {
|
||||
it('uppercase "7 DAYS"', () => {
|
||||
const r = parsePeriod('7 DAYS');
|
||||
expect(r.value).toBe(7);
|
||||
expect(r.unit).toBe('days');
|
||||
});
|
||||
|
||||
it('mixed case "3 Months"', () => {
|
||||
const r = parsePeriod('3 Months');
|
||||
expect(r.unit).toBe('months');
|
||||
});
|
||||
|
||||
it('leading and trailing whitespace " 30 days "', () => {
|
||||
const r = parsePeriod(' 30 days ');
|
||||
expect(r.value).toBe(30);
|
||||
expect(r.unit).toBe('days');
|
||||
});
|
||||
|
||||
it('multiple internal spaces "7 days"', () => {
|
||||
const r = parsePeriod('7 days');
|
||||
expect(r.value).toBe(7);
|
||||
expect(r.unit).toBe('days');
|
||||
});
|
||||
|
||||
it('no space between number and unit "30days"', () => {
|
||||
const r = parsePeriod('30days');
|
||||
expect(r.value).toBe(30);
|
||||
expect(r.unit).toBe('days');
|
||||
});
|
||||
|
||||
it('"1YEAR" (uppercase, no space)', () => {
|
||||
const r = parsePeriod('1YEAR');
|
||||
expect(r.unit).toBe('years');
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// parsePeriod — unparseable → 30-day default
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('parsePeriod — unparseable inputs → 30-day default', () => {
|
||||
const expectDefault = r => {
|
||||
expect(r.value).toBe(30);
|
||||
expect(r.unit).toBe('days');
|
||||
expect(r.durationMs).toBe(30 * MS_PER_DAY);
|
||||
expect(r.label).toBe('30 days');
|
||||
};
|
||||
|
||||
it('null → default', () => expectDefault(parsePeriod(null)));
|
||||
it('undefined → default', () => expectDefault(parsePeriod(undefined)));
|
||||
it('empty string → default', () => expectDefault(parsePeriod('')));
|
||||
it('whitespace only → default', () => expectDefault(parsePeriod(' ')));
|
||||
it('letters only → default', () => expectDefault(parsePeriod('abc')));
|
||||
it('natural language → default', () => expectDefault(parsePeriod('last month')));
|
||||
it('unknown unit "5x" → default', () => expectDefault(parsePeriod('5x')));
|
||||
it('"0" → default (zero is nonsensical)', () => expectDefault(parsePeriod('0')));
|
||||
it('"0d" → default', () => expectDefault(parsePeriod('0d')));
|
||||
it('negative-like "-5d" → default (not a digit-start)', () => expectDefault(parsePeriod('-5d')));
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// parsePeriod — return shape invariant
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('parsePeriod — return shape', () => {
|
||||
it('always returns { durationMs, value, unit, label }', () => {
|
||||
for (const input of ['7d', '2w', '3m', '6mo', '1y', '30', null, 'junk']) {
|
||||
const r = parsePeriod(input);
|
||||
expect(typeof r.durationMs).toBe('number');
|
||||
expect(typeof r.value).toBe('number');
|
||||
expect(typeof r.unit).toBe('string');
|
||||
expect(typeof r.label).toBe('string');
|
||||
}
|
||||
});
|
||||
|
||||
it('returns a fresh object each call (not the same frozen reference)', () => {
|
||||
const a = parsePeriod(null);
|
||||
const b = parsePeriod(undefined);
|
||||
expect(a).not.toBe(b);
|
||||
});
|
||||
});
|
||||
|
||||
// ===========================================================================
|
||||
// shapeStats — fixtures
|
||||
// ===========================================================================
|
||||
|
||||
const MEMBER = 'member-001';
|
||||
const OTHER = 'other-002';
|
||||
|
||||
function event(overrides) {
|
||||
return {
|
||||
staffId: OTHER,
|
||||
type: 'claim',
|
||||
tier: 0,
|
||||
ticketType: 'email',
|
||||
wasClaimed: null,
|
||||
resolverId: null,
|
||||
fromId: null,
|
||||
toId: null,
|
||||
...overrides
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// shapeStats — claims
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('shapeStats — claims', () => {
|
||||
it('counts claim events where staffId===member', () => {
|
||||
const events = [
|
||||
event({ type: 'claim', staffId: MEMBER }),
|
||||
event({ type: 'claim', staffId: MEMBER }),
|
||||
event({ type: 'claim', staffId: OTHER }),
|
||||
];
|
||||
expect(shapeStats(events, MEMBER, 'all').claims).toBe(2);
|
||||
});
|
||||
|
||||
it('does not count claims by other staff', () => {
|
||||
expect(shapeStats([event({ type: 'claim', staffId: OTHER })], MEMBER, 'all').claims).toBe(0);
|
||||
});
|
||||
|
||||
it('non-claim event types do not increment claims', () => {
|
||||
const events = [
|
||||
event({ type: 'close', staffId: MEMBER }),
|
||||
event({ type: 'escalate', staffId: MEMBER }),
|
||||
];
|
||||
expect(shapeStats(events, MEMBER, 'all').claims).toBe(0);
|
||||
});
|
||||
|
||||
it('claimsWhileEscalated groups by numeric tier for tier > 0', () => {
|
||||
const events = [
|
||||
event({ type: 'claim', staffId: MEMBER, tier: 0 }),
|
||||
event({ type: 'claim', staffId: MEMBER, tier: 1 }),
|
||||
event({ type: 'claim', staffId: MEMBER, tier: 1 }),
|
||||
event({ type: 'claim', staffId: MEMBER, tier: 2 }),
|
||||
];
|
||||
const r = shapeStats(events, MEMBER, 'all');
|
||||
expect(r.claims).toBe(4);
|
||||
expect(r.claimsWhileEscalated).toEqual({ 1: 2, 2: 1 });
|
||||
});
|
||||
|
||||
it('tier=0 claims are NOT included in claimsWhileEscalated', () => {
|
||||
const events = [event({ type: 'claim', staffId: MEMBER, tier: 0 })];
|
||||
expect(shapeStats(events, MEMBER, 'all').claimsWhileEscalated).toEqual({});
|
||||
});
|
||||
|
||||
it('claimsWhileEscalated only includes the member\'s own claims', () => {
|
||||
const events = [event({ type: 'claim', staffId: OTHER, tier: 1 })];
|
||||
expect(shapeStats(events, MEMBER, 'all').claimsWhileEscalated).toEqual({});
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// shapeStats — closes
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('shapeStats — closes', () => {
|
||||
it('counts close events where staffId===member', () => {
|
||||
const events = [
|
||||
event({ type: 'close', staffId: MEMBER }),
|
||||
event({ type: 'close', staffId: MEMBER }),
|
||||
event({ type: 'close', staffId: OTHER }),
|
||||
event({ type: 'close', staffId: 'system' }),
|
||||
];
|
||||
expect(shapeStats(events, MEMBER, 'all').closes).toBe(2);
|
||||
});
|
||||
|
||||
it('system closes do not count toward member closes', () => {
|
||||
const events = [event({ type: 'close', staffId: 'system', resolverId: MEMBER })];
|
||||
expect(shapeStats(events, MEMBER, 'all').closes).toBe(0);
|
||||
});
|
||||
|
||||
it('unclaimedAtClose counts member closes where wasClaimed===false', () => {
|
||||
const events = [
|
||||
event({ type: 'close', staffId: MEMBER, wasClaimed: false }),
|
||||
event({ type: 'close', staffId: MEMBER, wasClaimed: true }),
|
||||
event({ type: 'close', staffId: MEMBER, wasClaimed: false }),
|
||||
event({ type: 'close', staffId: OTHER, wasClaimed: false }),
|
||||
];
|
||||
expect(shapeStats(events, MEMBER, 'all').unclaimedAtClose).toBe(2);
|
||||
});
|
||||
|
||||
it('wasClaimed===true does NOT count as unclaimed-at-close', () => {
|
||||
const events = [event({ type: 'close', staffId: MEMBER, wasClaimed: true })];
|
||||
expect(shapeStats(events, MEMBER, 'all').unclaimedAtClose).toBe(0);
|
||||
});
|
||||
|
||||
it('wasClaimed===null does NOT count as unclaimed-at-close', () => {
|
||||
const events = [event({ type: 'close', staffId: MEMBER, wasClaimed: null })];
|
||||
expect(shapeStats(events, MEMBER, 'all').unclaimedAtClose).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// shapeStats — resolved (credit to claimer via resolverId)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('shapeStats — resolved', () => {
|
||||
it('counts close events where resolverId===member', () => {
|
||||
const events = [
|
||||
event({ type: 'close', staffId: OTHER, resolverId: MEMBER }),
|
||||
event({ type: 'close', staffId: OTHER, resolverId: OTHER }),
|
||||
event({ type: 'close', staffId: MEMBER, resolverId: MEMBER }),
|
||||
];
|
||||
const r = shapeStats(events, MEMBER, 'all');
|
||||
expect(r.resolved).toBe(2);
|
||||
expect(r.closes).toBe(1);
|
||||
});
|
||||
|
||||
it('resolved is distinct from closes — different field keys', () => {
|
||||
const events = [
|
||||
event({ type: 'close', staffId: OTHER, resolverId: MEMBER }),
|
||||
];
|
||||
const r = shapeStats(events, MEMBER, 'all');
|
||||
expect(r.resolved).toBe(1);
|
||||
expect(r.closes).toBe(0);
|
||||
});
|
||||
|
||||
it('a self-close-and-resolve increments both closes and resolved', () => {
|
||||
const events = [
|
||||
event({ type: 'close', staffId: MEMBER, resolverId: MEMBER, wasClaimed: true })
|
||||
];
|
||||
const r = shapeStats(events, MEMBER, 'all');
|
||||
expect(r.closes).toBe(1);
|
||||
expect(r.resolved).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// shapeStats — escalations / de-escalations
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('shapeStats — escalations', () => {
|
||||
it('groups escalate events by numeric tier', () => {
|
||||
const events = [
|
||||
event({ type: 'escalate', staffId: MEMBER, tier: 1 }),
|
||||
event({ type: 'escalate', staffId: MEMBER, tier: 1 }),
|
||||
event({ type: 'escalate', staffId: MEMBER, tier: 2 }),
|
||||
];
|
||||
expect(shapeStats(events, MEMBER, 'all').escalations).toEqual({ 1: 2, 2: 1 });
|
||||
});
|
||||
|
||||
it('ignores escalations by other staff', () => {
|
||||
expect(shapeStats([event({ type: 'escalate', staffId: OTHER, tier: 1 })], MEMBER, 'all').escalations).toEqual({});
|
||||
});
|
||||
|
||||
it('escalations is empty when no escalate events', () => {
|
||||
expect(shapeStats([event({ type: 'claim', staffId: MEMBER })], MEMBER, 'all').escalations).toEqual({});
|
||||
});
|
||||
});
|
||||
|
||||
describe('shapeStats — de-escalations', () => {
|
||||
it('groups deescalate events by numeric tier', () => {
|
||||
const events = [
|
||||
event({ type: 'deescalate', staffId: MEMBER, tier: 1 }),
|
||||
event({ type: 'deescalate', staffId: MEMBER, tier: 2 }),
|
||||
event({ type: 'deescalate', staffId: MEMBER, tier: 2 }),
|
||||
];
|
||||
expect(shapeStats(events, MEMBER, 'all').deescalations).toEqual({ 1: 1, 2: 2 });
|
||||
});
|
||||
|
||||
it('ignores deescalations by other staff', () => {
|
||||
expect(shapeStats([event({ type: 'deescalate', staffId: OTHER, tier: 1 })], MEMBER, 'all').deescalations).toEqual({});
|
||||
});
|
||||
|
||||
it('escalations and deescalations are counted independently', () => {
|
||||
const events = [
|
||||
event({ type: 'escalate', staffId: MEMBER, tier: 1 }),
|
||||
event({ type: 'deescalate', staffId: MEMBER, tier: 1 }),
|
||||
];
|
||||
const r = shapeStats(events, MEMBER, 'all');
|
||||
expect(r.escalations).toEqual({ 1: 1 });
|
||||
expect(r.deescalations).toEqual({ 1: 1 });
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// shapeStats — transfers in vs out
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('shapeStats — transfers', () => {
|
||||
it('transfersIn counts transfer events where toId===member', () => {
|
||||
const events = [
|
||||
event({ type: 'transfer', staffId: OTHER, toId: MEMBER }),
|
||||
event({ type: 'transfer', staffId: OTHER, toId: OTHER }),
|
||||
];
|
||||
expect(shapeStats(events, MEMBER, 'all').transfersIn).toBe(1);
|
||||
});
|
||||
|
||||
it('transfersOut counts transfer events where staffId===member (initiator)', () => {
|
||||
const events = [
|
||||
event({ type: 'transfer', staffId: MEMBER, toId: OTHER }),
|
||||
event({ type: 'transfer', staffId: OTHER, toId: OTHER }),
|
||||
];
|
||||
expect(shapeStats(events, MEMBER, 'all').transfersOut).toBe(1);
|
||||
});
|
||||
|
||||
it('single transfer counts out for sender, in for receiver', () => {
|
||||
const events = [event({ type: 'transfer', staffId: MEMBER, toId: OTHER })];
|
||||
const rMember = shapeStats(events, MEMBER, 'all');
|
||||
const rOther = shapeStats(events, OTHER, 'all');
|
||||
expect(rMember.transfersOut).toBe(1);
|
||||
expect(rMember.transfersIn).toBe(0);
|
||||
expect(rOther.transfersIn).toBe(1);
|
||||
expect(rOther.transfersOut).toBe(0);
|
||||
});
|
||||
|
||||
it('transfersIn and transfersOut are counted on a single event if member is both', () => {
|
||||
// Degenerate: staffId===toId===member. Phase 5b prevents this in practice,
|
||||
// but the shaper is pure and should still count both dimensions.
|
||||
const events = [event({ type: 'transfer', staffId: MEMBER, toId: MEMBER })];
|
||||
const r = shapeStats(events, MEMBER, 'all');
|
||||
expect(r.transfersOut).toBe(1);
|
||||
expect(r.transfersIn).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// shapeStats — reopens (via resolverId, not staffId)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('shapeStats — reopens', () => {
|
||||
it('counts reopen events where resolverId===member', () => {
|
||||
const events = [
|
||||
event({ type: 'reopen', staffId: 'system', resolverId: MEMBER }),
|
||||
event({ type: 'reopen', staffId: 'system', resolverId: OTHER }),
|
||||
];
|
||||
expect(shapeStats(events, MEMBER, 'all').reopens).toBe(1);
|
||||
});
|
||||
|
||||
it('staffId on reopen is typically "system" — does not drive the reopen count', () => {
|
||||
const events = [event({ type: 'reopen', staffId: MEMBER, resolverId: OTHER })];
|
||||
expect(shapeStats(events, MEMBER, 'all').reopens).toBe(0);
|
||||
});
|
||||
|
||||
it('null resolverId does not count', () => {
|
||||
const events = [event({ type: 'reopen', staffId: 'system', resolverId: null })];
|
||||
expect(shapeStats(events, MEMBER, 'all').reopens).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// shapeStats — source filter
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('shapeStats — source filter', () => {
|
||||
it('"all" includes both email and discord events', () => {
|
||||
const events = [
|
||||
event({ type: 'claim', staffId: MEMBER, ticketType: 'email' }),
|
||||
event({ type: 'claim', staffId: MEMBER, ticketType: 'discord' }),
|
||||
];
|
||||
expect(shapeStats(events, MEMBER, 'all').claims).toBe(2);
|
||||
});
|
||||
|
||||
it('"email" includes only email events', () => {
|
||||
const events = [
|
||||
event({ type: 'claim', staffId: MEMBER, ticketType: 'email' }),
|
||||
event({ type: 'claim', staffId: MEMBER, ticketType: 'discord' }),
|
||||
];
|
||||
expect(shapeStats(events, MEMBER, 'email').claims).toBe(1);
|
||||
});
|
||||
|
||||
it('"discord" includes only discord events', () => {
|
||||
const events = [
|
||||
event({ type: 'claim', staffId: MEMBER, ticketType: 'email' }),
|
||||
event({ type: 'claim', staffId: MEMBER, ticketType: 'discord' }),
|
||||
];
|
||||
expect(shapeStats(events, MEMBER, 'discord').claims).toBe(1);
|
||||
});
|
||||
|
||||
it('source filter applies before all metric calculations', () => {
|
||||
const events = [
|
||||
event({ type: 'close', staffId: MEMBER, resolverId: MEMBER, wasClaimed: false, ticketType: 'email' }),
|
||||
event({ type: 'close', staffId: MEMBER, resolverId: MEMBER, wasClaimed: true, ticketType: 'discord' }),
|
||||
];
|
||||
const r = shapeStats(events, MEMBER, 'email');
|
||||
expect(r.closes).toBe(1);
|
||||
expect(r.resolved).toBe(1);
|
||||
expect(r.unclaimedAtClose).toBe(1);
|
||||
});
|
||||
|
||||
it('undefined source defaults to "all"', () => {
|
||||
const events = [
|
||||
event({ type: 'claim', staffId: MEMBER, ticketType: 'email' }),
|
||||
event({ type: 'claim', staffId: MEMBER, ticketType: 'discord' }),
|
||||
];
|
||||
expect(shapeStats(events, MEMBER).claims).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// shapeStats — bySource breakdown
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('shapeStats — bySource breakdown', () => {
|
||||
it('splits claims by email/discord', () => {
|
||||
const events = [
|
||||
event({ type: 'claim', staffId: MEMBER, ticketType: 'email' }),
|
||||
event({ type: 'claim', staffId: MEMBER, ticketType: 'discord' }),
|
||||
event({ type: 'claim', staffId: MEMBER, ticketType: 'discord' }),
|
||||
];
|
||||
const r = shapeStats(events, MEMBER, 'all');
|
||||
expect(r.bySource.email.claims).toBe(1);
|
||||
expect(r.bySource.discord.claims).toBe(2);
|
||||
});
|
||||
|
||||
it('splits closes by email/discord', () => {
|
||||
const events = [
|
||||
event({ type: 'close', staffId: MEMBER, ticketType: 'email' }),
|
||||
event({ type: 'close', staffId: MEMBER, ticketType: 'discord' }),
|
||||
];
|
||||
const r = shapeStats(events, MEMBER, 'all');
|
||||
expect(r.bySource.email.closes).toBe(1);
|
||||
expect(r.bySource.discord.closes).toBe(1);
|
||||
});
|
||||
|
||||
it('splits resolved by email/discord (using resolverId key)', () => {
|
||||
const events = [
|
||||
event({ type: 'close', staffId: OTHER, resolverId: MEMBER, ticketType: 'email' }),
|
||||
event({ type: 'close', staffId: OTHER, resolverId: MEMBER, ticketType: 'discord' }),
|
||||
];
|
||||
const r = shapeStats(events, MEMBER, 'all');
|
||||
expect(r.bySource.email.resolved).toBe(1);
|
||||
expect(r.bySource.discord.resolved).toBe(1);
|
||||
});
|
||||
|
||||
it('events with unknown ticketType are bucketed as email', () => {
|
||||
const events = [event({ type: 'claim', staffId: MEMBER, ticketType: undefined })];
|
||||
const r = shapeStats(events, MEMBER, 'all');
|
||||
expect(r.bySource.email.claims).toBe(1);
|
||||
expect(r.bySource.discord.claims).toBe(0);
|
||||
});
|
||||
|
||||
it('bySource totals match headline counts', () => {
|
||||
const events = [
|
||||
event({ type: 'claim', staffId: MEMBER, ticketType: 'email' }),
|
||||
event({ type: 'claim', staffId: MEMBER, ticketType: 'discord' }),
|
||||
event({ type: 'close', staffId: MEMBER, resolverId: MEMBER, wasClaimed: true, ticketType: 'email' }),
|
||||
event({ type: 'close', staffId: OTHER, resolverId: MEMBER, ticketType: 'discord' }),
|
||||
];
|
||||
const r = shapeStats(events, MEMBER, 'all');
|
||||
expect(r.bySource.email.claims + r.bySource.discord.claims).toBe(r.claims);
|
||||
expect(r.bySource.email.closes + r.bySource.discord.closes).toBe(r.closes);
|
||||
expect(r.bySource.email.resolved + r.bySource.discord.resolved).toBe(r.resolved);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// shapeStats — edge cases
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('shapeStats — edge cases', () => {
|
||||
it('empty events array returns zero counts', () => {
|
||||
const r = shapeStats([], MEMBER, 'all');
|
||||
expect(r.claims).toBe(0);
|
||||
expect(r.closes).toBe(0);
|
||||
expect(r.resolved).toBe(0);
|
||||
expect(r.unclaimedAtClose).toBe(0);
|
||||
expect(r.transfersIn).toBe(0);
|
||||
expect(r.transfersOut).toBe(0);
|
||||
expect(r.reopens).toBe(0);
|
||||
expect(r.claimsWhileEscalated).toEqual({});
|
||||
expect(r.escalations).toEqual({});
|
||||
expect(r.deescalations).toEqual({});
|
||||
expect(r.bySource.email.claims).toBe(0);
|
||||
expect(r.bySource.discord.claims).toBe(0);
|
||||
});
|
||||
|
||||
it('null events array is treated as empty', () => {
|
||||
const r = shapeStats(null, MEMBER, 'all');
|
||||
expect(r.claims).toBe(0);
|
||||
});
|
||||
|
||||
it('events from other members are ignored for the requested member', () => {
|
||||
const events = [
|
||||
event({ type: 'claim', staffId: OTHER }),
|
||||
event({ type: 'close', staffId: OTHER, resolverId: OTHER }),
|
||||
event({ type: 'transfer', staffId: OTHER, toId: OTHER }),
|
||||
event({ type: 'reopen', staffId: 'system', resolverId: OTHER }),
|
||||
event({ type: 'escalate', staffId: OTHER, tier: 1 }),
|
||||
event({ type: 'deescalate',staffId: OTHER, tier: 1 }),
|
||||
];
|
||||
const r = shapeStats(events, MEMBER, 'all');
|
||||
expect(r.claims).toBe(0);
|
||||
expect(r.closes).toBe(0);
|
||||
expect(r.resolved).toBe(0);
|
||||
expect(r.transfersIn).toBe(0);
|
||||
expect(r.transfersOut).toBe(0);
|
||||
expect(r.reopens).toBe(0);
|
||||
expect(r.escalations).toEqual({});
|
||||
expect(r.deescalations).toEqual({});
|
||||
});
|
||||
|
||||
it('handles member appearing in multiple roles across events', () => {
|
||||
const events = [
|
||||
event({ type: 'claim', staffId: MEMBER, tier: 0, ticketType: 'email' }),
|
||||
event({ type: 'close', staffId: MEMBER, resolverId: OTHER, wasClaimed: false, ticketType: 'email' }),
|
||||
event({ type: 'close', staffId: OTHER, resolverId: MEMBER, wasClaimed: true, ticketType: 'discord' }),
|
||||
event({ type: 'transfer', staffId: OTHER, toId: MEMBER }),
|
||||
event({ type: 'reopen', staffId: 'system', resolverId: MEMBER }),
|
||||
event({ type: 'escalate', staffId: MEMBER, tier: 1 }),
|
||||
];
|
||||
const r = shapeStats(events, MEMBER, 'all');
|
||||
expect(r.claims).toBe(1);
|
||||
expect(r.closes).toBe(1);
|
||||
expect(r.unclaimedAtClose).toBe(1);
|
||||
expect(r.resolved).toBe(1);
|
||||
expect(r.transfersIn).toBe(1);
|
||||
expect(r.transfersOut).toBe(0);
|
||||
expect(r.reopens).toBe(1);
|
||||
expect(r.escalations).toEqual({ 1: 1 });
|
||||
});
|
||||
|
||||
it('events matching no member fields contribute nothing', () => {
|
||||
const events = [
|
||||
event({ type: 'response', staffId: MEMBER }), // 'response' type has no shaper rule
|
||||
];
|
||||
const r = shapeStats(events, MEMBER, 'all');
|
||||
expect(r.claims + r.closes + r.resolved + r.transfersIn + r.transfersOut + r.reopens).toBe(0);
|
||||
});
|
||||
});
|
||||
166
tests/transferEvents.test.js
Normal file
166
tests/transferEvents.test.js
Normal file
@@ -0,0 +1,166 @@
|
||||
/**
|
||||
* Phase 5b — transfer 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.
|
||||
*
|
||||
* Tests applyTransfer directly (same pattern as testing applyClaim directly).
|
||||
*
|
||||
* Covers:
|
||||
* (a) transfer to different member — one 'transfer' event with fromId/toId
|
||||
* (b) transfer to current claimer — fromId === toId → no event
|
||||
* (c) fromId/toId/staffId/guildId correctness
|
||||
*/
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
|
||||
import { applyTransfer } from '../handlers/commands/index.js';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Shared factories
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function makeTicket(overrides = {}) {
|
||||
return {
|
||||
gmailThreadId: 'discord-test-001',
|
||||
escalationTier: 0,
|
||||
escalated: false,
|
||||
claimerId: 'prev-claimer-001',
|
||||
claimedBy: 'PrevClaimerName',
|
||||
priority: 'normal',
|
||||
game: 'TestGame',
|
||||
senderEmail: 'user@example.com',
|
||||
creatorId: 'creator-001',
|
||||
ticketNumber: 42,
|
||||
...overrides
|
||||
};
|
||||
}
|
||||
|
||||
function makeGuildMember(userId = 'new-claimer-002') {
|
||||
return {
|
||||
id: userId,
|
||||
displayName: 'New Claimer',
|
||||
user: {
|
||||
id: userId,
|
||||
username: 'newclaimer',
|
||||
tag: 'newclaimer#0002',
|
||||
toString: () => `<@${userId}>`
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
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) Transfer to a different member — one event
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('applyTransfer — different member emits one event', () => {
|
||||
it('emits exactly one "transfer" event', async () => {
|
||||
const ticket = makeTicket({ claimerId: 'prev-claimer-001' });
|
||||
const guildMember = makeGuildMember('new-claimer-002');
|
||||
const mockUpdateOne = vi.fn().mockResolvedValue({ modifiedCount: 1 });
|
||||
const mockRecord = vi.fn();
|
||||
const interaction = makeInteraction('staff-001');
|
||||
|
||||
await applyTransfer(interaction, ticket, guildMember, 'reason', { updateOne: mockUpdateOne }, mockRecord);
|
||||
|
||||
expect(mockRecord).toHaveBeenCalledTimes(1);
|
||||
const [staffId, type] = mockRecord.mock.calls[0];
|
||||
expect(staffId).toBe('staff-001');
|
||||
expect(type).toBe('transfer');
|
||||
});
|
||||
|
||||
it('passes correct fromId, toId, guildId, and ticket', async () => {
|
||||
const ticket = makeTicket({ claimerId: 'prev-claimer-001' });
|
||||
const guildMember = makeGuildMember('new-claimer-002');
|
||||
const mockUpdateOne = vi.fn().mockResolvedValue({ modifiedCount: 1 });
|
||||
const mockRecord = vi.fn();
|
||||
const interaction = makeInteraction('staff-001');
|
||||
|
||||
await applyTransfer(interaction, ticket, guildMember, 'reason', { updateOne: mockUpdateOne }, mockRecord);
|
||||
|
||||
const [, , payload] = mockRecord.mock.calls[0];
|
||||
expect(payload.fromId).toBe('prev-claimer-001');
|
||||
expect(payload.toId).toBe('new-claimer-002');
|
||||
expect(payload.guildId).toBe('guild-001');
|
||||
expect(payload.ticket).toBe(ticket);
|
||||
});
|
||||
|
||||
it('fromId is the pre-write claimerId (captured before the DB write)', async () => {
|
||||
const ticket = makeTicket({ claimerId: 'original-claimer' });
|
||||
const guildMember = makeGuildMember('new-staff-003');
|
||||
const mockUpdateOne = vi.fn().mockResolvedValue({ modifiedCount: 1 });
|
||||
const mockRecord = vi.fn();
|
||||
const interaction = makeInteraction('staff-001');
|
||||
|
||||
await applyTransfer(interaction, ticket, guildMember, 'reason', { updateOne: mockUpdateOne }, mockRecord);
|
||||
|
||||
const [, , payload] = mockRecord.mock.calls[0];
|
||||
expect(payload.fromId).toBe('original-claimer');
|
||||
expect(payload.toId).toBe('new-staff-003');
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// (b) Transfer to the current claimer — no event
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('applyTransfer — transfer to current claimer emits no event', () => {
|
||||
it('emits no event when fromId === toId (transferring to existing claimer)', async () => {
|
||||
const ticket = makeTicket({ claimerId: 'same-claimer-001' });
|
||||
const guildMember = makeGuildMember('same-claimer-001');
|
||||
const mockUpdateOne = vi.fn().mockResolvedValue({ modifiedCount: 1 });
|
||||
const mockRecord = vi.fn();
|
||||
const interaction = makeInteraction('staff-001');
|
||||
|
||||
await applyTransfer(interaction, ticket, guildMember, 'reason', { updateOne: mockUpdateOne }, mockRecord);
|
||||
|
||||
expect(mockRecord).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('emits one event when the ticket is unclaimed (fromId null) and toId is non-null — null !== toId satisfies the fromId !== toId gate', async () => {
|
||||
// null !== 'new-claimer' so this IS a real transfer — event IS emitted
|
||||
const ticket = makeTicket({ claimerId: null });
|
||||
const guildMember = makeGuildMember('new-claimer-002');
|
||||
const mockUpdateOne = vi.fn().mockResolvedValue({ modifiedCount: 1 });
|
||||
const mockRecord = vi.fn();
|
||||
const interaction = makeInteraction('staff-001');
|
||||
|
||||
await applyTransfer(interaction, ticket, guildMember, 'reason', { updateOne: mockUpdateOne }, mockRecord);
|
||||
|
||||
// null !== 'new-claimer-002' → event IS emitted
|
||||
expect(mockRecord).toHaveBeenCalledTimes(1);
|
||||
const [, , payload] = mockRecord.mock.calls[0];
|
||||
expect(payload.fromId).toBeNull();
|
||||
expect(payload.toId).toBe('new-claimer-002');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user