fix: align backend APIs and upload flow

This commit is contained in:
2026-09-15 14:10:55 +08:00
parent 32177a7293
commit 53e6195938
149 changed files with 4791 additions and 435 deletions
@@ -0,0 +1,92 @@
using MessageService.Domain.Entities;
using MessageService.Domain.Enums;
using MessageService.Infrastructure;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Xunit;
namespace MessageService.Tests;
public sealed class ConversationModelTests
{
[Fact]
public void Conversation_read_state_and_unread_count_are_consistent()
{
var conversation = new Conversation(
Guid.NewGuid(), Guid.NewGuid(), string.Empty, "target", null, 0, ChatType.PRIVATE, string.Empty);
conversation.IncrementUnread();
conversation.IncrementUnread();
conversation.MarkAsRead(12);
Assert.Equal(0, conversation.UnreadCount);
Assert.Equal(12, conversation.LastReadSequenceId);
}
[Fact]
public void Marking_conversation_read_does_not_change_last_message_activity()
{
var conversation = new Conversation(
Guid.NewGuid(), Guid.NewGuid(), string.Empty, "target", null, 1, ChatType.PRIVATE, "old");
var messageTime = new DateTimeOffset(2026, 9, 13, 8, 30, 0, TimeSpan.Zero);
conversation.UpdateLastMessage("new", messageTime);
conversation.MarkAsRead(42);
Assert.Equal(messageTime, conversation.LastMessageTime);
Assert.Equal(42, conversation.LastReadSequenceId);
Assert.Equal(0, conversation.UnreadCount);
}
[Fact]
public void Active_conversation_key_is_generated_and_unique()
{
using var db = CreateContext();
var entity = db.Model.FindEntityType(typeof(Conversation));
var property = entity!.FindProperty("ActiveConversationKey");
var index = entity.GetIndexes().Single(item => item.Properties.Contains(property!));
Assert.NotNull(property!.GetComputedColumnSql());
Assert.True(index.IsUnique);
}
[Fact]
public void Migration_script_contains_deduplication_and_active_unique_index()
{
using var db = CreateContext();
var migrator = db.Database.GetService<IMigrator>();
var script = migrator.GenerateScript(
"20260909000100_ApiAlignmentFixes",
"20260911000100_ConversationUniqueness");
Assert.Contains("conversation_dedup", script);
Assert.Contains("UX_conversations_ActiveConversationKey", script);
Assert.Contains("ROW_NUMBER() OVER", script);
}
[Fact]
public void Activity_migration_backfills_latest_message_time_and_adds_search_index()
{
using var db = CreateContext();
var migrator = db.Database.GetService<IMigrator>();
var script = migrator.GenerateScript(
"20260911000100_ConversationUniqueness",
"20260913000100_ConversationActivityAndMessageSearch");
Assert.Contains("LastMessageTime", script);
Assert.Contains("MAX(`message`.`CreationTime`)", script);
Assert.Contains("IX_messages_StreamKey_MsgType_State_SequenceId", script);
}
private static MessageDbContext CreateContext()
{
const string connectionString = "Server=localhost;Database=im_message_tests;User=test;Password=test";
var options = new DbContextOptionsBuilder<MessageDbContext>()
.UseMySql(connectionString, new MySqlServerVersion(new Version(8, 0, 36)))
.Options;
return new MessageDbContext(options, null!);
}
}
@@ -0,0 +1,153 @@
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<IMigrator>();
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<MySqlException>(() =>
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<MessageDbContext>()
.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.";
}
}
}
@@ -0,0 +1,25 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<IsPackable>false</IsPackable>
<IsTestProject>true</IsTestProject>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.12.0" />
<PackageReference Include="Testcontainers.MySql" Version="4.13.0" />
<PackageReference Include="xunit" Version="2.9.2" />
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\MessageService.Infrastructure\MessageService.Infrastructure.csproj" />
</ItemGroup>
</Project>