Update search provider sort logic, and added testing for circle search provider.

This commit is contained in:
2025-08-30 16:21:35 -04:00
parent f221deea36
commit 516060963e
11 changed files with 435 additions and 143 deletions

View File

@@ -0,0 +1,28 @@
using JSMR.Infrastructure.Data;
using Microsoft.EntityFrameworkCore;
namespace JSMR.Tests.Fixtures;
public class CircleSearchProviderFixture : MariaDbFixture
{
protected override async Task OnInitializedAsync(AppDbContext context)
{
await SeedAsync(context);
}
private static async Task SeedAsync(AppDbContext context)
{
// Make seeding idempotent (quick existence check)
if (await context.Circles.AnyAsync())
return;
context.Circles.AddRange(
new() { CircleId = 1, Name = "Good Dreams", MakerId = "RG00001" },
new() { CircleId = 2, Name = "Sweet Dreams", Favorite = true, MakerId = "RG00002" },
new() { CircleId = 3, Name = "Nightmare Fuel", Blacklisted = true, MakerId = "RG00003" },
new() { CircleId = 4, Name = "Garbage Studio", Spam = true, MakerId = "RG00004" }
);
await context.SaveChangesAsync();
}
}

View File

@@ -0,0 +1,67 @@
using JSMR.Infrastructure.Data;
using Microsoft.EntityFrameworkCore;
using Testcontainers.MariaDb;
namespace JSMR.Tests.Fixtures;
public class MariaDbFixture : IAsyncLifetime
{
const int MajorVersion = 10;
const int MinorVersion = 11;
const int Build = 6;
public MariaDbContainer? MariaDbContainer { get; private set; }
public string ConnectionString { get; private set; } = default!;
public async Task InitializeAsync()
{
MariaDbContainer = new MariaDbBuilder()
.WithImage($"mariadb:{MajorVersion}.{MinorVersion}.{Build}")
.Build();
await MariaDbContainer.StartAsync();
ConnectionString = MariaDbContainer.GetConnectionString();
await using AppDbContext context = CreateDbContext();
await context.Database.EnsureCreatedAsync();
await OnInitializedAsync(context);
}
protected virtual Task OnInitializedAsync(AppDbContext context)
{
return Task.FromResult(Task.CompletedTask);
}
public async Task DisposeAsync()
{
if (MariaDbContainer is not null)
{
await MariaDbContainer.StopAsync();
await MariaDbContainer.DisposeAsync();
}
}
public AppDbContext CreateDbContext()
{
MySqlServerVersion serverVersion = new(new Version(MajorVersion, MinorVersion, Build));
DbContextOptions<AppDbContext> options = new DbContextOptionsBuilder<AppDbContext>()
.UseMySql(ConnectionString, serverVersion,
o => o.EnableRetryOnFailure())
.EnableSensitiveDataLogging()
.Options;
return new AppDbContext(options);
}
public async Task ResetAsync()
{
await using AppDbContext context = CreateDbContext();
await context.Database.EnsureDeletedAsync();
await context.Database.EnsureCreatedAsync();
}
}

View File

@@ -0,0 +1,65 @@
using JSMR.Infrastructure.Data;
using Microsoft.EntityFrameworkCore;
namespace JSMR.Tests.Fixtures;
public class SearchProviderFixture : MariaDbFixture
{
protected override async Task OnInitializedAsync(AppDbContext context)
{
await SeedAsync(context);
}
private static async Task SeedAsync(AppDbContext context)
{
// Make seeding idempotent (quick existence check)
if (await context.Circles.AnyAsync())
return;
context.Circles.AddRange(
new() { CircleId = 1, Name = "Good Dreams", MakerId = "RG00001" },
new() { CircleId = 2, Name = "Sweet Dreams", Favorite = true, MakerId = "RG00002" },
new() { CircleId = 3, Name = "Nightmare Fuel", Blacklisted = true, MakerId = "RG00003" }
);
//context.VoiceWorks.AddRange(
// new() { VoiceWorkId = 1, CircleId = 1, ProductId = "RJ0000001", ProductName = "Today Sounds", Description = "An average product.", Status = (byte)VoiceWorkStatus.Available },
// new() { VoiceWorkId = 2, CircleId = 2, ProductId = "RJ0000002", ProductName = "Super Comfy ASMR", Description = "An amazing product!", Status = (byte)VoiceWorkStatus.NewRelease },
// new() { VoiceWorkId = 4, CircleId = 3, ProductId = "RJ0000003", ProductName = "Low Effort", Description = "A bad product.", Status = (byte)VoiceWorkStatus.Available },
// new() { VoiceWorkId = 5, CircleId = 1, ProductId = "RJ0000004", ProductName = "Tomorrow Sounds", Description = "A average upcoming product.", Status = (byte)VoiceWorkStatus.Upcoming },
// new() { VoiceWorkId = 6, CircleId = 2, ProductId = "RJ0000005", ProductName = "Super Comfy ASMR+", Description = "All your favorite sounds, plus more!", Status = (byte)VoiceWorkStatus.NewAndUpcoming }
//);
context.Tags.AddRange(
new() { TagId = 1, Name = "ASMR" },
new() { TagId = 2, Name = "OL" },
new() { TagId = 3, Name = "ほのぼの" },
new() { TagId = 4, Name = "エルフ/妖精" },
new() { TagId = 5, Name = "ツンデレ", Favorite = true },
new() { TagId = 6, Name = "オールハッピー" },
new() { TagId = 7, Name = "ギャル" },
new() { TagId = 8, Name = "メイド" }
);
context.EnglishTags.AddRange(
new() { EnglishTagId = 1, TagId = 1, Name = "ASMR" },
new() { EnglishTagId = 2, TagId = 2, Name = "Office Lady" },
new() { EnglishTagId = 3, TagId = 3, Name = "Heartwarming" },
new() { EnglishTagId = 4, TagId = 4, Name = "Elf / Fairy" },
new() { EnglishTagId = 5, TagId = 5, Name = "Tsundere" },
new() { EnglishTagId = 6, TagId = 6, Name = "All Happy" },
new() { EnglishTagId = 7, TagId = 7, Name = "Gal" },
new() { EnglishTagId = 8, TagId = 8, Name = "Maid" }
);
context.Creators.AddRange(
new() { CreatorId = 1, Name = "陽向葵ゅか", Favorite = true },
new() { CreatorId = 2, Name = "秋野かえで" },
new() { CreatorId = 3, Name = "柚木つばめ" },
new() { CreatorId = 4, Name = "逢坂成美" },
new() { CreatorId = 5, Name = "山田じぇみ子", Blacklisted = true }
);
await context.SaveChangesAsync();
}
}

