using System.Text.Json; using Jiaowu.Api.Domain.Academic; using Jiaowu.Api.Infrastructure.Caching; using Jiaowu.Api.Infrastructure.Timetables; using Microsoft.Extensions.Caching.Distributed; using Microsoft.Extensions.Caching.Memory; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.FileProviders; using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Options; namespace Jiaowu.Api.Tests; public sealed class AppCacheTests { [Fact] public void Statistics_keys_isolate_data_scope_college_and_filters() { var firstCollege = Guid.NewGuid(); var secondCollege = Guid.NewGuid(); var term = Guid.NewGuid().ToString("N"); var first = AppCacheKeys.Statistics( "grades", "College", firstCollege, term, "-"); var otherCollege = AppCacheKeys.Statistics( "grades", "College", secondCollege, term, "-"); var otherScope = AppCacheKeys.Statistics( "grades", "All", firstCollege, term, "-"); var otherFilter = AppCacheKeys.Statistics( "grades", "College", firstCollege, Guid.NewGuid().ToString("N"), "-"); Assert.NotEqual(first, otherCollege); Assert.NotEqual(first, otherScope); Assert.NotEqual(first, otherFilter); Assert.Equal( first, AppCacheKeys.Statistics( " GRADES ", "COLLEGE", firstCollege, term, null)); } [Fact] public async Task Hybrid_cache_reuses_value_and_tag_invalidation_reloads_source() { await using var provider = CreateProvider(enabled: true); var cache = provider.GetRequiredService(); var sourceCalls = 0; var key = $"test:{Guid.NewGuid():N}"; Task Load(CancellationToken _) { sourceCalls++; return Task.FromResult(sourceCalls); } var first = await cache.GetOrCreateAsync( key, Load, AppCacheProfile.ReferenceData, [AppCacheTags.BaseData], CancellationToken.None); var second = await cache.GetOrCreateAsync( key, Load, AppCacheProfile.ReferenceData, [AppCacheTags.BaseData], CancellationToken.None); Assert.Equal(1, first); Assert.Equal(1, second); Assert.Equal(1, sourceCalls); await cache.RemoveByTagAsync(AppCacheTags.BaseData); var afterInvalidation = await cache.GetOrCreateAsync( key, Load, AppCacheProfile.ReferenceData, [AppCacheTags.BaseData], CancellationToken.None); Assert.Equal(2, afterInvalidation); Assert.Equal(2, sourceCalls); } [Fact] public async Task Disabled_cache_always_uses_source() { await using var provider = CreateProvider(enabled: false); var cache = provider.GetRequiredService(); var sourceCalls = 0; Task Load(CancellationToken _) { sourceCalls++; return Task.FromResult(sourceCalls); } var first = await cache.GetOrCreateAsync( "disabled", Load, AppCacheProfile.ReferenceData, [], CancellationToken.None); var second = await cache.GetOrCreateAsync( "disabled", Load, AppCacheProfile.ReferenceData, [], CancellationToken.None); Assert.Equal(1, first); Assert.Equal(2, second); } [Fact] public async Task Timetable_data_round_trips_through_distributed_cache() { IDistributedCache distributedCache = new SharedDistributedCache( new MemoryDistributedCache( Options.Create(new MemoryDistributedCacheOptions()))); var keyPrefix = $"tests:{Guid.NewGuid():N}"; await using var writer = CreateProvider(true, distributedCache, keyPrefix); var source = CreateTimetableData(); await writer.GetRequiredService().GetOrCreateAsync( "timetable", _ => Task.FromResult(source), AppCacheProfile.PublishedTimetable, [AppCacheTags.Timetables], CancellationToken.None); await using var reader = CreateProvider(true, distributedCache, keyPrefix); var sourceCalled = false; var result = await reader.GetRequiredService().GetOrCreateAsync( "timetable", _ => { sourceCalled = true; return Task.FromResult(source); }, AppCacheProfile.PublishedTimetable, [AppCacheTags.Timetables], CancellationToken.None); Assert.False(sourceCalled); Assert.Equal(source.Term.Id, result.Term.Id); Assert.Equal(source.Plan!.Id, result.Plan!.Id); Assert.Equal("缓存测试课程", result.Entries.Single().CourseName); Assert.Equal(new TimeOnly(8, 45), result.Slots.Single().EndsAt); } [Fact] public async Task Analytics_json_round_trips_through_distributed_cache() { IDistributedCache distributedCache = new SharedDistributedCache( new MemoryDistributedCache( Options.Create(new MemoryDistributedCacheOptions()))); var keyPrefix = $"tests:{Guid.NewGuid():N}"; var source = JsonSerializer.SerializeToElement(new { totals = new { totalCourses = 3 } }); await using (var writer = CreateProvider( true, distributedCache, keyPrefix)) { await writer.GetRequiredService().GetOrCreateAsync( "statistics:courses", _ => Task.FromResult(source), AppCacheProfile.Analytics, [AppCacheTags.Analytics], CancellationToken.None); } await using var reader = CreateProvider( true, distributedCache, keyPrefix); var sourceCalled = false; var result = await reader.GetRequiredService() .GetOrCreateAsync( "statistics:courses", _ => { sourceCalled = true; return Task.FromResult(source); }, AppCacheProfile.Analytics, [AppCacheTags.Analytics], CancellationToken.None); Assert.False(sourceCalled); Assert.Equal( 3, result.GetProperty("totals") .GetProperty("totalCourses") .GetInt32()); } private static ServiceProvider CreateProvider( bool enabled, IDistributedCache? distributedCache = null, string? keyPrefix = null) { var services = new ServiceCollection(); services.AddLogging(); services.AddHybridCache(); if (distributedCache is not null) services.AddSingleton(distributedCache); services.AddSingleton(new AppCacheOptions { Enabled = enabled, KeyPrefix = keyPrefix ?? $"tests:{Guid.NewGuid():N}" }); services.AddSingleton(new TestHostEnvironment()); services.AddSingleton(); return services.BuildServiceProvider(); } private static TimetableData CreateTimetableData() { var termId = Guid.NewGuid(); var planId = Guid.NewGuid(); var taskId = Guid.NewGuid(); return new TimetableData( new TimetableTermDto( termId, "2026-2027 学年第一学期", "2026-2027", TermSeason.Autumn, new DateOnly(2026, 9, 1), new DateOnly(2027, 1, 20), true), new TimetableSubjectDto( Guid.NewGuid(), "SE202601", "软件工程 2026 级 1 班", TimetableResourceType.Class, 2026, Guid.NewGuid(), "软件工程", Guid.NewGuid(), "计算机学院", null, null, null), null, null, new TimetablePlanDto( planId, "正式课表", "V1", SchedulePlanStatus.Published, DateTime.UtcNow, DateTime.UtcNow), [ new TimetableSlotDto( 1, "第 1 节", new TimeOnly(8, 0), new TimeOnly(8, 45)) ], [ new TimetableEntryDto( Guid.NewGuid(), taskId, "TASK-001", "缓存测试教学班", "CACHE-01", "缓存测试课程", ["测试教师"], ["软件工程 2026 级 1 班"], "第一教学楼 101", "第一教学楼", "主校区", 1, 1, 2, 1, 16, WeekPattern.All, null) ], [], []); } private sealed class TestHostEnvironment : IHostEnvironment { public string EnvironmentName { get; set; } = Environments.Development; public string ApplicationName { get; set; } = nameof(AppCacheTests); public string ContentRootPath { get; set; } = AppContext.BaseDirectory; public IFileProvider ContentRootFileProvider { get; set; } = new NullFileProvider(); } private sealed class SharedDistributedCache(IDistributedCache inner) : IDistributedCache { public byte[]? Get(string key) => inner.Get(key); public Task GetAsync( string key, CancellationToken token = default) => inner.GetAsync(key, token); public void Refresh(string key) => inner.Refresh(key); public Task RefreshAsync( string key, CancellationToken token = default) => inner.RefreshAsync(key, token); public void Remove(string key) => inner.Remove(key); public Task RemoveAsync( string key, CancellationToken token = default) => inner.RemoveAsync(key, token); public void Set( string key, byte[] value, DistributedCacheEntryOptions options) => inner.Set(key, value, options); public Task SetAsync( string key, byte[] value, DistributedCacheEntryOptions options, CancellationToken token = default) => inner.SetAsync(key, value, options, token); } }