mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-14 22:50:51 +00:00
Merge remote-tracking branch 'origin/release/v1.46.0' into feat/v146-ui
# Conflicts: # src/__tests__/baselines/source-function-lines.json # src/__tests__/baselines/version-stamp-comments.json
This commit is contained in:
commit
879473305c
115 files changed
+5037
-599
No files matched your search
@@ -194,6 +194,7 @@
|
||||
"sleepStage": null,
|
||||
"rhythmClassification": null,
|
||||
"deviceType": null,
|
||||
"ingestTokenId": null,
|
||||
"syncVersion": 1,
|
||||
"deletedAt": null,
|
||||
"createdAt": "2026-05-01T08:20:03.000Z",
|
||||
@@ -217,6 +218,7 @@
|
||||
"sleepStage": "DEEP",
|
||||
"rhythmClassification": null,
|
||||
"deviceType": null,
|
||||
"ingestTokenId": null,
|
||||
"syncVersion": 1,
|
||||
"deletedAt": null,
|
||||
"createdAt": "2026-05-01T08:20:04.000Z",
|
||||
@@ -240,6 +242,7 @@
|
||||
"sleepStage": null,
|
||||
"rhythmClassification": null,
|
||||
"deviceType": null,
|
||||
"ingestTokenId": null,
|
||||
"syncVersion": 4,
|
||||
"deletedAt": "<tombstone>",
|
||||
"createdAt": "2026-05-01T08:20:01.000Z",
|
||||
@@ -263,6 +266,7 @@
|
||||
"sleepStage": null,
|
||||
"rhythmClassification": null,
|
||||
"deviceType": "rt-Measurement-deviceType",
|
||||
"ingestTokenId": null,
|
||||
"syncVersion": 1,
|
||||
"deletedAt": null,
|
||||
"createdAt": "2026-05-01T08:20:00.000Z",
|
||||
@@ -286,6 +290,7 @@
|
||||
"sleepStage": null,
|
||||
"rhythmClassification": "IRREGULAR",
|
||||
"deviceType": null,
|
||||
"ingestTokenId": null,
|
||||
"syncVersion": 1,
|
||||
"deletedAt": null,
|
||||
"createdAt": "2026-05-01T08:20:02.000Z",
|
||||
@@ -2629,6 +2634,7 @@
|
||||
"sleepStage": null,
|
||||
"rhythmClassification": null,
|
||||
"deviceType": null,
|
||||
"ingestTokenId": null,
|
||||
"syncVersion": 1,
|
||||
"deletedAt": null,
|
||||
"createdAt": "2026-05-01T08:20:03.000Z",
|
||||
@@ -2652,6 +2658,7 @@
|
||||
"sleepStage": "DEEP",
|
||||
"rhythmClassification": null,
|
||||
"deviceType": null,
|
||||
"ingestTokenId": null,
|
||||
"syncVersion": 1,
|
||||
"deletedAt": null,
|
||||
"createdAt": "2026-05-01T08:20:04.000Z",
|
||||
@@ -2675,6 +2682,7 @@
|
||||
"sleepStage": null,
|
||||
"rhythmClassification": null,
|
||||
"deviceType": null,
|
||||
"ingestTokenId": null,
|
||||
"syncVersion": 4,
|
||||
"deletedAt": "<tombstone>",
|
||||
"createdAt": "2026-05-01T08:20:01.000Z",
|
||||
@@ -2698,6 +2706,7 @@
|
||||
"sleepStage": null,
|
||||
"rhythmClassification": null,
|
||||
"deviceType": "rt-Measurement-deviceType",
|
||||
"ingestTokenId": null,
|
||||
"syncVersion": 1,
|
||||
"deletedAt": null,
|
||||
"createdAt": "2026-05-01T08:20:00.000Z",
|
||||
@@ -2721,6 +2730,7 @@
|
||||
"sleepStage": null,
|
||||
"rhythmClassification": "IRREGULAR",
|
||||
"deviceType": null,
|
||||
"ingestTokenId": null,
|
||||
"syncVersion": 1,
|
||||
"deletedAt": null,
|
||||
"createdAt": "2026-05-01T08:20:02.000Z",
|
||||
|
||||
@@ -428,7 +428,8 @@
|
||||
"syncVersion": 1,
|
||||
"deletedAt": null,
|
||||
"createdAt": "2026-05-01T08:20:03.000Z",
|
||||
"updatedAt": "2026-05-01T08:21:03.000Z"
|
||||
"updatedAt": "2026-05-01T08:21:03.000Z",
|
||||
"ingestTokenId": null
|
||||
},
|
||||
{
|
||||
"id": "<id:2>",
|
||||
@@ -452,7 +453,8 @@
|
||||
"syncVersion": 1,
|
||||
"deletedAt": null,
|
||||
"createdAt": "2026-05-01T08:20:02.000Z",
|
||||
"updatedAt": "2026-05-01T08:21:02.000Z"
|
||||
"updatedAt": "2026-05-01T08:21:02.000Z",
|
||||
"ingestTokenId": null
|
||||
},
|
||||
{
|
||||
"id": "<id:3>",
|
||||
@@ -476,7 +478,8 @@
|
||||
"syncVersion": 4,
|
||||
"deletedAt": "<tombstone>",
|
||||
"createdAt": "2026-05-01T08:20:01.000Z",
|
||||
"updatedAt": "2026-05-01T08:21:01.000Z"
|
||||
"updatedAt": "2026-05-01T08:21:01.000Z",
|
||||
"ingestTokenId": null
|
||||
},
|
||||
{
|
||||
"id": "<id:4>",
|
||||
@@ -500,7 +503,8 @@
|
||||
"syncVersion": 1,
|
||||
"deletedAt": null,
|
||||
"createdAt": "2026-05-01T08:20:04.000Z",
|
||||
"updatedAt": "2026-05-01T08:21:04.000Z"
|
||||
"updatedAt": "2026-05-01T08:21:04.000Z",
|
||||
"ingestTokenId": null
|
||||
},
|
||||
{
|
||||
"id": "<id:5>",
|
||||
@@ -524,7 +528,8 @@
|
||||
"syncVersion": 1,
|
||||
"deletedAt": null,
|
||||
"createdAt": "2026-05-01T08:20:00.000Z",
|
||||
"updatedAt": "2026-05-01T08:21:00.000Z"
|
||||
"updatedAt": "2026-05-01T08:21:00.000Z",
|
||||
"ingestTokenId": null
|
||||
}
|
||||
],
|
||||
"IntradayCumulativeProfile": [
|
||||
@@ -3091,6 +3096,7 @@
|
||||
"sleepStage": null,
|
||||
"rhythmClassification": null,
|
||||
"deviceType": null,
|
||||
"ingestTokenId": null,
|
||||
"syncVersion": 1,
|
||||
"deletedAt": null,
|
||||
"createdAt": "2026-05-01T08:20:03.000Z",
|
||||
@@ -3114,6 +3120,7 @@
|
||||
"sleepStage": "DEEP",
|
||||
"rhythmClassification": null,
|
||||
"deviceType": null,
|
||||
"ingestTokenId": null,
|
||||
"syncVersion": 1,
|
||||
"deletedAt": null,
|
||||
"createdAt": "2026-05-01T08:20:04.000Z",
|
||||
@@ -3137,6 +3144,7 @@
|
||||
"sleepStage": null,
|
||||
"rhythmClassification": null,
|
||||
"deviceType": null,
|
||||
"ingestTokenId": null,
|
||||
"syncVersion": 4,
|
||||
"deletedAt": "<tombstone>",
|
||||
"createdAt": "2026-05-01T08:20:01.000Z",
|
||||
@@ -3160,6 +3168,7 @@
|
||||
"sleepStage": null,
|
||||
"rhythmClassification": null,
|
||||
"deviceType": "rt-Measurement-deviceType",
|
||||
"ingestTokenId": null,
|
||||
"syncVersion": 1,
|
||||
"deletedAt": null,
|
||||
"createdAt": "2026-05-01T08:20:00.000Z",
|
||||
@@ -3183,6 +3192,7 @@
|
||||
"sleepStage": null,
|
||||
"rhythmClassification": "IRREGULAR",
|
||||
"deviceType": null,
|
||||
"ingestTokenId": null,
|
||||
"syncVersion": 1,
|
||||
"deletedAt": null,
|
||||
"createdAt": "2026-05-01T08:20:02.000Z",
|
||||
|
||||
@@ -520,7 +520,8 @@
|
||||
"syncVersion": 1,
|
||||
"deletedAt": null,
|
||||
"createdAt": "<now>",
|
||||
"updatedAt": "<now>"
|
||||
"updatedAt": "<now>",
|
||||
"ingestTokenId": null
|
||||
},
|
||||
{
|
||||
"id": "<id:2>",
|
||||
@@ -544,7 +545,8 @@
|
||||
"syncVersion": 1,
|
||||
"deletedAt": null,
|
||||
"createdAt": "<now>",
|
||||
"updatedAt": "<now>"
|
||||
"updatedAt": "<now>",
|
||||
"ingestTokenId": null
|
||||
},
|
||||
{
|
||||
"id": "<id:3>",
|
||||
@@ -568,7 +570,8 @@
|
||||
"syncVersion": 1,
|
||||
"deletedAt": null,
|
||||
"createdAt": "<now>",
|
||||
"updatedAt": "<now>"
|
||||
"updatedAt": "<now>",
|
||||
"ingestTokenId": null
|
||||
},
|
||||
{
|
||||
"id": "<id:4>",
|
||||
@@ -592,7 +595,8 @@
|
||||
"syncVersion": 1,
|
||||
"deletedAt": null,
|
||||
"createdAt": "<now>",
|
||||
"updatedAt": "<now>"
|
||||
"updatedAt": "<now>",
|
||||
"ingestTokenId": null
|
||||
}
|
||||
],
|
||||
"IntradayCumulativeProfile": [
|
||||
|
||||
@@ -550,7 +550,8 @@
|
||||
"syncVersion": 1,
|
||||
"deletedAt": null,
|
||||
"createdAt": "<now>",
|
||||
"updatedAt": "<now>"
|
||||
"updatedAt": "<now>",
|
||||
"ingestTokenId": null
|
||||
}
|
||||
],
|
||||
"IntradayCumulativeProfile": [],
|
||||
|
||||
@@ -426,7 +426,8 @@
|
||||
"syncVersion": 1,
|
||||
"deletedAt": null,
|
||||
"createdAt": "<now>",
|
||||
"updatedAt": "<now>"
|
||||
"updatedAt": "<now>",
|
||||
"ingestTokenId": null
|
||||
},
|
||||
{
|
||||
"id": "<id:2>",
|
||||
@@ -450,7 +451,8 @@
|
||||
"syncVersion": 1,
|
||||
"deletedAt": null,
|
||||
"createdAt": "<now>",
|
||||
"updatedAt": "<now>"
|
||||
"updatedAt": "<now>",
|
||||
"ingestTokenId": null
|
||||
},
|
||||
{
|
||||
"id": "<id:3>",
|
||||
@@ -474,7 +476,8 @@
|
||||
"syncVersion": 1,
|
||||
"deletedAt": null,
|
||||
"createdAt": "<now>",
|
||||
"updatedAt": "<now>"
|
||||
"updatedAt": "<now>",
|
||||
"ingestTokenId": null
|
||||
},
|
||||
{
|
||||
"id": "<id:4>",
|
||||
@@ -498,7 +501,8 @@
|
||||
"syncVersion": 1,
|
||||
"deletedAt": null,
|
||||
"createdAt": "<now>",
|
||||
"updatedAt": "<now>"
|
||||
"updatedAt": "<now>",
|
||||
"ingestTokenId": null
|
||||
}
|
||||
],
|
||||
"IntradayCumulativeProfile": [],
|
||||
@@ -1289,6 +1293,7 @@
|
||||
"sleepStage": null,
|
||||
"rhythmClassification": null,
|
||||
"deviceType": null,
|
||||
"ingestTokenId": null,
|
||||
"syncVersion": 1,
|
||||
"deletedAt": null,
|
||||
"createdAt": "<now>",
|
||||
@@ -1312,6 +1317,7 @@
|
||||
"sleepStage": null,
|
||||
"rhythmClassification": null,
|
||||
"deviceType": null,
|
||||
"ingestTokenId": null,
|
||||
"syncVersion": 1,
|
||||
"deletedAt": null,
|
||||
"createdAt": "<now>",
|
||||
@@ -1335,6 +1341,7 @@
|
||||
"sleepStage": null,
|
||||
"rhythmClassification": null,
|
||||
"deviceType": null,
|
||||
"ingestTokenId": null,
|
||||
"syncVersion": 1,
|
||||
"deletedAt": null,
|
||||
"createdAt": "<now>",
|
||||
@@ -1358,6 +1365,7 @@
|
||||
"sleepStage": null,
|
||||
"rhythmClassification": null,
|
||||
"deviceType": null,
|
||||
"ingestTokenId": null,
|
||||
"syncVersion": 1,
|
||||
"deletedAt": null,
|
||||
"createdAt": "<now>",
|
||||
|
||||
@@ -26,7 +26,8 @@
|
||||
"syncVersion": 1,
|
||||
"deletedAt": null,
|
||||
"createdAt": "2026-05-01T08:20:03.000Z",
|
||||
"updatedAt": "2026-05-01T08:21:03.000Z"
|
||||
"updatedAt": "2026-05-01T08:21:03.000Z",
|
||||
"ingestTokenId": null
|
||||
},
|
||||
{
|
||||
"id": "<id:2>",
|
||||
@@ -50,7 +51,8 @@
|
||||
"syncVersion": 1,
|
||||
"deletedAt": null,
|
||||
"createdAt": "2026-05-01T08:20:02.000Z",
|
||||
"updatedAt": "2026-05-01T08:21:02.000Z"
|
||||
"updatedAt": "2026-05-01T08:21:02.000Z",
|
||||
"ingestTokenId": null
|
||||
},
|
||||
{
|
||||
"id": "<id:3>",
|
||||
@@ -74,7 +76,8 @@
|
||||
"syncVersion": 4,
|
||||
"deletedAt": "<tombstone>",
|
||||
"createdAt": "2026-05-01T08:20:01.000Z",
|
||||
"updatedAt": "2026-05-01T08:21:01.000Z"
|
||||
"updatedAt": "2026-05-01T08:21:01.000Z",
|
||||
"ingestTokenId": null
|
||||
},
|
||||
{
|
||||
"id": "<id:4>",
|
||||
@@ -98,7 +101,8 @@
|
||||
"syncVersion": 1,
|
||||
"deletedAt": null,
|
||||
"createdAt": "2026-05-01T08:20:04.000Z",
|
||||
"updatedAt": "2026-05-01T08:21:04.000Z"
|
||||
"updatedAt": "2026-05-01T08:21:04.000Z",
|
||||
"ingestTokenId": null
|
||||
},
|
||||
{
|
||||
"id": "<id:5>",
|
||||
@@ -122,7 +126,8 @@
|
||||
"syncVersion": 1,
|
||||
"deletedAt": null,
|
||||
"createdAt": "2026-05-01T08:20:00.000Z",
|
||||
"updatedAt": "2026-05-01T08:21:00.000Z"
|
||||
"updatedAt": "2026-05-01T08:21:00.000Z",
|
||||
"ingestTokenId": null
|
||||
}
|
||||
],
|
||||
"IntradayCumulativeProfile": [
|
||||
|
||||
@@ -0,0 +1,351 @@
|
||||
/**
|
||||
* Per-token Apple Health write-back (#1272), end to end against Postgres.
|
||||
*
|
||||
* A reading a `measurements:write` token carries keeps its `EXTERNAL` source
|
||||
* and is linked to the token. Every measurement row answers a resolved
|
||||
* `appleHealthMirror`: WITHINGS, IMPORT and MANUAL true, EXTERNAL whatever the
|
||||
* person chose for the token, everything else false. Switching the setting is
|
||||
* one statement on the token row: the readings themselves are not touched
|
||||
* (no `updatedAt` or `syncVersion` bump, so nothing keyed on them recomputes),
|
||||
* and the `measurementMirror` domain of `/api/sync/changes` re-delivers the
|
||||
* token's rows with the new value.
|
||||
*/
|
||||
import { beforeEach, describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
GET as listMeasurements,
|
||||
POST as createMeasurement,
|
||||
} from "@/app/api/measurements/route";
|
||||
import { POST as batchMeasurements } from "@/app/api/measurements/batch/route";
|
||||
import { GET as readMeasurement } from "@/app/api/measurements/[id]/route";
|
||||
import { GET as syncChanges } from "@/app/api/sync/changes/route";
|
||||
import {
|
||||
PATCH as patchToken,
|
||||
DELETE as revokeToken,
|
||||
} from "@/app/api/tokens/[id]/route";
|
||||
import { GET as listTokens } from "@/app/api/tokens/route";
|
||||
import { MEASUREMENTS_WRITE_SCOPE } from "@/lib/measurements/scopes";
|
||||
import { MEASUREMENT_SOURCES } from "@/lib/measurements/sources";
|
||||
|
||||
import { callRoute, type RouteAuth } from "../kit/call-route";
|
||||
import {
|
||||
makeMeasurement,
|
||||
makeSession,
|
||||
makeToken,
|
||||
makeUser,
|
||||
type BearerFixture,
|
||||
} from "../kit/factories";
|
||||
import { getPrismaClient, truncateAllTables } from "./setup";
|
||||
|
||||
const prisma = getPrismaClient();
|
||||
|
||||
let userId: string;
|
||||
let owner: RouteAuth;
|
||||
let scale: BearerFixture;
|
||||
|
||||
beforeEach(async () => {
|
||||
await truncateAllTables(prisma);
|
||||
const user = await makeUser({ timezone: "UTC" });
|
||||
userId = user.id;
|
||||
owner = await makeSession(user);
|
||||
scale = await makeToken(user, {
|
||||
permissions: [MEASUREMENTS_WRITE_SCOPE],
|
||||
name: "Scale",
|
||||
});
|
||||
});
|
||||
|
||||
async function postReading(
|
||||
value: number,
|
||||
at: string,
|
||||
headers?: Record<string, string>,
|
||||
) {
|
||||
return callRoute<{ id: string }>(createMeasurement, {
|
||||
method: "POST",
|
||||
route: "/api/measurements",
|
||||
body: { type: "WEIGHT", value, unit: "kg", measuredAt: at },
|
||||
auth: scale,
|
||||
headers,
|
||||
});
|
||||
}
|
||||
|
||||
async function list(query: Record<string, string | number> = {}) {
|
||||
const res = await callRoute<{
|
||||
measurements: Array<{
|
||||
id: string;
|
||||
source: string;
|
||||
appleHealthMirror: boolean;
|
||||
}>;
|
||||
}>(listMeasurements, {
|
||||
method: "GET",
|
||||
route: "/api/measurements",
|
||||
query: { limit: 50, ...query },
|
||||
auth: owner,
|
||||
});
|
||||
expect(res.status).toBe(200);
|
||||
return res.data.measurements;
|
||||
}
|
||||
|
||||
async function setMirror(id: string, on: boolean, auth: RouteAuth = owner) {
|
||||
return callRoute<{ id: string; mirrorToAppleHealth: boolean }>(patchToken, {
|
||||
method: "PATCH",
|
||||
route: "/api/tokens/{id}",
|
||||
params: { id },
|
||||
body: { mirrorToAppleHealth: on },
|
||||
auth,
|
||||
});
|
||||
}
|
||||
|
||||
describe("a token's readings and the resolved write-back", () => {
|
||||
it("links the reading to its token and resolves each source", async () => {
|
||||
const res = await postReading(81.2, "2026-10-14T06:30:00.000Z");
|
||||
expect(res.status).toBe(201);
|
||||
const stored = await prisma.measurement.findFirstOrThrow({
|
||||
where: { userId, source: "EXTERNAL" },
|
||||
select: { ingestTokenId: true },
|
||||
});
|
||||
expect(stored.ingestTokenId).toBe(scale.tokenId);
|
||||
|
||||
await makeMeasurement(
|
||||
{ id: userId },
|
||||
{
|
||||
source: "MANUAL",
|
||||
measuredAt: new Date("2026-10-13T06:30:00.000Z"),
|
||||
},
|
||||
);
|
||||
await makeMeasurement(
|
||||
{ id: userId },
|
||||
{
|
||||
source: "APPLE_HEALTH",
|
||||
measuredAt: new Date("2026-10-12T06:30:00.000Z"),
|
||||
externalId: "hk-1",
|
||||
},
|
||||
);
|
||||
|
||||
const rows = await list({ type: "WEIGHT" });
|
||||
const bySource = Object.fromEntries(
|
||||
rows.map((r) => [r.source, r.appleHealthMirror]),
|
||||
);
|
||||
expect(bySource).toEqual({
|
||||
EXTERNAL: false,
|
||||
MANUAL: true,
|
||||
APPLE_HEALTH: false,
|
||||
});
|
||||
// The token never reaches the wire.
|
||||
for (const row of rows) {
|
||||
expect(row).not.toHaveProperty("ingestTokenId");
|
||||
expect(row).not.toHaveProperty("ingestToken");
|
||||
}
|
||||
});
|
||||
|
||||
it("links every row of a token batch", async () => {
|
||||
const res = await callRoute(batchMeasurements, {
|
||||
method: "POST",
|
||||
route: "/api/measurements/batch",
|
||||
body: {
|
||||
entries: [
|
||||
{
|
||||
hkIdentifier: "HKQuantityTypeIdentifierBodyMass",
|
||||
value: 80.1,
|
||||
unit: "kg",
|
||||
startDate: "2026-10-14T06:30:00.000Z",
|
||||
endDate: "2026-10-14T06:30:00.000Z",
|
||||
externalId: "scale-1",
|
||||
},
|
||||
],
|
||||
},
|
||||
auth: scale,
|
||||
});
|
||||
expect(res.status).toBe(200);
|
||||
const rows = await prisma.measurement.findMany({
|
||||
where: { userId },
|
||||
select: { source: true, ingestTokenId: true },
|
||||
});
|
||||
expect(rows).toEqual([
|
||||
{ source: "EXTERNAL", ingestTokenId: scale.tokenId },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("the list filter", () => {
|
||||
it("returns exactly the rows that resolve true, for every source", async () => {
|
||||
const on = await makeToken(
|
||||
{ id: userId },
|
||||
{ permissions: [MEASUREMENTS_WRITE_SCOPE], name: "On" },
|
||||
);
|
||||
await prisma.apiToken.update({
|
||||
where: { id: on.tokenId },
|
||||
data: { mirrorToAppleHealth: true },
|
||||
});
|
||||
let minute = 0;
|
||||
for (const source of MEASUREMENT_SOURCES) {
|
||||
for (const ingestTokenId of [null, scale.tokenId, on.tokenId]) {
|
||||
minute += 1;
|
||||
await makeMeasurement(
|
||||
{ id: userId },
|
||||
{
|
||||
source,
|
||||
ingestTokenId,
|
||||
measuredAt: new Date(Date.UTC(2026, 9, 1, 6, minute)),
|
||||
externalId: `row-${minute}`,
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
const all = await list({ limit: 500 });
|
||||
expect(all).toHaveLength(MEASUREMENT_SOURCES.length * 3);
|
||||
const expected = all
|
||||
.filter((r) => r.appleHealthMirror)
|
||||
.map((r) => r.id)
|
||||
.sort();
|
||||
expect(expected.length).toBeGreaterThan(0);
|
||||
const filtered = await list({ limit: 500, appleHealthMirror: "true" });
|
||||
expect(filtered.map((r) => r.id).sort()).toEqual(expected);
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ groupBy: "day" },
|
||||
{ dayKey: "2026-10-14" },
|
||||
{ aggregate: "daily" },
|
||||
{ type: "SLEEP_DURATION" },
|
||||
])("is refused beside %j", async (mode) => {
|
||||
const res = await callRoute(listMeasurements, {
|
||||
method: "GET",
|
||||
route: "/api/measurements",
|
||||
query: { type: "WEIGHT", ...mode, appleHealthMirror: "true" },
|
||||
auth: owner,
|
||||
});
|
||||
expect(res.status).toBe(422);
|
||||
});
|
||||
});
|
||||
|
||||
describe("switching the setting", () => {
|
||||
it("turns the token's rows true everywhere, and leaves the rows themselves alone", async () => {
|
||||
const created = await postReading(81.2, "2026-10-14T06:30:00.000Z");
|
||||
const id = created.data.id;
|
||||
const before = await prisma.measurement.findUniqueOrThrow({
|
||||
where: { id },
|
||||
select: { updatedAt: true, syncVersion: true },
|
||||
});
|
||||
const first = await callRoute<{ cursor: string }>(syncChanges, {
|
||||
method: "GET",
|
||||
route: "/api/sync/changes",
|
||||
auth: owner,
|
||||
});
|
||||
|
||||
const res = await setMirror(scale.tokenId, true);
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.data).toEqual({ id: scale.tokenId, mirrorToAppleHealth: true });
|
||||
|
||||
const after = await prisma.measurement.findUniqueOrThrow({
|
||||
where: { id },
|
||||
select: { updatedAt: true, syncVersion: true },
|
||||
});
|
||||
expect(after).toEqual(before);
|
||||
|
||||
expect((await list())[0].appleHealthMirror).toBe(true);
|
||||
expect(
|
||||
(await list({ appleHealthMirror: "true" })).map((r) => r.id),
|
||||
).toEqual([id]);
|
||||
const detail = await callRoute<{ appleHealthMirror: boolean }>(
|
||||
readMeasurement,
|
||||
{
|
||||
method: "GET",
|
||||
route: "/api/measurements/{id}",
|
||||
params: { id },
|
||||
auth: owner,
|
||||
},
|
||||
);
|
||||
expect(detail.data.appleHealthMirror).toBe(true);
|
||||
|
||||
const next = await callRoute<{
|
||||
changes: {
|
||||
measurements: { upserts: unknown[] };
|
||||
measurementMirror: {
|
||||
upserts: Array<{ id: string; appleHealthMirror: boolean }>;
|
||||
};
|
||||
};
|
||||
}>(syncChanges, {
|
||||
method: "GET",
|
||||
route: "/api/sync/changes",
|
||||
query: { cursor: first.data.cursor },
|
||||
auth: owner,
|
||||
});
|
||||
expect(next.data.changes.measurements.upserts).toEqual([]);
|
||||
expect(next.data.changes.measurementMirror.upserts).toEqual([
|
||||
{ id, appleHealthMirror: true },
|
||||
]);
|
||||
|
||||
const audit = await prisma.auditLog.findMany({
|
||||
where: { userId, action: "tokens.measurements.mirror.update" },
|
||||
select: { details: true },
|
||||
});
|
||||
expect(audit).toHaveLength(1);
|
||||
expect(JSON.parse(audit[0].details ?? "{}")).toMatchObject({
|
||||
tokenId: scale.tokenId,
|
||||
mirrorToAppleHealth: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps a revoked token's choice, and shows it on the token list", async () => {
|
||||
await postReading(81.2, "2026-10-14T06:30:00.000Z");
|
||||
await setMirror(scale.tokenId, true);
|
||||
const revoked = await callRoute(revokeToken, {
|
||||
method: "DELETE",
|
||||
route: "/api/tokens/{id}",
|
||||
params: { id: scale.tokenId },
|
||||
auth: owner,
|
||||
});
|
||||
expect(revoked.status).toBe(200);
|
||||
expect((await list())[0].appleHealthMirror).toBe(true);
|
||||
|
||||
// A revoked token can still be switched.
|
||||
expect((await setMirror(scale.tokenId, false)).status).toBe(200);
|
||||
expect((await list())[0].appleHealthMirror).toBe(false);
|
||||
|
||||
const tokens = await callRoute<
|
||||
Array<{ id: string; mirrorToAppleHealth: boolean }>
|
||||
>(listTokens, { method: "GET", route: "/api/tokens", auth: owner });
|
||||
expect(tokens.data.find((t) => t.id === scale.tokenId)).toMatchObject({
|
||||
mirrorToAppleHealth: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("answers a retried create from its idempotency record, unchanged", async () => {
|
||||
const headers = { "idempotency-key": "scale-reading-1" };
|
||||
const first = await postReading(81.2, "2026-10-14T06:30:00.000Z", headers);
|
||||
await setMirror(scale.tokenId, true);
|
||||
const replay = await postReading(81.2, "2026-10-14T06:30:00.000Z", headers);
|
||||
expect(replay.status).toBe(first.status);
|
||||
expect(replay.body).toEqual(first.body);
|
||||
expect(
|
||||
await prisma.measurement.count({ where: { userId, source: "EXTERNAL" } }),
|
||||
).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("what the switch refuses", () => {
|
||||
it("refuses a token that is not a measurement token", async () => {
|
||||
const wildcard = await makeToken({ id: userId }, { permissions: ["*"] });
|
||||
const res = await setMirror(wildcard.tokenId, true);
|
||||
expect(res.status).toBe(422);
|
||||
expect(res.meta?.errorCode).toBe("tokens.mirror.not_measurement_token");
|
||||
});
|
||||
|
||||
it("answers another account's token as missing", async () => {
|
||||
const stranger = await makeUser({ timezone: "UTC" });
|
||||
const theirs = await makeToken(stranger, {
|
||||
permissions: [MEASUREMENTS_WRITE_SCOPE],
|
||||
});
|
||||
expect((await setMirror(theirs.tokenId, true)).status).toBe(404);
|
||||
});
|
||||
|
||||
it("refuses a Bearer caller, the token's own included", async () => {
|
||||
const res = await setMirror(scale.tokenId, true, scale);
|
||||
expect([401, 403]).toContain(res.status);
|
||||
const stored = await prisma.apiToken.findUniqueOrThrow({
|
||||
where: { id: scale.tokenId },
|
||||
select: { mirrorToAppleHealth: true },
|
||||
});
|
||||
expect(stored.mirrorToAppleHealth).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,131 @@
|
||||
/**
|
||||
* A backup carries the token link of an EXTERNAL reading, and a restore keeps
|
||||
* it only where it still means something (#1272).
|
||||
*
|
||||
* Tokens are not part of a backup. A reading linked to a token the restoring
|
||||
* account still holds comes back linked, so its Apple Health write-back is
|
||||
* still that token's setting; a reading whose token is gone comes back with no
|
||||
* link rather than a dangling id. The file is built by the real writer.
|
||||
*/
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
process.env.ENCRYPTION_KEY =
|
||||
"0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
|
||||
|
||||
import { encrypt } from "@/lib/crypto";
|
||||
import { buildFullBackupPayload } from "@/lib/export/full-backup-payload";
|
||||
import { parseBackupPayload } from "@/lib/validations/backup";
|
||||
import { MEASUREMENTS_WRITE_SCOPE } from "@/lib/measurements/scopes";
|
||||
import { POST as RESTORE } from "./restore-job-driver";
|
||||
|
||||
import { makeToken } from "../kit/factories";
|
||||
import { cookieJar, headerJar } from "./mock-next-headers";
|
||||
import { getPrismaClient, truncateAllTables } from "./setup";
|
||||
|
||||
vi.mock("next/headers", async () => {
|
||||
const { cookieJar, headerJar } = await import("./mock-next-headers");
|
||||
return {
|
||||
headers: vi.fn(async () => ({
|
||||
get: (name: string) => headerJar.get(name.toLowerCase()) ?? null,
|
||||
})),
|
||||
cookies: vi.fn(async () => ({
|
||||
get: (name: string) => {
|
||||
const value = cookieJar.get(name);
|
||||
return value ? { name, value } : undefined;
|
||||
},
|
||||
set: (name: string, value: string) => cookieJar.set(name, value),
|
||||
delete: (name: string) => cookieJar.delete(name),
|
||||
})),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("@/lib/db-compat", () => ({
|
||||
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/cache/invalidate", async (importOriginal) => {
|
||||
const actual =
|
||||
await importOriginal<typeof import("@/lib/cache/invalidate")>();
|
||||
return { ...actual, invalidateUserData: vi.fn() };
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
await truncateAllTables(getPrismaClient());
|
||||
cookieJar.clear();
|
||||
headerJar.clear();
|
||||
});
|
||||
|
||||
describe("restoring EXTERNAL readings", () => {
|
||||
it("keeps the link to a token the account holds and drops the rest", async () => {
|
||||
const prisma = getPrismaClient();
|
||||
const owner = await prisma.user.create({
|
||||
data: {
|
||||
username: "token-link-owner",
|
||||
email: "token-link-owner@example.test",
|
||||
role: "ADMIN",
|
||||
},
|
||||
});
|
||||
const session = await prisma.session.create({
|
||||
data: { userId: owner.id, expiresAt: new Date(Date.now() + 60_000) },
|
||||
});
|
||||
cookieJar.set("healthlog_session", session.id);
|
||||
|
||||
const kept = await makeToken(owner, {
|
||||
permissions: [MEASUREMENTS_WRITE_SCOPE],
|
||||
});
|
||||
const gone = await makeToken(owner, {
|
||||
permissions: [MEASUREMENTS_WRITE_SCOPE],
|
||||
});
|
||||
const reading = (day: string, ingestTokenId: string | null) => ({
|
||||
userId: owner.id,
|
||||
type: "WEIGHT" as const,
|
||||
source: "EXTERNAL" as const,
|
||||
value: 80,
|
||||
unit: "kg",
|
||||
measuredAt: new Date(`${day}T06:00:00.000Z`),
|
||||
ingestTokenId,
|
||||
});
|
||||
await prisma.measurement.createMany({
|
||||
data: [
|
||||
reading("2026-10-01", kept.tokenId),
|
||||
reading("2026-10-02", gone.tokenId),
|
||||
reading("2026-10-03", null),
|
||||
],
|
||||
});
|
||||
|
||||
const { payload: built } = await buildFullBackupPayload(prisma, owner.id, {
|
||||
purpose: "disaster-recovery",
|
||||
});
|
||||
const parsed = parseBackupPayload(JSON.stringify(built));
|
||||
const backup = await prisma.dataBackup.create({
|
||||
data: {
|
||||
userId: owner.id,
|
||||
type: "TOKEN_LINK_ROUND_TRIP",
|
||||
data: encrypt(JSON.stringify(parsed)),
|
||||
},
|
||||
});
|
||||
await prisma.measurement.deleteMany({ where: { userId: owner.id } });
|
||||
await prisma.apiToken.delete({ where: { id: gone.tokenId } });
|
||||
|
||||
const response = await RESTORE(
|
||||
new Request(`http://localhost/api/admin/backups/${backup.id}/restore`, {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ confirm: "RESTORE" }),
|
||||
}) as unknown as Parameters<typeof RESTORE>[0],
|
||||
{ params: Promise.resolve({ id: backup.id }) },
|
||||
);
|
||||
expect(response.status).toBe(200);
|
||||
|
||||
const restored = await prisma.measurement.findMany({
|
||||
where: { userId: owner.id },
|
||||
select: { measuredAt: true, ingestTokenId: true },
|
||||
orderBy: { measuredAt: "asc" },
|
||||
});
|
||||
expect(restored.map((r) => r.ingestTokenId)).toEqual([
|
||||
kept.tokenId,
|
||||
null,
|
||||
null,
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -27,6 +27,7 @@ const token = {
|
||||
expiresAt: null,
|
||||
createdAt: "2026-01-01T00:00:00.000Z",
|
||||
revoked: false,
|
||||
mirrorToAppleHealth: false,
|
||||
};
|
||||
|
||||
function answering(status: number, body: unknown) {
|
||||
|
||||
@@ -24,7 +24,8 @@
|
||||
"syncVersion": "<syncVersion>",
|
||||
"deletedAt": null,
|
||||
"createdAt": "<createdAt>",
|
||||
"updatedAt": "<updatedAt>"
|
||||
"updatedAt": "<updatedAt>",
|
||||
"appleHealthMirror": true
|
||||
},
|
||||
{
|
||||
"id": "<id:3>",
|
||||
@@ -47,7 +48,8 @@
|
||||
"syncVersion": "<syncVersion>",
|
||||
"deletedAt": null,
|
||||
"createdAt": "<createdAt>",
|
||||
"updatedAt": "<updatedAt>"
|
||||
"updatedAt": "<updatedAt>",
|
||||
"appleHealthMirror": false
|
||||
},
|
||||
{
|
||||
"id": "<id:4>",
|
||||
@@ -70,7 +72,8 @@
|
||||
"syncVersion": "<syncVersion>",
|
||||
"deletedAt": null,
|
||||
"createdAt": "<createdAt>",
|
||||
"updatedAt": "<updatedAt>"
|
||||
"updatedAt": "<updatedAt>",
|
||||
"appleHealthMirror": false
|
||||
},
|
||||
{
|
||||
"id": "<id:5>",
|
||||
@@ -93,7 +96,8 @@
|
||||
"syncVersion": "<syncVersion>",
|
||||
"deletedAt": null,
|
||||
"createdAt": "<createdAt>",
|
||||
"updatedAt": "<updatedAt>"
|
||||
"updatedAt": "<updatedAt>",
|
||||
"appleHealthMirror": true
|
||||
},
|
||||
{
|
||||
"id": "<id:6>",
|
||||
@@ -116,7 +120,8 @@
|
||||
"syncVersion": "<syncVersion>",
|
||||
"deletedAt": null,
|
||||
"createdAt": "<createdAt>",
|
||||
"updatedAt": "<updatedAt>"
|
||||
"updatedAt": "<updatedAt>",
|
||||
"appleHealthMirror": true
|
||||
},
|
||||
{
|
||||
"id": "<id:7>",
|
||||
@@ -139,7 +144,8 @@
|
||||
"syncVersion": "<syncVersion>",
|
||||
"deletedAt": null,
|
||||
"createdAt": "<createdAt>",
|
||||
"updatedAt": "<updatedAt>"
|
||||
"updatedAt": "<updatedAt>",
|
||||
"appleHealthMirror": true
|
||||
},
|
||||
{
|
||||
"id": "<id:8>",
|
||||
@@ -162,7 +168,8 @@
|
||||
"syncVersion": "<syncVersion>",
|
||||
"deletedAt": null,
|
||||
"createdAt": "<createdAt>",
|
||||
"updatedAt": "<updatedAt>"
|
||||
"updatedAt": "<updatedAt>",
|
||||
"appleHealthMirror": true
|
||||
},
|
||||
{
|
||||
"id": "<id:9>",
|
||||
@@ -185,7 +192,8 @@
|
||||
"syncVersion": "<syncVersion>",
|
||||
"deletedAt": null,
|
||||
"createdAt": "<createdAt>",
|
||||
"updatedAt": "<updatedAt>"
|
||||
"updatedAt": "<updatedAt>",
|
||||
"appleHealthMirror": true
|
||||
},
|
||||
{
|
||||
"id": "<id:10>",
|
||||
@@ -208,7 +216,8 @@
|
||||
"syncVersion": "<syncVersion>",
|
||||
"deletedAt": null,
|
||||
"createdAt": "<createdAt>",
|
||||
"updatedAt": "<updatedAt>"
|
||||
"updatedAt": "<updatedAt>",
|
||||
"appleHealthMirror": true
|
||||
},
|
||||
{
|
||||
"id": "<id:11>",
|
||||
@@ -231,7 +240,8 @@
|
||||
"syncVersion": "<syncVersion>",
|
||||
"deletedAt": null,
|
||||
"createdAt": "<createdAt>",
|
||||
"updatedAt": "<updatedAt>"
|
||||
"updatedAt": "<updatedAt>",
|
||||
"appleHealthMirror": false
|
||||
},
|
||||
{
|
||||
"id": "<id:12>",
|
||||
@@ -254,7 +264,8 @@
|
||||
"syncVersion": "<syncVersion>",
|
||||
"deletedAt": null,
|
||||
"createdAt": "<createdAt>",
|
||||
"updatedAt": "<updatedAt>"
|
||||
"updatedAt": "<updatedAt>",
|
||||
"appleHealthMirror": false
|
||||
}
|
||||
],
|
||||
"meta": {
|
||||
|
||||
+4
-2
@@ -24,7 +24,8 @@
|
||||
"syncVersion": "<syncVersion>",
|
||||
"deletedAt": null,
|
||||
"createdAt": "<createdAt>",
|
||||
"updatedAt": "<updatedAt>"
|
||||
"updatedAt": "<updatedAt>",
|
||||
"appleHealthMirror": true
|
||||
},
|
||||
{
|
||||
"id": "<id:3>",
|
||||
@@ -47,7 +48,8 @@
|
||||
"syncVersion": "<syncVersion>",
|
||||
"deletedAt": null,
|
||||
"createdAt": "<createdAt>",
|
||||
"updatedAt": "<updatedAt>"
|
||||
"updatedAt": "<updatedAt>",
|
||||
"appleHealthMirror": true
|
||||
}
|
||||
],
|
||||
"meta": {
|
||||
|
||||
+4
-2
@@ -24,7 +24,8 @@
|
||||
"syncVersion": "<syncVersion>",
|
||||
"deletedAt": null,
|
||||
"createdAt": "<createdAt>",
|
||||
"updatedAt": "<updatedAt>"
|
||||
"updatedAt": "<updatedAt>",
|
||||
"appleHealthMirror": true
|
||||
},
|
||||
{
|
||||
"id": "<id:3>",
|
||||
@@ -47,7 +48,8 @@
|
||||
"syncVersion": "<syncVersion>",
|
||||
"deletedAt": null,
|
||||
"createdAt": "<createdAt>",
|
||||
"updatedAt": "<updatedAt>"
|
||||
"updatedAt": "<updatedAt>",
|
||||
"appleHealthMirror": true
|
||||
}
|
||||
],
|
||||
"meta": {
|
||||
|
||||
+2
-1
@@ -24,7 +24,8 @@
|
||||
"syncVersion": "<syncVersion>",
|
||||
"deletedAt": null,
|
||||
"createdAt": "<createdAt>",
|
||||
"updatedAt": "<updatedAt>"
|
||||
"updatedAt": "<updatedAt>",
|
||||
"appleHealthMirror": false
|
||||
}
|
||||
],
|
||||
"meta": {
|
||||
|
||||
@@ -22,7 +22,8 @@
|
||||
"syncVersion": "<syncVersion>",
|
||||
"deletedAt": null,
|
||||
"createdAt": "<createdAt>",
|
||||
"updatedAt": "<updatedAt>"
|
||||
"updatedAt": "<updatedAt>",
|
||||
"appleHealthMirror": true
|
||||
},
|
||||
"error": null
|
||||
},
|
||||
@@ -73,7 +74,9 @@
|
||||
"syncVersion": "<syncVersion>",
|
||||
"deletedAt": null,
|
||||
"createdAt": "<createdAt>",
|
||||
"updatedAt": "<updatedAt>"
|
||||
"updatedAt": "<updatedAt>",
|
||||
"ingestTokenId": null,
|
||||
"ingestToken": null
|
||||
}
|
||||
],
|
||||
"manual"
|
||||
|
||||
+5
-2
@@ -22,7 +22,8 @@
|
||||
"syncVersion": "<syncVersion>",
|
||||
"deletedAt": null,
|
||||
"createdAt": "<createdAt>",
|
||||
"updatedAt": "<updatedAt>"
|
||||
"updatedAt": "<updatedAt>",
|
||||
"appleHealthMirror": true
|
||||
},
|
||||
"error": null
|
||||
},
|
||||
@@ -73,7 +74,9 @@
|
||||
"syncVersion": "<syncVersion>",
|
||||
"deletedAt": null,
|
||||
"createdAt": "<createdAt>",
|
||||
"updatedAt": "<updatedAt>"
|
||||
"updatedAt": "<updatedAt>",
|
||||
"ingestTokenId": null,
|
||||
"ingestToken": null
|
||||
}
|
||||
],
|
||||
"manual"
|
||||
|
||||
@@ -23,7 +23,8 @@
|
||||
"syncVersion": "<syncVersion>",
|
||||
"deletedAt": null,
|
||||
"createdAt": "<createdAt>",
|
||||
"updatedAt": "<updatedAt>"
|
||||
"updatedAt": "<updatedAt>",
|
||||
"appleHealthMirror": true
|
||||
},
|
||||
{
|
||||
"id": "<id:3>",
|
||||
@@ -46,7 +47,8 @@
|
||||
"syncVersion": "<syncVersion>",
|
||||
"deletedAt": null,
|
||||
"createdAt": "<createdAt>",
|
||||
"updatedAt": "<updatedAt>"
|
||||
"updatedAt": "<updatedAt>",
|
||||
"appleHealthMirror": true
|
||||
},
|
||||
{
|
||||
"id": "<id:4>",
|
||||
@@ -69,7 +71,8 @@
|
||||
"syncVersion": "<syncVersion>",
|
||||
"deletedAt": null,
|
||||
"createdAt": "<createdAt>",
|
||||
"updatedAt": "<updatedAt>"
|
||||
"updatedAt": "<updatedAt>",
|
||||
"appleHealthMirror": true
|
||||
}
|
||||
],
|
||||
"error": null
|
||||
@@ -126,7 +129,9 @@
|
||||
"syncVersion": "<syncVersion>",
|
||||
"deletedAt": null,
|
||||
"createdAt": "<createdAt>",
|
||||
"updatedAt": "<updatedAt>"
|
||||
"updatedAt": "<updatedAt>",
|
||||
"ingestTokenId": null,
|
||||
"ingestToken": null
|
||||
},
|
||||
{
|
||||
"id": "<id:3>",
|
||||
@@ -150,7 +155,9 @@
|
||||
"syncVersion": "<syncVersion>",
|
||||
"deletedAt": null,
|
||||
"createdAt": "<createdAt>",
|
||||
"updatedAt": "<updatedAt>"
|
||||
"updatedAt": "<updatedAt>",
|
||||
"ingestTokenId": null,
|
||||
"ingestToken": null
|
||||
},
|
||||
{
|
||||
"id": "<id:4>",
|
||||
@@ -174,7 +181,9 @@
|
||||
"syncVersion": "<syncVersion>",
|
||||
"deletedAt": null,
|
||||
"createdAt": "<createdAt>",
|
||||
"updatedAt": "<updatedAt>"
|
||||
"updatedAt": "<updatedAt>",
|
||||
"ingestTokenId": null,
|
||||
"ingestToken": null
|
||||
}
|
||||
],
|
||||
"manual"
|
||||
|
||||
+1
@@ -23,6 +23,7 @@
|
||||
"deletedAt": null,
|
||||
"createdAt": "<createdAt>",
|
||||
"updatedAt": "<updatedAt>",
|
||||
"appleHealthMirror": true,
|
||||
"status": "inserted"
|
||||
},
|
||||
"error": null
|
||||
|
||||
+1
@@ -23,6 +23,7 @@
|
||||
"deletedAt": null,
|
||||
"createdAt": "<createdAt>",
|
||||
"updatedAt": "<updatedAt>",
|
||||
"appleHealthMirror": true,
|
||||
"status": "duplicate"
|
||||
},
|
||||
"error": null
|
||||
|
||||
+1
@@ -23,6 +23,7 @@
|
||||
"deletedAt": null,
|
||||
"createdAt": "<createdAt>",
|
||||
"updatedAt": "<updatedAt>",
|
||||
"appleHealthMirror": true,
|
||||
"status": "duplicate"
|
||||
},
|
||||
"error": null
|
||||
|
||||
+13
-8
@@ -22,7 +22,8 @@
|
||||
"syncVersion": "<syncVersion>",
|
||||
"deletedAt": null,
|
||||
"createdAt": "<createdAt>",
|
||||
"updatedAt": "<updatedAt>"
|
||||
"updatedAt": "<updatedAt>",
|
||||
"appleHealthMirror": false
|
||||
},
|
||||
"error": null
|
||||
},
|
||||
@@ -73,7 +74,11 @@
|
||||
"syncVersion": "<syncVersion>",
|
||||
"deletedAt": null,
|
||||
"createdAt": "<createdAt>",
|
||||
"updatedAt": "<updatedAt>"
|
||||
"updatedAt": "<updatedAt>",
|
||||
"ingestTokenId": "<id:3>",
|
||||
"ingestToken": {
|
||||
"mirrorToAppleHealth": false
|
||||
}
|
||||
}
|
||||
],
|
||||
"manual"
|
||||
@@ -111,7 +116,7 @@
|
||||
],
|
||||
"stored": [
|
||||
{
|
||||
"id": "<id:3>",
|
||||
"id": "<id:4>",
|
||||
"type": "WEIGHT",
|
||||
"value": 80.4,
|
||||
"unit": "kg",
|
||||
@@ -141,7 +146,7 @@
|
||||
"sealedNote": false
|
||||
},
|
||||
{
|
||||
"id": "<id:4>",
|
||||
"id": "<id:5>",
|
||||
"type": "BLOOD_PRESSURE_SYS",
|
||||
"value": 128,
|
||||
"unit": "mmHg",
|
||||
@@ -156,7 +161,7 @@
|
||||
"sealedNote": false
|
||||
},
|
||||
{
|
||||
"id": "<id:5>",
|
||||
"id": "<id:6>",
|
||||
"type": "BLOOD_PRESSURE_DIA",
|
||||
"value": 82,
|
||||
"unit": "mmHg",
|
||||
@@ -171,7 +176,7 @@
|
||||
"sealedNote": false
|
||||
},
|
||||
{
|
||||
"id": "<id:6>",
|
||||
"id": "<id:7>",
|
||||
"type": "PULSE",
|
||||
"value": 66,
|
||||
"unit": "bpm",
|
||||
@@ -186,7 +191,7 @@
|
||||
"sealedNote": true
|
||||
},
|
||||
{
|
||||
"id": "<id:7>",
|
||||
"id": "<id:8>",
|
||||
"type": "SLEEP_DURATION",
|
||||
"value": 90,
|
||||
"unit": "minutes",
|
||||
@@ -201,7 +206,7 @@
|
||||
"sealedNote": false
|
||||
},
|
||||
{
|
||||
"id": "<id:8>",
|
||||
"id": "<id:9>",
|
||||
"type": "BLOOD_GLUCOSE",
|
||||
"value": 104,
|
||||
"unit": "mg/dL",
|
||||
|
||||
+5
-2
@@ -22,7 +22,8 @@
|
||||
"syncVersion": "<syncVersion>",
|
||||
"deletedAt": null,
|
||||
"createdAt": "<createdAt>",
|
||||
"updatedAt": "<updatedAt>"
|
||||
"updatedAt": "<updatedAt>",
|
||||
"appleHealthMirror": true
|
||||
},
|
||||
"error": null
|
||||
},
|
||||
@@ -73,7 +74,9 @@
|
||||
"syncVersion": "<syncVersion>",
|
||||
"deletedAt": null,
|
||||
"createdAt": "<createdAt>",
|
||||
"updatedAt": "<updatedAt>"
|
||||
"updatedAt": "<updatedAt>",
|
||||
"ingestTokenId": null,
|
||||
"ingestToken": null
|
||||
}
|
||||
],
|
||||
"manual"
|
||||
|
||||
@@ -18,6 +18,7 @@
|
||||
"measuredAt": "2026-10-14T06:30:00.000Z",
|
||||
"source": "MANUAL",
|
||||
"notes": "after the run",
|
||||
"appleHealthMirror": true,
|
||||
"syncVersion": "<syncVersion>",
|
||||
"updatedAt": "<updatedAt>"
|
||||
},
|
||||
@@ -30,6 +31,7 @@
|
||||
"measuredAt": "2026-10-14T07:00:00.000Z",
|
||||
"source": "WITHINGS",
|
||||
"notes": null,
|
||||
"appleHealthMirror": true,
|
||||
"syncVersion": "<syncVersion>",
|
||||
"updatedAt": "<updatedAt>"
|
||||
}
|
||||
@@ -197,6 +199,10 @@
|
||||
}
|
||||
],
|
||||
"tombstones": []
|
||||
},
|
||||
"measurementMirror": {
|
||||
"upserts": [],
|
||||
"tombstones": []
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -219,6 +225,7 @@
|
||||
"cycle_day_tombstones": 0,
|
||||
"cycle_upserts": 2,
|
||||
"cycle_tombstones": 0,
|
||||
"measurement_mirror_upserts": 0,
|
||||
"cycle_module_enabled": true,
|
||||
"has_more": true,
|
||||
"cursor_present": false
|
||||
|
||||
@@ -18,6 +18,7 @@
|
||||
"measuredAt": "2026-06-17T09:30:00.000Z",
|
||||
"source": "APPLE_HEALTH",
|
||||
"notes": null,
|
||||
"appleHealthMirror": false,
|
||||
"syncVersion": "<syncVersion>",
|
||||
"updatedAt": "<updatedAt>"
|
||||
}
|
||||
@@ -69,6 +70,10 @@
|
||||
"updatedAt": "<updatedAt>"
|
||||
}
|
||||
]
|
||||
},
|
||||
"measurementMirror": {
|
||||
"upserts": [],
|
||||
"tombstones": []
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -91,6 +96,7 @@
|
||||
"cycle_day_tombstones": 1,
|
||||
"cycle_upserts": 0,
|
||||
"cycle_tombstones": 1,
|
||||
"measurement_mirror_upserts": 0,
|
||||
"cycle_module_enabled": true,
|
||||
"has_more": true,
|
||||
"cursor_present": true
|
||||
|
||||
@@ -18,6 +18,7 @@
|
||||
"measuredAt": "2026-10-14T08:00:00.000Z",
|
||||
"source": "MANUAL",
|
||||
"notes": null,
|
||||
"appleHealthMirror": true,
|
||||
"syncVersion": "<syncVersion>",
|
||||
"updatedAt": "<updatedAt>"
|
||||
}
|
||||
@@ -39,6 +40,10 @@
|
||||
"cycles": {
|
||||
"upserts": [],
|
||||
"tombstones": []
|
||||
},
|
||||
"measurementMirror": {
|
||||
"upserts": [],
|
||||
"tombstones": []
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -61,6 +66,7 @@
|
||||
"cycle_day_tombstones": 0,
|
||||
"cycle_upserts": 0,
|
||||
"cycle_tombstones": 0,
|
||||
"measurement_mirror_upserts": 0,
|
||||
"cycle_module_enabled": true,
|
||||
"has_more": false,
|
||||
"cursor_present": true
|
||||
|
||||
@@ -26,6 +26,10 @@
|
||||
"cycles": {
|
||||
"upserts": [],
|
||||
"tombstones": []
|
||||
},
|
||||
"measurementMirror": {
|
||||
"upserts": [],
|
||||
"tombstones": []
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -48,6 +52,7 @@
|
||||
"cycle_day_tombstones": 0,
|
||||
"cycle_upserts": 0,
|
||||
"cycle_tombstones": 0,
|
||||
"measurement_mirror_upserts": 0,
|
||||
"cycle_module_enabled": true,
|
||||
"has_more": false,
|
||||
"cursor_present": true
|
||||
|
||||
@@ -26,6 +26,10 @@
|
||||
"cycles": {
|
||||
"upserts": [],
|
||||
"tombstones": []
|
||||
},
|
||||
"measurementMirror": {
|
||||
"upserts": [],
|
||||
"tombstones": []
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -26,6 +26,10 @@
|
||||
"cycles": {
|
||||
"upserts": [],
|
||||
"tombstones": []
|
||||
},
|
||||
"measurementMirror": {
|
||||
"upserts": [],
|
||||
"tombstones": []
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -18,6 +18,7 @@
|
||||
"measuredAt": "2026-10-14T06:30:00.000Z",
|
||||
"source": "MANUAL",
|
||||
"notes": "after the run",
|
||||
"appleHealthMirror": true,
|
||||
"syncVersion": "<syncVersion>",
|
||||
"updatedAt": "<updatedAt>"
|
||||
},
|
||||
@@ -30,6 +31,7 @@
|
||||
"measuredAt": "2026-10-14T07:00:00.000Z",
|
||||
"source": "WITHINGS",
|
||||
"notes": null,
|
||||
"appleHealthMirror": true,
|
||||
"syncVersion": "<syncVersion>",
|
||||
"updatedAt": "<updatedAt>"
|
||||
},
|
||||
@@ -42,6 +44,7 @@
|
||||
"measuredAt": "2026-06-17T09:30:00.000Z",
|
||||
"source": "APPLE_HEALTH",
|
||||
"notes": null,
|
||||
"appleHealthMirror": false,
|
||||
"syncVersion": "<syncVersion>",
|
||||
"updatedAt": "<updatedAt>"
|
||||
},
|
||||
@@ -54,6 +57,7 @@
|
||||
"measuredAt": "2026-10-14T08:00:00.000Z",
|
||||
"source": "MANUAL",
|
||||
"notes": null,
|
||||
"appleHealthMirror": true,
|
||||
"syncVersion": "<syncVersion>",
|
||||
"updatedAt": "<updatedAt>"
|
||||
}
|
||||
@@ -158,6 +162,10 @@
|
||||
"cycles": {
|
||||
"upserts": [],
|
||||
"tombstones": []
|
||||
},
|
||||
"measurementMirror": {
|
||||
"upserts": [],
|
||||
"tombstones": []
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -180,6 +188,7 @@
|
||||
"cycle_day_tombstones": 0,
|
||||
"cycle_upserts": 0,
|
||||
"cycle_tombstones": 0,
|
||||
"measurement_mirror_upserts": 0,
|
||||
"cycle_module_enabled": false,
|
||||
"has_more": false,
|
||||
"cursor_present": false
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
{
|
||||
"status": 200,
|
||||
"body": {
|
||||
"data": {
|
||||
"unattributedReadings": 1
|
||||
},
|
||||
"error": null
|
||||
},
|
||||
"events": [
|
||||
{
|
||||
"status": 200,
|
||||
"action": {
|
||||
"name": "tokens.measurements.summary"
|
||||
},
|
||||
"meta": {
|
||||
"unattributed_readings": 1
|
||||
},
|
||||
"warnings": null,
|
||||
"error": null
|
||||
}
|
||||
],
|
||||
"stored": {
|
||||
"tokens": [
|
||||
{
|
||||
"name": "Connector",
|
||||
"permissions": [
|
||||
"health:read"
|
||||
],
|
||||
"revoked": false,
|
||||
"mirrorToAppleHealth": false,
|
||||
"mirrorChangedAt": null
|
||||
},
|
||||
{
|
||||
"name": "Phone",
|
||||
"permissions": [
|
||||
"*"
|
||||
],
|
||||
"revoked": false,
|
||||
"mirrorToAppleHealth": false,
|
||||
"mirrorChangedAt": null
|
||||
},
|
||||
{
|
||||
"name": "Scale",
|
||||
"permissions": [
|
||||
"measurements:write"
|
||||
],
|
||||
"revoked": false,
|
||||
"mirrorToAppleHealth": false,
|
||||
"mirrorChangedAt": null
|
||||
}
|
||||
],
|
||||
"audit": [],
|
||||
"readings": [
|
||||
{
|
||||
"source": "EXTERNAL",
|
||||
"ingestTokenId": null,
|
||||
"stamp": "2026-10-10T08:00:00.000Z",
|
||||
"version": 1
|
||||
},
|
||||
{
|
||||
"source": "EXTERNAL",
|
||||
"ingestTokenId": "<id:1>",
|
||||
"stamp": "2026-10-10T08:00:00.000Z",
|
||||
"version": 1
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
{
|
||||
"status": 200,
|
||||
"body": {
|
||||
"data": {
|
||||
"id": "<id:1>",
|
||||
"mirrorToAppleHealth": true
|
||||
},
|
||||
"error": null
|
||||
},
|
||||
"events": [
|
||||
{
|
||||
"status": 200,
|
||||
"action": {
|
||||
"name": "tokens.measurements.mirror.update"
|
||||
},
|
||||
"meta": {
|
||||
"mirror_to_apple_health": true,
|
||||
"changed": true
|
||||
},
|
||||
"warnings": null,
|
||||
"error": null
|
||||
}
|
||||
],
|
||||
"stored": {
|
||||
"tokens": [
|
||||
{
|
||||
"name": "Connector",
|
||||
"permissions": [
|
||||
"health:read"
|
||||
],
|
||||
"revoked": false,
|
||||
"mirrorToAppleHealth": false,
|
||||
"mirrorChangedAt": null
|
||||
},
|
||||
{
|
||||
"name": "Phone",
|
||||
"permissions": [
|
||||
"*"
|
||||
],
|
||||
"revoked": false,
|
||||
"mirrorToAppleHealth": false,
|
||||
"mirrorChangedAt": null
|
||||
},
|
||||
{
|
||||
"name": "Scale",
|
||||
"permissions": [
|
||||
"measurements:write"
|
||||
],
|
||||
"revoked": false,
|
||||
"mirrorToAppleHealth": true,
|
||||
"mirrorChangedAt": "<mirrorChangedAt>"
|
||||
}
|
||||
],
|
||||
"audit": [
|
||||
{
|
||||
"action": "tokens.measurements.mirror.update",
|
||||
"details": {
|
||||
"tokenId": "<id:1>",
|
||||
"mirrorToAppleHealth": true
|
||||
}
|
||||
}
|
||||
],
|
||||
"readings": [
|
||||
{
|
||||
"source": "EXTERNAL",
|
||||
"ingestTokenId": null,
|
||||
"stamp": "2026-10-10T08:00:00.000Z",
|
||||
"version": 1
|
||||
},
|
||||
{
|
||||
"source": "EXTERNAL",
|
||||
"ingestTokenId": "<id:1>",
|
||||
"stamp": "2026-10-10T08:00:00.000Z",
|
||||
"version": 1
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
{
|
||||
"status": 200,
|
||||
"body": {
|
||||
"data": {
|
||||
"id": "<id:1>",
|
||||
"mirrorToAppleHealth": true
|
||||
},
|
||||
"error": null
|
||||
},
|
||||
"events": [
|
||||
{
|
||||
"status": 200,
|
||||
"action": {
|
||||
"name": "tokens.measurements.mirror.update"
|
||||
},
|
||||
"meta": {
|
||||
"mirror_to_apple_health": true,
|
||||
"changed": false
|
||||
},
|
||||
"warnings": null,
|
||||
"error": null
|
||||
}
|
||||
],
|
||||
"stored": {
|
||||
"tokens": [
|
||||
{
|
||||
"name": "Connector",
|
||||
"permissions": [
|
||||
"health:read"
|
||||
],
|
||||
"revoked": false,
|
||||
"mirrorToAppleHealth": false,
|
||||
"mirrorChangedAt": null
|
||||
},
|
||||
{
|
||||
"name": "Phone",
|
||||
"permissions": [
|
||||
"*"
|
||||
],
|
||||
"revoked": false,
|
||||
"mirrorToAppleHealth": false,
|
||||
"mirrorChangedAt": null
|
||||
},
|
||||
{
|
||||
"name": "Scale",
|
||||
"permissions": [
|
||||
"measurements:write"
|
||||
],
|
||||
"revoked": false,
|
||||
"mirrorToAppleHealth": true,
|
||||
"mirrorChangedAt": "<mirrorChangedAt>"
|
||||
}
|
||||
],
|
||||
"audit": [
|
||||
{
|
||||
"action": "tokens.measurements.mirror.update",
|
||||
"details": {
|
||||
"tokenId": "<id:1>",
|
||||
"mirrorToAppleHealth": true
|
||||
}
|
||||
}
|
||||
],
|
||||
"readings": [
|
||||
{
|
||||
"source": "EXTERNAL",
|
||||
"ingestTokenId": null,
|
||||
"stamp": "2026-10-10T08:00:00.000Z",
|
||||
"version": 1
|
||||
},
|
||||
{
|
||||
"source": "EXTERNAL",
|
||||
"ingestTokenId": "<id:1>",
|
||||
"stamp": "2026-10-10T08:00:00.000Z",
|
||||
"version": 1
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
{
|
||||
"status": 200,
|
||||
"body": {
|
||||
"data": [
|
||||
{
|
||||
"id": "<id:1>",
|
||||
"name": "Scale",
|
||||
"permissions": [
|
||||
"measurements:write"
|
||||
],
|
||||
"lastUsedAt": null,
|
||||
"expiresAt": "2027-01-13T12:00:00.000Z",
|
||||
"createdAt": "<createdAt>",
|
||||
"revoked": false,
|
||||
"mirrorToAppleHealth": true
|
||||
},
|
||||
{
|
||||
"id": "<id:2>",
|
||||
"name": "Phone",
|
||||
"permissions": [
|
||||
"*"
|
||||
],
|
||||
"lastUsedAt": null,
|
||||
"expiresAt": "2027-01-13T12:00:00.000Z",
|
||||
"createdAt": "<createdAt>",
|
||||
"revoked": false,
|
||||
"mirrorToAppleHealth": false
|
||||
},
|
||||
{
|
||||
"id": "<id:3>",
|
||||
"name": "Connector",
|
||||
"permissions": [
|
||||
"health:read"
|
||||
],
|
||||
"lastUsedAt": null,
|
||||
"expiresAt": "2027-01-13T12:00:00.000Z",
|
||||
"createdAt": "<createdAt>",
|
||||
"revoked": false,
|
||||
"mirrorToAppleHealth": false
|
||||
}
|
||||
],
|
||||
"error": null
|
||||
},
|
||||
"events": [
|
||||
{
|
||||
"status": 200,
|
||||
"action": {
|
||||
"name": "tokens.list"
|
||||
},
|
||||
"meta": null,
|
||||
"warnings": null,
|
||||
"error": null
|
||||
}
|
||||
],
|
||||
"stored": {
|
||||
"tokens": [
|
||||
{
|
||||
"name": "Connector",
|
||||
"permissions": [
|
||||
"health:read"
|
||||
],
|
||||
"revoked": false,
|
||||
"mirrorToAppleHealth": false,
|
||||
"mirrorChangedAt": null
|
||||
},
|
||||
{
|
||||
"name": "Phone",
|
||||
"permissions": [
|
||||
"*"
|
||||
],
|
||||
"revoked": false,
|
||||
"mirrorToAppleHealth": false,
|
||||
"mirrorChangedAt": null
|
||||
},
|
||||
{
|
||||
"name": "Scale",
|
||||
"permissions": [
|
||||
"measurements:write"
|
||||
],
|
||||
"revoked": false,
|
||||
"mirrorToAppleHealth": true,
|
||||
"mirrorChangedAt": "<mirrorChangedAt>"
|
||||
}
|
||||
],
|
||||
"audit": [
|
||||
{
|
||||
"action": "tokens.measurements.mirror.update",
|
||||
"details": {
|
||||
"tokenId": "<id:1>",
|
||||
"mirrorToAppleHealth": true
|
||||
}
|
||||
}
|
||||
],
|
||||
"readings": [
|
||||
{
|
||||
"source": "EXTERNAL",
|
||||
"ingestTokenId": null,
|
||||
"stamp": "2026-10-10T08:00:00.000Z",
|
||||
"version": 1
|
||||
},
|
||||
{
|
||||
"source": "EXTERNAL",
|
||||
"ingestTokenId": "<id:1>",
|
||||
"stamp": "2026-10-10T08:00:00.000Z",
|
||||
"version": 1
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
{
|
||||
"status": 200,
|
||||
"body": {
|
||||
"data": {
|
||||
"id": "<id:1>",
|
||||
"mirrorToAppleHealth": false
|
||||
},
|
||||
"error": null
|
||||
},
|
||||
"events": [
|
||||
{
|
||||
"status": 200,
|
||||
"action": {
|
||||
"name": "tokens.measurements.mirror.update"
|
||||
},
|
||||
"meta": {
|
||||
"mirror_to_apple_health": false,
|
||||
"changed": true
|
||||
},
|
||||
"warnings": null,
|
||||
"error": null
|
||||
}
|
||||
],
|
||||
"stored": {
|
||||
"tokens": [
|
||||
{
|
||||
"name": "Connector",
|
||||
"permissions": [
|
||||
"health:read"
|
||||
],
|
||||
"revoked": false,
|
||||
"mirrorToAppleHealth": false,
|
||||
"mirrorChangedAt": null
|
||||
},
|
||||
{
|
||||
"name": "Phone",
|
||||
"permissions": [
|
||||
"*"
|
||||
],
|
||||
"revoked": false,
|
||||
"mirrorToAppleHealth": false,
|
||||
"mirrorChangedAt": null
|
||||
},
|
||||
{
|
||||
"name": "Scale",
|
||||
"permissions": [
|
||||
"measurements:write"
|
||||
],
|
||||
"revoked": true,
|
||||
"mirrorToAppleHealth": false,
|
||||
"mirrorChangedAt": "<mirrorChangedAt>"
|
||||
}
|
||||
],
|
||||
"audit": [
|
||||
{
|
||||
"action": "tokens.measurements.mirror.update",
|
||||
"details": {
|
||||
"tokenId": "<id:1>",
|
||||
"mirrorToAppleHealth": true
|
||||
}
|
||||
},
|
||||
{
|
||||
"action": "tokens.measurements.mirror.update",
|
||||
"details": {
|
||||
"tokenId": "<id:1>",
|
||||
"mirrorToAppleHealth": false
|
||||
}
|
||||
}
|
||||
],
|
||||
"readings": [
|
||||
{
|
||||
"source": "EXTERNAL",
|
||||
"ingestTokenId": null,
|
||||
"stamp": "2026-10-10T08:00:00.000Z",
|
||||
"version": 1
|
||||
},
|
||||
{
|
||||
"source": "EXTERNAL",
|
||||
"ingestTokenId": "<id:1>",
|
||||
"stamp": "2026-10-10T08:00:00.000Z",
|
||||
"version": 1
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
{
|
||||
"status": 422,
|
||||
"body": {
|
||||
"data": null,
|
||||
"error": "Only a measurement token has this setting",
|
||||
"meta": {
|
||||
"errorCode": "tokens.mirror.not_measurement_token",
|
||||
"requestId": "<requestId>"
|
||||
}
|
||||
},
|
||||
"events": [
|
||||
{
|
||||
"status": 422,
|
||||
"action": {
|
||||
"name": "tokens.measurements.mirror.update"
|
||||
},
|
||||
"meta": null,
|
||||
"warnings": null,
|
||||
"error": null
|
||||
}
|
||||
],
|
||||
"stored": {
|
||||
"tokens": [
|
||||
{
|
||||
"name": "Connector",
|
||||
"permissions": [
|
||||
"health:read"
|
||||
],
|
||||
"revoked": false,
|
||||
"mirrorToAppleHealth": false,
|
||||
"mirrorChangedAt": null
|
||||
},
|
||||
{
|
||||
"name": "Phone",
|
||||
"permissions": [
|
||||
"*"
|
||||
],
|
||||
"revoked": false,
|
||||
"mirrorToAppleHealth": false,
|
||||
"mirrorChangedAt": null
|
||||
},
|
||||
{
|
||||
"name": "Scale",
|
||||
"permissions": [
|
||||
"measurements:write"
|
||||
],
|
||||
"revoked": true,
|
||||
"mirrorToAppleHealth": false,
|
||||
"mirrorChangedAt": "<mirrorChangedAt>"
|
||||
}
|
||||
],
|
||||
"audit": [
|
||||
{
|
||||
"action": "tokens.measurements.mirror.update",
|
||||
"details": {
|
||||
"tokenId": "<id:1>",
|
||||
"mirrorToAppleHealth": true
|
||||
}
|
||||
},
|
||||
{
|
||||
"action": "tokens.measurements.mirror.update",
|
||||
"details": {
|
||||
"tokenId": "<id:1>",
|
||||
"mirrorToAppleHealth": false
|
||||
}
|
||||
}
|
||||
],
|
||||
"readings": [
|
||||
{
|
||||
"source": "EXTERNAL",
|
||||
"ingestTokenId": null,
|
||||
"stamp": "2026-10-10T08:00:00.000Z",
|
||||
"version": 1
|
||||
},
|
||||
{
|
||||
"source": "EXTERNAL",
|
||||
"ingestTokenId": "<id:1>",
|
||||
"stamp": "2026-10-10T08:00:00.000Z",
|
||||
"version": 1
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
{
|
||||
"status": 422,
|
||||
"body": {
|
||||
"data": null,
|
||||
"error": "Only a measurement token has this setting",
|
||||
"meta": {
|
||||
"errorCode": "tokens.mirror.not_measurement_token",
|
||||
"requestId": "<requestId>"
|
||||
}
|
||||
},
|
||||
"events": [
|
||||
{
|
||||
"status": 422,
|
||||
"action": {
|
||||
"name": "tokens.measurements.mirror.update"
|
||||
},
|
||||
"meta": null,
|
||||
"warnings": null,
|
||||
"error": null
|
||||
}
|
||||
],
|
||||
"stored": {
|
||||
"tokens": [
|
||||
{
|
||||
"name": "Connector",
|
||||
"permissions": [
|
||||
"health:read"
|
||||
],
|
||||
"revoked": false,
|
||||
"mirrorToAppleHealth": false,
|
||||
"mirrorChangedAt": null
|
||||
},
|
||||
{
|
||||
"name": "Phone",
|
||||
"permissions": [
|
||||
"*"
|
||||
],
|
||||
"revoked": false,
|
||||
"mirrorToAppleHealth": false,
|
||||
"mirrorChangedAt": null
|
||||
},
|
||||
{
|
||||
"name": "Scale",
|
||||
"permissions": [
|
||||
"measurements:write"
|
||||
],
|
||||
"revoked": true,
|
||||
"mirrorToAppleHealth": false,
|
||||
"mirrorChangedAt": "<mirrorChangedAt>"
|
||||
}
|
||||
],
|
||||
"audit": [
|
||||
{
|
||||
"action": "tokens.measurements.mirror.update",
|
||||
"details": {
|
||||
"tokenId": "<id:1>",
|
||||
"mirrorToAppleHealth": true
|
||||
}
|
||||
},
|
||||
{
|
||||
"action": "tokens.measurements.mirror.update",
|
||||
"details": {
|
||||
"tokenId": "<id:1>",
|
||||
"mirrorToAppleHealth": false
|
||||
}
|
||||
}
|
||||
],
|
||||
"readings": [
|
||||
{
|
||||
"source": "EXTERNAL",
|
||||
"ingestTokenId": null,
|
||||
"stamp": "2026-10-10T08:00:00.000Z",
|
||||
"version": 1
|
||||
},
|
||||
{
|
||||
"source": "EXTERNAL",
|
||||
"ingestTokenId": "<id:1>",
|
||||
"stamp": "2026-10-10T08:00:00.000Z",
|
||||
"version": 1
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
+83
@@ -0,0 +1,83 @@
|
||||
{
|
||||
"status": 404,
|
||||
"body": {
|
||||
"data": null,
|
||||
"error": "Token not found",
|
||||
"meta": {
|
||||
"errorCode": "http.not_found",
|
||||
"requestId": "<requestId>"
|
||||
}
|
||||
},
|
||||
"events": [
|
||||
{
|
||||
"status": 404,
|
||||
"action": {
|
||||
"name": "tokens.measurements.mirror.update"
|
||||
},
|
||||
"meta": null,
|
||||
"warnings": null,
|
||||
"error": null
|
||||
}
|
||||
],
|
||||
"stored": {
|
||||
"tokens": [
|
||||
{
|
||||
"name": "Connector",
|
||||
"permissions": [
|
||||
"health:read"
|
||||
],
|
||||
"revoked": false,
|
||||
"mirrorToAppleHealth": false,
|
||||
"mirrorChangedAt": null
|
||||
},
|
||||
{
|
||||
"name": "Phone",
|
||||
"permissions": [
|
||||
"*"
|
||||
],
|
||||
"revoked": false,
|
||||
"mirrorToAppleHealth": false,
|
||||
"mirrorChangedAt": null
|
||||
},
|
||||
{
|
||||
"name": "Scale",
|
||||
"permissions": [
|
||||
"measurements:write"
|
||||
],
|
||||
"revoked": true,
|
||||
"mirrorToAppleHealth": false,
|
||||
"mirrorChangedAt": "<mirrorChangedAt>"
|
||||
}
|
||||
],
|
||||
"audit": [
|
||||
{
|
||||
"action": "tokens.measurements.mirror.update",
|
||||
"details": {
|
||||
"tokenId": "<id:1>",
|
||||
"mirrorToAppleHealth": true
|
||||
}
|
||||
},
|
||||
{
|
||||
"action": "tokens.measurements.mirror.update",
|
||||
"details": {
|
||||
"tokenId": "<id:1>",
|
||||
"mirrorToAppleHealth": false
|
||||
}
|
||||
}
|
||||
],
|
||||
"readings": [
|
||||
{
|
||||
"source": "EXTERNAL",
|
||||
"ingestTokenId": null,
|
||||
"stamp": "2026-10-10T08:00:00.000Z",
|
||||
"version": 1
|
||||
},
|
||||
{
|
||||
"source": "EXTERNAL",
|
||||
"ingestTokenId": "<id:1>",
|
||||
"stamp": "2026-10-10T08:00:00.000Z",
|
||||
"version": 1
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
{
|
||||
"status": 404,
|
||||
"body": {
|
||||
"data": null,
|
||||
"error": "Token not found",
|
||||
"meta": {
|
||||
"errorCode": "http.not_found",
|
||||
"requestId": "<requestId>"
|
||||
}
|
||||
},
|
||||
"events": [
|
||||
{
|
||||
"status": 404,
|
||||
"action": {
|
||||
"name": "tokens.measurements.mirror.update"
|
||||
},
|
||||
"meta": null,
|
||||
"warnings": null,
|
||||
"error": null
|
||||
}
|
||||
],
|
||||
"stored": {
|
||||
"tokens": [
|
||||
{
|
||||
"name": "Connector",
|
||||
"permissions": [
|
||||
"health:read"
|
||||
],
|
||||
"revoked": false,
|
||||
"mirrorToAppleHealth": false,
|
||||
"mirrorChangedAt": null
|
||||
},
|
||||
{
|
||||
"name": "Phone",
|
||||
"permissions": [
|
||||
"*"
|
||||
],
|
||||
"revoked": false,
|
||||
"mirrorToAppleHealth": false,
|
||||
"mirrorChangedAt": null
|
||||
},
|
||||
{
|
||||
"name": "Scale",
|
||||
"permissions": [
|
||||
"measurements:write"
|
||||
],
|
||||
"revoked": true,
|
||||
"mirrorToAppleHealth": false,
|
||||
"mirrorChangedAt": "<mirrorChangedAt>"
|
||||
}
|
||||
],
|
||||
"audit": [
|
||||
{
|
||||
"action": "tokens.measurements.mirror.update",
|
||||
"details": {
|
||||
"tokenId": "<id:1>",
|
||||
"mirrorToAppleHealth": true
|
||||
}
|
||||
},
|
||||
{
|
||||
"action": "tokens.measurements.mirror.update",
|
||||
"details": {
|
||||
"tokenId": "<id:1>",
|
||||
"mirrorToAppleHealth": false
|
||||
}
|
||||
}
|
||||
],
|
||||
"readings": [
|
||||
{
|
||||
"source": "EXTERNAL",
|
||||
"ingestTokenId": null,
|
||||
"stamp": "2026-10-10T08:00:00.000Z",
|
||||
"version": 1
|
||||
},
|
||||
{
|
||||
"source": "EXTERNAL",
|
||||
"ingestTokenId": "<id:1>",
|
||||
"stamp": "2026-10-10T08:00:00.000Z",
|
||||
"version": 1
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
+100
@@ -0,0 +1,100 @@
|
||||
{
|
||||
"status": 422,
|
||||
"body": {
|
||||
"data": null,
|
||||
"error": "Validation failed",
|
||||
"details": {
|
||||
"issues": [
|
||||
{
|
||||
"path": "mirrorToAppleHealth",
|
||||
"code": "invalid_type",
|
||||
"message": "Invalid input: expected boolean, received string"
|
||||
},
|
||||
{
|
||||
"path": "",
|
||||
"code": "unrecognized_keys",
|
||||
"message": "Unrecognized key: \"name\"",
|
||||
"keys": [
|
||||
"name"
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
"meta": {
|
||||
"errorCode": "http.invalid",
|
||||
"requestId": "<requestId>"
|
||||
}
|
||||
},
|
||||
"events": [
|
||||
{
|
||||
"status": 422,
|
||||
"action": {
|
||||
"name": "tokens.measurements.mirror.update"
|
||||
},
|
||||
"meta": null,
|
||||
"warnings": null,
|
||||
"error": null
|
||||
}
|
||||
],
|
||||
"stored": {
|
||||
"tokens": [
|
||||
{
|
||||
"name": "Connector",
|
||||
"permissions": [
|
||||
"health:read"
|
||||
],
|
||||
"revoked": false,
|
||||
"mirrorToAppleHealth": false,
|
||||
"mirrorChangedAt": null
|
||||
},
|
||||
{
|
||||
"name": "Phone",
|
||||
"permissions": [
|
||||
"*"
|
||||
],
|
||||
"revoked": false,
|
||||
"mirrorToAppleHealth": false,
|
||||
"mirrorChangedAt": null
|
||||
},
|
||||
{
|
||||
"name": "Scale",
|
||||
"permissions": [
|
||||
"measurements:write"
|
||||
],
|
||||
"revoked": true,
|
||||
"mirrorToAppleHealth": false,
|
||||
"mirrorChangedAt": "<mirrorChangedAt>"
|
||||
}
|
||||
],
|
||||
"audit": [
|
||||
{
|
||||
"action": "tokens.measurements.mirror.update",
|
||||
"details": {
|
||||
"tokenId": "<id:1>",
|
||||
"mirrorToAppleHealth": true
|
||||
}
|
||||
},
|
||||
{
|
||||
"action": "tokens.measurements.mirror.update",
|
||||
"details": {
|
||||
"tokenId": "<id:1>",
|
||||
"mirrorToAppleHealth": false
|
||||
}
|
||||
}
|
||||
],
|
||||
"readings": [
|
||||
{
|
||||
"source": "EXTERNAL",
|
||||
"ingestTokenId": null,
|
||||
"stamp": "2026-10-10T08:00:00.000Z",
|
||||
"version": 1
|
||||
},
|
||||
{
|
||||
"source": "EXTERNAL",
|
||||
"ingestTokenId": "<id:1>",
|
||||
"stamp": "2026-10-10T08:00:00.000Z",
|
||||
"version": 1
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
{
|
||||
"status": 401,
|
||||
"body": {
|
||||
"data": null,
|
||||
"error": "Not authenticated",
|
||||
"meta": {
|
||||
"errorCode": "auth.missing",
|
||||
"requestId": "<requestId>"
|
||||
}
|
||||
},
|
||||
"events": [
|
||||
{
|
||||
"status": 401,
|
||||
"action": null,
|
||||
"meta": null,
|
||||
"warnings": null,
|
||||
"error": {
|
||||
"type": "HttpError",
|
||||
"message": "Not authenticated"
|
||||
}
|
||||
}
|
||||
],
|
||||
"stored": {
|
||||
"tokens": [
|
||||
{
|
||||
"name": "Connector",
|
||||
"permissions": [
|
||||
"health:read"
|
||||
],
|
||||
"revoked": false,
|
||||
"mirrorToAppleHealth": false,
|
||||
"mirrorChangedAt": null
|
||||
},
|
||||
{
|
||||
"name": "Phone",
|
||||
"permissions": [
|
||||
"*"
|
||||
],
|
||||
"revoked": false,
|
||||
"mirrorToAppleHealth": false,
|
||||
"mirrorChangedAt": null
|
||||
},
|
||||
{
|
||||
"name": "Scale",
|
||||
"permissions": [
|
||||
"measurements:write"
|
||||
],
|
||||
"revoked": true,
|
||||
"mirrorToAppleHealth": false,
|
||||
"mirrorChangedAt": "<mirrorChangedAt>"
|
||||
}
|
||||
],
|
||||
"audit": [
|
||||
{
|
||||
"action": "tokens.measurements.mirror.update",
|
||||
"details": {
|
||||
"tokenId": "<id:1>",
|
||||
"mirrorToAppleHealth": true
|
||||
}
|
||||
},
|
||||
{
|
||||
"action": "tokens.measurements.mirror.update",
|
||||
"details": {
|
||||
"tokenId": "<id:1>",
|
||||
"mirrorToAppleHealth": false
|
||||
}
|
||||
}
|
||||
],
|
||||
"readings": [
|
||||
{
|
||||
"source": "EXTERNAL",
|
||||
"ingestTokenId": null,
|
||||
"stamp": "2026-10-10T08:00:00.000Z",
|
||||
"version": 1
|
||||
},
|
||||
{
|
||||
"source": "EXTERNAL",
|
||||
"ingestTokenId": "<id:1>",
|
||||
"stamp": "2026-10-10T08:00:00.000Z",
|
||||
"version": 1
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
+84
@@ -0,0 +1,84 @@
|
||||
{
|
||||
"status": 401,
|
||||
"body": {
|
||||
"data": null,
|
||||
"error": "Not authenticated",
|
||||
"meta": {
|
||||
"errorCode": "auth.missing",
|
||||
"requestId": "<requestId>"
|
||||
}
|
||||
},
|
||||
"events": [
|
||||
{
|
||||
"status": 401,
|
||||
"action": null,
|
||||
"meta": null,
|
||||
"warnings": null,
|
||||
"error": {
|
||||
"type": "HttpError",
|
||||
"message": "Not authenticated"
|
||||
}
|
||||
}
|
||||
],
|
||||
"stored": {
|
||||
"tokens": [
|
||||
{
|
||||
"name": "Connector",
|
||||
"permissions": [
|
||||
"health:read"
|
||||
],
|
||||
"revoked": false,
|
||||
"mirrorToAppleHealth": false,
|
||||
"mirrorChangedAt": null
|
||||
},
|
||||
{
|
||||
"name": "Phone",
|
||||
"permissions": [
|
||||
"*"
|
||||
],
|
||||
"revoked": false,
|
||||
"mirrorToAppleHealth": false,
|
||||
"mirrorChangedAt": null
|
||||
},
|
||||
{
|
||||
"name": "Scale",
|
||||
"permissions": [
|
||||
"measurements:write"
|
||||
],
|
||||
"revoked": true,
|
||||
"mirrorToAppleHealth": false,
|
||||
"mirrorChangedAt": "<mirrorChangedAt>"
|
||||
}
|
||||
],
|
||||
"audit": [
|
||||
{
|
||||
"action": "tokens.measurements.mirror.update",
|
||||
"details": {
|
||||
"tokenId": "<id:1>",
|
||||
"mirrorToAppleHealth": true
|
||||
}
|
||||
},
|
||||
{
|
||||
"action": "tokens.measurements.mirror.update",
|
||||
"details": {
|
||||
"tokenId": "<id:1>",
|
||||
"mirrorToAppleHealth": false
|
||||
}
|
||||
}
|
||||
],
|
||||
"readings": [
|
||||
{
|
||||
"source": "EXTERNAL",
|
||||
"ingestTokenId": null,
|
||||
"stamp": "2026-10-10T08:00:00.000Z",
|
||||
"version": 1
|
||||
},
|
||||
{
|
||||
"source": "EXTERNAL",
|
||||
"ingestTokenId": "<id:1>",
|
||||
"stamp": "2026-10-10T08:00:00.000Z",
|
||||
"version": 1
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,238 @@
|
||||
/**
|
||||
* Parity for the Apple Health write-back of measurement tokens (#1272):
|
||||
* `PATCH /api/tokens/{id}`, the summary `GET /api/tokens/measurements` and
|
||||
* the token list that carries the setting.
|
||||
*
|
||||
* One account walks through the cases the routes tell apart, in order: the
|
||||
* summary with readings no token reaches, the switch turned on, the same
|
||||
* value again (nothing written, nothing audited), turned off on a revoked
|
||||
* token, a wildcard and an MCP token refused, another account's token, an
|
||||
* unknown id, bodies the schema refuses, and a Bearer caller. After each
|
||||
* request the snapshot holds the response, the wide event, the token rows,
|
||||
* the audit rows and the readings' stamps, which must never move.
|
||||
*/
|
||||
import { beforeAll, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { PATCH, DELETE as revoke } from "@/app/api/tokens/[id]/route";
|
||||
import { GET as listTokens } from "@/app/api/tokens/route";
|
||||
import { GET as summary } from "@/app/api/tokens/measurements/route";
|
||||
import { MEASUREMENTS_WRITE_SCOPE } from "@/lib/measurements/scopes";
|
||||
|
||||
import { callRoute, type RouteAuth } from "../../../kit/call-route";
|
||||
import {
|
||||
makeMeasurement,
|
||||
makeSession,
|
||||
makeToken,
|
||||
makeUser,
|
||||
type BearerFixture,
|
||||
} from "../../../kit/factories";
|
||||
import {
|
||||
eventFacts,
|
||||
expectParity,
|
||||
stable,
|
||||
wideEventRecorder,
|
||||
} from "../../../kit/parity";
|
||||
import { getPrismaClient, truncateAllTables } from "../../setup";
|
||||
|
||||
vi.mock("@/lib/logging/transports", async (importOriginal) => {
|
||||
const actual =
|
||||
await importOriginal<typeof import("@/lib/logging/transports")>();
|
||||
const { wideEventRecorder: recorder } = await import("../../../kit/parity");
|
||||
return {
|
||||
...actual,
|
||||
emitIfSampled: (event: (typeof recorder.events)[number]) => {
|
||||
recorder.events.push(event);
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
const prisma = getPrismaClient();
|
||||
const NOW = new Date("2026-10-15T12:00:00.000Z");
|
||||
|
||||
let userId: string;
|
||||
let owner: RouteAuth;
|
||||
let scale: BearerFixture;
|
||||
let wildcard: BearerFixture;
|
||||
let connector: BearerFixture;
|
||||
let theirs: BearerFixture;
|
||||
|
||||
async function stored() {
|
||||
const [tokens, audit, readings] = await Promise.all([
|
||||
prisma.apiToken.findMany({
|
||||
where: { userId },
|
||||
orderBy: { name: "asc" },
|
||||
select: {
|
||||
name: true,
|
||||
permissions: true,
|
||||
revoked: true,
|
||||
mirrorToAppleHealth: true,
|
||||
mirrorChangedAt: true,
|
||||
},
|
||||
}),
|
||||
prisma.auditLog.findMany({
|
||||
where: { userId, action: { startsWith: "tokens." } },
|
||||
orderBy: { createdAt: "asc" },
|
||||
select: { action: true, details: true },
|
||||
}),
|
||||
prisma.measurement.findMany({
|
||||
where: { userId },
|
||||
orderBy: { measuredAt: "asc" },
|
||||
select: {
|
||||
source: true,
|
||||
ingestTokenId: true,
|
||||
updatedAt: true,
|
||||
syncVersion: true,
|
||||
},
|
||||
}),
|
||||
]);
|
||||
// Renamed so the snapshot keeps the values (`updatedAt` and `syncVersion`
|
||||
// are blanked as volatile): a switch must never move them.
|
||||
return {
|
||||
tokens,
|
||||
// Parsed, so the token id inside reads as its placeholder.
|
||||
audit: audit.map((a) => ({
|
||||
action: a.action,
|
||||
details: a.details ? JSON.parse(a.details) : null,
|
||||
})),
|
||||
readings: readings.map((r) => ({
|
||||
source: r.source,
|
||||
ingestTokenId: r.ingestTokenId,
|
||||
stamp: r.updatedAt.toISOString(),
|
||||
version: r.syncVersion,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
async function step(
|
||||
name: string,
|
||||
call:
|
||||
| { kind: "patch"; id: string; body: unknown; auth?: RouteAuth }
|
||||
| { kind: "summary" | "list"; auth?: RouteAuth },
|
||||
): Promise<void> {
|
||||
wideEventRecorder.take();
|
||||
const res =
|
||||
call.kind === "patch"
|
||||
? await callRoute(PATCH, {
|
||||
method: "PATCH",
|
||||
route: "/api/tokens/{id}",
|
||||
params: { id: call.id },
|
||||
body: call.body,
|
||||
auth: call.auth ?? owner,
|
||||
})
|
||||
: await callRoute(call.kind === "summary" ? summary : listTokens, {
|
||||
method: "GET",
|
||||
route:
|
||||
call.kind === "summary"
|
||||
? "/api/tokens/measurements"
|
||||
: "/api/tokens",
|
||||
auth: call.auth ?? owner,
|
||||
});
|
||||
const events = wideEventRecorder.take();
|
||||
expect(events.length).toBeGreaterThan(0);
|
||||
await expectParity(
|
||||
`tokens-mirror.${name}`,
|
||||
stable(
|
||||
{
|
||||
status: res.status,
|
||||
body: res.body,
|
||||
events: events.map(eventFacts),
|
||||
stored: await stored(),
|
||||
},
|
||||
{ since: NOW, volatileKeys: ["mirrorChangedAt"] },
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
beforeAll(async () => {
|
||||
vi.useFakeTimers({ toFake: ["Date"], now: NOW });
|
||||
await truncateAllTables(prisma);
|
||||
const user = await makeUser({ timezone: "UTC" });
|
||||
userId = user.id;
|
||||
owner = await makeSession(user);
|
||||
scale = await makeToken(user, {
|
||||
permissions: [MEASUREMENTS_WRITE_SCOPE],
|
||||
name: "Scale",
|
||||
});
|
||||
wildcard = await makeToken(user, { permissions: ["*"], name: "Phone" });
|
||||
connector = await makeToken(user, {
|
||||
permissions: ["health:read"],
|
||||
name: "Connector",
|
||||
});
|
||||
theirs = await makeToken(await makeUser({ timezone: "UTC" }), {
|
||||
permissions: [MEASUREMENTS_WRITE_SCOPE],
|
||||
name: "Theirs",
|
||||
});
|
||||
const stamp = new Date("2026-10-10T08:00:00.000Z");
|
||||
await makeMeasurement(user, {
|
||||
source: "EXTERNAL",
|
||||
ingestTokenId: scale.tokenId,
|
||||
measuredAt: new Date("2026-10-10T07:00:00.000Z"),
|
||||
updatedAt: stamp,
|
||||
});
|
||||
await makeMeasurement(user, {
|
||||
source: "EXTERNAL",
|
||||
measuredAt: new Date("2026-10-09T07:00:00.000Z"),
|
||||
updatedAt: stamp,
|
||||
});
|
||||
});
|
||||
|
||||
describe("the Apple Health write-back of a measurement token", () => {
|
||||
it("walks the cases in order", async () => {
|
||||
await step("01-summary", { kind: "summary" });
|
||||
await step("02-on", {
|
||||
kind: "patch",
|
||||
id: scale.tokenId,
|
||||
body: { mirrorToAppleHealth: true },
|
||||
});
|
||||
await step("03-on-again", {
|
||||
kind: "patch",
|
||||
id: scale.tokenId,
|
||||
body: { mirrorToAppleHealth: true },
|
||||
});
|
||||
await step("04-list", { kind: "list" });
|
||||
const revoked = await callRoute(revoke, {
|
||||
method: "DELETE",
|
||||
route: "/api/tokens/{id}",
|
||||
params: { id: scale.tokenId },
|
||||
auth: owner,
|
||||
});
|
||||
expect(revoked.status).toBe(200);
|
||||
await step("05-off-revoked", {
|
||||
kind: "patch",
|
||||
id: scale.tokenId,
|
||||
body: { mirrorToAppleHealth: false },
|
||||
});
|
||||
await step("06-wildcard", {
|
||||
kind: "patch",
|
||||
id: wildcard.tokenId,
|
||||
body: { mirrorToAppleHealth: true },
|
||||
});
|
||||
await step("07-connector", {
|
||||
kind: "patch",
|
||||
id: connector.tokenId,
|
||||
body: { mirrorToAppleHealth: true },
|
||||
});
|
||||
await step("08-another-account", {
|
||||
kind: "patch",
|
||||
id: theirs.tokenId,
|
||||
body: { mirrorToAppleHealth: true },
|
||||
});
|
||||
await step("09-unknown", {
|
||||
kind: "patch",
|
||||
id: "missing-token",
|
||||
body: { mirrorToAppleHealth: true },
|
||||
});
|
||||
await step("10-invalid-body", {
|
||||
kind: "patch",
|
||||
id: scale.tokenId,
|
||||
body: { mirrorToAppleHealth: "yes", name: "Renamed" },
|
||||
});
|
||||
await step("11-bearer", {
|
||||
kind: "patch",
|
||||
id: scale.tokenId,
|
||||
body: { mirrorToAppleHealth: true },
|
||||
auth: wildcard,
|
||||
});
|
||||
await step("12-summary-bearer", { kind: "summary", auth: wildcard });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,132 @@
|
||||
/**
|
||||
* Migration 0476 links existing EXTERNAL readings to the token that carried
|
||||
* them only where that is provable (#1272): the account holds exactly one
|
||||
* measurement token, revoked ones counted, and the reading is no older than
|
||||
* the token. Everything else stays unlinked. The pass writes nothing but the
|
||||
* link, so no reading's `updatedAt` or `syncVersion` moves, and a rerun
|
||||
* changes nothing.
|
||||
*/
|
||||
import { readFileSync } from "node:fs";
|
||||
import { resolve } from "node:path";
|
||||
import { beforeEach, describe, expect, it } from "vitest";
|
||||
|
||||
import { MEASUREMENTS_WRITE_SCOPE } from "@/lib/measurements/scopes";
|
||||
|
||||
import { makeToken, makeUser } from "../kit/factories";
|
||||
import { getPrismaClient, truncateAllTables } from "./setup";
|
||||
|
||||
const prisma = getPrismaClient();
|
||||
|
||||
const MIGRATION = readFileSync(
|
||||
resolve(
|
||||
process.cwd(),
|
||||
"prisma",
|
||||
"migrations",
|
||||
"0476_token_apple_health_mirror",
|
||||
"migration.sql",
|
||||
),
|
||||
"utf8",
|
||||
);
|
||||
|
||||
/** The file is three `DO` blocks; each goes through as its own statement. */
|
||||
async function runMigration(): Promise<void> {
|
||||
const blocks = MIGRATION.match(/DO \$migration\$[\s\S]*?\$migration\$;/g);
|
||||
expect(blocks).toHaveLength(3);
|
||||
for (const block of blocks!) await prisma.$executeRawUnsafe(block);
|
||||
}
|
||||
|
||||
const TOKEN_AT = new Date("2026-06-01T00:00:00.000Z");
|
||||
|
||||
async function externalReading(
|
||||
userId: string,
|
||||
createdAt: Date,
|
||||
source: "EXTERNAL" | "MANUAL" = "EXTERNAL",
|
||||
) {
|
||||
return prisma.measurement.create({
|
||||
data: {
|
||||
userId,
|
||||
type: "WEIGHT",
|
||||
value: 80,
|
||||
unit: "kg",
|
||||
source,
|
||||
measuredAt: createdAt,
|
||||
createdAt,
|
||||
updatedAt: new Date("2026-07-01T00:00:00.000Z"),
|
||||
},
|
||||
select: { id: true },
|
||||
});
|
||||
}
|
||||
|
||||
async function measurementToken(userId: string, revoked = false) {
|
||||
const token = await makeToken(
|
||||
{ id: userId },
|
||||
{ permissions: [MEASUREMENTS_WRITE_SCOPE] },
|
||||
);
|
||||
await prisma.apiToken.update({
|
||||
where: { id: token.tokenId },
|
||||
data: { createdAt: TOKEN_AT, revoked },
|
||||
});
|
||||
return token.tokenId;
|
||||
}
|
||||
|
||||
beforeEach(async () => {
|
||||
await truncateAllTables(prisma);
|
||||
});
|
||||
|
||||
describe("migration 0476 backfill", () => {
|
||||
it("links only what one token provably carried", async () => {
|
||||
const sole = await makeUser({ timezone: "UTC" });
|
||||
const soleToken = await measurementToken(sole.id, true);
|
||||
// A wildcard sign-in token does not make the measurement token ambiguous.
|
||||
await makeToken(sole, { permissions: ["*"] });
|
||||
const after = await externalReading(
|
||||
sole.id,
|
||||
new Date("2026-06-02T08:00:00.000Z"),
|
||||
);
|
||||
const before = await externalReading(
|
||||
sole.id,
|
||||
new Date("2026-05-30T08:00:00.000Z"),
|
||||
);
|
||||
const manual = await externalReading(
|
||||
sole.id,
|
||||
new Date("2026-06-03T08:00:00.000Z"),
|
||||
"MANUAL",
|
||||
);
|
||||
|
||||
const two = await makeUser({ timezone: "UTC" });
|
||||
await measurementToken(two.id);
|
||||
await measurementToken(two.id);
|
||||
const ambiguous = await externalReading(
|
||||
two.id,
|
||||
new Date("2026-06-02T08:00:00.000Z"),
|
||||
);
|
||||
|
||||
const stamps = await prisma.measurement.findMany({
|
||||
select: { id: true, updatedAt: true, syncVersion: true },
|
||||
orderBy: { id: "asc" },
|
||||
});
|
||||
|
||||
await runMigration();
|
||||
await runMigration();
|
||||
|
||||
const links = Object.fromEntries(
|
||||
(
|
||||
await prisma.measurement.findMany({
|
||||
select: { id: true, ingestTokenId: true },
|
||||
})
|
||||
).map((r) => [r.id, r.ingestTokenId]),
|
||||
);
|
||||
expect(links).toEqual({
|
||||
[after.id]: soleToken,
|
||||
[before.id]: null,
|
||||
[manual.id]: null,
|
||||
[ambiguous.id]: null,
|
||||
});
|
||||
expect(
|
||||
await prisma.measurement.findMany({
|
||||
select: { id: true, updatedAt: true, syncVersion: true },
|
||||
orderBy: { id: "asc" },
|
||||
}),
|
||||
).toEqual(stamps);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user