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>
221 lines
10 KiB
C#
221 lines
10 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
|
|
{
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
[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<TranslationDTO> { new TranslationDTO { language = "fr", value = "La cave" } };
|
|
db.Sections.Add(section);
|
|
|
|
var day = DateTime.UtcNow.AddDays(-2);
|
|
var events = new List<VisitEvent>
|
|
{
|
|
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<ObjectResult>(result);
|
|
Assert.True(objectResult.StatusCode == 200, $"GetSummary a rendu {objectResult.StatusCode} : {objectResult.Value}");
|
|
|
|
return Assert.IsType<StatsSummaryDTO>(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<ObjectResult>(result);
|
|
Assert.True(objectResult.StatusCode == 200, $"GetSummary a rendu {objectResult.StatusCode} : {objectResult.Value}");
|
|
|
|
var summary = Assert.IsType<StatsSummaryDTO>(objectResult.Value);
|
|
Assert.Equal(1, summary.TotalSessions);
|
|
Assert.Equal(1, summary.AppTypeDistribution["Tablet"]);
|
|
}
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
[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<ObjectResult>(result);
|
|
Assert.True(objectResult.StatusCode == 200, $"GetSummary a rendu {objectResult.StatusCode} : {objectResult.Value}");
|
|
|
|
var summary = Assert.IsType<StatsSummaryDTO>(objectResult.Value);
|
|
Assert.Equal(0, summary.TotalSessions);
|
|
Assert.Equal(0, summary.AvgVisitDurationSeconds);
|
|
Assert.Empty(summary.TopSections);
|
|
}
|
|
}
|
|
}
|