diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 3f1f82806..b73a874a5 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -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". diff --git a/prisma/migrations/0361_encrypt_route_geometry_and_practitioner_contact/migration.sql b/prisma/migrations/0361_encrypt_route_geometry_and_practitioner_contact/migration.sql index cd9f3ebf4..a7b97295e 100644 --- a/prisma/migrations/0361_encrypt_route_geometry_and_practitioner_contact/migration.sql +++ b/prisma/migrations/0361_encrypt_route_geometry_and_practitioner_contact/migration.sql @@ -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; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index ec8d93d28..963b5ce71 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -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 diff --git a/src/app/api/environment/home/route.ts b/src/app/api/environment/home/route.ts index ded1c12e5..507cfc6bb 100644 --- a/src/app/api/environment/home/route.ts +++ b/src/app/api/environment/home/route.ts @@ -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. */ diff --git a/src/app/api/environment/travel/route.ts b/src/app/api/environment/travel/route.ts index 6f5d31f6c..5f0a0eed2 100644 --- a/src/app/api/environment/travel/route.ts +++ b/src/app/api/environment/travel/route.ts @@ -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. */ diff --git a/src/lib/environment/__tests__/coarse-location.test.ts b/src/lib/environment/__tests__/coarse-location.test.ts new file mode 100644 index 000000000..8162e858e --- /dev/null +++ b/src/lib/environment/__tests__/coarse-location.test.ts @@ -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 }); + }); +}); diff --git a/src/lib/environment/open-meteo.ts b/src/lib/environment/open-meteo.ts index 38e947677..130d44232 100644 --- a/src/lib/environment/open-meteo.ts +++ b/src/lib/environment/open-meteo.ts @@ -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 { diff --git a/src/lib/environment/service.ts b/src/lib/environment/service.ts index d1f168127..3c2fb43ed 100644 --- a/src/lib/environment/service.ts +++ b/src/lib/environment/service.ts @@ -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, diff --git a/src/lib/export/environment-backup.ts b/src/lib/export/environment-backup.ts index c02042875..cc96d9853 100644 --- a/src/lib/export/environment-backup.ts +++ b/src/lib/export/environment-backup.ts @@ -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, diff --git a/src/lib/openapi/routes/environment.ts b/src/lib/openapi/routes/environment.ts index 8d4c132ab..40339d20c 100644 --- a/src/lib/openapi/routes/environment.ts +++ b/src/lib/openapi/routes/environment.ts @@ -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 = { 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 = { 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 } }, diff --git a/tests/integration/backup-round-trip.test.ts b/tests/integration/backup-round-trip.test.ts index 6f4cc1117..a9cae4e6b 100644 --- a/tests/integration/backup-round-trip.test.ts +++ b/tests/integration/backup-round-trip.test.ts @@ -1206,14 +1206,16 @@ async function seedEveryTwoEndedModel(prisma: PrismaClient): Promise { // 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 { { 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 { { 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, diff --git a/tests/integration/environment-coarse-location.test.ts b/tests/integration/environment-coarse-location.test.ts new file mode 100644 index 000000000..45ee987ea --- /dev/null +++ b/tests/integration/environment-coarse-location.test.ts @@ -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]); + } + }); +});