mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-07 04:12:04 +00:00
fix(measurements): show source badge on mobile card too
The Apple Health pink chip rendered only inside the desktop `<Table>` 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) <noreply@anthropic.com>
This commit is contained in:
1 parent
d5202e5dd8
commit
a2dfc5e256
3 files changed
+60
-1
No files matched your search
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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 `<Table>` 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);
|
||||
});
|
||||
});
|
||||
@@ -447,6 +447,7 @@ export function MeasurementList({ onEdit, onAddFirst }: MeasurementListProps) {
|
||||
{m.source !== "MANUAL" && (
|
||||
<Badge
|
||||
variant="outline"
|
||||
data-testid="measurement-source-badge"
|
||||
className={`text-xs ${sourceBadgeClass(m.source)}`.trim()}
|
||||
>
|
||||
{formatMeasurementSource(m.source, t)}
|
||||
@@ -506,7 +507,16 @@ export function MeasurementList({ onEdit, onAddFirst }: MeasurementListProps) {
|
||||
{m.value} {m.unit}
|
||||
</span>
|
||||
<p className="text-muted-foreground truncate text-xs">
|
||||
{formatDateTime(m.measuredAt)}
|
||||
<span>{formatDateTime(m.measuredAt)}</span>
|
||||
{m.source !== "MANUAL" && (
|
||||
<Badge
|
||||
variant="outline"
|
||||
data-testid="measurement-source-badge"
|
||||
className={`ml-1.5 h-4 px-1 text-[10px] ${sourceBadgeClass(m.source)}`.trim()}
|
||||
>
|
||||
{formatMeasurementSource(m.source, t)}
|
||||
</Badge>
|
||||
)}
|
||||
</p>
|
||||
{m.notes && (
|
||||
<p className="text-muted-foreground truncate text-xs">
|
||||
|
||||
Reference in new issue
Block a user