mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-12 22:26:13 +00:00
test(integration): move the mood tag route tests onto Postgres
The effective tag read, hide and show, custom tags and groups, and the tag layout are tested through the real handlers on stored rows instead of a mocked Prisma client: the migration-seeded catalogue served after a truncate, owner scoping that leaves another account's tag byte-identical, the caps, sealed labels, soft delete against purge, a deleted group re-homing its tags, and the layout's optimistic token from 200 through 409 to a successful retry.
This commit is contained in:
1 parent
26df61f638
commit
ff9ef0883b
8 files changed
+1594
-1206
No files matched your search
@@ -1,284 +0,0 @@
|
||||
/**
|
||||
* v1.13.0 — custom mood-tag CRUD route guards: create cap, ownership 404 on
|
||||
* edit/delete, soft-delete vs purge, and the custom-key rejection on the
|
||||
* catalogue-hide route.
|
||||
*/
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
const db = vi.hoisted(() => ({
|
||||
moodTag: {
|
||||
count: vi.fn(),
|
||||
create: vi.fn(),
|
||||
findFirst: vi.fn(),
|
||||
update: vi.fn(),
|
||||
delete: vi.fn(),
|
||||
},
|
||||
moodTagCategory: { findFirst: vi.fn() },
|
||||
moodTagHidden: { upsert: vi.fn(), deleteMany: vi.fn() },
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/db", () => ({ prisma: db }));
|
||||
vi.mock("@/lib/crypto", () => ({
|
||||
encrypt: (s: string) => `enc:${s}`,
|
||||
decrypt: (s: string) => s.replace(/^enc:/, ""),
|
||||
}));
|
||||
vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() }));
|
||||
vi.mock("@/lib/auth/audit", () => ({
|
||||
auditLog: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() }));
|
||||
vi.mock("@/lib/db-compat", () => ({
|
||||
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
vi.mock("next/headers", () => ({
|
||||
headers: vi.fn(async () => ({ get: () => null })),
|
||||
cookies: vi.fn(async () => ({
|
||||
get: () => undefined,
|
||||
set: () => {},
|
||||
delete: () => {},
|
||||
})),
|
||||
}));
|
||||
|
||||
import { POST } from "../custom/route";
|
||||
import { PATCH, DELETE } from "../custom/[key]/route";
|
||||
import { PUT } from "../[key]/hidden/route";
|
||||
import { getSession } from "@/lib/auth/session";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
|
||||
const SESSION_OK = {
|
||||
session: { id: "s1", expiresAt: new Date(Date.now() + 3_600_000) },
|
||||
user: { id: "user-1", username: "t", role: "USER" as const },
|
||||
};
|
||||
|
||||
function jsonReq(url: string, method: string, body: unknown) {
|
||||
return new NextRequest(url, {
|
||||
method,
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
});
|
||||
|
||||
describe("POST /api/mood/tags/custom", () => {
|
||||
it("creates a custom tag and returns it (custom:true, decrypted label)", async () => {
|
||||
db.moodTag.count.mockResolvedValue(3);
|
||||
db.moodTag.create.mockResolvedValue({
|
||||
key: "custom:abc",
|
||||
icon: "Heart",
|
||||
kind: "BINARY",
|
||||
scaleMin: 1,
|
||||
scaleMax: 5,
|
||||
inverse: false,
|
||||
});
|
||||
const res = await POST(
|
||||
jsonReq("http://localhost/api/mood/tags/custom", "POST", {
|
||||
label: "Date night",
|
||||
icon: "Heart",
|
||||
}),
|
||||
);
|
||||
expect(res.status).toBe(201);
|
||||
const body = (await res.json()) as {
|
||||
data: { custom: boolean; label: string; labelKey: null; key: string };
|
||||
};
|
||||
expect(body.data).toMatchObject({
|
||||
custom: true,
|
||||
label: "Date night",
|
||||
labelKey: null,
|
||||
key: "custom:abc",
|
||||
});
|
||||
// Label is stored encrypted, never plaintext.
|
||||
expect(db.moodTag.create.mock.calls[0][0].data.labelEncrypted).toBe(
|
||||
"enc:Date night",
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects over the per-user cap with 422", async () => {
|
||||
db.moodTag.count.mockResolvedValue(50);
|
||||
const res = await POST(
|
||||
jsonReq("http://localhost/api/mood/tags/custom", "POST", { label: "X" }),
|
||||
);
|
||||
expect(res.status).toBe(422);
|
||||
expect(db.moodTag.create).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("defaults to the seeded custom category without a categoryKey (v1.13 contract)", async () => {
|
||||
db.moodTag.count.mockResolvedValue(0);
|
||||
db.moodTag.create.mockResolvedValue({
|
||||
key: "custom:x",
|
||||
icon: null,
|
||||
kind: "BINARY",
|
||||
scaleMin: 1,
|
||||
scaleMax: 5,
|
||||
inverse: false,
|
||||
});
|
||||
await POST(
|
||||
jsonReq("http://localhost/api/mood/tags/custom", "POST", { label: "X" }),
|
||||
);
|
||||
expect(db.moodTagCategory.findFirst).not.toHaveBeenCalled();
|
||||
expect(db.moodTag.create.mock.calls[0][0].data.categoryId).toBe(
|
||||
"mtc_custom",
|
||||
);
|
||||
});
|
||||
|
||||
it("resolves categoryKey to a seeded OR own group (v1.17.0) and 422s an unknown one", async () => {
|
||||
db.moodTag.count.mockResolvedValue(0);
|
||||
db.moodTagCategory.findFirst.mockResolvedValue({ id: "cg1" });
|
||||
db.moodTag.create.mockResolvedValue({
|
||||
key: "custom:x",
|
||||
icon: null,
|
||||
kind: "BINARY",
|
||||
scaleMin: 1,
|
||||
scaleMax: 5,
|
||||
inverse: false,
|
||||
});
|
||||
const ok = await POST(
|
||||
jsonReq("http://localhost/api/mood/tags/custom", "POST", {
|
||||
label: "X",
|
||||
categoryKey: "customcat:g1",
|
||||
}),
|
||||
);
|
||||
expect(ok.status).toBe(201);
|
||||
expect(db.moodTag.create.mock.calls[0][0].data.categoryId).toBe("cg1");
|
||||
// Resolution is owner-scoped: seeded (userId null) OR the caller's own.
|
||||
const catWhere = db.moodTagCategory.findFirst.mock.calls[0][0].where;
|
||||
expect(catWhere).toEqual({
|
||||
key: "customcat:g1",
|
||||
isActive: true,
|
||||
OR: [{ userId: null }, { userId: "user-1" }],
|
||||
});
|
||||
|
||||
db.moodTagCategory.findFirst.mockResolvedValue(null);
|
||||
const bad = await POST(
|
||||
jsonReq("http://localhost/api/mood/tags/custom", "POST", {
|
||||
label: "X",
|
||||
categoryKey: "customcat:theirs",
|
||||
}),
|
||||
);
|
||||
expect(bad.status).toBe(422);
|
||||
});
|
||||
});
|
||||
|
||||
describe("PATCH/DELETE /api/mood/tags/custom/:key", () => {
|
||||
const params = (key: string) => ({ params: Promise.resolve({ key }) });
|
||||
|
||||
it("404s when the key is not the caller's own custom tag", async () => {
|
||||
db.moodTag.findFirst.mockResolvedValue(null);
|
||||
const res = await PATCH(
|
||||
jsonReq("http://localhost/api/mood/tags/custom/custom:x", "PATCH", {
|
||||
isActive: false,
|
||||
}),
|
||||
params("custom:x"),
|
||||
);
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
|
||||
it("404s a non-custom key without touching the DB", async () => {
|
||||
const res = await DELETE(
|
||||
new NextRequest("http://localhost/api/mood/tags/custom/happy"),
|
||||
params("happy"),
|
||||
);
|
||||
expect(res.status).toBe(404);
|
||||
expect(db.moodTag.findFirst).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("moves a custom tag to another group via categoryKey (v1.17.0)", async () => {
|
||||
db.moodTag.findFirst.mockResolvedValue({ id: "id1" });
|
||||
db.moodTagCategory.findFirst.mockResolvedValue({ id: "cg1" });
|
||||
db.moodTag.update.mockResolvedValue({
|
||||
key: "custom:x",
|
||||
icon: null,
|
||||
kind: "BINARY",
|
||||
scaleMin: 1,
|
||||
scaleMax: 5,
|
||||
inverse: false,
|
||||
isActive: true,
|
||||
labelEncrypted: "enc:X",
|
||||
});
|
||||
const res = await PATCH(
|
||||
jsonReq("http://localhost/api/mood/tags/custom/custom:x", "PATCH", {
|
||||
categoryKey: "customcat:g1",
|
||||
}),
|
||||
params("custom:x"),
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
expect(db.moodTag.update.mock.calls[0][0].data).toEqual({
|
||||
categoryId: "cg1",
|
||||
});
|
||||
});
|
||||
|
||||
it("422s a move to an unknown / foreign group without writing", async () => {
|
||||
db.moodTag.findFirst.mockResolvedValue({ id: "id1" });
|
||||
db.moodTagCategory.findFirst.mockResolvedValue(null);
|
||||
const res = await PATCH(
|
||||
jsonReq("http://localhost/api/mood/tags/custom/custom:x", "PATCH", {
|
||||
categoryKey: "ghost",
|
||||
}),
|
||||
params("custom:x"),
|
||||
);
|
||||
expect(res.status).toBe(422);
|
||||
expect(db.moodTag.update).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("soft-deactivates by default and hard-deletes on ?purge=true", async () => {
|
||||
db.moodTag.findFirst.mockResolvedValue({ id: "id1" });
|
||||
const soft = await DELETE(
|
||||
new NextRequest("http://localhost/api/mood/tags/custom/custom:abc"),
|
||||
params("custom:abc"),
|
||||
);
|
||||
expect(soft.status).toBe(200);
|
||||
expect(db.moodTag.update).toHaveBeenCalledWith({
|
||||
where: { id: "id1" },
|
||||
data: { isActive: false },
|
||||
});
|
||||
expect(db.moodTag.delete).not.toHaveBeenCalled();
|
||||
|
||||
vi.clearAllMocks();
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
db.moodTag.findFirst.mockResolvedValue({ id: "id1" });
|
||||
const purge = await DELETE(
|
||||
new NextRequest(
|
||||
"http://localhost/api/mood/tags/custom/custom:abc?purge=true",
|
||||
),
|
||||
params("custom:abc"),
|
||||
);
|
||||
expect(purge.status).toBe(200);
|
||||
expect(db.moodTag.delete).toHaveBeenCalledWith({ where: { id: "id1" } });
|
||||
// A purge cascades `mood_entry_tag_links` and unpicks history, so it must
|
||||
// leave a ledger row recording that it happened.
|
||||
expect(vi.mocked(auditLog)).toHaveBeenCalledWith(
|
||||
"mood.tag.custom.delete",
|
||||
expect.objectContaining({
|
||||
details: expect.objectContaining({ purge: true }),
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("PUT /api/mood/tags/:key/hidden", () => {
|
||||
it("rejects a custom key with 400 (custom tags hide via isActive)", async () => {
|
||||
const res = await PUT(
|
||||
jsonReq("http://localhost/api/mood/tags/custom:abc/hidden", "PUT", {
|
||||
hidden: true,
|
||||
}),
|
||||
{ params: Promise.resolve({ key: "custom:abc" }) },
|
||||
);
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
it("upserts a hide row for a catalogue tag", async () => {
|
||||
db.moodTag.findFirst.mockResolvedValue({ id: "t_sad" });
|
||||
const res = await PUT(
|
||||
jsonReq("http://localhost/api/mood/tags/sad/hidden", "PUT", {
|
||||
hidden: true,
|
||||
}),
|
||||
{ params: Promise.resolve({ key: "sad" }) },
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
expect(db.moodTagHidden.upsert).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -1,256 +0,0 @@
|
||||
/**
|
||||
* v1.17.0 — custom mood-tag group CRUD guards: create cap, ownership 404 on
|
||||
* edit/delete, and the non-destructive delete (re-home own tags → seeded
|
||||
* `custom`, strip the group from the layout blob, soft-deactivate by default
|
||||
* vs hard-delete on ?purge=true).
|
||||
*/
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
const db = vi.hoisted(() => {
|
||||
const base = {
|
||||
moodTagCategory: {
|
||||
count: vi.fn(),
|
||||
create: vi.fn(),
|
||||
findFirst: vi.fn(),
|
||||
update: vi.fn(),
|
||||
delete: vi.fn(),
|
||||
},
|
||||
moodTag: { updateMany: vi.fn() },
|
||||
user: { findUnique: vi.fn(), update: vi.fn() },
|
||||
$transaction: vi.fn(),
|
||||
};
|
||||
// Interactive tx: hand the same mock surface to the callback.
|
||||
base.$transaction.mockImplementation(
|
||||
async (fn: (tx: typeof base) => Promise<unknown>) => fn(base),
|
||||
);
|
||||
return base;
|
||||
});
|
||||
|
||||
vi.mock("@/lib/db", () => ({
|
||||
prisma: db,
|
||||
toJson: <T>(v: T) => v,
|
||||
}));
|
||||
vi.mock("@/lib/crypto", () => ({
|
||||
encrypt: (s: string) => `enc:${s}`,
|
||||
decrypt: (s: string) => s.replace(/^enc:/, ""),
|
||||
}));
|
||||
vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() }));
|
||||
vi.mock("@/lib/auth/audit", () => ({
|
||||
auditLog: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() }));
|
||||
vi.mock("@/lib/db-compat", () => ({
|
||||
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
vi.mock("next/headers", () => ({
|
||||
headers: vi.fn(async () => ({ get: () => null })),
|
||||
cookies: vi.fn(async () => ({
|
||||
get: () => undefined,
|
||||
set: () => {},
|
||||
delete: () => {},
|
||||
})),
|
||||
}));
|
||||
|
||||
import { POST } from "../groups/route";
|
||||
import { PATCH, DELETE } from "../groups/[key]/route";
|
||||
import { getSession } from "@/lib/auth/session";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import { CUSTOM_CATEGORY_ID } from "@/lib/mood/custom-tags";
|
||||
|
||||
const SESSION_OK = {
|
||||
session: { id: "s1", expiresAt: new Date(Date.now() + 3_600_000) },
|
||||
user: { id: "user-1", username: "t", role: "USER" as const },
|
||||
};
|
||||
|
||||
function jsonReq(url: string, method: string, body: unknown) {
|
||||
return new NextRequest(url, {
|
||||
method,
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
}
|
||||
|
||||
const params = (key: string) => ({ params: Promise.resolve({ key }) });
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
db.$transaction.mockImplementation(
|
||||
async (fn: (tx: typeof db) => Promise<unknown>) => fn(db),
|
||||
);
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
});
|
||||
|
||||
describe("POST /api/mood/tags/groups", () => {
|
||||
it("creates a group and returns it (custom:true, decrypted label)", async () => {
|
||||
db.moodTagCategory.count.mockResolvedValue(2);
|
||||
db.moodTagCategory.create.mockResolvedValue({
|
||||
key: "customcat:abc",
|
||||
icon: "Stethoscope",
|
||||
});
|
||||
const res = await POST(
|
||||
jsonReq("http://localhost/api/mood/tags/groups", "POST", {
|
||||
label: "Therapie",
|
||||
icon: "Stethoscope",
|
||||
}),
|
||||
);
|
||||
expect(res.status).toBe(201);
|
||||
const body = (await res.json()) as {
|
||||
data: { key: string; label: string; labelKey: null; custom: boolean };
|
||||
};
|
||||
expect(body.data).toMatchObject({
|
||||
key: "customcat:abc",
|
||||
label: "Therapie",
|
||||
labelKey: null,
|
||||
custom: true,
|
||||
});
|
||||
const data = db.moodTagCategory.create.mock.calls[0][0].data;
|
||||
// Label is stored encrypted, never plaintext; owner pinned from session.
|
||||
expect(data.labelEncrypted).toBe("enc:Therapie");
|
||||
expect(data.userId).toBe("user-1");
|
||||
expect(data.key.startsWith("customcat:")).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects over the per-user cap with 422", async () => {
|
||||
db.moodTagCategory.count.mockResolvedValue(12);
|
||||
const res = await POST(
|
||||
jsonReq("http://localhost/api/mood/tags/groups", "POST", { label: "X" }),
|
||||
);
|
||||
expect(res.status).toBe(422);
|
||||
expect(db.moodTagCategory.create).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("PATCH /api/mood/tags/groups/:key", () => {
|
||||
it("404s a seeded category key without touching the DB", async () => {
|
||||
const res = await PATCH(
|
||||
jsonReq("http://localhost/api/mood/tags/groups/feelings", "PATCH", {
|
||||
label: "x",
|
||||
}),
|
||||
params("feelings"),
|
||||
);
|
||||
expect(res.status).toBe(404);
|
||||
expect(db.moodTagCategory.findFirst).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("404s when the key is not the caller's own group", async () => {
|
||||
db.moodTagCategory.findFirst.mockResolvedValue(null);
|
||||
const res = await PATCH(
|
||||
jsonReq("http://localhost/api/mood/tags/groups/customcat:x", "PATCH", {
|
||||
label: "x",
|
||||
}),
|
||||
params("customcat:x"),
|
||||
);
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
|
||||
it("renames (re-encrypts) and returns the updated group", async () => {
|
||||
db.moodTagCategory.findFirst.mockResolvedValue({ id: "cg1" });
|
||||
db.moodTagCategory.update.mockResolvedValue({
|
||||
key: "customcat:x",
|
||||
icon: null,
|
||||
isActive: true,
|
||||
labelEncrypted: "enc:Neu",
|
||||
});
|
||||
const res = await PATCH(
|
||||
jsonReq("http://localhost/api/mood/tags/groups/customcat:x", "PATCH", {
|
||||
label: "Neu",
|
||||
}),
|
||||
params("customcat:x"),
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
const body = (await res.json()) as {
|
||||
data: { label: string; isActive: boolean };
|
||||
};
|
||||
expect(body.data).toMatchObject({ label: "Neu", isActive: true });
|
||||
expect(db.moodTagCategory.update.mock.calls[0][0].data).toEqual({
|
||||
labelEncrypted: "enc:Neu",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("DELETE /api/mood/tags/groups/:key", () => {
|
||||
beforeEach(() => {
|
||||
db.moodTagCategory.findFirst.mockResolvedValue({ id: "cg1" });
|
||||
db.moodTag.updateMany.mockResolvedValue({ count: 2 });
|
||||
db.user.findUnique.mockResolvedValue({
|
||||
moodTagLayoutJson: {
|
||||
groupOrder: ["customcat:x", "feelings"],
|
||||
placements: { "customcat:x": ["happy"], feelings: ["sad"] },
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("re-homes own tags, strips the layout, soft-deactivates by default", async () => {
|
||||
const res = await DELETE(
|
||||
new NextRequest("http://localhost/api/mood/tags/groups/customcat:x"),
|
||||
params("customcat:x"),
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
const body = (await res.json()) as {
|
||||
data: { purged: boolean; rehomedCount: number };
|
||||
};
|
||||
expect(body.data).toMatchObject({ purged: false, rehomedCount: 2 });
|
||||
|
||||
// Own custom tags re-home to the seeded custom category.
|
||||
expect(db.moodTag.updateMany).toHaveBeenCalledWith({
|
||||
where: { categoryId: "cg1", userId: "user-1" },
|
||||
data: { categoryId: CUSTOM_CATEGORY_ID },
|
||||
});
|
||||
// Layout blob drops the group from order + placements, keeps the rest.
|
||||
expect(db.user.update).toHaveBeenCalledWith({
|
||||
where: { id: "user-1" },
|
||||
data: {
|
||||
moodTagLayoutJson: {
|
||||
groupOrder: ["feelings"],
|
||||
placements: { feelings: ["sad"] },
|
||||
},
|
||||
},
|
||||
});
|
||||
// Default = retire, never delete.
|
||||
expect(db.moodTagCategory.update).toHaveBeenCalledWith({
|
||||
where: { id: "cg1" },
|
||||
data: { isActive: false },
|
||||
});
|
||||
expect(db.moodTagCategory.delete).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("hard-deletes the emptied group row on ?purge=true", async () => {
|
||||
const res = await DELETE(
|
||||
new NextRequest(
|
||||
"http://localhost/api/mood/tags/groups/customcat:x?purge=true",
|
||||
),
|
||||
params("customcat:x"),
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
expect(db.moodTagCategory.delete).toHaveBeenCalledWith({
|
||||
where: { id: "cg1" },
|
||||
});
|
||||
expect(db.moodTagCategory.update).not.toHaveBeenCalled();
|
||||
// Re-home still ran first — purge removes the group, never the tags.
|
||||
expect(db.moodTag.updateMany).toHaveBeenCalled();
|
||||
// A group delete/purge leaves a ledger row recording purge + re-home.
|
||||
expect(vi.mocked(auditLog)).toHaveBeenCalledWith(
|
||||
"mood.tag.group.delete",
|
||||
expect.objectContaining({
|
||||
details: expect.objectContaining({ purge: true }),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("404s a non-custom key and a foreign group", async () => {
|
||||
const seeded = await DELETE(
|
||||
new NextRequest("http://localhost/api/mood/tags/groups/custom"),
|
||||
params("custom"),
|
||||
);
|
||||
expect(seeded.status).toBe(404);
|
||||
|
||||
db.moodTagCategory.findFirst.mockResolvedValue(null);
|
||||
const foreign = await DELETE(
|
||||
new NextRequest("http://localhost/api/mood/tags/groups/customcat:theirs"),
|
||||
params("customcat:theirs"),
|
||||
);
|
||||
expect(foreign.status).toBe(404);
|
||||
expect(db.moodTag.updateMany).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -1,139 +0,0 @@
|
||||
/**
|
||||
* v1.17.0 — `GET/PUT /api/mood/tags/layout`: resolved groupOrder over the
|
||||
* effective category set, preserve-when-absent merge on PUT, and the Zod
|
||||
* bounds on the blob.
|
||||
*/
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
const db = vi.hoisted(() => ({
|
||||
user: { findUnique: vi.fn(), update: vi.fn(), updateMany: vi.fn() },
|
||||
moodTagCategory: { findMany: vi.fn() },
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/db", () => ({
|
||||
prisma: db,
|
||||
toJson: <T>(v: T) => v,
|
||||
}));
|
||||
vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() }));
|
||||
vi.mock("@/lib/auth/audit", () => ({
|
||||
auditLog: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() }));
|
||||
vi.mock("@/lib/db-compat", () => ({
|
||||
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
vi.mock("next/headers", () => ({
|
||||
headers: vi.fn(async () => ({ get: () => null })),
|
||||
cookies: vi.fn(async () => ({
|
||||
get: () => undefined,
|
||||
set: () => {},
|
||||
delete: () => {},
|
||||
})),
|
||||
}));
|
||||
|
||||
import { GET, PUT } from "../layout/route";
|
||||
import { getSession } from "@/lib/auth/session";
|
||||
|
||||
const SESSION_OK = {
|
||||
session: { id: "s1", expiresAt: new Date(Date.now() + 3_600_000) },
|
||||
user: { id: "user-1", username: "t", role: "USER" as const },
|
||||
};
|
||||
|
||||
const CATEGORY_KEYS = [
|
||||
{ key: "feelings" },
|
||||
{ key: "custom" },
|
||||
{ key: "customcat:g1" },
|
||||
];
|
||||
|
||||
function putReq(body: unknown) {
|
||||
return new NextRequest("http://localhost/api/mood/tags/layout", {
|
||||
method: "PUT",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
db.moodTagCategory.findMany.mockResolvedValue(CATEGORY_KEYS);
|
||||
db.user.findUnique.mockResolvedValue({ moodTagLayoutJson: null });
|
||||
});
|
||||
|
||||
describe("GET /api/mood/tags/layout", () => {
|
||||
it("returns seeded defaults when nothing is stored", async () => {
|
||||
const res = await GET();
|
||||
expect(res.status).toBe(200);
|
||||
const body = (await res.json()) as {
|
||||
data: { groupOrder: string[]; placements: Record<string, string[]> };
|
||||
};
|
||||
expect(body.data).toEqual({
|
||||
groupOrder: ["feelings", "custom", "customcat:g1"],
|
||||
placements: {},
|
||||
});
|
||||
});
|
||||
|
||||
it("resolves the stored order: layout first, unknown dropped, missing appended", async () => {
|
||||
db.user.findUnique.mockResolvedValue({
|
||||
moodTagLayoutJson: {
|
||||
groupOrder: ["customcat:g1", "ghost", "feelings"],
|
||||
placements: { "customcat:g1": ["happy"] },
|
||||
},
|
||||
});
|
||||
const res = await GET();
|
||||
const body = (await res.json()) as {
|
||||
data: { groupOrder: string[]; placements: Record<string, string[]> };
|
||||
};
|
||||
expect(body.data.groupOrder).toEqual([
|
||||
"customcat:g1",
|
||||
"feelings",
|
||||
"custom",
|
||||
]);
|
||||
expect(body.data.placements).toEqual({ "customcat:g1": ["happy"] });
|
||||
});
|
||||
});
|
||||
|
||||
describe("PUT /api/mood/tags/layout", () => {
|
||||
it("merges preserve-when-absent: a groupOrder-only PUT keeps stored placements", async () => {
|
||||
db.user.findUnique.mockResolvedValue({
|
||||
moodTagLayoutJson: { placements: { feelings: ["sad"] } },
|
||||
});
|
||||
const res = await PUT(putReq({ groupOrder: ["custom", "feelings"] }));
|
||||
expect(res.status).toBe(200);
|
||||
expect(db.user.update).toHaveBeenCalledWith({
|
||||
where: { id: "user-1" },
|
||||
// v1.32.22 — tokenless write echoes the fresh `updatedAt` token.
|
||||
select: { updatedAt: true },
|
||||
data: {
|
||||
moodTagLayoutJson: {
|
||||
groupOrder: ["custom", "feelings"],
|
||||
placements: { feelings: ["sad"] },
|
||||
},
|
||||
},
|
||||
});
|
||||
const body = (await res.json()) as {
|
||||
data: { groupOrder: string[]; placements: Record<string, string[]> };
|
||||
};
|
||||
expect(body.data.groupOrder).toEqual([
|
||||
"custom",
|
||||
"feelings",
|
||||
"customcat:g1",
|
||||
]);
|
||||
expect(body.data.placements).toEqual({ feelings: ["sad"] });
|
||||
});
|
||||
|
||||
it("422s an over-bound blob without writing", async () => {
|
||||
const res = await PUT(
|
||||
putReq({ groupOrder: Array.from({ length: 51 }, (_, i) => `g${i}`) }),
|
||||
);
|
||||
expect(res.status).toBe(422);
|
||||
expect(db.user.update).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("422s a malformed body (non-array groupOrder)", async () => {
|
||||
const res = await PUT(putReq({ groupOrder: "feelings" }));
|
||||
expect(res.status).toBe(422);
|
||||
expect(db.user.update).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -1,354 +0,0 @@
|
||||
/**
|
||||
* v1.12.0 / v1.13.0 / v1.17.0 — `GET /api/mood/tags` exposes the rated-factor
|
||||
* metadata (`kind` / `scaleMin` / `scaleMax` / `inverse`) AND the effective
|
||||
* per-user set: own custom tags merged in (label decrypted, `custom: true`),
|
||||
* hidden catalogue tags omitted by default and surfaced under
|
||||
* `?include=hidden`. v1.17.0 layers custom groups, the layout blob (group
|
||||
* order + placements), `include=archived` (own inactive customs) and
|
||||
* `include=usage` (per-tag live-entry counts) on top.
|
||||
*/
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
const categoryFindMany = vi.fn();
|
||||
const tagFindMany = vi.fn();
|
||||
const hiddenFindMany = vi.fn();
|
||||
const userFindUnique = vi.fn();
|
||||
const linkGroupBy = vi.fn();
|
||||
|
||||
vi.mock("@/lib/db", () => ({
|
||||
prisma: {
|
||||
moodTagCategory: { findMany: (...a: unknown[]) => categoryFindMany(...a) },
|
||||
moodTag: { findMany: (...a: unknown[]) => tagFindMany(...a) },
|
||||
moodTagHidden: { findMany: (...a: unknown[]) => hiddenFindMany(...a) },
|
||||
user: { findUnique: (...a: unknown[]) => userFindUnique(...a) },
|
||||
moodEntryTagLink: { groupBy: (...a: unknown[]) => linkGroupBy(...a) },
|
||||
},
|
||||
}));
|
||||
|
||||
// Identity-ish crypto so the decrypted custom label is asserted in plaintext.
|
||||
vi.mock("@/lib/crypto", () => ({
|
||||
encrypt: (s: string) => `enc:${s}`,
|
||||
decrypt: (s: string) => s.replace(/^enc:/, ""),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() }));
|
||||
vi.mock("@/lib/auth/audit", () => ({
|
||||
auditLog: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() }));
|
||||
vi.mock("@/lib/db-compat", () => ({
|
||||
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
vi.mock("next/headers", () => ({
|
||||
headers: vi.fn(async () => ({ get: () => null })),
|
||||
cookies: vi.fn(async () => ({
|
||||
get: () => undefined,
|
||||
set: () => {},
|
||||
delete: () => {},
|
||||
})),
|
||||
}));
|
||||
|
||||
import { GET } from "../route";
|
||||
import { getSession } from "@/lib/auth/session";
|
||||
|
||||
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 CATEGORIES = [
|
||||
{
|
||||
id: "c1",
|
||||
key: "feelings",
|
||||
labelKey: "mood.tagCategory.feelings",
|
||||
icon: "Smile",
|
||||
userId: null,
|
||||
labelEncrypted: null,
|
||||
},
|
||||
{
|
||||
id: "mtc_custom",
|
||||
key: "custom",
|
||||
labelKey: "mood.tagCategory.custom",
|
||||
icon: "Tag",
|
||||
userId: null,
|
||||
labelEncrypted: null,
|
||||
},
|
||||
];
|
||||
|
||||
const OWN_GROUP = {
|
||||
id: "cg1",
|
||||
key: "customcat:g1",
|
||||
labelKey: "customcat:g1",
|
||||
icon: "Stethoscope",
|
||||
userId: "user-1",
|
||||
labelEncrypted: "enc:Therapie",
|
||||
};
|
||||
|
||||
const TAGS = [
|
||||
{
|
||||
id: "t_happy",
|
||||
categoryId: "c1",
|
||||
key: "happy",
|
||||
labelKey: "mood.tag.happy",
|
||||
icon: "Smile",
|
||||
kind: "BINARY",
|
||||
scaleMin: 1,
|
||||
scaleMax: 5,
|
||||
inverse: false,
|
||||
isActive: true,
|
||||
userId: null,
|
||||
labelEncrypted: null,
|
||||
},
|
||||
{
|
||||
id: "t_sad",
|
||||
categoryId: "c1",
|
||||
key: "sad",
|
||||
labelKey: "mood.tag.sad",
|
||||
icon: "Frown",
|
||||
kind: "BINARY",
|
||||
scaleMin: 1,
|
||||
scaleMax: 5,
|
||||
inverse: false,
|
||||
isActive: true,
|
||||
userId: null,
|
||||
labelEncrypted: null,
|
||||
},
|
||||
{
|
||||
id: "t_custom",
|
||||
categoryId: "mtc_custom",
|
||||
key: "custom:abc",
|
||||
labelKey: "custom:abc",
|
||||
icon: "Heart",
|
||||
kind: "BINARY",
|
||||
scaleMin: 1,
|
||||
scaleMax: 5,
|
||||
inverse: false,
|
||||
isActive: true,
|
||||
userId: "user-1",
|
||||
labelEncrypted: "enc:Migräne",
|
||||
},
|
||||
];
|
||||
|
||||
const ARCHIVED_TAG = {
|
||||
id: "t_arch",
|
||||
categoryId: "mtc_custom",
|
||||
key: "custom:arch",
|
||||
labelKey: "custom:arch",
|
||||
icon: null,
|
||||
kind: "BINARY",
|
||||
scaleMin: 1,
|
||||
scaleMax: 5,
|
||||
inverse: false,
|
||||
isActive: false,
|
||||
userId: "user-1",
|
||||
labelEncrypted: "enc:Alt",
|
||||
};
|
||||
|
||||
interface TagOut {
|
||||
key: string;
|
||||
labelKey: string | null;
|
||||
label: string | null;
|
||||
custom: boolean;
|
||||
kind: string;
|
||||
scaleMin: number;
|
||||
scaleMax: number;
|
||||
inverse: boolean;
|
||||
hidden?: boolean;
|
||||
archived?: boolean;
|
||||
usageCount?: number;
|
||||
}
|
||||
interface CategoryOut {
|
||||
key: string;
|
||||
labelKey: string | null;
|
||||
label: string | null;
|
||||
icon: string | null;
|
||||
custom: boolean;
|
||||
tags: TagOut[];
|
||||
}
|
||||
interface Body {
|
||||
data: { categories: CategoryOut[] };
|
||||
}
|
||||
|
||||
function req(url = "http://localhost/api/mood/tags") {
|
||||
return new NextRequest(url);
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
categoryFindMany.mockResolvedValue(CATEGORIES);
|
||||
tagFindMany.mockResolvedValue(TAGS);
|
||||
hiddenFindMany.mockResolvedValue([{ moodTagId: "t_sad" }]);
|
||||
userFindUnique.mockResolvedValue({ moodTagLayoutJson: null });
|
||||
linkGroupBy.mockResolvedValue([]);
|
||||
});
|
||||
|
||||
function flat(body: Body): Record<string, TagOut> {
|
||||
const out: Record<string, TagOut> = {};
|
||||
for (const c of body.data.categories) for (const t of c.tags) out[t.key] = t;
|
||||
return out;
|
||||
}
|
||||
|
||||
describe("GET /api/mood/tags — effective per-user set (v1.13.0)", () => {
|
||||
it("emits rated-factor metadata + custom/label fields and omits hidden by default", async () => {
|
||||
const res = await GET(req());
|
||||
expect(res.status).toBe(200);
|
||||
const tags = flat((await res.json()) as Body);
|
||||
|
||||
// Catalogue tag: custom=false, label=null, labelKey kept, metadata present.
|
||||
expect(tags.happy).toMatchObject({
|
||||
custom: false,
|
||||
label: null,
|
||||
labelKey: "mood.tag.happy",
|
||||
kind: "BINARY",
|
||||
scaleMin: 1,
|
||||
scaleMax: 5,
|
||||
inverse: false,
|
||||
});
|
||||
expect(tags.happy.hidden).toBeUndefined();
|
||||
|
||||
// Custom tag merged in: custom=true, decrypted label, labelKey null.
|
||||
expect(tags["custom:abc"]).toMatchObject({
|
||||
custom: true,
|
||||
label: "Migräne",
|
||||
labelKey: null,
|
||||
});
|
||||
|
||||
// Hidden catalogue tag omitted by default.
|
||||
expect(tags.sad).toBeUndefined();
|
||||
|
||||
// Tag query scopes to catalogue OR the caller's own customs.
|
||||
const tagWhere = tagFindMany.mock.calls[0]?.[0]?.where;
|
||||
expect(tagWhere.OR).toEqual([{ userId: null }, { userId: "user-1" }]);
|
||||
|
||||
// Plain picker read never pays the usage groupBy.
|
||||
expect(linkGroupBy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("surfaces hidden catalogue tags with hidden:true under ?include=hidden", async () => {
|
||||
const res = await GET(req("http://localhost/api/mood/tags?include=hidden"));
|
||||
const tags = flat((await res.json()) as Body);
|
||||
expect(tags.sad).toMatchObject({ hidden: true, custom: false });
|
||||
expect(tags.happy).toMatchObject({ hidden: false });
|
||||
// Custom tags never carry a hidden flag.
|
||||
expect(tags["custom:abc"].hidden).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("GET /api/mood/tags — groups + layout + archived + usage (v1.17.0)", () => {
|
||||
it("returns own custom groups with decrypted label and custom:true on categories", async () => {
|
||||
categoryFindMany.mockResolvedValue([...CATEGORIES, OWN_GROUP]);
|
||||
tagFindMany.mockResolvedValue([
|
||||
...TAGS,
|
||||
{
|
||||
...TAGS[2],
|
||||
id: "t_c2",
|
||||
categoryId: "cg1",
|
||||
key: "custom:ing1",
|
||||
labelKey: "custom:ing1",
|
||||
labelEncrypted: "enc:Sitzung",
|
||||
},
|
||||
]);
|
||||
const res = await GET(req());
|
||||
const body = (await res.json()) as Body;
|
||||
const group = body.data.categories.find((c) => c.key === "customcat:g1");
|
||||
expect(group).toMatchObject({
|
||||
custom: true,
|
||||
label: "Therapie",
|
||||
labelKey: null,
|
||||
icon: "Stethoscope",
|
||||
});
|
||||
expect(group?.tags.map((t) => t.key)).toEqual(["custom:ing1"]);
|
||||
// Seeded categories stay labelKey-driven.
|
||||
const feelings = body.data.categories.find((c) => c.key === "feelings");
|
||||
expect(feelings).toMatchObject({
|
||||
custom: false,
|
||||
label: null,
|
||||
labelKey: "mood.tagCategory.feelings",
|
||||
});
|
||||
// Category query scopes to seeded OR own groups.
|
||||
const catWhere = categoryFindMany.mock.calls[0]?.[0]?.where;
|
||||
expect(catWhere.OR).toEqual([{ userId: null }, { userId: "user-1" }]);
|
||||
});
|
||||
|
||||
it("applies the layout blob: group order + catalogue-tag placement", async () => {
|
||||
categoryFindMany.mockResolvedValue([...CATEGORIES, OWN_GROUP]);
|
||||
userFindUnique.mockResolvedValue({
|
||||
moodTagLayoutJson: {
|
||||
groupOrder: ["customcat:g1", "custom", "feelings"],
|
||||
placements: { "customcat:g1": ["happy"] },
|
||||
},
|
||||
});
|
||||
const res = await GET(req());
|
||||
const body = (await res.json()) as Body;
|
||||
// `feelings` ends empty (happy placed away, sad hidden) → dropped.
|
||||
expect(body.data.categories.map((c) => c.key)).toEqual([
|
||||
"customcat:g1",
|
||||
"custom",
|
||||
]);
|
||||
// Catalogue tag `happy` rendered inside the user's group (placement,
|
||||
// not a categoryId change) — gone from its home category.
|
||||
const group = body.data.categories.find((c) => c.key === "customcat:g1");
|
||||
expect(group?.tags.map((t) => t.key)).toEqual(["happy"]);
|
||||
const feelings = body.data.categories.find((c) => c.key === "feelings");
|
||||
expect(feelings).toBeUndefined(); // only hidden `sad` left → dropped
|
||||
});
|
||||
|
||||
it("drops a stale layout placement (hidden tag) silently", async () => {
|
||||
categoryFindMany.mockResolvedValue([...CATEGORIES, OWN_GROUP]);
|
||||
userFindUnique.mockResolvedValue({
|
||||
moodTagLayoutJson: {
|
||||
placements: { "customcat:g1": ["sad", "ghost"] }, // sad is hidden
|
||||
},
|
||||
});
|
||||
const res = await GET(req());
|
||||
const body = (await res.json()) as Body;
|
||||
expect(
|
||||
body.data.categories.find((c) => c.key === "customcat:g1"),
|
||||
).toBeUndefined(); // nothing visible placed → empty → dropped (plain read)
|
||||
});
|
||||
|
||||
it("returns archived own customs with archived:true under include=archived and keeps empty own groups", async () => {
|
||||
categoryFindMany.mockResolvedValue([...CATEGORIES, OWN_GROUP]);
|
||||
tagFindMany.mockResolvedValue([...TAGS, ARCHIVED_TAG]);
|
||||
const res = await GET(
|
||||
req("http://localhost/api/mood/tags?include=hidden,archived"),
|
||||
);
|
||||
const body = (await res.json()) as Body;
|
||||
const tags = flat(body);
|
||||
expect(tags["custom:arch"]).toMatchObject({
|
||||
custom: true,
|
||||
archived: true,
|
||||
label: "Alt",
|
||||
});
|
||||
expect(tags["custom:abc"]).toMatchObject({ archived: false });
|
||||
// Catalogue rows never carry the archived flag.
|
||||
expect(tags.happy.archived).toBeUndefined();
|
||||
// The archived read lifts isActive only for the caller's own rows.
|
||||
const tagWhere = tagFindMany.mock.calls[0]?.[0]?.where;
|
||||
expect(tagWhere).toEqual({
|
||||
OR: [{ userId: null, isActive: true }, { userId: "user-1" }],
|
||||
});
|
||||
// Empty own group kept on a management read.
|
||||
expect(
|
||||
body.data.categories.find((c) => c.key === "customcat:g1"),
|
||||
).toMatchObject({ custom: true, tags: [] });
|
||||
});
|
||||
|
||||
it("emits usageCount per tag under include=usage (live entries only)", async () => {
|
||||
linkGroupBy.mockResolvedValue([
|
||||
{ moodTagId: "t_happy", _count: { _all: 7 } },
|
||||
]);
|
||||
const res = await GET(req("http://localhost/api/mood/tags?include=usage"));
|
||||
const tags = flat((await res.json()) as Body);
|
||||
expect(tags.happy.usageCount).toBe(7);
|
||||
expect(tags["custom:abc"].usageCount).toBe(0);
|
||||
expect(linkGroupBy).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
by: ["moodTagId"],
|
||||
where: { moodEntry: { userId: "user-1", deletedAt: null } },
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,173 +0,0 @@
|
||||
/**
|
||||
* v1.32.22 — `PUT /api/mood/tags/layout` optimistic concurrency.
|
||||
*
|
||||
* The two manage cards write DIFFERENT fields of the same blob (group order
|
||||
* vs placements) from the same page; a stale merge would resurrect the other
|
||||
* card's overwritten field. The base token 409s that instead. A tokenless PUT
|
||||
* keeps the prior unconditional write.
|
||||
*/
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
vi.mock("@/lib/db", () => ({
|
||||
prisma: {
|
||||
user: {
|
||||
findUnique: vi.fn(),
|
||||
update: vi.fn(),
|
||||
updateMany: vi.fn(),
|
||||
},
|
||||
moodTagCategory: {
|
||||
findMany: vi.fn(),
|
||||
},
|
||||
},
|
||||
toJson: <T>(v: T) => v,
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() }));
|
||||
|
||||
vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() }));
|
||||
|
||||
vi.mock("@/lib/db-compat", () => ({
|
||||
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/logging/context", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("@/lib/logging/context")>();
|
||||
return { ...actual, annotate: vi.fn() };
|
||||
});
|
||||
|
||||
vi.mock("next/headers", () => ({
|
||||
headers: vi.fn(async () => ({ get: () => null })),
|
||||
cookies: vi.fn(async () => ({
|
||||
get: () => undefined,
|
||||
set: () => {},
|
||||
delete: () => {},
|
||||
})),
|
||||
}));
|
||||
|
||||
import { GET, PUT } from "../route";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { getSession } from "@/lib/auth/session";
|
||||
|
||||
const SESSION_OK = {
|
||||
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
|
||||
user: { id: "user-1", username: "testuser", role: "USER" as const },
|
||||
};
|
||||
|
||||
function mkPut(body: unknown): NextRequest {
|
||||
return new NextRequest("http://localhost/api/mood/tags/layout", {
|
||||
method: "PUT",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
vi.mocked(prisma.moodTagCategory.findMany).mockResolvedValue([] as never);
|
||||
});
|
||||
|
||||
describe("GET /api/mood/tags/layout", () => {
|
||||
it("returns the layout plus the optimistic-concurrency token", async () => {
|
||||
vi.mocked(prisma.user.findUnique).mockResolvedValue({
|
||||
moodTagLayoutJson: { groupOrder: ["a", "b"], placements: {} },
|
||||
updatedAt: new Date("2026-07-24T10:00:00.000Z"),
|
||||
} as never);
|
||||
|
||||
const res = await (GET as () => Promise<Response>)();
|
||||
expect(res.status).toBe(200);
|
||||
const env = (await res.json()) as {
|
||||
data: { groupOrder: string[]; updatedAt?: string };
|
||||
};
|
||||
expect(env.data.updatedAt).toBe("2026-07-24T10:00:00.000Z");
|
||||
});
|
||||
|
||||
it("omits updatedAt for a fresh user (null row)", async () => {
|
||||
vi.mocked(prisma.user.findUnique).mockResolvedValue({
|
||||
moodTagLayoutJson: null,
|
||||
} as never);
|
||||
|
||||
const res = await (GET as () => Promise<Response>)();
|
||||
const env = (await res.json()) as {
|
||||
data: { updatedAt?: string };
|
||||
};
|
||||
expect(env.data.updatedAt).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("PUT /api/mood/tags/layout — optimistic concurrency", () => {
|
||||
it("guards on the base token and echoes the advanced token", async () => {
|
||||
vi.mocked(prisma.user.findUnique)
|
||||
// merge read
|
||||
.mockResolvedValueOnce({ moodTagLayoutJson: null } as never)
|
||||
// post-write fresh token
|
||||
.mockResolvedValueOnce({
|
||||
updatedAt: new Date("2026-07-24T10:05:00.000Z"),
|
||||
} as never);
|
||||
vi.mocked(prisma.user.updateMany).mockResolvedValue({ count: 1 } as never);
|
||||
|
||||
const res = await (PUT as (r: NextRequest) => Promise<Response>)(
|
||||
mkPut({
|
||||
groupOrder: ["a", "b"],
|
||||
baseUpdatedAt: "2026-07-24T10:00:00.000Z",
|
||||
}),
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
expect(prisma.user.updateMany).toHaveBeenCalledTimes(1);
|
||||
const whereArg = vi.mocked(prisma.user.updateMany).mock.calls[0]?.[0]
|
||||
?.where as { id: string; updatedAt: Date };
|
||||
expect((whereArg.updatedAt as Date).toISOString()).toBe(
|
||||
"2026-07-24T10:00:00.000Z",
|
||||
);
|
||||
expect(prisma.user.update).not.toHaveBeenCalled();
|
||||
const env = (await res.json()) as { data: { updatedAt: string } };
|
||||
expect(env.data.updatedAt).toBe("2026-07-24T10:05:00.000Z");
|
||||
});
|
||||
|
||||
it("interleaved writers: the stale-base second writer gets 409 and clobbers nothing", async () => {
|
||||
vi.mocked(prisma.user.findUnique).mockResolvedValue({
|
||||
moodTagLayoutJson: { groupOrder: ["a"], placements: {} },
|
||||
} as never);
|
||||
vi.mocked(prisma.user.updateMany).mockResolvedValue({ count: 0 } as never);
|
||||
|
||||
const res = await (PUT as (r: NextRequest) => Promise<Response>)(
|
||||
mkPut({
|
||||
placements: { a: ["t1"] },
|
||||
baseUpdatedAt: "2026-07-24T08:00:00.000Z",
|
||||
}),
|
||||
);
|
||||
expect(res.status).toBe(409);
|
||||
const env = (await res.json()) as { meta?: { errorCode?: string } };
|
||||
expect(env.meta?.errorCode).toBe("mood_tag_layout_conflict");
|
||||
expect(prisma.user.updateMany).toHaveBeenCalledTimes(1);
|
||||
expect(prisma.user.update).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("keeps the unconditional write when the token is omitted (compat)", async () => {
|
||||
vi.mocked(prisma.user.findUnique).mockResolvedValue({
|
||||
moodTagLayoutJson: null,
|
||||
} as never);
|
||||
vi.mocked(prisma.user.update).mockResolvedValue({
|
||||
updatedAt: new Date("2026-07-24T11:00:00.000Z"),
|
||||
} as never);
|
||||
|
||||
const res = await (PUT as (r: NextRequest) => Promise<Response>)(
|
||||
mkPut({ groupOrder: ["a", "b"] }),
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
expect(prisma.user.update).toHaveBeenCalledTimes(1);
|
||||
expect(prisma.user.updateMany).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("422s a malformed base token without touching the row", async () => {
|
||||
const res = await (PUT as (r: NextRequest) => Promise<Response>)(
|
||||
mkPut({ groupOrder: ["a"], baseUpdatedAt: "not-a-date" }),
|
||||
);
|
||||
expect(res.status).toBe(422);
|
||||
const env = (await res.json()) as { meta?: { errorCode?: string } };
|
||||
expect(env.meta?.errorCode).toBe("invalid_base_updated_at");
|
||||
expect(prisma.user.update).not.toHaveBeenCalled();
|
||||
expect(prisma.user.updateMany).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user