mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 20:42:43 +00:00
test(dawarich): cover the daily values, the refusals and the backup ends
A stub Dawarich instance on the loopback interface drives the shipped routes and the sync against real SQL. Unit tests pin the parsers, the time-away arithmetic, the origin grant, the travel runs, the places route's no-write rule, the backup pair and the schema's no-location rule.
This commit is contained in:
1 parent
b838ed4403
commit
b99bcaeec6
2 files changed
+518
-2
No files matched your search
@@ -92,7 +92,6 @@ describe("GET /api/dawarich/places", () => {
|
||||
]);
|
||||
const res = (await GET(
|
||||
new NextRequest("http://localhost/api/dawarich/places?q=o"),
|
||||
undefined as never,
|
||||
)) as Response;
|
||||
expect(res.status).toBe(200);
|
||||
const body = await res.json();
|
||||
@@ -112,7 +111,6 @@ describe("GET /api/dawarich/places", () => {
|
||||
fetchPlacesMock.mockRejectedValue(new Error("ECONNREFUSED"));
|
||||
const res = (await GET(
|
||||
new NextRequest("http://localhost/api/dawarich/places"),
|
||||
undefined as never,
|
||||
)) as Response;
|
||||
expect(res.status).toBe(502);
|
||||
expect((await res.json()).meta.errorCode).toBe("dawarich.unreachable");
|
||||
|
||||
@@ -0,0 +1,518 @@
|
||||
/**
|
||||
* 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),
|
||||
})),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("@/lib/db-compat", () => ({
|
||||
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
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 },
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
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":
|
||||
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;
|
||||
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
|
||||
`;
|
||||
const dump = rows.map((r) => r.row).join("\n");
|
||||
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);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user