mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 04:32:42 +00:00
test(ingest): prove the external-id floor cannot be reintroduced
Harden the structural guard so it reads the file body with its imports stripped: grepping the raw source cannot tell a file that APPLIES the check from one that merely imports it, so deleting the call while leaving the import slipped straight through. Add the partial-success case on a second batch envelope — the mood bulk ingest echoes the offending entry back with its index, status and reason while the good rows still upsert.
This commit is contained in:
1 parent
2ab66ab2e3
commit
6e4bcffadf
2 files changed
+105
-7
No files matched your search
@@ -44,6 +44,19 @@ function read(rel: string): string {
|
||||
return readFileSync(join(SRC, rel), "utf8");
|
||||
}
|
||||
|
||||
/**
|
||||
* The file with its `import … from "…";` statements removed.
|
||||
*
|
||||
* A guard that greps the raw source cannot tell "this file APPLIES the
|
||||
* check" from "this file merely imports it" — deleting the call while
|
||||
* leaving the import would slip straight through. Every usage assertion
|
||||
* below therefore runs against the body, and the module import is
|
||||
* asserted separately where it matters.
|
||||
*/
|
||||
function body(rel: string): string {
|
||||
return read(rel).replace(/import\s+[\s\S]*?from\s+"[^"]+";/g, "");
|
||||
}
|
||||
|
||||
/**
|
||||
* A REQUEST-side declaration of a client-supplied external identifier:
|
||||
* `externalId: z.…` / `externalSourceId: z.…` in a Zod object shape.
|
||||
@@ -106,11 +119,10 @@ describe("every client-supplied external id routes through the stability floor",
|
||||
return;
|
||||
}
|
||||
|
||||
const source = read(rel);
|
||||
const usesFieldCheck =
|
||||
source.includes(VALIDATOR_MODULE) &&
|
||||
source.includes("assertStableExternalId");
|
||||
const marksPerEntry = MARKER.test(source);
|
||||
read(rel).includes(VALIDATOR_MODULE) &&
|
||||
body(rel).includes("assertStableExternalId");
|
||||
const marksPerEntry = MARKER.test(read(rel));
|
||||
expect(
|
||||
usesFieldCheck || marksPerEntry,
|
||||
`${rel} declares a client-supplied external id but neither applies ` +
|
||||
@@ -134,7 +146,7 @@ describe("every client-supplied external id routes through the stability floor",
|
||||
target.startsWith("src/"),
|
||||
`${rel}: marker target "${target}" must be a repo-root-relative src/ path`,
|
||||
).toBe(true);
|
||||
const checker = read(target.replace(/^src\//, ""));
|
||||
const checker = body(target.replace(/^src\//, ""));
|
||||
expect(
|
||||
checker.includes("classifyExternalId"),
|
||||
`${rel} points its per-entry marker at ${target}, but that file never calls classifyExternalId.`,
|
||||
@@ -158,7 +170,7 @@ describe("the batch surfaces refuse per entry, never whole-batch", () => {
|
||||
];
|
||||
|
||||
it.each(BATCH_ROUTES)("%s classifies each entry itself", (rel) => {
|
||||
const source = read(rel);
|
||||
const source = body(rel);
|
||||
expect(source).toContain("classifyExternalId");
|
||||
expect(source).toContain("unstableExternalIdMeta");
|
||||
});
|
||||
@@ -168,7 +180,7 @@ describe("the batch surfaces refuse per entry, never whole-batch", () => {
|
||||
(rel) => {
|
||||
// The JSON importer has no per-entry status envelope — it reports a
|
||||
// skipped COUNT — so it is the one batch surface without the code.
|
||||
expect(read(rel)).toContain("UNSTABLE_EXTERNAL_ID_REASON");
|
||||
expect(body(rel)).toContain("UNSTABLE_EXTERNAL_ID_REASON");
|
||||
},
|
||||
);
|
||||
});
|
||||
@@ -461,6 +461,92 @@ describe("POST /api/mood-entries/bulk — structured tagKeys (v1.12.0)", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("POST /api/mood-entries/bulk — unstable external ids", () => {
|
||||
/**
|
||||
* `(userId, source, externalId)` dedups only while the id is stable
|
||||
* across client launches. A per-process value — an object description
|
||||
* carrying a memory address — never matches its own earlier row, so
|
||||
* every re-post mints another entry. The refusal is PER ENTRY: a client
|
||||
* draining an offline queue must not lose its good rows to one bad one.
|
||||
*/
|
||||
beforeEach(() => {
|
||||
vi.mocked(prisma.moodEntry.findMany).mockResolvedValue([]);
|
||||
vi.mocked(prisma.moodEntry.upsert).mockResolvedValue({
|
||||
id: "entry-1",
|
||||
} as never);
|
||||
});
|
||||
|
||||
it("skips the offending entry and still writes the rest of the batch", async () => {
|
||||
const res = await POST(
|
||||
postReq({
|
||||
entries: [
|
||||
{
|
||||
mood: "GUT",
|
||||
moodLoggedAt: "2026-05-16T08:00:00.000Z",
|
||||
externalId: "8AD2A9CB-3F0C-4E4D-9C1E-4B7E2A1D6F30",
|
||||
},
|
||||
{
|
||||
mood: "OKAY",
|
||||
moodLoggedAt: "2026-05-16T09:00:00.000Z",
|
||||
externalId: "<HKHealthConceptIdentifier: 0x12568db80>",
|
||||
},
|
||||
{
|
||||
mood: "SUPER_GUT",
|
||||
moodLoggedAt: "2026-05-16T10:00:00.000Z",
|
||||
externalId: "0xdeadbeef",
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
const body = (await res.json()) as {
|
||||
data: {
|
||||
processed: number;
|
||||
inserted: number;
|
||||
skipped: Array<{ index: number; reason: string }>;
|
||||
entries: Array<{
|
||||
index: number;
|
||||
status: string;
|
||||
reason?: string;
|
||||
externalId?: string;
|
||||
}>;
|
||||
};
|
||||
};
|
||||
expect(body.data.processed).toBe(3);
|
||||
expect(body.data.entries[0].status).toBe("inserted");
|
||||
expect(body.data.entries[1]).toMatchObject({
|
||||
index: 1,
|
||||
status: "skipped",
|
||||
reason: "unstable_external_id",
|
||||
externalId: "<HKHealthConceptIdentifier: 0x12568db80>",
|
||||
});
|
||||
expect(body.data.entries[2]).toMatchObject({
|
||||
index: 2,
|
||||
status: "skipped",
|
||||
reason: "unstable_external_id",
|
||||
});
|
||||
expect(body.data.skipped).toEqual([
|
||||
{ index: 1, reason: "unstable_external_id" },
|
||||
{ index: 2, reason: "unstable_external_id" },
|
||||
]);
|
||||
// Only the good entry reached the writer.
|
||||
expect(prisma.moodEntry.upsert).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("leaves an entry without an externalId untouched", async () => {
|
||||
const res = await POST(
|
||||
postReq({
|
||||
entries: [{ mood: "OKAY", moodLoggedAt: "2026-05-16T08:00:00.000Z" }],
|
||||
}),
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
const body = (await res.json()) as {
|
||||
data: { entries: Array<{ status: string }> };
|
||||
};
|
||||
expect(body.data.entries[0].status).toBe("inserted");
|
||||
});
|
||||
});
|
||||
|
||||
describe("POST /api/mood-entries/bulk — tombstone suppression", () => {
|
||||
it("keeps a tombstoned match deleted: true no-op reported as duplicate", async () => {
|
||||
// A soft-deleted row under the batched pre-read: the sync feed already
|
||||
|
||||
Reference in new issue
Block a user