Compare commits

...

2 Commits

Author SHA1 Message Date
Thomas Fransolet
149ebc0cf1 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>
2026-08-11 16:40:11 +02:00
Thomas Fransolet
934d71c2ee Lot G : les 8 écarts de MigrationController
Trois n'existaient pas — vérifié dans l'export Mongo plutôt que dans la doc :
- (a) « BuildSection ne couvre que 11 types sur 13 » : les 327 sections de
  l'export ne contiennent que les types 0 à 10. SectionEvent et
  SectionParcours sont nés avec Postgres v3, le default est un filet.
- (f) IsQRCode/IsSearchText/IsSearchNumber n'existent pas dans Mongo, false
  est le bon défaut. Contrôle inverse fait : les cinq réglages qui existent
  vraiment sont bien mappés sur AppConfigurationLink.
- (g) était tombé avec le rename du lot B.

Les cinq réels :
- (b) QuizQuestions = new() jetait les questions. Mesuré : 5 sections quiz
  en portent 41, réponses comprises. Migrées en MultipleChoice, le type de
  validation n'existant pas dans l'ancien modèle. EventAgendas et l'absence
  de GuidedPath sont corrects : Mongo n'a ni événement ni parcours.
- (c) la source ne contient que 4 champs, il n'y avait rien à mapper de
  plus. PublicApiKey générée (même schéma que l'onboarding), WebSlug via
  SlugHelper, drapeaux de plateforme dérivés des configurations, plan
  affecté par nom avec repli sur Pro, IsAssistant allumé seulement si le
  plan donne des jetons.
- (d) Mongo n'a pas de champ Role : le ContentEditor en dur dégradait les
  10 utilisateurs en silence, plus personne n'aurait pu gérer les
  utilisateurs de son instance. Passé à InstanceAdmin.
- (e) StoragePath n'était pas écrit du tout. Passe par ResourceStorage, le
  calculateur commun à la création et au backfill. L'échec du HEAD, jusqu'ici
  avalé, remonte dans le rapport.
- (h) transaction unique sur les neuf étapes, et 500 au lieu de 200 OK sur
  échec fatal — un script de bascule testant le code HTTP concluait au
  succès alors que l'erreur n'était que dans le corps.

ApplyPlanQuotas est dupliqué depuis InstanceController plutôt qu'appelé : la
migration ne doit pas casser si ce contrôleur change de forme.

dotnet build vert, dotnet test 143/143. Reste le dry run.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-11 16:26:01 +02:00
2 changed files with 310 additions and 4 deletions

View 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);
}
}
}

View File

