ce652ff5de
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>
146 lines
3.8 KiB
TypeScript
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,
|
|
});
|
|
});
|
|
}); |