Files
mabibli/MaBibli.Tests/ServiceCatalogueTests.cs
T
mathieuandClaude Opus 5 94f0ef15c0 Compte les pages d'un livre, et fais des envies une liste à deux moitiés
Lot U — le nombre de pages
`Livre.NombrePages` est nullable, sans valeur par défaut : « 0 page » se lirait
comme une donnée là où l'on veut dire « on ne sait pas ». Même raison que pour
`TypeDocument.NonPrecise` — un défaut qui ne prétend rien n'a rien à reprendre,
d'où une migration réduite à un `AddColumn`. Le service refuse un zéro plutôt
que de l'écrire ; effacer le champ reste la façon de revenir à « inconnu ».

Le préremplissage vient de `dc:format`, que rien ne lisait jusqu'ici. ⚠️ Ce
champ n'est pas un nombre mais une phrase décrivant le support, et les notices
déjà enregistrées sous Fixtures/ le montrent : « 1 vol. (113 p.) : ill., couv.
ill. en coul. ; 18 cm », « 503 p. : couv. ill. ; 17 cm ». Les règles sont donc
étroites — un nombre suivi de « p. » ou de « page(s) », rien d'autre — et tout
le reste rend `null`. L'erreur n'est pas symétrique : un champ vide se remplit à
la main en trois secondes, un chiffre faux s'enregistre sans que personne ne le
voie. « 1 vol. » ne vaut pas 1, « 30 cm » ne vaut pas 30, et « (p. 45-90) »,
qui est une pagination de contribution, ne vaut rien. La valeur reste proposée
dans un champ modifiable, et rien n'est déduit pour un ebook.

Lot X — éditer une envie, et souhaiter une revue
`PUT /api/souhaits/{id}` recalcule la clé d'œuvre et l'auteur normalisé : sans
ce recalcul, le rapprochement « déjà au catalogue » continuerait de se faire sur
l'ancienne forme, et le signalement mentirait sans le dire. Le filtre sur
l'appelant fait partie de la clé de recherche, pas d'une vérification ultérieure
— l'envie d'un autre est introuvable (404), jamais refusée (403).

⚠️ Une édition peut heurter l'unicité (utilisateur, œuvre, auteur), ce qu'un
ajout ne peut pas : renommer une envie en une autre déjà présente répond par un
message lisible, jamais par « UNIQUE constraint failed ». 400 et non 409,
contrairement au doublon du catalogue : là-bas posséder deux exemplaires est
légitime et l'appel se reconfirme, ici l'index l'interdit et il n'y a rien à
confirmer. Le rang n'est pas touché — l'ordre a son propre point d'entrée.

⚠️ La couverture n'est écrite que si la charge utile en porte une. Aucun écran
n'offre de champ « URL de couverture » pour une envie (décision actée), donc un
remplacement inconditionnel l'aurait effacée à la première faute de frappe
corrigée.

`RevueSouhaitee` est une table sœur, et non des colonnes de plus sur
`LivreSouhaite` : un numéro n'a pas d'auteur et se distingue par son numéro,
deux choses que la clé d'unicité des envies de livres ne sait pas exprimer sans
devenir fausse pour tout le monde. `NumeroNormalise` est NOT NULL avec un défaut
vide — SQLite tient deux NULL pour distincts, et « Médor, sans numéro »
s'ajouterait autant de fois qu'on cliquerait. L'ISSN est canonisé avec son tiret,
seul code du projet rangé ainsi.

Le coût de la table sœur est payé partout où il devait l'être : affichage,
`.txt`, `.csv` et instantané hors-ligne `souhaits-revues`. ⚠️ Les revues forment
une SECTION à part plutôt que des lignes entrelacées : chaque table numérote son
rang indépendamment, et mélanger deux suites sans rapport produirait un ordre que
personne n'a choisi. Le `.txt`, groupé par auteur, ne pouvait de toute façon pas
les accueillir — elles n'en ont pas, et « Auteur non précisé » désigne des livres
dont l'auteur est inconnu. Le CSV gagne une colonne « Type » : sans elle, un tri
par titre rendrait revues et livres indiscernables, et la colonne des codes
mêlerait ISBN et ISSN en silence.

`ServiceRenormalisation` connaît la nouvelle table, avec la règle de collision
déjà en place. ⚠️ L'ISSN y est canonisé à part : `Renormaliser` n'applique rien
quand la clé ne bouge pas, un ISSN mal formé sur une ligne au titre inchangé y
échapperait.

