using LiveRecorder.Application.Abstractions.Logging; using LiveRecorder.Application.Abstractions.Recording; using LiveRecorder.Application.Models.Logs; using LiveRecorder.Domain.Entities; using LiveRecorder.Domain.Enums; using LiveRecorder.Infrastructure.Persistence; using LiveRecorder.Infrastructure.Services; using Microsoft.EntityFrameworkCore; namespace LiveRecorder.Tests; public sealed class RecoveryServiceTests : IDisposable { private readonly string _temporaryDirectory = Path.Combine( Path.GetTempPath(), $"liverecorder-artifact-recovery-{Guid.NewGuid():N}"); [Fact] public async Task AcceptShortArtifact_RequiresConfirmationThenLeavesItForManualUpload() { Directory.CreateDirectory(_temporaryDirectory); var mediaPath = Path.Combine(_temporaryDirectory, "short-fragment.mp4"); await File.WriteAllBytesAsync(mediaPath, [1, 2, 3, 4]); await using var context = CreateContext(); var taskId = await SeedFailedTaskAsync(context, mediaPath, durationSeconds: 3); var service = CreateService(context, new VideoMetadata(3, 1920, 1080, "h264", "aac", 30, 2_000_000)); var rejected = await service.AcceptRecordingArtifactAsync(taskId, confirmShortArtifact: false); Assert.Equal(0, rejected.SuccessCount); Assert.Contains("需要确认短分片", rejected.Messages.Single()); Assert.Equal(RecordTaskStatus.Failed, (await context.RecordTasks.FindAsync(taskId))!.Status); var accepted = await service.AcceptRecordingArtifactAsync(taskId, confirmShortArtifact: true); Assert.Equal(1, accepted.SuccessCount); var task = await context.RecordTasks.Include(item => item.Result).SingleAsync(item => item.Id == taskId); Assert.Equal(RecordTaskStatus.Completed, task.Status); Assert.Equal(RecordArtifactUploadStatus.NotUploaded, task.Result!.UploadStatus); Assert.Null(task.Result.LastUploadProvider); Assert.Null(task.Result.LastUploadedAt); Assert.Null(task.Result.UploadErrorMessage); Assert.True(File.Exists(mediaPath)); } [Fact] public async Task ListFailures_IdentifiesMissingMediaAsUnrecoverable() { Directory.CreateDirectory(_temporaryDirectory); var missingPath = Path.Combine(_temporaryDirectory, "missing.mp4"); await using var context = CreateContext(); await SeedFailedTaskAsync(context, missingPath, durationSeconds: 120); var service = CreateService(context, metadata: null); var response = await service.ListRecordingFailuresAsync("MissingMedia", 0, 20); var item = Assert.Single(response.Items); Assert.Equal("MissingMedia", item.FailureKind); Assert.False(item.FileExists); Assert.False(item.CanAccept); Assert.False(item.CanRepair); } private static LiveRecorderDbContext CreateContext() { var options = new DbContextOptionsBuilder() .UseInMemoryDatabase($"artifact-recovery-{Guid.NewGuid():N}") .Options; return new LiveRecorderDbContext(options); } private static async Task SeedFailedTaskAsync( LiveRecorderDbContext context, string mediaPath, double durationSeconds) { var now = DateTimeOffset.UtcNow; var room = new LiveRoom( LivePlatformType.Douyin, "https://live.example/recovery", "recovery-room", "https://live.example/recovery", now.AddHours(-1)); room.UpdateMetadata("恢复测试直播间", "恢复主播", null, null, null, now.AddHours(-1)); var session = new RecordSession( room.Id, "origin", RecordOutputFormat.Mp4, RecordSaveMode.Segmented, now.AddMinutes(-10)); session.MarkFailed("ffmpeg exited unexpectedly", now); var task = new RecordTask( room.Id, session.Id, 1, "origin", RecordOutputFormat.Mp4, now.AddMinutes(-10)); task.MarkStarting("https://stream.example/recovery", mediaPath, now.AddMinutes(-10)); task.MarkFailed("ffmpeg exited unexpectedly", now, durationSeconds); var result = new RecordResult( task.Id, mediaPath, File.Exists(mediaPath) ? new FileInfo(mediaPath).Length : null, durationSeconds, null, 0, RecordTaskStatus.Failed, task.ErrorMessage, now); result.MarkUploadFailed("openlist", "previous upload error", now); context.AddRange(room, session, task, result); await context.SaveChangesAsync(); return task.Id; } private static RecoveryService CreateService(LiveRecorderDbContext context, VideoMetadata? metadata) => new( context, systemSettingsService: null!, storageGuardService: null!, ffmpegService: null!, recordService: null!, videoMetadataService: new FixedVideoMetadataService(metadata), systemLogService: new NullSystemLogService()); public void Dispose() { if (Directory.Exists(_temporaryDirectory)) { Directory.Delete(_temporaryDirectory, recursive: true); } } private sealed class FixedVideoMetadataService(VideoMetadata? metadata) : IVideoMetadataService { public Task ExtractMetadataAsync( string filePath, CancellationToken cancellationToken = default) => Task.FromResult(metadata); public Task GenerateThumbnailAsync( string filePath, string outputDir, CancellationToken cancellationToken = default) => Task.FromResult(null); } private sealed class NullSystemLogService : ISystemLogService { public Task WriteAsync( SystemLogLevel level, string category, string message, string? detail = null, Guid? liveRoomId = null, Guid? recordSessionId = null, Guid? recordTaskId = null, CancellationToken cancellationToken = default) => 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>([]); } }