using Manager.Services; using ManagerService.Controllers; using ManagerService.Data; using ManagerService.DTOs; using ManagerService.Services; using ManagerService.Tests.Infrastructure; using Microsoft.AspNetCore.Mvc; using Microsoft.Extensions.Caching.Memory; using Microsoft.Extensions.Logging.Abstractions; using System.Collections.Generic; using System.Linq; using Xunit; namespace ManagerService.Tests.Controllers { public class ResourceControllerTests { private static ResourceController BuildController( MyInfoMateDbContext db, FakeHttpClientFactory httpClientFactory = null, FakeBlobService blobService = null) { var cfg = FakeMongoConfig.Create(); var controller = new ResourceController( NullLogger.Instance, new ResourceDatabaseService(cfg), new SectionDatabaseService(cfg), new ConfigurationDatabaseService(cfg), db, httpClientFactory ?? new FakeHttpClientFactory(), blobService ?? new FakeBlobService(), new ResourceUsageService(db, new MemoryCache(new MemoryCacheOptions()))); FakeUser.SetUser(controller, FakeUser.Create("Manager.contenteditor", "inst-test")); return controller; } // ── GET ────────────────────────────────────────────────────────────── [Fact] public void Get_FiltersToInstance() { using var db = DbContextFactory.Create(); db.Resources.AddRange( new Resource { Id = "r1", InstanceId = "inst-test", Label = "A", Type = ResourceType.Image }, new Resource { Id = "r2", InstanceId = "other-inst", Label = "B", Type = ResourceType.Image } ); db.SaveChanges(); var result = BuildController(db).Get("inst-test", new List()); var ok = Assert.IsType(result); var resources = (ok.Value as System.Collections.IEnumerable)!.Cast().ToList(); Assert.Single(resources); } [Fact] public void Get_WithTypeFilter_FiltersType() { using var db = DbContextFactory.Create(); db.Resources.AddRange( new Resource { Id = "r1", InstanceId = "inst-test", Label = "A", Type = ResourceType.Image }, new Resource { Id = "r2", InstanceId = "inst-test", Label = "B", Type = ResourceType.Video } ); db.SaveChanges(); var result = BuildController(db).Get("inst-test", new List { ResourceType.Image }); var ok = Assert.IsType(result); var resources = (ok.Value as System.Collections.IEnumerable)!.Cast().ToList(); Assert.Single(resources); } // ── CREATE ─────────────────────────────────────────────────────────── [Fact] public void Create_FileType_SetsStoragePathAndSizeBytes() { using var db = DbContextFactory.Create(); var result = BuildController(db).Create(new ResourceDTO { instanceId = "inst-test", label = "brochure", type = ResourceType.PDF, sizeBytes = 12345 }); Assert.IsType(result); var created = db.Resources.First(); Assert.Equal($"pictures/inst-test/{created.Id}", created.StoragePath); Assert.Equal(12345, created.SizeBytes); } [Fact] public void Create_UrlType_LeavesStoragePathAndSizeBytesEmpty() { using var db = DbContextFactory.Create(); var result = BuildController(db).Create(new ResourceDTO { instanceId = "inst-test", label = "wikipedia", type = ResourceType.ImageUrl, url = "https://example.org/photo.jpg", sizeBytes = 12345 }); Assert.IsType(result); var created = db.Resources.First(); Assert.Null(created.StoragePath); Assert.Equal(0, created.SizeBytes); } // ── UPDATE ─────────────────────────────────────────────────────────── [Fact] public void Update_ExistingResource_UpdatesLabel() { using var db = DbContextFactory.Create(); db.Resources.Add(new Resource { Id = "r1", InstanceId = "inst-test", Label = "Old", Type = ResourceType.Image }); db.SaveChanges(); var result = BuildController(db).Update(new ResourceDTO { id = "r1", label = "New Label" }); Assert.IsType(result); Assert.Equal("New Label", db.Resources.First().Label); } // D2 : `dateUpdate` est le seul signal de fraîcheur dont dispose la visite hors // ligne. S'il cessait d'être exposé, le device garderait indéfiniment ses fichiers // périmés — sans erreur, sans trace. D'où ces deux tests plutôt qu'une relecture. [Fact] public void ToDTO_ExposesDateUpdate() { using var db = DbContextFactory.Create(); db.Resources.Add(new Resource { Id = "r1", InstanceId = "inst-test", Label = "Img", Type = ResourceType.Image }); db.SaveChanges(); var dto = db.Resources.First().ToDTO(); Assert.NotEqual(default, dto.dateUpdate); Assert.Equal(db.Resources.First().DateUpdate, dto.dateUpdate); } [Fact] public void Update_AdvancesDateUpdate() { using var db = DbContextFactory.Create(); db.Resources.Add(new Resource { Id = "r1", InstanceId = "inst-test", Label = "Old", Type = ResourceType.Image }); db.SaveChanges(); var before = db.Resources.First().DateUpdate; var result = BuildController(db).Update(new ResourceDTO { id = "r1", label = "New Label" }); var dto = Assert.IsType(result).Value as ResourceDTO; Assert.True(dto.dateUpdate > before); } [Fact] public void Update_UnknownResource_Returns404() { using var db = DbContextFactory.Create(); var result = BuildController(db).Update(new ResourceDTO { id = "unknown", label = "X" }); Assert.IsType(result); } // ── DELETE ─────────────────────────────────────────────────────────── // La suppression d'une ressource utilisée est refusée depuis la Médiathèque V1 : // la suite de Delete déréférence la ressource partout où elle sert, ce qui viderait // des contenus en silence. Ces deux tests figeaient l'ancien comportement. [Fact] public void Delete_ResourceUsedByConfiguration_Returns409() { using var db = DbContextFactory.Create(); db.Resources.Add(new Resource { Id = "r1", InstanceId = "inst-test", Label = "Img", Type = ResourceType.Image }); db.Configurations.Add(new Configuration { Id = "c1", InstanceId = "inst-test", Label = "C", ImageId = "r1", Title = new List() }); db.SaveChanges(); var result = BuildController(db).Delete("r1").GetAwaiter().GetResult(); Assert.IsType(result); Assert.Equal("r1", db.Configurations.First().ImageId); Assert.Equal(1, db.Resources.Count()); } [Fact] public void Delete_ResourceUsedBySection_Returns409() { using var db = DbContextFactory.Create(); db.Resources.Add(new Resource { Id = "r1", InstanceId = "inst-test", Label = "Img", Type = ResourceType.Image }); db.Configurations.Add(new Configuration { Id = "c1", InstanceId = "inst-test", Label = "C", Title = new List() }); var section = TestSection.Article("s1", "inst-test", "S", "c1"); section.ImageId = "r1"; db.Sections.Add(section); db.SaveChanges(); var result = BuildController(db).Delete("r1").GetAwaiter().GetResult(); Assert.IsType(result); Assert.Equal("r1", db.Sections.First().ImageId); } [Fact] public void DeleteBulk_DeletesFreeResources_AndRefusesUsedOnes() { using var db = DbContextFactory.Create(); db.Resources.AddRange( new Resource { Id = "r-used", InstanceId = "inst-test", Label = "Utilisée", Type = ResourceType.Image }, new Resource { Id = "r-free", InstanceId = "inst-test", Label = "Libre", Type = ResourceType.Image }); db.Configurations.Add(new Configuration { Id = "c1", InstanceId = "inst-test", Label = "C", ImageId = "r-used", Title = new List() }); db.SaveChanges(); var result = BuildController(db) .DeleteBulk(new ResourceBulkDeleteRequestDTO { ids = new List { "r-used", "r-free" } }) .GetAwaiter().GetResult(); var report = Assert.IsType(Assert.IsType(result).Value); Assert.Equal(new[] { "r-free" }, report.deleted); Assert.Equal("r-used", Assert.Single(report.refused).id); Assert.Equal(1, Assert.Single(report.refused).usageCount); } [Fact] public void Delete_UnknownResource_Returns404() { using var db = DbContextFactory.Create(); var result = BuildController(db).Delete("unknown").GetAwaiter().GetResult(); Assert.IsType(result); } [Fact] public void Delete_ExistingResource_Returns202() { using var db = DbContextFactory.Create(); db.Resources.Add(new Resource { Id = "r1", InstanceId = "inst-test", Label = "A", Type = ResourceType.Image }); db.SaveChanges(); var result = BuildController(db).Delete("r1").GetAwaiter().GetResult(); var obj = Assert.IsType(result); Assert.Equal(202, obj.StatusCode); Assert.Equal(0, db.Resources.Count()); } // ── QUOTA & BLOB (C3) ──────────────────────────────────────────────── private static void SeedPlan(MyInfoMateDbContext db, long planQuota, long instanceQuota = 0) { db.SubscriptionPlans.Add(new SubscriptionPlan { Id = "plan-test", Name = "Test", StorageQuotaBytes = planQuota }); db.Instances.Add(new Instance { Id = "inst-test", Name = "Test", SubscriptionPlanId = "plan-test", StorageQuotaBytes = instanceQuota }); db.SaveChanges(); } [Fact] public void Create_OverQuota_Returns413() { using var db = DbContextFactory.Create(); SeedPlan(db, planQuota: 1000); db.Resources.Add(new Resource { Id = "r0", InstanceId = "inst-test", Label = "Déjà là", Type = ResourceType.Image, SizeBytes = 900 }); db.SaveChanges(); var result = BuildController(db).Create(new ResourceDTO { instanceId = "inst-test", label = "Trop gros", type = ResourceType.Image, sizeBytes = 200 }); Assert.Equal(413, result.StatusCode); Assert.Equal(1, db.Resources.Count()); } [Fact] public void Create_UrlType_IgnoresQuota() { using var db = DbContextFactory.Create(); SeedPlan(db, planQuota: 1000); db.Resources.Add(new Resource { Id = "r0", InstanceId = "inst-test", Label = "Déjà là", Type = ResourceType.Image, SizeBytes = 1000 }); db.SaveChanges(); // Un lien externe n'occupe rien dans le bucket : il ne doit pas être refusé // pour un quota de stockage, même sur une instance pleine. var result = BuildController(db).Create(new ResourceDTO { instanceId = "inst-test", label = "Lien", type = ResourceType.ImageUrl, url = "https://ailleurs/img.png" }); Assert.Equal(200, result.StatusCode); } /// /// La divergence que StorageQuota ferme : la surcharge portée par l'instance /// l'emporte sur le plan. Avant, le pré-vol lisait le plan et bloquait à 1 000 /// alors que l'écran de quota annonçait 10 000. /// [Fact] public void Create_InstanceOverridesPlanQuota() { using var db = DbContextFactory.Create(); SeedPlan(db, planQuota: 1000, instanceQuota: 10000); db.Resources.Add(new Resource { Id = "r0", InstanceId = "inst-test", Label = "Déjà là", Type = ResourceType.Image, SizeBytes = 900 }); db.SaveChanges(); var result = BuildController(db).Create(new ResourceDTO { instanceId = "inst-test", label = "Dans le quota surchargé", type = ResourceType.Image, sizeBytes = 200 }); Assert.Equal(200, result.StatusCode); } [Fact] public void Delete_RemovesBlobBeforeRow() { using var db = DbContextFactory.Create(); db.Resources.Add(new Resource { Id = "r1", InstanceId = "inst-test", Label = "A", Type = ResourceType.Image, StoragePath = "pictures/inst-test/r1" }); db.SaveChanges(); var blob = new FakeBlobService(); var result = BuildController(db, blobService: blob).Delete("r1").GetAwaiter().GetResult(); Assert.Equal(202, result.StatusCode); Assert.Equal(new[] { "pictures/inst-test/r1" }, blob.DeletedPaths); Assert.Equal(0, db.Resources.Count()); } /// /// Le cœur du choix : un échec de bucket conserve la ligne. Une ligne encore /// listée se rattrape ; un blob dont plus aucune ligne ne porte le chemin est /// facturé sans que rien ne le désigne. /// [Fact] public void Delete_BlobFailure_KeepsRowAndReturns502() { using var db = DbContextFactory.Create(); db.Resources.Add(new Resource { Id = "r1", InstanceId = "inst-test", Label = "A", Type = ResourceType.Image, StoragePath = "pictures/inst-test/r1" }); db.SaveChanges(); var blob = new FakeBlobService(BlobDeleteOutcome.Failed); var result = BuildController(db, blobService: blob).Delete("r1").GetAwaiter().GetResult(); Assert.Equal(502, result.StatusCode); Assert.Equal(1, db.Resources.Count()); } [Fact] public void Delete_MissingStoragePath_IsRebuiltFromTheDeterministicPath() { using var db = DbContextFactory.Create(); db.Resources.Add(new Resource { Id = "r1", InstanceId = "inst-test", Label = "Avant le backfill", Type = ResourceType.Image }); db.SaveChanges(); var blob = new FakeBlobService(); BuildController(db, blobService: blob).Delete("r1").GetAwaiter().GetResult(); Assert.Equal(new[] { "pictures/inst-test/r1" }, blob.DeletedPaths); } [Fact] public void Update_TypeBecomesFile_FillsStoragePath() { using var db = DbContextFactory.Create(); db.Resources.Add(new Resource { Id = "r1", InstanceId = "inst-test", Label = "A", Type = ResourceType.ImageUrl, Url = "https://ailleurs/img.png" }); db.SaveChanges(); BuildController(db).Update(new ResourceDTO { id = "r1", type = ResourceType.Image, sizeBytes = 256 }); var resource = db.Resources.Single(); Assert.Equal("pictures/inst-test/r1", resource.StoragePath); Assert.Equal(256, resource.SizeBytes); } [Fact] public void Update_TypeBecomesUrl_ClearsStorageColumns() { using var db = DbContextFactory.Create(); db.Resources.Add(new Resource { Id = "r1", InstanceId = "inst-test", Label = "A", Type = ResourceType.Image, StoragePath = "pictures/inst-test/r1", SizeBytes = 4096 }); db.SaveChanges(); BuildController(db).Update(new ResourceDTO { id = "r1", type = ResourceType.ImageUrl }); var resource = db.Resources.Single(); Assert.Null(resource.StoragePath); Assert.Equal(0, resource.SizeBytes); } // ── BACKFILL STORAGE (C2) ──────────────────────────────────────────── private static ResourceBackfillReportDTO RunBackfill( ResourceController controller, bool dryRun = false, string instanceId = null) { var result = controller.BackfillStorage(dryRun, instanceId).GetAwaiter().GetResult(); var ok = Assert.IsType(result); return Assert.IsType(ok.Value); } [Fact] public void Backfill_FillsStoragePathAndSizeBytes() { using var db = DbContextFactory.Create(); db.Resources.Add(new Resource { Id = "r1", InstanceId = "inst-test", Label = "A", Type = ResourceType.Image, Url = "https://bucket/r1" }); db.SaveChanges(); var http = new FakeHttpClientFactory(new Dictionary { ["https://bucket/r1"] = 2048 }); var report = RunBackfill(BuildController(db, http)); var resource = db.Resources.Single(); Assert.Equal("pictures/inst-test/r1", resource.StoragePath); Assert.Equal(2048, resource.SizeBytes); Assert.Equal(1, report.StoragePathFilled); Assert.Equal(1, report.SizeBytesFilled); } /// /// Le point du lien L10 : un backfill qui écrirait quand même laisserait le quota /// de C3 porter sur des zéros. Une ligne non sondable doit ressortir nommée. /// [Fact] public void Backfill_UnreachableBlob_IsReportedNotCountedAsZero() { using var db = DbContextFactory.Create(); db.Resources.Add(new Resource { Id = "r1", InstanceId = "inst-test", Label = "Absente", Type = ResourceType.Image, Url = "https://bucket/disparue" }); db.SaveChanges(); var report = RunBackfill(BuildController(db, new FakeHttpClientFactory())); Assert.Equal(0, report.SizeBytesFilled); Assert.Single(report.Unsized); Assert.Contains("r1", report.Unsized[0]); // Le chemin, lui, est déterministe : il se calcule même sans blob joignable. Assert.Equal("pictures/inst-test/r1", db.Resources.Single().StoragePath); } [Fact] public void Backfill_FileTypeWithoutUrl_IsListedAsOrphan() { using var db = DbContextFactory.Create(); db.Resources.Add(new Resource { Id = "r1", InstanceId = "inst-test", Label = "Sans URL", Type = ResourceType.PDF }); db.SaveChanges(); var report = RunBackfill(BuildController(db)); Assert.Single(report.Orphans); Assert.Empty(report.Unsized); } [Fact] public void Backfill_UrlType_KeepsNoPathAndNoSize() { using var db = DbContextFactory.Create(); db.Resources.Add(new Resource { Id = "r1", InstanceId = "inst-test", Label = "Lien", Type = ResourceType.ImageUrl, Url = "https://ailleurs/img.png", StoragePath = "pictures/inst-test/r1", SizeBytes = 999 }); db.SaveChanges(); var report = RunBackfill(BuildController(db)); var resource = db.Resources.Single(); Assert.Null(resource.StoragePath); Assert.Equal(0, resource.SizeBytes); Assert.Equal(1, report.UrlTypesNormalised); } [Fact] public void Backfill_AlreadyCompleteRow_IsLeftAlone() { using var db = DbContextFactory.Create(); db.Resources.Add(new Resource { Id = "r1", InstanceId = "inst-test", Label = "A", Type = ResourceType.Image, Url = "https://bucket/r1", StoragePath = "pictures/inst-test/r1", SizeBytes = 512 }); db.SaveChanges(); var http = new FakeHttpClientFactory(new Dictionary { ["https://bucket/r1"] = 999999 }); var report = RunBackfill(BuildController(db, http)); // La taille en base a été écrite par la création, qui connaissait le fichier : // la resonder n'apporterait rien et écraserait une valeur plus sûre. Assert.Equal(512, db.Resources.Single().SizeBytes); Assert.Equal(1, report.AlreadyComplete); Assert.Equal(0, report.SizeBytesFilled); } [Fact] public void Backfill_DryRun_WritesNothing() { using var db = DbContextFactory.Create(); db.Resources.Add(new Resource { Id = "r1", InstanceId = "inst-test", Label = "A", Type = ResourceType.Image, Url = "https://bucket/r1" }); db.SaveChanges(); var http = new FakeHttpClientFactory(new Dictionary { ["https://bucket/r1"] = 2048 }); var report = RunBackfill(BuildController(db, http), dryRun: true); Assert.True(report.DryRun); Assert.Equal(1, report.StoragePathFilled); // Sans vider le ChangeTracker, l'entité modifiée en mémoire répondrait la // nouvelle valeur alors que rien n'a été enregistré : l'assertion serait creuse. db.ChangeTracker.Clear(); var reread = db.Resources.Single(); Assert.Null(reread.StoragePath); Assert.Equal(0, reread.SizeBytes); } [Fact] public void Backfill_ScopedToInstance_IgnoresOthers() { using var db = DbContextFactory.Create(); db.Resources.AddRange( new Resource { Id = "r1", InstanceId = "inst-test", Label = "A", Type = ResourceType.Image }, new Resource { Id = "r2", InstanceId = "other-inst", Label = "B", Type = ResourceType.Image } ); db.SaveChanges(); var report = RunBackfill(BuildController(db), instanceId: "inst-test"); Assert.Equal(1, report.Examined); Assert.Null(db.Resources.Single(r => r.Id == "r2").StoragePath); } } }