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:
Marc-André Bombeck authored and GitHub committed 2026-05-16 12:37:55 +02:00
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);
});
});
+170
View File
@@ -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);
});
});
+137
View File
@@ -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);
});
});