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);
}
}
}