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>
349 lines
12 KiB
C#
349 lines
12 KiB
C#
using MaBibli.Api.Data;
|
|
using MaBibli.Api.Services.Catalogue;
|
|
using MaBibli.Api.Services.Prets;
|
|
using MaBibli.Shared.Catalogue;
|
|
using MaBibli.Shared.Dtos;
|
|
using MaBibli.Shared.Entites;
|
|
using Microsoft.Data.Sqlite;
|
|
using Microsoft.EntityFrameworkCore;
|
|
|
|
namespace MaBibli.Tests;
|
|
|
|
/// <summary>
|
|
/// Prêts, sur une base SQLite <b>en mémoire</b> : même moteur qu'en production — ce qui compte
|
|
/// ici, l'index unique partiel « un seul prêt ouvert par livre » n'existant que côté base.
|
|
/// </summary>
|
|
public class ServicePretsTests : IDisposable
|
|
{
|
|
private readonly SqliteConnection _connexion;
|
|
private readonly MaBibliDbContext _db;
|
|
private readonly ServicePrets _prets;
|
|
private readonly ServiceCatalogue _catalogue;
|
|
|
|
public ServicePretsTests()
|
|
{
|
|
_connexion = new SqliteConnection("Data Source=:memory:");
|
|
_connexion.Open();
|
|
|
|
var options = new DbContextOptionsBuilder<MaBibliDbContext>()
|
|
.UseSqlite(_connexion)
|
|
.Options;
|
|
|
|
_db = new MaBibliDbContext(options);
|
|
_db.Database.EnsureCreated();
|
|
|
|
_prets = new ServicePrets(_db);
|
|
_catalogue = new ServiceCatalogue(_db, new ServiceAuteurs(_db));
|
|
}
|
|
|
|
private async Task<int> LivreAsync(string titre = "Germinal", Format format = Format.Physique)
|
|
{
|
|
var resultat = await _catalogue.CreerAsync(
|
|
new EnregistrementLivre { Titre = titre, Auteur = "Émile Zola", Format = format },
|
|
"mathieu");
|
|
|
|
return resultat.Livre!.Id;
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Preter_ouvre_un_pret_sans_date_de_retour()
|
|
{
|
|
var livreId = await LivreAsync();
|
|
|
|
var resultat = await _prets.PreterAsync(livreId, new EnregistrementPret { Emprunteur = "Paul" });
|
|
|
|
Assert.Null(resultat.Erreur);
|
|
var pret = resultat.Pret!;
|
|
Assert.Equal("Paul", pret.Emprunteur);
|
|
Assert.Null(pret.DateRetour);
|
|
Assert.True(pret.EstEnCours);
|
|
|
|
// Le livre est recopié à plat pour que la vue « prêts en cours » n'ait qu'un appel à faire.
|
|
Assert.Equal("Germinal", pret.LivreTitre);
|
|
Assert.Equal("Émile Zola", pret.LivreAuteurs);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Preter_ecarte_les_espaces_autour_de_lemprunteur()
|
|
{
|
|
var livreId = await LivreAsync();
|
|
|
|
var resultat = await _prets.PreterAsync(livreId, new EnregistrementPret { Emprunteur = " Paul " });
|
|
|
|
Assert.Equal("Paul", resultat.Pret!.Emprunteur);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Preter_refuse_un_emprunteur_vide()
|
|
{
|
|
var livreId = await LivreAsync();
|
|
|
|
var resultat = await _prets.PreterAsync(livreId, new EnregistrementPret { Emprunteur = " " });
|
|
|
|
Assert.NotNull(resultat.Erreur);
|
|
Assert.Empty(await _db.Prets.ToListAsync());
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Preter_refuse_un_livre_inconnu()
|
|
{
|
|
var resultat = await _prets.PreterAsync(999, new EnregistrementPret { Emprunteur = "Paul" });
|
|
|
|
Assert.True(resultat.EstIntrouvable);
|
|
}
|
|
|
|
/// <summary>
|
|
/// CLAUDE.md : un ebook n'est qu'une fiche, il n'y a pas d'exemplaire à confier. La règle est
|
|
/// tenue par l'API, pas seulement par l'interface.
|
|
/// </summary>
|
|
[Fact]
|
|
public async Task Preter_refuse_un_livre_numerique()
|
|
{
|
|
var livreId = await LivreAsync("Effective Java", Format.Numerique);
|
|
|
|
var resultat = await _prets.PreterAsync(livreId, new EnregistrementPret { Emprunteur = "Paul" });
|
|
|
|
Assert.NotNull(resultat.Erreur);
|
|
Assert.Contains("numérique", resultat.Erreur);
|
|
Assert.Empty(await _db.Prets.ToListAsync());
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Preter_refuse_un_livre_deja_sorti_et_nomme_qui_la()
|
|
{
|
|
var livreId = await LivreAsync();
|
|
await _prets.PreterAsync(livreId, new EnregistrementPret { Emprunteur = "Paul" });
|
|
|
|
var resultat = await _prets.PreterAsync(livreId, new EnregistrementPret { Emprunteur = "Marie" });
|
|
|
|
Assert.NotNull(resultat.Erreur);
|
|
Assert.Contains("Paul", resultat.Erreur);
|
|
Assert.Single(await _db.Prets.ToListAsync());
|
|
}
|
|
|
|
/// <summary>
|
|
/// Le service vérifie déjà le doublon, mais entre sa vérification et son insertion il y a une
|
|
/// fenêtre. Ce test contourne volontairement le service pour prouver que la base refuse aussi.
|
|
/// </summary>
|
|
[Fact]
|
|
public async Task La_base_refuse_deux_prets_ouverts_sur_le_meme_livre()
|
|
{
|
|
var livreId = await LivreAsync();
|
|
await _prets.PreterAsync(livreId, new EnregistrementPret { Emprunteur = "Paul" });
|
|
|
|
_db.Prets.Add(new Pret
|
|
{
|
|
LivreId = livreId,
|
|
Emprunteur = "Marie",
|
|
DatePret = DateTime.UtcNow,
|
|
DateRetour = null,
|
|
});
|
|
|
|
await Assert.ThrowsAsync<DbUpdateException>(() => _db.SaveChangesAsync());
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Preter_accepte_une_date_passee()
|
|
{
|
|
var livreId = await LivreAsync();
|
|
var noel = new DateTime(2025, 12, 25, 0, 0, 0, DateTimeKind.Utc);
|
|
|
|
var resultat = await _prets.PreterAsync(
|
|
livreId, new EnregistrementPret { Emprunteur = "Paul", DatePret = noel });
|
|
|
|
Assert.Null(resultat.Erreur);
|
|
Assert.Equal(noel, resultat.Pret!.DatePret);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Preter_refuse_une_date_future()
|
|
{
|
|
var livreId = await LivreAsync();
|
|
|
|
var resultat = await _prets.PreterAsync(
|
|
livreId,
|
|
new EnregistrementPret { Emprunteur = "Paul", DatePret = DateTime.UtcNow.AddDays(30) });
|
|
|
|
Assert.NotNull(resultat.Erreur);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Rendre_pose_la_date_sans_supprimer_le_pret()
|
|
{
|
|
var livreId = await LivreAsync();
|
|
var pret = (await _prets.PreterAsync(livreId, new EnregistrementPret { Emprunteur = "Paul" })).Pret!;
|
|
|
|
var resultat = await _prets.RendreAsync(pret.Id, new RetourPret());
|
|
|
|
Assert.Null(resultat.Erreur);
|
|
Assert.NotNull(resultat.Pret!.DateRetour);
|
|
Assert.False(resultat.Pret.EstEnCours);
|
|
|
|
// La ligne est toujours là : c'est toute la raison d'être d'une table séparée.
|
|
Assert.Single(await _db.Prets.ToListAsync());
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Rendre_refuse_un_pret_deja_clos()
|
|
{
|
|
var livreId = await LivreAsync();
|
|
var pret = (await _prets.PreterAsync(livreId, new EnregistrementPret { Emprunteur = "Paul" })).Pret!;
|
|
await _prets.RendreAsync(pret.Id, new RetourPret());
|
|
|
|
var resultat = await _prets.RendreAsync(pret.Id, new RetourPret());
|
|
|
|
Assert.NotNull(resultat.Erreur);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Rendre_refuse_un_pret_inconnu()
|
|
{
|
|
Assert.True((await _prets.RendreAsync(999, new RetourPret())).EstIntrouvable);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Rendre_refuse_une_date_anterieure_au_pret()
|
|
{
|
|
var livreId = await LivreAsync();
|
|
var pret = (await _prets.PreterAsync(
|
|
livreId,
|
|
new EnregistrementPret { Emprunteur = "Paul", DatePret = DateTime.UtcNow.AddDays(-3) })).Pret!;
|
|
|
|
var resultat = await _prets.RendreAsync(
|
|
pret.Id, new RetourPret { DateRetour = DateTime.UtcNow.AddDays(-10) });
|
|
|
|
Assert.NotNull(resultat.Erreur);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Un_livre_rendu_peut_etre_prete_a_quelquun_dautre()
|
|
{
|
|
var livreId = await LivreAsync();
|
|
var premier = (await _prets.PreterAsync(livreId, new EnregistrementPret { Emprunteur = "Paul" })).Pret!;
|
|
await _prets.RendreAsync(premier.Id, new RetourPret());
|
|
|
|
var second = await _prets.PreterAsync(livreId, new EnregistrementPret { Emprunteur = "Marie" });
|
|
|
|
Assert.Null(second.Erreur);
|
|
|
|
var historique = await _prets.HistoriqueAsync(livreId);
|
|
Assert.Equal(2, historique!.Count);
|
|
|
|
// Le prêt clos n'a pas bougé : l'historique conserve bien qui avait le livre avant.
|
|
Assert.Contains(historique, p => p.Emprunteur == "Paul" && !p.EstEnCours);
|
|
Assert.Contains(historique, p => p.Emprunteur == "Marie" && p.EstEnCours);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Historique_va_du_plus_recent_au_plus_ancien()
|
|
{
|
|
var livreId = await LivreAsync();
|
|
|
|
foreach (var (nom, jours) in new[] { ("Paul", -90), ("Marie", -30) })
|
|
{
|
|
var pret = (await _prets.PreterAsync(
|
|
livreId,
|
|
new EnregistrementPret { Emprunteur = nom, DatePret = DateTime.UtcNow.AddDays(jours) })).Pret!;
|
|
|
|
await _prets.RendreAsync(pret.Id, new RetourPret { DateRetour = DateTime.UtcNow.AddDays(jours + 10) });
|
|
}
|
|
|
|
var historique = await _prets.HistoriqueAsync(livreId);
|
|
|
|
Assert.Equal(["Marie", "Paul"], historique!.Select(p => p.Emprunteur));
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Historique_distingue_livre_inconnu_et_livre_jamais_prete()
|
|
{
|
|
var livreId = await LivreAsync();
|
|
|
|
Assert.Null(await _prets.HistoriqueAsync(999));
|
|
Assert.Empty((await _prets.HistoriqueAsync(livreId))!);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task EnCours_ne_liste_que_les_livres_dehors_du_plus_ancien_au_plus_recent()
|
|
{
|
|
var germinal = await LivreAsync("Germinal");
|
|
var horla = await LivreAsync("Le Horla");
|
|
var petitPrince = await LivreAsync("Le Petit Prince");
|
|
|
|
// Rendu : ne doit plus apparaître.
|
|
var rendu = (await _prets.PreterAsync(germinal, new EnregistrementPret { Emprunteur = "Paul" })).Pret!;
|
|
await _prets.RendreAsync(rendu.Id, new RetourPret());
|
|
|
|
await _prets.PreterAsync(
|
|
horla, new EnregistrementPret { Emprunteur = "Marie", DatePret = DateTime.UtcNow.AddDays(-2) });
|
|
await _prets.PreterAsync(
|
|
petitPrince, new EnregistrementPret { Emprunteur = "Jean", DatePret = DateTime.UtcNow.AddDays(-200) });
|
|
|
|
var enCours = await _prets.EnCoursAsync();
|
|
|
|
// Le plus ancien d'abord : c'est le livre oublié qu'on cherche dans cette vue.
|
|
Assert.Equal(["Jean", "Marie"], enCours.Select(p => p.Emprunteur));
|
|
Assert.DoesNotContain(enCours, p => p.LivreId == germinal);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Le catalogue doit signaler qu'un livre est dehors — et cette information est <b>commune</b>
|
|
/// au foyer, contrairement au statut de lecture.
|
|
/// </summary>
|
|
[Fact]
|
|
public async Task Le_catalogue_signale_un_livre_prete_a_tout_le_monde()
|
|
{
|
|
var livreId = await LivreAsync();
|
|
await _prets.PreterAsync(livreId, new EnregistrementPret { Emprunteur = "Paul" });
|
|
|
|
foreach (var utilisateur in new[] { "mathieu", "quelquun-dautre" })
|
|
{
|
|
var livre = await _catalogue.ObtenirAsync(livreId, utilisateur);
|
|
Assert.Equal("Paul", livre!.PreteA);
|
|
Assert.True(livre.EstPrete);
|
|
Assert.NotNull(livre.PreteDepuis);
|
|
|
|
var liste = await _catalogue.ListerAsync(new CritereLivres(), utilisateur);
|
|
Assert.Equal("Paul", liste.Single().PreteA);
|
|
}
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Le_catalogue_ne_signale_plus_rien_une_fois_le_livre_rendu()
|
|
{
|
|
var livreId = await LivreAsync();
|
|
var pret = (await _prets.PreterAsync(livreId, new EnregistrementPret { Emprunteur = "Paul" })).Pret!;
|
|
await _prets.RendreAsync(pret.Id, new RetourPret());
|
|
|
|
var livre = await _catalogue.ObtenirAsync(livreId, "mathieu");
|
|
|
|
Assert.Null(livre!.PreteA);
|
|
Assert.False(livre.EstPrete);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Choix assumé : supprimer un livre emporte ses prêts. Un historique orphelin — « quelqu'un
|
|
/// a emprunté quelque chose » — ne se lit plus. La cascade était déjà déclarée sur la relation,
|
|
/// ce test la fige pour qu'un changement de comportement ne passe pas inaperçu.
|
|
/// </summary>
|
|
[Fact]
|
|
public async Task Supprimer_un_livre_emporte_son_historique_de_prets()
|
|
{
|
|
var livreId = await LivreAsync();
|
|
var pret = (await _prets.PreterAsync(livreId, new EnregistrementPret { Emprunteur = "Paul" })).Pret!;
|
|
await _prets.RendreAsync(pret.Id, new RetourPret());
|
|
await _prets.PreterAsync(livreId, new EnregistrementPret { Emprunteur = "Marie" });
|
|
|
|
Assert.True(await _catalogue.SupprimerAsync(livreId));
|
|
|
|
Assert.Empty(await _db.Prets.ToListAsync());
|
|
Assert.Empty(await _prets.EnCoursAsync());
|
|
}
|
|
|
|
public void Dispose()
|
|
{
|
|
_db.Dispose();
|
|
_connexion.Dispose();
|
|
GC.SuppressFinalize(this);
|
|
}
|
|
}
|