mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 20:42:43 +00:00
test(01-14): add failing truthful cohort contracts
- distinguish complete partial failed parked skipped and clean-zero outcomes - require bounded privacy-safe scalar serialization and retry codes
This commit is contained in:
1 parent
34372b57bd
commit
690c4aeb5a
2 files changed
+455
No files matched your search
@@ -0,0 +1,195 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { jobDone, jobFailed, type JobOutcome } from "@/lib/jobs/job-outcome";
|
||||
|
||||
type SerializableJobOutcome = {
|
||||
ok: boolean;
|
||||
reason_code?: string;
|
||||
did?: Readonly<Record<string, string | number | boolean>>;
|
||||
};
|
||||
|
||||
type JobOutcomeSubject = {
|
||||
JOB_FACT_ALLOWLIST: ReadonlySet<string>;
|
||||
MAX_JOB_FACTS: number;
|
||||
MAX_JOB_OUTCOME_BYTES: number;
|
||||
serializeJobOutcome(outcome: JobOutcome): SerializableJobOutcome;
|
||||
};
|
||||
|
||||
function loadSubject(): Promise<JobOutcomeSubject> {
|
||||
return vi.importActual<JobOutcomeSubject>("@/lib/jobs/job-outcome");
|
||||
}
|
||||
|
||||
const COHORT_FACT_KEYS = [
|
||||
"provider",
|
||||
"outcome",
|
||||
"total",
|
||||
"users_synced",
|
||||
"users_complete",
|
||||
"users_partial",
|
||||
"users_failed",
|
||||
"users_parked",
|
||||
"users_skipped",
|
||||
"users_useful",
|
||||
"users_clean_zero",
|
||||
"users_retryable",
|
||||
"downstream_failed",
|
||||
"measurements_imported",
|
||||
] as const;
|
||||
|
||||
const FORBIDDEN_FACT_KEYS = [
|
||||
"user_id",
|
||||
"userId",
|
||||
"email",
|
||||
"name",
|
||||
"access_token",
|
||||
"refresh_token",
|
||||
"secret",
|
||||
"url",
|
||||
"raw_error",
|
||||
"health_value",
|
||||
"blood_glucose",
|
||||
] as const;
|
||||
|
||||
describe("bounded generic JobOutcome serialization", () => {
|
||||
it("publishes a fixed allowlist for truthful cohort scalars", async () => {
|
||||
const { JOB_FACT_ALLOWLIST } = await loadSubject();
|
||||
for (const key of COHORT_FACT_KEYS) {
|
||||
expect(JOB_FACT_ALLOWLIST.has(key), key).toBe(true);
|
||||
}
|
||||
for (const key of FORBIDDEN_FACT_KEYS) {
|
||||
expect(JOB_FACT_ALLOWLIST.has(key), key).toBe(false);
|
||||
}
|
||||
});
|
||||
|
||||
it.each(FORBIDDEN_FACT_KEYS)(
|
||||
"rejects the private or unbounded fact key %s",
|
||||
async (key) => {
|
||||
const { serializeJobOutcome } = await loadSubject();
|
||||
expect(() =>
|
||||
serializeJobOutcome(
|
||||
jobDone({ [key]: "private-value" } as Record<string, string>),
|
||||
),
|
||||
).toThrow(/fact|allow|private|unsupported/i);
|
||||
},
|
||||
);
|
||||
|
||||
it("rejects nested objects, arrays, nulls, non-finite numbers, and unsafe integers", async () => {
|
||||
const { serializeJobOutcome } = await loadSubject();
|
||||
for (const unsafe of [
|
||||
{ nested: true },
|
||||
["user-a"],
|
||||
null,
|
||||
Number.NaN,
|
||||
Number.POSITIVE_INFINITY,
|
||||
Number.MAX_SAFE_INTEGER + 1,
|
||||
-1,
|
||||
]) {
|
||||
expect(() =>
|
||||
serializeJobOutcome(
|
||||
jobDone({ total: unsafe } as unknown as Record<string, number>),
|
||||
),
|
||||
).toThrow(/scalar|finite|integer|range|unsupported/i);
|
||||
}
|
||||
});
|
||||
|
||||
it.each([
|
||||
"patient@example.test",
|
||||
"https://provider.test/capability",
|
||||
"token=secret",
|
||||
"raw upstream stack trace",
|
||||
"blood glucose 411",
|
||||
"x".repeat(65),
|
||||
])("rejects an unbounded or private string fact: %s", async (value) => {
|
||||
const { serializeJobOutcome } = await loadSubject();
|
||||
expect(() => serializeJobOutcome(jobDone({ provider: value }))).toThrow(
|
||||
/code|string|private|length|unsupported/i,
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects too many facts and enforces a small serialized byte ceiling", async () => {
|
||||
const {
|
||||
JOB_FACT_ALLOWLIST,
|
||||
MAX_JOB_FACTS,
|
||||
MAX_JOB_OUTCOME_BYTES,
|
||||
serializeJobOutcome,
|
||||
} = await loadSubject();
|
||||
expect(MAX_JOB_FACTS).toBeGreaterThanOrEqual(COHORT_FACT_KEYS.length);
|
||||
expect(MAX_JOB_FACTS).toBeLessThanOrEqual(32);
|
||||
expect(MAX_JOB_OUTCOME_BYTES).toBeLessThanOrEqual(2_048);
|
||||
|
||||
const allowed = [...JOB_FACT_ALLOWLIST];
|
||||
const tooMany = Object.fromEntries(
|
||||
Array.from({ length: MAX_JOB_FACTS + 1 }, (_, index) => [
|
||||
allowed[index] ?? `overflow_${index}`,
|
||||
index,
|
||||
]),
|
||||
);
|
||||
expect(() => serializeJobOutcome(jobDone(tooMany))).toThrow(
|
||||
/fact|count|limit|allow/i,
|
||||
);
|
||||
});
|
||||
|
||||
it("distinguishes useful work from a successful clean zero", async () => {
|
||||
const { serializeJobOutcome } = await loadSubject();
|
||||
const useful = serializeJobOutcome(
|
||||
jobDone({ outcome: "useful", total: 1, measurements_imported: 4 }),
|
||||
);
|
||||
const cleanZero = serializeJobOutcome(
|
||||
jobDone({ outcome: "clean_zero", total: 1, measurements_imported: 0 }),
|
||||
);
|
||||
|
||||
expect(useful.did?.outcome).toBe("useful");
|
||||
expect(cleanZero.did?.outcome).toBe("clean_zero");
|
||||
expect(useful).not.toEqual(cleanZero);
|
||||
});
|
||||
|
||||
it("emits only stable failure codes and never serializes raw causes", async () => {
|
||||
const { serializeJobOutcome } = await loadSubject();
|
||||
const privateText =
|
||||
"patient@example.test https://provider.test?token=secret glucose=411";
|
||||
const serialized = serializeJobOutcome(
|
||||
jobFailed("provider_cohort_failed", new Error(privateText), {
|
||||
outcome: "clean_zero",
|
||||
total: 0,
|
||||
}),
|
||||
);
|
||||
|
||||
expect(serialized).toEqual({
|
||||
ok: false,
|
||||
reason_code: "provider_cohort_failed",
|
||||
did: { outcome: "clean_zero", total: 0 },
|
||||
});
|
||||
expect(JSON.stringify(serialized)).not.toContain(privateText);
|
||||
expect(serialized).not.toHaveProperty("cause");
|
||||
});
|
||||
|
||||
it.each([
|
||||
"raw upstream error",
|
||||
"https://provider.test/failure",
|
||||
"failed_for_user_patient@example.test",
|
||||
"token=secret",
|
||||
"x".repeat(65),
|
||||
])("rejects an unstable or private failure reason: %s", async (reason) => {
|
||||
const { serializeJobOutcome } = await loadSubject();
|
||||
expect(() => serializeJobOutcome(jobFailed(reason))).toThrow(
|
||||
/reason|code|private|length/i,
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps a retryable queue failure failed so runJob will retry it", async () => {
|
||||
const { serializeJobOutcome } = await loadSubject();
|
||||
const serialized = serializeJobOutcome(
|
||||
jobFailed("provider_cohort_retry", new Error("socket reset"), {
|
||||
outcome: "clean_zero",
|
||||
users_retryable: 1,
|
||||
}),
|
||||
);
|
||||
|
||||
expect(serialized.ok).toBe(false);
|
||||
expect(serialized.reason_code).toBe("provider_cohort_retry");
|
||||
expect(serialized.did).toEqual({
|
||||
outcome: "clean_zero",
|
||||
users_retryable: 1,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,260 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
type Provider = "google_health" | "withings";
|
||||
type UserStatus = "complete" | "partial" | "failed" | "parked" | "skipped";
|
||||
|
||||
type UserVerdict = {
|
||||
status: UserStatus;
|
||||
imported: number;
|
||||
downstreamFailed?: boolean;
|
||||
retryable?: boolean;
|
||||
reasonCode?: string;
|
||||
};
|
||||
|
||||
type CohortFacts = {
|
||||
provider: Provider;
|
||||
outcome: "useful" | "clean_zero";
|
||||
total: number;
|
||||
users_synced: number;
|
||||
users_complete: number;
|
||||
users_partial: number;
|
||||
users_failed: number;
|
||||
users_parked: number;
|
||||
users_skipped: number;
|
||||
users_useful: number;
|
||||
users_clean_zero: number;
|
||||
users_retryable: number;
|
||||
downstream_failed: number;
|
||||
measurements_imported: number;
|
||||
};
|
||||
|
||||
type CohortSubject = {
|
||||
foldIntegrationCohortOutcomes(input: {
|
||||
provider: Provider;
|
||||
verdicts: readonly UserVerdict[];
|
||||
}): { ok: true; did: CohortFacts };
|
||||
};
|
||||
|
||||
function loadSubject(): Promise<CohortSubject> {
|
||||
return vi.importActual<CohortSubject>("@/lib/jobs/reminder/poll-cohort");
|
||||
}
|
||||
|
||||
const googleHandlerSource = readFileSync(
|
||||
join(process.cwd(), "src/lib/jobs/reminder/google-health-sync.ts"),
|
||||
"utf8",
|
||||
);
|
||||
const googleSyncSource = readFileSync(
|
||||
join(process.cwd(), "src/lib/google-health/sync.ts"),
|
||||
"utf8",
|
||||
);
|
||||
const withingsHandlerSource = readFileSync(
|
||||
join(process.cwd(), "src/lib/jobs/reminder/withings-sync.ts"),
|
||||
"utf8",
|
||||
);
|
||||
|
||||
describe("provider handlers use the truthful verdict fold", () => {
|
||||
it("does not discard Google Health's resolved failed verdict", () => {
|
||||
expect(googleSyncSource).not.toMatch(
|
||||
/syncUserGoogleHealth\(userId\)\.then\(\(r\)\s*=>\s*r\.imported\)/,
|
||||
);
|
||||
expect(googleHandlerSource).toMatch(/foldIntegrationCohortOutcomes/);
|
||||
});
|
||||
|
||||
it("does not count every resolved Withings call as synced unconditionally", () => {
|
||||
expect(withingsHandlerSource).toMatch(/foldIntegrationCohortOutcomes/);
|
||||
expect(withingsHandlerSource).not.toMatch(
|
||||
/(?:syncUserMeasurements|syncUserActivity|syncUserSleep|syncUserEcg)[\s\S]{0,180}usersSynced\+\+/,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe.each(["google_health", "withings"] as const)(
|
||||
"%s truthful integration cohort outcomes",
|
||||
(provider) => {
|
||||
it("classifies complete, partial, failed, parked, and skipped users separately", async () => {
|
||||
const { foldIntegrationCohortOutcomes } = await loadSubject();
|
||||
const outcome = foldIntegrationCohortOutcomes({
|
||||
provider,
|
||||
verdicts: [
|
||||
{ status: "complete", imported: 3 },
|
||||
{
|
||||
status: "partial",
|
||||
imported: 2,
|
||||
downstreamFailed: true,
|
||||
retryable: true,
|
||||
reasonCode: "rollup_failed",
|
||||
},
|
||||
{
|
||||
status: "failed",
|
||||
imported: 0,
|
||||
retryable: true,
|
||||
reasonCode: "provider_unavailable",
|
||||
},
|
||||
{
|
||||
status: "parked",
|
||||
imported: 0,
|
||||
reasonCode: "reauth_required",
|
||||
},
|
||||
{ status: "skipped", imported: 0, reasonCode: "not_configured" },
|
||||
],
|
||||
});
|
||||
|
||||
expect(outcome).toEqual({
|
||||
ok: true,
|
||||
did: {
|
||||
provider,
|
||||
outcome: "useful",
|
||||
total: 5,
|
||||
users_synced: 1,
|
||||
users_complete: 1,
|
||||
users_partial: 1,
|
||||
users_failed: 1,
|
||||
users_parked: 1,
|
||||
users_skipped: 1,
|
||||
users_useful: 2,
|
||||
users_clean_zero: 0,
|
||||
users_retryable: 2,
|
||||
downstream_failed: 1,
|
||||
measurements_imported: 5,
|
||||
},
|
||||
});
|
||||
expect(
|
||||
outcome.did.users_complete +
|
||||
outcome.did.users_partial +
|
||||
outcome.did.users_failed +
|
||||
outcome.did.users_parked +
|
||||
outcome.did.users_skipped,
|
||||
).toBe(outcome.did.total);
|
||||
});
|
||||
|
||||
it("counts a fully successful zero-write user as clean zero, not useful work", async () => {
|
||||
const { foldIntegrationCohortOutcomes } = await loadSubject();
|
||||
const outcome = foldIntegrationCohortOutcomes({
|
||||
provider,
|
||||
verdicts: [{ status: "complete", imported: 0 }],
|
||||
});
|
||||
|
||||
expect(outcome.did).toMatchObject({
|
||||
provider,
|
||||
outcome: "clean_zero",
|
||||
total: 1,
|
||||
users_synced: 1,
|
||||
users_complete: 1,
|
||||
users_useful: 0,
|
||||
users_clean_zero: 1,
|
||||
measurements_imported: 0,
|
||||
});
|
||||
});
|
||||
|
||||
it.each([
|
||||
["failed", true],
|
||||
["parked", false],
|
||||
["skipped", false],
|
||||
] as const)(
|
||||
"never counts a %s user as synced",
|
||||
async (status, retryable) => {
|
||||
const { foldIntegrationCohortOutcomes } = await loadSubject();
|
||||
const outcome = foldIntegrationCohortOutcomes({
|
||||
provider,
|
||||
verdicts: [{ status, imported: 0, retryable }],
|
||||
});
|
||||
|
||||
expect(outcome.did.users_synced).toBe(0);
|
||||
expect(outcome.did[`users_${status}`]).toBe(1);
|
||||
expect(outcome.did.users_retryable).toBe(retryable ? 1 : 0);
|
||||
},
|
||||
);
|
||||
|
||||
it("marks raw writes plus a rollup/downstream failure as partial and retryable", async () => {
|
||||
const { foldIntegrationCohortOutcomes } = await loadSubject();
|
||||
const outcome = foldIntegrationCohortOutcomes({
|
||||
provider,
|
||||
verdicts: [
|
||||
{
|
||||
status: "partial",
|
||||
imported: 7,
|
||||
downstreamFailed: true,
|
||||
retryable: true,
|
||||
reasonCode: "rollup_failed",
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expect(outcome.did).toMatchObject({
|
||||
outcome: "useful",
|
||||
users_synced: 0,
|
||||
users_partial: 1,
|
||||
users_retryable: 1,
|
||||
downstream_failed: 1,
|
||||
measurements_imported: 7,
|
||||
});
|
||||
});
|
||||
|
||||
it("never emits per-user identifiers, PII, secrets, URLs, raw errors, or health values", async () => {
|
||||
const { foldIntegrationCohortOutcomes } = await loadSubject();
|
||||
const privateVerdict = {
|
||||
status: "failed",
|
||||
imported: 0,
|
||||
retryable: false,
|
||||
reasonCode: "provider_failed",
|
||||
userId: "user-private-123",
|
||||
email: "patient@example.test",
|
||||
accessToken: "secret-token",
|
||||
url: "https://provider.test/capability?token=secret",
|
||||
rawError: new Error("blood glucose=411"),
|
||||
healthValue: 411,
|
||||
} as UserVerdict;
|
||||
|
||||
const serialized = JSON.stringify(
|
||||
foldIntegrationCohortOutcomes({
|
||||
provider,
|
||||
verdicts: [privateVerdict],
|
||||
}),
|
||||
);
|
||||
expect(serialized).not.toMatch(
|
||||
/user-private|patient@|secret-token|provider\.test|blood glucose|411|rawError|healthValue/i,
|
||||
);
|
||||
});
|
||||
|
||||
it("folds a high-volume cohort into one fixed bounded scalar payload", async () => {
|
||||
const { foldIntegrationCohortOutcomes } = await loadSubject();
|
||||
const verdicts = Array.from({ length: 10_000 }, (_, index) => ({
|
||||
status: index % 2 === 0 ? ("complete" as const) : ("skipped" as const),
|
||||
imported: index % 2 === 0 ? 1 : 0,
|
||||
}));
|
||||
|
||||
const outcome = foldIntegrationCohortOutcomes({ provider, verdicts });
|
||||
expect(Object.keys(outcome.did).sort()).toEqual(
|
||||
[
|
||||
"downstream_failed",
|
||||
"measurements_imported",
|
||||
"outcome",
|
||||
"provider",
|
||||
"total",
|
||||
"users_clean_zero",
|
||||
"users_complete",
|
||||
"users_failed",
|
||||
"users_parked",
|
||||
"users_partial",
|
||||
"users_retryable",
|
||||
"users_skipped",
|
||||
"users_synced",
|
||||
"users_useful",
|
||||
].sort(),
|
||||
);
|
||||
expect(
|
||||
Object.values(outcome.did).every((value) => {
|
||||
return (
|
||||
typeof value === "string" ||
|
||||
(typeof value === "number" &&
|
||||
Number.isSafeInteger(value) &&
|
||||
value >= 0)
|
||||
);
|
||||
}),
|
||||
).toBe(true);
|
||||
expect(JSON.stringify(outcome).length).toBeLessThan(1_024);
|
||||
});
|
||||
},
|
||||
);
|
||||
Reference in new issue
Block a user