Files
Omika/tests/Omika.Tests/Api/RdvScopeServiceTests.cs
T
2026-10-23 18:46:50 +02:00

299 lines
11 KiB
C#

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;
/// <summary>
/// Tests du filtre de périmètre <see cref="RdvScopeService"/> : 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é.
/// </summary>
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<string, bool> Droits(
bool societePrincipale = false,
bool societes = false,
bool equipe = false)
=> new Dictionary<string, bool>
{
[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<long> _allowed;
public FakeCollaborateurService(IReadOnlyList<long> allowed)
=> _allowed = allowed;
public (long CollaborateurId, bool SocietePrincipale, bool Societes, bool Equipe)? LastCall { get; private set; }
public Task<IReadOnlyList<long>> GetAllowedCollaborateurIdsAsync(
long collaborateurId,
bool includeSocietePrincipale,
bool includeSocietes,
bool includeEquipe,
CancellationToken cancellationToken = default)
{
LastCall = (collaborateurId, includeSocietePrincipale, includeSocietes, includeEquipe);
return Task.FromResult(_allowed);
}
public Task<PagedResult<CollaborateurSummaryDto>> SearchAsync(SearchCollaborateurRequest request, CancellationToken cancellationToken = default)
=> throw new NotSupportedException();
public Task<PagedResult<CollaborateurDetailsDto>> SearchDetailsAsync(SearchCollaborateurRequest request, CancellationToken cancellationToken = default)
=> throw new NotSupportedException();
public Task<CollaborateurDetailsDto?> GetByIdAsync(string id, CancellationToken cancellationToken = default)
=> throw new NotSupportedException();
public Task<CollaborateurDetailsDto> CreateAsync(CreateCollaborateurRequest request, CancellationToken cancellationToken = default, AuditActor? auteur = null)
=> throw new NotSupportedException();
public Task<CollaborateurDetailsDto> UpdateAsync(string id, UpdateCollaborateurRequest request, CancellationToken cancellationToken = default, AuditActor? auteur = null)
=> throw new NotSupportedException();
public Task<bool> ArchiveAsync(string id, CancellationToken cancellationToken = default)
=> throw new NotSupportedException();
public Task<IReadOnlyList<long>> GetSocieteIdsAsync(long collaborateurId, CancellationToken cancellationToken = default)
=> throw new NotSupportedException();
public Task<IReadOnlyList<RendezVousDetailsDto>> GetRendezVousAsync(string collaborateurId, DateTime debutMin, DateTime debutMax, CancellationToken cancellationToken = default)
=> throw new NotSupportedException();
public Task<IReadOnlyList<RendezVousDetailsDto>> GetRendezVousForCollaborateursAsync(IReadOnlyList<string> collaborateurIds, DateTime debutMin, DateTime debutMax, CancellationToken cancellationToken = default)
=> throw new NotSupportedException();
}
}