mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 20:42:43 +00:00
The v1.16.13 catalog curation deactivated the default tags happy, excited, hydrated, music and the factor_sleep_quality RATED factor. Integration tests that run the real migrations against testcontainers Postgres still hard-coded the pre-curation catalog and failed. Swap the placeable built-in tag from happy to grateful (a long-standing active feelings tag) and the seeded RATED factor from factor_sleep_quality to factor_sadness (still active, same 1..5 inverse scale). Assertions and intent are unchanged — only the fixtures move onto the live catalog.
295 lines
9.1 KiB
TypeScript
295 lines
9.1 KiB
TypeScript
/**
|
|
* 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("persists structured tagKeys as MoodEntryTagLink rows (v1.12.0)", 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",
|
|
// Two valid catalog keys (one from the hobbies category, one
|
|
// health key) plus one unknown key that must be dropped
|
|
// silently.
|
|
tagKeys: ["movies", "alcohol", "not_a_real_tag"],
|
|
},
|
|
],
|
|
}),
|
|
);
|
|
expect(res.status).toBe(200);
|
|
const json = (await res.json()) as { data: { inserted: number } };
|
|
expect(json.data.inserted).toBe(1);
|
|
|
|
const entry = await getPrismaClient().moodEntry.findFirstOrThrow({
|
|
where: { userId: TEST_USER_ID },
|
|
});
|
|
const links = await getPrismaClient().moodEntryTagLink.findMany({
|
|
where: { moodEntryId: entry.id },
|
|
select: { moodTag: { select: { key: true } } },
|
|
});
|
|
const keys = links.map((l) => l.moodTag.key).sort();
|
|
// The unknown key is dropped; the two catalog keys round-trip.
|
|
expect(keys).toEqual(["alcohol", "movies"]);
|
|
});
|
|
|
|
it("persists a rated factor with its score on the link (v1.12.0)", 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",
|
|
ratedFactors: [
|
|
{ key: "factor_work", rating: 4 },
|
|
{ key: "factor_sadness", rating: 2 },
|
|
],
|
|
},
|
|
],
|
|
}),
|
|
);
|
|
expect(res.status).toBe(200);
|
|
const json = (await res.json()) as { data: { inserted: number } };
|
|
expect(json.data.inserted).toBe(1);
|
|
|
|
const entry = await getPrismaClient().moodEntry.findFirstOrThrow({
|
|
where: { userId: TEST_USER_ID },
|
|
});
|
|
const links = await getPrismaClient().moodEntryTagLink.findMany({
|
|
where: { moodEntryId: entry.id },
|
|
select: { rating: true, moodTag: { select: { key: true, kind: true } } },
|
|
orderBy: { moodTag: { key: "asc" } },
|
|
});
|
|
expect(
|
|
links.map((l) => ({
|
|
key: l.moodTag.key,
|
|
kind: l.moodTag.kind,
|
|
rating: l.rating,
|
|
})),
|
|
).toEqual([
|
|
{ key: "factor_sadness", kind: "RATED", rating: 2 },
|
|
{ key: "factor_work", kind: "RATED", rating: 4 },
|
|
]);
|
|
});
|
|
|
|
it("rejects the batch when a rated factor falls outside the 1..5 envelope (v1.12.0)", async () => {
|
|
// Every seeded RATED factor uses the full 1..5 slider scale, so the
|
|
// outer Zod envelope (1..5) is the binding out-of-range gate — a
|
|
// rating above it fails parse and 422s the whole batch before any
|
|
// row lands. The narrower per-tag-scale path (a factor with a tighter
|
|
// `scaleMin..scaleMax`) is covered by the `resolveRatedFactors` unit
|
|
// tests.
|
|
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",
|
|
// factor_sadness scale is 1..5; rating 9 is out of range.
|
|
ratedFactors: [{ key: "factor_sadness", rating: 9 }],
|
|
},
|
|
],
|
|
}),
|
|
);
|
|
expect(res.status).toBe(422);
|
|
|
|
// Nothing persisted — the batch was rejected at parse time.
|
|
const entryCount = await getPrismaClient().moodEntry.count({
|
|
where: { userId: TEST_USER_ID },
|
|
});
|
|
expect(entryCount).toBe(0);
|
|
});
|
|
|
|
it("ignores tagKeys that resolve to no catalog rows", async () => {
|
|
const { POST } = await import("@/app/api/mood-entries/bulk/route");
|
|
const res = await POST(
|
|
makeRequest({
|
|
entries: [
|
|
{
|
|
mood: "OKAY",
|
|
moodLoggedAt: "2026-05-16T09:00:00.000Z",
|
|
tagKeys: ["totally_unknown_key"],
|
|
},
|
|
],
|
|
}),
|
|
);
|
|
expect(res.status).toBe(200);
|
|
const entry = await getPrismaClient().moodEntry.findFirstOrThrow({
|
|
where: { userId: TEST_USER_ID },
|
|
});
|
|
const linkCount = await getPrismaClient().moodEntryTagLink.count({
|
|
where: { moodEntryId: entry.id },
|
|
});
|
|
expect(linkCount).toBe(0);
|
|
});
|
|
|
|
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");
|
|
});
|
|
});
|