mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-12 14:16:12 +00:00
Merge environment data into release/v1.42.0
# Conflicts: # src/__tests__/i18n-reverse-coverage.test.ts
This commit is contained in:
commit
7bcb6f71d9
73 files changed
+6061
-385
No files matched your search
@@ -53,6 +53,7 @@ process.env.ENCRYPTION_KEY ??=
|
||||
import type { PrismaClient } from "@/generated/prisma/client";
|
||||
import { decrypt, decryptBytes, encrypt, encryptBytes } from "@/lib/crypto";
|
||||
import { decryptFromBytes, encryptToBytes } from "@/lib/ai/coach/bytes-codec";
|
||||
import { openLocation, sealLocation } from "@/lib/environment/location-cipher";
|
||||
import { readNote } from "@/lib/crypto/note-cipher";
|
||||
import {
|
||||
decryptFactData,
|
||||
@@ -1386,14 +1387,18 @@ async function seedEveryTwoEndedModel(prisma: PrismaClient): Promise<void> {
|
||||
// it. The assertion after the restore reads the two back TOGETHER. The
|
||||
// coordinates are at 1 decimal, the precision the app stores since v1.39.4;
|
||||
// a restore rounds anything finer, so finer values would not round-trip.
|
||||
// Since v1.42 the location is stored sealed (`locationEncrypted`), the way
|
||||
// the writers store it; the readable columns stay empty.
|
||||
await prisma.environmentTravelLocation.create({
|
||||
data: {
|
||||
userId: OWNER_ID,
|
||||
startDate: "2026-06-10",
|
||||
endDate: "2026-06-20",
|
||||
lat: 41.4,
|
||||
lon: 2.2,
|
||||
label: "Barcelona",
|
||||
locationEncrypted: sealLocation({
|
||||
lat: 41.4,
|
||||
lon: 2.2,
|
||||
label: "Barcelona",
|
||||
}),
|
||||
},
|
||||
});
|
||||
await prisma.environmentContext.createMany({
|
||||
@@ -1401,9 +1406,11 @@ async function seedEveryTwoEndedModel(prisma: PrismaClient): Promise<void> {
|
||||
{
|
||||
userId: OWNER_ID,
|
||||
date: "2026-06-15",
|
||||
lat: 41.4,
|
||||
lon: 2.2,
|
||||
locationLabel: "Barcelona",
|
||||
locationEncrypted: sealLocation({
|
||||
lat: 41.4,
|
||||
lon: 2.2,
|
||||
label: "Barcelona",
|
||||
}),
|
||||
source: "TRAVEL",
|
||||
tempMin: 19.4,
|
||||
tempMax: 28.1,
|
||||
@@ -1422,9 +1429,11 @@ async function seedEveryTwoEndedModel(prisma: PrismaClient): Promise<void> {
|
||||
{
|
||||
userId: OWNER_ID,
|
||||
date: "2026-07-01",
|
||||
lat: 52.5,
|
||||
lon: 13.4,
|
||||
locationLabel: "Berlin",
|
||||
locationEncrypted: sealLocation({
|
||||
lat: 52.5,
|
||||
lon: 13.4,
|
||||
label: "Berlin",
|
||||
}),
|
||||
source: "HOME",
|
||||
tempMin: 13.1,
|
||||
tempMax: 22.7,
|
||||
@@ -2659,9 +2668,10 @@ describe("every model the plan claims two-ended survives a real restore", () =>
|
||||
expect(
|
||||
readings.map((reading) => ({
|
||||
date: reading.date,
|
||||
lat: reading.lat,
|
||||
lon: reading.lon,
|
||||
locationLabel: reading.locationLabel,
|
||||
// The sealed location, opened (v1.42).
|
||||
...(({ lat, lon, label }) => ({ lat, lon, locationLabel: label }))(
|
||||
openLocation(reading.locationEncrypted!),
|
||||
),
|
||||
source: reading.source,
|
||||
tempMean: reading.tempMean,
|
||||
pressureDelta: reading.pressureDelta,
|
||||
@@ -2710,15 +2720,17 @@ describe("every model the plan claims two-ended survives a real restore", () =>
|
||||
where: { userId: OWNER_ID },
|
||||
});
|
||||
const travelDay = readings.find((reading) => reading.source === "TRAVEL")!;
|
||||
const tripPlace = openLocation(trip.locationEncrypted!);
|
||||
const dayPlace = openLocation(travelDay.locationEncrypted!);
|
||||
expect(
|
||||
{
|
||||
startDate: trip.startDate,
|
||||
endDate: trip.endDate,
|
||||
label: trip.label,
|
||||
label: tripPlace.label,
|
||||
covers:
|
||||
travelDay.date >= trip.startDate && travelDay.date <= trip.endDate,
|
||||
sameLat: trip.lat === travelDay.lat,
|
||||
sameLon: trip.lon === travelDay.lon,
|
||||
sameLat: tripPlace.lat === dayPlace.lat,
|
||||
sameLon: tripPlace.lon === dayPlace.lon,
|
||||
},
|
||||
"the period must still explain the reading it was exported beside",
|
||||
).toEqual({
|
||||
@@ -3500,6 +3512,22 @@ const COLUMN_EXCLUSIONS: Readonly<Record<string, string>> = {
|
||||
"insert stamp of a symptom link; no reader, the day log's own timestamps travel",
|
||||
"IllnessSymptomLink.createdAt":
|
||||
"insert stamp of a symptom link; no reader, the illness day log's own timestamps travel",
|
||||
// v1.42 (#615) — the readable copies of the sealed environment locations.
|
||||
// The restore seals whatever location a file carries into
|
||||
// `locationEncrypted` and writes these empty; the location itself is
|
||||
// compared through the sealed column. They drop in v1.43.
|
||||
"EnvironmentContext.lat":
|
||||
"readable copy of the sealed day location; the restore seals it and writes this empty",
|
||||
"EnvironmentContext.lon":
|
||||
"readable copy of the sealed day location; the restore seals it and writes this empty",
|
||||
"EnvironmentContext.locationLabel":
|
||||
"readable copy of the sealed day location; the restore seals it and writes this empty",
|
||||
"EnvironmentTravelLocation.lat":
|
||||
"readable copy of the sealed period location; the restore seals it and writes this empty",
|
||||
"EnvironmentTravelLocation.lon":
|
||||
"readable copy of the sealed period location; the restore seals it and writes this empty",
|
||||
"EnvironmentTravelLocation.label":
|
||||
"readable copy of the sealed period location; the restore seals it and writes this empty",
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -3510,35 +3538,9 @@ const COLUMN_EXCLUSIONS: Readonly<Record<string, string>> = {
|
||||
* starts coming back, so the entry has to go in the change that carries it.
|
||||
* The list is empty again before the release is tagged.
|
||||
*/
|
||||
const ENVIRONMENT_V142 =
|
||||
"v1.42 (#615): the environment section carries the air-quality columns and the sealed location";
|
||||
const LABS_V142 =
|
||||
"v1.42 (#1095): the lab section carries the conversion provenance";
|
||||
const RELEASE_PENDING_COLUMNS: Readonly<Record<string, string>> = {
|
||||
"EnvironmentContext.aodMax": ENVIRONMENT_V142,
|
||||
"EnvironmentContext.apparentMax": ENVIRONMENT_V142,
|
||||
"EnvironmentContext.aqDomain": ENVIRONMENT_V142,
|
||||
"EnvironmentContext.aqFetchedAt": ENVIRONMENT_V142,
|
||||
"EnvironmentContext.aqHours": ENVIRONMENT_V142,
|
||||
"EnvironmentContext.coMean": ENVIRONMENT_V142,
|
||||
"EnvironmentContext.dustMax": ENVIRONMENT_V142,
|
||||
"EnvironmentContext.eaqiMax": ENVIRONMENT_V142,
|
||||
"EnvironmentContext.no2Mean": ENVIRONMENT_V142,
|
||||
"EnvironmentContext.o3Max8h": ENVIRONMENT_V142,
|
||||
"EnvironmentContext.pm10Mean": ENVIRONMENT_V142,
|
||||
"EnvironmentContext.pm25Max": ENVIRONMENT_V142,
|
||||
"EnvironmentContext.pm25Mean": ENVIRONMENT_V142,
|
||||
"EnvironmentContext.pollenAlderMax": ENVIRONMENT_V142,
|
||||
"EnvironmentContext.pollenBirchMax": ENVIRONMENT_V142,
|
||||
"EnvironmentContext.pollenGrassMax": ENVIRONMENT_V142,
|
||||
"EnvironmentContext.pollenMugwortMax": ENVIRONMENT_V142,
|
||||
"EnvironmentContext.pollenOliveMax": ENVIRONMENT_V142,
|
||||
"EnvironmentContext.pollenRagweedMax": ENVIRONMENT_V142,
|
||||
"EnvironmentContext.so2Mean": ENVIRONMENT_V142,
|
||||
"EnvironmentContext.usaqiMax": ENVIRONMENT_V142,
|
||||
"EnvironmentContext.uvIndexMax": ENVIRONMENT_V142,
|
||||
"EnvironmentContext.locationEncrypted": ENVIRONMENT_V142,
|
||||
"EnvironmentTravelLocation.locationEncrypted": ENVIRONMENT_V142,
|
||||
"Biomarker.analyteKey": LABS_V142,
|
||||
"LabResult.sourceValue": LABS_V142,
|
||||
"LabResult.sourceUnit": LABS_V142,
|
||||
@@ -3704,6 +3706,8 @@ const BYTE_OPENERS: ReadonlyArray<(value: Uint8Array) => string> = [
|
||||
decryptFromBytes,
|
||||
decryptNoteFromBytes,
|
||||
decryptContextFromBytes,
|
||||
// v1.42 — the sealed environment locations, under their own label.
|
||||
(value) => JSON.stringify(openLocation(value)),
|
||||
(value) => decryptBytes(Buffer.from(value)).toString("base64"),
|
||||
];
|
||||
|
||||
@@ -4045,6 +4049,20 @@ const USER_FILL_OVERRIDES: Readonly<Record<string, unknown>> = {
|
||||
return bytes;
|
||||
},
|
||||
"User.avatarContentType": "image/png",
|
||||
// v1.42 — a sealed home the way the writers seal it, so it opens.
|
||||
"User.homeLocationEncrypted": () =>
|
||||
sealLocation({ lat: 2.5, lon: 2.5, label: "round-trip home" }),
|
||||
};
|
||||
|
||||
/**
|
||||
* v1.42 (#615) — readable setting columns the restore seals into another
|
||||
* column and writes empty. The value they held is compared through the sealed
|
||||
* column (`homeLocationEncrypted`).
|
||||
*/
|
||||
const SEALED_ON_RESTORE: Readonly<Record<string, string>> = {
|
||||
homeLat: "sealed into homeLocationEncrypted",
|
||||
homeLon: "sealed into homeLocationEncrypted",
|
||||
homeLabel: "sealed into homeLocationEncrypted",
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -4183,8 +4201,15 @@ describe("the account's own settings survive a real restore", () => {
|
||||
})) as unknown as Record<string, unknown>;
|
||||
|
||||
const lost = [...settings].filter(
|
||||
(name) => opened(after[name]) !== opened(source[name]),
|
||||
(name) =>
|
||||
!Object.hasOwn(SEALED_ON_RESTORE, name) &&
|
||||
opened(after[name]) !== opened(source[name]),
|
||||
);
|
||||
// The readable home columns come back empty by design; the home itself
|
||||
// came back sealed, compared above through `homeLocationEncrypted`.
|
||||
for (const name of Object.keys(SEALED_ON_RESTORE)) {
|
||||
expect(after[name], name).toBeNull();
|
||||
}
|
||||
expect(
|
||||
lost,
|
||||
"these settings left with a value and came back without it",
|
||||
|
||||
@@ -8,6 +8,10 @@
|
||||
* coarse;
|
||||
* - the rounding block of migration 0361 coarsens rows written before it,
|
||||
* and running it twice changes nothing more.
|
||||
*
|
||||
* v1.42 (#615): the locations are stored sealed, so the coarse value is
|
||||
* asserted on the opened sealed copy, and the readable columns are empty. The
|
||||
* rounding still happens before sealing; nothing finer is ever stored.
|
||||
*/
|
||||
import { readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
@@ -19,6 +23,7 @@ process.env.ENCRYPTION_KEY ??=
|
||||
import { PUT as putHome } from "@/app/api/environment/home/route";
|
||||
import { POST as postTravel } from "@/app/api/environment/travel/route";
|
||||
import { restoreEnvironmentData } from "@/lib/export/environment-backup";
|
||||
import { openLocation } from "@/lib/environment/location-cipher";
|
||||
|
||||
import { cookieJar, headerJar } from "./mock-next-headers";
|
||||
import { getPrismaClient, truncateAllTables } from "./setup";
|
||||
@@ -95,7 +100,16 @@ describe("environment coordinates at one decimal", () => {
|
||||
const user = await getPrismaClient().user.findUniqueOrThrow({
|
||||
where: { id: OWNER_ID },
|
||||
});
|
||||
expect([user.homeLat, user.homeLon]).toEqual([52.5, 13.4]);
|
||||
expect([user.homeLat, user.homeLon, user.homeLabel]).toEqual([
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
]);
|
||||
expect(openLocation(user.homeLocationEncrypted!)).toEqual({
|
||||
lat: 52.5,
|
||||
lon: 13.4,
|
||||
label: "Berlin, Germany",
|
||||
});
|
||||
|
||||
const travel = await postTravel(
|
||||
jsonRequest("http://localhost/api/environment/travel", "POST", {
|
||||
@@ -107,10 +121,16 @@ describe("environment coordinates at one decimal", () => {
|
||||
}),
|
||||
);
|
||||
expect(travel.status).toBe(201);
|
||||
expect((await travel.json()).data).toMatchObject({ lat: 48.1, lon: 11.6 });
|
||||
const stored = await getPrismaClient().environmentTravelLocation.findMany({
|
||||
where: { userId: OWNER_ID },
|
||||
});
|
||||
expect(stored.map((t) => [t.lat, t.lon])).toEqual([[48.1, 11.6]]);
|
||||
expect(stored.map((t) => [t.lat, t.lon, t.label])).toEqual([
|
||||
[null, null, null],
|
||||
]);
|
||||
expect(stored.map((t) => openLocation(t.locationEncrypted!))).toEqual([
|
||||
{ lat: 48.1, lon: 11.6, label: "Munich, Germany" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("restores a file written with two-decimal coordinates at one decimal", async () => {
|
||||
@@ -143,8 +163,15 @@ describe("environment coordinates at one decimal", () => {
|
||||
const days = await prisma.environmentContext.findMany({
|
||||
where: { userId: OWNER_ID },
|
||||
});
|
||||
expect(travel.map((t) => [t.lat, t.lon])).toEqual([[48.1, 11.6]]);
|
||||
expect(days.map((d) => [d.lat, d.lon])).toEqual([[48.1, 11.6]]);
|
||||
expect(travel.map((t) => openLocation(t.locationEncrypted!))).toEqual([
|
||||
{ lat: 48.1, lon: 11.6, label: "Munich, Germany" },
|
||||
]);
|
||||
expect(days.map((d) => openLocation(d.locationEncrypted!))).toEqual([
|
||||
{ lat: 48.1, lon: 11.6, label: "Munich, Germany" },
|
||||
]);
|
||||
expect(days.map((d) => [d.lat, d.lon, d.locationLabel])).toEqual([
|
||||
[null, null, null],
|
||||
]);
|
||||
});
|
||||
|
||||
it("coarsens rows written before migration 0361, idempotently", async () => {
|
||||
|
||||
@@ -0,0 +1,492 @@
|
||||
/**
|
||||
* v1.42 (#615) — the environment locations sealed at rest, against real
|
||||
* Postgres with the migrated schema (0381, 0382).
|
||||
*
|
||||
* - the writers (home, period) store only the sealed copy, the overview
|
||||
* answers readable, and the air-quality switch round-trips through its
|
||||
* route;
|
||||
* - the boot-time backfill finds the accounts still holding a readable
|
||||
* home, period or day, seals each, clears the readable columns, and
|
||||
* converges (a second pass and a second discovery find nothing);
|
||||
* - the backup carries the location readable in a portable file and sealed
|
||||
* in a disaster-recovery file, and both restore to a location that opens
|
||||
* to the same coarse place, with the air-quality values of the day;
|
||||
* - the data wipe clears the sealed home with the rest.
|
||||
*/
|
||||
import { Buffer } from "node:buffer";
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { PUT as putHome } from "@/app/api/environment/home/route";
|
||||
import { POST as postTravel } from "@/app/api/environment/travel/route";
|
||||
import { PATCH as patchPreferences } from "@/app/api/environment/preferences/route";
|
||||
import { GET as getOverview } from "@/app/api/environment/route";
|
||||
import { setGlobalBoss } from "@/lib/jobs/boss-instance";
|
||||
import {
|
||||
enqueueBootTimeFreeTextEncryptionBackfill,
|
||||
runFreeTextEncryptionBackfillForUser,
|
||||
} from "@/lib/jobs/free-text-encryption-backfill";
|
||||
import { openLocation, sealLocation } from "@/lib/environment/location-cipher";
|
||||
import {
|
||||
buildEnvironmentBackupSection,
|
||||
restoreEnvironmentData,
|
||||
type EnvironmentRestoreInput,
|
||||
} from "@/lib/export/environment-backup";
|
||||
|
||||
/** A section as a file carries it: through JSON and back. */
|
||||
function throughFile(section: unknown): EnvironmentRestoreInput {
|
||||
return JSON.parse(JSON.stringify(section)) as EnvironmentRestoreInput;
|
||||
}
|
||||
|
||||
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),
|
||||
}));
|
||||
|
||||
const OWNER = "env-sealed-owner";
|
||||
|
||||
function jsonRequest(url: string, method: string, body: unknown) {
|
||||
return new Request(url, {
|
||||
method,
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
}) as never;
|
||||
}
|
||||
|
||||
async function seedUser(id: string) {
|
||||
await getPrismaClient().user.create({
|
||||
data: {
|
||||
id,
|
||||
username: id,
|
||||
email: `${id}@example.test`,
|
||||
timezone: "Europe/Berlin",
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
beforeEach(async () => {
|
||||
const prisma = getPrismaClient();
|
||||
await truncateAllTables(prisma);
|
||||
cookieJar.clear();
|
||||
headerJar.clear();
|
||||
await seedUser(OWNER);
|
||||
const session = await prisma.session.create({
|
||||
data: {
|
||||
userId: OWNER,
|
||||
expiresAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000),
|
||||
mfaVerifiedAt: new Date(),
|
||||
},
|
||||
});
|
||||
cookieJar.set("healthlog_session", session.id);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
setGlobalBoss(null as never);
|
||||
});
|
||||
|
||||
describe("environment writers and the overview", () => {
|
||||
it("store the sealed copy only and answer readable", async () => {
|
||||
const prisma = getPrismaClient();
|
||||
expect(
|
||||
(
|
||||
await putHome(
|
||||
jsonRequest("http://localhost/api/environment/home", "PUT", {
|
||||
lat: 51.4818,
|
||||
lon: 7.2162,
|
||||
label: "Bochum, Germany",
|
||||
timezone: "Europe/Berlin",
|
||||
}),
|
||||
)
|
||||
).status,
|
||||
).toBe(200);
|
||||
expect(
|
||||
(
|
||||
await postTravel(
|
||||
jsonRequest("http://localhost/api/environment/travel", "POST", {
|
||||
startDate: "2026-08-01",
|
||||
endDate: "2026-08-10",
|
||||
lat: 38.7223,
|
||||
lon: -9.1393,
|
||||
label: "Lisbon, Portugal",
|
||||
}),
|
||||
)
|
||||
).status,
|
||||
).toBe(201);
|
||||
|
||||
const user = await prisma.user.findUniqueOrThrow({ where: { id: OWNER } });
|
||||
expect([user.homeLat, user.homeLon, user.homeLabel]).toEqual([
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
]);
|
||||
const travel = await prisma.environmentTravelLocation.findFirstOrThrow({
|
||||
where: { userId: OWNER },
|
||||
});
|
||||
expect([travel.lat, travel.lon, travel.label]).toEqual([null, null, null]);
|
||||
// No readable trace of either place anywhere in the two rows.
|
||||
expect(
|
||||
JSON.stringify({ ...user, homeLocationEncrypted: null }),
|
||||
).not.toMatch(/Bochum/);
|
||||
|
||||
const overview = (await (await getOverview()).json()).data;
|
||||
expect(overview.home).toMatchObject({
|
||||
lat: 51.5,
|
||||
lon: 7.2,
|
||||
label: "Bochum, Germany",
|
||||
});
|
||||
expect(overview.travel).toEqual([
|
||||
expect.objectContaining({
|
||||
startDate: "2026-08-01",
|
||||
lat: 38.7,
|
||||
lon: -9.1,
|
||||
label: "Lisbon, Portugal",
|
||||
}),
|
||||
]);
|
||||
expect(overview.airQuality).toMatchObject({
|
||||
enabled: true,
|
||||
operatorDisabled: false,
|
||||
days: 0,
|
||||
});
|
||||
expect(overview.attributions.length).toBe(3);
|
||||
});
|
||||
|
||||
it("turns the air-quality part off and on through its route", async () => {
|
||||
const prisma = getPrismaClient();
|
||||
const off = await patchPreferences(
|
||||
jsonRequest("http://localhost/api/environment/preferences", "PATCH", {
|
||||
airQualityEnabled: false,
|
||||
}),
|
||||
);
|
||||
expect(off.status).toBe(200);
|
||||
expect((await off.json()).data).toEqual({
|
||||
airQualityEnabled: false,
|
||||
operatorDisabled: false,
|
||||
});
|
||||
expect(
|
||||
(await prisma.user.findUniqueOrThrow({ where: { id: OWNER } }))
|
||||
.environmentAirQualityEnabled,
|
||||
).toBe(false);
|
||||
|
||||
const bad = await patchPreferences(
|
||||
jsonRequest("http://localhost/api/environment/preferences", "PATCH", {
|
||||
airQualityEnabled: "yes",
|
||||
userId: "someone-else",
|
||||
}),
|
||||
);
|
||||
expect(bad.status).toBe(422);
|
||||
});
|
||||
|
||||
it("leaves the air-quality values of a stored day out of the overview while off", async () => {
|
||||
const prisma = getPrismaClient();
|
||||
await prisma.environmentContext.create({
|
||||
data: {
|
||||
userId: OWNER,
|
||||
date: "2026-10-06",
|
||||
source: "HOME",
|
||||
locationEncrypted: sealLocation({ lat: 51.5, lon: 7.2, label: "Home" }),
|
||||
tempMin: 21,
|
||||
pm25Mean: 12,
|
||||
eaqiMax: 85,
|
||||
pollenGrassMax: 70,
|
||||
aqHours: 24,
|
||||
aqDomain: "cams_europe",
|
||||
aqFetchedAt: new Date(),
|
||||
},
|
||||
});
|
||||
const on = (await (await getOverview()).json()).data;
|
||||
expect(on.latestDay).toMatchObject({
|
||||
date: "2026-10-06",
|
||||
tempMin: 21,
|
||||
airQuality: { pm25Mean: 12, eaqiMax: 85, pollen: { grass: 70 } },
|
||||
});
|
||||
expect(on.airQuality).toMatchObject({ days: 1, domain: "cams_europe" });
|
||||
|
||||
await prisma.user.update({
|
||||
where: { id: OWNER },
|
||||
data: { environmentAirQualityEnabled: false },
|
||||
});
|
||||
const off = (await (await getOverview()).json()).data;
|
||||
expect(off.latestDay.airQuality).toBeNull();
|
||||
expect(off.attributions.length).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("the encryption backfill (real Postgres)", () => {
|
||||
it("discovers, seals, clears and converges", async () => {
|
||||
const prisma = getPrismaClient();
|
||||
await seedUser("env-other");
|
||||
const stamp = new Date("2026-03-01T10:00:00.000Z");
|
||||
await prisma.user.update({
|
||||
where: { id: OWNER },
|
||||
data: { homeLat: 51.5, homeLon: 7.2, homeLabel: "Bochum, Germany" },
|
||||
});
|
||||
await prisma.environmentTravelLocation.create({
|
||||
data: {
|
||||
userId: OWNER,
|
||||
startDate: "2026-08-01",
|
||||
endDate: "2026-08-10",
|
||||
lat: 38.7,
|
||||
lon: -9.1,
|
||||
label: "Lisbon, Portugal",
|
||||
updatedAt: stamp,
|
||||
},
|
||||
});
|
||||
await prisma.environmentContext.create({
|
||||
data: {
|
||||
userId: OWNER,
|
||||
date: "2026-08-02",
|
||||
lat: 38.7,
|
||||
lon: -9.1,
|
||||
locationLabel: "Lisbon, Portugal",
|
||||
source: "TRAVEL",
|
||||
},
|
||||
});
|
||||
|
||||
const sent: string[] = [];
|
||||
setGlobalBoss({
|
||||
send: async (_q: string, payload: { userId?: string }) => {
|
||||
if (payload.userId) sent.push(payload.userId);
|
||||
return "job";
|
||||
},
|
||||
} as never);
|
||||
const discovery = await enqueueBootTimeFreeTextEncryptionBackfill();
|
||||
expect(discovery.error).toBeNull();
|
||||
expect(sent).toEqual([OWNER]);
|
||||
|
||||
const summary = await runFreeTextEncryptionBackfillForUser(OWNER);
|
||||
expect(summary).toMatchObject({
|
||||
homeLocationsMigrated: 1,
|
||||
travelLocationsMigrated: 1,
|
||||
dayLocationsMigrated: 1,
|
||||
});
|
||||
|
||||
const user = await prisma.user.findUniqueOrThrow({ where: { id: OWNER } });
|
||||
expect(user.homeLat).toBeNull();
|
||||
expect(openLocation(user.homeLocationEncrypted!)).toEqual({
|
||||
lat: 51.5,
|
||||
lon: 7.2,
|
||||
label: "Bochum, Germany",
|
||||
});
|
||||
const travel = await prisma.environmentTravelLocation.findFirstOrThrow({
|
||||
where: { userId: OWNER },
|
||||
});
|
||||
expect(travel.lat).toBeNull();
|
||||
expect(travel.updatedAt).toEqual(stamp);
|
||||
expect(openLocation(travel.locationEncrypted!).label).toBe(
|
||||
"Lisbon, Portugal",
|
||||
);
|
||||
const day = await prisma.environmentContext.findFirstOrThrow({
|
||||
where: { userId: OWNER },
|
||||
});
|
||||
expect([day.lat, day.lon, day.locationLabel]).toEqual([null, null, null]);
|
||||
expect(openLocation(day.locationEncrypted!).lat).toBe(38.7);
|
||||
|
||||
// Converged: nothing readable is left to find.
|
||||
expect(await runFreeTextEncryptionBackfillForUser(OWNER)).toMatchObject({
|
||||
homeLocationsMigrated: 0,
|
||||
travelLocationsMigrated: 0,
|
||||
dayLocationsMigrated: 0,
|
||||
});
|
||||
sent.length = 0;
|
||||
await enqueueBootTimeFreeTextEncryptionBackfill();
|
||||
expect(sent).toEqual([]);
|
||||
const remaining = await prisma.$queryRaw<{ n: bigint }[]>`
|
||||
SELECT (SELECT count(*) FROM users WHERE home_lat IS NOT NULL)
|
||||
+ (SELECT count(*) FROM environment_travel_locations WHERE lat IS NOT NULL)
|
||||
+ (SELECT count(*) FROM environment_contexts WHERE lat IS NOT NULL) AS n`;
|
||||
expect(Number(remaining[0].n)).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("the environment backup with sealed locations", () => {
|
||||
async function seedDay() {
|
||||
const prisma = getPrismaClient();
|
||||
await prisma.environmentTravelLocation.create({
|
||||
data: {
|
||||
userId: OWNER,
|
||||
startDate: "2026-08-01",
|
||||
endDate: "2026-08-10",
|
||||
locationEncrypted: sealLocation({
|
||||
lat: 38.7,
|
||||
lon: -9.1,
|
||||
label: "Lisbon, Portugal",
|
||||
}),
|
||||
},
|
||||
});
|
||||
await prisma.environmentContext.create({
|
||||
data: {
|
||||
userId: OWNER,
|
||||
date: "2026-08-02",
|
||||
source: "TRAVEL",
|
||||
locationEncrypted: sealLocation({
|
||||
lat: 38.7,
|
||||
lon: -9.1,
|
||||
label: "Lisbon, Portugal",
|
||||
}),
|
||||
tempMin: 20.5,
|
||||
pm25Mean: 7.5,
|
||||
o3Max8h: 96,
|
||||
pollenOliveMax: null,
|
||||
pollenGrassMax: 4,
|
||||
aqDomain: "cams_europe",
|
||||
aqHours: 24,
|
||||
aqFetchedAt: new Date("2026-08-05T02:10:00.000Z"),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
it("writes the location readable in a portable file and restores it sealed", async () => {
|
||||
const prisma = getPrismaClient();
|
||||
await seedDay();
|
||||
const section = await buildEnvironmentBackupSection(prisma, OWNER, {
|
||||
purpose: "portable-export",
|
||||
});
|
||||
const [day] = section.environmentContexts;
|
||||
expect(day).toMatchObject({
|
||||
lat: 38.7,
|
||||
lon: -9.1,
|
||||
locationLabel: "Lisbon, Portugal",
|
||||
pm25Mean: 7.5,
|
||||
pollenOliveMax: null,
|
||||
aqFetchedAt: "2026-08-05T02:10:00.000Z",
|
||||
});
|
||||
expect(day).not.toHaveProperty("locationEncrypted");
|
||||
expect(section.environmentTravelLocations[0]).toMatchObject({
|
||||
lat: 38.7,
|
||||
label: "Lisbon, Portugal",
|
||||
});
|
||||
|
||||
const parsed = throughFile(section);
|
||||
await prisma.$transaction((tx) =>
|
||||
restoreEnvironmentData(tx, OWNER, parsed),
|
||||
);
|
||||
const restored = await prisma.environmentContext.findFirstOrThrow({
|
||||
where: { userId: OWNER },
|
||||
});
|
||||
expect([restored.lat, restored.locationLabel]).toEqual([null, null]);
|
||||
expect(openLocation(restored.locationEncrypted!)).toEqual({
|
||||
lat: 38.7,
|
||||
lon: -9.1,
|
||||
label: "Lisbon, Portugal",
|
||||
});
|
||||
expect(restored).toMatchObject({
|
||||
pm25Mean: 7.5,
|
||||
o3Max8h: 96,
|
||||
pollenGrassMax: 4,
|
||||
aqHours: 24,
|
||||
aqDomain: "cams_europe",
|
||||
});
|
||||
expect(restored.aqFetchedAt).toEqual(new Date("2026-08-05T02:10:00.000Z"));
|
||||
});
|
||||
|
||||
it("carries the sealed value verbatim in a disaster-recovery file", async () => {
|
||||
const prisma = getPrismaClient();
|
||||
await seedDay();
|
||||
const before = await prisma.environmentContext.findFirstOrThrow({
|
||||
where: { userId: OWNER },
|
||||
});
|
||||
const section = await buildEnvironmentBackupSection(prisma, OWNER, {
|
||||
purpose: "disaster-recovery",
|
||||
});
|
||||
const [day] = section.environmentContexts;
|
||||
expect(day.lat).toBeNull();
|
||||
expect(day.locationEncrypted).toBe(
|
||||
Buffer.from(before.locationEncrypted!).toString("base64"),
|
||||
);
|
||||
const parsed = throughFile(section);
|
||||
await prisma.$transaction((tx) =>
|
||||
restoreEnvironmentData(tx, OWNER, parsed),
|
||||
);
|
||||
const after = await prisma.environmentContext.findFirstOrThrow({
|
||||
where: { userId: OWNER },
|
||||
});
|
||||
expect(
|
||||
Buffer.from(after.locationEncrypted!).equals(
|
||||
Buffer.from(before.locationEncrypted!),
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("restores a file from before air quality as a day never fetched", async () => {
|
||||
const prisma = getPrismaClient();
|
||||
await prisma.$transaction((tx) =>
|
||||
restoreEnvironmentData(tx, OWNER, {
|
||||
environmentTravelLocations: [],
|
||||
environmentContexts: [
|
||||
{
|
||||
date: "2026-01-02",
|
||||
lat: 51.5,
|
||||
lon: 7.2,
|
||||
locationLabel: "Bochum, Germany",
|
||||
source: "HOME",
|
||||
tempMin: 1,
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
const day = await prisma.environmentContext.findFirstOrThrow({
|
||||
where: { userId: OWNER },
|
||||
});
|
||||
expect(day.aqFetchedAt).toBeNull();
|
||||
expect(day.pm25Mean).toBeNull();
|
||||
expect(openLocation(day.locationEncrypted!).label).toBe("Bochum, Germany");
|
||||
});
|
||||
});
|
||||
|
||||
describe("the data wipe", () => {
|
||||
it("clears the sealed home, the periods and the days, and resets the switch", async () => {
|
||||
const prisma = getPrismaClient();
|
||||
await prisma.user.update({
|
||||
where: { id: OWNER },
|
||||
data: {
|
||||
homeLocationEncrypted: sealLocation({
|
||||
lat: 51.5,
|
||||
lon: 7.2,
|
||||
label: "Bochum, Germany",
|
||||
}),
|
||||
environmentAirQualityEnabled: false,
|
||||
},
|
||||
});
|
||||
await prisma.environmentContext.create({
|
||||
data: {
|
||||
userId: OWNER,
|
||||
date: "2026-08-02",
|
||||
source: "HOME",
|
||||
locationEncrypted: sealLocation({ lat: 51.5, lon: 7.2, label: "x" }),
|
||||
},
|
||||
});
|
||||
const { DELETE } = await import("@/app/api/settings/data/route");
|
||||
const response = await DELETE(
|
||||
new Request("http://localhost/api/settings/data", {
|
||||
method: "DELETE",
|
||||
body: JSON.stringify({ confirm: "DELETE" }),
|
||||
}) as never,
|
||||
);
|
||||
expect(response.status).toBe(200);
|
||||
const user = await prisma.user.findUniqueOrThrow({ where: { id: OWNER } });
|
||||
expect(user.homeLocationEncrypted).toBeNull();
|
||||
expect(user.environmentAirQualityEnabled).toBe(true);
|
||||
expect(
|
||||
await prisma.environmentContext.count({ where: { userId: OWNER } }),
|
||||
).toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,154 @@
|
||||
/**
|
||||
* v1.42 (#615) — key rotation covers the three sealed environment locations
|
||||
* (the home, the dated periods, the stored days), against real Postgres.
|
||||
*
|
||||
* Rows are sealed under the old key, the registry walk (shared by the in-app
|
||||
* rotation and `scripts/rotate-encryption-key.ts`) re-seals them under the new
|
||||
* one with the environment-location label, and they still open once the old
|
||||
* key is gone. A walk that re-sealed them without the label, or with the
|
||||
* string codec, would leave values no reader can open.
|
||||
*/
|
||||
import { beforeEach, describe, expect, it } from "vitest";
|
||||
|
||||
process.env.ENCRYPTION_KEYS = JSON.stringify({
|
||||
v1: "1".repeat(64),
|
||||
v2: "2".repeat(64),
|
||||
});
|
||||
process.env.ENCRYPTION_ACTIVE_KEY_ID = "v1";
|
||||
delete process.env.ENCRYPTION_KEY;
|
||||
|
||||
import { _resetCryptoCacheForTests, extractKeyIdFromBytes } from "@/lib/crypto";
|
||||
import {
|
||||
ENCRYPTED_COLUMNS,
|
||||
type EncryptedColumn,
|
||||
} from "@/lib/crypto/encrypted-columns";
|
||||
import {
|
||||
rotateColumn,
|
||||
type CorpusClient,
|
||||
} from "@/lib/crypto/encryption-corpus";
|
||||
import { openLocation, sealLocation } from "@/lib/environment/location-cipher";
|
||||
import { getPrismaClient, truncateAllTables } from "./setup";
|
||||
|
||||
const USER_ID = "user-rotation-environment";
|
||||
|
||||
function column(model: string, field: string): EncryptedColumn {
|
||||
const col = ENCRYPTED_COLUMNS.find(
|
||||
(c) => c.model === model && c.field === field,
|
||||
);
|
||||
if (!col) throw new Error(`${model}.${field} is not registered`);
|
||||
return col;
|
||||
}
|
||||
|
||||
function activate(keyId: string) {
|
||||
process.env.ENCRYPTION_ACTIVE_KEY_ID = keyId;
|
||||
_resetCryptoCacheForTests();
|
||||
}
|
||||
|
||||
beforeEach(async () => {
|
||||
process.env.ENCRYPTION_KEYS = JSON.stringify({
|
||||
v1: "1".repeat(64),
|
||||
v2: "2".repeat(64),
|
||||
});
|
||||
activate("v1");
|
||||
await truncateAllTables(getPrismaClient());
|
||||
});
|
||||
|
||||
describe("key rotation over the environment locations", () => {
|
||||
it("re-seals the home, a period and a day under the active key", async () => {
|
||||
const prisma = getPrismaClient();
|
||||
const stamp = new Date("2026-05-04T10:00:00.000Z");
|
||||
await prisma.user.create({
|
||||
data: {
|
||||
id: USER_ID,
|
||||
username: "rotation-environment",
|
||||
email: "rotation-environment@example.test",
|
||||
timezone: "Europe/Berlin",
|
||||
homeLocationEncrypted: sealLocation({
|
||||
lat: 51.5,
|
||||
lon: 7.2,
|
||||
label: "Bochum, Germany",
|
||||
}),
|
||||
},
|
||||
});
|
||||
await prisma.environmentTravelLocation.create({
|
||||
data: {
|
||||
id: "et-1",
|
||||
userId: USER_ID,
|
||||
startDate: "2026-08-01",
|
||||
endDate: "2026-08-10",
|
||||
locationEncrypted: sealLocation({
|
||||
lat: 38.7,
|
||||
lon: -9.1,
|
||||
label: "Lisbon, Portugal",
|
||||
}),
|
||||
updatedAt: stamp,
|
||||
},
|
||||
});
|
||||
await prisma.environmentContext.create({
|
||||
data: {
|
||||
id: "ec-1",
|
||||
userId: USER_ID,
|
||||
date: "2026-08-02",
|
||||
source: "TRAVEL",
|
||||
locationEncrypted: sealLocation({
|
||||
lat: 38.7,
|
||||
lon: -9.1,
|
||||
label: "Lisbon, Portugal",
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
||||
activate("v2");
|
||||
const client = {
|
||||
user: prisma.user,
|
||||
environmentTravelLocation: prisma.environmentTravelLocation,
|
||||
environmentContext: prisma.environmentContext,
|
||||
} as unknown as CorpusClient;
|
||||
for (const [model, field] of [
|
||||
["User", "homeLocationEncrypted"],
|
||||
["EnvironmentTravelLocation", "locationEncrypted"],
|
||||
["EnvironmentContext", "locationEncrypted"],
|
||||
] as const) {
|
||||
const result = await rotateColumn(client, column(model, field));
|
||||
expect(result, `${model}.${field}`).toMatchObject({
|
||||
scanned: 1,
|
||||
rotated: 1,
|
||||
errors: 0,
|
||||
});
|
||||
expect((await rotateColumn(client, column(model, field))).rotated).toBe(
|
||||
0,
|
||||
);
|
||||
}
|
||||
|
||||
// The old key is gone; every location still opens.
|
||||
process.env.ENCRYPTION_KEYS = JSON.stringify({ v2: "2".repeat(64) });
|
||||
_resetCryptoCacheForTests();
|
||||
const user = await prisma.user.findUniqueOrThrow({
|
||||
where: { id: USER_ID },
|
||||
});
|
||||
const travel = await prisma.environmentTravelLocation.findUniqueOrThrow({
|
||||
where: { id: "et-1" },
|
||||
});
|
||||
const day = await prisma.environmentContext.findUniqueOrThrow({
|
||||
where: { id: "ec-1" },
|
||||
});
|
||||
for (const sealed of [
|
||||
user.homeLocationEncrypted!,
|
||||
travel.locationEncrypted!,
|
||||
day.locationEncrypted!,
|
||||
]) {
|
||||
expect(extractKeyIdFromBytes(Buffer.from(sealed))).toBe("v2");
|
||||
}
|
||||
expect(openLocation(user.homeLocationEncrypted!).label).toBe(
|
||||
"Bochum, Germany",
|
||||
);
|
||||
expect(openLocation(travel.locationEncrypted!)).toEqual({
|
||||
lat: 38.7,
|
||||
lon: -9.1,
|
||||
label: "Lisbon, Portugal",
|
||||
});
|
||||
expect(openLocation(day.locationEncrypted!).lat).toBe(38.7);
|
||||
// Re-sealing is not an edit: the period keeps its stamp.
|
||||
expect(travel.updatedAt).toEqual(stamp);
|
||||
});
|
||||
});
|
||||
@@ -133,6 +133,9 @@ describe("free-text encryption backfill (real Postgres)", () => {
|
||||
routeGeometriesMigrated: 0,
|
||||
appointmentAddressesCleared: 0,
|
||||
contactAuditRowsScrubbed: 0,
|
||||
homeLocationsMigrated: 0,
|
||||
travelLocationsMigrated: 0,
|
||||
dayLocationsMigrated: 0,
|
||||
});
|
||||
expect(await runFreeTextEncryptionBackfillForUser("ft-b")).toEqual({
|
||||
conversationTitlesMigrated: 0,
|
||||
@@ -141,6 +144,9 @@ describe("free-text encryption backfill (real Postgres)", () => {
|
||||
routeGeometriesMigrated: 0,
|
||||
appointmentAddressesCleared: 0,
|
||||
contactAuditRowsScrubbed: 0,
|
||||
homeLocationsMigrated: 0,
|
||||
travelLocationsMigrated: 0,
|
||||
dayLocationsMigrated: 0,
|
||||
});
|
||||
|
||||
const conversations = await prisma.coachConversation.findMany({
|
||||
@@ -189,6 +195,9 @@ describe("free-text encryption backfill (real Postgres)", () => {
|
||||
routeGeometriesMigrated: 0,
|
||||
appointmentAddressesCleared: 0,
|
||||
contactAuditRowsScrubbed: 0,
|
||||
homeLocationsMigrated: 0,
|
||||
travelLocationsMigrated: 0,
|
||||
dayLocationsMigrated: 0,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -273,6 +282,9 @@ describe("free-text encryption backfill (real Postgres)", () => {
|
||||
routeGeometriesMigrated: 0,
|
||||
appointmentAddressesCleared: 0,
|
||||
contactAuditRowsScrubbed: 0,
|
||||
homeLocationsMigrated: 0,
|
||||
travelLocationsMigrated: 0,
|
||||
dayLocationsMigrated: 0,
|
||||
});
|
||||
expect(await runFreeTextEncryptionBackfillForUser("pc-b")).toEqual({
|
||||
conversationTitlesMigrated: 0,
|
||||
@@ -281,6 +293,9 @@ describe("free-text encryption backfill (real Postgres)", () => {
|
||||
routeGeometriesMigrated: 1,
|
||||
appointmentAddressesCleared: 0,
|
||||
contactAuditRowsScrubbed: 0,
|
||||
homeLocationsMigrated: 0,
|
||||
travelLocationsMigrated: 0,
|
||||
dayLocationsMigrated: 0,
|
||||
});
|
||||
|
||||
const practitioners = await prisma.practitioner.findMany({
|
||||
@@ -341,6 +356,9 @@ describe("free-text encryption backfill (real Postgres)", () => {
|
||||
routeGeometriesMigrated: 0,
|
||||
appointmentAddressesCleared: 0,
|
||||
contactAuditRowsScrubbed: 0,
|
||||
homeLocationsMigrated: 0,
|
||||
travelLocationsMigrated: 0,
|
||||
dayLocationsMigrated: 0,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -438,6 +456,9 @@ describe("free-text encryption backfill (real Postgres)", () => {
|
||||
expect(summaryA).toMatchObject({
|
||||
appointmentAddressesCleared: 1,
|
||||
contactAuditRowsScrubbed: 1,
|
||||
homeLocationsMigrated: 0,
|
||||
travelLocationsMigrated: 0,
|
||||
dayLocationsMigrated: 0,
|
||||
});
|
||||
await runFreeTextEncryptionBackfillForUser("ad-b");
|
||||
|
||||
|
||||
@@ -19,6 +19,10 @@
|
||||
* schema exactly as it was advertised. A tool the case table does not cover
|
||||
* fails the run, so a new tool cannot ship without joining it.
|
||||
*
|
||||
* v1.42: `get_environment` declares its result field by field, so the
|
||||
* strict validator here is also what proves it carries no coordinate and no
|
||||
* place name: an undeclared key would fail the run.
|
||||
*
|
||||
* It also pins the live-rows rule on the two presence probes: a measurement
|
||||
* type whose readings were all deleted is not listed as present.
|
||||
*/
|
||||
@@ -37,6 +41,7 @@ import {
|
||||
} from "@/lib/mcp/rich-reads";
|
||||
import type { McpAuthContext } from "@/lib/mcp/auth";
|
||||
|
||||
import { sealLocation } from "@/lib/environment/location-cipher";
|
||||
import { getPrismaClient, truncateAllTables } from "./setup";
|
||||
|
||||
const DAY = 86_400_000;
|
||||
@@ -241,6 +246,42 @@ async function seedRecord(): Promise<string> {
|
||||
},
|
||||
});
|
||||
}
|
||||
// v1.42 — environment days with weather and air quality, one of them not
|
||||
// yet fetched, so get_environment answers its full shape (and a null
|
||||
// air-quality part) under the strict validator. The location is sealed.
|
||||
for (let i = 2; i <= 12; i += 1) {
|
||||
await prisma.environmentContext.create({
|
||||
data: {
|
||||
userId: id,
|
||||
date: isoDay(daysAgo(i)),
|
||||
source: i === 5 ? "TRAVEL" : "HOME",
|
||||
locationEncrypted: sealLocation({
|
||||
lat: 51.5,
|
||||
lon: 7.2,
|
||||
label: "Somewhere",
|
||||
}),
|
||||
tempMin: 10 + (i % 4),
|
||||
tempMax: 20,
|
||||
tempMean: 15,
|
||||
apparentMax: 21,
|
||||
sunshineSec: 7200,
|
||||
...(i === 3
|
||||
? {}
|
||||
: {
|
||||
pm25Mean: 8,
|
||||
pm25Max: 16,
|
||||
o3Max8h: 70,
|
||||
eaqiMax: 40,
|
||||
uvIndexMax: 3,
|
||||
pollenGrassMax: 12,
|
||||
pollenBirchMax: null,
|
||||
aqDomain: "cams_europe",
|
||||
aqHours: 24,
|
||||
aqFetchedAt: daysAgo(1),
|
||||
}),
|
||||
},
|
||||
});
|
||||
}
|
||||
return id;
|
||||
}
|
||||
|
||||
@@ -297,6 +338,7 @@ const CASES: Record<string, Array<Record<string, unknown>>> = {
|
||||
get_workouts: [{ window: "last30days" }],
|
||||
get_illness_recovery: [{}],
|
||||
get_cycle: [{}],
|
||||
get_environment: [{}, { window: "last7days" }],
|
||||
get_metrics: [
|
||||
{ metrics: ["weight", "bp", "steps"], window: "last30days" },
|
||||
{ metrics: ["hrv", "vo2_max"] },
|
||||
@@ -439,6 +481,27 @@ describe("MCP tool outputs against their advertised schemas", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("get_environment answers with data and no location anywhere", async () => {
|
||||
const { client, close } = await connect(userId);
|
||||
try {
|
||||
const result = await client.callTool({
|
||||
name: "get_environment",
|
||||
arguments: {},
|
||||
});
|
||||
const out = result.structuredContent as {
|
||||
present: boolean;
|
||||
data?: { daily: Array<{ airQuality?: unknown }> };
|
||||
};
|
||||
expect(out.present).toBe(true);
|
||||
expect(out.data!.daily.some((d) => d.airQuality === null)).toBe(true);
|
||||
const text = JSON.stringify(out);
|
||||
expect(text).not.toMatch(/"(lat|lon|label|locationLabel)"/);
|
||||
expect(text).not.toContain("Somewhere");
|
||||
} finally {
|
||||
await close();
|
||||
}
|
||||
});
|
||||
|
||||
it("list_metrics carries availability, and it is declared", async () => {
|
||||
const { client, close } = await connect(userId);
|
||||
try {
|
||||
|
||||
Reference in new issue
Block a user