Added initial test cases. Fixed circle search provider logic.

This commit is contained in:
2025-08-28 00:50:26 -04:00
parent 22b8513e34
commit f221deea36
9 changed files with 313 additions and 59 deletions

View File

@@ -3,4 +3,5 @@
public class CircleSearchCriteria
{
public string? Name { get; init; }
public CircleStatus? Status { get; init; }
}

View File

@@ -0,0 +1,9 @@
namespace JSMR.Application.Circles.Queries.Search;
public enum CircleStatus
{
NotBlacklisted,
Favorited,
Blacklisted,
Spam
}

View File

@@ -3,7 +3,7 @@ using Microsoft.EntityFrameworkCore;
namespace JSMR.Infrastructure.Data;
public class AppDbContext : DbContext
public class AppDbContext(DbContextOptions<AppDbContext> options) : DbContext(options)
{
public DbSet<VoiceWork> VoiceWorks { get; set; }
public DbSet<EnglishVoiceWork> EnglishVoiceWorks { get; set; }

View File

@@ -9,38 +9,9 @@ public class CircleSearchProvider(AppDbContext context) : SearchProvider<CircleS
{
protected override IQueryable<CircleSearchItem> GetBaseQuery()
{
// Precompute LatestProductId per circle (by productId length, then value)
var latestPerCircle =
from vw in context.VoiceWorks.AsNoTracking()
group vw by vw.CircleId into g
let latest = g
.OrderByDescending(x => x.ProductId.Length)
.ThenByDescending(x => x.ProductId)
.Select(x => x.ProductId)
.FirstOrDefault()
select new { CircleId = g.Key, LatestProductId = latest };
// Aggregates per circle
var aggregates =
from vw in context.VoiceWorks.AsNoTracking()
group vw by vw.CircleId into g
select new
{
CircleId = g.Key,
Downloads = g.Sum(x => x.Downloads ?? 0),
Releases = g.Count(x => x.SalesDate != null),
Pending = g.Count(x => x.ExpectedDate != null),
FirstReleaseDate = g.Min(x => x.SalesDate),
LatestReleaseDate = g.Max(x => x.SalesDate)
};
// Join circles with aggregates and latest product id
var baseQuery =
// Project from Circles so we can use correlated subqueries per CircleId.
var q =
from c in context.Circles.AsNoTracking()
join agg in aggregates on c.CircleId equals agg.CircleId into aggs
from a in aggs.DefaultIfEmpty()
join lp in latestPerCircle on c.CircleId equals lp.CircleId into lps
from l in lps.DefaultIfEmpty()
select new CircleSearchItem
{
CircleId = c.CircleId,
@@ -49,18 +20,54 @@ public class CircleSearchProvider(AppDbContext context) : SearchProvider<CircleS
Favorite = c.Favorite,
Blacklisted = c.Blacklisted,
Spam = c.Spam,
Downloads = a != null ? a.Downloads : 0,
Releases = a != null ? a.Releases : 0,
Pending = a != null ? a.Pending : 0,
FirstReleaseDate = a != null ? a.FirstReleaseDate : null,
LatestReleaseDate = a != null ? a.LatestReleaseDate : null,
LatestProductId = l != null ? l.LatestProductId : null,
// these two get filled in during Select stage (below)
LatestVoiceWorkHasImage = null,
LatestVoiceWorkSalesDate = null
// Aggregates
Downloads = context.VoiceWorks
.Where(v => v.CircleId == c.CircleId)
.Select(v => (int?)v.Downloads) // make nullable for Sum over empty set
.Sum() ?? 0,
Releases = context.VoiceWorks
.Count(v => v.CircleId == c.CircleId && v.SalesDate != null),
Pending = context.VoiceWorks
.Count(v => v.CircleId == c.CircleId && v.ExpectedDate != null),
FirstReleaseDate = context.VoiceWorks
.Where(v => v.CircleId == c.CircleId)
.Select(v => v.SalesDate)
.Min(),
LatestReleaseDate = context.VoiceWorks
.Where(v => v.CircleId == c.CircleId)
.Select(v => v.SalesDate)
.Max(),
// "Latest" by ProductId length, then value
LatestProductId = context.VoiceWorks
.Where(v => v.CircleId == c.CircleId)
.OrderByDescending(v => v.ProductId.Length)
.ThenByDescending(v => v.ProductId)
.Select(v => v.ProductId)
.FirstOrDefault(),
// If you want these two in base query too:
LatestVoiceWorkHasImage = context.VoiceWorks
.Where(v => v.CircleId == c.CircleId)
.OrderByDescending(v => v.ProductId.Length)
.ThenByDescending(v => v.ProductId)
.Select(v => (bool?)v.HasImage)
.FirstOrDefault(),
LatestVoiceWorkSalesDate = context.VoiceWorks
.Where(v => v.CircleId == c.CircleId)
.OrderByDescending(v => v.ProductId.Length)
.ThenByDescending(v => v.ProductId)
.Select(v => v.SalesDate)
.FirstOrDefault()
};
return baseQuery;
return q;
}
protected override IQueryable<CircleSearchItem> ApplyFilters(IQueryable<CircleSearchItem> query, CircleSearchCriteria criteria)
@@ -74,14 +81,21 @@ public class CircleSearchProvider(AppDbContext context) : SearchProvider<CircleS
EF.Functions.Like(x.MakerId, term));
}
//if (criteria.Status is CircleStatus.NotBlacklisted)
// query = query.Where(x => !x.Blacklisted);
//else if (criteria.Status is CircleStatus.Favorited)
// query = query.Where(x => x.Favorite);
//else if (criteria.Status is CircleStatus.Blacklisted)
// query = query.Where(x => x.Blacklisted);
//else if (criteria.Status is CircleStatus.Spam)
// query = query.Where(x => x.Spam);
switch (criteria.Status)
{
case CircleStatus.NotBlacklisted:
query = query.Where(x => !x.Blacklisted);
break;
case CircleStatus.Favorited:
query = query.Where(x => x.Favorite);
break;
case CircleStatus.Blacklisted:
query = query.Where(x => x.Blacklisted);
break;
case CircleStatus.Spam:
query = query.Where(x => x.Spam);
break;
}
return query;
}

