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"]);
}
[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);
}
}
}