test(time-tracking): unit tests for cron route, drain summary, workspace token (cycle 9)
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:
Nora
2026-07-03 19:37:53 -06:00
parent da488b2cb0
commit ce652ff5de
6 changed files with 834 additions and 61 deletions
+205
View File
@@ -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<unknown>) =>
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<unknown>) =>
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<unknown>) =>
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<unknown>) =>
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<unknown>) =>
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<unknown>) =>
fn({
select: () => ({
from: () => ({
where: () => ({
limit: () => [],
}),
}),
}),
}),
);
await resolveWorkspaceToken("the-right-brand");
expect(mockWithBrand).toHaveBeenCalledWith(
"the-right-brand",
expect.any(Function),
);
});
});
+146
View File
@@ -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,
});
});
});
@@ -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);
});
});