From a2dfc5e256ec1d8f8f856485b7ed6479a6bc5ff1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Mon, 11 May 2026 02:16:18 +0200 Subject: [PATCH] fix(measurements): show source badge on mobile card too MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Apple Health pink chip rendered only inside the desktop `` cell. iOS users — the audience that actually consumes Apple Health data — land on the mobile card branch and saw no source indicator at all, risking duplicate hand-entries on top of synced rows. Add a compact badge inline with the timestamp on the mobile card so the distinction lands at every viewport. Component test asserts the `measurement-source-badge` testid appears twice (desktop + mobile) so the next layout refactor can't silently drop one branch. Also extends the `POST /api/devices` unit test mocks for the NotificationChannel auto-upsert added in the previous commit, plus a new test that pins the same-user apnsToken collision path returning 201 idempotently (the cross-user path keeps its 409). Co-Authored-By: Claude Opus 4.7 (1M context) --- src/app/api/devices/__tests__/route.test.ts | 35 +++++++++++++++++++ ...asurement-list-apple-health-badge.test.tsx | 14 ++++++++ .../measurements/measurement-list.tsx | 12 ++++++- 3 files changed, 60 insertions(+), 1 deletion(-) diff --git a/src/app/api/devices/__tests__/route.test.ts b/src/app/api/devices/__tests__/route.test.ts index 63d2800a7..f2f9a6201 100644 --- a/src/app/api/devices/__tests__/route.test.ts +++ b/src/app/api/devices/__tests__/route.test.ts @@ -9,9 +9,17 @@ vi.mock("@/lib/db", () => ({ create: vi.fn(), update: vi.fn(), }, + notificationChannel: { + upsert: vi.fn(), + }, }, })); +vi.mock("@/lib/crypto", () => ({ + encrypt: (s: string) => `enc(${s})`, + decrypt: (s: string) => s.replace(/^enc\(|\)$/g, ""), +})); + vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() })); vi.mock("@/lib/auth/audit", () => ({ @@ -56,6 +64,9 @@ beforeEach(() => { vi.mocked(prisma.device.findFirst).mockResolvedValue(null); vi.mocked(prisma.device.create).mockResolvedValue({ id: "dev-1" } as never); vi.mocked(prisma.device.update).mockResolvedValue({ id: "dev-1" } as never); + vi.mocked(prisma.notificationChannel.upsert).mockResolvedValue({ + id: "ch-1", + } as never); }); describe("POST /api/devices", () => { @@ -182,6 +193,7 @@ describe("POST /api/devices", () => { vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); vi.mocked(prisma.device.findFirst).mockResolvedValue({ id: "other-dev", + userId: "other-user", } as never); const res = await POST( req({ @@ -195,4 +207,27 @@ describe("POST /api/devices", () => { expect(prisma.device.create).not.toHaveBeenCalled(); expect(prisma.device.update).not.toHaveBeenCalled(); }); + + it("falls through idempotently when the apnsToken already belongs to the same user", async () => { + // v1.4.23 W6 reconcile (HIGH 3): the cross-user-hijack guard used + // to filter `NOT: { userId }` so it skipped same-user collisions + // entirely, letting `findMany({ apnsToken })` fan a single push + // out to two Device rows. The guard now reads `userId` and only + // rejects cross-user collisions; same-user collisions fall + // through to the legacy-token upsert path. + vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); + vi.mocked(prisma.device.findFirst).mockResolvedValue({ + id: "self-dev", + userId: "user-1", + } as never); + const res = await POST( + req({ + token: "abcd1234efgh5678", + bundleId: "io.healthlog.app", + apnsToken: "deadbeef".repeat(8), + apnsEnvironment: "sandbox", + }), + ); + expect(res.status).toBe(201); + }); }); diff --git a/src/components/measurements/__tests__/measurement-list-apple-health-badge.test.tsx b/src/components/measurements/__tests__/measurement-list-apple-health-badge.test.tsx index bb896d084..297f16f12 100644 --- a/src/components/measurements/__tests__/measurement-list-apple-health-badge.test.tsx +++ b/src/components/measurements/__tests__/measurement-list-apple-health-badge.test.tsx @@ -85,4 +85,18 @@ describe("MeasurementList — APPLE_HEALTH badge", () => { const html = render("de"); expect(html).toContain("Apple Health"); }); + + it("paints the source badge in BOTH the desktop table and the mobile card", () => { + // v1.4.23 W6 design review HIGH-1: the badge previously rendered + // only inside the desktop `
` cell. iOS users (the audience + // that actually consumes Apple Health data) land on the mobile + // card branch and saw no source indicator at all, risking + // duplicate hand-entries on top of synced rows. Both branches must + // carry the badge so the surface is honest at every viewport. + const html = render("en"); + const matches = html.match( + /data-testid="measurement-source-badge"/g, + ); + expect(matches?.length ?? 0).toBe(2); + }); }); diff --git a/src/components/measurements/measurement-list.tsx b/src/components/measurements/measurement-list.tsx index 0b14a695a..f2d731437 100644 --- a/src/components/measurements/measurement-list.tsx +++ b/src/components/measurements/measurement-list.tsx @@ -447,6 +447,7 @@ export function MeasurementList({ onEdit, onAddFirst }: MeasurementListProps) { {m.source !== "MANUAL" && ( {formatMeasurementSource(m.source, t)} @@ -506,7 +507,16 @@ export function MeasurementList({ onEdit, onAddFirst }: MeasurementListProps) { {m.value} {m.unit}

- {formatDateTime(m.measuredAt)} + {formatDateTime(m.measuredAt)} + {m.source !== "MANUAL" && ( + + {formatMeasurementSource(m.source, t)} + + )}

{m.notes && (