diff --git a/ManagerService.Tests/Controllers/StatsControllerPostgresTests.cs b/ManagerService.Tests/Controllers/StatsControllerPostgresTests.cs
new file mode 100644
index 0000000..4801576
--- /dev/null
+++ b/ManagerService.Tests/Controllers/StatsControllerPostgresTests.cs
@@ -0,0 +1,197 @@
+using Manager.DTOs;
+using ManagerService.Controllers;
+using ManagerService.Data;
+using ManagerService.DTOs;
+using ManagerService.Tests.Infrastructure;
+using Microsoft.AspNetCore.Mvc;
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using Xunit;
+
+namespace ManagerService.Tests.Controllers
+{
+ ///
+ /// GetSummary agrège désormais en SQL. Les tests InMemory tiennent la sémantique mais
+ /// pas la traduction : le provider InMemory évalue tout côté client, donc une requête
+ /// intraduisible y passerait au vert et exploserait en production. Ceux-ci s'exécutent
+ /// contre un vrai PostgreSQL.
+ ///
+ [Collection(PostgresCollection.Name)]
+ public class StatsControllerPostgresTests
+ {
+ private readonly PostgresFixture _postgres;
+
+ public StatsControllerPostgresTests(PostgresFixture postgres)
+ {
+ _postgres = postgres;
+ }
+
+ private static StatsController BuildController(MyInfoMateDbContext db)
+ {
+ var controller = new StatsController(db);
+ FakeUser.SetUser(controller, FakeUser.Create(Permissions.SuperAdmin, "i1"));
+ return controller;
+ }
+
+ private static void Seed(MyInfoMateDbContext db, bool advanced)
+ {
+ db.Instances.Add(new Instance
+ {
+ Id = "i1",
+ Name = "Musée de test",
+ HasStats = true,
+ HasAdvancedStats = advanced,
+ StatsHistoryDays = MyInfoMateDbContext.StatsRetentionDays
+ });
+
+ var section = TestSection.Article("sect-a", "i1", "A", "c1");
+ section.Title = new List { new TranslationDTO { language = "fr", value = "La cave" } };
+ db.Sections.Add(section);
+
+ var day = DateTime.UtcNow.AddDays(-2);
+ var events = new List
+ {
+ Ev("e1", VisitEventType.SectionView, "s1", day, sectionId: "sect-a", language: "fr", appType: AppType.Mobile),
+ Ev("e2", VisitEventType.SectionView, "s2", day.AddHours(1), sectionId: "sect-a", language: "nl", appType: AppType.Tablet),
+ Ev("e3", VisitEventType.SectionLeave, "s1", day.AddMinutes(5), sectionId: "sect-a", durationSeconds: 30),
+ // s2 est une session tablette de bout en bout : un appType qui change en
+ // cours de session n'existe pas, et l'y laisser fausserait la distribution.
+ Ev("e4", VisitEventType.SectionLeave, "s2", day.AddHours(1).AddMinutes(5), sectionId: "sect-a", durationSeconds: 10, appType: AppType.Tablet),
+ Ev("e5", VisitEventType.ArticleRead, "s1", day, sectionId: "sect-a"),
+ Ev("e6", VisitEventType.QrScan, "s1", day, metadata: "{\"valid\":true}"),
+ Ev("e7", VisitEventType.MapPoiTap, "s1", day, sectionId: "sect-a", metadata: "{\"geoPointId\":7,\"geoPointTitle\":\"Ruche\"}"),
+ Ev("e8", VisitEventType.AgendaEventTap, "s2", day, metadata: "{\"eventId\":\"ev1\",\"eventTitle\":\"Concert\"}", appType: AppType.Tablet),
+ Ev("e9", VisitEventType.GameComplete, "s1", day, metadata: "{\"gameType\":\"Puzzle\",\"durationSeconds\":60}"),
+ Ev("e10", VisitEventType.MenuItemTap, "s2", day, metadata: "{\"targetSectionId\":\"sect-a\",\"menuItemTitle\":\"Accueil\"}", appType: AppType.Tablet),
+ Ev("e11", VisitEventType.QuizComplete, "s1", day, sectionId: "sect-a", metadata: "{\"score\":4,\"totalQuestions\":5}")
+ };
+
+ db.VisitEvents.AddRange(events);
+ db.SaveChanges();
+ }
+
+ private static VisitEvent Ev(string id, VisitEventType type, string sessionId, DateTime timestamp,
+ string sectionId = null, string metadata = null, string language = null,
+ AppType appType = AppType.Mobile, int? durationSeconds = null) =>
+ new VisitEvent
+ {
+ Id = id,
+ InstanceId = "i1",
+ SessionId = sessionId,
+ EventType = type,
+ SectionId = sectionId,
+ Metadata = metadata,
+ Language = language,
+ AppType = appType,
+ DurationSeconds = durationSeconds,
+ Timestamp = timestamp
+ };
+
+ private static StatsSummaryDTO Summarize(MyInfoMateDbContext db)
+ {
+ var result = BuildController(db).GetSummary("i1", null, null, null);
+
+ // Le contrôleur enveloppe tout dans un try/catch qui rend 500 : une requête
+ // intraduisible ne remonterait pas en exception mais en corps de réponse.
+ // On lit donc le code avant de caster, sinon l'échec serait illisible.
+ var objectResult = Assert.IsAssignableFrom(result);
+ Assert.True(objectResult.StatusCode == 200, $"GetSummary a rendu {objectResult.StatusCode} : {objectResult.Value}");
+
+ return Assert.IsType(objectResult.Value);
+ }
+
+ [SkippableFact]
+ public void GetSummary_TranslatesToSql_OnTheBasicPlan()
+ {
+ Skip.IfNot(_postgres.Available, _postgres.SkipReason);
+
+ using var db = _postgres.CreateMigratedContext("stats_basic");
+ Seed(db, advanced: false);
+
+ var summary = Summarize(db);
+
+ Assert.Equal(2, summary.TotalSessions);
+ Assert.Equal(20, summary.AvgVisitDurationSeconds); // (30 + 10) / 2 sessions
+
+ var section = Assert.Single(summary.TopSections);
+ Assert.Equal("La cave", section.SectionTitle);
+ Assert.Equal(2, section.Views);
+ Assert.Equal(20, section.AvgDurationSeconds);
+
+ var day = Assert.Single(summary.VisitsByDay);
+ Assert.Equal(2, day.Total);
+ Assert.Equal(1, day.Mobile);
+ Assert.Equal(1, day.Tablet);
+
+ Assert.Equal(1, summary.AppTypeDistribution["Mobile"]);
+ Assert.Equal(1, summary.AppTypeDistribution["Tablet"]);
+
+ // Plan sans stats avancées : rien de tout ça n'a été calculé.
+ Assert.Empty(summary.LanguageDistribution);
+ Assert.Empty(summary.TopPois);
+ Assert.Equal(0, summary.QrScans.TotalScans);
+ }
+
+ [SkippableFact]
+ public void GetSummary_TranslatesToSql_OnTheAdvancedPlan()
+ {
+ Skip.IfNot(_postgres.Available, _postgres.SkipReason);
+
+ using var db = _postgres.CreateMigratedContext("stats_advanced");
+ Seed(db, advanced: true);
+
+ var summary = Summarize(db);
+
+ Assert.Equal(1, summary.LanguageDistribution["fr"]);
+ Assert.Equal(1, summary.LanguageDistribution["nl"]);
+ Assert.Equal("Ruche", Assert.Single(summary.TopPois).Title);
+ Assert.Equal("Concert", Assert.Single(summary.TopAgendaEvents).EventTitle);
+ Assert.Equal("Puzzle", Assert.Single(summary.GameStats).GameType);
+ Assert.Equal("Accueil", Assert.Single(summary.TopMenuItems).MenuItemTitle);
+ Assert.Equal(1, Assert.Single(summary.TopArticles).Reads);
+ Assert.Equal(1, summary.QrScans.TotalScans);
+
+ var quiz = Assert.Single(summary.QuizStats);
+ Assert.Equal("La cave", quiz.SectionTitle);
+ Assert.Equal(4.0, quiz.AvgScore);
+ }
+
+ [SkippableFact]
+ public void GetSummary_TranslatesToSql_WithAnAppTypeFilter()
+ {
+ Skip.IfNot(_postgres.Available, _postgres.SkipReason);
+
+ using var db = _postgres.CreateMigratedContext("stats_filtered");
+ Seed(db, advanced: true);
+
+ var result = BuildController(db).GetSummary("i1", null, null, "Tablet");
+ var objectResult = Assert.IsAssignableFrom(result);
+ Assert.True(objectResult.StatusCode == 200, $"GetSummary a rendu {objectResult.StatusCode} : {objectResult.Value}");
+
+ var summary = Assert.IsType(objectResult.Value);
+ Assert.Equal(1, summary.TotalSessions);
+ Assert.Equal(1, summary.AppTypeDistribution["Tablet"]);
+ }
+
+ [SkippableFact]
+ public void GetSummary_OnAnEmptyWindow_TranslatesWithoutFailing()
+ {
+ Skip.IfNot(_postgres.Available, _postgres.SkipReason);
+
+ using var db = _postgres.CreateMigratedContext("stats_empty");
+ Seed(db, advanced: true);
+
+ // Fenêtre vide : les agrégats portent sur zéro ligne. C'est là qu'une moyenne
+ // SQL rend NULL et qu'un cast maladroit lèverait.
+ var result = BuildController(db).GetSummary("i1", DateTime.UtcNow.AddDays(-1), DateTime.UtcNow, null);
+ var objectResult = Assert.IsAssignableFrom(result);
+ Assert.True(objectResult.StatusCode == 200, $"GetSummary a rendu {objectResult.StatusCode} : {objectResult.Value}");
+
+ var summary = Assert.IsType(objectResult.Value);
+ Assert.Equal(0, summary.TotalSessions);
+ Assert.Equal(0, summary.AvgVisitDurationSeconds);
+ Assert.Empty(summary.TopSections);
+ }
+ }
+}
diff --git a/ManagerService.Tests/Infrastructure/FakeEmbeddingService.cs b/ManagerService.Tests/Infrastructure/FakeEmbeddingService.cs
new file mode 100644
index 0000000..2249347
--- /dev/null
+++ b/ManagerService.Tests/Infrastructure/FakeEmbeddingService.cs
@@ -0,0 +1,68 @@
+using ManagerService.Data;
+using ManagerService.Services;
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Threading;
+using System.Threading.Tasks;
+
+namespace ManagerService.Tests.Infrastructure
+{
+ ///
+ /// Embeddings déterministes, sans appel réseau. Le texte pilote directement la
+ /// direction du vecteur, ce qui permet d'écrire « ce morceau est proche de cette
+ /// question » comme un fait vérifiable plutôt que comme une supposition.
+ ///
+ ///
+ /// Convention : un texte préfixé axis:N produit le vecteur unitaire de l'axe N,
+ /// éventuellement mêlé d'un peu de bruit stable (axis:N#seed). Deux textes sur
+ /// des axes différents sont donc quasi orthogonaux — distance cosinus proche de 1.
+ /// Ce qui suit les chiffres du seed est ignoré : cela permet d'écrire des textes
+ /// distincts qui partagent un même vecteur, sans quoi la déduplication par texte de
+ /// SearchAsync écraserait les lignes et fausserait tout comptage de résultats.
+ ///
+ public class FakeEmbeddingService : IEmbeddingService
+ {
+ public int Dimensions => ContentEmbedding.Dimensions;
+
+ public Task EmbedAsync(string text, CancellationToken cancellationToken = default) =>
+ Task.FromResult(Embed(text));
+
+ public Task> EmbedBatchAsync(IReadOnlyList texts, CancellationToken cancellationToken = default) =>
+ Task.FromResult>(texts.Select(Embed).ToList());
+
+ public float[] Embed(string text)
+ {
+ var vector = new float[Dimensions];
+
+ var axis = 0;
+ var seed = 0;
+
+ if (text != null && text.StartsWith("axis:"))
+ {
+ var payload = text.Substring("axis:".Length);
+ var hash = payload.IndexOf('#');
+ if (hash >= 0)
+ {
+ var digits = new string(payload.Substring(hash + 1).TakeWhile(char.IsDigit).ToArray());
+ int.TryParse(digits, out seed);
+ payload = payload.Substring(0, hash);
+ }
+ int.TryParse(payload, out axis);
+ }
+ else
+ {
+ axis = Math.Abs((text ?? "").GetHashCode()) % Dimensions;
+ }
+
+ vector[axis % Dimensions] = 1f;
+
+ // Bruit stable : deux morceaux du même axe ne sont pas strictement identiques,
+ // sinon HNSW n'aurait aucun ordre à produire entre eux.
+ if (seed != 0)
+ vector[(axis + 1 + (seed % 16)) % Dimensions] = 0.01f * (seed % 10);
+
+ return vector;
+ }
+ }
+}
diff --git a/ManagerService.Tests/Infrastructure/PostgresFixture.cs b/ManagerService.Tests/Infrastructure/PostgresFixture.cs
new file mode 100644
index 0000000..8b040b4
--- /dev/null
+++ b/ManagerService.Tests/Infrastructure/PostgresFixture.cs
@@ -0,0 +1,168 @@
+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";
+ }
+}
diff --git a/ManagerService.Tests/ManagerService.Tests.csproj b/ManagerService.Tests/ManagerService.Tests.csproj
index 2a8135d..2a71edb 100644
--- a/ManagerService.Tests/ManagerService.Tests.csproj
+++ b/ManagerService.Tests/ManagerService.Tests.csproj
@@ -10,6 +10,7 @@
+
runtime; build; native; contentfiles; analyzers; buildtransitive
@@ -24,6 +25,7 @@
+
diff --git a/ManagerService.Tests/Services/VectorStoreServiceTests.cs b/ManagerService.Tests/Services/VectorStoreServiceTests.cs
new file mode 100644
index 0000000..d1c30e1
--- /dev/null
+++ b/ManagerService.Tests/Services/VectorStoreServiceTests.cs
@@ -0,0 +1,312 @@
+using ManagerService.Data;
+using ManagerService.Services;
+using ManagerService.Tests.Infrastructure;
+using Microsoft.EntityFrameworkCore;
+using Npgsql;
+using Pgvector;
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Threading.Tasks;
+using Xunit;
+
+namespace ManagerService.Tests.Services
+{
+ ///
+ /// Le RAG n'avait tourné que sur une base à UNE instance — le cas où le
+ /// post-filtrage HNSW ne se voit pas. Ces tests le mettent en défaut à deux.
+ ///
+ [Collection(PostgresCollection.Name)]
+ public class VectorStoreServiceTests
+ {
+ ///
+ /// Un rapport de 30 pour 1 entre les deux instances : assez déséquilibré pour que
+ /// toutes les lignes de la minoritaire tombent derrière hnsw.ef_search
+ /// (40 par défaut) si jamais l'index HNSW était parcouru avant le filtre.
+ ///
+ private const int NoisyRows = 600;
+ private const int TargetRows = 20;
+
+ private readonly PostgresFixture _postgres;
+
+ public VectorStoreServiceTests(PostgresFixture postgres)
+ {
+ _postgres = postgres;
+ }
+
+ private VectorStoreService BuildService(MyInfoMateDbContext db) =>
+ new VectorStoreService(db, new FakeEmbeddingService(), FakeConfiguration.Create());
+
+ ///
+ /// Deux instances : « noisy » sature l'axe 0 (celui de la question), « target »
+ /// n'a que quelques lignes sur l'axe 5. Toutes les lignes de noisy passent donc
+ /// devant toutes celles de target au classement brut.
+ ///
+ private static void SeedTwoInstances(MyInfoMateDbContext db)
+ {
+ var embeddings = new FakeEmbeddingService();
+ var rows = new List();
+
+ // Le suffixe rend chaque texte unique sans changer le vecteur : sinon la
+ // déduplication par texte de SearchAsync réduirait 20 lignes à 9.
+ for (var i = 0; i < NoisyRows; i++)
+ rows.Add(Row(embeddings, "noisy", $"noisy-{i}", i, $"axis:0#{i % 9 + 1}-{i}"));
+
+ for (var i = 0; i < TargetRows; i++)
+ rows.Add(Row(embeddings, "target", $"target-{i}", i, $"axis:5#{i % 9 + 1}-{i}"));
+
+ db.ContentEmbeddings.AddRange(rows);
+ db.SaveChanges();
+ }
+
+ private static ContentEmbedding Row(FakeEmbeddingService embeddings, string instanceId,
+ string contentId, int chunkIndex, string text) =>
+ new ContentEmbedding
+ {
+ InstanceId = instanceId,
+ ConfigurationId = null,
+ ContentId = contentId,
+ ContentType = ContentSourceType.Section,
+ ChunkIndex = chunkIndex,
+ Language = "fr",
+ Text = text,
+ Embedding = new Vector(embeddings.Embed(text)),
+ UpdatedAt = DateTime.UtcNow
+ };
+
+ [SkippableFact]
+ public void Extensions_AreCreatedByMigrations()
+ {
+ Skip.IfNot(_postgres.Available, _postgres.SkipReason);
+
+ using var db = _postgres.CreateMigratedContext("vs_extensions");
+
+ using var connection = _postgres.OpenRawConnection("vs_extensions");
+ using var command = new NpgsqlCommand(
+ "SELECT extname FROM pg_extension WHERE extname IN ('vector', 'postgis')", connection);
+ using var reader = command.ExecuteReader();
+
+ var found = new List();
+ while (reader.Read()) found.Add(reader.GetString(0));
+
+ // Aucun CREATE EXTENSION n'est joué à la main : si les migrations ne les
+ // déclarent pas, un environnement neuf part sans, et rien ne le dit avant
+ // la première recherche.
+ Assert.Contains("vector", found);
+ Assert.Contains("postgis", found);
+ }
+
+ [SkippableFact]
+ public void IterativeScan_IsSupportedByTheInstalledPgvector()
+ {
+ Skip.IfNot(_postgres.Available, _postgres.SkipReason);
+
+ using var db = _postgres.CreateMigratedContext("vs_iterative");
+
+ // SearchAsync pose ce paramètre à chaque recherche. Il n'existe qu'à partir de
+ // pgvector 0.8 : sur une version antérieure, la recherche échouerait en
+ // production sur « unrecognized configuration parameter ».
+ var exception = Record.Exception(() =>
+ db.Database.ExecuteSqlRaw("SET hnsw.iterative_scan = relaxed_order"));
+
+ Assert.Null(exception);
+ }
+
+ private const string FilteredNearestQuery =
+ "SELECT 1 FROM \"ContentEmbeddings\" WHERE \"InstanceId\" = 'target' " +
+ "ORDER BY \"Embedding\" <=> $1 LIMIT 25";
+
+ private static NpgsqlCommand CountFilteredNearest(NpgsqlConnection connection)
+ {
+ var command = new NpgsqlCommand($"SELECT count(*) FROM ({FilteredNearestQuery}) s", connection);
+ // La question vise l'axe saturé par l'AUTRE instance : le pire cas, celui où
+ // toutes les lignes de « target » sont derrière toutes celles de « noisy ».
+ command.Parameters.Add(new NpgsqlParameter { Value = new Vector(new FakeEmbeddingService().Embed("axis:0")) });
+ return command;
+ }
+
+ ///
+ /// Ce qui protège réellement du post-filtrage, ce n'est pas le parcours itératif :
+ /// c'est l'index sur (InstanceId, ContentType). Le planificateur filtre par
+ /// instance d'abord et trie exactement — l'index HNSW n'est pas touché, donc il n'y
+ /// a rien à post-filtrer. Vérifié aussi à 22 000 lignes hors suite.
+ ///
+ [SkippableFact]
+ public void InstanceIndex_KeepsThePlannerAwayFromHnsw()
+ {
+ Skip.IfNot(_postgres.Available, _postgres.SkipReason);
+
+ using var db = _postgres.CreateMigratedContext("vs_plan", forceIndexScan: true);
+ SeedTwoInstances(db);
+
+ using var connection = _postgres.OpenRawConnection("vs_plan");
+ using var command = new NpgsqlCommand($"EXPLAIN (COSTS OFF) {FilteredNearestQuery}", connection);
+ command.Parameters.Add(new NpgsqlParameter { Value = new Vector(new FakeEmbeddingService().Embed("axis:0")) });
+
+ var plan = "";
+ using (var reader = command.ExecuteReader())
+ while (reader.Read()) plan += reader.GetString(0) + "\n";
+
+ Assert.Contains("IX_ContentEmbeddings_InstanceId_ContentType", plan);
+ Assert.DoesNotContain("IX_ContentEmbeddings_Embedding", plan);
+ }
+
+ ///
+ /// Et voici ce qui arriverait si cet index disparaissait : le WHERE s'applique
+ /// APRÈS le parcours HNSW, les voisins ramenés appartiennent tous à « noisy », et
+ /// le filtre les jette tous. Les 20 lignes du client existent, la recherche en
+ /// rend zéro, et rien ne signale l'anomalie.
+ ///
+ ///
+ /// ⚠️ Le second volet est le résultat inattendu de ce chantier :
+ /// hnsw.iterative_scan = relaxed_order — le garde-fou que pose
+ /// SearchAsync — ne rattrape rien ici. Le parcours s'épuise après
+ /// ~335 lignes (Rows Removed by Filter à l'EXPLAIN ANALYZE) sans avoir
+ /// atteint l'instance minoritaire, parce que le graphe est construit au fil des
+ /// insertions et non en bloc — la migration crée l'index avant qu'il n'y ait des
+ /// lignes. Le même jeu de données, index HNSW construit après l'insertion, rend
+ /// bien les 20 lignes : c'est donc la connectivité du graphe qui décide, pas le
+ /// réglage. La protection réelle est l'index sur (InstanceId, ContentType), que le
+ /// test précédent verrouille. Ce test-ci fige le comportement d'aujourd'hui pour
+ /// qu'un changement de version de pgvector se voie.
+ ///
+ [SkippableFact]
+ public void WithoutTheInstanceIndex_PostFilteringLosesEverything()
+ {
+ Skip.IfNot(_postgres.Available, _postgres.SkipReason);
+
+ using var db = _postgres.CreateMigratedContext("vs_postfilter", forceIndexScan: true);
+ SeedTwoInstances(db);
+
+ using var connection = _postgres.OpenRawConnection("vs_postfilter");
+ using (var drop = new NpgsqlCommand("DROP INDEX \"IX_ContentEmbeddings_InstanceId_ContentType\"", connection))
+ drop.ExecuteNonQuery();
+
+ using (var off = new NpgsqlCommand("SET hnsw.iterative_scan = off", connection))
+ off.ExecuteNonQuery();
+ using (var command = CountFilteredNearest(connection))
+ Assert.Equal(0, Convert.ToInt32(command.ExecuteScalar()));
+
+ using (var on = new NpgsqlCommand("SET hnsw.iterative_scan = relaxed_order", connection))
+ on.ExecuteNonQuery();
+ using (var command = CountFilteredNearest(connection))
+ Assert.Equal(0, Convert.ToInt32(command.ExecuteScalar()));
+ }
+
+ [SkippableFact]
+ public async Task Search_WithTwoInstances_StillReturnsTheMinorityInstance()
+ {
+ Skip.IfNot(_postgres.Available, _postgres.SkipReason);
+
+ using var db = _postgres.CreateMigratedContext("vs_postfilter_on", forceIndexScan: true);
+ SeedTwoInstances(db);
+
+ // Exactement le cas que le test précédent met en échec : la question vise
+ // l'axe saturé par l'autre instance. C'est ce que SearchAsync doit rattraper.
+ var results = await BuildService(db).SearchAsync("target", null, "fr", "axis:0");
+
+ Assert.Equal(5, results.Count);
+ // Et jamais une ligne de l'autre client, quoi qu'en dise la distance.
+ Assert.All(results, r => Assert.StartsWith("target-", r.ContentId));
+ }
+
+ [SkippableFact]
+ public async Task Search_HonoursTheConfiguredTopK()
+ {
+ Skip.IfNot(_postgres.Available, _postgres.SkipReason);
+
+ using var db = _postgres.CreateMigratedContext("vs_topk", forceIndexScan: true);
+ SeedTwoInstances(db);
+
+ var results = await BuildService(db).SearchAsync("target", null, "fr", "axis:0", topK: 12);
+
+ Assert.Equal(12, results.Count);
+ Assert.All(results, r => Assert.StartsWith("target-", r.ContentId));
+ }
+
+ [SkippableFact]
+ public async Task Search_DeduplicatesIdenticalTexts()
+ {
+ Skip.IfNot(_postgres.Available, _postgres.SkipReason);
+
+ using var db = _postgres.CreateMigratedContext("vs_dedup");
+ var embeddings = new FakeEmbeddingService();
+
+ // Le même paragraphe recopié sur plusieurs contenus — la description d'un lieu
+ // reprise sur chacun de ses événements.
+ for (var i = 0; i < 6; i++)
+ db.ContentEmbeddings.Add(Row(embeddings, "solo", $"c-{i}", 0, "axis:3"));
+ db.ContentEmbeddings.Add(Row(embeddings, "solo", "autre", 0, "axis:4"));
+ db.SaveChanges();
+
+ var results = await BuildService(db).SearchAsync("solo", null, "fr", "axis:3");
+
+ Assert.Equal(2, results.Count);
+ Assert.Equal(results.Select(r => r.Text).Distinct().Count(), results.Count);
+ }
+
+ [SkippableFact]
+ public async Task ReplaceAsync_PurgesTheFormerChunks()
+ {
+ Skip.IfNot(_postgres.Available, _postgres.SkipReason);
+
+ using var db = _postgres.CreateMigratedContext("vs_replace");
+ var service = BuildService(db);
+
+ await service.ReplaceAsync("solo", "cfg", "sect-1", ContentSourceType.Section, new[]
+ {
+ new ContentChunk("axis:1", 0, null, "fr"),
+ new ContentChunk("axis:2", 1, null, "fr"),
+ new ContentChunk("axis:3", 2, null, "fr")
+ });
+
+ // Une version plus courte : sans purge, les morceaux 1 et 2 resteraient et
+ // continueraient d'alimenter les réponses avec du contenu supprimé.
+ await service.ReplaceAsync("solo", "cfg", "sect-1", ContentSourceType.Section, new[]
+ {
+ new ContentChunk("axis:1", 0, null, "fr")
+ });
+
+ Assert.Equal(1, db.ContentEmbeddings.Count(e => e.ContentId == "sect-1"));
+ }
+
+ [SkippableFact]
+ public async Task ReplaceAsync_EmptyChunkList_PurgesWithoutLeavingGhosts()
+ {
+ Skip.IfNot(_postgres.Available, _postgres.SkipReason);
+
+ using var db = _postgres.CreateMigratedContext("vs_purge");
+ var service = BuildService(db);
+
+ await service.ReplaceAsync("solo", "cfg", "sect-1", ContentSourceType.Section, new[]
+ {
+ new ContentChunk("axis:1", 0, null, "fr")
+ });
+ await service.ReplaceAsync("solo", "cfg", "sect-1", ContentSourceType.Section, Array.Empty());
+
+ Assert.Empty(db.ContentEmbeddings.Where(e => e.ContentId == "sect-1").ToList());
+ }
+
+ [SkippableFact]
+ public async Task ReplaceAsync_IsIdempotent_AgainstTheUniqueIndex()
+ {
+ Skip.IfNot(_postgres.Available, _postgres.SkipReason);
+
+ using var db = _postgres.CreateMigratedContext("vs_idempotent");
+ var service = BuildService(db);
+
+ var chunks = new[]
+ {
+ new ContentChunk("axis:1", 0, null, "fr"),
+ new ContentChunk("axis:2", 1, null, "fr")
+ };
+
+ // Hangfire rejoue les jobs : un second passage ne doit pas dupliquer les
+ // morceaux, qui remonteraient ensuite deux fois dans chaque réponse.
+ await service.ReplaceAsync("solo", "cfg", "sect-1", ContentSourceType.Section, chunks);
+ await service.ReplaceAsync("solo", "cfg", "sect-1", ContentSourceType.Section, chunks);
+
+ Assert.Equal(2, db.ContentEmbeddings.Count(e => e.ContentId == "sect-1"));
+ }
+ }
+}