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); + } +}