import type { AppError } from "@infra/errors"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { gmailApi, resolveGmailAccessToken } from "./gmail-api"; import { __test__ } from "./gmail-sync"; describe("gmail sync http behavior", () => { const originalClientId = process.env.GMAIL_OAUTH_CLIENT_ID; const originalClientSecret = process.env.GMAIL_OAUTH_CLIENT_SECRET; beforeEach(() => { vi.stubGlobal("fetch", vi.fn()); process.env.GMAIL_OAUTH_CLIENT_ID = "client-id"; process.env.GMAIL_OAUTH_CLIENT_SECRET = "client-secret"; }); afterEach(() => { process.env.GMAIL_OAUTH_CLIENT_ID = originalClientId; process.env.GMAIL_OAUTH_CLIENT_SECRET = originalClientSecret; vi.restoreAllMocks(); }); it("maps token refresh abort to REQUEST_TIMEOUT", async () => { vi.mocked(fetch).mockRejectedValueOnce( new DOMException("Aborted", "AbortError"), ); await expect( resolveGmailAccessToken({ refreshToken: "refresh-token" }), ).rejects.toMatchObject({ status: 408, code: "REQUEST_TIMEOUT", } satisfies Partial); }); it("throws upstream token refresh error when response is not ok", async () => { vi.mocked(fetch).mockResolvedValueOnce({ ok: false, status: 401, json: vi.fn().mockResolvedValue({ error: "invalid_grant" }), } as unknown as Response); await expect( resolveGmailAccessToken({ refreshToken: "refresh-token" }), ).rejects.toThrow("Gmail token refresh failed with HTTP 401."); }); it("returns refreshed credentials when token refresh succeeds", async () => { vi.mocked(fetch).mockResolvedValueOnce({ ok: true, status: 200, json: vi.fn().mockResolvedValue({ access_token: "new-access-token", expires_in: 1200, }), } as unknown as Response); const refreshed = await resolveGmailAccessToken({ refreshToken: "refresh-token", }); expect(refreshed.accessToken).toBe("new-access-token"); expect(typeof refreshed.expiryDate).toBe("number"); expect(refreshed.expiryDate).toBeGreaterThan(Date.now()); }); it("maps gmail API abort to REQUEST_TIMEOUT", async () => { vi.mocked(fetch).mockRejectedValueOnce( new DOMException("Aborted", "AbortError"), ); await expect( gmailApi("access-token", "https://gmail.googleapis.com/test"), ).rejects.toMatchObject({ status: 408, code: "REQUEST_TIMEOUT", } satisfies Partial); }); it("throws when gmail API response is not ok", async () => { vi.mocked(fetch).mockResolvedValueOnce({ ok: false, status: 502, json: vi.fn().mockResolvedValue({}), } as unknown as Response); await expect( gmailApi("access-token", "https://gmail.googleapis.com/test"), ).rejects.toThrow("Gmail API request failed (502)."); }); it("returns gmail API payload on success", async () => { vi.mocked(fetch).mockResolvedValueOnce({ ok: true, status: 200, json: vi.fn().mockResolvedValue({ id: "message-1" }), } as unknown as Response); const response = await gmailApi<{ id: string }>( "access-token", "https://gmail.googleapis.com/test", ); expect(response).toEqual({ id: "message-1" }); }); }); describe("gmail sync body extraction", () => { const encodeBase64Url = (value: string): string => Buffer.from(value, "utf8").toString("base64url"); it("removes scripts/styles/images and strips link URLs from html bodies", () => { const payload = { mimeType: "text/html", body: { data: encodeBase64Url(`

Hello there.

Apply now Banner `), }, }; const body = __test__.extractBodyText(payload); expect(body).toContain("Hello there."); expect(body).toContain("Apply now"); expect(body).not.toContain("https://example.com/apply?token=abc"); expect(body).not.toContain("display: none"); expect(body).not.toContain('console.log("secret")'); expect(body).not.toContain("banner.png"); }); it("uses text/plain only for multipart/alternative when plain text exceeds threshold", () => { const payload = { mimeType: "multipart/alternative", parts: [ { mimeType: "text/plain", body: { data: encodeBase64Url( "This plain text message is definitely longer than fifty characters and should win.", ), }, }, { mimeType: "text/html", body: { data: encodeBase64Url( "

HTML version should be ignored when plain text is long enough.

", ), }, }, ], }; const body = __test__.extractBodyText(payload); expect(body).toContain("plain text message"); expect(body).not.toContain("HTML version should be ignored"); }); it("prefers plain text even when multipart/alternative plain text is short", () => { const payload = { mimeType: "multipart/alternative", parts: [ { mimeType: "text/plain", body: { data: encodeBase64Url("Too short") }, }, { mimeType: "text/html", body: { data: encodeBase64Url("

Preferred HTML content

"), }, }, ], }; const body = __test__.extractBodyText(payload); expect(body).toContain("Too short"); expect(body).not.toContain("Preferred HTML content"); }); it("deduplicates repeated text chunks across parts", () => { const payload = { mimeType: "multipart/mixed", parts: [ { mimeType: "text/plain", body: { data: encodeBase64Url("Repeated sentence here.") }, }, { mimeType: "text/plain", body: { data: encodeBase64Url("Repeated sentence here.") }, }, ], }; const body = __test__.extractBodyText(payload); expect(body).toBe("Repeated sentence here."); }); it("returns empty string when payload is missing", () => { expect(__test__.extractBodyText(undefined)).toBe(""); }); }); describe("gmail sync prompt assembly", () => { it("omits snippet from email text sent to the llm", () => { const emailText = __test__.buildEmailText({ from: "jobs@example.com", subject: "Interview update", date: "Mon, 1 Jan 2026 10:00:00 +0000", body: "Hello from body", }); expect(emailText).toContain("From: jobs@example.com"); expect(emailText).toContain("Subject: Interview update"); expect(emailText).toContain("Date: Mon, 1 Jan 2026 10:00:00 +0000"); expect(emailText).toContain("Body:\nHello from body"); expect(emailText).not.toContain("Snippet:"); }); });