Regression introduite par le commit qui journalise les sections.
WeatherSyncService ecrit `section.WeatherResult` sur cron, a 6 h et a 13 h.
Le job existait et etait benin tant que les sections n'etaient pas auditees ;
depuis, chaque rafraichissement produisait une ligne portant la prevision
OpenWeather complete en avant ET en apres -- quelques dizaines de Ko, deux
fois par jour, par section meteo, indefiniment.
Le stockage est le moindre probleme : ce bruit noie les modifications
humaines que l'ecran d'audit existe pour montrer.
Correctif : une liste de colonnes machine (WeatherResult,
WeatherUpdatedDate, DateUpdate) exclues du journal, et surtout aucune ligne
produite quand une modification ne touche qu'elles -- plutot qu'une ligne au
diff vide, qui aurait deplace le bruit sans le retirer.
DateUpdate y est pour une raison distincte de la meteo : estampille a chaque
SaveChanges, il figurait dans tous les diffs sans jamais rien y apprendre. Le
signal etait deja la -- un test ecrit hier devait l'ecarter a la main pour
rester lisible (`newValues.Keys.Where(k => k != "DateUpdate")`). Quand un
test doit filtrer une donnee pour etre lisible, la donnee n'a rien a y faire.
Verifie au passage : AgendaSyncService ecrit des EventAgenda, qui ne sont pas
audites, et ne touche pas la ligne Section. Lui n'est pas concerne.
⚠️ Consequence sur la dette signalee hier : sans le flot meteo, la table ne
grossit plus qu'au rythme des editions humaines -- de l'ordre de quelques
dizaines de milliers de lignes par an sur 4 clients. L'index sur Timestamp
evoque hier n'a plus lieu d'etre, donc pas de migration et le gel du lot B
n'est pas remis en cause. La purge reste pertinente, mais comme sujet RGPD
(lot J), plus comme sujet de volume.
3 tests. dotnet test 203/203.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
239 lines
8.6 KiB
C#
239 lines
8.6 KiB
C#
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
|
|
{
|
|
/// <summary>
|
|
/// Le journal d'audit ne voyait aucune section : le filtre exigeait l'égalité exacte
|
|
/// de type, or <see cref="Section"/> est abstraite. Ces tests tiennent l'invariant —
|
|
/// un sous-type est journalisé, et il l'est sous le nom « Section ».
|
|
/// </summary>
|
|
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<Dictionary<string, JsonElement>>(
|
|
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<Dictionary<string, JsonElement>>(update.OldValues!);
|
|
var newValues = JsonSerializer.Deserialize<Dictionary<string, JsonElement>>(update.NewValues!);
|
|
|
|
// DateUpdate est estampillé à chaque SaveChanges : il n'a rien à faire dans le
|
|
// diff, et son absence est ce qui rend cette assertion lisible.
|
|
Assert.Equal("Label", Assert.Single(newValues!.Keys));
|
|
Assert.Equal("Label", oldValues!["Label"].GetString());
|
|
Assert.Equal("Renommée", newValues["Label"].GetString());
|
|
}
|
|
|
|
// ── COLONNES MACHINE ─────────────────────────────────────────────────
|
|
//
|
|
// WeatherSyncService réécrit WeatherResult deux fois par jour, sur cron. Depuis que
|
|
// les sections sont journalisées, chaque passage produisait une ligne portant la
|
|
// prévision complète en avant ET en après — du bruit qui noie les modifications
|
|
// humaines que l'écran d'audit existe pour montrer.
|
|
|
|
private static SectionWeather NewWeatherSection() =>
|
|
new SectionWeather
|
|
{
|
|
Id = "w1",
|
|
InstanceId = "i1",
|
|
Label = "Météo",
|
|
ConfigurationId = "c1",
|
|
Type = SectionType.Weather,
|
|
Title = new List<TranslationDTO>(),
|
|
Description = new List<TranslationDTO>(),
|
|
WeatherCity = "Namur"
|
|
};
|
|
|
|
[Fact]
|
|
public void WeatherRefresh_ProducesNoAuditEntry()
|
|
{
|
|
using var db = DbContextFactory.Create();
|
|
db.Sections.Add(NewWeatherSection());
|
|
db.SaveChanges();
|
|
|
|
var section = db.Sections.OfType<SectionWeather>().Single();
|
|
section.WeatherResult = "{\"list\":[/* 30 Ko de prévisions */]}";
|
|
section.WeatherUpdatedDate = DateTimeOffset.UtcNow;
|
|
db.SaveChanges();
|
|
|
|
Assert.Empty(db.AuditLogs.Where(a => a.Action == "Update").ToList());
|
|
}
|
|
|
|
[Fact]
|
|
public void HumanEditOnAWeatherSection_IsStillAudited_WithoutTheForecast()
|
|
{
|
|
using var db = DbContextFactory.Create();
|
|
db.Sections.Add(NewWeatherSection());
|
|
db.SaveChanges();
|
|
|
|
var section = db.Sections.OfType<SectionWeather>().Single();
|
|
section.WeatherCity = "Liège";
|
|
section.WeatherResult = "{\"list\":[/* rafraîchi dans la foulée */]}";
|
|
db.SaveChanges();
|
|
|
|
var update = db.AuditLogs.Single(a => a.Action == "Update");
|
|
var newValues = JsonSerializer.Deserialize<Dictionary<string, JsonElement>>(update.NewValues!);
|
|
|
|
Assert.Equal("WeatherCity", Assert.Single(newValues!.Keys));
|
|
Assert.Equal("Liège", newValues["WeatherCity"].GetString());
|
|
}
|
|
|
|
[Fact]
|
|
public void SectionCreation_DoesNotCarryTheForecastBlob()
|
|
{
|
|
using var db = DbContextFactory.Create();
|
|
|
|
var section = NewWeatherSection();
|
|
section.WeatherResult = "{\"list\":[/* déjà rempli */]}";
|
|
db.Sections.Add(section);
|
|
db.SaveChanges();
|
|
|
|
var values = JsonSerializer.Deserialize<Dictionary<string, JsonElement>>(
|
|
db.AuditLogs.Single().NewValues!);
|
|
|
|
Assert.DoesNotContain("WeatherResult", values!.Keys);
|
|
Assert.DoesNotContain("DateUpdate", values.Keys);
|
|
Assert.Equal("Namur", values["WeatherCity"].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);
|
|
}
|
|
|
|
/// <summary>
|
|
/// 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é.
|
|
/// </summary>
|
|
[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 }));
|
|
}
|
|
}
|
|
}
|
|
}
|