manager-service/ManagerService.Tests/Services/QuestionThemingServiceTests.cs
Thomas Fransolet bd484db48a Lot J : agrégats de thèmes, job de regroupement, interrupteur de collecte
Table QuestionThemeMonthly (instance, mois, thème, compteur). C'est elle qui rend
tenable le §8.4 des CGU : le regroupement vivait dans ThemeId, colonne de la ligne
VisitorQuestion, donc la purge du 90e jour l'emportait avec la question et le
client perdait tout au 91e. Elle ne porte que des compteurs — aucune donnée
personnelle, ce qui est précisément ce qui l'autorise à survivre. Insights lit
désormais les thèmes dans cette table, pas dans les questions de la fenêtre.

Liste fixe de 8 thèmes, pas de thèmes découverts par l'IA : des libellés
régénérés à chaque passage donneraient « Horaires » en janvier et « Questions
d'horaires » en février, deux lignes distinctes et une courbe qui ne veut rien
dire — alors que la table existe pour porter cet historique.

Le job tourne à 2 h, la purge à 3 h 30 : une question purgée avant d'avoir été
classée ne compte dans aucun agrégat et rien ne peut la rattraper. Le plafond de
500 par passage ne perd rien, il retarde — les plus anciennes d'abord, un passage
par jour, avertissement si le retard dépasse un passage. Les jetons ne sont pas
décomptés du quota client : il n'a pas demandé ces appels. Un lot en échec n'est
pas marqué « Autre » pour s'en débarrasser, ce serait une perte définitive
maquillée en résultat ; et la relecture se fait par numéro, jamais par position,
pour qu'une ligne manquante ne décale pas les suivantes.

Instance.IsVisitorQuestionCollectionEnabled (défaut true) + garde dans Chat : le
client est responsable de traitement, la collecte était inconditionnelle.

Ajout d'une fabrique design-time : EF construisait tout l'hôte pour trouver le
contexte, et l'hôte ouvre une connexion au démarrage — générer une migration
exigeait donc une base joignable, impossible sur une machine sans Postgres ni
Docker.

dotnet test : 211 passés, 15 sautés, 0 échec.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-13 15:58:35 +02:00

194 lines
8.2 KiB
C#