View File

@@ -3,54 +3,234 @@ using JSMR.Application.Circles.Queries.Search;
using JSMR.Application.Common.Search;
using JSMR.Infrastructure.Data;
using JSMR.Infrastructure.Data.Repositories.Circles;
using JSMR.Tests.Fixtures;
using Shouldly;
namespace JSMR.Tests.Integration;
public class CircleSearchProviderTests(MariaDbFixture fixture) : IClassFixture<MariaDbFixture>
public class CircleSearchProviderTests(CircleSearchProviderFixture fixture) : IClassFixture<CircleSearchProviderFixture>
{
[Fact]
public async Task Search_ByName_Filters_And_Sorts()
public async Task Filter_None()
{
await fixture.ResetAsync();
await using AppDbContext context = fixture.CreateDbContext();
await Seed.SeedCirclesWithWorksAsync(context);
CircleSearchProvider provider = new(context);
var options = new SearchOptions<CircleSearchCriteria, CircleSortField>
var options = new SearchOptions<CircleSearchCriteria, CircleSortField>()
{
PageNumber = 1,
PageSize = 50,
SortOptions = [new SortOption<CircleSortField>(CircleSortField.Name, SortDirection.Ascending)],
Criteria = new CircleSearchCriteria { Name = "Circle" }
Criteria = new()
{
}
};
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);
result.Items.Length.ShouldBe(4);
result.TotalItems.ShouldBe(4);
}
//[Fact]
//public async Task Search_Status_Favorited_Only()
//{
// await fixture.ResetAsync();
// await using var db = fixture.CreateDbContext();
// await Seed.SeedCirclesWithWorksAsync(db);
[Fact]
public async Task Filter_By_Status_Not_Blacklisted()
{
await using AppDbContext context = fixture.CreateDbContext();
CircleSearchProvider provider = new(context);
// var provider = new CircleSearchProvider(db);
var options = new SearchOptions<CircleSearchCriteria, CircleSortField>()
{
Criteria = new()
{
Status = Application.Circles.Queries.Search.CircleStatus.NotBlacklisted
}
};
// 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);
// var result = await provider.SearchAsync(options, CancellationToken.None);
result.Items.Length.ShouldBe(3);
result.TotalItems.ShouldBe(3);
result.Items.ShouldNotContain(item => item.Blacklisted);
}
// Assert.All(result.Items, i => Assert.True(i.Favorite));
//}
}
[Fact]
public async Task Filter_By_Status_Favorited()
{
await using AppDbContext context = fixture.CreateDbContext();
CircleSearchProvider provider = new(context);
var options = new SearchOptions<CircleSearchCriteria, CircleSortField>()
{
Criteria = new()
{
Status = Application.Circles.Queries.Search.CircleStatus.Favorited
}
};
var result = await provider.SearchAsync(options, CancellationToken.None);
result.Items.Length.ShouldBe(1);
result.TotalItems.ShouldBe(1);
result.Items.ShouldAllBe(item => item.Favorite);
}
[Fact]
public async Task Filter_By_Status_Blacklisted()
{
await using AppDbContext context = fixture.CreateDbContext();
CircleSearchProvider provider = new(context);
var options = new SearchOptions<CircleSearchCriteria, CircleSortField>()
{
Criteria = new()
{
Status = Application.Circles.Queries.Search.CircleStatus.Blacklisted
}
};
var result = await provider.SearchAsync(options, CancellationToken.None);
result.Items.Length.ShouldBe(1);
result.TotalItems.ShouldBe(1);
result.Items.ShouldAllBe(item => item.Blacklisted);
}
[Fact]
public async Task Filter_By_Status_Spam()
{
await using AppDbContext context = fixture.CreateDbContext();
CircleSearchProvider provider = new(context);
var options = new SearchOptions<CircleSearchCriteria, CircleSortField>()
{
Criteria = new()
{
Status = Application.Circles.Queries.Search.CircleStatus.Spam
}
};
var result = await provider.SearchAsync(options, CancellationToken.None);
result.Items.Length.ShouldBe(1);
result.TotalItems.ShouldBe(1);
result.Items.ShouldAllBe(item => item.Spam);
}
[Fact]
public async Task Filter_By_Name_Circle_Name()
{
await using AppDbContext context = fixture.CreateDbContext();
CircleSearchProvider provider = new(context);
var options = new SearchOptions<CircleSearchCriteria, CircleSortField>()
{
Criteria = new()
{
Name = "Dreams"
}
};
var result = await provider.SearchAsync(options, CancellationToken.None);
result.Items.Length.ShouldBe(2);
result.TotalItems.ShouldBe(2);
result.Items.ShouldAllBe(item => item.Name.Contains("Dreams", StringComparison.OrdinalIgnoreCase));
}
[Fact]
public async Task Filter_By_Name_Circle_Id()
{
await using AppDbContext context = fixture.CreateDbContext();
CircleSearchProvider provider = new(context);
var options = new SearchOptions<CircleSearchCriteria, CircleSortField>()
{
Criteria = new()
{
Name = "003"
}
};
var result = await provider.SearchAsync(options, CancellationToken.None);
result.Items.Length.ShouldBe(1);
result.TotalItems.ShouldBe(1);
result.Items.ShouldContain(item => item.MakerId.Contains("003", StringComparison.OrdinalIgnoreCase));
}
[Fact]
public async Task Sort_By_Name_Descending()
{
await using AppDbContext context = fixture.CreateDbContext();
CircleSearchProvider provider = new(context);
var options = new SearchOptions<CircleSearchCriteria, CircleSortField>()
{
SortOptions = [new(CircleSortField.Name, Application.Common.Search.SortDirection.Descending)]
};
var result = await provider.SearchAsync(options, CancellationToken.None);
result.Items.Length.ShouldBe(4);
result.TotalItems.ShouldBe(4);
result.Items[0].Name.ShouldBe("Sweet Dreams");
result.Items[1].Name.ShouldBe("Nightmare Fuel");
result.Items[2].Name.ShouldBe("Good Dreams");
result.Items[3].Name.ShouldBe("Garbage Studio");
}
[Fact]
public async Task Sort_By_Favorite_Ascending()
{
await using AppDbContext context = fixture.CreateDbContext();
CircleSearchProvider provider = new(context);
var options = new SearchOptions<CircleSearchCriteria, CircleSortField>()
{
SortOptions = [new(CircleSortField.Favorite, Application.Common.Search.SortDirection.Ascending)]
};
var result = await provider.SearchAsync(options, CancellationToken.None);
result.Items.Length.ShouldBe(4);
result.TotalItems.ShouldBe(4);
result.Items[0].Name.ShouldBe("Sweet Dreams");
result.Items[1].Name.ShouldBe("Garbage Studio");
}
[Fact]
public async Task Sort_By_Blacklisted_Ascending()
{
await using AppDbContext context = fixture.CreateDbContext();
CircleSearchProvider provider = new(context);
var options = new SearchOptions<CircleSearchCriteria, CircleSortField>()
{
SortOptions = [new(CircleSortField.Blacklisted, Application.Common.Search.SortDirection.Ascending)]
};
var result = await provider.SearchAsync(options, CancellationToken.None);
result.Items.Length.ShouldBe(4);
result.TotalItems.ShouldBe(4);
result.Items[0].Name.ShouldBe("Nightmare Fuel");
result.Items[1].Name.ShouldBe("Garbage Studio");
}
[Fact]
public async Task Sort_By_Spam_Ascending()
{
await using AppDbContext context = fixture.CreateDbContext();
CircleSearchProvider provider = new(context);
var options = new SearchOptions<CircleSearchCriteria, CircleSortField>()
{
SortOptions = [new(CircleSortField.Spam, Application.Common.Search.SortDirection.Ascending)]
};
var result = await provider.SearchAsync(options, CancellationToken.None);
result.Items.Length.ShouldBe(4);
result.TotalItems.ShouldBe(4);
result.Items[0].Name.ShouldBe("Garbage Studio");
result.Items[1].Name.ShouldBe("Good Dreams");
}
}

View File

@@ -1,89 +0,0 @@
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

@@ -14,8 +14,9 @@
</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="Shouldly" Version="4.3.0" />
<PackageReference Include="Testcontainers" Version="4.7.0" />
<PackageReference Include="Testcontainers.MariaDb" Version="4.7.0" />
<PackageReference Include="xunit" Version="2.9.3" />
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.4">
<PrivateAssets>all</PrivateAssets>