QUAL-004 handlers/messages.js — DM-on-customer-reply now reads guild.members.cache.get(claimerId) first and only falls back to guild.members.fetch on cache miss. Avoids a REST round-trip per non-staff reply on busy tickets. GuildMembers intent already keeps the cache warm. QUAL-005 handlers/buttons.js (runFinalClose) + handlers/commands/close.js (finalizeForceClose) — close paths now $unset welcomeMessageId alongside the status: 'closed' write. Stops a stale message-ID from carrying into a future reopen on the same Gmail thread, where escalation's "edit welcome buttons" path would silently fail trying to fetch a message in a deleted channel. QUAL-007 services/configPersistence.js — writeEnvFile mismatch error now includes the missing/extra key sets, not just count vs count. Saves the operator from guessing which key vanished after a partial write. QUAL-008 utils.js stripEmailQuotes — replaced order-dependent first-match loop with an earliest-match-across-all-markers scan. The previous code could truncate at a late "_____" signature underline even when an earlier "On X wrote:" reply header was the real cutoff. New test in tests/utils.test.js exercises the dual-marker case. QUAL-010 broccolini-discord.js — moved `let httpServer / internalServer / appReady` declarations from after the ready handler to before it. Same runtime behavior (module-load completes before ready fires asynchronously), but the read order now matches the assignment order. SEC-002 routes/internalApi.js — POST /restart now goes through a tighter 2/min limiter on top of the shared 10/min internalLimiter. Defense in depth in case INTERNAL_API_SECRET ever leaks; an attacker with the secret can no longer crash-loop the container. Skipped: QUAL-009 (re-checked the regex; ^\s*\n* → \n is already idempotent — the audit finding was incorrect). vitest run: 88/88 (one new test for QUAL-008).
242 lines
7.6 KiB
JavaScript
242 lines
7.6 KiB
JavaScript
import { describe, it, expect } from 'vitest';
|
|
import {
|
|
stripEmailQuotes,
|
|
stripMobileFooter,
|
|
extractRawEmail,
|
|
escapeHtml,
|
|
sanitizeEmbedText,
|
|
truncateEmbedDescription,
|
|
replaceVariables,
|
|
getPriorityEmoji,
|
|
safeEqual,
|
|
isStaff
|
|
} from '../utils.js';
|
|
|
|
describe('stripEmailQuotes', () => {
|
|
it('strips "On X wrote:" reply quote', () => {
|
|
const input = 'My reply.\nOn Mon, May 5, 2025 at 1:00 PM Bob <bob@x.com> wrote:\n> previous message';
|
|
expect(stripEmailQuotes(input)).toBe('My reply.');
|
|
});
|
|
|
|
it('strips "From: …" reply header block', () => {
|
|
const input = 'New reply text.\nFrom: Bob <bob@x.com>\nSent: Monday\nSubject: Re: foo';
|
|
expect(stripEmailQuotes(input)).toBe('New reply text.');
|
|
});
|
|
|
|
it('strips "_____" signature underline', () => {
|
|
const input = 'My message.\n_____\nold thread content';
|
|
expect(stripEmailQuotes(input)).toBe('My message.');
|
|
});
|
|
|
|
it('returns empty string for empty input', () => {
|
|
expect(stripEmailQuotes('')).toBe('');
|
|
});
|
|
|
|
it('trims whitespace when no marker is found', () => {
|
|
expect(stripEmailQuotes(' hello ')).toBe('hello');
|
|
});
|
|
|
|
it('keeps body intact when "On" appears mid-text without "wrote:"', () => {
|
|
expect(stripEmailQuotes('I clicked On the button.')).toBe('I clicked On the button.');
|
|
});
|
|
|
|
it('normalizes CRLF before scanning', () => {
|
|
const input = 'New reply.\r\nOn Monday Bob <b@x.com> wrote:\r\n> quoted';
|
|
expect(stripEmailQuotes(input)).toBe('New reply.');
|
|
});
|
|
|
|
it('picks earliest cutoff when multiple markers match', () => {
|
|
// Earlier in the body: "On X wrote:". Later: "_____" underline.
|
|
// The earliest cutoff is the reply marker, not the underline.
|
|
const input = 'My new reply.\nOn Mon Bob wrote:\n> quoted text\n_____\nsignature';
|
|
expect(stripEmailQuotes(input)).toBe('My new reply.');
|
|
});
|
|
});
|
|
|
|
describe('stripMobileFooter', () => {
|
|
it('removes "Sent from my iPhone"', () => {
|
|
expect(stripMobileFooter('Hi\nSent from my iPhone').trim()).toBe('Hi');
|
|
});
|
|
|
|
it('removes "Sent from my Android"', () => {
|
|
expect(stripMobileFooter('Hi\nSent from my Android').trim()).toBe('Hi');
|
|
});
|
|
|
|
it('removes "Sent from my Galaxy"', () => {
|
|
expect(stripMobileFooter('Hi\nSent from my Galaxy').trim()).toBe('Hi');
|
|
});
|
|
|
|
it('removes "Get Outlook for iOS"', () => {
|
|
expect(stripMobileFooter('Hi\nGet Outlook for iOS').trim()).toBe('Hi');
|
|
});
|
|
|
|
it('returns input unchanged when no footer present', () => {
|
|
expect(stripMobileFooter('Just a normal message')).toBe('Just a normal message');
|
|
});
|
|
|
|
it('returns null/undefined unchanged', () => {
|
|
expect(stripMobileFooter(null)).toBe(null);
|
|
expect(stripMobileFooter(undefined)).toBe(undefined);
|
|
});
|
|
|
|
it('returns empty string unchanged', () => {
|
|
expect(stripMobileFooter('')).toBe('');
|
|
});
|
|
});
|
|
|
|
describe('extractRawEmail', () => {
|
|
it('extracts address from "Name <email>" form', () => {
|
|
expect(extractRawEmail('Bob <bob@example.com>')).toBe('bob@example.com');
|
|
});
|
|
|
|
it('returns trimmed input when angle brackets absent', () => {
|
|
expect(extractRawEmail(' bob@example.com ')).toBe('bob@example.com');
|
|
});
|
|
|
|
it('handles quoted name', () => {
|
|
expect(extractRawEmail('"Bob, the Developer" <bob@example.com>')).toBe('bob@example.com');
|
|
});
|
|
});
|
|
|
|
describe('escapeHtml', () => {
|
|
it('escapes <, >, &, ", \'', () => {
|
|
expect(escapeHtml('<script>alert("xss")</script>'))
|
|
.toBe('<script>alert("xss")</script>');
|
|
expect(escapeHtml("a & b's <foo>")).toBe('a & b's <foo>');
|
|
});
|
|
|
|
it('returns empty string for null/undefined', () => {
|
|
expect(escapeHtml(null)).toBe('');
|
|
expect(escapeHtml(undefined)).toBe('');
|
|
});
|
|
|
|
it('passes through plain text unchanged', () => {
|
|
expect(escapeHtml('plain text')).toBe('plain text');
|
|
});
|
|
});
|
|
|
|
describe('sanitizeEmbedText', () => {
|
|
it('replaces triple-backticks to prevent code-block escape', () => {
|
|
expect(sanitizeEmbedText('```injected```')).toBe("'''injected'''");
|
|
});
|
|
|
|
it('trims whitespace', () => {
|
|
expect(sanitizeEmbedText(' hello ')).toBe('hello');
|
|
});
|
|
|
|
it('returns empty string for null/undefined', () => {
|
|
expect(sanitizeEmbedText(null)).toBe('');
|
|
expect(sanitizeEmbedText(undefined)).toBe('');
|
|
});
|
|
});
|
|
|
|
describe('truncateEmbedDescription', () => {
|
|
it('returns short strings unchanged', () => {
|
|
expect(truncateEmbedDescription('hi')).toBe('hi');
|
|
});
|
|
|
|
it('truncates at default 4096 with ellipsis', () => {
|
|
const big = 'a'.repeat(5000);
|
|
const out = truncateEmbedDescription(big);
|
|
expect(out.length).toBe(4096);
|
|
expect(out.endsWith('...')).toBe(true);
|
|
});
|
|
|
|
it('respects custom max', () => {
|
|
expect(truncateEmbedDescription('abcdef', 5)).toBe('ab...');
|
|
});
|
|
|
|
it('returns empty string for null/undefined', () => {
|
|
expect(truncateEmbedDescription(null)).toBe('');
|
|
expect(truncateEmbedDescription(undefined)).toBe('');
|
|
});
|
|
});
|
|
|
|
describe('replaceVariables', () => {
|
|
it('substitutes ticket fields', () => {
|
|
const ctx = {
|
|
ticket: {
|
|
sender_name: 'Alice',
|
|
senderEmail: 'alice@x.com',
|
|
ticketNumber: 42,
|
|
subject: 'Help'
|
|
}
|
|
};
|
|
const out = replaceVariables('User {ticket.user} ({ticket.email}) #{ticket.number} - {ticket.subject}', ctx);
|
|
expect(out).toBe('User Alice (alice@x.com) #42 - Help');
|
|
});
|
|
|
|
it('falls back when fields missing', () => {
|
|
const out = replaceVariables('{ticket.user} {ticket.email} {ticket.subject}', { ticket: {} });
|
|
expect(out).toBe('Unknown No subject');
|
|
});
|
|
|
|
it('substitutes staff fields', () => {
|
|
const ctx = {
|
|
staff: { username: 'bob', displayName: 'Bob the Builder', mention: '<@123>' }
|
|
};
|
|
expect(replaceVariables('{staff.user} / {staff.name} / {staff.mention}', ctx))
|
|
.toBe('bob / Bob the Builder / <@123>');
|
|
});
|
|
|
|
it('returns empty string for empty template', () => {
|
|
expect(replaceVariables('')).toBe('');
|
|
expect(replaceVariables(null)).toBe('');
|
|
});
|
|
|
|
it('substitutes hours when provided', () => {
|
|
expect(replaceVariables('after {hours} hours', { hours: 24 })).toBe('after 24 hours');
|
|
});
|
|
|
|
it('substitutes {date} and {time} from current time', () => {
|
|
const out = replaceVariables('on {date}', {});
|
|
expect(out).toMatch(/^on \S+/);
|
|
expect(out).not.toContain('{date}');
|
|
});
|
|
});
|
|
|
|
describe('getPriorityEmoji', () => {
|
|
it('maps high/medium/low/normal to CONFIG values', () => {
|
|
expect(typeof getPriorityEmoji('high')).toBe('string');
|
|
expect(typeof getPriorityEmoji('low')).toBe('string');
|
|
expect(typeof getPriorityEmoji('medium')).toBe('string');
|
|
expect(typeof getPriorityEmoji('normal')).toBe('string');
|
|
});
|
|
|
|
it('falls back for unknown priority', () => {
|
|
expect(typeof getPriorityEmoji('weird')).toBe('string');
|
|
});
|
|
});
|
|
|
|
describe('safeEqual', () => {
|
|
it('returns true for matching strings', () => {
|
|
expect(safeEqual('hello', 'hello')).toBe(true);
|
|
});
|
|
|
|
it('returns false for mismatched strings', () => {
|
|
expect(safeEqual('hello', 'world')).toBe(false);
|
|
});
|
|
|
|
it('returns false for length mismatch (no throw)', () => {
|
|
expect(safeEqual('a', 'abc')).toBe(false);
|
|
});
|
|
|
|
it('returns false for null/undefined inputs', () => {
|
|
expect(safeEqual(null, 'abc')).toBe(false);
|
|
expect(safeEqual(undefined, undefined)).toBe(true);
|
|
expect(safeEqual('', '')).toBe(true);
|
|
});
|
|
});
|
|
|
|
describe('isStaff', () => {
|
|
it('returns false for null/undefined member', () => {
|
|
expect(isStaff(null)).toBe(false);
|
|
expect(isStaff(undefined)).toBe(false);
|
|
expect(isStaff({})).toBe(false);
|
|
});
|
|
|
|
it('returns false for member with no roles cache', () => {
|
|
expect(isStaff({ roles: null })).toBe(false);
|
|
});
|
|
});
|