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)