diff --git a/src/Modules/Files/Modules.Files/Jobs/PurgeDeletedFilesJob.cs b/src/Modules/Files/Modules.Files/Jobs/PurgeDeletedFilesJob.cs
index 6824fd3213..feae358297 100644
--- a/src/Modules/Files/Modules.Files/Jobs/PurgeDeletedFilesJob.cs
+++ b/src/Modules/Files/Modules.Files/Jobs/PurgeDeletedFilesJob.cs
@@ -1,20 +1,26 @@
+using Finbuckle.MultiTenant;
+using Finbuckle.MultiTenant.Abstractions;
+using FSH.Framework.Persistence;
using FSH.Framework.Quota;
+using FSH.Framework.Shared.Multitenancy;
using FSH.Framework.Shared.Quota;
using FSH.Framework.Storage.Services;
using FSH.Modules.Files.Data;
using Hangfire;
using Microsoft.EntityFrameworkCore;
+using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
namespace FSH.Modules.Files.Jobs;
///
-/// Daily purge of soft-deleted FileAsset rows past the retention window. Hard-deletes the row,
-/// removes the bytes from storage, and refunds the quota (the bytes were debited at finalize time).
+/// Daily purge of soft-deleted FileAsset rows past the retention window, in every tenant. Hard-deletes
+/// the row, removes the bytes from storage, and refunds the owning tenant's quota (the bytes were
+/// debited at finalize time).
///
public sealed class PurgeDeletedFilesJob(
- FilesDbContext db,
+ IServiceScopeFactory scopeFactory,
IStorageService storage,
IQuotaService quotas,
IOptions options,
@@ -23,60 +29,103 @@ public sealed class PurgeDeletedFilesJob(
[AutomaticRetry(Attempts = 2, DelaysInSeconds = [300, 1800])]
public async Task RunAsync(CancellationToken cancellationToken)
{
+ // The recurring job is registered without a tenant, so none is resolved. Each tenant is purged
+ // inside its own context, which also points FilesDbContext at a tenant's dedicated database.
+ List tenants;
+ using (var scope = scopeFactory.CreateScope())
+ {
+ var tenantStore = scope.ServiceProvider.GetRequiredService>();
+ tenants = (await tenantStore.GetAllAsync().ConfigureAwait(false)).ToList();
+ }
+
var cutoff = DateTimeOffset.UtcNow.AddDays(-options.Value.SoftDeleteRetentionDays);
- var candidates = await db.FileAssets
- .IgnoreQueryFilters()
- .Where(f => f.IsDeleted && f.DeletedOnUtc != null && f.DeletedOnUtc < cutoff)
- .ToListAsync(cancellationToken)
- .ConfigureAwait(false);
+ int total = 0;
+ foreach (var tenant in tenants)
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+ total += await PurgeTenantAsync(tenant, cutoff, cancellationToken).ConfigureAwait(false);
+ }
- if (candidates.Count == 0)
+ if (total > 0 && logger.IsEnabled(LogLevel.Information))
{
- return;
+ logger.LogInformation("Hard-purged {Total} soft-deleted file assets across {TenantCount} tenants",
+ total, tenants.Count);
}
+ }
- // Best-effort byte removal per file. Schema-per-tenant means all rows share one tenant,
- // and Hangfire wires the job per-tenant for multi-tenant deployments.
- foreach (var f in candidates)
+ private async Task PurgeTenantAsync(AppTenantInfo tenant, DateTimeOffset cutoff, CancellationToken cancellationToken)
+ {
+ try
{
- try
+ using var scope = scopeFactory.CreateScope();
+ scope.ServiceProvider.GetRequiredService()
+ .MultiTenantContext = new MultiTenantContext(tenant);
+ var db = scope.ServiceProvider.GetRequiredService();
+
+ // Soft-deleted rows are hidden by the soft-delete filter; lift only that one so the tenant
+ // filter stays on.
+ var candidates = await db.FileAssets
+ .IgnoreQueryFilters([QueryFilters.SoftDelete])
+ .Where(f => f.IsDeleted && f.DeletedOnUtc != null && f.DeletedOnUtc < cutoff)
+ .ToListAsync(cancellationToken)
+ .ConfigureAwait(false);
+
+ if (candidates.Count == 0)
{
- await storage.RemoveAsync(f.StorageKey, cancellationToken).ConfigureAwait(false);
+ return 0;
}
- catch (Exception ex)
+
+ // Best-effort byte removal per file.
+ foreach (var f in candidates)
{
- logger.LogWarning(ex, "Storage remove failed for {Key}", f.StorageKey);
+ try
+ {
+ await storage.RemoveAsync(f.StorageKey, cancellationToken).ConfigureAwait(false);
+ }
+ catch (Exception ex) when (ex is not OperationCanceledException)
+ {
+ logger.LogWarning(ex, "Storage remove failed for {Key} for tenant {TenantId}",
+ f.StorageKey, tenant.Id);
+ }
}
- }
- // Quota refund — group bytes once per tenant. In schema-per-tenant the resolved tenant
- // matches every row's logical tenant; the framework's QuotaService is tenant-scoped via DI.
- var totalBytes = candidates.Sum(f => f.SizeBytes);
- if (totalBytes > 0)
- {
- // Empty tenant id satisfies the contract; QuotaService resolves the tenant from DI.
- // Falls back gracefully with no tenant (the refund is simply lost).
- try
+ var ids = candidates.Select(f => f.Id).ToList();
+ await db.FileAssets
+ .IgnoreQueryFilters([QueryFilters.SoftDelete])
+ .Where(f => ids.Contains(f.Id))
+ .ExecuteDeleteAsync(cancellationToken)
+ .ConfigureAwait(false);
+
+ // Refund the bytes debited at finalize, under the tenant that owns them. Done after the
+ // rows are gone so a failed delete can't refund bytes that are still counted as stored.
+ var totalBytes = candidates.Sum(f => f.SizeBytes);
+ if (totalBytes > 0)
{
- await quotas.RecordAsync("", QuotaResource.StorageBytes, -totalBytes, cancellationToken).ConfigureAwait(false);
+ try
+ {
+ await quotas.RecordAsync(tenant.Id, QuotaResource.StorageBytes, -totalBytes, cancellationToken)
+ .ConfigureAwait(false);
+ }
+ catch (Exception ex) when (ex is not OperationCanceledException)
+ {
+ logger.LogWarning(ex, "Quota refund of {Bytes} bytes failed for tenant {TenantId}",
+ totalBytes, tenant.Id);
+ }
}
- catch (Exception ex)
+
+ if (logger.IsEnabled(LogLevel.Information))
{
- logger.LogWarning(ex, "Quota refund failed for {Bytes} bytes", totalBytes);
+ logger.LogInformation("Hard-purged {Count} soft-deleted file assets ({Bytes} bytes) for tenant {TenantId}",
+ candidates.Count, totalBytes, tenant.Id);
}
- }
- var ids = candidates.Select(f => f.Id).ToList();
- await db.FileAssets
- .IgnoreQueryFilters()
- .Where(f => ids.Contains(f.Id))
- .ExecuteDeleteAsync(cancellationToken)
- .ConfigureAwait(false);
-
- if (logger.IsEnabled(LogLevel.Information))
+ return candidates.Count;
+ }
+ catch (Exception ex) when (ex is not OperationCanceledException)
{
- logger.LogInformation("Hard-purged {Count} soft-deleted file assets ({Bytes} bytes total)",
- candidates.Count, totalBytes);
+ // One tenant's database being unreachable must not keep the other tenants' files around.
+ logger.LogError(ex, "Deleted file purge failed for tenant {TenantId}", tenant.Id);
+ return 0;
}
}
}
diff --git a/src/Modules/Files/Modules.Files/Jobs/PurgeOrphanedFilesJob.cs b/src/Modules/Files/Modules.Files/Jobs/PurgeOrphanedFilesJob.cs
index b280ffd585..bdfcc7ab79 100644
--- a/src/Modules/Files/Modules.Files/Jobs/PurgeOrphanedFilesJob.cs
+++ b/src/Modules/Files/Modules.Files/Jobs/PurgeOrphanedFilesJob.cs
@@ -1,65 +1,114 @@
+using Finbuckle.MultiTenant;
+using Finbuckle.MultiTenant.Abstractions;
+using FSH.Framework.Persistence;
+using FSH.Framework.Shared.Multitenancy;
using FSH.Framework.Storage.Services;
using FSH.Modules.Files.Contracts.v1.DTOs;
using FSH.Modules.Files.Data;
using FSH.Modules.Files.Domain;
using Hangfire;
using Microsoft.EntityFrameworkCore;
+using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
namespace FSH.Modules.Files.Jobs;
///
-/// Hourly purge of FileAsset rows stuck in PendingUpload past their UploadDeadline. Best-effort
-/// removal of any bytes that did make it to storage. No quota effect — those bytes were never
-/// debited.
+/// Hourly purge of FileAsset rows stuck in PendingUpload past their UploadDeadline, in every tenant.
+/// Best-effort removal of any bytes that did make it to storage. No quota effect — those bytes were
+/// never debited.
///
public sealed class PurgeOrphanedFilesJob(
- FilesDbContext db,
+ IServiceScopeFactory scopeFactory,
IStorageService storage,
ILogger logger)
{
[AutomaticRetry(Attempts = 3, DelaysInSeconds = [30, 120, 600])]
public async Task RunAsync(CancellationToken cancellationToken)
{
+ // The recurring job is registered without a tenant, so none is resolved. Each tenant is purged
+ // inside its own context, which also points FilesDbContext at a tenant's dedicated database.
+ List tenants;
+ using (var scope = scopeFactory.CreateScope())
+ {
+ var tenantStore = scope.ServiceProvider.GetRequiredService>();
+ tenants = (await tenantStore.GetAllAsync().ConfigureAwait(false)).ToList();
+ }
+
var now = DateTimeOffset.UtcNow;
- var orphans = await db.FileAssets
- .IgnoreQueryFilters()
- .Where(f => f.Status == FileAssetStatus.PendingUpload
- && f.UploadDeadline != null
- && f.UploadDeadline < now)
- .ToListAsync(cancellationToken)
- .ConfigureAwait(false);
+ int total = 0;
+ foreach (var tenant in tenants)
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+ total += await PurgeTenantAsync(tenant, now, cancellationToken).ConfigureAwait(false);
+ }
- if (orphans.Count == 0)
+ if (total > 0 && logger.IsEnabled(LogLevel.Information))
{
- return;
+ logger.LogInformation("Purged {Total} orphaned file assets across {TenantCount} tenants",
+ total, tenants.Count);
}
+ }
- foreach (var f in orphans)
+ private async Task PurgeTenantAsync(AppTenantInfo tenant, DateTimeOffset now, CancellationToken cancellationToken)
+ {
+ try
{
- try
+ using var scope = scopeFactory.CreateScope();
+ scope.ServiceProvider.GetRequiredService()
+ .MultiTenantContext = new MultiTenantContext(tenant);
+ var db = scope.ServiceProvider.GetRequiredService();
+
+ // Only the soft-delete filter is lifted; the tenant filter stays on.
+ var orphans = await db.FileAssets
+ .IgnoreQueryFilters([QueryFilters.SoftDelete])
+ .Where(f => f.Status == FileAssetStatus.PendingUpload
+ && f.UploadDeadline != null
+ && f.UploadDeadline < now)
+ .ToListAsync(cancellationToken)
+ .ConfigureAwait(false);
+
+ if (orphans.Count == 0)
{
- await storage.RemoveAsync(f.StorageKey, cancellationToken).ConfigureAwait(false);
+ return 0;
}
- catch (Exception ex)
+
+ foreach (var f in orphans)
{
- logger.LogWarning(ex, "Failed to remove orphan storage object {Key}", f.StorageKey);
+ try
+ {
+ await storage.RemoveAsync(f.StorageKey, cancellationToken).ConfigureAwait(false);
+ }
+ catch (Exception ex) when (ex is not OperationCanceledException)
+ {
+ logger.LogWarning(ex, "Failed to remove orphan storage object {Key} for tenant {TenantId}",
+ f.StorageKey, tenant.Id);
+ }
}
- // Hard delete (row never reached Available, so soft-delete doesn't apply). FileAsset is ISoftDeletable,
- // so Remove() would become UPDATE IsDeleted=true — we use the bulk ExecuteDelete below to bypass the interceptor instead.
- }
- // Bulk hard delete — bypasses the soft-delete interceptor.
- var ids = orphans.Select(f => f.Id).ToList();
- await db.FileAssets
- .IgnoreQueryFilters()
- .Where(f => ids.Contains(f.Id))
- .ExecuteDeleteAsync(cancellationToken)
- .ConfigureAwait(false);
+ // Hard delete (the row never reached Available, so soft-delete doesn't apply). FileAsset is
+ // ISoftDeletable, so Remove() would become UPDATE IsDeleted=true — the bulk ExecuteDelete
+ // bypasses the interceptor instead.
+ var ids = orphans.Select(f => f.Id).ToList();
+ await db.FileAssets
+ .IgnoreQueryFilters([QueryFilters.SoftDelete])
+ .Where(f => ids.Contains(f.Id))
+ .ExecuteDeleteAsync(cancellationToken)
+ .ConfigureAwait(false);
- if (logger.IsEnabled(LogLevel.Information))
+ if (logger.IsEnabled(LogLevel.Information))
+ {
+ logger.LogInformation("Purged {Count} orphaned file assets for tenant {TenantId}",
+ orphans.Count, tenant.Id);
+ }
+
+ return orphans.Count;
+ }
+ catch (Exception ex) when (ex is not OperationCanceledException)
{
- logger.LogInformation("Purged {Count} orphaned file assets", orphans.Count);
+ // One tenant's database being unreachable must not keep the other tenants' orphans around.
+ logger.LogError(ex, "Orphaned file purge failed for tenant {TenantId}", tenant.Id);
+ return 0;
}
}
}
diff --git a/src/Tests/Integration.Tests/Tests/Files/PurgeJobsPerTenantTests.cs b/src/Tests/Integration.Tests/Tests/Files/PurgeJobsPerTenantTests.cs
new file mode 100644
index 0000000000..ea08ae591a
--- /dev/null
+++ b/src/Tests/Integration.Tests/Tests/Files/PurgeJobsPerTenantTests.cs
@@ -0,0 +1,271 @@
+using System.Security.Cryptography;
+using Finbuckle.MultiTenant;
+using Finbuckle.MultiTenant.Abstractions;
+using FSH.Framework.Quota;
+using FSH.Framework.Shared.Multitenancy;
+using FSH.Framework.Shared.Quota;
+using FSH.Framework.Storage.Services;
+using FSH.Modules.Files.Contracts.v1.DTOs;
+using FSH.Modules.Files.Data;
+using FSH.Modules.Files.Jobs;
+using Integration.Tests.Infrastructure;
+using Integration.Tests.Infrastructure.Extensions;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.Extensions.Configuration;
+using Microsoft.Extensions.DependencyInjection;
+using Npgsql;
+
+namespace Integration.Tests.Tests.Files;
+
+///
+/// The Files purge jobs are Hangfire recurring jobs registered without a tenant, so they run with no
+/// ambient tenant. Purgeable files are seeded in three tenants — root, a tenant on the shared database
+/// and a tenant with its own dedicated database — and each job is resolved from a fresh, tenant-less
+/// scope (the shape Hangfire's activator gives it) and run once. Every tenant's rows and blobs must be
+/// purged, and the deleted-files purge must refund each tenant's storage quota under that tenant's id.
+///
+[Collection(FshCollectionDefinition.Name)]
+public sealed class PurgeJobsPerTenantTests
+{
+ private const string FilesBasePath = "/api/v1/files";
+ private const int FileSize = 256;
+
+ private readonly FshWebApplicationFactory _factory;
+ private readonly AuthHelper _auth;
+
+ public PurgeJobsPerTenantTests(FshWebApplicationFactory factory)
+ {
+ _factory = factory;
+ _auth = new AuthHelper(factory);
+ }
+
+ [Fact]
+ public async Task PurgeDeletedFilesJob_Should_PurgeAndRefundQuota_InEveryTenant_IncludingDedicatedDatabase()
+ {
+ // Arrange
+ var tenants = await CreateTenantClientsAsync("files-del");
+ var quotaStore = new InMemoryQuotaStore();
+ var quotas = CreateQuotaService(quotaStore);
+
+ var seeded = new List<(string TenantId, Guid Id, string Key)>();
+ foreach (var (tenantId, client) in tenants)
+ {
+ var (id, key) = await UploadFinalizeAndDeleteAsync(tenantId, client, "purge-me.pdf");
+ await BackdateAsync(tenantId, id, deletedOnUtc: DateTimeOffset.UtcNow.AddDays(-90), uploadDeadline: null);
+
+ // Quota is disabled in the harness, so the finalize debit is replayed here against the
+ // in-memory counter the job is handed.
+ await quotas.RecordAsync(tenantId, QuotaResource.StorageBytes, FileSize);
+ seeded.Add((tenantId, id, key));
+ }
+
+ // Act — fresh scope, no tenant set, as FshJobActivator gives a job registered via AddOrUpdate.
+ using (var jobScope = _factory.Services.CreateScope())
+ {
+ var job = ActivatorUtilities.CreateInstance(jobScope.ServiceProvider, quotas);
+ await job.RunAsync(CancellationToken.None);
+ }
+
+ // Assert
+ foreach (var (tenantId, id, key) in seeded)
+ {
+ (await RowExistsIgnoringFiltersAsync(tenantId, id))
+ .ShouldBeFalse($"soft-deleted file past retention in tenant '{tenantId}' must be hard-purged");
+ (await ObjectExistsAsync(key))
+ .ShouldBeFalse($"storage object of the purged file in tenant '{tenantId}' must be removed");
+ (await quotas.GetCurrentAsync(tenantId, QuotaResource.StorageBytes))
+ .ShouldBe(0, $"the purged bytes must be refunded to tenant '{tenantId}'");
+ }
+
+ quotaStore.Counters.Keys.ShouldNotContain(k => k.StartsWith("quota::", StringComparison.Ordinal),
+ "no refund may be recorded against an empty tenant id");
+ }
+
+ [Fact]
+ public async Task PurgeOrphanedFilesJob_Should_PurgeExpiredPendingUploads_InEveryTenant_IncludingDedicatedDatabase()
+ {
+ // Arrange
+ var tenants = await CreateTenantClientsAsync("files-orph");
+
+ var seeded = new List<(string TenantId, Guid Id, string Key)>();
+ foreach (var (tenantId, client) in tenants)
+ {
+ var (id, key) = await RequestUploadAndPutBytesAsync(tenantId, client, "orphan.pdf");
+ await BackdateAsync(tenantId, id, deletedOnUtc: null, uploadDeadline: DateTimeOffset.UtcNow.AddHours(-2));
+ seeded.Add((tenantId, id, key));
+ }
+
+ // Act — fresh scope, no tenant set.
+ using (var jobScope = _factory.Services.CreateScope())
+ {
+ var job = ActivatorUtilities.CreateInstance(jobScope.ServiceProvider);
+ await job.RunAsync(CancellationToken.None);
+ }
+
+ // Assert
+ foreach (var (tenantId, id, key) in seeded)
+ {
+ (await RowExistsIgnoringFiltersAsync(tenantId, id))
+ .ShouldBeFalse($"expired pending upload in tenant '{tenantId}' must be hard-purged");
+ (await ObjectExistsAsync(key))
+ .ShouldBeFalse($"orphaned storage object in tenant '{tenantId}' must be removed");
+ }
+ }
+
+ // ─── tenants ─────────────────────────────────────────────────────
+
+ /// Root, a shared-database tenant and a dedicated-database tenant, each with an admin client.
+ private async Task> CreateTenantClientsAsync(string prefix)
+ {
+ using var rootClient = await _auth.CreateRootAdminClientAsync();
+ var uniqueId = Guid.NewGuid().ToString("N")[..8];
+
+ var sharedTenantId = $"{prefix}-shared-{uniqueId}";
+ var dedicatedTenantId = $"{prefix}-dedicated-{uniqueId}";
+
+ var defaultConnection = _factory.Services.GetRequiredService()["DatabaseOptions:ConnectionString"];
+ defaultConnection.ShouldNotBeNullOrWhiteSpace();
+ var dedicatedConnection = new NpgsqlConnectionStringBuilder(defaultConnection)
+ {
+ Database = $"fsh_{prefix.Replace('-', '_')}_{uniqueId}",
+ }.ConnectionString;
+
+ await CreateTenantAsync(rootClient, sharedTenantId, connectionString: null);
+ await CreateTenantAsync(rootClient, dedicatedTenantId, dedicatedConnection);
+ await TenantProvisioningWait.WaitForProvisioningAsync(rootClient, sharedTenantId);
+ await TenantProvisioningWait.WaitForProvisioningAsync(rootClient, dedicatedTenantId);
+
+ return
+ [
+ (TestConstants.RootTenantId, await _auth.CreateRootAdminClientAsync()),
+ (sharedTenantId, await _auth.CreateAuthenticatedClientAsync(AdminEmail(sharedTenantId), TestConstants.DefaultPassword, sharedTenantId)),
+ (dedicatedTenantId, await _auth.CreateAuthenticatedClientAsync(AdminEmail(dedicatedTenantId), TestConstants.DefaultPassword, dedicatedTenantId)),
+ ];
+ }
+
+ private static string AdminEmail(string tenantId) => $"admin-{tenantId}@tenant.com";
+
+ private static async Task CreateTenantAsync(HttpClient rootClient, string tenantId, string? connectionString)
+ {
+ using var response = await rootClient.PostAsJsonAsync(TestConstants.TenantsBasePath, new
+ {
+ id = tenantId,
+ name = $"Tenant {tenantId}",
+ connectionString,
+ adminEmail = AdminEmail(tenantId),
+ adminPassword = TestConstants.DefaultPassword,
+ issuer = $"{tenantId}.issuer"
+ });
+ var body = await response.Content.ReadAsStringAsync();
+ response.StatusCode.ShouldBe(HttpStatusCode.Created, $"Create tenant failed: {body}");
+ }
+
+ private static InMemoryQuotaService CreateQuotaService(InMemoryQuotaStore store)
+ {
+ var options = new QuotaOptions { Enabled = true };
+ return new InMemoryQuotaService(store, options, new QuotaPlanResolver(options), [], TimeProvider.System);
+ }
+
+ // ─── files ───────────────────────────────────────────────────────
+
+ private async Task<(Guid Id, string StorageKey)> UploadFinalizeAndDeleteAsync(
+ string tenantId, HttpClient client, string fileName)
+ {
+ var (id, storageKey) = await RequestUploadAndPutBytesAsync(tenantId, client, fileName);
+
+ using var finalize = await client.PostAsync($"{FilesBasePath}/{id}/finalize", null);
+ finalize.EnsureSuccessStatusCode();
+
+ using var del = await client.DeleteAsync($"{FilesBasePath}/{id}");
+ del.StatusCode.ShouldBe(HttpStatusCode.NoContent);
+
+ return (id, storageKey);
+ }
+
+ private async Task<(Guid Id, string StorageKey)> RequestUploadAndPutBytesAsync(
+ string tenantId, HttpClient client, string fileName)
+ {
+ using var response = await client.PostAsJsonAsync($"{FilesBasePath}/upload-url", new
+ {
+ ownerType = "MyFiles",
+ ownerId = (Guid?)null,
+ fileName,
+ contentType = "application/pdf",
+ sizeBytes = FileSize,
+ visibility = 1,
+ category = "Document",
+ });
+ var body = await response.Content.ReadAsStringAsync();
+ response.StatusCode.ShouldBe(HttpStatusCode.OK, $"upload-url failed in tenant '{tenantId}': {body}");
+ var presigned = await response.DeserializeAsync();
+
+ byte[] bytes = new byte[FileSize];
+ RandomNumberGenerator.Fill(bytes);
+ using var raw = new HttpClient();
+ using var put = new HttpRequestMessage(HttpMethod.Put, presigned.UploadUrl)
+ {
+ Content = new ByteArrayContent(bytes)
+ {
+ Headers = { ContentType = new MediaTypeHeaderValue("application/pdf") }
+ }
+ };
+ using var putResp = await raw.SendAsync(put);
+ putResp.EnsureSuccessStatusCode();
+
+ var storageKey = await ReadStorageKeyAsync(tenantId, presigned.FileAssetId);
+ return (presigned.FileAssetId, storageKey);
+ }
+
+ // Tenant context is an AsyncLocal, so it is set in the same method as each DbContext call.
+
+ private async Task ReadStorageKeyAsync(string tenantId, Guid id)
+ {
+ using var scope = _factory.Services.CreateScope();
+ var tenant = await scope.ServiceProvider.GetRequiredService>().GetAsync(tenantId);
+ tenant.ShouldNotBeNull();
+ scope.ServiceProvider.GetRequiredService().MultiTenantContext =
+ new MultiTenantContext(tenant);
+
+ var db = scope.ServiceProvider.GetRequiredService();
+ var key = await db.FileAssets.IgnoreQueryFilters()
+ .Where(f => f.Id == id)
+ .Select(f => f.StorageKey)
+ .FirstOrDefaultAsync();
+ key.ShouldNotBeNull();
+ return key;
+ }
+
+ private async Task BackdateAsync(string tenantId, Guid id, DateTimeOffset? deletedOnUtc, DateTimeOffset? uploadDeadline)
+ {
+ using var scope = _factory.Services.CreateScope();
+ var tenant = await scope.ServiceProvider.GetRequiredService>().GetAsync(tenantId);
+ tenant.ShouldNotBeNull();
+ scope.ServiceProvider.GetRequiredService().MultiTenantContext =
+ new MultiTenantContext(tenant);
+
+ var db = scope.ServiceProvider.GetRequiredService();
+ var query = db.FileAssets.IgnoreQueryFilters().Where(f => f.Id == id);
+ int updated = deletedOnUtc is { } deleted
+ ? await query.ExecuteUpdateAsync(s => s.SetProperty(f => f.DeletedOnUtc, deleted))
+ : await query.ExecuteUpdateAsync(s => s.SetProperty(f => f.UploadDeadline, uploadDeadline));
+ updated.ShouldBe(1);
+ }
+
+ private async Task RowExistsIgnoringFiltersAsync(string tenantId, Guid id)
+ {
+ using var scope = _factory.Services.CreateScope();
+ var tenant = await scope.ServiceProvider.GetRequiredService>().GetAsync(tenantId);
+ tenant.ShouldNotBeNull();
+ scope.ServiceProvider.GetRequiredService().MultiTenantContext =
+ new MultiTenantContext(tenant);
+
+ var db = scope.ServiceProvider.GetRequiredService();
+ return await db.FileAssets.IgnoreQueryFilters().AnyAsync(f => f.Id == id);
+ }
+
+ private async Task ObjectExistsAsync(string storageKey)
+ {
+ using var scope = _factory.Services.CreateScope();
+ return await scope.ServiceProvider.GetRequiredService().ExistsAsync(storageKey);
+ }
+}