View File

@@ -0,0 +1,56 @@
using JSMR.Application.Circles.Contracts;
using JSMR.Application.Circles.Queries.Search;
using JSMR.Application.Common.Search;
using JSMR.Infrastructure.Data;
using JSMR.Infrastructure.Data.Repositories.Circles;
namespace JSMR.Tests.Integration;
public class CircleSearchProviderTests(MariaDbFixture fixture) : IClassFixture<MariaDbFixture>
{
[Fact]
public async Task Search_ByName_Filters_And_Sorts()
{
await fixture.ResetAsync();
await using AppDbContext context = fixture.CreateDbContext();
await Seed.SeedCirclesWithWorksAsync(context);
CircleSearchProvider provider = new(context);
var options = new SearchOptions<CircleSearchCriteria, CircleSortField>
{
PageNumber = 1,
PageSize = 50,
SortOptions = [new SortOption<CircleSortField>(CircleSortField.Name, SortDirection.Ascending)],
Criteria = new CircleSearchCriteria { Name = "Circle" }
};
var result = await provider.SearchAsync(options, CancellationToken.None);
Assert.True(result.TotalItems >= 2);
Assert.Equal("Circle A", result.Items[0].Name);
Assert.Equal("Circle B", result.Items[1].Name);
}
//[Fact]
//public async Task Search_Status_Favorited_Only()
//{
// await fixture.ResetAsync();
// await using var db = fixture.CreateDbContext();
// await Seed.SeedCirclesWithWorksAsync(db);
// var provider = new CircleSearchProvider(db);
// var options = new SearchOptions<CircleSearchCriteria, CircleSortField>
// {
// PageNumber = 1,
// PageSize = 50,
// SortOptions = Array.Empty<SortOption<CircleSortField>>(),
// Criteria = new CircleSearchCriteria { Status = Application.Circles.Queries.Search.CircleStatus.Favorited }
// };
// var result = await provider.SearchAsync(options, CancellationToken.None);
// Assert.All(result.Items, i => Assert.True(i.Favorite));
//}
}

