Dry run de la bascule, et les 20 sections qui disparaissaient en silence
Le dry run est automatisé en test (MigrationDryRunTests) : il appelle le contrôleur directement, ce qui évite la chaîne login + JWT SuperAdmin, et se saute si aucun Mongo n'écoute sur MIGRATION_TEST_MONGO — la suite reste donc verte sans dépendance. Les tailles de fichier sont court-circuitées : un HEAD sur 2374 ressources prendrait des minutes pour une information que ce test ne vérifie pas. Il a trouvé du premier coup ce qu'aucune lecture de code n'avait vu : 327 sections dans Mongo, 307 migrées, et Erreurs : 0. Les 20 manquantes référencent deux configurations supprimées dans Mongo — 19 articles et un slider, du contenu MDLF. La migration a raison de ne pas les reprendre : les sections sont collectées configuration par configuration, donc les orphelines ne sont jamais énumérées, et la FK ConfigurationId les refuserait. Le défaut était le silence — elles partent désormais dans Skipped, avec le détail de la configuration introuvable. L'invariant affirmé par le test n'est donc pas « tout est migré » mais « rien ne disparaît sans être signalé » : migrées + signalées = Mongo. dotnet test 144/144. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
parent
934d71c2ee
commit
149ebc0cf1
131
ManagerService.Tests/Controllers/MigrationDryRunTests.cs
Normal file
131
ManagerService.Tests/Controllers/MigrationDryRunTests.cs
Normal file
@ -0,0 +1,131 @@
|
||||
using ManagerService.Controllers;
|
||||
using ManagerService.Tests.Infrastructure;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using MongoDB.Driver;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Net;
|
||||
using System.Net.Http;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Xunit;
|
||||
using Xunit.Abstractions;
|
||||
|
||||
namespace ManagerService.Tests.Controllers
|
||||
{
|
||||
/// <summary>
|
||||
/// Dry run de la bascule Mongo → Postgres, et comparaison des comptages par entité.
|
||||
/// C'est la seule preuve que les écarts (b), (c) et (e) sont fermés : la lecture de
|
||||
/// code ne dit pas ce qui arrive vraiment dans la base.
|
||||
///
|
||||
/// Se saute si aucun Mongo n'écoute sur MIGRATION_TEST_MONGO (défaut
|
||||
/// mongodb://localhost:27018) — la suite reste donc verte sans dépendance.
|
||||
/// Pour l'exécuter : importer `migration-data/` dans un Mongo jetable, cf. STATUS.md §1quinquies.
|
||||
/// </summary>
|
||||
public class MigrationDryRunTests
|
||||
{
|
||||
private readonly ITestOutputHelper _output;
|
||||
|
||||
public MigrationDryRunTests(ITestOutputHelper output) => _output = output;
|
||||
|
||||
private static string MongoUri =>
|
||||
Environment.GetEnvironmentVariable("MIGRATION_TEST_MONGO") ?? "mongodb://localhost:27018";
|
||||
|
||||
private static bool MongoIsReachable(out Dictionary<string, long> counts)
|
||||
{
|
||||
counts = new Dictionary<string, long>();
|
||||
try
|
||||
{
|
||||
var settings = MongoClientSettings.FromConnectionString(MongoUri);
|
||||
settings.ServerSelectionTimeout = TimeSpan.FromSeconds(2);
|
||||
var db = new MongoClient(settings).GetDatabase("TabletDb");
|
||||
|
||||
foreach (var name in new[] { "Instances", "Users", "Configurations", "Resources", "Sections", "Devices" })
|
||||
counts[name] = db.GetCollection<MongoDB.Bson.BsonDocument>(name)
|
||||
.CountDocuments(MongoDB.Bson.BsonDocument.Parse("{}"));
|
||||
|
||||
return counts["Instances"] > 0;
|
||||
}
|
||||
catch
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Les tailles de fichier passent par un HEAD sur chaque URL. Sur 2374 ressources
|
||||
/// cela interrogerait Firebase pendant plusieurs minutes, pour une information que
|
||||
/// ce test ne vérifie pas : il compare des comptages. On coupe court.
|
||||
/// </summary>
|
||||
private sealed class UnreachableHttpClientFactory : IHttpClientFactory
|
||||
{
|
||||
private sealed class Handler : HttpMessageHandler
|
||||
{
|
||||
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken ct) =>
|
||||
Task.FromResult(new HttpResponseMessage(HttpStatusCode.NotFound));
|
||||
}
|
||||
|
||||
public HttpClient CreateClient(string name) => new HttpClient(new Handler());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DryRun_MatchesMongoCounts()
|
||||
{
|
||||
if (!MongoIsReachable(out var mongo))
|
||||
{
|
||||
_output.WriteLine($"Mongo injoignable sur {MongoUri} — test sauté.");
|
||||
return;
|
||||
}
|
||||
|
||||
var config = new ConfigurationBuilder()
|
||||
.AddInMemoryCollection(new Dictionary<string, string> { ["ConnectionStrings:TabletDb"] = MongoUri })
|
||||
.Build();
|
||||
|
||||
using var db = DbContextFactory.Create();
|
||||
var controller = new MigrationController(
|
||||
db,
|
||||
new Manager.Services.InstanceDatabaseService(config),
|
||||
new Manager.Services.ConfigurationDatabaseService(config),
|
||||
new Manager.Services.SectionDatabaseService(config),
|
||||
new Manager.Services.ResourceDatabaseService(config),
|
||||
new Manager.Services.UserDatabaseService(config),
|
||||
new Manager.Services.DeviceDatabaseService(config),
|
||||
new UnreachableHttpClientFactory());
|
||||
|
||||
var result = await controller.Run(dryRun: true);
|
||||
var report = Assert.IsType<MigrationReportDTO>(result.Value);
|
||||
|
||||
_output.WriteLine($"FatalError : {report.FatalError ?? "(aucun)"}");
|
||||
_output.WriteLine($"Erreurs : {report.Errors.Count}");
|
||||
foreach (var e in report.Errors.Take(15)) _output.WriteLine(" " + e);
|
||||
_output.WriteLine("");
|
||||
_output.WriteLine($"{"Entité",-18}{"Mongo",8}{"Migré",8}");
|
||||
_output.WriteLine($"{"Instances",-18}{mongo["Instances"],8}{report.Migrated.Instances,8}");
|
||||
_output.WriteLine($"{"Users",-18}{mongo["Users"],8}{report.Migrated.Users,8}");
|
||||
_output.WriteLine($"{"Configurations",-18}{mongo["Configurations"],8}{report.Migrated.Configurations,8}");
|
||||
_output.WriteLine($"{"Resources",-18}{mongo["Resources"],8}{report.Migrated.Resources,8}");
|
||||
_output.WriteLine($"{"Sections",-18}{mongo["Sections"],8}{report.Migrated.Sections,8}");
|
||||
_output.WriteLine($"{"Devices",-18}{mongo["Devices"],8}{report.Migrated.Devices,8}");
|
||||
_output.WriteLine($"{"ApplicationInstances",-18}{"—",8}{report.Migrated.ApplicationInstances,8}");
|
||||
_output.WriteLine($"{"AppConfigLinks",-18}{"—",8}{report.Migrated.AppConfigurationLinks,8}");
|
||||
|
||||
Assert.Null(report.FatalError);
|
||||
|
||||
// Une entité migrée sur deux, c'est une bascule qui se déclare réussie en
|
||||
// ayant perdu la moitié du contenu. C'est exactement ce que le dry run doit
|
||||
// attraper avant le jour J.
|
||||
Assert.Equal(mongo["Instances"], report.Migrated.Instances);
|
||||
Assert.Equal(mongo["Users"], report.Migrated.Users);
|
||||
Assert.Equal(mongo["Configurations"], report.Migrated.Configurations);
|
||||
// Les sections dont la configuration a été supprimée dans Mongo ne peuvent pas
|
||||
// être migrées, mais elles doivent être **signalées**. L'invariant qui compte
|
||||
// n'est donc pas « tout est migré » : c'est « rien ne disparaît en silence ».
|
||||
var skippedSections = report.Skipped.Count(s => s.StartsWith("Section "));
|
||||
_output.WriteLine($"{" dont orphelines",-18}{"",8}{skippedSections,8}");
|
||||
Assert.Equal(mongo["Sections"], report.Migrated.Sections + skippedSections);
|
||||
Assert.Equal(mongo["Devices"], report.Migrated.Devices);
|
||||
Assert.Equal(mongo["Resources"], report.Migrated.Resources);
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -514,6 +514,28 @@ namespace ManagerService.Controllers
|
||||
.GroupBy(s => s.Id).Select(g => g.First())
|
||||
.ToList();
|
||||
|
||||
// Les sections sont collectées configuration par configuration : celles dont
|
||||
// la configuration a été supprimée dans Mongo ne sont jamais énumérées, donc
|
||||
// ne partaient ni dans Migrated, ni dans Skipped, ni dans Errors. Le rapport
|
||||
// affichait un compte inférieur à Mongo sans dire pourquoi — mesuré au dry
|
||||
// run du 2026-08-11 : 327 sections en base, 307 reprises, 20 muettes.
|
||||
// Elles ne peuvent pas être migrées (la FK ConfigurationId les refuserait),
|
||||
// mais leur disparition doit être un constat, pas une surprise du jour J.
|
||||
var knownConfigIds = configIds.ToHashSet();
|
||||
var orphans = instanceIds
|
||||
.SelectMany(id => _sectionSvc.GetAll(id))
|
||||
.GroupBy(s => s.Id).Select(g => g.First())
|
||||
.Where(s => !knownConfigIds.Contains(s.ConfigurationId))
|
||||
.ToList();
|
||||
|
||||
foreach (var orphan in orphans)
|
||||
report.Skipped.Add(
|
||||
$"Section {orphan.Id} ({orphan.Label}) — configuration {orphan.ConfigurationId} introuvable, section orpheline dans Mongo");
|
||||
|
||||
if (orphans.Count > 0)
|
||||
report.Details.Add(
|
||||
$"{orphans.Count} section(s) orpheline(s) non migrée(s) : leur configuration n'existe plus dans Mongo");
|
||||
|
||||
var opts = new JsonSerializerOptions { PropertyNameCaseInsensitive = true };
|
||||
|
||||
foreach (var old in source)
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user