Phase 2 : service de lookup ISBN en cascade BnF puis OpenLibrary
Recuperation des metadonnees d'un livre a partir de son ISBN, cote serveur uniquement (aucune interface, aucune persistance). Cascade : BnF en ISBN-13, puis BnF en ISBN-10 converti, puis OpenLibrary. La conversion 13 -> 10 est indispensable et non optionnelle : la BnF indexe l'ISBN tel qu'imprime, les ouvrages d'avant 2007 ne portent qu'un ISBN-10 et sont introuvables par l'EAN-13 que lit le scanner. Toutes les notices trouvees sont remontees (jusqu'a 5) : un meme ISBN peut correspondre a plusieurs reeditions, le choix revient a l'utilisateur. Le double appel /isbn puis /authors d'OpenLibrary est bien fait, avec repli sur l'oeuvre quand l'edition ne porte aucun auteur : c'est le bug de BookLogr (titre rempli, auteur vide) qu'il ne faut pas reproduire. La couverture vient toujours d'OpenLibrary, avec ?default=false pour obtenir un 404 plutot qu'une image placeholder. 87 tests xUnit, sur fixtures enregistrees : aucune dependance au reseau. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,185 @@
|
||||
using MaBibli.Api.Services.Isbn;
|
||||
using MaBibli.Shared.Dtos;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
|
||||
namespace MaBibli.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Enchaînement de la cascade BnF (ISBN-13) → BnF (ISBN-10) → OpenLibrary,
|
||||
/// avec des sources simulées : aucun accès réseau.
|
||||
/// </summary>
|
||||
public class IsbnLookupServiceTests
|
||||
{
|
||||
private sealed class BnfFake : IBnfClient
|
||||
{
|
||||
public required Func<string, IReadOnlyList<CandidatLivre>> Reponse { get; init; }
|
||||
|
||||
public List<string> IsbnInterroges { get; } = [];
|
||||
|
||||
public string? Avertissement { get; init; }
|
||||
|
||||
public Task<(IReadOnlyList<CandidatLivre>, string?)> RechercherAsync(
|
||||
string isbn, string? urlCouverture, CancellationToken ct = default)
|
||||
{
|
||||
IsbnInterroges.Add(isbn);
|
||||
return Task.FromResult((Reponse(isbn), Avertissement));
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class OpenLibraryFake : IOpenLibraryClient
|
||||
{
|
||||
public required Func<string, IReadOnlyList<CandidatLivre>> Reponse { get; init; }
|
||||
|
||||
public List<string> IsbnInterroges { get; } = [];
|
||||
|
||||
public Task<(IReadOnlyList<CandidatLivre>, string?)> RechercherAsync(
|
||||
string isbn, string? urlCouverture, CancellationToken ct = default)
|
||||
{
|
||||
IsbnInterroges.Add(isbn);
|
||||
return Task.FromResult((Reponse(isbn), (string?)null));
|
||||
}
|
||||
}
|
||||
|
||||
private static CandidatLivre Notice(SourceMetadonnees source, string isbn) =>
|
||||
new() { Titre = "Un titre", Source = source, IsbnInterroge = isbn };
|
||||
|
||||
private static IsbnLookupService Service(IBnfClient bnf, IOpenLibraryClient ol) =>
|
||||
new(bnf, ol, NullLogger<IsbnLookupService>.Instance);
|
||||
|
||||
[Fact]
|
||||
public async Task Sarrete_a_la_BnF_quand_lISBN13_suffit()
|
||||
{
|
||||
var bnf = new BnfFake { Reponse = isbn => [Notice(SourceMetadonnees.Bnf, isbn)] };
|
||||
var ol = new OpenLibraryFake { Reponse = _ => [] };
|
||||
|
||||
var r = await Service(bnf, ol).RechercherAsync("978-2-07-061275-8");
|
||||
|
||||
Assert.NotNull(r);
|
||||
Assert.Equal("9782070612758", r.IsbnDemande);
|
||||
Assert.Equal("2070612759", r.Isbn10);
|
||||
Assert.Equal(["9782070612758"], bnf.IsbnInterroges);
|
||||
Assert.Empty(ol.IsbnInterroges);
|
||||
Assert.Single(r.Candidats);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Reessaie_la_BnF_avec_lISBN10_converti()
|
||||
{
|
||||
// Le cas Germinal : rien en ISBN-13, tout en ISBN-10.
|
||||
var bnf = new BnfFake
|
||||
{
|
||||
Reponse = isbn => isbn == "2253004227"
|
||||
? [Notice(SourceMetadonnees.Bnf, isbn), Notice(SourceMetadonnees.Bnf, isbn)]
|
||||
: [],
|
||||
};
|
||||
var ol = new OpenLibraryFake { Reponse = _ => [] };
|
||||
|
||||
var r = await Service(bnf, ol).RechercherAsync("9782253004226");
|
||||
|
||||
Assert.Equal(["9782253004226", "2253004227"], bnf.IsbnInterroges);
|
||||
Assert.Empty(ol.IsbnInterroges);
|
||||
Assert.Equal(2, r!.Candidats.Count);
|
||||
Assert.All(r.Candidats, c => Assert.Equal("2253004227", c.IsbnInterroge));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Bascule_sur_OpenLibrary_quand_la_BnF_ignore_les_deux_formes()
|
||||
{
|
||||
var bnf = new BnfFake { Reponse = _ => [] };
|
||||
var ol = new OpenLibraryFake
|
||||
{
|
||||
Reponse = isbn => [Notice(SourceMetadonnees.OpenLibrary, isbn)],
|
||||
};
|
||||
|
||||
var r = await Service(bnf, ol).RechercherAsync("9780262033848");
|
||||
|
||||
Assert.Equal(["9780262033848", "0262033844"], bnf.IsbnInterroges);
|
||||
// OpenLibrary est interrogée avec l'ISBN-13, la forme qu'elle indexe.
|
||||
Assert.Equal(["9780262033848"], ol.IsbnInterroges);
|
||||
Assert.Equal(SourceMetadonnees.OpenLibrary, Assert.Single(r!.Candidats).Source);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Une_BnF_en_panne_ne_fait_pas_echouer_la_cascade()
|
||||
{
|
||||
var bnf = new BnfFake { Reponse = _ => [], Avertissement = "BnF injoignable" };
|
||||
var ol = new OpenLibraryFake { Reponse = isbn => [Notice(SourceMetadonnees.OpenLibrary, isbn)] };
|
||||
|
||||
var r = await Service(bnf, ol).RechercherAsync("9782253004226");
|
||||
|
||||
Assert.Single(r!.Candidats);
|
||||
Assert.Equal(2, r.Avertissements.Count); // une par tentative BnF
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Aucune_source_ne_trouve_rien()
|
||||
{
|
||||
var bnf = new BnfFake { Reponse = _ => [] };
|
||||
var ol = new OpenLibraryFake { Reponse = _ => [] };
|
||||
|
||||
var r = await Service(bnf, ol).RechercherAsync("9782253004226");
|
||||
|
||||
Assert.NotNull(r);
|
||||
Assert.Empty(r.Candidats);
|
||||
Assert.Empty(r.Avertissements);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ISBN_en_979_ninterroge_la_BnF_quune_seule_fois()
|
||||
{
|
||||
var bnf = new BnfFake { Reponse = _ => [] };
|
||||
var ol = new OpenLibraryFake { Reponse = _ => [] };
|
||||
|
||||
var r = await Service(bnf, ol).RechercherAsync("9791023507027");
|
||||
|
||||
Assert.Null(r!.Isbn10);
|
||||
Assert.Equal(["9791023507027"], bnf.IsbnInterroges);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("")]
|
||||
[InlineData(" ")]
|
||||
[InlineData("pas-un-isbn")]
|
||||
[InlineData("9782253004225")] // clé fausse
|
||||
[InlineData("123")]
|
||||
public async Task ISBN_invalide_renvoie_null(string entree)
|
||||
{
|
||||
var bnf = new BnfFake { Reponse = _ => [] };
|
||||
var ol = new OpenLibraryFake { Reponse = _ => [] };
|
||||
|
||||
Assert.Null(await Service(bnf, ol).RechercherAsync(entree));
|
||||
Assert.Empty(bnf.IsbnInterroges);
|
||||
Assert.Empty(ol.IsbnInterroges);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task La_couverture_OpenLibrary_est_transmise_meme_pour_une_notice_BnF()
|
||||
{
|
||||
string? couvertureVue = null;
|
||||
var bnf = new BnfFake { Reponse = _ => [] };
|
||||
var ol = new OpenLibraryFake { Reponse = _ => [] };
|
||||
|
||||
// On capture l'URL passée à la BnF via un client dédié.
|
||||
var bnfCapture = new CaptureCouverture();
|
||||
var r = await Service(bnfCapture, ol).RechercherAsync("9782253004226");
|
||||
couvertureVue = bnfCapture.DerniereCouverture;
|
||||
|
||||
Assert.NotNull(r);
|
||||
Assert.Equal(
|
||||
"https://covers.openlibrary.org/b/isbn/9782253004226-L.jpg?default=false",
|
||||
couvertureVue);
|
||||
Assert.Empty(bnf.IsbnInterroges);
|
||||
}
|
||||
|
||||
private sealed class CaptureCouverture : IBnfClient
|
||||
{
|
||||
public string? DerniereCouverture { get; private set; }
|
||||
|
||||
public Task<(IReadOnlyList<CandidatLivre>, string?)> RechercherAsync(
|
||||
string isbn, string? urlCouverture, CancellationToken ct = default)
|
||||
{
|
||||
DerniereCouverture = urlCouverture;
|
||||
return Task.FromResult(((IReadOnlyList<CandidatLivre>)[], (string?)null));
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user