using Manager.DTOs; using ManagerService.Controllers; using ManagerService.Data; using ManagerService.DTOs; using ManagerService.Tests.Infrastructure; using Microsoft.AspNetCore.Mvc; using System; using System.Collections.Generic; using System.Linq; using Xunit; namespace ManagerService.Tests.Controllers { /// /// GetSummary agrège désormais en SQL. Les tests InMemory tiennent la sémantique mais /// pas la traduction : le provider InMemory évalue tout côté client, donc une requête /// intraduisible y passerait au vert et exploserait en production. Ceux-ci s'exécutent /// contre un vrai PostgreSQL. /// [Collection(PostgresCollection.Name)] public class StatsControllerPostgresTests { private readonly PostgresFixture _postgres; public StatsControllerPostgresTests(PostgresFixture postgres) { _postgres = postgres; } private static StatsController BuildController(MyInfoMateDbContext db) { var controller = new StatsController(db); FakeUser.SetUser(controller, FakeUser.Create(Permissions.SuperAdmin, "i1")); return controller; } private static void Seed(MyInfoMateDbContext db, bool advanced) { db.Instances.Add(new Instance { Id = "i1", Name = "Musée de test", HasStats = true, HasAdvancedStats = advanced, StatsHistoryDays = MyInfoMateDbContext.StatsRetentionDays }); var section = TestSection.Article("sect-a", "i1", "A", "c1"); section.Title = new List { new TranslationDTO { language = "fr", value = "La cave" } }; db.Sections.Add(section); var day = DateTime.UtcNow.AddDays(-2); var events = new List { Ev("e1", VisitEventType.SectionView, "s1", day, sectionId: "sect-a", language: "fr", appType: AppType.Mobile), Ev("e2", VisitEventType.SectionView, "s2", day.AddHours(1), sectionId: "sect-a", language: "nl", appType: AppType.Tablet), Ev("e3", VisitEventType.SectionLeave, "s1", day.AddMinutes(5), sectionId: "sect-a", durationSeconds: 30), // s2 est une session tablette de bout en bout : un appType qui change en // cours de session n'existe pas, et l'y laisser fausserait la distribution. Ev("e4", VisitEventType.SectionLeave, "s2", day.AddHours(1).AddMinutes(5), sectionId: "sect-a", durationSeconds: 10, appType: AppType.Tablet), Ev("e5", VisitEventType.ArticleRead, "s1", day, sectionId: "sect-a"), Ev("e6", VisitEventType.QrScan, "s1", day, metadata: "{\"valid\":true}"), Ev("e7", VisitEventType.MapPoiTap, "s1", day, sectionId: "sect-a", metadata: "{\"geoPointId\":7,\"geoPointTitle\":\"Ruche\"}"), Ev("e8", VisitEventType.AgendaEventTap, "s2", day, metadata: "{\"eventId\":\"ev1\",\"eventTitle\":\"Concert\"}", appType: AppType.Tablet), Ev("e9", VisitEventType.GameComplete, "s1", day, metadata: "{\"gameType\":\"Puzzle\",\"durationSeconds\":60}"), Ev("e10", VisitEventType.MenuItemTap, "s2", day, metadata: "{\"targetSectionId\":\"sect-a\",\"menuItemTitle\":\"Accueil\"}", appType: AppType.Tablet), Ev("e11", VisitEventType.QuizComplete, "s1", day, sectionId: "sect-a", metadata: "{\"score\":4,\"totalQuestions\":5}") }; db.VisitEvents.AddRange(events); db.SaveChanges(); } private static VisitEvent Ev(string id, VisitEventType type, string sessionId, DateTime timestamp, string sectionId = null, string metadata = null, string language = null, AppType appType = AppType.Mobile, int? durationSeconds = null) => new VisitEvent { Id = id, InstanceId = "i1", SessionId = sessionId, EventType = type, SectionId = sectionId, Metadata = metadata, Language = language, AppType = appType, DurationSeconds = durationSeconds, Timestamp = timestamp }; private static StatsSummaryDTO Summarize(MyInfoMateDbContext db) { var result = BuildController(db).GetSummary("i1", null, null, null); // Le contrôleur enveloppe tout dans un try/catch qui rend 500 : une requête // intraduisible ne remonterait pas en exception mais en corps de réponse. // On lit donc le code avant de caster, sinon l'échec serait illisible. var objectResult = Assert.IsAssignableFrom(result); Assert.True(objectResult.StatusCode == 200, $"GetSummary a rendu {objectResult.StatusCode} : {objectResult.Value}"); return Assert.IsType(objectResult.Value); } [SkippableFact] public void GetSummary_TranslatesToSql_OnTheBasicPlan() { Skip.IfNot(_postgres.Available, _postgres.SkipReason); using var db = _postgres.CreateMigratedContext("stats_basic"); Seed(db, advanced: false); var summary = Summarize(db); Assert.Equal(2, summary.TotalSessions); Assert.Equal(20, summary.AvgVisitDurationSeconds); // (30 + 10) / 2 sessions var section = Assert.Single(summary.TopSections); Assert.Equal("La cave", section.SectionTitle); Assert.Equal(2, section.Views); Assert.Equal(20, section.AvgDurationSeconds); var day = Assert.Single(summary.VisitsByDay); Assert.Equal(2, day.Total); Assert.Equal(1, day.Mobile); Assert.Equal(1, day.Tablet); Assert.Equal(1, summary.AppTypeDistribution["Mobile"]); Assert.Equal(1, summary.AppTypeDistribution["Tablet"]); // Plan sans stats avancées : rien de tout ça n'a été calculé. Assert.Empty(summary.LanguageDistribution); Assert.Empty(summary.TopPois); Assert.Equal(0, summary.QrScans.TotalScans); } [SkippableFact] public void GetSummary_TranslatesToSql_OnTheAdvancedPlan() { Skip.IfNot(_postgres.Available, _postgres.SkipReason); using var db = _postgres.CreateMigratedContext("stats_advanced"); Seed(db, advanced: true); var summary = Summarize(db); Assert.Equal(1, summary.LanguageDistribution["fr"]); Assert.Equal(1, summary.LanguageDistribution["nl"]); Assert.Equal("Ruche", Assert.Single(summary.TopPois).Title); Assert.Equal("Concert", Assert.Single(summary.TopAgendaEvents).EventTitle); Assert.Equal("Puzzle", Assert.Single(summary.GameStats).GameType); Assert.Equal("Accueil", Assert.Single(summary.TopMenuItems).MenuItemTitle); Assert.Equal(1, Assert.Single(summary.TopArticles).Reads); Assert.Equal(1, summary.QrScans.TotalScans); var quiz = Assert.Single(summary.QuizStats); Assert.Equal("La cave", quiz.SectionTitle); Assert.Equal(4.0, quiz.AvgScore); } [SkippableFact] public void GetSummary_TranslatesToSql_WithAnAppTypeFilter() { Skip.IfNot(_postgres.Available, _postgres.SkipReason); using var db = _postgres.CreateMigratedContext("stats_filtered"); Seed(db, advanced: true); var result = BuildController(db).GetSummary("i1", null, null, "Tablet"); var objectResult = Assert.IsAssignableFrom(result); Assert.True(objectResult.StatusCode == 200, $"GetSummary a rendu {objectResult.StatusCode} : {objectResult.Value}"); var summary = Assert.IsType(objectResult.Value); Assert.Equal(1, summary.TotalSessions); Assert.Equal(1, summary.AppTypeDistribution["Tablet"]); } /// /// Le comptage des sessions vocales filtre sur une sous-chaîne du JSON de Metadata. /// InMemory l'évalue côté client et passerait au vert quoi qu'il arrive : seul un /// vrai PostgreSQL dit si le `Contains` se traduit en `LIKE` plutôt que d'échouer. /// [SkippableFact] public void GetSummary_VoiceSessions_TranslatesToSql() { Skip.IfNot(_postgres.Available, _postgres.SkipReason); using var db = _postgres.CreateMigratedContext("stats_voice"); Seed(db, advanced: true); // s1 utilise le vocal une fois ; le reste de sa session est tactile. db.VisitEvents.Add(Ev("e12", VisitEventType.SectionView, "s1", DateTime.UtcNow.AddDays(-2), sectionId: "sect-a", metadata: "{\"voice\":true}")); db.SaveChanges(); var summary = Summarize(db); Assert.Equal(1, summary.VoiceSessions); Assert.Equal(2, summary.TotalSessions); } [SkippableFact] public void GetSummary_OnAnEmptyWindow_TranslatesWithoutFailing() { Skip.IfNot(_postgres.Available, _postgres.SkipReason); using var db = _postgres.CreateMigratedContext("stats_empty"); Seed(db, advanced: true); // Fenêtre vide : les agrégats portent sur zéro ligne. C'est là qu'une moyenne // SQL rend NULL et qu'un cast maladroit lèverait. var result = BuildController(db).GetSummary("i1", DateTime.UtcNow.AddDays(-1), DateTime.UtcNow, null); var objectResult = Assert.IsAssignableFrom(result); Assert.True(objectResult.StatusCode == 200, $"GetSummary a rendu {objectResult.StatusCode} : {objectResult.Value}"); var summary = Assert.IsType(objectResult.Value); Assert.Equal(0, summary.TotalSessions); Assert.Equal(0, summary.AvgVisitDurationSeconds); Assert.Empty(summary.TopSections); } } }