feature/FA-misc_ReportingService #64

Merged
conco merged 14 commits from feature/FA-misc_ReportingService into master 2026-02-01 17:31:31 +00:00
3 changed files with 271 additions and 0 deletions
Showing only changes of commit 7c3df7ab11 - Show all commits

View File

@@ -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<ReportingDbContext>()
.UseInMemoryDatabase(databaseName: Guid.NewGuid().ToString())
.Options;
_dbContext = new TestReportingDbContext(options, NullLogger<ReportingDbContext>.Instance);
_consumer = new JobStatusUpdateConsumer(
NullLogger<JobStatusUpdateConsumer>.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<IJobStatusUpdate> CreateConsumeContext(JobStatusUpdate message)
{
var context = Substitute.For<ConsumeContext<IJobStatusUpdate>>();
context.Message.Returns(message);
return context;
}
public void Dispose()
{
_dbContext.Dispose();
}
/// <summary>
/// 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.
/// </summary>
private class TestReportingDbContext : ReportingDbContext
{
public TestReportingDbContext(DbContextOptions options, ILogger<ReportingDbContext> logger)
: base(options, logger)
{
}
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
base.OnModelCreating(modelBuilder);
modelBuilder.Entity<Job>(entity =>
{
entity.Property(j => j.Metadata)
.HasConversion(
v => v == null ? null : JsonSerializer.Serialize(v, (JsonSerializerOptions?)null),
v => v == null ? null : JsonSerializer.Deserialize<Dictionary<string, string>>(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
}
}
}

View File

@@ -0,0 +1,32 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<IsPackable>false</IsPackable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="FluentAssertions" Version="6.12.0" />
<PackageReference Include="MassTransit" Version="8.5.7" />
<PackageReference Include="Microsoft.EntityFrameworkCore.InMemory" Version="9.0.11" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.11.1" />
<PackageReference Include="NodaTime.Testing" Version="3.3.0" />
<PackageReference Include="NSubstitute" Version="5.1.0" />
<PackageReference Include="xunit" Version="2.9.2" />
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="coverlet.collector" Version="6.0.2">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\FictionArchive.Service.ReportingService\FictionArchive.Service.ReportingService.csproj" />
</ItemGroup>
</Project>

View File

@@ -25,6 +25,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "FictionArchive.Service.User
EndProject EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "FictionArchive.Service.ReportingService", "FictionArchive.Service.ReportingService\FictionArchive.Service.ReportingService.csproj", "{F29F7969-2B40-4B92-A8F5-9544A4F700DC}" Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "FictionArchive.Service.ReportingService", "FictionArchive.Service.ReportingService\FictionArchive.Service.ReportingService.csproj", "{F29F7969-2B40-4B92-A8F5-9544A4F700DC}"
EndProject 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 Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU 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}.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.ActiveCfg = Release|Any CPU
{F29F7969-2B40-4B92-A8F5-9544A4F700DC}.Release|Any CPU.Build.0 = 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 EndGlobalSection
EndGlobal EndGlobal