fix(mood): preserve rated factors during edits

This commit is contained in:
Marc-André Bombeck committed 2026-07-20 11:11:03 +02:00
1 parent d31a33e14c
commit 1f94249bd5
6 files changed
+403 -56

No files matched your search

@@ -3,6 +3,16 @@
*/
import { describe, it, expect, vi, beforeEach } from "vitest";
import { NextRequest } from "next/server";
import type * as TagLinksModule from "@/lib/mood/tag-links";
const txClient = vi.hoisted(() => ({
moodEntry: {
update: vi.fn(),
},
moodEntryTagLink: {
findMany: vi.fn(),
},
}));
vi.mock("@/lib/db", () => ({
prisma: {
@@ -12,10 +22,20 @@ vi.mock("@/lib/db", () => ({
update: vi.fn(),
delete: vi.fn(),
},
moodEntryTagLink: {
findMany: vi.fn(),
},
auditLog: { create: vi.fn() },
$transaction: vi.fn(),
},
}));
vi.mock("@/lib/mood/tag-links", async (importOriginal) => ({
...(await importOriginal<typeof TagLinksModule>()),
replaceTagLinks: vi.fn(),
replaceRatedFactorLinks: vi.fn(),
}));
vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() }));
vi.mock("@/lib/auth/audit", () => ({
auditLog: vi.fn().mockResolvedValue(undefined),
@@ -42,12 +62,37 @@ vi.mock("next/headers", () => ({
import { PUT } from "../route";
import { prisma } from "@/lib/db";
import { getSession } from "@/lib/auth/session";
import {
RatedFactorOutOfRangeError,
replaceRatedFactorLinks,
replaceTagLinks,
} from "@/lib/mood/tag-links";
const SESSION_OK = {
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
user: { id: "user-1", username: "tester", role: "USER" as const },
};
const EXISTING_ENTRY = {
id: "me1",
userId: "user-1",
date: "2026-06-01",
mood: "OKAY",
score: 3,
tags: null,
note: null,
noteEncrypted: null,
source: "MANUAL",
moodLoggedAt: new Date("2026-06-01T08:00:00.000Z"),
syncVersion: 7,
};
const UPDATED_ENTRY = {
...EXISTING_ENTRY,
moodLoggedAt: new Date("2026-06-02T08:00:00.000Z"),
syncVersion: 8,
};
function putReq(body: unknown): NextRequest {
return new NextRequest("http://localhost/api/mood-entries/me1", {
method: "PUT",
@@ -62,16 +107,21 @@ beforeEach(() => {
vi.resetAllMocks();
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(prisma.auditLog.create).mockResolvedValue({} as never);
vi.mocked(prisma.$transaction).mockImplementation(async (callback) =>
callback(txClient as never),
);
txClient.moodEntry.update.mockResolvedValue(UPDATED_ENTRY as never);
txClient.moodEntryTagLink.findMany.mockResolvedValue([]);
vi.mocked(replaceTagLinks).mockResolvedValue(undefined);
vi.mocked(replaceRatedFactorLinks).mockResolvedValue(undefined);
// v1.7.0 sync — PUT now looks the row up via `findFirst` with a
// `deletedAt: null` guard (refuses to resurrect-edit a tombstone).
vi.mocked(prisma.moodEntry.findFirst).mockResolvedValue({
id: "me1",
userId: "user-1",
} as never);
vi.mocked(prisma.moodEntry.findUnique).mockResolvedValue({
id: "me1",
userId: "user-1",
} as never);
vi.mocked(prisma.moodEntry.findFirst).mockResolvedValue(
EXISTING_ENTRY as never,
);
vi.mocked(prisma.moodEntry.findUnique).mockResolvedValue(
EXISTING_ENTRY as never,
);
});
describe("PUT /api/mood-entries/[id] — 422 multi-issue (v1.4.43 W6)", () => {
@@ -139,3 +189,162 @@ describe("PUT /api/mood-entries/[id] — 422 multi-issue (v1.4.43 W6)", () => {
expect(res.status).toBe(422);
});
});
describe("PUT /api/mood-entries/[id] — split tag replacement", () => {
it("preserves RATED links for a note/time/tagKeys-only edit", async () => {
txClient.moodEntryTagLink.findMany.mockResolvedValue([
{ rating: null, moodTag: { key: "happy", kind: "BINARY" } },
{ rating: 4, moodTag: { key: "factor_work", kind: "RATED" } },
]);
const res = await PUT(
putReq({
note: null,
moodLoggedAt: "2026-06-02T08:00:00.000Z",
tagKeys: ["happy"],
}),
ROUTE_CTX,
);
expect(res.status).toBe(200);
expect(replaceTagLinks).toHaveBeenCalledWith(
"me1",
"user-1",
["happy"],
txClient,
);
expect(replaceRatedFactorLinks).not.toHaveBeenCalled();
const body = (await res.json()) as {
data: {
tagKeys: string[];
ratedFactors: Array<{ key: string; rating: number }>;
};
error: null;
};
expect(body.data.tagKeys).toEqual(["happy"]);
expect(body.data.ratedFactors).toEqual([
{ key: "factor_work", rating: 4 },
]);
expect(body.error).toBeNull();
});
it("preserves BINARY links and updates scores for a ratedFactors-only edit", async () => {
txClient.moodEntryTagLink.findMany.mockResolvedValue([
{ rating: null, moodTag: { key: "happy", kind: "BINARY" } },
{ rating: 5, moodTag: { key: "factor_work", kind: "RATED" } },
]);
const res = await PUT(
putReq({ ratedFactors: [{ key: "factor_work", rating: 5 }] }),
ROUTE_CTX,
);
expect(res.status).toBe(200);
expect(replaceTagLinks).not.toHaveBeenCalled();
expect(replaceRatedFactorLinks).toHaveBeenCalledWith(
"me1",
"user-1",
[{ key: "factor_work", rating: 5 }],
txClient,
);
const body = (await res.json()) as {
data: {
tagKeys: string[];
ratedFactors: Array<{ key: string; rating: number }>;
};
};
expect(body.data.tagKeys).toEqual(["happy"]);
expect(body.data.ratedFactors).toEqual([
{ key: "factor_work", rating: 5 },
]);
});
it("returns 422 when a factor score is outside its catalog scale", async () => {
vi.mocked(replaceRatedFactorLinks).mockRejectedValueOnce(
new RatedFactorOutOfRangeError("factor_conflict", 5, 1, 2),
);
const res = await PUT(
putReq({ ratedFactors: [{ key: "factor_conflict", rating: 5 }] }),
ROUTE_CTX,
);
expect(res.status).toBe(422);
const body = (await res.json()) as {
data: null;
error: string;
meta: { errorCode: string };
};
expect(body.data).toBeNull();
expect(body.meta.errorCode).toBe("mood.ratedFactor.out_of_range");
});
it("rolls back the mood row, both link sets, and syncVersion when the second replacement fails", async () => {
let persisted = {
mood: "OKAY",
syncVersion: 7,
binary: ["happy"],
rated: [{ key: "factor_work", rating: 2 }],
};
let draft = structuredClone(persisted);
const transactionClient = {
moodEntry: {
update: vi.fn(async ({ data }: { data: Record<string, unknown> }) => {
if (typeof data.mood === "string") draft.mood = data.mood;
if (data.syncVersion) draft.syncVersion += 1;
return UPDATED_ENTRY;
}),
},
moodEntryTagLink: {
findMany: vi.fn(),
},
};
vi.mocked(replaceTagLinks).mockImplementationOnce(async () => {
draft.binary = ["calm"];
});
vi.mocked(replaceRatedFactorLinks).mockImplementationOnce(async () => {
draft.rated = [{ key: "factor_work", rating: 5 }];
throw new Error("injected rated-link replacement failure");
});
vi.mocked(prisma.$transaction).mockImplementationOnce(async (callback) => {
draft = structuredClone(persisted);
const result = await callback(transactionClient as never);
persisted = draft;
return result;
});
const res = await PUT(
putReq({
mood: "GUT",
tagKeys: ["calm"],
ratedFactors: [{ key: "factor_work", rating: 5 }],
}),
ROUTE_CTX,
);
expect(replaceTagLinks).toHaveBeenCalledTimes(1);
expect(replaceRatedFactorLinks).toHaveBeenCalledTimes(1);
expect(res.status).toBe(500);
expect(persisted).toEqual({
mood: "OKAY",
syncVersion: 7,
binary: ["happy"],
rated: [{ key: "factor_work", rating: 2 }],
});
});
it.each([
{ ratedFactors: null },
{ ratedFactors: [] },
])("clears RATED links for an explicit $ratedFactors", async (payload) => {
const res = await PUT(putReq(payload), ROUTE_CTX);
expect(res.status).toBe(200);
expect(replaceRatedFactorLinks).toHaveBeenCalledWith(
"me1",
"user-1",
[],
txClient,
);
});
});
+58 -16
View File
@@ -19,7 +19,11 @@ import { moodDateKey, DEFAULT_TIMEZONE } from "@/lib/mood/date-key";
import { encryptNote, shapeMoodNote } from "@/lib/crypto/note-cipher";
import { invalidateUserMood } from "@/lib/cache/invalidate";
import { recomputeMoodBucketsForEntry } from "@/lib/rollups/mood-rollups";
import { replaceTagLinks } from "@/lib/mood/tag-links";
import {
RatedFactorOutOfRangeError,
replaceRatedFactorLinks,
replaceTagLinks,
} from "@/lib/mood/tag-links";
type RouteParams = { params: Promise<{ id: string }> };
@@ -140,38 +144,75 @@ export const PUT = apiHandler(
// monotonic value and paired clients reconcile higher-wins.
updateData.syncVersion = { increment: 1 };
// v1.8.5 — update the entry and replace its structured-tag links in
// one transaction. A tag-link failure must roll the entry update back
// too — otherwise a client retry on the 5xx double-applies the edit
// (and the `syncVersion` increment). The tx client is threaded through
// the helper so both writes commit (or abort) together.
const { entry, persistedTagKeys } = await prisma.$transaction(
async (tx) => {
// Update the row and replace each submitted half of the structured-link
// contract in one transaction. A failure in either replacement rolls
// back the mood edit and its syncVersion increment as one unit.
const persistUpdate = () =>
prisma.$transaction(async (tx) => {
const updated = await tx.moodEntry.update({
where: { id },
data: updateData,
});
// Full replacement of the structured-tag link set when `tagKeys`
// is present in the body. `null` clears every link; an omitted
// field leaves the links untouched.
// Omission preserves the corresponding link set. Explicit null/empty
// clears it, matching the validated update contract.
if (data.tagKeys !== undefined) {
await replaceTagLinks(id, user.id, data.tagKeys ?? [], tx);
}
if (data.ratedFactors !== undefined) {
await replaceRatedFactorLinks(
id,
user.id,
data.ratedFactors ?? [],
tx,
);
}
// v1.8.5 — read the persisted link keys back so the update
// response mirrors the list GET shape exactly.
// Return the same split shape as list/create: binary keys are
// independent from rated-factor keys and their per-entry scores.
const links = await tx.moodEntryTagLink.findMany({
where: { moodEntryId: id },
select: { moodTag: { select: { key: true } } },
select: {
rating: true,
moodTag: { select: { key: true, kind: true } },
},
});
return {
entry: updated,
persistedTagKeys: links.map((link) => link.moodTag.key),
persistedTagKeys: links
.filter((link) => link.moodTag.kind !== "RATED")
.map((link) => link.moodTag.key),
persistedRatedFactors: links
.filter(
(link) => link.moodTag.kind === "RATED" && link.rating !== null,
)
.map((link) => ({
key: link.moodTag.key,
rating: link.rating as number,
})),
};
},
});
const transactionOutcome = await persistUpdate().then(
(result) => ({ ok: true as const, result }),
(error: unknown) => ({ ok: false as const, error }),
);
if (!transactionOutcome.ok) {
const { error } = transactionOutcome;
if (error instanceof RatedFactorOutOfRangeError) {
annotate({
action: { name: "mood-entries.update.rated-factor-out-of-range" },
meta: { scaleMin: error.scaleMin, scaleMax: error.scaleMax },
});
return apiError(error.message, 422, {
errorCode: "mood.ratedFactor.out_of_range",
});
}
throw error;
}
const { entry, persistedTagKeys, persistedRatedFactors } =
transactionOutcome.result;
await auditLog("moodEntry.update", {
userId: user.id,
@@ -219,6 +260,7 @@ export const PUT = apiHandler(
// hydrating from the update response renders the tag set without a
// refetch (shape-matches the list GET).
tagKeys: persistedTagKeys,
ratedFactors: persistedRatedFactors,
});
},
);