using Jiaowu.Api.Domain.Academic; using Jiaowu.Api.Domain.System; using Jiaowu.Api.Infrastructure.BackgroundJobs; using Jiaowu.Api.Infrastructure.Persistence; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.DependencyInjection; namespace Jiaowu.Api.Tests; public sealed class BackgroundJobOutboxTests { [Fact] public async Task Publisher_moves_pending_message_to_in_memory_transport() { var databasePath = Path.Combine( Path.GetTempPath(), $"jiaowu-outbox-{Guid.NewGuid():N}.sqlite"); var services = new ServiceCollection(); services.AddLogging(); services.AddDbContext(options => options.UseSqlite($"Data Source={databasePath};Pooling=False")); var options = new BackgroundJobOptions { PollIntervalMilliseconds = 100 }; services.AddSingleton(options); services.AddSingleton(); services.AddSingleton(provider => provider.GetRequiredService()); services.AddSingleton(); var provider = services.BuildServiceProvider(); try { Guid jobId; await using (var scope = provider.CreateAsyncScope()) { var db = scope.ServiceProvider.GetRequiredService(); await db.Database.EnsureCreatedAsync(); var term = new AcademicTerm { Code = "RECOVERY", Name = "补投测试学期", AcademicYear = "2026-2027", Season = TermSeason.Autumn, StartDate = new DateOnly(2026, 9, 1), EndDate = new DateOnly(2027, 1, 15) }; var plan = new SchedulePlan { AcademicTerm = term, Name = "补投测试排课", Version = "V1" }; var job = new AutomaticScheduleJob { SchedulePlan = plan, ActiveSchedulePlanId = plan.Id }; jobId = job.Id; db.AddRange(term, plan, job); await db.SaveChangesAsync(); } var publisher = provider.GetRequiredService(); await publisher.StartAsync(CancellationToken.None); try { var published = false; for (var attempt = 0; attempt < 50 && !published; attempt++) { await Task.Delay(50); await using var scope = provider.CreateAsyncScope(); var db = scope.ServiceProvider.GetRequiredService(); published = await db.BackgroundJobOutboxMessages.AsNoTracking() .AnyAsync(x => x.JobId == jobId && x.JobKind == BackgroundJobKind.AutomaticSchedule && x.State == BackgroundJobOutboxState.Published); } Assert.True(published); } finally { await publisher.StopAsync(CancellationToken.None); } } finally { await provider.DisposeAsync(); File.Delete(databasePath); } } [Fact] public async Task Runner_stops_after_retry_limit_and_ignores_duplicate_delivery() { var databasePath = Path.Combine( Path.GetTempPath(), $"jiaowu-runner-{Guid.NewGuid():N}.sqlite"); var services = new ServiceCollection(); services.AddLogging(); services.AddDbContext(options => options.UseSqlite($"Data Source={databasePath};Pooling=False")); services.AddScoped(); services.AddScoped(); services.AddSingleton(new BackgroundJobOptions()); services.AddSingleton(); services.AddSingleton(provider => provider.GetRequiredService()); services.AddSingleton(); var provider = services.BuildServiceProvider(); try { BackgroundJobEnvelope envelope; await using (var scope = provider.CreateAsyncScope()) { var db = scope.ServiceProvider.GetRequiredService(); await db.Database.EnsureCreatedAsync(); var term = new AcademicTerm { Code = "OUTBOX", Name = "消息测试学期", AcademicYear = "2026-2027", Season = TermSeason.Autumn, StartDate = new DateOnly(2026, 9, 1), EndDate = new DateOnly(2027, 1, 15) }; var plan = new SchedulePlan { AcademicTerm = term, Name = "消息测试排课", Version = "V1" }; var job = new AutomaticScheduleJob { SchedulePlan = plan, ActiveSchedulePlanId = plan.Id, Status = AutomaticScheduleJobStatus.Queued }; var outbox = BackgroundJobOutboxMessage.Create( BackgroundJobKind.AutomaticSchedule, job.Id); outbox.State = BackgroundJobOutboxState.Published; outbox.ProcessingAttempts = 5; db.AddRange(term, plan, job, outbox); await db.SaveChangesAsync(); envelope = new BackgroundJobEnvelope( outbox.Id, outbox.JobKind, outbox.JobId); } var runner = provider.GetRequiredService(); var first = await runner.RunAsync(envelope, CancellationToken.None); var duplicate = await runner.RunAsync(envelope, CancellationToken.None); Assert.Equal(BackgroundJobRunOutcome.Completed, first.Outcome); Assert.Equal(BackgroundJobRunOutcome.Completed, duplicate.Outcome); await using var assertScope = provider.CreateAsyncScope(); var assertDb = assertScope.ServiceProvider.GetRequiredService(); var persistedOutbox = await assertDb.BackgroundJobOutboxMessages.SingleAsync(); Assert.Equal(BackgroundJobOutboxState.Completed, persistedOutbox.State); Assert.NotNull(persistedOutbox.CompletedAt); Assert.Null(persistedOutbox.ProcessingToken); Assert.Null(persistedOutbox.LeaseExpiresAt); Assert.Equal(6, persistedOutbox.ProcessingAttempts); var persistedJob = await assertDb.AutomaticScheduleJobs.SingleAsync(); Assert.Equal(AutomaticScheduleJobStatus.Failed, persistedJob.Status); Assert.Null(persistedJob.ActiveSchedulePlanId); Assert.Contains("超过 5 次", persistedJob.ErrorMessage); } finally { await provider.DisposeAsync(); File.Delete(databasePath); } } }