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>
113 lines
4 KiB
TypeScript
113 lines
4 KiB
TypeScript
import { HttpError } from "@batch-cooking/error-tools";
|
|
import { ErrorCode } from "@batch-cooking/shared";
|
|
import { expect } from "chai";
|
|
import { env } from "../src/config/env.js";
|
|
import { forgejo } from "../src/lib/forgejo/forgejo.service.js";
|
|
|
|
/**
|
|
* Stubs `globalThis.fetch` for one test — same approach as
|
|
* `recipe-sources/the-meal-db.test.ts` (no HTTP-mock library in this repo).
|
|
* `capture` collects the `(url, init)` each call was made with, so a test
|
|
* can assert the request shape. Restored by `afterEach`.
|
|
*/
|
|
function stubFetch(
|
|
responder: (url: string, init: RequestInit | undefined) => Response | Promise<Response>,
|
|
capture: { url: string; init: RequestInit | undefined }[],
|
|
) {
|
|
globalThis.fetch = (async (input: string | URL | Request, init?: RequestInit) => {
|
|
const url = typeof input === "string" ? input : input.toString();
|
|
capture.push({ url, init });
|
|
return responder(url, init);
|
|
}) as typeof fetch;
|
|
}
|
|
|
|
const configured = forgejo.isConfigured();
|
|
|
|
describe("ForgejoService", () => {
|
|
let originalFetch: typeof fetch;
|
|
|
|
beforeEach(() => {
|
|
originalFetch = globalThis.fetch;
|
|
});
|
|
|
|
afterEach(() => {
|
|
globalThis.fetch = originalFetch;
|
|
});
|
|
|
|
it("isConfigured() reports whether all three FORGEJO_* vars are set", () => {
|
|
expect(forgejo.isConfigured()).to.equal(
|
|
env.FORGEJO_BASE_URL !== undefined &&
|
|
env.FORGEJO_TOKEN !== undefined &&
|
|
env.FORGEJO_REPO !== undefined,
|
|
);
|
|
});
|
|
|
|
describe("createIssue", () => {
|
|
it("POSTs to the repo's issues endpoint with a `token` auth header and { title, body }", async function () {
|
|
if (!configured) {
|
|
// biome-ignore lint/suspicious/noExplicitAny: mocha's `this.skip()` isn't typed here.
|
|
(this as any).skip();
|
|
return;
|
|
}
|
|
const calls: { url: string; init: RequestInit | undefined }[] = [];
|
|
stubFetch(
|
|
() =>
|
|
new Response(JSON.stringify({ number: 42, html_url: "https://forge/x/y/issues/42" }), {
|
|
status: 201,
|
|
}),
|
|
calls,
|
|
);
|
|
|
|
const result = await forgejo.createIssue({ title: "Un titre", body: "Un corps" });
|
|
|
|
expect(result).to.deep.equal({ number: 42, htmlUrl: "https://forge/x/y/issues/42" });
|
|
expect(calls).to.have.lengthOf(1);
|
|
const [call] = calls;
|
|
expect(call.url).to.equal(`${env.FORGEJO_BASE_URL}/api/v1/repos/${env.FORGEJO_REPO}/issues`);
|
|
expect(call.init?.method).to.equal("POST");
|
|
const headers = call.init?.headers as Record<string, string>;
|
|
expect(headers.Authorization).to.equal(`token ${env.FORGEJO_TOKEN}`);
|
|
expect(JSON.parse(String(call.init?.body))).to.deep.equal({
|
|
title: "Un titre",
|
|
body: "Un corps",
|
|
});
|
|
});
|
|
|
|
it("throws 502 FORGEJO_REQUEST_FAILED on a non-2xx response", async function () {
|
|
if (!configured) {
|
|
// biome-ignore lint/suspicious/noExplicitAny: mocha's `this.skip()` isn't typed here.
|
|
(this as any).skip();
|
|
return;
|
|
}
|
|
stubFetch(() => new Response("nope", { status: 403 }), []);
|
|
|
|
try {
|
|
await forgejo.createIssue({ title: "t", body: "b" });
|
|
expect.fail("expected createIssue to throw");
|
|
} catch (err) {
|
|
expect(err).to.be.instanceOf(HttpError);
|
|
expect((err as HttpError).status).to.equal(502);
|
|
expect((err as HttpError).code).to.equal(ErrorCode.FORGEJO_REQUEST_FAILED);
|
|
}
|
|
});
|
|
|
|
it("throws 502 FORGEJO_REQUEST_FAILED on a network error", async function () {
|
|
if (!configured) {
|
|
// biome-ignore lint/suspicious/noExplicitAny: mocha's `this.skip()` isn't typed here.
|
|
(this as any).skip();
|
|
return;
|
|
}
|
|
stubFetch(() => {
|
|
throw new Error("ECONNREFUSED");
|
|
}, []);
|
|
|
|
try {
|
|
await forgejo.createIssue({ title: "t", body: "b" });
|
|
expect.fail("expected createIssue to throw");
|
|
} catch (err) {
|
|
expect(err).to.be.instanceOf(HttpError);
|
|
expect((err as HttpError).code).to.equal(ErrorCode.FORGEJO_REQUEST_FAILED);
|
|
}
|
|
});
|
|
});
|
|
});
|