manager-service/ManagerService.Tests/Controllers/StatsControllerPostgresTests.cs
Thomas Fransolet 2b1b20cd49 Le vector store est enfin eprouve a DEUX instances, sur un vrai Postgres
Le RAG n'avait tourne que sur une base a une seule instance -- le cas ou le
post-filtrage HNSW ne se voit pas. 14 tests Testcontainers sur l'image de
`Deployment/Dockerfile.postgres`, donc le meme PostGIS + pgvector que la
prod. Ils se sautent proprement (SkippableFact) sans demon Docker.

Ce que ca etablit :

- pgvector 0.8.6 : `SET hnsw.iterative_scan` est accepte. Ce SET est pose a
  chaque recherche par SearchAsync et n'existe qu'a partir de 0.8 -- sur une
  version anterieure, toute recherche echouait en production.
- Les extensions vector et postgis sont bien creees par les migrations.
- A deux instances, l'une saturant l'axe de la question a 30 contre 1,
  SearchAsync rend le bon nombre de resultats, tous de la bonne instance.
  Aucune fuite d'un client vers un autre.

⚠️ DEUX RESULTATS INATTENDUS, contraires a ce que le plan supposait.

1. Ce qui protege du post-filtrage n'est PAS le parcours iteratif, c'est
   l'index sur (InstanceId, ContentType). Le planificateur filtre par
   instance d'abord et trie exactement : l'index HNSW n'est jamais touche,
   donc il n'y a rien a post-filtrer. Verifie a 620 lignes (test) et a
   22 000 hors suite, avec 2 000 lignes pour l'instance cible. Un test fige
   ce plan -- si cet index disparaissait, la recherche se degraderait sans
   qu'aucune erreur ne le dise.

2. Et si on retire cet index, `hnsw.iterative_scan = relaxed_order` NE
   RATTRAPE RIEN : le parcours s'epuise apres ~335 lignes sans avoir atteint
   l'instance minoritaire, et rend zero. Le meme jeu de donnees avec l'index
   HNSW construit APRES l'insertion rend bien ses 20 lignes -- c'est donc la
   connectivite du graphe qui decide, et nos migrations creent l'index sur
   une table vide. Le SET de SearchAsync est donc une assurance qui ne couvre
   pas ce qu'on croyait ; on le garde (il est correct et sans cout), mais
   c'est l'index qui fait le travail.

Deux notes d'outillage :

- Testcontainers est epingle en 3.10.0 : la 4.x parle l'API Docker 1.44 et
  l'engine local plafonne a 1.43.
- L'image est construite par le CLI docker, pas par le constructeur d'images
  de Testcontainers : celui-ci relit le FROM pour pre-tirer l'image de base
  et ne sait pas parser `tag@sha256:`. Ce digest protege la base d'un
  changement de glibc sous ses index -- il ne se retire pas pour un test.

S'ajoutent 4 tests de GetSummary contre le vrai Postgres, qui levent le
caveat du commit precedent : le provider InMemory evalue tout cote client,
donc il ne disait rien de la traduction SQL. Les agregats du plan de base,
ceux du plan avance, le filtre appType et la fenetre vide traduisent tous.

dotnet test 200/200, aucun saute.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-12 15:34:56 +02:00

198 lines
9.0 KiB
C#

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
{
/// <summary>
/// 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.
/// </summary>
[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<TranslationDTO> { new TranslationDTO { language = "fr", value = "La cave" } };
db.Sections.Add(section);
var day = DateTime.UtcNow.AddDays(-2);
var events = new List<VisitEvent>
{
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<ObjectResult>(result);
Assert.True(objectResult.StatusCode == 200, $"GetSummary a rendu {objectResult.StatusCode} : {objectResult.Value}");
return Assert.IsType<StatsSummaryDTO>(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<ObjectResult>(result);
Assert.True(objectResult.StatusCode == 200, $"GetSummary a rendu {objectResult.StatusCode} : {objectResult.Value}");
var summary = Assert.IsType<StatsSummaryDTO>(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<ObjectResult>(result);
Assert.True(objectResult.StatusCode == 200, $"GetSummary a rendu {objectResult.StatusCode} : {objectResult.Value}");
var summary = Assert.IsType<StatsSummaryDTO>(objectResult.Value);
Assert.Equal(0, summary.TotalSessions);
Assert.Equal(0, summary.AvgVisitDurationSeconds);
Assert.Empty(summary.TopSections);
}
}
}