Some checks failed
Formulaire in-app de remontée de bug / suggestion pour les utilisateurs connectés (PR 1/2 de l'issue #80). - Modèle Prisma `ContactMessage` (+ relation inverse sur `UserProfile`) et migration `20260908120000_contact_message` — table neuve, aucun backfill. `authorId` nullable + `onDelete: SetNull` : un signalement survit à la suppression de son compte. - Contrat partagé `schemas/contact.ts` : `CONTACT_MESSAGE_TYPES`, `submitContactMessageSchema` (type enum, message 10-5000 trim, pageUrl / appVersion optionnels — jamais le user-agent). - Module API `contact` : `POST /contact` (`requireAuth`, monté `/contact`), `204` sans corps. Le user-agent est lu de l'en-tête de requête, jamais du corps. Émet `analytics.recordEvent(contact.submitted, …)`. - Page `/parametres/contact` (`ContactPage` + `contact.ts` + `.scss`) : select de type, textarea, validation cliente via le schéma partagé, états succès / erreur (`ErrorMessageService`, jamais `err.message`). Entrée de nav « Nous contacter » dans `SETTINGS_ITEMS` + clé i18n + icône. `apiClient.submitContactMessage(input)` ajoute `pageUrl` / `appVersion`. - Tests : `apps/api/test/contact.test.ts` (Mocha — insertion, en-tête UA, champs nullables, message trop court / trop long, type inconnu), `contact.feature` + steps (Cypress e2e — envoi + confirmation + contexte), `contact-page.cy.ts` (rendu + garde `RequireAuth` + label d'erreur). - Docs : `batch-cooking-modele.md`, `backend-architecture.md`, `frontend-architecture.md`. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
143 lines
5.3 KiB
TypeScript
143 lines
5.3 KiB
TypeScript
import { ErrorCode, type SignupInput } 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 { prisma } from "../src/db/prisma.js";
|
|
import { resetDatabase } from "../test-support/reset-db.js";
|
|
|
|
/** See `auth.test.ts` — same rationale for generating rather than hardcoding. */
|
|
function buildSignupPayload(): SignupInput {
|
|
const firstName = faker.person.firstName();
|
|
const lastName = faker.person.lastName();
|
|
return {
|
|
firstName,
|
|
lastName,
|
|
email: faker.internet.email({ firstName, lastName }).toLowerCase(),
|
|
password: faker.internet.password({ length: 16 }),
|
|
};
|
|
}
|
|
|
|
/** A valid `POST /contact` body — message comfortably over the 10-char minimum. */
|
|
function buildContactPayload() {
|
|
return {
|
|
type: "bug" as const,
|
|
message: "Le bouton « Envoyer » ne réagit pas sur la page planning.",
|
|
pageUrl: "/",
|
|
appVersion: "0.2.1",
|
|
};
|
|
}
|
|
|
|
describe("Contact", () => {
|
|
const app = createApp();
|
|
|
|
beforeEach(async () => {
|
|
await resetDatabase();
|
|
});
|
|
|
|
after(async () => {
|
|
await prisma.$disconnect();
|
|
});
|
|
|
|
describe("POST /contact", () => {
|
|
it("rejects requests without a session with 401 NOT_AUTHENTICATED", async () => {
|
|
const res = await request(app).post("/contact").send(buildContactPayload());
|
|
|
|
expect(res.status).to.equal(401);
|
|
expect(res.body.code).to.equal(ErrorCode.NOT_AUTHENTICATED);
|
|
expect(await prisma.contactMessage.count()).to.equal(0);
|
|
});
|
|
|
|
it("stores the message with its fields, attributed to the caller, and captures the user-agent from the header", async () => {
|
|
const agent = request.agent(app);
|
|
const signup = buildSignupPayload();
|
|
await agent.post("/auth/signup").send(signup);
|
|
const author = await prisma.userProfile.findFirstOrThrow({ where: { email: signup.email } });
|
|
|
|
const res = await agent
|
|
.post("/contact")
|
|
.set("User-Agent", "BatchCookingApp/0.2.1 (Android 14)")
|
|
.send(buildContactPayload());
|
|
|
|
expect(res.status).to.equal(204);
|
|
|
|
const rows = await prisma.contactMessage.findMany();
|
|
expect(rows).to.have.lengthOf(1);
|
|
const [row] = rows;
|
|
expect(row.authorId).to.equal(author.id);
|
|
expect(row.type).to.equal("bug");
|
|
expect(row.message).to.equal(buildContactPayload().message);
|
|
expect(row.pageUrl).to.equal("/");
|
|
expect(row.appVersion).to.equal("0.2.1");
|
|
// Read from the request header, never the body.
|
|
expect(row.userAgent).to.equal("BatchCookingApp/0.2.1 (Android 14)");
|
|
});
|
|
|
|
it("ignores any user-agent sent in the body — the header is the only source", async () => {
|
|
const agent = request.agent(app);
|
|
await agent.post("/auth/signup").send(buildSignupPayload());
|
|
|
|
const res = await agent
|
|
.post("/contact")
|
|
.set("User-Agent", "real-agent/1.0")
|
|
.send({ ...buildContactPayload(), userAgent: "spoofed-agent/9.9" });
|
|
|
|
expect(res.status).to.equal(204);
|
|
const row = await prisma.contactMessage.findFirstOrThrow();
|
|
expect(row.userAgent).to.equal("real-agent/1.0");
|
|
});
|
|
|
|
it("keeps pageUrl / appVersion null when the client omits them", async () => {
|
|
const agent = request.agent(app);
|
|
await agent.post("/auth/signup").send(buildSignupPayload());
|
|
|
|
const res = await agent.post("/contact").send({
|
|
type: "suggestion",
|
|
message: "Ajoutez un mode hors-ligne pour la liste de courses.",
|
|
});
|
|
|
|
expect(res.status).to.equal(204);
|
|
const row = await prisma.contactMessage.findFirstOrThrow();
|
|
expect(row.type).to.equal("suggestion");
|
|
expect(row.pageUrl).to.equal(null);
|
|
expect(row.appVersion).to.equal(null);
|
|
});
|
|
|
|
it("rejects a message shorter than 10 characters with 400 VALIDATION_ERROR", async () => {
|
|
const agent = request.agent(app);
|
|
await agent.post("/auth/signup").send(buildSignupPayload());
|
|
|
|
// 5 chars — and the schema trims before counting, so leading/trailing
|
|
// whitespace can't pad it to the minimum.
|
|
const res = await agent.post("/contact").send({ type: "bug", message: " court " });
|
|
|
|
expect(res.status).to.equal(400);
|
|
expect(res.body.code).to.equal(ErrorCode.VALIDATION_ERROR);
|
|
expect(await prisma.contactMessage.count()).to.equal(0);
|
|
});
|
|
|
|
it("rejects a message longer than 5000 characters with 400 VALIDATION_ERROR", async () => {
|
|
const agent = request.agent(app);
|
|
await agent.post("/auth/signup").send(buildSignupPayload());
|
|
|
|
const res = await agent.post("/contact").send({ type: "bug", message: "a".repeat(5001) });
|
|
|
|
expect(res.status).to.equal(400);
|
|
expect(res.body.code).to.equal(ErrorCode.VALIDATION_ERROR);
|
|
expect(await prisma.contactMessage.count()).to.equal(0);
|
|
});
|
|
|
|
it("rejects an unknown type with 400 VALIDATION_ERROR", async () => {
|
|
const agent = request.agent(app);
|
|
await agent.post("/auth/signup").send(buildSignupPayload());
|
|
|
|
const res = await agent
|
|
.post("/contact")
|
|
.send({ type: "complaint", message: "Ceci est un message de test suffisamment long." });
|
|
|
|
expect(res.status).to.equal(400);
|
|
expect(res.body.code).to.equal(ErrorCode.VALIDATION_ERROR);
|
|
expect(await prisma.contactMessage.count()).to.equal(0);
|
|
});
|
|
});
|
|
});
|