Premiere commit - Omika
This commit is contained in:
@@ -0,0 +1,928 @@
|
||||
using System.Text.Json;
|
||||
using Omika.App.Local.Models;
|
||||
using Omika.App.Local.Services;
|
||||
using Omika.Core.Contracts.Clients;
|
||||
using Omika.Core.Contracts.Common;
|
||||
using Omika.Core.Contracts.Mobile.Snapshot;
|
||||
using Omika.Core.Contracts.Planning;
|
||||
|
||||
namespace Omika.Tests.Mobile.Local;
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// Tests de la base SQLite locale : création, insertion/lecture, refresh
|
||||
/// snapshot atomique, rollback, données préservées.
|
||||
/// </summary>
|
||||
public class LocalSqliteTests : LocalTestBase
|
||||
{
|
||||
[Fact]
|
||||
public async Task CreationBaseLocale_InitialiseSchema()
|
||||
{
|
||||
// Arrange / Act : le schéma est initialisé dans le constructeur.
|
||||
// Assert : les tables techniques existent.
|
||||
object? outboxCount = await Database.ExecuteScalarAsync("SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name='Outbox'");
|
||||
object? mappingCount = await Database.ExecuteScalarAsync("SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name='IdentityMapping'");
|
||||
object? syncStateCount = await Database.ExecuteScalarAsync("SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name='SyncState'");
|
||||
object? docCacheCount = await Database.ExecuteScalarAsync("SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name='DocumentCache'");
|
||||
object? bootstrapCount = await Database.ExecuteScalarAsync("SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name='LocalBootstrap'");
|
||||
|
||||
Assert.Equal(1L, Convert.ToInt64(outboxCount));
|
||||
Assert.Equal(1L, Convert.ToInt64(mappingCount));
|
||||
Assert.Equal(1L, Convert.ToInt64(syncStateCount));
|
||||
Assert.Equal(1L, Convert.ToInt64(docCacheCount));
|
||||
Assert.Equal(1L, Convert.ToInt64(bootstrapCount));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task InsertionLecture_EntiteMetier()
|
||||
{
|
||||
// Arrange
|
||||
var client = new LocalClient
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
PayloadJson = """{"Id":"test","Nom":"Client Test"}"""
|
||||
};
|
||||
|
||||
// Act
|
||||
await ClientRepository.UpsertAsync(client);
|
||||
|
||||
// Assert
|
||||
LocalClient? loaded = await ClientRepository.GetByIdAsync(client.Id);
|
||||
Assert.NotNull(loaded);
|
||||
Assert.Equal(client.Id, loaded.Id);
|
||||
Assert.Equal(client.PayloadJson, loaded.PayloadJson);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RefreshSnapshot_Atomique()
|
||||
{
|
||||
// Arrange : une personne existante (table encore portée par le snapshot).
|
||||
var personneA = new LocalPersonne { Id = Guid.NewGuid(), PayloadJson = """{"Id":"A","Nom":"Ancien"}""" };
|
||||
await PersonneRepository.UpsertAsync(personneA);
|
||||
|
||||
var applier = new SnapshotApplier(Database);
|
||||
var snapshot = new MobileSnapshotResponse
|
||||
{
|
||||
GeneratedAtUtc = DateTimeOffset.UtcNow,
|
||||
Personnes = [new ContactDto { Id = Guid.NewGuid().ToString(), Nom = "Nouveau" }]
|
||||
};
|
||||
|
||||
// Act
|
||||
await applier.ApplyAsync(snapshot);
|
||||
|
||||
// Assert
|
||||
Assert.Equal(1, await PersonneRepository.CountAsync());
|
||||
LocalPersonne? loaded = (await PersonneRepository.GetAllAsync()).First();
|
||||
Assert.NotEqual(personneA.Id, loaded.Id);
|
||||
Assert.Contains("Nouveau", loaded.PayloadJson);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ApplyAsync_PreserveRendezVousGuids_ConserveLaMutationLocale()
|
||||
{
|
||||
// Arrange : un rendez-vous local déjà annulé (mutation pending non confirmée).
|
||||
Guid rendezVousGuid = Guid.NewGuid();
|
||||
await RendezVousRepository.UpsertAsync(new LocalRendezVous
|
||||
{
|
||||
Id = rendezVousGuid,
|
||||
PayloadJson = JsonSerializer.Serialize(new RendezVousDetailsDto
|
||||
{
|
||||
Id = rendezVousGuid.ToString("D"),
|
||||
Titre = "RDV annulé localement",
|
||||
Statut = new ReferenceValueDto { Key = "S.RDV.ANNU", Label = "Annulé" }
|
||||
})
|
||||
});
|
||||
|
||||
// Snapshot serveur plus ancien : le rendez-vous y est encore « Confirmé ».
|
||||
var applier = new SnapshotApplier(Database);
|
||||
var snapshot = new MobileSnapshotResponse
|
||||
{
|
||||
GeneratedAtUtc = DateTimeOffset.UtcNow,
|
||||
RendezVous =
|
||||
[
|
||||
new RendezVousDetailsDto
|
||||
{
|
||||
Id = rendezVousGuid.ToString("D"),
|
||||
Titre = "RDV annulé localement",
|
||||
Statut = new ReferenceValueDto { Key = "S.RDV.CONF", Label = "Confirmé" }
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
// Act : le snapshot est appliqué en préservant le rendez-vous en attente.
|
||||
await applier.ApplyAsync(snapshot, [rendezVousGuid]);
|
||||
|
||||
// Assert : la mutation locale (Annulé) n'est pas écrasée.
|
||||
LocalRendezVous? local = await RendezVousRepository.GetByIdAsync(rendezVousGuid);
|
||||
Assert.NotNull(local);
|
||||
RendezVousDetailsDto? dto = JsonSerializer.Deserialize<RendezVousDetailsDto>(local!.PayloadJson);
|
||||
Assert.Equal("S.RDV.ANNU", dto?.Statut?.Key);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Règle métier : un rendez-vous ARCHIVÉ ne monte JAMAIS dans le mobile. Il est
|
||||
/// écarté à l'import du snapshot et sa copie locale n'est pas conservée.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task ApplyAsync_RendezVousArchive_EstEcarte()
|
||||
{
|
||||
// Arrange : une copie locale d'un rendez-vous d'hier et un snapshot qui
|
||||
// n'en contient plus que la version ARCHIVÉE.
|
||||
Guid rendezVousGuid = Guid.NewGuid();
|
||||
DateTime hier = DateTime.Today.AddDays(-1);
|
||||
await RendezVousRepository.UpsertAsync(new LocalRendezVous
|
||||
{
|
||||
Id = rendezVousGuid,
|
||||
PayloadJson = JsonSerializer.Serialize(new RendezVousDetailsDto
|
||||
{
|
||||
Id = rendezVousGuid.ToString("D"),
|
||||
Titre = "RDV archivé",
|
||||
Debut = hier.AddHours(9),
|
||||
Fin = hier.AddHours(10)
|
||||
})
|
||||
});
|
||||
|
||||
var applier = new SnapshotApplier(Database);
|
||||
var snapshot = new MobileSnapshotResponse
|
||||
{
|
||||
GeneratedAtUtc = DateTimeOffset.UtcNow,
|
||||
RendezVous =
|
||||
[
|
||||
new RendezVousDetailsDto
|
||||
{
|
||||
Id = rendezVousGuid.ToString("D"),
|
||||
Titre = "RDV archivé",
|
||||
Debut = hier.AddHours(9),
|
||||
Fin = hier.AddHours(10),
|
||||
EstArchive = true
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
// Act
|
||||
await applier.ApplyAsync(snapshot);
|
||||
|
||||
// Assert : le rendez-vous archivé n'est ni importé ni conservé.
|
||||
Assert.Null(await RendezVousRepository.GetByIdAsync(rendezVousGuid));
|
||||
Assert.Equal(0, await RendezVousRepository.CountAsync());
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Un rendez-vous de la fenêtre d'agenda ABSENT du snapshot (archivé côté
|
||||
/// serveur, retiré de l'agenda du collaborateur ou collaborateur retiré du
|
||||
/// groupe) ne doit pas être conservé : sa copie locale ne doit plus apparaître.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task ApplyAsync_RendezVousDansLaFenetreAbsentDuSnapshot_EstRetire()
|
||||
{
|
||||
Guid rendezVousGuid = Guid.NewGuid();
|
||||
DateTime hier = DateTime.Today.AddDays(-1);
|
||||
await RendezVousRepository.UpsertAsync(new LocalRendezVous
|
||||
{
|
||||
Id = rendezVousGuid,
|
||||
PayloadJson = JsonSerializer.Serialize(new RendezVousDetailsDto
|
||||
{
|
||||
Id = rendezVousGuid.ToString("D"),
|
||||
Titre = "RDV retiré",
|
||||
Debut = hier.AddHours(9),
|
||||
Fin = hier.AddHours(10)
|
||||
})
|
||||
});
|
||||
|
||||
var applier = new SnapshotApplier(Database);
|
||||
|
||||
// Act : snapshot sans ce rendez-vous.
|
||||
await applier.ApplyAsync(new MobileSnapshotResponse { GeneratedAtUtc = DateTimeOffset.UtcNow });
|
||||
|
||||
// Assert
|
||||
Assert.Null(await RendezVousRepository.GetByIdAsync(rendezVousGuid));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Une copie locale chargée À LA DEMANDE, HORS de la fenêtre d'agenda du
|
||||
/// snapshot, reste conservée : elle ne sera pas re-fournie par le snapshot et
|
||||
/// doit rester consultable hors connexion.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task ApplyAsync_RendezVousHorsFenetreAbsentDuSnapshot_EstConserve()
|
||||
{
|
||||
Guid rendezVousGuid = Guid.NewGuid();
|
||||
DateTime loin = DateTime.Today.AddDays(40);
|
||||
await RendezVousRepository.UpsertAsync(new LocalRendezVous
|
||||
{
|
||||
Id = rendezVousGuid,
|
||||
PayloadJson = JsonSerializer.Serialize(new RendezVousDetailsDto
|
||||
{
|
||||
Id = rendezVousGuid.ToString("D"),
|
||||
Titre = "RDV chargé à la demande",
|
||||
Debut = loin.AddHours(9),
|
||||
Fin = loin.AddHours(10)
|
||||
})
|
||||
});
|
||||
|
||||
var applier = new SnapshotApplier(Database);
|
||||
|
||||
// Act : snapshot sans ce rendez-vous (hors fenêtre d'agenda).
|
||||
await applier.ApplyAsync(new MobileSnapshotResponse { GeneratedAtUtc = DateTimeOffset.UtcNow });
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(await RendezVousRepository.GetByIdAsync(rendezVousGuid));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ApplyAsync_RendezVous_PersisteColonnesDeReference()
|
||||
{
|
||||
// Arrange : un rendez-vous lié à une intervention, un client, un site et un dossier.
|
||||
Guid interventionGuid = Guid.NewGuid();
|
||||
Guid clientGuid = Guid.NewGuid();
|
||||
Guid siteGuid = Guid.NewGuid();
|
||||
Guid dossierGuid = Guid.NewGuid();
|
||||
|
||||
var applier = new SnapshotApplier(Database);
|
||||
var snapshot = new MobileSnapshotResponse
|
||||
{
|
||||
GeneratedAtUtc = DateTimeOffset.UtcNow,
|
||||
RendezVous =
|
||||
[
|
||||
new RendezVousDetailsDto
|
||||
{
|
||||
Id = Guid.NewGuid().ToString("D"),
|
||||
ClientId = clientGuid.ToString("D"),
|
||||
SiteId = siteGuid.ToString("D"),
|
||||
DossierId = dossierGuid.ToString("D"),
|
||||
InterventionId = interventionGuid.ToString("D"),
|
||||
Titre = "RDV lié",
|
||||
Debut = new DateTime(2026, 8, 19, 9, 0, 0)
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
// Act
|
||||
await applier.ApplyAsync(snapshot);
|
||||
|
||||
// Assert : les colonnes de référence typées sont persistées puis relues.
|
||||
LocalRendezVous? local = (await RendezVousRepository.GetAllAsync()).Single();
|
||||
Assert.NotNull(local.InterventionId);
|
||||
Assert.Equal(interventionGuid, local.InterventionId!.Value);
|
||||
Assert.Equal(clientGuid, local.ClientId!.Value);
|
||||
Assert.Equal(siteGuid, local.SiteId!.Value);
|
||||
Assert.Equal(dossierGuid, local.DossierId!.Value);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RefreshSnapshot_Erreur_Rollback()
|
||||
{
|
||||
// Arrange : une intervention existante (table encore portée par le snapshot).
|
||||
var interventionA = new LocalIntervention { Id = Guid.NewGuid(), PayloadJson = """{"Id":"A","Titre":"Ancien"}""" };
|
||||
await InterventionRepository.UpsertAsync(interventionA);
|
||||
|
||||
var applier = new SnapshotApplier(Database);
|
||||
var snapshot = new MobileSnapshotResponse
|
||||
{
|
||||
GeneratedAtUtc = DateTimeOffset.UtcNow,
|
||||
Interventions = [new Omika.Core.Contracts.Interventions.InterventionDetailsDto { Id = "not-a-guid", Titre = "Invalide" }]
|
||||
};
|
||||
|
||||
// Act / Assert : Guid.Parse("not-a-guid") lève une exception → rollback.
|
||||
await Assert.ThrowsAsync<FormatException>(() => applier.ApplyAsync(snapshot));
|
||||
|
||||
// L'ancienne donnée est conservée.
|
||||
Assert.Equal(1, await InterventionRepository.CountAsync());
|
||||
LocalIntervention? loaded = await InterventionRepository.GetByIdAsync(interventionA.Id);
|
||||
Assert.NotNull(loaded);
|
||||
Assert.Equal(interventionA.Id, loaded.Id);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RefreshSnapshot_ConserveParametreMetier()
|
||||
{
|
||||
// Arrange : insérer une donnée dans une table non-snapshot (LocalBootstrap).
|
||||
await BootstrapRepository.SaveAsync(new LocalBootstrapState
|
||||
{
|
||||
ParametresMetier = [new Omika.Core.Contracts.Configuration.ParametreMetierDto { Code = "PARAM1", Valeur = "valeur-conservée" }]
|
||||
});
|
||||
|
||||
var applier = new SnapshotApplier(Database);
|
||||
var snapshot = new MobileSnapshotResponse
|
||||
{
|
||||
GeneratedAtUtc = DateTimeOffset.UtcNow,
|
||||
Clients = [new ClientDetailsDto { Id = Guid.NewGuid().ToString(), Nom = "Client" }]
|
||||
};
|
||||
|
||||
// Act
|
||||
await applier.ApplyAsync(snapshot);
|
||||
|
||||
// Assert : la donnée technique est conservée.
|
||||
LocalBootstrapState? loaded = await BootstrapRepository.GetAsync();
|
||||
Assert.NotNull(loaded);
|
||||
Assert.Single(loaded.ParametresMetier);
|
||||
Assert.Equal("PARAM1", loaded.ParametresMetier[0].Code);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RefreshSnapshot_ConserveProfilDroits()
|
||||
{
|
||||
// Arrange
|
||||
await BootstrapRepository.SaveAsync(new LocalBootstrapState
|
||||
{
|
||||
Profil = new Omika.Core.Contracts.Mobile.Bootstrap.MobileProfileDto { Code = "admin" },
|
||||
Droits = new Dictionary<string, bool> { ["read"] = true }
|
||||
});
|
||||
|
||||
var applier = new SnapshotApplier(Database);
|
||||
var snapshot = new MobileSnapshotResponse { GeneratedAtUtc = DateTimeOffset.UtcNow };
|
||||
|
||||
// Act
|
||||
await applier.ApplyAsync(snapshot);
|
||||
|
||||
// Assert
|
||||
LocalBootstrapState? loaded = await BootstrapRepository.GetAsync();
|
||||
Assert.NotNull(loaded);
|
||||
Assert.NotNull(loaded.Profil);
|
||||
Assert.Equal("admin", loaded.Profil.Code);
|
||||
Assert.Single(loaded.Droits);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RefreshSnapshot_ConserveLicenceEtConfiguration()
|
||||
{
|
||||
// Arrange : licence et configuration locale dans LocalBootstrap.
|
||||
await BootstrapRepository.SaveAsync(new LocalBootstrapState
|
||||
{
|
||||
Licence = new Omika.Core.Contracts.Mobile.License.LicenseResponse
|
||||
{
|
||||
IsValid = true,
|
||||
CodeClient = "CLIENT01",
|
||||
NumeroLicence = "LIC-001"
|
||||
},
|
||||
Configuration = new Omika.Core.Contracts.Mobile.Bootstrap.MobileConfigurationDto
|
||||
{
|
||||
// Configuration locale utile au mobile.
|
||||
}
|
||||
});
|
||||
|
||||
var applier = new SnapshotApplier(Database);
|
||||
var snapshot = new MobileSnapshotResponse { GeneratedAtUtc = DateTimeOffset.UtcNow };
|
||||
|
||||
// Act
|
||||
await applier.ApplyAsync(snapshot);
|
||||
|
||||
// Assert : licence et configuration sont préservées après le refresh.
|
||||
LocalBootstrapState? loaded = await BootstrapRepository.GetAsync();
|
||||
Assert.NotNull(loaded);
|
||||
Assert.NotNull(loaded.Licence);
|
||||
Assert.True(loaded.Licence.IsValid);
|
||||
Assert.Equal("CLIENT01", loaded.Licence.CodeClient);
|
||||
Assert.Equal("LIC-001", loaded.Licence.NumeroLicence);
|
||||
Assert.NotNull(loaded.Configuration);
|
||||
}
|
||||
|
||||
|
||||
[Fact]
|
||||
public async Task RefreshSnapshot_ConserveOutbox()
|
||||
{
|
||||
// Arrange
|
||||
var operation = new OutboxOperation
|
||||
{
|
||||
OperationId = Guid.NewGuid(),
|
||||
EntityType = "Client",
|
||||
OperationType = Omika.Core.Contracts.Mobile.Sync.SyncOperationType.Create,
|
||||
RequestedEntityGuid = Guid.NewGuid(),
|
||||
Payload = "{}",
|
||||
State = OutboxState.Pending
|
||||
};
|
||||
await OutboxRepository.AddAsync(operation);
|
||||
|
||||
var applier = new SnapshotApplier(Database);
|
||||
var snapshot = new MobileSnapshotResponse { GeneratedAtUtc = DateTimeOffset.UtcNow };
|
||||
|
||||
// Act
|
||||
await applier.ApplyAsync(snapshot);
|
||||
|
||||
// Assert
|
||||
OutboxOperation? loaded = await OutboxRepository.GetByIdAsync(operation.OperationId);
|
||||
Assert.NotNull(loaded);
|
||||
Assert.Equal(operation.OperationId, loaded.OperationId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RefreshSnapshot_ConserveCacheDocument()
|
||||
{
|
||||
// Arrange
|
||||
var entry = new DocumentCacheEntry
|
||||
{
|
||||
DocumentGuid = Guid.NewGuid(),
|
||||
LocalPath = "/tmp/doc.pdf",
|
||||
DownloadedAtUtc = DateTimeOffset.UtcNow,
|
||||
Size = 1234
|
||||
};
|
||||
await DocumentCacheRepository.UpsertAsync(entry);
|
||||
|
||||
var applier = new SnapshotApplier(Database);
|
||||
var snapshot = new MobileSnapshotResponse { GeneratedAtUtc = DateTimeOffset.UtcNow };
|
||||
|
||||
// Act
|
||||
await applier.ApplyAsync(snapshot);
|
||||
|
||||
// Assert
|
||||
DocumentCacheEntry? loaded = await DocumentCacheRepository.GetAsync(entry.DocumentGuid);
|
||||
Assert.NotNull(loaded);
|
||||
Assert.Equal(entry.LocalPath, loaded.LocalPath);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RefreshSnapshot_ConserveMappingIdentites()
|
||||
{
|
||||
// Arrange
|
||||
var mapping = new IdentityMapping
|
||||
{
|
||||
EntityType = "Client",
|
||||
RequestedEntityGuid = Guid.NewGuid(),
|
||||
CanonicalEntityGuid = Guid.NewGuid()
|
||||
};
|
||||
await IdentityMappingRepository.UpsertAsync(mapping);
|
||||
|
||||
var applier = new SnapshotApplier(Database);
|
||||
var snapshot = new MobileSnapshotResponse { GeneratedAtUtc = DateTimeOffset.UtcNow };
|
||||
|
||||
// Act
|
||||
await applier.ApplyAsync(snapshot);
|
||||
|
||||
// Assert
|
||||
IdentityMapping? loaded = await IdentityMappingRepository.GetAsync(mapping.EntityType, mapping.RequestedEntityGuid);
|
||||
Assert.NotNull(loaded);
|
||||
Assert.Equal(mapping.CanonicalEntityGuid, loaded.CanonicalEntityGuid);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RefreshSnapshot_NImportePasSuivisTemps()
|
||||
{
|
||||
// Arrange : le snapshot ne porte plus les saisies de temps ; la table
|
||||
// locale SuiviTemps est un cache alimenté à la demande, jamais vidé.
|
||||
var applier = new SnapshotApplier(Database);
|
||||
var snapshot = new MobileSnapshotResponse
|
||||
{
|
||||
GeneratedAtUtc = DateTimeOffset.UtcNow,
|
||||
SuivisTemps =
|
||||
[
|
||||
new Omika.Core.Contracts.Planning.SuiviTempsDto
|
||||
{
|
||||
Id = Guid.NewGuid().ToString(),
|
||||
DureeMinutes = 60,
|
||||
Description = "Saisie test"
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
// Act
|
||||
await applier.ApplyAsync(snapshot);
|
||||
|
||||
// Assert : aucune saisie n'est importée (table devenue cache).
|
||||
Assert.Equal(0, await SuiviTempsRepository.CountAsync());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RefreshSnapshot_ImporteIntervention_ConserveReferences()
|
||||
{
|
||||
// Arrange : le correctif doit être générique (non spécifique à SuiviTemps).
|
||||
// Intervention possède des colonnes de référence structurées (ClientId,
|
||||
// SiteId, DossierId) qui doivent être persistées lors du refresh snapshot.
|
||||
var applier = new SnapshotApplier(Database);
|
||||
Guid interventionId = Guid.NewGuid();
|
||||
Guid clientId = Guid.NewGuid();
|
||||
Guid siteId = Guid.NewGuid();
|
||||
Guid dossierId = Guid.NewGuid();
|
||||
var snapshot = new MobileSnapshotResponse
|
||||
{
|
||||
GeneratedAtUtc = DateTimeOffset.UtcNow,
|
||||
Interventions =
|
||||
[
|
||||
new Omika.Core.Contracts.Interventions.InterventionDetailsDto
|
||||
{
|
||||
Id = interventionId.ToString(),
|
||||
ClientId = clientId.ToString(),
|
||||
SiteId = siteId.ToString(),
|
||||
DossierId = dossierId.ToString(),
|
||||
Titre = "Intervention test"
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
// Act
|
||||
await applier.ApplyAsync(snapshot);
|
||||
|
||||
// Assert : l'intervention est importée avec ses références typées.
|
||||
Assert.Equal(1, await InterventionRepository.CountAsync());
|
||||
LocalIntervention? loaded = await InterventionRepository.GetByIdAsync(interventionId);
|
||||
Assert.NotNull(loaded);
|
||||
Assert.Equal(interventionId, loaded.Id);
|
||||
Assert.Equal(clientId, loaded.ClientId);
|
||||
Assert.Equal(siteId, loaded.SiteId);
|
||||
Assert.Equal(dossierId, loaded.DossierId);
|
||||
Assert.Contains("Intervention test", loaded.PayloadJson);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RefreshSnapshot_ReferenceOptionnelleNull()
|
||||
{
|
||||
// Arrange : une référence optionnelle absente doit rester NULL (pas de
|
||||
// Guid.Empty ni de valeur inventée).
|
||||
var applier = new SnapshotApplier(Database);
|
||||
Guid interventionId = Guid.NewGuid();
|
||||
Guid clientId = Guid.NewGuid();
|
||||
var snapshot = new MobileSnapshotResponse
|
||||
{
|
||||
GeneratedAtUtc = DateTimeOffset.UtcNow,
|
||||
Interventions =
|
||||
[
|
||||
new Omika.Core.Contracts.Interventions.InterventionDetailsDto
|
||||
{
|
||||
Id = interventionId.ToString(),
|
||||
ClientId = clientId.ToString(),
|
||||
// SiteId et DossierId absents → NULL.
|
||||
Titre = "Sans références optionnelles"
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
// Act
|
||||
await applier.ApplyAsync(snapshot);
|
||||
|
||||
// Assert : les références optionnelles restent NULL.
|
||||
LocalIntervention? loaded = await InterventionRepository.GetByIdAsync(interventionId);
|
||||
Assert.NotNull(loaded);
|
||||
Assert.Equal(clientId, loaded.ClientId);
|
||||
Assert.Null(loaded.SiteId);
|
||||
Assert.Null(loaded.DossierId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RefreshSnapshot_ImporteToutesLesCollections()
|
||||
{
|
||||
// Arrange : un snapshot complet avec toutes les collections.
|
||||
var applier = new SnapshotApplier(Database);
|
||||
Guid clientId = Guid.NewGuid();
|
||||
Guid personneId = Guid.NewGuid();
|
||||
Guid siteId = Guid.NewGuid();
|
||||
Guid documentId = Guid.NewGuid();
|
||||
Guid interventionId = Guid.NewGuid();
|
||||
Guid dossierId = Guid.NewGuid();
|
||||
Guid rendezVousId = Guid.NewGuid();
|
||||
Guid collaborateurId = Guid.NewGuid();
|
||||
Guid suiviId = Guid.NewGuid();
|
||||
|
||||
var snapshot = new MobileSnapshotResponse
|
||||
{
|
||||
GeneratedAtUtc = DateTimeOffset.UtcNow,
|
||||
Clients =
|
||||
[
|
||||
new ClientDetailsDto { Id = clientId.ToString(), Nom = "Client" }
|
||||
],
|
||||
Personnes =
|
||||
[
|
||||
new ContactDto { Id = personneId.ToString(), Nom = "Personne", ClientId = clientId.ToString() }
|
||||
],
|
||||
Sites =
|
||||
[
|
||||
new Omika.Core.Contracts.Sites.SiteDetailsDto { Id = siteId.ToString(), Nom = "Site", ClientId = clientId.ToString() }
|
||||
],
|
||||
Documents =
|
||||
[
|
||||
new Omika.Core.Contracts.Documents.DocumentSummaryDto { Id = documentId.ToString(), Nom = "Doc.pdf", InterventionId = interventionId.ToString() }
|
||||
],
|
||||
Interventions =
|
||||
[
|
||||
new Omika.Core.Contracts.Interventions.InterventionDetailsDto
|
||||
{
|
||||
Id = interventionId.ToString(),
|
||||
ClientId = clientId.ToString(),
|
||||
SiteId = siteId.ToString(),
|
||||
DossierId = dossierId.ToString(),
|
||||
Titre = "Intervention"
|
||||
}
|
||||
],
|
||||
Dossiers =
|
||||
[
|
||||
new Omika.Core.Contracts.Interventions.DossierDetailsDto { Id = dossierId.ToString(), Titre = "Dossier", ClientId = clientId.ToString() }
|
||||
],
|
||||
RendezVous =
|
||||
[
|
||||
new Omika.Core.Contracts.Planning.RendezVousDetailsDto
|
||||
{
|
||||
Id = rendezVousId.ToString(),
|
||||
Titre = "RDV",
|
||||
Debut = new DateTime(2026, 8, 10, 9, 0, 0),
|
||||
Fin = new DateTime(2026, 8, 10, 10, 0, 0),
|
||||
ClientId = clientId.ToString()
|
||||
}
|
||||
],
|
||||
Collaborateurs =
|
||||
[
|
||||
new Omika.Core.Contracts.Organisation.CollaborateurDetailsDto { Id = collaborateurId.ToString(), Nom = "Collab" }
|
||||
],
|
||||
SuivisTemps =
|
||||
[
|
||||
new Omika.Core.Contracts.Planning.SuiviTempsDto
|
||||
{
|
||||
Id = suiviId.ToString(),
|
||||
CollaborateurId = collaborateurId.ToString(),
|
||||
InterventionId = interventionId.ToString(),
|
||||
Date = new DateTime(2026, 8, 10),
|
||||
DureeMinutes = 60
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
// Act
|
||||
await applier.ApplyAsync(snapshot);
|
||||
|
||||
// Assert : seules les collections portées par le snapshot sont importées.
|
||||
// Client / Dossier / Collaborateur / Site / SuiviTemps sont des caches
|
||||
// alimentés à la demande, purgés à chaque synchro : le snapshot ne les
|
||||
// fournit plus. Les MÉTADONNÉES DE DOCUMENTS sont, elles, désormais
|
||||
// importées avec le snapshot (documents des interventions de la fenêtre
|
||||
// d'agenda, pour un accès hors ligne dès le lancement) : le document
|
||||
// fourni ici est donc bien présent.
|
||||
Assert.Equal(0, await ClientRepository.CountAsync());
|
||||
Assert.Equal(1, await PersonneRepository.CountAsync());
|
||||
Assert.Equal(0, await SiteRepository.CountAsync());
|
||||
Assert.Equal(1, await DocumentMetadataRepository.CountAsync());
|
||||
Assert.Equal(1, await InterventionRepository.CountAsync());
|
||||
Assert.Equal(0, await DossierRepository.CountAsync());
|
||||
Assert.Equal(1, await RendezVousRepository.CountAsync());
|
||||
Assert.Equal(0, await CollaborateurRepository.CountAsync());
|
||||
Assert.Equal(0, await SuiviTempsRepository.CountAsync());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RefreshSnapshot_DeuxPersonnes_IdsDistincts_ImportOk()
|
||||
{
|
||||
// Arrange : deux Personnes avec des Id différents → import accepté.
|
||||
var applier = new SnapshotApplier(Database);
|
||||
Guid personneA = Guid.NewGuid();
|
||||
Guid personneB = Guid.NewGuid();
|
||||
|
||||
var snapshot = new MobileSnapshotResponse
|
||||
{
|
||||
GeneratedAtUtc = DateTimeOffset.UtcNow,
|
||||
Personnes =
|
||||
[
|
||||
new ContactDto { Id = personneA.ToString(), Nom = "A" },
|
||||
new ContactDto { Id = personneB.ToString(), Nom = "B" }
|
||||
]
|
||||
};
|
||||
|
||||
// Act
|
||||
await applier.ApplyAsync(snapshot);
|
||||
|
||||
// Assert : les deux Personnes sont importées.
|
||||
Assert.Equal(2, await PersonneRepository.CountAsync());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RefreshSnapshot_Personne_IdDuplique_RejetAvantEcriture()
|
||||
{
|
||||
// Arrange : une ancienne Personne déjà présente dans la base locale.
|
||||
Guid ancienId = Guid.NewGuid();
|
||||
await PersonneRepository.UpsertAsync(new LocalPersonne
|
||||
{
|
||||
Id = ancienId,
|
||||
PayloadJson = """{"Id":"ancien","Nom":"Ancien"}"""
|
||||
});
|
||||
|
||||
var applier = new SnapshotApplier(Database);
|
||||
Guid duplicateId = Guid.NewGuid();
|
||||
var snapshot = new MobileSnapshotResponse
|
||||
{
|
||||
GeneratedAtUtc = DateTimeOffset.UtcNow,
|
||||
Personnes =
|
||||
[
|
||||
new ContactDto { Id = duplicateId.ToString(), Nom = "P1" },
|
||||
new ContactDto { Id = duplicateId.ToString(), Nom = "P2" }
|
||||
]
|
||||
};
|
||||
|
||||
// Act / Assert : rejet AVANT toute écriture (aucun DELETE/INSERT partiel).
|
||||
InvalidOperationException ex = await Assert.ThrowsAsync<InvalidOperationException>(
|
||||
() => applier.ApplyAsync(snapshot));
|
||||
Assert.Equal("Snapshot invalide : Personne.Id dupliqué.", ex.Message);
|
||||
|
||||
// L'ancienne base est conservée intacte.
|
||||
Assert.Equal(1, await PersonneRepository.CountAsync());
|
||||
LocalPersonne? loaded = await PersonneRepository.GetByIdAsync(ancienId);
|
||||
Assert.NotNull(loaded);
|
||||
Assert.Equal(ancienId, loaded.Id);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RefreshSnapshot_PersonnesUniques_ToutesInserees()
|
||||
{
|
||||
// Arrange : un snapshot corrigé avec uniquement des Personnes uniques.
|
||||
var applier = new SnapshotApplier(Database);
|
||||
Guid[] ids = [Guid.NewGuid(), Guid.NewGuid(), Guid.NewGuid()];
|
||||
|
||||
var snapshot = new MobileSnapshotResponse
|
||||
{
|
||||
GeneratedAtUtc = DateTimeOffset.UtcNow,
|
||||
Personnes = ids.Select(id => new ContactDto { Id = id.ToString(), Nom = "P" }).ToArray()
|
||||
};
|
||||
|
||||
// Act
|
||||
await applier.ApplyAsync(snapshot);
|
||||
|
||||
// Assert : toutes les Personnes sont insérées.
|
||||
Assert.Equal(ids.Length, await PersonneRepository.CountAsync());
|
||||
foreach (Guid id in ids)
|
||||
{
|
||||
Assert.NotNull(await PersonneRepository.GetByIdAsync(id));
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RefreshSnapshot_RepositoriesLisentNouvellesDonnees()
|
||||
{
|
||||
// Arrange : un snapshot avec une personne, puis un second snapshot avec une
|
||||
// personne différente. Les repositories doivent lire les nouvelles données.
|
||||
var applier = new SnapshotApplier(Database);
|
||||
|
||||
Guid premierePersonneId = Guid.NewGuid();
|
||||
var premierSnapshot = new MobileSnapshotResponse
|
||||
{
|
||||
GeneratedAtUtc = DateTimeOffset.UtcNow,
|
||||
Personnes = [new ContactDto { Id = premierePersonneId.ToString(), Nom = "Premier" }]
|
||||
};
|
||||
await applier.ApplyAsync(premierSnapshot);
|
||||
|
||||
// Act : second refresh avec une personne différente.
|
||||
Guid secondePersonneId = Guid.NewGuid();
|
||||
var secondSnapshot = new MobileSnapshotResponse
|
||||
{
|
||||
GeneratedAtUtc = DateTimeOffset.UtcNow,
|
||||
Personnes = [new ContactDto { Id = secondePersonneId.ToString(), Nom = "Second" }]
|
||||
};
|
||||
await applier.ApplyAsync(secondSnapshot);
|
||||
|
||||
// Assert : le repository lit les nouvelles données (la première personne a
|
||||
// été remplacée, pas cumulée).
|
||||
Assert.Equal(1, await PersonneRepository.CountAsync());
|
||||
LocalPersonne? loaded = (await PersonneRepository.GetAllAsync()).First();
|
||||
Assert.Equal(secondePersonneId, loaded.Id);
|
||||
Assert.Contains("Second", loaded.PayloadJson);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RefreshSnapshot_Annulation_Rollback()
|
||||
{
|
||||
// Arrange : un client existant, puis un snapshot avec un token annulé.
|
||||
var clientA = new LocalClient { Id = Guid.NewGuid(), PayloadJson = """{"Id":"A","Nom":"Ancien"}""" };
|
||||
await ClientRepository.UpsertAsync(clientA);
|
||||
|
||||
var applier = new SnapshotApplier(Database);
|
||||
var snapshot = new MobileSnapshotResponse
|
||||
{
|
||||
GeneratedAtUtc = DateTimeOffset.UtcNow,
|
||||
Clients = [new ClientDetailsDto { Id = Guid.NewGuid().ToString(), Nom = "Nouveau" }]
|
||||
};
|
||||
|
||||
using var cts = new CancellationTokenSource();
|
||||
cts.Cancel();
|
||||
|
||||
// Act / Assert : l'annulation est propagée → rollback, ancienne base conservée.
|
||||
await Assert.ThrowsAnyAsync<OperationCanceledException>(() => applier.ApplyAsync(snapshot, cancellationToken: cts.Token));
|
||||
|
||||
// L'ancienne donnée est conservée.
|
||||
Assert.Equal(1, await ClientRepository.CountAsync());
|
||||
LocalClient? loaded = await ClientRepository.GetByIdAsync(clientA.Id);
|
||||
Assert.NotNull(loaded);
|
||||
Assert.Equal(clientA.Id, loaded.Id);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Test ciblé reproduisant le pipeline réel Desktop :
|
||||
/// API (sérialisation JSON) → MobileApiClient (désérialisation JSON avec
|
||||
/// JsonSerializerDefaults.Web) → SnapshotApplier.ApplyAsync → SQLite.
|
||||
/// Vérifie que les collections désérialisées depuis JSON sont réellement
|
||||
/// remplies et que les INSERT atteignent COMMIT.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task RefreshSnapshot_DeserialisationJson_ImporteToutesLesCollections()
|
||||
{
|
||||
// Arrange : construire un snapshot complet (comme le ferait MobileSnapshotService).
|
||||
Guid clientId = Guid.NewGuid();
|
||||
Guid personneId = Guid.NewGuid();
|
||||
Guid siteId = Guid.NewGuid();
|
||||
Guid documentId = Guid.NewGuid();
|
||||
Guid interventionId = Guid.NewGuid();
|
||||
Guid dossierId = Guid.NewGuid();
|
||||
Guid rendezVousId = Guid.NewGuid();
|
||||
Guid collaborateurId = Guid.NewGuid();
|
||||
Guid suiviId = Guid.NewGuid();
|
||||
|
||||
var original = new MobileSnapshotResponse
|
||||
{
|
||||
GeneratedAtUtc = DateTimeOffset.UtcNow,
|
||||
Clients =
|
||||
[
|
||||
new ClientDetailsDto { Id = clientId.ToString(), Nom = "Client" }
|
||||
],
|
||||
Personnes =
|
||||
[
|
||||
new ContactDto { Id = personneId.ToString(), Nom = "Personne", ClientId = clientId.ToString() }
|
||||
],
|
||||
Sites =
|
||||
[
|
||||
new Omika.Core.Contracts.Sites.SiteDetailsDto { Id = siteId.ToString(), Nom = "Site", ClientId = clientId.ToString() }
|
||||
],
|
||||
Documents =
|
||||
[
|
||||
new Omika.Core.Contracts.Documents.DocumentSummaryDto { Id = documentId.ToString(), Nom = "Doc.pdf", InterventionId = interventionId.ToString() }
|
||||
],
|
||||
Interventions =
|
||||
[
|
||||
new Omika.Core.Contracts.Interventions.InterventionDetailsDto
|
||||
{
|
||||
Id = interventionId.ToString(),
|
||||
ClientId = clientId.ToString(),
|
||||
SiteId = siteId.ToString(),
|
||||
DossierId = dossierId.ToString(),
|
||||
Titre = "Intervention"
|
||||
}
|
||||
],
|
||||
Dossiers =
|
||||
[
|
||||
new Omika.Core.Contracts.Interventions.DossierDetailsDto { Id = dossierId.ToString(), Titre = "Dossier", ClientId = clientId.ToString() }
|
||||
],
|
||||
RendezVous =
|
||||
[
|
||||
new Omika.Core.Contracts.Planning.RendezVousDetailsDto
|
||||
{
|
||||
Id = rendezVousId.ToString(),
|
||||
Titre = "RDV",
|
||||
Debut = new DateTime(2026, 8, 10, 9, 0, 0),
|
||||
Fin = new DateTime(2026, 8, 10, 10, 0, 0),
|
||||
ClientId = clientId.ToString()
|
||||
}
|
||||
],
|
||||
Collaborateurs =
|
||||
[
|
||||
new Omika.Core.Contracts.Organisation.CollaborateurDetailsDto { Id = collaborateurId.ToString(), Nom = "Collab" }
|
||||
],
|
||||
SuivisTemps =
|
||||
[
|
||||
new Omika.Core.Contracts.Planning.SuiviTempsDto
|
||||
{
|
||||
Id = suiviId.ToString(),
|
||||
CollaborateurId = collaborateurId.ToString(),
|
||||
InterventionId = interventionId.ToString(),
|
||||
Date = new DateTime(2026, 8, 10),
|
||||
DureeMinutes = 60
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
// Simuler l'API : sérialiser en JSON (camelCase, comme l'API ASP.NET).
|
||||
var jsonOptions = new JsonSerializerOptions(JsonSerializerDefaults.Web);
|
||||
string json = JsonSerializer.Serialize(original, jsonOptions);
|
||||
|
||||
// Simuler le Desktop : désérialiser le JSON (comme MobileApiClient.GetSnapshotAsync).
|
||||
MobileSnapshotResponse? deserialized = JsonSerializer.Deserialize<MobileSnapshotResponse>(json, jsonOptions);
|
||||
Assert.NotNull(deserialized);
|
||||
|
||||
// Vérifier que les collections désérialisées sont réellement remplies.
|
||||
Assert.Equal(1, deserialized.Clients.Count);
|
||||
Assert.Equal(1, deserialized.Personnes.Count);
|
||||
Assert.Equal(1, deserialized.Sites.Count);
|
||||
Assert.Equal(1, deserialized.Documents.Count);
|
||||
Assert.Equal(1, deserialized.Interventions.Count);
|
||||
Assert.Equal(1, deserialized.Dossiers.Count);
|
||||
Assert.Equal(1, deserialized.RendezVous.Count);
|
||||
Assert.Equal(1, deserialized.Collaborateurs.Count);
|
||||
Assert.Equal(1, deserialized.SuivisTemps.Count);
|
||||
|
||||
// Act : appliquer le snapshot désérialisé (comme le ferait SyncEngine).
|
||||
var applier = new SnapshotApplier(Database);
|
||||
await applier.ApplyAsync(deserialized);
|
||||
|
||||
// Assert : seules les collections portées par le snapshot sont importées.
|
||||
// Client / Dossier / Collaborateur / Site / SuiviTemps sont des caches
|
||||
// alimentés à la demande, purgés à chaque synchro : le snapshot ne les
|
||||
// fournit plus. Les MÉTADONNÉES DE DOCUMENTS sont, elles, désormais
|
||||
// importées avec le snapshot (documents des interventions de la fenêtre
|
||||
// d'agenda, pour un accès hors ligne dès le lancement) : le document
|
||||
// fourni ici est donc bien présent.
|
||||
Assert.Equal(0, await ClientRepository.CountAsync());
|
||||
Assert.Equal(1, await PersonneRepository.CountAsync());
|
||||
Assert.Equal(0, await SiteRepository.CountAsync());
|
||||
Assert.Equal(1, await DocumentMetadataRepository.CountAsync());
|
||||
Assert.Equal(1, await InterventionRepository.CountAsync());
|
||||
Assert.Equal(0, await DossierRepository.CountAsync());
|
||||
Assert.Equal(1, await RendezVousRepository.CountAsync());
|
||||
Assert.Equal(0, await CollaborateurRepository.CountAsync());
|
||||
Assert.Equal(0, await SuiviTempsRepository.CountAsync());
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user