using System.Text.Json; using FictionArchive.Common.Enums; using FictionArchive.Service.ReportingService.Consumers; using FictionArchive.Service.ReportingService.Models; using FictionArchive.Service.ReportingService.Services; using FictionArchive.Service.Shared.Contracts.Events; using FluentAssertions; using MassTransit; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging.Abstractions; using NSubstitute; using Xunit; namespace FictionArchive.Service.ReportingService.Tests.Consumers; public class JobStatusUpdateConsumerTests : IDisposable { private readonly ReportingDbContext _dbContext; private readonly JobStatusUpdateConsumer _consumer; public JobStatusUpdateConsumerTests() { var options = new DbContextOptionsBuilder() .UseInMemoryDatabase(databaseName: Guid.NewGuid().ToString()) .Options; _dbContext = new TestReportingDbContext(options, NullLogger.Instance); _consumer = new JobStatusUpdateConsumer( NullLogger.Instance, _dbContext); } [Fact] public async Task Should_create_new_job_on_first_event() { var jobId = Guid.NewGuid(); var context = CreateConsumeContext(new JobStatusUpdate( jobId, null, "TestJob", "Test job display", JobStatus.InProgress, null, new() { ["key1"] = "value1" })); await _consumer.Consume(context); var job = await _dbContext.Jobs.FindAsync(jobId); job.Should().NotBeNull(); job!.JobType.Should().Be("TestJob"); job.DisplayName.Should().Be("Test job display"); job.Status.Should().Be(JobStatus.InProgress); job.Metadata.Should().ContainKey("key1").WhoseValue.Should().Be("value1"); } [Fact] public async Task Should_update_status_on_subsequent_event() { var jobId = Guid.NewGuid(); // First event: create await _consumer.Consume(CreateConsumeContext(new JobStatusUpdate( jobId, null, "TestJob", "Test job", JobStatus.InProgress, null, null))); // Second event: update await _consumer.Consume(CreateConsumeContext(new JobStatusUpdate( jobId, null, "TestJob", "Test job", JobStatus.Completed, null, null))); var job = await _dbContext.Jobs.FindAsync(jobId); job!.Status.Should().Be(JobStatus.Completed); } [Fact] public async Task Should_merge_metadata_on_update() { var jobId = Guid.NewGuid(); // First event with initial metadata await _consumer.Consume(CreateConsumeContext(new JobStatusUpdate( jobId, null, "TestJob", "Test job", JobStatus.InProgress, null, new() { ["NovelId"] = "42" }))); // Second event with additional metadata await _consumer.Consume(CreateConsumeContext(new JobStatusUpdate( jobId, null, "TestJob", "Test job", JobStatus.Completed, null, new() { ["ChapterId"] = "7" }))); var job = await _dbContext.Jobs.FindAsync(jobId); job!.Metadata.Should().ContainKey("NovelId").WhoseValue.Should().Be("42"); job.Metadata.Should().ContainKey("ChapterId").WhoseValue.Should().Be("7"); } [Fact] public async Task Should_not_overwrite_job_type_on_update() { var jobId = Guid.NewGuid(); await _consumer.Consume(CreateConsumeContext(new JobStatusUpdate( jobId, null, "OriginalType", "Test job", JobStatus.InProgress, null, null))); await _consumer.Consume(CreateConsumeContext(new JobStatusUpdate( jobId, null, "DifferentType", "Test job", JobStatus.Completed, null, null))); var job = await _dbContext.Jobs.FindAsync(jobId); job!.JobType.Should().Be("OriginalType"); } [Fact] public async Task Should_not_overwrite_parent_job_id_on_update() { var jobId = Guid.NewGuid(); var parentId = Guid.NewGuid(); await _consumer.Consume(CreateConsumeContext(new JobStatusUpdate( jobId, parentId, "TestJob", "Test job", JobStatus.InProgress, null, null))); await _consumer.Consume(CreateConsumeContext(new JobStatusUpdate( jobId, null, "TestJob", "Test job", JobStatus.Completed, null, null))); var job = await _dbContext.Jobs.FindAsync(jobId); job!.ParentJobId.Should().Be(parentId); } [Fact] public async Task Should_set_error_message_on_failure() { var jobId = Guid.NewGuid(); await _consumer.Consume(CreateConsumeContext(new JobStatusUpdate( jobId, null, "TestJob", "Test job", JobStatus.Failed, "Something went wrong", null))); var job = await _dbContext.Jobs.FindAsync(jobId); job!.Status.Should().Be(JobStatus.Failed); job.ErrorMessage.Should().Be("Something went wrong"); } [Fact] public async Task Should_store_parent_job_id() { var parentId = Guid.NewGuid(); var childId = Guid.NewGuid(); await _consumer.Consume(CreateConsumeContext(new JobStatusUpdate( parentId, null, "ParentJob", "Parent", JobStatus.InProgress, null, null))); await _consumer.Consume(CreateConsumeContext(new JobStatusUpdate( childId, parentId, "ChildJob", "Child", JobStatus.InProgress, null, null))); var child = await _dbContext.Jobs.FindAsync(childId); child!.ParentJobId.Should().Be(parentId); } [Fact] public async Task Should_handle_null_metadata_on_create() { var jobId = Guid.NewGuid(); await _consumer.Consume(CreateConsumeContext(new JobStatusUpdate( jobId, null, "TestJob", "Test job", JobStatus.InProgress, null, null))); var job = await _dbContext.Jobs.FindAsync(jobId); job!.Metadata.Should().BeNull(); } [Fact] public async Task Should_add_metadata_to_job_with_null_metadata() { var jobId = Guid.NewGuid(); // First event: no metadata await _consumer.Consume(CreateConsumeContext(new JobStatusUpdate( jobId, null, "TestJob", "Test job", JobStatus.InProgress, null, null))); // Second event: adds metadata await _consumer.Consume(CreateConsumeContext(new JobStatusUpdate( jobId, null, "TestJob", "Test job", JobStatus.Completed, null, new() { ["result"] = "success" }))); var job = await _dbContext.Jobs.FindAsync(jobId); job!.Metadata.Should().ContainKey("result").WhoseValue.Should().Be("success"); } private static ConsumeContext CreateConsumeContext(JobStatusUpdate message) { var context = Substitute.For>(); context.Message.Returns(message); return context; } public void Dispose() { _dbContext.Dispose(); } /// /// Test-specific subclass that adds a JSON value converter for Dictionary properties, /// since the InMemory provider does not support the jsonb column type used in production. /// private class TestReportingDbContext : ReportingDbContext { public TestReportingDbContext(DbContextOptions options, ILogger logger) : base(options, logger) { } protected override void OnModelCreating(ModelBuilder modelBuilder) { base.OnModelCreating(modelBuilder); modelBuilder.Entity(entity => { entity.Property(j => j.Metadata) .HasConversion( v => v == null ? null : JsonSerializer.Serialize(v, (JsonSerializerOptions?)null), v => v == null ? null : JsonSerializer.Deserialize>(v, (JsonSerializerOptions?)null)) .HasColumnType(null!); }); } protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) { // Skip base OnConfiguring to avoid adding AuditInterceptor // which is not needed for unit tests } } }