Added initial test cases. Fixed circle search provider logic.
This commit is contained in:
@@ -3,4 +3,5 @@
|
||||
public class CircleSearchCriteria
|
||||
{
|
||||
public string? Name { get; init; }
|
||||
public CircleStatus? Status { get; init; }
|
||||
}
|
||||
9
JSMR.Application/Circles/Queries/Search/CircleStatus.cs
Normal file
9
JSMR.Application/Circles/Queries/Search/CircleStatus.cs
Normal file
@@ -0,0 +1,9 @@
|
||||
namespace JSMR.Application.Circles.Queries.Search;
|
||||
|
||||
public enum CircleStatus
|
||||
{
|
||||
NotBlacklisted,
|
||||
Favorited,
|
||||
Blacklisted,
|
||||
Spam
|
||||
}
|
||||
@@ -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; }
|
||||
|
||||
@@ -9,58 +9,65 @@ 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)
|
||||
// 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<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;
|
||||
}
|
||||
|
||||
56
JSMR.Tests/Integration/CircleSearchProviderTests.cs
Normal file
56
JSMR.Tests/Integration/CircleSearchProviderTests.cs
Normal 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));
|
||||
//}
|
||||
}
|
||||
89
JSMR.Tests/Integration/MariaDbFixture.cs
Normal file
89
JSMR.Tests/Integration/MariaDbFixture.cs
Normal 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;");
|
||||
}
|
||||
}
|
||||
45
JSMR.Tests/Integration/Seed.cs
Normal file
45
JSMR.Tests/Integration/Seed.cs
Normal 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();
|
||||
}
|
||||
}
|
||||
34
JSMR.Tests/JSMR.Tests.csproj
Normal file
34
JSMR.Tests/JSMR.Tests.csproj
Normal 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>
|
||||
6
JSMR.sln
6
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
|
||||
|
||||
Reference in New Issue
Block a user