/** * 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); }); });