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>
228 lines
8.8 KiB
C#
228 lines
8.8 KiB
C#
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));
|
|
}
|
|
|
|
/// <summary>
|
|
/// Ces tests ne portent que sur la cascade ISBN : la recherche par auteur n'y intervient
|
|
/// pas, mais l'interface l'exige. Elle est couverte par BnfBibliographieParserTests.
|
|
/// </summary>
|
|
public Task<(ResultatBibliographie, string?)> RechercherParAuteurAsync(
|
|
string auteur, CancellationToken ct = default) =>
|
|
Task.FromResult((new ResultatBibliographie(), (string?)null));
|
|
|
|
/// <summary>
|
|
/// Ces tests ne portent que sur la cascade ISBN d'un livre : le préfixe 977 (périodique)
|
|
/// est couvert par ses propres tests. L'interface l'exige, d'où ce stub.
|
|
/// </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));
|
|
}
|
|
|
|
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));
|
|
}
|
|
|
|
/// <summary>
|
|
/// Ces tests ne portent que sur la cascade ISBN : la recherche par auteur n'y intervient
|
|
/// pas, mais l'interface l'exige. Elle est couverte par BnfBibliographieParserTests.
|
|
/// </summary>
|
|
public Task<(ResultatBibliographie, string?)> RechercherParAuteurAsync(
|
|
string auteur, CancellationToken ct = default) =>
|
|
Task.FromResult((new ResultatBibliographie(), (string?)null));
|
|
|
|
/// <summary>
|
|
/// Ces tests ne portent que sur la cascade ISBN d'un livre : le préfixe 977 (périodique)
|
|
/// est couvert par ses propres tests. L'interface l'exige, d'où ce stub.
|
|
/// </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));
|
|
}
|
|
}
|