VoiceSessions : nombre de sessions ayant utilisé le vocal au moins une fois, lu dans le JSON de Metadata. Volontairement hors d'AppTypeDistribution — le vocal n'est pas une plateforme mais un mode d'interaction : un visiteur passe des lunettes à l'écran dans une même session, or la distribution compte une entrée par session tranchée sur l'événement le plus ancien. Le chiffre aurait dépendu de l'ordre dans lequel le visiteur a touché ses appareils. Colonne Metadata déjà existante : aucune migration, le gel du lot B tient. Le test InMemory ne prouve rien ici — le provider évalue le filtre côté client, donc une requête intraduisible passerait au vert. Un test Postgres l'accompagne. IsAutoTriggered devient IsVisitorQuestion (défaut true). Le nom d'hier ne couvrait qu'un des deux cas : le vrai sens n'est pas « déclenché automatiquement » mais « ce tour n'est pas une question de visiteur », ce qui vaut aussi pour l'aperçu de conversation, où c'est le gestionnaire qui teste sa personnalité. Renommé pendant qu'un seul commit en dépendait. Le défaut à true est délibéré : une app visiteur publiée qui n'envoie pas le champ continue de journaliser, et un test le fixe. dotnet test : 204 passés, 14 sautés, 0 échec. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
343 lines
14 KiB
C#
343 lines
14 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));
|
|
}
|
|
|
|
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);
|
|
}
|
|
}
|
|
}
|