basic unit tests
This commit is contained in:
1456
package-lock.json
generated
1456
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -8,7 +8,9 @@
|
|||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "tsc && chmod 755 build/index.js",
|
"build": "tsc && chmod 755 build/index.js",
|
||||||
"auth": "npm run build && node build/auth.js"
|
"auth": "npm run build && node build/auth.js",
|
||||||
|
"test": "vitest run",
|
||||||
|
"test:watch": "vitest"
|
||||||
},
|
},
|
||||||
"files": [
|
"files": [
|
||||||
"build"
|
"build"
|
||||||
@@ -23,6 +25,7 @@
|
|||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/node": "^25.2.3",
|
"@types/node": "^25.2.3",
|
||||||
"typescript": "^5.9.3"
|
"typescript": "^5.9.3",
|
||||||
|
"vitest": "^4.0.18"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ import {
|
|||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Email parsing helpers
|
// Email parsing helpers
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
const getHeader = (
|
export const getHeader = (
|
||||||
headers: gmail_v1.Schema$MessagePartHeader[] | undefined,
|
headers: gmail_v1.Schema$MessagePartHeader[] | undefined,
|
||||||
name: string
|
name: string
|
||||||
): string => {
|
): string => {
|
||||||
@@ -24,7 +24,7 @@ const getHeader = (
|
|||||||
return header?.value ?? "";
|
return header?.value ?? "";
|
||||||
};
|
};
|
||||||
|
|
||||||
const decodeBody = (message: gmail_v1.Schema$Message): string => {
|
export const decodeBody = (message: gmail_v1.Schema$Message): string => {
|
||||||
const parts = message.payload?.parts;
|
const parts = message.payload?.parts;
|
||||||
let encoded = "";
|
let encoded = "";
|
||||||
|
|
||||||
|
|||||||
167
test/fixtures/mock-emails.ts
vendored
Normal file
167
test/fixtures/mock-emails.ts
vendored
Normal file
@@ -0,0 +1,167 @@
|
|||||||
|
/**
|
||||||
|
* Canned Gmail API responses representing one email from each category.
|
||||||
|
* Body text is base64url-encoded to match the real Gmail API format.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const toBase64Url = (text: string): string =>
|
||||||
|
Buffer.from(text, "utf-8").toString("base64url");
|
||||||
|
|
||||||
|
// Category A — Acknowledgement only
|
||||||
|
const catABody = [
|
||||||
|
"Dear Steven,",
|
||||||
|
"",
|
||||||
|
"Thank you for your interest in the Senior Software Engineer position at Acme Corp.",
|
||||||
|
"We have received your application and it is currently under review.",
|
||||||
|
"We will contact you if your qualifications match our needs.",
|
||||||
|
"",
|
||||||
|
"Best regards,",
|
||||||
|
"Acme Talent Acquisition",
|
||||||
|
].join("\n");
|
||||||
|
|
||||||
|
// Category B — Advancement to next step
|
||||||
|
const catBBody = [
|
||||||
|
"Hi Steven,",
|
||||||
|
"",
|
||||||
|
"Thanks for applying to the Full Stack Engineer role at Globex Corp.",
|
||||||
|
"We'd love to schedule a phone screen to discuss your background.",
|
||||||
|
"Are you available this Thursday at 2:00 PM CST?",
|
||||||
|
"Please join via Zoom: https://zoom.us/j/123456789",
|
||||||
|
"",
|
||||||
|
"Looking forward to connecting,",
|
||||||
|
"Jane Smith",
|
||||||
|
"Senior Recruiter, Globex Corp",
|
||||||
|
"jane.smith@globexcorp.com",
|
||||||
|
"(555) 867-5309",
|
||||||
|
].join("\n");
|
||||||
|
|
||||||
|
// Category C — Rejection
|
||||||
|
const catCBody = [
|
||||||
|
"Dear Steven,",
|
||||||
|
"",
|
||||||
|
"Thank you for taking the time to interview for the Platform Engineer position at Initech.",
|
||||||
|
"After careful consideration, we have decided to pursue other candidates",
|
||||||
|
"whose experience more closely aligns with our current needs.",
|
||||||
|
"",
|
||||||
|
"We wish you the best in your job search.",
|
||||||
|
"",
|
||||||
|
"Regards,",
|
||||||
|
"Initech Recruiting Team",
|
||||||
|
].join("\n");
|
||||||
|
|
||||||
|
// Category D — Other / non-job
|
||||||
|
const catDBody = [
|
||||||
|
"Your Tailscale subscription payment of $6.00 was unsuccessful.",
|
||||||
|
"Please update your payment method at https://login.tailscale.com/billing.",
|
||||||
|
"",
|
||||||
|
"— Tailscale Billing",
|
||||||
|
].join("\n");
|
||||||
|
|
||||||
|
const makeHeaders = (from: string, subject: string, date: string) => [
|
||||||
|
{ name: "From", value: from },
|
||||||
|
{ name: "Subject", value: subject },
|
||||||
|
{ name: "Date", value: date },
|
||||||
|
{ name: "To", value: "steven@sjdev.co" },
|
||||||
|
];
|
||||||
|
|
||||||
|
export const MOCK_MESSAGE_LIST = {
|
||||||
|
data: {
|
||||||
|
messages: [
|
||||||
|
{ id: "msg-cat-a-001" },
|
||||||
|
{ id: "msg-cat-b-001" },
|
||||||
|
{ id: "msg-cat-c-001" },
|
||||||
|
{ id: "msg-cat-d-001" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const MOCK_MESSAGES: Record<string, { data: any }> = {
|
||||||
|
"msg-cat-a-001": {
|
||||||
|
data: {
|
||||||
|
id: "msg-cat-a-001",
|
||||||
|
snippet: "Thank you for your interest in the Senior Software Engineer position...",
|
||||||
|
payload: {
|
||||||
|
headers: makeHeaders(
|
||||||
|
"Acme Talent <talent@acmecorp.com>",
|
||||||
|
"Application Received — Senior Software Engineer",
|
||||||
|
"Mon, 17 Feb 2026 10:00:00 +0000"
|
||||||
|
),
|
||||||
|
mimeType: "multipart/alternative",
|
||||||
|
parts: [
|
||||||
|
{
|
||||||
|
mimeType: "text/plain",
|
||||||
|
body: { data: toBase64Url(catABody) },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
mimeType: "text/html",
|
||||||
|
body: { data: toBase64Url(`<p>${catABody}</p>`) },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
"msg-cat-b-001": {
|
||||||
|
data: {
|
||||||
|
id: "msg-cat-b-001",
|
||||||
|
snippet: "We'd love to schedule a phone screen...",
|
||||||
|
payload: {
|
||||||
|
headers: makeHeaders(
|
||||||
|
"Jane Smith <jane.smith@globexcorp.com>",
|
||||||
|
"Phone Screen — Full Stack Engineer at Globex Corp",
|
||||||
|
"Tue, 18 Feb 2026 14:30:00 +0000"
|
||||||
|
),
|
||||||
|
mimeType: "multipart/alternative",
|
||||||
|
parts: [
|
||||||
|
{
|
||||||
|
mimeType: "text/plain",
|
||||||
|
body: { data: toBase64Url(catBBody) },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
"msg-cat-c-001": {
|
||||||
|
data: {
|
||||||
|
id: "msg-cat-c-001",
|
||||||
|
snippet: "We have decided to pursue other candidates...",
|
||||||
|
payload: {
|
||||||
|
headers: makeHeaders(
|
||||||
|
"Initech Recruiting <recruiting@initech.com>",
|
||||||
|
"Update on Your Application — Platform Engineer",
|
||||||
|
"Wed, 19 Feb 2026 09:15:00 +0000"
|
||||||
|
),
|
||||||
|
mimeType: "text/plain",
|
||||||
|
body: { data: toBase64Url(catCBody) },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
"msg-cat-d-001": {
|
||||||
|
data: {
|
||||||
|
id: "msg-cat-d-001",
|
||||||
|
snippet: "Your Tailscale subscription payment of $6.00 was unsuccessful.",
|
||||||
|
payload: {
|
||||||
|
headers: makeHeaders(
|
||||||
|
"Tailscale Billing <billing@tailscale.com>",
|
||||||
|
"$6.00 payment to Tailscale US Inc. was unsuccessful",
|
||||||
|
"Thu, 20 Feb 2026 17:00:00 +0000"
|
||||||
|
),
|
||||||
|
mimeType: "text/plain",
|
||||||
|
body: { data: toBase64Url(catDBody) },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const MOCK_EMPTY_LIST = {
|
||||||
|
data: { messages: undefined },
|
||||||
|
};
|
||||||
|
|
||||||
|
// Raw body text for assertion comparisons
|
||||||
|
export const RAW_BODIES = {
|
||||||
|
"msg-cat-a-001": catABody,
|
||||||
|
"msg-cat-b-001": catBBody,
|
||||||
|
"msg-cat-c-001": catCBody,
|
||||||
|
"msg-cat-d-001": catDBody,
|
||||||
|
};
|
||||||
288
test/integration/email-workflow.test.ts
Normal file
288
test/integration/email-workflow.test.ts
Normal file
@@ -0,0 +1,288 @@
|
|||||||
|
import { describe, it, expect, vi, beforeAll, afterAll, beforeEach } from "vitest";
|
||||||
|
import { z } from "zod";
|
||||||
|
import fs from "fs";
|
||||||
|
import os from "os";
|
||||||
|
import path from "path";
|
||||||
|
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
|
||||||
|
import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js";
|
||||||
|
import { MOCK_MESSAGE_LIST, MOCK_MESSAGES, MOCK_EMPTY_LIST } from "../fixtures/mock-emails.js";
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// Mock the loaders module so no real credentials/tokens/APIs are needed.
|
||||||
|
// vi.mock is hoisted — runs before any module imports.
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
const mockGmailList = vi.fn();
|
||||||
|
const mockGmailGet = vi.fn();
|
||||||
|
const mockGmailTrash = vi.fn();
|
||||||
|
let tmpDir: string;
|
||||||
|
|
||||||
|
vi.mock("../../src/loaders/prompt-config-loaders.js", () => {
|
||||||
|
const testAccounts = {
|
||||||
|
work: { label: "test@test.com", tokenFile: "token.json" },
|
||||||
|
secondary: { label: "test2@test.com", tokenFile: "token-secondary.json" },
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
PROJECT_ROOT: "/tmp/test-project",
|
||||||
|
VALID_ACCOUNTS: ["work", "secondary"] as const,
|
||||||
|
accounts: testAccounts,
|
||||||
|
accountSchema: z
|
||||||
|
.enum(["work", "secondary"])
|
||||||
|
.describe("test account"),
|
||||||
|
loadAccounts: () => testAccounts,
|
||||||
|
getTokenPath: () => "/tmp/fake-token.json",
|
||||||
|
getSummaryPath: (account: string) => {
|
||||||
|
// tmpDir is set in beforeAll, but this is called lazily so it's fine
|
||||||
|
const base = tmpDir || os.tmpdir();
|
||||||
|
return account === "work"
|
||||||
|
? path.join(base, "summary.json")
|
||||||
|
: path.join(base, `summary-${account}.json`);
|
||||||
|
},
|
||||||
|
loadClassificationPrompt: () => "CLASSIFY EACH EMAIL AS A, B, C, OR D.",
|
||||||
|
loadActionPrompt: () => "PHASE 2 INSTRUCTIONS HERE.",
|
||||||
|
getGmailClient: () => ({
|
||||||
|
users: {
|
||||||
|
messages: {
|
||||||
|
list: mockGmailList,
|
||||||
|
get: mockGmailGet,
|
||||||
|
trash: mockGmailTrash,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
getSheetsClient: vi.fn(),
|
||||||
|
getCalendarClient: vi.fn(),
|
||||||
|
getSpreadsheetId: vi.fn(),
|
||||||
|
getCalendarId: () => "primary",
|
||||||
|
getOAuth2Client: vi.fn(),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
// Now import the server and tool modules (they'll use the mocked loaders)
|
||||||
|
const { server } = await import("../../src/McpServer.js");
|
||||||
|
await import("../../src/tools/tools-email.js");
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// Helpers
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
let client: Client;
|
||||||
|
|
||||||
|
async function connectClient(): Promise<void> {
|
||||||
|
client = new Client({ name: "test-client", version: "1.0.0" });
|
||||||
|
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
|
||||||
|
await Promise.all([
|
||||||
|
client.connect(clientTransport),
|
||||||
|
server.connect(serverTransport),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function callTool(name: string, args: Record<string, unknown>) {
|
||||||
|
const result = await client.callTool({ name, arguments: args });
|
||||||
|
const text = (result.content as Array<{ type: string; text: string }>)[0]?.text ?? "";
|
||||||
|
return text;
|
||||||
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// Test suite
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
describe("Phase 1: Email Review Workflow", () => {
|
||||||
|
beforeAll(async () => {
|
||||||
|
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "mcp-test-"));
|
||||||
|
await connectClient();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
await client?.close();
|
||||||
|
fs.rmSync(tmpDir, { recursive: true, force: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
// ----- Step 1: Fetch new emails -----
|
||||||
|
describe("fetch_new_emails", () => {
|
||||||
|
it("returns formatted emails with classification instructions", async () => {
|
||||||
|
mockGmailList.mockResolvedValue(MOCK_MESSAGE_LIST);
|
||||||
|
mockGmailGet.mockImplementation(({ id }: { id: string }) =>
|
||||||
|
Promise.resolve(MOCK_MESSAGES[id])
|
||||||
|
);
|
||||||
|
|
||||||
|
const text = await callTool("fetch_new_emails", {
|
||||||
|
account: "work",
|
||||||
|
maxResults: 10,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(text).toContain("Found 4 unread email(s)");
|
||||||
|
|
||||||
|
// All 4 messages present
|
||||||
|
expect(text).toContain("msg-cat-a-001");
|
||||||
|
expect(text).toContain("msg-cat-b-001");
|
||||||
|
expect(text).toContain("msg-cat-c-001");
|
||||||
|
expect(text).toContain("msg-cat-d-001");
|
||||||
|
|
||||||
|
// Headers extracted correctly
|
||||||
|
expect(text).toContain("FROM: Acme Talent <talent@acmecorp.com>");
|
||||||
|
expect(text).toContain("SUBJECT: Phone Screen — Full Stack Engineer at Globex Corp");
|
||||||
|
expect(text).toContain("FROM: Initech Recruiting <recruiting@initech.com>");
|
||||||
|
|
||||||
|
// Body content decoded
|
||||||
|
expect(text).toContain("We have received your application");
|
||||||
|
expect(text).toContain("schedule a phone screen");
|
||||||
|
expect(text).toContain("decided to pursue other candidates");
|
||||||
|
expect(text).toContain("payment of $6.00 was unsuccessful");
|
||||||
|
|
||||||
|
// Classification instructions appended
|
||||||
|
expect(text).toContain("CLASSIFICATION INSTRUCTIONS (PHASE 1)");
|
||||||
|
expect(text).toContain("CLASSIFY EACH EMAIL AS A, B, C, OR D.");
|
||||||
|
|
||||||
|
// Phase 2 instructions also appended
|
||||||
|
expect(text).toContain("ACTION INSTRUCTIONS (PHASE 2)");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("handles an empty inbox", async () => {
|
||||||
|
mockGmailList.mockResolvedValue(MOCK_EMPTY_LIST);
|
||||||
|
|
||||||
|
const text = await callTool("fetch_new_emails", {
|
||||||
|
account: "work",
|
||||||
|
maxResults: 10,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(text).toBe("No unread emails found.");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("handles Gmail API errors gracefully", async () => {
|
||||||
|
mockGmailList.mockRejectedValue(new Error("Token expired"));
|
||||||
|
|
||||||
|
const text = await callTool("fetch_new_emails", {
|
||||||
|
account: "work",
|
||||||
|
maxResults: 10,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(text).toContain("Error fetching emails");
|
||||||
|
expect(text).toContain("Token expired");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ----- Step 2: Delete A and C emails -----
|
||||||
|
describe("delete_emails", () => {
|
||||||
|
it("trashes the specified message IDs", async () => {
|
||||||
|
mockGmailTrash.mockResolvedValue({});
|
||||||
|
|
||||||
|
const text = await callTool("delete_emails", {
|
||||||
|
account: "work",
|
||||||
|
messageIds: ["msg-cat-a-001", "msg-cat-c-001"],
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(mockGmailTrash).toHaveBeenCalledTimes(2);
|
||||||
|
expect(mockGmailTrash).toHaveBeenCalledWith({ userId: "me", id: "msg-cat-a-001" });
|
||||||
|
expect(mockGmailTrash).toHaveBeenCalledWith({ userId: "me", id: "msg-cat-c-001" });
|
||||||
|
expect(text).toContain("Trashed: msg-cat-a-001");
|
||||||
|
expect(text).toContain("Trashed: msg-cat-c-001");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reports partial failures without aborting", async () => {
|
||||||
|
mockGmailTrash
|
||||||
|
.mockResolvedValueOnce({})
|
||||||
|
.mockRejectedValueOnce(new Error("Not Found"));
|
||||||
|
|
||||||
|
const text = await callTool("delete_emails", {
|
||||||
|
account: "work",
|
||||||
|
messageIds: ["msg-cat-a-001", "msg-bad-id"],
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(text).toContain("Trashed: msg-cat-a-001");
|
||||||
|
expect(text).toContain("Failed to trash msg-bad-id: Not Found");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("handles auth/client errors", async () => {
|
||||||
|
// Make getGmailClient itself throw by having list throw before any trash
|
||||||
|
mockGmailTrash.mockRejectedValue(new Error("Auth failed"));
|
||||||
|
|
||||||
|
const text = await callTool("delete_emails", {
|
||||||
|
account: "work",
|
||||||
|
messageIds: ["msg-cat-a-001"],
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(text).toContain("Failed to trash msg-cat-a-001: Auth failed");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ----- Step 3: Summarize B and D -----
|
||||||
|
describe("append_to_summary", () => {
|
||||||
|
it("creates a summary file and appends entries", async () => {
|
||||||
|
const entries = [
|
||||||
|
{
|
||||||
|
senderName: "Jane Smith",
|
||||||
|
senderEmail: "jane.smith@globexcorp.com",
|
||||||
|
dateReceived: "Tue, 18 Feb 2026 14:30:00 +0000",
|
||||||
|
subject: "Phone Screen — Full Stack Engineer at Globex Corp",
|
||||||
|
category: "B",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
senderName: "Tailscale Billing",
|
||||||
|
senderEmail: "billing@tailscale.com",
|
||||||
|
dateReceived: "Thu, 20 Feb 2026 17:00:00 +0000",
|
||||||
|
subject: "$6.00 payment to Tailscale US Inc. was unsuccessful",
|
||||||
|
category: "D",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const text = await callTool("append_to_summary", {
|
||||||
|
account: "work",
|
||||||
|
entries,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(text).toContain("Appended 2 entry/entries to summary.");
|
||||||
|
expect(text).toContain("Total entries in summary: 2");
|
||||||
|
|
||||||
|
// Verify the file was actually written
|
||||||
|
const summaryPath = path.join(tmpDir, "summary.json");
|
||||||
|
const written = JSON.parse(fs.readFileSync(summaryPath, "utf-8"));
|
||||||
|
expect(written).toHaveLength(2);
|
||||||
|
expect(written[0].senderName).toBe("Jane Smith");
|
||||||
|
expect(written[0].category).toBe("B");
|
||||||
|
expect(written[1].category).toBe("D");
|
||||||
|
expect(written[0].addedAt).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("purges entries older than 30 days", async () => {
|
||||||
|
const summaryPath = path.join(tmpDir, "summary.json");
|
||||||
|
const oldDate = new Date(Date.now() - 31 * 24 * 60 * 60 * 1000).toISOString();
|
||||||
|
|
||||||
|
// Seed with an old entry
|
||||||
|
fs.writeFileSync(
|
||||||
|
summaryPath,
|
||||||
|
JSON.stringify([
|
||||||
|
{
|
||||||
|
senderName: "Old Entry",
|
||||||
|
senderEmail: "old@example.com",
|
||||||
|
dateReceived: "2025-01-01",
|
||||||
|
subject: "Ancient email",
|
||||||
|
category: "D",
|
||||||
|
addedAt: oldDate,
|
||||||
|
},
|
||||||
|
])
|
||||||
|
);
|
||||||
|
|
||||||
|
const text = await callTool("append_to_summary", {
|
||||||
|
account: "work",
|
||||||
|
entries: [
|
||||||
|
{
|
||||||
|
senderName: "New Entry",
|
||||||
|
senderEmail: "new@example.com",
|
||||||
|
dateReceived: "2026-02-18",
|
||||||
|
subject: "Fresh email",
|
||||||
|
category: "B",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(text).toContain("Purged 1 entry/entries older than 30 days.");
|
||||||
|
expect(text).toContain("Total entries in summary: 1");
|
||||||
|
|
||||||
|
const written = JSON.parse(fs.readFileSync(summaryPath, "utf-8"));
|
||||||
|
expect(written).toHaveLength(1);
|
||||||
|
expect(written[0].senderName).toBe("New Entry");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
86
test/unit/email-parsing.test.ts
Normal file
86
test/unit/email-parsing.test.ts
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
import { describe, it, expect } from "vitest";
|
||||||
|
import { getHeader, decodeBody } from "../../src/tools/tools-email.js";
|
||||||
|
import { MOCK_MESSAGES, RAW_BODIES } from "../fixtures/mock-emails.js";
|
||||||
|
|
||||||
|
describe("getHeader", () => {
|
||||||
|
const headers = [
|
||||||
|
{ name: "From", value: "Jane <jane@example.com>" },
|
||||||
|
{ name: "Subject", value: "Hello World" },
|
||||||
|
{ name: "Date", value: "Mon, 17 Feb 2026 10:00:00 +0000" },
|
||||||
|
];
|
||||||
|
|
||||||
|
it("extracts a header by name (case-insensitive)", () => {
|
||||||
|
expect(getHeader(headers, "from")).toBe("Jane <jane@example.com>");
|
||||||
|
expect(getHeader(headers, "FROM")).toBe("Jane <jane@example.com>");
|
||||||
|
expect(getHeader(headers, "From")).toBe("Jane <jane@example.com>");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns empty string for missing header", () => {
|
||||||
|
expect(getHeader(headers, "Cc")).toBe("");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns empty string for undefined headers array", () => {
|
||||||
|
expect(getHeader(undefined, "From")).toBe("");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns empty string for empty headers array", () => {
|
||||||
|
expect(getHeader([], "From")).toBe("");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("decodeBody", () => {
|
||||||
|
it("decodes a multipart message (text/plain preferred)", () => {
|
||||||
|
const msg = MOCK_MESSAGES["msg-cat-a-001"].data;
|
||||||
|
const body = decodeBody(msg);
|
||||||
|
expect(body).toBe(RAW_BODIES["msg-cat-a-001"]);
|
||||||
|
expect(body).toContain("We have received your application");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("decodes a multipart message with only text/plain part", () => {
|
||||||
|
const msg = MOCK_MESSAGES["msg-cat-b-001"].data;
|
||||||
|
const body = decodeBody(msg);
|
||||||
|
expect(body).toBe(RAW_BODIES["msg-cat-b-001"]);
|
||||||
|
expect(body).toContain("schedule a phone screen");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("decodes a single-part message (no parts array)", () => {
|
||||||
|
const msg = MOCK_MESSAGES["msg-cat-c-001"].data;
|
||||||
|
const body = decodeBody(msg);
|
||||||
|
expect(body).toBe(RAW_BODIES["msg-cat-c-001"]);
|
||||||
|
expect(body).toContain("decided to pursue other candidates");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("falls back to snippet when body data is empty", () => {
|
||||||
|
const msg = {
|
||||||
|
snippet: "This is a snippet fallback",
|
||||||
|
payload: { body: { data: "" } },
|
||||||
|
};
|
||||||
|
expect(decodeBody(msg)).toBe("This is a snippet fallback");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("falls back to snippet when payload has no body data at all", () => {
|
||||||
|
const msg = {
|
||||||
|
snippet: "Snippet only",
|
||||||
|
payload: {},
|
||||||
|
};
|
||||||
|
expect(decodeBody(msg)).toBe("Snippet only");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns empty string when no body and no snippet", () => {
|
||||||
|
const msg = { payload: {} };
|
||||||
|
expect(decodeBody(msg)).toBe("");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("falls back to text/html when text/plain is missing in multipart", () => {
|
||||||
|
const htmlContent = "<p>Hello from HTML</p>";
|
||||||
|
const encoded = Buffer.from(htmlContent).toString("base64url");
|
||||||
|
const msg = {
|
||||||
|
payload: {
|
||||||
|
parts: [
|
||||||
|
{ mimeType: "text/html", body: { data: encoded } },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
expect(decodeBody(msg)).toBe(htmlContent);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -11,5 +11,5 @@
|
|||||||
"forceConsistentCasingInFileNames": true
|
"forceConsistentCasingInFileNames": true
|
||||||
},
|
},
|
||||||
"include": ["src/**/*"],
|
"include": ["src/**/*"],
|
||||||
"exclude": ["node_modules"]
|
"exclude": ["node_modules", "test", "vitest.config.ts"]
|
||||||
}
|
}
|
||||||
|
|||||||
8
vitest.config.ts
Normal file
8
vitest.config.ts
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
import { defineConfig } from "vitest/config";
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
test: {
|
||||||
|
include: ["test/**/*.test.ts"],
|
||||||
|
globals: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user