();
+
return services;
}
}
diff --git a/src/backend/src/FantasyRealm.Infrastructure/Email/EmailSettings.cs b/src/backend/src/FantasyRealm.Infrastructure/Email/EmailSettings.cs
index 72dce71..3a84836 100644
--- a/src/backend/src/FantasyRealm.Infrastructure/Email/EmailSettings.cs
+++ b/src/backend/src/FantasyRealm.Infrastructure/Email/EmailSettings.cs
@@ -44,5 +44,10 @@ public class EmailSettings
/// Gets or sets the sender display name.
///
public string FromName { get; set; } = string.Empty;
+
+ ///
+ /// Gets or sets the base URL for links in email templates.
+ ///
+ public string BaseUrl { get; set; } = string.Empty;
}
}
diff --git a/src/backend/src/FantasyRealm.Infrastructure/Email/EmailTemplates.cs b/src/backend/src/FantasyRealm.Infrastructure/Email/EmailTemplates.cs
index e8e1ce0..dd5b275 100644
--- a/src/backend/src/FantasyRealm.Infrastructure/Email/EmailTemplates.cs
+++ b/src/backend/src/FantasyRealm.Infrastructure/Email/EmailTemplates.cs
@@ -4,30 +4,80 @@ namespace FantasyRealm.Infrastructure.Email
{
///
/// Provides HTML email templates for various notification types.
+ /// Uses a dark fantasy theme consistent with the FantasyRealm MMORPG aesthetic.
///
public static class EmailTemplates
{
- private const string BaseUrl = "https://fantasy-realm.com";
+ private const string DarkBg = "#0D0D0F"; // --dark-900
+ private const string CardBg = "#121110"; // --dark-800
+ private const string CardBorder = "#18181B"; // --dark-700
+ private const string GoldPrimary = "#F59E0B"; // --gold-500
+ private const string GoldLight = "#FBBF24"; // --gold-400
+ private const string GoldDark = "#D97706"; // --gold-600
+ private const string TextLight = "#E8E4DE"; // --cream-200 (text-primary)
+ private const string TextMuted = "#A8A29E"; // --cream-400 (text-muted)
///
/// Generates a welcome email template for new users.
///
/// The user's display name.
+ /// The base URL for links in the email.
/// The HTML email body.
- public static string GetWelcomeTemplate(string pseudo)
+ public static string GetWelcomeTemplate(string pseudo, string baseUrl)
{
- return WrapInLayout($@"
- Welcome to FantasyRealm, {Encode(pseudo)}!
- Thank you for joining our community of adventurers.
- You can now:
-
- - Create and customize your characters
- - Equip them with weapons, armor, and accessories
- - Share your creations with the community
- - Comment and rate other players' characters
-
- Start your adventure now!
- Create Your First Character
+ return WrapInLayout(
+ "Bienvenue dans FantasyRealm !",
+ $@"
+
+
⚔️
+
+ Bienvenue, {Encode(pseudo)} !
+
+
+ Votre aventure commence maintenant
+
+
+
+
+
+
+ Merci d'avoir rejoint la guilde des aventuriers de FantasyRealm Online.
+ Un monde de magie et d'aventures vous attend !
+
+
+
+
+ 🎮 Vous pouvez maintenant :
+
+
+ -
+ ✦
+ Créer et personnaliser vos personnages
+
+ -
+ ✦
+ Les équiper d'armes, armures et accessoires légendaires
+
+ -
+ ✦
+ Partager vos créations avec la communauté
+
+ -
+ ✦
+ Découvrir les héros des autres joueurs
+
+
+
+
+
+
+
+ {GetPrimaryButton("Créer mon premier personnage", $"{baseUrl}/characters/create", "🛡️")}
+
+
+
+ Que la fortune guide vos pas, aventurier !
+
");
}
@@ -36,17 +86,100 @@ public static string GetWelcomeTemplate(string pseudo)
///
/// The user's display name.
/// The password reset token.
+ /// The base URL for links in the email.
/// The HTML email body.
- public static string GetPasswordResetTemplate(string pseudo, string resetToken)
+ public static string GetPasswordResetTemplate(string pseudo, string resetToken, string baseUrl)
{
- var resetUrl = $"{BaseUrl}/reset-password?token={Uri.EscapeDataString(resetToken)}";
- return WrapInLayout($@"
- Password Reset Request
- Hello {Encode(pseudo)},
- We received a request to reset your password. Click the button below to create a new password:
- Reset Password
- This link will expire in 24 hours.
- If you didn't request this password reset, you can safely ignore this email.
+ var resetUrl = $"{baseUrl}/reset-password?token={Uri.EscapeDataString(resetToken)}";
+ return WrapInLayout(
+ "Réinitialisation de mot de passe",
+ $@"
+
+
🔐
+
+ Réinitialisation de mot de passe
+
+
+
+
+
+ Bonjour {Encode(pseudo)},
+
+
+ Nous avons reçu une demande de réinitialisation de votre mot de passe.
+ Cliquez sur le bouton ci-dessous pour créer un nouveau mot de passe :
+
+
+
+
+ {GetPrimaryButton("Réinitialiser mon mot de passe", resetUrl, "🔑")}
+
+
+
+
+ ⏱️ Ce lien expirera dans 24 heures.
+ 🛡️ Si vous n'êtes pas à l'origine de cette demande, ignorez simplement cet email.
+
+
+ ");
+ }
+
+ ///
+ /// Generates a temporary password email template.
+ ///
+ /// The user's display name.
+ /// The generated temporary password.
+ /// The base URL for links in the email.
+ /// The HTML email body.
+ public static string GetTemporaryPasswordTemplate(string pseudo, string temporaryPassword, string baseUrl)
+ {
+ return WrapInLayout(
+ "Nouveau mot de passe temporaire",
+ $@"
+
+
🔐
+
+ Nouveau mot de passe temporaire
+
+
+
+
+
+ Bonjour {Encode(pseudo)},
+
+
+ Suite à votre demande, voici votre nouveau mot de passe temporaire :
+
+
+
+
+
+ Votre mot de passe temporaire
+
+
+ {Encode(temporaryPassword)}
+
+
+
+
+
+ ⚠️ Important
+
+
+ Vous devrez changer ce mot de passe lors de votre prochaine connexion.
+ Ce mot de passe est temporaire et ne devrait pas être réutilisé.
+
+
+
+
+ {GetPrimaryButton("Se connecter", $"{baseUrl}/login", "🔑")}
+
+
+
+
+ 🛡️ Si vous n'êtes pas à l'origine de cette demande, veuillez contacter notre support immédiatement.
+
+
");
}
@@ -55,15 +188,39 @@ public static string GetPasswordResetTemplate(string pseudo, string resetToken)
///
/// The user's display name.
/// The approved character's name.
+ /// The base URL for links in the email.
/// The HTML email body.
- public static string GetCharacterApprovedTemplate(string pseudo, string characterName)
+ public static string GetCharacterApprovedTemplate(string pseudo, string characterName, string baseUrl)
{
- return WrapInLayout($@"
- Character Approved!
- Great news, {Encode(pseudo)}!
- Your character {Encode(characterName)} has been reviewed and approved by our moderation team.
- Your character is now visible to the entire FantasyRealm community. Other players can view, comment, and rate your creation.
- View Your Characters
+ return WrapInLayout(
+ "Personnage approuvé !",
+ $@"
+
+
🎉
+
+ Personnage approuvé !
+
+
+
+
+
+ Excellente nouvelle, {Encode(pseudo)} !
+
+
+ Votre personnage {Encode(characterName)} a été examiné
+ et approuvé par notre équipe de modération.
+
+
+
+
+
+ ✨ Votre personnage est maintenant visible par toute la communauté FantasyRealm !
+
+
+
+
+ {GetPrimaryButton("Voir mes personnages", $"{baseUrl}/characters", "👥")}
+
");
}
@@ -73,16 +230,46 @@ public static string GetCharacterApprovedTemplate(string pseudo, string characte
/// The user's display name.
/// The rejected character's name.
/// The rejection reason.
+ /// The base URL for links in the email.
/// The HTML email body.
- public static string GetCharacterRejectedTemplate(string pseudo, string characterName, string reason)
+ public static string GetCharacterRejectedTemplate(string pseudo, string characterName, string reason, string baseUrl)
{
- return WrapInLayout($@"
- Character Not Approved
- Hello {Encode(pseudo)},
- Unfortunately, your character {Encode(characterName)} was not approved for public display.
- Reason: {Encode(reason)}
- You can modify your character and submit it again for review.
- Edit Your Character
+ return WrapInLayout(
+ "Personnage non approuvé",
+ $@"
+
+
📝
+
+ Personnage non approuvé
+
+
+
+
+
+ Bonjour {Encode(pseudo)},
+
+
+ Malheureusement, votre personnage {Encode(characterName)}
+ n'a pas été approuvé pour l'affichage public.
+
+
+
+
+
+ ⚠️ Raison du refus :
+
+
+ {Encode(reason)}
+
+
+
+
+ Vous pouvez modifier votre personnage et le soumettre à nouveau pour examen.
+
+
+
+ {GetPrimaryButton("Modifier mon personnage", $"{baseUrl}/characters", "✏️")}
+
");
}
@@ -91,15 +278,37 @@ public static string GetCharacterRejectedTemplate(string pseudo, string characte
///
/// The user's display name.
/// The character's name that was commented on.
+ /// The base URL for links in the email.
/// The HTML email body.
- public static string GetCommentApprovedTemplate(string pseudo, string characterName)
+ public static string GetCommentApprovedTemplate(string pseudo, string characterName, string baseUrl)
{
- return WrapInLayout($@"
- Comment Published!
- Hello {Encode(pseudo)},
- Your comment on the character {Encode(characterName)} has been approved and is now visible to the community.
- Thank you for contributing to the FantasyRealm community!
- Browse the Gallery
+ return WrapInLayout(
+ "Commentaire publié !",
+ $@"
+
+
💬
+
+ Commentaire publié !
+
+
+
+
+
+ Bonjour {Encode(pseudo)},
+
+
+ Votre commentaire sur le personnage {Encode(characterName)}
+ a été approuvé et est maintenant visible par la communauté.
+
+
+
+
+ 🙏 Merci de contribuer à la communauté FantasyRealm !
+
+
+
+ {GetPrimaryButton("Explorer la galerie", $"{baseUrl}/gallery", "🖼️")}
+
");
}
@@ -112,12 +321,38 @@ public static string GetCommentApprovedTemplate(string pseudo, string characterN
/// The HTML email body.
public static string GetCommentRejectedTemplate(string pseudo, string characterName, string reason)
{
- return WrapInLayout($@"
- Comment Not Approved
- Hello {Encode(pseudo)},
- Your comment on the character {Encode(characterName)} was not approved for publication.
- Reason: {Encode(reason)}
- Please review our community guidelines and feel free to submit a new comment.
+ return WrapInLayout(
+ "Commentaire non approuvé",
+ $@"
+
+
💬
+
+ Commentaire non approuvé
+
+
+
+
+
+ Bonjour {Encode(pseudo)},
+
+
+ Votre commentaire sur le personnage {Encode(characterName)}
+ n'a pas été approuvé pour publication.
+
+
+
+
+
+ ⚠️ Raison du refus :
+
+
+ {Encode(reason)}
+
+
+
+
+ Veuillez consulter nos règles de communauté. Vous pouvez soumettre un nouveau commentaire respectant ces règles.
+
");
}
@@ -129,13 +364,40 @@ public static string GetCommentRejectedTemplate(string pseudo, string characterN
/// The HTML email body.
public static string GetAccountSuspendedTemplate(string pseudo, string reason)
{
- return WrapInLayout($@"
- Account Suspended
- Hello {Encode(pseudo)},
- Your FantasyRealm account has been suspended due to a violation of our terms of service.
- Reason: {Encode(reason)}
- If you believe this is an error, please contact our support team.
- You will not be able to access your account or characters until this matter is resolved.
+ return WrapInLayout(
+ "Compte suspendu",
+ $@"
+
+
🚫
+
+ Compte suspendu
+
+
+
+
+
+ Bonjour {Encode(pseudo)},
+
+
+ Votre compte FantasyRealm a été suspendu en raison d'une violation de nos conditions d'utilisation.
+
+
+
+
+
+ ⚠️ Raison de la suspension :
+
+
+ {Encode(reason)}
+
+
+
+
+
+ 📧 Si vous pensez qu'il s'agit d'une erreur, veuillez contacter notre équipe de support.
+ 🔒 Vous ne pourrez pas accéder à votre compte ni à vos personnages tant que cette situation n'aura pas été résolue.
+
+
");
}
@@ -144,95 +406,116 @@ private static string Encode(string value)
return WebUtility.HtmlEncode(value);
}
- private static string WrapInLayout(string content)
+ private static string GetPrimaryButton(string text, string url, string icon = "")
+ {
+ var iconHtml = string.IsNullOrEmpty(icon) ? "" : $"{icon} ";
+ return $@"
+
+ {iconHtml}{text}
+ ";
+ }
+
+ private static string WrapInLayout(string title, string content)
{
return $@"
-
-
-
-
-
- FantasyRealm
-
-
-
-
-
-
FantasyRealm
-
- {content}
-
-
© {DateTime.UtcNow.Year} FantasyRealm by PixelVerse Studios. All rights reserved.
-
This is an automated message. Please do not reply to this email.
-
-
-
-";
+
+
+
+
+
+ {Encode(title)} - FantasyRealm
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ | 🏰 |
+
+
+ FANTASYREALM
+
+
+
+ Character Manager
+
+ |
+
+
+ |
+
+
+ |
+
+
+
+
+ |
+ {content}
+ |
+
+
+
+
+
+
+
+ |
+
+
+
+
+ © {DateTime.UtcNow.Year} FantasyRealm par PixelVerse Studios
+
+
+ Ceci est un message automatique. Merci de ne pas répondre à cet email.
+
+ |
+
+
+ |
+
+
+
+
+ |
+
+
+
+
+ ";
}
}
}
diff --git a/src/backend/src/FantasyRealm.Infrastructure/Email/SmtpEmailService.cs b/src/backend/src/FantasyRealm.Infrastructure/Email/SmtpEmailService.cs
index 3f2b78b..f92aeb5 100644
--- a/src/backend/src/FantasyRealm.Infrastructure/Email/SmtpEmailService.cs
+++ b/src/backend/src/FantasyRealm.Infrastructure/Email/SmtpEmailService.cs
@@ -1,5 +1,4 @@
using FantasyRealm.Application.Interfaces;
-using MailKit.Net.Smtp;
using MailKit.Security;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
@@ -10,33 +9,24 @@ namespace FantasyRealm.Infrastructure.Email
///
/// SMTP-based implementation of the email service using MailKit.
///
- public class SmtpEmailService : IEmailService
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ /// The email configuration settings.
+ /// The factory for creating SMTP clients.
+ /// The logger instance.
+ public class SmtpEmailService(
+ IOptions settings,
+ ISmtpClientFactory smtpClientFactory,
+ ILogger logger) : IEmailService
{
- private readonly EmailSettings _settings;
- private readonly ISmtpClientFactory _smtpClientFactory;
- private readonly ILogger _logger;
-
- ///
- /// Initializes a new instance of the class.
- ///
- /// The email configuration settings.
- /// The factory for creating SMTP clients.
- /// The logger instance.
- public SmtpEmailService(
- IOptions settings,
- ISmtpClientFactory smtpClientFactory,
- ILogger logger)
- {
- _settings = settings.Value;
- _smtpClientFactory = smtpClientFactory;
- _logger = logger;
- }
+ private readonly EmailSettings _settings = settings.Value;
///
public async Task SendWelcomeEmailAsync(string toEmail, string pseudo, CancellationToken cancellationToken = default)
{
var subject = "Welcome to FantasyRealm!";
- var body = EmailTemplates.GetWelcomeTemplate(pseudo);
+ var body = EmailTemplates.GetWelcomeTemplate(pseudo, _settings.BaseUrl);
await SendEmailAsync(toEmail, subject, body, cancellationToken);
}
@@ -44,7 +34,15 @@ public async Task SendWelcomeEmailAsync(string toEmail, string pseudo, Cancellat
public async Task SendPasswordResetEmailAsync(string toEmail, string pseudo, string resetToken, CancellationToken cancellationToken = default)
{
var subject = "Reset your FantasyRealm password";
- var body = EmailTemplates.GetPasswordResetTemplate(pseudo, resetToken);
+ var body = EmailTemplates.GetPasswordResetTemplate(pseudo, resetToken, _settings.BaseUrl);
+ await SendEmailAsync(toEmail, subject, body, cancellationToken);
+ }
+
+ ///
+ public async Task SendTemporaryPasswordEmailAsync(string toEmail, string pseudo, string temporaryPassword, CancellationToken cancellationToken = default)
+ {
+ var subject = "Votre nouveau mot de passe FantasyRealm";
+ var body = EmailTemplates.GetTemporaryPasswordTemplate(pseudo, temporaryPassword, _settings.BaseUrl);
await SendEmailAsync(toEmail, subject, body, cancellationToken);
}
@@ -52,7 +50,7 @@ public async Task SendPasswordResetEmailAsync(string toEmail, string pseudo, str
public async Task SendCharacterApprovedEmailAsync(string toEmail, string pseudo, string characterName, CancellationToken cancellationToken = default)
{
var subject = $"Your character {characterName} has been approved!";
- var body = EmailTemplates.GetCharacterApprovedTemplate(pseudo, characterName);
+ var body = EmailTemplates.GetCharacterApprovedTemplate(pseudo, characterName, _settings.BaseUrl);
await SendEmailAsync(toEmail, subject, body, cancellationToken);
}
@@ -60,7 +58,7 @@ public async Task SendCharacterApprovedEmailAsync(string toEmail, string pseudo,
public async Task SendCharacterRejectedEmailAsync(string toEmail, string pseudo, string characterName, string reason, CancellationToken cancellationToken = default)
{
var subject = $"Your character {characterName} was not approved";
- var body = EmailTemplates.GetCharacterRejectedTemplate(pseudo, characterName, reason);
+ var body = EmailTemplates.GetCharacterRejectedTemplate(pseudo, characterName, reason, _settings.BaseUrl);
await SendEmailAsync(toEmail, subject, body, cancellationToken);
}
@@ -68,7 +66,7 @@ public async Task SendCharacterRejectedEmailAsync(string toEmail, string pseudo,
public async Task SendCommentApprovedEmailAsync(string toEmail, string pseudo, string characterName, CancellationToken cancellationToken = default)
{
var subject = "Your comment has been approved!";
- var body = EmailTemplates.GetCommentApprovedTemplate(pseudo, characterName);
+ var body = EmailTemplates.GetCommentApprovedTemplate(pseudo, characterName, _settings.BaseUrl);
await SendEmailAsync(toEmail, subject, body, cancellationToken);
}
@@ -103,7 +101,7 @@ private async Task SendEmailAsync(string toEmail, string subject, string htmlBod
try
{
- using var client = _smtpClientFactory.Create();
+ using var client = smtpClientFactory.Create();
var secureSocketOptions = _settings.UseSsl
? SecureSocketOptions.StartTls
@@ -114,11 +112,11 @@ private async Task SendEmailAsync(string toEmail, string subject, string htmlBod
await client.SendAsync(message, cancellationToken);
await client.DisconnectAsync(true, cancellationToken);
- _logger.LogInformation("Email sent successfully to {Email} with subject '{Subject}'", toEmail, subject);
+ logger.LogInformation("Email sent successfully to {Email} with subject '{Subject}'", toEmail, subject);
}
catch (Exception ex)
{
- _logger.LogError(ex, "Failed to send email to {Email} with subject '{Subject}'", toEmail, subject);
+ logger.LogError(ex, "Failed to send email to {Email} with subject '{Subject}'", toEmail, subject);
throw;
}
}
diff --git a/src/backend/src/FantasyRealm.Infrastructure/FantasyRealm.Infrastructure.csproj b/src/backend/src/FantasyRealm.Infrastructure/FantasyRealm.Infrastructure.csproj
index 7362e39..33c7cce 100644
--- a/src/backend/src/FantasyRealm.Infrastructure/FantasyRealm.Infrastructure.csproj
+++ b/src/backend/src/FantasyRealm.Infrastructure/FantasyRealm.Infrastructure.csproj
@@ -6,12 +6,13 @@
-
+
+
diff --git a/src/backend/src/FantasyRealm.Infrastructure/Persistence/Converters/UtcDateTimeConverter.cs b/src/backend/src/FantasyRealm.Infrastructure/Persistence/Converters/UtcDateTimeConverter.cs
new file mode 100644
index 0000000..fc8b4eb
--- /dev/null
+++ b/src/backend/src/FantasyRealm.Infrastructure/Persistence/Converters/UtcDateTimeConverter.cs
@@ -0,0 +1,20 @@
+using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
+
+namespace FantasyRealm.Infrastructure.Persistence.Converters
+{
+ ///
+ /// Converts DateTime values to and from UTC for PostgreSQL compatibility.
+ /// PostgreSQL with Npgsql requires DateTime values to have Kind=Utc for timestamp with time zone columns.
+ ///
+ public sealed class UtcDateTimeConverter : ValueConverter
+ {
+ public UtcDateTimeConverter()
+ : base(
+ v => v.Kind == DateTimeKind.Unspecified
+ ? DateTime.SpecifyKind(v, DateTimeKind.Utc)
+ : v.ToUniversalTime(),
+ v => DateTime.SpecifyKind(v, DateTimeKind.Utc))
+ {
+ }
+ }
+}
diff --git a/src/backend/src/FantasyRealm.Infrastructure/Persistence/FantasyRealmDbContext.cs b/src/backend/src/FantasyRealm.Infrastructure/Persistence/FantasyRealmDbContext.cs
index 036d50a..cc1d952 100644
--- a/src/backend/src/FantasyRealm.Infrastructure/Persistence/FantasyRealmDbContext.cs
+++ b/src/backend/src/FantasyRealm.Infrastructure/Persistence/FantasyRealmDbContext.cs
@@ -1,6 +1,7 @@
using FantasyRealm.Domain.Entities;
using FantasyRealm.Domain.Enums;
using FantasyRealm.Infrastructure.Persistence.Conventions;
+using FantasyRealm.Infrastructure.Persistence.Converters;
using Microsoft.EntityFrameworkCore;
namespace FantasyRealm.Infrastructure.Persistence
@@ -22,6 +23,14 @@ public class FantasyRealmDbContext(DbContextOptions optio
public DbSet Comments { get; set; } = null!;
+ protected override void ConfigureConventions(ModelConfigurationBuilder configurationBuilder)
+ {
+ base.ConfigureConventions(configurationBuilder);
+
+ configurationBuilder.Properties()
+ .HaveConversion();
+ }
+
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
base.OnModelCreating(modelBuilder);
@@ -52,10 +61,12 @@ private static void ConfigureUser(ModelBuilder modelBuilder)
{
entity.HasKey(e => e.Id);
entity.Property(e => e.Pseudo).HasMaxLength(50).IsRequired();
- entity.Property(e => e.Email).HasMaxLength(100).IsRequired();
+ entity.Property(e => e.Email).HasMaxLength(255).IsRequired();
entity.Property(e => e.PasswordHash).HasMaxLength(255).IsRequired();
entity.Property(e => e.IsSuspended).HasDefaultValue(false);
entity.Property(e => e.MustChangePassword).HasDefaultValue(false);
+ entity.Property(e => e.CreatedAt).HasDefaultValueSql("CURRENT_TIMESTAMP");
+ entity.Property(e => e.UpdatedAt).HasDefaultValueSql("CURRENT_TIMESTAMP");
entity.HasIndex(e => e.Pseudo).IsUnique();
entity.HasIndex(e => e.Email).IsUnique();
diff --git a/src/backend/src/FantasyRealm.Infrastructure/Repositories/UserRepository.cs b/src/backend/src/FantasyRealm.Infrastructure/Repositories/UserRepository.cs
new file mode 100644
index 0000000..d90b85a
--- /dev/null
+++ b/src/backend/src/FantasyRealm.Infrastructure/Repositories/UserRepository.cs
@@ -0,0 +1,80 @@
+using FantasyRealm.Application.Interfaces;
+using FantasyRealm.Domain.Entities;
+using FantasyRealm.Infrastructure.Persistence;
+using Microsoft.EntityFrameworkCore;
+
+namespace FantasyRealm.Infrastructure.Repositories
+{
+ ///
+ /// Repository implementation for User entity data access operations.
+ ///
+ public sealed class UserRepository(FantasyRealmDbContext context) : IUserRepository
+ {
+ ///
+ public async Task ExistsByEmailAsync(string email, CancellationToken cancellationToken = default)
+ {
+ var normalizedEmail = email.ToLowerInvariant().Trim();
+ return await context.Users
+ .AnyAsync(u => u.Email == normalizedEmail, cancellationToken);
+ }
+
+ ///
+ public async Task ExistsByPseudoAsync(string pseudo, CancellationToken cancellationToken = default)
+ {
+ var normalizedPseudo = pseudo.Trim();
+ return await context.Users
+ .AnyAsync(u => u.Pseudo == normalizedPseudo, cancellationToken);
+ }
+
+ ///
+ public async Task CreateAsync(User user, CancellationToken cancellationToken = default)
+ {
+ context.Users.Add(user);
+ await context.SaveChangesAsync(cancellationToken);
+ return user;
+ }
+
+ ///
+ public async Task GetRoleByLabelAsync(string label, CancellationToken cancellationToken = default)
+ {
+ return await context.Roles
+ .FirstOrDefaultAsync(r => r.Label == label, cancellationToken);
+ }
+
+ ///
+ public async Task GetByEmailWithRoleAsync(string email, CancellationToken cancellationToken = default)
+ {
+ var normalizedEmail = email.ToLowerInvariant().Trim();
+ return await context.Users
+ .Include(u => u.Role)
+ .FirstOrDefaultAsync(u => u.Email == normalizedEmail, cancellationToken);
+ }
+
+ ///
+ public async Task GetByIdWithRoleAsync(int id, CancellationToken cancellationToken = default)
+ {
+ return await context.Users
+ .Include(u => u.Role)
+ .FirstOrDefaultAsync(u => u.Id == id, cancellationToken);
+ }
+
+ ///
+ public async Task GetByEmailAndPseudoAsync(string email, string pseudo, CancellationToken cancellationToken = default)
+ {
+ var normalizedEmail = email.ToLowerInvariant().Trim();
+ var normalizedPseudo = pseudo.Trim();
+ return await context.Users
+ .Include(u => u.Role)
+ .FirstOrDefaultAsync(u => u.Email == normalizedEmail && u.Pseudo == normalizedPseudo, cancellationToken);
+ }
+
+ ///
+ public async Task UpdateAsync(User user, CancellationToken cancellationToken = default)
+ {
+ user.UpdatedAt = DateTime.UtcNow;
+ context.Users.Update(user);
+ await context.SaveChangesAsync(cancellationToken);
+ return user;
+ }
+ }
+}
diff --git a/src/backend/src/FantasyRealm.Infrastructure/Security/Argon2PasswordHasher.cs b/src/backend/src/FantasyRealm.Infrastructure/Security/Argon2PasswordHasher.cs
new file mode 100644
index 0000000..4754d69
--- /dev/null
+++ b/src/backend/src/FantasyRealm.Infrastructure/Security/Argon2PasswordHasher.cs
@@ -0,0 +1,73 @@
+using System.Security.Cryptography;
+using System.Text;
+using FantasyRealm.Application.Interfaces;
+using Konscious.Security.Cryptography;
+
+namespace FantasyRealm.Infrastructure.Security
+{
+ ///
+ /// Argon2id password hasher implementation following OWASP recommendations.
+ ///
+ public sealed class Argon2PasswordHasher : IPasswordHasher
+ {
+ private const int SaltSize = 16;
+ private const int HashSize = 32;
+ private const int MemorySize = 65536;
+ private const int Iterations = 3;
+ private const int DegreeOfParallelism = 4;
+
+ ///
+ public string Hash(string password)
+ {
+ var salt = RandomNumberGenerator.GetBytes(SaltSize);
+ var hash = HashPassword(password, salt);
+
+ var result = new byte[SaltSize + HashSize];
+ Buffer.BlockCopy(salt, 0, result, 0, SaltSize);
+ Buffer.BlockCopy(hash, 0, result, SaltSize, HashSize);
+
+ return Convert.ToBase64String(result);
+ }
+
+ ///
+ public bool Verify(string password, string hash)
+ {
+ try
+ {
+ var hashBytes = Convert.FromBase64String(hash);
+
+ if (hashBytes.Length != SaltSize + HashSize)
+ {
+ return false;
+ }
+
+ var salt = new byte[SaltSize];
+ var storedHash = new byte[HashSize];
+
+ Buffer.BlockCopy(hashBytes, 0, salt, 0, SaltSize);
+ Buffer.BlockCopy(hashBytes, SaltSize, storedHash, 0, HashSize);
+
+ var computedHash = HashPassword(password, salt);
+
+ return CryptographicOperations.FixedTimeEquals(computedHash, storedHash);
+ }
+ catch
+ {
+ return false;
+ }
+ }
+
+ private static byte[] HashPassword(string password, byte[] salt)
+ {
+ using var argon2 = new Argon2id(Encoding.UTF8.GetBytes(password))
+ {
+ Salt = salt,
+ MemorySize = MemorySize,
+ Iterations = Iterations,
+ DegreeOfParallelism = DegreeOfParallelism
+ };
+
+ return argon2.GetBytes(HashSize);
+ }
+ }
+}
diff --git a/src/backend/src/FantasyRealm.Infrastructure/Security/JwtService.cs b/src/backend/src/FantasyRealm.Infrastructure/Security/JwtService.cs
new file mode 100644
index 0000000..d2d80a4
--- /dev/null
+++ b/src/backend/src/FantasyRealm.Infrastructure/Security/JwtService.cs
@@ -0,0 +1,54 @@
+using System.IdentityModel.Tokens.Jwt;
+using System.Security.Claims;
+using System.Text;
+using FantasyRealm.Application.Interfaces;
+using Microsoft.Extensions.Options;
+using Microsoft.IdentityModel.Tokens;
+
+namespace FantasyRealm.Infrastructure.Security
+{
+ ///
+ /// Service implementation for JWT token generation.
+ ///
+ public sealed class JwtService : IJwtService
+ {
+ private readonly JwtSettings _settings;
+
+ public JwtService(IOptions settings)
+ {
+ _settings = settings.Value;
+ }
+
+ ///
+ public string GenerateToken(int userId, string email, string pseudo, string role)
+ {
+ var securityKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(_settings.Secret));
+ var credentials = new SigningCredentials(securityKey, SecurityAlgorithms.HmacSha256);
+
+ var claims = new[]
+ {
+ new Claim(JwtRegisteredClaimNames.Sub, userId.ToString()),
+ new Claim(JwtRegisteredClaimNames.Email, email),
+ new Claim("pseudo", pseudo),
+ new Claim(ClaimTypes.Role, role),
+ new Claim(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString())
+ };
+
+ var token = new JwtSecurityToken(
+ issuer: _settings.Issuer,
+ audience: _settings.Audience,
+ claims: claims,
+ expires: GetExpirationDate(),
+ signingCredentials: credentials
+ );
+
+ return new JwtSecurityTokenHandler().WriteToken(token);
+ }
+
+ ///
+ public DateTime GetExpirationDate()
+ {
+ return DateTime.UtcNow.AddHours(_settings.ExpirationHours);
+ }
+ }
+}
diff --git a/src/backend/src/FantasyRealm.Infrastructure/Security/JwtSettings.cs b/src/backend/src/FantasyRealm.Infrastructure/Security/JwtSettings.cs
new file mode 100644
index 0000000..0bb0c63
--- /dev/null
+++ b/src/backend/src/FantasyRealm.Infrastructure/Security/JwtSettings.cs
@@ -0,0 +1,33 @@
+namespace FantasyRealm.Infrastructure.Security
+{
+ ///
+ /// Configuration settings for JWT token generation and validation.
+ ///
+ public sealed class JwtSettings
+ {
+ ///
+ /// The configuration section name in appsettings.json.
+ ///
+ public const string SectionName = "Jwt";
+
+ ///
+ /// The secret key used to sign JWT tokens. Must be at least 32 characters.
+ ///
+ public string Secret { get; init; } = string.Empty;
+
+ ///
+ /// The issuer claim for the JWT token.
+ ///
+ public string Issuer { get; init; } = string.Empty;
+
+ ///
+ /// The audience claim for the JWT token.
+ ///
+ public string Audience { get; init; } = string.Empty;
+
+ ///
+ /// The token expiration time in hours.
+ ///
+ public int ExpirationHours { get; init; } = 24;
+ }
+}
diff --git a/src/backend/src/FantasyRealm.Infrastructure/Security/SecurePasswordGenerator.cs b/src/backend/src/FantasyRealm.Infrastructure/Security/SecurePasswordGenerator.cs
new file mode 100644
index 0000000..d020a4f
--- /dev/null
+++ b/src/backend/src/FantasyRealm.Infrastructure/Security/SecurePasswordGenerator.cs
@@ -0,0 +1,55 @@
+using System.Security.Cryptography;
+using FantasyRealm.Application.Interfaces;
+
+namespace FantasyRealm.Infrastructure.Security
+{
+ ///
+ /// Generates cryptographically secure passwords meeting CNIL requirements.
+ ///
+ public sealed class SecurePasswordGenerator : IPasswordGenerator
+ {
+ private const string UppercaseChars = "ABCDEFGHIJKLMNOPQRSTUVWXYZ";
+ private const string LowercaseChars = "abcdefghijklmnopqrstuvwxyz";
+ private const string DigitChars = "0123456789";
+ private const string SpecialChars = "@#$%^&*!?-_+=";
+ private const string AllChars = UppercaseChars + LowercaseChars + DigitChars + SpecialChars;
+ private const int MinimumLength = 12;
+
+ ///
+ public string GenerateSecurePassword(int length = 16)
+ {
+ if (length < MinimumLength)
+ {
+ length = MinimumLength;
+ }
+
+ var password = new char[length];
+ var remainingPositions = Enumerable.Range(0, length).ToList();
+
+ password[TakeRandomPosition(remainingPositions)] = GetRandomChar(UppercaseChars);
+ password[TakeRandomPosition(remainingPositions)] = GetRandomChar(LowercaseChars);
+ password[TakeRandomPosition(remainingPositions)] = GetRandomChar(DigitChars);
+ password[TakeRandomPosition(remainingPositions)] = GetRandomChar(SpecialChars);
+
+ foreach (var position in remainingPositions)
+ {
+ password[position] = GetRandomChar(AllChars);
+ }
+
+ return new string(password);
+ }
+
+ private static char GetRandomChar(string characterSet)
+ {
+ return characterSet[RandomNumberGenerator.GetInt32(characterSet.Length)];
+ }
+
+ private static int TakeRandomPosition(List positions)
+ {
+ var index = RandomNumberGenerator.GetInt32(positions.Count);
+ var position = positions[index];
+ positions.RemoveAt(index);
+ return position;
+ }
+ }
+}
diff --git a/src/backend/tests/FantasyRealm.Tests.Integration/Controllers/AuthControllerIntegrationTests.cs b/src/backend/tests/FantasyRealm.Tests.Integration/Controllers/AuthControllerIntegrationTests.cs
new file mode 100644
index 0000000..cd75ccb
--- /dev/null
+++ b/src/backend/tests/FantasyRealm.Tests.Integration/Controllers/AuthControllerIntegrationTests.cs
@@ -0,0 +1,790 @@
+using System.IdentityModel.Tokens.Jwt;
+using System.Net;
+using System.Net.Http.Json;
+using FantasyRealm.Application.DTOs;
+using FantasyRealm.Domain.Entities;
+using FantasyRealm.Infrastructure.Persistence;
+using FluentAssertions;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.Extensions.DependencyInjection;
+using Moq;
+
+namespace FantasyRealm.Tests.Integration.Controllers
+{
+ ///
+ /// Integration tests for AuthController endpoints using Testcontainers.
+ ///
+ [Trait("Category", "Integration")]
+ [Trait("Category", "Auth")]
+ public class AuthControllerIntegrationTests : IClassFixture
+ {
+ private readonly HttpClient _client;
+ private readonly FantasyRealmWebApplicationFactory _factory;
+
+ public AuthControllerIntegrationTests(FantasyRealmWebApplicationFactory factory)
+ {
+ _factory = factory;
+ _client = factory.CreateClient();
+ }
+
+ [Fact]
+ public async Task Register_WithValidData_ReturnsCreatedAndUserResponse()
+ {
+ // Arrange
+ var request = new
+ {
+ Email = $"test_{Guid.NewGuid():N}@example.com",
+ Pseudo = $"User{Guid.NewGuid():N}"[..20],
+ Password = "MySecure@Pass123",
+ ConfirmPassword = "MySecure@Pass123"
+ };
+
+ // Act
+ var response = await _client.PostAsJsonAsync("/api/auth/register", request);
+
+ // Assert
+ response.StatusCode.Should().Be(HttpStatusCode.Created);
+
+ var result = await response.Content.ReadFromJsonAsync();
+ result.Should().NotBeNull();
+ result!.Email.Should().Be(request.Email.ToLowerInvariant());
+ result.Pseudo.Should().Be(request.Pseudo);
+ result.Role.Should().Be("User");
+ result.Id.Should().BeGreaterThan(0);
+ }
+
+ [Fact]
+ public async Task Register_WithValidData_SendsWelcomeEmail()
+ {
+ // Arrange
+ _factory.EmailServiceMock.Reset();
+ var request = new
+ {
+ Email = $"email_{Guid.NewGuid():N}@example.com",
+ Pseudo = $"User{Guid.NewGuid():N}"[..20],
+ Password = "MySecure@Pass123",
+ ConfirmPassword = "MySecure@Pass123"
+ };
+
+ // Act
+ await _client.PostAsJsonAsync("/api/auth/register", request);
+
+ // Assert - Wait for fire-and-forget email task
+ await Task.Delay(500);
+ _factory.EmailServiceMock.Verify(
+ e => e.SendWelcomeEmailAsync(
+ request.Email.ToLowerInvariant(),
+ request.Pseudo,
+ It.IsAny()),
+ Times.Once);
+ }
+
+ [Fact]
+ public async Task Register_WithExistingEmail_ReturnsConflict()
+ {
+ // Arrange
+ var email = $"duplicate_{Guid.NewGuid():N}@example.com";
+ var firstRequest = new
+ {
+ Email = email,
+ Pseudo = $"First{Guid.NewGuid():N}"[..20],
+ Password = "MySecure@Pass123",
+ ConfirmPassword = "MySecure@Pass123"
+ };
+ await _client.PostAsJsonAsync("/api/auth/register", firstRequest);
+
+ var secondRequest = new
+ {
+ Email = email,
+ Pseudo = $"Second{Guid.NewGuid():N}"[..20],
+ Password = "MySecure@Pass123",
+ ConfirmPassword = "MySecure@Pass123"
+ };
+
+ // Act
+ var response = await _client.PostAsJsonAsync("/api/auth/register", secondRequest);
+
+ // Assert
+ response.StatusCode.Should().Be(HttpStatusCode.Conflict);
+ var error = await response.Content.ReadFromJsonAsync();
+ error?.Message.Should().Contain("email");
+ }
+
+ [Fact]
+ public async Task Register_WithExistingPseudo_ReturnsConflict()
+ {
+ // Arrange
+ var pseudo = $"Dup{Guid.NewGuid():N}"[..20];
+ var firstRequest = new
+ {
+ Email = $"first_{Guid.NewGuid():N}@example.com",
+ Pseudo = pseudo,
+ Password = "MySecure@Pass123",
+ ConfirmPassword = "MySecure@Pass123"
+ };
+ await _client.PostAsJsonAsync("/api/auth/register", firstRequest);
+
+ var secondRequest = new
+ {
+ Email = $"second_{Guid.NewGuid():N}@example.com",
+ Pseudo = pseudo,
+ Password = "MySecure@Pass123",
+ ConfirmPassword = "MySecure@Pass123"
+ };
+
+ // Act
+ var response = await _client.PostAsJsonAsync("/api/auth/register", secondRequest);
+
+ // Assert
+ response.StatusCode.Should().Be(HttpStatusCode.Conflict);
+ var error = await response.Content.ReadFromJsonAsync();
+ error?.Message.Should().Contain("pseudo");
+ }
+
+ [Fact]
+ public async Task Register_WithMismatchedPasswords_ReturnsBadRequest()
+ {
+ // Arrange
+ var request = new
+ {
+ Email = $"mismatch_{Guid.NewGuid():N}@example.com",
+ Pseudo = $"User{Guid.NewGuid():N}"[..20],
+ Password = "MySecure@Pass123",
+ ConfirmPassword = "DifferentPass@123"
+ };
+
+ // Act
+ var response = await _client.PostAsJsonAsync("/api/auth/register", request);
+
+ // Assert
+ response.StatusCode.Should().Be(HttpStatusCode.BadRequest);
+ var error = await response.Content.ReadFromJsonAsync();
+ error?.Message.Should().Contain("correspondent");
+ }
+
+ [Fact]
+ public async Task Register_WithWeakPassword_ReturnsBadRequest()
+ {
+ // Arrange
+ var request = new
+ {
+ Email = $"weak_{Guid.NewGuid():N}@example.com",
+ Pseudo = $"User{Guid.NewGuid():N}"[..20],
+ Password = "weak",
+ ConfirmPassword = "weak"
+ };
+
+ // Act
+ var response = await _client.PostAsJsonAsync("/api/auth/register", request);
+
+ // Assert
+ response.StatusCode.Should().Be(HttpStatusCode.BadRequest);
+ }
+
+ [Theory]
+ [InlineData("", "TestUser", "MySecure@Pass123", "MySecure@Pass123")]
+ [InlineData("invalid-email", "TestUser", "MySecure@Pass123", "MySecure@Pass123")]
+ [InlineData("test@example.com", "", "MySecure@Pass123", "MySecure@Pass123")]
+ [InlineData("test@example.com", "ab", "MySecure@Pass123", "MySecure@Pass123")]
+ public async Task Register_WithInvalidData_ReturnsBadRequest(
+ string email, string pseudo, string password, string confirmPassword)
+ {
+ // Arrange
+ var request = new { Email = email, Pseudo = pseudo, Password = password, ConfirmPassword = confirmPassword };
+
+ // Act
+ var response = await _client.PostAsJsonAsync("/api/auth/register", request);
+
+ // Assert
+ response.StatusCode.Should().Be(HttpStatusCode.BadRequest);
+ }
+
+ [Fact]
+ public async Task Register_NormalizesEmailToLowercase()
+ {
+ // Arrange
+ var email = $"UPPER_{Guid.NewGuid():N}@EXAMPLE.COM";
+ var request = new
+ {
+ Email = email,
+ Pseudo = $"User{Guid.NewGuid():N}"[..20],
+ Password = "MySecure@Pass123",
+ ConfirmPassword = "MySecure@Pass123"
+ };
+
+ // Act
+ var response = await _client.PostAsJsonAsync("/api/auth/register", request);
+
+ // Assert
+ response.StatusCode.Should().Be(HttpStatusCode.Created);
+ var result = await response.Content.ReadFromJsonAsync();
+ result!.Email.Should().Be(email.ToLowerInvariant());
+ }
+
+ #region Login Tests
+
+ [Fact]
+ public async Task Login_WithValidCredentials_ReturnsOkAndToken()
+ {
+ // Arrange - Register a user first
+ var email = $"login_{Guid.NewGuid():N}@example.com";
+ var password = "MySecure@Pass123";
+ var pseudo = $"Login{Guid.NewGuid():N}"[..20];
+
+ await _client.PostAsJsonAsync("/api/auth/register", new
+ {
+ Email = email,
+ Pseudo = pseudo,
+ Password = password,
+ ConfirmPassword = password
+ });
+
+ var loginRequest = new { Email = email, Password = password };
+
+ // Act
+ var response = await _client.PostAsJsonAsync("/api/auth/login", loginRequest);
+
+ // Assert
+ response.StatusCode.Should().Be(HttpStatusCode.OK);
+
+ var result = await response.Content.ReadFromJsonAsync();
+ result.Should().NotBeNull();
+ result!.Token.Should().NotBeNullOrEmpty();
+ result.ExpiresAt.Should().BeAfter(DateTime.UtcNow);
+ result.User.Email.Should().Be(email.ToLowerInvariant());
+ result.User.Pseudo.Should().Be(pseudo);
+ result.User.Role.Should().Be("User");
+ result.MustChangePassword.Should().BeFalse();
+ }
+
+ [Fact]
+ public async Task Login_WithValidCredentials_ReturnsValidJwtToken()
+ {
+ // Arrange
+ var email = $"jwt_{Guid.NewGuid():N}@example.com";
+ var password = "MySecure@Pass123";
+ var pseudo = $"Jwt{Guid.NewGuid():N}"[..20];
+
+ var registerResponse = await _client.PostAsJsonAsync("/api/auth/register", new
+ {
+ Email = email,
+ Pseudo = pseudo,
+ Password = password,
+ ConfirmPassword = password
+ });
+ var registeredUser = await registerResponse.Content.ReadFromJsonAsync();
+
+ var loginRequest = new { Email = email, Password = password };
+
+ // Act
+ var response = await _client.PostAsJsonAsync("/api/auth/login", loginRequest);
+ var result = await response.Content.ReadFromJsonAsync();
+
+ // Assert - Decode and validate JWT claims
+ var handler = new JwtSecurityTokenHandler();
+ var token = handler.ReadJwtToken(result!.Token);
+
+ token.Claims.Should().Contain(c => c.Type == "sub" && c.Value == registeredUser!.Id.ToString());
+ token.Claims.Should().Contain(c => c.Type == "email" && c.Value == email.ToLowerInvariant());
+ token.Claims.Should().Contain(c => c.Type == "pseudo" && c.Value == pseudo);
+ token.Claims.Should().Contain(c => c.Type.Contains("role") && c.Value == "User");
+ }
+
+ [Fact]
+ public async Task Login_WithNonExistentEmail_ReturnsUnauthorized()
+ {
+ // Arrange
+ var loginRequest = new
+ {
+ Email = $"nonexistent_{Guid.NewGuid():N}@example.com",
+ Password = "SomePassword@123"
+ };
+
+ // Act
+ var response = await _client.PostAsJsonAsync("/api/auth/login", loginRequest);
+
+ // Assert
+ response.StatusCode.Should().Be(HttpStatusCode.Unauthorized);
+ var error = await response.Content.ReadFromJsonAsync();
+ error?.Message.Should().Be("Identifiants incorrects.");
+ }
+
+ [Fact]
+ public async Task Login_WithWrongPassword_ReturnsUnauthorized()
+ {
+ // Arrange - Register a user first
+ var email = $"wrongpwd_{Guid.NewGuid():N}@example.com";
+
+ await _client.PostAsJsonAsync("/api/auth/register", new
+ {
+ Email = email,
+ Pseudo = $"Wrong{Guid.NewGuid():N}"[..20],
+ Password = "MySecure@Pass123",
+ ConfirmPassword = "MySecure@Pass123"
+ });
+
+ var loginRequest = new { Email = email, Password = "WrongPassword@123" };
+
+ // Act
+ var response = await _client.PostAsJsonAsync("/api/auth/login", loginRequest);
+
+ // Assert
+ response.StatusCode.Should().Be(HttpStatusCode.Unauthorized);
+ var error = await response.Content.ReadFromJsonAsync();
+ error?.Message.Should().Be("Identifiants incorrects.");
+ }
+
+ [Fact]
+ public async Task Login_WithSuspendedAccount_ReturnsForbidden()
+ {
+ // Arrange - Register a user first, then suspend them
+ var email = $"tosuspend_{Guid.NewGuid():N}@example.com";
+ var password = "MySecure@Pass123";
+
+ await _client.PostAsJsonAsync("/api/auth/register", new
+ {
+ Email = email,
+ Pseudo = $"ToSusp{Guid.NewGuid():N}"[..20],
+ Password = password,
+ ConfirmPassword = password
+ });
+
+ // Suspend the user directly in DB
+ using var scope = _factory.Services.CreateScope();
+ var context = scope.ServiceProvider.GetRequiredService();
+ var user = await context.Users.FirstAsync(u => u.Email == email.ToLowerInvariant());
+ user.IsSuspended = true;
+ await context.SaveChangesAsync();
+
+ var loginRequest = new { Email = email, Password = password };
+
+ // Act
+ var response = await _client.PostAsJsonAsync("/api/auth/login", loginRequest);
+
+ // Assert
+ response.StatusCode.Should().Be(HttpStatusCode.Forbidden);
+ var error = await response.Content.ReadFromJsonAsync();
+ error?.Message.Should().Be("Votre compte a été suspendu.");
+ }
+
+ [Fact]
+ public async Task Login_WithUppercaseEmail_NormalizesAndSucceeds()
+ {
+ // Arrange
+ var email = $"normalize_{Guid.NewGuid():N}@example.com";
+ var password = "MySecure@Pass123";
+
+ await _client.PostAsJsonAsync("/api/auth/register", new
+ {
+ Email = email,
+ Pseudo = $"Norm{Guid.NewGuid():N}"[..20],
+ Password = password,
+ ConfirmPassword = password
+ });
+
+ var loginRequest = new { Email = email.ToUpperInvariant(), Password = password };
+
+ // Act
+ var response = await _client.PostAsJsonAsync("/api/auth/login", loginRequest);
+
+ // Assert
+ response.StatusCode.Should().Be(HttpStatusCode.OK);
+ }
+
+ [Theory]
+ [InlineData("", "password")]
+ [InlineData("test@example.com", "")]
+ public async Task Login_WithEmptyFields_ReturnsBadRequest(string email, string password)
+ {
+ // Arrange
+ var loginRequest = new { Email = email, Password = password };
+
+ // Act
+ var response = await _client.PostAsJsonAsync("/api/auth/login", loginRequest);
+
+ // Assert
+ response.StatusCode.Should().Be(HttpStatusCode.BadRequest);
+ }
+
+ #endregion
+
+ #region ChangePassword Tests
+
+ [Fact]
+ public async Task ChangePassword_WithValidData_ReturnsOkAndNewToken()
+ {
+ // Arrange - Register and login
+ var email = $"changepwd_{Guid.NewGuid():N}@example.com";
+ var oldPassword = "MySecure@Pass123";
+ var newPassword = "NewSecure@Pass456";
+ var pseudo = $"ChgPwd{Guid.NewGuid():N}"[..20];
+
+ await _client.PostAsJsonAsync("/api/auth/register", new
+ {
+ Email = email,
+ Pseudo = pseudo,
+ Password = oldPassword,
+ ConfirmPassword = oldPassword
+ });
+
+ var loginResponse = await _client.PostAsJsonAsync("/api/auth/login", new { Email = email, Password = oldPassword });
+ var loginResult = await loginResponse.Content.ReadFromJsonAsync();
+
+ var request = new
+ {
+ CurrentPassword = oldPassword,
+ NewPassword = newPassword,
+ ConfirmNewPassword = newPassword
+ };
+
+ using var requestMessage = new HttpRequestMessage(HttpMethod.Post, "/api/auth/change-password");
+ requestMessage.Headers.Add("Authorization", $"Bearer {loginResult!.Token}");
+ requestMessage.Content = JsonContent.Create(request);
+
+ // Act
+ var response = await _client.SendAsync(requestMessage);
+
+ // Assert
+ response.StatusCode.Should().Be(HttpStatusCode.OK);
+
+ var result = await response.Content.ReadFromJsonAsync();
+ result.Should().NotBeNull();
+ result!.Token.Should().NotBeNullOrEmpty();
+ result.Token.Should().NotBe(loginResult.Token);
+ result.ExpiresAt.Should().BeAfter(DateTime.UtcNow);
+ result.User.Email.Should().Be(email.ToLowerInvariant());
+ }
+
+ [Fact]
+ public async Task ChangePassword_WithNewToken_CanLoginWithNewPassword()
+ {
+ // Arrange - Register, login, and change password
+ var email = $"newlogin_{Guid.NewGuid():N}@example.com";
+ var oldPassword = "MySecure@Pass123";
+ var newPassword = "NewSecure@Pass456";
+ var pseudo = $"NewLog{Guid.NewGuid():N}"[..20];
+
+ await _client.PostAsJsonAsync("/api/auth/register", new
+ {
+ Email = email,
+ Pseudo = pseudo,
+ Password = oldPassword,
+ ConfirmPassword = oldPassword
+ });
+
+ var loginResponse = await _client.PostAsJsonAsync("/api/auth/login", new { Email = email, Password = oldPassword });
+ var loginResult = await loginResponse.Content.ReadFromJsonAsync();
+
+ using var changeRequest = new HttpRequestMessage(HttpMethod.Post, "/api/auth/change-password");
+ changeRequest.Headers.Add("Authorization", $"Bearer {loginResult!.Token}");
+ changeRequest.Content = JsonContent.Create(new
+ {
+ CurrentPassword = oldPassword,
+ NewPassword = newPassword,
+ ConfirmNewPassword = newPassword
+ });
+
+ await _client.SendAsync(changeRequest);
+
+ // Act - Try to login with new password
+ var newLoginResponse = await _client.PostAsJsonAsync("/api/auth/login", new { Email = email, Password = newPassword });
+
+ // Assert
+ newLoginResponse.StatusCode.Should().Be(HttpStatusCode.OK);
+ }
+
+ [Fact]
+ public async Task ChangePassword_WithoutAuthentication_ReturnsUnauthorized()
+ {
+ // Arrange
+ var request = new
+ {
+ CurrentPassword = "OldPassword@123",
+ NewPassword = "NewPassword@456",
+ ConfirmNewPassword = "NewPassword@456"
+ };
+
+ // Act
+ var response = await _client.PostAsJsonAsync("/api/auth/change-password", request);
+
+ // Assert
+ response.StatusCode.Should().Be(HttpStatusCode.Unauthorized);
+ }
+
+ [Fact]
+ public async Task ChangePassword_WithInvalidToken_ReturnsUnauthorized()
+ {
+ // Arrange
+ var request = new
+ {
+ CurrentPassword = "OldPassword@123",
+ NewPassword = "NewPassword@456",
+ ConfirmNewPassword = "NewPassword@456"
+ };
+
+ using var requestMessage = new HttpRequestMessage(HttpMethod.Post, "/api/auth/change-password");
+ requestMessage.Headers.Add("Authorization", "Bearer invalid.token.here");
+ requestMessage.Content = JsonContent.Create(request);
+
+ // Act
+ var response = await _client.SendAsync(requestMessage);
+
+ // Assert
+ response.StatusCode.Should().Be(HttpStatusCode.Unauthorized);
+ }
+
+ [Fact]
+ public async Task ChangePassword_WithWrongCurrentPassword_ReturnsUnauthorized()
+ {
+ // Arrange
+ var email = $"wrongcur_{Guid.NewGuid():N}@example.com";
+ var password = "MySecure@Pass123";
+ var pseudo = $"WrngCur{Guid.NewGuid():N}"[..20];
+
+ await _client.PostAsJsonAsync("/api/auth/register", new
+ {
+ Email = email,
+ Pseudo = pseudo,
+ Password = password,
+ ConfirmPassword = password
+ });
+
+ var loginResponse = await _client.PostAsJsonAsync("/api/auth/login", new { Email = email, Password = password });
+ var loginResult = await loginResponse.Content.ReadFromJsonAsync();
+
+ var request = new
+ {
+ CurrentPassword = "WrongPassword@123",
+ NewPassword = "NewSecure@Pass456",
+ ConfirmNewPassword = "NewSecure@Pass456"
+ };
+
+ using var requestMessage = new HttpRequestMessage(HttpMethod.Post, "/api/auth/change-password");
+ requestMessage.Headers.Add("Authorization", $"Bearer {loginResult!.Token}");
+ requestMessage.Content = JsonContent.Create(request);
+
+ // Act
+ var response = await _client.SendAsync(requestMessage);
+
+ // Assert
+ response.StatusCode.Should().Be(HttpStatusCode.Unauthorized);
+ var error = await response.Content.ReadFromJsonAsync();
+ error?.Message.Should().Contain("mot de passe actuel");
+ }
+
+ [Fact]
+ public async Task ChangePassword_WithMismatchedNewPasswords_ReturnsBadRequest()
+ {
+ // Arrange
+ var email = $"mismatch_{Guid.NewGuid():N}@example.com";
+ var password = "MySecure@Pass123";
+ var pseudo = $"MisMat{Guid.NewGuid():N}"[..20];
+
+ await _client.PostAsJsonAsync("/api/auth/register", new
+ {
+ Email = email,
+ Pseudo = pseudo,
+ Password = password,
+ ConfirmPassword = password
+ });
+
+ var loginResponse = await _client.PostAsJsonAsync("/api/auth/login", new { Email = email, Password = password });
+ var loginResult = await loginResponse.Content.ReadFromJsonAsync();
+
+ var request = new
+ {
+ CurrentPassword = password,
+ NewPassword = "NewSecure@Pass456",
+ ConfirmNewPassword = "DifferentPass@789"
+ };
+
+ using var requestMessage = new HttpRequestMessage(HttpMethod.Post, "/api/auth/change-password");
+ requestMessage.Headers.Add("Authorization", $"Bearer {loginResult!.Token}");
+ requestMessage.Content = JsonContent.Create(request);
+
+ // Act
+ var response = await _client.SendAsync(requestMessage);
+
+ // Assert
+ response.StatusCode.Should().Be(HttpStatusCode.BadRequest);
+ var error = await response.Content.ReadFromJsonAsync();
+ error?.Message.Should().Contain("correspondent");
+ }
+
+ [Fact]
+ public async Task ChangePassword_WithWeakNewPassword_ReturnsBadRequest()
+ {
+ // Arrange
+ var email = $"weaknew_{Guid.NewGuid():N}@example.com";
+ var password = "MySecure@Pass123";
+ var pseudo = $"WeakNw{Guid.NewGuid():N}"[..20];
+
+ await _client.PostAsJsonAsync("/api/auth/register", new
+ {
+ Email = email,
+ Pseudo = pseudo,
+ Password = password,
+ ConfirmPassword = password
+ });
+
+ var loginResponse = await _client.PostAsJsonAsync("/api/auth/login", new { Email = email, Password = password });
+ var loginResult = await loginResponse.Content.ReadFromJsonAsync();
+
+ var request = new
+ {
+ CurrentPassword = password,
+ NewPassword = "weak",
+ ConfirmNewPassword = "weak"
+ };
+
+ using var requestMessage = new HttpRequestMessage(HttpMethod.Post, "/api/auth/change-password");
+ requestMessage.Headers.Add("Authorization", $"Bearer {loginResult!.Token}");
+ requestMessage.Content = JsonContent.Create(request);
+
+ // Act
+ var response = await _client.SendAsync(requestMessage);
+
+ // Assert
+ response.StatusCode.Should().Be(HttpStatusCode.BadRequest);
+ }
+
+ [Fact]
+ public async Task ChangePassword_WithSameAsOldPassword_ReturnsBadRequest()
+ {
+ // Arrange
+ var email = $"sameold_{Guid.NewGuid():N}@example.com";
+ var password = "MySecure@Pass123";
+ var pseudo = $"SameOl{Guid.NewGuid():N}"[..20];
+
+ await _client.PostAsJsonAsync("/api/auth/register", new
+ {
+ Email = email,
+ Pseudo = pseudo,
+ Password = password,
+ ConfirmPassword = password
+ });
+
+ var loginResponse = await _client.PostAsJsonAsync("/api/auth/login", new { Email = email, Password = password });
+ var loginResult = await loginResponse.Content.ReadFromJsonAsync();
+
+ var request = new
+ {
+ CurrentPassword = password,
+ NewPassword = password,
+ ConfirmNewPassword = password
+ };
+
+ using var requestMessage = new HttpRequestMessage(HttpMethod.Post, "/api/auth/change-password");
+ requestMessage.Headers.Add("Authorization", $"Bearer {loginResult!.Token}");
+ requestMessage.Content = JsonContent.Create(request);
+
+ // Act
+ var response = await _client.SendAsync(requestMessage);
+
+ // Assert
+ response.StatusCode.Should().Be(HttpStatusCode.BadRequest);
+ var error = await response.Content.ReadFromJsonAsync();
+ error?.Message.Should().Contain("différent");
+ }
+
+ [Fact]
+ public async Task ChangePassword_WithSuspendedAccount_ReturnsForbidden()
+ {
+ // Arrange
+ var email = $"suspchg_{Guid.NewGuid():N}@example.com";
+ var password = "MySecure@Pass123";
+ var pseudo = $"SusChg{Guid.NewGuid():N}"[..20];
+
+ await _client.PostAsJsonAsync("/api/auth/register", new
+ {
+ Email = email,
+ Pseudo = pseudo,
+ Password = password,
+ ConfirmPassword = password
+ });
+
+ var loginResponse = await _client.PostAsJsonAsync("/api/auth/login", new { Email = email, Password = password });
+ var loginResult = await loginResponse.Content.ReadFromJsonAsync();
+
+ // Suspend the user directly in DB
+ using var scope = _factory.Services.CreateScope();
+ var context = scope.ServiceProvider.GetRequiredService();
+ var user = await context.Users.FirstAsync(u => u.Email == email.ToLowerInvariant());
+ user.IsSuspended = true;
+ await context.SaveChangesAsync();
+
+ var request = new
+ {
+ CurrentPassword = password,
+ NewPassword = "NewSecure@Pass456",
+ ConfirmNewPassword = "NewSecure@Pass456"
+ };
+
+ using var requestMessage = new HttpRequestMessage(HttpMethod.Post, "/api/auth/change-password");
+ requestMessage.Headers.Add("Authorization", $"Bearer {loginResult!.Token}");
+ requestMessage.Content = JsonContent.Create(request);
+
+ // Act
+ var response = await _client.SendAsync(requestMessage);
+
+ // Assert
+ response.StatusCode.Should().Be(HttpStatusCode.Forbidden);
+ var error = await response.Content.ReadFromJsonAsync();
+ error?.Message.Should().Contain("suspendu");
+ }
+
+ [Fact]
+ public async Task ChangePassword_SetsMustChangePasswordToFalse()
+ {
+ // Arrange
+ var email = $"mustchg_{Guid.NewGuid():N}@example.com";
+ var password = "MySecure@Pass123";
+ var pseudo = $"MustCh{Guid.NewGuid():N}"[..20];
+
+ await _client.PostAsJsonAsync("/api/auth/register", new
+ {
+ Email = email,
+ Pseudo = pseudo,
+ Password = password,
+ ConfirmPassword = password
+ });
+
+ // Set MustChangePassword to true in DB
+ using (var scope = _factory.Services.CreateScope())
+ {
+ var context = scope.ServiceProvider.GetRequiredService();
+ var user = await context.Users.FirstAsync(u => u.Email == email.ToLowerInvariant());
+ user.MustChangePassword = true;
+ await context.SaveChangesAsync();
+ }
+
+ var loginResponse = await _client.PostAsJsonAsync("/api/auth/login", new { Email = email, Password = password });
+ var loginResult = await loginResponse.Content.ReadFromJsonAsync();
+ loginResult!.MustChangePassword.Should().BeTrue();
+
+ var request = new
+ {
+ CurrentPassword = password,
+ NewPassword = "NewSecure@Pass456",
+ ConfirmNewPassword = "NewSecure@Pass456"
+ };
+
+ using var requestMessage = new HttpRequestMessage(HttpMethod.Post, "/api/auth/change-password");
+ requestMessage.Headers.Add("Authorization", $"Bearer {loginResult.Token}");
+ requestMessage.Content = JsonContent.Create(request);
+
+ // Act
+ await _client.SendAsync(requestMessage);
+
+ // Assert - Check DB
+ using var verifyScope = _factory.Services.CreateScope();
+ var verifyContext = verifyScope.ServiceProvider.GetRequiredService();
+ var updatedUser = await verifyContext.Users.FirstAsync(u => u.Email == email.ToLowerInvariant());
+ updatedUser.MustChangePassword.Should().BeFalse();
+ }
+
+ #endregion
+
+ private record ErrorResponse(string Message);
+ }
+}
diff --git a/src/backend/tests/FantasyRealm.Tests.Integration/FantasyRealm.Tests.Integration.csproj b/src/backend/tests/FantasyRealm.Tests.Integration/FantasyRealm.Tests.Integration.csproj
index d1783bd..08b3b17 100644
--- a/src/backend/tests/FantasyRealm.Tests.Integration/FantasyRealm.Tests.Integration.csproj
+++ b/src/backend/tests/FantasyRealm.Tests.Integration/FantasyRealm.Tests.Integration.csproj
@@ -11,6 +11,7 @@
+
diff --git a/src/backend/tests/FantasyRealm.Tests.Integration/FantasyRealmWebApplicationFactory.cs b/src/backend/tests/FantasyRealm.Tests.Integration/FantasyRealmWebApplicationFactory.cs
new file mode 100644
index 0000000..a420c80
--- /dev/null
+++ b/src/backend/tests/FantasyRealm.Tests.Integration/FantasyRealmWebApplicationFactory.cs
@@ -0,0 +1,113 @@
+using FantasyRealm.Api;
+using FantasyRealm.Application.Interfaces;
+using FantasyRealm.Infrastructure.Persistence;
+using Microsoft.AspNetCore.Hosting;
+using Microsoft.AspNetCore.Mvc.Testing;
+using Microsoft.AspNetCore.TestHost;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.Extensions.DependencyInjection;
+using Moq;
+using Npgsql;
+using Testcontainers.PostgreSql;
+
+namespace FantasyRealm.Tests.Integration
+{
+ ///
+ /// Custom WebApplicationFactory for integration testing with Testcontainers.
+ /// Executes real SQL scripts from database/sql/ for production parity.
+ ///
+ public class FantasyRealmWebApplicationFactory : WebApplicationFactory, IAsyncLifetime
+ {
+ private readonly PostgreSqlContainer _postgresContainer = new PostgreSqlBuilder()
+ .WithImage("postgres:16-alpine")
+ .WithDatabase("fantasyrealm_test")
+ .WithUsername("test")
+ .WithPassword("test")
+ .Build();
+
+ public Mock EmailServiceMock { get; } = new();
+
+ public async Task InitializeAsync()
+ {
+ await _postgresContainer.StartAsync();
+ await ExecuteSqlScriptsAsync();
+ }
+
+ private async Task ExecuteSqlScriptsAsync()
+ {
+ var sqlDirectory = FindSqlDirectory();
+ var sqlFiles = Directory.GetFiles(sqlDirectory, "*.sql")
+ .OrderBy(f => f)
+ .ToList();
+
+ await using var connection = new NpgsqlConnection(_postgresContainer.GetConnectionString());
+ await connection.OpenAsync();
+
+ foreach (var sqlFile in sqlFiles)
+ {
+ var sql = await File.ReadAllTextAsync(sqlFile);
+ await using var command = new NpgsqlCommand(sql, connection);
+ await command.ExecuteNonQueryAsync();
+ }
+ }
+
+ private static string FindSqlDirectory()
+ {
+ var currentDir = Directory.GetCurrentDirectory();
+ var searchDir = currentDir;
+
+ while (searchDir != null)
+ {
+ var sqlPath = Path.Combine(searchDir, "database", "sql");
+ if (Directory.Exists(sqlPath))
+ {
+ return sqlPath;
+ }
+
+ var parentSqlPath = Path.Combine(searchDir, "..", "..", "..", "..", "..", "database", "sql");
+ if (Directory.Exists(parentSqlPath))
+ {
+ return Path.GetFullPath(parentSqlPath);
+ }
+
+ searchDir = Directory.GetParent(searchDir)?.FullName;
+ }
+
+ throw new DirectoryNotFoundException(
+ $"Could not find database/sql directory. Current directory: {currentDir}");
+ }
+
+ public new async Task DisposeAsync()
+ {
+ await _postgresContainer.DisposeAsync();
+ await base.DisposeAsync();
+ }
+
+ protected override void ConfigureWebHost(IWebHostBuilder builder)
+ {
+ builder.ConfigureTestServices(services =>
+ {
+ var dbDescriptor = services.SingleOrDefault(
+ d => d.ServiceType == typeof(DbContextOptions));
+
+ if (dbDescriptor != null)
+ {
+ services.Remove(dbDescriptor);
+ }
+
+ services.AddDbContext(options =>
+ options.UseNpgsql(_postgresContainer.GetConnectionString()));
+
+ var emailDescriptor = services.SingleOrDefault(
+ d => d.ServiceType == typeof(IEmailService));
+
+ if (emailDescriptor != null)
+ {
+ services.Remove(emailDescriptor);
+ }
+
+ services.AddScoped(_ => EmailServiceMock.Object);
+ });
+ }
+ }
+}
diff --git a/src/backend/tests/FantasyRealm.Tests.Integration/Persistence/FantasyRealmDbContextIntegrationTests.cs b/src/backend/tests/FantasyRealm.Tests.Integration/Persistence/FantasyRealmDbContextIntegrationTests.cs
new file mode 100644
index 0000000..4ca2d19
--- /dev/null
+++ b/src/backend/tests/FantasyRealm.Tests.Integration/Persistence/FantasyRealmDbContextIntegrationTests.cs
@@ -0,0 +1,354 @@
+using FantasyRealm.Domain.Entities;
+using FantasyRealm.Domain.Enums;
+using FantasyRealm.Infrastructure.Persistence;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.Extensions.DependencyInjection;
+
+namespace FantasyRealm.Tests.Integration.Persistence
+{
+ ///
+ /// Integration tests for FantasyRealmDbContext using real PostgreSQL via Testcontainers.
+ /// Validates that the EF Core model matches the SQL schema.
+ ///
+ [Trait("Category", "Integration")]
+ [Trait("Category", "Persistence")]
+ public class FantasyRealmDbContextIntegrationTests : IClassFixture
+ {
+ private readonly FantasyRealmWebApplicationFactory _factory;
+
+ public FantasyRealmDbContextIntegrationTests(FantasyRealmWebApplicationFactory factory)
+ {
+ _factory = factory;
+ }
+
+ private FantasyRealmDbContext CreateDbContext()
+ {
+ var scope = _factory.Services.CreateScope();
+ return scope.ServiceProvider.GetRequiredService();
+ }
+
+ [Fact]
+ public async Task CanRetrieveSeededRoles()
+ {
+ // Arrange
+ using var context = CreateDbContext();
+
+ // Act
+ var roles = await context.Roles.OrderBy(r => r.Id).ToListAsync();
+
+ // Assert
+ Assert.Equal(3, roles.Count);
+ Assert.Equal("User", roles[0].Label);
+ Assert.Equal("Employee", roles[1].Label);
+ Assert.Equal("Admin", roles[2].Label);
+ }
+
+ [Fact]
+ public async Task CanAddUserWithRole()
+ {
+ // Arrange
+ using var context = CreateDbContext();
+ var role = await context.Roles.FirstAsync(r => r.Label == "User");
+
+ var user = new User
+ {
+ Pseudo = $"player_{Guid.NewGuid():N}"[..20],
+ Email = $"player_{Guid.NewGuid():N}@test.com",
+ PasswordHash = "hashed_password",
+ RoleId = role.Id
+ };
+
+ // Act
+ context.Users.Add(user);
+ await context.SaveChangesAsync();
+
+ // Assert
+ var savedUser = await context.Users
+ .Include(u => u.Role)
+ .FirstAsync(u => u.Id == user.Id);
+
+ Assert.Equal(user.Pseudo, savedUser.Pseudo);
+ Assert.Equal("User", savedUser.Role.Label);
+ }
+
+ [Fact]
+ public async Task CanAddCharacterWithUser()
+ {
+ // Arrange
+ using var context = CreateDbContext();
+ var role = await context.Roles.FirstAsync(r => r.Label == "User");
+
+ var user = new User
+ {
+ Pseudo = $"char_owner_{Guid.NewGuid():N}"[..20],
+ Email = $"char_owner_{Guid.NewGuid():N}@test.com",
+ PasswordHash = "hashed_password",
+ RoleId = role.Id
+ };
+ context.Users.Add(user);
+ await context.SaveChangesAsync();
+
+ var character = new Character
+ {
+ Name = "Thorin",
+ Gender = Gender.Male,
+ SkinColor = "#E8BEAC",
+ EyeColor = "#4A90D9",
+ HairColor = "#2C1810",
+ EyeShape = "almond",
+ NoseShape = "aquiline",
+ MouthShape = "thin",
+ UserId = user.Id
+ };
+
+ // Act
+ context.Characters.Add(character);
+ await context.SaveChangesAsync();
+
+ // Assert
+ var savedCharacter = await context.Characters
+ .Include(c => c.User)
+ .FirstAsync(c => c.Id == character.Id);
+
+ Assert.Equal("Thorin", savedCharacter.Name);
+ Assert.Equal(Gender.Male, savedCharacter.Gender);
+ Assert.Equal(user.Pseudo, savedCharacter.User.Pseudo);
+ }
+
+ [Fact]
+ public async Task CanEquipArticleToCharacter()
+ {
+ // Arrange
+ using var context = CreateDbContext();
+ var role = await context.Roles.FirstAsync(r => r.Label == "User");
+
+ var user = new User
+ {
+ Pseudo = $"equip_user_{Guid.NewGuid():N}"[..20],
+ Email = $"equip_user_{Guid.NewGuid():N}@test.com",
+ PasswordHash = "hashed_password",
+ RoleId = role.Id
+ };
+ context.Users.Add(user);
+
+ var article = new Article
+ {
+ Name = "Iron Sword",
+ Type = ArticleType.Weapon
+ };
+ context.Articles.Add(article);
+ await context.SaveChangesAsync();
+
+ var character = new Character
+ {
+ Name = "Warrior",
+ Gender = Gender.Male,
+ SkinColor = "#E8BEAC",
+ EyeColor = "#4A90D9",
+ HairColor = "#2C1810",
+ EyeShape = "almond",
+ NoseShape = "aquiline",
+ MouthShape = "thin",
+ UserId = user.Id
+ };
+ context.Characters.Add(character);
+ await context.SaveChangesAsync();
+
+ var characterArticle = new CharacterArticle
+ {
+ CharacterId = character.Id,
+ ArticleId = article.Id
+ };
+
+ // Act
+ context.CharacterArticles.Add(characterArticle);
+ await context.SaveChangesAsync();
+
+ // Assert
+ var savedCharacter = await context.Characters
+ .Include(c => c.CharacterArticles)
+ .ThenInclude(ca => ca.Article)
+ .FirstAsync(c => c.Id == character.Id);
+
+ Assert.Single(savedCharacter.CharacterArticles);
+ Assert.Equal("Iron Sword", savedCharacter.CharacterArticles.First().Article.Name);
+ }
+
+ [Fact]
+ public async Task CanAddCommentToCharacter()
+ {
+ // Arrange
+ using var context = CreateDbContext();
+ var role = await context.Roles.FirstAsync(r => r.Label == "User");
+
+ var owner = new User
+ {
+ Pseudo = $"owner_{Guid.NewGuid():N}"[..20],
+ Email = $"owner_{Guid.NewGuid():N}@test.com",
+ PasswordHash = "hashed_password",
+ RoleId = role.Id
+ };
+ var commenter = new User
+ {
+ Pseudo = $"commenter_{Guid.NewGuid():N}"[..20],
+ Email = $"commenter_{Guid.NewGuid():N}@test.com",
+ PasswordHash = "hashed_password",
+ RoleId = role.Id
+ };
+ context.Users.AddRange(owner, commenter);
+ await context.SaveChangesAsync();
+
+ var character = new Character
+ {
+ Name = "SharedHero",
+ Gender = Gender.Female,
+ SkinColor = "#E8BEAC",
+ EyeColor = "#4A90D9",
+ HairColor = "#2C1810",
+ EyeShape = "almond",
+ NoseShape = "aquiline",
+ MouthShape = "thin",
+ IsShared = true,
+ IsAuthorized = true,
+ UserId = owner.Id
+ };
+ context.Characters.Add(character);
+ await context.SaveChangesAsync();
+
+ var comment = new Comment
+ {
+ Rating = 5,
+ Text = "Amazing character!",
+ Status = CommentStatus.Approved,
+ CommentedAt = DateTime.UtcNow,
+ CharacterId = character.Id,
+ AuthorId = commenter.Id
+ };
+
+ // Act
+ context.Comments.Add(comment);
+ await context.SaveChangesAsync();
+
+ // Assert
+ var savedComment = await context.Comments
+ .Include(c => c.Character)
+ .Include(c => c.Author)
+ .FirstAsync(c => c.Id == comment.Id);
+
+ Assert.Equal(5, savedComment.Rating);
+ Assert.Equal("SharedHero", savedComment.Character.Name);
+ Assert.Equal(commenter.Pseudo, savedComment.Author.Pseudo);
+ }
+
+ [Fact]
+ public async Task UniqueEmailConstraint_ThrowsOnDuplicate()
+ {
+ // Arrange
+ using var context = CreateDbContext();
+ var role = await context.Roles.FirstAsync(r => r.Label == "User");
+ var email = $"unique_{Guid.NewGuid():N}@test.com";
+
+ var user1 = new User
+ {
+ Pseudo = $"user1_{Guid.NewGuid():N}"[..20],
+ Email = email,
+ PasswordHash = "hashed_password",
+ RoleId = role.Id
+ };
+ context.Users.Add(user1);
+ await context.SaveChangesAsync();
+
+ var user2 = new User
+ {
+ Pseudo = $"user2_{Guid.NewGuid():N}"[..20],
+ Email = email,
+ PasswordHash = "hashed_password",
+ RoleId = role.Id
+ };
+ context.Users.Add(user2);
+
+ // Act & Assert
+ await Assert.ThrowsAsync(() => context.SaveChangesAsync());
+ }
+
+ [Fact]
+ public async Task UniquePseudoConstraint_ThrowsOnDuplicate()
+ {
+ // Arrange
+ using var context = CreateDbContext();
+ var role = await context.Roles.FirstAsync(r => r.Label == "User");
+ var pseudo = $"dup_{Guid.NewGuid():N}"[..20];
+
+ var user1 = new User
+ {
+ Pseudo = pseudo,
+ Email = $"user1_{Guid.NewGuid():N}@test.com",
+ PasswordHash = "hashed_password",
+ RoleId = role.Id
+ };
+ context.Users.Add(user1);
+ await context.SaveChangesAsync();
+
+ var user2 = new User
+ {
+ Pseudo = pseudo,
+ Email = $"user2_{Guid.NewGuid():N}@test.com",
+ PasswordHash = "hashed_password",
+ RoleId = role.Id
+ };
+ context.Users.Add(user2);
+
+ // Act & Assert
+ await Assert.ThrowsAsync(() => context.SaveChangesAsync());
+ }
+
+ [Fact]
+ public async Task CommentRatingConstraint_EnforcesRange()
+ {
+ // Arrange
+ using var context = CreateDbContext();
+ var role = await context.Roles.FirstAsync(r => r.Label == "User");
+
+ var user = new User
+ {
+ Pseudo = $"rating_user_{Guid.NewGuid():N}"[..20],
+ Email = $"rating_user_{Guid.NewGuid():N}@test.com",
+ PasswordHash = "hashed_password",
+ RoleId = role.Id
+ };
+ context.Users.Add(user);
+ await context.SaveChangesAsync();
+
+ var character = new Character
+ {
+ Name = "TestChar",
+ Gender = Gender.Male,
+ SkinColor = "#E8BEAC",
+ EyeColor = "#4A90D9",
+ HairColor = "#2C1810",
+ EyeShape = "almond",
+ NoseShape = "aquiline",
+ MouthShape = "thin",
+ IsShared = true,
+ IsAuthorized = true,
+ UserId = user.Id
+ };
+ context.Characters.Add(character);
+ await context.SaveChangesAsync();
+
+ var invalidComment = new Comment
+ {
+ Rating = 10,
+ Text = "Invalid rating",
+ Status = CommentStatus.Pending,
+ CommentedAt = DateTime.UtcNow,
+ CharacterId = character.Id,
+ AuthorId = user.Id
+ };
+ context.Comments.Add(invalidComment);
+
+ // Act & Assert
+ await Assert.ThrowsAsync(() => context.SaveChangesAsync());
+ }
+ }
+}
diff --git a/src/backend/tests/FantasyRealm.Tests.Unit/Email/EmailTemplatesTests.cs b/src/backend/tests/FantasyRealm.Tests.Unit/Email/EmailTemplatesTests.cs
index e8418bf..7ddb3a1 100644
--- a/src/backend/tests/FantasyRealm.Tests.Unit/Email/EmailTemplatesTests.cs
+++ b/src/backend/tests/FantasyRealm.Tests.Unit/Email/EmailTemplatesTests.cs
@@ -4,16 +4,21 @@ namespace FantasyRealm.Tests.Unit.Email
{
public class EmailTemplatesTests
{
+ private const string TestBaseUrl = "https://test.fantasy-realm.com";
+
[Fact]
public void GetWelcomeTemplate_ContainsPseudo()
{
+ // Arrange
var pseudo = "TestPlayer";
- var result = EmailTemplates.GetWelcomeTemplate(pseudo);
+ // Act
+ var result = EmailTemplates.GetWelcomeTemplate(pseudo, TestBaseUrl);
+ // Assert
Assert.Contains(pseudo, result);
- Assert.Contains("Welcome to FantasyRealm", result);
- Assert.Contains("Create Your First Character", result);
+ Assert.Contains("Bienvenue", result);
+ Assert.Contains("Créer mon premier personnage", result);
}
[Fact]
@@ -21,24 +26,35 @@ public void GetWelcomeTemplate_EscapesHtmlCharacters()
{
var pseudo = "";
- var result = EmailTemplates.GetWelcomeTemplate(pseudo);
+ var result = EmailTemplates.GetWelcomeTemplate(pseudo, TestBaseUrl);
Assert.DoesNotContain("";
+ var temporaryPassword = "TempPass@123!";
+
+ var result = EmailTemplates.GetTemporaryPasswordTemplate(pseudo, temporaryPassword, TestBaseUrl);
+
+ Assert.DoesNotContain("