basic unit tests

This commit is contained in:
KS Jannette
2026-02-18 07:26:13 -05:00
parent 410ee0656f
commit a0e1b43360
8 changed files with 2012 additions and 6 deletions

167
test/fixtures/mock-emails.ts vendored Normal file
View 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,
};

View 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");
});
});
});

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