Files
broccolini-bot/tests/gmailLabels.test.js
indifferentketchup 6bae3e79b1 Gmail folder auto-advance + /forward command
- 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
2026-06-05 02:46:50 +00:00

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