using LiveRecorder.Application.Abstractions.Logging; using LiveRecorder.Application.Models.Logs; using LiveRecorder.Domain.Entities; using LiveRecorder.Domain.Enums; using LiveRecorder.Infrastructure.Persistence; using LiveRecorder.Infrastructure.Services; using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.Storage; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging.Abstractions; namespace LiveRecorder.Tests; public sealed class StalePendingRecoveryTests { [Fact] public async Task StalePendingSessionWithoutMedia_IsFailedAndRetainedForAudit() { var services = new ServiceCollection(); var databaseRoot = new InMemoryDatabaseRoot(); var databaseName = $"stale-pending-{Guid.NewGuid():N}"; services.AddDbContext(options => options.UseInMemoryDatabase(databaseName, databaseRoot)); var logService = new CapturingSystemLogService(); services.AddSingleton(logService); await using var provider = services.BuildServiceProvider(); var createdAt = DateTimeOffset.UtcNow.AddMinutes(-11); Guid sessionId; Guid taskId; await using (var scope = provider.CreateAsyncScope()) { var dbContext = scope.ServiceProvider.GetRequiredService(); var liveRoom = new LiveRoom( LivePlatformType.Douyin, "https://live.example/room", "room-1", "https://live.example/room", createdAt); var session = new RecordSession( liveRoom.Id, "origin", RecordOutputFormat.Mp4, RecordSaveMode.Segmented, createdAt); var task = new RecordTask( liveRoom.Id, session.Id, 1, "origin", RecordOutputFormat.Mp4, createdAt); sessionId = session.Id; taskId = task.Id; dbContext.AddRange(liveRoom, session, task); await dbContext.SaveChangesAsync(); } var service = new FfmpegService( provider.GetRequiredService(), null!, null!, NullLogger.Instance); await using (var preconditionScope = provider.CreateAsyncScope()) { var preconditionContext = preconditionScope.ServiceProvider.GetRequiredService(); var persisted = await preconditionContext.RecordSessions.SingleAsync(); Assert.Equal(RecordSessionStatus.Pending, persisted.Status); Assert.True( persisted.UpdatedAt <= DateTimeOffset.UtcNow.AddMinutes(-10), $"updatedAt={persisted.UpdatedAt:O}"); } var recovered = await service.RecoverStalePendingSessionsAsync(); Assert.Equal(1, recovered); await using var verificationScope = provider.CreateAsyncScope(); var verificationContext = verificationScope.ServiceProvider.GetRequiredService(); var persistedSession = await verificationContext.RecordSessions.SingleAsync(item => item.Id == sessionId); var persistedTask = await verificationContext.RecordTasks .Include(item => item.Result) .SingleAsync(item => item.Id == taskId); Assert.Equal(RecordSessionStatus.Failed, persistedSession.Status); Assert.Equal(RecordTaskStatus.Failed, persistedTask.Status); Assert.Contains("interrupted before stream initialization", persistedSession.ErrorMessage); Assert.Null(persistedTask.Result); Assert.Single(logService.Entries); Assert.Equal(SystemLogLevel.Warning, logService.Entries[0].Level); } private sealed class CapturingSystemLogService : ISystemLogService { public List<(SystemLogLevel Level, string Message)> Entries { get; } = []; public Task WriteAsync( SystemLogLevel level, string category, string message, string? detail = null, Guid? liveRoomId = null, Guid? recordSessionId = null, Guid? recordTaskId = null, CancellationToken cancellationToken = default) { Entries.Add((level, message)); return Task.CompletedTask; } public Task> ListAsync( Guid? liveRoomId = null, Guid? recordSessionId = null, Guid? recordTaskId = null, SystemLogLevel? level = null, string? content = null, int take = 200, CancellationToken cancellationToken = default) => Task.FromResult>([]); } }