All checks were successful
Depuis /admin/messages, un admin peut créer une issue Forgejo à partir
d'un message utilisateur : bouton « Créer un ticket » → modale pré-remplie
éditable → l'issue est créée via l'API Forgejo et le lien (#N) est
mémorisé sur le message. Première intégration Forgejo d'apps/api.
- Modèle : `forgejoIssueNumber` / `forgejoIssueUrl` sur `ContactMessage`
(migration `20260908130000_contact_message_forgejo_link`, 2 colonnes
nullables, aucun backfill).
- Config : `FORGEJO_BASE_URL` / `FORGEJO_TOKEN` / `FORGEJO_REPO`, toutes
`.optional()` — **fail-closed** (même posture que `ADMIN_JWT_SECRET`) :
bouton masqué + endpoint `503` tant que les trois ne sont pas là.
Documentées dans `.env.example` ; valeurs de test dans `ci.yml`.
- `ForgejoService` (`lib/forgejo/forgejo.service.ts`, singleton `forgejo`) :
`isConfigured()` + `createIssue({ title, body })` → `POST .../issues`,
en-tête `Authorization: token <pat>`, lit `{ number, html_url }` ;
erreur réseau / non-2xx / JSON invalide ⇒ `HttpError 502
FORGEJO_REQUEST_FAILED`.
- `admin-contact` : `POST /admin/api/contact/:id/forgejo-issue`
(`createForgejoIssueFromMessage` — ordre 503 → 404 → 409 → Forgejo →
écriture BDD ; un échec Forgejo ne touche pas la BDD) et
`GET /admin/api/contact/forgejo/status`.
- Contrat partagé : `createForgejoIssueSchema` (title 1–255, body 1–60000),
`ContactMessageAdminView` étendu, `ForgejoIntegrationStatus`, nouveaux
`ErrorCode` (`FORGEJO_NOT_CONFIGURED`=5001, `FORGEJO_REQUEST_FAILED`=5002,
`CONTACT_MESSAGE_ALREADY_LINKED`=4026, `CONTACT_MESSAGE_NOT_FOUND`=4055)
+ labels i18n.
- Front : `MessagesPage` — sonde `forgejo/status` au montage, colonne
« Ticket » (lien `#N` si lié, bouton sinon, « non configurée » sinon),
`CreateForgejoIssueModal` + helpers purs `messages.ts`
(`buildIssueTitle` / `buildIssueBody`).
- Tests : `forgejo.service.test.ts` (stub `globalThis.fetch`),
`admin-contact.test.ts` étendu (nominal / 503 / 404 / 409 / 502 +
ligne intacte / 400), `admin-messages.cy.ts` étendu (modale pré-remplie,
lien après création, masquage si non configuré).
- Docs : `backend-architecture.md`, `batch-cooking-modele.md`.
Closes #81.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
346 lines
12 KiB
TypeScript
346 lines
12 KiB
TypeScript
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);
|
|
});
|
|
});
|
|
});
|