Gestion de bibliothèque personnelle auto-hébergée : catalogue, prêts, scan de code-barres, consultation hors-ligne. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
391 lines
14 KiB
C#
391 lines
14 KiB
C#
using MaBibli.Api.Data;
|
|
using MaBibli.Shared.Entites;
|
|
using Microsoft.Data.Sqlite;
|
|
using Microsoft.EntityFrameworkCore;
|
|
using Microsoft.Extensions.Logging.Abstractions;
|
|
|
|
namespace MaBibli.Tests;
|
|
|
|
/// <summary>
|
|
/// Rattrapage des colonnes normalisées après une migration.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// Les données sont écrites <b>telles que la migration SQL les laisse</b> : normalisation
|
|
/// approchée (<c>lower()</c> 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.
|
|
/// </remarks>
|
|
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<MaBibliDbContext>().UseSqlite(_connexion).Options);
|
|
_db.Database.EnsureCreated();
|
|
}
|
|
|
|
private Task ExecuterAsync() =>
|
|
new ServiceRenormalisation(_db, NullLogger<ServiceRenormalisation>.Instance).ExecuterAsync();
|
|
|
|
/// <summary>Écrit un auteur comme la migration le ferait : formes en <c>lower()</c> brut.</summary>
|
|
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));
|
|
}
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
[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);
|
|
}
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
[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());
|
|
}
|
|
|
|
/// <summary>Le cas ambigu reste ambigu : il sera proposé, pas appliqué.</summary>
|
|
[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);
|
|
}
|
|
|
|
/// <summary>
|
|
/// ⚠️ 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.
|
|
/// </summary>
|
|
[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);
|
|
}
|
|
|
|
/// <summary>
|
|
/// ⚠️ Le cas qui empêcherait le serveur de démarrer.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// La nouvelle règle rend identiques deux séries qui ne l'étaient pas. L'index unique
|
|
/// refuserait l'écriture, et l'exception surviendrait <b>au démarrage</b> : 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.
|
|
/// </remarks>
|
|
[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());
|
|
}
|
|
|
|
/// <summary>
|
|
/// Une ligne déjà correcte ne doit pas se bloquer elle-même.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// 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é.
|
|
/// </remarks>
|
|
[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);
|
|
}
|
|
|
|
/// <summary>
|
|
/// 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 <c>bib.issn</c>.
|
|
/// </summary>
|
|
[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);
|
|
}
|
|
|
|
/// <summary>
|
|
/// ⚠️ L'unicité de l'ISSN est un index <b>partiel</b> en base : deux fiches que la
|
|
/// canonisation rendrait identiques feraient échouer l'écriture — <i>au démarrage</i>, donc
|
|
/// avant que l'application ne réponde. La seconde garde donc sa forme, et rien ne meurt.
|
|
/// </summary>
|
|
[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);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Une revue <b>sans</b> ISSN n'entre pas dans le jeu des clés : sans quoi la deuxième
|
|
/// bloquerait la première, tous les <c>NULL</c> se confondant en une seule valeur.
|
|
/// </summary>
|
|
[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);
|
|
}
|
|
}
|