`RevueSouhaitee` ne porte PAS de `CoverUrl` : rien à ajouter au garde de
`GET /api/couvertures`.

Vérifié en exécution : ISSN « 24666718 » rangé « 2466-6718 », édition de l'envie
d'un autre en 404, et les deux exports portant bien les deux moitiés.
602 tests au vert (552 au départ), aucun avertissement de compilation.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-21 14:02:09 +02:00

654 lines
25 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,
params string[] themes) => new()
{
Titre = titre,
Auteur = auteur,
Isbn = isbn,
Format = format,
Statut = statut,
Themes = themes.ToList(),
};
[Fact]
public async Task Les_themes_sont_communs_et_normalises()
{
var premier = (await _service.CreerAsync(
Saisie("Dune", themes: ["Science-fiction", "Dark Fantasy"]), "mathieu")).Livre!;
var second = (await _service.CreerAsync(
Saisie("Fondation", themes: ["science-fiction", "dark fantasy"]), "camille")).Livre!;
Assert.Equal(["Dark Fantasy", "Science-fiction"], premier.Themes);
Assert.Equal(["Dark Fantasy", "Science-fiction"], second.Themes);
Assert.Equal(2, await _db.Themes.CountAsync());
Assert.Equal(4, await _db.LivreThemes.CountAsync());
}
[Fact]
public async Task Modifier_remplace_les_themes_du_livre()
{
var livre = (await _service.CreerAsync(
Saisie("Dune", themes: ["Science-fiction", "Space opera"]), "mathieu")).Livre!;
var modifie = await _service.ModifierAsync(
livre.Id, Saisie("Dune", themes: ["space OPERA", "Aventure"]), "mathieu");
Assert.Equal(["Aventure", "Space opera"], modifie.Livre!.Themes);
Assert.Equal(2, await _db.LivreThemes.CountAsync());
}
[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_conserve_lurl_de_la_notice_source()
{
var resultat = await _service.CreerAsync(
new EnregistrementLivre
{
Titre = "Germinal",
UrlNotice = "https://catalogue.bnf.fr/ark:/12148/cb34676750b",
},
"mathieu");
Assert.Equal(
"https://catalogue.bnf.fr/ark:/12148/cb34676750b",
resultat.Livre!.UrlNotice);
Assert.Equal(
resultat.Livre.UrlNotice,
(await _db.Livres.SingleAsync()).UrlNotice);
}
/// <summary>
/// Le nombre de pages est facultatif, et son absence est une <b>vraie</b> absence.
/// </summary>
/// <remarks>
/// ⚠️ <c>0</c> est refusé plutôt qu'enregistré : la colonne est nullable précisément pour
/// que « je ne sais pas » ait sa propre valeur, et un zéro stocké se lirait comme une donnée.
/// </remarks>
[Fact]
public async Task Creer_conserve_le_nombre_de_pages_ou_son_absence()
{
var avec = await _service.CreerAsync(
new EnregistrementLivre { Titre = "Germinal", NombrePages = 503 }, "mathieu");
Assert.Equal(503, avec.Livre!.NombrePages);
var sans = await _service.CreerAsync(
new EnregistrementLivre { Titre = "Nana" }, "mathieu");
Assert.Null(sans.Livre!.NombrePages);
Assert.Null((await _db.Livres.SingleAsync(l => l.Titre == "Nana")).NombrePages);
}
[Fact]
public async Task Creer_refuse_un_nombre_de_pages_nul_ou_negatif()
{
var resultat = await _service.CreerAsync(
new EnregistrementLivre { Titre = "Germinal", NombrePages = 0 }, "mathieu");
Assert.NotNull(resultat.Erreur);
Assert.Empty(_db.Livres);
}
[Fact]
public async Task Modifier_peut_effacer_le_nombre_de_pages()
{
var livre = (await _service.CreerAsync(
new EnregistrementLivre { Titre = "Germinal", NombrePages = 503 }, "mathieu")).Livre!;
var resultat = await _service.ModifierAsync(
livre.Id, new EnregistrementLivre { Titre = "Germinal" }, "mathieu");
// Effacer le champ le remet à « inconnu » : c'est la seule façon de corriger une valeur
// proposée à tort par une notice.
Assert.Null(resultat.Livre!.NombrePages);
}
[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));
}
[Fact]
public async Task Un_livre_sans_type_precise_reste_non_precise()
{
var livre = (await _service.CreerAsync(Saisie("Germinal", "Émile Zola"), "mathieu")).Livre!;
// ⚠️ Le défaut n'est PAS « Roman » : on ne remplit pas une case à la place de
// l'utilisateur, et l'interface n'affiche que ce qui a été choisi.
Assert.Equal(TypeDocument.NonPrecise, livre.TypeDocument);
}
[Fact]
public async Task Le_type_de_document_est_enregistre_puis_modifiable()
{
var saisie = Saisie("Les fourmis", "Bernard Werber");
saisie.TypeDocument = TypeDocument.BandeDessinee;
var cree = (await _service.CreerAsync(saisie, "mathieu")).Livre!;
Assert.Equal(TypeDocument.BandeDessinee, cree.TypeDocument);
saisie.TypeDocument = TypeDocument.Roman;
var modifie = (await _service.ModifierAsync(cree.Id, saisie, "mathieu")).Livre!;
Assert.Equal(TypeDocument.Roman, modifie.TypeDocument);
}
[Fact]
public async Task Le_catalogue_se_filtre_par_type_de_document()
{
var bd = Saisie("Les fourmis", "Bernard Werber");
bd.TypeDocument = TypeDocument.BandeDessinee;
await _service.CreerAsync(bd, "mathieu");
await _service.CreerAsync(Saisie("Germinal", "Émile Zola"), "mathieu");
var bds = await _service.ListerAsync(
new CritereLivres { TypeDocument = TypeDocument.BandeDessinee }, "mathieu");
var nonPrecises = await _service.ListerAsync(
new CritereLivres { TypeDocument = TypeDocument.NonPrecise }, "mathieu");
Assert.Equal(["Les fourmis"], bds.Select(l => l.Titre));
Assert.Equal(["Germinal"], nonPrecises.Select(l => l.Titre));
}
// ── Rôles des auteurs : ils vivent sur le LIEN, pas sur la personne ──────
[Fact]
public async Task Les_roles_sont_enregistres_par_livre_et_pas_par_personne()
{
var bd = Saisie("Le Horla", format: Format.Physique);
bd.TypeDocument = TypeDocument.BandeDessinee;
bd.Auteurs =
[
new AuteurSaisi("Guy de Maupassant", RoleAuteur.Scenario),
new AuteurSaisi("Guillaume Sorel", RoleAuteur.Dessin),
];
var premier = (await _service.CreerAsync(bd, "mathieu")).Livre!;
// Le MÊME dessinateur, scénariste ailleurs : une seule fiche d'auteur, deux rôles.
var autre = Saisie("Autre album");
autre.Auteurs = [new AuteurSaisi("Guillaume Sorel", RoleAuteur.Scenario)];
var second = (await _service.CreerAsync(autre, "mathieu", confirmerDoublon: true)).Livre!;
Assert.Equal(
[RoleAuteur.Scenario, RoleAuteur.Dessin],
premier.Auteurs.Select(a => a.Role));
Assert.Equal(RoleAuteur.Scenario, second.Auteurs.Single().Role);
Assert.Equal(2, await _db.Auteurs.CountAsync());
}
[Fact]
public async Task Un_livre_sans_role_precise_reste_non_precise()
{
var livre = (await _service.CreerAsync(Saisie("Germinal", "Émile Zola"), "mathieu")).Livre!;
Assert.Equal(RoleAuteur.NonPrecise, livre.Auteurs.Single().Role);
}
[Fact]
public async Task Le_role_suit_lauteur_meme_si_son_nom_est_ecrit_autrement()
{
var saisie = Saisie("Le Horla");
saisie.Auteurs =
[
// Variante sûre d'un auteur déjà connu : le serveur la rapproche, et le rôle doit
// suivre la fiche RÉSOLUE, pas la position dans la saisie.
new AuteurSaisi("Maupassant, Guy de", RoleAuteur.Scenario),
];
await _service.CreerAsync(Saisie("Bel-Ami", "Guy de Maupassant"), "mathieu");
var livre = (await _service.CreerAsync(saisie, "mathieu")).Livre!;
Assert.Single(await _db.Auteurs.ToListAsync());
Assert.Equal(RoleAuteur.Scenario, livre.Auteurs.Single().Role);
}
[Fact]
public async Task Modifier_un_livre_change_le_role_sans_toucher_a_lauteur()
{
var saisie = Saisie("Le Horla");
saisie.Auteurs = [new AuteurSaisi("Guillaume Sorel", RoleAuteur.Dessin)];
var cree = (await _service.CreerAsync(saisie, "mathieu")).Livre!;
saisie.Auteurs = [new AuteurSaisi("Guillaume Sorel", RoleAuteur.Couleurs)];
var modifie = (await _service.ModifierAsync(cree.Id, saisie, "mathieu")).Livre!;
Assert.Equal(RoleAuteur.Couleurs, modifie.Auteurs.Single().Role);
Assert.Single(await _db.Auteurs.ToListAsync());
}
// ─────────────────────────────────────────────────────────────────────────
// Doublons : un AVERTISSEMENT, jamais un refus. Posséder deux exemplaires est légitime —
// on garde le sien et on prête l'autre — et l'ISBN est facultatif, donc il ne peut pas
// servir de clé unique. Ces tests verrouillent les deux moitiés : ce qui est signalé, et
// le fait qu'une confirmation suffise toujours à passer outre.
// ─────────────────────────────────────────────────────────────────────────
[Fact]
public async Task Le_meme_isbn_est_signale_sans_rien_ecrire()
{
await _service.CreerAsync(Saisie("Le Horla", "Guy de Maupassant", "9782080704092"), "mathieu");
var second = await _service.CreerAsync(
Saisie("Le Horla", "Guy de Maupassant", "9782080704092"), "mathieu");
Assert.Null(second.Livre);
Assert.Null(second.Erreur);
var doublons = second.Doublons!;
Assert.Contains("Le Horla", doublons.Message);
Assert.Single(doublons.Livres);
Assert.Equal(1, await _db.Livres.CountAsync());
}
[Fact]
public async Task Le_meme_isbn_confirme_cree_bien_un_second_exemplaire()
{
await _service.CreerAsync(Saisie("Le Horla", "Guy de Maupassant", "9782080704092"), "mathieu");
var second = await _service.CreerAsync(
Saisie("Le Horla", "Guy de Maupassant", "9782080704092"), "mathieu", confirmerDoublon: true);
Assert.Null(second.Doublons);
Assert.NotNull(second.Livre);
Assert.Equal(2, await _db.Livres.CountAsync());
}
[Fact]
public async Task Sans_isbn_le_titre_et_lauteur_suffisent_a_signaler()
{
await _service.CreerAsync(Saisie("Germinal", "Émile Zola"), "mathieu");
// Sous-titre, accents et ordre du nom diffèrent : c'est exactement ce que CleOeuvre et
// RapprochementAuteurs sont là pour absorber.
var second = await _service.CreerAsync(Saisie("Germinal : roman", "Zola, Emile"), "camille");
Assert.NotNull(second.Doublons);
Assert.Single(second.Doublons!.Livres);
}
[Fact]
public async Task Un_titre_identique_dun_autre_auteur_nest_pas_un_doublon()
{
await _service.CreerAsync(Saisie("Nouvelles", "Guy de Maupassant"), "mathieu");
var autre = await _service.CreerAsync(Saisie("Nouvelles", "Anton Tchekhov"), "mathieu");
Assert.Null(autre.Doublons);
Assert.NotNull(autre.Livre);
}
[Fact]
public async Task Un_titre_seulement_commence_pareil_nest_pas_un_doublon()
{
await _service.CreerAsync(Saisie("Germinal", "Émile Zola"), "mathieu");
// Le préfixe attrape le candidat en SQL, la clé d'œuvre le rejette ensuite en mémoire :
// sans cette seconde passe, tout ce qui commence par « Germinal » serait signalé.
var autre = await _service.CreerAsync(Saisie("Germinal les années noires", "Émile Zola"), "mathieu");
Assert.Null(autre.Doublons);
Assert.NotNull(autre.Livre);
}
[Fact]
public async Task Un_livre_trouve_par_son_isbn_ET_son_titre_nest_signale_quune_fois()
{
await _service.CreerAsync(Saisie("Germinal", "Émile Zola", "9782253004226"), "mathieu");
var second = await _service.CreerAsync(Saisie("Germinal", "Émile Zola", "9782253004226"), "mathieu");
Assert.Single(second.Doublons!.Livres);
}
[Fact]
public async Task Une_saisie_invalide_est_une_erreur_avant_detre_un_doublon()
{
await _service.CreerAsync(Saisie("Germinal", "Émile Zola"), "mathieu");
var invalide = await _service.CreerAsync(Saisie("Germinal", "Émile Zola", "1234567890123"), "mathieu");
Assert.NotNull(invalide.Erreur);
Assert.Null(invalide.Doublons);
}
public void Dispose()
{
_db.Dispose();
_connexion.Dispose();
GC.SuppressFinalize(this);
}
}