mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-14 06:40:50 +00:00
chore(release): v1.4.30
Server-side prep for the next iOS TestFlight build. Five surfaces land together so the iOS engineer can pick them up in one cut-over: a locked externalId shape for daily-aggregated cumulative HealthKit rows, the SyncMode foundation columns + handshake + bulk-backfill endpoints, a first-class MoodEntry.note column that replaces the legacy tag-based workaround, a cross-source workout dedup helper, and two new MeasurementType enums plus the shared category overlay that drives the iOS permission picker and the future Insights nav.
This commit is contained in:
1 parent
945b556664
commit
3ece95f192
49 files changed
+4744
-32
No files matched your search
@@ -0,0 +1,188 @@
|
||||
/**
|
||||
* v1.4.30 — `POST /api/admin/drain-per-sample-cumulative` real-
|
||||
* Postgres integration. The library-level helper rides
|
||||
* `tests/integration/drain-per-sample-cumulative.test.ts`; this
|
||||
* suite pins the route's admin gate + payload shape + audit log
|
||||
* trail end-to-end.
|
||||
*/
|
||||
import { NextRequest } from "next/server";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { cookieJar } from "./mock-next-headers";
|
||||
import { getPrismaClient, truncateAllTables } from "./setup";
|
||||
|
||||
const ADMIN_USER_ID = "user-admin-drain";
|
||||
const STANDARD_USER_ID = "user-standard-drain";
|
||||
|
||||
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),
|
||||
}));
|
||||
|
||||
beforeEach(async () => {
|
||||
await truncateAllTables(getPrismaClient());
|
||||
cookieJar.clear();
|
||||
await getPrismaClient().user.createMany({
|
||||
data: [
|
||||
{
|
||||
id: ADMIN_USER_ID,
|
||||
username: "admin-drain",
|
||||
email: "admin-drain@example.test",
|
||||
role: "ADMIN",
|
||||
timezone: "Europe/Berlin",
|
||||
},
|
||||
{
|
||||
id: STANDARD_USER_ID,
|
||||
username: "standard-drain",
|
||||
email: "standard-drain@example.test",
|
||||
role: "USER",
|
||||
timezone: "Europe/Berlin",
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
async function setSessionFor(userId: string): Promise<void> {
|
||||
const session = await getPrismaClient().session.create({
|
||||
data: {
|
||||
userId,
|
||||
expiresAt: new Date(Date.now() + 60 * 60 * 1000),
|
||||
},
|
||||
});
|
||||
cookieJar.clear();
|
||||
cookieJar.set("healthlog_session", session.id);
|
||||
}
|
||||
|
||||
function makeRequest(body: unknown): NextRequest {
|
||||
return new NextRequest(
|
||||
"http://localhost/api/admin/drain-per-sample-cumulative",
|
||||
{
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
describe("POST /api/admin/drain-per-sample-cumulative (real Postgres)", () => {
|
||||
it("returns 403 when the session belongs to a standard (non-admin) user", async () => {
|
||||
await setSessionFor(STANDARD_USER_ID);
|
||||
const { POST } = await import(
|
||||
"@/app/api/admin/drain-per-sample-cumulative/route"
|
||||
);
|
||||
const res = await POST(makeRequest({ dryRun: true }));
|
||||
expect(res.status).toBe(403);
|
||||
});
|
||||
|
||||
it("dry-runs by default and reports buckets without writing", async () => {
|
||||
const prisma = getPrismaClient();
|
||||
await prisma.measurement.createMany({
|
||||
data: [
|
||||
{
|
||||
userId: STANDARD_USER_ID,
|
||||
type: "ACTIVITY_STEPS",
|
||||
value: 1200,
|
||||
unit: "steps",
|
||||
source: "APPLE_HEALTH",
|
||||
measuredAt: new Date("2026-05-16T08:00:00.000Z"),
|
||||
externalId: "hk-uuid-1",
|
||||
},
|
||||
{
|
||||
userId: STANDARD_USER_ID,
|
||||
type: "ACTIVITY_STEPS",
|
||||
value: 800,
|
||||
unit: "steps",
|
||||
source: "APPLE_HEALTH",
|
||||
measuredAt: new Date("2026-05-16T14:00:00.000Z"),
|
||||
externalId: "hk-uuid-2",
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
await setSessionFor(ADMIN_USER_ID);
|
||||
const { POST } = await import(
|
||||
"@/app/api/admin/drain-per-sample-cumulative/route"
|
||||
);
|
||||
// No body = dryRun defaults to true.
|
||||
const res = await POST(makeRequest({ userId: STANDARD_USER_ID }));
|
||||
expect(res.status).toBe(200);
|
||||
const json = (await res.json()) as {
|
||||
data: {
|
||||
dryRun: boolean;
|
||||
totals: { bucketsCollapsed: number };
|
||||
};
|
||||
};
|
||||
expect(json.data.dryRun).toBe(true);
|
||||
expect(json.data.totals.bucketsCollapsed).toBe(1);
|
||||
|
||||
// DB unchanged.
|
||||
const remaining = await prisma.measurement.findMany({
|
||||
where: { userId: STANDARD_USER_ID, type: "ACTIVITY_STEPS" },
|
||||
});
|
||||
expect(remaining).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("commits when dryRun=false is passed explicitly", async () => {
|
||||
const prisma = getPrismaClient();
|
||||
await prisma.measurement.createMany({
|
||||
data: [
|
||||
{
|
||||
userId: STANDARD_USER_ID,
|
||||
type: "ACTIVITY_STEPS",
|
||||
value: 1200,
|
||||
unit: "steps",
|
||||
source: "APPLE_HEALTH",
|
||||
measuredAt: new Date("2026-05-16T08:00:00.000Z"),
|
||||
externalId: "hk-uuid-1",
|
||||
},
|
||||
{
|
||||
userId: STANDARD_USER_ID,
|
||||
type: "ACTIVITY_STEPS",
|
||||
value: 800,
|
||||
unit: "steps",
|
||||
source: "APPLE_HEALTH",
|
||||
measuredAt: new Date("2026-05-16T14:00:00.000Z"),
|
||||
externalId: "hk-uuid-2",
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
await setSessionFor(ADMIN_USER_ID);
|
||||
const { POST } = await import(
|
||||
"@/app/api/admin/drain-per-sample-cumulative/route"
|
||||
);
|
||||
const res = await POST(
|
||||
makeRequest({ userId: STANDARD_USER_ID, dryRun: false }),
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
|
||||
const remaining = await prisma.measurement.findMany({
|
||||
where: { userId: STANDARD_USER_ID, type: "ACTIVITY_STEPS" },
|
||||
});
|
||||
expect(remaining).toHaveLength(1);
|
||||
expect(remaining[0].value).toBe(2000);
|
||||
expect(remaining[0].externalId).toBe(
|
||||
"stats:HKQuantityTypeIdentifierStepCount:2026-05-16",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,249 @@
|
||||
/**
|
||||
* v1.4.30 — real-Postgres integration coverage for
|
||||
* `drainPerSampleCumulative()`. Mirrors the
|
||||
* `measurements-aggregate-daily` fixture: the testcontainer is hot
|
||||
* before this file loads, so the body just truncates + seeds + drains.
|
||||
*
|
||||
* The drain logic operates against `prisma.$transaction` with an upsert
|
||||
* inside; mocking that surface would re-introduce the v1.4.29 lesson
|
||||
* (mocks hide real-SQL behaviour). The container test pins the
|
||||
* idempotent-re-run contract end-to-end.
|
||||
*/
|
||||
import { beforeEach, describe, expect, it } from "vitest";
|
||||
|
||||
import { getPrismaClient, truncateAllTables } from "./setup";
|
||||
import { drainPerSampleCumulative } from "@/lib/measurements/drain-per-sample-cumulative";
|
||||
|
||||
const TEST_USER_ID = "user-drain-cumulative";
|
||||
|
||||
beforeEach(async () => {
|
||||
await truncateAllTables(getPrismaClient());
|
||||
await getPrismaClient().user.create({
|
||||
data: {
|
||||
id: TEST_USER_ID,
|
||||
username: "drain-cumulative",
|
||||
email: "drain-cumulative@example.test",
|
||||
timezone: "Europe/Berlin",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
describe("drainPerSampleCumulative (real Postgres)", () => {
|
||||
it("collapses three per-sample step rows on the same day into one daily row", async () => {
|
||||
const prisma = getPrismaClient();
|
||||
await prisma.measurement.createMany({
|
||||
data: [
|
||||
{
|
||||
userId: TEST_USER_ID,
|
||||
type: "ACTIVITY_STEPS",
|
||||
value: 1200,
|
||||
unit: "steps",
|
||||
source: "APPLE_HEALTH",
|
||||
measuredAt: new Date("2026-05-16T08:00:00.000Z"),
|
||||
externalId: "hk-uuid-1",
|
||||
},
|
||||
{
|
||||
userId: TEST_USER_ID,
|
||||
type: "ACTIVITY_STEPS",
|
||||
value: 3400,
|
||||
unit: "steps",
|
||||
source: "APPLE_HEALTH",
|
||||
measuredAt: new Date("2026-05-16T14:00:00.000Z"),
|
||||
externalId: "hk-uuid-2",
|
||||
},
|
||||
{
|
||||
userId: TEST_USER_ID,
|
||||
type: "ACTIVITY_STEPS",
|
||||
value: 800,
|
||||
unit: "steps",
|
||||
source: "APPLE_HEALTH",
|
||||
measuredAt: new Date("2026-05-16T20:00:00.000Z"),
|
||||
externalId: "hk-uuid-3",
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const summary = await drainPerSampleCumulative(prisma, {
|
||||
userId: TEST_USER_ID,
|
||||
dryRun: false,
|
||||
log: () => {},
|
||||
});
|
||||
|
||||
expect(summary.dryRun).toBe(false);
|
||||
expect(summary.totals.bucketsCollapsed).toBe(1);
|
||||
expect(summary.totals.perSampleRowsDeleted).toBe(3);
|
||||
expect(summary.totals.dailyRowsUpserted).toBe(1);
|
||||
|
||||
const remaining = await prisma.measurement.findMany({
|
||||
where: {
|
||||
userId: TEST_USER_ID,
|
||||
type: "ACTIVITY_STEPS",
|
||||
source: "APPLE_HEALTH",
|
||||
},
|
||||
orderBy: { measuredAt: "asc" },
|
||||
});
|
||||
|
||||
expect(remaining).toHaveLength(1);
|
||||
expect(remaining[0].value).toBe(5400);
|
||||
expect(remaining[0].externalId).toBe(
|
||||
"stats:HKQuantityTypeIdentifierStepCount:2026-05-16",
|
||||
);
|
||||
// 12:00 Berlin during DST = 10:00 UTC.
|
||||
expect(remaining[0].measuredAt.toISOString()).toBe(
|
||||
"2026-05-16T10:00:00.000Z",
|
||||
);
|
||||
});
|
||||
|
||||
it("is idempotent — a second run after a successful drain is a no-op", async () => {
|
||||
const prisma = getPrismaClient();
|
||||
await prisma.measurement.createMany({
|
||||
data: [
|
||||
{
|
||||
userId: TEST_USER_ID,
|
||||
type: "ACTIVITY_STEPS",
|
||||
value: 1200,
|
||||
unit: "steps",
|
||||
source: "APPLE_HEALTH",
|
||||
measuredAt: new Date("2026-05-16T08:00:00.000Z"),
|
||||
externalId: "hk-uuid-1",
|
||||
},
|
||||
{
|
||||
userId: TEST_USER_ID,
|
||||
type: "ACTIVITY_STEPS",
|
||||
value: 800,
|
||||
unit: "steps",
|
||||
source: "APPLE_HEALTH",
|
||||
measuredAt: new Date("2026-05-16T14:00:00.000Z"),
|
||||
externalId: "hk-uuid-2",
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
await drainPerSampleCumulative(prisma, {
|
||||
userId: TEST_USER_ID,
|
||||
dryRun: false,
|
||||
log: () => {},
|
||||
});
|
||||
const second = await drainPerSampleCumulative(prisma, {
|
||||
userId: TEST_USER_ID,
|
||||
dryRun: false,
|
||||
log: () => {},
|
||||
});
|
||||
|
||||
expect(second.totals.bucketsCollapsed).toBe(0);
|
||||
expect(second.totals.perSampleRowsDeleted).toBe(0);
|
||||
expect(second.totals.dailyRowsUpserted).toBe(0);
|
||||
});
|
||||
|
||||
it("dry-run reports the plan without touching the DB", async () => {
|
||||
const prisma = getPrismaClient();
|
||||
await prisma.measurement.createMany({
|
||||
data: [
|
||||
{
|
||||
userId: TEST_USER_ID,
|
||||
type: "FLIGHTS_CLIMBED",
|
||||
value: 5,
|
||||
unit: "flights",
|
||||
source: "APPLE_HEALTH",
|
||||
measuredAt: new Date("2026-05-16T08:00:00.000Z"),
|
||||
externalId: "hk-flights-1",
|
||||
},
|
||||
{
|
||||
userId: TEST_USER_ID,
|
||||
type: "FLIGHTS_CLIMBED",
|
||||
value: 3,
|
||||
unit: "flights",
|
||||
source: "APPLE_HEALTH",
|
||||
measuredAt: new Date("2026-05-16T14:00:00.000Z"),
|
||||
externalId: "hk-flights-2",
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const summary = await drainPerSampleCumulative(prisma, {
|
||||
userId: TEST_USER_ID,
|
||||
dryRun: true,
|
||||
log: () => {},
|
||||
});
|
||||
|
||||
expect(summary.dryRun).toBe(true);
|
||||
expect(summary.totals.bucketsCollapsed).toBe(1);
|
||||
expect(summary.buckets[0].sumValue).toBe(8);
|
||||
expect(summary.buckets[0].externalId).toBe(
|
||||
"stats:HKQuantityTypeIdentifierFlightsClimbed:2026-05-16",
|
||||
);
|
||||
|
||||
// DB unchanged.
|
||||
const remaining = await prisma.measurement.findMany({
|
||||
where: { userId: TEST_USER_ID, type: "FLIGHTS_CLIMBED" },
|
||||
});
|
||||
expect(remaining).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("ignores spot metrics (PULSE) — only cumulative types collapse", async () => {
|
||||
const prisma = getPrismaClient();
|
||||
await prisma.measurement.createMany({
|
||||
data: [
|
||||
{
|
||||
userId: TEST_USER_ID,
|
||||
type: "PULSE",
|
||||
value: 64,
|
||||
unit: "bpm",
|
||||
source: "APPLE_HEALTH",
|
||||
measuredAt: new Date("2026-05-16T08:00:00.000Z"),
|
||||
externalId: "hk-pulse-1",
|
||||
},
|
||||
{
|
||||
userId: TEST_USER_ID,
|
||||
type: "PULSE",
|
||||
value: 72,
|
||||
unit: "bpm",
|
||||
source: "APPLE_HEALTH",
|
||||
measuredAt: new Date("2026-05-16T14:00:00.000Z"),
|
||||
externalId: "hk-pulse-2",
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const summary = await drainPerSampleCumulative(prisma, {
|
||||
userId: TEST_USER_ID,
|
||||
dryRun: false,
|
||||
log: () => {},
|
||||
});
|
||||
|
||||
expect(summary.totals.bucketsCollapsed).toBe(0);
|
||||
|
||||
const remaining = await prisma.measurement.findMany({
|
||||
where: { userId: TEST_USER_ID, type: "PULSE" },
|
||||
});
|
||||
expect(remaining).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("skips rows already in stats:... shape (re-running after an earlier drain)", async () => {
|
||||
const prisma = getPrismaClient();
|
||||
await prisma.measurement.create({
|
||||
data: {
|
||||
userId: TEST_USER_ID,
|
||||
type: "ACTIVITY_STEPS",
|
||||
value: 5400,
|
||||
unit: "steps",
|
||||
source: "APPLE_HEALTH",
|
||||
measuredAt: new Date("2026-05-16T10:00:00.000Z"),
|
||||
externalId: "stats:HKQuantityTypeIdentifierStepCount:2026-05-16",
|
||||
},
|
||||
});
|
||||
|
||||
const summary = await drainPerSampleCumulative(prisma, {
|
||||
userId: TEST_USER_ID,
|
||||
dryRun: false,
|
||||
log: () => {},
|
||||
});
|
||||
|
||||
expect(summary.totals.bucketsCollapsed).toBe(0);
|
||||
const remaining = await prisma.measurement.findMany({
|
||||
where: { userId: TEST_USER_ID, type: "ACTIVITY_STEPS" },
|
||||
});
|
||||
expect(remaining).toHaveLength(1);
|
||||
expect(remaining[0].value).toBe(5400);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,187 @@
|
||||
/**
|
||||
* v1.4.30 — `POST /api/medications/intake/bulk` real-Postgres
|
||||
* integration.
|
||||
*
|
||||
* Asserts the iOS SyncMode bulk-backfill contract:
|
||||
* - inserts a clean batch
|
||||
* - skips entries that point at a medication the user doesn't own
|
||||
* - returns `duplicate` when an idempotencyKey is re-used
|
||||
*/
|
||||
import { NextRequest } from "next/server";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { cookieJar } from "./mock-next-headers";
|
||||
import { getPrismaClient, truncateAllTables } from "./setup";
|
||||
|
||||
const TEST_USER_ID = "user-medications-intake-bulk";
|
||||
const OTHER_USER_ID = "user-medications-intake-other";
|
||||
|
||||
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),
|
||||
}));
|
||||
|
||||
let ownedMedId = "";
|
||||
let foreignMedId = "";
|
||||
|
||||
beforeEach(async () => {
|
||||
await truncateAllTables(getPrismaClient());
|
||||
cookieJar.clear();
|
||||
const prisma = getPrismaClient();
|
||||
await prisma.user.createMany({
|
||||
data: [
|
||||
{
|
||||
id: TEST_USER_ID,
|
||||
username: "intake-bulk",
|
||||
email: "intake-bulk@example.test",
|
||||
timezone: "Europe/Berlin",
|
||||
},
|
||||
{
|
||||
id: OTHER_USER_ID,
|
||||
username: "intake-other",
|
||||
email: "intake-other@example.test",
|
||||
timezone: "Europe/Berlin",
|
||||
},
|
||||
],
|
||||
});
|
||||
const owned = await prisma.medication.create({
|
||||
data: {
|
||||
userId: TEST_USER_ID,
|
||||
name: "Mounjaro",
|
||||
dose: "5mg",
|
||||
active: true,
|
||||
},
|
||||
});
|
||||
ownedMedId = owned.id;
|
||||
const foreign = await prisma.medication.create({
|
||||
data: {
|
||||
userId: OTHER_USER_ID,
|
||||
name: "Levothyroxin",
|
||||
dose: "50µg",
|
||||
active: true,
|
||||
},
|
||||
});
|
||||
foreignMedId = foreign.id;
|
||||
|
||||
const session = await prisma.session.create({
|
||||
data: {
|
||||
userId: TEST_USER_ID,
|
||||
expiresAt: new Date(Date.now() + 60 * 60 * 1000),
|
||||
},
|
||||
});
|
||||
cookieJar.set("healthlog_session", session.id);
|
||||
});
|
||||
|
||||
function makeRequest(body: unknown): NextRequest {
|
||||
return new NextRequest("http://localhost/api/medications/intake/bulk", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
}
|
||||
|
||||
describe("POST /api/medications/intake/bulk (real Postgres)", () => {
|
||||
it("inserts a clean batch", async () => {
|
||||
const { POST } = await import("@/app/api/medications/intake/bulk/route");
|
||||
const res = await POST(
|
||||
makeRequest({
|
||||
entries: [
|
||||
{
|
||||
medicationId: ownedMedId,
|
||||
scheduledFor: "2026-05-16T08:00:00.000Z",
|
||||
takenAt: "2026-05-16T08:02:00.000Z",
|
||||
},
|
||||
{
|
||||
medicationId: ownedMedId,
|
||||
scheduledFor: "2026-05-17T08:00:00.000Z",
|
||||
skipped: true,
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
const json = (await res.json()) as {
|
||||
data: { processed: number; inserted: number; duplicates: number };
|
||||
};
|
||||
expect(json.data.processed).toBe(2);
|
||||
expect(json.data.inserted).toBe(2);
|
||||
|
||||
const stored = await getPrismaClient().medicationIntakeEvent.findMany({
|
||||
where: { userId: TEST_USER_ID },
|
||||
});
|
||||
expect(stored).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("skips entries that reference a medication the user doesn't own", async () => {
|
||||
const { POST } = await import("@/app/api/medications/intake/bulk/route");
|
||||
const res = await POST(
|
||||
makeRequest({
|
||||
entries: [
|
||||
{
|
||||
medicationId: ownedMedId,
|
||||
scheduledFor: "2026-05-16T08:00:00.000Z",
|
||||
},
|
||||
{
|
||||
medicationId: foreignMedId,
|
||||
scheduledFor: "2026-05-16T08:00:00.000Z",
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
const json = (await res.json()) as {
|
||||
data: {
|
||||
inserted: number;
|
||||
skipped: Array<{ index: number; reason: string }>;
|
||||
};
|
||||
};
|
||||
expect(json.data.inserted).toBe(1);
|
||||
expect(json.data.skipped).toEqual([
|
||||
{ index: 1, reason: "medication_not_found" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("returns `duplicate` when an idempotencyKey is re-used", async () => {
|
||||
const { POST } = await import("@/app/api/medications/intake/bulk/route");
|
||||
const body = {
|
||||
entries: [
|
||||
{
|
||||
medicationId: ownedMedId,
|
||||
scheduledFor: "2026-05-16T08:00:00.000Z",
|
||||
idempotencyKey: "ios-sync-key-001",
|
||||
},
|
||||
],
|
||||
};
|
||||
await POST(makeRequest(body));
|
||||
const res = await POST(makeRequest(body));
|
||||
const json = (await res.json()) as {
|
||||
data: { duplicates: number; inserted: number };
|
||||
};
|
||||
expect(json.data.duplicates).toBe(1);
|
||||
expect(json.data.inserted).toBe(0);
|
||||
|
||||
const stored = await getPrismaClient().medicationIntakeEvent.findMany({
|
||||
where: { userId: TEST_USER_ID },
|
||||
});
|
||||
expect(stored).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,170 @@
|
||||
/**
|
||||
* v1.4.30 — `POST /api/mood-entries/bulk` real-Postgres integration.
|
||||
*
|
||||
* Asserts the iOS SyncMode bulk-backfill contract:
|
||||
* - inserts a clean batch
|
||||
* - upserts on re-post (last-writer-wins on mood / tags)
|
||||
* - rejects oversize batches
|
||||
*/
|
||||
import { NextRequest } from "next/server";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { cookieJar } from "./mock-next-headers";
|
||||
import { getPrismaClient, truncateAllTables } from "./setup";
|
||||
|
||||
const TEST_USER_ID = "user-mood-bulk";
|
||||
|
||||
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),
|
||||
}));
|
||||
|
||||
beforeEach(async () => {
|
||||
await truncateAllTables(getPrismaClient());
|
||||
cookieJar.clear();
|
||||
await getPrismaClient().user.create({
|
||||
data: {
|
||||
id: TEST_USER_ID,
|
||||
username: "mood-bulk",
|
||||
email: "mood-bulk@example.test",
|
||||
timezone: "Europe/Berlin",
|
||||
},
|
||||
});
|
||||
const session = await getPrismaClient().session.create({
|
||||
data: {
|
||||
userId: TEST_USER_ID,
|
||||
expiresAt: new Date(Date.now() + 60 * 60 * 1000),
|
||||
},
|
||||
});
|
||||
cookieJar.set("healthlog_session", session.id);
|
||||
});
|
||||
|
||||
function makeRequest(body: unknown): NextRequest {
|
||||
return new NextRequest("http://localhost/api/mood-entries/bulk", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
}
|
||||
|
||||
describe("POST /api/mood-entries/bulk (real Postgres)", () => {
|
||||
it("inserts a clean batch of three entries", async () => {
|
||||
const { POST } = await import("@/app/api/mood-entries/bulk/route");
|
||||
const res = await POST(
|
||||
makeRequest({
|
||||
entries: [
|
||||
{
|
||||
mood: "GUT",
|
||||
moodLoggedAt: "2026-05-16T08:00:00.000Z",
|
||||
},
|
||||
{
|
||||
mood: "OKAY",
|
||||
moodLoggedAt: "2026-05-16T14:00:00.000Z",
|
||||
tags: ["coffee"],
|
||||
},
|
||||
{
|
||||
mood: "SUPER_GUT",
|
||||
moodLoggedAt: "2026-05-16T20:00:00.000Z",
|
||||
source: "DAYLIO",
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
const json = (await res.json()) as {
|
||||
data: {
|
||||
processed: number;
|
||||
inserted: number;
|
||||
duplicates: number;
|
||||
entries: Array<{ status: string }>;
|
||||
};
|
||||
};
|
||||
expect(json.data.processed).toBe(3);
|
||||
expect(json.data.inserted).toBe(3);
|
||||
expect(json.data.duplicates).toBe(0);
|
||||
|
||||
const stored = await getPrismaClient().moodEntry.findMany({
|
||||
where: { userId: TEST_USER_ID },
|
||||
orderBy: { moodLoggedAt: "asc" },
|
||||
});
|
||||
expect(stored).toHaveLength(3);
|
||||
expect(stored[0].mood).toBe("GUT");
|
||||
expect(stored[0].score).toBe(4);
|
||||
expect(stored[0].tz).toBe("Europe/Berlin");
|
||||
});
|
||||
|
||||
it("upserts when the same (userId, date, moodLoggedAt) tuple is re-posted", async () => {
|
||||
const { POST } = await import("@/app/api/mood-entries/bulk/route");
|
||||
const body = {
|
||||
entries: [
|
||||
{
|
||||
mood: "OKAY",
|
||||
moodLoggedAt: "2026-05-16T08:00:00.000Z",
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
await POST(makeRequest(body));
|
||||
const res = await POST(
|
||||
makeRequest({
|
||||
entries: [
|
||||
{
|
||||
// Same timestamp + user but a different mood.
|
||||
mood: "SUPER_GUT",
|
||||
moodLoggedAt: "2026-05-16T08:00:00.000Z",
|
||||
tags: ["workout"],
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
|
||||
const json = (await res.json()) as {
|
||||
data: { duplicates: number; inserted: number };
|
||||
};
|
||||
expect(json.data.duplicates).toBe(1);
|
||||
expect(json.data.inserted).toBe(0);
|
||||
|
||||
const stored = await getPrismaClient().moodEntry.findMany({
|
||||
where: { userId: TEST_USER_ID },
|
||||
});
|
||||
expect(stored).toHaveLength(1);
|
||||
expect(stored[0].mood).toBe("SUPER_GUT");
|
||||
expect(stored[0].score).toBe(5);
|
||||
});
|
||||
|
||||
it("rejects an over-cap batch with 422 and the documented error code", async () => {
|
||||
const { POST } = await import("@/app/api/mood-entries/bulk/route");
|
||||
const entries = Array.from({ length: 501 }, (_, i) => ({
|
||||
mood: "OKAY",
|
||||
moodLoggedAt: new Date(2026, 4, 16, 8, i % 60).toISOString(),
|
||||
}));
|
||||
const res = await POST(makeRequest({ entries }));
|
||||
expect(res.status).toBe(422);
|
||||
const json = (await res.json()) as {
|
||||
error: string;
|
||||
meta?: { errorCode?: string };
|
||||
};
|
||||
expect(json.meta?.errorCode).toBe("mood.bulk.too_large");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,169 @@
|
||||
/**
|
||||
* v1.4.30 — `MoodEntry.note` column round-trip integration.
|
||||
*
|
||||
* Asserts:
|
||||
* - POST /api/mood-entries accepts the new `note` field
|
||||
* - PUT /api/mood-entries/[id] updates `note` (and accepts null to clear)
|
||||
* - POST /api/mood-entries/bulk persists `note` per entry
|
||||
* - The Zod cap (500 chars) rejects oversize prose
|
||||
*/
|
||||
import { NextRequest } from "next/server";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { cookieJar } from "./mock-next-headers";
|
||||
import { getPrismaClient, truncateAllTables } from "./setup";
|
||||
|
||||
const TEST_USER_ID = "user-mood-note";
|
||||
|
||||
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),
|
||||
}));
|
||||
|
||||
beforeEach(async () => {
|
||||
await truncateAllTables(getPrismaClient());
|
||||
cookieJar.clear();
|
||||
await getPrismaClient().user.create({
|
||||
data: {
|
||||
id: TEST_USER_ID,
|
||||
username: "mood-note",
|
||||
email: "mood-note@example.test",
|
||||
timezone: "Europe/Berlin",
|
||||
},
|
||||
});
|
||||
const session = await getPrismaClient().session.create({
|
||||
data: {
|
||||
userId: TEST_USER_ID,
|
||||
expiresAt: new Date(Date.now() + 60 * 60 * 1000),
|
||||
},
|
||||
});
|
||||
cookieJar.set("healthlog_session", session.id);
|
||||
});
|
||||
|
||||
function postRequest(path: string, body: unknown): NextRequest {
|
||||
return new NextRequest(`http://localhost${path}`, {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
}
|
||||
|
||||
function putRequest(path: string, body: unknown): NextRequest {
|
||||
return new NextRequest(`http://localhost${path}`, {
|
||||
method: "PUT",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
}
|
||||
|
||||
describe("MoodEntry.note round-trip (real Postgres)", () => {
|
||||
it("POST /api/mood-entries persists `note`", async () => {
|
||||
const { POST } = await import("@/app/api/mood-entries/route");
|
||||
const res = await POST(
|
||||
postRequest("/api/mood-entries", {
|
||||
mood: "GUT",
|
||||
moodLoggedAt: "2026-05-16T08:00:00.000Z",
|
||||
note: "Long run before breakfast.",
|
||||
}),
|
||||
);
|
||||
expect(res.status).toBe(201);
|
||||
const stored = await getPrismaClient().moodEntry.findFirst({
|
||||
where: { userId: TEST_USER_ID },
|
||||
});
|
||||
expect(stored?.note).toBe("Long run before breakfast.");
|
||||
});
|
||||
|
||||
it("PUT /api/mood-entries/[id] updates `note` and accepts null to clear it", async () => {
|
||||
const created = await getPrismaClient().moodEntry.create({
|
||||
data: {
|
||||
userId: TEST_USER_ID,
|
||||
date: "2026-05-16",
|
||||
tz: "Europe/Berlin",
|
||||
mood: "OKAY",
|
||||
score: 3,
|
||||
moodLoggedAt: new Date("2026-05-16T08:00:00.000Z"),
|
||||
note: "initial note",
|
||||
},
|
||||
});
|
||||
const { PUT } = await import("@/app/api/mood-entries/[id]/route");
|
||||
|
||||
const res1 = await PUT(
|
||||
putRequest(`/api/mood-entries/${created.id}`, { note: "revised note" }),
|
||||
{ params: Promise.resolve({ id: created.id }) },
|
||||
);
|
||||
expect(res1.status).toBe(200);
|
||||
const after1 = await getPrismaClient().moodEntry.findUnique({
|
||||
where: { id: created.id },
|
||||
});
|
||||
expect(after1?.note).toBe("revised note");
|
||||
|
||||
const res2 = await PUT(
|
||||
putRequest(`/api/mood-entries/${created.id}`, { note: null }),
|
||||
{ params: Promise.resolve({ id: created.id }) },
|
||||
);
|
||||
expect(res2.status).toBe(200);
|
||||
const after2 = await getPrismaClient().moodEntry.findUnique({
|
||||
where: { id: created.id },
|
||||
});
|
||||
expect(after2?.note).toBeNull();
|
||||
});
|
||||
|
||||
it("POST /api/mood-entries/bulk persists `note` per entry", async () => {
|
||||
const { POST } = await import("@/app/api/mood-entries/bulk/route");
|
||||
const res = await POST(
|
||||
postRequest("/api/mood-entries/bulk", {
|
||||
entries: [
|
||||
{
|
||||
mood: "GUT",
|
||||
moodLoggedAt: "2026-05-16T08:00:00.000Z",
|
||||
note: "Morning",
|
||||
},
|
||||
{
|
||||
mood: "OKAY",
|
||||
moodLoggedAt: "2026-05-16T14:00:00.000Z",
|
||||
note: "Afternoon dip",
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
const stored = await getPrismaClient().moodEntry.findMany({
|
||||
where: { userId: TEST_USER_ID },
|
||||
orderBy: { moodLoggedAt: "asc" },
|
||||
});
|
||||
expect(stored.map((r) => r.note)).toEqual(["Morning", "Afternoon dip"]);
|
||||
});
|
||||
|
||||
it("rejects an oversize note (501 chars) with 422", async () => {
|
||||
const { POST } = await import("@/app/api/mood-entries/route");
|
||||
const oversize = "x".repeat(501);
|
||||
const res = await POST(
|
||||
postRequest("/api/mood-entries", {
|
||||
mood: "OKAY",
|
||||
moodLoggedAt: "2026-05-16T08:00:00.000Z",
|
||||
note: oversize,
|
||||
}),
|
||||
);
|
||||
expect(res.status).toBe(422);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,137 @@
|
||||
/**
|
||||
* v1.4.30 — `GET /api/sync/state` real-Postgres integration.
|
||||
*
|
||||
* The endpoint is the iOS SyncMode handshake. Asserts:
|
||||
* - first call returns `lastSyncedAt: null` then writes the
|
||||
* checkpoint
|
||||
* - subsequent call sees the previous checkpoint
|
||||
* - measurement counters distinguish live vs tombstoned rows
|
||||
*/
|
||||
import { NextRequest } from "next/server";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { cookieJar } from "./mock-next-headers";
|
||||
import { getPrismaClient, truncateAllTables } from "./setup";
|
||||
|
||||
const TEST_USER_ID = "user-sync-state";
|
||||
|
||||
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),
|
||||
}));
|
||||
|
||||
beforeEach(async () => {
|
||||
await truncateAllTables(getPrismaClient());
|
||||
cookieJar.clear();
|
||||
await getPrismaClient().user.create({
|
||||
data: {
|
||||
id: TEST_USER_ID,
|
||||
username: "sync-state",
|
||||
email: "sync-state@example.test",
|
||||
timezone: "Europe/Berlin",
|
||||
},
|
||||
});
|
||||
const session = await getPrismaClient().session.create({
|
||||
data: {
|
||||
userId: TEST_USER_ID,
|
||||
expiresAt: new Date(Date.now() + 60 * 60 * 1000),
|
||||
},
|
||||
});
|
||||
cookieJar.set("healthlog_session", session.id);
|
||||
});
|
||||
|
||||
function makeRequest(): NextRequest {
|
||||
return new NextRequest("http://localhost/api/sync/state", { method: "GET" });
|
||||
}
|
||||
|
||||
describe("GET /api/sync/state (real Postgres)", () => {
|
||||
it("returns lastSyncedAt: null on the first call and writes the checkpoint", async () => {
|
||||
const { GET } = await import("@/app/api/sync/state/route");
|
||||
const res = await GET(makeRequest());
|
||||
expect(res.status).toBe(200);
|
||||
const json = (await res.json()) as {
|
||||
data: {
|
||||
userId: string;
|
||||
timezone: string;
|
||||
lastSyncedAt: string | null;
|
||||
serverNow: string;
|
||||
measurements: { lastUpdatedAt: string | null; liveCount: number; tombstonedCount: number };
|
||||
};
|
||||
};
|
||||
expect(json.data.userId).toBe(TEST_USER_ID);
|
||||
expect(json.data.timezone).toBe("Europe/Berlin");
|
||||
expect(json.data.lastSyncedAt).toBeNull();
|
||||
expect(json.data.measurements.liveCount).toBe(0);
|
||||
expect(json.data.measurements.tombstonedCount).toBe(0);
|
||||
|
||||
// Server-side, lastSyncedAt now carries a value.
|
||||
const after = await getPrismaClient().user.findUnique({
|
||||
where: { id: TEST_USER_ID },
|
||||
select: { lastSyncedAt: true },
|
||||
});
|
||||
expect(after?.lastSyncedAt).not.toBeNull();
|
||||
});
|
||||
|
||||
it("returns the previous checkpoint on the second call", async () => {
|
||||
const { GET } = await import("@/app/api/sync/state/route");
|
||||
await GET(makeRequest());
|
||||
const res = await GET(makeRequest());
|
||||
const json = (await res.json()) as {
|
||||
data: { lastSyncedAt: string | null };
|
||||
};
|
||||
expect(json.data.lastSyncedAt).not.toBeNull();
|
||||
});
|
||||
|
||||
it("distinguishes live vs tombstoned measurement counts", async () => {
|
||||
const prisma = getPrismaClient();
|
||||
await prisma.measurement.createMany({
|
||||
data: [
|
||||
{
|
||||
userId: TEST_USER_ID,
|
||||
type: "PULSE",
|
||||
value: 64,
|
||||
unit: "bpm",
|
||||
source: "MANUAL",
|
||||
measuredAt: new Date("2026-05-16T10:00:00.000Z"),
|
||||
},
|
||||
{
|
||||
userId: TEST_USER_ID,
|
||||
type: "PULSE",
|
||||
value: 70,
|
||||
unit: "bpm",
|
||||
source: "MANUAL",
|
||||
measuredAt: new Date("2026-05-16T11:00:00.000Z"),
|
||||
deletedAt: new Date("2026-05-16T12:00:00.000Z"),
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const { GET } = await import("@/app/api/sync/state/route");
|
||||
const res = await GET(makeRequest());
|
||||
const json = (await res.json()) as {
|
||||
data: { measurements: { liveCount: number; tombstonedCount: number } };
|
||||
};
|
||||
expect(json.data.measurements.liveCount).toBe(1);
|
||||
expect(json.data.measurements.tombstonedCount).toBe(1);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user