diff --git a/CLAUDE.md b/CLAUDE.md index 899773b..25c3553 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -835,6 +835,65 @@ livre sorti depuis six mois qu'on avait oublié, pas celui prêté hier. (`ServiceLivresApi.EnUtc`). Sans cela le prêt se décalerait d'un jour pour la moitié du globe. Vérifié en exécution : saisie du 12/08 → `2026-08-11 22:00` en base (CEST). +## Doublons du catalogue — avertir, jamais refuser (2026-08-19) + +Défaut remonté en usage : rien n'empêchait de rescanner un livre déjà catalogué, et +`ServiceCatalogue.CreerAsync` ne faisait aucun contrôle. + +**Décidé : un avertissement confirmable**, pas un index unique. `HasIndex(l => l.Isbn)` reste +délibérément **non unique**, et il ne faut pas le rendre unique « pour bien faire » : + +- **posséder deux exemplaires est légitime** — on garde le sien et on prête l'autre. C'est + précisément le cas qu'un index unique rendrait impossible, sans recours ; +- **l'ISBN est facultatif** (livres anciens, tirages sans ISBN) : il ne peut pas porter à lui + seul l'unicité, et SQLite tient deux `NULL` pour distincts de toute façon. + +⚠️ Le contraste avec la liste d'envies est voulu : `LivreSouhaite` **refuse** le doublon par +index unique. Une envie décrit une œuvre, dont on ne souhaite pas deux exemplaires ; un livre +décrit un **objet**, qu'on peut posséder en double. Ne pas uniformiser les deux. + +### Deux critères, dont aucun n'est une clé + +| Critère | Attrape | Rate, ou signale à tort | +|---|---|---| +| **ISBN identique** | le rescan du même livre, cas visé | rien quand l'ISBN manque | +| **`CleOeuvre` + un auteur commun** | la saisie manuelle sans ISBN, le sous-titre, l'ordre `Nom, Prénom` | le poche à côté du grand format — deux exemplaires bien réels | + +L'auteur est **exigé en plus du titre** (au sens de `RapprochementAuteurs`) : sans lui, deux +recueils « Nouvelles » sans rapport se signaleraient l'un l'autre. Deux fiches **sans aucun** +auteur concordent, le titre étant alors tout ce dont on dispose. + +⚠️ **La clé d'œuvre est un préfixe de `TitreNormalise`** — la troncature du sous-titre précède +la normalisation, qui préserve l'ordre des caractères. C'est ce qui permet de dégrossir en SQL +sur la colonne indexée (`== cle` ou `StartsWith(cle + " ")`) puis de vérifier l'égalité exacte +des clés en mémoire. Sans cette seconde passe, « Germinal les années noires » serait signalé +comme doublon de « Germinal » ; sans la première, il faudrait charger tout le catalogue à +chaque ajout. + +### 409, et surtout pas 400 + +`POST /api/livres` répond **409 Conflict** avec `DoublonsLivre` (message + les fiches en cause), +et `?confirmerDoublon=true` enregistre la même saisie sans plus rien demander. + +⚠️ **Un doublon n'est pas une erreur**, et le code de statut n'est pas un détail : la saisie est +valide, c'est le catalogue qui contient déjà quelque chose. D'où un **troisième état** dans +`ResultatEcriture` et dans `ResultatCreation` côté client, au lieu d'un message logé dans +`Erreur` — un texte rouge dirait « c'est raté » là où l'écran doit proposer « ajouter quand +même ». Le 409 traverse donc `EnvoyerAsync` sans passer par le traitement d'erreur générique, +qui l'aurait aplati en « L'enregistrement a échoué (409) ». + +### L'écran montre les fiches, il ne les décrit pas + +`AvertissementDoublon` remplace le formulaire tant que la question n'est pas tranchée (la saisie +attend dans `_saisie` et revient intacte si l'on renonce), et affiche chaque livre en cause +**en lien vers sa fiche** : la vraie question est « est-ce bien le même livre ? », et on n'y +répond qu'en regardant celui qui est déjà là. Le prêt en cours y figure — un exemplaire dehors +est justement une raison d'en vouloir un second. + +**Ce qui n'est délibérément pas fait** : le contrôle ne porte que sur la **création**. Une +édition qui rendrait deux fiches identiques est un geste délibéré sur une fiche existante, pas +un scan répété par mégarde. + ## Liste d'envies — implémentée le 2026-08-18 ### Une table à part, et non un statut de plus diff --git a/IDEES.md b/IDEES.md index 5b0060d..a79180e 100644 --- a/IDEES.md +++ b/IDEES.md @@ -80,28 +80,6 @@ rendu en canvas, pas sur une image de caméra. # Retours d'usage du 2026-08-19 (3ᵉ série) -## Bug — un livre déjà catalogué peut être réencodé - -Vérifié dans le code : `ServiceCatalogue.CreerAsync` ne fait **aucun** contrôle de doublon, et -`HasIndex(l => l.Isbn)` n'est **pas** unique. Scanner deux fois le même livre crée deux fiches. - -⚠️ **À trancher avant de coder : refuser, ou avertir ?** Refuser tout net serait un piège, car -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 être le seul critère. - -Trois critères candidats, de plus en plus large : - -| Critère | Attrape | Rate / gêne | -|---|---|---| -| **ISBN identique** | le rescan du même livre, cas visé | rien si l'ISBN manque ; refuse un second exemplaire | -| **Clé d'œuvre + auteur** (`CleOeuvre`, comme la liste d'envies) | la saisie manuelle sans ISBN | confond le poche et le grand format, qui sont deux éditions possédées | -| **les deux, en avertissement** | tout ce qui précède | demande une confirmation de plus | - -Piste privilégiée : **avertir et laisser confirmer** (« Vous avez déjà *Germinal* — ajouter -quand même ? »), plutôt que l'index unique qui interdirait le second exemplaire. La liste -d'envies, elle, refuse — mais elle décrit une envie, pas un objet, et deux exemplaires d'une -envie n'ont pas de sens. - ## À l'ajout d'un livre, signaler l'envie correspondante **Décidé le 2026-08-19 : on ne supprime rien, on signale.** La liste d'envies marque l'entrée diff --git a/MaBibli.Api/Endpoints/LivresEndpoints.cs b/MaBibli.Api/Endpoints/LivresEndpoints.cs index b418683..4dc5d95 100644 --- a/MaBibli.Api/Endpoints/LivresEndpoints.cs +++ b/MaBibli.Api/Endpoints/LivresEndpoints.cs @@ -69,21 +69,36 @@ public static class LivresEndpoints groupe.MapPost("/", async ( EnregistrementLivre saisie, + bool? confirmerDoublon, IServiceCatalogue service, IFournisseurUtilisateur utilisateurs, CancellationToken ct) => { // AjoutePar vient du serveur, pas de la charge utile. var utilisateur = utilisateurs.Obtenir(); - var resultat = await service.CreerAsync(saisie, utilisateur.Identifiant, ct); + var resultat = await service.CreerAsync( + saisie, utilisateur.Identifiant, confirmerDoublon ?? false, ct); - return resultat.Erreur is not null - ? Results.BadRequest(new { erreur = resultat.Erreur }) + if (resultat.Erreur is not null) + { + return Results.BadRequest(new { erreur = resultat.Erreur }); + } + + // 409 et non 400 : la saisie est valide, c'est le catalogue qui contient déjà + // quelque chose de semblable. Le même appel avec « confirmerDoublon » aboutira. + return resultat.Doublons is { } doublons + ? Results.Conflict(doublons) : Results.Created($"/api/livres/{resultat.Livre!.Id}", resultat.Livre); }) .WithName("CreerLivre") .WithSummary("Ajoute un livre. « AjoutePar » est déterminé par le serveur.") + .WithDescription( + "Répond 409 quand le catalogue contient déjà un livre de même ISBN, ou de même " + + "titre et même auteur, en donnant les fiches en cause. Ce n'est pas un refus : " + + "posséder deux exemplaires est légitime, et « confirmerDoublon=true » " + + "enregistre la même saisie sans plus rien demander.") .Produces(StatusCodes.Status201Created) + .Produces(StatusCodes.Status409Conflict) .Produces(StatusCodes.Status400BadRequest); groupe.MapPut("/{id:int}", async ( diff --git a/MaBibli.Api/Services/Catalogue/ServiceCatalogue.cs b/MaBibli.Api/Services/Catalogue/ServiceCatalogue.cs index ae2d970..6d541b9 100644 --- a/MaBibli.Api/Services/Catalogue/ServiceCatalogue.cs +++ b/MaBibli.Api/Services/Catalogue/ServiceCatalogue.cs @@ -3,21 +3,34 @@ using MaBibli.Shared.Catalogue; using MaBibli.Shared.Dtos; using MaBibli.Shared.Entites; using MaBibli.Shared.Isbn; +using MaBibli.Shared.Textes; using Microsoft.EntityFrameworkCore; namespace MaBibli.Api.Services.Catalogue; -/// Issue d'une écriture : soit le livre, soit un message d'erreur destiné à l'utilisateur. -public readonly record struct ResultatEcriture(LivreDto? Livre, string? Erreur) +/// +/// Issue d'une écriture : le livre, un message d'erreur, ou des doublons à faire confirmer. +/// +public readonly record struct ResultatEcriture(LivreDto? Livre, string? Erreur, DoublonsLivre? Doublons) { - public static ResultatEcriture Ok(LivreDto livre) => new(livre, null); + public static ResultatEcriture Ok(LivreDto livre) => new(livre, null, null); - public static ResultatEcriture Invalide(string message) => new(null, message); + public static ResultatEcriture Invalide(string message) => new(null, message, null); - /// Ni livre ni erreur : la ressource demandée n'existe pas. - public static readonly ResultatEcriture Introuvable = new(null, null); + /// + /// L'écriture n'a pas eu lieu : elle ressemble trop à ce qui est déjà là. + /// + /// + /// Ce n'est pas une erreur — la même saisie, confirmée, s'enregistrera telle quelle. + /// D'où un troisième état plutôt qu'un message dans Erreur : l'écran doit proposer + /// « ajouter quand même », ce qu'un texte rouge ne suggère pas. + /// + public static ResultatEcriture Doublon(DoublonsLivre doublons) => new(null, null, doublons); - public bool EstIntrouvable => Livre is null && Erreur is null; + /// Ni livre, ni erreur, ni doublon : la ressource demandée n'existe pas. + public static readonly ResultatEcriture Introuvable = new(null, null, null); + + public bool EstIntrouvable => Livre is null && Erreur is null && Doublons is null; } public interface IServiceCatalogue @@ -28,7 +41,10 @@ public interface IServiceCatalogue Task ObtenirAsync(int id, string? utilisateur, CancellationToken ct = default); Task CreerAsync( - EnregistrementLivre saisie, string? utilisateur, CancellationToken ct = default); + EnregistrementLivre saisie, + string? utilisateur, + bool confirmerDoublon = false, + CancellationToken ct = default); Task ModifierAsync( int id, EnregistrementLivre saisie, string? utilisateur, CancellationToken ct = default); @@ -89,13 +105,25 @@ public sealed class ServiceCatalogue(MaBibliDbContext db, IServiceAuteurs auteur } public async Task CreerAsync( - EnregistrementLivre saisie, string? utilisateur, CancellationToken ct = default) + EnregistrementLivre saisie, + string? utilisateur, + bool confirmerDoublon = false, + CancellationToken ct = default) { if (Valider(saisie, out var isbn, out var erreur) is false) { return ResultatEcriture.Invalide(erreur!); } + if (!confirmerDoublon) + { + var deja = await DoublonsAsync(isbn, saisie, utilisateur, ct); + if (deja is not null) + { + return ResultatEcriture.Doublon(deja); + } + } + var livre = new Livre { Isbn = isbn, @@ -184,6 +212,116 @@ public sealed class ServiceCatalogue(MaBibliDbContext db, IServiceAuteurs auteur return true; } + /// + /// Cherche ce que le catalogue contient déjà de semblable, ou null s'il n'y a rien. + /// + /// + /// Deux critères, et aucun n'est une clé. L'ISBN identique attrape le cas visé — le + /// même livre scanné deux fois — mais il manque à beaucoup de fiches, puisqu'il est + /// facultatif. La clé d'œuvre attrape la saisie manuelle sans ISBN, au prix de confondre le + /// poche et le grand format. C'est précisément parce qu'aucun des deux ne tranche que le + /// résultat est un avertissement et non un refus : un index unique interdirait le + /// second exemplaire, qui est un cas parfaitement normal. + /// + /// Le titre seul ne suffit pas : il faut aussi un auteur commun, au sens de + /// — sinon deux « Nouvelles » sans rapport se + /// signaleraient l'une l'autre. + /// + /// + private async Task DoublonsAsync( + string? isbn, EnregistrementLivre saisie, string? utilisateur, CancellationToken ct) + { + // Dictionnaire et non liste : un livre trouvé par son ISBN ET par son titre ne doit + // apparaître qu'une fois. + var candidats = new Dictionary(); + + if (isbn is not null) + { + foreach (var livre in await AvecAuteurs().Where(l => l.Isbn == isbn).ToListAsync(ct)) + { + candidats[livre.Id] = livre; + } + } + + var cle = CleOeuvre.Cle(saisie.Titre); + if (cle.Length > 0) + { + // La clé d'œuvre est un PRÉFIXE de TitreNormalise : la troncature du sous-titre + // précède la normalisation, qui préserve l'ordre des caractères. SQLite dégrossit + // donc avec la colonne indexée, et l'égalité des clés se vérifie ensuite en mémoire + // — sans quoi il faudrait charger tout le catalogue à chaque ajout. + var parTitre = await AvecAuteurs() + .Where(l => l.TitreNormalise == cle || l.TitreNormalise.StartsWith(cle + " ")) + .ToListAsync(ct); + + foreach (var livre in parTitre) + { + if (CleOeuvre.Cle(livre.Titre) == cle && MemeAuteur(livre, saisie.Auteurs)) + { + candidats[livre.Id] = livre; + } + } + } + + if (candidats.Count == 0) + { + return null; + } + + var trouves = candidats.Values.OrderBy(l => l.Id).ToList(); + var ids = trouves.Select(l => l.Id).ToList(); + var statuts = await StatutsAsync(ids, utilisateur, ct); + var prets = await PretsOuvertsAsync(ids, ct); + + return new DoublonsLivre + { + Message = Avertir(trouves), + Livres = trouves + .Select(l => Projeter(l, statuts.GetValueOrDefault(l.Id), prets.GetValueOrDefault(l.Id))) + .ToList(), + }; + } + + private IQueryable AvecAuteurs() => + db.Livres.AsNoTracking().Include(l => l.Auteurs).ThenInclude(la => la.Auteur); + + /// + /// Un des auteurs du livre catalogué est-il l'un de ceux qu'on est en train de saisir ? + /// + /// + /// Deux fiches sans aucun auteur comptent comme concordantes : le titre est alors la seule + /// information disponible, et refuser de le signaler reviendrait à ne rien dire du tout. + /// + private static bool MemeAuteur(Livre livre, IReadOnlyList saisis) + { + var connus = livre.Auteurs + .Select(la => la.Auteur?.Nom) + .Where(nom => !string.IsNullOrWhiteSpace(nom)) + .ToList(); + + if (connus.Count == 0 && saisis.Count == 0) + { + return true; + } + + return connus.Any(connu => saisis.Any(saisi => RapprochementAuteurs.SontLeMemeAuteur(connu, saisi))); + } + + /// Phrase d'avertissement, écrite pour être lue telle quelle par l'utilisateur. + /// + /// Elle nomme ce qu'on possède déjà et invite à passer outre : c'est un doute soumis à + /// l'utilisateur, pas un reproche. Les fiches elles-mêmes sont affichées à côté, donc le + /// texte n'a pas à les décrire. + /// + private static string Avertir(IReadOnlyList trouves) + { + var debut = trouves.Count == 1 + ? $"« {trouves[0].Titre} » est déjà au catalogue." + : $"{trouves.Count} livres du catalogue ressemblent à celui-ci."; + + return debut + " Ajoutez quand même s'il s'agit d'un autre exemplaire ou d'une autre édition."; + } + /// Statut de pour chacun des livres demandés. private async Task> StatutsAsync( IReadOnlyList livreIds, string? utilisateur, CancellationToken ct) diff --git a/MaBibli.Client/Composants/AvertissementDoublon.razor b/MaBibli.Client/Composants/AvertissementDoublon.razor new file mode 100644 index 0000000..9972764 --- /dev/null +++ b/MaBibli.Client/Composants/AvertissementDoublon.razor @@ -0,0 +1,74 @@ +@* + Le catalogue contient déjà quelque chose de semblable. + + ⚠️ C'est un AVERTISSEMENT, jamais un refus : posséder deux exemplaires est légitime — on + garde le sien et on prête l'autre — et le rapprochement se trompe dans les deux sens (il + confond le poche et le grand format, il rate une réédition retitrée). L'écran montre donc + les fiches en cause plutôt que de les décrire, et laisse la décision à l'utilisateur. + + Les fiches sont des LIENS : la question qui se pose vraiment est « est-ce bien le même + livre ? », et on n'y répond qu'en regardant celui qui est déjà là. +*@ + + +
    + @foreach (var livre in Doublons.Livres) + { +
  • + + +
    + @livre.Titre + + @if (livre.Auteurs.Count > 0) + { +

    @string.Join(", ", livre.Auteurs.Select(a => a.Nom))

    + } + +

    + @if (livre.Format == Format.Numerique) + { + @Libelles.Format(livre.Format) + } + + @* Un exemplaire dehors est justement une raison d'en vouloir un second. *@ + @if (livre.PreteA is { } emprunteur) + { + Prêté à @emprunteur + } + + @if (!string.IsNullOrWhiteSpace(livre.Editeur)) + { + @livre.Editeur + } + + @if (!string.IsNullOrWhiteSpace(livre.Isbn)) + { + @FormatageIsbn.Afficher(livre.Isbn) + } +

    +
    +
  • + } +
