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>
802 lines
31 KiB
C#
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);
|
|
}
|
|
}
|