Thomas Fransolet e3ef4137a5 Canal vocal des stats, et IsAutoTriggered renommé IsVisitorQuestion
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>
2026-08-13 14:32:54 +02:00

509 lines
22 KiB
C#

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<NoContentResult>(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<BadRequestObjectResult>(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<BadRequestObjectResult>(result);
}
// ── GET SUMMARY ──────────────────────────────────────────────────────
[Fact]
public void GetSummary_MissingInstanceId_Returns400()
{
using var db = DbContextFactory.Create();
var result = BuildController(db).GetSummary("", null, null, null);
Assert.IsType<BadRequestObjectResult>(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<OkObjectResult>(result);
var summary = Assert.IsType<StatsSummaryDTO>(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<OkObjectResult>(result);
var summary = Assert.IsType<StatsSummaryDTO>(ok.Value);
Assert.Equal(2, summary.TotalSessions);
}
/// <summary>
/// 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.
/// </summary>
[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<OkObjectResult>(result);
var summary = Assert.IsType<StatsSummaryDTO>(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<OkObjectResult>(result);
Assert.Equal(0, Assert.IsType<StatsSummaryDTO>(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<OkObjectResult>(result);
var summary = Assert.IsType<StatsSummaryDTO>(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<OkObjectResult>(result);
var summary = Assert.IsType<StatsSummaryDTO>(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<OkObjectResult>(result);
return Assert.IsType<StatsSummaryDTO>(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<TranslationDTO>
{
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<StatsSummaryDTO>(Assert.IsType<OkObjectResult>(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<OkObjectResult>(result);
var summary = Assert.IsType<StatsSummaryDTO>(ok.Value);
Assert.Equal(2, summary.TopSections.Count);
Assert.Equal("sect-a", summary.TopSections.First().SectionId);
Assert.Equal(2, summary.TopSections.First().Views);
}
}
}