using DotNet.Testcontainers.Builders; using ManagerService.Data; using Microsoft.AspNetCore.Http; using Microsoft.EntityFrameworkCore; using Npgsql; using System; using System.Diagnostics; using System.IO; using System.Threading.Tasks; using Testcontainers.PostgreSql; using Xunit; namespace ManagerService.Tests.Infrastructure { /// /// Un vrai PostgreSQL, construit depuis Deployment/Dockerfile.postgres — /// donc le même PostGIS + pgvector que la production, à l'image épinglée près. /// /// /// Le vector store ne peut pas être éprouvé sur EF InMemory : le type vector, /// l'index HNSW et le post-filtrage n'y existent pas, et le modèle exclut d'ailleurs /// explicitement ContentEmbedding hors Npgsql. /// /// Sans démon Docker, la fixture ne lève pas : passe à false /// et les tests se sautent. La suite reste verte sur une machine sans Docker, comme /// MigrationDryRunTests le fait déjà sans Mongo. /// public class PostgresFixture : IAsyncLifetime { private const string ImageTag = "myinfomate-postgres-tests:latest"; private PostgreSqlContainer _container; public bool Available { get; private set; } public string SkipReason { get; private set; } public string ConnectionString { get; private set; } public async Task InitializeAsync() { try { BuildImage(); _container = new PostgreSqlBuilder() .WithImage(ImageTag) .WithDatabase("my_info_mate") .WithUsername("postgres") .WithPassword("postgres") .Build(); await _container.StartAsync(); ConnectionString = _container.GetConnectionString(); Available = true; } catch (Exception ex) { SkipReason = $"Docker indisponible : {ex.Message}"; Available = false; } } /// /// Construit l'image via le CLI docker plutôt que par le constructeur d'images de /// Testcontainers : celui-ci relit le FROM pour pré-tirer l'image de base et /// ne sait pas parser la forme tag@sha256:…. Or ce digest est justement ce /// qui protège la base d'un changement de glibc sous ses index — il n'est pas /// négociable pour arranger un test. /// private static void BuildImage() { var dockerfileDirectory = Path.Combine( CommonDirectoryPath.GetSolutionDirectory().DirectoryPath, "ManagerService", "Deployment"); var process = Process.Start(new ProcessStartInfo("docker") { ArgumentList = { "build", "-f", "Dockerfile.postgres", "-t", ImageTag, "." }, WorkingDirectory = dockerfileDirectory, RedirectStandardOutput = true, RedirectStandardError = true }); process.WaitForExit((int)TimeSpan.FromMinutes(10).TotalMilliseconds); if (process.ExitCode != 0) throw new InvalidOperationException( $"docker build a échoué :\n{process.StandardError.ReadToEnd()}"); } public async Task DisposeAsync() { if (_container != null) await _container.DisposeAsync(); } /// /// Contexte sur une base neuve, migrations appliquées. Les extensions PostGIS et /// pgvector sont créées par les migrations, pas par l'image : un CREATE EXTENSION /// manuel masquerait justement l'oubli qu'on veut voir. /// public MyInfoMateDbContext CreateContext(string databaseName) { var builder = new NpgsqlConnectionStringBuilder(ConnectionString) { Database = databaseName }; var dataSourceBuilder = new NpgsqlDataSourceBuilder(builder.ConnectionString); dataSourceBuilder.UseNetTopologySuite(); dataSourceBuilder.UseVector(); dataSourceBuilder.EnableDynamicJson(); var options = new DbContextOptionsBuilder() .UseNpgsql(dataSourceBuilder.Build(), o => o.UseNetTopologySuite().UseVector()) .Options; return new MyInfoMateDbContext(options, new HttpContextAccessor()); } /// Crée une base vide puis y applique les migrations. /// /// Coupe le parcours séquentiel pour la base entière. Sur les volumes d'un test, /// un seq scan reste moins cher qu'un index HNSW, et le post-filtrage — ce qu'on /// vient justement éprouver — resterait invisible. Le réglage est posé sur la base /// et non sur la session : un SET par ExecuteSqlRaw serait effacé au retour de la /// connexion au pool, et le test passerait au vert sans rien avoir prouvé. /// public MyInfoMateDbContext CreateMigratedContext(string databaseName, bool forceIndexScan = false) { NpgsqlConnection.ClearAllPools(); using (var admin = new NpgsqlConnection(ConnectionString)) { admin.Open(); using var drop = new NpgsqlCommand($"DROP DATABASE IF EXISTS \"{databaseName}\"", admin); drop.ExecuteNonQuery(); using var create = new NpgsqlCommand($"CREATE DATABASE \"{databaseName}\"", admin); create.ExecuteNonQuery(); if (forceIndexScan) { using var tune = new NpgsqlCommand($"ALTER DATABASE \"{databaseName}\" SET enable_seqscan = off", admin); tune.ExecuteNonQuery(); } } var db = CreateContext(databaseName); db.Database.Migrate(); return db; } /// Connexion brute sur une base du conteneur, hors EF. public NpgsqlConnection OpenRawConnection(string databaseName) { var builder = new NpgsqlConnectionStringBuilder(ConnectionString) { Database = databaseName }; var dataSourceBuilder = new NpgsqlDataSourceBuilder(builder.ConnectionString); dataSourceBuilder.UseVector(); var connection = dataSourceBuilder.Build().OpenConnection(); return connection; } } [CollectionDefinition(Name)] public class PostgresCollection : ICollectionFixture { public const string Name = "postgres"; } }