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

362 lines
15 KiB
C#

using ManagerService.Controllers;
using ManagerService.Data;
using ManagerService.DTOs;
using ManagerService.Services;
using ManagerService.Tests.Infrastructure;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Logging.Abstractions;
using Hangfire;
using Moq;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using Xunit;
namespace ManagerService.Tests.Controllers
{
public class AiControllerTests
{
private static readonly AiChatResponse FakeResponse = new AiChatResponse { Reply = "OK", TokensUsed = 42 };
// Chat vérifie les droits de l'appelant (IsSuperAdmin / instance du token) :
// sans utilisateur, User est null et le contrôleur renvoie un 500.
private AiController BuildController(MyInfoMateDbContext db, Mock<IAssistantService>? mockService = null,
string callerRole = Permissions.SuperAdmin, string callerInstanceId = "i1")
{
mockService ??= new Mock<IAssistantService>();
mockService
.Setup(s => s.ChatAsync(It.IsAny<AiChatRequest>()))
.ReturnsAsync(FakeResponse);
var controller = new AiController(mockService.Object, db, NullLogger<AiController>.Instance,
new Mock<IBackgroundJobClient>().Object);
FakeUser.SetUser(controller, FakeUser.Create(callerRole, callerInstanceId));
return controller;
}
private static AiChatRequest MakeRequest(string instanceId, AppType appType = AppType.Tablet) =>
new AiChatRequest { InstanceId = instanceId, AppType = appType, Message = "Bonjour" };
// ── FORBID CASES ─────────────────────────────────────────────────────
[Fact]
public async Task Chat_InstanceNotFound_ReturnsForbid()
{
using var db = DbContextFactory.Create();
var result = await BuildController(db).Chat(MakeRequest("unknown"));
Assert.IsType<ForbidResult>(result);
}
[Fact]
public async Task Chat_InstanceAssistantDisabled_ReturnsForbid()
{
using var db = DbContextFactory.Create();
db.Instances.Add(new Instance
{
Id = "i1", Name = "Musée", IsAssistant = false, DateCreation = DateTime.UtcNow
});
db.ApplicationInstances.Add(new ApplicationInstance
{
Id = "ai1", InstanceId = "i1", AppType = AppType.Tablet, IsAssistant = true,
Languages = new List<string>()
});
db.SaveChanges();
var result = await BuildController(db).Chat(MakeRequest("i1"));
Assert.IsType<ForbidResult>(result);
}
[Fact]
public async Task Chat_AppInstanceAssistantDisabled_ReturnsForbid()
{
using var db = DbContextFactory.Create();
db.Instances.Add(new Instance
{
Id = "i1", Name = "Musée", IsAssistant = true, DateCreation = DateTime.UtcNow
});
db.ApplicationInstances.Add(new ApplicationInstance
{
Id = "ai1", InstanceId = "i1", AppType = AppType.Tablet, IsAssistant = false,
Languages = new List<string>()
});
db.SaveChanges();
var result = await BuildController(db).Chat(MakeRequest("i1"));
Assert.IsType<ForbidResult>(result);
}
[Fact]
public async Task Chat_NoAppInstance_ReturnsForbid()
{
using var db = DbContextFactory.Create();
db.Instances.Add(new Instance
{
Id = "i1", Name = "Musée", IsAssistant = true, DateCreation = DateTime.UtcNow
});
db.SaveChanges();
var result = await BuildController(db).Chat(MakeRequest("i1"));
Assert.IsType<ForbidResult>(result);
}
// ── JOURNALISATION DES QUESTIONS ─────────────────────────────────────
/// <summary>
/// Un tour qui n'est pas une question de visiteur — prompt du mode proactif, ou
/// gestionnaire testant sa personnalité dans l'aperçu — ne doit pas apparaître dans
/// « Ce que demandent vos visiteurs ». Mais ses jetons sont bien consommés, donc
/// bien comptés : c'est la paire qui compte, pas chaque moitié isolément.
/// </summary>
[Fact]
public async Task Chat_NotAVisitorQuestion_CountsTokensButLogsNothing()
{
using var db = DbContextFactory.Create();
SeedAssistantInstance(db);
var request = MakeRequest("i1");
request.IsVisitorQuestion = false;
await BuildController(db).Chat(request);
Assert.Empty(db.VisitorQuestions);
Assert.Equal(42, db.Instances.First().AiTokensThisMonth);
}
/// <summary>
/// Le défaut journalise : une app visiteur déjà publiée, qui n'envoie pas le champ,
/// doit continuer à alimenter le rapport.
/// </summary>
[Fact]
public async Task Chat_FieldAbsent_DefaultsToLogging()
{
using var db = DbContextFactory.Create();
SeedAssistantInstance(db);
await BuildController(db).Chat(new AiChatRequest
{
InstanceId = "i1", AppType = AppType.Tablet, Message = "Bonjour"
});
Assert.Single(db.VisitorQuestions);
}
[Fact]
public async Task Chat_AskedByVisitor_LogsVisitorQuestion()
{
using var db = DbContextFactory.Create();
SeedAssistantInstance(db);
await BuildController(db).Chat(MakeRequest("i1"));
var logged = Assert.Single(db.VisitorQuestions);
Assert.Equal("Bonjour", logged.Question);
Assert.Equal(42, db.Instances.First().AiTokensThisMonth);
}
/// <summary>
/// Deux tours d'une même conversation partagent leur ConversationId — c'est le seul
/// lien entre eux côté serveur, l'historique étant reconstruit par le client.
/// ⚠️ Aucun client ne l'envoyait jusqu'au 2026-08-13 : le repli `Guid.NewGuid()`
/// s'appliquait à chaque appel, donc chaque question formait sa propre conversation.
/// </summary>
[Fact]
public async Task Chat_SameConversationId_LinksBothTurns()
{
using var db = DbContextFactory.Create();
SeedAssistantInstance(db);
var controller = BuildController(db);
var first = MakeRequest("i1");
first.ConversationId = "conv-1";
var second = MakeRequest("i1");
second.ConversationId = "conv-1";
await controller.Chat(first);
await controller.Chat(second);
Assert.Equal(2, db.VisitorQuestions.Count());
Assert.All(db.VisitorQuestions, q => Assert.Equal("conv-1", q.ConversationId));
}
[Fact]
public async Task Chat_NoConversationId_FallsBackToAGeneratedOne()
{
using var db = DbContextFactory.Create();
SeedAssistantInstance(db);
await BuildController(db).Chat(MakeRequest("i1"));
Assert.False(string.IsNullOrWhiteSpace(db.VisitorQuestions.First().ConversationId));
}
/// <summary>
/// Le client est responsable de traitement : couper la collecte doit vraiment
/// l'arrêter. Les jetons restent comptés — la question a bien été posée et traitée.
/// </summary>
[Fact]
public async Task Chat_CollectionDisabled_AnswersButLogsNothing()
{
using var db = DbContextFactory.Create();
SeedAssistantInstance(db);
db.Instances.First().IsVisitorQuestionCollectionEnabled = false;
db.SaveChanges();
var result = await BuildController(db).Chat(MakeRequest("i1"));
Assert.IsType<OkObjectResult>(result);
Assert.Empty(db.VisitorQuestions);
Assert.Equal(42, db.Instances.First().AiTokensThisMonth);
}
private static void SeedAssistantInstance(MyInfoMateDbContext db)
{
db.Instances.Add(new Instance
{
Id = "i1", Name = "Musée", IsAssistant = true, DateCreation = DateTime.UtcNow,
AiTokensPerMonth = 1_000, AiUsageMonthKey = DateTime.UtcNow.ToString("yyyy-MM")
});
db.ApplicationInstances.Add(new ApplicationInstance
{
Id = "ai1", InstanceId = "i1", AppType = AppType.Tablet, IsAssistant = true,
Languages = new List<string>()
});
db.SaveChanges();
}
// ── QUOTA COUNTER ────────────────────────────────────────────────────
[Fact]
public async Task Chat_FirstRequestOfMonth_ResetsCounterAndAddsTokensUsed()
{
using var db = DbContextFactory.Create();
db.Instances.Add(new Instance
{
Id = "i1", Name = "Musée", IsAssistant = true, DateCreation = DateTime.UtcNow,
AiTokensThisMonth = 99, AiTokensPerMonth = 1_000, AiUsageMonthKey = "2020-01"
});
db.ApplicationInstances.Add(new ApplicationInstance
{
Id = "ai1", InstanceId = "i1", AppType = AppType.Tablet, IsAssistant = true,
Languages = new List<string>()
});
db.SaveChanges();
await BuildController(db).Chat(MakeRequest("i1"));
var inst = db.Instances.First();
Assert.Equal(42, inst.AiTokensThisMonth);
Assert.Equal(DateTime.UtcNow.ToString("yyyy-MM"), inst.AiUsageMonthKey);
}
[Fact]
public async Task Chat_SameMonth_AddsTokensUsedToCounter()
{
using var db = DbContextFactory.Create();
var monthKey = DateTime.UtcNow.ToString("yyyy-MM");
db.Instances.Add(new Instance
{
Id = "i1", Name = "Musée", IsAssistant = true, DateCreation = DateTime.UtcNow,
AiTokensThisMonth = 3, AiTokensPerMonth = 1_000, AiUsageMonthKey = monthKey
});
db.ApplicationInstances.Add(new ApplicationInstance
{
Id = "ai1", InstanceId = "i1", AppType = AppType.Tablet, IsAssistant = true,
Languages = new List<string>()
});
db.SaveChanges();
await BuildController(db).Chat(MakeRequest("i1"));
Assert.Equal(45, db.Instances.First().AiTokensThisMonth);
}
[Fact]
public async Task Chat_QuotaAlreadyReached_ReturnsTooManyRequestsWithoutCallingAssistantService()
{
using var db = DbContextFactory.Create();
var monthKey = DateTime.UtcNow.ToString("yyyy-MM");
db.Instances.Add(new Instance
{
Id = "i1", Name = "Musée", IsAssistant = true, DateCreation = DateTime.UtcNow,
AiTokensThisMonth = 100, AiTokensPerMonth = 100, AiUsageMonthKey = monthKey
});
db.ApplicationInstances.Add(new ApplicationInstance
{
Id = "ai1", InstanceId = "i1", AppType = AppType.Tablet, IsAssistant = true,
Languages = new List<string>()
});
db.SaveChanges();
var mockService = new Mock<IAssistantService>();
mockService.Setup(s => s.ChatAsync(It.IsAny<AiChatRequest>())).ReturnsAsync(FakeResponse);
var result = await BuildController(db, mockService).Chat(MakeRequest("i1"));
var status = Assert.IsType<ObjectResult>(result);
Assert.Equal(429, status.StatusCode);
mockService.Verify(s => s.ChatAsync(It.IsAny<AiChatRequest>()), Times.Never);
}
[Fact]
public async Task Chat_PlanWithoutAi_ReturnsForbiddenWithoutCallingAssistantService()
{
using var db = DbContextFactory.Create();
db.Instances.Add(new Instance
{
// AiTokensPerMonth à 0 = pas d'IA dans le plan, et surtout pas « illimité » :
// c'est l'état de plan-starter et de toute instance fraîchement migrée.
Id = "i1", Name = "Musée", IsAssistant = true, DateCreation = DateTime.UtcNow,
AiTokensPerMonth = 0, AiUsageMonthKey = DateTime.UtcNow.ToString("yyyy-MM")
});
db.ApplicationInstances.Add(new ApplicationInstance
{
Id = "ai1", InstanceId = "i1", AppType = AppType.Tablet, IsAssistant = true,
Languages = new List<string>()
});
db.SaveChanges();
var mockService = new Mock<IAssistantService>();
mockService.Setup(s => s.ChatAsync(It.IsAny<AiChatRequest>())).ReturnsAsync(FakeResponse);
var result = await BuildController(db, mockService).Chat(MakeRequest("i1"));
var status = Assert.IsType<ObjectResult>(result);
Assert.Equal(403, status.StatusCode);
mockService.Verify(s => s.ChatAsync(It.IsAny<AiChatRequest>()), Times.Never);
}
// ── NOMINAL ──────────────────────────────────────────────────────────
[Fact]
public async Task Chat_Success_CallsAssistantServiceAndReturns200()
{
using var db = DbContextFactory.Create();
var mockService = new Mock<IAssistantService>();
mockService.Setup(s => s.ChatAsync(It.IsAny<AiChatRequest>())).ReturnsAsync(FakeResponse);
db.Instances.Add(new Instance
{
Id = "i1", Name = "Musée", IsAssistant = true, DateCreation = DateTime.UtcNow,
AiTokensPerMonth = 1_000, AiUsageMonthKey = DateTime.UtcNow.ToString("yyyy-MM")
});
db.ApplicationInstances.Add(new ApplicationInstance
{
Id = "ai1", InstanceId = "i1", AppType = AppType.Tablet, IsAssistant = true,
Languages = new List<string>()
});
db.SaveChanges();
var result = await BuildController(db, mockService).Chat(MakeRequest("i1"));
var ok = Assert.IsType<OkObjectResult>(result);
Assert.Equal(FakeResponse, ok.Value);
mockService.Verify(s => s.ChatAsync(It.IsAny<AiChatRequest>()), Times.Once);
}
}
}