Files
mabibli/MaBibli.Tests/ServiceRevuesTests.cs
T
Mathieu LimonierandClaude Opus 5 6a6d745af4 MaBibli 1.0.0
Gestion de bibliothèque personnelle auto-hébergée : catalogue, prêts,
scan de code-barres, consultation hors-ligne.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-22 22:36:16 +02:00

376 lines
13 KiB
C#

using MaBibli.Api.Data;
using MaBibli.Api.Services.Revues;
using MaBibli.Shared.Dtos;
using Microsoft.Data.Sqlite;
using Microsoft.EntityFrameworkCore;
namespace MaBibli.Tests;
/// <summary>
/// Revues et numéros, sur une base SQLite <b>en mémoire</b> : l'unicité d'un ISSN passe par un
/// index partiel, qui n'existe qu'en base.
/// </summary>
/// <remarks>
/// L'invariant central : <b>une fiche par revue</b>, les numéros à l'intérieur. C'est ce qui
/// évite douze fiches identiques pour douze numéros d'un même magazine.
/// </remarks>
public class ServiceRevuesTests : IDisposable
{
private readonly SqliteConnection _connexion;
private readonly MaBibliDbContext _db;
private readonly ServiceRevues _revues;
private const string Mathieu = "mathieu";
public ServiceRevuesTests()
{
_connexion = new SqliteConnection("Data Source=:memory:");
_connexion.Open();
var options = new DbContextOptionsBuilder<MaBibliDbContext>()
.UseSqlite(_connexion)
.Options;
_db = new MaBibliDbContext(options);
_db.Database.EnsureCreated();
_revues = new ServiceRevues(_db);
}
private async Task<RevueDto> RevueAsync(string titre = "Médor", string? issn = "2466-6718")
{
var resultat = await _revues.CreerOuRetrouverAsync(
new EnregistrementRevue { Titre = titre, Issn = issn }, Mathieu);
Assert.Null(resultat.Erreur);
return resultat.Revue!;
}
[Fact]
public async Task Rescanner_la_meme_revue_ne_cree_pas_une_seconde_fiche()
{
var premiere = await RevueAsync();
// Le second numéro du même magazine porte le même ISSN : c'est le cas NORMAL du flux
// « 977 », et il ne doit surtout pas produire une fiche de plus.
var seconde = await RevueAsync("Médor (Bruxelles)");
Assert.Equal(premiere.Id, seconde.Id);
Assert.Single(await _revues.ListerAsync());
}
[Fact]
public async Task Une_revue_saisie_a_la_main_apprend_son_issn_au_premier_scan()
{
var aLaMain = await RevueAsync("Médor", issn: null);
var scannee = await RevueAsync("Médor");
Assert.Equal(aLaMain.Id, scannee.Id);
Assert.Equal("2466-6718", scannee.Issn);
}
[Fact]
public async Task Les_numeros_vivent_dans_la_revue_et_ne_font_pas_de_fiches()
{
var revue = await RevueAsync();
await _revues.AjouterNumeroAsync(revue.Id, new AjoutNumeroRevue
{
Numero = "43",
DateParution = new DateTime(2024, 6, 1, 0, 0, 0, DateTimeKind.Utc),
});
var apres = (await _revues.AjouterNumeroAsync(revue.Id, new AjoutNumeroRevue
{
Numero = "44",
DateParution = new DateTime(2024, 9, 1, 0, 0, 0, DateTimeKind.Utc),
})).Revue!;
Assert.Single(await _revues.ListerAsync());
// Du plus récent au plus ancien : sur un magazine, c'est le dernier reçu qu'on cherche.
Assert.Equal(["44", "43"], apres.Numeros.Select(n => n.Numero));
}
[Fact]
public async Task Un_numero_sans_numero_est_refuse()
{
var revue = await RevueAsync();
var resultat = await _revues.AjouterNumeroAsync(revue.Id, new AjoutNumeroRevue { Numero = " " });
Assert.NotNull(resultat.Erreur);
}
[Fact]
public async Task Le_meme_numero_ne_sajoute_pas_deux_fois()
{
var revue = await RevueAsync();
await _revues.AjouterNumeroAsync(revue.Id, new AjoutNumeroRevue { Numero = "43" });
var doublon = await _revues.AjouterNumeroAsync(revue.Id, new AjoutNumeroRevue { Numero = "43" });
Assert.NotNull(doublon.Erreur);
}
[Fact]
public async Task Un_numero_a_paraitre_est_refuse()
{
var revue = await RevueAsync();
var resultat = await _revues.AjouterNumeroAsync(revue.Id, new AjoutNumeroRevue
{
Numero = "99",
DateParution = DateTime.UtcNow.AddMonths(2),
});
Assert.NotNull(resultat.Erreur);
}
[Fact]
public async Task Les_numeros_sans_date_ferment_la_liste()
{
var revue = await RevueAsync();
await _revues.AjouterNumeroAsync(revue.Id, new AjoutNumeroRevue { Numero = "hors-série 1" });
var apres = (await _revues.AjouterNumeroAsync(revue.Id, new AjoutNumeroRevue
{
Numero = "43",
DateParution = new DateTime(2024, 6, 1, 0, 0, 0, DateTimeKind.Utc),
})).Revue!;
// Une date inconnue ne doit pas faire passer un numéro pour le plus ancien.
Assert.Equal(["43", "hors-série 1"], apres.Numeros.Select(n => n.Numero));
}
[Fact]
public async Task Supprimer_la_revue_emporte_ses_numeros()
{
var revue = await RevueAsync();
await _revues.AjouterNumeroAsync(revue.Id, new AjoutNumeroRevue { Numero = "43" });
Assert.True(await _revues.SupprimerAsync(revue.Id));
// Contrairement aux livres d'une série, un numéro n'existe que par sa revue.
Assert.Empty(await _db.NumerosRevue.ToListAsync());
}
[Fact]
public async Task Deux_revues_ne_partagent_ni_titre_ni_issn()
{
await RevueAsync();
var autre = await RevueAsync("Socialter", issn: "2270-4426");
var titrePris = await _revues.ModifierAsync(
autre.Id, new EnregistrementRevue { Titre = "médor" });
var issnPris = await _revues.ModifierAsync(
autre.Id, new EnregistrementRevue { Titre = "Socialter", Issn = "2466-6718" });
Assert.NotNull(titrePris.Erreur);
Assert.NotNull(issnPris.Erreur);
}
/// <summary>
/// ⚠️ Le cas qui a motivé la canonisation (lot K2) : une revue saisie à la main, ISSN tapé
/// sans tiret, doit être <b>retrouvée</b> par le scan — qui produit toujours la forme à
/// tiret. Sans elle, le code-barres créait une seconde fiche à côté de la première.
/// </summary>
[Fact]
public async Task Un_issn_sans_tiret_designe_la_meme_revue_que_le_code_barres()
{
var aLaMain = await RevueAsync("Médor", issn: "24666718");
Assert.Equal("2466-6718", aLaMain.Issn);
var scanne = await RevueAsync("Médor (Bruxelles)", issn: "2466-6718");
Assert.Equal(aLaMain.Id, scanne.Id);
Assert.Single(await _revues.ListerAsync());
}
[Fact]
public async Task Un_numero_porte_sa_couverture_et_ses_articles_a_la_une()
{
var revue = await RevueAsync();
var apres = await _revues.AjouterNumeroAsync(revue.Id, new AjoutNumeroRevue
{
Numero = "43",
CoverUrl = "https://exemple.fr/medor-43.jpg",
Articles = ["Ukraine, deux ans après", "Le retour du train de nuit"],
});
var numero = Assert.Single(apres.Revue!.Numeros);
Assert.Equal("https://exemple.fr/medor-43.jpg", numero.CoverUrl);
Assert.Equal(["Ukraine, deux ans après", "Le retour du train de nuit"], numero.Articles);
}
/// <summary>
/// La une se recopie <b>après coup</b> : on note un numéro le jour où on le range, on en
/// recopie le sommaire plus tard. Sans modification, ces champs n'auraient existé qu'à la
/// création, c'est-à-dire jamais.
/// </summary>
[Fact]
public async Task Un_numero_apprend_sa_une_apres_coup()
{
var revue = await RevueAsync();
var cree = (await _revues.AjouterNumeroAsync(
revue.Id, new AjoutNumeroRevue { Numero = "43" })).Revue!;
var numeroId = cree.Numeros[0].Id;
var apres = await _revues.ModifierNumeroAsync(numeroId, new AjoutNumeroRevue
{
Numero = "43",
Articles = ["Le retour du train de nuit"],
});
Assert.Equal(["Le retour du train de nuit"], apres.Revue!.Numeros[0].Articles);
}
/// <summary>
/// ⚠️ Remplacement et non fusion : la ligne de saisie <b>est</b> la liste. Un titre effacé
/// du champ doit disparaître, comme pour les thèmes d'un livre.
/// </summary>
[Fact]
public async Task Reecrire_la_une_remplace_lancienne()
{
var revue = await RevueAsync();
var cree = (await _revues.AjouterNumeroAsync(revue.Id, new AjoutNumeroRevue
{
Numero = "43",
Articles = ["Premier", "Second"],
})).Revue!;
var apres = await _revues.ModifierNumeroAsync(
cree.Numeros[0].Id, new AjoutNumeroRevue { Numero = "43", Articles = ["Troisième"] });
Assert.Equal(["Troisième"], apres.Revue!.Numeros[0].Articles);
Assert.Single(await _db.ArticlesUne.ToListAsync());
}
[Fact]
public async Task Retirer_le_numero_emporte_sa_une()
{
var revue = await RevueAsync();
var cree = (await _revues.AjouterNumeroAsync(revue.Id, new AjoutNumeroRevue
{
Numero = "43",
Articles = ["Ukraine, deux ans après"],
})).Revue!;
Assert.True(await _revues.RetirerNumeroAsync(cree.Numeros[0].Id));
// Cascade : un article à la une n'existe que par la parution qui le porte.
Assert.Empty(await _db.ArticlesUne.ToListAsync());
}
/// <summary>
/// L'unicité <c>(NumeroRevueId, TitreNormalise)</c> vit en base : un doublon envoyé
/// directement à l'API ferait échouer l'écriture si le service ne l'écartait pas.
/// </summary>
[Fact]
public async Task Le_meme_article_ne_sannonce_pas_deux_fois()
{
var revue = await RevueAsync();
var apres = await _revues.AjouterNumeroAsync(revue.Id, new AjoutNumeroRevue
{
Numero = "43",
Articles = ["Ukraine, deux ans après", "ukraine, DEUX ans après", " "],
});
Assert.Equal(["Ukraine, deux ans après"], apres.Revue!.Numeros[0].Articles);
}
[Fact]
public async Task Deux_numeros_peuvent_titrer_pareil()
{
var revue = await RevueAsync();
await _revues.AjouterNumeroAsync(revue.Id, new AjoutNumeroRevue
{
Numero = "43",
Articles = ["Le dossier de l'année"],
});
var apres = await _revues.AjouterNumeroAsync(revue.Id, new AjoutNumeroRevue
{
Numero = "44",
Articles = ["Le dossier de l'année"],
});
// L'unicité est bornée au numéro : deux parutions peuvent parfaitement titrer pareil.
Assert.Null(apres.Erreur);
Assert.Equal(2, (await _db.ArticlesUne.ToListAsync()).Count);
}
/// <summary>
/// L'écran d'édition (lot W) affiche <b>telle quelle</b> la revue rendue par la
/// modification d'un numéro : elle doit donc revenir déjà rangée.
/// </summary>
/// <remarks>
/// ⚠️ C'est ce qui empêche de croire qu'on a mal saisi une date : corriger la parution d'un
/// numéro le déplace dans la liste, et l'écran ne recharge rien de son côté. Les numéros
/// sans date ferment la liste, ici comme partout — une date inconnue n'est pas une date
/// ancienne.
/// </remarks>
[Fact]
public async Task Corriger_la_parution_range_le_numero_a_sa_place()
{
var revue = await RevueAsync();
await _revues.AjouterNumeroAsync(revue.Id, new AjoutNumeroRevue
{
Numero = "43",
DateParution = new DateTime(2024, 6, 1, 0, 0, 0, DateTimeKind.Utc),
});
var cree = (await _revues.AjouterNumeroAsync(revue.Id, new AjoutNumeroRevue
{
Numero = "hors-série 1",
})).Revue!;
Assert.Equal(["43", "hors-série 1"], cree.Numeros.Select(n => n.Numero));
var horsSerie = cree.Numeros.First(n => n.Numero == "hors-série 1").Id;
var apres = await _revues.ModifierNumeroAsync(horsSerie, new AjoutNumeroRevue
{
Numero = "hors-série 1",
DateParution = new DateTime(2025, 3, 1, 0, 0, 0, DateTimeKind.Utc),
});
Assert.Equal(["hors-série 1", "43"], apres.Revue!.Numeros.Select(n => n.Numero));
}
/// <summary>
/// Retirer un numéro depuis l'écran d'édition ne touche qu'à lui : les autres parutions et
/// la fiche de la revue restent en place.
/// </summary>
[Fact]
public async Task Retirer_un_numero_laisse_la_revue_et_ses_autres_numeros()
{
var revue = await RevueAsync();
await _revues.AjouterNumeroAsync(revue.Id, new AjoutNumeroRevue { Numero = "43" });
var cree = (await _revues.AjouterNumeroAsync(
revue.Id, new AjoutNumeroRevue { Numero = "44" })).Revue!;
Assert.True(await _revues.RetirerNumeroAsync(cree.Numeros.First(n => n.Numero == "44").Id));
var restantes = await _revues.ListerAsync();
Assert.Equal(["43"], Assert.Single(restantes).Numeros.Select(n => n.Numero));
}
public void Dispose()
{
_db.Dispose();
_connexion.Dispose();
GC.SuppressFinalize(this);
}
}