From ff9ef0883ba1a386648e091d8371b6a76d2edc8c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 22 Oct 2026 05:32:21 +0200 Subject: [PATCH] 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. --- .../mood/tags/__tests__/custom-crud.test.ts | 284 ------ .../mood/tags/__tests__/groups-crud.test.ts | 256 ------ .../api/mood/tags/__tests__/layout.test.ts | 139 --- src/app/api/mood/tags/__tests__/route.test.ts | 354 -------- .../mood/tags/layout/__tests__/route.test.ts | 173 ---- .../routes/mood-tag-custom.test.ts | 806 ++++++++++++++++++ tests/integration/routes/mood-tag-fixtures.ts | 101 +++ tests/integration/routes/mood-tags.test.ts | 687 +++++++++++++++ 8 files changed, 1594 insertions(+), 1206 deletions(-) delete mode 100644 src/app/api/mood/tags/__tests__/custom-crud.test.ts delete mode 100644 src/app/api/mood/tags/__tests__/groups-crud.test.ts delete mode 100644 src/app/api/mood/tags/__tests__/layout.test.ts delete mode 100644 src/app/api/mood/tags/__tests__/route.test.ts delete mode 100644 src/app/api/mood/tags/layout/__tests__/route.test.ts create mode 100644 tests/integration/routes/mood-tag-custom.test.ts create mode 100644 tests/integration/routes/mood-tag-fixtures.ts create mode 100644 tests/integration/routes/mood-tags.test.ts diff --git a/src/app/api/mood/tags/__tests__/custom-crud.test.ts b/src/app/api/mood/tags/__tests__/custom-crud.test.ts deleted file mode 100644 index e3c8d83da..000000000 --- a/src/app/api/mood/tags/__tests__/custom-crud.test.ts +++ /dev/null @@ -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(); - }); -}); diff --git a/src/app/api/mood/tags/__tests__/groups-crud.test.ts b/src/app/api/mood/tags/__tests__/groups-crud.test.ts deleted file mode 100644 index ce4ca9ad9..000000000 --- a/src/app/api/mood/tags/__tests__/groups-crud.test.ts +++ /dev/null @@ -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) => fn(base), - ); - return base; -}); - -vi.mock("@/lib/db", () => ({ - prisma: db, - toJson: (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) => 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(); - }); -}); diff --git a/src/app/api/mood/tags/__tests__/layout.test.ts b/src/app/api/mood/tags/__tests__/layout.test.ts deleted file mode 100644 index 4400ef564..000000000 --- a/src/app/api/mood/tags/__tests__/layout.test.ts +++ /dev/null @@ -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: (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 }; - }; - 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 }; - }; - 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 }; - }; - 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(); - }); -}); diff --git a/src/app/api/mood/tags/__tests__/route.test.ts b/src/app/api/mood/tags/__tests__/route.test.ts deleted file mode 100644 index 568ac916a..000000000 --- a/src/app/api/mood/tags/__tests__/route.test.ts +++ /dev/null @@ -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 { - const out: Record = {}; - 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 } }, - }), - ); - }); -}); diff --git a/src/app/api/mood/tags/layout/__tests__/route.test.ts b/src/app/api/mood/tags/layout/__tests__/route.test.ts deleted file mode 100644 index ff1f6bff5..000000000 --- a/src/app/api/mood/tags/layout/__tests__/route.test.ts +++ /dev/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: (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(); - 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)(); - 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)(); - 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)( - 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)( - 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)( - 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)( - 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(); - }); -}); diff --git a/tests/integration/routes/mood-tag-custom.test.ts b/tests/integration/routes/mood-tag-custom.test.ts new file mode 100644 index 000000000..c13c82fe9 --- /dev/null +++ b/tests/integration/routes/mood-tag-custom.test.ts @@ -0,0 +1,806 @@ +/** + * An account's own mood tags and tag groups, created, edited and removed + * through `/api/mood/tags/custom` and `/api/mood/tags/groups`, against the + * real database and through the real API kit. + * + * What is pinned, read back from the rows: + * - a created tag or group belongs to the caller, carries a minted key with + * its prefix, and stores its label sealed, never as plain text; + * - a tag's home group is the seeded `custom` category unless the request + * names a seeded category or one of the caller's own groups; another + * account's group, or a key nobody holds, is refused; + * - the per-account caps hold, and refused input writes nothing; + * - another account's tag or group answers 404 to every edit and removal, + * and is left exactly as it was; + * - removing a tag retires it by default and deletes it, with its entry + * links, only on `?purge=true`; removing a group never removes a tag: its + * tags move to the seeded `custom` category and the layout forgets it; + * - every create, edit and removal leaves an audit row. + */ +import { beforeEach, describe, expect, it } from "vitest"; + +import { POST as createTag } from "@/app/api/mood/tags/custom/route"; +import { + DELETE as deleteTag, + PATCH as patchTag, +} from "@/app/api/mood/tags/custom/[key]/route"; +import { POST as createGroup } from "@/app/api/mood/tags/groups/route"; +import { + DELETE as deleteGroup, + PATCH as patchGroup, +} from "@/app/api/mood/tags/groups/[key]/route"; +import { + CUSTOM_CATEGORY_ID, + MAX_CUSTOM_GROUPS_PER_USER, + MAX_CUSTOM_TAGS_PER_USER, + decryptCustomLabel, +} from "@/lib/mood/custom-tags"; + +import { anonymous, callRoute } from "../../kit/call-route"; +import { makeSession, makeUser } from "../../kit/factories"; +import { getPrismaClient, truncateAllTables } from "../setup"; +import { + auditDetails, + makeCustomGroup, + makeCustomTag, + makeMoodEntry, +} from "./mood-tag-fixtures"; + +const prisma = getPrismaClient(); + +interface TagOut { + key: string; + labelKey: string | null; + label: string | null; + icon: string | null; + kind: string; + scaleMin: number; + scaleMax: number; + inverse: boolean; + isActive?: boolean; + custom: boolean; +} + +interface GroupOut { + key: string; + labelKey: string | null; + label: string | null; + icon: string | null; + isActive?: boolean; + custom: boolean; +} + +interface Issue { + path: string; + code: string; +} + +function issuePaths(body: unknown): string[] { + const details = + typeof body === "object" && body !== null && "details" in body + ? (body as { details?: { issues?: Issue[] } }).details + : undefined; + return (details?.issues ?? []).map((i) => i.path); +} + +beforeEach(async () => { + await truncateAllTables(prisma); +}); + +describe("POST /api/mood/tags/custom", () => { + it("creates a tag the caller owns, in the seeded custom category, with its label sealed", async () => { + const owner = await makeUser(); + + const res = await callRoute(createTag, { + method: "POST", + route: "/api/mood/tags/custom", + body: { label: "Date night", icon: "Heart" }, + auth: await makeSession(owner), + }); + + expect(res.status).toBe(201); + expect(res.data).toMatchObject({ + labelKey: null, + label: "Date night", + icon: "Heart", + kind: "BINARY", + custom: true, + }); + expect(res.data.key.startsWith("custom:")).toBe(true); + + const row = await prisma.moodTag.findUniqueOrThrow({ + where: { key: res.data.key }, + }); + expect(row).toMatchObject({ + userId: owner.id, + categoryId: CUSTOM_CATEGORY_ID, + labelKey: res.data.key, + icon: "Heart", + kind: "BINARY", + isActive: true, + }); + expect(row.labelEncrypted).not.toContain("Date night"); + expect(decryptCustomLabel(row.labelEncrypted)).toBe("Date night"); + expect(await auditDetails(owner.id, "mood.tag.custom.create")).toEqual([ + { icon: "Heart" }, + ]); + }); + + it("homes the tag in a seeded category or the caller's own group when one is named", async () => { + const owner = await makeUser(); + const auth = await makeSession(owner); + const group = await makeCustomGroup(owner, "Therapy"); + const sleep = await prisma.moodTagCategory.findUniqueOrThrow({ + where: { key: "sleep" }, + }); + + const seeded = await callRoute(createTag, { + method: "POST", + route: "/api/mood/tags/custom", + body: { label: "Nap", categoryKey: "sleep" }, + auth, + }); + const own = await callRoute(createTag, { + method: "POST", + route: "/api/mood/tags/custom", + body: { label: "Session", categoryKey: group.key }, + auth, + }); + + expect(seeded.status).toBe(201); + expect(own.status).toBe(201); + expect( + ( + await prisma.moodTag.findUniqueOrThrow({ + where: { key: seeded.data.key }, + }) + ).categoryId, + ).toBe(sleep.id); + expect( + (await prisma.moodTag.findUniqueOrThrow({ where: { key: own.data.key } })) + .categoryId, + ).toBe(group.id); + }); + + it("refuses another account's group and a key nobody holds, writing nothing", async () => { + const owner = await makeUser(); + const other = await makeUser(); + const theirs = await makeCustomGroup(other, "Theirs"); + const auth = await makeSession(owner); + + for (const categoryKey of [theirs.key, "customcat:nobody", "ghost"]) { + const res = await callRoute(createTag, { + method: "POST", + route: "/api/mood/tags/custom", + body: { label: "X", categoryKey }, + auth, + }); + expect(res.status).toBe(422); + expect(res.error).toBe("Unknown category"); + } + expect(await prisma.moodTag.count({ where: { userId: owner.id } })).toBe(0); + }); + + it("refuses a tag past the cap of active tags, and a retired tag does not count", async () => { + const owner = await makeUser(); + const auth = await makeSession(owner); + for (let i = 0; i < MAX_CUSTOM_TAGS_PER_USER - 1; i++) { + await makeCustomTag(owner, `Tag ${i}`); + } + await makeCustomTag(owner, "Retired", { isActive: false }); + + const last = await callRoute(createTag, { + method: "POST", + route: "/api/mood/tags/custom", + body: { label: "Fiftieth" }, + auth, + }); + const over = await callRoute(createTag, { + method: "POST", + route: "/api/mood/tags/custom", + body: { label: "Fifty-first" }, + auth, + }); + + expect(last.status).toBe(201); + expect(over.status).toBe(422); + expect(over.error).toBe( + `Custom tag limit reached (${MAX_CUSTOM_TAGS_PER_USER})`, + ); + expect( + await prisma.moodTag.count({ + where: { userId: owner.id, isActive: true }, + }), + ).toBe(MAX_CUSTOM_TAGS_PER_USER); + }); + + it("refuses an empty label, an over-long label and an unknown icon, naming each field", async () => { + const owner = await makeUser(); + const auth = await makeSession(owner); + + const cases: Array<[unknown, string]> = [ + [{ label: "" }, "label"], + [{ label: " " }, "label"], + [{ label: "x".repeat(41) }, "label"], + [{ label: "Fine", icon: "NoSuchIcon" }, "icon"], + [{}, "label"], + ]; + for (const [body, field] of cases) { + const res = await callRoute(createTag, { + method: "POST", + route: "/api/mood/tags/custom", + body, + auth, + }); + expect(res.status).toBe(422); + expect(res.error).toBe("Validation failed"); + expect(issuePaths(res.body)).toContain(field); + } + expect(await prisma.moodTag.count({ where: { userId: owner.id } })).toBe(0); + }); + + it("refuses an anonymous caller", async () => { + const res = await callRoute(createTag, { + method: "POST", + route: "/api/mood/tags/custom", + body: { label: "X" }, + auth: anonymous, + }); + + expect(res.status).toBe(401); + expect( + await prisma.moodTag.count({ where: { userId: { not: null } } }), + ).toBe(0); + }); +}); + +describe("PATCH /api/mood/tags/custom/{key}", () => { + it("renames, re-icons and retires the caller's tag", async () => { + const owner = await makeUser(); + const tag = await makeCustomTag(owner, "Old name"); + + const res = await callRoute(patchTag, { + method: "PATCH", + route: "/api/mood/tags/custom/{key}", + params: { key: tag.key }, + body: { label: "New name", icon: "Heart", isActive: false }, + auth: await makeSession(owner), + }); + + expect(res.status).toBe(200); + expect(res.data).toMatchObject({ + key: tag.key, + label: "New name", + icon: "Heart", + isActive: false, + custom: true, + }); + const row = await prisma.moodTag.findUniqueOrThrow({ + where: { id: tag.id }, + }); + expect(decryptCustomLabel(row.labelEncrypted)).toBe("New name"); + expect(row).toMatchObject({ icon: "Heart", isActive: false }); + expect(await auditDetails(owner.id, "mood.tag.custom.update")).toEqual([ + { fields: ["label", "icon", "isActive"], isActive: false }, + ]); + }); + + it("moves the tag to the caller's own group, and refuses another account's", async () => { + const owner = await makeUser(); + const other = await makeUser(); + const tag = await makeCustomTag(owner, "Session"); + const mine = await makeCustomGroup(owner, "Therapy"); + const theirs = await makeCustomGroup(other, "Theirs"); + const auth = await makeSession(owner); + + const moved = await callRoute(patchTag, { + method: "PATCH", + route: "/api/mood/tags/custom/{key}", + params: { key: tag.key }, + body: { categoryKey: mine.key }, + auth, + }); + expect(moved.status).toBe(200); + expect( + (await prisma.moodTag.findUniqueOrThrow({ where: { id: tag.id } })) + .categoryId, + ).toBe(mine.id); + + const refused = await callRoute(patchTag, { + method: "PATCH", + route: "/api/mood/tags/custom/{key}", + params: { key: tag.key }, + body: { categoryKey: theirs.key, label: "Changed" }, + auth, + }); + expect(refused.status).toBe(422); + expect(refused.error).toBe("Unknown category"); + const row = await prisma.moodTag.findUniqueOrThrow({ + where: { id: tag.id }, + }); + expect(row.categoryId).toBe(mine.id); + expect(decryptCustomLabel(row.labelEncrypted)).toBe("Session"); + }); + + it("answers 404 for another account's tag and leaves it as it was", async () => { + const owner = await makeUser(); + const other = await makeUser(); + const theirs = await makeCustomTag(other, "Private"); + + const res = await callRoute(patchTag, { + method: "PATCH", + route: "/api/mood/tags/custom/{key}", + params: { key: theirs.key }, + body: { label: "Taken over", isActive: false }, + auth: await makeSession(owner), + }); + + expect(res.status).toBe(404); + expect( + await prisma.moodTag.findUniqueOrThrow({ where: { id: theirs.id } }), + ).toEqual(theirs); + }); + + it("answers 404 for a catalogue key and leaves the catalogue row alone", async () => { + const owner = await makeUser(); + const happy = await prisma.moodTag.findUniqueOrThrow({ + where: { key: "happy" }, + }); + + const res = await callRoute(patchTag, { + method: "PATCH", + route: "/api/mood/tags/custom/{key}", + params: { key: "happy" }, + body: { isActive: false }, + auth: await makeSession(owner), + }); + + expect(res.status).toBe(404); + expect(res.error).toBe("Not a custom tag"); + expect( + await prisma.moodTag.findUniqueOrThrow({ where: { key: "happy" } }), + ).toEqual(happy); + }); + + it("refuses an empty edit and an invalid field", async () => { + const owner = await makeUser(); + const tag = await makeCustomTag(owner, "Keep"); + const auth = await makeSession(owner); + + for (const body of [{}, { label: "" }, { isActive: "no" }]) { + const res = await callRoute(patchTag, { + method: "PATCH", + route: "/api/mood/tags/custom/{key}", + params: { key: tag.key }, + body, + auth, + }); + expect(res.status).toBe(422); + } + expect( + await prisma.moodTag.findUniqueOrThrow({ where: { id: tag.id } }), + ).toEqual(tag); + }); +}); + +describe("DELETE /api/mood/tags/custom/{key}", () => { + it("retires the tag by default and keeps the entries that picked it", async () => { + const owner = await makeUser(); + const tag = await makeCustomTag(owner, "Migraine"); + const entry = await makeMoodEntry(owner, [tag]); + + const res = await callRoute(deleteTag, { + method: "DELETE", + route: "/api/mood/tags/custom/{key}", + params: { key: tag.key }, + auth: await makeSession(owner), + }); + + expect(res.status).toBe(200); + expect(res.data).toEqual({ key: tag.key, purged: false }); + expect( + (await prisma.moodTag.findUniqueOrThrow({ where: { id: tag.id } })) + .isActive, + ).toBe(false); + expect( + await prisma.moodEntryTagLink.count({ where: { moodEntryId: entry.id } }), + ).toBe(1); + expect(await auditDetails(owner.id, "mood.tag.custom.delete")).toEqual([ + { purge: false }, + ]); + }); + + it("deletes the tag and its entry links on ?purge=true, keeping the entries", async () => { + const owner = await makeUser(); + const tag = await makeCustomTag(owner, "Migraine"); + const happy = await prisma.moodTag.findUniqueOrThrow({ + where: { key: "happy" }, + }); + const entry = await makeMoodEntry(owner, [tag, happy]); + + const res = await callRoute(deleteTag, { + method: "DELETE", + route: "/api/mood/tags/custom/{key}", + params: { key: tag.key }, + query: { purge: "true" }, + auth: await makeSession(owner), + }); + + expect(res.status).toBe(200); + expect(res.data).toEqual({ key: tag.key, purged: true }); + expect( + await prisma.moodTag.findUnique({ where: { id: tag.id } }), + ).toBeNull(); + const links = await prisma.moodEntryTagLink.findMany({ + where: { moodEntryId: entry.id }, + }); + expect(links.map((l) => l.moodTagId)).toEqual([happy.id]); + expect( + await prisma.moodEntry.findUnique({ where: { id: entry.id } }), + ).not.toBeNull(); + expect(await auditDetails(owner.id, "mood.tag.custom.delete")).toEqual([ + { purge: true }, + ]); + }); + + it("answers 404 for another account's tag, purge or not, and leaves it and its links", async () => { + const owner = await makeUser(); + const other = await makeUser(); + const theirs = await makeCustomTag(other, "Private"); + const entry = await makeMoodEntry(other, [theirs]); + const auth = await makeSession(owner); + + for (const query of [{}, { purge: "true" }]) { + const res = await callRoute(deleteTag, { + method: "DELETE", + route: "/api/mood/tags/custom/{key}", + params: { key: theirs.key }, + query, + auth, + }); + expect(res.status).toBe(404); + } + expect( + await prisma.moodTag.findUniqueOrThrow({ where: { id: theirs.id } }), + ).toEqual(theirs); + expect( + await prisma.moodEntryTagLink.count({ where: { moodEntryId: entry.id } }), + ).toBe(1); + expect(await auditDetails(owner.id, "mood.tag.custom.delete")).toEqual([]); + }); + + it("answers 404 for a catalogue key and leaves the catalogue row alone", async () => { + const owner = await makeUser(); + const happy = await prisma.moodTag.findUniqueOrThrow({ + where: { key: "happy" }, + }); + + const res = await callRoute(deleteTag, { + method: "DELETE", + route: "/api/mood/tags/custom/{key}", + params: { key: "happy" }, + query: { purge: "true" }, + auth: await makeSession(owner), + }); + + expect(res.status).toBe(404); + expect(res.error).toBe("Not a custom tag"); + expect( + await prisma.moodTag.findUniqueOrThrow({ where: { key: "happy" } }), + ).toEqual(happy); + }); +}); + +describe("POST /api/mood/tags/groups", () => { + it("creates a group the caller owns, after the seeded categories, with its label sealed", async () => { + const owner = await makeUser(); + await makeCustomGroup(owner, "Existing"); + + const res = await callRoute(createGroup, { + method: "POST", + route: "/api/mood/tags/groups", + body: { label: "Therapy", icon: "Stethoscope" }, + auth: await makeSession(owner), + }); + + expect(res.status).toBe(201); + expect(res.data).toMatchObject({ + labelKey: null, + label: "Therapy", + icon: "Stethoscope", + custom: true, + }); + expect(res.data.key.startsWith("customcat:")).toBe(true); + const row = await prisma.moodTagCategory.findUniqueOrThrow({ + where: { key: res.data.key }, + }); + expect(row).toMatchObject({ + userId: owner.id, + labelKey: res.data.key, + icon: "Stethoscope", + sortOrder: 102, + isActive: true, + }); + expect(row.labelEncrypted).not.toContain("Therapy"); + expect(decryptCustomLabel(row.labelEncrypted)).toBe("Therapy"); + expect(await auditDetails(owner.id, "mood.tag.group.create")).toEqual([ + { icon: "Stethoscope" }, + ]); + }); + + it("refuses a group past the cap and invalid input, writing nothing", async () => { + const owner = await makeUser(); + const auth = await makeSession(owner); + for (let i = 0; i < MAX_CUSTOM_GROUPS_PER_USER; i++) { + await makeCustomGroup(owner, `Group ${i}`); + } + + const over = await callRoute(createGroup, { + method: "POST", + route: "/api/mood/tags/groups", + body: { label: "One more" }, + auth, + }); + expect(over.status).toBe(422); + expect(over.error).toBe( + `Custom group limit reached (${MAX_CUSTOM_GROUPS_PER_USER})`, + ); + + const other = await makeUser(); + const invalid = await callRoute(createGroup, { + method: "POST", + route: "/api/mood/tags/groups", + body: { label: "", icon: "NoSuchIcon" }, + auth: await makeSession(other), + }); + expect(invalid.status).toBe(422); + expect(issuePaths(invalid.body)).toEqual( + expect.arrayContaining(["label", "icon"]), + ); + + expect( + await prisma.moodTagCategory.count({ where: { userId: owner.id } }), + ).toBe(MAX_CUSTOM_GROUPS_PER_USER); + expect( + await prisma.moodTagCategory.count({ where: { userId: other.id } }), + ).toBe(0); + }); +}); + +describe("PATCH /api/mood/tags/groups/{key}", () => { + it("renames and re-icons the caller's group", async () => { + const owner = await makeUser(); + const group = await makeCustomGroup(owner, "Old"); + + const res = await callRoute(patchGroup, { + method: "PATCH", + route: "/api/mood/tags/groups/{key}", + params: { key: group.key }, + body: { label: "New", icon: "Heart" }, + auth: await makeSession(owner), + }); + + expect(res.status).toBe(200); + expect(res.data).toMatchObject({ + key: group.key, + label: "New", + icon: "Heart", + isActive: true, + custom: true, + }); + const row = await prisma.moodTagCategory.findUniqueOrThrow({ + where: { id: group.id }, + }); + expect(decryptCustomLabel(row.labelEncrypted)).toBe("New"); + expect(await auditDetails(owner.id, "mood.tag.group.update")).toEqual([ + { fields: ["label", "icon"], isActive: true }, + ]); + }); + + it("answers 404 for a seeded category and for another account's group, changing neither", async () => { + const owner = await makeUser(); + const other = await makeUser(); + const theirs = await makeCustomGroup(other, "Theirs"); + const feelings = await prisma.moodTagCategory.findUniqueOrThrow({ + where: { key: "feelings" }, + }); + const auth = await makeSession(owner); + + const seeded = await callRoute(patchGroup, { + method: "PATCH", + route: "/api/mood/tags/groups/{key}", + params: { key: "feelings" }, + body: { label: "Mine now" }, + auth, + }); + const foreign = await callRoute(patchGroup, { + method: "PATCH", + route: "/api/mood/tags/groups/{key}", + params: { key: theirs.key }, + body: { label: "Mine now", isActive: false }, + auth, + }); + + expect(seeded.status).toBe(404); + expect(seeded.error).toBe("Not a custom group"); + expect(foreign.status).toBe(404); + expect( + await prisma.moodTagCategory.findUniqueOrThrow({ + where: { key: "feelings" }, + }), + ).toEqual(feelings); + expect( + await prisma.moodTagCategory.findUniqueOrThrow({ + where: { id: theirs.id }, + }), + ).toEqual(theirs); + }); + + it("refuses an empty edit", async () => { + const owner = await makeUser(); + const group = await makeCustomGroup(owner, "Keep"); + + const res = await callRoute(patchGroup, { + method: "PATCH", + route: "/api/mood/tags/groups/{key}", + params: { key: group.key }, + body: {}, + auth: await makeSession(owner), + }); + + expect(res.status).toBe(422); + expect( + await prisma.moodTagCategory.findUniqueOrThrow({ + where: { id: group.id }, + }), + ).toEqual(group); + }); +}); + +describe("DELETE /api/mood/tags/groups/{key}", () => { + async function groupWithTags() { + const owner = await makeUser(); + const group = await makeCustomGroup(owner, "Therapy"); + const kept = await makeCustomGroup(owner, "Training"); + const tags = [ + await makeCustomTag(owner, "Session", { categoryId: group.id }), + await makeCustomTag(owner, "Homework", { categoryId: group.id }), + ]; + const entry = await makeMoodEntry(owner, tags); + await prisma.user.update({ + where: { id: owner.id }, + data: { + moodTagLayoutJson: { + groupOrder: [group.key, "feelings", kept.key], + placements: { [group.key]: ["happy"], [kept.key]: ["tired"] }, + }, + }, + }); + return { owner, group, kept, tags, entry }; + } + + it("moves the group's tags to the custom category, drops it from the layout, and retires it", async () => { + const { owner, group, kept, tags, entry } = await groupWithTags(); + + const res = await callRoute(deleteGroup, { + method: "DELETE", + route: "/api/mood/tags/groups/{key}", + params: { key: group.key }, + auth: await makeSession(owner), + }); + + expect(res.status).toBe(200); + expect(res.data).toEqual({ + key: group.key, + purged: false, + rehomedCount: 2, + }); + const rows = await prisma.moodTag.findMany({ + where: { id: { in: tags.map((t) => t.id) } }, + }); + expect(rows.map((t) => [t.categoryId, t.isActive])).toEqual([ + [CUSTOM_CATEGORY_ID, true], + [CUSTOM_CATEGORY_ID, true], + ]); + expect( + await prisma.moodEntryTagLink.count({ where: { moodEntryId: entry.id } }), + ).toBe(2); + expect( + ( + await prisma.moodTagCategory.findUniqueOrThrow({ + where: { id: group.id }, + }) + ).isActive, + ).toBe(false); + expect( + (await prisma.user.findUniqueOrThrow({ where: { id: owner.id } })) + .moodTagLayoutJson, + ).toEqual({ + groupOrder: ["feelings", kept.key], + placements: { [kept.key]: ["tired"] }, + }); + expect(await auditDetails(owner.id, "mood.tag.group.delete")).toEqual([ + { purge: false, rehomedCount: 2 }, + ]); + }); + + it("deletes the emptied group row on ?purge=true and still keeps every tag", async () => { + const { owner, group, tags, entry } = await groupWithTags(); + + const res = await callRoute(deleteGroup, { + method: "DELETE", + route: "/api/mood/tags/groups/{key}", + params: { key: group.key }, + query: { purge: "true" }, + auth: await makeSession(owner), + }); + + expect(res.status).toBe(200); + expect(res.data).toEqual({ key: group.key, purged: true, rehomedCount: 2 }); + expect( + await prisma.moodTagCategory.findUnique({ where: { id: group.id } }), + ).toBeNull(); + expect( + await prisma.moodTag.count({ + where: { + id: { in: tags.map((t) => t.id) }, + categoryId: CUSTOM_CATEGORY_ID, + }, + }), + ).toBe(2); + expect( + await prisma.moodEntryTagLink.count({ where: { moodEntryId: entry.id } }), + ).toBe(2); + expect(await auditDetails(owner.id, "mood.tag.group.delete")).toEqual([ + { purge: true, rehomedCount: 2 }, + ]); + }); + + it("answers 404 for a seeded category and another account's group, moving nothing", async () => { + const { owner: other, group: theirs, tags } = await groupWithTags(); + const caller = await makeUser(); + const auth = await makeSession(caller); + const custom = await prisma.moodTagCategory.findUniqueOrThrow({ + where: { id: CUSTOM_CATEGORY_ID }, + }); + + const seeded = await callRoute(deleteGroup, { + method: "DELETE", + route: "/api/mood/tags/groups/{key}", + params: { key: custom.key }, + query: { purge: "true" }, + auth, + }); + const foreign = await callRoute(deleteGroup, { + method: "DELETE", + route: "/api/mood/tags/groups/{key}", + params: { key: theirs.key }, + query: { purge: "true" }, + auth, + }); + + expect(seeded.status).toBe(404); + expect(foreign.status).toBe(404); + expect( + await prisma.moodTagCategory.findUniqueOrThrow({ + where: { id: CUSTOM_CATEGORY_ID }, + }), + ).toEqual(custom); + expect( + await prisma.moodTagCategory.findUniqueOrThrow({ + where: { id: theirs.id }, + }), + ).toEqual(theirs); + expect( + await prisma.moodTag.count({ + where: { id: { in: tags.map((t) => t.id) }, categoryId: theirs.id }, + }), + ).toBe(2); + expect( + (await prisma.user.findUniqueOrThrow({ where: { id: other.id } })) + .moodTagLayoutJson, + ).toMatchObject({ + groupOrder: [theirs.key, "feelings", expect.any(String)], + }); + }); +}); diff --git a/tests/integration/routes/mood-tag-fixtures.ts b/tests/integration/routes/mood-tag-fixtures.ts new file mode 100644 index 000000000..72fa3be40 --- /dev/null +++ b/tests/integration/routes/mood-tag-fixtures.ts @@ -0,0 +1,101 @@ +/** + * Fixtures for the mood-tag route tests: an account's own custom tags and + * groups, stored the way the routes store them (key minted with its prefix, + * label sealed with the custom-label cipher), and mood entries that pick + * tags. Overrides are the Prisma create input, so the compiler checks them. + */ +import type { + MoodEntry, + MoodTag, + MoodTagCategory, + Prisma, + User, +} from "@/generated/prisma/client"; +import { prisma } from "@/lib/db"; +import { + CUSTOM_CATEGORY_ID, + encryptCustomLabel, + mintCustomCategoryKey, + mintCustomTagKey, +} from "@/lib/mood/custom-tags"; + +export async function makeCustomTag( + user: Pick, + label: string, + overrides: Partial< + Omit + > = {}, +): Promise { + const key = mintCustomTagKey(); + return prisma.moodTag.create({ + data: { + key, + labelKey: key, + labelEncrypted: encryptCustomLabel(label), + categoryId: CUSTOM_CATEGORY_ID, + kind: "BINARY", + userId: user.id, + ...overrides, + }, + }); +} + +export async function makeCustomGroup( + user: Pick, + label: string, + overrides: Partial< + Omit< + Prisma.MoodTagCategoryUncheckedCreateInput, + "userId" | "key" | "labelKey" + > + > = {}, +): Promise { + const key = mintCustomCategoryKey(); + return prisma.moodTagCategory.create({ + data: { + key, + labelKey: key, + labelEncrypted: encryptCustomLabel(label), + sortOrder: 101, + userId: user.id, + ...overrides, + }, + }); +} + +let entryMinute = 0; + +/** A mood entry that picked `tags`. */ +export async function makeMoodEntry( + user: Pick, + tags: ReadonlyArray>, + overrides: Partial< + Omit + > = {}, +): Promise { + entryMinute += 1; + return prisma.moodEntry.create({ + data: { + userId: user.id, + date: "2026-09-01", + mood: "GUT", + score: 4, + moodLoggedAt: new Date(Date.UTC(2026, 8, 1, 8, entryMinute)), + ...overrides, + tagLinks: { create: tags.map((t) => ({ moodTagId: t.id })) }, + }, + }); +} + +/** The audit rows filed under `userId` for `action`, details parsed. */ +export async function auditDetails( + userId: string, + action: string, +): Promise { + const rows = await prisma.auditLog.findMany({ + where: { userId, action }, + orderBy: { createdAt: "asc" }, + select: { details: true }, + }); + return rows.map((r) => (r.details ? JSON.parse(r.details) : null)); +} diff --git a/tests/integration/routes/mood-tags.test.ts b/tests/integration/routes/mood-tags.test.ts new file mode 100644 index 000000000..f91e4ec35 --- /dev/null +++ b/tests/integration/routes/mood-tags.test.ts @@ -0,0 +1,687 @@ +/** + * The effective mood-tag taxonomy an account sees (`GET /api/mood/tags`), the + * catalogue tags it hides (`PUT /api/mood/tags/{key}/hidden`) and the layout + * it arranges them in (`/api/mood/tags/layout`), against the real database + * and through the real API kit. + * + * The catalogue is reference data the migrations seed; the truncate between + * tests puts it back, so every case reads the catalogue a fresh install + * holds. Tags are picked from it by query rather than by name, because the + * migrations retire and revive catalogue tags over time. + * + * What is pinned: + * - the read carries every live catalogue tag with its rated-factor + * metadata, the caller's own tags and groups with their labels unsealed, + * and nothing of another account's; + * - a hidden catalogue tag leaves the plain read and comes back flagged + * under `?include=hidden`; hiding is per account and idempotent; + * - the layout orders the groups and places catalogue tags into the + * caller's own groups without moving their stored category, and a stale + * entry is dropped at read time; + * - `?include=archived` returns the caller's retired tags and empty groups, + * never a retired catalogue tag; `?include=usage` counts the caller's + * live entries only; + * - the layout write merges what it is sent over what is stored, and a + * write against a stale token is refused without touching the row. + */ +import { beforeEach, describe, expect, it } from "vitest"; + +import type { MoodTag } from "@/generated/prisma/client"; +import { GET as readTags } from "@/app/api/mood/tags/route"; +import { PUT as putHidden } from "@/app/api/mood/tags/[key]/hidden/route"; +import { + GET as readLayout, + PUT as putLayout, +} from "@/app/api/mood/tags/layout/route"; + +import { anonymous, callRoute } from "../../kit/call-route"; +import { + makeSession, + makeUser, + type SessionFixture, +} from "../../kit/factories"; +import { getPrismaClient, truncateAllTables } from "../setup"; +import { + makeCustomGroup, + makeCustomTag, + makeMoodEntry, +} from "./mood-tag-fixtures"; + +const prisma = getPrismaClient(); + +interface TagOut { + key: string; + labelKey: string | null; + label: string | null; + icon: string | null; + kind: string; + scaleMin: number; + scaleMax: number; + inverse: boolean; + custom: 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 Layout { + groupOrder: string[]; + placements: Record; + updatedAt?: string; +} + +async function read( + auth: SessionFixture, + include?: string, +): Promise { + const res = await callRoute<{ categories: CategoryOut[] }>(readTags, { + method: "GET", + route: "/api/mood/tags", + query: { include }, + auth, + }); + expect(res.status).toBe(200); + return res.data.categories; +} + +function tagsByKey(categories: CategoryOut[]): Map { + return new Map(categories.flatMap((c) => c.tags.map((t) => [t.key, t]))); +} + +function category( + categories: CategoryOut[], + key: string, +): CategoryOut | undefined { + return categories.find((c) => c.key === key); +} + +/** The live catalogue as the migrations left it, with each tag's category. */ +async function liveCatalogue() { + return prisma.moodTag.findMany({ + where: { + userId: null, + isActive: true, + category: { userId: null, isActive: true }, + }, + include: { category: true }, + orderBy: { sortOrder: "asc" }, + }); +} + +/** Two live catalogue tags that share a seeded category. */ +async function twoSiblings(): Promise<{ + categoryKey: string; + first: MoodTag; + second: MoodTag; +}> { + const tags = await liveCatalogue(); + for (const tag of tags) { + const sibling = tags.find( + (t) => t.categoryId === tag.categoryId && t.id !== tag.id, + ); + if (sibling) { + return { categoryKey: tag.category.key, first: tag, second: sibling }; + } + } + throw new Error("the seeded catalogue has no category with two live tags"); +} + +async function storedLayout(userId: string): Promise { + return (await prisma.user.findUniqueOrThrow({ where: { id: userId } })) + .moodTagLayoutJson; +} + +beforeEach(async () => { + await truncateAllTables(prisma); +}); + +describe("GET /api/mood/tags", () => { + it("serves the seeded catalogue to an account with nothing of its own", async () => { + const owner = await makeUser(); + const catalogue = await liveCatalogue(); + expect(catalogue.length).toBeGreaterThan(0); + + const categories = await read(await makeSession(owner)); + + const tags = tagsByKey(categories); + expect(new Set(tags.keys())).toEqual(new Set(catalogue.map((t) => t.key))); + for (const row of catalogue) { + expect(tags.get(row.key)).toEqual({ + key: row.key, + labelKey: row.labelKey, + label: null, + icon: row.icon, + kind: row.kind, + scaleMin: row.scaleMin, + scaleMax: row.scaleMax, + inverse: row.inverse, + custom: false, + }); + } + // Every category is a seeded one and holds at least one tag: the seeded + // `custom` category, empty for this account, is left out. + expect(new Set(categories.map((c) => c.key))).toEqual( + new Set(catalogue.map((t) => t.category.key)), + ); + expect(category(categories, "custom")).toBeUndefined(); + for (const c of categories) { + expect(c).toMatchObject({ custom: false, label: null }); + expect(c.labelKey).not.toBeNull(); + } + }); + + it("carries the rated-factor metadata of a rated catalogue tag", async () => { + const owner = await makeUser(); + const rated = (await liveCatalogue()).find((t) => t.kind === "RATED"); + expect(rated).toBeDefined(); + + const tags = tagsByKey(await read(await makeSession(owner))); + + expect(tags.get(rated!.key)).toMatchObject({ + kind: "RATED", + scaleMin: rated!.scaleMin, + scaleMax: rated!.scaleMax, + inverse: rated!.inverse, + }); + }); + + it("merges in the caller's own tags and groups with their labels, and nothing of another account's", async () => { + const owner = await makeUser(); + const other = await makeUser(); + const group = await makeCustomGroup(owner, "Therapy", { + icon: "Stethoscope", + }); + const inGroup = await makeCustomTag(owner, "Session", { + categoryId: group.id, + icon: "Heart", + }); + const plain = await makeCustomTag(owner, "Migraine"); + const theirGroup = await makeCustomGroup(other, "Theirs"); + const theirTag = await makeCustomTag(other, "Private"); + await makeCustomTag(other, "Private grouped", { + categoryId: theirGroup.id, + }); + + const categories = await read(await makeSession(owner)); + + expect(category(categories, group.key)).toEqual({ + key: group.key, + labelKey: null, + label: "Therapy", + icon: "Stethoscope", + custom: true, + tags: [ + { + key: inGroup.key, + labelKey: null, + label: "Session", + icon: "Heart", + kind: "BINARY", + scaleMin: 1, + scaleMax: 5, + inverse: false, + custom: true, + }, + ], + }); + expect(category(categories, "custom")?.tags.map((t) => t.key)).toEqual([ + plain.key, + ]); + expect(tagsByKey(categories).get(plain.key)).toMatchObject({ + label: "Migraine", + labelKey: null, + custom: true, + }); + expect(category(categories, theirGroup.key)).toBeUndefined(); + expect(tagsByKey(categories).has(theirTag.key)).toBe(false); + }); + + it("refuses an anonymous caller", async () => { + const res = await callRoute(readTags, { + method: "GET", + route: "/api/mood/tags", + auth: anonymous, + }); + + expect(res.status).toBe(401); + }); +}); + +describe("PUT /api/mood/tags/{key}/hidden", () => { + it("hides a catalogue tag from the caller's read only, and shows it again", async () => { + const owner = await makeUser(); + const other = await makeUser(); + const { first } = await twoSiblings(); + const auth = await makeSession(owner); + const own = await makeCustomTag(owner, "Mine"); + + for (let i = 0; i < 2; i++) { + const res = await callRoute(putHidden, { + method: "PUT", + route: "/api/mood/tags/{key}/hidden", + params: { key: first.key }, + body: { hidden: true }, + auth, + }); + expect(res.status).toBe(200); + expect(res.data).toEqual({ key: first.key, hidden: true }); + } + // Hiding twice keeps one row, and the catalogue row is untouched. + expect( + await prisma.moodTagHidden.findMany({ where: { userId: owner.id } }), + ).toEqual([expect.objectContaining({ moodTagId: first.id })]); + expect( + (await prisma.moodTag.findUniqueOrThrow({ where: { id: first.id } })) + .isActive, + ).toBe(true); + + expect(tagsByKey(await read(auth)).has(first.key)).toBe(false); + const withHidden = tagsByKey(await read(auth, "hidden")); + expect(withHidden.get(first.key)?.hidden).toBe(true); + const visible = [...withHidden.values()].filter( + (t) => !t.custom && t.key !== first.key, + ); + expect(visible.length).toBeGreaterThan(0); + expect(visible.every((t) => t.hidden === false)).toBe(true); + // A custom tag never carries the flag; it hides through its own isActive. + expect(withHidden.get(own.key)).not.toHaveProperty("hidden"); + // Another account still sees the tag. + expect(tagsByKey(await read(await makeSession(other))).has(first.key)).toBe( + true, + ); + + const shown = await callRoute(putHidden, { + method: "PUT", + route: "/api/mood/tags/{key}/hidden", + params: { key: first.key }, + body: { hidden: false }, + auth, + }); + expect(shown.status).toBe(200); + expect( + await prisma.moodTagHidden.count({ where: { userId: owner.id } }), + ).toBe(0); + expect(tagsByKey(await read(auth)).has(first.key)).toBe(true); + }); + + it("refuses a custom key with 400, an unknown key with 404, and a malformed body with 422", async () => { + const owner = await makeUser(); + const own = await makeCustomTag(owner, "Mine"); + const { first } = await twoSiblings(); + const auth = await makeSession(owner); + + const custom = await callRoute(putHidden, { + method: "PUT", + route: "/api/mood/tags/{key}/hidden", + params: { key: own.key }, + body: { hidden: true }, + auth, + }); + const unknown = await callRoute(putHidden, { + method: "PUT", + route: "/api/mood/tags/{key}/hidden", + params: { key: "no_such_tag" }, + body: { hidden: true }, + auth, + }); + const malformed = await callRoute(putHidden, { + method: "PUT", + route: "/api/mood/tags/{key}/hidden", + params: { key: first.key }, + body: { hidden: "yes" }, + auth, + }); + + expect(custom.status).toBe(400); + expect(unknown.status).toBe(404); + expect(malformed.status).toBe(422); + expect( + await prisma.moodTagHidden.count({ where: { userId: owner.id } }), + ).toBe(0); + }); +}); + +describe("the layout on the read", () => { + it("orders the groups and places a catalogue tag into the caller's group without moving it", async () => { + const owner = await makeUser(); + const { categoryKey, first, second } = await twoSiblings(); + const group = await makeCustomGroup(owner, "Therapy"); + await prisma.user.update({ + where: { id: owner.id }, + data: { + moodTagLayoutJson: { + groupOrder: [group.key, categoryKey], + placements: { [group.key]: [first.key] }, + }, + }, + }); + + const categories = await read(await makeSession(owner)); + + expect(categories.slice(0, 2).map((c) => c.key)).toEqual([ + group.key, + categoryKey, + ]); + expect(category(categories, group.key)?.tags.map((t) => t.key)).toEqual([ + first.key, + ]); + const home = category(categories, categoryKey)?.tags.map((t) => t.key); + expect(home).not.toContain(first.key); + expect(home).toContain(second.key); + // Placement is display only: the stored category is the seeded one. + expect( + (await prisma.moodTag.findUniqueOrThrow({ where: { id: first.id } })) + .categoryId, + ).toBe(first.categoryId); + }); + + it("drops a placement of a hidden or unknown tag, and the group it leaves empty", async () => { + const owner = await makeUser(); + const { first } = await twoSiblings(); + const group = await makeCustomGroup(owner, "Therapy"); + await prisma.moodTagHidden.create({ + data: { userId: owner.id, moodTagId: first.id }, + }); + await prisma.user.update({ + where: { id: owner.id }, + data: { + moodTagLayoutJson: { + placements: { [group.key]: [first.key, "ghost"] }, + }, + }, + }); + + const categories = await read(await makeSession(owner)); + + expect(category(categories, group.key)).toBeUndefined(); + expect(tagsByKey(categories).has(first.key)).toBe(false); + expect(tagsByKey(categories).has("ghost")).toBe(false); + }); +}); + +describe("GET /api/mood/tags with include", () => { + it("returns the caller's retired tags flagged and its empty groups under archived, never a retired catalogue tag", async () => { + const owner = await makeUser(); + const other = await makeUser(); + const active = await makeCustomTag(owner, "Active"); + const retired = await makeCustomTag(owner, "Retired", { isActive: false }); + const empty = await makeCustomGroup(owner, "Empty"); + const theirRetired = await makeCustomTag(other, "Theirs", { + isActive: false, + }); + const retiredCatalogue = await prisma.moodTag.findFirst({ + where: { userId: null, isActive: false }, + }); + const auth = await makeSession(owner); + + const plain = tagsByKey(await read(auth)); + expect(plain.has(retired.key)).toBe(false); + + const categories = await read(auth, "hidden,archived"); + const tags = tagsByKey(categories); + expect(tags.get(retired.key)).toMatchObject({ + custom: true, + archived: true, + label: "Retired", + }); + expect(tags.get(active.key)).toMatchObject({ archived: false }); + expect(tags.has(theirRetired.key)).toBe(false); + // The migrations retire catalogue tags; one of them must stay retired. + expect(retiredCatalogue).not.toBeNull(); + expect(tags.has(retiredCatalogue!.key)).toBe(false); + const catalogueTag = [...tags.values()].find((t) => !t.custom); + expect(catalogueTag).not.toHaveProperty("archived"); + expect(category(categories, empty.key)).toMatchObject({ + custom: true, + label: "Empty", + tags: [], + }); + }); + + it("counts each tag's use over the caller's live entries only under usage", async () => { + const owner = await makeUser(); + const other = await makeUser(); + const { first, second } = await twoSiblings(); + const own = await makeCustomTag(owner, "Mine"); + await makeMoodEntry(owner, [first, own]); + await makeMoodEntry(owner, [first]); + await makeMoodEntry(owner, [first, second], { deletedAt: new Date() }); + await makeMoodEntry(other, [first, second]); + + const tags = tagsByKey(await read(await makeSession(owner), "usage")); + + expect(tags.get(first.key)?.usageCount).toBe(2); + expect(tags.get(second.key)?.usageCount).toBe(0); + expect(tags.get(own.key)?.usageCount).toBe(1); + // The plain read carries no count. + expect( + tagsByKey(await read(await makeSession(owner))).get(first.key), + ).not.toHaveProperty("usageCount"); + }); +}); + +describe("GET /api/mood/tags/layout", () => { + it("resolves the seeded order with the caller's groups when nothing is stored", async () => { + const owner = await makeUser(); + const group = await makeCustomGroup(owner, "Therapy", { sortOrder: 500 }); + await makeCustomGroup(await makeUser(), "Theirs"); + const seeded = await prisma.moodTagCategory.findMany({ + where: { userId: null, isActive: true }, + orderBy: { sortOrder: "asc" }, + }); + + const res = await callRoute(readLayout, { + method: "GET", + route: "/api/mood/tags/layout", + auth: await makeSession(owner), + }); + + expect(res.status).toBe(200); + expect(res.data.groupOrder).toEqual([ + ...seeded.map((c) => c.key), + group.key, + ]); + expect(res.data.placements).toEqual({}); + // The account's row always exists, so the read always carries a token. + expect(res.data.updatedAt).toBe( + ( + await prisma.user.findUniqueOrThrow({ where: { id: owner.id } }) + ).updatedAt.toISOString(), + ); + }); + + it("puts the stored order first, drops unknown keys and appends the rest", async () => { + const owner = await makeUser(); + const group = await makeCustomGroup(owner, "Therapy"); + const seeded = await prisma.moodTagCategory.findMany({ + where: { userId: null, isActive: true }, + orderBy: { sortOrder: "asc" }, + }); + const last = seeded[seeded.length - 1]!.key; + await prisma.user.update({ + where: { id: owner.id }, + data: { + moodTagLayoutJson: { + groupOrder: [group.key, "ghost", last], + placements: { [group.key]: ["some_tag"] }, + }, + }, + }); + + const res = await callRoute(readLayout, { + method: "GET", + route: "/api/mood/tags/layout", + auth: await makeSession(owner), + }); + + expect(res.data.groupOrder).toEqual([ + group.key, + last, + ...seeded.map((c) => c.key).filter((k) => k !== last), + ]); + expect(res.data.placements).toEqual({ [group.key]: ["some_tag"] }); + }); +}); + +describe("PUT /api/mood/tags/layout", () => { + it("keeps the stored placements on an order-only write, and the order on a placements-only write", async () => { + const owner = await makeUser(); + const auth = await makeSession(owner); + await prisma.user.update({ + where: { id: owner.id }, + data: { moodTagLayoutJson: { placements: { feelings: ["sad"] } } }, + }); + + const ordered = await callRoute(putLayout, { + method: "PUT", + route: "/api/mood/tags/layout", + body: { groupOrder: ["custom", "feelings"] }, + auth, + }); + expect(ordered.status).toBe(200); + expect(ordered.data.groupOrder.slice(0, 2)).toEqual(["custom", "feelings"]); + expect(ordered.data.placements).toEqual({ feelings: ["sad"] }); + expect(await storedLayout(owner.id)).toEqual({ + groupOrder: ["custom", "feelings"], + placements: { feelings: ["sad"] }, + }); + + const placed = await callRoute(putLayout, { + method: "PUT", + route: "/api/mood/tags/layout", + body: { placements: { custom: ["tired"] } }, + auth, + }); + expect(placed.status).toBe(200); + expect(await storedLayout(owner.id)).toEqual({ + groupOrder: ["custom", "feelings"], + placements: { custom: ["tired"] }, + }); + + // The next read answers what was written. + const back = await callRoute(readLayout, { + method: "GET", + route: "/api/mood/tags/layout", + auth, + }); + expect(back.data).toEqual(placed.data); + }); + + it("writes against a current token, refuses a stale one with 409, and leaves the row as the first writer left it", async () => { + const owner = await makeUser(); + const auth = await makeSession(owner); + const loaded = await callRoute(readLayout, { + method: "GET", + route: "/api/mood/tags/layout", + auth, + }); + const base = loaded.data.updatedAt; + expect(base).toBeDefined(); + + const first = await callRoute(putLayout, { + method: "PUT", + route: "/api/mood/tags/layout", + body: { groupOrder: ["custom"], baseUpdatedAt: base }, + auth, + }); + expect(first.status).toBe(200); + expect(Date.parse(first.data.updatedAt ?? "")).toBeGreaterThan( + Date.parse(base ?? ""), + ); + + // A second card loaded the same version and writes the other field. + const second = await callRoute(putLayout, { + method: "PUT", + route: "/api/mood/tags/layout", + body: { placements: { custom: ["tired"] }, baseUpdatedAt: base }, + auth, + }); + expect(second.status).toBe(409); + expect(second.meta?.errorCode).toBe("mood_tag_layout_conflict"); + expect(await storedLayout(owner.id)).toEqual({ groupOrder: ["custom"] }); + + // Retried against the token the first write answered with, it lands. + const retried = await callRoute(putLayout, { + method: "PUT", + route: "/api/mood/tags/layout", + body: { + placements: { custom: ["tired"] }, + baseUpdatedAt: first.data.updatedAt, + }, + auth, + }); + expect(retried.status).toBe(200); + expect(await storedLayout(owner.id)).toEqual({ + groupOrder: ["custom"], + placements: { custom: ["tired"] }, + }); + }); + + it("writes unconditionally without a token", async () => { + const owner = await makeUser(); + const auth = await makeSession(owner); + await prisma.user.update({ + where: { id: owner.id }, + data: { moodTagLayoutJson: { groupOrder: ["feelings"] } }, + }); + + const res = await callRoute(putLayout, { + method: "PUT", + route: "/api/mood/tags/layout", + body: { groupOrder: ["custom", "feelings"] }, + auth, + }); + + expect(res.status).toBe(200); + expect(res.data.updatedAt).toBe( + ( + await prisma.user.findUniqueOrThrow({ where: { id: owner.id } }) + ).updatedAt.toISOString(), + ); + expect(await storedLayout(owner.id)).toEqual({ + groupOrder: ["custom", "feelings"], + }); + }); + + it("refuses a malformed token, an over-long order and a malformed order without writing", async () => { + const owner = await makeUser(); + const auth = await makeSession(owner); + const stored = { groupOrder: ["feelings"] }; + await prisma.user.update({ + where: { id: owner.id }, + data: { moodTagLayoutJson: stored }, + }); + + const token = await callRoute(putLayout, { + method: "PUT", + route: "/api/mood/tags/layout", + body: { groupOrder: ["custom"], baseUpdatedAt: "not-a-date" }, + auth, + }); + expect(token.status).toBe(422); + expect(token.meta?.errorCode).toBe("invalid_base_updated_at"); + + for (const groupOrder of [ + Array.from({ length: 51 }, (_, i) => `g${i}`), + "feelings", + ]) { + const res = await callRoute(putLayout, { + method: "PUT", + route: "/api/mood/tags/layout", + body: { groupOrder }, + auth, + }); + expect(res.status).toBe(422); + } + expect(await storedLayout(owner.id)).toEqual(stored); + }); +});