Deux changements de modele en une seule migration, plus la recherche qui en depend. Le statut de lecture devient personnel. Il etait une colonne de Livre, donc partage par tout le foyer, alors que deux membres lisent le meme exemplaire a des rythmes differents. Il vit desormais dans une table (LivreId, Utilisateur, Statut) avec unicite sur le couple. L'absence de ligne vaut « non commence » : sur une bibliotheque de foyer la plupart des couples n'ont aucun statut, et les materialiser tous multiplierait les lignes par le nombre de comptes pour n'exprimer qu'un vide. Rien n'est donc ecrit a la creation d'un livre. Piege rencontre : un Dictionary<int, Statut> renvoyait la valeur 0 de l'enumeration — « À lire » — pour un livre sans ligne, rendant « non commence » indiscernable d'un choix explicite. Le dictionnaire est desormais typé Statut?. L'auteur devient une table. Deux formes normalisees y cohabitent, et ce n'est pas une redondance : NomNormalise garde l'ordre de saisie pour la recherche en sous-chaine, CleRegroupement trie les mots et porte l'index unique, donc l'invariant « un auteur, une fiche ». Les initiales echappent a la cle et sont traitees en memoire, sur une table qui compte au plus quelques centaines de lignes. Un livre peut avoir plusieurs auteurs — le lookup ISBN en renvoie quatre pour Introduction to Algorithms — d'ou la table de liaison, avec une position qui conserve l'ordre de la couverture. Les rapprochements ambigus ne sont jamais appliques seuls : l'API les liste, l'utilisateur accepte ou refuse, et les refus sont memorises pour que la suggestion ne revienne pas. Le couple refuse est range par identifiant croissant, donc un refus vaut dans les deux sens. Reprise des donnees existantes. L'ancien statut, commun, est rattache a AjoutePar — seule personne que la base associe au livre. Les statuts des livres sans AjoutePar sont perdus : les attribuer serait une invention. Les trois valeurs sont reprises telles quelles, « À lire » compris, parce que c'est ce que l'ancienne interface affichait. L'ancien champ auteur devient une fiche par valeur distincte. La migration ne peut pas tout faire : lower() de SQLite ne retire pas les accents, donc « Émile Zola » et « emile zola » y restent deux fiches. ServiceRenormalisation finit le travail en C# au demarrage, reunit ces variantes, applique aussi la regle des initiales — sans quoi une base heritee resterait eclatee la ou une saisie neuve aurait ete reunie d'emblee — et garde le nom d'affichage le plus presentable. Il est idempotent, et sert de filet si les regles de normalisation changent. L'ordre de la migration compte : les colonnes condamnees sont recopiees dans une table de transit avant d'etre supprimees, parce que supprimer une colonne sous SQLite reconstruit la table. Verifie sur une base a l'ancien schema contenant 9 livres, 2 prets et trois variantes de Zola : prets intacts, statuts rattaches, les trois Zola reunis sous « Émile Zola », « P.F. Hamilton » absorbe par « Peter F. Hamilton », « Hamilton » seul laisse en suggestion. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
358 lines
13 KiB
C#
358 lines
13 KiB
C#
using MaBibli.Api.Data;
|
|
using MaBibli.Api.Services.Catalogue;
|
|
using MaBibli.Shared.Catalogue;
|
|
using MaBibli.Shared.Dtos;
|
|
using MaBibli.Shared.Entites;
|
|
using Microsoft.Data.Sqlite;
|
|
using Microsoft.EntityFrameworkCore;
|
|
|
|
namespace MaBibli.Tests;
|
|
|
|
/// <summary>
|
|
/// CRUD du catalogue, sur une base SQLite <b>en mémoire</b> : même moteur qu'en production,
|
|
/// aucun fichier, aucun réseau.
|
|
/// </summary>
|
|
public class ServiceCatalogueTests : IDisposable
|
|
{
|
|
private readonly SqliteConnection _connexion;
|
|
private readonly MaBibliDbContext _db;
|
|
private readonly ServiceCatalogue _service;
|
|
|
|
public ServiceCatalogueTests()
|
|
{
|
|
// La connexion doit rester ouverte : SQLite détruit une base « :memory: » à sa fermeture.
|
|
_connexion = new SqliteConnection("Data Source=:memory:");
|
|
_connexion.Open();
|
|
|
|
var options = new DbContextOptionsBuilder<MaBibliDbContext>()
|
|
.UseSqlite(_connexion)
|
|
.Options;
|
|
|
|
_db = new MaBibliDbContext(options);
|
|
_db.Database.EnsureCreated();
|
|
|
|
_service = new ServiceCatalogue(_db, new ServiceAuteurs(_db));
|
|
}
|
|
|
|
private static EnregistrementLivre Saisie(
|
|
string titre,
|
|
string? auteur = null,
|
|
string? isbn = null,
|
|
Format format = Format.Physique,
|
|
Statut? statut = null) => new()
|
|
{
|
|
Titre = titre,
|
|
Auteur = auteur,
|
|
Isbn = isbn,
|
|
Format = format,
|
|
Statut = statut,
|
|
};
|
|
|
|
[Fact]
|
|
public async Task Creer_enregistre_le_livre_et_trace_lauteur_de_la_saisie()
|
|
{
|
|
var resultat = await _service.CreerAsync(Saisie("Germinal", "Émile Zola"), "mathieu");
|
|
|
|
Assert.Null(resultat.Erreur);
|
|
var livre = resultat.Livre!;
|
|
Assert.True(livre.Id > 0);
|
|
Assert.Equal("Germinal", livre.Titre);
|
|
Assert.Equal("mathieu", livre.AjoutePar);
|
|
Assert.Equal(["Émile Zola"], livre.Auteurs.Select(a => a.Nom));
|
|
Assert.NotEqual(default, livre.DateAjout);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Creer_normalise_lisbn()
|
|
{
|
|
var resultat = await _service.CreerAsync(Saisie("Germinal", isbn: "978-2-253-00422-6"), "mathieu");
|
|
|
|
Assert.Equal("9782253004226", resultat.Livre!.Isbn);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Creer_refuse_un_titre_vide()
|
|
{
|
|
var resultat = await _service.CreerAsync(Saisie(" "), "mathieu");
|
|
|
|
Assert.Null(resultat.Livre);
|
|
Assert.NotNull(resultat.Erreur);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Creer_refuse_un_isbn_invalide_mais_accepte_labsence_disbn()
|
|
{
|
|
var invalide = await _service.CreerAsync(Saisie("Sans titre", isbn: "1234567890123"), "mathieu");
|
|
Assert.Null(invalide.Livre);
|
|
Assert.NotNull(invalide.Erreur);
|
|
|
|
// Certains livres n'ont pas d'ISBN : l'absence doit rester parfaitement légitime.
|
|
var sansIsbn = await _service.CreerAsync(Saisie("Carnet de famille"), "mathieu");
|
|
Assert.Null(sansIsbn.Erreur);
|
|
Assert.Null(sansIsbn.Livre!.Isbn);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Le_catalogue_est_unique_physiques_et_numeriques_cohabitent()
|
|
{
|
|
await _service.CreerAsync(Saisie("Papier", format: Format.Physique), "mathieu");
|
|
await _service.CreerAsync(Saisie("Epub", format: Format.Numerique), "mathieu");
|
|
|
|
var tout = await _service.ListerAsync(new CritereLivres(), "mathieu");
|
|
Assert.Equal(2, tout.Count);
|
|
|
|
var numeriques = await _service.ListerAsync(new CritereLivres { Format = Format.Numerique }, "mathieu");
|
|
Assert.Equal(["Epub"], numeriques.Select(l => l.Titre));
|
|
}
|
|
|
|
[Fact]
|
|
public async Task La_recherche_traverse_bien_SQLite()
|
|
{
|
|
await _service.CreerAsync(Saisie("Germinal", "Émile Zola"), "mathieu");
|
|
await _service.CreerAsync(Saisie("Le Horla", "Guy de Maupassant"), "mathieu");
|
|
|
|
var parAuteur = await _service.ListerAsync(new CritereLivres { Recherche = "maupassant" }, "mathieu");
|
|
Assert.Equal(["Le Horla"], parAuteur.Select(l => l.Titre));
|
|
|
|
var parTitre = await _service.ListerAsync(new CritereLivres { Recherche = "GERMI" }, "mathieu");
|
|
Assert.Equal(["Germinal"], parTitre.Select(l => l.Titre));
|
|
}
|
|
|
|
/// <summary>
|
|
/// La recherche sans accents doit traverser SQLite, pas seulement LINQ-to-Objects : c'est là
|
|
/// que la colonne normalisée gagne son existence.
|
|
/// </summary>
|
|
[Theory]
|
|
[InlineData("emile")]
|
|
[InlineData("EMILE")]
|
|
[InlineData("Émile Zola")]
|
|
public async Task La_recherche_sans_accents_traverse_bien_SQLite(string terme)
|
|
{
|
|
await _service.CreerAsync(Saisie("Germinal", "Émile Zola"), "mathieu");
|
|
await _service.CreerAsync(Saisie("Le Horla", "Guy de Maupassant"), "mathieu");
|
|
|
|
var resultat = await _service.ListerAsync(new CritereLivres { Recherche = terme }, "mathieu");
|
|
|
|
Assert.Equal(["Germinal"], resultat.Select(l => l.Titre));
|
|
}
|
|
|
|
/// <summary>
|
|
/// <b>Invariant central</b> : la bibliothèque est commune. Des livres saisis par des comptes
|
|
/// différents doivent tous ressortir, quelle que soit la personne qui consulte.
|
|
/// </summary>
|
|
[Fact]
|
|
public async Task La_liste_renvoie_les_livres_de_tous_les_utilisateurs()
|
|
{
|
|
await _service.CreerAsync(Saisie("Livre de Mathieu"), "mathieu");
|
|
await _service.CreerAsync(Saisie("Livre de Camille"), "camille");
|
|
await _service.CreerAsync(Saisie("Livre anonyme"), null);
|
|
|
|
var tout = await _service.ListerAsync(new CritereLivres(), "mathieu");
|
|
|
|
Assert.Equal(3, tout.Count);
|
|
Assert.Contains(tout, l => l.AjoutePar == "mathieu");
|
|
Assert.Contains(tout, l => l.AjoutePar == "camille");
|
|
Assert.Contains(tout, l => l.AjoutePar is null);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Modifier_ne_reecrit_ni_AjoutePar_ni_DateAjout()
|
|
{
|
|
var cree = (await _service.CreerAsync(Saisie("Titre initial", "Auteur Initial"), "camille")).Livre!;
|
|
|
|
var modifie = await _service.ModifierAsync(cree.Id, new EnregistrementLivre
|
|
{
|
|
Titre = "Titre corrigé",
|
|
Auteur = "Auteur Corrigé",
|
|
Format = Format.Numerique,
|
|
Statut = Statut.Lu,
|
|
}, "camille");
|
|
|
|
Assert.Null(modifie.Erreur);
|
|
Assert.Equal("Titre corrigé", modifie.Livre!.Titre);
|
|
Assert.Equal(Format.Numerique, modifie.Livre.Format);
|
|
Assert.Equal(["Auteur Corrigé"], modifie.Livre.Auteurs.Select(a => a.Nom));
|
|
|
|
// La trace de la saisie d'origine survit à l'édition, y compris par quelqu'un d'autre.
|
|
Assert.Equal("camille", modifie.Livre.AjoutePar);
|
|
Assert.Equal(cree.DateAjout, modifie.Livre.DateAjout);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Modifier_un_livre_inexistant_est_signale_comme_introuvable()
|
|
{
|
|
var resultat = await _service.ModifierAsync(4242, Saisie("Fantôme"), "mathieu");
|
|
|
|
Assert.True(resultat.EstIntrouvable);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Changer_le_statut_ne_touche_que_le_statut()
|
|
{
|
|
var cree = (await _service.CreerAsync(Saisie("Germinal", "Émile Zola"), "mathieu")).Livre!;
|
|
|
|
var apres = await _service.ChangerStatutAsync(cree.Id, Statut.EnCours, "mathieu");
|
|
|
|
Assert.NotNull(apres);
|
|
Assert.Equal(Statut.EnCours, apres!.Statut);
|
|
Assert.Equal(cree.Titre, apres.Titre);
|
|
Assert.Equal(cree.Auteurs.Select(a => a.Nom), apres.Auteurs.Select(a => a.Nom));
|
|
Assert.Equal(cree.AjoutePar, apres.AjoutePar);
|
|
}
|
|
|
|
/// <summary>
|
|
/// <b>La décision de cette phase, vérifiée de bout en bout.</b> Un seul livre, deux personnes,
|
|
/// deux statuts qui ne se marchent pas dessus.
|
|
/// </summary>
|
|
[Fact]
|
|
public async Task Deux_utilisateurs_ont_des_statuts_differents_sur_le_meme_livre()
|
|
{
|
|
var livre = (await _service.CreerAsync(Saisie("Germinal", "Émile Zola"), "mathieu")).Livre!;
|
|
|
|
await _service.ChangerStatutAsync(livre.Id, Statut.Lu, "mathieu");
|
|
await _service.ChangerStatutAsync(livre.Id, Statut.EnCours, "camille");
|
|
|
|
Assert.Equal(Statut.Lu, (await _service.ObtenirAsync(livre.Id, "mathieu"))!.Statut);
|
|
Assert.Equal(Statut.EnCours, (await _service.ObtenirAsync(livre.Id, "camille"))!.Statut);
|
|
|
|
// Une troisième personne n'a rien posé : pour elle le livre est « non commencé ».
|
|
Assert.Null((await _service.ObtenirAsync(livre.Id, "claire"))!.Statut);
|
|
|
|
// Une seule ligne par personne : rien ne s'empile à force de changer d'avis.
|
|
Assert.Equal(2, await _db.StatutsLecture.CountAsync(s => s.LivreId == livre.Id));
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Un_livre_cree_sans_statut_nen_ecrit_aucun()
|
|
{
|
|
var livre = (await _service.CreerAsync(Saisie("Germinal"), "mathieu")).Livre!;
|
|
|
|
Assert.Null((await _service.ObtenirAsync(livre.Id, "mathieu"))!.Statut);
|
|
Assert.Empty(_db.StatutsLecture);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Un_statut_nul_remet_le_livre_a_non_commence()
|
|
{
|
|
var livre = (await _service.CreerAsync(Saisie("Germinal", statut: Statut.Lu), "mathieu")).Livre!;
|
|
Assert.Equal(Statut.Lu, livre.Statut);
|
|
|
|
var apres = await _service.ChangerStatutAsync(livre.Id, null, "mathieu");
|
|
|
|
Assert.Null(apres!.Statut);
|
|
Assert.Empty(_db.StatutsLecture);
|
|
}
|
|
|
|
/// <summary>Éditer un livre pose MON statut, jamais celui de la personne qui l'a saisi.</summary>
|
|
[Fact]
|
|
public async Task Modifier_un_livre_ne_touche_pas_au_statut_des_autres()
|
|
{
|
|
var livre = (await _service.CreerAsync(Saisie("Germinal", statut: Statut.Lu), "mathieu")).Livre!;
|
|
|
|
await _service.ModifierAsync(
|
|
livre.Id, Saisie("Germinal", statut: Statut.EnCours), "camille");
|
|
|
|
Assert.Equal(Statut.Lu, (await _service.ObtenirAsync(livre.Id, "mathieu"))!.Statut);
|
|
Assert.Equal(Statut.EnCours, (await _service.ObtenirAsync(livre.Id, "camille"))!.Statut);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Changer_le_statut_dun_livre_inexistant_renvoie_null()
|
|
=> Assert.Null(await _service.ChangerStatutAsync(4242, Statut.Lu, "mathieu"));
|
|
|
|
[Fact]
|
|
public async Task Sans_identite_aucun_statut_nest_ecrit()
|
|
{
|
|
var livre = (await _service.CreerAsync(Saisie("Anonyme", statut: Statut.Lu), null)).Livre!;
|
|
|
|
Assert.Null(livre.Statut);
|
|
Assert.Empty(_db.StatutsLecture);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Un_livre_peut_avoir_plusieurs_auteurs_dans_lordre()
|
|
{
|
|
var livre = (await _service.CreerAsync(
|
|
Saisie("Introduction to Algorithms",
|
|
"Thomas H. Cormen; Charles E. Leiserson; Ronald L. Rivest; Clifford Stein"),
|
|
"mathieu")).Livre!;
|
|
|
|
Assert.Equal(
|
|
["Thomas H. Cormen", "Charles E. Leiserson", "Ronald L. Rivest", "Clifford Stein"],
|
|
livre.Auteurs.Select(a => a.Nom));
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Les_variantes_sures_dun_auteur_partagent_la_meme_fiche()
|
|
{
|
|
var a = (await _service.CreerAsync(Saisie("Germinal", "Émile Zola"), "mathieu")).Livre!;
|
|
var b = (await _service.CreerAsync(Saisie("Nana", "Zola, Emile"), "mathieu")).Livre!;
|
|
var c = (await _service.CreerAsync(Saisie("La Curée", "E. Zola"), "mathieu")).Livre!;
|
|
|
|
var id = a.Auteurs.Single().Id;
|
|
Assert.Equal(id, b.Auteurs.Single().Id);
|
|
Assert.Equal(id, c.Auteurs.Single().Id);
|
|
Assert.Equal(1, await _db.Auteurs.CountAsync());
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Un_patronyme_seul_ne_se_fond_pas_dans_un_nom_complet()
|
|
{
|
|
var complet = (await _service.CreerAsync(
|
|
Saisie("Pandora's Star", "Peter F. Hamilton"), "mathieu")).Livre!;
|
|
var seul = (await _service.CreerAsync(Saisie("Autre livre", "Hamilton"), "mathieu")).Livre!;
|
|
|
|
// Deux fiches distinctes : le rapprochement est ambigu, il sera proposé, pas appliqué.
|
|
Assert.NotEqual(complet.Auteurs.Single().Id, seul.Auteurs.Single().Id);
|
|
Assert.Equal(2, await _db.Auteurs.CountAsync());
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Supprimer_retire_le_livre_du_catalogue()
|
|
{
|
|
var cree = (await _service.CreerAsync(Saisie("À jeter", "Auteur Jetable"), "mathieu")).Livre!;
|
|
|
|
Assert.True(await _service.SupprimerAsync(cree.Id));
|
|
Assert.Null(await _service.ObtenirAsync(cree.Id, "mathieu"));
|
|
Assert.Empty(await _service.ListerAsync(new CritereLivres(), "mathieu"));
|
|
|
|
// L'auteur n'a plus aucun livre : il ne doit pas rester à polluer l'écran des auteurs.
|
|
Assert.Empty(_db.Auteurs);
|
|
|
|
// Seconde suppression : plus rien à supprimer.
|
|
Assert.False(await _service.SupprimerAsync(cree.Id));
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Un_auteur_encore_lu_par_un_autre_livre_survit_a_une_suppression()
|
|
{
|
|
var premier = (await _service.CreerAsync(Saisie("Germinal", "Émile Zola"), "mathieu")).Livre!;
|
|
await _service.CreerAsync(Saisie("Nana", "Émile Zola"), "mathieu");
|
|
|
|
await _service.SupprimerAsync(premier.Id);
|
|
|
|
Assert.Equal(1, await _db.Auteurs.CountAsync());
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Obtenir_renvoie_la_fiche_complete()
|
|
{
|
|
var cree = (await _service.CreerAsync(
|
|
Saisie("Le Horla", "Guy de Maupassant", "9782080704092"), "mathieu")).Livre!;
|
|
|
|
var relu = await _service.ObtenirAsync(cree.Id, "mathieu");
|
|
|
|
Assert.NotNull(relu);
|
|
Assert.Equal("Le Horla", relu!.Titre);
|
|
Assert.Equal("9782080704092", relu.Isbn);
|
|
Assert.Equal(["Guy de Maupassant"], relu.Auteurs.Select(a => a.Nom));
|
|
}
|
|
|
|
public void Dispose()
|
|
{
|
|
_db.Dispose();
|
|
_connexion.Dispose();
|
|
GC.SuppressFinalize(this);
|
|
}
|
|
}
|