- Reply-cycle auto-advance: staff reply files the thread to "Awaiting Reply", a customer response files it to "Needs Response" (new GMAIL_LABEL_AWAITING_REPLY / GMAIL_LABEL_NEEDS_RESPONSE labels + autoAdvanceFolder, which only moves threads still in the auto-cycle and leaves hand-filed folders alone) - /forward: forward a ticket's email to another address (handlers/commands/forward.js + forward composition in services/gmail.js) - Tests for the auto-advance cycle; label fixtures updated for the new labels
232 lines
8.3 KiB
JavaScript
232 lines
8.3 KiB
JavaScript
import { describe, it, expect, beforeEach } from 'vitest';
|
|
import {
|
|
computeLabelMutation,
|
|
resolveLabelId,
|
|
moveThreadToFolder,
|
|
folderDisplayName,
|
|
getManagedFolderKey,
|
|
autoAdvanceFolder,
|
|
MANAGED_USER_KEYS,
|
|
__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: 'Awaiting Reply', id: 'L_AR' },
|
|
{ name: 'Needs Response', id: 'L_NR' },
|
|
{ name: 'Escalated', id: 'L_ESC' },
|
|
{ name: 'Resolved', id: 'L_RES' },
|
|
{ name: 'Complete', 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();
|
|
});
|
|
});
|
|
|
|
// Derive label name↔id from the live config so these tests don't depend on the
|
|
// actual GMAIL_LABEL_* names in .env (e.g. RESOLVED may be customized to "Complete").
|
|
const idForKey = key => `LID_${key}`;
|
|
const CYCLE_LABELS = MANAGED_USER_KEYS.map(k => ({ name: folderDisplayName(k), id: idForKey(k) }));
|
|
|
|
// Mock Gmail whose thread carries the given label ids; records threads.modify.
|
|
function makeGmail({ threadLabelIds = [], onModify } = {}) {
|
|
return {
|
|
users: {
|
|
labels: {
|
|
list: async () => ({ data: { labels: CYCLE_LABELS } }),
|
|
create: async () => { throw new Error('no create expected'); }
|
|
},
|
|
threads: {
|
|
get: async () => ({ data: { messages: [{ labelIds: threadLabelIds }] } }),
|
|
modify: async (args) => { if (onModify) onModify(args); return { data: {} }; }
|
|
}
|
|
}
|
|
};
|
|
}
|
|
|
|
describe('getManagedFolderKey', () => {
|
|
beforeEach(() => __clearLabelCache());
|
|
|
|
it('maps a thread label id to its managed folder key', async () => {
|
|
expect(await getManagedFolderKey('t', makeGmail({ threadLabelIds: [idForKey('FOR_JAKE'), 'INBOX'] }))).toBe('FOR_JAKE');
|
|
});
|
|
|
|
it('detects the system SPAM label', async () => {
|
|
expect(await getManagedFolderKey('t', makeGmail({ threadLabelIds: ['SPAM'] }))).toBe('SPAM');
|
|
});
|
|
|
|
it('returns null when no managed label is present', async () => {
|
|
expect(await getManagedFolderKey('t', makeGmail({ threadLabelIds: ['INBOX', 'UNREAD'] }))).toBeNull();
|
|
});
|
|
});
|
|
|
|
describe('autoAdvanceFolder', () => {
|
|
beforeEach(() => __clearLabelCache());
|
|
|
|
it('advances an auto-cycle thread (Awaiting Reply → Needs Response)', async () => {
|
|
let modifyArgs = null;
|
|
const gmail = makeGmail({ threadLabelIds: [idForKey('AWAITING_REPLY')], onModify: a => { modifyArgs = a; } });
|
|
expect(await autoAdvanceFolder('t', 'NEEDS_RESPONSE', gmail)).toBe(true);
|
|
expect(modifyArgs.requestBody.addLabelIds).toEqual([idForKey('NEEDS_RESPONSE')]);
|
|
});
|
|
|
|
it('advances a thread with no managed label', async () => {
|
|
let called = false;
|
|
const gmail = makeGmail({ threadLabelIds: ['INBOX'], onModify: () => { called = true; } });
|
|
expect(await autoAdvanceFolder('t', 'AWAITING_REPLY', gmail)).toBe(true);
|
|
expect(called).toBe(true);
|
|
});
|
|
|
|
it('leaves a manually-filed thread (For Jake) untouched', async () => {
|
|
let called = false;
|
|
const gmail = makeGmail({ threadLabelIds: [idForKey('FOR_JAKE')], onModify: () => { called = true; } });
|
|
expect(await autoAdvanceFolder('t', 'NEEDS_RESPONSE', gmail)).toBe(false);
|
|
expect(called).toBe(false);
|
|
});
|
|
|
|
it('leaves a SPAM-filed thread untouched', async () => {
|
|
let called = false;
|
|
const gmail = makeGmail({ threadLabelIds: ['SPAM'], onModify: () => { called = true; } });
|
|
expect(await autoAdvanceFolder('t', 'NEEDS_RESPONSE', gmail)).toBe(false);
|
|
expect(called).toBe(false);
|
|
});
|
|
|
|
it('rejects an unknown target key before touching the API', async () => {
|
|
await expect(autoAdvanceFolder('t', 'BOGUS', makeGmail())).rejects.toThrow();
|
|
});
|
|
});
|