using System; using System.Collections.Generic; using System.IO; using System.Net; using System.Net.Http; using System.Security.Cryptography; using System.Text; using System.Threading; using System.Threading.Tasks; using Omika.App.Local.Abstractions; using Omika.App.Local.Infrastructure; using Omika.App.Local.Models; using Omika.App.Local.Services; using Omika.Core.Contracts.Mobile.Languages; using Xunit; namespace Omika.Tests.Mobile; /// /// Téléchargement et intégrité des packs de langue hors ligne. /// /// /// Ces tests ne font AUCUN appel réseau réel : le client HTTP est remplacé par un /// gestionnaire en mémoire, et les fichiers sont écrits dans un dossier /// temporaire — jamais dans le dossier de données de l'application. /// /// public sealed class LanguagePackTests : IDisposable { private readonly string _racine = Path.Combine(Path.GetTempPath(), "omika-langues-tests", Guid.NewGuid().ToString("N")); public void Dispose() { try { if (Directory.Exists(_racine)) { Directory.Delete(_racine, recursive: true); } } catch (IOException) { // Best-effort : un fichier encore verrouillé ne doit pas faire // échouer la suite de tests. } } // ===== Assainissement des codes et des chemins ===== [Theory] [InlineData("es")] [InlineData("pt")] [InlineData("pt-br")] [InlineData("zh")] [InlineData("ar")] public void EstCodeValide_AccepteLesCodesIso(string code) => Assert.True(LanguagePaths.EstCodeValide(code)); [Theory] [InlineData("")] [InlineData(" ")] [InlineData("..")] [InlineData("../es")] [InlineData("es/../..")] [InlineData("es\\..")] [InlineData("e")] [InlineData("codebeaucouptroplong")] [InlineData("-es")] [InlineData("es-")] [InlineData("es 1")] [InlineData("ES")] public void EstCodeValide_RefuseCeQuiNEstPasUnCodeDeLangue(string code) => Assert.False(LanguagePaths.EstCodeValide(code)); [Fact] public void FichierModele_UnNomDeFichierRelatif_neSortJamaisDuDossierDeLaLangue() { // Un manifeste serveur compromis ne doit JAMAIS pouvoir faire écrire // hors du dossier de la langue : le nom est réduit à son nom simple. string chemin = LanguagePaths.FichierModeleDans( _racine, "es", LanguageModelDirections.VersFrancais, "../../../evil.onnx"); string dossierAttendu = LanguagePaths.DossierSensDans( _racine, "es", LanguageModelDirections.VersFrancais); Assert.Equal(dossierAttendu, Path.GetDirectoryName(chemin)); Assert.Equal("evil.onnx", Path.GetFileName(chemin)); } // ===== Dépôt local ===== [Fact] public async Task Store_UnPackMarqueInstalle_EstReluAvecSesFichiers() { var store = new LanguagePackStore(_racine); (FichierModeleInstalle fichier, byte[] contenu) = CreerFichier("es", "encoder.onnx", "MODELE"); EcrireFichier(store, "es", LanguageModelDirections.VersFrancais, fichier.NomFichier, contenu); await store.EnregistrerPackAsync(Pack("es", fichier)); PackLangueInstalle? relu = await store.GetPackInstalleAsync("es"); Assert.NotNull(relu); Assert.Equal("es", relu!.Code); Assert.Single(relu.Fichiers); Assert.True(await store.VerifierFichierAsync("es", relu.Fichiers[0])); IReadOnlyList tous = await store.ListerPacksInstallesAsync(); Assert.Single(tous); } [Fact] public async Task Store_UnFichierManquant_AnnuleLInstallation() { var store = new LanguagePackStore(_racine); (FichierModeleInstalle fichier, _) = CreerFichier("pt", "encoder.onnx", "MODELE"); // Marqueur écrit SANS le fichier : c'est exactement ce que laisse un // effacement par le système (nettoyage de stockage). await store.EnregistrerPackAsync(Pack("pt", fichier)); Assert.Null(await store.GetPackInstalleAsync("pt")); } [Fact] public async Task Store_UneEmpreinteQuiNeCorrespondPas_EstRefusee() { var store = new LanguagePackStore(_racine); (FichierModeleInstalle fichier, byte[] contenu) = CreerFichier("es", "encoder.onnx", "MODELE"); EcrireFichier(store, "es", LanguageModelDirections.VersFrancais, fichier.NomFichier, contenu); var falsifie = new FichierModeleInstalle { Sens = fichier.Sens, NomFichier = fichier.NomFichier, TailleOctets = contenu.Length, Sha256 = new string('0', 64) }; Assert.False(await store.VerifierFichierAsync("es", falsifie)); } [Fact] public async Task Store_UnFichierPartiel_NEstJamaisPrisPourUnFichierInstalle() { var store = new LanguagePackStore(_racine); (FichierModeleInstalle fichier, byte[] contenu) = CreerFichier("es", "encoder.onnx", "MODELE"); // Le téléchargement s'écrit d'abord en « .part » : ce fichier ne doit // jamais satisfaire les contrôles de présence. string partiel = store.PreparerFichierPartiel( "es", LanguageModelDirections.VersFrancais, fichier.NomFichier); await File.WriteAllBytesAsync(partiel, contenu); Assert.False(store.FichierPresent("es", fichier)); } // ===== Téléchargement ===== [Fact] public async Task Telechargement_VerifieLIntegrite_PuisMarqueLePackInstalle() { (FichierModeleInstalle fichier, byte[] contenu) = CreerFichier("es", "encoder.onnx", "MODELE"); var store = new LanguagePackStore(_racine); var service = CreerService(store, new Manifeste("es", "Espagnol", fichier), contenu); var progression = new List(); PackLangueInstalle pack = await service.TelechargerAsync( "es", new Progress(progression.Add)); Assert.Equal("es", pack.Code); Assert.True(File.Exists(LanguagePaths.PackInstalleDans(_racine, "es"))); Assert.Equal(contenu, await File.ReadAllBytesAsync( LanguagePaths.FichierModeleDans(_racine, "es", fichier.Sens, fichier.NomFichier))); // Le fichier partiel ne doit jamais subsister après vérification. Assert.False(File.Exists( LanguagePaths.FichierModeleDans(_racine, "es", fichier.Sens, fichier.NomFichier) + LanguagePaths.ExtensionPartielle)); } [Fact] public async Task Telechargement_SensCommun_EstAccepteEtRangeDansLeDossierPartage() { // Un modèle multilingue (famille de langues) couvre les deux sens avec les // MÊMES poids : il les déclare une seule fois sous « commun ». Refuser ce // sens ferait télécharger deux fois le même giga-octet sur chaque // téléphone — c'est exactement ce que le partage évite. (FichierModeleInstalle origine, byte[] contenu) = CreerFichier("es", "encoder_model.onnx", "MODELE"); var partage = new FichierModeleInstalle { Sens = LanguageModelDirections.Commun, NomFichier = origine.NomFichier, TailleOctets = contenu.Length, Sha256 = origine.Sha256 }; var store = new LanguagePackStore(_racine); var service = CreerService(store, new Manifeste("es", "Espagnol", partage), contenu); PackLangueInstalle pack = await service.TelechargerAsync("es"); Assert.True(pack.ContientSens(LanguageModelDirections.Commun)); Assert.True(File.Exists(LanguagePaths.FichierModeleDans( _racine, "es", LanguageModelDirections.Commun, partage.NomFichier))); } [Fact] public async Task Manifeste_SensInconnu_RefuseLePack() { (FichierModeleInstalle origine, byte[] contenu) = CreerFichier("es", "encoder.onnx", "MODELE"); var inconnu = new FichierModeleInstalle { Sens = "vertical", NomFichier = origine.NomFichier, TailleOctets = contenu.Length, Sha256 = origine.Sha256 }; var store = new LanguagePackStore(_racine); var service = CreerService(store, new Manifeste("es", "Espagnol", inconnu), contenu); LanguagePackException erreur = await Assert.ThrowsAsync(() => service.TelechargerAsync("es")); Assert.Contains("vertical", erreur.Message, StringComparison.Ordinal); } [Fact] public async Task Telechargement_EmpreinteNonConforme_NInstalleRien() { (FichierModeleInstalle fichier, byte[] contenu) = CreerFichier("es", "encoder.onnx", "MODELE"); var store = new LanguagePackStore(_racine); // Le manifeste annonce une empreinte qui ne correspond PAS aux octets // réellement servis (fichier altéré en transit ou sur le disque serveur). var falsifie = new FichierModeleInstalle { Sens = fichier.Sens, NomFichier = fichier.NomFichier, TailleOctets = contenu.Length, Sha256 = new string('f', 64) }; var service = CreerService(store, new Manifeste("es", "Espagnol", falsifie), contenu); await Assert.ThrowsAsync(() => service.TelechargerAsync("es")); // Ni le fichier ni le marqueur ne doivent exister : mieux vaut ne rien // installer que d'installer un modèle non vérifié. Assert.False(File.Exists(LanguagePaths.PackInstalleDans(_racine, "es"))); Assert.Null(await store.GetPackInstalleAsync("es")); } [Fact] public async Task Manifeste_SansEmpreinte_RefuseLePack() { (FichierModeleInstalle fichier, byte[] contenu) = CreerFichier("es", "encoder.onnx", "MODELE"); var store = new LanguagePackStore(_racine); var sansEmpreinte = new FichierModeleInstalle { Sens = fichier.Sens, NomFichier = fichier.NomFichier, TailleOctets = contenu.Length, Sha256 = string.Empty }; var service = CreerService(store, new Manifeste("es", "Espagnol", sansEmpreinte), contenu); LanguagePackException erreur = await Assert.ThrowsAsync(() => service.TelechargerAsync("es")); Assert.Contains("SHA-256", erreur.Message, StringComparison.Ordinal); Assert.False(File.Exists(LanguagePaths.PackInstalleDans(_racine, "es"))); } [Fact] public async Task Manifeste_UrlNonConfiguree_LeDitClairement() { var store = new LanguagePackStore(_racine); var service = new LanguagePackService( store, _ => Task.FromResult(null), new HttpClient(new FauxHandler([]))); LanguagePackException erreur = await Assert.ThrowsAsync( () => service.ListerLanguesAsync()); Assert.Contains("n'est pas configuré", erreur.Message, StringComparison.Ordinal); } [Fact] public async Task Manifeste_EstMemorisePuisReluSansReseau() { (FichierModeleInstalle fichier, byte[] contenu) = CreerFichier("es", "encoder.onnx", "MODELE"); var store = new LanguagePackStore(_racine); var handler = new FauxHandler([]); var service = CreerService(store, new Manifeste("es", "Espagnol", fichier), contenu, handler); IReadOnlyList enLigne = await service.ListerLanguesAsync(forcerReseau: true); Assert.Single(enLigne); // Deuxième appel sans forcer : le manifeste mémorisé suffit, aucun nouvel // appel réseau (l'écran Paramètres doit rester consultable hors ligne). int appelsApresPremier = handler.NombreAppels; IReadOnlyList horsLigne = await service.ListerLanguesAsync(); Assert.Single(horsLigne); Assert.Equal(appelsApresPremier, handler.NombreAppels); } // ===== Fabrique de test ===== /// Description d'un pack servi par le faux serveur. private sealed record Manifeste(string Code, string Libelle, FichierModeleInstalle Fichier); private static LanguagePackService CreerService( LanguagePackStore store, Manifeste manifeste, byte[] contenu, FauxHandler? handler = null) { handler ??= new FauxHandler(contenu); handler.Manifeste = manifeste; return new LanguagePackService( store, _ => Task.FromResult("https://langues.exemple.test"), new HttpClient(handler)); } /// Construit un fichier de test dont l'empreinte est calculée sur le contenu réel. private static (FichierModeleInstalle Fichier, byte[] Contenu) CreerFichier( string code, string nom, string texte) { _ = code; byte[] contenu = Encoding.UTF8.GetBytes(texte); string empreinte = Convert.ToHexString(SHA256.HashData(contenu)).ToLowerInvariant(); return (new FichierModeleInstalle { Sens = LanguageModelDirections.VersFrancais, NomFichier = nom, TailleOctets = contenu.Length, Sha256 = empreinte }, contenu); } private static PackLangueInstalle Pack(string code, FichierModeleInstalle fichier) => new() { Code = code, Libelle = code, Version = "1.0.0", InstalleLeUtc = DateTimeOffset.UtcNow, Fichiers = [fichier] }; private static void EcrireFichier( LanguagePackStore store, string code, string sens, string nom, byte[] contenu) { string chemin = store.CheminFichier(code, sens, nom); Directory.CreateDirectory(Path.GetDirectoryName(chemin)!); File.WriteAllBytes(chemin, contenu); } /// /// Faux serveur : sert un manifeste et les octets d'un fichier, sans réseau. /// private sealed class FauxHandler : HttpMessageHandler { private readonly byte[] _contenu; public FauxHandler(byte[] contenu) => _contenu = contenu; /// Pack annoncé par le manifeste (null : manifeste vide). public Manifeste? Manifeste { get; set; } /// Nombre d'appels reçus (permet de vérifier le cache local). public int NombreAppels { get; private set; } protected override Task SendAsync( HttpRequestMessage request, CancellationToken cancellationToken) { NombreAppels++; string chemin = request.RequestUri?.AbsolutePath ?? string.Empty; if (chemin.EndsWith("/api/langues", StringComparison.Ordinal)) { return Task.FromResult(Reponse(HttpStatusCode.OK, Encoding.UTF8.GetBytes(ManifesteJson()))); } if (Manifeste is not null && chemin.EndsWith($"/{Manifeste.Fichier.NomFichier}", StringComparison.Ordinal)) { return Task.FromResult(Reponse(HttpStatusCode.OK, _contenu)); } return Task.FromResult(Reponse(HttpStatusCode.NotFound, [])); } private string ManifesteJson() { if (Manifeste is null) { return """{"packs":[]}"""; } FichierModeleInstalle fichier = Manifeste.Fichier; // Sérialisation manuelle : le test vérifie la FORME attendue du // manifeste (clés camelCase) telle que la produit l'API des langues. return $$""" { "packs": [ { "code": "{{Manifeste.Code}}", "libelle": "{{Manifeste.Libelle}}", "version": "1.0.0", "fichiers": [ { "sens": "{{fichier.Sens}}", "nomFichier": "{{fichier.NomFichier}}", "tailleOctets": {{fichier.TailleOctets}}, "sha256": "{{fichier.Sha256}}" } ] } ] } """; } private static HttpResponseMessage Reponse(HttpStatusCode statut, byte[] contenu) => new(statut) { Content = new ByteArrayContent(contenu) }; } }