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(); }); });