* chore: move @types/canvas-confetti to devDependencies, remove unused get-tsconfig direct dep * chore: configure knip with workspace entry points for all packages * refactor(shared): split 1119-line types.ts into domain modules under types/ * refactor: remove llm-service.ts shim, migrate all import sites to llm/service directly * refactor(settings): migrate 4 manually-resolved settings into conversion registry * refactor: split gmail-sync.ts into gmail-api, email-router, and thin orchestrator * refactor(orchestrator): extract useKeyboardShortcuts and usePipelineControls from OrchestratorPage Splits the 840-line OrchestratorPage into a thin orchestration shell (~480 lines) by extracting keyboard shortcut handling into useKeyboardShortcuts.ts and pipeline control logic into usePipelineControls.ts. Net negative line count across all files. * feat: create settings registry (Step 1) Introduces a single source of truth for all settings, combining schema definitions, default logic, parsing, and serialization into a single configuration object. * feat: derive schema, keys, and types from settings registry (Step 2) Derives AppSettings nested shape, SettingKey DB union, and updateSettingsSchema Zod shape automatically from the settings registry. * refactor: gut envSettings and remove settings-conversion (Step 3) Replaces manual env arrays with registry-driven maps in envSettings.ts. Deletes settings-conversion.ts since all parsing/defaults now live in the registry. * refactor: simplify getEffectiveSettings with generic loop (Step 4) Replaces ~334 lines of manual key-by-key unpacking with a generic registry-driven iteration loop (~40 lines). Models, typed, string, and virtual kinds are automatically derived. * refactor: simplify settingsUpdateRegistry (Step 5) Replaces ~350 lines of explicit per-key update handlers with a dynamic generic loop over the settings registry, properly routing persistence and side effects. * refactor(settings): implement nested settings registry and clean up tests - Migrate settings system to use a centralized nested registry (`settings-schema.ts`, `registry.ts`) - Remove obsolete flat-to-nested conversion logic (`settings-conversion.ts`) - Address Biome warnings by explicitly ignoring intentional `any` usage in generic runtime schema builder and registry logic - Clean up unused variables in test files (`SettingsPage.test.tsx`) to achieve a 100% green CI pipeline * refactor(settings): address PR comments on env data and registry parsing - Narrow `getEnvSettingsData` return type to `Partial<AppSettings>` to satisfy strict typing and omit 'typed' registry entries - Introduce `parseNonEmptyStringOrNull` for typed string settings so empty-string overrides cleanly fall back to defaults (matching original `||` logic) - Add missing unit tests for registry parse/serialize helpers (JSON, bools, numeric clamping)
231 lines
7.0 KiB
TypeScript
231 lines
7.0 KiB
TypeScript
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<AppError>);
|
|
});
|
|
|
|
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<AppError>);
|
|
});
|
|
|
|
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(`
|
|
<html>
|
|
<head>
|
|
<style>.hidden { display: none; }</style>
|
|
<script>console.log("secret");</script>
|
|
</head>
|
|
<body>
|
|
<p>Hello <strong>there</strong>.</p>
|
|
<a href="https://example.com/apply?token=abc">Apply now</a>
|
|
<img src="https://example.com/banner.png" alt="Banner">
|
|
</body>
|
|
</html>
|
|
`),
|
|
},
|
|
};
|
|
|
|
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(
|
|
"<p>HTML version should be ignored when plain text is long enough.</p>",
|
|
),
|
|
},
|
|
},
|
|
],
|
|
};
|
|
|
|
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("<p>Preferred <b>HTML</b> content</p>"),
|
|
},
|
|
},
|
|
],
|
|
};
|
|
|
|
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:");
|
|
});
|
|
});
|