View File

@@ -0,0 +1,89 @@
using DotNet.Testcontainers.Builders;
using JSMR.Infrastructure.Data;
using Microsoft.EntityFrameworkCore;
using Testcontainers.MariaDb;
namespace JSMR.Tests.Integration;
public sealed class MariaDbFixture : IAsyncLifetime
{
public MariaDbContainer? MariaDbContainer { get; private set; }
public string ConnectionString { get; private set; } = default!;
public async Task InitializeAsync()
{
MariaDbContainer = new MariaDbBuilder()
.WithImage("mariadb:10.11.6")
.WithEnvironment("MARIADB_ROOT_PASSWORD", "rootpwd")
.WithEnvironment("MARIADB_DATABASE", "appdb")
.WithPortBinding(3307, 3306) // host:container; avoid conflicts
.WithWaitStrategy(Wait.ForUnixContainer().UntilPortIsAvailable(3306))
.Build();
await MariaDbContainer.StartAsync();
ConnectionString =
"Server=localhost;Port=3307;Database=appdb;User=root;Password=rootpwd;SslMode=None;AllowPublicKeyRetrieval=True;";
//ConnectionString = MariaDbContainer.GetConnectionString();
// Run migrations here to create schema
await using AppDbContext context = CreateDbContext();
await context.Database.EnsureCreatedAsync();
//await context.Database.MigrateAsync();
}
public async Task DisposeAsync()
{
if (MariaDbContainer is not null)
{
await MariaDbContainer.StopAsync();
await MariaDbContainer.DisposeAsync();
}
}
public AppDbContext CreateDbContext()
{
MySqlServerVersion serverVersion = new(new Version(10, 11, 6));
DbContextOptions<AppDbContext> options = new DbContextOptionsBuilder<AppDbContext>()
.UseMySql(ConnectionString, serverVersion,
o => o.EnableRetryOnFailure())
.EnableSensitiveDataLogging()
.Options;
return new AppDbContext(options);
}
/// <summary>Clean tables between tests; use Respawn or manual TRUNCATE in correct FK order.</summary>
public async Task ResetAsync()
{
await using AppDbContext context = CreateDbContext();
await context.Database.EnsureDeletedAsync();
await context.Database.EnsureCreatedAsync();
//await using var connection = context.Database.GetDbConnection();
//await connection.OpenAsync();
//using var cmd = connection.CreateCommand();
//cmd.CommandText = "SELECT DATABASE()";
//var dbName = (string?)await cmd.ExecuteScalarAsync();
//Console.WriteLine($"[TEST] Connected to DB: {dbName}");
// Fast reset (example): disable FK checks, truncate, re-enable
//await context.Database.ExecuteSqlRawAsync("SET FOREIGN_KEY_CHECKS = 0;");
//await context.Database.ExecuteSqlRawAsync("TRUNCATE TABLE voice_work_creators;");
//await context.Database.ExecuteSqlRawAsync("TRUNCATE TABLE voice_work_tags;");
//await context.Database.ExecuteSqlRawAsync("TRUNCATE TABLE english_tags;");
//await context.Database.ExecuteSqlRawAsync("TRUNCATE TABLE english_voice_works;");
//await context.Database.ExecuteSqlRawAsync("TRUNCATE TABLE voice_work_searches;");
//await context.Database.ExecuteSqlRawAsync("TRUNCATE TABLE voice_works;");
//await context.Database.ExecuteSqlRawAsync("TRUNCATE TABLE creators;");
//await context.Database.ExecuteSqlRawAsync("TRUNCATE TABLE tags;");
//await context.Database.ExecuteSqlRawAsync("TRUNCATE TABLE circles;");
//await context.Database.ExecuteSqlRawAsync("SET FOREIGN_KEY_CHECKS = 1;");
}
}

View File

