Files
route-commerce/tests/unit/sync-drain-summary.test.ts
T
Nora ce652ff5de
Deploy to route.crispygoat.com / deploy (push) Successful in 4m17s
test(time-tracking): unit tests for cron route, drain summary, workspace token (cycle 9)
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>
2026-07-03 19:37:53 -06:00

146 lines
3.8 KiB
TypeScript

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