Email ticketing fixes, comms polish, and .env cleanup
Inbound: - Gmail poll query is:unread in:inbox (was category:primary, which matched nothing on a no-tabs Workspace inbox) Outbound email: - Closed/escalation auto-emails editable via TICKET_CLOSE_MESSAGE and new TICKET_ESCALATION_EMAIL_MESSAGE; drop the staff signature from closing emails - Replies quote the customer's latest message (gmail_quote markup so clients collapse it), embed custom emoji inline via CID attachment, and strip Discord role mentions - Tagline spacing fix in the company signature Discord side: - Suppress all mentions in log + transcript posts (no more pinging on close) - Drop the staff-role ping from new-ticket and follow-up notifications - Ticket channels inherit category permissions instead of setting per-channel overwrites (removes the Manage Roles requirement) Gmail folders: - Folder/label routing (gmailLabels.js) with /folder; close files to Complete Config: - Remove ~56 stale .env keys for long-removed features; refresh stale copy Docs: - Design specs for folder routing, email-flow toggle, and per-staff metrics
This commit is contained in:
152
tests/gmailLabels.test.js
Normal file
152
tests/gmailLabels.test.js
Normal file
@@ -0,0 +1,152 @@
|
||||
import { describe, it, expect, beforeEach } from 'vitest';
|
||||
import {
|
||||
computeLabelMutation,
|
||||
resolveLabelId,
|
||||
moveThreadToFolder,
|
||||
folderDisplayName,
|
||||
__clearLabelCache
|
||||
} from '../services/gmailLabels.js';
|
||||
|
||||
const FULL_IDS = {
|
||||
TRIAGE: 'L_TRIAGE',
|
||||
ESCALATED: 'L_ESC',
|
||||
RESOLVED: 'L_RES',
|
||||
FOR_JAKE: 'L_FJ',
|
||||
DASHBOARD_ERRORS: 'L_DE',
|
||||
PARTNERSHIP_OFFERS: 'L_PO',
|
||||
SPAM: 'SPAM'
|
||||
};
|
||||
|
||||
const FULL_LABELS = [
|
||||
{ name: 'Triage', id: 'L_TRIAGE' },
|
||||
{ name: 'Escalated', id: 'L_ESC' },
|
||||
{ name: 'Resolved', id: 'L_RES' },
|
||||
{ name: 'For Jake', id: 'L_FJ' },
|
||||
{ name: 'Dashboard Errors', id: 'L_DE' },
|
||||
{ name: 'Partnership Offers', id: 'L_PO' }
|
||||
];
|
||||
|
||||
describe('computeLabelMutation', () => {
|
||||
it('adds the target, removes every other managed label plus INBOX/UNREAD', () => {
|
||||
const { addLabelIds, removeLabelIds } = computeLabelMutation('FOR_JAKE', FULL_IDS);
|
||||
expect(addLabelIds).toEqual(['L_FJ']);
|
||||
expect(removeLabelIds).toContain('INBOX');
|
||||
expect(removeLabelIds).toContain('UNREAD');
|
||||
expect(removeLabelIds).toContain('SPAM');
|
||||
expect(removeLabelIds).toContain('L_TRIAGE');
|
||||
expect(removeLabelIds).not.toContain('L_FJ'); // target is never removed
|
||||
});
|
||||
|
||||
it('moving to SPAM adds SPAM and removes all user labels but not SPAM itself', () => {
|
||||
const { addLabelIds, removeLabelIds } = computeLabelMutation('SPAM', FULL_IDS);
|
||||
expect(addLabelIds).toEqual(['SPAM']);
|
||||
expect(removeLabelIds).not.toContain('SPAM');
|
||||
expect(removeLabelIds).toContain('L_TRIAGE');
|
||||
expect(removeLabelIds).toContain('INBOX');
|
||||
});
|
||||
|
||||
it('throws when the target id is missing', () => {
|
||||
expect(() => computeLabelMutation('FOR_JAKE', { TRIAGE: 'x' })).toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('folderDisplayName', () => {
|
||||
it('returns null for the system SPAM folder', () => {
|
||||
expect(folderDisplayName('SPAM')).toBeNull();
|
||||
});
|
||||
it('returns the configured/default name for a user folder', () => {
|
||||
expect(folderDisplayName('FOR_JAKE')).toBe('For Jake');
|
||||
});
|
||||
it('throws on an unknown key', () => {
|
||||
expect(() => folderDisplayName('NOPE')).toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolveLabelId', () => {
|
||||
beforeEach(() => __clearLabelCache());
|
||||
|
||||
it('short-circuits SPAM to the system id without any API call', async () => {
|
||||
let called = false;
|
||||
const gmail = { users: { labels: { list: async () => { called = true; return { data: {} }; } } } };
|
||||
expect(await resolveLabelId(gmail, 'SPAM')).toBe('SPAM');
|
||||
expect(called).toBe(false);
|
||||
});
|
||||
|
||||
it('returns an existing label id matched by name', async () => {
|
||||
const gmail = {
|
||||
users: { labels: {
|
||||
list: async () => ({ data: { labels: [{ name: 'For Jake', id: 'L_EXISTING' }] } }),
|
||||
create: async () => { throw new Error('should not create'); }
|
||||
} }
|
||||
};
|
||||
expect(await resolveLabelId(gmail, 'FOR_JAKE')).toBe('L_EXISTING');
|
||||
});
|
||||
|
||||
it('creates a missing user label under its configured name and caches it', async () => {
|
||||
let createdName = null;
|
||||
const gmail = {
|
||||
users: { labels: {
|
||||
list: async () => ({ data: { labels: [] } }),
|
||||
create: async ({ requestBody }) => { createdName = requestBody.name; return { data: { id: 'L_NEW' } }; }
|
||||
} }
|
||||
};
|
||||
expect(await resolveLabelId(gmail, 'FOR_JAKE')).toBe('L_NEW');
|
||||
expect(createdName).toBe('For Jake');
|
||||
});
|
||||
});
|
||||
|
||||
describe('moveThreadToFolder', () => {
|
||||
beforeEach(() => __clearLabelCache());
|
||||
|
||||
it('resolves labels then issues one threads.modify with exclusive sets', async () => {
|
||||
let modifyArgs = null;
|
||||
const gmail = {
|
||||
users: {
|
||||
labels: {
|
||||
list: async () => ({ data: { labels: FULL_LABELS } }),
|
||||
create: async () => { throw new Error('no create expected'); }
|
||||
},
|
||||
threads: { modify: async (args) => { modifyArgs = args; return { data: {} }; } }
|
||||
}
|
||||
};
|
||||
await moveThreadToFolder('thread123', 'ESCALATED', gmail);
|
||||
expect(modifyArgs.id).toBe('thread123');
|
||||
expect(modifyArgs.requestBody.addLabelIds).toEqual(['L_ESC']);
|
||||
expect(modifyArgs.requestBody.removeLabelIds).toContain('L_TRIAGE');
|
||||
expect(modifyArgs.requestBody.removeLabelIds).toContain('INBOX');
|
||||
expect(modifyArgs.requestBody.removeLabelIds).not.toContain('L_ESC');
|
||||
});
|
||||
|
||||
it('clears the cache and retries once on an invalid-label error', async () => {
|
||||
let modifyCalls = 0;
|
||||
let listCalls = 0;
|
||||
const gmail = {
|
||||
users: {
|
||||
labels: {
|
||||
list: async () => { listCalls++; return { data: { labels: FULL_LABELS } }; },
|
||||
create: async () => ({ data: { id: 'X' } })
|
||||
},
|
||||
threads: {
|
||||
modify: async () => {
|
||||
modifyCalls++;
|
||||
if (modifyCalls === 1) { const e = new Error('invalid label'); e.code = 400; throw e; }
|
||||
return { data: {} };
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
await moveThreadToFolder('t1', 'TRIAGE', gmail);
|
||||
expect(modifyCalls).toBe(2);
|
||||
expect(listCalls).toBe(2); // cache was cleared and labels re-listed
|
||||
});
|
||||
|
||||
it('rejects an unknown folder key before touching the API', async () => {
|
||||
const gmail = {
|
||||
users: {
|
||||
labels: { list: async () => ({ data: { labels: [] } }) },
|
||||
threads: { modify: async () => ({}) }
|
||||
}
|
||||
};
|
||||
await expect(moveThreadToFolder('t', 'BOGUS', gmail)).rejects.toThrow();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user