Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions docs/tasks/plan.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,8 +31,8 @@ Raise test coverage from 46.92% to 90%+ across the entire repository by adding u
| # | Task | Status | Depends On | File |
|---|------|--------|------------|------|
| 7 | Domain & application layer test coverage | done (PR #9) | — | [007](007-domain-app-test-coverage.md) |
| 8 | Infrastructure utilities & events test coverage | reviewed | — | [008](008-infra-utils-events-test-coverage.md) |
| 9 | Infrastructure adapters test coverage | pending | — | [009](009-infra-adapters-test-coverage.md) |
| 8 | Infrastructure utilities & events test coverage | done (PR #10) | — | [008](008-infra-utils-events-test-coverage.md) |
| 9 | Infrastructure adapters test coverage | reviewed | — | [009](009-infra-adapters-test-coverage.md) |

### Dependency Graph

Expand Down
116 changes: 116 additions & 0 deletions src/infrastructure/gmail/__tests__/email-processor.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
jest.mock('../../utils/logger', () => ({
logger: {
info: jest.fn(),
error: jest.fn(),
warn: jest.fn(),
debug: jest.fn(),
},
}));

// Mock googleapis
const mockMessagesList = jest.fn();
const mockMessagesGet = jest.fn();
const mockMessagesModify = jest.fn();

jest.mock('googleapis', () => ({
google: {
gmail: () => ({
users: {
messages: {
list: mockMessagesList,
get: mockMessagesGet,
modify: mockMessagesModify,
},
},
}),
},
}));

import { EmailProcessor } from '../email-processor';

describe('EmailProcessor', () => {
let processor: EmailProcessor;
const mockAuth = {};

beforeEach(() => {
jest.clearAllMocks();
processor = new EmailProcessor(mockAuth);
});

describe('fetchUnreadEmails', () => {
it('should return parsed emails', async () => {
mockMessagesList.mockResolvedValue({
data: {
messages: [{ id: 'msg-1' }],
},
});

mockMessagesGet.mockResolvedValue({
data: {
payload: {
headers: [
{ name: 'Subject', value: 'Test' },
{ name: 'From', value: 'from@test.com' },
{ name: 'To', value: 'to@test.com' },
{ name: 'Date', value: '2024-03-15' },
],
body: {
data: Buffer.from('Email body').toString('base64'),
},
},
},
});

const emails = await processor.fetchUnreadEmails('from:test');
expect(emails).toHaveLength(1);
expect(emails[0].id).toBe('msg-1');
expect(emails[0].subject).toBe('Test');
expect(emails[0].body).toBe('Email body');
});

it('should return empty array when no messages', async () => {
mockMessagesList.mockResolvedValue({
data: { messages: undefined },
});

const emails = await processor.fetchUnreadEmails('from:test');
expect(emails).toHaveLength(0);
});

it('should throw on API error', async () => {
mockMessagesList.mockRejectedValue(new Error('API error'));
await expect(processor.fetchUnreadEmails('from:test')).rejects.toThrow('API error');
});

it('should skip emails that fail to fetch details', async () => {
mockMessagesList.mockResolvedValue({
data: { messages: [{ id: 'msg-1' }] },
});

mockMessagesGet.mockRejectedValue(new Error('Not found'));

const emails = await processor.fetchUnreadEmails('from:test');
expect(emails).toHaveLength(0);
});
});

describe('markAsRead', () => {
it('should call modify API to remove UNREAD label', async () => {
mockMessagesModify.mockResolvedValue({});

await processor.markAsRead('msg-1');
expect(mockMessagesModify).toHaveBeenCalledWith(
expect.objectContaining({
id: 'msg-1',
userId: 'me',
requestBody: { removeLabelIds: ['UNREAD'] },
})
);
});

it('should throw on API error', async () => {
mockMessagesModify.mockRejectedValue(new Error('API error'));
await expect(processor.markAsRead('msg-1')).rejects.toThrow('API error');
});
});
});
110 changes: 110 additions & 0 deletions src/infrastructure/gmail/__tests__/email.utils.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
import { extractEmailHeaders, extractEmailBody, htmlToPlainText, processEmailContent } from '../email.utils';

describe('email.utils', () => {
describe('extractEmailHeaders', () => {
it('should extract all headers', () => {
const headers = [
{ name: 'Subject', value: 'Test Subject' },
{ name: 'From', value: 'sender@test.com' },
{ name: 'To', value: 'recipient@test.com' },
{ name: 'Date', value: '2024-03-15T10:00:00Z' },
];

const result = extractEmailHeaders(headers);
expect(result).toEqual({
subject: 'Test Subject',
from: 'sender@test.com',
to: 'recipient@test.com',
date: '2024-03-15T10:00:00Z',
});
});

it('should return empty strings for missing headers', () => {
const result = extractEmailHeaders([]);
expect(result).toEqual({
subject: '',
from: '',
to: '',
date: '',
});
});
});

describe('extractEmailBody', () => {
it('should decode base64 body from direct payload', () => {
const payload = {
body: {
data: Buffer.from('Hello World').toString('base64'),
},
};

const result = extractEmailBody(payload);
expect(result).toBe('Hello World');
});

it('should prefer text/plain from multipart', () => {
const payload = {
parts: [
{
mimeType: 'text/html',
body: { data: Buffer.from('<b>HTML</b>').toString('base64') },
},
{
mimeType: 'text/plain',
body: { data: Buffer.from('Plain text').toString('base64') },
},
],
};

const result = extractEmailBody(payload);
expect(result).toBe('Plain text');
});

it('should fall back to text/html when no text/plain', () => {
const payload = {
parts: [
{
mimeType: 'text/html',
body: { data: Buffer.from('<p>Hello</p>').toString('base64') },
},
],
};

const result = extractEmailBody(payload);
// Should be converted from HTML to plain text
expect(result).toContain('Hello');
});

it('should return empty string for empty payload', () => {
const result = extractEmailBody({});
expect(result).toBe('');
});
});

describe('htmlToPlainText', () => {
it('should convert HTML to plain text', () => {
const result = htmlToPlainText('<p>Hello <b>World</b></p>');
expect(result).toContain('Hello');
expect(result).toContain('World');
});

it('should return empty string for empty input', () => {
expect(htmlToPlainText('')).toBe('');
});
});

describe('processEmailContent', () => {
it('should return plain text as-is', () => {
expect(processEmailContent('Hello World')).toBe('Hello World');
});

it('should convert HTML to plain text', () => {
const result = processEmailContent('<p>Hello</p>');
expect(result).toContain('Hello');
});

it('should return empty string for empty input', () => {
expect(processEmailContent('')).toBe('');
});
});
});
85 changes: 85 additions & 0 deletions src/infrastructure/gmail/__tests__/gmail.adapter.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
jest.mock('../../utils/logger', () => ({
logger: {
info: jest.fn(),
error: jest.fn(),
warn: jest.fn(),
debug: jest.fn(),
},
}));

// Mock TokenManager
const mockTokenManagerInit = jest.fn().mockResolvedValue(undefined);
const mockGetAuth = jest.fn().mockReturnValue({});
const mockGenerateAuthUrl = jest.fn().mockReturnValue('https://auth.url');
const mockGetInitialTokens = jest.fn();

jest.mock('../token-manager', () => ({
TokenManager: {
initialize: jest.fn().mockResolvedValue({
getAuth: mockGetAuth,
generateAuthUrl: mockGenerateAuthUrl,
getInitialTokens: mockGetInitialTokens,
}),
loadCredentials: jest.fn(),
loadTokens: jest.fn(),
},
}));

// Mock EmailProcessor
const mockFetchUnreadEmails = jest.fn().mockResolvedValue([]);
const mockMarkAsRead = jest.fn().mockResolvedValue(undefined);

jest.mock('../email-processor', () => ({
EmailProcessor: jest.fn().mockImplementation(() => ({
fetchUnreadEmails: mockFetchUnreadEmails,
markAsRead: mockMarkAsRead,
})),
}));

// Mock googleapis for getProfile
const mockGetProfile = jest.fn().mockResolvedValue({
data: { emailAddress: 'test@gmail.com' },
});

jest.mock('googleapis', () => ({
google: {
gmail: jest.fn().mockReturnValue({
users: {
getProfile: mockGetProfile,
},
}),
},
}));

import { GmailAdapter } from '../gmail.adapter';
import { TokenManager } from '../token-manager';

describe('GmailAdapter', () => {
describe('initialize', () => {
it('should initialize token manager and verify connection', async () => {
const adapter = await GmailAdapter.initialize();
expect(TokenManager.initialize).toHaveBeenCalled();
expect(mockGetProfile).toHaveBeenCalled();
expect(adapter).toBeInstanceOf(GmailAdapter);
});
});

describe('fetchUnreadEmails', () => {
it('should delegate to email processor', async () => {
const mockEmails = [{ id: '1', subject: 'Test', from: '', to: '', body: '' }];
mockFetchUnreadEmails.mockResolvedValue(mockEmails);

const adapter = await GmailAdapter.initialize();
const result = await adapter.fetchUnreadEmails('from:test');
expect(result).toEqual(mockEmails);
});
});

describe('markAsRead', () => {
it('should delegate to email processor', async () => {
const adapter = await GmailAdapter.initialize();
await adapter.markAsRead('msg-1');
expect(mockMarkAsRead).toHaveBeenCalledWith('msg-1');
});
});
});
Loading
Loading