Files
HealthLog/tests/integration/mood-writers-agree.test.ts
T
Marc-André Bombeck 3dd79ec734 fix(mood): one spelling on the wire, one day-key helper, one written decision
Three small things the audit named together.

The mood reads spread the whole row, so adding the wire keys shipped
every level-A value twice — once as the column name and once as the key
the write path accepts. A client would have picked one and been stuck
with it. The column spelling is stripped now, and the create response's
key set is asserted so it stays stripped. The published contract is
unaffected either way; these routes are not in it.

The dimension series carried its own copy of the day-key subtraction
that the freshness module already exports. One copy now.

And the reason a stale dimension ships with a notice instead of being
dropped the way the status surfaces drop one is written at the builder:
a one-line card has nothing left to say about a value it cannot narrate,
while an open conversation can say when the value is from — and silence
there would leave no way to tell an unanswered dimension from one nobody
has looked at in a week.
2026-08-08 03:03:38 +02:00

491 lines
17 KiB
TypeScript

/**
* v1.37 — every mood writer resolves the same pleasantness value.
*
* Five paths write a `MoodEntry`, and only one of them is the web form. If
* the derivation lived at the call site, a mood tapped in a chat and the same
* mood typed into the form would drift apart the first time one of the five
* was edited without the others. This test posts the identical mood through
* all five and asserts the rows agree, against real Postgres and the real
* handlers:
*
* 1. POST /api/mood-entries (web + iOS single)
* 2. POST /api/mood-entries/bulk (iOS backfill)
* 3. logTelegramMood (the bot)
* 4. POST /api/import (JSON restore)
* 5. logMcpMood (the assistant tool)
*
* It also pins the three rules the columns exist for: a re-post refreshes the
* value rather than leaving a stale one beside a fresh mood, an explicit
* client value beats the derivation, and nothing ever invents A2 to A5.
*/
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 USER = "user-mood-writers-agree";
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),
}));
async function createUserSession() {
await getPrismaClient().user.create({
data: {
id: USER,
username: "mood-writers-agree",
email: "mood-writers-agree@example.test",
timezone: "Europe/Berlin",
},
});
const session = await getPrismaClient().session.create({
data: {
userId: USER,
expiresAt: new Date(Date.now() + 60 * 60 * 1000),
},
});
cookieJar.set("healthlog_session", session.id);
}
function jsonRequest(path: string, body: unknown): NextRequest {
return new NextRequest(`http://localhost${path}`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify(body),
});
}
beforeEach(async () => {
await truncateAllTables(getPrismaClient());
cookieJar.clear();
});
describe("mood writers agree on the derived level-A value", () => {
it("writes the same moodA1 through all five writers for the same mood", async () => {
await createUserSession();
const { POST: postSingle } = await import("@/app/api/mood-entries/route");
const { POST: postBulk } =
await import("@/app/api/mood-entries/bulk/route");
const { POST: postImport } = await import("@/app/api/import/route");
const { logTelegramMood } = await import("@/lib/mood/create-from-telegram");
const { logMcpMood } = await import("@/lib/mcp/writes");
// 1 — single create.
const single = await postSingle(
jsonRequest("/api/mood-entries", {
mood: "GUT",
moodLoggedAt: "2026-05-16T08:00:00.000Z",
externalId: "writer-single",
}),
);
expect(single.status).toBe(201);
// 2 — bulk backfill. The request shape carries no level-A field and the
// server derives it, which is why the published contract does not move:
// this batch path is the one in `docs/api/openapi.yaml`, and it is
// byte-identical. The unpublished single routes DO answer five new keys
// on their reads, which is additive and which the client ignores.
const bulk = await postBulk(
jsonRequest("/api/mood-entries/bulk", {
entries: [
{
mood: "GUT",
moodLoggedAt: "2026-05-16T09:00:00.000Z",
externalId: "writer-bulk",
},
],
}),
);
expect(bulk.status).toBe(200);
// 3 — the bot. Its input is the 1-5 score; it resolves the label itself.
const telegram = await logTelegramMood({
userId: USER,
score: 4,
tz: "Europe/Berlin",
externalId: "telegram:mood:1:2:4",
});
expect(telegram.created).toBe(true);
// 4 — the JSON import. Deliberately carries a `score` that disagrees with
// its own label: this is the one writer whose score comes out of the file
// rather than out of the label, and the self-assessment column has to
// follow the label the user picked, not the number the file asserts.
const imported = await postImport(
jsonRequest("/api/import", {
moodEntries: [
{
date: "2026-05-16",
mood: "GUT",
score: 2,
loggedAt: "2026-05-16T10:00:00.000Z",
externalId: "writer-import",
},
],
}),
);
expect(imported.status).toBe(200);
// 5 — the assistant tool.
const mcp = await logMcpMood({
userId: USER,
score: 4,
tz: "Europe/Berlin",
idempotencyKey: "writer-mcp-1",
});
expect(mcp.status).toBe("written");
const rows = await getPrismaClient().moodEntry.findMany({
where: { userId: USER },
orderBy: { source: "asc" },
select: {
source: true,
mood: true,
score: true,
moodA1: true,
stressA2: true,
energyA3: true,
connectionA4: true,
stabilityA5: true,
},
});
// Five writers, five rows — and one of them is the bulk path, which must
// not have been folded into the single path's row.
expect(rows).toHaveLength(5);
expect(rows.map((r) => r.source).sort()).toEqual([
"IMPORT",
"MANUAL",
"MANUAL",
"MCP",
"TELEGRAM",
]);
for (const row of rows) {
expect(row.mood).toBe("GUT");
// GUT sits at 7 on the level-A scale. Asserted by value on every row,
// not by comparing the rows to each other: five rows agreeing on a
// wrong number would pass a same-value assertion.
expect(row.moodA1).toBe(7);
// Nothing derives the other four, on any path.
expect(row.stressA2).toBeNull();
expect(row.energyA3).toBeNull();
expect(row.connectionA4).toBeNull();
expect(row.stabilityA5).toBeNull();
}
// The imported row keeps the file's own score while its level-A value
// follows the label — the disagreement the file carried is not copied
// into the new column.
const importedRow = rows.find((r) => r.source === "IMPORT");
expect(importedRow?.score).toBe(2);
expect(importedRow?.moodA1).toBe(7);
});
it("refreshes moodA1 when an externalId is re-posted with a different mood", async () => {
await createUserSession();
const { POST: postSingle } = await import("@/app/api/mood-entries/route");
const { POST: postBulk } =
await import("@/app/api/mood-entries/bulk/route");
await postSingle(
jsonRequest("/api/mood-entries", {
mood: "LAUSIG",
moodLoggedAt: "2026-05-16T08:00:00.000Z",
externalId: "repost-single",
}),
);
await postSingle(
jsonRequest("/api/mood-entries", {
mood: "SUPER_GUT",
moodLoggedAt: "2026-05-16T08:10:00.000Z",
externalId: "repost-single",
}),
);
await postBulk(
jsonRequest("/api/mood-entries/bulk", {
entries: [
{
mood: "LAUSIG",
moodLoggedAt: "2026-05-16T11:00:00.000Z",
externalId: "repost-bulk",
},
],
}),
);
await postBulk(
jsonRequest("/api/mood-entries/bulk", {
entries: [
{
mood: "SUPER_GUT",
moodLoggedAt: "2026-05-16T11:10:00.000Z",
externalId: "repost-bulk",
},
],
}),
);
const rows = await getPrismaClient().moodEntry.findMany({
where: { userId: USER },
orderBy: { externalId: "asc" },
select: { externalId: true, mood: true, score: true, moodA1: true },
});
// One row per externalId — the re-post updated in place, as before.
expect(rows).toHaveLength(2);
for (const row of rows) {
expect(row.mood).toBe("SUPER_GUT");
expect(row.score).toBe(5);
// The update arm of both upserts restates the value. Without it the row
// would carry LAUSIG's 1 beside SUPER_GUT's 5.
expect(row.moodA1).toBe(9);
}
});
it("leaves a dimension the re-post said nothing about exactly where it was", async () => {
await createUserSession();
const { POST: postSingle } = await import("@/app/api/mood-entries/route");
const { POST: postBulk } =
await import("@/app/api/mood-entries/bulk/route");
// Somebody answers the sliders on the web.
await postSingle(
jsonRequest("/api/mood-entries", {
mood: "OKAY",
moodLoggedAt: "2026-05-16T08:00:00.000Z",
externalId: "preserve-single",
a2: 9,
a3: 2,
}),
);
// The phone re-posts the same entry it holds: label, timestamp, id, and no
// sliders, because its build has none. This must not blank what the web
// wrote. Absence in a request means "nothing to say", not "set to
// nothing" — a request that cleared four columns by staying silent would
// destroy answers on every sync round.
await postSingle(
jsonRequest("/api/mood-entries", {
mood: "GUT",
moodLoggedAt: "2026-05-16T08:05:00.000Z",
externalId: "preserve-single",
}),
);
const afterRepost = await getPrismaClient().moodEntry.findFirstOrThrow({
where: { userId: USER, externalId: "preserve-single" },
});
expect(afterRepost.mood).toBe("GUT");
// Pleasantness DOES follow the label: the request carried one, and a
// stale A1 beside a changed face would contradict the entry itself.
expect(afterRepost.moodA1).toBe(7);
// The four the request said nothing about are untouched.
expect(afterRepost.stressA2).toBe(9);
expect(afterRepost.energyA3).toBe(2);
expect(afterRepost.connectionA4).toBeNull();
expect(afterRepost.stabilityA5).toBeNull();
// An explicit null is how an answer is taken back — the other route out of
// the same three-state contract.
await postSingle(
jsonRequest("/api/mood-entries", {
mood: "GUT",
moodLoggedAt: "2026-05-16T08:06:00.000Z",
externalId: "preserve-single",
a2: null,
}),
);
const afterClear = await getPrismaClient().moodEntry.findFirstOrThrow({
where: { userId: USER, externalId: "preserve-single" },
});
expect(afterClear.stressA2).toBeNull();
// Clearing one says nothing about the others.
expect(afterClear.energyA3).toBe(2);
// The batch path answers the same way, and always has: it carries no
// level-A input at all, so every re-import preserves.
await postSingle(
jsonRequest("/api/mood-entries", {
mood: "OKAY",
moodLoggedAt: "2026-05-16T12:00:00.000Z",
externalId: "preserve-bulk",
a4: 6,
}),
);
await postBulk(
jsonRequest("/api/mood-entries/bulk", {
entries: [
{
mood: "SUPER_GUT",
moodLoggedAt: "2026-05-16T12:30:00.000Z",
externalId: "preserve-bulk",
},
],
}),
);
const afterBulk = await getPrismaClient().moodEntry.findFirstOrThrow({
where: { userId: USER, externalId: "preserve-bulk" },
});
expect(afterBulk.mood).toBe("SUPER_GUT");
expect(afterBulk.moodA1).toBe(9);
expect(afterBulk.connectionA4).toBe(6);
});
it("lets an explicit client value win over the derivation, and stores the other four literally", async () => {
await createUserSession();
const { POST: postSingle } = await import("@/app/api/mood-entries/route");
const res = await postSingle(
jsonRequest("/api/mood-entries", {
mood: "OKAY",
moodLoggedAt: "2026-05-16T12:00:00.000Z",
// A day that felt middling overall but was genuinely stressful: the
// whole reason five values beat one.
a1: 4,
a2: 9,
a3: 2,
}),
);
expect(res.status).toBe(201);
const json = (await res.json()) as {
data: Record<string, unknown> & {
a1: number;
a2: number;
a3: number;
a4: null;
a5: null;
};
};
// The response echoes the values under the keys the request used.
expect(json.data.a1).toBe(4);
expect(json.data.a2).toBe(9);
expect(json.data.a5).toBeNull();
// And under those keys ONLY. The routes spread the whole row, so simply
// adding the wire keys shipped every value twice under two spellings; a
// client would then have picked one and been stuck with it.
const keys = Object.keys(json.data as Record<string, unknown>);
expect(keys).not.toContain("moodA1");
expect(keys).not.toContain("stressA2");
expect(keys).not.toContain("energyA3");
expect(keys).not.toContain("connectionA4");
expect(keys).not.toContain("stabilityA5");
const row = await getPrismaClient().moodEntry.findFirstOrThrow({
where: { userId: USER },
});
// Not 5 — OKAY's derived value lost to the number the user set.
expect(row.moodA1).toBe(4);
// Stored literally, higher meaning more stress. A value flipped on the
// way in could never be recovered.
expect(row.stressA2).toBe(9);
expect(row.energyA3).toBe(2);
// Untouched sliders write nothing at all.
expect(row.connectionA4).toBeNull();
expect(row.stabilityA5).toBeNull();
// The five-point axis is untouched by any of this.
expect(row.score).toBe(3);
});
it("re-derives A1 when an entry's mood is corrected, and clears a value on an explicit null", async () => {
await createUserSession();
const { POST: postSingle } = await import("@/app/api/mood-entries/route");
const { PUT } = await import("@/app/api/mood-entries/[id]/route");
const created = await postSingle(
jsonRequest("/api/mood-entries", {
mood: "SCHLECHT",
moodLoggedAt: "2026-05-16T13:00:00.000Z",
a2: 8,
}),
);
const createdJson = (await created.json()) as { data: { id: string } };
const id = createdJson.data.id;
const corrected = await PUT(
new NextRequest(`http://localhost/api/mood-entries/${id}`, {
method: "PUT",
headers: { "content-type": "application/json" },
body: JSON.stringify({ mood: "GUT", a2: null }),
}),
{ params: Promise.resolve({ id }) },
);
expect(corrected.status).toBe(200);
const row = await getPrismaClient().moodEntry.findUniqueOrThrow({
where: { id },
});
expect(row.mood).toBe("GUT");
expect(row.score).toBe(4);
// The label moved, so pleasantness moved with it — the same way `score`
// does. A stale 3 here would contradict the entry's own label.
expect(row.moodA1).toBe(7);
// An explicit null takes back an answer.
expect(row.stressA2).toBeNull();
});
it("will not empty pleasantness, on either route", async () => {
await createUserSession();
const { POST: postSingle } = await import("@/app/api/mood-entries/route");
const { PUT } = await import("@/app/api/mood-entries/[id]/route");
// An entry always carries a five-point label, so it always implies a
// pleasantness value. A null asks for one that cannot exist, and both
// routes answer it the same way: derive from the label. The capture
// surfaces therefore offer no clear control for this one field, which is
// the UI half of the same rule.
const created = await postSingle(
jsonRequest("/api/mood-entries", {
mood: "SUPER_GUT",
moodLoggedAt: "2026-05-16T14:00:00.000Z",
a1: null,
}),
);
expect(created.status).toBe(201);
const createdJson = (await created.json()) as {
data: { id: string; a1: number };
};
expect(createdJson.data.a1).toBe(9);
const id = createdJson.data.id;
const cleared = await PUT(
new NextRequest(`http://localhost/api/mood-entries/${id}`, {
method: "PUT",
headers: { "content-type": "application/json" },
body: JSON.stringify({ a1: null }),
}),
{ params: Promise.resolve({ id }) },
);
expect(cleared.status).toBe(200);
const row = await getPrismaClient().moodEntry.findUniqueOrThrow({
where: { id },
});
// Derived from the entry's own stored label, not emptied.
expect(row.moodA1).toBe(9);
});
});