diff --git a/src/app/api/time-tracking/smartsheet-sync/route.ts b/src/app/api/time-tracking/smartsheet-sync/route.ts index b6ad393..dd6b044 100644 --- a/src/app/api/time-tracking/smartsheet-sync/route.ts +++ b/src/app/api/time-tracking/smartsheet-sync/route.ts @@ -31,16 +31,31 @@ import { eq } from "drizzle-orm"; import { withPlatformAdmin } from "@/db/client"; import { timeTrackingSmartsheetConfig } from "@/db/schema/time-tracking"; import { runScheduledTTSync } from "@/services/time-tracking-smartsheet-sync"; - -const CRON_SECRET = - process.env.SMARTSHEET_CRON_SECRET ?? process.env.CRON_SECRET ?? ""; +import { + drainOneBrand, + summarizeDrains, +} from "@/services/sync-drain-summary"; function unauthorized() { return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); } +function readCronSecret(): string { + // Treat empty strings as "not set" so the CRON_SECRET fallback + // works even when SMARTSHEET_CRON_SECRET is present-but-empty + // (the vi.stubEnv idiom and some hosting dashboards). + const a = process.env.SMARTSHEET_CRON_SECRET?.trim(); + const b = process.env.CRON_SECRET?.trim(); + if (a) return a; + if (b) return b; + return ""; +} + function authenticate(req: NextRequest): boolean { - if (!CRON_SECRET) { + // Read env per-request so deploys can rotate CRON_SECRET without a + // module reload. + const secret = readCronSecret(); + if (!secret) { // No secret configured: refuse in prod, allow in dev so local // debugging doesn't need a token. return process.env.NODE_ENV !== "production"; @@ -49,53 +64,7 @@ function authenticate(req: NextRequest): boolean { .get("authorization") ?.replace(/^Bearer\s+/i, ""); const queryToken = new URL(req.url).searchParams.get("secret"); - return headerToken === CRON_SECRET || queryToken === CRON_SECRET; -} - -type BrandSummary = { - brandId: string; - considered: number; - synced: number; - skipped: number; - failed: number; - skippedReason?: string; - error?: string; -}; - -async function drainOneBrand(brandId: string): Promise { - try { - const result = await runScheduledTTSync(brandId); - // Discriminated union: TTDrainResult has `skipped: number`; the - // "skipped" branch has `skipped: true` (literal). The brand isn't - // due yet (hourly/15min gate) — return a zero summary so the - // totals stay clean. - if (result.skipped === true) { - return { - brandId, - considered: 0, - synced: 0, - skipped: 0, - failed: 0, - skippedReason: result.reason, - }; - } - return { - brandId, - considered: result.considered, - synced: result.synced, - skipped: result.skipped, - failed: result.failed, - }; - } catch (err) { - return { - brandId, - considered: 0, - synced: 0, - skipped: 0, - failed: 0, - error: (err as Error).message ?? "Unknown error", - }; - } + return headerToken === secret || queryToken === secret; } // We need to enumerate every brand with a time-tracking smartsheet @@ -131,18 +100,10 @@ export async function POST(req: NextRequest) { : await listActiveBrandIds(); const results = await Promise.all( - brandIds.map((brandId) => drainOneBrand(brandId)), + brandIds.map((brandId) => drainOneBrand(brandId, runScheduledTTSync)), ); - const totals = results.reduce( - (acc, r) => ({ - considered: acc.considered + r.considered, - synced: acc.synced + r.synced, - skipped: acc.skipped + r.skipped, - failed: acc.failed + r.failed, - }), - { considered: 0, synced: 0, skipped: 0, failed: 0 }, - ); + const totals = summarizeDrains(results); return NextResponse.json({ success: totals.failed === 0, diff --git a/src/services/sync-drain-summary.ts b/src/services/sync-drain-summary.ts new file mode 100644 index 0000000..239711d --- /dev/null +++ b/src/services/sync-drain-summary.ts @@ -0,0 +1,108 @@ +/** + * Per-brand drain summary for the cron route. + * + * Cycle 9 — extracted from `/api/time-tracking/smartsheet-sync/route.ts` + * so the discriminated-union narrowing (`result.skipped === true`) + * lives in a pure, unit-testable module instead of inside an HTTP + * handler. The route is now a thin shell that authenticates, parses + * the body, fans out to this helper, and aggregates the results. + * + * The TT sync service (`runScheduledTTSync`) returns either: + * - a regular `TTDrainResult` (counters) + * - `{ skipped: true, reason }` when the brand isn't due yet + * + * Both branches carry a `skipped` property, so we narrow on the + * literal `=== true` rather than the `in` operator — TS doesn't + * otherwise distinguish the two cases. + */ +import "server-only"; + +export type DrainSummary = { + brandId: string; + considered: number; + synced: number; + skipped: number; + failed: number; + /** Set when the brand isn't due yet (frequency gate). */ + skippedReason?: string; + /** Set when `run*` itself threw. */ + error?: string; +}; + +export type TTDrainCounters = { + brandId: string; + considered: number; + synced: number; + skipped: number; + failed: number; +}; + +export type TTDrainSkipped = { + skipped: true; + reason: string; +}; + +/** + * Mirrors the return shape of `runScheduledTTSync` (see + * `src/services/time-tracking-smartsheet-sync.ts`). The two branches + * share a `skipped` property by name but differ in type — literal + * `true` vs `number` — so callers narrow on `result.skipped === true`. + */ +export type TTDrainResult = TTDrainCounters | TTDrainSkipped; + +export type DrainRunner = (brandId: string) => Promise; + +/** + * Run the per-brand drain and translate the union into a flat + * summary that the cron route can aggregate. + * + * Errors are caught and surfaced in `error`, so one failing brand + * never aborts the batch. + */ +export async function drainOneBrand( + brandId: string, + run: DrainRunner, +): Promise { + try { + const result = await run(brandId); + if (result.skipped === true) { + return { + brandId, + considered: 0, + synced: 0, + skipped: 0, + failed: 0, + skippedReason: result.reason, + }; + } + return { + brandId, + considered: result.considered, + synced: result.synced, + skipped: result.skipped, + failed: result.failed, + }; + } catch (err) { + return { + brandId, + considered: 0, + synced: 0, + skipped: 0, + failed: 0, + error: err instanceof Error ? err.message : String(err), + }; + } +} + +/** Sum a list of per-brand summaries into route-level totals. */ +export function summarizeDrains(results: DrainSummary[]) { + return results.reduce( + (acc, r) => ({ + considered: acc.considered + r.considered, + synced: acc.synced + r.synced, + skipped: acc.skipped + r.skipped, + failed: acc.failed + r.failed, + }), + { considered: 0, synced: 0, skipped: 0, failed: 0 }, + ); +} \ No newline at end of file diff --git a/tests/unit/resolve-workspace-token.test.ts b/tests/unit/resolve-workspace-token.test.ts new file mode 100644 index 0000000..4e9093a --- /dev/null +++ b/tests/unit/resolve-workspace-token.test.ts @@ -0,0 +1,205 @@ +/** + * Unit tests for `resolveWorkspaceToken` — the security-critical + * helper that returns the plaintext Smartsheet API token for a brand. + * + * Every async export of a "use server" file is reachable as a server + * action RPC, so this helper was extracted to a plain server-only + * module (cycle 7) precisely to prevent accidental client exposure. + * The 4-branch discriminated union below is the security boundary — + * if it ever leaks or collapses, tokens can leak. + */ +import { describe, it, expect, vi, beforeEach } from "vitest"; + +const { mockWithBrand, mockDecryptToken } = vi.hoisted(() => ({ + mockWithBrand: vi.fn(), + mockDecryptToken: vi.fn(), +})); + +// Stub the server-only guard so the module can be imported under vitest. +vi.mock("server-only", () => ({})); + +// Mock the Drizzle client wrapper so we can simulate per-brand reads +// without hitting Postgres. +vi.mock("@/db/client", () => ({ + withBrand: mockWithBrand, +})); + +// Mock the AES-256-GCM decryption layer so we can simulate bad +// ciphertext without managing real keys / IVs. +vi.mock("@/lib/crypto", () => ({ + decryptToken: mockDecryptToken, +})); + +import { resolveWorkspaceToken } from "@/services/workspace-token"; + +beforeEach(() => { + mockWithBrand.mockReset(); + mockDecryptToken.mockReset(); +}); + +describe("resolveWorkspaceToken", () => { + it("returns ok:true with the plaintext token when the workspace exists, is enabled, and decrypts", async () => { + mockWithBrand.mockImplementationOnce( + async (_brandId: string, fn: (db: unknown) => Promise) => + fn({ + select: () => ({ + from: () => ({ + where: () => ({ + limit: () => [ + { + brandId: "brand-1", + encryptedApiToken: "cipher", + tokenIv: "iv", + tokenAuthTag: "tag", + connectionEnabled: true, + }, + ], + }), + }), + }), + }), + ); + mockDecryptToken.mockReturnValueOnce("plain-token-abc"); + + const result = await resolveWorkspaceToken("brand-1"); + expect(result).toEqual({ ok: true, token: "plain-token-abc" }); + expect(mockDecryptToken).toHaveBeenCalledWith({ + cipher: "cipher", + iv: "iv", + authTag: "tag", + }); + }); + + it("returns ok:false reason:no_workspace when the workspace row does not exist", async () => { + mockWithBrand.mockImplementationOnce( + async (_brandId: string, fn: (db: unknown) => Promise) => + fn({ + select: () => ({ + from: () => ({ + where: () => ({ + limit: () => [], + }), + }), + }), + }), + ); + + const result = await resolveWorkspaceToken("brand-1"); + expect(result).toEqual({ ok: false, reason: "no_workspace" }); + expect(mockDecryptToken).not.toHaveBeenCalled(); + }); + + it("returns ok:false reason:connection_disabled when the workspace is paused", async () => { + mockWithBrand.mockImplementationOnce( + async (_brandId: string, fn: (db: unknown) => Promise) => + fn({ + select: () => ({ + from: () => ({ + where: () => ({ + limit: () => [ + { + brandId: "brand-1", + encryptedApiToken: "cipher", + tokenIv: "iv", + tokenAuthTag: "tag", + connectionEnabled: false, + }, + ], + }), + }), + }), + }), + ); + + const result = await resolveWorkspaceToken("brand-1"); + expect(result).toEqual({ ok: false, reason: "connection_disabled" }); + expect(mockDecryptToken).not.toHaveBeenCalled(); + }); + + it("returns ok:false reason:decryption_failed with the underlying error message", async () => { + mockWithBrand.mockImplementationOnce( + async (_brandId: string, fn: (db: unknown) => Promise) => + fn({ + select: () => ({ + from: () => ({ + where: () => ({ + limit: () => [ + { + brandId: "brand-1", + encryptedApiToken: "cipher", + tokenIv: "iv", + tokenAuthTag: "tag", + connectionEnabled: true, + }, + ], + }), + }), + }), + }), + ); + mockDecryptToken.mockImplementationOnce(() => { + throw new Error("Unsupported state or unable to authenticate data"); + }); + + const result = await resolveWorkspaceToken("brand-1"); + expect(result).toEqual({ + ok: false, + reason: "decryption_failed", + error: "Unsupported state or unable to authenticate data", + }); + }); + + it("wraps non-Error thrown values in a string for decryption_failed", async () => { + mockWithBrand.mockImplementationOnce( + async (_brandId: string, fn: (db: unknown) => Promise) => + fn({ + select: () => ({ + from: () => ({ + where: () => ({ + limit: () => [ + { + brandId: "brand-1", + encryptedApiToken: "cipher", + tokenIv: "iv", + tokenAuthTag: "tag", + connectionEnabled: true, + }, + ], + }), + }), + }), + }), + ); + mockDecryptToken.mockImplementationOnce(() => { + throw "string-throw-not-error"; + }); + + const result = await resolveWorkspaceToken("brand-1"); + expect(result).toEqual({ + ok: false, + reason: "decryption_failed", + error: "string-throw-not-error", + }); + }); + + it("threads the brandId through to withBrand", async () => { + mockWithBrand.mockImplementationOnce( + async (_brandId: string, fn: (db: unknown) => Promise) => + fn({ + select: () => ({ + from: () => ({ + where: () => ({ + limit: () => [], + }), + }), + }), + }), + ); + + await resolveWorkspaceToken("the-right-brand"); + expect(mockWithBrand).toHaveBeenCalledWith( + "the-right-brand", + expect.any(Function), + ); + }); +}); \ No newline at end of file diff --git a/tests/unit/sync-drain-summary.test.ts b/tests/unit/sync-drain-summary.test.ts new file mode 100644 index 0000000..ae27d8a --- /dev/null +++ b/tests/unit/sync-drain-summary.test.ts @@ -0,0 +1,146 @@ +/** + * Unit tests for `src/services/sync-drain-summary.ts` — the per-brand + * drain translator that the cycle-8 cron route delegates to. + * + * The interesting case here is the discriminated union narrowing: + * `runScheduledTTSync` returns either a regular `TTDrainResult` + * (counters, `skipped: number`) or `{ skipped: true, reason }`. Both + * branches carry a `skipped` property, so narrowing on the literal + * `result.skipped === true` is the only correct disambiguation. + * + * Regression coverage: if narrowing switched to `"skipped" in result`, + * the happy-branch test (lines 30-44) would fail because + * `{ skipped: 1, ... }` would misroute into the if-branch and + * `result.reason` would be undefined. + */ +import { describe, it, expect, vi } from "vitest"; + +vi.mock("server-only", () => ({})); + +import { + drainOneBrand, + summarizeDrains, + type DrainRunner, +} from "@/services/sync-drain-summary"; + +describe("drainOneBrand", () => { + it("forwards a regular TTDrainResult as a flat DrainSummary", async () => { + const run: DrainRunner = vi.fn(async (brandId) => ({ + brandId, + considered: 5, + synced: 3, + skipped: 1, + failed: 1, + })); + + const summary = await drainOneBrand("brand-1", run); + expect(summary).toEqual({ + brandId: "brand-1", + considered: 5, + synced: 3, + skipped: 1, + failed: 1, + }); + expect(summary.skippedReason).toBeUndefined(); + expect(summary.error).toBeUndefined(); + }); + + it("translates the { skipped: true, reason } branch into a zero summary with skippedReason", async () => { + const run: DrainRunner = vi.fn(async () => ({ + skipped: true as const, + reason: "Last sync 3m ago; need 15m", + })); + + const summary = await drainOneBrand("brand-1", run); + expect(summary).toEqual({ + brandId: "brand-1", + considered: 0, + synced: 0, + skipped: 0, + failed: 0, + skippedReason: "Last sync 3m ago; need 15m", + }); + }); + + it("catches a thrown Error and surfaces it as `error` (does not reject)", async () => { + const run: DrainRunner = vi.fn(async () => { + throw new Error("Smartsheet 401"); + }); + + const summary = await drainOneBrand("brand-1", run); + expect(summary).toEqual({ + brandId: "brand-1", + considered: 0, + synced: 0, + skipped: 0, + failed: 0, + error: "Smartsheet 401", + }); + }); + + it("coerces non-Error thrown values to a string", async () => { + const run: DrainRunner = vi.fn(async () => { + throw "string-failure"; + }); + + const summary = await drainOneBrand("brand-1", run); + expect(summary.error).toBe("string-failure"); + }); + + it("threads brandId to the runner", async () => { + const run: DrainRunner = vi.fn(async () => ({ + brandId: "ignored", + considered: 0, + synced: 0, + skipped: 0, + failed: 0, + })); + await drainOneBrand("the-real-brand", run); + expect(run).toHaveBeenCalledWith("the-real-brand"); + }); +}); + +describe("summarizeDrains", () => { + it("sums every counter across all per-brand summaries", () => { + const totals = summarizeDrains([ + { + brandId: "a", + considered: 5, + synced: 3, + skipped: 1, + failed: 1, + }, + { + brandId: "b", + considered: 4, + synced: 2, + skipped: 1, + failed: 1, + skippedReason: "Not due", + }, + { + brandId: "c", + considered: 0, + synced: 0, + skipped: 0, + failed: 0, + error: "401", + }, + ]); + expect(totals).toEqual({ + considered: 9, + synced: 5, + skipped: 2, + failed: 2, + }); + }); + + it("returns zeros for an empty batch", () => { + expect(summarizeDrains([])).toEqual({ + considered: 0, + synced: 0, + skipped: 0, + failed: 0, + }); + }); +}); \ No newline at end of file diff --git a/tests/unit/time-tracking-smartsheet-sync-route.test.ts b/tests/unit/time-tracking-smartsheet-sync-route.test.ts new file mode 100644 index 0000000..3e99b0d --- /dev/null +++ b/tests/unit/time-tracking-smartsheet-sync-route.test.ts @@ -0,0 +1,351 @@ +/** + * Unit tests for the cycle-8 cron route at + * `src/app/api/time-tracking/smartsheet-sync/route.ts`. + * + * The route is intentionally thin: authenticate → parse body → fan + * out per brand → aggregate. These tests mock the three external + * dependencies (withPlatformAdmin, timeTrackingSmartsheetConfig, and + * runScheduledTTSync) and exercise: + * + * - auth (Bearer header, ?secret query, missing-in-prod, allow-in-dev) + * - body parsing (optional, malformed JSON doesn't crash) + * - body.brandId restricts to one brand; omitted fans out to all + * - success flag is false when any brand has failures + * + * The pure per-brand drain logic is tested separately in + * `sync-drain-summary.test.ts`. + */ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { NextRequest } from "next/server"; + +const { + mockWithPlatformAdmin, + mockRunScheduledTTSync, + mockDrainOneBrand, + mockSummarizeDrains, + mockSelect, + mockFrom, + mockWhere, + mockLimit, + mockTimeTrackingSmartsheetConfig, +} = vi.hoisted(() => { + const mockSelect = vi.fn(); + const mockFrom = vi.fn(); + const mockWhere = vi.fn(); + const mockLimit = vi.fn(); + const mockTimeTrackingSmartsheetConfig = { brandId: "fake_brand_id_col" }; + return { + mockWithPlatformAdmin: vi.fn(), + mockRunScheduledTTSync: vi.fn(), + mockDrainOneBrand: vi.fn(), + mockSummarizeDrains: vi.fn(), + mockSelect, + mockFrom, + mockWhere, + mockLimit, + mockTimeTrackingSmartsheetConfig, + }; +}); + +vi.mock("server-only", () => ({})); + +vi.mock("@/db/client", () => ({ + withPlatformAdmin: mockWithPlatformAdmin, +})); + +vi.mock("@/db/schema/time-tracking", () => ({ + timeTrackingSmartsheetConfig: mockTimeTrackingSmartsheetConfig, +})); + +vi.mock("@/services/time-tracking-smartsheet-sync", () => ({ + runScheduledTTSync: mockRunScheduledTTSync, +})); + +vi.mock("@/services/sync-drain-summary", () => ({ + drainOneBrand: mockDrainOneBrand, + summarizeDrains: mockSummarizeDrains, +})); + +// Import the route AFTER all mocks are wired up. +import { POST, GET } from "@/app/api/time-tracking/smartsheet-sync/route"; + +const ORIGINAL_ENV = { ...process.env }; + +function fakeNextRequest({ + url = "http://localhost/api/time-tracking/smartsheet-sync", + method = "POST", + headers = {}, + body, +}: { + url?: string; + method?: string; + headers?: Record; + body?: string; +} = {}) { + return new NextRequest(url, { + method, + headers, + body, + }); +} + +function setCronSecret(secret: string | undefined, nodeEnv = "test") { + // vi.stubEnv handles the typed-readonly quirk on NODE_ENV for us + // and restores the previous value on vi.unstubAllEnvs(). + vi.stubEnv("NODE_ENV", nodeEnv); + if (secret === undefined) { + vi.stubEnv("SMARTSHEET_CRON_SECRET", ""); + vi.stubEnv("CRON_SECRET", ""); + } else { + vi.stubEnv("SMARTSHEET_CRON_SECRET", secret); + vi.stubEnv("CRON_SECRET", ""); + } +} + +beforeEach(() => { + vi.resetModules(); + mockWithPlatformAdmin.mockReset(); + mockRunScheduledTTSync.mockReset(); + mockDrainOneBrand.mockReset(); + mockSummarizeDrains.mockReset(); + mockSelect.mockReset(); + mockFrom.mockReset(); + mockWhere.mockReset(); + mockLimit.mockReset(); + + // Default chain: select → from → where → (thenable rows) + // The route's listActiveBrandIds does `db.select(...).from(...).where(...)` + // and awaits the result directly (no `.limit()`), so the where() + // call has to resolve to an array of rows. + const defaultRows = [{ id: "brand-a" }, { id: "brand-b" }]; + mockWhere.mockImplementation(() => Promise.resolve(defaultRows)); + mockFrom.mockReturnValue({ where: mockWhere }); + mockSelect.mockReturnValue({ from: mockFrom }); + + mockWithPlatformAdmin.mockImplementation( + async (fn: (db: unknown) => Promise) => + fn({ select: mockSelect }), + ); + + // Default behavior: pretend every brand skipped (so we can assert + // per-brand delegation works without coupling to drainOneBrand's + // own logic). + mockDrainOneBrand.mockImplementation(async (brandId: string) => ({ + brandId, + considered: 0, + synced: 0, + skipped: 0, + failed: 0, + skippedReason: "no-op", + })); + + mockSummarizeDrains.mockReturnValue({ + considered: 0, + synced: 0, + skipped: 0, + failed: 0, + }); +}); + +afterEach(() => { + vi.unstubAllEnvs(); + // Re-apply ORIGINAL_ENV in case anything outside vi.stubEnv mutated it + // (defense-in-depth; the production code only reads via process.env). + process.env = { ...ORIGINAL_ENV }; +}); + +describe("auth", () => { + it("rejects with 401 when CRON_SECRET is set and no token is presented in prod", async () => { + setCronSecret("the-secret", "production"); + const res = await POST(fakeNextRequest({})); + expect(res.status).toBe(401); + expect(mockDrainOneBrand).not.toHaveBeenCalled(); + }); + + it("rejects with 401 when the Bearer header does not match", async () => { + setCronSecret("the-secret", "production"); + const res = await POST( + fakeNextRequest({ + headers: { authorization: "Bearer wrong-token" }, + }), + ); + expect(res.status).toBe(401); + }); + + it("accepts a matching Bearer header in prod", async () => { + setCronSecret("the-secret", "production"); + const res = await POST( + fakeNextRequest({ + headers: { authorization: "Bearer the-secret" }, + }), + ); + expect(res.status).toBe(200); + }); + + it("accepts a matching ?secret query in prod", async () => { + setCronSecret("the-secret", "production"); + const res = await POST( + fakeNextRequest({ + url: "http://localhost/api/time-tracking/smartsheet-sync?secret=the-secret", + }), + ); + expect(res.status).toBe(200); + }); + + it("falls back to CRON_SECRET when SMARTSHEET_CRON_SECRET is unset", async () => { + vi.stubEnv("SMARTSHEET_CRON_SECRET", ""); + vi.stubEnv("CRON_SECRET", "fallback-secret"); + // NODE_ENV is typed as readonly on the index signature; mutate + // through Object.assign so the test reads the new value while the + // TS compiler stays quiet. + Object.assign(process.env, { NODE_ENV: "production" }); + + const res = await POST( + fakeNextRequest({ + headers: { authorization: "Bearer fallback-secret" }, + }), + ); + expect(res.status).toBe(200); + }); + + it("allows unauthenticated calls in non-production when no secret is configured", async () => { + setCronSecret(undefined, "development"); + const res = await POST(fakeNextRequest({})); + expect(res.status).toBe(200); + }); +}); + +describe("body parsing", () => { + beforeEach(() => { + setCronSecret("the-secret", "production"); + }); + + it("fans out to every active brand when no body is supplied", async () => { + const res = await POST( + fakeNextRequest({ + headers: { authorization: "Bearer the-secret" }, + }), + ); + expect(res.status).toBe(200); + // Two rows came back from the limit() chain → two brands drained + expect(mockDrainOneBrand).toHaveBeenCalledTimes(2); + expect(mockDrainOneBrand).toHaveBeenNthCalledWith( + 1, + "brand-a", + mockRunScheduledTTSync, + ); + expect(mockDrainOneBrand).toHaveBeenNthCalledWith( + 2, + "brand-b", + mockRunScheduledTTSync, + ); + }); + + it("restricts to the requested brandId when supplied", async () => { + const res = await POST( + fakeNextRequest({ + headers: { authorization: "Bearer the-secret" }, + body: JSON.stringify({ brandId: "brand-only" }), + }), + ); + expect(res.status).toBe(200); + expect(mockDrainOneBrand).toHaveBeenCalledTimes(1); + expect(mockDrainOneBrand).toHaveBeenCalledWith( + "brand-only", + mockRunScheduledTTSync, + ); + // Should NOT have queried the DB for the active brand list + expect(mockWithPlatformAdmin).not.toHaveBeenCalled(); + }); + + it("tolerates a malformed JSON body (treats as no body)", async () => { + const res = await POST( + fakeNextRequest({ + headers: { authorization: "Bearer the-secret" }, + body: "this-is-not-json", + }), + ); + expect(res.status).toBe(200); + // No body.brandId → fans out to active brands + expect(mockDrainOneBrand).toHaveBeenCalledTimes(2); + }); +}); + +describe("aggregation", () => { + beforeEach(() => { + setCronSecret("the-secret", "production"); + }); + + it("returns success:true and aggregates per-brand summaries", async () => { + mockDrainOneBrand.mockImplementation(async (brandId: string) => ({ + brandId, + considered: 2, + synced: 1, + skipped: 1, + failed: 0, + })); + mockSummarizeDrains.mockReturnValue({ + considered: 2, + synced: 1, + skipped: 1, + failed: 0, + }); + + const res = await POST( + fakeNextRequest({ + headers: { authorization: "Bearer the-secret" }, + body: JSON.stringify({ brandId: "brand-only" }), + }), + ); + expect(res.status).toBe(200); + const body = await res.json(); + expect(body.success).toBe(true); + expect(body.totals).toEqual({ + considered: 2, + synced: 1, + skipped: 1, + failed: 0, + }); + expect(body.results).toHaveLength(1); + }); + + it("returns success:false when any brand has a failure", async () => { + mockSummarizeDrains.mockReturnValue({ + considered: 0, + synced: 0, + skipped: 0, + failed: 1, + }); + + const res = await POST( + fakeNextRequest({ + headers: { authorization: "Bearer the-secret" }, + body: JSON.stringify({ brandId: "brand-only" }), + }), + ); + expect(res.status).toBe(200); + const body = await res.json(); + expect(body.success).toBe(false); + expect(body.totals.failed).toBe(1); + }); +}); + +describe("GET", () => { + it("delegates to POST (same auth + body handling)", async () => { + setCronSecret("the-secret", "production"); + const res = await GET( + fakeNextRequest({ + method: "GET", + headers: { authorization: "Bearer the-secret" }, + }), + ); + expect(res.status).toBe(200); + expect(mockDrainOneBrand).toHaveBeenCalled(); + }); + + it("returns 401 on GET when auth fails", async () => { + setCronSecret("the-secret", "production"); + const res = await GET(fakeNextRequest({ method: "GET" })); + expect(res.status).toBe(401); + }); +}); \ No newline at end of file diff --git a/vitest.config.ts b/vitest.config.ts index a12d55c..df83d21 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -9,6 +9,8 @@ export default defineConfig({ { find: /^@\/(?!db)/, replacement: path.resolve(__dirname, "src") + "/" }, // Specific sub-paths must come before the general @/db catch-all. { find: "@/db/schema/water-log", replacement: path.resolve(__dirname, "db/schema/water-log.ts") }, + { find: "@/db/schema/smartsheet-workspace", replacement: path.resolve(__dirname, "db/schema/smartsheet-workspace.ts") }, + { find: "@/db/schema/time-tracking", replacement: path.resolve(__dirname, "db/schema/time-tracking.ts") }, { find: "@/db/client", replacement: path.resolve(__dirname, "db/client.ts") }, { find: "@/db/schema", replacement: path.resolve(__dirname, "db/schema/index.ts") }, { find: "@/db", replacement: path.resolve(__dirname, "db") },