batchCooking/apps/web/cypress/e2e/admin-messages.cy.ts
Nicolas 30f975d29a
All checks were successful
CI / lint (push) Successful in 3m16s
CI / build (push) Successful in 3m29s
CI / intent-service-test (push) Successful in 4m39s
CI / e2e (push) Successful in 10m24s
CI / test (push) Successful in 18m48s
CI / build-android (push) Has been skipped
feat(admin): crée un ticket Forgejo depuis un message de contact (#81)
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>
2026-09-08 20:10:54 +02:00

159 lines
5.6 KiB
TypeScript

// Mocks the admin API via cy.intercept — no live backend (see
// .github/workflows/ci.yml); apps/api's Mocha suite covers
// `GET /admin/api/contact` and the Forgejo routes against a real database.
const adminBody = {
id: 1,
email: "ops@example.com",
name: "Ops",
createdAt: "2026-08-01T00:00:00.000Z",
lastLoginAt: "2026-08-28T09:00:00.000Z",
};
// Matches the list endpoint (`/contact`, `/contact?type=…`) but NOT the
// nested `/contact/forgejo/status` or `/contact/:id/forgejo-issue`.
const LIST_URL = /\/admin\/api\/contact(\?|$)/;
function messages() {
return [
{
id: 2,
type: "bug",
message: "Le bouton Envoyer ne réagit pas sur la page planning.",
pageUrl: "/",
userAgent: "Mozilla/5.0 (Linux; Android 14) BatchCookingApp/0.2.1",
appVersion: "0.2.1",
createdAt: "2026-09-07T10:00:00.000Z",
author: { id: 9, firstName: "Alice", lastName: "Martin", email: "alice@example.com" },
forgejoIssueNumber: null,
forgejoIssueUrl: null,
},
{
id: 1,
type: "suggestion",
message: "Ajoutez un mode hors-ligne pour la liste de courses.",
pageUrl: "/liste-de-courses",
userAgent: null,
appVersion: null,
createdAt: "2026-09-06T08:30:00.000Z",
author: null,
forgejoIssueNumber: null,
forgejoIssueUrl: null,
},
];
}
describe("Admin contact messages (/admin/messages)", () => {
beforeEach(() => {
cy.viewport(1400, 1000);
cy.intercept("GET", "**/admin/api/auth/me", { statusCode: 200, body: adminBody });
// Default: Forgejo integration off — individual tests override.
cy.intercept("GET", "**/admin/api/contact/forgejo/status", {
statusCode: 200,
body: { enabled: false },
}).as("forgejoStatus");
});
it("lists the messages read-only: sender or « Compte supprimé », type badge, message", () => {
cy.intercept("GET", LIST_URL, { statusCode: 200, body: messages() }).as("getMessages");
cy.visit("/admin/messages");
cy.wait("@getMessages");
cy.get(".messages-table tbody tr").should("have.length", 2);
cy.contains(".messages-table tr", "page planning").should("contain.text", "alice@example.com");
cy.contains(".messages-table tr", "mode hors-ligne").should("contain.text", "Compte supprimé");
// Integration off ⇒ no create button, and the "not configured" note shows.
cy.get(".messages-table__ticket-button").should("not.exist");
cy.contains(".messages-forgejo-note", "non configurée").should("be.visible");
});
it("filters by type through the query param", () => {
cy.intercept("GET", "**/admin/api/contact", { statusCode: 200, body: messages() }).as("all");
cy.intercept("GET", "**/admin/api/contact?type=suggestion", {
statusCode: 200,
body: [messages()[1]],
}).as("filtered");
cy.visit("/admin/messages");
cy.wait("@all");
cy.get(".messages-filters select").select("Suggestion");
cy.wait("@filtered");
cy.get(".messages-table tbody tr").should("have.length", 1);
cy.contains(".messages-table tr", "mode hors-ligne").should("be.visible");
});
it("creates a Forgejo issue from a message through the pre-filled modal", () => {
cy.intercept("GET", "**/admin/api/contact/forgejo/status", {
statusCode: 200,
body: { enabled: true },
}).as("forgejoStatus");
cy.intercept("GET", LIST_URL, { statusCode: 200, body: messages() }).as("getMessages");
cy.intercept("POST", "**/admin/api/contact/2/forgejo-issue", {
statusCode: 200,
body: {
...messages()[0],
forgejoIssueNumber: 101,
forgejoIssueUrl: "https://git.example/kyuno/batchCooking/issues/101",
},
}).as("createIssue");
cy.visit("/admin/messages");
cy.wait("@getMessages");
cy.contains(".messages-table tr", "page planning")
.find(".messages-table__ticket-button")
.click();
// Modal opens pre-filled from the message.
cy.get("#forgejoIssueTitle").should(
"have.value",
"[Bug] Le bouton Envoyer ne réagit pas sur la page planning.",
);
cy.get("#forgejoIssueBody").should("contain.value", "Signalé par");
cy.contains(".dialog-panel__footer button", "Créer le ticket").click();
cy.wait("@createIssue").its("request.body").should("have.keys", ["title", "body"]);
// Modal closed, the row now shows the link instead of the button.
cy.get("#forgejoIssueTitle").should("not.exist");
cy.contains(".messages-table tr", "page planning")
.find("a")
.should("have.attr", "href", "https://git.example/kyuno/batchCooking/issues/101")
.and("contain.text", "#101");
});
it("shows the existing link and no create button for an already-linked message", () => {
cy.intercept("GET", "**/admin/api/contact/forgejo/status", {
statusCode: 200,
body: { enabled: true },
});
const linked = messages().map((m) =>
m.id === 2
? {
...m,
forgejoIssueNumber: 55,
forgejoIssueUrl: "https://git.example/kyuno/batchCooking/issues/55",
}
: m,
);
cy.intercept("GET", LIST_URL, { statusCode: 200, body: linked }).as("getMessages");
cy.visit("/admin/messages");
cy.wait("@getMessages");
cy.contains(".messages-table tr", "page planning").within(() => {
cy.contains("a", "#55").should(
"have.attr",
"href",
"https://git.example/kyuno/batchCooking/issues/55",
);
cy.get(".messages-table__ticket-button").should("not.exist");
});
// The still-unlinked suggestion row keeps its button.
cy.contains(".messages-table tr", "mode hors-ligne")
.find(".messages-table__ticket-button")
.should("be.visible");
});
});