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);
}