import { ErrorCode } from "@batch-cooking/shared"; import { faker } from "@faker-js/faker"; import { expect } from "chai"; import request from "supertest"; import { createApp } from "../src/app.js"; import { env } from "../src/config/env.js"; import { prisma } from "../src/db/prisma.js"; import { hashAdminPassword } from "../src/modules/admin/admin-auth.service.js"; import { resetDatabase } from "../test-support/reset-db.js"; async function seedAdmin(): Promise<{ email: string; password: string }> { const email = faker.internet.email().toLowerCase(); const password = faker.internet.password({ length: 16 }); await prisma.adminUser.create({ data: { email, name: faker.person.fullName(), passwordHash: await hashAdminPassword(password) }, }); return { email, password }; } /** Creates a `UserProfile` to attribute contact messages to. */ async function seedUser() { return prisma.userProfile.create({ data: { firstName: faker.person.firstName(), lastName: faker.person.lastName(), email: `${faker.string.uuid()}@example.test`, passwordHash: "x", }, }); } /** One `ContactMessage`, `createdAt` overridable so ordering is deterministic. */ async function seedMessage(overrides: { authorId: number | null; type?: string; message?: string; createdAt?: Date; }) { return prisma.contactMessage.create({ data: { authorId: overrides.authorId, type: overrides.type ?? "bug", message: overrides.message ?? faker.lorem.sentence(), pageUrl: "/", userAgent: "test-agent/1.0", appVersion: "0.2.1", ...(overrides.createdAt ? { createdAt: overrides.createdAt } : {}), }, }); } const adminSecretConfigured = env.ADMIN_JWT_SECRET !== undefined; const forgejoConfigured = env.FORGEJO_BASE_URL !== undefined && env.FORGEJO_TOKEN !== undefined && env.FORGEJO_REPO !== undefined; describe("Admin contact messages", () => { const app = createApp(); beforeEach(async () => { await resetDatabase(); }); after(async () => { await prisma.$disconnect(); }); async function adminAgent() { const { email, password } = await seedAdmin(); const agent = request.agent(app); await agent.post("/admin/api/auth/login").send({ email, password }); return agent; } it("rejects the route without an admin session", async () => { const res = await request(app).get("/admin/api/contact"); expect(res.status).to.equal(401); }); it("returns messages newest first, with the author resolved", async function () { if (!adminSecretConfigured) { // biome-ignore lint/suspicious/noExplicitAny: mocha's `this.skip()` isn't typed here. (this as any).skip(); return; } const user = await seedUser(); await seedMessage({ authorId: user.id, message: "le plus ancien", createdAt: new Date("2026-01-01T00:00:00.000Z"), }); await seedMessage({ authorId: user.id, message: "le plus récent", createdAt: new Date("2026-06-01T00:00:00.000Z"), }); const agent = await adminAgent(); const res = await agent.get("/admin/api/contact"); expect(res.status).to.equal(200); expect(res.body.map((row: { message: string }) => row.message)).to.deep.equal([ "le plus récent", "le plus ancien", ]); expect(res.body[0].author).to.deep.equal({ id: user.id, firstName: user.firstName, lastName: user.lastName, email: user.email, }); }); it("filters by type", async function () { if (!adminSecretConfigured) { // biome-ignore lint/suspicious/noExplicitAny: mocha's `this.skip()` isn't typed here. (this as any).skip(); return; } const user = await seedUser(); await seedMessage({ authorId: user.id, type: "bug" }); await seedMessage({ authorId: user.id, type: "suggestion" }); await seedMessage({ authorId: user.id, type: "suggestion" }); const agent = await adminAgent(); const res = await agent.get("/admin/api/contact").query({ type: "suggestion" }); expect(res.status).to.equal(200); expect(res.body).to.have.lengthOf(2); expect(res.body.every((row: { type: string }) => row.type === "suggestion")).to.equal(true); }); it("rejects an unknown type filter with 400", async function () { if (!adminSecretConfigured) { // biome-ignore lint/suspicious/noExplicitAny: mocha's `this.skip()` isn't typed here. (this as any).skip(); return; } const agent = await adminAgent(); const res = await agent.get("/admin/api/contact").query({ type: "complaint" }); expect(res.status).to.equal(400); }); it("caps the page at 200 rows", async function () { if (!adminSecretConfigured) { // biome-ignore lint/suspicious/noExplicitAny: mocha's `this.skip()` isn't typed here. (this as any).skip(); return; } const user = await seedUser(); await prisma.contactMessage.createMany({ data: Array.from({ length: 205 }, () => ({ authorId: user.id, type: "bug", message: faker.lorem.sentence(), })), }); const agent = await adminAgent(); const res = await agent.get("/admin/api/contact"); expect(res.status).to.equal(200); expect(res.body).to.have.lengthOf(200); }); it("reports author: null once the sender's account is deleted", async function () { if (!adminSecretConfigured) { // biome-ignore lint/suspicious/noExplicitAny: mocha's `this.skip()` isn't typed here. (this as any).skip(); return; } const user = await seedUser(); await seedMessage({ authorId: user.id, message: "signalé puis compte supprimé" }); await prisma.userProfile.delete({ where: { id: user.id } }); const agent = await adminAgent(); const res = await agent.get("/admin/api/contact"); expect(res.status).to.equal(200); expect(res.body).to.have.lengthOf(1); expect(res.body[0].message).to.equal("signalé puis compte supprimé"); expect(res.body[0].author).to.equal(null); }); describe("Forgejo issue creation (#81)", () => { let originalFetch: typeof fetch; beforeEach(() => { originalFetch = globalThis.fetch; }); afterEach(() => { globalThis.fetch = originalFetch; }); /** Makes `forgejo.createIssue`'s `fetch` return `body` with `status`. */ function stubForgejoFetch(body: unknown, status = 201) { globalThis.fetch = (async () => new Response(JSON.stringify(body), { status })) as typeof fetch; } it("returns 401 without an admin session", async () => { const res = await request(app).post("/admin/api/contact/1/forgejo-issue").send({ title: "t", body: "b", }); expect(res.status).to.equal(401); }); it("exposes the integration status", async function () { if (!adminSecretConfigured) { // biome-ignore lint/suspicious/noExplicitAny: mocha's `this.skip()` isn't typed here. (this as any).skip(); return; } const agent = await adminAgent(); const res = await agent.get("/admin/api/contact/forgejo/status"); expect(res.status).to.equal(200); expect(res.body).to.deep.equal({ enabled: forgejoConfigured }); }); it("responds 503 FORGEJO_NOT_CONFIGURED when the env isn't set", async function () { if (!adminSecretConfigured || forgejoConfigured) { // biome-ignore lint/suspicious/noExplicitAny: mocha's `this.skip()` isn't typed here. (this as any).skip(); return; } const user = await seedUser(); const message = await seedMessage({ authorId: user.id }); const agent = await adminAgent(); const res = await agent .post(`/admin/api/contact/${message.id}/forgejo-issue`) .send({ title: "Titre", body: "Corps du ticket" }); expect(res.status).to.equal(503); expect(res.body.code).to.equal(ErrorCode.FORGEJO_NOT_CONFIGURED); }); it("creates the issue and records the link on the message", async function () { if (!adminSecretConfigured || !forgejoConfigured) { // biome-ignore lint/suspicious/noExplicitAny: mocha's `this.skip()` isn't typed here. (this as any).skip(); return; } stubForgejoFetch({ number: 77, html_url: "https://forge.example/ci/repo/issues/77" }); const user = await seedUser(); const message = await seedMessage({ authorId: user.id, message: "un bug précis" }); const agent = await adminAgent(); const res = await agent .post(`/admin/api/contact/${message.id}/forgejo-issue`) .send({ title: "[Bug] un bug précis", body: "un bug précis\n\n---\ncontexte" }); expect(res.status).to.equal(200); expect(res.body.forgejoIssueNumber).to.equal(77); expect(res.body.forgejoIssueUrl).to.equal("https://forge.example/ci/repo/issues/77"); const row = await prisma.contactMessage.findFirstOrThrow({ where: { id: message.id } }); expect(row.forgejoIssueNumber).to.equal(77); expect(row.forgejoIssueUrl).to.equal("https://forge.example/ci/repo/issues/77"); }); it("responds 404 for an unknown message id", async function () { if (!adminSecretConfigured || !forgejoConfigured) { // biome-ignore lint/suspicious/noExplicitAny: mocha's `this.skip()` isn't typed here. (this as any).skip(); return; } stubForgejoFetch({ number: 1, html_url: "x" }); const agent = await adminAgent(); const res = await agent .post("/admin/api/contact/999999/forgejo-issue") .send({ title: "Titre", body: "Corps" }); expect(res.status).to.equal(404); expect(res.body.code).to.equal(ErrorCode.CONTACT_MESSAGE_NOT_FOUND); }); it("responds 409 when the message already links to an issue", async function () { if (!adminSecretConfigured || !forgejoConfigured) { // biome-ignore lint/suspicious/noExplicitAny: mocha's `this.skip()` isn't typed here. (this as any).skip(); return; } stubForgejoFetch({ number: 2, html_url: "x" }); const user = await seedUser(); const message = await seedMessage({ authorId: user.id }); await prisma.contactMessage.update({ where: { id: message.id }, data: { forgejoIssueNumber: 5, forgejoIssueUrl: "https://forge.example/ci/repo/issues/5" }, }); const agent = await adminAgent(); const res = await agent .post(`/admin/api/contact/${message.id}/forgejo-issue`) .send({ title: "Titre", body: "Corps" }); expect(res.status).to.equal(409); expect(res.body.code).to.equal(ErrorCode.CONTACT_MESSAGE_ALREADY_LINKED); }); it("leaves the message untouched when the Forgejo call fails (502)", async function () { if (!adminSecretConfigured || !forgejoConfigured) { // biome-ignore lint/suspicious/noExplicitAny: mocha's `this.skip()` isn't typed here. (this as any).skip(); return; } stubForgejoFetch("upstream boom", 500); const user = await seedUser(); const message = await seedMessage({ authorId: user.id }); const agent = await adminAgent(); const res = await agent .post(`/admin/api/contact/${message.id}/forgejo-issue`) .send({ title: "Titre", body: "Corps" }); expect(res.status).to.equal(502); expect(res.body.code).to.equal(ErrorCode.FORGEJO_REQUEST_FAILED); const row = await prisma.contactMessage.findFirstOrThrow({ where: { id: message.id } }); expect(row.forgejoIssueNumber).to.equal(null); expect(row.forgejoIssueUrl).to.equal(null); }); it("rejects an empty title with 400", async function () { if (!adminSecretConfigured || !forgejoConfigured) { // biome-ignore lint/suspicious/noExplicitAny: mocha's `this.skip()` isn't typed here. (this as any).skip(); return; } const user = await seedUser(); const message = await seedMessage({ authorId: user.id }); const agent = await adminAgent(); const res = await agent .post(`/admin/api/contact/${message.id}/forgejo-issue`) .send({ title: " ", body: "Corps" }); expect(res.status).to.equal(400); expect(res.body.code).to.equal(ErrorCode.VALIDATION_ERROR); }); }); });