mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-14 14:40:52 +00:00
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.
168 lines
4.6 KiB
TypeScript
168 lines
4.6 KiB
TypeScript
/**
|
|
* The activity calendar's reader against a real Postgres (D1036).
|
|
*
|
|
* - steps resolve to the source the person's ladder picks per day, so a day
|
|
* two devices counted shows one device's total, never the sum;
|
|
* - the day is the person's local day: a reading at 23:30 Berlin time lands
|
|
* on that day, not on the UTC day after;
|
|
* - a workout two devices recorded counts once, its minutes once;
|
|
* - the window is exact: nothing before `fromKey` or after `toKey`.
|
|
*/
|
|
import { beforeAll, describe, expect, it, vi } from "vitest";
|
|
|
|
import { getPrismaClient, truncateAllTables } from "./setup";
|
|
import { readActivityDays } from "@/lib/activity/calendar-read";
|
|
import { invalidateUserTimezone } from "@/lib/tz/resolver";
|
|
import { userDayKey } from "@/lib/tz/format";
|
|
|
|
const prisma = getPrismaClient();
|
|
const TZ = "Europe/Berlin";
|
|
const USER = "activity-reader";
|
|
const DAY = 86_400_000;
|
|
const NOW = new Date();
|
|
|
|
function daysAgo(n: number, hourUtc = 10): Date {
|
|
const d = new Date(NOW.getTime() - n * DAY);
|
|
d.setUTCHours(hourUtc, 0, 0, 0);
|
|
return d;
|
|
}
|
|
|
|
beforeAll(async () => {
|
|
await truncateAllTables(prisma);
|
|
await prisma.user.create({
|
|
data: {
|
|
id: USER,
|
|
username: USER,
|
|
timezone: TZ,
|
|
sourcePriorityJson: {
|
|
metricPriority: { steps: ["FITBIT", "APPLE_HEALTH"] },
|
|
},
|
|
},
|
|
});
|
|
invalidateUserTimezone(USER);
|
|
|
|
await prisma.measurement.createMany({
|
|
data: [
|
|
// Day 3: both devices; the ladder picks Fitbit's 8 000.
|
|
{
|
|
userId: USER,
|
|
type: "ACTIVITY_STEPS",
|
|
value: 9500,
|
|
unit: "steps",
|
|
source: "APPLE_HEALTH",
|
|
measuredAt: daysAgo(3),
|
|
},
|
|
{
|
|
userId: USER,
|
|
type: "ACTIVITY_STEPS",
|
|
value: 8000,
|
|
unit: "steps",
|
|
source: "FITBIT",
|
|
measuredAt: daysAgo(3),
|
|
},
|
|
// Day 2: Apple only, two slices that add up.
|
|
{
|
|
userId: USER,
|
|
type: "ACTIVITY_STEPS",
|
|
value: 3000,
|
|
unit: "steps",
|
|
source: "APPLE_HEALTH",
|
|
measuredAt: daysAgo(2, 8),
|
|
},
|
|
{
|
|
userId: USER,
|
|
type: "ACTIVITY_STEPS",
|
|
value: 4000,
|
|
unit: "steps",
|
|
source: "APPLE_HEALTH",
|
|
measuredAt: daysAgo(2, 15),
|
|
},
|
|
// 21:30 UTC is 23:30 in Berlin (summer) or 22:30 (winter): still day 5 locally.
|
|
{
|
|
userId: USER,
|
|
type: "ACTIVITY_STEPS",
|
|
value: 1200,
|
|
unit: "steps",
|
|
source: "APPLE_HEALTH",
|
|
measuredAt: daysAgo(5, 21),
|
|
},
|
|
{
|
|
userId: USER,
|
|
type: "EXERCISE_MINUTES",
|
|
value: 25,
|
|
unit: "min",
|
|
source: "APPLE_HEALTH",
|
|
measuredAt: daysAgo(3, 9),
|
|
},
|
|
{
|
|
userId: USER,
|
|
type: "EXERCISE_MINUTES",
|
|
value: 15,
|
|
unit: "min",
|
|
source: "APPLE_HEALTH",
|
|
measuredAt: daysAgo(3, 17),
|
|
},
|
|
// Outside the window asked for below.
|
|
{
|
|
userId: USER,
|
|
type: "ACTIVITY_STEPS",
|
|
value: 777,
|
|
unit: "steps",
|
|
source: "APPLE_HEALTH",
|
|
measuredAt: daysAgo(40),
|
|
},
|
|
],
|
|
});
|
|
|
|
const start = daysAgo(3, 7);
|
|
await prisma.workout.createMany({
|
|
data: [
|
|
{
|
|
userId: USER,
|
|
sportType: "running",
|
|
startedAt: start,
|
|
endedAt: new Date(start.getTime() + 40 * 60_000),
|
|
durationSec: 2400,
|
|
source: "APPLE_HEALTH",
|
|
externalId: "hk-1",
|
|
},
|
|
{
|
|
userId: USER,
|
|
sportType: "running",
|
|
startedAt: new Date(start.getTime() + 30_000),
|
|
endedAt: new Date(start.getTime() + 40 * 60_000),
|
|
durationSec: 2370,
|
|
source: "FITBIT",
|
|
externalId: "fb-1",
|
|
},
|
|
],
|
|
});
|
|
});
|
|
|
|
describe("readActivityDays", () => {
|
|
it("follows the ladder, the local day and the workout collapse", async () => {
|
|
const fromKey = userDayKey(daysAgo(10), TZ);
|
|
const toKey = userDayKey(NOW, TZ);
|
|
const { days } = await readActivityDays({
|
|
userId: USER,
|
|
tz: TZ,
|
|
fromKey,
|
|
toKey,
|
|
includeWorkouts: true,
|
|
});
|
|
const by = new Map(days.map((d) => [d.date, d]));
|
|
|
|
const d3 = by.get(userDayKey(daysAgo(3), TZ));
|
|
expect(d3?.steps).toBe(8000);
|
|
expect(d3?.exerciseMinutes).toBe(40);
|
|
expect(d3?.workoutCount).toBe(1);
|
|
expect(d3?.workoutsBySport).toEqual({ running: 1 });
|
|
expect(d3?.workoutMinutes).toBe(40);
|
|
|
|
expect(by.get(userDayKey(daysAgo(2), TZ))?.steps).toBe(7000);
|
|
expect(by.get(userDayKey(daysAgo(5, 21), TZ))?.steps).toBe(1200);
|
|
expect(days.every((d) => d.date >= fromKey && d.date <= toKey)).toBe(true);
|
|
expect(days.some((d) => d.steps === 777)).toBe(false);
|
|
});
|
|
});
|