diff --git a/ManagerService.Tests/Controllers/MigrationDryRunTests.cs b/ManagerService.Tests/Controllers/MigrationDryRunTests.cs new file mode 100644 index 0000000..e0b5334 --- /dev/null +++ b/ManagerService.Tests/Controllers/MigrationDryRunTests.cs @@ -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 +{ + /// + /// 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. + /// + 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 counts) + { + counts = new Dictionary(); + 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(name) + .CountDocuments(MongoDB.Bson.BsonDocument.Parse("{}")); + + return counts["Instances"] > 0; + } + catch + { + return false; + } + } + + /// + /// 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. + /// + private sealed class UnreachableHttpClientFactory : IHttpClientFactory + { + private sealed class Handler : HttpMessageHandler + { + protected override Task 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 { ["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(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); + } + } +} diff --git a/ManagerService/Controllers/MigrationController.cs b/ManagerService/Controllers/MigrationController.cs index c8c37da..5edb933 100644 --- a/ManagerService/Controllers/MigrationController.cs +++ b/ManagerService/Controllers/MigrationController.cs @@ -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)