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