ajout des champs date de naissance et gender sur l'inscription, modification de la permition de lancer le jeu concour par l'administrateur, envoie des mail a chaque lot remporter
This commit is contained in:
@@ -1,7 +1,8 @@
|
|||||||
const bcrypt = require("bcrypt");
|
const bcrypt = require("bcrypt");
|
||||||
const crypto = require("crypto");
|
const crypto = require("crypto");
|
||||||
const prisma = require("../config/prisma");
|
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+"];
|
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) => {
|
const getStats = async (req, res) => {
|
||||||
try {
|
try {
|
||||||
await expireOverdueClaims();
|
|
||||||
const gameWindow = await getGameWindow();
|
const gameWindow = await getGameWindow();
|
||||||
|
|
||||||
const [
|
const [
|
||||||
@@ -80,9 +80,11 @@ const getStats = async (req, res) => {
|
|||||||
ticketsUsed,
|
ticketsUsed,
|
||||||
ticketsRemaining: ticketsTotal - ticketsUsed,
|
ticketsRemaining: ticketsTotal - ticketsUsed,
|
||||||
participations,
|
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,
|
claimsDelivered,
|
||||||
claimsCancelled,
|
claimsCancelled: gameWindow.isOpen ? claimsCancelled : claimsCancelled + claimsPending,
|
||||||
users,
|
users,
|
||||||
gameWindow,
|
gameWindow,
|
||||||
ageStats: computeAgeStats(clientsWithBirthdate),
|
ageStats: computeAgeStats(clientsWithBirthdate),
|
||||||
@@ -215,6 +217,31 @@ const createEmployee = async (req, res) => {
|
|||||||
|
|
||||||
const drawWinner = async (req, res) => {
|
const drawWinner = async (req, res) => {
|
||||||
try {
|
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({
|
const existingWinner = await prisma.grandPrizeWinner.findFirst({
|
||||||
include: {
|
include: {
|
||||||
user: {
|
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: `
|
||||||
|
<div style="font-family:sans-serif;max-width:480px;margin:auto">
|
||||||
|
<h2 style="color:#006B6B">Félicitations ${savedWinner.user.firstname} !</h2>
|
||||||
|
<p>Le tirage au sort du jeu-concours « 100% Gagnant » vient d'avoir lieu,
|
||||||
|
et c'est vous qui remportez le gros lot :</p>
|
||||||
|
<div style="font-size:20px;font-weight:bold;color:#006B6B;text-align:center;padding:20px;background:#F0F0F0;border-radius:12px">
|
||||||
|
${savedWinner.prize}
|
||||||
|
</div>
|
||||||
|
<p>Notre équipe vous recontactera très prochainement pour convenir des
|
||||||
|
modalités de remise de votre lot.</p>
|
||||||
|
<p style="color:#888;font-size:13px">Merci de votre participation et à bientôt chez Thé Tip Top.</p>
|
||||||
|
</div>
|
||||||
|
`,
|
||||||
|
});
|
||||||
|
} catch (mailError) {
|
||||||
|
emailSent = false;
|
||||||
|
console.error("Erreur envoi email au grand gagnant:", mailError);
|
||||||
|
}
|
||||||
|
|
||||||
return res.status(201).json({
|
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,
|
totalEligibleParticipants: participants.length,
|
||||||
|
emailSent,
|
||||||
winner: savedWinner,
|
winner: savedWinner,
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} 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) => {
|
const getGrandPrizeWinner = async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const winner = await prisma.grandPrizeWinner.findFirst({
|
const winner = await prisma.grandPrizeWinner.findFirst({
|
||||||
@@ -329,4 +434,6 @@ module.exports = {
|
|||||||
createEmployee,
|
createEmployee,
|
||||||
drawWinner,
|
drawWinner,
|
||||||
getGrandPrizeWinner,
|
getGrandPrizeWinner,
|
||||||
|
setGameSettings,
|
||||||
|
getGameSettings,
|
||||||
};
|
};
|
||||||
@@ -62,6 +62,12 @@ const register = async (req, res) => {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 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({
|
await transporter.sendMail({
|
||||||
from: process.env.MAIL_FROM,
|
from: process.env.MAIL_FROM,
|
||||||
to: email,
|
to: email,
|
||||||
@@ -78,6 +84,20 @@ const register = async (req, res) => {
|
|||||||
</div>
|
</div>
|
||||||
`,
|
`,
|
||||||
});
|
});
|
||||||
|
} 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({
|
return res.status(201).json({
|
||||||
message: "Inscription réussie. Un code de validation a été envoyé par email.",
|
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
|
// 4. Supprimer les participations
|
||||||
await tx.participation.deleteMany({ where: { userId } });
|
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({
|
await tx.ticket.updateMany({
|
||||||
where: { userId },
|
where: { userId },
|
||||||
data: { userId: null, isUsed: false, usedAt: null },
|
data: { userId: null },
|
||||||
});
|
});
|
||||||
|
|
||||||
// 6. Supprimer l'utilisateur
|
// 6. Supprimer l'utilisateur
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
const prisma = require("../config/prisma");
|
const prisma = require("../config/prisma");
|
||||||
const { expireOverdueClaims } = require("../utils/gameWindow");
|
const { getGameWindow, effectiveClaimStatus } = require("../utils/gameWindow");
|
||||||
|
|
||||||
const getGainByTicketCode = async (req, res) => {
|
const getGainByTicketCode = async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const { code } = req.params;
|
const { code } = req.params;
|
||||||
|
|
||||||
await expireOverdueClaims();
|
const gameWindow = await getGameWindow();
|
||||||
|
|
||||||
const ticket = await prisma.ticket.findUnique({
|
const ticket = await prisma.ticket.findUnique({
|
||||||
where: { code: code.toUpperCase() },
|
where: { code: code.toUpperCase() },
|
||||||
@@ -52,7 +52,7 @@ const getGainByTicketCode = async (req, res) => {
|
|||||||
},
|
},
|
||||||
claim: {
|
claim: {
|
||||||
id: ticket.participation.claim.id,
|
id: ticket.participation.claim.id,
|
||||||
status: ticket.participation.claim.status,
|
status: effectiveClaimStatus(ticket.participation.claim.status, gameWindow),
|
||||||
claimedAt: ticket.participation.claim.claimedAt,
|
claimedAt: ticket.participation.claim.claimedAt,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -69,7 +69,7 @@ const deliverClaim = async (req, res) => {
|
|||||||
const { id } = req.params;
|
const { id } = req.params;
|
||||||
const employeeId = req.user.id;
|
const employeeId = req.user.id;
|
||||||
|
|
||||||
await expireOverdueClaims();
|
const gameWindow = await getGameWindow();
|
||||||
|
|
||||||
const claim = await prisma.claim.findUnique({
|
const claim = await prisma.claim.findUnique({
|
||||||
where: { id: Number(id) },
|
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({
|
return res.status(400).json({
|
||||||
message: "Ce gain est expiré et ne peut plus être remis.",
|
message: "Ce gain est expiré et ne peut plus être remis.",
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,5 +1,13 @@
|
|||||||
const prisma = require("../config/prisma");
|
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) => {
|
const participate = async (req, res) => {
|
||||||
try {
|
try {
|
||||||
@@ -7,9 +15,13 @@ const participate = async (req, res) => {
|
|||||||
const userId = req.user.id;
|
const userId = req.user.id;
|
||||||
|
|
||||||
const gameWindow = await getGameWindow();
|
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({
|
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 result = await prisma.$transaction(async (tx) => {
|
||||||
const updatedTicket = await tx.ticket.update({
|
// Le contrôle isUsed ci-dessus ne suffit pas : deux requêtes simultanées
|
||||||
where: { id: ticket.id },
|
// 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: {
|
data: {
|
||||||
isUsed: true,
|
isUsed: true,
|
||||||
usedAt: new Date(),
|
usedAt: new Date(),
|
||||||
userId,
|
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({
|
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: `
|
||||||
|
<div style="font-family:sans-serif;max-width:480px;margin:auto">
|
||||||
|
<h2 style="color:#006B6B">Bravo ${req.user.firstname} !</h2>
|
||||||
|
<p>Votre code <strong>${result.ticket.code}</strong> est gagnant. Vous remportez :</p>
|
||||||
|
<div style="font-size:20px;font-weight:bold;color:#006B6B;text-align:center;padding:20px;background:#F0F0F0;border-radius:12px">
|
||||||
|
${result.ticket.lot.name}
|
||||||
|
</div>
|
||||||
|
${result.ticket.lot.description ? `<p style="text-align:center;color:#666">${result.ticket.lot.description}</p>` : ""}
|
||||||
|
<p>Présentez-vous en boutique avec ce code pour retirer votre lot${
|
||||||
|
gameWindow.claimDeadline
|
||||||
|
? `, avant le <strong>${gameWindow.claimDeadline.toLocaleDateString("fr-FR")}</strong>`
|
||||||
|
: ""
|
||||||
|
}.</p>
|
||||||
|
<p style="color:#888;font-size:13px">Retrouvez à tout moment vos gains dans votre espace « Mes gains ».</p>
|
||||||
|
</div>
|
||||||
|
`,
|
||||||
|
});
|
||||||
|
} catch (mailError) {
|
||||||
|
emailSent = false;
|
||||||
|
console.error("Erreur envoi email de gain:", mailError);
|
||||||
|
}
|
||||||
|
|
||||||
return res.status(201).json({
|
return res.status(201).json({
|
||||||
message: "Participation enregistrée avec succès.",
|
message: "Participation enregistrée avec succès.",
|
||||||
|
emailSent,
|
||||||
gain: {
|
gain: {
|
||||||
name: result.ticket.lot.name,
|
name: result.ticket.lot.name,
|
||||||
description: result.ticket.lot.description,
|
description: result.ticket.lot.description,
|
||||||
@@ -80,6 +133,12 @@ const participate = async (req, res) => {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
if (error instanceof TicketDejaUtiliseError) {
|
||||||
|
return res.status(409).json({
|
||||||
|
message: "Ce code a déjà été utilisé.",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
console.error("Erreur participation:", error);
|
console.error("Erreur participation:", error);
|
||||||
return res.status(500).json({
|
return res.status(500).json({
|
||||||
message: "Erreur serveur lors de la participation.",
|
message: "Erreur serveur lors de la participation.",
|
||||||
@@ -91,7 +150,7 @@ const myGains = async (req, res) => {
|
|||||||
try {
|
try {
|
||||||
const userId = req.user.id;
|
const userId = req.user.id;
|
||||||
|
|
||||||
await expireOverdueClaims();
|
const gameWindow = await getGameWindow();
|
||||||
|
|
||||||
const participations = await prisma.participation.findMany({
|
const participations = await prisma.participation.findMany({
|
||||||
where: { userId },
|
where: { userId },
|
||||||
@@ -112,7 +171,9 @@ const myGains = async (req, res) => {
|
|||||||
lot: participation.lot.name,
|
lot: participation.lot.name,
|
||||||
description: participation.lot.description,
|
description: participation.lot.description,
|
||||||
value: participation.lot.value,
|
value: participation.lot.value,
|
||||||
claimStatus: participation.claim?.status,
|
claimStatus: participation.claim
|
||||||
|
? effectiveClaimStatus(participation.claim.status, gameWindow)
|
||||||
|
: undefined,
|
||||||
wonAt: participation.createdAt,
|
wonAt: participation.createdAt,
|
||||||
})),
|
})),
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -6,12 +6,14 @@ const {
|
|||||||
createEmployee,
|
createEmployee,
|
||||||
drawWinner,
|
drawWinner,
|
||||||
getGrandPrizeWinner,
|
getGrandPrizeWinner,
|
||||||
|
setGameSettings,
|
||||||
|
getGameSettings,
|
||||||
} = require("../controllers/admin.controller");
|
} = require("../controllers/admin.controller");
|
||||||
|
|
||||||
const authMiddleware = require("../middlewares/auth.middleware");
|
const authMiddleware = require("../middlewares/auth.middleware");
|
||||||
const roleMiddleware = require("../middlewares/role.middleware");
|
const roleMiddleware = require("../middlewares/role.middleware");
|
||||||
const validate = require("../middlewares/validate.middleware");
|
const validate = require("../middlewares/validate.middleware");
|
||||||
const { createEmployeeSchema } = require("../validations/admin.validation");
|
const { createEmployeeSchema, gameSettingsSchema } = require("../validations/admin.validation");
|
||||||
|
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
|
|
||||||
@@ -109,4 +111,44 @@ router.post("/draw", drawWinner);
|
|||||||
*/
|
*/
|
||||||
router.get("/draw", getGrandPrizeWinner);
|
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;
|
module.exports = router;
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
const express = require("express");
|
const express = require("express");
|
||||||
const { participate, myGains } = require("../controllers/game.controller");
|
const { participate, myGains } = require("../controllers/game.controller");
|
||||||
const authMiddleware = require("../middlewares/auth.middleware");
|
const authMiddleware = require("../middlewares/auth.middleware");
|
||||||
|
const roleMiddleware = require("../middlewares/role.middleware");
|
||||||
const validate = require("../middlewares/validate.middleware");
|
const validate = require("../middlewares/validate.middleware");
|
||||||
const { participateSchema } = require("../validations/game.validation");
|
const { participateSchema } = require("../validations/game.validation");
|
||||||
|
|
||||||
@@ -20,7 +21,9 @@ const router = express.Router();
|
|||||||
* description: Participation enregistrée
|
* 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
|
* @swagger
|
||||||
@@ -36,6 +39,6 @@ router.post("/participate", authMiddleware, validate(participateSchema), partici
|
|||||||
* description: Liste des gains
|
* description: Liste des gains
|
||||||
*/
|
*/
|
||||||
|
|
||||||
router.get("/my-gains", authMiddleware, myGains);
|
router.get("/my-gains", authMiddleware, roleMiddleware("CLIENT"), myGains);
|
||||||
|
|
||||||
module.exports = router;
|
module.exports = router;
|
||||||
@@ -1,23 +1,41 @@
|
|||||||
const prisma = require("../config/prisma");
|
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 GAME_DURATION_DAYS = 30;
|
||||||
const CLAIM_EXTENSION_DAYS = 30;
|
const CLAIM_EXTENSION_DAYS = 30;
|
||||||
const DAY_MS = 24 * 60 * 60 * 1000;
|
const DAY_MS = 24 * 60 * 60 * 1000;
|
||||||
|
|
||||||
// Fenêtre pendant laquelle un joueur peut à la fois tester son code et
|
// Deux échéances distinctes à partir de startDate :
|
||||||
// réclamer son lot : 30 jours de jeu + 30 jours supplémentaires à compter
|
// - participationDeadline (30 j) : dernier moment pour tester un code ;
|
||||||
// de startDate. Tant qu'aucune GameSettings n'existe, le jeu est considéré
|
// - claimDeadline (60 j) : dernier moment pour retirer un lot déjà gagné.
|
||||||
// comme non démarré et donc ouvert (comportement de dev par défaut).
|
// Les 30 jours supplémentaires servent uniquement au retrait, pas au jeu.
|
||||||
async function getGameWindow() {
|
// 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" } });
|
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) {
|
if (!settings) {
|
||||||
return {
|
return {
|
||||||
configured: false,
|
configured: false,
|
||||||
|
phase: PHASES.NOT_CONFIGURED,
|
||||||
startDate: null,
|
startDate: null,
|
||||||
participationDeadline: null,
|
participationDeadline: null,
|
||||||
claimDeadline: 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
|
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 {
|
return {
|
||||||
configured: true,
|
configured: true,
|
||||||
|
phase,
|
||||||
startDate: settings.startDate,
|
startDate: settings.startDate,
|
||||||
participationDeadline,
|
participationDeadline,
|
||||||
claimDeadline,
|
claimDeadline,
|
||||||
isOpen: new Date() <= claimDeadline,
|
hasStarted,
|
||||||
|
isParticipationOpen,
|
||||||
|
isOpen,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// Bascule en CANCELLED les gains PENDING non réclamés une fois la fenêtre
|
// L'expiration d'un gain est *dérivée* de la fenêtre de jeu, jamais écrite en
|
||||||
// fermée. Appelé au fil de l'eau (pas de cron) avant toute lecture/écriture
|
// base : un PENDING dont le délai de retrait est passé est présenté comme
|
||||||
// de claims, ce qui suffit pour ce projet.
|
// CANCELLED, mais la ligne reste intacte. Corriger une startDate erronée
|
||||||
async function expireOverdueClaims() {
|
// suffit donc à rétablir les gains, et aucune lecture ne modifie la base.
|
||||||
const window = await getGameWindow();
|
function effectiveClaimStatus(status, gameWindow) {
|
||||||
if (window.isOpen) return;
|
if (status === "PENDING" && !gameWindow.isOpen) return "CANCELLED";
|
||||||
|
return status;
|
||||||
await prisma.claim.updateMany({
|
|
||||||
where: { status: "PENDING" },
|
|
||||||
data: { status: "CANCELLED" },
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
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,
|
||||||
|
};
|
||||||
|
|||||||
@@ -9,6 +9,13 @@ const createEmployeeSchema = z.object({
|
|||||||
gender: z.string().optional(),
|
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 = {
|
module.exports = {
|
||||||
createEmployeeSchema,
|
createEmployeeSchema,
|
||||||
|
gameSettingsSchema,
|
||||||
};
|
};
|
||||||
106
backend/tests/drawNotification.test.js
Normal file
106
backend/tests/drawNotification.test.js
Normal file
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
113
backend/tests/gainNotification.test.js
Normal file
113
backend/tests/gainNotification.test.js
Normal file
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
163
backend/tests/gameFlow.test.js
Normal file
163
backend/tests/gameFlow.test.js
Normal file
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
145
backend/tests/gamePhases.test.js
Normal file
145
backend/tests/gamePhases.test.js
Normal file
@@ -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");
|
||||||
|
});
|
||||||
|
});
|
||||||
66
backend/tests/register.mail.test.js
Normal file
66
backend/tests/register.mail.test.js
Normal file
@@ -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");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useState, useEffect } from 'react'
|
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 { searchTicketApi, deliverClaimApi } from '../services/employee'
|
||||||
import { useHandleAuthError } from '../hooks/useHandleAuthError'
|
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) {
|
function formatDate(dateStr) {
|
||||||
return new Date(dateStr).toLocaleDateString('fr-FR', {
|
return new Date(dateStr).toLocaleDateString('fr-FR', {
|
||||||
day: '2-digit', month: 'long', year: 'numeric',
|
day: '2-digit', month: 'long', year: 'numeric',
|
||||||
@@ -31,6 +57,12 @@ export default function Admin() {
|
|||||||
const [error, setError] = useState('')
|
const [error, setError] = useState('')
|
||||||
const [showConfirm, setShowConfirm] = useState(false)
|
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
|
// Remise de lot
|
||||||
const [ticketCode, setTicketCode] = useState('')
|
const [ticketCode, setTicketCode] = useState('')
|
||||||
const [ticketResult, setTicketResult] = useState(null)
|
const [ticketResult, setTicketResult] = useState(null)
|
||||||
@@ -58,6 +90,25 @@ export default function Admin() {
|
|||||||
.finally(() => setLoading(false))
|
.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 () => {
|
const handleDraw = async () => {
|
||||||
setShowConfirm(false)
|
setShowConfirm(false)
|
||||||
setDrawing(true)
|
setDrawing(true)
|
||||||
@@ -164,21 +215,61 @@ export default function Admin() {
|
|||||||
<h1 className="text-xl sm:text-2xl font-bold text-gray-800">Tableau de bord Admin</h1>
|
<h1 className="text-xl sm:text-2xl font-bold text-gray-800">Tableau de bord Admin</h1>
|
||||||
|
|
||||||
{/* Fenêtre du jeu-concours */}
|
{/* Fenêtre du jeu-concours */}
|
||||||
{stats?.gameWindow?.configured && (
|
{stats?.gameWindow && (
|
||||||
<div className={`rounded-xl shadow p-4 sm:p-6 ${stats.gameWindow.isOpen ? 'bg-white' : 'bg-red-50 border border-red-200'}`}>
|
<div className={`rounded-xl shadow p-4 sm:p-6 ${PHASE_PANELS[stats.gameWindow.phase] ?? 'bg-white'}`}>
|
||||||
<div className="flex items-center justify-between flex-wrap gap-2">
|
<div className="flex items-center justify-between flex-wrap gap-2">
|
||||||
<div>
|
<div>
|
||||||
<p className="text-sm font-semibold text-gray-800">
|
<p className="text-sm font-semibold text-gray-800">
|
||||||
Jeu-concours {stats.gameWindow.isOpen ? 'en cours' : 'terminé'}
|
{PHASE_TITLES[stats.gameWindow.phase] ?? 'Jeu-concours'}
|
||||||
</p>
|
</p>
|
||||||
|
{stats.gameWindow.configured ? (
|
||||||
<p className="text-xs text-gray-500 mt-1">
|
<p className="text-xs text-gray-500 mt-1">
|
||||||
Début : {formatDate(stats.gameWindow.startDate)} — Fin des participations et réclamations : {formatDate(stats.gameWindow.claimDeadline)}
|
Début : {formatDate(stats.gameWindow.startDate)}
|
||||||
|
<br />
|
||||||
|
Fin des participations : {formatDate(stats.gameWindow.participationDeadline)}
|
||||||
|
<br />
|
||||||
|
Fin des retraits de lots : {formatDate(stats.gameWindow.claimDeadline)}
|
||||||
</p>
|
</p>
|
||||||
|
) : (
|
||||||
|
<p className="text-xs text-gray-600 mt-1">
|
||||||
|
Choisissez une date de début pour ouvrir le jeu-concours.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<span className={`text-xs font-semibold px-2.5 py-1 rounded-full ${stats.gameWindow.isOpen ? 'bg-green-100 text-green-700' : 'bg-red-100 text-red-700'}`}>
|
<span className={`text-xs font-semibold px-2.5 py-1 rounded-full ${PHASE_BADGES[stats.gameWindow.phase]?.className ?? 'bg-gray-100 text-gray-700'}`}>
|
||||||
{stats.gameWindow.isOpen ? 'Ouvert' : 'Fermé'}
|
{PHASE_BADGES[stats.gameWindow.phase]?.label ?? 'État inconnu'}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<form onSubmit={handleSaveGameSettings} className="mt-4 pt-4 border-t border-gray-200 flex flex-wrap items-end gap-3">
|
||||||
|
<div className="flex-1 min-w-[220px]">
|
||||||
|
<label htmlFor="game-start" className="block text-xs font-medium text-gray-700 mb-1">
|
||||||
|
{stats.gameWindow.configured ? 'Corriger la date de début' : 'Date de début du jeu-concours'}
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="datetime-local"
|
||||||
|
id="game-start"
|
||||||
|
value={startDateInput}
|
||||||
|
onChange={(e) => 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]"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={settingsSaving || !startDateInput}
|
||||||
|
className="bg-[#006B6B] hover:bg-[#005959] disabled:opacity-50 text-white font-semibold px-4 py-2 rounded-lg transition-colors text-xs sm:text-sm"
|
||||||
|
>
|
||||||
|
{settingsSaving ? 'Enregistrement…' : stats.gameWindow.configured ? 'Mettre à jour' : 'Lancer le jeu'}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<p className="text-xs text-gray-400 mt-2">
|
||||||
|
Les participations durent 30 jours, puis 30 jours supplémentaires sont laissés pour retirer les lots.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{settingsError && <p className="text-red-500 text-xs mt-2">{settingsError}</p>}
|
||||||
|
{settingsOk && <p className="text-green-600 text-xs mt-2">{settingsOk}</p>}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -440,6 +531,13 @@ export default function Admin() {
|
|||||||
Tirage effectué le {formatDate(winner.winner.createdAt)}
|
Tirage effectué le {formatDate(winner.winner.createdAt)}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
{/* 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 && (
|
||||||
|
<p className="text-xs text-amber-700 bg-amber-50 border border-amber-200 rounded-lg px-3 py-2">
|
||||||
|
L'email de notification n'a pas pu être envoyé au gagnant. Merci de le contacter directement à l'adresse ci-dessus.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
/* Pas encore de tirage */
|
/* Pas encore de tirage */
|
||||||
|
|||||||
@@ -132,9 +132,12 @@ export default function Connexion() {
|
|||||||
onClick={handleGoogleLogin}
|
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"
|
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"
|
||||||
>
|
>
|
||||||
<div className="w-5 h-5 rounded-full border border-gray-400 flex items-center justify-center text-[10px] font-bold text-gray-500">
|
<svg xmlns="http://www.w3.org/2000/svg" width="22" height="22" viewBox="0 0 48 48" aria-hidden="true">
|
||||||
G
|
<path fill="#FFC107" d="M43.611,20.083H42V20H24v8h11.303c-1.649,4.657-6.08,8-11.303,8c-6.627,0-12-5.373-12-12c0-6.627,5.373-12,12-12c3.059,0,5.842,1.154,7.961,3.039l5.657-5.657C34.046,6.053,29.268,4,24,4C12.955,4,4,12.955,4,24c0,11.045,8.955,20,20,20c11.045,0,20-8.955,20-20C44,22.659,43.862,21.35,43.611,20.083z"></path>
|
||||||
</div>
|
<path fill="#FF3D00" d="M6.306,14.691l6.571,4.819C14.655,15.108,18.961,12,24,12c3.059,0,5.842,1.154,7.961,3.039l5.657-5.657C34.046,6.053,29.268,4,24,4C16.318,4,9.656,8.337,6.306,14.691z"></path>
|
||||||
|
<path fill="#4CAF50" d="M24,44c5.166,0,9.86-1.977,13.409-5.192l-6.19-5.238C29.211,35.091,26.715,36,24,36c-5.202,0-9.619-3.317-11.283-7.946l-6.522,5.025C9.505,39.556,16.227,44,24,44z"></path>
|
||||||
|
<path fill="#1976D2" d="M43.611,20.083H42V20H24v8h11.303c-0.792,2.237-2.231,4.166-4.087,5.571c0.001-0.001,0.002-0.001,0.003-0.002l6.19,5.238C36.971,39.205,44,34,44,24C44,22.659,43.862,21.35,43.611,20.083z"></path>
|
||||||
|
</svg>
|
||||||
Google
|
Google
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
@@ -143,9 +146,10 @@ export default function Connexion() {
|
|||||||
title="Connexion Facebook bientôt disponible"
|
title="Connexion Facebook bientôt disponible"
|
||||||
className="flex items-center justify-center gap-2 border border-gray-200 rounded-lg py-3 text-sm text-gray-400 cursor-not-allowed opacity-60"
|
className="flex items-center justify-center gap-2 border border-gray-200 rounded-lg py-3 text-sm text-gray-400 cursor-not-allowed opacity-60"
|
||||||
>
|
>
|
||||||
<div className="w-5 h-5 rounded-full border border-gray-300 flex items-center justify-center text-[10px] font-bold text-gray-400">
|
<svg xmlns="http://www.w3.org/2000/svg" width="22" height="22" viewBox="0 0 24 24" aria-hidden="true">
|
||||||
f
|
<circle cx="12" cy="12" r="12" fill="#1877F2" />
|
||||||
</div>
|
<path fill="#FFFFFF" d="M13.5 21v-8h2.7l.4-3h-3.1V8.1c0-.9.3-1.6 1.7-1.6h1.8V3.8c-.3 0-1.4-.1-2.6-.1-2.6 0-4.4 1.6-4.4 4.5V10H7.2v3H10v8h3.5z" />
|
||||||
|
</svg>
|
||||||
Facebook
|
Facebook
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -3,6 +3,13 @@ import { Link, useNavigate } from 'react-router-dom'
|
|||||||
import { registerApi, verifyCodeApi } from '../services/auth'
|
import { registerApi, verifyCodeApi } from '../services/auth'
|
||||||
import { useAuth } from '../context/AuthContext'
|
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() {
|
export default function Inscription() {
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
const { login } = useAuth()
|
const { login } = useAuth()
|
||||||
@@ -10,8 +17,18 @@ export default function Inscription() {
|
|||||||
// Étape : 'form' | 'verify'
|
// Étape : 'form' | 'verify'
|
||||||
const [step, setStep] = useState('form')
|
const [step, setStep] = useState('form')
|
||||||
const [email, setEmail] = useState('')
|
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)
|
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 [showPassword, setShowPassword] = useState(false)
|
||||||
const [error, setError] = useState('')
|
const [error, setError] = useState('')
|
||||||
const [loading, setLoading] = useState(false)
|
const [loading, setLoading] = useState(false)
|
||||||
@@ -40,6 +57,9 @@ export default function Inscription() {
|
|||||||
firstname: form.firstName,
|
firstname: form.firstName,
|
||||||
lastname: form.lastName,
|
lastname: form.lastName,
|
||||||
password: form.password,
|
password: form.password,
|
||||||
|
birthdate: form.birthdate,
|
||||||
|
gender: form.gender,
|
||||||
|
marketingOptIn,
|
||||||
})
|
})
|
||||||
setEmail(form.email)
|
setEmail(form.email)
|
||||||
setStep('verify')
|
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]" />
|
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]" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label htmlFor="reg-birthdate" className="block text-sm font-medium text-gray-800 mb-1">Date de naissance</label>
|
||||||
|
<input type="date" id="reg-birthdate" name="birthdate" value={form.birthdate} onChange={handleChange} max={AUJOURD_HUI}
|
||||||
|
className="w-full border border-gray-200 rounded-lg px-4 py-3 text-sm text-gray-700 focus:outline-none focus:ring-1 focus:ring-[#006B6B] focus:border-[#006B6B]" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label htmlFor="reg-gender" className="block text-sm font-medium text-gray-800 mb-1">Genre</label>
|
||||||
|
<select id="reg-gender" name="gender" value={form.gender} onChange={handleChange}
|
||||||
|
className="w-full border border-gray-200 rounded-lg px-4 py-3 text-sm text-gray-700 bg-white focus:outline-none focus:ring-1 focus:ring-[#006B6B] focus:border-[#006B6B]">
|
||||||
|
<option value="">Non précisé</option>
|
||||||
|
{GENRES.map((genre) => (
|
||||||
|
<option key={genre} value={genre}>{genre}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label htmlFor="reg-password" className="block text-sm font-medium text-gray-800 mb-1">Mot de passe <span className="text-gray-800">*</span></label>
|
<label htmlFor="reg-password" className="block text-sm font-medium text-gray-800 mb-1">Mot de passe <span className="text-gray-800">*</span></label>
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
@@ -206,6 +243,18 @@ export default function Inscription() {
|
|||||||
</span>
|
</span>
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
|
<label className="flex items-start gap-3 cursor-pointer">
|
||||||
|
<input type="checkbox" name="marketingOptIn" checked={marketingOptIn}
|
||||||
|
onChange={(e) => setMarketingOptIn(e.target.checked)}
|
||||||
|
className="mt-0.5 w-4 h-4 accent-[#006B6B] flex-shrink-0" />
|
||||||
|
<span className="text-xs text-gray-500 leading-relaxed">
|
||||||
|
J'accepte de recevoir les offres et actualités de Thé Tip Top par email.
|
||||||
|
<span className="block text-gray-400 mt-0.5">
|
||||||
|
Facultatif — vous pouvez vous désinscrire à tout moment.
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
|
||||||
<button type="submit" disabled={loading || !acceptedTerms}
|
<button type="submit" disabled={loading || !acceptedTerms}
|
||||||
className="w-full bg-[#006B6B] hover:bg-[#005959] disabled:opacity-60 text-white font-medium py-3 rounded-lg transition-colors">
|
className="w-full bg-[#006B6B] hover:bg-[#005959] disabled:opacity-60 text-white font-medium py-3 rounded-lg transition-colors">
|
||||||
{loading ? 'Inscription…' : "S'inscrire"}
|
{loading ? 'Inscription…' : "S'inscrire"}
|
||||||
@@ -220,11 +269,19 @@ export default function Inscription() {
|
|||||||
|
|
||||||
<div className="grid grid-cols-2 gap-3">
|
<div className="grid grid-cols-2 gap-3">
|
||||||
<button type="button" onClick={handleGoogleSignup} 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">
|
<button type="button" onClick={handleGoogleSignup} 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">
|
||||||
<div className="w-5 h-5 rounded-full border border-gray-400 flex items-center justify-center text-[10px] font-bold text-gray-500">G</div>
|
<svg xmlns="http://www.w3.org/2000/svg" width="22" height="22" viewBox="0 0 48 48" aria-hidden="true">
|
||||||
|
<path fill="#FFC107" d="M43.611,20.083H42V20H24v8h11.303c-1.649,4.657-6.08,8-11.303,8c-6.627,0-12-5.373-12-12c0-6.627,5.373-12,12-12c3.059,0,5.842,1.154,7.961,3.039l5.657-5.657C34.046,6.053,29.268,4,24,4C12.955,4,4,12.955,4,24c0,11.045,8.955,20,20,20c11.045,0,20-8.955,20-20C44,22.659,43.862,21.35,43.611,20.083z"></path>
|
||||||
|
<path fill="#FF3D00" d="M6.306,14.691l6.571,4.819C14.655,15.108,18.961,12,24,12c3.059,0,5.842,1.154,7.961,3.039l5.657-5.657C34.046,6.053,29.268,4,24,4C16.318,4,9.656,8.337,6.306,14.691z"></path>
|
||||||
|
<path fill="#4CAF50" d="M24,44c5.166,0,9.86-1.977,13.409-5.192l-6.19-5.238C29.211,35.091,26.715,36,24,36c-5.202,0-9.619-3.317-11.283-7.946l-6.522,5.025C9.505,39.556,16.227,44,24,44z"></path>
|
||||||
|
<path fill="#1976D2" d="M43.611,20.083H42V20H24v8h11.303c-0.792,2.237-2.231,4.166-4.087,5.571c0.001-0.001,0.002-0.001,0.003-0.002l6.19,5.238C36.971,39.205,44,34,44,24C44,22.659,43.862,21.35,43.611,20.083z"></path>
|
||||||
|
</svg>
|
||||||
Google
|
Google
|
||||||
</button>
|
</button>
|
||||||
<button type="button" disabled title="Inscription Facebook bientôt disponible" className="flex items-center justify-center gap-2 border border-gray-200 rounded-lg py-3 text-sm text-gray-400 cursor-not-allowed opacity-60">
|
<button type="button" disabled title="Inscription Facebook bientôt disponible" className="flex items-center justify-center gap-2 border border-gray-200 rounded-lg py-3 text-sm text-gray-400 cursor-not-allowed opacity-60">
|
||||||
<div className="w-5 h-5 rounded-full border border-gray-300 flex items-center justify-center text-[10px] font-bold text-gray-400">f</div>
|
<svg xmlns="http://www.w3.org/2000/svg" width="22" height="22" viewBox="0 0 24 24" aria-hidden="true">
|
||||||
|
<circle cx="12" cy="12" r="12" fill="#1877F2" />
|
||||||
|
<path fill="#FFFFFF" d="M13.5 21v-8h2.7l.4-3h-3.1V8.1c0-.9.3-1.6 1.7-1.6h1.8V3.8c-.3 0-1.4-.1-2.6-.1-2.6 0-4.4 1.6-4.4 4.5V10H7.2v3H10v8h3.5z" />
|
||||||
|
</svg>
|
||||||
Facebook
|
Facebook
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -39,6 +39,17 @@ export async function getEmailingContactsApi() {
|
|||||||
return data
|
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() {
|
export async function getWinnerApi() {
|
||||||
const res = await fetch('/api/admin/draw', { headers: authHeaders() })
|
const res = await fetch('/api/admin/draw', { headers: authHeaders() })
|
||||||
const data = await res.json()
|
const data = await res.json()
|
||||||
|
|||||||
@@ -17,11 +17,29 @@ export async function loginApi({ email, password }) {
|
|||||||
return data
|
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`, {
|
const res = await fetch(`${BASE}/register`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
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()
|
const data = await res.json()
|
||||||
if (!res.ok) throw apiError(data.message || "Erreur lors de l'inscription.", res.status)
|
if (!res.ok) throw apiError(data.message || "Erreur lors de l'inscription.", res.status)
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ async function remplirEtSoumettre() {
|
|||||||
await userEvent.type(screen.getByLabelText(/^nom/i), 'Dupont')
|
await userEvent.type(screen.getByLabelText(/^nom/i), 'Dupont')
|
||||||
await userEvent.type(screen.getByLabelText(/prénom/i), 'Jean')
|
await userEvent.type(screen.getByLabelText(/prénom/i), 'Jean')
|
||||||
await userEvent.type(screen.getByLabelText(/^mot de passe/i), 'MotDePasse123!')
|
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 }))
|
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 })
|
const bouton = screen.getByRole('button', { name: /s'inscrire/i })
|
||||||
expect(bouton).toBeDisabled()
|
expect(bouton).toBeDisabled()
|
||||||
|
|
||||||
await userEvent.click(screen.getByRole('checkbox'))
|
await userEvent.click(screen.getByRole('checkbox', { name: /conditions d'utilisation/i }))
|
||||||
expect(bouton).toBeEnabled()
|
expect(bouton).toBeEnabled()
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -64,11 +64,64 @@ describe('Inscription', () => {
|
|||||||
firstname: 'Jean',
|
firstname: 'Jean',
|
||||||
lastname: 'Dupont',
|
lastname: 'Dupont',
|
||||||
password: 'MotDePasse123!',
|
password: 'MotDePasse123!',
|
||||||
|
birthdate: '',
|
||||||
|
gender: '',
|
||||||
|
marketingOptIn: false,
|
||||||
})
|
})
|
||||||
expect(await screen.findByRole('heading', { name: /vérifiez votre email/i })).toBeInTheDocument()
|
expect(await screen.findByRole('heading', { name: /vérifiez votre email/i })).toBeInTheDocument()
|
||||||
expect(screen.getByText('jean@example.com')).toBeInTheDocument()
|
expect(screen.getByText('jean@example.com')).toBeInTheDocument()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it("transmet la date de naissance et le genre saisis", async () => {
|
||||||
|
registerApi.mockResolvedValue({ message: 'ok' })
|
||||||
|
|
||||||
|
renderWithProviders(<Inscription />, { 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(<Inscription />, { 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(<Inscription />, { 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 () => {
|
it("affiche l'erreur de l'API si l'inscription échoue", async () => {
|
||||||
registerApi.mockRejectedValue(new Error('Cet email est déjà utilisé.'))
|
registerApi.mockRejectedValue(new Error('Cet email est déjà utilisé.'))
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user