using System.Net.Http;
using System.Text;
using System.Text.Json;
using Microsoft.Extensions.Logging.Abstractions;
using Omika.App.Local.Abstractions;
using Omika.App.Local.Models;
using Omika.App.Local.Services;
using Omika.Core.Contracts.Mobile.Bootstrap;
using Omika.Core.Contracts.Mobile.Documents;
using Omika.Core.Contracts.Mobile.License;
using Omika.Core.Contracts.Mobile.Snapshot;
using Omika.Core.Contracts.Mobile.Sync;
namespace Omika.Tests.Mobile.Local;
///
/// Tests du MobileSessionService : activation, validation, persistance du token
/// (secure storage), bootstrap local, période offline autorisée et suppression
/// de session sans toucher aux données métier.
///
public class MobileSessionServiceTests : LocalTestBase
{
private sealed class FakeTokenStore : ISecureTokenStore
{
public string? Token { get; private set; }
public int SaveCalls { get; private set; }
public int ClearCalls { get; private set; }
public Task SaveAsync(string token, CancellationToken ct = default)
{
Token = token;
SaveCalls++;
return Task.CompletedTask;
}
public Task GetAsync(CancellationToken ct = default)
=> Task.FromResult(Token);
public Task ClearAsync(CancellationToken ct = default)
{
Token = null;
ClearCalls++;
return Task.CompletedTask;
}
}
private sealed class FakeDeviceIdProvider : IDeviceIdProvider
{
public string DeviceId { get; set; } = "device-123";
public Task GetDeviceIdAsync(CancellationToken ct = default) => Task.FromResult(DeviceId);
}
private sealed class FakeApiClient : IMobileApiClient
{
public string? AccessToken { get; set; }
public MobileLicenseSessionResponse ActivateToReturn { get; set; } = new();
public MobileLicenseSessionResponse ValidateToReturn { get; set; } = new();
public MobileBootstrapResponse BootstrapToReturn { get; set; } = new();
public LicenseActivateRequest? LastActivateRequest { get; private set; }
public LicenseValidateRequest? LastValidateRequest { get; private set; }
public int BootstrapCalls { get; private set; }
public int ValidateCalls { get; private set; }
public Exception? ValidateException { get; set; }
public Task ActivateAsync(
LicenseActivateRequest request, CancellationToken ct = default)
{
LastActivateRequest = request;
return Task.FromResult(ActivateToReturn);
}
public Task ValidateAsync(
LicenseValidateRequest request, CancellationToken ct = default)
{
LastValidateRequest = request;
ValidateCalls++;
if (ValidateException is not null)
{
throw ValidateException;
}
return Task.FromResult(ValidateToReturn);
}
public Task GetDevelopmentSessionAsync(string deviceId, CancellationToken ct = default)
=> throw new NotImplementedException();
public Task GetBootstrapAsync(CancellationToken ct = default)
{
BootstrapCalls++;
return Task.FromResult(BootstrapToReturn);
}
public Task SendOperationAsync(SyncOperation operation, CancellationToken ct = default)
=> throw new NotImplementedException();
public Task GetStatusAsync(Guid operationId, CancellationToken ct = default)
=> throw new NotImplementedException();
public Task GetSnapshotAsync(CancellationToken ct = default)
=> throw new NotImplementedException();
public Task DownloadDocumentAsync(Guid documentGuid, CancellationToken ct = default)
=> throw new NotImplementedException();
public Task UploadDocumentAsync(DocumentUploadRequest request, CancellationToken ct = default)
=> throw new NotImplementedException();
}
private readonly FakeTokenStore _tokenStore = new();
private readonly FakeDeviceIdProvider _deviceId = new();
private readonly FakeApiClient _api = new();
private readonly MobileSessionService _service;
public MobileSessionServiceTests()
{
_service = new MobileSessionService(
_api, _tokenStore, _deviceId, BootstrapRepository,
NullLogger.Instance);
}
private static LicenseResponse ValidLicense() => new()
{
IsValid = true,
CodeClient = "CLIENT01",
NumeroLicence = "LIC-001",
NomSociete = "Société Test",
UserNom = "Dupont",
UserPrenom = "Jean",
OfflineGraceDays = 7
};
private static MobileLicenseSessionResponse ValidSession() => new()
{
License = ValidLicense(),
AccessToken = "token-secret-123",
AccessTokenExpiresAtUtc = DateTimeOffset.UtcNow.AddHours(24)
};
[Fact]
public async Task SansSession_HasValidSession_Faux()
{
// Act
bool result = await _service.HasValidSessionAsync();
// Assert
Assert.False(result);
}
[Fact]
public async Task SansSession_HasExpiredSession_Faux()
{
// Act
bool result = await _service.HasExpiredSessionAsync();
// Assert
Assert.False(result);
}
[Fact]
public async Task Activation_Valide_PersisteTokenEtBootstrap()
{
// Arrange
_api.ActivateToReturn = ValidSession();
_api.BootstrapToReturn = new MobileBootstrapResponse
{
Utilisateur = new Omika.Core.Contracts.Security.CurrentUserDto { NomAffiche = "Dupont" },
Licence = ValidLicense()
};
// Act
LicenseResponse result = await _service.ActivateAsync("CLIENT01", "LIC-001");
// Assert : licence valide, token persisté, bootstrap persisté.
Assert.True(result.IsValid);
Assert.Equal("token-secret-123", _tokenStore.Token);
Assert.Equal(1, _tokenStore.SaveCalls);
Assert.Equal(1, _api.BootstrapCalls);
LocalBootstrapState? bootstrap = await _service.GetBootstrapAsync();
Assert.NotNull(bootstrap);
Assert.Equal("CLIENT01", bootstrap.Licence!.CodeClient);
Assert.Equal("Dupont", bootstrap.Utilisateur!.NomAffiche);
}
[Fact]
public async Task Activation_Valide_EnvoieDeviceId()
{
// Arrange
_api.ActivateToReturn = ValidSession();
_api.BootstrapToReturn = new MobileBootstrapResponse { Licence = ValidLicense() };
// Act
await _service.ActivateAsync("CLIENT01", "LIC-001");
// Assert : le device id est transmis dans la requête.
Assert.NotNull(_api.LastActivateRequest);
Assert.Equal("CLIENT01", _api.LastActivateRequest.CodeClient);
Assert.Equal("LIC-001", _api.LastActivateRequest.NumeroLicence);
Assert.Equal("device-123", _api.LastActivateRequest.DeviceId);
}
[Fact]
public async Task Activation_Invalide_NePersisteRien()
{
// Arrange
_api.ActivateToReturn = new MobileLicenseSessionResponse
{
License = new LicenseResponse
{
IsValid = false,
ErrorCode = "E1001",
ErrorMessage = "Licence inconnue"
}
};
// Act
LicenseResponse result = await _service.ActivateAsync("CLIENT01", "LIC-999");
// Assert : pas de token, pas de bootstrap, pas d'appel bootstrap.
Assert.False(result.IsValid);
Assert.Null(_tokenStore.Token);
Assert.Equal(0, _tokenStore.SaveCalls);
Assert.Equal(0, _api.BootstrapCalls);
}
[Fact]
public async Task Activation_ChampsVides_LeveException()
{
// Act / Assert
await Assert.ThrowsAsync(() => _service.ActivateAsync("", "LIC-001"));
await Assert.ThrowsAsync(() => _service.ActivateAsync("CLIENT01", ""));
}
[Fact]
public async Task Validation_SansSessionLocale_RetourneErreur()
{
// Act
LicenseResponse result = await _service.ValidateAsync();
// Assert
Assert.False(result.IsValid);
Assert.Equal("E9000", result.ErrorCode);
}
[Fact]
public async Task Validation_Valide_RafraichitTokenEtBootstrap()
{
// Arrange : session déjà présente.
_api.ActivateToReturn = ValidSession();
_api.BootstrapToReturn = new MobileBootstrapResponse { Licence = ValidLicense() };
await _service.ActivateAsync("CLIENT01", "LIC-001");
// Nouveau token pour la validation.
_api.ValidateToReturn = new MobileLicenseSessionResponse
{
License = ValidLicense(),
AccessToken = "token-rafraichi-456",
AccessTokenExpiresAtUtc = DateTimeOffset.UtcNow.AddHours(24)
};
// Act
LicenseResponse result = await _service.ValidateAsync();
// Assert : token rafraîchi, bootstrap rechargé.
Assert.True(result.IsValid);
Assert.Equal("token-rafraichi-456", _tokenStore.Token);
Assert.NotNull(_api.LastValidateRequest);
Assert.Equal("CLIENT01", _api.LastValidateRequest.CodeClient);
Assert.Equal("device-123", _api.LastValidateRequest.DeviceId);
}
[Fact]
public async Task SessionValide_HasValidSession_Vrai()
{
// Arrange
_api.ActivateToReturn = ValidSession();
_api.BootstrapToReturn = new MobileBootstrapResponse { Licence = ValidLicense() };
await _service.ActivateAsync("CLIENT01", "LIC-001");
// Act
bool result = await _service.HasValidSessionAsync();
// Assert
Assert.True(result);
}
[Fact]
public async Task SessionExpiree_HorsGrace_HasExpiredSession_Vrai()
{
// Arrange : session avec licence invalide et grace nulle (0 jour).
// Le repository force UpdatedAtUtc = UtcNow à chaque sauvegarde, donc la
// période offline est nulle si OfflineGraceDays <= 0.
_api.ActivateToReturn = ValidSession();
_api.BootstrapToReturn = new MobileBootstrapResponse { Licence = ValidLicense() };
await _service.ActivateAsync("CLIENT01", "LIC-001");
// Rendre la licence invalide avec une grace nulle.
var state = await BootstrapRepository.GetAsync();
state!.Licence = new LicenseResponse
{
IsValid = false,
CodeClient = "CLIENT01",
NumeroLicence = "LIC-001",
OfflineGraceDays = 0
};
await BootstrapRepository.SaveAsync(state);
// Act
bool hasValid = await _service.HasValidSessionAsync();
bool hasExpired = await _service.HasExpiredSessionAsync();
// Assert
Assert.False(hasValid);
Assert.True(hasExpired);
}
[Fact]
public async Task SessionExpiree_DansGrace_HasValidSession_Vrai()
{
// Arrange : licence invalide mais dans la période offline autorisée.
_api.ActivateToReturn = ValidSession();
_api.BootstrapToReturn = new MobileBootstrapResponse { Licence = ValidLicense() };
await _service.ActivateAsync("CLIENT01", "LIC-001");
var state = await BootstrapRepository.GetAsync();
state!.Licence = new LicenseResponse
{
IsValid = false,
CodeClient = "CLIENT01",
NumeroLicence = "LIC-001",
OfflineGraceDays = 7
};
state.LastLicenseValidationUtc = DateTimeOffset.UtcNow.AddDays(-2);
await BootstrapRepository.SaveAsync(state);
// Act
bool hasValid = await _service.HasValidSessionAsync();
bool hasExpired = await _service.HasExpiredSessionAsync();
// Assert : dans la grace, la session reste valide offline.
Assert.True(hasValid);
Assert.False(hasExpired);
}
[Fact]
public async Task ClearSession_SupprimeTokenEtBootstrap_ConserveDonneesMetier()
{
// Arrange : session active + données métier.
_api.ActivateToReturn = ValidSession();
_api.BootstrapToReturn = new MobileBootstrapResponse { Licence = ValidLicense() };
await _service.ActivateAsync("CLIENT01", "LIC-001");
var client = new LocalClient { Id = Guid.NewGuid(), PayloadJson = """{"Id":"x"}""" };
await ClientRepository.UpsertAsync(client);
// Act
await _service.ClearSessionAsync();
// Assert : token et bootstrap supprimés, données métier conservées.
Assert.Null(_tokenStore.Token);
Assert.Equal(1, _tokenStore.ClearCalls);
Assert.False(await _service.HasValidSessionAsync());
Assert.Equal(1, await ClientRepository.CountAsync());
}
[Fact]
public async Task GetAccessToken_RetourneToken()
{
// Arrange
_api.ActivateToReturn = ValidSession();
_api.BootstrapToReturn = new MobileBootstrapResponse { Licence = ValidLicense() };
await _service.ActivateAsync("CLIENT01", "LIC-001");
// Act
string? token = await _service.GetAccessTokenAsync();
// Assert
Assert.Equal("token-secret-123", token);
}
// =========================================================================
// Renouvellement de session (single-flight)
// =========================================================================
private static string ExpiredToken()
{
var payload = new { ExpiresAtUtc = DateTimeOffset.UtcNow.AddHours(-1).ToUnixTimeSeconds() };
string b64 = Convert.ToBase64String(JsonSerializer.SerializeToUtf8Bytes(payload))
.TrimEnd('=').Replace('+', '-').Replace('/', '_');
return b64 + ".signature-ignored";
}
[Fact]
public async Task EnsureValidSession_TokenNonExpire_NeValidePas()
{
// Arrange : session valide, token sans ExpiresAtUtc (non expiré localement).
_api.ActivateToReturn = ValidSession();
_api.BootstrapToReturn = new MobileBootstrapResponse { Licence = ValidLicense() };
await _service.ActivateAsync("CLIENT01", "LIC-001");
int validateBefore = _api.ValidateCalls;
// Act
SessionRevalidationResult result = await _service.EnsureValidSessionAsync();
// Assert : aucun appel license/validate.
Assert.Equal(SessionRevalidationOutcome.Success, result.Outcome);
Assert.Equal(validateBefore, _api.ValidateCalls);
}
[Fact]
public async Task EnsureValidSession_TokenExpire_RevalideEtRenouvelle()
{
// Arrange : session valide puis token expiré en secure storage.
_api.ActivateToReturn = ValidSession();
_api.BootstrapToReturn = new MobileBootstrapResponse { Licence = ValidLicense() };
await _service.ActivateAsync("CLIENT01", "LIC-001");
await _tokenStore.SaveAsync(ExpiredToken());
_api.ValidateToReturn = new MobileLicenseSessionResponse
{
License = ValidLicense(),
AccessToken = "token-renouvele-789",
AccessTokenExpiresAtUtc = DateTimeOffset.UtcNow.AddHours(24)
};
// Act
SessionRevalidationResult result = await _service.EnsureValidSessionAsync();
// Assert
Assert.Equal(SessionRevalidationOutcome.Success, result.Outcome);
Assert.Equal(1, _api.ValidateCalls);
Assert.Equal("token-renouvele-789", _tokenStore.Token);
Assert.Equal("token-renouvele-789", _api.AccessToken);
Assert.Equal("device-123", _api.LastValidateRequest!.DeviceId);
}
[Fact]
public async Task RevalidateSession_Terminal_NeConservePasNouveauToken_EtCache()
{
// Arrange
_api.ActivateToReturn = ValidSession();
_api.BootstrapToReturn = new MobileBootstrapResponse { Licence = ValidLicense() };
await _service.ActivateAsync("CLIENT01", "LIC-001");
string expired = ExpiredToken();
await _tokenStore.SaveAsync(expired);
_api.ValidateToReturn = new MobileLicenseSessionResponse
{
License = new LicenseResponse { IsValid = false, ErrorCode = "E3003", ErrorMessage = "Licence expirée." }
};
// Act
SessionRevalidationResult first = await _service.RevalidateSessionAsync(expired);
SessionRevalidationResult second = await _service.RevalidateSessionAsync(expired);
// Assert : rejet terminal, aucun nouveau token, un seul appel serveur.
Assert.Equal(SessionRevalidationOutcome.InvalidLicense, first.Outcome);
Assert.Equal("E3003", first.License!.ErrorCode);
Assert.Equal(SessionRevalidationOutcome.InvalidLicense, second.Outcome);
Assert.Equal(1, _api.ValidateCalls);
Assert.Equal(expired, _tokenStore.Token);
}
[Fact]
public async Task RevalidateSession_ErreurReseau_NePurgePas()
{
// Arrange
_api.ActivateToReturn = ValidSession();
_api.BootstrapToReturn = new MobileBootstrapResponse { Licence = ValidLicense() };
await _service.ActivateAsync("CLIENT01", "LIC-001");
string expired = ExpiredToken();
await _tokenStore.SaveAsync(expired);
_api.ValidateException = new HttpRequestException("network down");
// Act
SessionRevalidationResult result = await _service.RevalidateSessionAsync(expired);
// Assert : rien n'est purgé, issue transitoire.
Assert.Equal(SessionRevalidationOutcome.TransientError, result.Outcome);
Assert.Equal(expired, _tokenStore.Token);
Assert.Equal(0, _tokenStore.ClearCalls);
}
[Fact]
public async Task RevalidateSession_Concurrent_UnSeulValidate()
{
// Arrange
_api.ActivateToReturn = ValidSession();
_api.BootstrapToReturn = new MobileBootstrapResponse { Licence = ValidLicense() };
await _service.ActivateAsync("CLIENT01", "LIC-001");
string expired = ExpiredToken();
await _tokenStore.SaveAsync(expired);
_api.ValidateToReturn = new MobileLicenseSessionResponse
{
License = ValidLicense(),
AccessToken = "token-concurrent",
AccessTokenExpiresAtUtc = DateTimeOffset.UtcNow.AddHours(24)
};
// Act : 4 renouvellements simultanés avec le même token expiré.
Task[] tasks = Enumerable.Range(0, 4)
.Select(_ => _service.RevalidateSessionAsync(expired))
.ToArray();
SessionRevalidationResult[] results = await Task.WhenAll(tasks);
// Assert : un seul license/validate, tous les appels aboutissent.
Assert.All(results, r => Assert.Equal(SessionRevalidationOutcome.Success, r.Outcome));
Assert.Equal(1, _api.ValidateCalls);
Assert.Equal("token-concurrent", _tokenStore.Token);
}
// =========================================================================
// Offline grace basée sur la dernière validation licence (pas UpdatedAtUtc)
// =========================================================================
private static readonly DateTimeOffset FixedValidation = new(2026, 1, 1, 0, 0, 0, TimeSpan.Zero);
[Fact]
public async Task Activation_InitialiseLastLicenseValidation()
{
// Arrange / Act
_api.ActivateToReturn = ValidSession();
_api.BootstrapToReturn = new MobileBootstrapResponse { Licence = ValidLicense() };
await _service.ActivateAsync("CLIENT01", "LIC-001");
// Assert : la date de validation est initialisée à ~maintenant.
LocalBootstrapState? state = await BootstrapRepository.GetAsync();
Assert.NotNull(state!.LastLicenseValidationUtc);
Assert.True(state.LastLicenseValidationUtc!.Value >= DateTimeOffset.UtcNow.AddMinutes(-1));
}
[Fact]
public async Task Revalidation_Reussie_MetAJourLastLicenseValidation()
{
// Arrange : validation ancienne (J1) + token expiré.
_api.ActivateToReturn = ValidSession();
_api.BootstrapToReturn = new MobileBootstrapResponse { Licence = ValidLicense() };
await _service.ActivateAsync("CLIENT01", "LIC-001");
var state = await BootstrapRepository.GetAsync();
state!.LastLicenseValidationUtc = FixedValidation;
await BootstrapRepository.SaveAsync(state);
string expired = ExpiredToken();
await _tokenStore.SaveAsync(expired);
_api.ValidateToReturn = new MobileLicenseSessionResponse
{
License = ValidLicense(),
AccessToken = "token-j8",
AccessTokenExpiresAtUtc = DateTimeOffset.UtcNow.AddHours(24)
};
// Act
SessionRevalidationResult result = await _service.RevalidateSessionAsync(expired);
// Assert : la date a été repoussée à ~maintenant (validation réelle).
Assert.Equal(SessionRevalidationOutcome.Success, result.Outcome);
LocalBootstrapState? updated = await BootstrapRepository.GetAsync();
Assert.NotNull(updated!.LastLicenseValidationUtc);
Assert.True(updated.LastLicenseValidationUtc!.Value >= DateTimeOffset.UtcNow.AddMinutes(-1));
}
[Fact]
public async Task RefreshBootstrap_NeModifiePasLastLicenseValidation()
{
// Arrange
_api.ActivateToReturn = ValidSession();
_api.BootstrapToReturn = new MobileBootstrapResponse { Licence = ValidLicense() };
await _service.ActivateAsync("CLIENT01", "LIC-001");
var state = await BootstrapRepository.GetAsync();
state!.LastLicenseValidationUtc = FixedValidation;
await BootstrapRepository.SaveAsync(state);
// Act : un simple refresh bootstrap (ex. après sync).
await _service.RefreshBootstrapAsync();
// Assert : la date de validation licence n'a pas bougé.
LocalBootstrapState? after = await BootstrapRepository.GetAsync();
Assert.Equal(FixedValidation, after!.LastLicenseValidationUtc);
}
[Fact]
public async Task ErreurReseau_NeModifiePasLastLicenseValidation()
{
// Arrange
_api.ActivateToReturn = ValidSession();
_api.BootstrapToReturn = new MobileBootstrapResponse { Licence = ValidLicense() };
await _service.ActivateAsync("CLIENT01", "LIC-001");
var state = await BootstrapRepository.GetAsync();
state!.LastLicenseValidationUtc = FixedValidation;
await BootstrapRepository.SaveAsync(state);
string expired = ExpiredToken();
await _tokenStore.SaveAsync(expired);
_api.ValidateException = new HttpRequestException("offline");
// Act
SessionRevalidationResult result = await _service.RevalidateSessionAsync(expired);
// Assert
Assert.Equal(SessionRevalidationOutcome.TransientError, result.Outcome);
LocalBootstrapState? after = await BootstrapRepository.GetAsync();
Assert.Equal(FixedValidation, after!.LastLicenseValidationUtc);
}
[Fact]
public async Task LicenceInvalide_NeModifiePasLastLicenseValidation()
{
// Arrange
_api.ActivateToReturn = ValidSession();
_api.BootstrapToReturn = new MobileBootstrapResponse { Licence = ValidLicense() };
await _service.ActivateAsync("CLIENT01", "LIC-001");
var state = await BootstrapRepository.GetAsync();
state!.LastLicenseValidationUtc = FixedValidation;
await BootstrapRepository.SaveAsync(state);
string expired = ExpiredToken();
await _tokenStore.SaveAsync(expired);
_api.ValidateToReturn = new MobileLicenseSessionResponse
{
License = new LicenseResponse { IsValid = false, ErrorCode = "E3003", ErrorMessage = "Licence expirée." }
};
// Act
SessionRevalidationResult result = await _service.RevalidateSessionAsync(expired);
// Assert
Assert.Equal(SessionRevalidationOutcome.InvalidLicense, result.Outcome);
LocalBootstrapState? after = await BootstrapRepository.GetAsync();
Assert.Equal(FixedValidation, after!.LastLicenseValidationUtc);
}
[Fact]
public async Task Grace_AvantLimite_HasValidSession_Vrai()
{
// Arrange : licence localement invalide, validation à -1 jour, grace 7 jours.
_api.ActivateToReturn = ValidSession();
_api.BootstrapToReturn = new MobileBootstrapResponse { Licence = ValidLicense() };
await _service.ActivateAsync("CLIENT01", "LIC-001");
var state = await BootstrapRepository.GetAsync();
state!.Licence = new LicenseResponse
{
IsValid = false,
CodeClient = "CLIENT01",
NumeroLicence = "LIC-001",
OfflineGraceDays = 7
};
state.LastLicenseValidationUtc = DateTimeOffset.UtcNow.AddDays(-1);
await BootstrapRepository.SaveAsync(state);
// Act / Assert
Assert.True(await _service.HasValidSessionAsync());
Assert.False(await _service.HasExpiredSessionAsync());
}
[Fact]
public async Task Grace_ApresLimite_HasValidSession_Faux()
{
// Arrange : validation à -8 jours, grace 7 jours → hors grace.
_api.ActivateToReturn = ValidSession();
_api.BootstrapToReturn = new MobileBootstrapResponse { Licence = ValidLicense() };
await _service.ActivateAsync("CLIENT01", "LIC-001");
var state = await BootstrapRepository.GetAsync();
state!.Licence = new LicenseResponse
{
IsValid = false,
CodeClient = "CLIENT01",
NumeroLicence = "LIC-001",
OfflineGraceDays = 7
};
state.LastLicenseValidationUtc = DateTimeOffset.UtcNow.AddDays(-8);
await BootstrapRepository.SaveAsync(state);
// Act / Assert
Assert.False(await _service.HasValidSessionAsync());
Assert.True(await _service.HasExpiredSessionAsync());
}
[Fact]
public async Task Migration_InstallationExistante_InitialiseDepuisUpdatedAtUtc()
{
// Arrange : session présente, puis état "pré-migration" (LastLicenseValidationUtc
// NULL, UpdatedAtUtc = J1).
_api.ActivateToReturn = ValidSession();
_api.BootstrapToReturn = new MobileBootstrapResponse { Licence = ValidLicense() };
await _service.ActivateAsync("CLIENT01", "LIC-001");
await Database.ExecuteNonQueryAsync(
"""
UPDATE "LocalBootstrap"
SET "LastLicenseValidationUtc" = NULL,
"UpdatedAtUtc" = '2026-01-01T00:00:00.0000000+00:00'
WHERE "Id" = 1
""");
// Act : la lecture migre (backfill une seule fois) la date de validation.
LocalBootstrapState? state = await BootstrapRepository.GetAsync();
// Assert : la date migrée vient d'UpdatedAtUtc (même fenêtre qu'avant).
Assert.Equal(FixedValidation, state!.LastLicenseValidationUtc);
}
[Fact]
public async Task ClearSession_SupprimeLastLicenseValidation()
{
// Arrange
_api.ActivateToReturn = ValidSession();
_api.BootstrapToReturn = new MobileBootstrapResponse { Licence = ValidLicense() };
await _service.ActivateAsync("CLIENT01", "LIC-001");
// Act
await _service.ClearSessionAsync();
// Assert : logout → licence + date de validation effacées.
LocalBootstrapState? state = await BootstrapRepository.GetAsync();
Assert.Null(state!.Licence);
Assert.Null(state.LastLicenseValidationUtc);
}
}