using BuildFlow.Domain.Interfaces; using BuildFlow.Infrastructure.Cache; using FluentAssertions; using Microsoft.Extensions.Options; using StackExchange.Redis; using Xunit; namespace BuildFlow.Rag.Tests.Chat; /// Redis List 聊天缓存的并发追加、传输粒度、顺序和 TTL 集成测试。 [Collection(RedisChatMessageCacheCollection.Name)] public sealed class RedisChatMessageCacheTests(RedisChatMessageCacheFixture fixture) { [Fact] public async Task ConcurrentAppendsPreserveEveryMessage() { var conversationId = Guid.NewGuid(); await fixture.DeleteAsync(conversationId); var cache = CreateCache(); var messages = Enumerable.Range(0, 100) .Select(index => CreateMessage(index)) .ToArray(); await Task.WhenAll(messages.Select(message => cache.AppendMessageAsync(conversationId, message, CancellationToken.None))); var stored = await cache.GetMessagesAsync(conversationId, CancellationToken.None); var database = fixture.Redis.GetDatabase(); stored.Should().NotBeNull(); var storedMessages = stored!; storedMessages.Should().HaveCount(100); storedMessages.Select(x => x.Id).Should().OnlyHaveUniqueItems(); (await database.KeyTypeAsync(RedisChatMessageCacheFixture.Key(conversationId))) .Should().Be(RedisType.List); (await database.ListLengthAsync(RedisChatMessageCacheFixture.Key(conversationId))) .Should().Be(100); } [Fact] public async Task AppendAddsOneListElementAndRefreshesConfiguredTtl() { var conversationId = Guid.NewGuid(); await fixture.DeleteAsync(conversationId); var cache = CreateCache(); var existing = Enumerable.Range(0, 500) .Select(CreateMessage) .ToList(); await cache.SetMessagesAsync(conversationId, existing, CancellationToken.None); var database = fixture.Redis.GetDatabase(); var key = RedisChatMessageCacheFixture.Key(conversationId); await cache.AppendMessageAsync( conversationId, CreateMessage(500), CancellationToken.None); (await database.KeyTypeAsync(key)).Should().Be(RedisType.List); (await database.ListLengthAsync(key)).Should().Be(501); var ttl = await database.KeyTimeToLiveAsync(key); ttl.Should().NotBeNull(); ttl!.Value.Should().BeGreaterThan(TimeSpan.FromMinutes(4)); ttl.Value.Should().BeLessThanOrEqualTo(TimeSpan.FromMinutes(5)); } [Fact] public async Task SetAndGetRoundTripCompleteHistoryInOrder() { var conversationId = Guid.NewGuid(); await fixture.DeleteAsync(conversationId); var cache = CreateCache(); var messages = Enumerable.Range(0, 20) .Select(CreateMessage) .ToList(); await cache.SetMessagesAsync(conversationId, messages, CancellationToken.None); var result = await cache.GetMessagesAsync(conversationId, CancellationToken.None); result.Should().NotBeNull(); var storedMessages = result!; storedMessages.Select(x => x.Id).Should().Equal(messages.Select(x => x.Id)); storedMessages.Select(x => x.Content).Should().Equal(messages.Select(x => x.Content)); } private ChatMessageCache CreateCache() => new( fixture.Redis, Options.Create(new ChatOptions { MaxContextMessages = 50, MaxContextTokens = 8000, CacheTtlMinutes = 5, })); private static CachedChatMessage CreateMessage(int index) => new( Guid.NewGuid(), index % 2 == 0 ? "User" : "Assistant", $"message-{index}", null, new DateTime(2026, 1, 1, 0, 0, 0, DateTimeKind.Utc).AddMinutes(index)); }