using MessageService.Domain.Entities; using MessageService.Domain.Enums; using MessageService.Infrastructure; using MessageService.Infrastructure.Reposities; using MessageService.Domain.KeyObjects; using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.Infrastructure; using Microsoft.EntityFrameworkCore.Migrations; using MySqlConnector; using Testcontainers.MySql; using Xunit; namespace MessageService.Tests; public sealed class ConversationUniquenessTests : IAsyncLifetime { private readonly MySqlContainer mysql = new MySqlBuilder("mysql:8.0").Build(); public Task InitializeAsync() => string.Equals(Environment.GetEnvironmentVariable("RUN_DOCKER_TESTS"), "1", StringComparison.Ordinal) ? mysql.StartAsync() : Task.CompletedTask; public Task DisposeAsync() => mysql.DisposeAsync().AsTask(); [DockerFact] [Trait("Category", "Docker")] public async Task Migration_deduplicates_active_rows_and_enforces_active_uniqueness() { await using var db = CreateContext(); var migrator = db.Database.GetService(); await migrator.MigrateAsync("20260909000100_ApiAlignmentFixes"); var userId = Guid.NewGuid(); var targetId = Guid.NewGuid(); await InsertConversationAsync(db, Guid.NewGuid(), userId, targetId, 2, 10, new DateTimeOffset(2026, 1, 1, 0, 0, 0, TimeSpan.Zero)); await InsertConversationAsync(db, Guid.NewGuid(), userId, targetId, 7, 15, new DateTimeOffset(2026, 2, 1, 0, 0, 0, TimeSpan.Zero)); await migrator.MigrateAsync(); var all = await db.Conversations.IgnoreQueryFilters().AsNoTracking().ToListAsync(); var active = all.Single(x => !x.IsDeleted); Assert.Equal(2, all.Count); Assert.Equal(7, active.UnreadCount); Assert.Equal(15, active.LastReadSequenceId); await Assert.ThrowsAsync(() => InsertConversationAsync(db, Guid.NewGuid(), userId, targetId, 0, null, DateTimeOffset.UtcNow)); db.ChangeTracker.Clear(); var trackedActive = await db.Conversations.SingleAsync(); trackedActive.SoftDelete(); await db.SaveChangesAsync(); await InsertConversationAsync(db, Guid.NewGuid(), userId, targetId, 0, null, DateTimeOffset.UtcNow); Assert.Equal(1, await db.Conversations.CountAsync()); } [DockerFact] [Trait("Category", "Docker")] public async Task Repository_uses_exact_active_lookup_and_orders_owner_list_newest_first() { await using var db = CreateContext(); await db.Database.MigrateAsync(); var userId = Guid.NewGuid(); var firstTarget = Guid.NewGuid(); var secondTarget = Guid.NewGuid(); await InsertConversationAsync(db, Guid.NewGuid(), userId, firstTarget, 0, null, new DateTimeOffset(2026, 1, 1, 0, 0, 0, TimeSpan.Zero)); await InsertConversationAsync(db, Guid.NewGuid(), userId, secondTarget, 0, null, new DateTimeOffset(2026, 2, 1, 0, 0, 0, TimeSpan.Zero)); var repository = new ConversationReposity(db); var exact = await repository.FindActiveAsync(userId, firstTarget, ChatType.PRIVATE); var list = (await repository.ListByUserIdAsync(userId)).ToList(); Assert.NotNull(exact); Assert.Equal(firstTarget, exact.TargetId); Assert.Equal(secondTarget, list[0].TargetId); } [DockerFact] [Trait("Category", "Docker")] public async Task Message_search_is_scoped_filtered_and_uses_an_exclusive_cursor() { await using var db = CreateContext(); await db.Database.MigrateAsync(); var senderId = Guid.NewGuid(); var targetId = Guid.NewGuid(); var context = new MessageCreateContext(ChatType.PRIVATE, Guid.NewGuid(), senderId, targetId); var first = Message.BuildTxt(context, "项目进度 一", 1); var second = Message.BuildTxt(context with { ClientMsgId = Guid.NewGuid() }, "项目进度 二", 2); var withdrawn = Message.BuildTxt(context with { ClientMsgId = Guid.NewGuid() }, "项目进度 已撤回", 3); withdrawn.Withdraw(); var image = Message.BuildImg(context with { ClientMsgId = Guid.NewGuid() }, "image", 10, 10, "thumb", 4); var other = Message.BuildTxt( new MessageCreateContext(ChatType.GROUP, Guid.NewGuid(), senderId, Guid.NewGuid()), "项目进度 其他会话", 5); db.Messages.AddRange(first, second, withdrawn, image, other); await db.SaveChangesAsync(); var repository = new MessageReposity(db); var firstPage = await repository.SearchAsync(first.StreamKey, "项目进度", null, 1); var secondPage = await repository.SearchAsync(first.StreamKey, "项目进度", firstPage.messages.Single().SequenceId, 10); Assert.True(firstPage.hasMore); Assert.Equal(2, firstPage.messages.Single().SequenceId); Assert.False(secondPage.hasMore); Assert.Equal(1, secondPage.messages.Single().SequenceId); } private MessageDbContext CreateContext() { var connectionString = mysql.GetConnectionString(); var options = new DbContextOptionsBuilder() .UseMySql( connectionString, new MySqlServerVersion(new Version(8, 0)), mysqlOptions => mysqlOptions.EnableRetryOnFailure(3, TimeSpan.FromSeconds(2), null)) .Options; return new MessageDbContext(options, null!); } private static Task InsertConversationAsync( MessageDbContext db, Guid id, Guid userId, Guid targetId, int unreadCount, long? lastReadSequenceId, DateTimeOffset modificationTime) { return db.Database.ExecuteSqlInterpolatedAsync($""" INSERT INTO `conversations` (`Id`, `UserId`, `TargetId`, `TargetAvatar`, `TargetName`, `LastReadSequenceId`, `UnreadCount`, `ChatType`, `StreamKey`, `LastMessage`, `CreationTime`, `ModificationTime`, `IsDeleted`, `Deletion`) VALUES ({id}, {userId}, {targetId}, {string.Empty}, {"target"}, {lastReadSequenceId}, {unreadCount}, {(int)ChatType.PRIVATE}, {"private-stream"}, {string.Empty}, {modificationTime}, {modificationTime}, {false}, {null}); """); } } public sealed class DockerFactAttribute : FactAttribute { public DockerFactAttribute() { if (!string.Equals(Environment.GetEnvironmentVariable("RUN_DOCKER_TESTS"), "1", StringComparison.Ordinal)) { Skip = "Set RUN_DOCKER_TESTS=1 when a Docker daemon is available."; } } }