diff --git a/ManagerService.Tests/Data/AuditLogTests.cs b/ManagerService.Tests/Data/AuditLogTests.cs
new file mode 100644
index 0000000..1472832
--- /dev/null
+++ b/ManagerService.Tests/Data/AuditLogTests.cs
@@ -0,0 +1,164 @@
+using Manager.DTOs;
+using ManagerService.Data;
+using ManagerService.Data.SubSection;
+using ManagerService.DTOs;
+using ManagerService.Tests.Infrastructure;
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Reflection;
+using System.Text.Json;
+using Xunit;
+
+namespace ManagerService.Tests.Data
+{
+ ///
+ /// Le journal d'audit ne voyait aucune section : le filtre exigeait l'égalité exacte
+ /// de type, or est abstraite. Ces tests tiennent l'invariant —
+ /// un sous-type est journalisé, et il l'est sous le nom « Section ».
+ ///
+ public class AuditLogTests
+ {
+ private static SectionArticle NewArticle(string id = "s1") =>
+ TestSection.Article(id, "i1", "Label", "c1");
+
+ [Fact]
+ public void SectionSubType_IsAudited()
+ {
+ using var db = DbContextFactory.Create();
+
+ db.Sections.Add(NewArticle());
+ db.SaveChanges();
+
+ var log = Assert.Single(db.AuditLogs.ToList());
+ Assert.Equal("Create", log.Action);
+ Assert.Equal("s1", log.EntityId);
+ Assert.Equal("i1", log.InstanceId);
+ }
+
+ [Fact]
+ public void SectionSubType_IsLoggedUnderBaseTypeName()
+ {
+ using var db = DbContextFactory.Create();
+
+ db.Sections.Add(NewArticle());
+ db.SaveChanges();
+
+ // Le filtre « Section » de l'écran d'audit interroge ce nom : s'il portait
+ // « SectionArticle », l'écran ne rendrait toujours aucune ligne.
+ Assert.Equal("Section", db.AuditLogs.Single().EntityType);
+ }
+
+ [Fact]
+ public void SectionSubType_KeepsConcreteTypeInValues()
+ {
+ using var db = DbContextFactory.Create();
+
+ db.Sections.Add(NewArticle());
+ db.SaveChanges();
+
+ // Normaliser EntityType ne perd pas le sous-type : le discriminateur TPH est
+ // une propriété du modèle, donc sérialisée avec les autres.
+ var values = JsonSerializer.Deserialize>(
+ db.AuditLogs.Single().NewValues!);
+ Assert.Equal("Article", values!["Discriminator"].GetString());
+ }
+
+ [Fact]
+ public void SectionUpdate_RecordsOnlyChangedFields()
+ {
+ using var db = DbContextFactory.Create();
+ db.Sections.Add(NewArticle());
+ db.SaveChanges();
+
+ var section = db.Sections.Single();
+ section.Label = "Renommée";
+ db.SaveChanges();
+
+ var update = db.AuditLogs.Single(a => a.Action == "Update");
+ var oldValues = JsonSerializer.Deserialize>(update.OldValues!);
+ var newValues = JsonSerializer.Deserialize>(update.NewValues!);
+
+ Assert.Equal("Label", Assert.Single(newValues!.Keys.Where(k => k != "DateUpdate")));
+ Assert.Equal("Label", oldValues!["Label"].GetString());
+ Assert.Equal("Renommée", newValues["Label"].GetString());
+ }
+
+ [Fact]
+ public void SectionDelete_IsAudited()
+ {
+ using var db = DbContextFactory.Create();
+ db.Sections.Add(NewArticle());
+ db.SaveChanges();
+
+ db.Sections.Remove(db.Sections.Single());
+ db.SaveChanges();
+
+ var delete = db.AuditLogs.Single(a => a.Action == "Delete");
+ Assert.Equal("Section", delete.EntityType);
+ Assert.Equal("s1", delete.EntityId);
+ }
+
+ [Fact]
+ public void NonAuditedEntity_IsNotLogged()
+ {
+ using var db = DbContextFactory.Create();
+
+ db.VisitEvents.Add(new VisitEvent
+ {
+ Id = "v1",
+ InstanceId = "i1",
+ SessionId = "sess",
+ EventType = VisitEventType.SectionView,
+ Timestamp = DateTime.UtcNow
+ });
+ db.SaveChanges();
+
+ Assert.Empty(db.AuditLogs.ToList());
+ }
+
+ [Fact]
+ public void OtherAuditedTypes_KeepTheirOwnName()
+ {
+ using var db = DbContextFactory.Create();
+
+ db.Users.Add(new User
+ {
+ Id = "u1",
+ InstanceId = "i1",
+ Email = "a@b.c",
+ FirstName = "A",
+ LastName = "B",
+ Password = "x",
+ Token = "t"
+ });
+ db.SaveChanges();
+
+ Assert.Equal("User", db.AuditLogs.Single().EntityType);
+ }
+
+ ///
+ /// Le trou d'origine venait d'une liste de types que rien n'obligeait à suivre les
+ /// sous-classes. Par réflexion : tout sous-type concret de Section doit être journalisé.
+ ///
+ [Fact]
+ public void EverySectionSubType_ResolvesToSection()
+ {
+ var subTypes = typeof(Section).Assembly.GetTypes()
+ .Where(t => typeof(Section).IsAssignableFrom(t) && !t.IsAbstract)
+ .ToList();
+
+ Assert.Equal(13, subTypes.Count);
+
+ var auditedTypeOf = typeof(MyInfoMateDbContext)
+ .GetMethod("AuditedTypeOf", BindingFlags.NonPublic | BindingFlags.Static)!;
+
+ foreach (var subType in subTypes)
+ {
+ var instance = System.Runtime.CompilerServices.RuntimeHelpers
+ .GetUninitializedObject(subType);
+ Assert.Equal(typeof(Section), auditedTypeOf.Invoke(null, new[] { instance }));
+ }
+ }
+ }
+}
diff --git a/ManagerService/Data/MyInfoMateDbContext.cs b/ManagerService/Data/MyInfoMateDbContext.cs
index df20721..300ef6d 100644
--- a/ManagerService/Data/MyInfoMateDbContext.cs
+++ b/ManagerService/Data/MyInfoMateDbContext.cs
@@ -108,12 +108,26 @@ namespace ManagerService.Data
}
}
- private static readonly HashSet AuditedTypes = new()
+ private static readonly Type[] AuditedTypes =
{
typeof(Section), typeof(Resource), typeof(Configuration),
typeof(Device), typeof(User), typeof(Instance)
};
+ ///
+ /// Type journalisé dont l'entité relève, ou null si elle n'est pas journalisée.
+ ///
+ ///
+ /// L'égalité exacte de type ne convient pas : est abstraite,
+ /// le type runtime est toujours un des 13 sous-types (SectionMap, SectionQuiz…),
+ /// et aucune section n'était donc journalisée. On remonte à la classe de base pour
+ /// que EntityType porte « Section » — le filtre de l'écran d'audit interroge
+ /// ce nom-là, et un nouveau sous-type y entre sans que personne n'ait à l'inscrire.
+ /// Le sous-type concret reste lisible dans les valeurs, via le discriminateur TPH.
+ ///
+ private static Type AuditedTypeOf(object entity) =>
+ AuditedTypes.FirstOrDefault(t => t.IsInstanceOfType(entity));
+
private List BuildAuditEntries()
{
var userId = _httpContextAccessor?.HttpContext?.User?.FindFirstValue(ClaimTypes.NameIdentifier);
@@ -123,11 +137,12 @@ namespace ManagerService.Data
// ChangeTracker, ce qui invaliderait l'énumération en cours
// ("Collection was modified"). Les logs sont donc ajoutés après la boucle.
var auditedEntries = ChangeTracker.Entries()
- .Where(e => AuditedTypes.Contains(e.Entity.GetType())
- && e.State is EntityState.Added or EntityState.Modified or EntityState.Deleted)
+ .Select(e => new { Entry = e, AuditedType = AuditedTypeOf(e.Entity) })
+ .Where(x => x.AuditedType != null
+ && x.Entry.State is EntityState.Added or EntityState.Modified or EntityState.Deleted)
.ToList();
- foreach (var entry in auditedEntries)
+ foreach (var (entry, auditedType) in auditedEntries.Select(x => (x.Entry, x.AuditedType)))
{
var action = entry.State switch
{
@@ -163,7 +178,7 @@ namespace ManagerService.Data
var log = new AuditLog
{
- EntityType = entry.Entity.GetType().Name,
+ EntityType = auditedType.Name,
EntityId = entityId ?? "",
Action = action!,
UserId = userId,