mirror of
https://github.com/SoPat712/allstarr.git
synced 2026-10-08 06:05:02 -04:00
235 lines
10 KiB
C#
235 lines
10 KiB
C#
using allstarr.Controllers;
|
|
using allstarr.Core.Operations;
|
|
using allstarr.Core.Storage;
|
|
using allstarr.Services.Admin;
|
|
using allstarr.Services.Common;
|
|
using System.Text.Json;
|
|
using Microsoft.AspNetCore.Http;
|
|
using Microsoft.AspNetCore.Mvc;
|
|
using Microsoft.EntityFrameworkCore;
|
|
using Microsoft.Extensions.Logging.Abstractions;
|
|
|
|
namespace allstarr.Tests;
|
|
|
|
public sealed class CacheDiagnosticsTests : IAsyncLifetime
|
|
{
|
|
private PostgresTestDatabase _database = null!;
|
|
private readonly string _mediaPath = Path.Combine(
|
|
Path.GetTempPath(),
|
|
$"allstarr-cache-diagnostics-media-{Guid.CreateVersion7():N}");
|
|
private HybridApplicationCache _cache = null!;
|
|
private BoundedHotApplicationCache _hot = null!;
|
|
private FileMediaApplicationCache _media = null!;
|
|
private TestClock _clock = null!;
|
|
private readonly ApplicationCacheActivityMetrics _activity = new();
|
|
|
|
public async Task InitializeAsync()
|
|
{
|
|
_database = await PostgresTestDatabase.CreateAsync();
|
|
var factory = new TestFactory(_database.Options);
|
|
_clock = new TestClock(
|
|
new DateTimeOffset(2026, 7, 24, 12, 0, 0, TimeSpan.Zero));
|
|
var database = new DatabaseApplicationCache(
|
|
factory,
|
|
_clock,
|
|
NullLogger<DatabaseApplicationCache>.Instance);
|
|
_hot = new BoundedHotApplicationCache(database);
|
|
_media = new FileMediaApplicationCache(
|
|
new FileMediaCacheOptions(_mediaPath),
|
|
_clock,
|
|
NullLogger<FileMediaApplicationCache>.Instance);
|
|
_cache = new HybridApplicationCache(
|
|
_hot,
|
|
_media,
|
|
activityMetrics: _activity);
|
|
|
|
}
|
|
|
|
[Fact]
|
|
public async Task CategoryPolicySuppliesDefaultExpiry()
|
|
{
|
|
const string key = "playlist:discovery:v2:global:shared:00000000000000000000000000000000:1:fixture:digest";
|
|
Assert.True(await _cache.SetStringAsync(key, "{}"));
|
|
|
|
await using var context = new AllstarrDbContext(_database.Options);
|
|
var entry = await context.Set<ApplicationCacheEntryRecord>().SingleAsync(item => item.Key == key);
|
|
Assert.Equal(ApplicationCacheCategory.PlaylistDiscovery.ToString(), entry.Category);
|
|
Assert.Equal(_clock.UtcNow.AddMinutes(5), entry.ExpiresAt);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Snapshot_ReportsEveryTierAndScopedPurgesStayIsolated()
|
|
{
|
|
Assert.True(await _cache.SetStringAsync("odesli:translate:v2:track-1:spotify", "metadata"));
|
|
Assert.True(await _cache.SetStringAsync("artwork:payload:v1:track-1", "media"));
|
|
|
|
var snapshot = await _cache.GetDiagnosticsAsync();
|
|
Assert.Equal(1, snapshot.Database.EntryCount);
|
|
Assert.Equal(1, snapshot.Hot.EntryCount);
|
|
Assert.Equal(1, snapshot.Media.EntryCount);
|
|
Assert.Equal(8, snapshot.Database.PayloadBytes);
|
|
Assert.Equal(8, snapshot.Hot.PayloadBytes);
|
|
Assert.Equal(5, snapshot.Media.PayloadBytes);
|
|
Assert.Equal(1, snapshot.Database.Writes);
|
|
Assert.Equal(1, snapshot.Hot.Writes);
|
|
Assert.Equal(1, snapshot.Media.Writes);
|
|
_activity.RecordCoalesced();
|
|
_activity.RecordStaleServe();
|
|
_activity.RecordUpstreamBytesAvoided(128);
|
|
snapshot = await _cache.GetDiagnosticsAsync();
|
|
Assert.Equal(1, snapshot.Activity.CoalescedRequests);
|
|
Assert.Equal(1, snapshot.Activity.StaleServes);
|
|
Assert.Equal(128, snapshot.Activity.UpstreamBytesAvoided);
|
|
Assert.Equal(16 * 1024 * 1024, snapshot.ArtworkLimits.MaximumEntryBytes);
|
|
Assert.Equal(16_000_000, snapshot.ArtworkLimits.MaximumDecodedPixels);
|
|
var metadataCategory = Assert.Single(
|
|
snapshot.Categories,
|
|
item => item.Category == ApplicationCacheCategory.ProviderResponse.ToString());
|
|
Assert.True(metadataCategory.Enabled);
|
|
Assert.Equal(1, metadataCategory.EntryCount);
|
|
Assert.Equal(8, metadataCategory.PayloadBytes);
|
|
var artworkCategory = Assert.Single(
|
|
snapshot.Categories,
|
|
item => item.Category == ApplicationCacheCategory.Artwork.ToString());
|
|
Assert.True(artworkCategory.Enabled);
|
|
Assert.Equal(1, artworkCategory.EntryCount);
|
|
Assert.Equal(5, artworkCategory.PayloadBytes);
|
|
|
|
Assert.Equal(1, await _cache.PurgeMediaAsync());
|
|
Assert.Equal("metadata", await _cache.GetStringAsync("odesli:translate:v2:track-1:spotify"));
|
|
Assert.Null(await _cache.GetStringAsync("artwork:payload:v1:track-1"));
|
|
snapshot = await _cache.GetDiagnosticsAsync();
|
|
Assert.Equal(1, snapshot.Hot.Hits);
|
|
Assert.Equal(1, snapshot.Media.Misses);
|
|
|
|
Assert.True(await _cache.SetStringAsync("artwork:payload:v1:track-2", "media"));
|
|
Assert.Equal(1, await _cache.PurgeMetadataAsync());
|
|
Assert.Null(await _cache.GetStringAsync("odesli:translate:v2:track-1:spotify"));
|
|
Assert.Equal("media", await _cache.GetStringAsync("artwork:payload:v1:track-2"));
|
|
|
|
Assert.Equal(1, await _cache.PurgeAllAsync());
|
|
Assert.Null(await _cache.GetStringAsync("artwork:payload:v1:track-2"));
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Controller_RequiresAdministratorAndRejectsArbitraryScopes()
|
|
{
|
|
var controller = new CacheDiagnosticsController(_cache)
|
|
{
|
|
ControllerContext = new ControllerContext
|
|
{
|
|
HttpContext = new DefaultHttpContext()
|
|
}
|
|
};
|
|
|
|
Assert.IsType<UnauthorizedObjectResult>(await controller.Get());
|
|
|
|
controller.HttpContext.Items[AdminAuthSessionService.HttpContextSessionItemKey] =
|
|
Session(isAdministrator: false);
|
|
var forbidden = Assert.IsType<ObjectResult>(await controller.Get());
|
|
Assert.Equal(StatusCodes.Status403Forbidden, forbidden.StatusCode);
|
|
|
|
controller.HttpContext.Items[AdminAuthSessionService.HttpContextSessionItemKey] =
|
|
Session(isAdministrator: true);
|
|
var ok = Assert.IsType<OkObjectResult>(await controller.Get());
|
|
Assert.Equal(
|
|
0,
|
|
Assert.IsType<ApplicationCacheDiagnosticsSnapshot>(ok.Value)
|
|
.ExtensionStorage.ActiveExtensions);
|
|
Assert.IsType<BadRequestObjectResult>(await controller.Purge("arbitrary:*"));
|
|
|
|
Assert.True(await _cache.SetStringAsync("lyrics:v2:fixture", "lyrics"));
|
|
Assert.True(await _cache.SetStringAsync("odesli:translate:v2:fixture:spotify", "provider"));
|
|
Assert.IsType<OkObjectResult>(await controller.PurgeCategory("lyrics"));
|
|
Assert.Null(await _cache.GetStringAsync("lyrics:v2:fixture"));
|
|
Assert.Equal("provider", await _cache.GetStringAsync("odesli:translate:v2:fixture:spotify"));
|
|
Assert.IsType<BadRequestObjectResult>(await controller.PurgeCategory("not-a-category"));
|
|
Assert.IsType<BadRequestObjectResult>(await controller.PurgeCategory("0"));
|
|
}
|
|
|
|
[Fact]
|
|
public async Task MaintenancePreviewAndRunCoverMetadataAndMediaTiers()
|
|
{
|
|
Assert.True(await _cache.SetStringAsync(
|
|
"search:v2:expired",
|
|
"metadata",
|
|
TimeSpan.FromMinutes(1)));
|
|
Assert.True(await _cache.SetStringAsync(
|
|
"artwork:payload:v1:expired",
|
|
"media",
|
|
TimeSpan.FromMinutes(1)));
|
|
_clock.UtcNow = _clock.UtcNow.AddMinutes(2);
|
|
|
|
var preview = await _cache.PreviewMaintenanceAsync();
|
|
Assert.Equal(1, preview.Metadata.ExpiredEntries);
|
|
Assert.Equal(1, preview.Media.ExpiredEntries);
|
|
|
|
Assert.Equal(2, await _cache.CleanupAsync());
|
|
Assert.Equal(0, (await _cache.PreviewMaintenanceAsync()).Metadata.ExpiredEntries);
|
|
Assert.Equal(0, (await _cache.PreviewMaintenanceAsync()).Media.ExpiredEntries);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task MaintenanceRemovesOnlyUnreferencedAgedArtworkPayloads()
|
|
{
|
|
var referencedKey = CacheKeyBuilder.BuildMediaAssetPayloadKey(new string('a', 64));
|
|
var orphanedKey = CacheKeyBuilder.BuildMediaAssetPayloadKey(new string('b', 64));
|
|
var descriptorKey = CacheKeyBuilder.BuildMediaAssetDescriptorKey(new(
|
|
null, null, null, "jellyfin", "playlist", "playlist-1", "revision-1"));
|
|
Assert.True(await _cache.SetStringAsync(referencedKey, "referenced"));
|
|
Assert.True(await _cache.SetStringAsync(orphanedKey, "orphaned"));
|
|
Assert.True(await _cache.SetStringAsync(
|
|
descriptorKey,
|
|
JsonSerializer.Serialize(new { PayloadKey = referencedKey })));
|
|
_clock.UtcNow = _clock.UtcNow.AddMinutes(6);
|
|
|
|
var preview = await _cache.PreviewMaintenanceAsync();
|
|
Assert.Equal(1, preview.UnreferencedArtworkPayloads);
|
|
Assert.False(preview.ArtworkReferenceScanLimitReached);
|
|
|
|
var maintenance = new ApplicationCacheMaintenanceService(
|
|
_cache,
|
|
NullLogger<ApplicationCacheMaintenanceService>.Instance);
|
|
Assert.Equal(1, await maintenance.RunOnceAsync());
|
|
Assert.Equal("referenced", await _cache.GetStringAsync(referencedKey));
|
|
Assert.Null(await _cache.GetStringAsync(orphanedKey));
|
|
}
|
|
|
|
public async Task DisposeAsync()
|
|
{
|
|
_hot.Dispose();
|
|
_media.Dispose();
|
|
|
|
if (Directory.Exists(_mediaPath))
|
|
{
|
|
Directory.Delete(_mediaPath, recursive: true);
|
|
}
|
|
if (_database is not null) await _database.DisposeAsync();
|
|
}
|
|
|
|
private static AdminAuthSession Session(bool isAdministrator) => new()
|
|
{
|
|
SessionId = "session",
|
|
UserId = "backend-user",
|
|
UserName = "tester",
|
|
IsAdministrator = isAdministrator,
|
|
JellyfinAccessToken = "token",
|
|
ExpiresAtUtc = DateTime.UtcNow.AddHours(1)
|
|
};
|
|
|
|
private sealed class TestFactory(DbContextOptions<AllstarrDbContext> options)
|
|
: IDbContextFactory<AllstarrDbContext>
|
|
{
|
|
public AllstarrDbContext CreateDbContext() => new(options);
|
|
|
|
public Task<AllstarrDbContext> CreateDbContextAsync(
|
|
CancellationToken cancellationToken = default) =>
|
|
Task.FromResult(CreateDbContext());
|
|
}
|
|
|
|
private sealed class TestClock(DateTimeOffset now) : IPlatformClock
|
|
{
|
|
public DateTimeOffset UtcNow { get; set; } = now;
|
|
}
|
|
}
|