mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-10 05:12:14 +00:00
R-E H-5. Up to v0.4.1.1 the iOS app stored mood-entry prose as `tags: ["note:<text>"]` because the server had no dedicated column. The workaround contaminated the tags axis with prose the Coach evidence shelf misread as taxonomic labels. Schema (migration 0063, additive): - mood_entries.note (TEXT, nullable) Validation + routes: - createMoodEntrySchema gains optional note (max 500 chars) - updateMoodEntrySchema accepts note + null to clear - POST /api/mood-entries persists note alongside tags - PUT /api/mood-entries/[id] honours partial note updates - POST /api/mood-entries/bulk carries note through the bulk path Backfill script at scripts/backfill-mood-note-column.ts lifts the legacy note:<text> tag entries into the new column. Idempotent dry-run mode is the default; --confirm commits. Helper logic lives in src/lib/mood/note-backfill.ts for tight unit coverage. iOS contract: additive. Old iOS payloads (no note field) round-trip unchanged. New iOS code paths can read or write note without touching tags.
170 lines
5.1 KiB
TypeScript
170 lines
5.1 KiB
TypeScript
/**
|
|
* 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);
|
|
});
|
|
});
|