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:
@@ -31,16 +31,31 @@ import { eq } from "drizzle-orm";
|
|||||||
import { withPlatformAdmin } from "@/db/client";
|
import { withPlatformAdmin } from "@/db/client";
|
||||||
import { timeTrackingSmartsheetConfig } from "@/db/schema/time-tracking";
|
import { timeTrackingSmartsheetConfig } from "@/db/schema/time-tracking";
|
||||||
import { runScheduledTTSync } from "@/services/time-tracking-smartsheet-sync";
|
import { runScheduledTTSync } from "@/services/time-tracking-smartsheet-sync";
|
||||||
|
import {
|
||||||
const CRON_SECRET =
|
drainOneBrand,
|
||||||
process.env.SMARTSHEET_CRON_SECRET ?? process.env.CRON_SECRET ?? "";
|
summarizeDrains,
|
||||||
|
} from "@/services/sync-drain-summary";
|
||||||
|
|
||||||
function unauthorized() {
|
function unauthorized() {
|
||||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function readCronSecret(): string {
|
||||||
|
// Treat 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).
|
||||||
|
const a = process.env.SMARTSHEET_CRON_SECRET?.trim();
|
||||||
|
const b = process.env.CRON_SECRET?.trim();
|
||||||
|
if (a) return a;
|
||||||
|
if (b) return b;
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
function authenticate(req: NextRequest): boolean {
|
function authenticate(req: NextRequest): boolean {
|
||||||
if (!CRON_SECRET) {
|
// Read env per-request so deploys can rotate CRON_SECRET without a
|
||||||
|
// module reload.
|
||||||
|
const secret = readCronSecret();
|
||||||
|
if (!secret) {
|
||||||
// No secret configured: refuse in prod, allow in dev so local
|
// No secret configured: refuse in prod, allow in dev so local
|
||||||
// debugging doesn't need a token.
|
// debugging doesn't need a token.
|
||||||
return process.env.NODE_ENV !== "production";
|
return process.env.NODE_ENV !== "production";
|
||||||
@@ -49,53 +64,7 @@ function authenticate(req: NextRequest): boolean {
|
|||||||
.get("authorization")
|
.get("authorization")
|
||||||
?.replace(/^Bearer\s+/i, "");
|
?.replace(/^Bearer\s+/i, "");
|
||||||
const queryToken = new URL(req.url).searchParams.get("secret");
|
const queryToken = new URL(req.url).searchParams.get("secret");
|
||||||
return headerToken === CRON_SECRET || queryToken === CRON_SECRET;
|
return headerToken === secret || queryToken === secret;
|
||||||
}
|
|
||||||
|
|
||||||
type BrandSummary = {
|
|
||||||
brandId: string;
|
|
||||||
considered: number;
|
|
||||||
synced: number;
|
|
||||||
skipped: number;
|
|
||||||
failed: number;
|
|
||||||
skippedReason?: string;
|
|
||||||
error?: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
async function drainOneBrand(brandId: string): Promise<BrandSummary> {
|
|
||||||
try {
|
|
||||||
const result = await runScheduledTTSync(brandId);
|
|
||||||
// Discriminated union: TTDrainResult has `skipped: number`; the
|
|
||||||
// "skipped" branch has `skipped: true` (literal). The brand isn't
|
|
||||||
// due yet (hourly/15min gate) — return a zero summary so the
|
|
||||||
// totals stay clean.
|
|
||||||
if (result.skipped === true) {
|
|
||||||
return {
|
|
||||||
brandId,
|
|
||||||
considered: 0,
|
|
||||||
synced: 0,
|
|
||||||
skipped: 0,
|
|
||||||
failed: 0,
|
|
||||||
skippedReason: result.reason,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
brandId,
|
|
||||||
considered: result.considered,
|
|
||||||
synced: result.synced,
|
|
||||||
skipped: result.skipped,
|
|
||||||
failed: result.failed,
|
|
||||||
};
|
|
||||||
} catch (err) {
|
|
||||||
return {
|
|
||||||
brandId,
|
|
||||||
considered: 0,
|
|
||||||
synced: 0,
|
|
||||||
skipped: 0,
|
|
||||||
failed: 0,
|
|
||||||
error: (err as Error).message ?? "Unknown error",
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// We need to enumerate every brand with a time-tracking smartsheet
|
// We need to enumerate every brand with a time-tracking smartsheet
|
||||||
@@ -131,18 +100,10 @@ export async function POST(req: NextRequest) {
|
|||||||
: await listActiveBrandIds();
|
: await listActiveBrandIds();
|
||||||
|
|
||||||
const results = await Promise.all(
|
const results = await Promise.all(
|
||||||
brandIds.map((brandId) => drainOneBrand(brandId)),
|
brandIds.map((brandId) => drainOneBrand(brandId, runScheduledTTSync)),
|
||||||
);
|
);
|
||||||
|
|
||||||
const totals = results.reduce(
|
const totals = summarizeDrains(results);
|
||||||
(acc, r) => ({
|
|
||||||
considered: acc.considered + r.considered,
|
|
||||||
synced: acc.synced + r.synced,
|
|
||||||
skipped: acc.skipped + r.skipped,
|
|
||||||
failed: acc.failed + r.failed,
|
|
||||||
}),
|
|
||||||
{ considered: 0, synced: 0, skipped: 0, failed: 0 },
|
|
||||||
);
|
|
||||||
|
|
||||||
return NextResponse.json({
|
return NextResponse.json({
|
||||||
success: totals.failed === 0,
|
success: totals.failed === 0,
|
||||||
|
|||||||
@@ -0,0 +1,108 @@
|
|||||||
|
/**
|
||||||
|
* Per-brand drain summary for the cron route.
|
||||||
|
*
|
||||||
|
* Cycle 9 — extracted from `/api/time-tracking/smartsheet-sync/route.ts`
|
||||||
|
* so the discriminated-union narrowing (`result.skipped === true`)
|
||||||
|
* lives in a pure, unit-testable module instead of inside an HTTP
|
||||||
|
* handler. The route is now a thin shell that authenticates, parses
|
||||||
|
* the body, fans out to this helper, and aggregates the results.
|
||||||
|
*
|
||||||
|
* The TT sync service (`runScheduledTTSync`) returns either:
|
||||||
|
* - a regular `TTDrainResult` (counters)
|
||||||
|
* - `{ skipped: true, reason }` when the brand isn't due yet
|
||||||
|
*
|
||||||
|
* Both branches carry a `skipped` property, so we narrow on the
|
||||||
|
* literal `=== true` rather than the `in` operator — TS doesn't
|
||||||
|
* otherwise distinguish the two cases.
|
||||||
|
*/
|
||||||
|
import "server-only";
|
||||||
|
|
||||||
|
export type DrainSummary = {
|
||||||
|
brandId: string;
|
||||||
|
considered: number;
|
||||||
|
synced: number;
|
||||||
|
skipped: number;
|
||||||
|
failed: number;
|
||||||
|
/** Set when the brand isn't due yet (frequency gate). */
|
||||||
|
skippedReason?: string;
|
||||||
|
/** Set when `run*` itself threw. */
|
||||||
|
error?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type TTDrainCounters = {
|
||||||
|
brandId: string;
|
||||||
|
considered: number;
|
||||||
|
synced: number;
|
||||||
|
skipped: number;
|
||||||
|
failed: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type TTDrainSkipped = {
|
||||||
|
skipped: true;
|
||||||
|
reason: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mirrors the return shape of `runScheduledTTSync` (see
|
||||||
|
* `src/services/time-tracking-smartsheet-sync.ts`). The two branches
|
||||||
|
* share a `skipped` property by name but differ in type — literal
|
||||||
|
* `true` vs `number` — so callers narrow on `result.skipped === true`.
|
||||||
|
*/
|
||||||
|
export type TTDrainResult = TTDrainCounters | TTDrainSkipped;
|
||||||
|
|
||||||
|
export type DrainRunner = (brandId: string) => Promise<TTDrainResult>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Run the per-brand drain and translate the union into a flat
|
||||||
|
* summary that the cron route can aggregate.
|
||||||
|
*
|
||||||
|
* Errors are caught and surfaced in `error`, so one failing brand
|
||||||
|
* never aborts the batch.
|
||||||
|
*/
|
||||||
|
export async function drainOneBrand(
|
||||||
|
brandId: string,
|
||||||
|
run: DrainRunner,
|
||||||
|
): Promise<DrainSummary> {
|
||||||
|
try {
|
||||||
|
const result = await run(brandId);
|
||||||
|
if (result.skipped === true) {
|
||||||
|
return {
|
||||||
|
brandId,
|
||||||
|
considered: 0,
|
||||||
|
synced: 0,
|
||||||
|
skipped: 0,
|
||||||
|
failed: 0,
|
||||||
|
skippedReason: result.reason,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
brandId,
|
||||||
|
considered: result.considered,
|
||||||
|
synced: result.synced,
|
||||||
|
skipped: result.skipped,
|
||||||
|
failed: result.failed,
|
||||||
|
};
|
||||||
|
} catch (err) {
|
||||||
|
return {
|
||||||
|
brandId,
|
||||||
|
considered: 0,
|
||||||
|
synced: 0,
|
||||||
|
skipped: 0,
|
||||||
|
failed: 0,
|
||||||
|
error: err instanceof Error ? err.message : String(err),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Sum a list of per-brand summaries into route-level totals. */
|
||||||
|
export function summarizeDrains(results: DrainSummary[]) {
|
||||||
|
return results.reduce(
|
||||||
|
(acc, r) => ({
|
||||||
|
considered: acc.considered + r.considered,
|
||||||
|
synced: acc.synced + r.synced,
|
||||||
|
skipped: acc.skipped + r.skipped,
|
||||||
|
failed: acc.failed + r.failed,
|
||||||
|
}),
|
||||||
|
{ considered: 0, synced: 0, skipped: 0, failed: 0 },
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -9,6 +9,8 @@ export default defineConfig({
|
|||||||
{ find: /^@\/(?!db)/, replacement: path.resolve(__dirname, "src") + "/" },
|
{ find: /^@\/(?!db)/, replacement: path.resolve(__dirname, "src") + "/" },
|
||||||
// Specific sub-paths must come before the general @/db catch-all.
|
// Specific sub-paths must come before the general @/db catch-all.
|
||||||
{ find: "@/db/schema/water-log", replacement: path.resolve(__dirname, "db/schema/water-log.ts") },
|
{ find: "@/db/schema/water-log", replacement: path.resolve(__dirname, "db/schema/water-log.ts") },
|
||||||
|
{ find: "@/db/schema/smartsheet-workspace", replacement: path.resolve(__dirname, "db/schema/smartsheet-workspace.ts") },
|
||||||
|
{ find: "@/db/schema/time-tracking", replacement: path.resolve(__dirname, "db/schema/time-tracking.ts") },
|
||||||
{ find: "@/db/client", replacement: path.resolve(__dirname, "db/client.ts") },
|
{ find: "@/db/client", replacement: path.resolve(__dirname, "db/client.ts") },
|
||||||
{ find: "@/db/schema", replacement: path.resolve(__dirname, "db/schema/index.ts") },
|
{ find: "@/db/schema", replacement: path.resolve(__dirname, "db/schema/index.ts") },
|
||||||
{ find: "@/db", replacement: path.resolve(__dirname, "db") },
|
{ find: "@/db", replacement: path.resolve(__dirname, "db") },
|
||||||
|
|||||||
Reference in New Issue
Block a user