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 { public class StatsControllerTests { // GetSummary vérifie les droits de l'appelant (IsSuperAdmin / instance du token) : // sans utilisateur, User est null et le contrôleur renvoie un 500. private StatsController BuildController(MyInfoMateDbContext db, string callerRole = Permissions.SuperAdmin, string callerInstanceId = "i1") { var controller = new StatsController(db); FakeUser.SetUser(controller, FakeUser.Create(callerRole, callerInstanceId)); return controller; } // ── TRACK EVENT ────────────────────────────────────────────────────── [Fact] public void TrackEvent_ValidDto_PersistsAndReturns204() { using var db = DbContextFactory.Create(); var result = BuildController(db).TrackEvent(new VisitEventDTO { instanceId = "i1", sessionId = "s1", eventType = "SectionView", appType = "Mobile" }); Assert.IsType(result); Assert.Equal(1, db.VisitEvents.Count()); } [Fact] public void TrackEvent_MissingInstanceId_Returns400() { using var db = DbContextFactory.Create(); var result = BuildController(db).TrackEvent(new VisitEventDTO { instanceId = "", eventType = "SectionView", appType = "Mobile" }); Assert.IsType(result); } [Fact] public void TrackEvent_UnknownEventType_Returns400() { using var db = DbContextFactory.Create(); var result = BuildController(db).TrackEvent(new VisitEventDTO { instanceId = "i1", eventType = "InvalidType", appType = "Mobile" }); Assert.IsType(result); } // ── GET SUMMARY ────────────────────────────────────────────────────── [Fact] public void GetSummary_MissingInstanceId_Returns400() { using var db = DbContextFactory.Create(); var result = BuildController(db).GetSummary("", null, null, null); Assert.IsType(result); } [Fact] public void GetSummary_FiltersToInstance() { using var db = DbContextFactory.Create(); db.VisitEvents.AddRange( new VisitEvent { Id = "e1", InstanceId = "i1", SessionId = "s1", EventType = VisitEventType.SectionView, Timestamp = DateTime.UtcNow }, new VisitEvent { Id = "e2", InstanceId = "other", SessionId = "s2", EventType = VisitEventType.SectionView, Timestamp = DateTime.UtcNow } ); db.SaveChanges(); var result = BuildController(db).GetSummary("i1", null, null, null); var ok = Assert.IsType(result); var summary = Assert.IsType(ok.Value); Assert.Equal(1, summary.TotalSessions); } [Fact] public void GetSummary_CountsDistinctSessions() { using var db = DbContextFactory.Create(); db.VisitEvents.AddRange( new VisitEvent { Id = "e1", InstanceId = "i1", SessionId = "session-A", EventType = VisitEventType.SectionView, Timestamp = DateTime.UtcNow }, new VisitEvent { Id = "e2", InstanceId = "i1", SessionId = "session-A", EventType = VisitEventType.SectionView, Timestamp = DateTime.UtcNow }, new VisitEvent { Id = "e3", InstanceId = "i1", SessionId = "session-B", EventType = VisitEventType.SectionView, Timestamp = DateTime.UtcNow } ); db.SaveChanges(); var result = BuildController(db).GetSummary("i1", null, null, null); var ok = Assert.IsType(result); var summary = Assert.IsType(ok.Value); Assert.Equal(2, summary.TotalSessions); } /// /// Le vocal se compte en **sessions ayant utilisé le vocal**, pas en sessions dont /// l'AppType vaudrait Voice : une même session mêle la voix et le tactile, c'est /// tout l'objet du miroir. Ici la session A fait les deux et compte une fois. /// [Fact] public void GetSummary_VoiceSessions_CountsSessionsThatUsedVoiceAtLeastOnce() { using var db = DbContextFactory.Create(); db.VisitEvents.AddRange( new VisitEvent { Id = "e1", InstanceId = "i1", SessionId = "session-A", EventType = VisitEventType.SectionView, Metadata = "{\"voice\":true}", Timestamp = DateTime.UtcNow }, new VisitEvent { Id = "e2", InstanceId = "i1", SessionId = "session-A", EventType = VisitEventType.SectionView, Timestamp = DateTime.UtcNow }, new VisitEvent { Id = "e3", InstanceId = "i1", SessionId = "session-B", EventType = VisitEventType.SectionView, Timestamp = DateTime.UtcNow } ); db.SaveChanges(); var result = BuildController(db).GetSummary("i1", null, null, null); var ok = Assert.IsType(result); var summary = Assert.IsType(ok.Value); Assert.Equal(1, summary.VoiceSessions); Assert.Equal(2, summary.TotalSessions); } [Fact] public void GetSummary_VoiceSessions_ZeroWhenNothingIsVoice() { using var db = DbContextFactory.Create(); db.VisitEvents.Add(new VisitEvent { Id = "e1", InstanceId = "i1", SessionId = "s1", EventType = VisitEventType.SectionView, Metadata = "{\"valid\":true}", Timestamp = DateTime.UtcNow }); db.SaveChanges(); var result = BuildController(db).GetSummary("i1", null, null, null); var ok = Assert.IsType(result); Assert.Equal(0, Assert.IsType(ok.Value).VoiceSessions); } [Fact] public void GetSummary_AppliesDateRange() { using var db = DbContextFactory.Create(); var inRange = DateTime.UtcNow.AddDays(-5); var outOfRange = DateTime.UtcNow.AddDays(-40); db.VisitEvents.AddRange( new VisitEvent { Id = "e1", InstanceId = "i1", SessionId = "s1", EventType = VisitEventType.SectionView, Timestamp = inRange }, new VisitEvent { Id = "e2", InstanceId = "i1", SessionId = "s2", EventType = VisitEventType.SectionView, Timestamp = outOfRange } ); db.SaveChanges(); var from = DateTime.UtcNow.AddDays(-10); var to = DateTime.UtcNow; var result = BuildController(db).GetSummary("i1", from, to, null); var ok = Assert.IsType(result); var summary = Assert.IsType(ok.Value); Assert.Equal(1, summary.TotalSessions); } [Fact] public void GetSummary_DefaultsTo30Days() { using var db = DbContextFactory.Create(); // Event dans les 30 jours db.VisitEvents.Add(new VisitEvent { Id = "e1", InstanceId = "i1", SessionId = "s1", EventType = VisitEventType.SectionView, Timestamp = DateTime.UtcNow.AddDays(-5) }); // Event au-delà des 30 jours db.VisitEvents.Add(new VisitEvent { Id = "e2", InstanceId = "i1", SessionId = "s2", EventType = VisitEventType.SectionView, Timestamp = DateTime.UtcNow.AddDays(-35) }); db.SaveChanges(); // Sans dates → filtre 30 derniers jours var result = BuildController(db).GetSummary("i1", null, null, null); var ok = Assert.IsType(result); var summary = Assert.IsType(ok.Value); Assert.Equal(1, summary.TotalSessions); } // ── STATS AVANCÉES ─────────────────────────────────────────────────── // // Sans Instance en base, hasAdvancedStats est faux et toute cette branche est // sautée : c'est le cas des tests ci-dessus. Ceux qui suivent la seedent, sans // quoi la moitié de GetSummary ne serait jamais exécutée par la suite. private static void SeedAdvancedInstance(MyInfoMateDbContext db) { db.Instances.Add(new Instance { Id = "i1", Name = "Instance de test", HasStats = true, HasAdvancedStats = true, StatsHistoryDays = MyInfoMateDbContext.StatsRetentionDays }); db.SaveChanges(); } private static VisitEvent Event(string id, VisitEventType type, string sessionId = "s1", string sectionId = null, string metadata = null, string language = null, AppType appType = AppType.Mobile, int? durationSeconds = null, DateTime? timestamp = null) => new VisitEvent { Id = id, InstanceId = "i1", SessionId = sessionId, EventType = type, SectionId = sectionId, Metadata = metadata, Language = language, AppType = appType, DurationSeconds = durationSeconds, Timestamp = timestamp ?? DateTime.UtcNow }; private static StatsSummaryDTO Summarize(MyInfoMateDbContext db, StatsController controller = null) { var result = (controller ?? BuildControllerStatic(db)).GetSummary("i1", null, null, null); var ok = Assert.IsType(result); return Assert.IsType(ok.Value); } private static StatsController BuildControllerStatic(MyInfoMateDbContext db) { var controller = new StatsController(db); FakeUser.SetUser(controller, FakeUser.Create(Permissions.SuperAdmin, "i1")); return controller; } [Fact] public void GetSummary_WithoutAdvancedStats_SkipsAdvancedBlocks() { using var db = DbContextFactory.Create(); db.Instances.Add(new Instance { Id = "i1", Name = "Basique", HasStats = true, HasAdvancedStats = false }); db.VisitEvents.AddRange( Event("e1", VisitEventType.SectionView, sectionId: "sect-a", language: "fr"), Event("e2", VisitEventType.QrScan, metadata: "{\"valid\":true}"), Event("e3", VisitEventType.ArticleRead, sectionId: "sect-a") ); db.SaveChanges(); var summary = Summarize(db); Assert.Equal(1, summary.TotalSessions); Assert.Empty(summary.LanguageDistribution); Assert.Empty(summary.TopArticles); Assert.Equal(0, summary.QrScans.TotalScans); // Les stats de base, elles, restent servies Assert.Single(summary.TopSections); Assert.NotEmpty(summary.AppTypeDistribution); } [Fact] public void GetSummary_AvgVisitDuration_SumsPerSessionThenAverages() { using var db = DbContextFactory.Create(); SeedAdvancedInstance(db); db.VisitEvents.AddRange( Event("e1", VisitEventType.SectionLeave, "s1", "sect-a", durationSeconds: 10), Event("e2", VisitEventType.SectionLeave, "s1", "sect-b", durationSeconds: 30), Event("e3", VisitEventType.SectionLeave, "s2", "sect-a", durationSeconds: 20) ); db.SaveChanges(); // s1 = 40, s2 = 20 → moyenne 30. Une moyenne sur les événements donnerait 20. Assert.Equal(30, Summarize(db).AvgVisitDurationSeconds); } [Fact] public void GetSummary_TopSections_CarryAvgDurationAndTitle() { using var db = DbContextFactory.Create(); SeedAdvancedInstance(db); var section = TestSection.Article("sect-a", "i1", "A", "c1"); section.Title = new List { new TranslationDTO { language = "en", value = "Cellar" }, new TranslationDTO { language = "fr", value = "La cave" } }; db.Sections.Add(section); db.VisitEvents.AddRange( Event("e1", VisitEventType.SectionView, "s1", "sect-a"), Event("e2", VisitEventType.SectionLeave, "s1", "sect-a", durationSeconds: 10), Event("e3", VisitEventType.SectionLeave, "s2", "sect-a", durationSeconds: 20) ); db.SaveChanges(); var stat = Assert.Single(Summarize(db).TopSections); Assert.Equal("La cave", stat.SectionTitle); // FR privilégié Assert.Equal(1, stat.Views); Assert.Equal(15, stat.AvgDurationSeconds); } [Fact] public void GetSummary_VisitsByDay_GroupsByCalendarDayAndChannel() { using var db = DbContextFactory.Create(); SeedAdvancedInstance(db); var day = DateTime.UtcNow.AddDays(-3); db.VisitEvents.AddRange( Event("e1", VisitEventType.SectionView, "s1", timestamp: day, appType: AppType.Mobile), Event("e2", VisitEventType.SectionView, "s2", timestamp: day.AddHours(2), appType: AppType.Tablet), Event("e3", VisitEventType.SectionView, "s3", timestamp: day.AddDays(1), appType: AppType.Mobile) ); db.SaveChanges(); var days = Summarize(db).VisitsByDay; Assert.Equal(2, days.Count); Assert.Equal(day.ToString("yyyy-MM-dd"), days[0].Date); Assert.Equal(2, days[0].Total); Assert.Equal(1, days[0].Mobile); Assert.Equal(1, days[0].Tablet); } [Fact] public void GetSummary_LanguageDistribution_CountsOneEntryPerSession() { using var db = DbContextFactory.Create(); SeedAdvancedInstance(db); var start = DateTime.UtcNow.AddHours(-2); db.VisitEvents.AddRange( Event("e1", VisitEventType.SectionView, "s1", language: "fr", timestamp: start), Event("e2", VisitEventType.SectionView, "s1", language: "fr", timestamp: start.AddMinutes(1)), // La session bascule en cours de route : c'est sa première langue qui compte Event("e3", VisitEventType.SectionView, "s1", language: "nl", timestamp: start.AddMinutes(2)), Event("e4", VisitEventType.SectionView, "s2", language: "nl", timestamp: start) ); db.SaveChanges(); var languages = Summarize(db).LanguageDistribution; Assert.Equal(1, languages["fr"]); Assert.Equal(1, languages["nl"]); } [Fact] public void GetSummary_AppTypeDistribution_CountsOneEntryPerSession() { using var db = DbContextFactory.Create(); SeedAdvancedInstance(db); db.VisitEvents.AddRange( Event("e1", VisitEventType.SectionView, "s1", appType: AppType.Tablet), Event("e2", VisitEventType.SectionView, "s1", appType: AppType.Tablet), Event("e3", VisitEventType.SectionView, "s2", appType: AppType.Mobile) ); db.SaveChanges(); var appTypes = Summarize(db).AppTypeDistribution; Assert.Equal(1, appTypes["Tablet"]); Assert.Equal(1, appTypes["Mobile"]); } [Fact] public void GetSummary_MetadataStats_AreAggregated() { using var db = DbContextFactory.Create(); SeedAdvancedInstance(db); db.VisitEvents.AddRange( Event("p1", VisitEventType.MapPoiTap, sectionId: "sect-map", metadata: "{\"geoPointId\":7,\"geoPointTitle\":\"Ruche\"}"), Event("p2", VisitEventType.MapPoiTap, sectionId: "sect-map", metadata: "{\"geoPointId\":7,\"geoPointTitle\":\"Ruche\"}"), Event("a1", VisitEventType.AgendaEventTap, metadata: "{\"eventId\":\"ev1\",\"eventTitle\":\"Concert\"}"), Event("g1", VisitEventType.GameComplete, metadata: "{\"gameType\":\"Puzzle\",\"durationSeconds\":60}"), Event("g2", VisitEventType.GameComplete, metadata: "{\"gameType\":\"Puzzle\",\"durationSeconds\":40}"), Event("m1", VisitEventType.MenuItemTap, metadata: "{\"targetSectionId\":\"sect-a\",\"menuItemTitle\":\"Accueil\"}"), Event("r1", VisitEventType.ArticleRead, sectionId: "sect-a") ); db.SaveChanges(); var summary = Summarize(db); var poi = Assert.Single(summary.TopPois); Assert.Equal(7, poi.GeoPointId); Assert.Equal(2, poi.Taps); Assert.Equal("sect-map", poi.SectionId); Assert.Equal("Concert", Assert.Single(summary.TopAgendaEvents).EventTitle); var game = Assert.Single(summary.GameStats); Assert.Equal(2, game.Completions); Assert.Equal(50, game.AvgDurationSeconds); Assert.Equal("Accueil", Assert.Single(summary.TopMenuItems).MenuItemTitle); Assert.Equal(1, Assert.Single(summary.TopArticles).Reads); } [Fact] public void GetSummary_QuizStats_AverageScorePerSection() { using var db = DbContextFactory.Create(); SeedAdvancedInstance(db); db.VisitEvents.AddRange( Event("q1", VisitEventType.QuizComplete, "s1", "sect-quiz", "{\"score\":4,\"totalQuestions\":5}"), Event("q2", VisitEventType.QuizComplete, "s2", "sect-quiz", "{\"score\":2,\"totalQuestions\":5}"), // Metadata illisible : compté, à zéro, comme avant Event("q3", VisitEventType.QuizComplete, "s3", "sect-quiz", "pas du json") ); db.SaveChanges(); var quiz = Assert.Single(Summarize(db).QuizStats); Assert.Equal("sect-quiz", quiz.SectionId); Assert.Equal(3, quiz.Completions); Assert.Equal(2.0, quiz.AvgScore); } [Fact] public void GetSummary_QrScans_CountsScansWithoutMetadataInTotalOnly() { using var db = DbContextFactory.Create(); SeedAdvancedInstance(db); db.VisitEvents.AddRange( Event("qr1", VisitEventType.QrScan, metadata: "{\"valid\":true}"), Event("qr2", VisitEventType.QrScan, metadata: "{\"valid\":false}"), Event("qr3", VisitEventType.QrScan) // sans metadata : ni valide ni invalide ); db.SaveChanges(); var qr = Summarize(db).QrScans; Assert.Equal(3, qr.TotalScans); Assert.Equal(1, qr.ValidScans); Assert.Equal(1, qr.InvalidScans); } [Fact] public void GetSummary_HasStatsDisabled_ReturnsEmptySummary() { using var db = DbContextFactory.Create(); db.Instances.Add(new Instance { Id = "i1", Name = "Sans stats", HasStats = false }); db.VisitEvents.Add(Event("e1", VisitEventType.SectionView, sectionId: "sect-a")); db.SaveChanges(); Assert.Equal(0, Summarize(db).TotalSessions); } [Fact] public void GetSummary_RetentionCapsTheRequestedRange() { // Le plan borne l'historique : une plage plus large que la rétention est // ramenée à la rétention, pas honorée. using var db = DbContextFactory.Create(); db.Instances.Add(new Instance { Id = "i1", Name = "30 jours", HasStats = true, StatsHistoryDays = 30 }); db.VisitEvents.AddRange( Event("e1", VisitEventType.SectionView, "s1", timestamp: DateTime.UtcNow.AddDays(-10)), Event("e2", VisitEventType.SectionView, "s2", timestamp: DateTime.UtcNow.AddDays(-90)) ); db.SaveChanges(); var result = BuildControllerStatic(db).GetSummary("i1", DateTime.UtcNow.AddDays(-365), DateTime.UtcNow, null); var summary = Assert.IsType(Assert.IsType(result).Value); Assert.Equal(1, summary.TotalSessions); } [Fact] public void GetSummary_TopSections_AggregatesByViewCount() { using var db = DbContextFactory.Create(); db.VisitEvents.AddRange( new VisitEvent { Id = "e1", InstanceId = "i1", SessionId = "s1", EventType = VisitEventType.SectionView, SectionId = "sect-a", Timestamp = DateTime.UtcNow }, new VisitEvent { Id = "e2", InstanceId = "i1", SessionId = "s2", EventType = VisitEventType.SectionView, SectionId = "sect-a", Timestamp = DateTime.UtcNow }, new VisitEvent { Id = "e3", InstanceId = "i1", SessionId = "s3", EventType = VisitEventType.SectionView, SectionId = "sect-b", Timestamp = DateTime.UtcNow } ); db.SaveChanges(); var result = BuildController(db).GetSummary("i1", null, null, null); var ok = Assert.IsType(result); var summary = Assert.IsType(ok.Value); Assert.Equal(2, summary.TopSections.Count); Assert.Equal("sect-a", summary.TopSections.First().SectionId); Assert.Equal(2, summary.TopSections.First().Views); } } }