Files
HealthLog/tests/integration/dawarich-context.test.ts
Marc-André Bombeck b6da360856 refactor(db): drop the runtime schema mirror
ensureDbCompatibility ran ALTER TABLE / CREATE TABLE IF NOT EXISTS on the
login, register, token and session paths, mirroring columns every
migration under prisma/migrations already creates. The entrypoint applies
those migrations before the server listens and stops the boot when one
fails, so the mirror never had work to do, and DDL on a request path
needs the table's owner: a connection that may only read and write rows
fails every login on it. Drop the module, its call sites and the mocks
that stubbed it out.
2026-11-03 20:32:35 +01:00

596 lines
20 KiB
TypeScript

/**
* Dawarich movement context against real SQL and a stub Dawarich instance.
*
* A small HTTP server on the loopback interface answers the four endpoints
* HealthLog reads (`/api/v1/users/me`, `/timeline`, `/visits`, `/residency`,
* `/places`) with the documented shapes, including the coordinates and names
* Dawarich sends along. The tests drive the shipped route exports and the
* sync, and read the database afterwards:
*
* - one day with timeline, visits and residency yields the expected values;
* a day without a visit has `awayFromHomeMinutes: null`, not zero;
* - no coordinate or place name lands anywhere in the stored rows;
* - a private instance without the operator's grant is refused (422
* `private_origin_not_approved`) and nothing is stored;
* - with the environment module off the routes answer 403 and the job
* sends no request;
* - travel days become a trip suggestion; accepting it through the trip
* route covers it; dismissing hides it;
* - disconnecting with `deleteData` empties the days.
*/
import { createServer, type IncomingMessage, type Server } from "node:http";
import type { AddressInfo } from "node:net";
import { NextRequest } from "next/server";
import {
afterAll,
beforeAll,
beforeEach,
describe,
expect,
it,
vi,
} from "vitest";
process.env.ENCRYPTION_KEY ??=
"0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
import { cookieJar } 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),
})),
};
});
const USER_ID = "user-dawarich";
const API_KEY = "dawarich-test-key";
const HOME = 7;
// ── The stub instance ─────────────────────────────────────────────────
const requests: string[] = [];
let server: Server;
let origin = "";
const VISITS = [
// Home overnight into 2026-06-15 (Berlin day = 22:00Z on the 14th to 22:00Z).
{
id: 1,
started_at: "2026-06-14T18:00:00Z",
ended_at: "2026-06-15T06:00:00Z",
status: "confirmed",
name: "Home Sweet Home",
place: { id: HOME, latitude: 52.52, longitude: 13.405 },
},
{
id: 2,
started_at: "2026-06-15T07:00:00Z",
ended_at: "2026-06-15T15:00:00Z",
status: "confirmed",
name: "Office Tower",
place: { id: 9, latitude: 41.3874, longitude: 2.1686 },
},
{
id: 3,
started_at: "2026-06-15T16:00:00Z",
ended_at: "2026-06-16T05:00:00Z",
status: "suggested",
name: "Home Sweet Home",
place: { id: HOME, latitude: 52.52, longitude: 13.405 },
},
];
const TIMELINE_DAYS = [
{
date: "2026-06-15",
summary: {
total_distance: 48.3,
distance_unit: "km",
places_visited: 4,
mode_distances: { train: 30.5, driving: 12, walking: 1.2 },
},
bounds: { sw_lat: 41.38, sw_lng: 2.16, ne_lat: 52.52, ne_lng: 13.4 },
entries: [{ type: "visit", name: "Office Tower" }],
},
{
date: "2026-06-17",
summary: { total_distance: 3.4, places_visited: 1, mode_distances: {} },
},
];
function residency(year: number) {
if (year !== 2026) return { year, daily_countries: {}, countries: [] };
const daily: Record<string, string> = {};
for (let d = 1; d <= 20; d++) {
daily[`2026-05-${String(d).padStart(2, "0")}`] = "Germany";
}
daily["2026-06-15"] = "Spain";
daily["2026-06-16"] = "Spain";
daily["2026-06-17"] = "Spain";
return {
year,
daily_countries: daily,
countries: [
{ country_name: "Germany", iso_a2: "DE", days: 20 },
{ country_name: "Spain", iso_a2: "ES", days: 3 },
],
};
}
/** A timeline that answers from the window, set by the test that needs it. */
let timelineAnswer: ((url: URL) => { status: number; body: unknown }) | null =
null;
function answer(req: IncomingMessage): { status: number; body: unknown } {
const url = new URL(req.url ?? "/", "http://stub");
requests.push(url.pathname);
if (req.headers.authorization !== `Bearer ${API_KEY}`) {
return { status: 401, body: { error: "unauthorized" } };
}
switch (url.pathname) {
case "/api/v1/users/me":
return { status: 200, body: { email: "someone@example.test" } };
case "/api/v1/timeline":
if (timelineAnswer) return timelineAnswer(url);
return { status: 200, body: { days: TIMELINE_DAYS } };
case "/api/v1/visits":
return { status: 200, body: VISITS };
case "/api/v1/residency":
return {
status: 200,
body: residency(Number(url.searchParams.get("year"))),
};
case "/api/v1/places":
return {
status: 200,
body: [
{ id: HOME, name: "Home Sweet Home", latitude: 52.52 },
{ id: 9, name: "Office Tower", latitude: 41.3874 },
],
};
case `/api/v1/places/${HOME}`:
return { status: 200, body: { id: HOME, name: "Home Sweet Home" } };
default:
return { status: 404, body: { error: "not found" } };
}
}
beforeAll(async () => {
server = createServer((req, res) => {
const { status, body } = answer(req);
res.writeHead(status, { "content-type": "application/json" });
res.end(JSON.stringify(body));
});
await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", resolve));
origin = `http://127.0.0.1:${(server.address() as AddressInfo).port}`;
});
afterAll(async () => {
await new Promise<void>((resolve) => server.close(() => resolve()));
});
// ── Fixtures ──────────────────────────────────────────────────────────
beforeEach(async () => {
const prisma = getPrismaClient();
await truncateAllTables(prisma);
cookieJar.clear();
requests.length = 0;
timelineAnswer = null;
process.env.DAWARICH_PRIVATE_ORIGINS = origin;
await prisma.user.create({
data: {
id: USER_ID,
username: "dawarich-user",
email: "dawarich@example.test",
timezone: "Europe/Berlin",
modulePreferencesJson: { environment: true },
},
});
const session = await prisma.session.create({
data: { userId: USER_ID, expiresAt: new Date(Date.now() + 3_600_000) },
});
cookieJar.set("healthlog_session", session.id);
});
function json(url: string, body: unknown, method = "POST") {
return new NextRequest(`http://localhost${url}`, {
method,
headers: { "content-type": "application/json" },
body: JSON.stringify(body),
});
}
async function connect(url = origin, apiKey = API_KEY) {
const { POST } = await import("@/app/api/dawarich/connect/route");
return POST(json("/api/dawarich/connect", { url, apiKey }));
}
async function setHome() {
const { PATCH } = await import("@/app/api/dawarich/settings/route");
return PATCH(json("/api/dawarich/settings", { homePlaceId: HOME }, "PATCH"));
}
async function syncFixedRange() {
const { syncDawarichRange } = await import("@/lib/dawarich/sync");
return syncDawarichRange(USER_ID, "2026-06-14", "2026-06-17");
}
async function storedDays() {
return getPrismaClient().dawarichDay.findMany({
where: { userId: USER_ID },
orderBy: { date: "asc" },
});
}
// ── Tests ─────────────────────────────────────────────────────────────
describe("Dawarich daily values", () => {
it("turns one day of timeline, visits and residency into the expected values", async () => {
expect((await connect()).status).toBe(200);
expect((await setHome()).status).toBe(200);
const result = await syncFixedRange();
// 06-14 has the overnight home visit only; 06-15..06-17 hold values.
expect(result.daysStored).toBe(4);
const days = await storedDays();
const byDate = new Map(days.map((d) => [d.date, d]));
const june15 = byDate.get("2026-06-15")!;
expect(june15).toMatchObject({
totalDistanceM: 48_300,
drivingDistanceM: 12_000,
trainDistanceM: 30_500,
placesVisited: 4,
// 24 h minus 8 h (overnight) and 6 h (evening) at home.
awayFromHomeMinutes: 600,
isTravelDay: true,
});
// No visit touches 06-17: no coverage, not zero minutes.
expect(byDate.get("2026-06-17")!.awayFromHomeMinutes).toBeNull();
expect(byDate.get("2026-06-17")!.totalDistanceM).toBe(3_400);
const { openDayCountry } = await import("@/lib/dawarich/connection");
expect(openDayCountry(june15.countryCodeEncrypted)).toBe("ES");
// The home country was derived from a year of residency.
const { GET } = await import("@/app/api/dawarich/status/route");
const status = await (await GET()).json();
expect(status.data).toMatchObject({
connected: true,
homePlaceSet: true,
homeCountry: "DE",
daysStored: 4,
oldestDay: "2026-06-14",
latestDay: "2026-06-17",
});
expect(status.data.health).not.toBeNull();
});
it("stores no coordinate and no place name anywhere", async () => {
await connect();
await setHome();
const { GET: places } = await import("@/app/api/dawarich/places/route");
const listed = await (
await places(new NextRequest("http://localhost/api/dawarich/places"))
).json();
expect(listed.data.places.map((p: { name: string }) => p.name)).toEqual([
"Home Sweet Home",
"Office Tower",
]);
await syncFixedRange();
const prisma = getPrismaClient();
const rows = await prisma.$queryRaw<Array<{ row: string }>>`
SELECT row_to_json(d)::text AS "row" FROM "dawarich_days" d
UNION ALL
SELECT row_to_json(u)::text FROM "users" u
UNION ALL
SELECT row_to_json(s)::text FROM "integration_statuses" s
`;
// Clock times go first: a timestamp written at :52.52x seconds holds the
// latitude's digits and failed the check without any coordinate stored.
const dump = rows
.map((r) => r.row)
.join("\n")
.replace(/\d{2}:\d{2}:\d{2}\.\d+/g, "<time>");
for (const leak of [
"Home Sweet",
"Office Tower",
"52.52",
"41.38",
"13.405",
]) {
expect(dump, leak).not.toContain(leak);
}
const { openDawarichPrefs } = await import("@/lib/dawarich/connection");
const user = await prisma.user.findUniqueOrThrow({
where: { id: USER_ID },
select: { dawarichPrefsEncrypted: true },
});
expect(
Object.keys(openDawarichPrefs(user.dawarichPrefsEncrypted)).sort(),
).toEqual([
"derivedHomeCountry",
"dismissedSuggestionIds",
"homeCountry",
"homePlaceId",
]);
});
it("carries the movement block on the owner's day, without places", async () => {
await connect();
await setHome();
await syncFixedRange();
const { GET } = await import("@/app/api/day/[date]/route");
const res = await GET(new Request("http://localhost/api/day/2026-06-15"), {
params: Promise.resolve({ date: "2026-06-15" }),
});
const body = await res.json();
expect(res.status).toBe(200);
expect(body.data.movement).toMatchObject({
source: "dawarich",
distance: { unit: "km", total: 48.3, driving: 12, train: 30.5 },
placesVisited: 4,
awayFromHomeMinutes: 600,
travel: { isTravelDay: true, countryCode: "ES" },
});
});
});
describe("Dawarich refusals", () => {
it("refuses a private instance the operator did not list, and stores nothing", async () => {
delete process.env.DAWARICH_PRIVATE_ORIGINS;
const res = await connect();
expect(res.status).toBe(422);
expect((await res.json()).meta.errorCode).toBe(
"private_origin_not_approved",
);
expect(requests).toEqual([]);
const user = await getPrismaClient().user.findUniqueOrThrow({
where: { id: USER_ID },
select: { dawarichUrlEncrypted: true },
});
expect(user.dawarichUrlEncrypted).toBeNull();
});
it("refuses a key the instance does not accept", async () => {
const res = await connect(origin, "wrong-key");
expect(res.status).toBe(422);
expect((await res.json()).meta.errorCode).toBe(
"dawarich.credentials_rejected",
);
});
it("answers 403 and fetches nothing with the environment module off", async () => {
await connect();
requests.length = 0;
await getPrismaClient().user.update({
where: { id: USER_ID },
data: { modulePreferencesJson: { environment: false } },
});
const { GET } = await import("@/app/api/dawarich/status/route");
expect((await GET()).status).toBe(403);
const { POST: sync } = await import("@/app/api/dawarich/sync/route");
expect((await sync(json("/api/dawarich/sync", {}))).status).toBe(403);
const { handleDawarichSync } = await import("@/lib/jobs/dawarich-sync");
const boss = { send: vi.fn() };
const discovery = await handleDawarichSync(boss as never, [
{ data: {} } as never,
]);
expect(discovery.ok).toBe(true);
expect(boss.send).not.toHaveBeenCalled();
const run = await handleDawarichSync(boss as never, [
{ data: { userId: USER_ID } } as never,
]);
expect(run.ok).toBe(true);
expect(requests).toEqual([]);
// The control: with the module on, the same run does reach the instance.
await getPrismaClient().user.update({
where: { id: USER_ID },
data: { modulePreferencesJson: { environment: true } },
});
await handleDawarichSync(boss as never, [
{ data: { userId: USER_ID } } as never,
]);
expect(requests).toContain("/api/v1/timeline");
});
it("records a failed fetch on the ledger as a short code", async () => {
await connect();
await getPrismaClient().user.update({
where: { id: USER_ID },
data: {
dawarichApiKeyEncrypted: (await import("@/lib/crypto")).encrypt(
"revoked",
),
},
});
await expect(syncFixedRange()).rejects.toThrow();
const ledger = await getPrismaClient().integrationStatus.findUniqueOrThrow({
where: {
userId_integration: { userId: USER_ID, integration: "dawarich" },
},
});
expect(ledger.state).toBe("error_reauth");
expect(JSON.stringify(ledger.recentCalls)).toContain('"401"');
expect(JSON.stringify(ledger.recentCalls)).not.toContain("127.0.0.1");
});
});
describe("Travel suggestions", () => {
async function suggestions() {
const { GET } =
await import("@/app/api/environment/travel/suggestions/route");
return (await (await GET()).json()).data.suggestions as Array<{
id: string;
startDate: string;
endDate: string;
countryCode: string;
}>;
}
it("suggests the run of travel days, and accepting it as a trip covers it", async () => {
await connect();
await setHome();
await syncFixedRange();
const open = await suggestions();
expect(open).toEqual([
{
id: "2026-06-15_2026-06-17_ES",
startDate: "2026-06-15",
endDate: "2026-06-17",
countryCode: "ES",
},
]);
const { POST } = await import("@/app/api/environment/travel/route");
const trip = await POST(
json("/api/environment/travel", {
startDate: "2026-06-15",
endDate: "2026-06-17",
lat: 41.39,
lon: 2.17,
label: "Barcelona",
}),
);
expect(trip.status).toBeLessThan(300);
expect(await suggestions()).toEqual([]);
// The trip is what the weather fetch reads for those days.
const stored = await getPrismaClient().environmentTravelLocation.findMany({
where: { userId: USER_ID },
});
expect(stored).toHaveLength(1);
});
it("dismisses a suggestion for good and refuses an unknown one", async () => {
await connect();
await setHome();
await syncFixedRange();
const [first] = await suggestions();
const { POST } =
await import("@/app/api/environment/travel/suggestions/[id]/dismiss/route");
const dismiss = (id: string) =>
POST(
new NextRequest(
`http://localhost/api/environment/travel/suggestions/${id}/dismiss`,
{ method: "POST" },
),
{ params: Promise.resolve({ id }) },
);
expect((await dismiss(first.id)).status).toBe(200);
expect(await suggestions()).toEqual([]);
const unknown = await dismiss("2020-01-01_2020-01-02_FR");
expect(unknown.status).toBe(404);
expect((await unknown.json()).meta.errorCode).toBe(
"environment.travel_suggestion.not_found",
);
});
});
describe("Disconnect", () => {
it("keeps the days by default and deletes them on request", async () => {
await connect();
await setHome();
await syncFixedRange();
const { POST } = await import("@/app/api/dawarich/disconnect/route");
const kept = await (
await POST(json("/api/dawarich/disconnect", {}))
).json();
expect(kept.data).toEqual({ disconnected: true, deletedDays: 0 });
expect(await storedDays()).toHaveLength(4);
const gone = await (
await POST(json("/api/dawarich/disconnect", { deleteData: true }))
).json();
expect(gone.data).toEqual({ disconnected: true, deletedDays: 4 });
expect(await storedDays()).toHaveLength(0);
});
});
describe("Timeline days across a request boundary", () => {
/**
* The instance as Dawarich builds a timeline (`Timeline::DayAssembler`):
* days in the Dawarich account's own zone, here New York while the
* HealthLog account is in Berlin, and only what lies inside the requested
* window is summed, so a window cut at Berlin midnight answers the New
* York day it cuts with a fragment. Every New York day holds a 1 km trip
* at 02:00 and a 10 km trip at 20:00 (02:00 the next morning in Berlin),
* so a complete day is 11 km and a fragment 1 or 10. A window longer than
* 31 days is refused as the instance refuses it.
*/
const INSTANCE_TZ = "America/New_York";
const nyDate = (ms: number) =>
new Date(ms).toLocaleDateString("en-CA", { timeZone: INSTANCE_TZ });
const nyInstant = (date: string, hour: number) => {
// New York is UTC-4 for the whole summer range this test reads.
return Date.parse(`${date}T${String(hour).padStart(2, "0")}:00:00-04:00`);
};
function instanceTimeline(url: URL) {
const startMs = Date.parse(url.searchParams.get("start_at") ?? "");
const endMs = Date.parse(url.searchParams.get("end_at") ?? "");
if (endMs - startMs > 31 * 24 * 3_600_000) {
return { status: 400, body: { error: "range too large" } };
}
const totals = new Map<string, number>();
const from = new Date(startMs - 2 * 86_400_000);
for (let d = from.getTime(); d <= endMs + 86_400_000; d += 86_400_000) {
const date = nyDate(d);
for (const [hour, km] of [
[2, 1],
[20, 10],
] as const) {
const at = nyInstant(date, hour);
if (at < startMs || at > endMs) continue;
totals.set(date, (totals.get(date) ?? 0) + km);
}
}
return {
status: 200,
body: {
days: [...totals].map(([date, km]) => ({
date,
summary: {
total_distance: km,
places_visited: 0,
mode_distances: {},
},
})),
},
};
}
it("never lets a boundary fragment overwrite a complete day", async () => {
timelineAnswer = instanceTimeline;
expect((await connect()).status).toBe(200);
await getPrismaClient().dawarichDay.deleteMany({});
const { syncDawarichRange } = await import("@/lib/dawarich/sync");
// Sixty days: three timeline requests, two boundaries between them.
await syncDawarichRange(USER_ID, "2026-06-01", "2026-07-30");
const days = await storedDays();
expect(days).toHaveLength(60);
const wrong = days
.filter((d) => d.totalDistanceM !== 11_000)
.map((d) => `${d.date}=${d.totalDistanceM}`);
expect(wrong).toEqual([]);
});
});