From 7c3df7ab11f27cbc900a478ea130a30039e4c54d Mon Sep 17 00:00:00 2001 From: gamer147 Date: Fri, 30 Jan 2026 16:47:26 -0500 Subject: [PATCH] [FA-misc] Add ReportingService consumer unit tests --- .../Consumers/JobStatusUpdateConsumerTests.cs | 233 ++++++++++++++++++ ...hive.Service.ReportingService.Tests.csproj | 32 +++ FictionArchive.sln | 6 + 3 files changed, 271 insertions(+) create mode 100644 FictionArchive.Service.ReportingService.Tests/Consumers/JobStatusUpdateConsumerTests.cs create mode 100644 FictionArchive.Service.ReportingService.Tests/FictionArchive.Service.ReportingService.Tests.csproj diff --git a/FictionArchive.Service.ReportingService.Tests/Consumers/JobStatusUpdateConsumerTests.cs b/FictionArchive.Service.ReportingService.Tests/Consumers/JobStatusUpdateConsumerTests.cs new file mode 100644 index 0000000..98fdb7f --- /dev/null +++ b/FictionArchive.Service.ReportingService.Tests/Consumers/JobStatusUpdateConsumerTests.cs @@ -0,0 +1,233 @@ +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 + } + } +} diff --git a/FictionArchive.Service.ReportingService.Tests/FictionArchive.Service.ReportingService.Tests.csproj b/FictionArchive.Service.ReportingService.Tests/FictionArchive.Service.ReportingService.Tests.csproj new file mode 100644 index 0000000..81228be --- /dev/null +++ b/FictionArchive.Service.ReportingService.Tests/FictionArchive.Service.ReportingService.Tests.csproj @@ -0,0 +1,32 @@ + + + + net8.0 + enable + enable + false + + + + + + + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + + diff --git a/FictionArchive.sln b/FictionArchive.sln index 3479e0f..45d052b 100644 --- a/FictionArchive.sln +++ b/FictionArchive.sln @@ -25,6 +25,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "FictionArchive.Service.User EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "FictionArchive.Service.ReportingService", "FictionArchive.Service.ReportingService\FictionArchive.Service.ReportingService.csproj", "{F29F7969-2B40-4B92-A8F5-9544A4F700DC}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "FictionArchive.Service.ReportingService.Tests", "FictionArchive.Service.ReportingService.Tests\FictionArchive.Service.ReportingService.Tests.csproj", "{E704ACF1-2E1D-4A1C-BBCE-8FAE9F1A9944}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -79,5 +81,9 @@ Global {F29F7969-2B40-4B92-A8F5-9544A4F700DC}.Debug|Any CPU.Build.0 = Debug|Any CPU {F29F7969-2B40-4B92-A8F5-9544A4F700DC}.Release|Any CPU.ActiveCfg = Release|Any CPU {F29F7969-2B40-4B92-A8F5-9544A4F700DC}.Release|Any CPU.Build.0 = Release|Any CPU + {E704ACF1-2E1D-4A1C-BBCE-8FAE9F1A9944}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {E704ACF1-2E1D-4A1C-BBCE-8FAE9F1A9944}.Debug|Any CPU.Build.0 = Debug|Any CPU + {E704ACF1-2E1D-4A1C-BBCE-8FAE9F1A9944}.Release|Any CPU.ActiveCfg = Release|Any CPU + {E704ACF1-2E1D-4A1C-BBCE-8FAE9F1A9944}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection EndGlobal