@@ -0,0 +1,45 @@
using JSMR.Domain.Entities;
using JSMR.Infrastructure.Data;
using Microsoft.EntityFrameworkCore;
namespace JSMR.Tests.Integration;
public static class Seed
{
public static async Task SeedBasicTagsAsync(AppDbContext context)
{
if (await context.Tags.AnyAsync())
return;
context.Tags.AddRange(
new() { TagId = 1, Name = "OL", Favorite = false, Blacklisted = false },
new() { TagId = 2, Name = "ほのぼの", Favorite = true, Blacklisted = false },
new() { TagId = 3, Name = "ツンデレ", Favorite = false, Blacklisted = true }
);
context.EnglishTags.AddRange(
new() { EnglishTagId = 1, TagId = 1, Name = "Office Lady" },
new() { EnglishTagId = 2, TagId = 2, Name = "Heartwarming" },
new() { EnglishTagId = 3, TagId = 3, Name = "Tsundere" }
);
await context.SaveChangesAsync();
}
public static async Task SeedCirclesWithWorksAsync(AppDbContext context)
{
var c1 = new Circle { Name = "Circle A", MakerId = "mk001", Favorite = false, Blacklisted = false, Spam = false };
var c2 = new Circle { Name = "Circle B", MakerId = "mk002", Favorite = true, Blacklisted = false, Spam = false };
context.Circles.AddRange(c1, c2);
await context.SaveChangesAsync();
context.VoiceWorks.AddRange(
new VoiceWork { CircleId = c1.CircleId, ProductId = "R-1", ProductName = "Work 1", Downloads = 100, SalesDate = new DateTime(2024, 1, 1), HasImage = true },
new VoiceWork { CircleId = c1.CircleId, ProductId = "R-10", ProductName = "Work 10", Downloads = 50, SalesDate = new DateTime(2024, 2, 1), HasImage = false },
new VoiceWork { CircleId = c2.CircleId, ProductId = "R-2", ProductName = "Work 2", Downloads = 200, SalesDate = new DateTime(2024, 3, 1), HasImage = true }
);
await context.SaveChangesAsync();
}
}

View File

@@ -0,0 +1,34 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net9.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<IsPackable>false</IsPackable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="coverlet.collector" Version="6.0.4">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.1" />
<PackageReference Include="Pomelo.EntityFrameworkCore.MySql" Version="9.0.0" />
<PackageReference Include="Testcontainers" Version="4.6.0" />
<PackageReference Include="Testcontainers.MariaDb" Version="4.6.0" />
<PackageReference Include="xunit" Version="2.9.3" />
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.4">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\JSMR.Infrastructure\JSMR.Infrastructure.csproj" />
</ItemGroup>
<ItemGroup>
<Using Include="Xunit" />
</ItemGroup>
</Project>

View File

@@ -9,6 +9,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "JSMR.Application", "JSMR.Ap
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "JSMR.Infrastructure", "JSMR.Infrastructure\JSMR.Infrastructure.csproj", "{10099B7E-DB1D-4EED-B12C-70BEB0C1D996}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "JSMR.Tests", "JSMR.Tests\JSMR.Tests.csproj", "{9C17C2F2-B43E-48F9-960E-E8AEA9F7763E}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
@@ -27,6 +29,10 @@ Global
{10099B7E-DB1D-4EED-B12C-70BEB0C1D996}.Debug|Any CPU.Build.0 = Debug|Any CPU
{10099B7E-DB1D-4EED-B12C-70BEB0C1D996}.Release|Any CPU.ActiveCfg = Release|Any CPU
{10099B7E-DB1D-4EED-B12C-70BEB0C1D996}.Release|Any CPU.Build.0 = Release|Any CPU
{9C17C2F2-B43E-48F9-960E-E8AEA9F7763E}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{9C17C2F2-B43E-48F9-960E-E8AEA9F7763E}.Debug|Any CPU.Build.0 = Debug|Any CPU
{9C17C2F2-B43E-48F9-960E-E8AEA9F7763E}.Release|Any CPU.ActiveCfg = Release|Any CPU
{9C17C2F2-B43E-48F9-960E-E8AEA9F7763E}.Release|Any CPU.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE