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); + }); +});