C2 — POST /api/Resource/backfill-storage, SuperAdmin, dryRun à true par
défaut : la migration se joue sur une base vide, ce backfill sur des lignes
de production. StoragePath par ResourceStorage.PathFor, SizeBytes par HEAD.
La méthode annoncée au plan — « SizeBytes par listing du bucket Firebase » —
était inapplicable : le serveur n'avait aucun client de stockage. Le sondage
passe donc par HEAD sur l'URL publique, comme le fait déjà la migration, et
le sondeur est extrait plutôt que recopié (Helpers/ResourceSizeProbe,
consommé par MigrationController et par le backfill). Même raisonnement que
pour ResourceStorage : deux copies auraient divergé sur ce qui compte, le
sort réservé aux échecs.
L'extraction a bouché un trou que personne ne cherchait. L'original ne notait
l'échec que dans son catch, or un HEAD sur un blob absent ne lève pas : il
répond 404, sans Content-Length. Ces ressources arrivaient à 0 octet sans
figurer dans le rapport — invisibles au quota et invisibles au diagnostic,
exactement ce que le commentaire d'origine voulait empêcher.
Le « 37 lignes sur 45 » du plan n'étant pas vérifiable, le backfill rend son
propre inventaire : Orphans (aucune URL, blob peut-être jamais téléversé) et
Unsized (URL présente, bucket muet) restent séparés, ce sont deux causes
distinctes.
C3 — pré-vol du quota sur les deux chemins de création, suppression du blob
à Delete, angle mort d'Update tranché.
Deux défauts trouvés en câblant, qui n'étaient documentés nulle part :
- Le pré-vol existait déjà à moitié. Upload (multipart) contrôlait et
renvoyait 413, Create (JSON) ne contrôlait rien — or c'est le chemin
qu'emprunte manager-app, qui crée la ligne puis téléverse.
- Les deux lectures du quota divergeaient. Upload lisait le quota du plan,
GetQuota celui de l'instance avec le plan en repli. Une instance à quota
surchargé — le mécanisme même de l'add-on — affichait un chiffre à l'écran
et se faisait bloquer sur un autre. Helpers/StorageQuota devient la seule
source de vérité pour les deux.
Delete supprime le blob AVANT la ligne et renvoie 502 en conservant la ligne
si le bucket échoue. manager-app faisait l'inverse en avalant l'échec dans un
print : la ligne disparaissait, le blob restait, et n'ayant plus de ligne il
devenait invisible au quota tout en restant facturé. Une ressource encore
listée se rattrape ; un blob que plus aucune ligne ne désigne, non.
L'angle mort laissé ouvert par C1 était une fausse crainte : PathFor ne
construit qu'un pictures/{instanceId}/{resourceId}, le type n'entre pas dans
le chemin, il décide seulement s'il y en a un. Recalculer ne peut donc pas
pointer ailleurs, et Update rejoue Apply.
Aucun secret nouveau : FirebaseAdmin était déjà référencé pour les
notifications push et Startup charge déjà un service account, donc
Google.Cloud.Storage.V1 réutilise le même GoogleCredential. Seule s'ajoute la
clé Firebase:StorageBucket, vide par défaut — à renseigner en prod (I9),
sans quoi Delete ne supprime rien et ne prétend pas le contraire.
dotnet build vert, dotnet test 163/163 (148 au départ, +7 pour C2, +8 pour C3).
Contient aussi le correctif d'indexation préparé en parallèle : un job
Hangfire par section dans BackfillInstanceAsync au lieu d'une boucle, et un
backoff sur 429/503 dans GoogleEmbeddingService.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
529 lines
21 KiB
C#
529 lines
21 KiB
C#
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.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<ResourceController>.Instance,
|
|
new ResourceDatabaseService(cfg),
|
|
new SectionDatabaseService(cfg),
|
|
new ConfigurationDatabaseService(cfg),
|
|
db,
|
|
httpClientFactory ?? new FakeHttpClientFactory(),
|
|
blobService ?? new FakeBlobService());
|
|
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<ResourceType>());
|
|
|
|
var ok = Assert.IsType<OkObjectResult>(result);
|
|
var resources = (ok.Value as System.Collections.IEnumerable)!.Cast<object>().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> { ResourceType.Image });
|
|
|
|
var ok = Assert.IsType<OkObjectResult>(result);
|
|
var resources = (ok.Value as System.Collections.IEnumerable)!.Cast<object>().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<OkObjectResult>(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<OkObjectResult>(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<OkObjectResult>(result);
|
|
Assert.Equal("New Label", db.Resources.First().Label);
|
|
}
|
|
|
|
[Fact]
|
|
public void Update_UnknownResource_Returns404()
|
|
{
|
|
using var db = DbContextFactory.Create();
|
|
|
|
var result = BuildController(db).Update(new ResourceDTO { id = "unknown", label = "X" });
|
|
|
|
Assert.IsType<NotFoundObjectResult>(result);
|
|
}
|
|
|
|
// ── DELETE ───────────────────────────────────────────────────────────
|
|
|
|
[Fact]
|
|
public void Delete_NullifiesConfigurationImageId()
|
|
{
|
|
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<TranslationDTO>() });
|
|
db.SaveChanges();
|
|
|
|
BuildController(db).Delete("r1");
|
|
|
|
Assert.Null(db.Configurations.First().ImageId);
|
|
}
|
|
|
|
[Fact]
|
|
public void Delete_NullifiesSectionImageId()
|
|
{
|
|
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<TranslationDTO>() });
|
|
var section = TestSection.Article("s1", "inst-test", "S", "c1");
|
|
section.ImageId = "r1";
|
|
db.Sections.Add(section);
|
|
db.SaveChanges();
|
|
|
|
BuildController(db).Delete("r1");
|
|
|
|
Assert.Null(db.Sections.First().ImageId);
|
|
}
|
|
|
|
[Fact]
|
|
public void Delete_UnknownResource_Returns404()
|
|
{
|
|
using var db = DbContextFactory.Create();
|
|
|
|
var result = BuildController(db).Delete("unknown").GetAwaiter().GetResult();
|
|
|
|
Assert.IsType<NotFoundObjectResult>(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<ObjectResult>(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);
|
|
}
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
[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());
|
|
}
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
[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<OkObjectResult>(result);
|
|
return Assert.IsType<ResourceBackfillReportDTO>(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<string, long> { ["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);
|
|
}
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
[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<string, long> { ["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<string, long> { ["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);
|
|
}
|
|
}
|
|
}
|