using Omika.App.Local.Abstractions; using Omika.App.Local.Infrastructure; using Omika.App.Local.Models; using Omika.App.Local.Services; using Omika.Core.Contracts.Common; using Omika.Core.Contracts.Security; namespace Omika.Tests.Mobile.Local; /// /// Tests de la préférence LOCALE « pause déjeuner » : identification métier du /// type de temps, stockage SQLite par utilisateur, isolation entre utilisateurs /// et valeurs par défaut. Aucun appel réseau n'est impliqué. /// public class PauseDejeunerPreferenceTests : LocalTestBase { // ===== Identification métier du type de temps « pause déjeuner ===== /// /// L'identification privilégie la clé métier stable. Le repli sur le libellé /// n'existe que pour la génération Batik (sans PA_CLE) et ne doit JAMAIS /// confondre la pause déjeuner avec « Temps de pause », qui est un autre type. /// [Theory] [InlineData("T.TEMPS.DJNER", true)] // clé métier Omika [InlineData("t.temps.djner", true)] // insensible à la casse [InlineData("Temps pause déjeuner", true)] // libellé Batik [InlineData("Temps pause dejeuner", true)] // libellé sans accent [InlineData("Pause déjeuner", true)] // libellé personnalisé [InlineData("Temps de pause", false)] // type métier DIFFÉRENT [InlineData("Temps de préparation", false)] [InlineData("T.TEMPS.INTER", false)] [InlineData("Temps d'intervention", false)] [InlineData("Temps de trajet", false)] [InlineData("T.TEMPS.GLB", false)] [InlineData("T.TEMPS.TMPSNNFF", false)] [InlineData("", false)] [InlineData(null, false)] public void IsPauseDejeunerTypeTemps_IdentifieLeTypeSansDependreDuLibelleExact( string? identifier, bool attendu) => Assert.Equal(attendu, SuiviTempsConstants.IsPauseDejeunerTypeTemps(identifier)); [Fact] public void FindPauseDejeunerTypeTemps_ParCleMetier_Omika() { IReadOnlyList types = [ new() { Key = "T.TEMPS.INTER", Label = "Temps d'intervention" }, new() { Key = SuiviTempsConstants.PauseDejeunerTypeTempsKey, Label = "Temps pause déjeuner" }, new() { Key = "T.TEMPS.TMPSNNFF", Label = "Temps non affecté" } ]; ReferenceValueDto? trouve = SuiviTempsConstants.FindPauseDejeunerTypeTemps(types); Assert.NotNull(trouve); Assert.Equal(SuiviTempsConstants.PauseDejeunerTypeTempsKey, trouve!.Key); } [Fact] public void FindPauseDejeunerTypeTemps_ParLibelle_Batik() { // Génération Batik : aucune PA_CLE, le libellé tient lieu de clé. IReadOnlyList types = [ new() { Label = "Temps d'intervention" }, new() { Label = "Temps pause déjeuner" }, new() { Label = "Temps de pause" } ]; ReferenceValueDto? trouve = SuiviTempsConstants.FindPauseDejeunerTypeTemps(types); Assert.NotNull(trouve); Assert.Equal("Temps pause déjeuner", trouve!.Label); } [Fact] public void FindPauseDejeunerTypeTemps_SansPauseDejeuner_RetourneNull() { IReadOnlyList types = [ new() { Label = "Temps d'intervention" }, new() { Label = "Temps de pause" } ]; Assert.Null(SuiviTempsConstants.FindPauseDejeunerTypeTemps(types)); } // ===== Repository : stockage local par utilisateur ===== [Fact] public async Task Repository_AllerRetour_ConserveLaPrecisionAMinute() { await PreferencesUtilisateurRepository.SavePauseDejeunerAsync( "U1", new TimeSpan(12, 15, 0), new TimeSpan(13, 15, 0)); PauseDejeunerPreference? relue = await PreferencesUtilisateurRepository.GetPauseDejeunerAsync("U1"); Assert.NotNull(relue); Assert.Equal(new TimeSpan(12, 15, 0), relue!.Debut); Assert.Equal(new TimeSpan(13, 15, 0), relue.Fin); Assert.True(relue.EstValide); } [Fact] public async Task Repository_AucunePreference_RetourneNull() => Assert.Null(await PreferencesUtilisateurRepository.GetPauseDejeunerAsync("INCONNU")); [Fact] public async Task Repository_DeuxUtilisateurs_NeSeMelangentPas() { await PreferencesUtilisateurRepository.SavePauseDejeunerAsync( "UTILISATEUR-A", new TimeSpan(12, 0, 0), new TimeSpan(13, 0, 0)); await PreferencesUtilisateurRepository.SavePauseDejeunerAsync( "UTILISATEUR-B", new TimeSpan(12, 30, 0), new TimeSpan(13, 30, 0)); PauseDejeunerPreference? a = await PreferencesUtilisateurRepository.GetPauseDejeunerAsync("UTILISATEUR-A"); PauseDejeunerPreference? b = await PreferencesUtilisateurRepository.GetPauseDejeunerAsync("UTILISATEUR-B"); Assert.Equal(new TimeSpan(12, 0, 0), a!.Debut); Assert.Equal(new TimeSpan(13, 0, 0), a.Fin); Assert.Equal(new TimeSpan(12, 30, 0), b!.Debut); Assert.Equal(new TimeSpan(13, 30, 0), b.Fin); } [Fact] public async Task Repository_EnregistrementDeuxFois_MetAJourLaMemeLigne() { await PreferencesUtilisateurRepository.SavePauseDejeunerAsync( "U1", new TimeSpan(12, 0, 0), new TimeSpan(13, 0, 0)); await PreferencesUtilisateurRepository.SavePauseDejeunerAsync( "U1", new TimeSpan(11, 45, 0), new TimeSpan(12, 45, 0)); object? nombre = await Database.ExecuteScalarAsync( "SELECT COUNT(*) FROM \"PreferencesUtilisateur\" WHERE \"UtilisateurId\" = 'U1'"); Assert.Equal(1L, Convert.ToInt64(nombre)); PauseDejeunerPreference? relue = await PreferencesUtilisateurRepository.GetPauseDejeunerAsync("U1"); Assert.Equal(new TimeSpan(11, 45, 0), relue!.Debut); } [Fact] public async Task Repository_ValeurIncoherenteEnBase_EstIgnoreeSansErreur() { // Fin ANTÉRIEURE au début : une valeur corrompue ne doit jamais produire // une pause aberrante, ni faire échouer la lecture. await Database.ExecuteNonQueryAsync( "INSERT INTO \"PreferencesUtilisateur\" " + "(\"UtilisateurId\", \"PauseDejeunerDebut\", \"PauseDejeunerFin\", \"UpdatedAtUtc\") " + "VALUES ('U1', '13:00', '12:00', '2026-01-01T00:00:00Z')"); Assert.Null(await PreferencesUtilisateurRepository.GetPauseDejeunerAsync("U1")); } [Fact] public async Task Repository_ValeurIllisible_EstIgnoreeSansErreur() { await Database.ExecuteNonQueryAsync( "INSERT INTO \"PreferencesUtilisateur\" " + "(\"UtilisateurId\", \"PauseDejeunerDebut\", \"PauseDejeunerFin\", \"UpdatedAtUtc\") " + "VALUES ('U1', 'midi', '13h00', '2026-01-01T00:00:00Z')"); Assert.Null(await PreferencesUtilisateurRepository.GetPauseDejeunerAsync("U1")); } [Fact] public async Task Repository_HeuresNulles_EstIgnoreSansErreur() { await Database.ExecuteNonQueryAsync( "INSERT INTO \"PreferencesUtilisateur\" " + "(\"UtilisateurId\", \"PauseDejeunerDebut\", \"PauseDejeunerFin\", \"UpdatedAtUtc\") " + "VALUES ('U1', NULL, NULL, '2026-01-01T00:00:00Z')"); Assert.Null(await PreferencesUtilisateurRepository.GetPauseDejeunerAsync("U1")); } [Fact] public async Task Repository_IdentifiantVide_NeLevePasEtRetourneNull() => Assert.Null(await PreferencesUtilisateurRepository.GetPauseDejeunerAsync(" ")); // ===== Service : identité de l'utilisateur connecté ===== [Fact] public async Task Service_SansPreferenceEnregistree_RetourneLesValeursParDefautSansEcrire() { var service = CreateService(); PauseDejeunerPreference preference = await service.GetAsync(); Assert.Equal(PauseDejeunerPreference.DebutParDefaut, preference.Debut); Assert.Equal(PauseDejeunerPreference.FinParDefaut, preference.Fin); // La lecture seule ne crée aucune préférence. object? nombre = await Database.ExecuteScalarAsync( "SELECT COUNT(*) FROM \"PreferencesUtilisateur\""); Assert.Equal(0L, Convert.ToInt64(nombre)); } [Fact] public async Task Service_UtilisateurA_PuisUtilisateurB_ChacunRetrouveSesHoraires() { // Utilisateur A configure 12:00 → 13:00. Connecte("Utilisateur-A"); var serviceA = CreateService(); await serviceA.SaveAsync(new TimeSpan(12, 0, 0), new TimeSpan(13, 0, 0)); // Déconnexion / reconnexion : utilisateur B configure 12:30 → 13:30. Connecte("Utilisateur-B"); var serviceB = CreateService(); await serviceB.SaveAsync(new TimeSpan(12, 30, 0), new TimeSpan(13, 30, 0)); // B retrouve ses horaires. PauseDejeunerPreference pourB = await serviceB.GetAsync(); Assert.Equal(new TimeSpan(12, 30, 0), pourB.Debut); Assert.Equal(new TimeSpan(13, 30, 0), pourB.Fin); // A retrouve les siens, intacts. Connecte("Utilisateur-A"); PauseDejeunerPreference pourA = await CreateService().GetAsync(); Assert.Equal(new TimeSpan(12, 0, 0), pourA.Debut); Assert.Equal(new TimeSpan(13, 0, 0), pourA.Fin); } [Fact] public async Task Service_PreferencePersistante_SurvitAUneNouvelleInstance() { Connecte("Utilisateur-A"); await CreateService().SaveAsync(new TimeSpan(12, 15, 0), new TimeSpan(13, 15, 0)); // Simule un redémarrage complet : nouvelle instance de service sur la // MÊME base locale (aucun réseau, aucune synchronisation). PauseDejeunerPreference relue = await CreateService().GetAsync(); Assert.Equal(new TimeSpan(12, 15, 0), relue.Debut); Assert.Equal(new TimeSpan(13, 15, 0), relue.Fin); } [Fact] public async Task Service_FinAnterieureOuEgaleAuDebut_RefuseLEnregistrement() { var service = CreateService(); await Assert.ThrowsAsync( () => service.SaveAsync(new TimeSpan(13, 30, 0), new TimeSpan(12, 0, 0))); await Assert.ThrowsAsync( () => service.SaveAsync(new TimeSpan(12, 0, 0), new TimeSpan(12, 0, 0))); // Aucune configuration incohérente n'a été enregistrée. Assert.Null(await PreferencesUtilisateurRepository.GetPauseDejeunerAsync( PauseDejeunerPreferenceService.CleSansUtilisateur)); } [Fact] public async Task Service_SansBootstrap_UtiliseLaCleDeRepliSansLeve() { SessionService.Bootstrap = null; var service = CreateService(); await service.SaveAsync(new TimeSpan(12, 0, 0), new TimeSpan(13, 0, 0)); PauseDejeunerPreference relue = await service.GetAsync(); Assert.Equal(new TimeSpan(12, 0, 0), relue.Debut); Assert.NotNull(await PreferencesUtilisateurRepository.GetPauseDejeunerAsync( PauseDejeunerPreferenceService.CleSansUtilisateur)); } [Fact] public async Task Service_BootstrapSansUtilisateur_UtiliseLaCleDeRepli() { SessionService.Bootstrap = new LocalBootstrapState { Utilisateur = null }; var service = CreateService(); await service.SaveAsync(new TimeSpan(12, 30, 0), new TimeSpan(13, 30, 0)); Assert.NotNull(await PreferencesUtilisateurRepository.GetPauseDejeunerAsync( PauseDejeunerPreferenceService.CleSansUtilisateur)); } /// Branche un utilisateur connecté dans le bootstrap local. private void Connecte(string utilisateurId) => SessionService.Bootstrap = new LocalBootstrapState { Utilisateur = new CurrentUserDto { Id = utilisateurId, Login = utilisateurId } }; private IPauseDejeunerPreferenceService CreateService() => new PauseDejeunerPreferenceService(PreferencesUtilisateurRepository, SessionService); }