using ManagerService.Data;
using ManagerService.Services;
using ManagerService.Tests.Infrastructure;
using Microsoft.Extensions.AI;
using Microsoft.Extensions.Logging.Abstractions;
using Moq;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Xunit;
namespace ManagerService.Tests.Services
{
public class QuestionThemingServiceTests
{
/// <summary>
/// Le modèle rend une ligne « numéro|thème » par question. Ce faux le rejoue à partir
/// des thèmes qu'on lui donne, dans l'ordre.
/// </summary>
private static QuestionThemingService BuildService(
MyInfoMateDbContext db, params string[] themesInOrder)
{
var reply = string.Join("\n", themesInOrder.Select((t, i) => $"{i + 1}|{t}"));
var chatClient = new Mock<IChatClient>();
chatClient
.Setup(c => c.GetResponseAsync(
It.IsAny<IEnumerable<ChatMessage>>(),
It.IsAny<ChatOptions>(),
It.IsAny<CancellationToken>()))
.ReturnsAsync(new ChatResponse(new ChatMessage(ChatRole.Assistant, reply)));
return new QuestionThemingService(db, chatClient.Object,
NullLogger<QuestionThemingService>.Instance);
}
private static VisitorQuestion Question(string id, string instanceId, string text, DateTime createdAt) =>
new VisitorQuestion
{
ConversationId = id,
InstanceId = instanceId,
Language = "fr",
Question = text,
Reply = "…",
HasAnswer = true,
CreatedAt = createdAt
};
[Fact]
public async Task RunAsync_AssignsThemesAndFillsMonthlyAggregates()
{
using var db = DbContextFactory.Create();
var day = new DateTime(2026, 5, 12, 10, 0, 0, DateTimeKind.Utc);
db.VisitorQuestions.AddRange(
Question("c1", "i1", "Vous ouvrez à quelle heure ?", day),
Question("c2", "i1", "C'est accessible en fauteuil ?", day));
db.SaveChanges();
await BuildService(db, "Horaires et tarifs", "Accessibilité").RunAsync();
Assert.Equal(new[] { "Accessibilité", "Horaires et tarifs" },
db.VisitorQuestions.Select(q => q.ThemeId).OrderBy(t => t).ToArray());
var aggregates = db.QuestionThemeMonthlies.OrderBy(a => a.Theme).ToList();
Assert.Equal(2, aggregates.Count);
Assert.All(aggregates, a => Assert.Equal(new DateTime(2026, 5, 1, 0, 0, 0, DateTimeKind.Utc), a.Month));
Assert.All(aggregates, a => Assert.Equal(1, a.Count));
}
/// <summary>
/// Le job est rejouable : un second passage ne doit pas doubler un mois. Les questions
/// déjà classées sont hors de son périmètre, donc les compteurs ne bougent pas.
/// </summary>
[Fact]
public async Task RunAsync_IsIdempotent_AlreadyThemedQuestionsAreLeftAlone()
{
using var db = DbContextFactory.Create();
db.VisitorQuestions.Add(Question("c1", "i1", "Vous ouvrez quand ?", DateTime.UtcNow));
db.SaveChanges();
await BuildService(db, "Horaires et tarifs").RunAsync();
await BuildService(db, "Horaires et tarifs").RunAsync();
var aggregate = Assert.Single(db.QuestionThemeMonthlies);
Assert.Equal(1, aggregate.Count);
}
/// <summary>
/// Les compteurs d'un même mois s'additionnent d'un passage à l'autre — c'est ce qui
/// permet au job de tourner tous les jours sans écraser le début du mois.
/// </summary>
[Fact]
public async Task RunAsync_AddsToAnExistingMonth()
{
using var db = DbContextFactory.Create();
var day = new DateTime(2026, 5, 12, 10, 0, 0, DateTimeKind.Utc);
db.VisitorQuestions.Add(Question("c1", "i1", "Horaires ?", day));
db.SaveChanges();
await BuildService(db, "Horaires et tarifs").RunAsync();
db.VisitorQuestions.Add(Question("c2", "i1", "Et le dimanche ?", day.AddDays(3)));
db.SaveChanges();
await BuildService(db, "Horaires et tarifs").RunAsync();
Assert.Equal(2, Assert.Single(db.QuestionThemeMonthlies).Count);
}
/// <summary>
/// Un thème inventé par le modèle ne doit pas créer de ligne d'agrégat parasite :
/// deux mois ne se comparent que si les libellés sont stables.
/// </summary>
[Fact]
public async Task RunAsync_UnknownThemeFallsBackToOther()
{
using var db = DbContextFactory.Create();
db.VisitorQuestions.Add(Question("c1", "i1", "Une question", DateTime.UtcNow));
db.SaveChanges();
await BuildService(db, "Questions diverses sur le musée").RunAsync();
Assert.Equal(QuestionThemes.Other, db.VisitorQuestions.First().ThemeId);
}
/// <summary>
/// ⚠️ Le point qui protège de la perte : une réponse du modèle à laquelle il manque une
/// ligne ne doit pas décaler les suivantes. Ici la ligne 1 manque — la question 2 doit
/// garder SON thème, pas hériter de celui de la 3.
/// </summary>
[Fact]
public async Task RunAsync_MissingLineDoesNotShiftTheOthers()
{
using var db = DbContextFactory.Create();
var day = DateTime.UtcNow;
db.VisitorQuestions.AddRange(
Question("c1", "i1", "Première", day),
Question("c2", "i1", "Deuxième", day.AddMinutes(1)),
Question("c3", "i1", "Troisième", day.AddMinutes(2)));
db.SaveChanges();
var chatClient = new Mock<IChatClient>();
chatClient
.Setup(c => c.GetResponseAsync(
It.IsAny<IEnumerable<ChatMessage>>(),
It.IsAny<ChatOptions>(),
It.IsAny<CancellationToken>()))
.ReturnsAsync(new ChatResponse(new ChatMessage(ChatRole.Assistant,
"2|Accessibilité\n3|Services sur place")));
await new QuestionThemingService(db, chatClient.Object,
NullLogger<QuestionThemingService>.Instance).RunAsync();
var byQuestion = db.VisitorQuestions.ToDictionary(q => q.Question, q => q.ThemeId);
Assert.Equal(QuestionThemes.Other, byQuestion["Première"]);
Assert.Equal("Accessibilité", byQuestion["Deuxième"]);
Assert.Equal("Services sur place", byQuestion["Troisième"]);
}
/// <summary>
/// Les plus anciennes d'abord : c'est ce qui fait que le plafond par passage retarde
/// sans jamais sauter une question, et donc qu'aucune ne soit purgée non classée.
/// </summary>
[Fact]
public async Task RunAsync_ProcessesOldestFirst()
{
using var db = DbContextFactory.Create();
var old = new DateTime(2026, 1, 1, 0, 0, 0, DateTimeKind.Utc);
db.VisitorQuestions.AddRange(
Question("c-recent", "i1", "Récente", old.AddDays(30)),
Question("c-old", "i1", "Ancienne", old));
db.SaveChanges();
var captured = new List<string>();
var chatClient = new Mock<IChatClient>();
chatClient
.Setup(c => c.GetResponseAsync(
It.IsAny<IEnumerable<ChatMessage>>(),
It.IsAny<ChatOptions>(),
It.IsAny<CancellationToken>()))
.Callback<IEnumerable<ChatMessage>, ChatOptions, CancellationToken>(
(messages, _, __) => captured.Add(messages.First().Text ?? ""))
.ReturnsAsync(new ChatResponse(new ChatMessage(ChatRole.Assistant, "1|Autre\n2|Autre")));
await new QuestionThemingService(db, chatClient.Object,
NullLogger<QuestionThemingService>.Instance).RunAsync();
var prompt = Assert.Single(captured);
Assert.Contains("1. Ancienne", prompt);
Assert.Contains("2. Récente", prompt);
}
}
}