Files
mabibli/MaBibli.Tests/ServiceSouhaitsTests.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

802 lines
31 KiB
C#

using MaBibli.Api.Data;
using MaBibli.Api.Services.Catalogue;
using MaBibli.Api.Services.Isbn;
using MaBibli.Api.Services.Souhaits;
using MaBibli.Shared.Catalogue;
using MaBibli.Shared.Dtos;
using MaBibli.Shared.Entites;
using Microsoft.Data.Sqlite;
using Microsoft.EntityFrameworkCore;
namespace MaBibli.Tests;
/// <summary>
/// Liste d'envies, sur une base SQLite <b>en mémoire</b> : l'unicité « une œuvre par personne »
/// n'existe qu'en base, elle ne se vérifierait pas sur un faux dépôt.
/// </summary>
/// <remarks>
/// L'invariant central testé ici est la <b>portée personnelle</b> : deux utilisateurs ne doivent
/// jamais se voir. C'est l'exact contraire du catalogue, commun au foyer — et une régression y
/// serait discrète, puisque tout continuerait de « marcher » avec un seul compte.
/// </remarks>
public class ServiceSouhaitsTests : IDisposable
{
private readonly SqliteConnection _connexion;
private readonly MaBibliDbContext _db;
private readonly ServiceSouhaits _souhaits;
private readonly ServiceCatalogue _catalogue;
private const string Mathieu = "mathieu";
private const string Camille = "camille";
public ServiceSouhaitsTests()
{
_connexion = new SqliteConnection("Data Source=:memory:");
_connexion.Open();
var options = new DbContextOptionsBuilder<MaBibliDbContext>()
.UseSqlite(_connexion)
.Options;
_db = new MaBibliDbContext(options);
_db.Database.EnsureCreated();
_souhaits = new ServiceSouhaits(_db);
_catalogue = new ServiceCatalogue(_db, new ServiceAuteurs(_db));
}
private Task<ResultatSouhait> AjouterAsync(
string titre, string? auteur = "Bernard Werber", string utilisateur = Mathieu,
string? isbn = null, string? note = null) =>
_souhaits.AjouterAsync(
new EnregistrementSouhait { Titre = titre, Auteur = auteur, Isbn = isbn, Note = note },
utilisateur);
// ── « Déjà au catalogue » : on signale, on ne supprime jamais ────────────
//
// Le catalogue est COMMUN, la liste d'envies PERSONNELLE : c'est ce croisement de portées
// que ces tests protègent. Retirer l'envie d'un autre, comme le demandait la version
// initiale de l'idée, modifierait sa liste en silence.
[Fact]
public async Task Une_envie_deja_au_catalogue_est_signalee_avec_sa_fiche()
{
var livre = (await _catalogue.CreerAsync(
new EnregistrementLivre { Titre = "Les fourmis", Auteur = "Bernard Werber" }, Mathieu)).Livre!;
// Sous-titre et ordre du nom différents : ce que la clé d'œuvre et le rapprochement
// d'auteurs doivent absorber.
await AjouterAsync("Les fourmis : roman", "Werber, Bernard");
var souhait = (await _souhaits.ListerAsync(Mathieu)).Single();
Assert.True(souhait.Possede);
Assert.Equal(livre.Id, souhait.LivreId);
}
[Fact]
public async Task Une_envie_signalee_reste_dans_la_liste_avec_sa_note()
{
await AjouterAsync("Les fourmis", note: "demandé à Noël");
await _catalogue.CreerAsync(
new EnregistrementLivre { Titre = "Les fourmis", Auteur = "Bernard Werber" }, Camille);
var souhait = (await _souhaits.ListerAsync(Mathieu)).Single();
// L'ajout au catalogue par QUELQU'UN D'AUTRE ne touche pas à la liste de Mathieu.
Assert.True(souhait.Possede);
Assert.Equal("demandé à Noël", souhait.Note);
}
[Fact]
public async Task Un_titre_identique_dun_autre_auteur_nest_pas_signale()
{
await _catalogue.CreerAsync(
new EnregistrementLivre { Titre = "Nouvelles", Auteur = "Guy de Maupassant" }, Mathieu);
await AjouterAsync("Nouvelles", "Anton Tchekhov");
Assert.False((await _souhaits.ListerAsync(Mathieu)).Single().Possede);
}
[Fact]
public async Task Le_meme_isbn_suffit_a_signaler_malgre_un_titre_different()
{
// L'ISBN désigne une édition précise : il tranche seul, et rattrape un titre saisi
// autrement qu'à la source.
var livre = (await _catalogue.CreerAsync(
new EnregistrementLivre
{
Titre = "Le Horla et autres contes",
Auteur = "Guy de Maupassant",
Isbn = "9782080704092",
},
Mathieu)).Livre!;
await AjouterAsync("Le Horla", "Maupassant", isbn: "9782080704092");
var souhait = (await _souhaits.ListerAsync(Mathieu)).Single();
Assert.Equal(livre.Id, souhait.LivreId);
}
[Fact]
public async Task Une_envie_sans_equivalent_nest_pas_signalee()
{
await _catalogue.CreerAsync(
new EnregistrementLivre { Titre = "Les fourmis", Auteur = "Bernard Werber" }, Mathieu);
await AjouterAsync("La prophétie des abeilles");
var souhait = (await _souhaits.ListerAsync(Mathieu)).Single();
Assert.False(souhait.Possede);
Assert.Null(souhait.LivreId);
}
[Fact]
public async Task Le_signalement_apparait_des_lajout_de_lenvie()
{
await _catalogue.CreerAsync(
new EnregistrementLivre { Titre = "Les fourmis", Auteur = "Bernard Werber" }, Mathieu);
var ajout = await AjouterAsync("Les fourmis");
// Sinon l'écran d'ajout renverrait une fiche muette, et il faudrait recharger la liste
// pour apprendre qu'on possède déjà le livre.
Assert.True(ajout.Souhait!.Possede);
}
// ── Ordre choisi à la main ───────────────────────────────────────────────
private async Task<IReadOnlyList<string>> TitresAsync(string utilisateur = Mathieu) =>
(await _souhaits.ListerAsync(utilisateur)).Select(s => s.Titre).ToList();
/// <summary>
/// L'ordre d'affichage est celui de la liste, pas l'alphabet : classer une liste d'envies
/// par titre répondait à une question que personne ne se pose.
/// </summary>
[Fact]
public async Task Les_envies_sortent_dans_lordre_dajout_par_defaut()
{
await AjouterAsync("Zèbre", auteur: "Zoé Zed");
await AjouterAsync("Abricot", auteur: "Anne Alpha");
Assert.Equal(["Zèbre", "Abricot"], await TitresAsync());
}
[Fact]
public async Task Une_envie_nouvelle_se_pose_en_fin_de_liste()
{
await AjouterAsync("Premier");
await AjouterAsync("Deuxième");
await AjouterAsync("Troisième");
Assert.Equal(["Premier", "Deuxième", "Troisième"], await TitresAsync());
}
[Fact]
public async Task Reordonner_fixe_lordre_demande()
{
var a = (await AjouterAsync("Premier")).Souhait!.Id;
var b = (await AjouterAsync("Deuxième")).Souhait!.Id;
var c = (await AjouterAsync("Troisième")).Souhait!.Id;
Assert.True(await _souhaits.ReordonnerAsync([c, a, b], Mathieu));
Assert.Equal(["Troisième", "Premier", "Deuxième"], await TitresAsync());
}
/// <summary>Réenvoyer le même ordre ne doit rien bouger.</summary>
[Fact]
public async Task Reordonner_est_idempotent()
{
var a = (await AjouterAsync("Premier")).Souhait!.Id;
var b = (await AjouterAsync("Deuxième")).Souhait!.Id;
await _souhaits.ReordonnerAsync([b, a], Mathieu);
await _souhaits.ReordonnerAsync([b, a], Mathieu);
Assert.Equal(["Deuxième", "Premier"], await TitresAsync());
}
/// <summary>
/// ⚠️ Le cas qui perdrait des données : la liste du client est périmée (une envie ajoutée
/// depuis un autre appareil n'y figure pas). L'absente doit <b>survivre</b>, rangée à la
/// suite — mal classée vaut infiniment mieux que disparue.
/// </summary>
[Fact]
public async Task Une_envie_absente_de_lordre_envoye_est_conservee()
{
var a = (await AjouterAsync("Premier")).Souhait!.Id;
var b = (await AjouterAsync("Deuxième")).Souhait!.Id;
await AjouterAsync("Ajoutée ailleurs");
await _souhaits.ReordonnerAsync([b, a], Mathieu);
Assert.Equal(["Deuxième", "Premier", "Ajoutée ailleurs"], await TitresAsync());
}
/// <summary>
/// ⚠️ L'invariant de la table : réordonner ne doit pas offrir un moyen détourné de toucher
/// à la liste de quelqu'un d'autre en devinant un identifiant.
/// </summary>
[Fact]
public async Task Reordonner_ne_touche_jamais_la_liste_dun_autre()
{
var sienne = (await AjouterAsync("À Camille", utilisateur: Camille)).Souhait!.Id;
var a = (await AjouterAsync("Premier", utilisateur: Mathieu)).Souhait!.Id;
var b = (await AjouterAsync("Deuxième", utilisateur: Mathieu)).Souhait!.Id;
// Mathieu glisse l'identifiant de Camille en tête de SA demande.
await _souhaits.ReordonnerAsync([sienne, b, a], Mathieu);
Assert.Equal(["Deuxième", "Premier"], await TitresAsync(Mathieu));
Assert.Equal(["À Camille"], await TitresAsync(Camille));
}
[Fact]
public async Task Reordonner_sans_identite_ne_fait_rien()
{
await AjouterAsync("Premier");
Assert.False(await _souhaits.ReordonnerAsync([1], null));
}
/// <summary>
/// Supprimer laisse des trous dans les rangs ; ils ne doivent pas déranger l'ordre, et le
/// prochain ajout doit tout de même atterrir en dernier.
/// </summary>
[Fact]
public async Task Une_suppression_ne_desordonne_pas_la_liste()
{
await AjouterAsync("Premier");
var b = (await AjouterAsync("Deuxième")).Souhait!.Id;
await AjouterAsync("Troisième");
Assert.True(await _souhaits.SupprimerAsync(b, Mathieu));
await AjouterAsync("Quatrième");
Assert.Equal(["Premier", "Troisième", "Quatrième"], await TitresAsync());
}
// ── Portée personnelle ───────────────────────────────────────────────────
[Fact]
public async Task Deux_utilisateurs_ont_deux_listes_distinctes()
{
await AjouterAsync("Les fourmis", utilisateur: Mathieu);
await AjouterAsync("Germinal", "Émile Zola", Camille);
var deMathieu = await _souhaits.ListerAsync(Mathieu);
var deCamille = await _souhaits.ListerAsync(Camille);
Assert.Equal("Les fourmis", Assert.Single(deMathieu).Titre);
Assert.Equal("Germinal", Assert.Single(deCamille).Titre);
}
[Fact]
public async Task Deux_utilisateurs_peuvent_souhaiter_le_meme_livre()
{
var premier = await AjouterAsync("Les fourmis", utilisateur: Mathieu);
var second = await AjouterAsync("Les fourmis", utilisateur: Camille);
// L'unicité porte sur (utilisateur, œuvre) : elle ne doit pas empêcher deux personnes
// d'avoir la même envie — c'est même le cas normal dans un foyer.
Assert.Null(premier.Erreur);
Assert.Null(second.Erreur);
}
[Fact]
public async Task On_ne_supprime_pas_l_envie_d_un_autre()
{
var souhait = await AjouterAsync("Les fourmis", utilisateur: Mathieu);
var supprime = await _souhaits.SupprimerAsync(souhait.Souhait!.Id, Camille);
Assert.False(supprime);
Assert.Single(await _souhaits.ListerAsync(Mathieu));
}
[Fact]
public async Task Sans_identite_il_n_y_a_ni_liste_ni_ecriture()
{
var ajout = await AjouterAsync("Les fourmis", utilisateur: null!);
Assert.NotNull(ajout.Erreur);
Assert.Empty(await _souhaits.ListerAsync(null));
}
// ── Cloisonnement d'avec le catalogue ────────────────────────────────────
/// <summary>
/// L'invariant qui justifie l'entité séparée : une envie n'entre ni dans le catalogue, ni
/// dans les compteurs, ni dans la table des auteurs.
/// </summary>
[Fact]
public async Task Une_envie_n_apparait_pas_dans_le_catalogue()
{
await AjouterAsync("La prophétie des abeilles", "Bernard Werber");
var catalogue = await _catalogue.ListerAsync(new CritereLivres(), Mathieu);
Assert.Empty(catalogue);
}
[Fact]
public async Task Une_envie_ne_cree_pas_de_fiche_auteur()
{
await AjouterAsync("La prophétie des abeilles", "Bernard Werber");
// Sinon « Bernard Werber » apparaîtrait sur l'écran des auteurs avec « 0 livre »,
// et trahirait au passage la liste d'envies de son propriétaire.
Assert.Empty(await new ServiceAuteurs(_db).ListerAsync());
}
[Fact]
public async Task Une_envie_recherchee_dans_le_catalogue_ne_remonte_pas()
{
await _catalogue.CreerAsync(
new EnregistrementLivre { Titre = "Les fourmis", Auteur = "Bernard Werber" }, Mathieu);
await AjouterAsync("La prophétie des abeilles", "Bernard Werber");
var trouves = await _catalogue.ListerAsync(
new CritereLivres { Recherche = "Werber" }, Mathieu);
Assert.Equal("Les fourmis", Assert.Single(trouves).Titre);
}
// ── Validation et doublons ───────────────────────────────────────────────
[Fact]
public async Task Le_titre_est_obligatoire()
{
var resultat = await AjouterAsync(" ");
Assert.NotNull(resultat.Erreur);
}
[Fact]
public async Task La_meme_oeuvre_ne_s_ajoute_pas_deux_fois()
{
await AjouterAsync("Les thanatonautes");
// Variante d'écriture du même titre : c'est exactement ce que produit un double clic
// sur deux lignes de bibliographie qui n'ont pas été regroupées côté BnF.
var doublon = await AjouterAsync("Les thanatonautes : roman");
Assert.NotNull(doublon.Erreur);
Assert.Single(await _souhaits.ListerAsync(Mathieu));
}
[Fact]
public async Task Deux_auteurs_peuvent_avoir_une_oeuvre_de_meme_titre()
{
var premier = await AjouterAsync("Nouvelles", "Bernard Werber");
var second = await AjouterAsync("Nouvelles", "Émile Zola");
Assert.Null(premier.Erreur);
Assert.Null(second.Erreur);
Assert.Equal(2, (await _souhaits.ListerAsync(Mathieu)).Count);
}
[Fact]
public async Task Un_ISBN_invalide_est_refuse()
{
var resultat = await AjouterAsync("Les fourmis", isbn: "1234567890123");
Assert.NotNull(resultat.Erreur);
}
[Fact]
public async Task L_ISBN_reste_facultatif()
{
var resultat = await AjouterAsync("Les fourmis", isbn: null);
Assert.Null(resultat.Erreur);
Assert.Null(resultat.Souhait!.Isbn);
}
/// <summary>
/// ⚠️ <b>Contrat changé le 2026-08-18</b> : la liste était classée par auteur puis titre,
/// elle suit désormais le <b>rang choisi par son propriétaire</b>. L'alphabet répondait à
/// une question que personne ne se pose devant une liste d'envies ; « qu'est-ce que je veux
/// le plus ? » en est une.
/// <para>
/// L'ancien ordre n'est pas perdu pour autant : la migration <c>RangDesEnvies</c> le
/// reconduit dans les rangs initiaux, pour que les listes existantes ne se réordonnent pas
/// toutes seules. C'est <c>MigrationRangDesEnviesTests</c> qui le vérifie.
/// </para>
/// </summary>
[Fact]
public async Task La_liste_ne_se_classe_plus_par_ordre_alphabetique()
{
await AjouterAsync("Les fourmis", "Bernard Werber");
await AjouterAsync("Sans auteur", null);
await AjouterAsync("Germinal", "Émile Zola");
await AjouterAsync("Demain les chats", "Bernard Werber");
Assert.Equal(
["Les fourmis", "Sans auteur", "Germinal", "Demain les chats"],
await TitresAsync());
}
[Fact]
public async Task Une_envie_supprimee_disparait()
{
var souhait = await AjouterAsync("Les fourmis");
Assert.True(await _souhaits.SupprimerAsync(souhait.Souhait!.Id, Mathieu));
Assert.Empty(await _souhaits.ListerAsync(Mathieu));
}
// ── Bibliographie : confrontation à l'étagère ────────────────────────────
/// <summary>
/// Client BnF simulé : ces tests portent sur le rapprochement, pas sur le réseau.
/// </summary>
private sealed class BnfBibliographieFake(string xml) : IBnfClient
{
public Task<(IReadOnlyList<CandidatLivre>, string?)> RechercherAsync(
string isbn, string? urlCouverture, CancellationToken ct = default) =>
Task.FromResult(((IReadOnlyList<CandidatLivre>)[], (string?)null));
public Task<(ResultatBibliographie, string?)> RechercherParAuteurAsync(
string auteur, CancellationToken ct = default) =>
Task.FromResult((BnfBibliographieParser.Parser(xml, auteur), (string?)null));
/// <summary>Les périodiques n'interviennent pas dans la bibliographie par auteur.</summary>
public Task<(PeriodiqueDetecte?, string?)> RechercherPeriodiqueAsync(
string issn, CancellationToken ct = default) =>
Task.FromResult(((PeriodiqueDetecte?)null, (string?)null));
/// <summary>Recherche par titre : couverte par ses propres tests.</summary>
public Task<(IReadOnlyList<CandidatLivre>, string?)> RechercherParTitreAsync(
string titre, string? auteur, CancellationToken ct = default) =>
Task.FromResult(((IReadOnlyList<CandidatLivre>)[], (string?)null));
}
/// <summary>
/// Client BnF qui <b>ne répond pas</b> : c'est le cas « Robert A. Harper » remonté en usage.
/// </summary>
private sealed class BnfMuetFake(EtatSourceBibliographie etat, string message) : IBnfClient
{
public Task<(IReadOnlyList<CandidatLivre>, string?)> RechercherAsync(
string isbn, string? urlCouverture, CancellationToken ct = default) =>
Task.FromResult(((IReadOnlyList<CandidatLivre>)[], (string?)null));
public Task<(ResultatBibliographie, string?)> RechercherParAuteurAsync(
string auteur, CancellationToken ct = default) =>
Task.FromResult((new ResultatBibliographie { Etat = etat }, (string?)message));
public Task<(PeriodiqueDetecte?, string?)> RechercherPeriodiqueAsync(
string issn, CancellationToken ct = default) =>
Task.FromResult(((PeriodiqueDetecte?)null, (string?)null));
public Task<(IReadOnlyList<CandidatLivre>, string?)> RechercherParTitreAsync(
string titre, string? auteur, CancellationToken ct = default) =>
Task.FromResult(((IReadOnlyList<CandidatLivre>)[], (string?)null));
}
/// <summary>
/// ⚠️ <b>Le défaut que ce lot corrige.</b> Une BnF muette rend une liste vide, exactement
/// comme un auteur réellement absent de son catalogue. Sans <c>SourceMuette</c>, l'écran
/// affirmait « la BnF ne connaît aucun livre de cet auteur » à propos d'une question qu'il
/// n'avait pas pu poser.
/// </summary>
[Theory]
[InlineData(EtatSourceBibliographie.DelaiDepasse)]
[InlineData(EtatSourceBibliographie.Injoignable)]
[InlineData(EtatSourceBibliographie.ReponseIllisible)]
public async Task Une_BnF_muette_ne_se_confond_pas_avec_un_auteur_sans_oeuvre(
EtatSourceBibliographie etat)
{
var biblio = await BibliographieAsync(new BnfMuetFake(etat, "La BnF n'a pas répondu."));
Assert.Empty(biblio.Oeuvres);
Assert.True(biblio.SourceMuette);
Assert.Equal(etat, biblio.Etat);
Assert.NotNull(biblio.Avertissement);
}
/// <summary>
/// Le pendant : la BnF <b>a</b> répondu, elle ne connaît simplement rien. Là, et là
/// seulement, l'écran a le droit d'expliquer l'absence.
/// </summary>
[Fact]
public async Task Une_BnF_qui_repond_sans_rien_connaitre_nest_pas_muette()
{
var biblio = await BibliographieAsync(new BnfBibliographieFake(ReponseVide));
Assert.Empty(biblio.Oeuvres);
Assert.False(biblio.SourceMuette);
Assert.Equal(EtatSourceBibliographie.Ok, biblio.Etat);
Assert.Null(biblio.Avertissement);
}
/// <summary>Une bibliographie qui a abouti n'est jamais signalée comme muette.</summary>
[Fact]
public async Task Une_bibliographie_obtenue_nest_pas_muette()
{
var biblio = await BibliographieAsync(
new BnfBibliographieFake(Fixture.Lire("bnf-auteur-werber.xml")));
Assert.NotEmpty(biblio.Oeuvres);
Assert.False(biblio.SourceMuette);
}
private const string ReponseVide = """
<srw:searchRetrieveResponse xmlns:srw="http://www.loc.gov/zing/srw/">
<srw:numberOfRecords>0</srw:numberOfRecords>
</srw:searchRetrieveResponse>
""";
/// <summary>Bibliographie de Werber avec le client BnF fourni : seule la source varie.</summary>
/// <summary>
/// Bibliographie de Werber avec le client BnF fourni : <b>seule la source varie</b>.
/// L'auteur doit exister au catalogue, une bibliographie partant toujours d'un auteur à soi.
/// </summary>
private async Task<BibliographieDto> BibliographieAsync(IBnfClient bnf)
{
await _catalogue.CreerAsync(
new EnregistrementLivre { Titre = "Les fourmis", Auteur = "Bernard Werber" }, Mathieu);
var auteurs = new ServiceAuteurs(_db);
var service = new ServiceBibliographie(_db, bnf, auteurs);
var werber = (await auteurs.ListerAsync()).Single(a => a.Nom.Contains("Werber"));
return (await service.ObtenirAsync(werber.Id, Mathieu))!;
}
private async Task<BibliographieDto> BibliographieWerberAsync(string utilisateur = Mathieu)
{
var auteurs = new ServiceAuteurs(_db);
var service = new ServiceBibliographie(
_db, new BnfBibliographieFake(Fixture.Lire("bnf-auteur-werber.xml")), auteurs);
var werber = (await auteurs.ListerAsync()).Single(a => a.Nom.Contains("Werber"));
return (await service.ObtenirAsync(werber.Id, utilisateur))!;
}
[Fact]
public async Task La_bibliographie_grise_les_livres_deja_possedes()
{
// Titre saisi sans le sous-titre « : roman » que porte la notice BnF : c'est le cas
// courant, et c'est précisément ce que la clé d'œuvre doit rattraper.
await _catalogue.CreerAsync(
new EnregistrementLivre { Titre = "Les thanatonautes", Auteur = "Bernard Werber" }, Mathieu);
var biblio = await BibliographieWerberAsync();
var oeuvre = biblio.Oeuvres.Single(
o => o.Titre.StartsWith("Les thanatonautes", StringComparison.OrdinalIgnoreCase));
Assert.True(oeuvre.Possede);
Assert.NotNull(oeuvre.LivreId);
// Les autres restent à découvrir : posséder un livre n'en grise pas d'autres.
Assert.Contains(biblio.Oeuvres, o => o.ADecouvrir);
}
[Fact]
public async Task La_bibliographie_signale_ce_qui_est_deja_souhaite()
{
await _catalogue.CreerAsync(
new EnregistrementLivre { Titre = "Les fourmis", Auteur = "Bernard Werber" }, Mathieu);
var avant = await BibliographieWerberAsync();
var cible = avant.Oeuvres.First(o => o.ADecouvrir);
await AjouterAsync(cible.Titre, "Bernard Werber");
var apres = await BibliographieWerberAsync();
var revue = apres.Oeuvres.Single(o => o.Titre == cible.Titre);
Assert.True(revue.Souhaite);
Assert.False(revue.ADecouvrir);
}
/// <summary>
/// « Possédé » vaut pour le foyer, « souhaité » seulement pour l'appelant : c'est ce qui
/// permet d'offrir un livre à quelqu'un sans qu'il le voie venir.
/// </summary>
[Fact]
public async Task Le_souhait_d_un_autre_n_apparait_pas_dans_ma_bibliographie()
{
await _catalogue.CreerAsync(
new EnregistrementLivre { Titre = "Les fourmis", Auteur = "Bernard Werber" }, Mathieu);
var biblio = await BibliographieWerberAsync(Mathieu);
var cible = biblio.Oeuvres.First(o => o.ADecouvrir);
await AjouterAsync(cible.Titre, "Bernard Werber", Camille);
var vueDeMathieu = await BibliographieWerberAsync(Mathieu);
var vueDeCamille = await BibliographieWerberAsync(Camille);
Assert.False(vueDeMathieu.Oeuvres.Single(o => o.Titre == cible.Titre).Souhaite);
Assert.True(vueDeCamille.Oeuvres.Single(o => o.Titre == cible.Titre).Souhaite);
}
[Fact]
public async Task Un_auteur_inconnu_n_a_pas_de_bibliographie()
{
var service = new ServiceBibliographie(
_db, new BnfBibliographieFake("<x/>"), new ServiceAuteurs(_db));
Assert.Null(await service.ObtenirAsync(4242, Mathieu));
}
/// <summary>
/// Le livre d'un homonyme ne doit pas griser l'œuvre d'un autre : la comparaison se fait
/// à auteur donné, pas sur tout le catalogue.
/// </summary>
[Fact]
public async Task Un_livre_d_un_autre_auteur_ne_grise_rien()
{
await _catalogue.CreerAsync(
new EnregistrementLivre { Titre = "Les fourmis", Auteur = "Bernard Werber" }, Mathieu);
// Un homonyme parfait, mais signé de quelqu'un d'autre.
await _catalogue.CreerAsync(
new EnregistrementLivre { Titre = "Les thanatonautes", Auteur = "Éric Corbeyran" }, Mathieu);
var biblio = await BibliographieWerberAsync();
Assert.False(
biblio.Oeuvres
.Single(o => o.Titre.StartsWith("Les thanatonautes", StringComparison.OrdinalIgnoreCase))
.Possede);
}
// ── Éditer une envie ────────────────────────────────────────────────────
//
// La liste est PERSONNELLE : c'est le seul invariant que ces tests protègent vraiment.
// Un 403 dirait « elle existe, mais elle n'est pas à vous » — exactement ce qu'on refuse
// de laisser deviner.
[Fact]
public async Task Editer_une_envie_recalcule_la_cle_doeuvre_et_lauteur()
{
var envie = (await AjouterAsync("Les fourmis")).Souhait!;
var resultat = await _souhaits.ModifierAsync(
envie.Id,
new EnregistrementSouhait { Titre = "L'Œuvre : roman", Auteur = "Zola, Émile" },
Mathieu);
Assert.Null(resultat.Erreur);
var ligne = _db.LivresSouhaites.Single();
// Clé d'ŒUVRE : le sous-titre est coupé, la ligature développée, les accents à plat.
Assert.Equal("l oeuvre", ligne.TitreNormalise);
// Sans ce recalcul, le rapprochement « déjà au catalogue » se ferait sur l'ancien nom.
Assert.Equal("emile zola", ligne.AuteurNormalise);
}
[Fact]
public async Task Editer_une_envie_ne_touche_pas_a_son_rang()
{
await AjouterAsync("Première");
var seconde = (await AjouterAsync("Seconde")).Souhait!;
await _souhaits.ModifierAsync(
seconde.Id, new EnregistrementSouhait { Titre = "Seconde, corrigée" }, Mathieu);
Assert.Equal(1, _db.LivresSouhaites.Single(s => s.Id == seconde.Id).Rang);
}
/// <summary>
/// Une correction ne doit pas effacer la couverture : aucun écran n'offre de champ pour la
/// saisir, un remplacement inconditionnel la perdrait donc à la première faute de frappe
/// corrigée.
/// </summary>
[Fact]
public async Task Editer_une_envie_conserve_sa_couverture()
{
var envie = (await _souhaits.AjouterAsync(
new EnregistrementSouhait
{
Titre = "Les fourmis",
CoverUrl = "https://exemple.test/fourmis.jpg",
},
Mathieu)).Souhait!;
await _souhaits.ModifierAsync(
envie.Id, new EnregistrementSouhait { Titre = "Les fourmis : roman" }, Mathieu);
Assert.Equal("https://exemple.test/fourmis.jpg", _db.LivresSouhaites.Single().CoverUrl);
}
[Fact]
public async Task Editer_lenvie_dun_autre_est_introuvable_et_ne_change_rien()
{
var sienne = (await AjouterAsync("Les fourmis", utilisateur: Camille)).Souhait!;
var resultat = await _souhaits.ModifierAsync(
sienne.Id, new EnregistrementSouhait { Titre = "Détourné" }, Mathieu);
// ⚠️ Introuvable, et non « refusé » : l'inexistence et l'appartenance à autrui doivent
// rester indiscernables de l'extérieur.
Assert.True(resultat.NExistePas);
Assert.Null(resultat.Erreur);
Assert.Equal("Les fourmis", _db.LivresSouhaites.Single().Titre);
}
[Fact]
public async Task Editer_une_envie_inexistante_est_introuvable()
{
var resultat = await _souhaits.ModifierAsync(
404, new EnregistrementSouhait { Titre = "Fantôme" }, Mathieu);
Assert.True(resultat.NExistePas);
}
/// <summary>
/// Le cas que l'ajout ne connaît pas : renommer une envie <b>en une autre déjà présente</b>.
/// L'unicité (Utilisateur, TitreNormalise, AuteurNormalise) l'interdit — il faut répondre
/// en français, pas laisser remonter « UNIQUE constraint failed ».
/// </summary>
[Fact]
public async Task Editer_vers_une_envie_deja_presente_est_refuse_lisiblement()
{
await AjouterAsync("Les fourmis");
var seconde = (await AjouterAsync("Les thanatonautes")).Souhait!;
var resultat = await _souhaits.ModifierAsync(
seconde.Id,
new EnregistrementSouhait { Titre = "Les fourmis : roman", Auteur = "Bernard Werber" },
Mathieu);
Assert.False(resultat.NExistePas);
Assert.NotNull(resultat.Erreur);
Assert.Contains("déjà dans votre liste", resultat.Erreur);
Assert.DoesNotContain("UNIQUE", resultat.Erreur, StringComparison.OrdinalIgnoreCase);
// Et rien n'a bougé en base : le refus est un refus, pas une écriture partielle.
Assert.Equal(2, _db.LivresSouhaites.Count());
}
/// <summary>
/// Deux personnes peuvent parfaitement viser la même œuvre : l'utilisateur ouvre l'index
/// unique, il n'est pas seulement un filtre de lecture.
/// </summary>
[Fact]
public async Task Editer_vers_lenvie_dun_autre_utilisateur_est_permis()
{
await AjouterAsync("Les fourmis", utilisateur: Camille);
var sienne = (await AjouterAsync("Les thanatonautes")).Souhait!;
var resultat = await _souhaits.ModifierAsync(
sienne.Id,
new EnregistrementSouhait { Titre = "Les fourmis", Auteur = "Bernard Werber" },
Mathieu);
Assert.Null(resultat.Erreur);
Assert.Equal(2, _db.LivresSouhaites.Count());
}
[Fact]
public async Task Editer_avec_un_isbn_invalide_est_refuse()
{
var envie = (await AjouterAsync("Les fourmis")).Souhait!;
var resultat = await _souhaits.ModifierAsync(
envie.Id,
new EnregistrementSouhait { Titre = "Les fourmis", Isbn = "123" },
Mathieu);
Assert.NotNull(resultat.Erreur);
Assert.Null(_db.LivresSouhaites.Single().Isbn);
}
public void Dispose()
{
_db.Dispose();
_connexion.Dispose();
GC.SuppressFinalize(this);
}
}