+ +
+ + +
+ +@code { + [Parameter, EditorRequired] public DoublonsLivre Doublons { get; set; } = default!; + + /// Ajout confirmé : second exemplaire, autre édition, ou rapprochement à côté. + [Parameter] public EventCallback OnConfirmer { get; set; } + + /// Retour en arrière : le livre était bien déjà là. + [Parameter] public EventCallback OnRenoncer { get; set; } + + [Parameter] public bool EnCours { get; set; } +} diff --git a/MaBibli.Client/Pages/AjoutIsbn.razor b/MaBibli.Client/Pages/AjoutIsbn.razor index c3bf0f9..2095131 100644 --- a/MaBibli.Client/Pages/AjoutIsbn.razor +++ b/MaBibli.Client/Pages/AjoutIsbn.razor @@ -145,17 +145,30 @@ @if (_etape == Etape.Formulaire) { -

- Vérifiez et complétez la fiche : tout reste modifiable. -

+ @* Le doublon prend toute la place tant qu'il n'est pas tranché : la fiche revient intacte + si l'on renonce. C'est ici que le cas se présente le plus — scanner deux fois le même + livre est précisément ce qui crée les fiches en double. *@ + @if (_doublons is { } doublons) + { + + } + else + { +

+ Vérifiez et complétez la fiche : tout reste modifiable. +

- + + } } @code { @@ -177,6 +190,7 @@ private IReadOnlyList _candidats = []; private IReadOnlyList _avertissements = []; private EnregistrementLivre _saisie = new(); + private DoublonsLivre? _doublons; protected override void OnInitialized() => Reseau.Change += SurChangementReseau; @@ -358,19 +372,35 @@ _erreur = null; _messagePeriodique = null; _erreurFormulaire = null; + _doublons = null; _candidats = []; RevenirALaSaisie(); } - private async Task EnregistrerAsync() + private Task EnregistrerAsync() => AjouterAsync(false); + + /// Ajout maintenu après avoir vu ce que le catalogue contenait déjà. + private Task ConfirmerAsync() => AjouterAsync(true); + + private async Task AjouterAsync(bool confirmerDoublon) { _enregistrement = true; _erreurFormulaire = null; - var resultat = await Api.CreerAsync(_saisie); + var resultat = await Api.CreerAsync(_saisie, confirmerDoublon); _enregistrement = false; + // Rien n'a été écrit : le catalogue contient déjà quelque chose de semblable, et c'est + // à l'utilisateur de dire s'il s'agit du même livre. Voir AvertissementDoublon. + if (resultat.Doublons is { } doublons) + { + _doublons = doublons; + return; + } + + _doublons = null; + if (!resultat.EstOk) { _erreurFormulaire = resultat.Erreur; diff --git a/MaBibli.Client/Pages/AjoutManuel.razor b/MaBibli.Client/Pages/AjoutManuel.razor index 4cf27ef..3721c6c 100644 --- a/MaBibli.Client/Pages/AjoutManuel.razor +++ b/MaBibli.Client/Pages/AjoutManuel.razor @@ -12,13 +12,28 @@ Pour les livres sans ISBN, ou dont aucune notice ne convient. L'ISBN reste facultatif.

