mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-11 13:52:20 +00:00
fix(mood): preserve rated factors during edits
This commit is contained in:
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,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
Reference in new issue
Block a user