From f221deea36e0ec6ef37f64427f4ad447de8c0753 Mon Sep 17 00:00:00 2001 From: Brian Bicknell Date: Thu, 28 Aug 2025 00:50:26 -0400 Subject: [PATCH] Added initial test cases. Fixed circle search provider logic. --- .../Queries/Search/CircleSearchCriteria.cs | 1 + .../Circles/Queries/Search/CircleStatus.cs | 9 ++ JSMR.Infrastructure/Data/AppDbContext.cs | 2 +- .../Circles/CircleSearchProvider.cs | 130 ++++++++++-------- .../Integration/CircleSearchProviderTests.cs | 56 ++++++++ JSMR.Tests/Integration/MariaDbFixture.cs | 89 ++++++++++++ JSMR.Tests/Integration/Seed.cs | 45 ++++++ JSMR.Tests/JSMR.Tests.csproj | 34 +++++ JSMR.sln | 6 + 9 files changed, 313 insertions(+), 59 deletions(-) create mode 100644 JSMR.Application/Circles/Queries/Search/CircleStatus.cs create mode 100644 JSMR.Tests/Integration/CircleSearchProviderTests.cs create mode 100644 JSMR.Tests/Integration/MariaDbFixture.cs create mode 100644 JSMR.Tests/Integration/Seed.cs create mode 100644 JSMR.Tests/JSMR.Tests.csproj diff --git a/JSMR.Application/Circles/Queries/Search/CircleSearchCriteria.cs b/JSMR.Application/Circles/Queries/Search/CircleSearchCriteria.cs index 93c3224..3f8d527 100644 --- a/JSMR.Application/Circles/Queries/Search/CircleSearchCriteria.cs +++ b/JSMR.Application/Circles/Queries/Search/CircleSearchCriteria.cs @@ -3,4 +3,5 @@ public class CircleSearchCriteria { public string? Name { get; init; } + public CircleStatus? Status { get; init; } } \ No newline at end of file diff --git a/JSMR.Application/Circles/Queries/Search/CircleStatus.cs b/JSMR.Application/Circles/Queries/Search/CircleStatus.cs new file mode 100644 index 0000000..e5d6ade --- /dev/null +++ b/JSMR.Application/Circles/Queries/Search/CircleStatus.cs @@ -0,0 +1,9 @@ +namespace JSMR.Application.Circles.Queries.Search; + +public enum CircleStatus +{ + NotBlacklisted, + Favorited, + Blacklisted, + Spam +} \ No newline at end of file diff --git a/JSMR.Infrastructure/Data/AppDbContext.cs b/JSMR.Infrastructure/Data/AppDbContext.cs index c49b8a5..6ccc5b6 100644 --- a/JSMR.Infrastructure/Data/AppDbContext.cs +++ b/JSMR.Infrastructure/Data/AppDbContext.cs @@ -3,7 +3,7 @@ using Microsoft.EntityFrameworkCore; namespace JSMR.Infrastructure.Data; -public class AppDbContext : DbContext +public class AppDbContext(DbContextOptions options) : DbContext(options) { public DbSet VoiceWorks { get; set; } public DbSet EnglishVoiceWorks { get; set; } diff --git a/JSMR.Infrastructure/Data/Repositories/Circles/CircleSearchProvider.cs b/JSMR.Infrastructure/Data/Repositories/Circles/CircleSearchProvider.cs index f731d41..6bbcb51 100644 --- a/JSMR.Infrastructure/Data/Repositories/Circles/CircleSearchProvider.cs +++ b/JSMR.Infrastructure/Data/Repositories/Circles/CircleSearchProvider.cs @@ -9,58 +9,65 @@ public class CircleSearchProvider(AppDbContext context) : SearchProvider 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) + // Project from Circles so we can use correlated subqueries per CircleId. + var q = + from c in context.Circles.AsNoTracking() + select new CircleSearchItem + { + CircleId = c.CircleId, + Name = c.Name, + MakerId = c.MakerId, + Favorite = c.Favorite, + Blacklisted = c.Blacklisted, + Spam = c.Spam, + + // 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() - 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 = - 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, - Name = c.Name, - MakerId = c.MakerId, - 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 - }; - - return baseQuery; + return q; } protected override IQueryable ApplyFilters(IQueryable query, CircleSearchCriteria criteria) @@ -74,14 +81,21 @@ public class CircleSearchProvider(AppDbContext context) : SearchProvider !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; } diff --git a/JSMR.Tests/Integration/CircleSearchProviderTests.cs b/JSMR.Tests/Integration/CircleSearchProviderTests.cs new file mode 100644 index 0000000..0be3c9f --- /dev/null +++ b/JSMR.Tests/Integration/CircleSearchProviderTests.cs @@ -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 +{ + [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 + { + PageNumber = 1, + PageSize = 50, + SortOptions = [new SortOption(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 + // { + // PageNumber = 1, + // PageSize = 50, + // SortOptions = Array.Empty>(), + // 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)); + //} +} diff --git a/JSMR.Tests/Integration/MariaDbFixture.cs b/JSMR.Tests/Integration/MariaDbFixture.cs new file mode 100644 index 0000000..9763c22 --- /dev/null +++ b/JSMR.Tests/Integration/MariaDbFixture.cs @@ -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 options = new DbContextOptionsBuilder() + .UseMySql(ConnectionString, serverVersion, + o => o.EnableRetryOnFailure()) + .EnableSensitiveDataLogging() + .Options; + + return new AppDbContext(options); + } + + /// Clean tables between tests; use Respawn or manual TRUNCATE in correct FK order. + 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;"); + } +} \ No newline at end of file diff --git a/JSMR.Tests/Integration/Seed.cs b/JSMR.Tests/Integration/Seed.cs new file mode 100644 index 0000000..1ac2a2f --- /dev/null +++ b/JSMR.Tests/Integration/Seed.cs @@ -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(); + } +} \ No newline at end of file diff --git a/JSMR.Tests/JSMR.Tests.csproj b/JSMR.Tests/JSMR.Tests.csproj new file mode 100644 index 0000000..49cf77a --- /dev/null +++ b/JSMR.Tests/JSMR.Tests.csproj @@ -0,0 +1,34 @@ + + + + net9.0 + enable + enable + false + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + + + + + + diff --git a/JSMR.sln b/JSMR.sln index 73690af..cd3c108 100644 --- a/JSMR.sln +++ b/JSMR.sln @@ -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