import {describe, it, expect, beforeEach, vi} from 'vitest';
import {GmailClient, GmailAuthContext} from './client.js';
vi.mock('../logger.js', () => ({
logger: {
info: vi.fn(),
error: vi.fn(),
warn: vi.fn(),
debug: vi.fn(),
},
}));
const mockMessagesList = vi.fn();
const mockMessagesGet = vi.fn();
const mockLabelsList = vi.fn();
const mockSetCredentials = vi.fn();
vi.mock('googleapis', () => ({
google: {
auth: {
OAuth2: class {
setCredentials = mockSetCredentials;
},
},
gmail: () => ({
users: {
messages: {
list: mockMessagesList,
get: mockMessagesGet,
},
labels: {
list: mockLabelsList,
},
},
}),
},
}));
function createMockContext(allowedLabels: string[] = []): GmailAuthContext {
return {
googleAccessToken: 'test-access-token',
email: 'test@example.com',
allowedLabels,
};
}
describe('GmailClient', () => {
beforeEach(() => {
vi.clearAllMocks();
mockMessagesList.mockResolvedValue({data: {messages: []}});
mockMessagesGet.mockResolvedValue({data: {}});
mockLabelsList.mockResolvedValue({
data: {
labels: [
{id: 'Label_1', name: 'Airflow'},
{id: 'Label_2', name: 'Work'},
{id: 'INBOX', name: 'INBOX'},
],
},
});
});
describe('listMessages', () => {
it('passes labelIds to Gmail API when allowedLabels configured', async () => {
const client = GmailClient.create(createMockContext(['Airflow']));
await client.initializeLabelFilter();
await client.listMessages({maxResults: 10});
expect(mockMessagesList).toHaveBeenCalledWith({
userId: 'me',
maxResults: 10,
pageToken: undefined,
labelIds: ['Label_1'],
});
});
it('passes multiple labelIds when multiple allowedLabels configured', async () => {
const client = GmailClient.create(createMockContext(['Airflow', 'Work']));
await client.initializeLabelFilter();
await client.listMessages({maxResults: 5});
expect(mockMessagesList).toHaveBeenCalledWith({
userId: 'me',
maxResults: 5,
pageToken: undefined,
labelIds: ['Label_1', 'Label_2'],
});
});
it('uses label name as-is when not found in Gmail labels', async () => {
const client = GmailClient.create(createMockContext(['INBOX', 'Unknown']));
await client.initializeLabelFilter();
await client.listMessages({});
expect(mockMessagesList).toHaveBeenCalledWith(
expect.objectContaining({
labelIds: ['INBOX', 'Unknown'],
})
);
});
});
describe('searchMessages', () => {
it('appends label filter to query when allowedLabels configured', async () => {
const client = GmailClient.create(createMockContext(['Airflow']));
await client.initializeLabelFilter();
await client.searchMessages({query: 'subject:test', maxResults: 10});
expect(mockMessagesList).toHaveBeenCalledWith({
userId: 'me',
q: '(subject:test) AND (label:Airflow)',
maxResults: 10,
pageToken: undefined,
});
});
it('appends multiple labels with OR when multiple allowedLabels', async () => {
const client = GmailClient.create(createMockContext(['Airflow', 'Work']));
await client.initializeLabelFilter();
await client.searchMessages({query: 'from:test@example.com'});
expect(mockMessagesList).toHaveBeenCalledWith({
userId: 'me',
q: '(from:test@example.com) AND (label:Airflow OR label:Work)',
maxResults: 10,
pageToken: undefined,
});
});
});
describe('initializeLabelFilter', () => {
it('resolves label names to IDs case-insensitively', async () => {
const client = GmailClient.create(createMockContext(['airflow', 'WORK']));
await client.initializeLabelFilter();
expect(client.getResolvedLabelIds()).toEqual(['Label_1', 'Label_2']);
});
});
describe('hasAllowedLabel', () => {
it('returns true when message has allowed label', async () => {
const client = GmailClient.create(createMockContext(['Airflow']));
await client.initializeLabelFilter();
expect(client.hasAllowedLabel(['Label_1', 'INBOX'])).toBe(true);
});
it('returns false when message lacks allowed label', async () => {
const client = GmailClient.create(createMockContext(['Airflow']));
await client.initializeLabelFilter();
expect(client.hasAllowedLabel(['Label_2', 'INBOX'])).toBe(false);
});
});
describe('isLabelAllowed', () => {
it('returns true for allowed label', async () => {
const client = GmailClient.create(createMockContext(['Airflow', 'Work']));
await client.initializeLabelFilter();
expect(client.isLabelAllowed('Airflow')).toBe(true);
});
it('returns true for allowed label case-insensitively', async () => {
const client = GmailClient.create(createMockContext(['Airflow']));
await client.initializeLabelFilter();
expect(client.isLabelAllowed('AIRFLOW')).toBe(true);
expect(client.isLabelAllowed('airflow')).toBe(true);
});
it('returns false for non-allowed label', async () => {
const client = GmailClient.create(createMockContext(['Airflow']));
await client.initializeLabelFilter();
expect(client.isLabelAllowed('Work')).toBe(false);
});
});
describe('validateLabels', () => {
it('does not throw for valid labels', async () => {
const client = GmailClient.create(createMockContext(['Airflow', 'Work']));
await client.initializeLabelFilter();
expect(() => client.validateLabels(['Airflow'])).not.toThrow();
expect(() => client.validateLabels(['Airflow', 'Work'])).not.toThrow();
});
it('throws error for invalid labels', async () => {
const client = GmailClient.create(createMockContext(['Airflow']));
await client.initializeLabelFilter();
expect(() => client.validateLabels(['InvalidLabel'])).toThrow('Labels not allowed: InvalidLabel');
});
it('includes all invalid labels in error message', async () => {
const client = GmailClient.create(createMockContext(['Airflow']));
await client.initializeLabelFilter();
expect(() => client.validateLabels(['Invalid1', 'Invalid2'])).toThrow('Labels not allowed: Invalid1, Invalid2');
});
});
describe('listMessages with explicit labels', () => {
it('uses only specified labels instead of all configured', async () => {
const client = GmailClient.create(createMockContext(['Airflow', 'Work']));
await client.initializeLabelFilter();
await client.listMessages({maxResults: 10, labels: ['Airflow']});
expect(mockMessagesList).toHaveBeenCalledWith({
userId: 'me',
maxResults: 10,
pageToken: undefined,
labelIds: ['Label_1'],
});
});
it('throws error when explicit labels are not allowed', async () => {
const client = GmailClient.create(createMockContext(['Airflow']));
await client.initializeLabelFilter();
await expect(client.listMessages({labels: ['InvalidLabel']})).rejects.toThrow('Labels not allowed: InvalidLabel');
});
});
describe('searchMessages with explicit labels', () => {
it('uses only specified labels instead of all configured', async () => {
const client = GmailClient.create(createMockContext(['Airflow', 'Work']));
await client.initializeLabelFilter();
await client.searchMessages({query: 'subject:test', labels: ['Work']});
expect(mockMessagesList).toHaveBeenCalledWith({
userId: 'me',
q: '(subject:test) AND (label:Work)',
maxResults: 10,
pageToken: undefined,
});
});
it('throws error when explicit labels are not allowed', async () => {
const client = GmailClient.create(createMockContext(['Airflow']));
await client.initializeLabelFilter();
await expect(client.searchMessages({
query: 'test',
labels: ['InvalidLabel']
})).rejects.toThrow('Labels not allowed: InvalidLabel');
});
it('rejects label: in query', async () => {
const client = GmailClient.create(createMockContext(['Airflow']));
await client.initializeLabelFilter();
await expect(client.searchMessages({query: 'subject:test label:Jira'}))
.rejects.toThrow('Use labels parameter instead of label: in query');
});
it('rejects quoted label: in query', async () => {
const client = GmailClient.create(createMockContext(['Airflow']));
await client.initializeLabelFilter();
await expect(client.searchMessages({query: 'from:test@example.com label:"Some Label"'}))
.rejects.toThrow('Use labels parameter instead of label: in query');
});
it('rejects negated label: in query', async () => {
const client = GmailClient.create(createMockContext(['Work']));
await client.initializeLabelFilter();
await expect(client.searchMessages({query: 'subject:urgent -label:spam'}))
.rejects.toThrow('Use labels parameter instead of label: in query');
});
});
describe('rejectLabelsInQuery', () => {
it('throws error for simple label pattern', async () => {
const client = GmailClient.create(createMockContext());
await client.initializeLabelFilter();
expect(() => client.rejectLabelsInQuery('subject:test label:Jira'))
.toThrow('Use labels parameter instead of label: in query');
});
it('throws error for quoted label pattern', async () => {
const client = GmailClient.create(createMockContext());
await client.initializeLabelFilter();
expect(() => client.rejectLabelsInQuery('from:a@b.com label:"My Label"'))
.toThrow('Use labels parameter instead of label: in query');
});
it('throws error for negated label', async () => {
const client = GmailClient.create(createMockContext());
await client.initializeLabelFilter();
expect(() => client.rejectLabelsInQuery('subject:test -label:spam'))
.toThrow('Use labels parameter instead of label: in query');
});
it('does not throw when no label in query', async () => {
const client = GmailClient.create(createMockContext());
await client.initializeLabelFilter();
expect(() => client.rejectLabelsInQuery('subject:test from:a@b.com')).not.toThrow();
});
});
describe('edge cases', () => {
it('defaults to maxResults=10 when maxResults=0 (falsy)', async () => {
const client = GmailClient.create(createMockContext(['Airflow']));
await client.initializeLabelFilter();
await client.listMessages({maxResults: 0});
expect(mockMessagesList).toHaveBeenCalledWith(
expect.objectContaining({maxResults: 10})
);
});
it('searchMessages with empty query constructs valid filter', async () => {
const client = GmailClient.create(createMockContext(['Airflow']));
await client.initializeLabelFilter();
await client.searchMessages({query: ''});
expect(mockMessagesList).toHaveBeenCalledWith(
expect.objectContaining({q: '() AND (label:Airflow)'})
);
});
it('searchMessages without labels just uses query', async () => {
const client = GmailClient.create(createMockContext([]));
await client.initializeLabelFilter();
await client.searchMessages({query: 'from:test@example.com'});
expect(mockMessagesList).toHaveBeenCalledWith(
expect.objectContaining({q: 'from:test@example.com'})
);
});
it('propagates API errors from listMessages', async () => {
const client = GmailClient.create(createMockContext(['Airflow']));
await client.initializeLabelFilter();
mockMessagesList.mockRejectedValueOnce(new Error('Gmail API error'));
await expect(client.listMessages({})).rejects.toThrow('Gmail API error');
});
it('propagates API errors from searchMessages', async () => {
const client = GmailClient.create(createMockContext(['Airflow']));
await client.initializeLabelFilter();
mockMessagesList.mockRejectedValueOnce(new Error('Gmail quota exceeded'));
await expect(client.searchMessages({query: 'test'})).rejects.toThrow('Gmail quota exceeded');
});
it('propagates API errors from getMessage', async () => {
const client = GmailClient.create(createMockContext(['Airflow']));
await client.initializeLabelFilter();
mockMessagesGet.mockRejectedValueOnce(new Error('Message not found'));
await expect(client.getMessage('nonexistent')).rejects.toThrow('Message not found');
});
it('handles labels with special characters in search query', async () => {
mockLabelsList.mockResolvedValueOnce({
data: {
labels: [{id: 'Label_Special', name: 'Work/Projects'}],
},
});
const client = GmailClient.create(createMockContext(['Work/Projects']));
await client.initializeLabelFilter();
await client.searchMessages({query: 'test'});
expect(mockMessagesList).toHaveBeenCalledWith(
expect.objectContaining({q: '(test) AND (label:Work/Projects)'})
);
});
it('handles unicode label names', async () => {
mockLabelsList.mockResolvedValueOnce({
data: {
labels: [{id: 'Label_Unicode', name: '日本語'}],
},
});
const client = GmailClient.create(createMockContext(['日本語']));
await client.initializeLabelFilter();
expect(client.isLabelAllowed('日本語')).toBe(true);
});
it('hasAllowedLabel returns true when no labels configured', async () => {
const client = GmailClient.create(createMockContext([]));
await client.initializeLabelFilter();
expect(client.hasAllowedLabel(['INBOX', 'SPAM'])).toBe(true);
});
it('hasAllowedLabel returns false when configured but none match', async () => {
const client = GmailClient.create(createMockContext(['Airflow']));
await client.initializeLabelFilter();
expect(client.hasAllowedLabel(['INBOX', 'SPAM'])).toBe(false);
});
});
});