From fd6438b912933970c927c8393be30d74682eec13 Mon Sep 17 00:00:00 2001 From: mathieu Date: Mon, 17 Aug 2026 22:55:07 +0200 Subject: [PATCH] Phase 3 : CRUD des livres, statuts de lecture et identite SSOwat API - CRUD complet sur /api/livres : liste filtrable (format, statut) et cherchable (titre, auteur), consultation, creation, modification, changement de statut isole, suppression. - Aucune lecture ne filtre sur AjoutePar : la bibliotheque est commune au foyer. Le critere n'existe meme pas dans CritereLivres, pour qu'on ne puisse pas s'en servir par inadvertance. Deux tests verrouillent l'invariant, dont un par reflexion sur le type des criteres. - AjoutePar est renseigne par le serveur a la creation, jamais par le client, et n'est pas reecrit a l'edition (pas plus que DateAjout). - Identite lue dans les en-tetes SSOwat (YNH_USER, YNH_USER_EMAIL, YNH_USER_FULLNAME), avec repli sur un utilisateur simule configure en developpement uniquement. Sans en-tete ni simulation, l'identite reste inconnue plutot qu'inventee. Expose par GET /api/moi. - Migration appliquee au demarrage : pas d'etape manuelle a l'installation. Interface Blazor, pensee mobile d'abord - Catalogue unique (physiques et numeriques melanges, distingues par le format) avec recherche, filtres et changement de statut directement dans la liste. - Ajout par ISBN : quand la cascade rend plusieurs notices, un ecran de choix les presente en mettant en avant editeur et annee, seuls elements qui les departagent. Le formulaire reste ensuite entierement modifiable. - Ajout manuel, edition, suppression avec confirmation explicite. - Couvertures : l'URL OpenLibrary n'est jamais validee par un appel prealable (502 intermittents mesures en phase 2) ; un attribut onerror masque l'image cassee et laisse apparaitre un substitut. Decisions prises la ou CLAUDE.md etait muet - Tri alphabetique par titre. - Un ISBN saisi doit etre valide, mais reste facultatif. - Ecran de choix affiche seulement a partir de deux candidats. Co-Authored-By: Claude Opus 5 --- MaBibli.Api/Endpoints/LivresEndpoints.cs | 135 +++++++ MaBibli.Api/Program.cs | 18 + .../Services/Catalogue/ServiceCatalogue.cs | 195 ++++++++++ .../Identite/FournisseurUtilisateur.cs | 97 +++++ MaBibli.Api/appsettings.Development.json | 9 + MaBibli.Client/Composants/Couverture.razor | 35 ++ .../Composants/FormulaireLivre.razor | 112 ++++++ MaBibli.Client/Composants/Libelles.cs | 31 ++ MaBibli.Client/Layout/MainLayout.razor | 36 +- MaBibli.Client/Layout/MainLayout.razor.css | 100 ++---- MaBibli.Client/Layout/NavMenu.razor | 29 -- MaBibli.Client/Layout/NavMenu.razor.css | 83 ----- MaBibli.Client/Pages/AjoutIsbn.razor | 214 +++++++++++ MaBibli.Client/Pages/AjoutManuel.razor | 44 +++ MaBibli.Client/Pages/Catalogue.razor | 201 +++++++++++ MaBibli.Client/Pages/EditionLivre.razor | 143 ++++++++ MaBibli.Client/Pages/Home.razor | 7 - MaBibli.Client/Program.cs | 1 + MaBibli.Client/Services/ServiceLivresApi.cs | 145 ++++++++ MaBibli.Client/_Imports.razor | 5 + MaBibli.Client/wwwroot/css/app.css | 338 +++++++++++++++++- MaBibli.Shared/Catalogue/CritereLivres.cs | 23 ++ MaBibli.Shared/Catalogue/FiltreLivres.cs | 55 +++ MaBibli.Shared/Dtos/EnregistrementLivre.cs | 34 ++ MaBibli.Shared/Dtos/LivreDto.cs | 34 ++ MaBibli.Shared/Dtos/UtilisateurCourant.cs | 30 ++ MaBibli.Tests/FiltreLivresTests.cs | 150 ++++++++ MaBibli.Tests/FournisseurUtilisateurTests.cs | 110 ++++++ MaBibli.Tests/ServiceCatalogueTests.cs | 219 ++++++++++++ 29 files changed, 2430 insertions(+), 203 deletions(-) create mode 100644 MaBibli.Api/Endpoints/LivresEndpoints.cs create mode 100644 MaBibli.Api/Services/Catalogue/ServiceCatalogue.cs create mode 100644 MaBibli.Api/Services/Identite/FournisseurUtilisateur.cs create mode 100644 MaBibli.Client/Composants/Couverture.razor create mode 100644 MaBibli.Client/Composants/FormulaireLivre.razor create mode 100644 MaBibli.Client/Composants/Libelles.cs delete mode 100644 MaBibli.Client/Layout/NavMenu.razor delete mode 100644 MaBibli.Client/Layout/NavMenu.razor.css create mode 100644 MaBibli.Client/Pages/AjoutIsbn.razor create mode 100644 MaBibli.Client/Pages/AjoutManuel.razor create mode 100644 MaBibli.Client/Pages/Catalogue.razor create mode 100644 MaBibli.Client/Pages/EditionLivre.razor delete mode 100644 MaBibli.Client/Pages/Home.razor create mode 100644 MaBibli.Client/Services/ServiceLivresApi.cs create mode 100644 MaBibli.Shared/Catalogue/CritereLivres.cs create mode 100644 MaBibli.Shared/Catalogue/FiltreLivres.cs create mode 100644 MaBibli.Shared/Dtos/EnregistrementLivre.cs create mode 100644 MaBibli.Shared/Dtos/LivreDto.cs create mode 100644 MaBibli.Shared/Dtos/UtilisateurCourant.cs create mode 100644 MaBibli.Tests/FiltreLivresTests.cs create mode 100644 MaBibli.Tests/FournisseurUtilisateurTests.cs create mode 100644 MaBibli.Tests/ServiceCatalogueTests.cs diff --git a/MaBibli.Api/Endpoints/LivresEndpoints.cs b/MaBibli.Api/Endpoints/LivresEndpoints.cs new file mode 100644 index 0000000..2a63bcb --- /dev/null +++ b/MaBibli.Api/Endpoints/LivresEndpoints.cs @@ -0,0 +1,135 @@ +using MaBibli.Api.Services.Catalogue; +using MaBibli.Api.Services.Identite; +using MaBibli.Shared.Catalogue; +using MaBibli.Shared.Dtos; +using MaBibli.Shared.Entites; + +namespace MaBibli.Api.Endpoints; + +public static class LivresEndpoints +{ + /// + /// CRUD du catalogue. + /// + /// + /// Aucune lecture ne filtre sur AjoutePar : la bibliothèque est commune. + /// Ce champ n'est même pas acceptable en paramètre de requête, précisément pour qu'on ne + /// puisse pas s'en servir comme d'un cloisonnement par inadvertance. + /// + public static IEndpointRouteBuilder MapLivresEndpoints(this IEndpointRouteBuilder routes) + { + var groupe = routes.MapGroup("/api/livres").WithTags("Livres"); + + groupe.MapGet("/", async ( + string? recherche, + Format? format, + Statut? statut, + IServiceCatalogue service, + CancellationToken ct) => + { + var criteres = new CritereLivres + { + Recherche = recherche, + Format = format, + Statut = statut, + }; + + return Results.Ok(await service.ListerAsync(criteres, ct)); + }) + .WithName("ListerLivres") + .WithSummary("Liste le catalogue, filtrable par format et statut, cherchable sur titre et auteur.") + .WithDescription( + "Catalogue unique : physiques et numériques cohabitent, distingués par leur format. " + + "La bibliothèque est commune — la liste n'est jamais restreinte à l'utilisateur courant.") + .Produces>(); + + groupe.MapGet("/{id:int}", async (int id, IServiceCatalogue service, CancellationToken ct) => + { + var livre = await service.ObtenirAsync(id, ct); + return livre is null ? Results.NotFound() : Results.Ok(livre); + }) + .WithName("ObtenirLivre") + .Produces() + .Produces(StatusCodes.Status404NotFound); + + groupe.MapPost("/", async ( + EnregistrementLivre saisie, + 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); + + return resultat.Erreur is not null + ? Results.BadRequest(new { erreur = resultat.Erreur }) + : Results.Created($"/api/livres/{resultat.Livre!.Id}", resultat.Livre); + }) + .WithName("CreerLivre") + .WithSummary("Ajoute un livre. « AjoutePar » est déterminé par le serveur.") + .Produces(StatusCodes.Status201Created) + .Produces(StatusCodes.Status400BadRequest); + + groupe.MapPut("/{id:int}", async ( + int id, + EnregistrementLivre saisie, + IServiceCatalogue service, + CancellationToken ct) => + { + var resultat = await service.ModifierAsync(id, saisie, ct); + + if (resultat.EstIntrouvable) + { + return Results.NotFound(); + } + + return resultat.Erreur is not null + ? Results.BadRequest(new { erreur = resultat.Erreur }) + : Results.Ok(resultat.Livre); + }) + .WithName("ModifierLivre") + .WithSummary("Modifie un livre. « AjoutePar » et « DateAjout » restent inchangés.") + .Produces() + .Produces(StatusCodes.Status400BadRequest) + .Produces(StatusCodes.Status404NotFound); + + groupe.MapPatch("/{id:int}/statut", async ( + int id, + ChangementStatut changement, + IServiceCatalogue service, + CancellationToken ct) => + { + var livre = await service.ChangerStatutAsync(id, changement.Statut, ct); + return livre is null ? Results.NotFound() : Results.Ok(livre); + }) + .WithName("ChangerStatutLivre") + .WithSummary("Change le seul statut de lecture — l'action la plus fréquente.") + .Produces() + .Produces(StatusCodes.Status404NotFound); + + groupe.MapDelete("/{id:int}", async (int id, IServiceCatalogue service, CancellationToken ct) => + { + var supprime = await service.SupprimerAsync(id, ct); + return supprime ? Results.NoContent() : Results.NotFound(); + }) + .WithName("SupprimerLivre") + .Produces(StatusCodes.Status204NoContent) + .Produces(StatusCodes.Status404NotFound); + + return routes; + } + + /// Expose l'identité de la personne connectée, pour affichage côté client. + public static IEndpointRouteBuilder MapIdentiteEndpoints(this IEndpointRouteBuilder routes) + { + routes.MapGet("/api/moi", (IFournisseurUtilisateur utilisateurs) => + Results.Ok(utilisateurs.Obtenir())) + .WithName("UtilisateurCourant") + .WithTags("Identité") + .WithSummary("Identité lue dans les en-têtes SSOwat, ou utilisateur simulé en développement.") + .Produces(); + + return routes; + } +} diff --git a/MaBibli.Api/Program.cs b/MaBibli.Api/Program.cs index d143c55..4280c74 100644 --- a/MaBibli.Api/Program.cs +++ b/MaBibli.Api/Program.cs @@ -1,5 +1,7 @@ using MaBibli.Api.Data; using MaBibli.Api.Endpoints; +using MaBibli.Api.Services.Catalogue; +using MaBibli.Api.Services.Identite; using MaBibli.Api.Services.Isbn; using Microsoft.EntityFrameworkCore; @@ -10,6 +12,14 @@ builder.Services.AddOpenApi(); builder.Services.AddDbContext(options => options.UseSqlite(builder.Configuration.GetConnectionString("MaBibli"))); +builder.Services.AddScoped(); + +// Identité : lue dans les en-têtes injectés par SSOwat, avec repli sur un utilisateur simulé +// configuré en développement (il n'y a pas de portail YunoHost en local). +builder.Services.AddHttpContextAccessor(); +builder.Services.Configure(builder.Configuration.GetSection(OptionsIdentite.Section)); +builder.Services.AddScoped(); + // Lookup ISBN : clients typés via IHttpClientFactory (pas de HttpClient instancié à la main). // Timeout court : une source lente ne doit pas bloquer la cascade, l'appelant bascule sur la suivante. var timeoutSources = TimeSpan.FromSeconds(10); @@ -32,6 +42,12 @@ builder.Services.AddScoped(); var app = builder.Build(); +// Une seule base SQLite, migrée au démarrage : pas d'étape manuelle à l'installation YunoHost. +using (var portee = app.Services.CreateScope()) +{ + portee.ServiceProvider.GetRequiredService().Database.Migrate(); +} + if (app.Environment.IsDevelopment()) { app.MapOpenApi(); @@ -43,6 +59,8 @@ app.UseBlazorFrameworkFiles(); app.UseStaticFiles(); app.MapIsbnEndpoints(); +app.MapLivresEndpoints(); +app.MapIdentiteEndpoints(); app.MapFallbackToFile("index.html"); diff --git a/MaBibli.Api/Services/Catalogue/ServiceCatalogue.cs b/MaBibli.Api/Services/Catalogue/ServiceCatalogue.cs new file mode 100644 index 0000000..23ab8dc --- /dev/null +++ b/MaBibli.Api/Services/Catalogue/ServiceCatalogue.cs @@ -0,0 +1,195 @@ +using MaBibli.Api.Data; +using MaBibli.Shared.Catalogue; +using MaBibli.Shared.Dtos; +using MaBibli.Shared.Entites; +using MaBibli.Shared.Isbn; +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) +{ + public static ResultatEcriture Ok(LivreDto livre) => new(livre, null); + + public static ResultatEcriture Invalide(string message) => new(null, message); + + /// Ni livre ni erreur : la ressource demandée n'existe pas. + public static readonly ResultatEcriture Introuvable = new(null, null); + + public bool EstIntrouvable => Livre is null && Erreur is null; +} + +public interface IServiceCatalogue +{ + Task> ListerAsync(CritereLivres criteres, CancellationToken ct = default); + + Task ObtenirAsync(int id, CancellationToken ct = default); + + Task CreerAsync(EnregistrementLivre saisie, string? ajoutePar, CancellationToken ct = default); + + Task ModifierAsync(int id, EnregistrementLivre saisie, CancellationToken ct = default); + + Task ChangerStatutAsync(int id, Statut statut, CancellationToken ct = default); + + Task SupprimerAsync(int id, CancellationToken ct = default); +} + +/// +/// CRUD du catalogue de livres — physiques et numériques confondus, distingués par +/// . Il n'y a qu'un seul catalogue. +/// +public sealed class ServiceCatalogue(MaBibliDbContext db) : IServiceCatalogue +{ + public async Task> ListerAsync( + CritereLivres criteres, CancellationToken ct = default) + { + // AsNoTracking : lecture pure. Et surtout : AUCUN filtre sur AjoutePar, + // la bibliothèque est commune à tout le foyer (CLAUDE.md). + var requete = FiltreLivres.Appliquer(db.Livres.AsNoTracking(), criteres); + + var livres = await requete.ToListAsync(ct); + return livres.Select(Projeter).ToList(); + } + + public async Task ObtenirAsync(int id, CancellationToken ct = default) + { + var livre = await db.Livres.AsNoTracking().FirstOrDefaultAsync(l => l.Id == id, ct); + return livre is null ? null : Projeter(livre); + } + + public async Task CreerAsync( + EnregistrementLivre saisie, string? ajoutePar, CancellationToken ct = default) + { + if (Valider(saisie, out var isbn, out var erreur) is false) + { + return ResultatEcriture.Invalide(erreur!); + } + + var livre = new Livre + { + Isbn = isbn, + Titre = saisie.Titre.Trim(), + Auteur = Vide(saisie.Auteur), + Editeur = Vide(saisie.Editeur), + Format = saisie.Format, + Statut = saisie.Statut, + CoverUrl = Vide(saisie.CoverUrl), + DateAjout = DateTime.UtcNow, + + // Renseigné par le serveur à partir de l'utilisateur authentifié, jamais par le client. + AjoutePar = ajoutePar, + }; + + db.Livres.Add(livre); + await db.SaveChangesAsync(ct); + + return ResultatEcriture.Ok(Projeter(livre)); + } + + public async Task ModifierAsync( + int id, EnregistrementLivre saisie, CancellationToken ct = default) + { + var livre = await db.Livres.FirstOrDefaultAsync(l => l.Id == id, ct); + if (livre is null) + { + return ResultatEcriture.Introuvable; + } + + if (Valider(saisie, out var isbn, out var erreur) is false) + { + return ResultatEcriture.Invalide(erreur!); + } + + livre.Isbn = isbn; + livre.Titre = saisie.Titre.Trim(); + livre.Auteur = Vide(saisie.Auteur); + livre.Editeur = Vide(saisie.Editeur); + livre.Format = saisie.Format; + livre.Statut = saisie.Statut; + livre.CoverUrl = Vide(saisie.CoverUrl); + + // DateAjout et AjoutePar ne sont jamais réécrits : ce sont des traces de la saisie + // d'origine, pas des champs éditables. + + await db.SaveChangesAsync(ct); + return ResultatEcriture.Ok(Projeter(livre)); + } + + public async Task ChangerStatutAsync(int id, Statut statut, CancellationToken ct = default) + { + var livre = await db.Livres.FirstOrDefaultAsync(l => l.Id == id, ct); + if (livre is null) + { + return null; + } + + livre.Statut = statut; + await db.SaveChangesAsync(ct); + return Projeter(livre); + } + + public async Task SupprimerAsync(int id, CancellationToken ct = default) + { + var livre = await db.Livres.FirstOrDefaultAsync(l => l.Id == id, ct); + if (livre is null) + { + return false; + } + + db.Livres.Remove(livre); + await db.SaveChangesAsync(ct); + return true; + } + + /// + /// Contrôles minimaux : un titre, et un ISBN qui tienne debout s'il est fourni. + /// + /// + /// L'ISBN est facultatif (livres anciens, tirages sans ISBN) mais, s'il est saisi, il doit + /// être valide : un ISBN faux est pire que pas d'ISBN, il empêcherait tout rapprochement + /// futur avec les catalogues. + /// + private static bool Valider(EnregistrementLivre saisie, out string? isbn, out string? erreur) + { + isbn = null; + erreur = null; + + if (string.IsNullOrWhiteSpace(saisie.Titre)) + { + erreur = "Le titre est obligatoire."; + return false; + } + + if (!string.IsNullOrWhiteSpace(saisie.Isbn)) + { + var normalise = IsbnUtils.Normaliser(saisie.Isbn); + if (!IsbnUtils.EstValide(normalise)) + { + erreur = $"« {saisie.Isbn} » n'est pas un ISBN valide. Laissez le champ vide si le livre n'en a pas."; + return false; + } + + isbn = normalise; + } + + return true; + } + + private static string? Vide(string? valeur) => + string.IsNullOrWhiteSpace(valeur) ? null : valeur.Trim(); + + private static LivreDto Projeter(Livre livre) => new() + { + Id = livre.Id, + Isbn = livre.Isbn, + Titre = livre.Titre, + Auteur = livre.Auteur, + Editeur = livre.Editeur, + Format = livre.Format, + Statut = livre.Statut, + CoverUrl = livre.CoverUrl, + DateAjout = livre.DateAjout, + AjoutePar = livre.AjoutePar, + }; +} diff --git a/MaBibli.Api/Services/Identite/FournisseurUtilisateur.cs b/MaBibli.Api/Services/Identite/FournisseurUtilisateur.cs new file mode 100644 index 0000000..f82c34c --- /dev/null +++ b/MaBibli.Api/Services/Identite/FournisseurUtilisateur.cs @@ -0,0 +1,97 @@ +using MaBibli.Shared.Dtos; + +namespace MaBibli.Api.Services.Identite; + +public interface IFournisseurUtilisateur +{ + /// Identité de la requête en cours, jamais null (au pire ). + UtilisateurCourant Obtenir(); +} + +/// +/// Options de l'identité, section Identite de la configuration. +/// +public sealed class OptionsIdentite +{ + public const string Section = "Identite"; + + /// + /// Utilisateur simulé en développement, où SSOwat n'existe pas. + /// + /// + /// CLAUDE.md : « prévoir un utilisateur simulé plutôt que de désactiver l'auth ». + /// Il n'est utilisé que si les en-têtes SSOwat sont absents, et n'est renseigné que dans + /// appsettings.Development.json — en production, aucune valeur, donc aucun repli. + /// + public string? UtilisateurSimule { get; set; } + + public string? EmailSimule { get; set; } + + public string? NomCompletSimule { get; set; } +} + +/// +/// Lit l'identité dans les en-têtes HTTP injectés par SSOwat. +/// +/// +/// nginx authentifie le visiteur via le portail YunoHost puis pousse YNH_USER, +/// YNH_USER_EMAIL et YNH_USER_FULLNAME. L'application se contente de les lire. +/// +/// ⚠️ Ces en-têtes ne sont dignes de confiance que parce que le service n'écoute que sur +/// 127.0.0.1 derrière nginx : exposé directement, n'importe qui pourrait forger +/// YNH_USER. Cette contrainte se règle dans systemd.service / nginx.conf, +/// qui vivent dans le dépôt mabibli_ynh (phase 7) — pas ici. +/// +/// +public sealed class FournisseurUtilisateurSsowat( + IHttpContextAccessor accesseur, + Microsoft.Extensions.Options.IOptions options) : IFournisseurUtilisateur +{ + public const string EnteteUtilisateur = "YNH_USER"; + public const string EnteteEmail = "YNH_USER_EMAIL"; + public const string EnteteNomComplet = "YNH_USER_FULLNAME"; + + public UtilisateurCourant Obtenir() + { + var entetes = accesseur.HttpContext?.Request.Headers; + var identifiant = Valeur(entetes, EnteteUtilisateur); + + if (identifiant is not null) + { + return new UtilisateurCourant + { + Identifiant = identifiant, + Email = Valeur(entetes, EnteteEmail), + NomComplet = Valeur(entetes, EnteteNomComplet), + Simule = false, + }; + } + + var simule = options.Value; + if (!string.IsNullOrWhiteSpace(simule.UtilisateurSimule)) + { + return new UtilisateurCourant + { + Identifiant = simule.UtilisateurSimule, + Email = simule.EmailSimule, + NomComplet = simule.NomCompletSimule, + Simule = true, + }; + } + + // Ni en-tête, ni utilisateur simulé : on n'invente pas d'identité. + // Le livre sera enregistré sans traçabilité plutôt que sous un faux nom. + return UtilisateurCourant.Anonyme; + } + + private static string? Valeur(IHeaderDictionary? entetes, string nom) + { + if (entetes is null || !entetes.TryGetValue(nom, out var valeurs)) + { + return null; + } + + var valeur = valeurs.ToString().Trim(); + return valeur.Length == 0 ? null : valeur; + } +} diff --git a/MaBibli.Api/appsettings.Development.json b/MaBibli.Api/appsettings.Development.json index 0c208ae..8f42b21 100644 --- a/MaBibli.Api/appsettings.Development.json +++ b/MaBibli.Api/appsettings.Development.json @@ -4,5 +4,14 @@ "Default": "Information", "Microsoft.AspNetCore": "Warning" } + }, + + // En développement il n'y a pas de SSOwat pour injecter YNH_USER : on simule un utilisateur + // plutôt que de désactiver l'authentification (CLAUDE.md). Aucune valeur équivalente en + // production : sans en-tête et sans simulation, l'identité reste inconnue. + "Identite": { + "UtilisateurSimule": "dev", + "EmailSimule": "dev@localhost", + "NomCompletSimule": "Utilisateur de développement" } } diff --git a/MaBibli.Client/Composants/Couverture.razor b/MaBibli.Client/Composants/Couverture.razor new file mode 100644 index 0000000..d214628 --- /dev/null +++ b/MaBibli.Client/Composants/Couverture.razor @@ -0,0 +1,35 @@ +@* + Couverture d'un livre, avec substitut. + + Les URL de couverture OpenLibrary renvoient des 502 intermittents (mesuré en phase 2 : + 200, 200, puis 502 sur le même ISBN). On ne valide donc JAMAIS l'URL par un appel préalable : + on l'affiche, et si l'image casse, l'attribut onerror la masque et laisse apparaître le + substitut qui se trouve déjà dessous. Aucun aller-retour serveur, aucun état C# à gérer. + + @key sur l'img : si l'URL change, Blazor recrée l'élément au lieu de réutiliser celui que + le onerror a marqué comme cassé. +*@ +
+ + + @if (!string.IsNullOrWhiteSpace(Url)) + { + Couverture de @Titre + } +
+ +@code { + [Parameter] public string? Url { get; set; } + + [Parameter] public string? Titre { get; set; } + + [Parameter] public string? Classe { get; set; } + + private string Initiale => + string.IsNullOrWhiteSpace(Titre) ? "?" : Titre.Trim()[..1].ToUpperInvariant(); +} diff --git a/MaBibli.Client/Composants/FormulaireLivre.razor b/MaBibli.Client/Composants/FormulaireLivre.razor new file mode 100644 index 0000000..ff71d64 --- /dev/null +++ b/MaBibli.Client/Composants/FormulaireLivre.razor @@ -0,0 +1,112 @@ +@* + Formulaire d'une fiche livre, partagé par l'ajout (ISBN ou manuel) et l'édition. + + Le pré-remplissage par ISBN est une AIDE, pas une contrainte : tous les champs restent + modifiables, et une fiche incomplète peut toujours être complétée à la main (CLAUDE.md). +*@ +
+ @if (!string.IsNullOrEmpty(Saisie.CoverUrl) || !string.IsNullOrWhiteSpace(Saisie.Titre)) + { +
+ +
+ } + + + + + + + + + +
+ Format +
+ @foreach (var format in Formats) + { + + } +
+
+ +
+ Statut de lecture +
+ @foreach (var statut in Statuts) + { + + } +
+
+ + + + @if (!string.IsNullOrEmpty(Erreur)) + { + + } + +
+ + + @if (OnAnnuler.HasDelegate) + { + + } +
+
+ +@code { + private static readonly Format[] Formats = Enum.GetValues(); + private static readonly Statut[] Statuts = Enum.GetValues(); + + [Parameter, EditorRequired] public EnregistrementLivre Saisie { get; set; } = new(); + + [Parameter] public string LibelleValidation { get; set; } = "Enregistrer"; + + [Parameter] public string? Erreur { get; set; } + + [Parameter] public bool EnCours { get; set; } + + [Parameter] public EventCallback OnValider { get; set; } + + [Parameter] public EventCallback OnAnnuler { get; set; } + + private Task Valider() => OnValider.InvokeAsync(); +} diff --git a/MaBibli.Client/Composants/Libelles.cs b/MaBibli.Client/Composants/Libelles.cs new file mode 100644 index 0000000..bc4a453 --- /dev/null +++ b/MaBibli.Client/Composants/Libelles.cs @@ -0,0 +1,31 @@ +using MaBibli.Shared.Entites; + +namespace MaBibli.Client.Composants; + +/// Libellés français des énumérations, au même endroit pour toute l'interface. +public static class Libelles +{ + public static string Format(Format format) => format switch + { + MaBibli.Shared.Entites.Format.Physique => "Physique", + MaBibli.Shared.Entites.Format.Numerique => "Numérique", + _ => format.ToString(), + }; + + public static string Statut(Statut statut) => statut switch + { + MaBibli.Shared.Entites.Statut.ALire => "À lire", + MaBibli.Shared.Entites.Statut.EnCours => "En cours", + MaBibli.Shared.Entites.Statut.Lu => "Lu", + _ => statut.ToString(), + }; + + /// Classe CSS associée au statut, pour la pastille de couleur. + public static string ClasseStatut(Statut statut) => statut switch + { + MaBibli.Shared.Entites.Statut.ALire => "statut-a-lire", + MaBibli.Shared.Entites.Statut.EnCours => "statut-en-cours", + MaBibli.Shared.Entites.Statut.Lu => "statut-lu", + _ => string.Empty, + }; +} diff --git a/MaBibli.Client/Layout/MainLayout.razor b/MaBibli.Client/Layout/MainLayout.razor index 76eb725..d27de9c 100644 --- a/MaBibli.Client/Layout/MainLayout.razor +++ b/MaBibli.Client/Layout/MainLayout.razor @@ -1,16 +1,26 @@ @inherits LayoutComponentBase -
- +@inject ServiceLivresApi Api -
-
- About -
+@* Mise en page pensée mobile d'abord : un bandeau, une colonne, rien à gauche. + Le PC hérite de la même colonne, simplement centrée et limitée en largeur. *@ +
+ MaBibli -
- @Body -
-
-
+ @if (_utilisateur?.Identifiant is not null) + { + + @_utilisateur.Affichage@(_utilisateur.Simule ? " (dev)" : "") + + } + + +
+ @Body +
+ +@code { + private UtilisateurCourant? _utilisateur; + + protected override async Task OnInitializedAsync() + => _utilisateur = await Api.ObtenirUtilisateurAsync(); +} diff --git a/MaBibli.Client/Layout/MainLayout.razor.css b/MaBibli.Client/Layout/MainLayout.razor.css index ecf25e5..252cba0 100644 --- a/MaBibli.Client/Layout/MainLayout.razor.css +++ b/MaBibli.Client/Layout/MainLayout.razor.css @@ -1,77 +1,37 @@ -.page { - position: relative; - display: flex; - flex-direction: column; -} - -main { - flex: 1; -} - -.sidebar { - background-image: linear-gradient(180deg, rgb(5, 39, 103) 0%, #3a0647 70%); -} - -.top-row { - background-color: #f7f7f7; - border-bottom: 1px solid #d6d5d5; - justify-content: flex-end; - height: 3.5rem; +.bandeau { + position: sticky; + top: 0; + z-index: 10; display: flex; align-items: center; + justify-content: space-between; + gap: 0.75rem; + padding: 0.75rem 1rem; + background: #1b3a5c; + color: #fff; } - .top-row ::deep a, .top-row ::deep .btn-link { - white-space: nowrap; - margin-left: 1.5rem; - text-decoration: none; - } - - .top-row ::deep a:hover, .top-row ::deep .btn-link:hover { - text-decoration: underline; - } - - .top-row ::deep a:first-child { - overflow: hidden; - text-overflow: ellipsis; - } - -@media (max-width: 640.98px) { - .top-row { - justify-content: space-between; - } - - .top-row ::deep a, .top-row ::deep .btn-link { - margin-left: 0; - } +.marque { + color: #fff; + font-size: 1.15rem; + font-weight: 600; + text-decoration: none; } -@media (min-width: 641px) { - .page { - flex-direction: row; - } - - .sidebar { - width: 250px; - height: 100vh; - position: sticky; - top: 0; - } - - .top-row { - position: sticky; - top: 0; - z-index: 1; - } - - .top-row.auth ::deep a:first-child { - flex: 1; - text-align: right; - width: 0; - } - - .top-row, article { - padding-left: 2rem !important; - padding-right: 1.5rem !important; - } +.utilisateur { + font-size: 0.8rem; + opacity: 0.85; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + max-width: 55%; +} + +.contenu { + display: block; + width: 100%; + max-width: 46rem; + margin: 0 auto; + /* Marge basse généreuse : les actions flottantes ne doivent pas masquer le dernier livre. */ + padding: 1rem 1rem 6rem; } diff --git a/MaBibli.Client/Layout/NavMenu.razor b/MaBibli.Client/Layout/NavMenu.razor deleted file mode 100644 index dd40b84..0000000 --- a/MaBibli.Client/Layout/NavMenu.razor +++ /dev/null @@ -1,29 +0,0 @@ - - - - -@code { - private bool collapseNavMenu = true; - - private string? NavMenuCssClass => collapseNavMenu ? "collapse" : null; - - private void ToggleNavMenu() - { - collapseNavMenu = !collapseNavMenu; - } -} diff --git a/MaBibli.Client/Layout/NavMenu.razor.css b/MaBibli.Client/Layout/NavMenu.razor.css deleted file mode 100644 index 617b89c..0000000 --- a/MaBibli.Client/Layout/NavMenu.razor.css +++ /dev/null @@ -1,83 +0,0 @@ -.navbar-toggler { - background-color: rgba(255, 255, 255, 0.1); -} - -.top-row { - min-height: 3.5rem; - background-color: rgba(0,0,0,0.4); -} - -.navbar-brand { - font-size: 1.1rem; -} - -.bi { - display: inline-block; - position: relative; - width: 1.25rem; - height: 1.25rem; - margin-right: 0.75rem; - top: -1px; - background-size: cover; -} - -.bi-house-door-fill-nav-menu { - background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' fill='white' class='bi bi-house-door-fill' viewBox='0 0 16 16'%3E%3Cpath d='M6.5 14.5v-3.505c0-.245.25-.495.5-.495h2c.25 0 .5.25.5.5v3.5a.5.5 0 0 0 .5.5h4a.5.5 0 0 0 .5-.5v-7a.5.5 0 0 0-.146-.354L13 5.793V2.5a.5.5 0 0 0-.5-.5h-1a.5.5 0 0 0-.5.5v1.293L8.354 1.146a.5.5 0 0 0-.708 0l-6 6A.5.5 0 0 0 1.5 7.5v7a.5.5 0 0 0 .5.5h4a.5.5 0 0 0 .5-.5Z'/%3E%3C/svg%3E"); -} - -.bi-plus-square-fill-nav-menu { - background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' fill='white' class='bi bi-plus-square-fill' viewBox='0 0 16 16'%3E%3Cpath d='M2 0a2 2 0 0 0-2 2v12a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V2a2 2 0 0 0-2-2H2zm6.5 4.5v3h3a.5.5 0 0 1 0 1h-3v3a.5.5 0 0 1-1 0v-3h-3a.5.5 0 0 1 0-1h3v-3a.5.5 0 0 1 1 0z'/%3E%3C/svg%3E"); -} - -.bi-list-nested-nav-menu { - background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' fill='white' class='bi bi-list-nested' viewBox='0 0 16 16'%3E%3Cpath fill-rule='evenodd' d='M4.5 11.5A.5.5 0 0 1 5 11h10a.5.5 0 0 1 0 1H5a.5.5 0 0 1-.5-.5zm-2-4A.5.5 0 0 1 3 7h10a.5.5 0 0 1 0 1H3a.5.5 0 0 1-.5-.5zm-2-4A.5.5 0 0 1 1 3h10a.5.5 0 0 1 0 1H1a.5.5 0 0 1-.5-.5z'/%3E%3C/svg%3E"); -} - -.nav-item { - font-size: 0.9rem; - padding-bottom: 0.5rem; -} - - .nav-item:first-of-type { - padding-top: 1rem; - } - - .nav-item:last-of-type { - padding-bottom: 1rem; - } - - .nav-item ::deep a { - color: #d7d7d7; - border-radius: 4px; - height: 3rem; - display: flex; - align-items: center; - line-height: 3rem; - } - -.nav-item ::deep a.active { - background-color: rgba(255,255,255,0.37); - color: white; -} - -.nav-item ::deep a:hover { - background-color: rgba(255,255,255,0.1); - color: white; -} - -@media (min-width: 641px) { - .navbar-toggler { - display: none; - } - - .collapse { - /* Never collapse the sidebar for wide screens */ - display: block; - } - - .nav-scrollable { - /* Allow sidebar to scroll for tall menus */ - height: calc(100vh - 3.5rem); - overflow-y: auto; - } -} diff --git a/MaBibli.Client/Pages/AjoutIsbn.razor b/MaBibli.Client/Pages/AjoutIsbn.razor new file mode 100644 index 0000000..c37479c --- /dev/null +++ b/MaBibli.Client/Pages/AjoutIsbn.razor @@ -0,0 +1,214 @@ +@page "/ajout/isbn" +@inject ServiceLivresApi Api +@inject NavigationManager Navigation + +MaBibli — ajouter par ISBN + +

Ajouter par ISBN

+ +@if (_etape == Etape.Saisie) +{ +

+ Saisissez l'ISBN imprimé sur le livre. Le scan par la caméra arrivera plus tard. +

+ +
+ +
+ +
+ + Saisir à la main +
+} + +@if (_erreur is not null) +{ + +} + +@foreach (var avertissement in _avertissements) +{ +

@avertissement

+} + +@if (_etape == Etape.Choix) +{ + @* Décision actée dans CLAUDE.md : plusieurs notices pour un même ISBN (rééditions + successives partageant l'ISBN), on ne choisit JAMAIS à la place de l'utilisateur. + Les candidats partagent titre et auteur : ce sont l'éditeur et l'année qui départagent, + donc ils sont mis en avant. *@ +

+ @_candidats.Count notices correspondent à cet ISBN. Elles se distinguent par l'éditeur et l'année. +

+ +
    + @foreach (var (candidat, index) in _candidats.Select((c, i) => (c, i))) + { +
  • + + +
    +

    @candidat.Titre

    + @if (!string.IsNullOrWhiteSpace(candidat.Auteur)) + { +

    @candidat.Auteur

    + } + +

    + @(candidat.Editeur ?? "éditeur inconnu") + @(candidat.Annee ?? "année inconnue") +

    + +

    + @candidat.Source + @if (!string.IsNullOrWhiteSpace(candidat.IsbnInterroge)) + { + trouvé via @candidat.IsbnInterroge + } +

    + + +
    +
  • + } +
+ +
+ +
+} + +@if (_etape == Etape.Formulaire) +{ +

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

+ + +} + +@code { + private enum Etape { Saisie, Choix, Formulaire } + + private Etape _etape = Etape.Saisie; + private string _isbn = string.Empty; + private bool _chargement; + private bool _enregistrement; + private string? _erreur; + private string? _erreurFormulaire; + private IReadOnlyList _candidats = []; + private IReadOnlyList _avertissements = []; + private EnregistrementLivre _saisie = new(); + + private async Task SurTouche(KeyboardEventArgs e) + { + if (e.Key == "Enter" && !string.IsNullOrWhiteSpace(_isbn)) + { + await ChercherAsync(); + } + } + + private async Task ChercherAsync() + { + _chargement = true; + _erreur = null; + _avertissements = []; + + try + { + var resultat = await Api.ChercherIsbnAsync(_isbn.Trim()); + + if (resultat is null) + { + _erreur = $"« {_isbn} » n'est pas un ISBN valide."; + return; + } + + _avertissements = resultat.Avertissements; + _candidats = resultat.Candidats; + + if (_candidats.Count == 0) + { + // Aucune notice : on ne bloque pas, le formulaire manuel reste la porte de sortie. + _erreur = "Aucune notice trouvée pour cet ISBN. Complétez la fiche à la main."; + _saisie = new EnregistrementLivre { Isbn = resultat.IsbnDemande }; + _etape = Etape.Formulaire; + return; + } + + if (_candidats.Count == 1) + { + Choisir(_candidats[0]); + return; + } + + _etape = Etape.Choix; + } + catch (Exception ex) + { + _erreur = $"La recherche a échoué : {ex.Message}"; + } + finally + { + _chargement = false; + } + } + + private void Choisir(CandidatLivre candidat) + { + _saisie = new EnregistrementLivre + { + Isbn = _isbn.Trim(), + Titre = candidat.Titre, + Auteur = candidat.Auteur, + Editeur = candidat.Editeur, + CoverUrl = candidat.CoverUrl, + Format = Format.Physique, + Statut = Statut.ALire, + }; + + _erreur = null; + _etape = Etape.Formulaire; + } + + private void Recommencer() + { + _etape = Etape.Saisie; + _erreur = null; + _erreurFormulaire = null; + _candidats = []; + } + + private async Task EnregistrerAsync() + { + _enregistrement = true; + _erreurFormulaire = null; + + var resultat = await Api.CreerAsync(_saisie); + + _enregistrement = false; + + if (!resultat.EstOk) + { + _erreurFormulaire = resultat.Erreur; + return; + } + + Navigation.NavigateTo("/"); + } +} diff --git a/MaBibli.Client/Pages/AjoutManuel.razor b/MaBibli.Client/Pages/AjoutManuel.razor new file mode 100644 index 0000000..7c70759 --- /dev/null +++ b/MaBibli.Client/Pages/AjoutManuel.razor @@ -0,0 +1,44 @@ +@page "/ajout/manuel" +@inject ServiceLivresApi Api +@inject NavigationManager Navigation + +MaBibli — saisie manuelle + +

Saisie manuelle

+ +

+ Pour les livres sans ISBN, ou dont aucune notice ne convient. L'ISBN reste facultatif. +

+ + + +@code { + private readonly EnregistrementLivre _saisie = new(); + private bool _enregistrement; + private string? _erreur; + + private async Task EnregistrerAsync() + { + _enregistrement = true; + _erreur = null; + + var resultat = await Api.CreerAsync(_saisie); + + _enregistrement = false; + + if (!resultat.EstOk) + { + _erreur = resultat.Erreur; + return; + } + + Navigation.NavigateTo("/"); + } + + private void Retour() => Navigation.NavigateTo("/"); +} diff --git a/MaBibli.Client/Pages/Catalogue.razor b/MaBibli.Client/Pages/Catalogue.razor new file mode 100644 index 0000000..44f6595 --- /dev/null +++ b/MaBibli.Client/Pages/Catalogue.razor @@ -0,0 +1,201 @@ +@page "/" +@inject ServiceLivresApi Api +@implements IDisposable + +MaBibli — catalogue + +

Catalogue

+ +
+ +
+ +
+
+ + @foreach (var format in Enum.GetValues()) + { + + } +
+ +
+ + @foreach (var statut in Enum.GetValues()) + { + + } +
+
+ +@if (_erreur is not null) +{ + +} + +@if (_chargement && _livres is null) +{ +

Chargement…

+} +else if (_livres is { Count: 0 }) +{ +

+ @(EstFiltre ? "Aucun livre ne correspond à cette recherche." : "La bibliothèque est vide. Ajoutez un premier livre.") +

+} +else if (_livres is not null) +{ +

@_livres.Count livre@(_livres.Count > 1 ? "s" : "")

+ +
    + @foreach (var livre in _livres) + { +
  • + + +
    + @livre.Titre + + @if (!string.IsNullOrWhiteSpace(livre.Auteur)) + { +

    @livre.Auteur

    + } + +

    + @Libelles.Format(livre.Format) + @if (!string.IsNullOrWhiteSpace(livre.Editeur)) + { + @livre.Editeur + } +

    + + @* Changement de statut : action la plus fréquente, donc directement dans la liste. *@ +
    + @foreach (var statut in Enum.GetValues()) + { + + } +
    +
    +
  • + } +
+} + + + +@code { + private IReadOnlyList? _livres; + private string _recherche = string.Empty; + private Format? _format; + private Statut? _statut; + private bool _chargement; + private string? _erreur; + private int? _statutEnCours; + + // Une frappe au clavier annule la recherche précédente encore en vol. + private CancellationTokenSource _cts = new(); + + private bool EstFiltre => + !string.IsNullOrWhiteSpace(_recherche) || _format is not null || _statut is not null; + + protected override Task OnInitializedAsync() => ChargerAsync(); + + private async Task SurRecherche(ChangeEventArgs e) + { + _recherche = e.Value?.ToString() ?? string.Empty; + await ChargerAsync(); + } + + private Task FiltrerFormat(Format? format) + { + _format = format; + return ChargerAsync(); + } + + private Task FiltrerStatut(Statut? statut) + { + _statut = statut; + return ChargerAsync(); + } + + private async Task ChargerAsync() + { + _cts.Cancel(); + _cts.Dispose(); + _cts = new CancellationTokenSource(); + var jeton = _cts.Token; + + _chargement = true; + _erreur = null; + + try + { + var criteres = new CritereLivres { Recherche = _recherche, Format = _format, Statut = _statut }; + _livres = await Api.ListerAsync(criteres, jeton); + } + catch (OperationCanceledException) + { + return; // Une recherche plus récente a pris le relais. + } + catch (Exception ex) + { + _erreur = $"Impossible de charger le catalogue : {ex.Message}"; + } + finally + { + if (!jeton.IsCancellationRequested) + { + _chargement = false; + } + } + } + + private async Task ChangerStatut(LivreDto livre, Statut statut) + { + if (livre.Statut == statut) + { + return; + } + + _statutEnCours = livre.Id; + var resultat = await Api.ChangerStatutAsync(livre.Id, statut); + _statutEnCours = null; + + if (!resultat.EstOk) + { + _erreur = resultat.Erreur; + return; + } + + if (_statut is not null) + { + // Un filtre par statut est actif : le livre vient peut-être de sortir de la sélection, + // il faut donc recharger plutôt que corriger la liste en place. + await ChargerAsync(); + return; + } + + // Remplacement local : évite de recharger toute la liste pour un seul champ. + _livres = _livres!.Select(l => l.Id == livre.Id ? resultat.Valeur! : l).ToList(); + } + + public void Dispose() + { + _cts.Cancel(); + _cts.Dispose(); + } +} diff --git a/MaBibli.Client/Pages/EditionLivre.razor b/MaBibli.Client/Pages/EditionLivre.razor new file mode 100644 index 0000000..9f9ce39 --- /dev/null +++ b/MaBibli.Client/Pages/EditionLivre.razor @@ -0,0 +1,143 @@ +@page "/livres/{Id:int}" +@inject ServiceLivresApi Api +@inject NavigationManager Navigation + +MaBibli — fiche du livre + +

Fiche du livre

+ +@if (_introuvable) +{ + + Retour au catalogue +} +else if (_saisie is null) +{ +

Chargement…

+} +else +{ + + + @if (_livre is not null) + { +

+ Ajouté le @_livre.DateAjout.ToLocalTime().ToString("dd/MM/yyyy") + @if (!string.IsNullOrWhiteSpace(_livre.AjoutePar)) + { + @($" par {_livre.AjoutePar}") + } + @* Information de traçabilité, jamais un filtre : la bibliothèque est commune. *@ +

+ } + +
+ @if (!_confirmationSuppression) + { + + } + else + { +

+ Supprimer « @_saisie.Titre » définitivement ? L'historique de ses prêts partira avec. +

+
+ + +
+ } +
+} + +@code { + [Parameter] public int Id { get; set; } + + private LivreDto? _livre; + private EnregistrementLivre? _saisie; + private bool _introuvable; + private bool _enregistrement; + private bool _suppression; + private bool _confirmationSuppression; + private string? _erreur; + + protected override async Task OnParametersSetAsync() + { + _introuvable = false; + _saisie = null; + + try + { + _livre = await Api.ObtenirAsync(Id); + } + catch (Exception ex) + { + _erreur = $"Chargement impossible : {ex.Message}"; + return; + } + + if (_livre is null) + { + _introuvable = true; + return; + } + + _saisie = new EnregistrementLivre + { + Isbn = _livre.Isbn, + Titre = _livre.Titre, + Auteur = _livre.Auteur, + Editeur = _livre.Editeur, + Format = _livre.Format, + Statut = _livre.Statut, + CoverUrl = _livre.CoverUrl, + }; + } + + private async Task EnregistrerAsync() + { + _enregistrement = true; + _erreur = null; + + var resultat = await Api.ModifierAsync(Id, _saisie!); + + _enregistrement = false; + + if (!resultat.EstOk) + { + _erreur = resultat.Erreur; + return; + } + + Navigation.NavigateTo("/"); + } + + private async Task SupprimerAsync() + { + _suppression = true; + var erreur = await Api.SupprimerAsync(Id); + _suppression = false; + + if (erreur is not null) + { + _erreur = erreur; + _confirmationSuppression = false; + return; + } + + Navigation.NavigateTo("/"); + } + + private void Retour() => Navigation.NavigateTo("/"); +} diff --git a/MaBibli.Client/Pages/Home.razor b/MaBibli.Client/Pages/Home.razor deleted file mode 100644 index 43c391e..0000000 --- a/MaBibli.Client/Pages/Home.razor +++ /dev/null @@ -1,7 +0,0 @@ -@page "/" - -MaBibli - -

MaBibli

- -

Squelette de l'application. Les fonctionnalités arrivent dans les phases suivantes.

diff --git a/MaBibli.Client/Program.cs b/MaBibli.Client/Program.cs index 180e393..db6261c 100644 --- a/MaBibli.Client/Program.cs +++ b/MaBibli.Client/Program.cs @@ -7,5 +7,6 @@ builder.RootComponents.Add("#app"); builder.RootComponents.Add("head::after"); builder.Services.AddScoped(sp => new HttpClient { BaseAddress = new Uri(builder.HostEnvironment.BaseAddress) }); +builder.Services.AddScoped(); await builder.Build().RunAsync(); diff --git a/MaBibli.Client/Services/ServiceLivresApi.cs b/MaBibli.Client/Services/ServiceLivresApi.cs new file mode 100644 index 0000000..d9f2dd0 --- /dev/null +++ b/MaBibli.Client/Services/ServiceLivresApi.cs @@ -0,0 +1,145 @@ +using System.Net; +using System.Net.Http.Json; +using System.Text.Json; +using MaBibli.Shared.Catalogue; +using MaBibli.Shared.Dtos; +using MaBibli.Shared.Entites; + +namespace MaBibli.Client.Services; + +/// +/// Accès à l'API depuis le client Blazor. +/// +/// +/// Aucun cache : la phase 3 suppose le réseau disponible. Le cache hors-ligne est la phase 6. +/// +public sealed class ServiceLivresApi(HttpClient http) +{ + private static readonly JsonSerializerOptions Json = new(JsonSerializerDefaults.Web); + + public async Task> ListerAsync(CritereLivres criteres, CancellationToken ct = default) + { + var parametres = new List(); + + if (!string.IsNullOrWhiteSpace(criteres.Recherche)) + { + parametres.Add($"recherche={Uri.EscapeDataString(criteres.Recherche.Trim())}"); + } + + if (criteres.Format is { } format) + { + parametres.Add($"format={format}"); + } + + if (criteres.Statut is { } statut) + { + parametres.Add($"statut={statut}"); + } + + var url = "api/livres" + (parametres.Count > 0 ? "?" + string.Join('&', parametres) : string.Empty); + + return await http.GetFromJsonAsync>(url, Json, ct) ?? []; + } + + public async Task ObtenirAsync(int id, CancellationToken ct = default) + { + var reponse = await http.GetAsync($"api/livres/{id}", ct); + return reponse.StatusCode == HttpStatusCode.NotFound + ? null + : await LireAsync(reponse, ct); + } + + public async Task> CreerAsync(EnregistrementLivre saisie, CancellationToken ct = default) + { + var reponse = await http.PostAsJsonAsync("api/livres", saisie, Json, ct); + return await InterpreterAsync(reponse, ct); + } + + public async Task> ModifierAsync(int id, EnregistrementLivre saisie, CancellationToken ct = default) + { + var reponse = await http.PutAsJsonAsync($"api/livres/{id}", saisie, Json, ct); + return await InterpreterAsync(reponse, ct); + } + + public async Task> ChangerStatutAsync(int id, Statut statut, CancellationToken ct = default) + { + var reponse = await http.PatchAsJsonAsync( + $"api/livres/{id}/statut", new ChangementStatut { Statut = statut }, Json, ct); + return await InterpreterAsync(reponse, ct); + } + + public async Task SupprimerAsync(int id, CancellationToken ct = default) + { + var reponse = await http.DeleteAsync($"api/livres/{id}", ct); + return reponse.IsSuccessStatusCode ? null : await MessageErreurAsync(reponse, ct); + } + + public async Task ChercherIsbnAsync(string isbn, CancellationToken ct = default) + { + var reponse = await http.GetAsync($"api/isbn/{Uri.EscapeDataString(isbn)}", ct); + return reponse.IsSuccessStatusCode + ? await LireAsync(reponse, ct) + : null; + } + + public async Task ObtenirUtilisateurAsync(CancellationToken ct = default) + { + try + { + return await http.GetFromJsonAsync("api/moi", Json, ct) + ?? UtilisateurCourant.Anonyme; + } + catch (HttpRequestException) + { + return UtilisateurCourant.Anonyme; + } + } + + private static async Task LireAsync(HttpResponseMessage reponse, CancellationToken ct) + { + reponse.EnsureSuccessStatusCode(); + return await reponse.Content.ReadFromJsonAsync(Json, ct); + } + + private static async Task> InterpreterAsync(HttpResponseMessage reponse, CancellationToken ct) + { + if (reponse.IsSuccessStatusCode) + { + var valeur = await reponse.Content.ReadFromJsonAsync(Json, ct); + return new ResultatApi(valeur, null); + } + + return new ResultatApi(default, await MessageErreurAsync(reponse, ct)); + } + + /// Récupère le message d'erreur de l'API pour l'afficher tel quel à l'utilisateur. + private static async Task MessageErreurAsync(HttpResponseMessage reponse, CancellationToken ct) + { + try + { + var corps = await reponse.Content.ReadFromJsonAsync(Json, ct); + if (!string.IsNullOrWhiteSpace(corps?.Erreur)) + { + return corps.Erreur; + } + } + catch (Exception) + { + // Corps illisible ou vide : on retombe sur le code HTTP. + } + + return reponse.StatusCode switch + { + HttpStatusCode.NotFound => "Ce livre n'existe plus.", + _ => $"L'enregistrement a échoué ({(int)reponse.StatusCode}).", + }; + } + + private sealed record ErreurApi(string? Erreur); +} + +/// Résultat d'un appel d'écriture : la valeur, ou un message d'erreur affichable. +public readonly record struct ResultatApi(T? Valeur, string? Erreur) +{ + public bool EstOk => Erreur is null; +} diff --git a/MaBibli.Client/_Imports.razor b/MaBibli.Client/_Imports.razor index 904ac57..0ca14fd 100644 --- a/MaBibli.Client/_Imports.razor +++ b/MaBibli.Client/_Imports.razor @@ -7,4 +7,9 @@ @using Microsoft.AspNetCore.Components.WebAssembly.Http @using Microsoft.JSInterop @using MaBibli.Client +@using MaBibli.Client.Composants @using MaBibli.Client.Layout +@using MaBibli.Client.Services +@using MaBibli.Shared.Catalogue +@using MaBibli.Shared.Dtos +@using MaBibli.Shared.Entites diff --git a/MaBibli.Client/wwwroot/css/app.css b/MaBibli.Client/wwwroot/css/app.css index fe44b1d..a86d6ee 100644 --- a/MaBibli.Client/wwwroot/css/app.css +++ b/MaBibli.Client/wwwroot/css/app.css @@ -112,4 +112,340 @@ code { .form-floating > .form-control-plaintext:focus::placeholder, .form-floating > .form-control:focus::placeholder { text-align: start; -} \ No newline at end of file +} +/* ------------------------------------------------------------------ + MaBibli — interface du catalogue (phase 3) + + Conçue mobile d'abord : une seule colonne, cibles tactiles d'au moins + 2,75 rem, aucune règle de largeur en dessous du point de rupture. + Le PC n'ajoute qu'une largeur maximale et une grille de cartes. + ------------------------------------------------------------------ */ + +:root { + --mb-accent: #1b3a5c; + --mb-bord: #dcdcdc; + --mb-fond-doux: #f4f5f7; + --mb-texte-doux: #5c6470; + --mb-danger: #a4242c; +} + +body { + background: #fff; + color: #1d2129; +} + +.titre-page { + font-size: 1.4rem; + margin: 0 0 0.75rem; +} + +.message-discret { + color: var(--mb-texte-doux); + font-size: 0.9rem; +} + +.message-erreur { + background: #fdecec; + border-left: 3px solid var(--mb-danger); + color: #7a1a20; + padding: 0.6rem 0.75rem; + border-radius: 4px; + margin: 0.75rem 0; +} + +.message-avertissement { + background: #fff8e1; + border-left: 3px solid #c99700; + color: #6b5200; + padding: 0.6rem 0.75rem; + border-radius: 4px; + margin: 0.5rem 0; + font-size: 0.9rem; +} + +.compteur { + color: var(--mb-texte-doux); + font-size: 0.85rem; + margin: 0.75rem 0 0.25rem; +} + +/* --- Champs de saisie --- */ + +.barre-recherche { + margin-bottom: 0.75rem; +} + +.champ { + display: block; + margin-bottom: 0.9rem; + border: 0; + padding: 0; +} + +.champ-libelle { + display: block; + font-size: 0.85rem; + font-weight: 600; + margin-bottom: 0.3rem; +} + +.champ-aide, +.obligatoire { + font-weight: 400; + color: var(--mb-texte-doux); +} + +.champ-saisie { + width: 100%; + /* 16px minimum : en dessous, iOS zoome au focus. */ + font-size: 1rem; + padding: 0.7rem 0.75rem; + border: 1px solid var(--mb-bord); + border-radius: 8px; + background: #fff; +} + +.champ-saisie:focus { + outline: 2px solid var(--mb-accent); + outline-offset: -1px; +} + +/* --- Segments (filtres, format, statut) --- */ + +.segments { + display: flex; + flex-wrap: wrap; + gap: 0.35rem; + margin-bottom: 0.5rem; +} + +.segment { + flex: 1 1 auto; + min-height: 2.5rem; + padding: 0.4rem 0.7rem; + font-size: 0.85rem; + border: 1px solid var(--mb-bord); + border-radius: 999px; + background: #fff; + color: #1d2129; + cursor: pointer; +} + +.segment-actif { + background: var(--mb-accent); + border-color: var(--mb-accent); + color: #fff; +} + +.segments-compacts .segment { + min-height: 2.2rem; + font-size: 0.8rem; +} + +.segment.statut-a-lire.segment-actif { background: #6b4fa8; border-color: #6b4fa8; } +.segment.statut-en-cours.segment-actif { background: #b06b00; border-color: #b06b00; } +.segment.statut-lu.segment-actif { background: #1f7a44; border-color: #1f7a44; } + +/* --- Boutons --- */ + +.bouton { + display: inline-flex; + align-items: center; + justify-content: center; + min-height: 2.75rem; + padding: 0.6rem 1rem; + border-radius: 8px; + border: 1px solid var(--mb-bord); + background: #fff; + color: #1d2129; + font-size: 0.95rem; + text-decoration: none; + cursor: pointer; +} + +.bouton:disabled { + opacity: 0.55; + cursor: default; +} + +.bouton-principal { + background: var(--mb-accent); + border-color: var(--mb-accent); + color: #fff; +} + +.bouton-discret { + background: var(--mb-fond-doux); +} + +.bouton-danger { + background: var(--mb-danger); + border-color: var(--mb-danger); + color: #fff; +} + +.actions-formulaire { + display: flex; + flex-wrap: wrap; + gap: 0.5rem; + margin: 1rem 0; +} + +.actions-flottantes { + position: fixed; + left: 0; + right: 0; + bottom: 0; + display: flex; + gap: 0.5rem; + padding: 0.6rem 1rem calc(0.6rem + env(safe-area-inset-bottom)); + background: rgba(255, 255, 255, 0.96); + border-top: 1px solid var(--mb-bord); +} + +.actions-flottantes .bouton { + flex: 1; +} + +/* --- Couvertures --- */ + +.couverture { + position: relative; + flex: 0 0 auto; + overflow: hidden; + border-radius: 6px; + background: var(--mb-fond-doux); + border: 1px solid var(--mb-bord); +} + +.couverture-petite { width: 3.5rem; height: 5.25rem; } +.couverture-moyenne { width: 6rem; height: 9rem; } + +.couverture-substitut { + position: absolute; + inset: 0; + display: flex; + align-items: center; + justify-content: center; + font-size: 1.5rem; + font-weight: 700; + color: #9aa2ad; +} + +.couverture-image { + position: absolute; + inset: 0; + width: 100%; + height: 100%; + object-fit: cover; +} + +/* Posée par l'attribut onerror : les couvertures OpenLibrary renvoient + des 502 intermittents, l'image cassée laisse place au substitut. */ +.couverture-image.image-cassee { + display: none; +} + +/* --- Liste de livres --- */ + +.liste-livres, +.liste-candidats { + list-style: none; + margin: 0; + padding: 0; +} + +.carte-livre, +.carte-candidat { + display: flex; + gap: 0.75rem; + padding: 0.75rem 0; + border-bottom: 1px solid var(--mb-bord); +} + +.carte-corps { + min-width: 0; + flex: 1; +} + +.carte-titre { + display: block; + font-weight: 600; + color: #1d2129; + text-decoration: none; + margin: 0 0 0.15rem; +} + +.carte-auteur { + margin: 0 0 0.25rem; + color: var(--mb-texte-doux); + font-size: 0.9rem; +} + +.carte-details { + display: flex; + flex-wrap: wrap; + gap: 0.4rem; + align-items: center; + margin: 0 0 0.5rem; + font-size: 0.8rem; + color: var(--mb-texte-doux); +} + +.etiquette { + background: var(--mb-fond-doux); + border-radius: 4px; + padding: 0.1rem 0.4rem; +} + +/* --- Choix entre plusieurs notices --- */ + +/* Les candidats partagent titre et auteur : ce sont l'éditeur et l'année + qui les départagent, donc ils sont mis en avant visuellement. */ +.candidat-distinction { + display: flex; + flex-wrap: wrap; + gap: 0.5rem; + margin: 0 0 0.4rem; + font-size: 0.9rem; +} + +.candidat-editeur { + font-weight: 600; +} + +.candidat-annee { + background: #eef2f7; + border-radius: 4px; + padding: 0.05rem 0.4rem; +} + +/* --- Formulaire --- */ + +.formulaire-apercu { + display: flex; + justify-content: center; + margin-bottom: 1rem; +} + +.zone-danger { + margin-top: 2rem; + padding-top: 1rem; + border-top: 1px solid var(--mb-bord); +} + +/* --- PC : simple élargissement, la structure mobile ne change pas. --- */ + +@media (min-width: 48rem) { + .actions-flottantes { + position: static; + padding: 0; + margin-top: 1.5rem; + background: none; + border-top: 0; + } + + .actions-flottantes .bouton { + flex: 0 0 auto; + } +} diff --git a/MaBibli.Shared/Catalogue/CritereLivres.cs b/MaBibli.Shared/Catalogue/CritereLivres.cs new file mode 100644 index 0000000..948f108 --- /dev/null +++ b/MaBibli.Shared/Catalogue/CritereLivres.cs @@ -0,0 +1,23 @@ +using MaBibli.Shared.Entites; + +namespace MaBibli.Shared.Catalogue; + +/// +/// Critères de consultation du catalogue. +/// +/// +/// Il n'existe volontairement aucun critère sur AjoutePar. La bibliothèque est +/// commune à tout le foyer : filtrer les lectures sur ce champ serait un bug de conception. +/// L'absence de ce critère est l'endroit où l'invariant est le plus visible. +/// +public record CritereLivres +{ + /// Texte cherché dans le titre ou l'auteur. Insensible à la casse. + public string? Recherche { get; init; } + + /// Physique ou numérique. null = les deux : le catalogue est unique. + public Format? Format { get; init; } + + /// Statut de lecture. null = tous. + public Statut? Statut { get; init; } +} diff --git a/MaBibli.Shared/Catalogue/FiltreLivres.cs b/MaBibli.Shared/Catalogue/FiltreLivres.cs new file mode 100644 index 0000000..6020262 --- /dev/null +++ b/MaBibli.Shared/Catalogue/FiltreLivres.cs @@ -0,0 +1,55 @@ +using MaBibli.Shared.Entites; + +namespace MaBibli.Shared.Catalogue; + +/// +/// Filtrage, recherche et tri du catalogue. +/// +/// +/// Écrit sur pour être exécuté par EF Core côté base et +/// testable en mémoire sur une simple liste, sans base ni réseau. +/// +/// La comparaison passe par ToLower() : EF Core la traduit en SQL, et LINQ-to-Objects +/// lui donne le même comportement insensible à la casse. Les accents ne sont pas normalisés +/// (« Emile » ne trouve pas « Émile ») — limitation assumée à ce stade. +/// +/// +public static class FiltreLivres +{ + public static IQueryable Appliquer(IQueryable source, CritereLivres criteres) + { + // AUCUN filtre sur AjoutePar : la bibliothèque est commune (CLAUDE.md). + // Ce commentaire est là pour qu'une régression future saute aux yeux en relecture. + + if (criteres.Format is { } format) + { + source = source.Where(l => l.Format == format); + } + + if (criteres.Statut is { } statut) + { + source = source.Where(l => l.Statut == statut); + } + + var terme = criteres.Recherche?.Trim(); + if (!string.IsNullOrEmpty(terme)) + { + var recherche = terme.ToLower(); + source = source.Where(l => + l.Titre.ToLower().Contains(recherche) + || (l.Auteur != null && l.Auteur.ToLower().Contains(recherche))); + } + + return Trier(source); + } + + /// + /// Tri par titre, puis par identifiant pour rendre l'ordre déterministe. + /// + /// + /// CLAUDE.md ne tranche pas le tri. L'ordre alphabétique a été retenu parce qu'une + /// bibliothèque se parcourt par titre ; l'ordre d'ajout n'a d'intérêt que le jour de la saisie. + /// + public static IQueryable Trier(IQueryable source) => + source.OrderBy(l => l.Titre).ThenBy(l => l.Id); +} diff --git a/MaBibli.Shared/Dtos/EnregistrementLivre.cs b/MaBibli.Shared/Dtos/EnregistrementLivre.cs new file mode 100644 index 0000000..fb088a5 --- /dev/null +++ b/MaBibli.Shared/Dtos/EnregistrementLivre.cs @@ -0,0 +1,34 @@ +using MaBibli.Shared.Entites; + +namespace MaBibli.Shared.Dtos; + +/// +/// Charge utile de création et de modification d'un livre. +/// +/// +/// n'y figure pas volontairement : il est déterminé par le serveur +/// à partir de l'utilisateur authentifié, jamais par le client. +/// +public record EnregistrementLivre +{ + /// Facultatif : certains livres n'ont pas d'ISBN. Normalisé par le serveur. + public string? Isbn { get; set; } + + public string Titre { get; set; } = string.Empty; + + public string? Auteur { get; set; } + + public string? Editeur { get; set; } + + public Format Format { get; set; } + + public Statut Statut { get; set; } + + public string? CoverUrl { get; set; } +} + +/// Charge utile du changement de statut de lecture — l'action la plus fréquente. +public record ChangementStatut +{ + public Statut Statut { get; set; } +} diff --git a/MaBibli.Shared/Dtos/LivreDto.cs b/MaBibli.Shared/Dtos/LivreDto.cs new file mode 100644 index 0000000..cdfb1ce --- /dev/null +++ b/MaBibli.Shared/Dtos/LivreDto.cs @@ -0,0 +1,34 @@ +using MaBibli.Shared.Entites; + +namespace MaBibli.Shared.Dtos; + +/// +/// Un livre tel qu'exposé par l'API. Volontairement sans la collection Prets : +/// les prêts sont la phase 4. +/// +public record LivreDto +{ + public required int Id { get; init; } + + public string? Isbn { get; init; } + + public required string Titre { get; init; } + + public string? Auteur { get; init; } + + public string? Editeur { get; init; } + + public required Format Format { get; init; } + + public required Statut Statut { get; init; } + + public string? CoverUrl { get; init; } + + public required DateTime DateAjout { get; init; } + + /// + /// Qui a saisi le livre. Information d'affichage, pas une frontière : + /// la bibliothèque est commune et aucune lecture ne filtre sur ce champ. + /// + public string? AjoutePar { get; init; } +} diff --git a/MaBibli.Shared/Dtos/UtilisateurCourant.cs b/MaBibli.Shared/Dtos/UtilisateurCourant.cs new file mode 100644 index 0000000..f12448d --- /dev/null +++ b/MaBibli.Shared/Dtos/UtilisateurCourant.cs @@ -0,0 +1,30 @@ +namespace MaBibli.Shared.Dtos; + +/// +/// Identité de la personne connectée, telle que fournie par les en-têtes SSOwat de YunoHost. +/// +/// +/// En développement il n'y a pas de SSOwat : un utilisateur simulé est configuré +/// (Identite:UtilisateurSimule), conformément à CLAUDE.md qui demande de simuler +/// plutôt que de désactiver l'authentification. +/// +public record UtilisateurCourant +{ + /// YNH_USER. null si l'identité n'a pas pu être déterminée. + public string? Identifiant { get; init; } + + /// YNH_USER_EMAIL. + public string? Email { get; init; } + + /// YNH_USER_FULLNAME — évite une requête LDAP pour l'affichage. + public string? NomComplet { get; init; } + + /// Vrai si l'identité provient de la configuration de développement, pas de SSOwat. + public bool Simule { get; init; } + + /// Libellé à afficher, avec repli sur l'identifiant. + public string Affichage => + NomComplet is { Length: > 0 } ? NomComplet : Identifiant ?? "inconnu"; + + public static readonly UtilisateurCourant Anonyme = new(); +} diff --git a/MaBibli.Tests/FiltreLivresTests.cs b/MaBibli.Tests/FiltreLivresTests.cs new file mode 100644 index 0000000..fe14b9c --- /dev/null +++ b/MaBibli.Tests/FiltreLivresTests.cs @@ -0,0 +1,150 @@ +using MaBibli.Shared.Catalogue; +using MaBibli.Shared.Entites; + +namespace MaBibli.Tests; + +/// +/// Filtrage et recherche du catalogue. Purement en mémoire : ni base, ni réseau. +/// +public class FiltreLivresTests +{ + private static readonly Livre[] Catalogue = + [ + new() + { + Id = 1, Titre = "Germinal", Auteur = "Émile Zola", + Format = Format.Physique, Statut = Statut.Lu, AjoutePar = "mathieu", + }, + new() + { + Id = 2, Titre = "La Bête humaine", Auteur = "Émile Zola", + Format = Format.Numerique, Statut = Statut.ALire, AjoutePar = "camille", + }, + new() + { + Id = 3, Titre = "Le Horla", Auteur = "Guy de Maupassant", + Format = Format.Physique, Statut = Statut.EnCours, AjoutePar = "camille", + }, + new() + { + Id = 4, Titre = "Bel-Ami", Auteur = null, + Format = Format.Numerique, Statut = Statut.Lu, AjoutePar = null, + }, + ]; + + private static List Filtrer(CritereLivres criteres) => + FiltreLivres.Appliquer(Catalogue.AsQueryable(), criteres).ToList(); + + [Fact] + public void Sans_critere_tout_le_catalogue_remonte() + => Assert.Equal(4, Filtrer(new CritereLivres()).Count); + + [Fact] + public void Le_tri_est_alphabetique_sur_le_titre() + => Assert.Equal( + ["Bel-Ami", "Germinal", "La Bête humaine", "Le Horla"], + Filtrer(new CritereLivres()).Select(l => l.Titre)); + + [Theory] + [InlineData(Format.Physique, 2)] + [InlineData(Format.Numerique, 2)] + public void Le_filtre_format_ne_garde_que_ce_format(Format format, int attendu) + { + var resultat = Filtrer(new CritereLivres { Format = format }); + + Assert.Equal(attendu, resultat.Count); + Assert.All(resultat, l => Assert.Equal(format, l.Format)); + } + + [Theory] + [InlineData(Statut.ALire, 1)] + [InlineData(Statut.EnCours, 1)] + [InlineData(Statut.Lu, 2)] + public void Le_filtre_statut_ne_garde_que_ce_statut(Statut statut, int attendu) + { + var resultat = Filtrer(new CritereLivres { Statut = statut }); + + Assert.Equal(attendu, resultat.Count); + Assert.All(resultat, l => Assert.Equal(statut, l.Statut)); + } + + [Fact] + public void La_recherche_porte_sur_le_titre() + => Assert.Equal(["Germinal"], Filtrer(new CritereLivres { Recherche = "germ" }).Select(l => l.Titre)); + + [Fact] + public void La_recherche_porte_aussi_sur_lauteur() + => Assert.Equal( + ["Germinal", "La Bête humaine"], + Filtrer(new CritereLivres { Recherche = "zola" }).Select(l => l.Titre)); + + [Theory] + [InlineData("HORLA")] + [InlineData("horla")] + [InlineData(" Horla ")] + public void La_recherche_ignore_la_casse_et_les_espaces_autour(string terme) + => Assert.Single(Filtrer(new CritereLivres { Recherche = terme })); + + [Fact] + public void Un_livre_sans_auteur_ne_fait_pas_echouer_la_recherche() + { + // Le livre 4 a Auteur = null : la clause sur l'auteur doit le traverser sans exploser. + var resultat = Filtrer(new CritereLivres { Recherche = "bel" }); + + Assert.Equal(["Bel-Ami"], resultat.Select(l => l.Titre)); + } + + [Fact] + public void Les_criteres_se_combinent() + { + var resultat = Filtrer(new CritereLivres + { + Recherche = "zola", + Format = Format.Physique, + Statut = Statut.Lu, + }); + + Assert.Equal(["Germinal"], resultat.Select(l => l.Titre)); + } + + [Fact] + public void Une_recherche_vide_equivaut_a_aucune_recherche() + { + Assert.Equal(4, Filtrer(new CritereLivres { Recherche = " " }).Count); + Assert.Equal(4, Filtrer(new CritereLivres { Recherche = "" }).Count); + } + + /// + /// Invariant le plus important du projet. La bibliothèque est commune au foyer : + /// une lecture ne doit JAMAIS être restreinte à ce qu'a saisi l'utilisateur courant. + /// Ce test échouerait au moindre filtre ajouté sur AjoutePar. + /// + [Fact] + public void La_lecture_ne_filtre_jamais_sur_AjoutePar() + { + var resultat = Filtrer(new CritereLivres()); + + // Les livres des trois provenances (deux comptes + un sans trace) sont tous là. + Assert.Contains(resultat, l => l.AjoutePar == "mathieu"); + Assert.Contains(resultat, l => l.AjoutePar == "camille"); + Assert.Contains(resultat, l => l.AjoutePar is null); + Assert.Equal(Catalogue.Length, resultat.Count); + } + + /// + /// Second verrou sur le même invariant, par le type : aucun critère de consultation ne doit + /// permettre d'exprimer un cloisonnement par utilisateur. Si quelqu'un ajoute une propriété + /// « AjoutePar » à , ce test le lui rappelle. + /// + [Fact] + public void Les_criteres_nexposent_aucun_champ_dutilisateur() + { + var proprietes = typeof(CritereLivres) + .GetProperties() + .Select(p => p.Name) + .ToArray(); + + Assert.DoesNotContain(proprietes, nom => nom.Contains("Ajoute", StringComparison.OrdinalIgnoreCase)); + Assert.DoesNotContain(proprietes, nom => nom.Contains("Utilisateur", StringComparison.OrdinalIgnoreCase)); + } +} diff --git a/MaBibli.Tests/FournisseurUtilisateurTests.cs b/MaBibli.Tests/FournisseurUtilisateurTests.cs new file mode 100644 index 0000000..203f411 --- /dev/null +++ b/MaBibli.Tests/FournisseurUtilisateurTests.cs @@ -0,0 +1,110 @@ +using MaBibli.Api.Services.Identite; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.Options; + +namespace MaBibli.Tests; + +/// +/// Lecture de l'identité dans les en-têtes SSOwat, et repli sur l'utilisateur simulé +/// en développement. +/// +public class FournisseurUtilisateurTests +{ + private static FournisseurUtilisateurSsowat Construire( + IDictionary? entetes = null, + OptionsIdentite? options = null) + { + var contexte = new DefaultHttpContext(); + foreach (var (nom, valeur) in entetes ?? new Dictionary()) + { + contexte.Request.Headers[nom] = valeur; + } + + var accesseur = new HttpContextAccessor { HttpContext = contexte }; + return new FournisseurUtilisateurSsowat(accesseur, Options.Create(options ?? new OptionsIdentite())); + } + + [Fact] + public void Les_entetes_ssowat_sont_lus() + { + var fournisseur = Construire(new Dictionary + { + ["YNH_USER"] = "mathieu", + ["YNH_USER_EMAIL"] = "mathieu@example.org", + ["YNH_USER_FULLNAME"] = "Mathieu L.", + }); + + var utilisateur = fournisseur.Obtenir(); + + Assert.Equal("mathieu", utilisateur.Identifiant); + Assert.Equal("mathieu@example.org", utilisateur.Email); + Assert.Equal("Mathieu L.", utilisateur.NomComplet); + Assert.False(utilisateur.Simule); + Assert.Equal("Mathieu L.", utilisateur.Affichage); + } + + [Fact] + public void Lentete_utilisateur_seul_suffit() + { + var utilisateur = Construire(new Dictionary { ["YNH_USER"] = "camille" }).Obtenir(); + + Assert.Equal("camille", utilisateur.Identifiant); + Assert.Null(utilisateur.Email); + Assert.Equal("camille", utilisateur.Affichage); + } + + [Fact] + public void Lentete_ssowat_prime_sur_lutilisateur_simule() + { + // Sur le serveur, une configuration de dev oubliée ne doit jamais masquer l'identité réelle. + var fournisseur = Construire( + new Dictionary { ["YNH_USER"] = "camille" }, + new OptionsIdentite { UtilisateurSimule = "dev" }); + + var utilisateur = fournisseur.Obtenir(); + + Assert.Equal("camille", utilisateur.Identifiant); + Assert.False(utilisateur.Simule); + } + + [Fact] + public void Sans_entete_lutilisateur_simule_prend_le_relais() + { + var fournisseur = Construire(options: new OptionsIdentite + { + UtilisateurSimule = "dev", + EmailSimule = "dev@localhost", + NomCompletSimule = "Utilisateur de développement", + }); + + var utilisateur = fournisseur.Obtenir(); + + Assert.Equal("dev", utilisateur.Identifiant); + Assert.Equal("Utilisateur de développement", utilisateur.NomComplet); + Assert.True(utilisateur.Simule); + } + + [Fact] + public void Sans_entete_ni_simulation_lidentite_reste_inconnue() + { + // On n'invente pas d'identité : mieux vaut un livre sans traçabilité qu'un faux nom. + var utilisateur = Construire().Obtenir(); + + Assert.Null(utilisateur.Identifiant); + Assert.False(utilisateur.Simule); + Assert.Equal("inconnu", utilisateur.Affichage); + } + + [Fact] + public void Un_entete_vide_est_traite_comme_absent() + { + var fournisseur = Construire( + new Dictionary { ["YNH_USER"] = " " }, + new OptionsIdentite { UtilisateurSimule = "dev" }); + + var utilisateur = fournisseur.Obtenir(); + + Assert.Equal("dev", utilisateur.Identifiant); + Assert.True(utilisateur.Simule); + } +} diff --git a/MaBibli.Tests/ServiceCatalogueTests.cs b/MaBibli.Tests/ServiceCatalogueTests.cs new file mode 100644 index 0000000..0d1648a --- /dev/null +++ b/MaBibli.Tests/ServiceCatalogueTests.cs @@ -0,0 +1,219 @@ +using MaBibli.Api.Data; +using MaBibli.Api.Services.Catalogue; +using MaBibli.Shared.Catalogue; +using MaBibli.Shared.Dtos; +using MaBibli.Shared.Entites; +using Microsoft.Data.Sqlite; +using Microsoft.EntityFrameworkCore; + +namespace MaBibli.Tests; + +/// +/// CRUD du catalogue, sur une base SQLite en mémoire : même moteur qu'en production, +/// aucun fichier, aucun réseau. +/// +public class ServiceCatalogueTests : IDisposable +{ + private readonly SqliteConnection _connexion; + private readonly MaBibliDbContext _db; + private readonly ServiceCatalogue _service; + + public ServiceCatalogueTests() + { + // La connexion doit rester ouverte : SQLite détruit une base « :memory: » à sa fermeture. + _connexion = new SqliteConnection("Data Source=:memory:"); + _connexion.Open(); + + var options = new DbContextOptionsBuilder() + .UseSqlite(_connexion) + .Options; + + _db = new MaBibliDbContext(options); + _db.Database.EnsureCreated(); + + _service = new ServiceCatalogue(_db); + } + + private static EnregistrementLivre Saisie( + string titre, + string? auteur = null, + string? isbn = null, + Format format = Format.Physique, + Statut statut = Statut.ALire) => new() + { + Titre = titre, + Auteur = auteur, + Isbn = isbn, + Format = format, + Statut = statut, + }; + + [Fact] + public async Task Creer_enregistre_le_livre_et_trace_lauteur_de_la_saisie() + { + var resultat = await _service.CreerAsync(Saisie("Germinal", "Émile Zola"), "mathieu"); + + Assert.Null(resultat.Erreur); + var livre = resultat.Livre!; + Assert.True(livre.Id > 0); + Assert.Equal("Germinal", livre.Titre); + Assert.Equal("mathieu", livre.AjoutePar); + Assert.NotEqual(default, livre.DateAjout); + } + + [Fact] + public async Task Creer_normalise_lisbn() + { + var resultat = await _service.CreerAsync(Saisie("Germinal", isbn: "978-2-253-00422-6"), "mathieu"); + + Assert.Equal("9782253004226", resultat.Livre!.Isbn); + } + + [Fact] + public async Task Creer_refuse_un_titre_vide() + { + var resultat = await _service.CreerAsync(Saisie(" "), "mathieu"); + + Assert.Null(resultat.Livre); + Assert.NotNull(resultat.Erreur); + } + + [Fact] + public async Task Creer_refuse_un_isbn_invalide_mais_accepte_labsence_disbn() + { + var invalide = await _service.CreerAsync(Saisie("Sans titre", isbn: "1234567890123"), "mathieu"); + Assert.Null(invalide.Livre); + Assert.NotNull(invalide.Erreur); + + // Certains livres n'ont pas d'ISBN : l'absence doit rester parfaitement légitime. + var sansIsbn = await _service.CreerAsync(Saisie("Carnet de famille"), "mathieu"); + Assert.Null(sansIsbn.Erreur); + Assert.Null(sansIsbn.Livre!.Isbn); + } + + [Fact] + public async Task Le_catalogue_est_unique_physiques_et_numeriques_cohabitent() + { + await _service.CreerAsync(Saisie("Papier", format: Format.Physique), "mathieu"); + await _service.CreerAsync(Saisie("Epub", format: Format.Numerique), "mathieu"); + + var tout = await _service.ListerAsync(new CritereLivres()); + Assert.Equal(2, tout.Count); + + var numeriques = await _service.ListerAsync(new CritereLivres { Format = Format.Numerique }); + Assert.Equal(["Epub"], numeriques.Select(l => l.Titre)); + } + + [Fact] + public async Task La_recherche_traverse_bien_SQLite() + { + await _service.CreerAsync(Saisie("Germinal", "Émile Zola"), "mathieu"); + await _service.CreerAsync(Saisie("Le Horla", "Guy de Maupassant"), "mathieu"); + + var parAuteur = await _service.ListerAsync(new CritereLivres { Recherche = "maupassant" }); + Assert.Equal(["Le Horla"], parAuteur.Select(l => l.Titre)); + + var parTitre = await _service.ListerAsync(new CritereLivres { Recherche = "GERMI" }); + Assert.Equal(["Germinal"], parTitre.Select(l => l.Titre)); + } + + /// + /// Invariant central : la bibliothèque est commune. Des livres saisis par des comptes + /// différents doivent tous ressortir, quelle que soit la personne qui consulte. + /// + [Fact] + public async Task La_liste_renvoie_les_livres_de_tous_les_utilisateurs() + { + await _service.CreerAsync(Saisie("Livre de Mathieu"), "mathieu"); + await _service.CreerAsync(Saisie("Livre de Camille"), "camille"); + await _service.CreerAsync(Saisie("Livre anonyme"), null); + + var tout = await _service.ListerAsync(new CritereLivres()); + + Assert.Equal(3, tout.Count); + Assert.Contains(tout, l => l.AjoutePar == "mathieu"); + Assert.Contains(tout, l => l.AjoutePar == "camille"); + Assert.Contains(tout, l => l.AjoutePar is null); + } + + [Fact] + public async Task Modifier_ne_reecrit_ni_AjoutePar_ni_DateAjout() + { + var cree = (await _service.CreerAsync(Saisie("Titre initial", "Auteur"), "camille")).Livre!; + + var modifie = await _service.ModifierAsync(cree.Id, new EnregistrementLivre + { + Titre = "Titre corrigé", + Auteur = "Auteur corrigé", + Format = Format.Numerique, + Statut = Statut.Lu, + }); + + Assert.Null(modifie.Erreur); + Assert.Equal("Titre corrigé", modifie.Livre!.Titre); + Assert.Equal(Format.Numerique, modifie.Livre.Format); + + // La trace de la saisie d'origine survit à l'édition, y compris par quelqu'un d'autre. + Assert.Equal("camille", modifie.Livre.AjoutePar); + Assert.Equal(cree.DateAjout, modifie.Livre.DateAjout); + } + + [Fact] + public async Task Modifier_un_livre_inexistant_est_signale_comme_introuvable() + { + var resultat = await _service.ModifierAsync(4242, Saisie("Fantôme")); + + Assert.True(resultat.EstIntrouvable); + } + + [Fact] + public async Task Changer_le_statut_ne_touche_que_le_statut() + { + var cree = (await _service.CreerAsync(Saisie("Germinal", "Émile Zola"), "mathieu")).Livre!; + + var apres = await _service.ChangerStatutAsync(cree.Id, Statut.EnCours); + + Assert.NotNull(apres); + Assert.Equal(Statut.EnCours, apres!.Statut); + Assert.Equal(cree.Titre, apres.Titre); + Assert.Equal(cree.Auteur, apres.Auteur); + Assert.Equal(cree.AjoutePar, apres.AjoutePar); + } + + [Fact] + public async Task Changer_le_statut_dun_livre_inexistant_renvoie_null() + => Assert.Null(await _service.ChangerStatutAsync(4242, Statut.Lu)); + + [Fact] + public async Task Supprimer_retire_le_livre_du_catalogue() + { + var cree = (await _service.CreerAsync(Saisie("À jeter"), "mathieu")).Livre!; + + Assert.True(await _service.SupprimerAsync(cree.Id)); + Assert.Null(await _service.ObtenirAsync(cree.Id)); + Assert.Empty(await _service.ListerAsync(new CritereLivres())); + + // Seconde suppression : plus rien à supprimer. + Assert.False(await _service.SupprimerAsync(cree.Id)); + } + + [Fact] + public async Task Obtenir_renvoie_la_fiche_complete() + { + var cree = (await _service.CreerAsync( + Saisie("Le Horla", "Guy de Maupassant", "9782080704092"), "mathieu")).Livre!; + + var relu = await _service.ObtenirAsync(cree.Id); + + Assert.NotNull(relu); + Assert.Equal("Le Horla", relu!.Titre); + Assert.Equal("9782080704092", relu.Isbn); + } + + public void Dispose() + { + _db.Dispose(); + _connexion.Dispose(); + GC.SuppressFinalize(this); + } +}