834 lines
31 KiB
C#
834 lines
31 KiB
C#
using System.Security.Cryptography;
|
|
using Microsoft.Extensions.Options;
|
|
using Omika.Core.Compatibility;
|
|
using Omika.Core.Configuration;
|
|
using Omika.Core.Contracts.Common;
|
|
using Omika.Core.Contracts.Mobile.Enrollment;
|
|
using Omika.Core.Contracts.Mobile.License;
|
|
using Omika.Core.Contracts.Mobile.Sync;
|
|
using Omika.Core.Domain.Commands;
|
|
using Omika.Core.Domain.Entities.Mobile;
|
|
using Omika.Core.Domain.Entities.Organisation;
|
|
using Omika.Core.Interfaces;
|
|
using Omika.Core.Interfaces.Mobile;
|
|
using Omika.Core.Services.Mobile;
|
|
|
|
namespace Omika.Tests.Mobile;
|
|
|
|
/// <summary>
|
|
/// Tests du service de licence mobile (LicenseService).
|
|
/// </summary>
|
|
public class LicenseServiceTests
|
|
{
|
|
private const string CodeClient = "BATIK.01";
|
|
private const string NumeroLicence = "1ab053eb5e274e1f92455bbb7de9f2d3";
|
|
private const string DeviceId = "device-abc-123";
|
|
|
|
// Clé RSA éphémère partagée pour signer/vérifier les QR de test. Aucun secret
|
|
// serveur : elle ne sert qu'à valider le format du QR produit par le service.
|
|
private const string TestKeyId = "omika-qr-2026-prod-01";
|
|
private static readonly string TestPrivateKey;
|
|
private static readonly string TestPublicKey;
|
|
|
|
static LicenseServiceTests()
|
|
{
|
|
using var rsa = RSA.Create(2048);
|
|
TestPrivateKey = rsa.ToXmlString(true);
|
|
TestPublicKey = rsa.ToXmlString(false);
|
|
}
|
|
|
|
private static LicenseOptions DefaultOptions() => new() { OfflineGraceDays = 10 };
|
|
|
|
private static LicenseQrOptions QrOptions() => new() { KeyId = TestKeyId, PrivateKeyXml = TestPrivateKey };
|
|
|
|
private static Licence ValidLicence(string? deviceId = null, bool active = true, DateTime? expiration = null, string code = CodeClient)
|
|
{
|
|
return new Licence
|
|
{
|
|
Numero = NumeroLicence,
|
|
CodeClient = code,
|
|
ClientId = 1,
|
|
DeviceId = deviceId,
|
|
Active = active,
|
|
DateExpiration = expiration,
|
|
UserNom = "Dupont",
|
|
UserPrenom = "Jean",
|
|
UserId = 42,
|
|
CollaborateurId = 7,
|
|
Type = 5
|
|
};
|
|
}
|
|
|
|
private static ClientBase ValidClient(string code = CodeClient) => new() { Id = 1, Code = code, Nom = "Société Test" };
|
|
|
|
private static LicenseService CreateService(
|
|
FakeLicenceRepository repo,
|
|
int offlineGraceDays = 10,
|
|
FakeCollaborateurRepository? collaborateurRepository = null,
|
|
LicenseQrOptions? qrOptions = null,
|
|
string? supportEmail = "support@example.test")
|
|
{
|
|
var options = Options.Create(new LicenseOptions { OfflineGraceDays = offlineGraceDays });
|
|
var qr = Options.Create(qrOptions ?? new LicenseQrOptions());
|
|
var support = Options.Create(new SupportOptions { Email = supportEmail ?? string.Empty });
|
|
return new LicenseService(
|
|
repo,
|
|
collaborateurRepository ?? new FakeCollaborateurRepository(),
|
|
new FakeIdentityResolver(),
|
|
options,
|
|
qr,
|
|
support);
|
|
}
|
|
|
|
/// <summary>Décode et vérifie un QR produit par le service (registre kid → clé publique).</summary>
|
|
private static EnrollmentQrPayload? DecodeQr(string? qrCode)
|
|
=> EnrollmentQrCodec.Decode(qrCode, new Dictionary<string, string> { [TestKeyId] = TestPublicKey });
|
|
|
|
[Fact]
|
|
public async Task Activate_Licence_Inexistante_Returns_Error()
|
|
{
|
|
// Arrange : client existant mais aucune licence.
|
|
var repo = new FakeLicenceRepository
|
|
{
|
|
Client = ValidClient()
|
|
};
|
|
var service = CreateService(repo);
|
|
var request = new LicenseActivateRequest
|
|
{
|
|
CodeClient = CodeClient,
|
|
NumeroLicence = NumeroLicence,
|
|
DeviceId = DeviceId
|
|
};
|
|
|
|
// Act
|
|
var result = await service.ActivateAsync(request);
|
|
|
|
// Assert
|
|
Assert.False(result.IsValid);
|
|
Assert.Equal(LicenseErrorCodes.LicenceInconnue, result.ErrorCode);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Activate_Licence_Inactive_Returns_Error()
|
|
{
|
|
// Arrange
|
|
var repo = new FakeLicenceRepository
|
|
{
|
|
Client = ValidClient(),
|
|
Licence = ValidLicence(active: false)
|
|
};
|
|
var service = CreateService(repo);
|
|
var request = new LicenseActivateRequest
|
|
{
|
|
CodeClient = CodeClient,
|
|
NumeroLicence = NumeroLicence,
|
|
DeviceId = DeviceId
|
|
};
|
|
|
|
// Act
|
|
var result = await service.ActivateAsync(request);
|
|
|
|
// Assert
|
|
Assert.False(result.IsValid);
|
|
Assert.Equal(LicenseErrorCodes.LicenceInactive, result.ErrorCode);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Activate_Licence_Expiree_Returns_Error()
|
|
{
|
|
// Arrange
|
|
var repo = new FakeLicenceRepository
|
|
{
|
|
Client = ValidClient(),
|
|
Licence = ValidLicence(expiration: DateTime.Now.AddDays(-1))
|
|
};
|
|
var service = CreateService(repo);
|
|
var request = new LicenseActivateRequest
|
|
{
|
|
CodeClient = CodeClient,
|
|
NumeroLicence = NumeroLicence,
|
|
DeviceId = DeviceId
|
|
};
|
|
|
|
// Act
|
|
var result = await service.ActivateAsync(request);
|
|
|
|
// Assert
|
|
Assert.False(result.IsValid);
|
|
Assert.Equal(LicenseErrorCodes.LicenceExpiree, result.ErrorCode);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Activate_Licence_Attribuee_A_Un_Autre_Device_Returns_Error()
|
|
{
|
|
// Arrange
|
|
var repo = new FakeLicenceRepository
|
|
{
|
|
Client = ValidClient(),
|
|
Licence = ValidLicence(deviceId: "autre-device")
|
|
};
|
|
var service = CreateService(repo);
|
|
var request = new LicenseActivateRequest
|
|
{
|
|
CodeClient = CodeClient,
|
|
NumeroLicence = NumeroLicence,
|
|
DeviceId = DeviceId
|
|
};
|
|
|
|
// Act
|
|
var result = await service.ActivateAsync(request);
|
|
|
|
// Assert
|
|
Assert.False(result.IsValid);
|
|
Assert.Equal(LicenseErrorCodes.LicenceAttribuee, result.ErrorCode);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Activate_Valide_Returns_Success_And_Assigns_Device()
|
|
{
|
|
// Arrange
|
|
var repo = new FakeLicenceRepository
|
|
{
|
|
Client = ValidClient(),
|
|
Licence = ValidLicence(),
|
|
MobileApiUrl = "https://api-a.example.test"
|
|
};
|
|
var service = CreateService(repo);
|
|
var request = new LicenseActivateRequest
|
|
{
|
|
CodeClient = CodeClient,
|
|
NumeroLicence = NumeroLicence,
|
|
DeviceId = DeviceId,
|
|
OsType = "Android",
|
|
OsVersion = "15",
|
|
AppVersion = "1.0.0"
|
|
};
|
|
|
|
// Act
|
|
var result = await service.ActivateAsync(request);
|
|
|
|
// Assert
|
|
Assert.True(result.IsValid);
|
|
Assert.Equal(LicenseErrorCodes.Ok, result.ErrorCode);
|
|
Assert.Equal(CodeClient, result.CodeClient);
|
|
Assert.Equal(NumeroLicence, result.NumeroLicence);
|
|
Assert.Equal("Dupont", result.UserNom);
|
|
Assert.Equal("Jean", result.UserPrenom);
|
|
Assert.Equal("42", result.UserId);
|
|
Assert.Equal("7", result.CollaborateurId);
|
|
Assert.True(repo.DeviceAssigned);
|
|
Assert.True(repo.HistoAdded);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Offline_Grace_Days_Comes_From_Configuration()
|
|
{
|
|
// Arrange
|
|
var repo = new FakeLicenceRepository
|
|
{
|
|
Client = ValidClient(),
|
|
Licence = ValidLicence(),
|
|
MobileApiUrl = "https://api-a.example.test"
|
|
};
|
|
var service = CreateService(repo, offlineGraceDays: 25);
|
|
var request = new LicenseActivateRequest
|
|
{
|
|
CodeClient = CodeClient,
|
|
NumeroLicence = NumeroLicence,
|
|
DeviceId = DeviceId
|
|
};
|
|
|
|
// Act
|
|
var result = await service.ActivateAsync(request);
|
|
|
|
// Assert : la durée offline provient de la configuration, pas codée en dur.
|
|
Assert.True(result.IsValid);
|
|
Assert.Equal(25, result.OfflineGraceDays);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Activate_Valide_RetourneEmailSupportConfig()
|
|
{
|
|
// Arrange
|
|
var repo = new FakeLicenceRepository
|
|
{
|
|
Client = ValidClient(),
|
|
Licence = ValidLicence(),
|
|
MobileApiUrl = "https://api-a.example.test"
|
|
};
|
|
var service = CreateService(repo, supportEmail: "support@example.test");
|
|
|
|
// Act
|
|
var result = await service.ActivateAsync(new LicenseActivateRequest
|
|
{
|
|
CodeClient = CodeClient,
|
|
NumeroLicence = NumeroLicence,
|
|
DeviceId = DeviceId
|
|
});
|
|
|
|
// Assert : l'email de support provient de la configuration API.
|
|
Assert.True(result.IsValid);
|
|
Assert.Equal("support@example.test", result.SupportEmail);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Activate_SupportEmailVide_RetourneNull()
|
|
{
|
|
// Arrange : email de support vide/espaces dans la configuration.
|
|
var repo = new FakeLicenceRepository
|
|
{
|
|
Client = ValidClient(),
|
|
Licence = ValidLicence(),
|
|
MobileApiUrl = "https://api-a.example.test"
|
|
};
|
|
var service = CreateService(repo, supportEmail: " ");
|
|
|
|
// Act
|
|
var result = await service.ActivateAsync(new LicenseActivateRequest
|
|
{
|
|
CodeClient = CodeClient,
|
|
NumeroLicence = NumeroLicence,
|
|
DeviceId = DeviceId
|
|
});
|
|
|
|
// Assert : jamais une chaîne vide transmise au mobile (masquage propre).
|
|
Assert.True(result.IsValid);
|
|
Assert.Null(result.SupportEmail);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Validate_DeviceId_Incompatible_Returns_Error()
|
|
{
|
|
// Arrange
|
|
var repo = new FakeLicenceRepository
|
|
{
|
|
Client = ValidClient(),
|
|
Licence = ValidLicence(deviceId: "autre-device")
|
|
};
|
|
var service = CreateService(repo);
|
|
var request = new LicenseValidateRequest
|
|
{
|
|
CodeClient = CodeClient,
|
|
NumeroLicence = NumeroLicence,
|
|
DeviceId = DeviceId
|
|
};
|
|
|
|
// Act
|
|
var result = await service.ValidateAsync(request);
|
|
|
|
// Assert
|
|
Assert.False(result.IsValid);
|
|
Assert.Equal(LicenseErrorCodes.LicenceAttribuee, result.ErrorCode);
|
|
}
|
|
|
|
// ===== Résolution multi-client de l'URL API depuis la base Licence =====
|
|
|
|
[Fact]
|
|
public async Task Activate_ApiUrlHttpsValide_Retournee()
|
|
{
|
|
var repo = new FakeLicenceRepository
|
|
{
|
|
Client = ValidClient(),
|
|
Licence = ValidLicence(),
|
|
MobileApiUrl = "https://api-a.example.test"
|
|
};
|
|
var service = CreateService(repo);
|
|
|
|
var result = await service.ActivateAsync(new LicenseActivateRequest
|
|
{
|
|
CodeClient = CodeClient,
|
|
NumeroLicence = NumeroLicence,
|
|
DeviceId = DeviceId
|
|
});
|
|
|
|
Assert.True(result.IsValid);
|
|
Assert.Equal("https://api-a.example.test", result.ApiUrl);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Activate_ApiUrlAbsente_Refus()
|
|
{
|
|
var repo = new FakeLicenceRepository
|
|
{
|
|
Client = ValidClient(),
|
|
Licence = ValidLicence(),
|
|
MobileApiUrl = null
|
|
};
|
|
var service = CreateService(repo);
|
|
|
|
var result = await service.ActivateAsync(new LicenseActivateRequest
|
|
{
|
|
CodeClient = CodeClient,
|
|
NumeroLicence = NumeroLicence,
|
|
DeviceId = DeviceId
|
|
});
|
|
|
|
Assert.False(result.IsValid);
|
|
Assert.Equal(LicenseErrorCodes.ClientConfig, result.ErrorCode);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Activate_ApiUrlVide_Refus()
|
|
{
|
|
var repo = new FakeLicenceRepository
|
|
{
|
|
Client = ValidClient(),
|
|
Licence = ValidLicence(),
|
|
MobileApiUrl = " "
|
|
};
|
|
var service = CreateService(repo);
|
|
|
|
var result = await service.ActivateAsync(new LicenseActivateRequest
|
|
{
|
|
CodeClient = CodeClient,
|
|
NumeroLicence = NumeroLicence,
|
|
DeviceId = DeviceId
|
|
});
|
|
|
|
Assert.False(result.IsValid);
|
|
Assert.Equal(LicenseErrorCodes.ClientConfig, result.ErrorCode);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Activate_ApiUrlHttp_Refus()
|
|
{
|
|
var repo = new FakeLicenceRepository
|
|
{
|
|
Client = ValidClient(),
|
|
Licence = ValidLicence(),
|
|
MobileApiUrl = "http://api-a.example.test"
|
|
};
|
|
var service = CreateService(repo);
|
|
|
|
var result = await service.ActivateAsync(new LicenseActivateRequest
|
|
{
|
|
CodeClient = CodeClient,
|
|
NumeroLicence = NumeroLicence,
|
|
DeviceId = DeviceId
|
|
});
|
|
|
|
Assert.False(result.IsValid);
|
|
Assert.Equal(LicenseErrorCodes.ClientConfig, result.ErrorCode);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Activate_ApiUrlLocalhost_Refus()
|
|
{
|
|
var repo = new FakeLicenceRepository
|
|
{
|
|
Client = ValidClient(),
|
|
Licence = ValidLicence(),
|
|
MobileApiUrl = "https://localhost/api"
|
|
};
|
|
var service = CreateService(repo);
|
|
|
|
var result = await service.ActivateAsync(new LicenseActivateRequest
|
|
{
|
|
CodeClient = CodeClient,
|
|
NumeroLicence = NumeroLicence,
|
|
DeviceId = DeviceId
|
|
});
|
|
|
|
Assert.False(result.IsValid);
|
|
Assert.Equal(LicenseErrorCodes.ClientConfig, result.ErrorCode);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Activate_ResoutDepuisBaseLicence_SansParametreSystemeMetier()
|
|
{
|
|
// L'URL est résolue depuis la base Licence (fake), pas depuis la base métier.
|
|
// Le fake collaborateur (base métier) ne doit être sollicité pour AUCUN
|
|
// paramètre système pendant l'activation : SRV_API_MOBILE n'est plus lu.
|
|
var collaborateurRepository = new FakeCollaborateurRepository();
|
|
var repo = new FakeLicenceRepository
|
|
{
|
|
Client = ValidClient(),
|
|
Licence = ValidLicence(),
|
|
MobileApiUrl = "https://api-a.example.test"
|
|
};
|
|
var service = CreateService(repo, collaborateurRepository: collaborateurRepository);
|
|
|
|
var result = await service.ActivateAsync(new LicenseActivateRequest
|
|
{
|
|
CodeClient = CodeClient,
|
|
NumeroLicence = NumeroLicence,
|
|
DeviceId = DeviceId
|
|
});
|
|
|
|
Assert.True(result.IsValid);
|
|
Assert.Equal("https://api-a.example.test", result.ApiUrl);
|
|
Assert.Empty(collaborateurRepository.RequestedParametreCodes);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task MultiClient_MemeService_RetourneUrlsDistinctes()
|
|
{
|
|
// Une seule instance de service de licences résout des URLs distinctes
|
|
// pour deux clients, sans changer de configuration/base/build/instance.
|
|
var repo = new FakeLicenceRepository();
|
|
repo.ClientsByCode["CLIENT.A"] = ValidClient("CLIENT.A");
|
|
repo.ClientsByCode["CLIENT.B"] = ValidClient("CLIENT.B");
|
|
repo.LicencesByCode["CLIENT.A"] = ValidLicence(code: "CLIENT.A");
|
|
repo.LicencesByCode["CLIENT.B"] = ValidLicence(code: "CLIENT.B");
|
|
repo.MobileApiUrlsByCode["CLIENT.A"] = "https://api-a.example.test";
|
|
repo.MobileApiUrlsByCode["CLIENT.B"] = "https://api-b.example.test";
|
|
|
|
var service = CreateService(repo);
|
|
|
|
var resultA = await service.ActivateAsync(new LicenseActivateRequest
|
|
{
|
|
CodeClient = "CLIENT.A",
|
|
NumeroLicence = NumeroLicence,
|
|
DeviceId = "device-a"
|
|
});
|
|
var resultB = await service.ActivateAsync(new LicenseActivateRequest
|
|
{
|
|
CodeClient = "CLIENT.B",
|
|
NumeroLicence = NumeroLicence,
|
|
DeviceId = "device-b"
|
|
});
|
|
|
|
Assert.True(resultA.IsValid);
|
|
Assert.True(resultB.IsValid);
|
|
Assert.Equal("https://api-a.example.test", resultA.ApiUrl);
|
|
Assert.Equal("https://api-b.example.test", resultB.ApiUrl);
|
|
}
|
|
|
|
// ===== Workflow administratif : attribution / consultation / libération =====
|
|
|
|
private static Licence FreeLicence(string numero) => new()
|
|
{
|
|
Numero = numero,
|
|
ClientId = 1,
|
|
CodeClient = CodeClient,
|
|
Active = true,
|
|
Type = 5
|
|
};
|
|
|
|
private static FakeCollaborateurRepository LiaisonRepo() => new()
|
|
{
|
|
Liaison = new CollaborateurUtilisateur(7, 42, "Dupont", "Jean"),
|
|
ParametresSysteme = new Dictionary<string, string?>
|
|
{
|
|
["SRV_LICENCE_MOBILE"] = "https://licence.example.test"
|
|
}
|
|
};
|
|
|
|
[Fact]
|
|
public async Task Assign_LicenceLibre_AttribueEtRetourneQrSigne()
|
|
{
|
|
var repo = new FakeLicenceRepository { Client = ValidClient() };
|
|
repo.FreeLicences.Add(FreeLicence("LIC-FREE-1"));
|
|
var service = CreateService(repo, collaborateurRepository: LiaisonRepo(), qrOptions: QrOptions());
|
|
|
|
CollaborateurLicenceDto? dto = await service.AssignMobileLicenceAsync("7", CodeClient);
|
|
|
|
Assert.NotNull(dto);
|
|
Assert.Equal("LIC-FREE-1", dto.Numero);
|
|
Assert.Equal(CodeClient, dto.CodeClient);
|
|
Assert.True(dto.Active);
|
|
Assert.False(string.IsNullOrWhiteSpace(dto.QrCode));
|
|
Assert.Equal(1, repo.AssignCallCount);
|
|
|
|
EnrollmentQrPayload? payload = DecodeQr(dto.QrCode);
|
|
Assert.NotNull(payload);
|
|
Assert.Equal(1, payload.Version);
|
|
Assert.Equal(TestKeyId, payload.KeyId);
|
|
Assert.Equal(CodeClient, payload.CodeClient);
|
|
Assert.Equal("LIC-FREE-1", payload.NumeroLicence);
|
|
Assert.Equal("https://licence.example.test", payload.UrlLicence);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Assign_SecondeAttribution_MemeLicence_SansConsommer()
|
|
{
|
|
var repo = new FakeLicenceRepository { Client = ValidClient() };
|
|
repo.FreeLicences.Add(FreeLicence("LIC-FREE-1"));
|
|
var service = CreateService(repo, collaborateurRepository: LiaisonRepo(), qrOptions: QrOptions());
|
|
|
|
CollaborateurLicenceDto? first = await service.AssignMobileLicenceAsync("7", CodeClient);
|
|
CollaborateurLicenceDto? second = await service.AssignMobileLicenceAsync("7", CodeClient);
|
|
|
|
Assert.NotNull(first);
|
|
Assert.NotNull(second);
|
|
Assert.Equal(first.Numero, second.Numero);
|
|
Assert.Equal(first.QrCode, second.QrCode);
|
|
// Idempotence : une seule écriture, aucune nouvelle licence consommée.
|
|
Assert.Equal(1, repo.AssignCallCount);
|
|
Assert.Single(repo.AssignedNumeros);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Assign_OmikaGuid_ResoutPkEtIdempotent()
|
|
{
|
|
// Omika : l'identité publique est un GUID physique ; le resolver ne
|
|
// fournit pas de PK numérique (ResolvePhysicalIdentity → Guid). Le CO_PKID
|
|
// est résolu via la fiche collaborateur. Sans cette résolution, l'idempotence
|
|
// est sautée et une seconde attribution crée un doublon de licence.
|
|
Guid guid = Guid.NewGuid();
|
|
var collaborateur = new Collaborateur();
|
|
collaborateur.SourceReference = new LegacyEntityReference(DatabaseSchemaVersion.Omika, NumericId: 7);
|
|
|
|
var collaborateurRepo = new FakeCollaborateurRepository
|
|
{
|
|
Liaison = new CollaborateurUtilisateur(7, 42, "Dupont", "Jean"),
|
|
Details = collaborateur
|
|
};
|
|
var repo = new FakeLicenceRepository { Client = ValidClient() };
|
|
repo.FreeLicences.Add(FreeLicence("LIC-FREE-1"));
|
|
var service = CreateService(repo, collaborateurRepository: collaborateurRepo, qrOptions: QrOptions());
|
|
|
|
CollaborateurLicenceDto? first = await service.AssignMobileLicenceAsync(guid.ToString("D"), CodeClient);
|
|
CollaborateurLicenceDto? second = await service.AssignMobileLicenceAsync(guid.ToString("D"), CodeClient);
|
|
|
|
Assert.NotNull(first);
|
|
Assert.NotNull(second);
|
|
Assert.Equal(first.Numero, second.Numero);
|
|
Assert.Equal(1, repo.AssignCallCount);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Get_OmikaGuid_ResoutPkEtRetourneLicence()
|
|
{
|
|
// Omika : la lecture de la licence doit résoudre le GUID physique en PK
|
|
// numérique via la fiche collaborateur, sinon la fiche affiche « aucune
|
|
// licence » alors qu'une licence est bien attribuée.
|
|
Guid guid = Guid.NewGuid();
|
|
var collaborateur = new Collaborateur();
|
|
collaborateur.SourceReference = new LegacyEntityReference(DatabaseSchemaVersion.Omika, NumericId: 7);
|
|
|
|
var collaborateurRepo = new FakeCollaborateurRepository { Details = collaborateur };
|
|
var repo = new FakeLicenceRepository { Client = ValidClient() };
|
|
repo.LicenceByCollaborateur[7] = FreeLicence("LIC-FREE-1");
|
|
var service = CreateService(repo, collaborateurRepository: collaborateurRepo, qrOptions: QrOptions());
|
|
|
|
CollaborateurLicenceDto? dto =
|
|
await service.GetMobileLicenceForCollaborateurAsync(guid.ToString("D"), CodeClient);
|
|
|
|
Assert.NotNull(dto);
|
|
Assert.Equal("LIC-FREE-1", dto.Numero);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Assign_AucuneLicenceLibre_LanceInvalidOperation()
|
|
{
|
|
var repo = new FakeLicenceRepository { Client = ValidClient() };
|
|
var service = CreateService(repo, collaborateurRepository: LiaisonRepo(), qrOptions: QrOptions());
|
|
|
|
await Assert.ThrowsAsync<InvalidOperationException>(
|
|
() => service.AssignMobileLicenceAsync("7", CodeClient));
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Assign_CollaborateurSansCompteUtilisateur_LanceInvalidOperation()
|
|
{
|
|
var repo = new FakeLicenceRepository { Client = ValidClient() };
|
|
repo.FreeLicences.Add(FreeLicence("LIC-FREE-1"));
|
|
// Aucune liaison collaborateur → compte utilisateur (défaut : null).
|
|
var service = CreateService(repo, collaborateurRepository: new FakeCollaborateurRepository(), qrOptions: QrOptions());
|
|
|
|
await Assert.ThrowsAsync<InvalidOperationException>(
|
|
() => service.AssignMobileLicenceAsync("7", CodeClient));
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Get_SansLicence_RetourneNull()
|
|
{
|
|
var repo = new FakeLicenceRepository { Client = ValidClient() };
|
|
var service = CreateService(repo, collaborateurRepository: LiaisonRepo(), qrOptions: QrOptions());
|
|
|
|
CollaborateurLicenceDto? dto = await service.GetMobileLicenceForCollaborateurAsync("7", CodeClient);
|
|
|
|
Assert.Null(dto);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Release_LicencePresente_Libere()
|
|
{
|
|
var repo = new FakeLicenceRepository { Client = ValidClient() };
|
|
repo.LicenceByCollaborateur[7] = FreeLicence("LIC-FREE-1");
|
|
var service = CreateService(repo, collaborateurRepository: LiaisonRepo(), qrOptions: QrOptions());
|
|
|
|
bool released = await service.ReleaseMobileLicenceAsync("7");
|
|
|
|
Assert.True(released);
|
|
Assert.Equal(1, repo.ReleaseCallCount);
|
|
Assert.Empty(repo.LicenceByCollaborateur);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Release_SansLicence_RetourneFalse()
|
|
{
|
|
var repo = new FakeLicenceRepository { Client = ValidClient() };
|
|
var service = CreateService(repo, collaborateurRepository: LiaisonRepo(), qrOptions: QrOptions());
|
|
|
|
bool released = await service.ReleaseMobileLicenceAsync("7");
|
|
|
|
Assert.False(released);
|
|
Assert.Equal(0, repo.ReleaseCallCount);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Assign_ApresLiberation_AttribueNouvelleLicence()
|
|
{
|
|
var repo = new FakeLicenceRepository { Client = ValidClient() };
|
|
repo.FreeLicences.Add(FreeLicence("LIC-FREE-1"));
|
|
var service = CreateService(repo, collaborateurRepository: LiaisonRepo(), qrOptions: QrOptions());
|
|
|
|
CollaborateurLicenceDto? first = await service.AssignMobileLicenceAsync("7", CodeClient);
|
|
Assert.Equal("LIC-FREE-1", first?.Numero);
|
|
|
|
await service.ReleaseMobileLicenceAsync("7");
|
|
|
|
// Nouvelle licence libre disponible après libération.
|
|
repo.FreeLicences.Add(FreeLicence("LIC-FREE-2"));
|
|
CollaborateurLicenceDto? second = await service.AssignMobileLicenceAsync("7", CodeClient);
|
|
|
|
Assert.Equal("LIC-FREE-2", second?.Numero);
|
|
Assert.Equal(2, repo.AssignCallCount);
|
|
}
|
|
|
|
// ===== Fake =====
|
|
|
|
private sealed class FakeIdentityResolver : IMobileEntityIdentityResolver
|
|
{
|
|
public Guid CreateCanonicalIdentity(string entityType, PhysicalEntityIdentity physical)
|
|
=> physical.Guid ?? Guid.Empty;
|
|
|
|
public PhysicalEntityIdentity? ResolvePhysicalIdentity(string entityType, Guid publicGuid)
|
|
=> null;
|
|
|
|
public bool IsCanonical(string entityType, Guid publicGuid)
|
|
=> publicGuid != Guid.Empty;
|
|
}
|
|
|
|
private sealed class FakeLicenceRepository : ILicenceRepository
|
|
{
|
|
public ClientBase? Client { get; set; }
|
|
public Licence? Licence { get; set; }
|
|
public bool DeviceAssigned { get; private set; }
|
|
public bool HistoAdded { get; private set; }
|
|
|
|
/// <summary>URL d'API mobile retournée par défaut (hors dictionnaire).</summary>
|
|
public string? MobileApiUrl { get; set; }
|
|
|
|
/// <summary>Variante multi-client : résolution par code client.</summary>
|
|
public Dictionary<string, ClientBase> ClientsByCode { get; } = new();
|
|
public Dictionary<string, Licence> LicencesByCode { get; } = new();
|
|
public Dictionary<string, string?> MobileApiUrlsByCode { get; } = new();
|
|
|
|
// --- État du workflow administratif de licence mobile ---
|
|
public List<Licence> FreeLicences { get; } = new();
|
|
public Dictionary<long, Licence> LicenceByCollaborateur { get; } = new();
|
|
public int AssignCallCount { get; private set; }
|
|
public int ReleaseCallCount { get; private set; }
|
|
public List<string> AssignedNumeros { get; } = new();
|
|
|
|
public Task<ClientBase?> GetClientByCodeAsync(string codeClient, CancellationToken cancellationToken = default)
|
|
=> Task.FromResult(ClientsByCode.TryGetValue(codeClient, out ClientBase? c) ? c : Client);
|
|
|
|
public Task<Licence?> GetByNumeroAsync(string numero, CancellationToken cancellationToken = default)
|
|
=> Task.FromResult(Licence);
|
|
|
|
public Task<Licence?> GetByNumeroAndClientAsync(string numero, string codeClient, CancellationToken cancellationToken = default)
|
|
{
|
|
Licence? assigned = LicenceByCollaborateur.Values.FirstOrDefault(l => l.Numero == numero);
|
|
return Task.FromResult(assigned
|
|
?? (LicencesByCode.TryGetValue(codeClient, out Licence? l) ? l : Licence));
|
|
}
|
|
|
|
public Task<string?> GetMobileApiUrlForClientAsync(string codeClient, CancellationToken cancellationToken = default)
|
|
=> Task.FromResult(MobileApiUrlsByCode.TryGetValue(codeClient, out string? url) ? url : MobileApiUrl);
|
|
|
|
public Task<Licence?> GetByCollaborateurAsync(long collaborateurId, CancellationToken cancellationToken = default)
|
|
=> Task.FromResult(LicenceByCollaborateur.TryGetValue(collaborateurId, out Licence? licence) ? licence : null);
|
|
|
|
public Task<Licence?> GetFreeMobileLicenceAsync(long clientId, CancellationToken cancellationToken = default)
|
|
=> Task.FromResult(FreeLicences.Count > 0 ? FreeLicences[0] : null);
|
|
|
|
public Task<bool> AssignMobileLicenceAsync(string numero, long userId, long collaborateurId, string? nom, string? prenom, CancellationToken cancellationToken = default)
|
|
{
|
|
AssignCallCount++;
|
|
AssignedNumeros.Add(numero);
|
|
FreeLicences.RemoveAll(l => l.Numero == numero);
|
|
LicenceByCollaborateur[collaborateurId] = new Licence
|
|
{
|
|
Numero = numero,
|
|
ClientId = Client?.Id ?? 0,
|
|
CodeClient = Client?.Code,
|
|
CollaborateurId = collaborateurId,
|
|
UserId = userId,
|
|
UserNom = nom,
|
|
UserPrenom = prenom,
|
|
Active = true,
|
|
Type = 5
|
|
};
|
|
return Task.FromResult(true);
|
|
}
|
|
|
|
public Task<bool> ReleaseMobileLicenceAsync(string numero, CancellationToken cancellationToken = default)
|
|
{
|
|
ReleaseCallCount++;
|
|
var pair = LicenceByCollaborateur.FirstOrDefault(kv => kv.Value.Numero == numero);
|
|
if (pair.Value is not null)
|
|
{
|
|
LicenceByCollaborateur.Remove(pair.Key);
|
|
}
|
|
return Task.FromResult(true);
|
|
}
|
|
|
|
public Task<bool> AssignDeviceAsync(string numero, string deviceId, CancellationToken cancellationToken = default)
|
|
{
|
|
DeviceAssigned = true;
|
|
return Task.FromResult(true);
|
|
}
|
|
|
|
public Task AddHistoAsync(LicenceHistoEntry entry, CancellationToken cancellationToken = default)
|
|
{
|
|
HistoAdded = true;
|
|
return Task.CompletedTask;
|
|
}
|
|
}
|
|
|
|
private sealed class FakeCollaborateurRepository : ICollaborateurRepository
|
|
{
|
|
public List<string> RequestedParametreCodes { get; } = new();
|
|
public CollaborateurUtilisateur? Liaison { get; set; }
|
|
public Collaborateur? Details { get; set; }
|
|
public Dictionary<string, string?> ParametresSysteme { get; set; } = new();
|
|
|
|
public Task<PagedResult<Collaborateur>> SearchAsync(CollaborateurSearchCriteria criteria, CancellationToken cancellationToken = default)
|
|
=> Task.FromResult(new PagedResult<Collaborateur>([], 1, 20, 0, 0));
|
|
|
|
public Task<PagedResult<Collaborateur>> SearchDetailsAsync(CollaborateurSearchCriteria criteria, CancellationToken cancellationToken = default)
|
|
=> Task.FromResult(new PagedResult<Collaborateur>([], 1, 20, 0, 0));
|
|
|
|
public Task<Collaborateur?> GetDetailsAsync(string id, CancellationToken cancellationToken = default)
|
|
=> Task.FromResult(Details);
|
|
|
|
public Task<CollaborateurUtilisateur?> GetUtilisateurByCollaborateurAsync(string collaborateurId, CancellationToken cancellationToken = default)
|
|
=> Task.FromResult(Liaison);
|
|
|
|
public Task<string?> GetParametreSystemeValueAsync(string code, CancellationToken cancellationToken = default)
|
|
{
|
|
RequestedParametreCodes.Add(code);
|
|
return Task.FromResult(ParametresSysteme.TryGetValue(code, out string? value) ? value : null);
|
|
}
|
|
|
|
public Task<Collaborateur> CreateAsync(CollaborateurCreateCommand command, CancellationToken cancellationToken = default)
|
|
=> throw new NotSupportedException();
|
|
|
|
public Task<Collaborateur?> UpdateAsync(string id, CollaborateurUpdateCommand command, CancellationToken cancellationToken = default)
|
|
=> Task.FromResult<Collaborateur?>(null);
|
|
|
|
public Task<bool> ArchiveAsync(string id, CancellationToken cancellationToken = default)
|
|
=> Task.FromResult(true);
|
|
|
|
public Task<IReadOnlyList<long>> GetSocieteIdsAsync(long collaborateurId, CancellationToken cancellationToken = default)
|
|
=> Task.FromResult<IReadOnlyList<long>>([]);
|
|
|
|
public Task<IReadOnlyList<long>> GetAllowedCollaborateurIdsAsync(long collaborateurId, bool includeSocietePrincipale, bool includeSocietes, bool includeEquipe, CancellationToken cancellationToken = default)
|
|
=> Task.FromResult<IReadOnlyList<long>>([]);
|
|
}
|
|
}
|