@ -3,6 +3,7 @@ using Manager.Services;
using ManagerService.Data;
using ManagerService.Data.SubSection;
using ManagerService.DTOs;
using ManagerService.Helpers;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
@ -12,6 +13,7 @@ using System;
using System.Collections.Generic;
using System.Linq;
using System.Net.Http;
using System.Security.Cryptography;
using System.Text.Json;
using System.Threading.Tasks;
@ -65,6 +67,13 @@ namespace ManagerService.Controllers
{
var report = new MigrationReportDTO { DryRun = dryRun };
// Écart (h). Neuf étapes, un SaveChanges chacune : un échec au milieu
// laissait la base à moitié remplie — des sections sans configuration, des
// configurations sans instance. Une transaction unique rend l'échec propre :
// soit tout est là, soit rien ne l'est, et on relance après correction.
// En dryRun rien n'est écrit, donc pas de transaction à ouvrir.
var transaction = dryRun ? null : await _db.Database.BeginTransactionAsync();
try
{
await MigrateInstancesAsync(report, dryRun, instanceId);
@ -76,10 +85,26 @@ namespace ManagerService.Controllers
await MigrateDevicesAsync(report, dryRun, instanceId);
await LinkMenuSectionsAsync(report, dryRun);
await EnrichPdfResourcesAsync(report, dryRun);
if (transaction != null)
await transaction.CommitAsync();
}
catch (Exception ex)
{
if (transaction != null)
await transaction.RollbackAsync();
report.FatalError = ex.Message;
// Renvoyer 200 OK sur un échec fatal était le piège : un script de
// bascule qui teste le code HTTP concluait que tout s'était bien passé,
// l'erreur n'étant que dans le corps de la réponse.
return new ObjectResult(report) { StatusCode = 500 };
}
finally
{
if (transaction != null)
await transaction.DisposeAsync();
}
return new OkObjectResult(report);
@ -103,14 +128,36 @@ namespace ManagerService.Controllers
continue;
}
// Mongo ne contient que ces 4 champs — vérifié dans l'export : les ~31
// autres colonnes sont nées avec Postgres v3. Il n'y a donc rien à
// mapper de plus, il faut générer, dériver et affecter. Sans ça
// l'instance arrive muette : PublicApiKey nul = les apps visiteur ne
// s'authentifient plus, plan nul = quotas à 0 = rien ne s'indexe.
var entity = new Instance
{
Id = old.Id,
Name = old.Name,
DateCreation = old.DateCreation,
PinCode = old.PinCode?.ToString(),
WebSlug = SlugHelper.GenerateUniqueSlug(_db, old.Name),
PublicApiKey = GeneratePublicApiKey(),
SubscriptionPlanId = PlanForInstance(old.Name),
IsActive = true,
};
// Les drapeaux de plateforme se déduisent des configurations, comme le
// fait déjà MigrateApplicationInstancesAsync pour créer les app
// instances. IsWeb reste faux : le canal web n'existait pas avant la
// bascule, c'est un réglage d'après.
var configs = _configSvc.GetAll(old.Id);
entity.IsTablet = configs.Any(c => c.IsTablet);
entity.IsMobile = configs.Any(c => c.IsMobile);
ApplyPlanQuotas(entity);
// L'assistant ne s'allume que si le plan lui donne des jetons —
// sinon le client verrait une entrée de menu qui ne répond jamais.
entity.IsAssistant = entity.AiTokensPerMonth > 0;
if (!dryRun)
_db.Instances.Add(entity);
@ -126,6 +173,50 @@ namespace ManagerService.Controllers
if (!dryRun) await _db.SaveChangesAsync();
}
/// <summary>
/// Même schéma que l'inscription self-service (`OnboardingController`) : préfixe
/// `ap_`, 32 octets aléatoires cryptographiques, encodés en base64 URL-safe.
/// </summary>
private static string GeneratePublicApiKey() =>
"ap_" + Convert.ToBase64String(RandomNumberGenerator.GetBytes(32))
.Replace("+", "-").Replace("/", "_").TrimEnd('=');
/// <summary>
/// Affectation décidée le 2026-08-11 pour les 4 clients existants — aucun n'est
/// sur Essentiel. Le rapprochement se fait par nom parce que c'est la seule
/// donnée lisible de l'export Mongo ; un id inconnu retombe sur Pro, le plan
/// des clients avec app mobile, jamais sur un plan qui offrirait l'IA.
/// ⚠️ Pro n'inclut pas l'assistant : MDLF et le Fort n'auront pas d'IA. Pour les
/// tests, soit les passer en Premium, soit leur poser un quota à la main.
/// </summary>
private static string PlanForInstance(string name)
{
if (name == null) return "plan-pro";
if (name.Contains("MyInfoMate", StringComparison.OrdinalIgnoreCase)) return "plan-premium";
if (name.Contains("VisitNamur", StringComparison.OrdinalIgnoreCase)) return "plan-premium";
return "plan-pro";
}
/// <summary>
/// Recopie les quotas du plan sur l'instance. Duplique volontairement la logique
/// d'`InstanceController.ApplyPlanQuotas` plutôt que d'en dépendre : la migration
/// ne doit pas casser si ce contrôleur change de forme. Les deux doivent rester
/// alignées — voir l'écart (c) du §1quinquies.
/// </summary>
private void ApplyPlanQuotas(Instance instance)
{
if (instance.SubscriptionPlanId == null) return;
var plan = _db.SubscriptionPlans.FirstOrDefault(p => p.Id == instance.SubscriptionPlanId);
if (plan == null) return;
instance.StorageQuotaBytes = plan.StorageQuotaBytes;
instance.AiTokensPerMonth = plan.AiTokensPerMonth;
instance.HasStats = plan.HasStats;
instance.StatsHistoryDays = plan.StatsHistoryDays;
instance.HasAdvancedStats = plan.HasAdvancedStats;
}
// ─── Resources ────────────────────────────────────────────────────────
private async Task MigrateResourcesAsync(MigrationReportDTO report, bool dryRun, string filterInstanceId)
@ -140,6 +231,7 @@ namespace ManagerService.Controllers
httpClient.Timeout = TimeSpan.FromSeconds(10);
var sizemap = new System.Collections.Concurrent.ConcurrentDictionary<string, long>();
var unsized = new System.Collections.Concurrent.ConcurrentDictionary<string, bool>();
var urlSource = source.Where(r => !string.IsNullOrEmpty(r.Url)).ToList();
const int batchSize = 30;
@ -155,7 +247,13 @@ namespace ManagerService.Controllers
if (resp.Content.Headers.ContentLength.HasValue)
sizemap[r.Id] = resp.Content.Headers.ContentLength.Value;
}
catch { /* URL inaccessible ou expirée → SizeBytes restera 0 */ }
catch
{
// L'échec était avalé sans laisser de trace : la ressource
// arrivait à 0 octet et le quota de stockage la comptait pour
// rien, sans que personne puisse savoir lesquelles. On le note.
unsized[r.Id] = true;
}
}));
}
@ -177,12 +275,20 @@ namespace ManagerService.Controllers
DateCreation = old.DateCreation,
InstanceId = old.InstanceId,
Url = old.Url,
SizeBytes = sizemap.TryGetValue(old.Id, out var size) ? size : 0,
};
// Écart (e) : StoragePath n'était pas renseigné du tout, et SizeBytes
// l'était à la main. Les deux passent par le même calculateur que la
// création et le backfill (lien L5) — sinon les trois divergent sur
// les types URL, qui n'ont ni blob ni poids à compter dans le quota.
ResourceStorage.Apply(entity, sizemap.TryGetValue(old.Id, out var size) ? size : 0);
if (!dryRun)
_db.Resources.Add(entity);
if (unsized.ContainsKey(old.Id) && ResourceStorage.HasBlob(entity.Type))
report.Errors.Add($"Resource {old.Id} ({old.Label}) : taille inconnue (HEAD en échec), SizeBytes à 0 — le quota de stockage la comptera pour rien");
report.Migrated.Resources++;
}
catch (Exception ex)
@ -222,7 +328,15 @@ namespace ManagerService.Controllers
Token = old.Token,
DateCreation = old.DateCreation,
InstanceId = old.InstanceId,
Role = UserRole.ContentEditor,
// Mongo n'a pas de champ Role : le rôle est une notion née avec
// Postgres v3. Tous les utilisateurs y avaient donc un accès
// complet. `ContentEditor` était un choix par défaut qui les
// dégradait tous en silence — plus personne n'aurait pu gérer les
// utilisateurs de son instance après la bascule.
// `InstanceAdmin` préserve ce qu'ils pouvaient faire hier, et
// c'est aussi le rôle que l'inscription self-service donne au
// premier utilisateur d'une instance.
Role = UserRole.InstanceAdmin,
};
if (!dryRun)
@ -400,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)
@ -523,7 +659,13 @@ namespace ManagerService.Controllers
var dto = ParseData<OldQuizzDTO>(old, opts, report);
var s = new SectionQuiz
{
QuizQuestions = new List<QuizQuestion>(),
// Écart (b). `new List<QuizQuestion>()` jetait les questions :
// mesuré sur l'export, 5 sections quiz en portent **41**. Un quiz
// arrivait donc sans une seule question, et rien ne le signalait.
// Les agendas, eux, n'ont pas d'événements dans Mongo et les
// parcours guidés n'y existent pas : leurs collections filles
// vides sont correctes, celle-ci ne l'était pas.
QuizQuestions = BuildQuizQuestions(dto?.questions),
QuizBadLevel = dto?.bad_level?.label ?? new List<TranslationAndResourceDTO>(),
QuizMediumLevel = dto?.medium_level?.label ?? new List<TranslationAndResourceDTO>(),
QuizGoodLevel = dto?.good_level?.label ?? new List<TranslationAndResourceDTO>(),
@ -613,6 +755,39 @@ namespace ManagerService.Controllers
}
}
/// <summary>
/// Écart (b) : les questions de quiz de Mongo, jusqu'ici perdues. Le type de
/// validation n'existait pas dans l'ancien modèle — toutes les questions y étaient
/// à choix multiples — d'où `MultipleChoice`, qui préserve le comportement d'hier
/// plutôt que de retomber sur le défaut `Simple` du modèle.
/// </summary>
private static List<QuizQuestion> BuildQuizQuestions(List<OldQuestionDTO> old)
{
var questions = new List<QuizQuestion>();
if (old == null) return questions;
foreach (var q in old)
{
questions.Add(new QuizQuestion
{
Label = q.label ?? new List<TranslationAndResourceDTO>(),
ResourceId = q.imageBackgroundResourceId,
Order = q.order,
ValidationQuestionType = QuestionType.MultipleChoice,
Responses = (q.responses ?? new List<OldResponseDTO>())
.Select(r => new ResponseDTO
{
label = r.label ?? new List<TranslationAndResourceDTO>(),
isGood = r.isGood,
order = r.order,
})
.ToList(),
});
}
return questions;
}
private GeoPoint BuildGeoPoint(OldGeoPointDTO p, string sectionMapId)
{
Geometry geometry = null;