mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-07 04:12:04 +00:00
fix(privacy): store environment coordinates at 1 decimal instead of 2
The home location of the environment module was rounded to 2 decimals, about 1 km, which places a home in a neighbourhood rather than the town the schema comment promised. The weather archive it feeds is gridded at 9 to 25 km, so the extra precision bought nothing. Every stored coordinate of the module now goes through the same 1-decimal rounding (about 11 km north-south, 7 km east-west at 50° N): the home, the travel periods, the geocoder's results, the per-day weather rows, and a restore of a file written with finer values. Migration 0361 rounds the existing users, travel periods and per-day rows; running it twice changes nothing more. The answers keep their shape, plain numbers. The API descriptions and the schema comment say 1 decimal now.
This commit is contained in:
1 parent
b8e78a78b3
commit
f2e32afb24
12 files changed
+347
-46
No files matched your search
@@ -20693,8 +20693,8 @@ paths:
|
||||
- Environment
|
||||
summary: Set the coarse home location
|
||||
description: >-
|
||||
Stores the picked city and stamps the effective-from instant. Coordinates are rounded to 2 dp server-side
|
||||
regardless of what was sent.
|
||||
Stores the picked city and stamps the effective-from instant. Coordinates are rounded to 1 decimal (about 11 km)
|
||||
server-side regardless of what was sent.
|
||||
|
||||
|
||||
Re-stamping is the part worth knowing: EVERY write moves `since` to now, including one that changes only the
|
||||
@@ -20850,7 +20850,7 @@ paths:
|
||||
summary: Add a travel override
|
||||
description: >-
|
||||
Declares that the account was somewhere other than home for an inclusive date range; the days it covers resolve
|
||||
against this location instead. Coordinates are rounded to 2 dp server-side.
|
||||
against this location instead. Coordinates are rounded to 1 decimal (about 11 km) server-side.
|
||||
|
||||
|
||||
Adding one enqueues a refresh over exactly the declared window so its days re-resolve. Nothing checks for
|
||||
@@ -72713,10 +72713,10 @@ components:
|
||||
properties:
|
||||
lat:
|
||||
type: number
|
||||
description: Coarse latitude, rounded to 2 dp (~1 km).
|
||||
description: Coarse latitude, rounded to 1 decimal (about 11 km).
|
||||
lon:
|
||||
type: number
|
||||
description: Coarse longitude, rounded to 2 dp.
|
||||
description: Coarse longitude, rounded to 1 decimal.
|
||||
label:
|
||||
anyOf:
|
||||
- type: string
|
||||
@@ -72855,10 +72855,10 @@ components:
|
||||
properties:
|
||||
lat:
|
||||
type: number
|
||||
description: Coarse latitude, rounded to 2 dp.
|
||||
description: Coarse latitude, rounded to 1 decimal.
|
||||
lon:
|
||||
type: number
|
||||
description: Coarse longitude, rounded to 2 dp.
|
||||
description: Coarse longitude, rounded to 1 decimal.
|
||||
label:
|
||||
type: string
|
||||
description: Assembled from name, region and country, e.g. "City, Region, Country".
|
||||
|
||||
+24
-3
@@ -12,9 +12,11 @@
|
||||
-- columns until the backfill has reached a row. The old columns are dropped in
|
||||
-- a later release, once the backfill reports nothing left on every instance.
|
||||
--
|
||||
-- Additive only: no table rewrite, no data change. The geometry loses its NOT
|
||||
-- NULL because every new track is written to the ciphertext column and the
|
||||
-- readable one stays empty.
|
||||
-- The encryption half is additive: no table rewrite, no data change. The
|
||||
-- geometry loses its NOT NULL because every new track is written to the
|
||||
-- ciphertext column and the readable one stays empty. The last block below is
|
||||
-- the one data change in this file: it coarsens the environment module's
|
||||
-- stored coordinates.
|
||||
|
||||
ALTER TABLE "workout_routes" ADD COLUMN "geometry_encrypted" BYTEA;
|
||||
ALTER TABLE "workout_routes" ALTER COLUMN "geometry" DROP NOT NULL;
|
||||
@@ -35,3 +37,22 @@ CREATE INDEX IF NOT EXISTS "workout_routes_geometry_backfill_idx"
|
||||
CREATE INDEX IF NOT EXISTS "practitioners_contact_backfill_idx"
|
||||
ON "practitioners" ("user_id")
|
||||
WHERE "phone" IS NOT NULL OR "location" IS NOT NULL;
|
||||
|
||||
-- The environment module's stored coordinates drop to 1 decimal, about 11 km
|
||||
-- north-south and 7 km east-west at 50° N. They were rounded to 2 decimals
|
||||
-- (about 1 km), which places a home in a neighbourhood rather than a town;
|
||||
-- the weather reanalysis the module reads is gridded at 9 to 25 km, so the
|
||||
-- coarser value costs no accuracy. The app rounds every new write the same
|
||||
-- way. Idempotent: rounding an already-rounded value changes nothing.
|
||||
UPDATE "users"
|
||||
SET "home_lat" = round("home_lat"::numeric, 1)::double precision,
|
||||
"home_lon" = round("home_lon"::numeric, 1)::double precision
|
||||
WHERE "home_lat" IS NOT NULL OR "home_lon" IS NOT NULL;
|
||||
|
||||
UPDATE "environment_travel_locations"
|
||||
SET "lat" = round("lat"::numeric, 1)::double precision,
|
||||
"lon" = round("lon"::numeric, 1)::double precision;
|
||||
|
||||
UPDATE "environment_contexts"
|
||||
SET "lat" = round("lat"::numeric, 1)::double precision,
|
||||
"lon" = round("lon"::numeric, 1)::double precision;
|
||||
@@ -81,10 +81,13 @@ model User {
|
||||
timezone String @default("Europe/Berlin")
|
||||
|
||||
// v1.25 (W-ENV) — opt-in environmental-context module home location. A coarse,
|
||||
// user-set city (rounded lat/lon, ~city granularity — never a GPS track) the
|
||||
// nightly weather fetch resolves against when no travel override covers the
|
||||
// day. City-level granularity is the privacy floor: the weather model only
|
||||
// needs ~10–25 km. All nullable — an account without the module enabled (or
|
||||
// user-set city (never a GPS track) the nightly weather fetch resolves
|
||||
// against when no travel override covers the day. Stored rounded to 1
|
||||
// decimal (`roundCoarse`): about 11 km north-south and 7 km east-west at
|
||||
// 50° N, a town or a city district. That is the privacy floor, and the
|
||||
// weather reanalysis is gridded at 9 to 25 km, so nothing finer is needed.
|
||||
// Up to v1.39.3 the rounding was 2 decimals (about 1 km, a neighbourhood);
|
||||
// migration 0361 rounded the existing rows. All nullable — an account without the module enabled (or
|
||||
// without a home set) simply has no environment rows. `homeTimezone` anchors
|
||||
// the day-key an observation is stored under (it can differ from the display
|
||||
// `timezone` above for an account that tracks weather for another locale).
|
||||
@@ -1055,8 +1058,8 @@ model EnvironmentContext {
|
||||
/// YYYY-MM-DD anchored to the resolved location's timezone.
|
||||
date String
|
||||
|
||||
/// Coarse resolved location for the day (rounded lat/lon + label + the source
|
||||
/// that won precedence).
|
||||
/// Coarse resolved location for the day (lat/lon rounded to 1 decimal, about
|
||||
/// 11 km, + label + the source that won precedence).
|
||||
lat Float
|
||||
lon Float
|
||||
/// @internal: human-readable label of the resolved location, stored as
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
* `PUT /api/environment/home` — set the account's coarse home location.
|
||||
*
|
||||
* The picked city (rounded lat/lon + label + IANA timezone) is stored on the
|
||||
* user. Coordinates are rounded to ~city granularity here as a defence-in-depth
|
||||
* floor even if a client sends finer values. Setting a home enqueues a
|
||||
* user. Coordinates are rounded to 1 decimal (about 11 km) here as a
|
||||
* defence-in-depth floor even if a client sends finer values. Setting a home enqueues a
|
||||
* lookback fetch so weather appears without waiting for the nightly tick.
|
||||
* Module-gated; `userId` is narrowed from auth.
|
||||
*/
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
*
|
||||
* For any day in [startDate, endDate] the weather fetch resolves against this
|
||||
* coarse location instead of home (a declared trip dominates the home
|
||||
* fallback). Coordinates are rounded to ~city granularity. Adding an override
|
||||
* fallback). Coordinates are rounded to 1 decimal (about 11 km). Adding an override
|
||||
* enqueues a lookback refresh so its days re-resolve. Module-gated; `userId` is
|
||||
* narrowed from auth.
|
||||
*/
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
/**
|
||||
* v1.39.4 — the environment module stores coordinates at 1 decimal (about
|
||||
* 11 km), not 2 (about 1 km). The geocoder is where a picked city's
|
||||
* coordinates enter, so its results must already be coarse.
|
||||
*/
|
||||
beforeEach(() => {
|
||||
vi.resetModules();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllEnvs();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
describe("coarse location", () => {
|
||||
it("rounds a coordinate to one decimal", async () => {
|
||||
const { roundCoarse } = await import("../open-meteo");
|
||||
expect(roundCoarse(52.5163)).toBe(52.5);
|
||||
expect(roundCoarse(13.3777)).toBe(13.4);
|
||||
expect(roundCoarse(-33.8688)).toBe(-33.9);
|
||||
// Already coarse values pass through unchanged.
|
||||
expect(roundCoarse(48.1)).toBe(48.1);
|
||||
});
|
||||
|
||||
it("hands back geocoding results at one decimal", async () => {
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn().mockResolvedValue(
|
||||
Response.json(
|
||||
{
|
||||
results: [
|
||||
{
|
||||
name: "Berlin",
|
||||
latitude: 52.52437,
|
||||
longitude: 13.41053,
|
||||
country: "Germany",
|
||||
timezone: "Europe/Berlin",
|
||||
},
|
||||
],
|
||||
},
|
||||
{ status: 200 },
|
||||
),
|
||||
),
|
||||
);
|
||||
const { geocodeLocation } = await import("../open-meteo");
|
||||
const [hit] = await geocodeLocation("Berlin");
|
||||
expect(hit).toMatchObject({ lat: 52.5, lon: 13.4 });
|
||||
});
|
||||
});
|
||||
@@ -40,7 +40,7 @@ export const OPEN_METEO_ATTRIBUTION = "Weather data by Open-Meteo.com";
|
||||
|
||||
/** One geocoding match — coarse city placement for the home-location picker. */
|
||||
export interface GeocodeResult {
|
||||
/** Rounded coarse latitude (2 dp — ~1 km, the privacy floor). */
|
||||
/** Rounded coarse latitude (1 dp, about 11 km, the privacy floor). */
|
||||
lat: number;
|
||||
/** Rounded coarse longitude (2 dp). */
|
||||
lon: number;
|
||||
@@ -68,9 +68,16 @@ export interface DailyEnvironmentObservation {
|
||||
weatherCode: number | null;
|
||||
}
|
||||
|
||||
/** Round a coordinate to 2 decimals (~1 km) — the coarse-location privacy floor. */
|
||||
/**
|
||||
* Round a coordinate to 1 decimal, the coarse-location privacy floor: about
|
||||
* 11 km north-south and 7 km east-west at 50° N, the size of a town or a
|
||||
* city district rather than a street. The weather reanalysis behind the
|
||||
* archive is gridded at 9 to 25 km, so a finer value buys no better weather.
|
||||
* Every stored coordinate of this module goes through here (v1.39.4; it was
|
||||
* 2 decimals, about 1 km, before).
|
||||
*/
|
||||
export function roundCoarse(value: number): number {
|
||||
return Math.round(value * 100) / 100;
|
||||
return Math.round(value * 10) / 10;
|
||||
}
|
||||
|
||||
function firstNumber(value: unknown): number | null {
|
||||
|
||||
@@ -24,6 +24,7 @@
|
||||
import { prisma } from "@/lib/db";
|
||||
import {
|
||||
fetchDailyEnvironment,
|
||||
roundCoarse,
|
||||
type DailyEnvironmentObservation,
|
||||
} from "@/lib/environment/open-meteo";
|
||||
import type { EnvironmentLocationSource } from "@/generated/prisma/client";
|
||||
@@ -256,8 +257,10 @@ function buildUpsert(
|
||||
obs: DailyEnvironmentObservation,
|
||||
) {
|
||||
const data = {
|
||||
lat: loc.lat,
|
||||
lon: loc.lon,
|
||||
// Already coarse at the source; rounded again so a per-day row never holds
|
||||
// more precision than the privacy floor, whatever resolved it.
|
||||
lat: roundCoarse(loc.lat),
|
||||
lon: roundCoarse(loc.lon),
|
||||
locationLabel: loc.label,
|
||||
source: loc.source,
|
||||
tempMin: obs.tempMin,
|
||||
|
||||
@@ -60,6 +60,7 @@
|
||||
*/
|
||||
import type { Prisma, PrismaClient } from "@/generated/prisma/client";
|
||||
import type { EnvironmentLocationSource } from "@/generated/prisma/client";
|
||||
import { roundCoarse } from "@/lib/environment/open-meteo";
|
||||
|
||||
/** One day's environmental observation, at the location resolved for that day. */
|
||||
export interface EnvironmentContextBackupEntry {
|
||||
@@ -312,8 +313,10 @@ export async function restoreEnvironmentData(
|
||||
// neither bound goes near a `Date`.
|
||||
startDate: entry.startDate,
|
||||
endDate: entry.endDate,
|
||||
lat: entry.lat,
|
||||
lon: entry.lon,
|
||||
// A file written before v1.39.4 carries 2-decimal coordinates; they
|
||||
// come back at today's privacy floor.
|
||||
lat: roundCoarse(entry.lat),
|
||||
lon: roundCoarse(entry.lon),
|
||||
label: entry.label,
|
||||
...(entry.createdAt ? { createdAt: new Date(entry.createdAt) } : {}),
|
||||
...(entry.updatedAt ? { updatedAt: new Date(entry.updatedAt) } : {}),
|
||||
@@ -326,8 +329,8 @@ export async function restoreEnvironmentData(
|
||||
data: payload.environmentContexts.map((entry) => ({
|
||||
userId: ownerId,
|
||||
date: entry.date,
|
||||
lat: entry.lat,
|
||||
lon: entry.lon,
|
||||
lat: roundCoarse(entry.lat),
|
||||
lon: roundCoarse(entry.lon),
|
||||
locationLabel: entry.locationLabel,
|
||||
source: entry.source,
|
||||
tempMin: entry.tempMin ?? null,
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
* the `environment` module off, every one of the six paths answers 403
|
||||
* `module.disabled` before it does anything else.
|
||||
*
|
||||
* Coordinates are rounded to ~city granularity (2 dp) on the way in, as a
|
||||
* Coordinates are rounded to 1 decimal (about 11 km) on the way in, as a
|
||||
* floor under whatever precision a client sends. That rounding is why the
|
||||
* geocode results and the stored home read back coarser than they were
|
||||
* picked.
|
||||
@@ -58,8 +58,10 @@ const geocodeQuery = geocodeQuerySchema.meta({
|
||||
|
||||
const environmentHome = z
|
||||
.object({
|
||||
lat: z.number().describe("Coarse latitude, rounded to 2 dp (~1 km)."),
|
||||
lon: z.number().describe("Coarse longitude, rounded to 2 dp."),
|
||||
lat: z
|
||||
.number()
|
||||
.describe("Coarse latitude, rounded to 1 decimal (about 11 km)."),
|
||||
lon: z.number().describe("Coarse longitude, rounded to 1 decimal."),
|
||||
label: z.string().nullable(),
|
||||
timezone: z
|
||||
.string()
|
||||
@@ -142,8 +144,8 @@ const environmentOverview = z
|
||||
|
||||
const geocodeResult = z
|
||||
.object({
|
||||
lat: z.number().describe("Coarse latitude, rounded to 2 dp."),
|
||||
lon: z.number().describe("Coarse longitude, rounded to 2 dp."),
|
||||
lat: z.number().describe("Coarse latitude, rounded to 1 decimal."),
|
||||
lon: z.number().describe("Coarse longitude, rounded to 1 decimal."),
|
||||
label: z
|
||||
.string()
|
||||
.describe(
|
||||
@@ -186,7 +188,7 @@ export const environmentPaths: NonNullable<ZodOpenApiObject["paths"]> = {
|
||||
tags: ["Environment"],
|
||||
summary: "Set the coarse home location",
|
||||
description:
|
||||
"Stores the picked city and stamps the effective-from instant. Coordinates are rounded to 2 dp server-side regardless of what was sent.\n\nRe-stamping is the part worth knowing: EVERY write moves `since` to now, including one that changes only the label. Days before the new `since` stop resolving against this home, so a correction to an existing home un-attributes the history that home used to cover.\n\nOn success a lookback fetch is enqueued so recent days populate without waiting for the nightly tick. That enqueue is best-effort — it no-ops cleanly when no worker is bound and the response is a 200 either way.\n\nBody cap 4 KiB. Module-gated.",
|
||||
"Stores the picked city and stamps the effective-from instant. Coordinates are rounded to 1 decimal (about 11 km) server-side regardless of what was sent.\n\nRe-stamping is the part worth knowing: EVERY write moves `since` to now, including one that changes only the label. Days before the new `since` stop resolving against this home, so a correction to an existing home un-attributes the history that home used to cover.\n\nOn success a lookback fetch is enqueued so recent days populate without waiting for the nightly tick. That enqueue is best-effort — it no-ops cleanly when no worker is bound and the response is a 200 either way.\n\nBody cap 4 KiB. Module-gated.",
|
||||
requestBody: {
|
||||
required: true,
|
||||
content: { "application/json": { schema: homeLocationRequest } },
|
||||
@@ -309,7 +311,7 @@ export const environmentPaths: NonNullable<ZodOpenApiObject["paths"]> = {
|
||||
tags: ["Environment"],
|
||||
summary: "Add a travel override",
|
||||
description:
|
||||
"Declares that the account was somewhere other than home for an inclusive date range; the days it covers resolve against this location instead. Coordinates are rounded to 2 dp server-side.\n\nAdding one enqueues a refresh over exactly the declared window so its days re-resolve. Nothing checks for overlap with an existing override — two ranges covering the same day are accepted, and the worker picks between them.\n\nBody cap 4 KiB. Module-gated.",
|
||||
"Declares that the account was somewhere other than home for an inclusive date range; the days it covers resolve against this location instead. Coordinates are rounded to 1 decimal (about 11 km) server-side.\n\nAdding one enqueues a refresh over exactly the declared window so its days re-resolve. Nothing checks for overlap with an existing override — two ranges covering the same day are accepted, and the worker picks between them.\n\nBody cap 4 KiB. Module-gated.",
|
||||
requestBody: {
|
||||
required: true,
|
||||
content: { "application/json": { schema: travelLocationRequest } },
|
||||
|
||||
@@ -1206,14 +1206,16 @@ async function seedEveryTwoEndedModel(prisma: PrismaClient): Promise<void> {
|
||||
// coordinates and different weather, and the trip day's reading only makes
|
||||
// sense next to the period that explains it: with the period gone, the next
|
||||
// refresh re-resolves 2026-06-15 to Berlin and upserts Berlin's weather over
|
||||
// it. The assertion after the restore reads the two back TOGETHER.
|
||||
// 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.
|
||||
await prisma.environmentTravelLocation.create({
|
||||
data: {
|
||||
userId: OWNER_ID,
|
||||
startDate: "2026-06-10",
|
||||
endDate: "2026-06-20",
|
||||
lat: 41.3874,
|
||||
lon: 2.1686,
|
||||
lat: 41.4,
|
||||
lon: 2.2,
|
||||
label: "Barcelona",
|
||||
},
|
||||
});
|
||||
@@ -1222,8 +1224,8 @@ async function seedEveryTwoEndedModel(prisma: PrismaClient): Promise<void> {
|
||||
{
|
||||
userId: OWNER_ID,
|
||||
date: "2026-06-15",
|
||||
lat: 41.3874,
|
||||
lon: 2.1686,
|
||||
lat: 41.4,
|
||||
lon: 2.2,
|
||||
locationLabel: "Barcelona",
|
||||
source: "TRAVEL",
|
||||
tempMin: 19.4,
|
||||
@@ -1243,8 +1245,8 @@ async function seedEveryTwoEndedModel(prisma: PrismaClient): Promise<void> {
|
||||
{
|
||||
userId: OWNER_ID,
|
||||
date: "2026-07-01",
|
||||
lat: 52.52,
|
||||
lon: 13.405,
|
||||
lat: 52.5,
|
||||
lon: 13.4,
|
||||
locationLabel: "Berlin",
|
||||
source: "HOME",
|
||||
tempMin: 13.1,
|
||||
@@ -2394,8 +2396,8 @@ describe("every model the plan claims two-ended survives a real restore", () =>
|
||||
).toEqual([
|
||||
{
|
||||
date: "2026-06-15",
|
||||
lat: 41.3874,
|
||||
lon: 2.1686,
|
||||
lat: 41.4,
|
||||
lon: 2.2,
|
||||
locationLabel: "Barcelona",
|
||||
source: "TRAVEL",
|
||||
tempMean: 23.6,
|
||||
@@ -2406,8 +2408,8 @@ describe("every model the plan claims two-ended survives a real restore", () =>
|
||||
},
|
||||
{
|
||||
date: "2026-07-01",
|
||||
lat: 52.52,
|
||||
lon: 13.405,
|
||||
lat: 52.5,
|
||||
lon: 13.4,
|
||||
locationLabel: "Berlin",
|
||||
source: "HOME",
|
||||
tempMean: 17.9,
|
||||
|
||||
@@ -0,0 +1,209 @@
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
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 { 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]).toEqual([52.5, 13.4]);
|
||||
|
||||
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);
|
||||
const stored = await getPrismaClient().environmentTravelLocation.findMany({
|
||||
where: { userId: OWNER_ID },
|
||||
});
|
||||
expect(stored.map((t) => [t.lat, t.lon])).toEqual([[48.1, 11.6]]);
|
||||
});
|
||||
|
||||
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) => [t.lat, t.lon])).toEqual([[48.1, 11.6]]);
|
||||
expect(days.map((d) => [d.lat, d.lon])).toEqual([[48.1, 11.6]]);
|
||||
});
|
||||
|
||||
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]);
|
||||
}
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user