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 { /// /// 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. /// 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(); chatClient .Setup(c => c.GetResponseAsync( It.IsAny>(), It.IsAny(), It.IsAny())) .ReturnsAsync(new ChatResponse(new ChatMessage(ChatRole.Assistant, reply))); return new QuestionThemingService(db, chatClient.Object, NullLogger.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)); } /// /// 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. /// [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); } /// /// 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. /// [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); } /// /// 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. /// [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); } /// /// ⚠️ 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. /// [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(); chatClient .Setup(c => c.GetResponseAsync( It.IsAny>(), It.IsAny(), It.IsAny())) .ReturnsAsync(new ChatResponse(new ChatMessage(ChatRole.Assistant, "2|Accessibilité\n3|Services sur place"))); await new QuestionThemingService(db, chatClient.Object, NullLogger.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"]); } /// /// 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. /// [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(); var chatClient = new Mock(); chatClient .Setup(c => c.GetResponseAsync( It.IsAny>(), It.IsAny(), It.IsAny())) .Callback, 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.Instance).RunAsync(); var prompt = Assert.Single(captured); Assert.Contains("1. Ancienne", prompt); Assert.Contains("2. Récente", prompt); } } }