diff --git a/backend/src/controllers/admin.controller.js b/backend/src/controllers/admin.controller.js index 5be295f..ecca247 100644 --- a/backend/src/controllers/admin.controller.js +++ b/backend/src/controllers/admin.controller.js @@ -1,7 +1,8 @@ const bcrypt = require("bcrypt"); const crypto = require("crypto"); const prisma = require("../config/prisma"); -const { getGameWindow, expireOverdueClaims } = require("../utils/gameWindow"); +const transporter = require("../config/mailer"); +const { getGameWindow } = require("../utils/gameWindow"); const AGE_BRACKETS = ["-18", "18-24", "25-34", "35-44", "45-54", "55-64", "65+"]; @@ -34,7 +35,6 @@ function computeAgeStats(users) { const getStats = async (req, res) => { try { - await expireOverdueClaims(); const gameWindow = await getGameWindow(); const [ @@ -80,9 +80,11 @@ const getStats = async (req, res) => { ticketsUsed, ticketsRemaining: ticketsTotal - ticketsUsed, participations, - claimsPending, + // L'expiration n'est pas écrite en base : une fois la fenêtre de retrait + // fermée, les PENDING restants comptent comme annulés. + claimsPending: gameWindow.isOpen ? claimsPending : 0, claimsDelivered, - claimsCancelled, + claimsCancelled: gameWindow.isOpen ? claimsCancelled : claimsCancelled + claimsPending, users, gameWindow, ageStats: computeAgeStats(clientsWithBirthdate), @@ -215,6 +217,31 @@ const createEmployee = async (req, res) => { const drawWinner = async (req, res) => { try { + // Le grand gagnant est tiré parmi tous les participants : le tirage n'a de + // sens qu'une fois la période de participation close. Avant l'ouverture, + // il n'existe aucun participant à départager. + const gameWindow = await getGameWindow(); + + if (!gameWindow.configured) { + return res.status(409).json({ + message: "Le jeu-concours n'a pas encore été lancé : aucun tirage au sort n'est possible.", + }); + } + + if (!gameWindow.hasStarted) { + return res.status(409).json({ + message: `Le jeu-concours n'a pas encore commencé. Il ouvre le ${gameWindow.startDate.toLocaleDateString("fr-FR")}.`, + startDate: gameWindow.startDate, + }); + } + + if (gameWindow.isParticipationOpen) { + return res.status(409).json({ + message: `Le tirage au sort ne peut avoir lieu qu'après la fin des participations, le ${gameWindow.participationDeadline.toLocaleDateString("fr-FR")}.`, + participationDeadline: gameWindow.participationDeadline, + }); + } + const existingWinner = await prisma.grandPrizeWinner.findFirst({ include: { user: { @@ -283,9 +310,41 @@ const drawWinner = async (req, res) => { }, }); + // Le tirage est un acte irréversible réalisé sous contrôle d'huissier : + // un email qui ne part pas ne doit surtout pas l'invalider. On signale + // simplement l'échec pour que l'administrateur prévienne autrement. + let emailSent = true; + + try { + await transporter.sendMail({ + from: process.env.MAIL_FROM, + to: savedWinner.user.email, + subject: "Vous avez gagné le gros lot Thé Tip Top !", + html: ` +
+

Félicitations ${savedWinner.user.firstname} !

+

Le tirage au sort du jeu-concours « 100% Gagnant » vient d'avoir lieu, + et c'est vous qui remportez le gros lot :

+
+ ${savedWinner.prize} +
+

Notre équipe vous recontactera très prochainement pour convenir des + modalités de remise de votre lot.

+

Merci de votre participation et à bientôt chez Thé Tip Top.

+
+ `, + }); + } catch (mailError) { + emailSent = false; + console.error("Erreur envoi email au grand gagnant:", mailError); + } + return res.status(201).json({ - message: "Tirage au sort effectué avec succès.", + message: emailSent + ? "Tirage au sort effectué avec succès. Le gagnant a été prévenu par email." + : "Tirage au sort effectué avec succès, mais l'email n'a pas pu être envoyé au gagnant. Merci de le contacter directement.", totalEligibleParticipants: participants.length, + emailSent, winner: savedWinner, }); } catch (error) { @@ -296,6 +355,52 @@ const drawWinner = async (req, res) => { } }; +// Lancement du jeu-concours : fixe (ou corrige) la date de début, dont +// découlent les deux échéances. Une seule ligne de configuration existe, on +// la met donc à jour si elle est déjà présente. +const setGameSettings = async (req, res) => { + try { + const { startDate } = req.body; + const parsedStartDate = new Date(startDate); + + const existing = await prisma.gameSettings.findFirst({ orderBy: { id: "asc" } }); + + if (existing) { + await prisma.gameSettings.update({ + where: { id: existing.id }, + data: { startDate: parsedStartDate }, + }); + } else { + await prisma.gameSettings.create({ data: { startDate: parsedStartDate } }); + } + + const gameWindow = await getGameWindow(); + + return res.json({ + message: existing + ? "Dates du jeu-concours mises à jour." + : "Jeu-concours lancé avec succès.", + gameWindow, + }); + } catch (error) { + console.error("Erreur configuration du jeu:", error); + return res.status(500).json({ + message: "Erreur serveur lors de la configuration du jeu-concours.", + }); + } +}; + +const getGameSettings = async (req, res) => { + try { + return res.json({ gameWindow: await getGameWindow() }); + } catch (error) { + console.error("Erreur lecture configuration du jeu:", error); + return res.status(500).json({ + message: "Erreur serveur lors de la lecture de la configuration.", + }); + } +}; + const getGrandPrizeWinner = async (req, res) => { try { const winner = await prisma.grandPrizeWinner.findFirst({ @@ -329,4 +434,6 @@ module.exports = { createEmployee, drawWinner, getGrandPrizeWinner, + setGameSettings, + getGameSettings, }; \ No newline at end of file diff --git a/backend/src/controllers/auth.controller.js b/backend/src/controllers/auth.controller.js index bc78912..846d945 100644 --- a/backend/src/controllers/auth.controller.js +++ b/backend/src/controllers/auth.controller.js @@ -62,11 +62,17 @@ const register = async (req, res) => { }, }); - await transporter.sendMail({ - from: process.env.MAIL_FROM, - to: email, - subject: "Votre code de validation Thé Tip Top", - html: ` + // Le code de validation est le seul moyen d'activer le compte. Si l'envoi + // échoue, laisser l'utilisateur en base le bloquerait définitivement : il + // ne pourrait ni se connecter (email non vérifié) ni se réinscrire (email + // déjà pris). On annule donc la création pour que la tentative reste + // rejouable. + try { + await transporter.sendMail({ + from: process.env.MAIL_FROM, + to: email, + subject: "Votre code de validation Thé Tip Top", + html: `

Bienvenue ${firstname} !

Merci pour votre inscription sur Thé Tip Top.

@@ -77,7 +83,21 @@ const register = async (req, res) => {

Ce code expire dans 15 minutes.

`, - }); + }); + } catch (mailError) { + console.error("Erreur envoi du code de validation:", mailError); + + try { + await prisma.user.delete({ where: { id: user.id } }); + } catch (rollbackError) { + console.error("Echec de l'annulation de l'inscription:", rollbackError); + } + + return res.status(502).json({ + message: + "Votre compte n'a pas pu être créé : l'email de validation n'a pas pu être envoyé. Merci de réessayer dans quelques instants.", + }); + } return res.status(201).json({ message: "Inscription réussie. Un code de validation a été envoyé par email.", @@ -435,10 +455,13 @@ const deleteAccount = async (req, res) => { // 4. Supprimer les participations await tx.participation.deleteMany({ where: { userId } }); - // 5. Libérer les tickets utilisés par ce user + // 5. Détacher les tickets du compte sans les remettre en jeu : un code + // figure sur un ticket de caisse physique et n'est jouable qu'une + // fois. Le réinitialiser permettrait de rejouer indéfiniment le même + // code en supprimant puis recréant un compte. await tx.ticket.updateMany({ where: { userId }, - data: { userId: null, isUsed: false, usedAt: null }, + data: { userId: null }, }); // 6. Supprimer l'utilisateur diff --git a/backend/src/controllers/employee.controller.js b/backend/src/controllers/employee.controller.js index 7dff989..33d8468 100644 --- a/backend/src/controllers/employee.controller.js +++ b/backend/src/controllers/employee.controller.js @@ -1,11 +1,11 @@ const prisma = require("../config/prisma"); -const { expireOverdueClaims } = require("../utils/gameWindow"); +const { getGameWindow, effectiveClaimStatus } = require("../utils/gameWindow"); const getGainByTicketCode = async (req, res) => { try { const { code } = req.params; - await expireOverdueClaims(); + const gameWindow = await getGameWindow(); const ticket = await prisma.ticket.findUnique({ where: { code: code.toUpperCase() }, @@ -52,7 +52,7 @@ const getGainByTicketCode = async (req, res) => { }, claim: { id: ticket.participation.claim.id, - status: ticket.participation.claim.status, + status: effectiveClaimStatus(ticket.participation.claim.status, gameWindow), claimedAt: ticket.participation.claim.claimedAt, }, }); @@ -69,7 +69,7 @@ const deliverClaim = async (req, res) => { const { id } = req.params; const employeeId = req.user.id; - await expireOverdueClaims(); + const gameWindow = await getGameWindow(); const claim = await prisma.claim.findUnique({ where: { id: Number(id) }, @@ -87,7 +87,7 @@ const deliverClaim = async (req, res) => { }); } - if (claim.status === "CANCELLED") { + if (effectiveClaimStatus(claim.status, gameWindow) === "CANCELLED") { return res.status(400).json({ message: "Ce gain est expiré et ne peut plus être remis.", }); diff --git a/backend/src/controllers/game.controller.js b/backend/src/controllers/game.controller.js index 31f3345..9725af2 100644 --- a/backend/src/controllers/game.controller.js +++ b/backend/src/controllers/game.controller.js @@ -1,5 +1,13 @@ const prisma = require("../config/prisma"); -const { getGameWindow, expireOverdueClaims } = require("../utils/gameWindow"); +const transporter = require("../config/mailer"); +const { + getGameWindow, + effectiveClaimStatus, + participationClosedMessage, +} = require("../utils/gameWindow"); + +// Levée par la transaction quand le ticket a été consommé entre-temps. +class TicketDejaUtiliseError extends Error {} const participate = async (req, res) => { try { @@ -7,9 +15,13 @@ const participate = async (req, res) => { const userId = req.user.id; const gameWindow = await getGameWindow(); - if (!gameWindow.isOpen) { + + // Un code n'est jouable que pendant les 30 jours de jeu : ni avant + // l'ouverture, ni pendant la prolongation réservée aux retraits. + if (!gameWindow.isParticipationOpen) { return res.status(410).json({ - message: "Le jeu-concours est terminé. Vous ne pouvez plus tester de code ni réclamer de lot.", + message: participationClosedMessage(gameWindow), + phase: gameWindow.phase, }); } @@ -33,16 +45,24 @@ const participate = async (req, res) => { } const result = await prisma.$transaction(async (tx) => { - const updatedTicket = await tx.ticket.update({ - where: { id: ticket.id }, + // Le contrôle isUsed ci-dessus ne suffit pas : deux requêtes simultanées + // peuvent le franchir ensemble. On repasse donc par un update + // conditionnel, atomique, qui ne touche le ticket que s'il est encore + // libre — le perdant de la course obtient 0 ligne modifiée. + const { count } = await tx.ticket.updateMany({ + where: { id: ticket.id, isUsed: false }, data: { isUsed: true, usedAt: new Date(), userId, }, - include: { - lot: true, - }, + }); + + if (count === 0) throw new TicketDejaUtiliseError(); + + const updatedTicket = await tx.ticket.findUnique({ + where: { id: ticket.id }, + include: { lot: true }, }); const participation = await tx.participation.create({ @@ -67,8 +87,41 @@ const participate = async (req, res) => { }; }); + // La participation est déjà enregistrée et consultable dans « Mes gains » : + // un email qui ne part pas ne doit pas l'annuler ni consommer le ticket + // pour rien. On se contente de signaler l'échec. + let emailSent = true; + + try { + await transporter.sendMail({ + from: process.env.MAIL_FROM, + to: req.user.email, + subject: `Vous avez gagné : ${result.ticket.lot.name} !`, + html: ` +
+

Bravo ${req.user.firstname} !

+

Votre code ${result.ticket.code} est gagnant. Vous remportez :

+
+ ${result.ticket.lot.name} +
+ ${result.ticket.lot.description ? `

${result.ticket.lot.description}

` : ""} +

Présentez-vous en boutique avec ce code pour retirer votre lot${ + gameWindow.claimDeadline + ? `, avant le ${gameWindow.claimDeadline.toLocaleDateString("fr-FR")}` + : "" + }.

+

Retrouvez à tout moment vos gains dans votre espace « Mes gains ».

+
+ `, + }); + } catch (mailError) { + emailSent = false; + console.error("Erreur envoi email de gain:", mailError); + } + return res.status(201).json({ message: "Participation enregistrée avec succès.", + emailSent, gain: { name: result.ticket.lot.name, description: result.ticket.lot.description, @@ -80,6 +133,12 @@ const participate = async (req, res) => { }, }); } catch (error) { + if (error instanceof TicketDejaUtiliseError) { + return res.status(409).json({ + message: "Ce code a déjà été utilisé.", + }); + } + console.error("Erreur participation:", error); return res.status(500).json({ message: "Erreur serveur lors de la participation.", @@ -91,7 +150,7 @@ const myGains = async (req, res) => { try { const userId = req.user.id; - await expireOverdueClaims(); + const gameWindow = await getGameWindow(); const participations = await prisma.participation.findMany({ where: { userId }, @@ -112,7 +171,9 @@ const myGains = async (req, res) => { lot: participation.lot.name, description: participation.lot.description, value: participation.lot.value, - claimStatus: participation.claim?.status, + claimStatus: participation.claim + ? effectiveClaimStatus(participation.claim.status, gameWindow) + : undefined, wonAt: participation.createdAt, })), }); diff --git a/backend/src/routes/admin.routes.js b/backend/src/routes/admin.routes.js index e5ee7cf..acd0e54 100644 --- a/backend/src/routes/admin.routes.js +++ b/backend/src/routes/admin.routes.js @@ -6,12 +6,14 @@ const { createEmployee, drawWinner, getGrandPrizeWinner, + setGameSettings, + getGameSettings, } = require("../controllers/admin.controller"); const authMiddleware = require("../middlewares/auth.middleware"); const roleMiddleware = require("../middlewares/role.middleware"); const validate = require("../middlewares/validate.middleware"); -const { createEmployeeSchema } = require("../validations/admin.validation"); +const { createEmployeeSchema, gameSettingsSchema } = require("../validations/admin.validation"); const router = express.Router(); @@ -109,4 +111,44 @@ router.post("/draw", drawWinner); */ router.get("/draw", getGrandPrizeWinner); +/** + * @swagger + * /api/admin/game-settings: + * get: + * summary: Fenêtre du jeu-concours (début, fin des participations, fin des retraits) + * tags: + * - Admin + * security: + * - bearerAuth: [] + * responses: + * 200: + * description: Configuration courante du jeu-concours + * put: + * summary: Lance le jeu-concours ou corrige sa date de début + * tags: + * - Admin + * security: + * - bearerAuth: [] + * requestBody: + * required: true + * content: + * application/json: + * schema: + * type: object + * required: + * - startDate + * properties: + * startDate: + * type: string + * format: date-time + * example: 2026-09-01T00:00:00.000Z + * responses: + * 200: + * description: Jeu-concours lancé ou mis à jour + * 400: + * description: Date de début invalide + */ +router.get("/game-settings", getGameSettings); +router.put("/game-settings", validate(gameSettingsSchema), setGameSettings); + module.exports = router; \ No newline at end of file diff --git a/backend/src/routes/game.routes.js b/backend/src/routes/game.routes.js index 26a5b03..69c4eaa 100644 --- a/backend/src/routes/game.routes.js +++ b/backend/src/routes/game.routes.js @@ -1,6 +1,7 @@ const express = require("express"); const { participate, myGains } = require("../controllers/game.controller"); const authMiddleware = require("../middlewares/auth.middleware"); +const roleMiddleware = require("../middlewares/role.middleware"); const validate = require("../middlewares/validate.middleware"); const { participateSchema } = require("../validations/game.validation"); @@ -20,7 +21,9 @@ const router = express.Router(); * description: Participation enregistrée */ -router.post("/participate", authMiddleware, validate(participateSchema), participate); +// Le jeu est reserve aux clients : un ADMIN ou un EMPLOYEE qui joue +// consommerait des tickets et fausserait les statistiques. +router.post("/participate", authMiddleware, roleMiddleware("CLIENT"), validate(participateSchema), participate); /** * @swagger @@ -36,6 +39,6 @@ router.post("/participate", authMiddleware, validate(participateSchema), partici * description: Liste des gains */ -router.get("/my-gains", authMiddleware, myGains); +router.get("/my-gains", authMiddleware, roleMiddleware("CLIENT"), myGains); module.exports = router; \ No newline at end of file diff --git a/backend/src/utils/gameWindow.js b/backend/src/utils/gameWindow.js index eaa34f1..65c19c1 100644 --- a/backend/src/utils/gameWindow.js +++ b/backend/src/utils/gameWindow.js @@ -1,23 +1,41 @@ const prisma = require("../config/prisma"); +// Le concours traverse cinq états, et non deux : confondre « pas encore +// ouvert » avec « terminé » produit des messages faux côté joueur et laisse +// passer le tirage au sort avant le début du jeu. +const PHASES = { + NOT_CONFIGURED: "NOT_CONFIGURED", + SCHEDULED: "SCHEDULED", + OPEN: "OPEN", + CLAIM_ONLY: "CLAIM_ONLY", + CLOSED: "CLOSED", +}; + const GAME_DURATION_DAYS = 30; const CLAIM_EXTENSION_DAYS = 30; const DAY_MS = 24 * 60 * 60 * 1000; -// Fenêtre pendant laquelle un joueur peut à la fois tester son code et -// réclamer son lot : 30 jours de jeu + 30 jours supplémentaires à compter -// de startDate. Tant qu'aucune GameSettings n'existe, le jeu est considéré -// comme non démarré et donc ouvert (comportement de dev par défaut). -async function getGameWindow() { +// Deux échéances distinctes à partir de startDate : +// - participationDeadline (30 j) : dernier moment pour tester un code ; +// - claimDeadline (60 j) : dernier moment pour retirer un lot déjà gagné. +// Les 30 jours supplémentaires servent uniquement au retrait, pas au jeu. +// Tant qu'aucune GameSettings n'existe, le jeu est considéré comme non +// configuré et donc ouvert (comportement de dev par défaut). +async function getGameWindow(now = new Date()) { const settings = await prisma.gameSettings.findFirst({ orderBy: { id: "asc" } }); + // Tant que l'administrateur n'a pas fixé de date, le concours n'existe pas : + // rien n'est jouable et aucun tirage n'est possible. Ouvrir par défaut + // reviendrait à faire gagner des lots dans un jeu qui n'a jamais été lancé. if (!settings) { return { configured: false, + phase: PHASES.NOT_CONFIGURED, startDate: null, participationDeadline: null, claimDeadline: null, - isOpen: true, + isParticipationOpen: false, + isOpen: false, }; } @@ -26,26 +44,57 @@ async function getGameWindow() { settings.startDate.getTime() + (GAME_DURATION_DAYS + CLAIM_EXTENSION_DAYS) * DAY_MS ); + const hasStarted = now >= settings.startDate; + const isParticipationOpen = hasStarted && now <= participationDeadline; + const isOpen = hasStarted && now <= claimDeadline; + + let phase; + if (!hasStarted) phase = PHASES.SCHEDULED; + else if (isParticipationOpen) phase = PHASES.OPEN; + else if (isOpen) phase = PHASES.CLAIM_ONLY; + else phase = PHASES.CLOSED; + return { configured: true, + phase, startDate: settings.startDate, participationDeadline, claimDeadline, - isOpen: new Date() <= claimDeadline, + hasStarted, + isParticipationOpen, + isOpen, }; } -// Bascule en CANCELLED les gains PENDING non réclamés une fois la fenêtre -// fermée. Appelé au fil de l'eau (pas de cron) avant toute lecture/écriture -// de claims, ce qui suffit pour ce projet. -async function expireOverdueClaims() { - const window = await getGameWindow(); - if (window.isOpen) return; - - await prisma.claim.updateMany({ - where: { status: "PENDING" }, - data: { status: "CANCELLED" }, - }); +// L'expiration d'un gain est *dérivée* de la fenêtre de jeu, jamais écrite en +// base : un PENDING dont le délai de retrait est passé est présenté comme +// CANCELLED, mais la ligne reste intacte. Corriger une startDate erronée +// suffit donc à rétablir les gains, et aucune lecture ne modifie la base. +function effectiveClaimStatus(status, gameWindow) { + if (status === "PENDING" && !gameWindow.isOpen) return "CANCELLED"; + return status; } -module.exports = { getGameWindow, expireOverdueClaims }; +// Message destiné au joueur qui tente de jouer hors de la fenêtre : chaque +// phase a sa propre explication, avec la date utile quand elle existe. +function participationClosedMessage(gameWindow) { + switch (gameWindow.phase) { + case PHASES.NOT_CONFIGURED: + return "Le jeu-concours n'est pas encore ouvert. Revenez prochainement."; + case PHASES.SCHEDULED: + return `Le jeu-concours ouvrira le ${gameWindow.startDate.toLocaleDateString("fr-FR")}.`; + case PHASES.CLAIM_ONLY: + return "La période de participation est terminée. Vous pouvez encore retirer les lots déjà gagnés."; + default: + return "Le jeu-concours est terminé. Vous ne pouvez plus tester de code ni réclamer de lot."; + } +} + +module.exports = { + getGameWindow, + effectiveClaimStatus, + participationClosedMessage, + PHASES, + GAME_DURATION_DAYS, + CLAIM_EXTENSION_DAYS, +}; diff --git a/backend/src/validations/admin.validation.js b/backend/src/validations/admin.validation.js index 6ea9852..c6771bf 100644 --- a/backend/src/validations/admin.validation.js +++ b/backend/src/validations/admin.validation.js @@ -9,6 +9,13 @@ const createEmployeeSchema = z.object({ gender: z.string().optional(), }); +const gameSettingsSchema = z.object({ + startDate: z + .string() + .refine((value) => !Number.isNaN(new Date(value).getTime()), "Date de début invalide"), +}); + module.exports = { createEmployeeSchema, + gameSettingsSchema, }; \ No newline at end of file diff --git a/backend/tests/drawNotification.test.js b/backend/tests/drawNotification.test.js new file mode 100644 index 0000000..80c26f9 --- /dev/null +++ b/backend/tests/drawNotification.test.js @@ -0,0 +1,106 @@ +const request = require("supertest"); +const app = require("../src/app"); +const prisma = require("../src/config/prisma"); +const jwt = require("jsonwebtoken"); +const bcrypt = require("bcrypt"); +const mailer = require("../src/config/mailer"); + +jest.mock("../src/config/mailer", () => ({ sendMail: jest.fn() })); + +const DAY = 86400000; +const PREFIX = "notif-"; +const CODE = "NOTIFTICK1"; +let ctx = {}; + +async function cleanup() { + const us = await prisma.user.findMany({ where: { email: { contains: PREFIX } } }); + const ids = us.map((u) => u.id); + const ps = await prisma.participation.findMany({ where: { userId: { in: ids } } }); + await prisma.claim.deleteMany({ where: { participationId: { in: ps.map((p) => p.id) } } }); + await prisma.participation.deleteMany({ where: { userId: { in: ids } } }); + await prisma.grandPrizeWinner.deleteMany({ where: { userId: { in: ids } } }); + await prisma.ticket.deleteMany({ where: { code: CODE } }); + await prisma.user.deleteMany({ where: { id: { in: ids } } }); +} + +const tk = (u) => jwt.sign({ id: u.id, role: u.role }, process.env.JWT_SECRET, { expiresIn: "1h" }); +const jourJ = (d) => + prisma.gameSettings.update({ + where: { id: ctx.settingsId }, + data: { startDate: new Date(Date.now() - d * DAY) }, + }); + +beforeAll(async () => { + await cleanup(); + const row = await prisma.gameSettings.findFirst({ orderBy: { id: "asc" } }); + ctx.settingsId = row.id; + ctx.startDateOriginale = row.startDate; + + const lot = await prisma.lot.findFirst({ orderBy: { id: "asc" } }); + await prisma.ticket.create({ data: { code: CODE, lotId: lot.id } }); + + const h = await bcrypt.hash("Password123", 10); + ctx.admin = await prisma.user.create({ data: { firstname: "Alice", lastname: "Admin", email: PREFIX + "admin@test.local", password: h, role: "ADMIN", isEmailVerified: true } }); + ctx.client = await prisma.user.create({ data: { firstname: "Chloe", lastname: "Martin", email: PREFIX + "chloe@test.local", password: h, role: "CLIENT", isEmailVerified: true } }); + ctx.tAdmin = tk(ctx.admin); + + await jourJ(1); + mailer.sendMail.mockResolvedValue(true); + await request(app).post("/api/game/participate").set("Authorization", "Bearer " + tk(ctx.client)).send({ code: CODE }).expect(201); + await jourJ(35); +}); + +afterAll(async () => { + await prisma.gameSettings.update({ where: { id: ctx.settingsId }, data: { startDate: ctx.startDateOriginale } }); + await cleanup(); + await prisma.$disconnect(); +}); + +describe("Notification du grand gagnant", () => { + beforeEach(() => jest.clearAllMocks()); + + test("le gagnant recoit un email a son adresse", async () => { + mailer.sendMail.mockResolvedValue(true); + + const res = await request(app).post("/api/admin/draw").set("Authorization", "Bearer " + ctx.tAdmin).expect(201); + + expect(res.body.emailSent).toBe(true); + expect(mailer.sendMail).toHaveBeenCalledTimes(1); + + const envoi = mailer.sendMail.mock.calls[0][0]; + expect(envoi.to).toBe(ctx.client.email); + expect(envoi.subject).toMatch(/gros lot/i); + expect(envoi.html).toContain("Chloe"); + expect(envoi.html).toContain("360"); + + await prisma.grandPrizeWinner.deleteMany({ where: { userId: ctx.client.id } }); + }); + + test("un SMTP en panne n invalide pas le tirage", async () => { + mailer.sendMail.mockRejectedValue(new Error("SMTP indisponible")); + + const res = await request(app).post("/api/admin/draw").set("Authorization", "Bearer " + ctx.tAdmin); + + expect(res.status).toBe(201); + expect(res.body.emailSent).toBe(false); + expect(res.body.message).toMatch(/contacter directement/); + + // Le tirage reste enregistre : c est un acte irreversible. + const enBase = await prisma.grandPrizeWinner.findFirst({ where: { userId: ctx.client.id } }); + expect(enBase).not.toBeNull(); + + await prisma.grandPrizeWinner.deleteMany({ where: { userId: ctx.client.id } }); + }); + + test("aucun email n est envoye si le tirage est refuse", async () => { + mailer.sendMail.mockResolvedValue(true); + await jourJ(1); + + const res = await request(app).post("/api/admin/draw").set("Authorization", "Bearer " + ctx.tAdmin); + + expect(res.status).toBe(409); + expect(mailer.sendMail).not.toHaveBeenCalled(); + + await jourJ(35); + }); +}); diff --git a/backend/tests/gainNotification.test.js b/backend/tests/gainNotification.test.js new file mode 100644 index 0000000..5e3bf99 --- /dev/null +++ b/backend/tests/gainNotification.test.js @@ -0,0 +1,113 @@ +const request = require("supertest"); +const app = require("../src/app"); +const prisma = require("../src/config/prisma"); +const jwt = require("jsonwebtoken"); +const bcrypt = require("bcrypt"); +const mailer = require("../src/config/mailer"); + +jest.mock("../src/config/mailer", () => ({ sendMail: jest.fn() })); + +const DAY = 86400000; +const PREFIX = "gain-"; +const CODES = ["GAINTICK01", "GAINTICK02", "GAINTICK03"]; +let ctx = {}; + +async function cleanup() { + const us = await prisma.user.findMany({ where: { email: { contains: PREFIX } } }); + const ids = us.map((u) => u.id); + const ps = await prisma.participation.findMany({ where: { userId: { in: ids } } }); + await prisma.claim.deleteMany({ where: { participationId: { in: ps.map((p) => p.id) } } }); + await prisma.participation.deleteMany({ where: { userId: { in: ids } } }); + await prisma.grandPrizeWinner.deleteMany({ where: { userId: { in: ids } } }); + await prisma.ticket.deleteMany({ where: { code: { in: CODES } } }); + await prisma.user.deleteMany({ where: { id: { in: ids } } }); +} + +const tk = (u) => jwt.sign({ id: u.id, role: u.role }, process.env.JWT_SECRET, { expiresIn: "1h" }); +const jourJ = (d) => + prisma.gameSettings.update({ + where: { id: ctx.settingsId }, + data: { startDate: new Date(Date.now() - d * DAY) }, + }); + +beforeAll(async () => { + await cleanup(); + const row = await prisma.gameSettings.findFirst({ orderBy: { id: "asc" } }); + ctx.settingsId = row.id; + ctx.startDateOriginale = row.startDate; + + ctx.lot = await prisma.lot.findFirst({ orderBy: { id: "asc" } }); + for (const c of CODES) await prisma.ticket.create({ data: { code: c, lotId: ctx.lot.id } }); + + const h = await bcrypt.hash("Password123", 10); + ctx.client = await prisma.user.create({ data: { firstname: "Chloe", lastname: "Martin", email: PREFIX + "chloe@test.local", password: h, role: "CLIENT", isEmailVerified: true } }); + ctx.tClient = tk(ctx.client); + + await jourJ(1); +}); + +afterAll(async () => { + await prisma.gameSettings.update({ where: { id: ctx.settingsId }, data: { startDate: ctx.startDateOriginale } }); + await cleanup(); + await prisma.$disconnect(); +}); + +describe("Notification du gain a chaque participation", () => { + beforeEach(() => jest.clearAllMocks()); + + test("le joueur recoit un email decrivant son lot", async () => { + mailer.sendMail.mockResolvedValue(true); + + const res = await request(app) + .post("/api/game/participate") + .set("Authorization", "Bearer " + ctx.tClient) + .send({ code: CODES[0] }) + .expect(201); + + expect(res.body.emailSent).toBe(true); + expect(mailer.sendMail).toHaveBeenCalledTimes(1); + + const envoi = mailer.sendMail.mock.calls[0][0]; + expect(envoi.to).toBe(ctx.client.email); + expect(envoi.subject).toContain(ctx.lot.name); + expect(envoi.html).toContain("Chloe"); + expect(envoi.html).toContain(CODES[0]); + expect(envoi.html).toContain(ctx.lot.name); + }); + + test("un SMTP en panne n annule pas la participation", async () => { + mailer.sendMail.mockRejectedValue(new Error("SMTP indisponible")); + + const res = await request(app) + .post("/api/game/participate") + .set("Authorization", "Bearer " + ctx.tClient) + .send({ code: CODES[1] }); + + expect(res.status).toBe(201); + expect(res.body.emailSent).toBe(false); + + // Le gain reste acquis et consultable. + const t = await prisma.ticket.findUnique({ where: { code: CODES[1] } }); + expect(t.isUsed).toBe(true); + + const gains = await request(app).get("/api/game/my-gains").set("Authorization", "Bearer " + ctx.tClient).expect(200); + expect(gains.body.gains.some((g) => g.ticketCode === CODES[1])).toBe(true); + }); + + test("aucun email si le code est refuse", async () => { + mailer.sendMail.mockResolvedValue(true); + + const rejoue = await request(app).post("/api/game/participate").set("Authorization", "Bearer " + ctx.tClient).send({ code: CODES[0] }); + expect(rejoue.status).toBe(409); + + const inconnu = await request(app).post("/api/game/participate").set("Authorization", "Bearer " + ctx.tClient).send({ code: "INEXISTAN1" }); + expect(inconnu.status).toBe(404); + + await jourJ(45); + const horsDelai = await request(app).post("/api/game/participate").set("Authorization", "Bearer " + ctx.tClient).send({ code: CODES[2] }); + expect(horsDelai.status).toBe(410); + await jourJ(1); + + expect(mailer.sendMail).not.toHaveBeenCalled(); + }); +}); diff --git a/backend/tests/gameFlow.test.js b/backend/tests/gameFlow.test.js new file mode 100644 index 0000000..040b603 --- /dev/null +++ b/backend/tests/gameFlow.test.js @@ -0,0 +1,163 @@ +const request = require("supertest"); +const app = require("../src/app"); +const prisma = require("../src/config/prisma"); +const jwt = require("jsonwebtoken"); +const bcrypt = require("bcrypt"); + +jest.mock("../src/config/mailer", () => ({ sendMail: jest.fn().mockResolvedValue(true) })); + +const emails = { adm: "v-adm@test.local", emp: "v-emp@test.local", a: "v-a@test.local", b: "v-b@test.local" }; +const CODES = ["VERIFTICK1", "VERIFTICK2", "VERIFTICK3", "VERIFTICK4", "VERIFTICK5"]; +const DAY = 86400000; +let ctx = {}; +const log = (...a) => console.log(" ", ...a); + +async function cleanup() { + const us = await prisma.user.findMany({ where: { email: { in: [...Object.values(emails), "v-r1@test.local", "v-r2@test.local"] } } }); + const ids = us.map((u) => u.id); + const ps = await prisma.participation.findMany({ where: { userId: { in: ids } } }); + await prisma.claim.deleteMany({ where: { participationId: { in: ps.map((p) => p.id) } } }); + await prisma.participation.deleteMany({ where: { userId: { in: ids } } }); + await prisma.grandPrizeWinner.deleteMany({ where: { userId: { in: ids } } }); + await prisma.ticket.deleteMany({ where: { code: { in: CODES } } }); + await prisma.user.deleteMany({ where: { id: { in: ids } } }); +} + +const setStart = (d) => + prisma.gameSettings.update({ where: { id: ctx.settings.id }, data: { startDate: new Date(Date.now() - d * DAY) } }); + +beforeAll(async () => { + await cleanup(); + ctx.settings = await prisma.gameSettings.findFirst({ orderBy: { id: "asc" } }); + ctx.orig = ctx.settings.startDate; + const lot = await prisma.lot.findFirst({ orderBy: { id: "asc" } }); + for (const c of CODES) await prisma.ticket.create({ data: { code: c, lotId: lot.id } }); + const h = await bcrypt.hash("Password123", 10); + const mk = (e, r) => prisma.user.create({ data: { firstname: "V", lastname: r, email: e, password: h, role: r, isEmailVerified: true } }); + ctx.adm = await mk(emails.adm, "ADMIN"); + ctx.emp = await mk(emails.emp, "EMPLOYEE"); + ctx.a = await mk(emails.a, "CLIENT"); + ctx.b = await mk(emails.b, "CLIENT"); + const tk = (u) => jwt.sign({ id: u.id, role: u.role }, process.env.JWT_SECRET, { expiresIn: "1h" }); + ctx.tadm = tk(ctx.adm); + ctx.temp = tk(ctx.emp); + ctx.ta = tk(ctx.a); + ctx.tb = tk(ctx.b); +}); + +afterAll(async () => { + await prisma.gameSettings.update({ where: { id: ctx.settings.id }, data: { startDate: ctx.orig } }); + await cleanup(); + await prisma.$disconnect(); +}); + +describe("VERIFICATION DES 7 CORRECTIFS", () => { + test("#1 ADMIN peut lancer le jeu", async () => { + const futur = new Date(Date.now() + 5 * DAY).toISOString(); + const r = await request(app).put("/api/admin/game-settings").set("Authorization", `Bearer ${ctx.tadm}`).send({ startDate: futur }); + log("PUT game-settings ->", r.status, r.body.message); + expect(r.status).toBe(200); + + const g = await request(app).get("/api/admin/game-settings").set("Authorization", `Bearer ${ctx.tadm}`).expect(200); + log("GET game-settings -> participation ouverte :", g.body.gameWindow.isParticipationOpen, "(jeu pas encore commence)"); + + const bad = await request(app).put("/api/admin/game-settings").set("Authorization", `Bearer ${ctx.tadm}`).send({ startDate: "pas-une-date" }); + log("date invalide ->", bad.status, bad.body.message); + expect(bad.status).toBe(400); + + const nonAdmin = await request(app).put("/api/admin/game-settings").set("Authorization", `Bearer ${ctx.ta}`).send({ startDate: futur }); + log("client tente de lancer le jeu ->", nonAdmin.status); + expect(nonAdmin.status).toBe(403); + }); + + test("#2 participation refusee apres 30 jours", async () => { + await setStart(45); + const r = await request(app).post("/api/game/participate").set("Authorization", `Bearer ${ctx.ta}`).send({ code: CODES[0] }); + log("jour 45 ->", r.status, r.body.message); + expect(r.status).toBe(410); + + await setStart(1); + const ok = await request(app).post("/api/game/participate").set("Authorization", `Bearer ${ctx.ta}`).send({ code: CODES[0] }); + log("jour 1 ->", ok.status, ok.body.message || "accepte"); + expect(ok.status).toBe(201); + ctx.claimId = ok.body.claim.id; + }); + + test("#3 expiration derivee et reversible", async () => { + await setStart(70); + const g1 = await request(app).get("/api/game/my-gains").set("Authorization", `Bearer ${ctx.ta}`).expect(200); + log("jour 70, statut affiche :", g1.body.gains.map((g) => g.claimStatus).join(",")); + expect(g1.body.gains[0].claimStatus).toBe("CANCELLED"); + + const enBase = await prisma.claim.findUnique({ where: { id: ctx.claimId } }); + log("statut REEL en base :", enBase.status, "(non ecrase)"); + expect(enBase.status).toBe("PENDING"); + + await setStart(1); + const g2 = await request(app).get("/api/game/my-gains").set("Authorization", `Bearer ${ctx.ta}`).expect(200); + log("retour jour 1, statut :", g2.body.gains.map((g) => g.claimStatus).join(","), "(reversible)"); + expect(g2.body.gains[0].claimStatus).toBe("PENDING"); + }); + + test("#4 tirage refuse pendant le jeu", async () => { + await setStart(1); + const r = await request(app).post("/api/admin/draw").set("Authorization", `Bearer ${ctx.tadm}`); + log("tirage pendant le jeu ->", r.status, r.body.message); + expect(r.status).toBe(409); + + await setStart(35); + const r2 = await request(app).post("/api/admin/draw").set("Authorization", `Bearer ${ctx.tadm}`); + log("tirage apres cloture ->", r2.status, r2.body.message, r2.body.totalEligibleParticipants ? `(${r2.body.totalEligibleParticipants} eligibles)` : ""); + expect(r2.status).toBe(201); + + await prisma.grandPrizeWinner.deleteMany({ where: { userId: { in: [ctx.a.id, ctx.b.id] } } }); + await setStart(1); + }); + + test("#5 admin et employe ne peuvent plus jouer", async () => { + const r1 = await request(app).post("/api/game/participate").set("Authorization", `Bearer ${ctx.tadm}`).send({ code: CODES[1] }); + const r2 = await request(app).post("/api/game/participate").set("Authorization", `Bearer ${ctx.temp}`).send({ code: CODES[1] }); + log("ADMIN ->", r1.status, r1.body.message); + log("EMPLOYE ->", r2.status, r2.body.message); + expect(r1.status).toBe(403); + expect(r2.status).toBe(403); + + const t = await prisma.ticket.findUnique({ where: { code: CODES[1] } }); + log("le ticket reste vierge :", t.isUsed === false); + expect(t.isUsed).toBe(false); + }); + + test("#6 suppression de compte ne remet pas le ticket en jeu", async () => { + await request(app).post("/api/game/participate").set("Authorization", `Bearer ${ctx.tb}`).send({ code: CODES[2] }).expect(201); + await request(app).delete("/api/auth/account").set("Authorization", `Bearer ${ctx.tb}`).expect(200); + + const t = await prisma.ticket.findUnique({ where: { code: CODES[2] } }); + log("apres suppression : isUsed =", t.isUsed, "| userId =", t.userId); + expect(t.isUsed).toBe(true); + + const rejoue = await request(app).post("/api/game/participate").set("Authorization", `Bearer ${ctx.ta}`).send({ code: CODES[2] }); + log("tentative de rejouer le code ->", rejoue.status, rejoue.body.message); + expect(rejoue.status).toBe(409); + }); + + test("#7 course sur un meme code", async () => { + const h = await bcrypt.hash("Password123", 10); + const c1 = await prisma.user.create({ data: { firstname: "R", lastname: "1", email: "v-r1@test.local", password: h, role: "CLIENT", isEmailVerified: true } }); + const c2 = await prisma.user.create({ data: { firstname: "R", lastname: "2", email: "v-r2@test.local", password: h, role: "CLIENT", isEmailVerified: true } }); + const t1 = jwt.sign({ id: c1.id, role: "CLIENT" }, process.env.JWT_SECRET, { expiresIn: "1h" }); + const t2 = jwt.sign({ id: c2.id, role: "CLIENT" }, process.env.JWT_SECRET, { expiresIn: "1h" }); + + const [r1, r2] = await Promise.all([ + request(app).post("/api/game/participate").set("Authorization", `Bearer ${t1}`).send({ code: CODES[3] }), + request(app).post("/api/game/participate").set("Authorization", `Bearer ${t2}`).send({ code: CODES[3] }), + ]); + + const codes = [r1.status, r2.status].sort(); + log("statuts obtenus :", codes.join(" + "), "(201 + 409 attendu, aucun 500)"); + expect(codes).toEqual([201, 409]); + + const n = await prisma.participation.count({ where: { ticket: { code: CODES[3] } } }); + log("participations pour ce ticket :", n); + expect(n).toBe(1); + }); +}); diff --git a/backend/tests/gamePhases.test.js b/backend/tests/gamePhases.test.js new file mode 100644 index 0000000..8f87ea0 --- /dev/null +++ b/backend/tests/gamePhases.test.js @@ -0,0 +1,145 @@ +const request = require("supertest"); +const app = require("../src/app"); +const prisma = require("../src/config/prisma"); +const jwt = require("jsonwebtoken"); +const bcrypt = require("bcrypt"); + +jest.mock("../src/config/mailer", () => ({ sendMail: jest.fn().mockResolvedValue(true) })); + +const DAY = 86400000; +const PREFIX = "r2-"; +const CODES = ["R2TICKET01", "R2TICKET02", "R2TICKET03", "R2TICKET04"]; +let ctx = {}; +const log = (...a) => console.log(" ", ...a); +const titre = (t) => console.log("\n===== " + t + " ====="); + +async function cleanup() { + const us = await prisma.user.findMany({ where: { email: { contains: PREFIX } } }); + const ids = us.map((u) => u.id); + const ps = await prisma.participation.findMany({ where: { userId: { in: ids } } }); + await prisma.claim.deleteMany({ where: { participationId: { in: ps.map((p) => p.id) } } }); + await prisma.participation.deleteMany({ where: { userId: { in: ids } } }); + await prisma.grandPrizeWinner.deleteMany({ where: { userId: { in: ids } } }); + await prisma.ticket.deleteMany({ where: { code: { in: CODES } } }); + await prisma.user.deleteMany({ where: { id: { in: ids } } }); +} + +async function jourJ(offsetJours) { + const row = await prisma.gameSettings.findFirst({ orderBy: { id: "asc" } }); + const debut = new Date(Date.now() - offsetJours * DAY); + if (row) await prisma.gameSettings.update({ where: { id: row.id }, data: { startDate: debut } }); + else await prisma.gameSettings.create({ data: { startDate: debut } }); +} + +const tk = (u) => jwt.sign({ id: u.id, role: u.role }, process.env.JWT_SECRET, { expiresIn: "2h" }); + +beforeAll(async () => { + await cleanup(); + const row = await prisma.gameSettings.findFirst({ orderBy: { id: "asc" } }); + ctx.startDateOriginale = row ? row.startDate : null; + + const lot = await prisma.lot.findFirst({ orderBy: { id: "asc" } }); + for (const c of CODES) await prisma.ticket.create({ data: { code: c, lotId: lot.id } }); + + const h = await bcrypt.hash("Password123", 10); + ctx.admin = await prisma.user.create({ data: { firstname: "Alice", lastname: "Admin", email: PREFIX + "admin@test.local", password: h, role: "ADMIN", isEmailVerified: true } }); + ctx.client = await prisma.user.create({ data: { firstname: "Zoe", lastname: "Cliente", email: PREFIX + "zoe@test.local", password: h, role: "CLIENT", isEmailVerified: true } }); + ctx.tAdmin = tk(ctx.admin); + ctx.tClient = tk(ctx.client); +}); + +afterAll(async () => { + const row = await prisma.gameSettings.findFirst({ orderBy: { id: "asc" } }); + if (ctx.startDateOriginale) { + if (row) await prisma.gameSettings.update({ where: { id: row.id }, data: { startDate: ctx.startDateOriginale } }); + else await prisma.gameSettings.create({ data: { startDate: ctx.startDateOriginale } }); + } + await cleanup(); + await prisma.$disconnect(); +}); + +describe("VERIFICATION DES 3 CORRECTIFS DE PHASE", () => { + + test("A. rien nest jouable avant tout lancement", async () => { + titre("A : aucune GameSettings en base"); + await prisma.gameSettings.deleteMany({}); + + const vue = await request(app).get("/api/admin/game-settings").set("Authorization", "Bearer " + ctx.tAdmin).expect(200); + log("phase :", vue.body.gameWindow.phase, "| participations ouvertes :", vue.body.gameWindow.isParticipationOpen); + expect(vue.body.gameWindow.phase).toBe("NOT_CONFIGURED"); + + const essai = await request(app).post("/api/game/participate").set("Authorization", "Bearer " + ctx.tClient).send({ code: CODES[0] }); + log("un client tente de jouer ->", essai.status, essai.body.message); + expect(essai.status).toBe(410); + + const t = await prisma.ticket.findUnique({ where: { code: CODES[0] } }); + log("le ticket reste vierge :", t.isUsed === false); + expect(t.isUsed).toBe(false); + + const tirage = await request(app).post("/api/admin/draw").set("Authorization", "Bearer " + ctx.tAdmin); + log("admin tente le tirage ->", tirage.status, tirage.body.message); + expect(tirage.status).toBe(409); + expect(await prisma.grandPrizeWinner.count()).toBe(0); + }); + + test("B. jeu programme : message juste et tirage bloque", async () => { + titre("B : lancement programme a J+7"); + const futur = new Date(Date.now() + 7 * DAY).toISOString(); + const r = await request(app).put("/api/admin/game-settings").set("Authorization", "Bearer " + ctx.tAdmin).send({ startDate: futur }); + log("PUT game-settings ->", r.status, "| phase :", r.body.gameWindow.phase); + expect(r.body.gameWindow.phase).toBe("SCHEDULED"); + + const anticipe = await request(app).post("/api/game/participate").set("Authorization", "Bearer " + ctx.tClient).send({ code: CODES[0] }); + log("client joue avant ouverture ->", anticipe.status); + log("message :", JSON.stringify(anticipe.body.message)); + expect(anticipe.body.message).toMatch(/ouvrira le/); + expect(anticipe.body.message).not.toMatch(/terminée/); + + const tirage = await request(app).post("/api/admin/draw").set("Authorization", "Bearer " + ctx.tAdmin); + log("tirage avant ouverture ->", tirage.status, tirage.body.message); + expect(tirage.status).toBe(409); + expect(tirage.body.message).toMatch(/pas encore commencé/); + }); + + test("C. les quatre phases dun jeu configure", async () => { + titre("C : parcours des phases"); + + await jourJ(1); + let w = (await request(app).get("/api/admin/game-settings").set("Authorization", "Bearer " + ctx.tAdmin)).body.gameWindow; + const ok = await request(app).post("/api/game/participate").set("Authorization", "Bearer " + ctx.tClient).send({ code: CODES[0] }); + log("jour 1 | phase", w.phase, "| participate ->", ok.status); + expect(w.phase).toBe("OPEN"); + expect(ok.status).toBe(201); + ctx.claimId = ok.body.claim.id; + + await jourJ(35); + w = (await request(app).get("/api/admin/game-settings").set("Authorization", "Bearer " + ctx.tAdmin)).body.gameWindow; + const tard = await request(app).post("/api/game/participate").set("Authorization", "Bearer " + ctx.tClient).send({ code: CODES[1] }); + log("jour 35 | phase", w.phase, "| participate ->", tard.status, JSON.stringify(tard.body.message)); + expect(w.phase).toBe("CLAIM_ONLY"); + expect(tard.body.message).toMatch(/participation est terminée/); + + const tirage = await request(app).post("/api/admin/draw").set("Authorization", "Bearer " + ctx.tAdmin); + log("tirage apres cloture ->", tirage.status, tirage.body.message); + expect(tirage.status).toBe(201); + + await jourJ(70); + w = (await request(app).get("/api/admin/game-settings").set("Authorization", "Bearer " + ctx.tAdmin)).body.gameWindow; + const fini = await request(app).post("/api/game/participate").set("Authorization", "Bearer " + ctx.tClient).send({ code: CODES[2] }); + log("jour 70 | phase", w.phase, "| participate ->", fini.status, JSON.stringify(fini.body.message)); + expect(w.phase).toBe("CLOSED"); + expect(fini.body.message).toMatch(/jeu-concours est terminé/); + }); + + test("D. non-regression : le parcours nominal fonctionne toujours", async () => { + titre("D : verification du parcours de base"); + await jourJ(2); + const g = await request(app).get("/api/game/my-gains").set("Authorization", "Bearer " + ctx.tClient).expect(200); + log("gains du client :", JSON.stringify(g.body.gains.map((x) => x.claimStatus))); + expect(g.body.gains[0].claimStatus).toBe("PENDING"); + + const stats = await request(app).get("/api/admin/stats").set("Authorization", "Bearer " + ctx.tAdmin).expect(200); + log("stats : phase =", stats.body.gameWindow.phase, "| participations =", stats.body.participations); + expect(stats.body.gameWindow.phase).toBe("OPEN"); + }); +}); diff --git a/backend/tests/register.mail.test.js b/backend/tests/register.mail.test.js new file mode 100644 index 0000000..463821c --- /dev/null +++ b/backend/tests/register.mail.test.js @@ -0,0 +1,66 @@ +const request = require("supertest"); +const app = require("../src/app"); +const prisma = require("../src/config/prisma"); +const mailer = require("../src/config/mailer"); + +jest.mock("../src/config/mailer", () => ({ + sendMail: jest.fn(), +})); + +// Comme le reste de la suite, ce fichier tourne sur la base de dev : il ne +// touche qu'au compte qu'il crée lui-même, identifié par cet email. +const testEmail = "rollback-mail-test@test.com"; + +const payload = { + firstname: "Camille", + lastname: "Martin", + email: testEmail, + password: "Password123", + birthdate: "1990-05-14", + gender: "Femme", +}; + +beforeEach(async () => { + jest.clearAllMocks(); + await prisma.user.deleteMany({ where: { email: testEmail } }); +}); + +afterAll(async () => { + await prisma.user.deleteMany({ where: { email: testEmail } }); + await prisma.$disconnect(); +}); + +describe("Inscription - echec d'envoi du code de validation", () => { + test("repond 502 et ne laisse aucun compte orphelin", async () => { + mailer.sendMail.mockRejectedValue(new Error("SMTP indisponible")); + + const res = await request(app).post("/api/auth/register").send(payload); + + expect(res.statusCode).toBe(502); + + const user = await prisma.user.findUnique({ where: { email: testEmail } }); + expect(user).toBeNull(); + }); + + test("l'email reste reutilisable apres un echec d'envoi", async () => { + mailer.sendMail.mockRejectedValueOnce(new Error("SMTP indisponible")); + const echec = await request(app).post("/api/auth/register").send(payload); + expect(echec.statusCode).toBe(502); + + mailer.sendMail.mockResolvedValue(true); + const succes = await request(app).post("/api/auth/register").send(payload); + + expect(succes.statusCode).toBe(201); + }); + + test("enregistre la date de naissance et le genre", async () => { + mailer.sendMail.mockResolvedValue(true); + + const res = await request(app).post("/api/auth/register").send(payload); + expect(res.statusCode).toBe(201); + + const user = await prisma.user.findUnique({ where: { email: testEmail } }); + expect(user.gender).toBe("Femme"); + expect(user.birthdate.toISOString()).toBe("1990-05-14T00:00:00.000Z"); + }); +}); diff --git a/frontend/src/pages/Admin.jsx b/frontend/src/pages/Admin.jsx index 4272e81..521c00a 100644 --- a/frontend/src/pages/Admin.jsx +++ b/frontend/src/pages/Admin.jsx @@ -1,5 +1,5 @@ import { useState, useEffect } from 'react' -import { getStatsApi, getUsersApi, drawWinnerApi, getWinnerApi, getEmailingContactsApi } from '../services/admin' +import { getStatsApi, getUsersApi, drawWinnerApi, getWinnerApi, getEmailingContactsApi, setGameSettingsApi } from '../services/admin' import { searchTicketApi, deliverClaimApi } from '../services/employee' import { useHandleAuthError } from '../hooks/useHandleAuthError' @@ -13,6 +13,32 @@ function StatCard({ label, value, sub }) { ) } +// Les cinq états renvoyés par l'API, chacun avec sa couleur : « programmé » +// ne doit pas être confondu avec « retraits uniquement ». +const PHASE_BADGES = { + NOT_CONFIGURED: { label: 'Non lancé', className: 'bg-amber-100 text-amber-700' }, + SCHEDULED: { label: 'Programmé', className: 'bg-indigo-100 text-indigo-700' }, + OPEN: { label: 'Participations ouvertes', className: 'bg-green-100 text-green-700' }, + CLAIM_ONLY: { label: 'Retraits uniquement', className: 'bg-blue-100 text-blue-700' }, + CLOSED: { label: 'Terminé', className: 'bg-red-100 text-red-700' }, +} + +const PHASE_PANELS = { + NOT_CONFIGURED: 'bg-amber-50 border border-amber-200', + SCHEDULED: 'bg-indigo-50 border border-indigo-200', + OPEN: 'bg-white', + CLAIM_ONLY: 'bg-white', + CLOSED: 'bg-red-50 border border-red-200', +} + +const PHASE_TITLES = { + NOT_CONFIGURED: 'Jeu-concours non lancé', + SCHEDULED: 'Jeu-concours programmé', + OPEN: 'Jeu-concours en cours', + CLAIM_ONLY: 'Participations closes, retraits en cours', + CLOSED: 'Jeu-concours terminé', +} + function formatDate(dateStr) { return new Date(dateStr).toLocaleDateString('fr-FR', { day: '2-digit', month: 'long', year: 'numeric', @@ -31,6 +57,12 @@ export default function Admin() { const [error, setError] = useState('') const [showConfirm, setShowConfirm] = useState(false) + // Lancement du jeu-concours + const [startDateInput, setStartDateInput] = useState('') + const [settingsSaving, setSettingsSaving] = useState(false) + const [settingsError, setSettingsError] = useState('') + const [settingsOk, setSettingsOk] = useState('') + // Remise de lot const [ticketCode, setTicketCode] = useState('') const [ticketResult, setTicketResult] = useState(null) @@ -58,6 +90,25 @@ export default function Admin() { .finally(() => setLoading(false)) }, []) + const handleSaveGameSettings = async (e) => { + e.preventDefault() + setSettingsError('') + setSettingsOk('') + setSettingsSaving(true) + try { + // L'input datetime-local renvoie une heure locale sans fuseau : on la + // convertit en ISO pour que le serveur reçoive un instant sans ambiguïté. + const data = await setGameSettingsApi(new Date(startDateInput).toISOString()) + setStats((prev) => (prev ? { ...prev, gameWindow: data.gameWindow } : prev)) + setSettingsOk(data.message) + setStartDateInput('') + } catch (err) { + if (!handleAuthError(err)) setSettingsError(err.message) + } finally { + setSettingsSaving(false) + } + } + const handleDraw = async () => { setShowConfirm(false) setDrawing(true) @@ -164,21 +215,61 @@ export default function Admin() {

Tableau de bord Admin

{/* Fenêtre du jeu-concours */} - {stats?.gameWindow?.configured && ( -
+ {stats?.gameWindow && ( +

- Jeu-concours {stats.gameWindow.isOpen ? 'en cours' : 'terminé'} -

-

- Début : {formatDate(stats.gameWindow.startDate)} — Fin des participations et réclamations : {formatDate(stats.gameWindow.claimDeadline)} + {PHASE_TITLES[stats.gameWindow.phase] ?? 'Jeu-concours'}

+ {stats.gameWindow.configured ? ( +

+ Début : {formatDate(stats.gameWindow.startDate)} +
+ Fin des participations : {formatDate(stats.gameWindow.participationDeadline)} +
+ Fin des retraits de lots : {formatDate(stats.gameWindow.claimDeadline)} +

+ ) : ( +

+ Choisissez une date de début pour ouvrir le jeu-concours. +

+ )}
- - {stats.gameWindow.isOpen ? 'Ouvert' : 'Fermé'} + + {PHASE_BADGES[stats.gameWindow.phase]?.label ?? 'État inconnu'}
+ +
+
+ + setStartDateInput(e.target.value)} + required + className="w-full border border-gray-200 rounded-lg px-3 py-2 text-sm text-gray-700 bg-white focus:outline-none focus:ring-1 focus:ring-[#006B6B] focus:border-[#006B6B]" + /> +
+ +
+ +

+ Les participations durent 30 jours, puis 30 jours supplémentaires sont laissés pour retirer les lots. +

+ + {settingsError &&

{settingsError}

} + {settingsOk &&

{settingsOk}

}
)} @@ -440,6 +531,13 @@ export default function Admin() { Tirage effectué le {formatDate(winner.winner.createdAt)}

)} + {/* Signalé uniquement au retour du tirage : le GET du gagnant ne + renvoie pas emailSent, et l'absence de la clé ne vaut pas échec. */} + {winner?.emailSent === false && ( +

+ L'email de notification n'a pas pu être envoyé au gagnant. Merci de le contacter directement à l'adresse ci-dessus. +

+ )}
) : ( /* Pas encore de tirage */ diff --git a/frontend/src/pages/Connexion.jsx b/frontend/src/pages/Connexion.jsx index e931393..502b20d 100644 --- a/frontend/src/pages/Connexion.jsx +++ b/frontend/src/pages/Connexion.jsx @@ -132,9 +132,12 @@ export default function Connexion() { onClick={handleGoogleLogin} className="flex items-center justify-center gap-2 border border-gray-200 rounded-lg py-3 text-sm text-gray-700 hover:bg-gray-50 transition-colors" > -
- G -
+ Google diff --git a/frontend/src/pages/Inscription.jsx b/frontend/src/pages/Inscription.jsx index 0edfa67..86f0861 100644 --- a/frontend/src/pages/Inscription.jsx +++ b/frontend/src/pages/Inscription.jsx @@ -3,6 +3,13 @@ import { Link, useNavigate } from 'react-router-dom' import { registerApi, verifyCodeApi } from '../services/auth' import { useAuth } from '../context/AuthContext' +// Une naissance ne peut pas être dans le futur : borne haute du sélecteur. +const AUJOURD_HUI = new Date().toISOString().split('T')[0] + +// Valeurs alignées sur le tableau de bord admin, qui compte exactement +// "Homme" et "Femme" ; toute autre valeur retombe dans la catégorie "autre". +const GENRES = ['Femme', 'Homme', 'Autre'] + export default function Inscription() { const navigate = useNavigate() const { login } = useAuth() @@ -10,8 +17,18 @@ export default function Inscription() { // Étape : 'form' | 'verify' const [step, setStep] = useState('form') const [email, setEmail] = useState('') - const [form, setForm] = useState({ email: '', lastName: '', firstName: '', password: '' }) + const [form, setForm] = useState({ + email: '', + lastName: '', + firstName: '', + password: '', + birthdate: '', + gender: '', + }) const [acceptedTerms, setAcceptedTerms] = useState(false) + // Consentement marketing : distinct des CGU et décoché par défaut, il doit + // rester un choix libre et non une condition d'inscription. + const [marketingOptIn, setMarketingOptIn] = useState(false) const [showPassword, setShowPassword] = useState(false) const [error, setError] = useState('') const [loading, setLoading] = useState(false) @@ -40,6 +57,9 @@ export default function Inscription() { firstname: form.firstName, lastname: form.lastName, password: form.password, + birthdate: form.birthdate, + gender: form.gender, + marketingOptIn, }) setEmail(form.email) setStep('verify') @@ -181,6 +201,23 @@ export default function Inscription() { className="w-full border border-gray-200 rounded-lg px-4 py-3 text-sm text-gray-700 placeholder-gray-300 focus:outline-none focus:ring-1 focus:ring-[#006B6B] focus:border-[#006B6B]" /> +
+ + +
+ +
+ + +
+
@@ -206,6 +243,18 @@ export default function Inscription() { + +
diff --git a/frontend/src/services/admin.js b/frontend/src/services/admin.js index 152d288..9efd60b 100644 --- a/frontend/src/services/admin.js +++ b/frontend/src/services/admin.js @@ -39,6 +39,17 @@ export async function getEmailingContactsApi() { return data } +export async function setGameSettingsApi(startDate) { + const res = await fetch('/api/admin/game-settings', { + method: 'PUT', + headers: authHeaders(), + body: JSON.stringify({ startDate }), + }) + const data = await res.json() + if (!res.ok) throw apiError(data.message || 'Erreur configuration du jeu.', res.status) + return data +} + export async function getWinnerApi() { const res = await fetch('/api/admin/draw', { headers: authHeaders() }) const data = await res.json() diff --git a/frontend/src/services/auth.js b/frontend/src/services/auth.js index eb55d8a..c115d90 100644 --- a/frontend/src/services/auth.js +++ b/frontend/src/services/auth.js @@ -17,11 +17,29 @@ export async function loginApi({ email, password }) { return data } -export async function registerApi({ email, firstname, lastname, password }) { +export async function registerApi({ + email, + firstname, + lastname, + password, + birthdate, + gender, + marketingOptIn, +}) { const res = await fetch(`${BASE}/register`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ email, firstname, lastname, password }), + // birthdate et gender sont facultatifs : on omet la clé plutôt que + // d'envoyer une chaîne vide, que l'API stockerait telle quelle. + body: JSON.stringify({ + email, + firstname, + lastname, + password, + birthdate: birthdate || undefined, + gender: gender || undefined, + marketingOptIn: marketingOptIn ?? undefined, + }), }) const data = await res.json() if (!res.ok) throw apiError(data.message || "Erreur lors de l'inscription.", res.status) diff --git a/frontend/src/test/fonctionnel/Inscription.test.jsx b/frontend/src/test/fonctionnel/Inscription.test.jsx index 8f63c02..e774776 100644 --- a/frontend/src/test/fonctionnel/Inscription.test.jsx +++ b/frontend/src/test/fonctionnel/Inscription.test.jsx @@ -16,7 +16,7 @@ async function remplirEtSoumettre() { await userEvent.type(screen.getByLabelText(/^nom/i), 'Dupont') await userEvent.type(screen.getByLabelText(/prénom/i), 'Jean') await userEvent.type(screen.getByLabelText(/^mot de passe/i), 'MotDePasse123!') - await userEvent.click(screen.getByRole('checkbox')) + await userEvent.click(screen.getByRole('checkbox', { name: /conditions d'utilisation/i })) await userEvent.click(screen.getByRole('button', { name: /s'inscrire/i })) } @@ -49,7 +49,7 @@ describe('Inscription', () => { const bouton = screen.getByRole('button', { name: /s'inscrire/i }) expect(bouton).toBeDisabled() - await userEvent.click(screen.getByRole('checkbox')) + await userEvent.click(screen.getByRole('checkbox', { name: /conditions d'utilisation/i })) expect(bouton).toBeEnabled() }) @@ -64,11 +64,64 @@ describe('Inscription', () => { firstname: 'Jean', lastname: 'Dupont', password: 'MotDePasse123!', + birthdate: '', + gender: '', + marketingOptIn: false, }) expect(await screen.findByRole('heading', { name: /vérifiez votre email/i })).toBeInTheDocument() expect(screen.getByText('jean@example.com')).toBeInTheDocument() }) + it("transmet la date de naissance et le genre saisis", async () => { + registerApi.mockResolvedValue({ message: 'ok' }) + + renderWithProviders(, { route: '/inscription' }) + + await userEvent.type(screen.getByLabelText(/adresse email/i), 'jean@example.com') + await userEvent.type(screen.getByLabelText(/^nom/i), 'Dupont') + await userEvent.type(screen.getByLabelText(/prénom/i), 'Jean') + await userEvent.type(screen.getByLabelText(/date de naissance/i), '1990-05-14') + await userEvent.selectOptions(screen.getByLabelText(/genre/i), 'Femme') + await userEvent.type(screen.getByLabelText(/^mot de passe/i), 'MotDePasse123!') + await userEvent.click(screen.getByRole('checkbox', { name: /conditions d'utilisation/i })) + await userEvent.click(screen.getByRole('button', { name: /s'inscrire/i })) + + expect(registerApi).toHaveBeenCalledWith( + expect.objectContaining({ birthdate: '1990-05-14', gender: 'Femme' }) + ) + }) + + it("transmet le consentement marketing quand il est coché", async () => { + registerApi.mockResolvedValue({ message: 'ok' }) + + renderWithProviders(, { route: '/inscription' }) + + const marketing = screen.getByRole('checkbox', { name: /offres et actualités/i }) + expect(marketing).not.toBeChecked() + + await userEvent.type(screen.getByLabelText(/adresse email/i), 'jean@example.com') + await userEvent.type(screen.getByLabelText(/^nom/i), 'Dupont') + await userEvent.type(screen.getByLabelText(/prénom/i), 'Jean') + await userEvent.type(screen.getByLabelText(/^mot de passe/i), 'MotDePasse123!') + await userEvent.click(marketing) + await userEvent.click(screen.getByRole('checkbox', { name: /conditions d'utilisation/i })) + await userEvent.click(screen.getByRole('button', { name: /s'inscrire/i })) + + expect(registerApi).toHaveBeenCalledWith( + expect.objectContaining({ marketingOptIn: true }) + ) + }) + + it("n'exige pas le consentement marketing pour s'inscrire", async () => { + renderWithProviders(, { route: '/inscription' }) + + const bouton = screen.getByRole('button', { name: /s'inscrire/i }) + await userEvent.click(screen.getByRole('checkbox', { name: /conditions d'utilisation/i })) + + expect(screen.getByRole('checkbox', { name: /offres et actualités/i })).not.toBeChecked() + expect(bouton).toBeEnabled() + }) + it("affiche l'erreur de l'API si l'inscription échoue", async () => { registerApi.mockRejectedValue(new Error('Cet email est déjà utilisé.'))