/** * 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 = {}; 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((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((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>` 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, "