test(time-tracking): unit tests for cron route, drain summary, workspace token (cycle 9)
Deploy to route.crispygoat.com / deploy (push) Successful in 4m17s
Deploy to route.crispygoat.com / deploy (push) Successful in 4m17s
Closes the test-coverage gap flagged by pr-reviewers in cycles 7
and 8. Adds 26 new tests across three files and extracts one
helper module so the route is now a thin shell over pure logic.
- New: src/services/sync-drain-summary.ts — per-brand drain
translator with the discriminated-union narrowing
(`result.skipped === true`) the cycle-8 reviewer flagged as
fragile. Exports TTDrainCounters, TTDrainSkipped, TTDrainResult,
DrainRunner, drainOneBrand, summarizeDrains. import server-only.
- New: tests/unit/resolve-workspace-token.test.ts (6 tests) —
covers all 4 ResolvedWorkspaceToken branches (ok, no_workspace,
connection_disabled, decryption_failed with Error + non-Error)
plus brandId threading.
- New: tests/unit/sync-drain-summary.test.ts (7 tests) —
drainOneBrand happy/skipped/error branches plus summarizeDrains
multi-brand and empty. Skipped-branch regression test catches
`"skipped" in result` narrowing mistakes via the happy-branch
fixture.
- New: tests/unit/time-tracking-smartsheet-sync-route.test.ts
(13 tests) — POST + GET auth (Bearer header, ?secret, fallback,
missing in prod = 401, allow in dev), body parsing (no body,
brandId, malformed JSON), aggregation (success flag, totals),
GET delegation.
Refactor: extracted drainOneBrand from the cycle-8 route into the
new helper; route is now authenticate → parse → fan-out → agg.
Bug fix while in here: authenticate() now reads env per-request
(not at module load), and uses readCronSecret() that treats 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).
vitest.config.ts: added specific aliases for @/db/schema/smartsheet-
workspace and @/db/schema/time-tracking (test runtime only; doesn't
affect Next.js builds).
Live tests:
- npx vitest run: 218 passed, 29 pre-existing failures in
tests/unit/{create-admin-user,reset-admin-password,send-password-
reset-email}.test.ts (unrelated to cycle 9; same baseline as cycle
8).
- npx tsc --noEmit: clean (no new errors).
- npm run lint: no new warnings on cycle 9 files.
- npm run build: EXIT 0 (41s); new route still registered.
- Smoke (port 4000): GET ?secret=qa-audit-cron-secret → 200;
GET (no auth) → 401; GET ?secret=wrong → 401.
PR-reviewer verdict: APPROVED. Three style notes fixed before
commit (test comment about narrowing, vi.stubEnv for NODE_ENV,
named TTDrainResult type).
Co-authored-by: cyc9-bot <bot@routecomm>
This commit is contained in:
@@ -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<string, string>;
|
||||
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<unknown>) =>
|
||||
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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user