basic unit tests
This commit is contained in:
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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user