Signaler les doublons à l'ajout d'un livre, sans jamais les refuser

Rien n'empêchait de rescanner un livre déjà catalogué. Plutôt qu'un index
unique — qui interdirait le second exemplaire, cas parfaitement légitime —
la création répond 409 avec les fiches semblables, et « confirmerDoublon »
enregistre la même saisie.

Deux critères, dont aucun n'est une clé : ISBN identique, ou clé d'œuvre
et auteur communs. La clé d'œuvre étant un préfixe de TitreNormalise, SQL
dégrossit sur la colonne indexée et l'égalité exacte se vérifie ensuite en
mémoire.

Vérifié en exécution : avertissement, retour au formulaire intact, et ajout
confirmé créant bien un second exemplaire.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
mathieu
2026-08-19 21:11:39 +02:00
co-authored by Claude Opus 5
parent 278d6579f7
commit 383ed43227
10 changed files with 600 additions and 69 deletions
+59
View File
@@ -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
-22
View File
@@ -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
+18 -3
View File
@@ -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<LivreDto>(StatusCodes.Status201Created)
.Produces<DoublonsLivre>(StatusCodes.Status409Conflict)
.Produces(StatusCodes.Status400BadRequest);
groupe.MapPut("/{id:int}", async (
@@ -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;
/// <summary>Issue d'une écriture : soit le livre, soit un message d'erreur destiné à l'utilisateur.</summary>
public readonly record struct ResultatEcriture(LivreDto? Livre, string? Erreur)
/// <summary>
/// Issue d'une écriture : le livre, un message d'erreur, ou des doublons à faire confirmer.
/// </summary>
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);
/// <summary>Ni livre ni erreur : la ressource demandée n'existe pas.</summary>
public static readonly ResultatEcriture Introuvable = new(null, null);
/// <summary>
/// L'écriture n'a pas eu lieu : elle ressemble trop à ce qui est déjà là.
/// </summary>
/// <remarks>
/// Ce n'est <b>pas</b> une erreur — la même saisie, confirmée, s'enregistrera telle quelle.
/// D'où un troisième état plutôt qu'un message dans <c>Erreur</c> : l'écran doit proposer
/// « ajouter quand même », ce qu'un texte rouge ne suggère pas.
/// </remarks>
public static ResultatEcriture Doublon(DoublonsLivre doublons) => new(null, null, doublons);
public bool EstIntrouvable => Livre is null && Erreur is null;
/// <summary>Ni livre, ni erreur, ni doublon : la ressource demandée n'existe pas.</summary>
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<LivreDto?> ObtenirAsync(int id, string? utilisateur, CancellationToken ct = default);
Task<ResultatEcriture> CreerAsync(
EnregistrementLivre saisie, string? utilisateur, CancellationToken ct = default);
EnregistrementLivre saisie,
string? utilisateur,
bool confirmerDoublon = false,
CancellationToken ct = default);
Task<ResultatEcriture> 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<ResultatEcriture> 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;
}
/// <summary>
/// Cherche ce que le catalogue contient déjà de semblable, ou <c>null</c> s'il n'y a rien.
/// </summary>
/// <remarks>
/// <b>Deux critères, et aucun n'est une clé.</b> 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 <b>avertissement</b> et non un refus : un index unique interdirait le
/// second exemplaire, qui est un cas parfaitement normal.
/// <para>
/// Le titre seul ne suffit pas : il faut aussi un auteur commun, au sens de
/// <see cref="RapprochementAuteurs"/> — sinon deux « Nouvelles » sans rapport se
/// signaleraient l'une l'autre.
/// </para>
/// </remarks>
private async Task<DoublonsLivre?> 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<int, Livre>();
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<Livre> AvecAuteurs() =>
db.Livres.AsNoTracking().Include(l => l.Auteurs).ThenInclude(la => la.Auteur);
/// <summary>
/// Un des auteurs du livre catalogué est-il l'un de ceux qu'on est en train de saisir ?
/// </summary>
/// <remarks>
/// 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.
/// </remarks>
private static bool MemeAuteur(Livre livre, IReadOnlyList<string> 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)));
}
/// <summary>Phrase d'avertissement, écrite pour être lue telle quelle par l'utilisateur.</summary>
/// <remarks>
/// Elle nomme ce qu'on possède déjà et <b>invite à passer outre</b> : 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.
/// </remarks>
private static string Avertir(IReadOnlyList<Livre> 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.";
}
/// <summary>Statut de <paramref name="utilisateur"/> pour chacun des livres demandés.</summary>
private async Task<Dictionary<int, Statut?>> StatutsAsync(
IReadOnlyList<int> livreIds, string? utilisateur, CancellationToken ct)
@@ -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à.
*@
<div class="message-avertissement" role="alert">@Doublons.Message</div>
<ul class="liste-livres">
@foreach (var livre in Doublons.Livres)
{
<li class="carte-livre" @key="livre.Id">
<Couverture Url="@livre.CoverUrl" Titre="@livre.Titre" Classe="couverture-petite" />
<div class="carte-corps">
<a class="carte-titre" href="@($"livres/{livre.Id}")">@livre.Titre</a>
@if (livre.Auteurs.Count > 0)
{
<p class="carte-auteur">@string.Join(", ", livre.Auteurs.Select(a => a.Nom))</p>
}
<p class="carte-details">
@if (livre.Format == Format.Numerique)
{
<span class="etiquette">@Libelles.Format(livre.Format)</span>
}
@* Un exemplaire dehors est justement une raison d'en vouloir un second. *@
@if (livre.PreteA is { } emprunteur)
{
<span class="etiquette etiquette-prete">Prêté à @emprunteur</span>
}
@if (!string.IsNullOrWhiteSpace(livre.Editeur))
{
<span>@livre.Editeur</span>
}
@if (!string.IsNullOrWhiteSpace(livre.Isbn))
{
<span class="code-isbn">@FormatageIsbn.Afficher(livre.Isbn)</span>
}
</p>
</div>
</li>
}
</ul>
<div class="actions-formulaire">
<button type="button" class="bouton bouton-principal" disabled="@EnCours" @onclick="OnConfirmer">
@(EnCours ? "Ajout…" : "Ajouter quand même")
</button>
<button type="button" class="bouton bouton-discret" disabled="@EnCours" @onclick="OnRenoncer">
Ne pas ajouter
</button>
</div>
@code {
[Parameter, EditorRequired] public DoublonsLivre Doublons { get; set; } = default!;
/// <summary>Ajout confirmé : second exemplaire, autre édition, ou rapprochement à côté.</summary>
[Parameter] public EventCallback OnConfirmer { get; set; }
/// <summary>Retour en arrière : le livre était bien déjà là.</summary>
[Parameter] public EventCallback OnRenoncer { get; set; }
[Parameter] public bool EnCours { get; set; }
}
+42 -12
View File
@@ -145,17 +145,30 @@
@if (_etape == Etape.Formulaire)
{
<p class="message-discret">
Vérifiez et complétez la fiche : tout reste modifiable.
</p>
@* 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)
{
<AvertissementDoublon Doublons="doublons"
EnCours="_enregistrement"
OnConfirmer="ConfirmerAsync"
OnRenoncer="() => _doublons = null" />
}
else
{
<p class="message-discret">
Vérifiez et complétez la fiche : tout reste modifiable.
</p>
<FormulaireLivre Saisie="_saisie"
LibelleValidation="Ajouter au catalogue"
Erreur="@_erreurFormulaire"
EnCours="_enregistrement"
MessageBlocage="@MotifBlocage"
OnValider="EnregistrerAsync"
OnAnnuler="Recommencer" />
<FormulaireLivre Saisie="_saisie"
LibelleValidation="Ajouter au catalogue"
Erreur="@_erreurFormulaire"
EnCours="_enregistrement"
MessageBlocage="@MotifBlocage"
OnValider="EnregistrerAsync"
OnAnnuler="Recommencer" />
}
}
@code {
@@ -177,6 +190,7 @@
private IReadOnlyList<CandidatLivre> _candidats = [];
private IReadOnlyList<string> _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);
/// <summary>Ajout maintenu après avoir vu ce que le catalogue contenait déjà.</summary>
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;
+40 -9
View File
@@ -12,13 +12,28 @@
Pour les livres sans ISBN, ou dont aucune notice ne convient. L'ISBN reste facultatif.
</p>
<FormulaireLivre Saisie="_saisie"
LibelleValidation="Ajouter au catalogue"
Erreur="@_erreur"
EnCours="_enregistrement"
MessageBlocage="@(Reseau.EnLigne ? null : EtatReseau.MotifHorsLigne)"
OnValider="EnregistrerAsync"
OnAnnuler="Retour" />
@*
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)
{
<AvertissementDoublon Doublons="doublons"
EnCours="_enregistrement"
OnConfirmer="ConfirmerAsync"
OnRenoncer="() => _doublons = null" />
}
else
{
<FormulaireLivre Saisie="_saisie"
LibelleValidation="Ajouter au catalogue"
Erreur="@_erreur"
EnCours="_enregistrement"
MessageBlocage="@(Reseau.EnLigne ? null : EtatReseau.MotifHorsLigne)"
OnValider="EnregistrerAsync"
OnAnnuler="Retour" />
}
@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);
/// <summary>Ajout maintenu après avoir vu ce que le catalogue contenait déjà.</summary>
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;
+102 -14
View File
@@ -127,8 +127,62 @@ public sealed class ServiceLivresApi(HttpClient http, CacheHorsLigne cache, Etat
return instantane?.Donnees.FirstOrDefault(l => l.Id == id);
}
public async Task<ResultatApi<LivreDto>> CreerAsync(EnregistrementLivre saisie, CancellationToken ct = default) =>
await EcrireAsync<LivreDto>(() => http.PostAsJsonAsync("api/livres", saisie, Json, ct), ct);
/// <summary>
/// Ajoute un livre, en rapportant le cas échéant ce que le catalogue a déjà de semblable.
/// </summary>
/// <param name="confirmerDoublon">
/// 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.
/// </param>
public async Task<ResultatCreation> 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<LivreDto>(reponse, ct);
if (resultat.EstOk)
{
_ = RafraichirEnFondAsync();
}
return new ResultatCreation(resultat.Valeur, resultat.Erreur, null);
}
/// <summary>Lit le corps d'un 409, ou <c>null</c> s'il est illisible.</summary>
private static async Task<DoublonsLivre?> LireDoublonsAsync(
HttpResponseMessage reponse, CancellationToken ct)
{
try
{
var doublons = await reponse.Content.ReadFromJsonAsync<DoublonsLivre>(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<ResultatApi<LivreDto>> ModifierAsync(int id, EnregistrementLivre saisie, CancellationToken ct = default) =>
await EcrireAsync<LivreDto>(() => http.PutAsJsonAsync($"api/livres/{id}", saisie, Json, ct), ct);
@@ -399,31 +453,52 @@ public sealed class ServiceLivresApi(HttpClient http, CacheHorsLigne cache, Etat
/// </remarks>
private async Task<ResultatApi<T>> EcrireAsync<T>(
Func<Task<HttpResponseMessage>> appel, CancellationToken ct)
{
var (reponse, motif) = await EnvoyerAsync(appel);
if (reponse is null)
{
return new ResultatApi<T>(default, motif);
}
var resultat = await InterpreterAsync<T>(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;
}
/// <summary>
/// Envoie une écriture : soit une réponse à interpréter, soit le motif de son impossibilité.
/// </summary>
/// <remarks>
/// Séparé de <see cref="EcrireAsync{T}"/> 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.
/// </remarks>
private async Task<(HttpResponseMessage? Reponse, string? Motif)> EnvoyerAsync(
Func<Task<HttpResponseMessage>> appel)
{
if (!reseau.EnLigne)
{
return new ResultatApi<T>(default, EtatReseau.MotifHorsLigne);
return (null, EtatReseau.MotifHorsLigne);
}
try
{
var reponse = await appel();
reseau.SignalerSuccesReseau();
var resultat = await InterpreterAsync<T>(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<T>(default, EtatReseau.MotifHorsLigne);
return (null, EtatReseau.MotifHorsLigne);
}
}
@@ -665,3 +740,16 @@ public readonly record struct ResultatApi<T>(T? Valeur, string? Erreur)
{
public bool EstOk => Erreur is null;
}
/// <summary>
/// Résultat d'un ajout au catalogue : le livre, une erreur, ou des doublons à confirmer.
/// </summary>
/// <remarks>
/// Trois issues et non deux : un doublon signalé n'est <b>pas</b> 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.
/// </remarks>
public readonly record struct ResultatCreation(LivreDto? Livre, string? Erreur, DoublonsLivre? Doublons)
{
public bool EstOk => Livre is not null;
}
+24
View File
@@ -0,0 +1,24 @@
namespace MaBibli.Shared.Dtos;
/// <summary>
/// Livres déjà catalogués qui ressemblent à celui qu'on est en train d'ajouter.
/// </summary>
/// <remarks>
/// <b>C'est un avertissement, jamais un refus.</b> 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.
/// <para>
/// Le rapprochement est <b>faillible dans les deux sens</b> : il rate une réédition retitrée
/// (<see cref="Textes.CleOeuvre"/>) 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.
/// </para>
/// </remarks>
public record DoublonsLivre
{
/// <summary>Phrase destinée à l'utilisateur, nommant ce qu'il possède déjà.</summary>
public string Message { get; set; } = string.Empty;
/// <summary>Les fiches en cause, pour que l'écran les montre plutôt que de les décrire.</summary>
public List<LivreDto> Livres { get; set; } = [];
}
+94
View File
@@ -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();