using MaBibli.Api.Data; using MaBibli.Shared.Entites; using Microsoft.Data.Sqlite; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Logging.Abstractions; namespace MaBibli.Tests; /// /// Rattrapage des colonnes normalisées après une migration. /// /// /// Les données sont écrites telles que la migration SQL les laisse : normalisation /// approchée (lower() ne touche pas aux accents), donc variantes d'un même auteur encore /// éclatées. C'est exactement l'état que ce service doit savoir rattraper. /// public class ServiceRenormalisationTests : IDisposable { private readonly SqliteConnection _connexion; private readonly MaBibliDbContext _db; public ServiceRenormalisationTests() { _connexion = new SqliteConnection("Data Source=:memory:"); _connexion.Open(); _db = new MaBibliDbContext( new DbContextOptionsBuilder().UseSqlite(_connexion).Options); _db.Database.EnsureCreated(); } private Task ExecuterAsync() => new ServiceRenormalisation(_db, NullLogger.Instance).ExecuterAsync(); /// Écrit un auteur comme la migration le ferait : formes en lower() brut. private Auteur AjouterHerite(string nom, params string[] titres) { var auteur = new Auteur { Nom = nom, NomNormalise = nom.ToLowerInvariant(), CleRegroupement = nom.ToLowerInvariant(), }; _db.Auteurs.Add(auteur); foreach (var titre in titres) { var livre = new Livre { Titre = titre, TitreNormalise = titre.ToLowerInvariant() }; _db.Livres.Add(livre); livre.Auteurs.Add(new LivreAuteur { Auteur = auteur }); } _db.SaveChanges(); return auteur; } [Fact] public async Task Les_titres_perdent_leurs_accents_dans_la_colonne_normalisee() { AjouterHerite("Émile Zola", "La Bête humaine"); await ExecuterAsync(); Assert.Equal("la bete humaine", _db.Livres.Single().TitreNormalise); } [Fact] public async Task Les_variantes_daccent_et_dordre_sont_reunies() { AjouterHerite("Emile Zola", "Germinal"); AjouterHerite("Émile Zola", "Nana"); AjouterHerite("ZOLA, Émile", "La Curée"); await ExecuterAsync(); var zola = Assert.Single(_db.Auteurs); Assert.Equal(3, await _db.LivreAuteurs.CountAsync(la => la.AuteurId == zola.Id)); } /// /// Le nom qui survit est celui qui se lit le mieux. Sans arbitrage, c'est l'ordre — arbitraire — /// des identifiants issus de la migration qui déciderait, et la bibliothèque perdrait ses /// accents au profit de la saisie la plus négligée. /// [Theory] // L'ordre est celui des identifiants après migration : le résultat doit en être indépendant. [InlineData("Emile Zola|Émile Zola")] [InlineData("Émile Zola|Emile Zola")] [InlineData("EMILE ZOLA|ZOLA, Émile|Émile Zola")] [InlineData("ZOLA, Émile|Émile Zola")] public async Task Le_nom_conserve_est_le_plus_presentable(string variantes) { foreach (var variante in variantes.Split('|')) { AjouterHerite(variante, $"Livre de {variante}"); } await ExecuterAsync(); Assert.Equal("Émile Zola", Assert.Single(_db.Auteurs).Nom); } /// /// Les initiales échappent à la clé de regroupement. Sans second tour, une base héritée /// resterait éclatée là où une saisie neuve aurait été réunie d'emblée. /// [Fact] public async Task Les_initiales_abregees_sont_reunies_sous_le_nom_le_plus_complet() { AjouterHerite("P.F. Hamilton", "Pandora's Star"); AjouterHerite("Peter F. Hamilton", "Judas Unchained"); await ExecuterAsync(); var hamilton = Assert.Single(_db.Auteurs); Assert.Equal("Peter F. Hamilton", hamilton.Nom); Assert.Equal(2, await _db.LivreAuteurs.CountAsync()); } /// Le cas ambigu reste ambigu : il sera proposé, pas appliqué. [Fact] public async Task Un_patronyme_seul_nest_jamais_absorbe() { AjouterHerite("Hamilton", "Great North Road"); AjouterHerite("Peter F. Hamilton", "Pandora's Star"); await ExecuterAsync(); Assert.Equal(2, await _db.Auteurs.CountAsync()); } [Fact] public async Task Un_livre_cosigne_par_deux_variantes_ne_garde_quun_lien() { var livre = new Livre { Titre = "Œuvre commune", TitreNormalise = "œuvre commune" }; _db.Livres.Add(livre); _db.SaveChanges(); foreach (var nom in new[] { "Emile Zola", "Émile Zola" }) { var auteur = new Auteur { Nom = nom, NomNormalise = nom.ToLowerInvariant(), CleRegroupement = nom.ToLowerInvariant(), }; _db.Auteurs.Add(auteur); _db.LivreAuteurs.Add(new LivreAuteur { LivreId = livre.Id, Auteur = auteur }); } _db.SaveChanges(); await ExecuterAsync(); Assert.Single(_db.Auteurs); Assert.Equal(1, await _db.LivreAuteurs.CountAsync()); } [Fact] public async Task Un_second_passage_ne_change_plus_rien() { AjouterHerite("Emile Zola", "Germinal"); AjouterHerite("Émile Zola", "Nana"); await ExecuterAsync(); var apresPremier = _db.Auteurs.Single(); var nom = apresPremier.Nom; var cle = apresPremier.CleRegroupement; await ExecuterAsync(); var apresSecond = Assert.Single(_db.Auteurs); Assert.Equal(nom, apresSecond.Nom); Assert.Equal(cle, apresSecond.CleRegroupement); Assert.Equal(2, await _db.LivreAuteurs.CountAsync()); } [Fact] public async Task Une_base_vide_ne_pose_aucun_probleme() { await ExecuterAsync(); Assert.Empty(_db.Auteurs); Assert.Empty(_db.Livres); } // ── Rattrapage des tables plus récentes ───────────────────────────────── // // Envies, séries, revues et numéros calculent leurs formes À L'ÉCRITURE : sans ce passage, // un changement de règle de normalisation — comme le développement des ligatures le // 2026-08-19 — laisserait ces lignes sur leur ancienne clé, donc introuvables par la // nouvelle. Trois de ces colonnes portent en plus une UNICITÉ, et une collision se paierait // au démarrage du serveur. [Fact] public async Task Une_envie_ecrite_avant_le_changement_de_regle_est_rattrapee() { // Forme telle qu'elle aurait été calculée avant le développement des ligatures. _db.LivresSouhaites.Add(new LivreSouhaite { Utilisateur = "mathieu", Titre = "L'Œuvre", TitreNormalise = "l œuvre", Auteur = "Émile Zola", AuteurNormalise = "emile zola", }); await _db.SaveChangesAsync(); await ExecuterAsync(); Assert.Equal("l oeuvre", _db.LivresSouhaites.Single().TitreNormalise); } [Fact] public async Task Une_envie_de_revue_ecrite_avant_le_changement_de_regle_est_rattrapee() { // Formes telles qu'elles auraient été calculées avant le développement des ligatures, // et ISSN saisi sans son tiret — deux rattrapages, deux mécanismes distincts. _db.RevuesSouhaitees.Add(new RevueSouhaitee { Utilisateur = "mathieu", Titre = "Cœur", TitreNormalise = "cœur", Numero = "Hors-Série 7", NumeroNormalise = "hors-série 7", Issn = "24666718", }); await _db.SaveChangesAsync(); await ExecuterAsync(); var envie = _db.RevuesSouhaitees.Single(); Assert.Equal("coeur", envie.TitreNormalise); Assert.Equal("hors serie 7", envie.NumeroNormalise); // ⚠️ L'ISSN ne suit pas la clé : sur une ligne au titre inchangé, il ne serait jamais // canonisé si ce rattrapage n'existait pas à part. Assert.Equal("2466-6718", envie.Issn); } /// /// ⚠️ Une collision d'unicité au démarrage empêcherait le serveur de se lancer. La règle est /// la même que partout ailleurs : la ligne la plus ancienne prend la nouvelle forme, les /// suivantes gardent l'ancienne. Mal normalisée vaut infiniment mieux que supprimée. /// [Fact] public async Task Deux_envies_de_revue_que_la_nouvelle_regle_confond_survivent_toutes_les_deux() { _db.RevuesSouhaitees.AddRange( new RevueSouhaitee { Utilisateur = "mathieu", Titre = "Cœur", TitreNormalise = "cœur", }, new RevueSouhaitee { Utilisateur = "mathieu", Titre = "Coeur", TitreNormalise = "coeur", }); await _db.SaveChangesAsync(); await ExecuterAsync(); var formes = _db.RevuesSouhaitees.OrderBy(e => e.Id).Select(e => e.TitreNormalise).ToList(); // La seconde garde « coeur » (elle l'avait déjà), la première ne peut pas la prendre. Assert.Equal(2, formes.Count); Assert.Equal("cœur", formes[0]); Assert.Equal("coeur", formes[1]); } [Fact] public async Task Une_serie_et_une_revue_sont_rattrapees_aussi() { _db.Series.Add(new Serie { Titre = "Les Sœurs", TitreNormalise = "les sœurs" }); _db.Revues.Add(new Revue { Titre = "Cœur", TitreNormalise = "cœur" }); await _db.SaveChangesAsync(); await ExecuterAsync(); Assert.Equal("les soeurs", _db.Series.Single().TitreNormalise); Assert.Equal("coeur", _db.Revues.Single().TitreNormalise); } /// /// ⚠️ Le cas qui empêcherait le serveur de démarrer. /// /// /// La nouvelle règle rend identiques deux séries qui ne l'étaient pas. L'index unique /// refuserait l'écriture, et l'exception surviendrait au démarrage : l'application ne /// se lancerait plus du tout. La plus ancienne prend la nouvelle forme, la suivante garde /// l'ancienne — mal normalisée vaut infiniment mieux que supprimée, ou qu'un serveur mort. /// [Fact] public async Task Une_collision_dunicite_ne_fait_pas_echouer_le_demarrage() { _db.Series.Add(new Serie { Titre = "L'Œuvre", TitreNormalise = "l œuvre" }); _db.Series.Add(new Serie { Titre = "L'oeuvre", TitreNormalise = "l oeuvre" }); await _db.SaveChangesAsync(); await ExecuterAsync(); var series = _db.Series.OrderBy(s => s.Id).ToList(); Assert.Equal(2, series.Count); Assert.Equal(["l oeuvre", "l œuvre"], series.Select(s => s.TitreNormalise).Order()); } /// /// Une ligne déjà correcte ne doit pas se bloquer elle-même. /// /// /// Le jeu des clés occupées part de l'état actuel de la base : sans la libération de sa /// propre clé, toute ligne dont la forme change verrait sa nouvelle valeur « déjà prise » /// dès qu'elle est égale à son ancienne — et rien ne serait jamais corrigé. /// [Fact] public async Task Le_rattrapage_reste_idempotent() { _db.Revues.Add(new Revue { Titre = "Cœur", TitreNormalise = "cœur" }); await _db.SaveChangesAsync(); await ExecuterAsync(); await ExecuterAsync(); Assert.Equal("coeur", _db.Revues.Single().TitreNormalise); } /// /// Les ISSN saisis avant le lot K2 prennent leur tiret au démarrage suivant, ce qui les rend /// enfin rapprochables d'un code-barres et de bib.issn. /// [Fact] public async Task Les_issn_existants_prennent_leur_tiret() { _db.Revues.Add(new Revue { Titre = "Médor", TitreNormalise = "medor", Issn = "24666718" }); await _db.SaveChangesAsync(); await ExecuterAsync(); Assert.Equal("2466-6718", _db.Revues.Single().Issn); } /// /// ⚠️ L'unicité de l'ISSN est un index partiel en base : deux fiches que la /// canonisation rendrait identiques feraient échouer l'écriture — au démarrage, donc /// avant que l'application ne réponde. La seconde garde donc sa forme, et rien ne meurt. /// [Fact] public async Task Deux_issn_qui_se_rejoignent_ne_tuent_pas_le_demarrage() { _db.Revues.Add(new Revue { Titre = "Médor", TitreNormalise = "medor", Issn = "2466-6718" }); _db.Revues.Add(new Revue { Titre = "Doublon", TitreNormalise = "doublon", Issn = "24666718" }); await _db.SaveChangesAsync(); await ExecuterAsync(); var issn = _db.Revues.OrderBy(r => r.Id).Select(r => r.Issn).ToList(); // Les deux lignes survivent : mal normalisée vaut infiniment mieux que supprimée. Assert.Equal(["2466-6718", "24666718"], issn); } /// /// Une revue sans ISSN n'entre pas dans le jeu des clés : sans quoi la deuxième /// bloquerait la première, tous les NULL se confondant en une seule valeur. /// [Fact] public async Task Les_revues_sans_issn_ne_se_bloquent_pas_entre_elles() { _db.Revues.Add(new Revue { Titre = "Une", TitreNormalise = "une" }); _db.Revues.Add(new Revue { Titre = "Deux", TitreNormalise = "deux" }); _db.Revues.Add(new Revue { Titre = "Trois", TitreNormalise = "trois", Issn = "24666718" }); await _db.SaveChangesAsync(); await ExecuterAsync(); Assert.Equal("2466-6718", _db.Revues.Single(r => r.Titre == "Trois").Issn); } public void Dispose() { _db.Dispose(); _connexion.Dispose(); GC.SuppressFinalize(this); } }