mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-11 05:42:19 +00:00
fix(medications): key the dedup pin guard on persisted attribution, not a timestamp shape
Every standalone intake anchors its slot on the take time since v1.15.19, so guarding the nightly dedup on that shape excluded ordinary drift duplicates from collapsing — the original point of the sweep. Releasing a pinned dose now persists the user's choice on the row, the dedup and the repair script key their exclusion on that flag, the read model reports a released row as deliberately off-schedule instead of re-snapping it within the anchor epsilon, and the attributed badge only shows on rows actually bound to a slot. A few fixture names and stray comments also drop personal references.
This commit is contained in:
1 parent
04765ea8ac
commit
a5650d0617
11 files changed
+267
-93
No files matched your search
@@ -430,7 +430,7 @@ export const POST = apiHandler(async (request: NextRequest) => {
|
||||
// v1.4.36 W3 T1 — `extractFeatures` enforces a 5 MB ceiling on the
|
||||
// serialised payload. If the raw (bucketed) shape ever blows past
|
||||
// it (regression watch — the v1.4.35 rawMeasurements shape hit
|
||||
// 25.9 MB on Marc's account), the helper throws
|
||||
// 25.9 MB on the largest tenant), the helper throws
|
||||
// `FeaturesPayloadTooLargeError`. We downgrade to the aggregated
|
||||
// shape rather than 500-ing so the user still gets an insight; the
|
||||
// annotate event surfaces the regression to ops via the Logflare
|
||||
|
||||
@@ -358,6 +358,50 @@ describe("PUT — v1.15.18 band re-attribution", () => {
|
||||
expect((created?.scheduledFor as Date).getTime()).toBe(at(19, 0).getTime());
|
||||
});
|
||||
|
||||
it("unpin (forceSlotInstant: null) keeps USER_PIN provenance on the released ad-hoc row (v1.16.0)", async () => {
|
||||
// The row is pinned onto the 07:00 anchor; its real takenAt (12:00) is
|
||||
// outside every band. Releasing the binding re-attributes by band →
|
||||
// miss → the row re-anchors on its own takenAt — and the release is
|
||||
// itself a user-fixed decision, so the row must keep USER_PIN (the
|
||||
// nightly dedup keys its standalone guarantee on that marker).
|
||||
vi.mocked(prisma.medicationIntakeEvent.findFirst)
|
||||
// PUT lookup of the edited event
|
||||
.mockResolvedValueOnce({
|
||||
id: "e1",
|
||||
userId: "user-1",
|
||||
medicationId: "m1",
|
||||
scheduledFor: at(7, 0),
|
||||
takenAt: at(12, 0),
|
||||
skipped: false,
|
||||
} as never)
|
||||
// lifecycle liveIntake probe
|
||||
.mockResolvedValue(null as never);
|
||||
// applyCanonicalSlotWrite: no existing row at the 12:00 instant → create.
|
||||
vi.mocked(prisma.medicationIntakeEvent.findMany).mockResolvedValue(
|
||||
[] as never,
|
||||
);
|
||||
vi.mocked(prisma.medicationIntakeEvent.update).mockResolvedValue(
|
||||
{} as never,
|
||||
);
|
||||
vi.mocked(prisma.medicationIntakeEvent.create).mockResolvedValue({
|
||||
id: "e2",
|
||||
userId: "user-1",
|
||||
medicationId: "m1",
|
||||
scheduledFor: at(12, 0),
|
||||
takenAt: at(12, 0),
|
||||
skipped: false,
|
||||
} as never);
|
||||
|
||||
const res = await PUT(putReq({ forceSlotInstant: null }), ROUTE_CTX);
|
||||
expect(res.status).toBe(200);
|
||||
|
||||
const created = vi.mocked(prisma.medicationIntakeEvent.create).mock
|
||||
.calls[0]?.[0]?.data;
|
||||
// Released row: anchored on its own takenAt, provenance stays USER_PIN.
|
||||
expect((created?.scheduledFor as Date).getTime()).toBe(at(12, 0).getTime());
|
||||
expect(created?.attributionSource).toBe("USER_PIN");
|
||||
});
|
||||
|
||||
it("422s an edited takenAt before the medication's start date (P0-4)", async () => {
|
||||
// Use fixed wall-clock so the schema's "not in the future / within 5
|
||||
// years" bounds hold regardless of when the suite runs.
|
||||
|
||||
@@ -131,7 +131,12 @@ export const PUT = apiHandler(
|
||||
// anchor instead of degrading the off-window take back to ad-hoc;
|
||||
// 3. else an explicit `forceSlotInstant: null` UNPINS (v1.15.20,
|
||||
// "Zuordnung lösen"): re-attribute the take by band on its takenAt
|
||||
// (ad-hoc when no band matches) and reset the provenance to AUTO;
|
||||
// (ad-hoc when no band matches). v1.16.0 — the release is itself a
|
||||
// deliberate binding decision, so the row KEEPS `USER_PIN`
|
||||
// provenance ("attribution user-fixed": onto a slot OR deliberately
|
||||
// ad-hoc). The persisted marker is what keeps the nightly slot
|
||||
// dedup from snapping the released row back into the cluster the
|
||||
// user just detached it from;
|
||||
// 4. else a taken edit attributes by band (slot anchor on match, the
|
||||
// take's own time on a miss → ad-hoc), provenance AUTO;
|
||||
// 5. a skip / pending edit keeps the existing `scheduledFor` anchor.
|
||||
@@ -198,10 +203,15 @@ export const PUT = apiHandler(
|
||||
});
|
||||
// band.at on match; the take's own time on a miss (ad-hoc).
|
||||
resolvedScheduledFor = attribution.slotInstant ?? nextTakenAt;
|
||||
attributionSource = "AUTO";
|
||||
// v1.16.0 — an explicit unpin stays user-fixed (USER_PIN) even on
|
||||
// the released row; a plain taken/skipped edit re-attributes AUTO.
|
||||
attributionSource =
|
||||
data.forceSlotInstant === null ? "USER_PIN" : "AUTO";
|
||||
} else if (data.forceSlotInstant === null) {
|
||||
// Unpin on a row that is not a confirmed take (skip / pending):
|
||||
// nothing to re-attribute, but the provenance still resets.
|
||||
// nothing to re-attribute, but the provenance still resets — a
|
||||
// skip / pending row anchors on its slot by construction, so there
|
||||
// is no user-fixed ad-hoc decision to persist.
|
||||
attributionSource = "AUTO";
|
||||
}
|
||||
// skip / pending edits leave `scheduledFor` on the existing anchor.
|
||||
|
||||
@@ -57,7 +57,7 @@ describe("GET /api/user/profile", () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
vi.mocked(prisma.user.findUnique).mockResolvedValue({
|
||||
username: "marc",
|
||||
displayName: "Marc B.",
|
||||
displayName: "Alex T.",
|
||||
email: "marc@example.com",
|
||||
dateOfBirth: new Date("1985-03-12T00:00:00.000Z"),
|
||||
gender: "MALE",
|
||||
@@ -77,7 +77,7 @@ describe("GET /api/user/profile", () => {
|
||||
};
|
||||
};
|
||||
expect(body.data.username).toBe("marc");
|
||||
expect(body.data.displayName).toBe("Marc B.");
|
||||
expect(body.data.displayName).toBe("Alex T.");
|
||||
expect(body.data.heightCm).toBe(180);
|
||||
expect(body.data.locale).toBe("de");
|
||||
});
|
||||
@@ -137,7 +137,7 @@ describe("PATCH /api/user/profile", () => {
|
||||
vi.mocked(prisma.user.update).mockResolvedValue({
|
||||
id: "user-1",
|
||||
username: "marc",
|
||||
displayName: "Marc B.",
|
||||
displayName: "Alex T.",
|
||||
email: null,
|
||||
role: "USER",
|
||||
heightCm: null,
|
||||
@@ -147,13 +147,13 @@ describe("PATCH /api/user/profile", () => {
|
||||
locale: "de",
|
||||
} as never);
|
||||
|
||||
const res = await PATCH(req({ displayName: "Marc B.", locale: "de" }));
|
||||
const res = await PATCH(req({ displayName: "Alex T.", locale: "de" }));
|
||||
expect(res.status).toBe(200);
|
||||
expect(prisma.user.update).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
where: { id: "user-1" },
|
||||
data: expect.objectContaining({
|
||||
displayName: "Marc B.",
|
||||
displayName: "Alex T.",
|
||||
locale: "de",
|
||||
}),
|
||||
}),
|
||||
|
||||
Reference in new issue
Block a user