using Omika.Api.Services; using Omika.Core.Compatibility; using Omika.Core.Constants; using Omika.Core.Contracts.Common; using Omika.Core.Contracts.Mobile.Sync; using Omika.Core.Contracts.Organisation; using Omika.Core.Contracts.Planning; using Omika.Core.Interfaces; using Omika.Core.Interfaces.Mobile; using Omika.Core.Domain; namespace Omika.Tests.Api; /// /// Tests du filtre de périmètre : compatibilité /// Omika (GUID + PK numérique résolu) sans régression Batik (comparaison par /// PK numérique). Le contrôle des droits n'est jamais contourné : un RDV hors /// périmètre reste filtré. /// public class RdvScopeServiceTests { // Le token porte le PK numérique du collaborateur (CO_PKID Omika), comme // produit par LicenseService.BuildSuccessAsync. private static readonly MobileTokenIdentity IdentityPk42 = new() { CodeClient = "CLI", DeviceId = "dev", UserId = "1", CollaborateurId = "42" }; [Fact] public async Task Cas1_UtilisateurAvecAcces_RendezVousVisible() { var service = new FakeCollaborateurService(allowed: [42]); var result = await RdvScopeService.ApplyScopeAsync( [RdvOmika(numericId: 42)], IdentityPk42, Droits(), service, new FakeIdentityResolver(), default); Assert.Single(result); } [Fact] public async Task Cas2_UtilisateurSansAcces_RendezVousFiltre() { var service = new FakeCollaborateurService(allowed: [42]); var result = await RdvScopeService.ApplyScopeAsync( [RdvOmika(numericId: 99)], IdentityPk42, Droits(), service, new FakeIdentityResolver(), default); Assert.Empty(result); } [Fact] public async Task Cas3_DroitSocietePrincipale_ComportementPreserve() { var service = new FakeCollaborateurService(allowed: [42, 100]); var result = await RdvScopeService.ApplyScopeAsync( [RdvOmika(numericId: 100)], IdentityPk42, Droits(societePrincipale: true), service, new FakeIdentityResolver(), default); Assert.Single(result); Assert.Equal((42L, true, false, false), service.LastCall); } [Fact] public async Task Cas4_DroitSociete_ComportementPreserve() { var service = new FakeCollaborateurService(allowed: [42, 200]); var result = await RdvScopeService.ApplyScopeAsync( [RdvOmika(numericId: 200)], IdentityPk42, Droits(societes: true), service, new FakeIdentityResolver(), default); Assert.Single(result); Assert.Equal((42L, false, true, false), service.LastCall); } [Fact] public async Task Cas5_DroitEquipe_ComportementPreserve() { var service = new FakeCollaborateurService(allowed: [42, 300]); var result = await RdvScopeService.ApplyScopeAsync( [RdvOmika(numericId: 300)], IdentityPk42, Droits(equipe: true), service, new FakeIdentityResolver(), default); Assert.Single(result); Assert.Equal((42L, false, false, true), service.LastCall); } [Fact] public async Task Cas6_CollaborateurOmikaIdentifieParGuid_RendezVousAutoriseVisible() { // Modèle Omika : l'affectation porte le GUID ET le PK numérique résolu. // Le RDV autorisé ne doit plus disparaître. Guid guid = Guid.Parse("0f8fad5b-d9cb-469f-a165-70867728950e"); var service = new FakeCollaborateurService(allowed: [42]); var result = await RdvScopeService.ApplyScopeAsync( [RdvOmika(numericId: 42, guid: guid)], IdentityPk42, Droits(), service, new FakeIdentityResolver(), default); Assert.Single(result); } [Fact] public async Task Cas7_Batik_DansPerimetre_Visible() { // Affectation Batik : référence numérique uniquement (FromLegacyId). // Le collaborateur courant (PK 42) est toujours inclus → visible. var rdv = new RendezVousDetailsDto { Id = Guid.NewGuid().ToString("D"), Debut = new DateTime(2026, 8, 19, 9, 0, 0), Collaborateurs = [ new AffectationCollaborateurDto { CollaborateurId = "42", SourceReference = LegacyEntityReference.FromLegacyId(42) } ] }; var service = new FakeCollaborateurService(allowed: [42]); var result = await RdvScopeService.ApplyScopeAsync( [rdv], IdentityPk42, Droits(), service, new FakeIdentityResolver(), default); Assert.Single(result); } [Fact] public async Task Cas7b_Batik_HorsPerimetre_Filtre() { // Comportement attendu Batik (aligné sur MobileBootstrapService) : sans les // 3 droits A.C.PL.*, un RDV affecté à un collaborateur hors périmètre est // filtré (le filtre par PK numérique s'applique). Avant le correctif, ce // cas était silencieusement fail-open (RDV visible à tort). var rdv = new RendezVousDetailsDto { Id = Guid.NewGuid().ToString("D"), Debut = new DateTime(2026, 8, 19, 9, 0, 0), Collaborateurs = [ new AffectationCollaborateurDto { CollaborateurId = "99", SourceReference = LegacyEntityReference.FromLegacyId(99) } ] }; var service = new FakeCollaborateurService(allowed: [42]); var result = await RdvScopeService.ApplyScopeAsync( [rdv], IdentityPk42, Droits(), service, new FakeIdentityResolver(), default); Assert.Empty(result); } [Fact] public async Task TroisDroits_AucunFiltre() { // Les 3 droits présents → aucune restriction (comportement historique). var service = new FakeCollaborateurService(allowed: [42]); var result = await RdvScopeService.ApplyScopeAsync( [RdvOmika(numericId: 99)], IdentityPk42, Droits(societePrincipale: true, societes: true, equipe: true), service, new FakeIdentityResolver(), default); Assert.Single(result); Assert.Null(service.LastCall); } private static RendezVousDetailsDto RdvOmika(long numericId, Guid? guid = null) => new() { Id = Guid.NewGuid().ToString("D"), Debut = new DateTime(2026, 8, 19, 9, 0, 0), Collaborateurs = [ new AffectationCollaborateurDto { CollaborateurId = guid?.ToString("D") ?? numericId.ToString(), SourceReference = new LegacyEntityReference( DatabaseSchemaVersion.Omika, NumericId: numericId, EntityGuid: guid) } ] }; private static IReadOnlyDictionary Droits( bool societePrincipale = false, bool societes = false, bool equipe = false) => new Dictionary { [MobilePermissionKeys.PlanningSocietePrincipale] = societePrincipale, [MobilePermissionKeys.PlanningSocietes] = societes, [MobilePermissionKeys.PlanningEquipe] = equipe }; private sealed class FakeIdentityResolver : IMobileEntityIdentityResolver { public Guid CreateCanonicalIdentity(string entityType, PhysicalEntityIdentity physical) => physical.Guid ?? Guid.NewGuid(); public PhysicalEntityIdentity? ResolvePhysicalIdentity(string entityType, Guid publicGuid) => new PhysicalEntityIdentity { Guid = publicGuid }; public bool IsCanonical(string entityType, Guid publicGuid) => publicGuid != Guid.Empty; } private sealed class FakeCollaborateurService : ICollaborateurService { private readonly IReadOnlyList _allowed; public FakeCollaborateurService(IReadOnlyList allowed) => _allowed = allowed; public (long CollaborateurId, bool SocietePrincipale, bool Societes, bool Equipe)? LastCall { get; private set; } public Task> GetAllowedCollaborateurIdsAsync( long collaborateurId, bool includeSocietePrincipale, bool includeSocietes, bool includeEquipe, CancellationToken cancellationToken = default) { LastCall = (collaborateurId, includeSocietePrincipale, includeSocietes, includeEquipe); return Task.FromResult(_allowed); } public Task> SearchAsync(SearchCollaborateurRequest request, CancellationToken cancellationToken = default) => throw new NotSupportedException(); public Task> SearchDetailsAsync(SearchCollaborateurRequest request, CancellationToken cancellationToken = default) => throw new NotSupportedException(); public Task GetByIdAsync(string id, CancellationToken cancellationToken = default) => throw new NotSupportedException(); public Task CreateAsync(CreateCollaborateurRequest request, CancellationToken cancellationToken = default, AuditActor? auteur = null) => throw new NotSupportedException(); public Task UpdateAsync(string id, UpdateCollaborateurRequest request, CancellationToken cancellationToken = default, AuditActor? auteur = null) => throw new NotSupportedException(); public Task ArchiveAsync(string id, CancellationToken cancellationToken = default) => throw new NotSupportedException(); public Task> GetSocieteIdsAsync(long collaborateurId, CancellationToken cancellationToken = default) => throw new NotSupportedException(); public Task> GetRendezVousAsync(string collaborateurId, DateTime debutMin, DateTime debutMax, CancellationToken cancellationToken = default) => throw new NotSupportedException(); public Task> GetRendezVousForCollaborateursAsync(IReadOnlyList collaborateurIds, DateTime debutMin, DateTime debutMax, CancellationToken cancellationToken = default) => throw new NotSupportedException(); } }