- +@* + Le formulaire s'efface pendant qu'on tranche le doublon : la saisie n'est pas perdue pour + autant, elle attend dans « _saisie » et revient telle quelle si l'on renonce. Laisser les + deux à l'écran donnerait deux boutons d'ajout, dont un seul confirme. +*@ +@if (_doublons is { } doublons) +{ + +} +else +{ + +} @code { private readonly EnregistrementLivre _saisie = new(); @@ -31,16 +46,32 @@ private bool _enregistrement; private string? _erreur; + private DoublonsLivre? _doublons; - private async Task EnregistrerAsync() + private Task EnregistrerAsync() => AjouterAsync(false); + + /// Ajout maintenu après avoir vu ce que le catalogue contenait déjà. + private Task ConfirmerAsync() => AjouterAsync(true); + + private async Task AjouterAsync(bool confirmerDoublon) { _enregistrement = true; _erreur = null; - var resultat = await Api.CreerAsync(_saisie); + var resultat = await Api.CreerAsync(_saisie, confirmerDoublon); _enregistrement = false; + // Un doublon signalé n'est pas un échec : rien n'a été écrit, et la même saisie + // confirmée s'enregistrera telle quelle. + if (resultat.Doublons is { } doublons) + { + _doublons = doublons; + return; + } + + _doublons = null; + if (!resultat.EstOk) { _erreur = resultat.Erreur; diff --git a/MaBibli.Client/Services/ServiceLivresApi.cs b/MaBibli.Client/Services/ServiceLivresApi.cs index 32b917a..ac12094 100644 --- a/MaBibli.Client/Services/ServiceLivresApi.cs +++ b/MaBibli.Client/Services/ServiceLivresApi.cs @@ -127,8 +127,62 @@ public sealed class ServiceLivresApi(HttpClient http, CacheHorsLigne cache, Etat return instantane?.Donnees.FirstOrDefault(l => l.Id == id); } - public async Task> CreerAsync(EnregistrementLivre saisie, CancellationToken ct = default) => - await EcrireAsync(() => http.PostAsJsonAsync("api/livres", saisie, Json, ct), ct); + /// + /// Ajoute un livre, en rapportant le cas échéant ce que le catalogue a déjà de semblable. + /// + /// + /// Vrai quand l'utilisateur a vu l'avertissement et veut ajouter le livre malgré tout — + /// second exemplaire, autre édition, ou rapprochement à côté de la plaque. + /// + public async Task CreerAsync( + EnregistrementLivre saisie, bool confirmerDoublon = false, CancellationToken ct = default) + { + var url = "api/livres" + (confirmerDoublon ? "?confirmerDoublon=true" : string.Empty); + var (reponse, motif) = await EnvoyerAsync(() => http.PostAsJsonAsync(url, saisie, Json, ct)); + + if (reponse is null) + { + return new ResultatCreation(null, motif, null); + } + + // Le 409 n'est PAS une erreur : le serveur demande une confirmation, et l'écran doit + // proposer d'ajouter quand même. Le faire passer par le message d'erreur générique le + // transformerait en échec, avec le livre perdu au passage. + if (reponse.StatusCode == HttpStatusCode.Conflict) + { + var doublons = await LireDoublonsAsync(reponse, ct); + if (doublons is not null) + { + return new ResultatCreation(null, null, doublons); + } + } + + var resultat = await InterpreterAsync(reponse, ct); + + if (resultat.EstOk) + { + _ = RafraichirEnFondAsync(); + } + + return new ResultatCreation(resultat.Valeur, resultat.Erreur, null); + } + + /// Lit le corps d'un 409, ou null s'il est illisible. + private static async Task LireDoublonsAsync( + HttpResponseMessage reponse, CancellationToken ct) + { + try + { + var doublons = await reponse.Content.ReadFromJsonAsync(Json, ct); + return string.IsNullOrWhiteSpace(doublons?.Message) ? null : doublons; + } + catch (Exception) + { + // Corps inattendu : on retombe sur le traitement d'erreur ordinaire, qui dira au + // moins que l'enregistrement n'a pas eu lieu. + return null; + } + } public async Task> ModifierAsync(int id, EnregistrementLivre saisie, CancellationToken ct = default) => await EcrireAsync(() => http.PutAsJsonAsync($"api/livres/{id}", saisie, Json, ct), ct); @@ -399,31 +453,52 @@ public sealed class ServiceLivresApi(HttpClient http, CacheHorsLigne cache, Etat /// private async Task> EcrireAsync( Func> appel, CancellationToken ct) + { + var (reponse, motif) = await EnvoyerAsync(appel); + + if (reponse is null) + { + return new ResultatApi(default, motif); + } + + var resultat = await InterpreterAsync(reponse, ct); + + if (resultat.EstOk) + { + // La base a changé : l'instantané doit suivre, sinon une coupure juste après une + // modification montrerait un catalogue d'avant. + _ = RafraichirEnFondAsync(); + } + + return resultat; + } + + /// + /// Envoie une écriture : soit une réponse à interpréter, soit le motif de son impossibilité. + /// + /// + /// Séparé de parce que toutes les écritures ne s'interprètent + /// pas de la même façon : la création doit reconnaître le 409 des doublons, qui n'est pas un + /// échec. Le refus hors-ligne et le signalement de l'état du réseau, eux, sont communs. + /// + private async Task<(HttpResponseMessage? Reponse, string? Motif)> EnvoyerAsync( + Func> appel) { if (!reseau.EnLigne) { - return new ResultatApi(default, EtatReseau.MotifHorsLigne); + return (null, EtatReseau.MotifHorsLigne); } try { var reponse = await appel(); reseau.SignalerSuccesReseau(); - var resultat = await InterpreterAsync(reponse, ct); - - if (resultat.EstOk) - { - // La base a changé : l'instantané doit suivre, sinon une coupure juste après une - // modification montrerait un catalogue d'avant. - _ = RafraichirEnFondAsync(); - } - - return resultat; + return (reponse, null); } catch (HttpRequestException) { reseau.SignalerEchecReseau(); - return new ResultatApi(default, EtatReseau.MotifHorsLigne); + return (null, EtatReseau.MotifHorsLigne); } } @@ -665,3 +740,16 @@ public readonly record struct ResultatApi(T? Valeur, string? Erreur) { public bool EstOk => Erreur is null; } + +/// +/// Résultat d'un ajout au catalogue : le livre, une erreur, ou des doublons à confirmer. +/// +/// +/// Trois issues et non deux : un doublon signalé n'est pas une erreur. La même saisie, +/// confirmée, s'enregistrera telle quelle — l'écran doit donc offrir « Ajouter quand même » +/// plutôt qu'un message rouge qui laisserait croire à un refus. +/// +public readonly record struct ResultatCreation(LivreDto? Livre, string? Erreur, DoublonsLivre? Doublons) +{ + public bool EstOk => Livre is not null; +} diff --git a/MaBibli.Shared/Dtos/DoublonsLivre.cs b/MaBibli.Shared/Dtos/DoublonsLivre.cs new file mode 100644 index 0000000..994b3bd --- /dev/null +++ b/MaBibli.Shared/Dtos/DoublonsLivre.cs @@ -0,0 +1,24 @@ +namespace MaBibli.Shared.Dtos; + +/// +/// Livres déjà catalogués qui ressemblent à celui qu'on est en train d'ajouter. +/// +/// +/// C'est un avertissement, jamais un refus. Posséder deux exemplaires du même livre est +/// parfaitement 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. Un index unique en base interdirait le second exemplaire +/// au lieu de rendre service ; ici l'utilisateur voit ce qu'il a déjà, et tranche. +/// +/// Le rapprochement est faillible dans les deux sens : il rate une réédition retitrée +/// () et il signale le poche à côté du grand format, qui sont deux +/// exemplaires bien distincts. C'est exactement pourquoi la décision revient à l'utilisateur. +/// +/// +public record DoublonsLivre +{ + /// Phrase destinée à l'utilisateur, nommant ce qu'il possède déjà. + public string Message { get; set; } = string.Empty; + + /// Les fiches en cause, pour que l'écran les montre plutôt que de les décrire. + public List Livres { get; set; } = []; +} diff --git a/MaBibli.Tests/ServiceCatalogueTests.cs b/MaBibli.Tests/ServiceCatalogueTests.cs index 3dc800a..f196f36 100644 --- a/MaBibli.Tests/ServiceCatalogueTests.cs +++ b/MaBibli.Tests/ServiceCatalogueTests.cs @@ -348,6 +348,100 @@ public class ServiceCatalogueTests : IDisposable Assert.Equal(["Guy de Maupassant"], relu.Auteurs.Select(a => a.Nom)); } + // ───────────────────────────────────────────────────────────────────────── + // 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();