mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-11 13:52:20 +00:00
Fetch the Open-Meteo air-quality feed (CAMS, domains=auto) with the weather: 17 hourly variables folded into local days by fixed rules (means and maxima only with 18 of 24 hours, ozone as the highest 8-hour running mean, UV only with the midday hours), a value the feed did not cover stored as null, never as zero. Requests are cut into 90-day chunks, newest first, and chunks before mid 2022 ask for the eight pollutant and index variables only. An air-quality failure never costs the weather row; the day stays unfetched and the nightly gap fill (up to 90 days per account, newest first) asks for it again. Every Open-Meteo request asks an instance-wide budget kept in Postgres first (500 a minute, 4000 an hour, 8000 a day, weighted by the hosted pricing rule). A refused request is not sent and the run ends as done with budget_blocked. OPENMETEO_AIR_QUALITY_URL and ENVIRONMENT_AIR_QUALITY_DISABLED are honoured; the account switch lives on PATCH /api/environment/preferences. The home, each dated location period and each stored day keep their coarse location sealed as one value under healthlog/environment-location/v1. The writers write the sealed value only, the readers open it and fall back to the readable columns for rows the free-text encryption backfill has not reached, and the backfill now seals all three and clears the readable columns. The backup carries the location readable in a portable file and sealed in a disaster-recovery file; a restore seals either under the receiving key. Three correlation channels join the environment family (PM2.5, the ozone 8-hour high, the day's highest pollen count), read through the averaged same-day window and left empty while air quality is off. The Coach and MCP gain get_environment: module-gated, clamped to the lookback limit, no coordinate or place name anywhere, with provenance and the attribution lines. The settings section gets the switch, the coverage and the newest day's values; the dashboard shows a quiet chip on a day with high pollen, a warm night or very poor air. Copernicus and Open-Meteo are credited next to the values.
237 lines
7.4 KiB
TypeScript
237 lines
7.4 KiB
TypeScript
/**
|
|
* v1.39.4 — the environment module's coordinates are stored at 1 decimal
|
|
* (about 11 km), against real Postgres.
|
|
*
|
|
* - setting a home and adding a travel period store the coarse value, and
|
|
* the home answer carries it in the same shape as before (numbers);
|
|
* - a restore of a file written with 2-decimal coordinates stores them
|
|
* 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";
|
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
|
|
process.env.ENCRYPTION_KEY ??=
|
|
"0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
|
|
|
|
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";
|
|
|
|
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_ID = "environment-coarse-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;
|
|
}
|
|
|
|
beforeEach(async () => {
|
|
const prisma = getPrismaClient();
|
|
await truncateAllTables(prisma);
|
|
cookieJar.clear();
|
|
headerJar.clear();
|
|
await prisma.user.create({
|
|
data: {
|
|
id: OWNER_ID,
|
|
username: OWNER_ID,
|
|
email: `${OWNER_ID}@example.test`,
|
|
timezone: "Europe/Berlin",
|
|
},
|
|
});
|
|
const session = await prisma.session.create({
|
|
data: {
|
|
userId: OWNER_ID,
|
|
expiresAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000),
|
|
mfaVerifiedAt: new Date(),
|
|
},
|
|
});
|
|
cookieJar.set("healthlog_session", session.id);
|
|
});
|
|
|
|
describe("environment coordinates at one decimal", () => {
|
|
it("stores a home and a travel period coarse, and answers in the same shape", async () => {
|
|
const home = await putHome(
|
|
jsonRequest("http://localhost/api/environment/home", "PUT", {
|
|
lat: 52.5163,
|
|
lon: 13.3777,
|
|
label: "Berlin, Germany",
|
|
timezone: "Europe/Berlin",
|
|
}),
|
|
);
|
|
expect(home.status).toBe(200);
|
|
expect((await home.json()).data.home).toMatchObject({
|
|
lat: 52.5,
|
|
lon: 13.4,
|
|
});
|
|
const user = await getPrismaClient().user.findUniqueOrThrow({
|
|
where: { id: OWNER_ID },
|
|
});
|
|
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", {
|
|
startDate: "2026-08-01",
|
|
endDate: "2026-08-10",
|
|
lat: 48.13743,
|
|
lon: 11.57549,
|
|
label: "Munich, Germany",
|
|
}),
|
|
);
|
|
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, 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 () => {
|
|
const prisma = getPrismaClient();
|
|
await prisma.$transaction((tx) =>
|
|
restoreEnvironmentData(tx, OWNER_ID, {
|
|
environmentTravelLocations: [
|
|
{
|
|
startDate: "2026-08-01",
|
|
endDate: "2026-08-10",
|
|
lat: 48.14,
|
|
lon: 11.58,
|
|
label: "Munich, Germany",
|
|
},
|
|
],
|
|
environmentContexts: [
|
|
{
|
|
date: "2026-08-02",
|
|
lat: 48.14,
|
|
lon: 11.58,
|
|
locationLabel: "Munich, Germany",
|
|
source: "TRAVEL",
|
|
},
|
|
],
|
|
}),
|
|
);
|
|
const travel = await prisma.environmentTravelLocation.findMany({
|
|
where: { userId: OWNER_ID },
|
|
});
|
|
const days = await prisma.environmentContext.findMany({
|
|
where: { userId: OWNER_ID },
|
|
});
|
|
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 () => {
|
|
const prisma = getPrismaClient();
|
|
await prisma.user.update({
|
|
where: { id: OWNER_ID },
|
|
data: { homeLat: 52.52, homeLon: 13.38 },
|
|
});
|
|
await prisma.environmentTravelLocation.create({
|
|
data: {
|
|
userId: OWNER_ID,
|
|
startDate: "2026-08-01",
|
|
endDate: "2026-08-10",
|
|
lat: 48.14,
|
|
lon: 11.58,
|
|
label: "Munich, Germany",
|
|
},
|
|
});
|
|
await prisma.environmentContext.create({
|
|
data: {
|
|
userId: OWNER_ID,
|
|
date: "2026-08-02",
|
|
lat: -33.87,
|
|
lon: 151.21,
|
|
locationLabel: "Sydney, Australia",
|
|
source: "TRAVEL",
|
|
},
|
|
});
|
|
|
|
const sql = readFileSync(
|
|
join(
|
|
__dirname,
|
|
"../../prisma/migrations/0361_encrypt_route_geometry_and_practitioner_contact/migration.sql",
|
|
),
|
|
"utf8",
|
|
);
|
|
const statements = sql
|
|
.slice(sql.indexOf('UPDATE "users"'))
|
|
.split(";")
|
|
.map((s) => s.trim())
|
|
.filter((s) => s.length > 0);
|
|
expect(statements).toHaveLength(3);
|
|
|
|
for (let pass = 0; pass < 2; pass++) {
|
|
for (const statement of statements) {
|
|
await prisma.$executeRawUnsafe(statement);
|
|
}
|
|
const user = await prisma.user.findUniqueOrThrow({
|
|
where: { id: OWNER_ID },
|
|
});
|
|
expect([user.homeLat, user.homeLon]).toEqual([52.5, 13.4]);
|
|
const travel = await prisma.environmentTravelLocation.findFirstOrThrow({
|
|
where: { userId: OWNER_ID },
|
|
});
|
|
expect([travel.lat, travel.lon]).toEqual([48.1, 11.6]);
|
|
const day = await prisma.environmentContext.findFirstOrThrow({
|
|
where: { userId: OWNER_ID },
|
|
});
|
|
expect([day.lat, day.lon]).toEqual([-33.9, 151.2]);
|
|
}
|
|
});
|
|
});
|