mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 04:50:59 +00:00
feat(mood): derive pleasantness on every write path
- one helper resolves the level-A values for a write; the five writers call it instead of carrying their own copy of the derivation - the create schema takes a1 to a5, and a value the user set wins over the derived one; the batch request shape is untouched - both arms of both upserts restate the values, so a re-post cannot leave a stale reading beside a mood that has since changed - the JSON import derives from the row's own label rather than the file's score, which is the one place the two can disagree - correcting an entry's label re-derives pleasantness the way it re-derives the score; an explicit null takes an answer back - a round trip through all five writers against real Postgres asserts the rows agree and that nothing invents the other four values
This commit is contained in:
1 parent
ad4f5c04f0
commit
a1c78eceef
9 files changed
+555
-1
No files matched your search
@@ -39,6 +39,7 @@ import { emitInsertedMeasurementArrivals } from "@/lib/arrivals/measurement-emit
|
||||
import { maybeEnqueueMorningRefresh } from "@/lib/daily/morning-refresh-trigger";
|
||||
import { isP2002, isP2025 } from "@/lib/prisma-errors";
|
||||
import { DEFAULT_TIMEZONE, moodDateKey } from "@/lib/mood/date-key";
|
||||
import { deriveA1 } from "@/lib/mood/level-a";
|
||||
import { zonedWallClockToUtc } from "@/lib/tz/wall-clock";
|
||||
|
||||
// Derived from canonical enum so round-trip export → import covers every
|
||||
@@ -364,6 +365,12 @@ export const POST = apiHandler(async (request: NextRequest) => {
|
||||
date: e.date,
|
||||
mood: e.mood,
|
||||
score: e.score,
|
||||
// v1.37 — pleasantness derived from the row's own label, not
|
||||
// from the file's `score`. This is the one writer whose score
|
||||
// comes out of the file rather than out of the label, so the
|
||||
// two can disagree; the column that records a self-assessment
|
||||
// has to agree with the label the user actually picked.
|
||||
moodA1: deriveA1(e.mood),
|
||||
tags: e.tags || null,
|
||||
moodLoggedAt: loggedAt,
|
||||
},
|
||||
@@ -372,6 +379,7 @@ export const POST = apiHandler(async (request: NextRequest) => {
|
||||
date: e.date,
|
||||
mood: e.mood,
|
||||
score: e.score,
|
||||
moodA1: deriveA1(e.mood),
|
||||
tags: e.tags || null,
|
||||
source: "IMPORT",
|
||||
externalId: e.externalId,
|
||||
@@ -385,6 +393,9 @@ export const POST = apiHandler(async (request: NextRequest) => {
|
||||
date: e.date,
|
||||
mood: e.mood,
|
||||
score: e.score,
|
||||
// See the upsert arm above: derived from the label, because
|
||||
// an imported file's `score` is its own field.
|
||||
moodA1: deriveA1(e.mood),
|
||||
tags: e.tags || null,
|
||||
source: "IMPORT",
|
||||
moodLoggedAt: loggedAt,
|
||||
|
||||
@@ -15,6 +15,7 @@ import { Prisma } from "@/generated/prisma/client";
|
||||
import { apiHandler, requireRecordAuth } from "@/lib/api-handler";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { moodDateKey, DEFAULT_TIMEZONE } from "@/lib/mood/date-key";
|
||||
import { deriveA1, levelAForWire } from "@/lib/mood/level-a";
|
||||
import { encryptNote, shapeMoodNote } from "@/lib/crypto/note-cipher";
|
||||
import { invalidateUserMood } from "@/lib/cache/invalidate";
|
||||
import { recomputeMoodBucketsForEntry } from "@/lib/rollups/mood-rollups";
|
||||
@@ -57,7 +58,12 @@ export const GET = apiHandler(
|
||||
meta: { moodEntryId: id },
|
||||
});
|
||||
|
||||
return apiSuccess({ ...shapeMoodNote(entry), tags: parseTags(entry.tags) });
|
||||
return apiSuccess({
|
||||
...shapeMoodNote(entry),
|
||||
// v1.37 — level-A values under the keys the write path takes.
|
||||
...levelAForWire(entry),
|
||||
tags: parseTags(entry.tags),
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
@@ -120,7 +126,19 @@ export const PUT = apiHandler(
|
||||
if (data.mood !== undefined) {
|
||||
updateData.mood = data.mood;
|
||||
updateData.score = getScoreForMood(data.mood);
|
||||
// v1.37 — pleasantness follows the label the same way `score` does, so
|
||||
// an entry corrected from "okay" to "bad" cannot keep yesterday's A1.
|
||||
// An explicit `a1` in the same request overrides it below.
|
||||
updateData.moodA1 = deriveA1(data.mood);
|
||||
}
|
||||
// v1.37 — level-A values are per-field on this path: omitted keeps the
|
||||
// stored value, an explicit number replaces it, an explicit null clears
|
||||
// it. Built one at a time from the parsed body, never spread.
|
||||
if (data.a1 !== undefined) updateData.moodA1 = data.a1;
|
||||
if (data.a2 !== undefined) updateData.stressA2 = data.a2;
|
||||
if (data.a3 !== undefined) updateData.energyA3 = data.a3;
|
||||
if (data.a4 !== undefined) updateData.connectionA4 = data.a4;
|
||||
if (data.a5 !== undefined) updateData.stabilityA5 = data.a5;
|
||||
if (data.moodLoggedAt !== undefined) {
|
||||
// v1.4.25 W7b — re-anchor the row's `date` to the user's current
|
||||
// displayTimezone. Also refresh the `tz` column so the row's
|
||||
@@ -291,6 +309,8 @@ export const PUT = apiHandler(
|
||||
|
||||
return apiSuccess({
|
||||
...entry,
|
||||
// v1.37 — level-A values under the keys the write path takes.
|
||||
...levelAForWire(entry),
|
||||
tags: parseTags(entry.tags),
|
||||
// v1.8.5 — surface the persisted structured-tag keys so a client
|
||||
// hydrating from the update response renders the tag set without a
|
||||
|
||||
@@ -51,6 +51,7 @@ import {
|
||||
type UnstableExternalIdShape,
|
||||
} from "@/lib/validations/external-id";
|
||||
import { moodDateKey, DEFAULT_TIMEZONE } from "@/lib/mood/date-key";
|
||||
import { deriveA1 } from "@/lib/mood/level-a";
|
||||
import { encryptNote } from "@/lib/crypto/note-cipher";
|
||||
import { invalidateUserMood } from "@/lib/cache/invalidate";
|
||||
import { recomputeMoodBucketsForEntry } from "@/lib/rollups/mood-rollups";
|
||||
@@ -276,6 +277,11 @@ async function postBulk(request: NextRequest): Promise<Response> {
|
||||
|
||||
const date = moodDateKey(entry.moodLoggedAt, tz);
|
||||
const score = getScoreForMood(entry.mood);
|
||||
// v1.37 — pleasantness derived from the same label the score comes from.
|
||||
// The batch request shape is unchanged: this path carries no level-A
|
||||
// input, so A2 to A5 are left alone rather than written as nulls, and a
|
||||
// row that already carries hand-set values keeps them across a re-import.
|
||||
const moodA1 = deriveA1(entry.mood);
|
||||
|
||||
try {
|
||||
// v1.12.1 — when the entry carries a source-stable `externalId`,
|
||||
@@ -347,6 +353,7 @@ async function postBulk(request: NextRequest): Promise<Response> {
|
||||
tz,
|
||||
mood: entry.mood,
|
||||
score,
|
||||
moodA1,
|
||||
tags: entry.tags ? JSON.stringify(entry.tags) : null,
|
||||
note: null,
|
||||
noteEncrypted: encryptNote(entry.note ?? null),
|
||||
@@ -363,6 +370,7 @@ async function postBulk(request: NextRequest): Promise<Response> {
|
||||
// the same row.
|
||||
mood: entry.mood,
|
||||
score,
|
||||
moodA1,
|
||||
tags: entry.tags ? JSON.stringify(entry.tags) : null,
|
||||
note: null,
|
||||
noteEncrypted: encryptNote(entry.note ?? null),
|
||||
|
||||
@@ -24,6 +24,7 @@ import { annotate } from "@/lib/logging/context";
|
||||
import { withIdempotency } from "@/lib/idempotency";
|
||||
import { encryptNote, shapeMoodNote } from "@/lib/crypto/note-cipher";
|
||||
import { moodDateKey, DEFAULT_TIMEZONE } from "@/lib/mood/date-key";
|
||||
import { levelAForWire, levelAForWrite } from "@/lib/mood/level-a";
|
||||
import { invalidateUserMood } from "@/lib/cache/invalidate";
|
||||
import { recomputeMoodBucketsForEntry } from "@/lib/rollups/mood-rollups";
|
||||
import {
|
||||
@@ -126,6 +127,10 @@ export const GET = apiHandler(async (request: NextRequest) => {
|
||||
const entriesWithParsedTags = entries.map(({ tagLinks, ...e }) => ({
|
||||
// v1.23 — decrypt `noteEncrypted` onto `note`, strip the ciphertext.
|
||||
...shapeMoodNote(e),
|
||||
// v1.37 — the five level-A values under the keys the create path takes,
|
||||
// so the edit form reads back what it wrote instead of mapping column
|
||||
// names to wire names by hand.
|
||||
...levelAForWire(e),
|
||||
tags: parseTags(e.tags),
|
||||
// v1.8.5 — flat list of binary structured-tag keys attached to the
|
||||
// entry (rated factors are surfaced separately below).
|
||||
@@ -206,6 +211,17 @@ async function postMoodEntry(request: NextRequest) {
|
||||
source,
|
||||
externalId,
|
||||
} = parsed.data;
|
||||
// v1.37 — the five level-A values, built field by field from the parsed
|
||||
// body (never spread) and resolved once for every branch below. A1 falls
|
||||
// back to the derivation from `mood`; the other four are whatever the user
|
||||
// set, or nothing.
|
||||
const levelA = levelAForWrite(mood, {
|
||||
a1: parsed.data.a1,
|
||||
a2: parsed.data.a2,
|
||||
a3: parsed.data.a3,
|
||||
a4: parsed.data.a4,
|
||||
a5: parsed.data.a5,
|
||||
});
|
||||
// v1.4.25 W7b (Decision A) — anchor the `date` string to the user's
|
||||
// current displayTimezone and store the resolved zone on the row.
|
||||
// Legacy rows with `tz IS NULL` continue to read as Europe/Berlin
|
||||
@@ -265,6 +281,11 @@ async function postMoodEntry(request: NextRequest) {
|
||||
tz,
|
||||
mood,
|
||||
score,
|
||||
moodA1: levelA.moodA1,
|
||||
stressA2: levelA.stressA2,
|
||||
energyA3: levelA.energyA3,
|
||||
connectionA4: levelA.connectionA4,
|
||||
stabilityA5: levelA.stabilityA5,
|
||||
tags: tags ? JSON.stringify(tags) : null,
|
||||
note: null,
|
||||
noteEncrypted: encryptNote(note ?? null),
|
||||
@@ -272,11 +293,20 @@ async function postMoodEntry(request: NextRequest) {
|
||||
externalId,
|
||||
moodLoggedAt,
|
||||
},
|
||||
// A re-post replaces the row, so the level-A values are restated
|
||||
// here too. Omitting them would leave a stress reading from an
|
||||
// earlier post sitting beside a mood the user has since changed
|
||||
// — the same staleness the `score` restatement already avoids.
|
||||
update: {
|
||||
date,
|
||||
tz,
|
||||
mood,
|
||||
score,
|
||||
moodA1: levelA.moodA1,
|
||||
stressA2: levelA.stressA2,
|
||||
energyA3: levelA.energyA3,
|
||||
connectionA4: levelA.connectionA4,
|
||||
stabilityA5: levelA.stabilityA5,
|
||||
tags: tags ? JSON.stringify(tags) : null,
|
||||
note: null,
|
||||
noteEncrypted: encryptNote(note ?? null),
|
||||
@@ -290,6 +320,11 @@ async function postMoodEntry(request: NextRequest) {
|
||||
tz,
|
||||
mood,
|
||||
score,
|
||||
moodA1: levelA.moodA1,
|
||||
stressA2: levelA.stressA2,
|
||||
energyA3: levelA.energyA3,
|
||||
connectionA4: levelA.connectionA4,
|
||||
stabilityA5: levelA.stabilityA5,
|
||||
tags: tags ? JSON.stringify(tags) : null,
|
||||
note: null,
|
||||
noteEncrypted: encryptNote(note ?? null),
|
||||
@@ -375,6 +410,8 @@ async function postMoodEntry(request: NextRequest) {
|
||||
{
|
||||
// v1.23 — decrypt `noteEncrypted` onto `note`, strip the ciphertext.
|
||||
...shapeMoodNote(entry),
|
||||
// v1.37 — level-A values under their wire keys (see the list read).
|
||||
...levelAForWire(entry),
|
||||
tags: parseTags(entry.tags),
|
||||
// v1.8.5 — surface the persisted structured-tag keys so a client
|
||||
// hydrating from the create response renders the tag set without a
|
||||
|
||||
@@ -41,6 +41,7 @@ import { recomputeMoodBucketsForEntry } from "@/lib/rollups/mood-rollups";
|
||||
import { MOOD_ENUM_BY_SCORE } from "@/lib/mood/labels";
|
||||
import { getScoreForMood } from "@/lib/validations/mood";
|
||||
import { moodDateKey, DEFAULT_TIMEZONE } from "@/lib/mood/date-key";
|
||||
import { deriveA1 } from "@/lib/mood/level-a";
|
||||
import {
|
||||
classifyExternalId,
|
||||
unstableExternalIdMessage,
|
||||
@@ -648,6 +649,10 @@ export async function logMcpMood(input: {
|
||||
tz,
|
||||
mood,
|
||||
score,
|
||||
// v1.37 — pleasantness derived from the same label the score comes
|
||||
// from. The tool still takes a 1-5 score; that is its input and it is
|
||||
// unchanged.
|
||||
moodA1: deriveA1(mood),
|
||||
note: null,
|
||||
noteEncrypted: encryptNote(note),
|
||||
source: MCP_SOURCE,
|
||||
|
||||
@@ -17,6 +17,7 @@ import type { Prisma, PrismaClient } from "@/generated/prisma/client";
|
||||
import { prisma as defaultPrisma } from "@/lib/db";
|
||||
import { MOOD_ENUM_BY_SCORE } from "@/lib/mood/labels";
|
||||
import { getScoreForMood } from "@/lib/validations/mood";
|
||||
import { deriveA1 } from "@/lib/mood/level-a";
|
||||
import { moodDateKey, DEFAULT_TIMEZONE } from "@/lib/mood/date-key";
|
||||
import { encryptNote } from "@/lib/crypto/note-cipher";
|
||||
import { invalidateUserMood } from "@/lib/cache/invalidate";
|
||||
@@ -79,6 +80,10 @@ export async function logTelegramMood(input: {
|
||||
tz,
|
||||
mood,
|
||||
score,
|
||||
// v1.37 — pleasantness derived from the same label the score comes
|
||||
// from, so a mood tapped in the chat carries the value a mood typed
|
||||
// into the web form would. Nothing here can answer the other four.
|
||||
moodA1: deriveA1(mood),
|
||||
// v1.23 — encrypt the note at rest; legacy plaintext column nulled.
|
||||
note: null,
|
||||
noteEncrypted: encryptNote(input.note ?? null),
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
/**
|
||||
* The one place a mood write resolves its five level-A values.
|
||||
*
|
||||
* Five writers reach `MoodEntry`, and every one of them sends exactly what it
|
||||
* sent before this existed: a five-point label and nothing else. The server
|
||||
* derives pleasantness (A1) from that label so a quick check-in is a full
|
||||
* entry rather than a degraded one, and so a Telegram row, an imported row and
|
||||
* a row typed into the web form all carry the same value for the same day.
|
||||
*
|
||||
* The other four are never derived. There is nothing in a five-point label to
|
||||
* derive stress, energy, connectedness or stability from, and a fabricated
|
||||
* value would read as an answer nobody gave — it would poison the trend and
|
||||
* any later model trained on these columns. They stay NULL until somebody
|
||||
* moves the slider.
|
||||
*/
|
||||
import { getA1ForMood } from "@/lib/validations/mood";
|
||||
|
||||
/** Level-A values as a client may send them, all optional. */
|
||||
export interface LevelAInput {
|
||||
a1?: number | null;
|
||||
a2?: number | null;
|
||||
a3?: number | null;
|
||||
a4?: number | null;
|
||||
a5?: number | null;
|
||||
}
|
||||
|
||||
/** Level-A values as they are written to `MoodEntry`. */
|
||||
export interface LevelAColumns {
|
||||
moodA1: number | null;
|
||||
stressA2: number | null;
|
||||
energyA3: number | null;
|
||||
connectionA4: number | null;
|
||||
stabilityA5: number | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* The five columns for a write of `mood`, with any explicit client value
|
||||
* winning over the derivation.
|
||||
*
|
||||
* A client that sends only a label gets the derived A1 and four NULLs. A
|
||||
* client that sends `a1` gets its own number — that is what makes the detail
|
||||
* sliders a real capture surface rather than a suggestion the server overrides
|
||||
* on the way to the database.
|
||||
*
|
||||
* Every field is stated explicitly, including the NULLs, because the callers
|
||||
* are upserts: on the `update:` arm an omitted field keeps a value from an
|
||||
* earlier post, and a stale stress reading sitting beside a mood the user just
|
||||
* changed is exactly the mismatch these columns exist to avoid. Callers whose
|
||||
* write semantics are per-field rather than whole-row (the PATCH route) build
|
||||
* their own object and use `deriveA1` directly.
|
||||
*/
|
||||
export function levelAForWrite(
|
||||
mood: string,
|
||||
explicit?: LevelAInput,
|
||||
): LevelAColumns {
|
||||
return {
|
||||
moodA1: explicit?.a1 ?? getA1ForMood(mood),
|
||||
stressA2: explicit?.a2 ?? null,
|
||||
energyA3: explicit?.a3 ?? null,
|
||||
connectionA4: explicit?.a4 ?? null,
|
||||
stabilityA5: explicit?.a5 ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
/** A1 for a five-point label — the derivation on its own, for writers that carry no level-A input. */
|
||||
export function deriveA1(mood: string): number {
|
||||
return getA1ForMood(mood);
|
||||
}
|
||||
|
||||
/** The five columns as a client reads them back. */
|
||||
export interface LevelAWire {
|
||||
a1: number | null;
|
||||
a2: number | null;
|
||||
a3: number | null;
|
||||
a4: number | null;
|
||||
a5: number | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Turn a stored row's five columns into the wire keys the client sends.
|
||||
*
|
||||
* A read that answered `moodA1` while the write took `a1` would leave every
|
||||
* form mapping the pair by hand, and one of them would eventually map it
|
||||
* wrong. The names match in both directions.
|
||||
*/
|
||||
export function levelAForWire(row: {
|
||||
moodA1: number | null;
|
||||
stressA2: number | null;
|
||||
energyA3: number | null;
|
||||
connectionA4: number | null;
|
||||
stabilityA5: number | null;
|
||||
}): LevelAWire {
|
||||
return {
|
||||
a1: row.moodA1,
|
||||
a2: row.stressA2,
|
||||
a3: row.energyA3,
|
||||
a4: row.connectionA4,
|
||||
a5: row.stabilityA5,
|
||||
};
|
||||
}
|
||||
@@ -106,6 +106,14 @@ const ratedFactor = z.object({
|
||||
});
|
||||
const ratedFactors = z.array(ratedFactor).max(30);
|
||||
|
||||
// v1.37 — the five level-A self-state values, each 0-10. Optional on every
|
||||
// path: a client that sends only a five-point label still writes a complete
|
||||
// entry, because the server derives A1 from the label. A2 to A5 are only ever
|
||||
// what the user set, so an omitted one stays absent rather than defaulting to
|
||||
// the middle of the scale. The bound is the scale itself; `MOOD_DIMENSIONS`
|
||||
// (`src/lib/mood/dimensions.ts`) carries the anchors the numbers mean.
|
||||
const levelADimension = z.number().int().min(0).max(10);
|
||||
|
||||
export const createMoodEntrySchema = z
|
||||
.object({
|
||||
mood: moodLevelEnum,
|
||||
@@ -120,6 +128,14 @@ export const createMoodEntrySchema = z
|
||||
// `MoodEntryTagLink.rating`. Out-of-scale or non-RATED keys are
|
||||
// rejected (422) / dropped server-side per the catalog.
|
||||
ratedFactors: ratedFactors.optional(),
|
||||
// v1.37 — the five level-A values the detail sliders capture. Absent
|
||||
// means the user did not answer; `a1` absent means the server derives it
|
||||
// from `mood`, which keeps the one-tap check-in a full entry.
|
||||
a1: levelADimension.optional(),
|
||||
a2: levelADimension.optional(),
|
||||
a3: levelADimension.optional(),
|
||||
a4: levelADimension.optional(),
|
||||
a5: levelADimension.optional(),
|
||||
// v1.4.30 H-5 — first-class free-text note. Replaces the
|
||||
// `tags: ["note:<text>"]` workaround. Capped at 500 chars so the
|
||||
// Coach evidence shelf renders cleanly without truncating chips.
|
||||
@@ -155,6 +171,15 @@ export const updateMoodEntrySchema = z.object({
|
||||
// v1.12.0 — full replacement of the rated-factor set when present.
|
||||
// `null` clears every rated link; omit to leave them untouched.
|
||||
ratedFactors: ratedFactors.nullable().optional(),
|
||||
// v1.37 — level-A values on the edit path. Omitted leaves the stored value
|
||||
// alone; an explicit `null` clears it, which is how a user takes back an
|
||||
// answer they did not mean to give. Editing the five-point label without
|
||||
// sending `a1` re-derives A1, the same way it re-derives `score`.
|
||||
a1: levelADimension.nullable().optional(),
|
||||
a2: levelADimension.nullable().optional(),
|
||||
a3: levelADimension.nullable().optional(),
|
||||
a4: levelADimension.nullable().optional(),
|
||||
a5: levelADimension.nullable().optional(),
|
||||
note: z.string().max(500).nullable().optional(),
|
||||
// v1.17 W1b — same plausibility bound on the edit path.
|
||||
moodLoggedAt: validateEntryInstant(
|
||||
|
||||
@@ -0,0 +1,343 @@
|
||||
/**
|
||||
* 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; the
|
||||
// server derives it, which is the whole point of this phase being
|
||||
// invisible on the wire.
|
||||
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("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: { 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();
|
||||
|
||||
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();
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user