Update search provider sort logic, and added testing for circle search provider.
This commit is contained in:
@@ -5,6 +5,5 @@ public enum CircleSortField
|
|||||||
Name,
|
Name,
|
||||||
Blacklisted,
|
Blacklisted,
|
||||||
Favorite,
|
Favorite,
|
||||||
Spam,
|
Spam
|
||||||
VoiceWorkCount
|
|
||||||
}
|
}
|
||||||
@@ -16,7 +16,7 @@ public abstract class SearchProvider<TItem, TCriteria, TSortField, TBaseQuery> :
|
|||||||
int total = await filteredQuery.CountAsync(cancellationToken);
|
int total = await filteredQuery.CountAsync(cancellationToken);
|
||||||
|
|
||||||
IOrderedQueryable<TBaseQuery> orderedQuery = ApplySorting(filteredQuery, options.SortOptions);
|
IOrderedQueryable<TBaseQuery> orderedQuery = ApplySorting(filteredQuery, options.SortOptions);
|
||||||
IOrderedQueryable<TItem> selectQuery = GetSelectQuery(orderedQuery);
|
IQueryable<TItem> selectQuery = GetSelectQuery(orderedQuery);
|
||||||
|
|
||||||
TItem[] items = await selectQuery
|
TItem[] items = await selectQuery
|
||||||
.Skip((options.PageNumber - 1) * options.PageSize)
|
.Skip((options.PageNumber - 1) * options.PageSize)
|
||||||
@@ -50,10 +50,40 @@ public abstract class SearchProvider<TItem, TCriteria, TSortField, TBaseQuery> :
|
|||||||
ordered = (i == 0) ? applyFirst(selector) : applyNext(selector);
|
ordered = (i == 0) ? applyFirst(selector) : applyNext(selector);
|
||||||
}
|
}
|
||||||
|
|
||||||
return ordered ?? GetDefaultSortExpression(query);
|
//return ordered ?? GetDefaultSortExpression(query);
|
||||||
|
|
||||||
|
// Always add the default as the final tiebreaker
|
||||||
|
var chain = GetDefaultSortChain();
|
||||||
|
|
||||||
|
if (ordered is null)
|
||||||
|
{
|
||||||
|
using var e = chain.GetEnumerator();
|
||||||
|
if (!e.MoveNext()) throw new InvalidOperationException("No default sort provided.");
|
||||||
|
|
||||||
|
var (Selector, Dir) = e.Current;
|
||||||
|
var res = Dir == SortDirection.Descending
|
||||||
|
? query.OrderByDescending(Selector)
|
||||||
|
: query.OrderBy(Selector);
|
||||||
|
|
||||||
|
while (e.MoveNext())
|
||||||
|
res = e.Current.Dir == SortDirection.Descending
|
||||||
|
? res.ThenByDescending(e.Current.Selector)
|
||||||
|
: res.ThenBy(e.Current.Selector);
|
||||||
|
|
||||||
|
return res;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
var res = ordered;
|
||||||
|
foreach (var (sel, dir) in chain)
|
||||||
|
res = dir == SortDirection.Descending ? res.ThenByDescending(sel) : res.ThenBy(sel);
|
||||||
|
return res;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
protected abstract Expression<Func<TBaseQuery, object>> GetSortExpression(TSortField field);
|
protected abstract Expression<Func<TBaseQuery, object>> GetSortExpression(TSortField field);
|
||||||
protected abstract IOrderedQueryable<TBaseQuery> GetDefaultSortExpression(IQueryable<TBaseQuery> query);
|
protected abstract IOrderedQueryable<TBaseQuery> GetDefaultSortExpression(IQueryable<TBaseQuery> query);
|
||||||
protected abstract IOrderedQueryable<TItem> GetSelectQuery(IOrderedQueryable<TBaseQuery> query);
|
//protected abstract (Expression<Func<TBaseQuery, object>> Selector, SortDirection Direction) GetDefaultSortExpression();
|
||||||
|
protected abstract IEnumerable<(Expression<Func<TBaseQuery, object>> Selector, SortDirection Dir)> GetDefaultSortChain();
|
||||||
|
protected abstract IQueryable<TItem> GetSelectQuery(IOrderedQueryable<TBaseQuery> query);
|
||||||
}
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
using JSMR.Application.Circles.Queries.Search;
|
using JSMR.Application.Circles.Queries.Search;
|
||||||
|
using JSMR.Application.Common.Search;
|
||||||
using JSMR.Infrastructure.Common.Queries;
|
using JSMR.Infrastructure.Common.Queries;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using System.Linq.Expressions;
|
using System.Linq.Expressions;
|
||||||
@@ -102,22 +103,22 @@ public class CircleSearchProvider(AppDbContext context) : SearchProvider<CircleS
|
|||||||
|
|
||||||
protected override Expression<Func<CircleSearchItem, object>> GetSortExpression(CircleSortField field) => field switch
|
protected override Expression<Func<CircleSearchItem, object>> GetSortExpression(CircleSortField field) => field switch
|
||||||
{
|
{
|
||||||
//CircleSortField.MakerId => x => x.MakerId,
|
CircleSortField.Favorite => x => !x.Favorite,
|
||||||
//CircleSortField.Downloads => x => x.Downloads,
|
CircleSortField.Blacklisted => x => !x.Blacklisted,
|
||||||
//CircleSortField.Releases => x => x.Releases,
|
CircleSortField.Spam => x => !x.Spam,
|
||||||
//CircleSortField.Pending => x => x.Pending,
|
|
||||||
//CircleSortField.FirstReleaseDate => x => x.FirstReleaseDate ?? DateTime.MinValue,
|
|
||||||
//CircleSortField.LatestReleaseDate => x => x.LatestReleaseDate ?? DateTime.MinValue,
|
|
||||||
CircleSortField.Favorite => x => x.Favorite,
|
|
||||||
CircleSortField.Blacklisted => x => x.Blacklisted,
|
|
||||||
CircleSortField.Spam => x => x.Spam,
|
|
||||||
_ => x => x.Name
|
_ => x => x.Name
|
||||||
};
|
};
|
||||||
|
|
||||||
protected override IOrderedQueryable<CircleSearchItem> GetDefaultSortExpression(IQueryable<CircleSearchItem> query)
|
protected override IOrderedQueryable<CircleSearchItem> GetDefaultSortExpression(IQueryable<CircleSearchItem> query)
|
||||||
=> query.OrderBy(x => x.Name).ThenBy(x => x.CircleId);
|
=> query.OrderBy(x => x.Name).ThenBy(x => x.CircleId);
|
||||||
|
|
||||||
protected override IOrderedQueryable<CircleSearchItem> GetSelectQuery(IOrderedQueryable<CircleSearchItem> query)
|
protected override IEnumerable<(Expression<Func<CircleSearchItem, object>> Selector, SortDirection Dir)> GetDefaultSortChain()
|
||||||
|
{
|
||||||
|
yield return (x => x.Name, SortDirection.Ascending);
|
||||||
|
yield return (x => x.MakerId, SortDirection.Ascending);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected override IQueryable<CircleSearchItem> GetSelectQuery(IOrderedQueryable<CircleSearchItem> query)
|
||||||
{
|
{
|
||||||
// Join to VoiceWorks by LatestProductId to fill HasImage / SalesDate
|
// Join to VoiceWorks by LatestProductId to fill HasImage / SalesDate
|
||||||
var selected =
|
var selected =
|
||||||
@@ -142,9 +143,6 @@ public class CircleSearchProvider(AppDbContext context) : SearchProvider<CircleS
|
|||||||
LatestVoiceWorkSalesDate = latest != null ? latest.SalesDate : null
|
LatestVoiceWorkSalesDate = latest != null ? latest.SalesDate : null
|
||||||
};
|
};
|
||||||
|
|
||||||
// Preserve existing ordering; add stable tiebreaker
|
return selected;
|
||||||
return selected.OrderBy(x => 0).ThenBy(x => x.Name).ThenBy(x => x.CircleId);
|
|
||||||
// NOTE: If your base class re-applies ordering after Select, you can just:
|
|
||||||
// return selected.OrderBy(x => x.Name).ThenBy(x => x.CircleId);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
using JSMR.Application.Creators.Queries.Search.Contracts;
|
using JSMR.Application.Common.Search;
|
||||||
|
using JSMR.Application.Creators.Queries.Search.Contracts;
|
||||||
using JSMR.Application.Creators.Queries.Search.Ports;
|
using JSMR.Application.Creators.Queries.Search.Ports;
|
||||||
using JSMR.Infrastructure.Common.Queries;
|
using JSMR.Infrastructure.Common.Queries;
|
||||||
using System.Linq.Expressions;
|
using System.Linq.Expressions;
|
||||||
@@ -54,6 +55,11 @@ public class CreatorSearchProvider(AppDbContext context) : SearchProvider<Creato
|
|||||||
return query.OrderBy(x => x.Name);
|
return query.OrderBy(x => x.Name);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
protected override IEnumerable<(Expression<Func<CreatorSearchItem, object>> Selector, SortDirection Dir)> GetDefaultSortChain()
|
||||||
|
{
|
||||||
|
yield return (x => x.Name ?? string.Empty, SortDirection.Ascending);
|
||||||
|
}
|
||||||
|
|
||||||
protected override IOrderedQueryable<CreatorSearchItem> GetSelectQuery(IOrderedQueryable<CreatorSearchItem> query)
|
protected override IOrderedQueryable<CreatorSearchItem> GetSelectQuery(IOrderedQueryable<CreatorSearchItem> query)
|
||||||
{
|
{
|
||||||
return query;
|
return query;
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
using JSMR.Application.Tags.Queries.Search.Contracts;
|
using JSMR.Application.Common.Search;
|
||||||
|
using JSMR.Application.Creators.Queries.Search.Contracts;
|
||||||
|
using JSMR.Application.Tags.Queries.Search.Contracts;
|
||||||
using JSMR.Application.Tags.Queries.Search.Ports;
|
using JSMR.Application.Tags.Queries.Search.Ports;
|
||||||
using JSMR.Infrastructure.Common.Queries;
|
using JSMR.Infrastructure.Common.Queries;
|
||||||
using System.Linq.Expressions;
|
using System.Linq.Expressions;
|
||||||
@@ -59,6 +61,11 @@ public class TagSearchProvider(AppDbContext context) : SearchProvider<TagSearchI
|
|||||||
return query.OrderBy(x => x.Name);
|
return query.OrderBy(x => x.Name);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
protected override IEnumerable<(Expression<Func<TagSearchItem, object>> Selector, SortDirection Dir)> GetDefaultSortChain()
|
||||||
|
{
|
||||||
|
yield return (x => x.Name ?? string.Empty, SortDirection.Ascending);
|
||||||
|
}
|
||||||
|
|
||||||
protected override IOrderedQueryable<TagSearchItem> GetSelectQuery(IOrderedQueryable<TagSearchItem> query)
|
protected override IOrderedQueryable<TagSearchItem> GetSelectQuery(IOrderedQueryable<TagSearchItem> query)
|
||||||
{
|
{
|
||||||
return query;
|
return query;
|
||||||
|
|||||||
28
JSMR.Tests/Fixtures/CircleSearchProviderFixture.cs
Normal file
28
JSMR.Tests/Fixtures/CircleSearchProviderFixture.cs
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
67
JSMR.Tests/Fixtures/MariaDbFixture.cs
Normal file
67
JSMR.Tests/Fixtures/MariaDbFixture.cs
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
65
JSMR.Tests/Fixtures/SearchProviderFixture.cs
Normal file
65
JSMR.Tests/Fixtures/SearchProviderFixture.cs
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,54 +3,234 @@ using JSMR.Application.Circles.Queries.Search;
|
|||||||
using JSMR.Application.Common.Search;
|
using JSMR.Application.Common.Search;
|
||||||
using JSMR.Infrastructure.Data;
|
using JSMR.Infrastructure.Data;
|
||||||
using JSMR.Infrastructure.Data.Repositories.Circles;
|
using JSMR.Infrastructure.Data.Repositories.Circles;
|
||||||
|
using JSMR.Tests.Fixtures;
|
||||||
|
using Shouldly;
|
||||||
|
|
||||||
namespace JSMR.Tests.Integration;
|
namespace JSMR.Tests.Integration;
|
||||||
|
|
||||||
public class CircleSearchProviderTests(MariaDbFixture fixture) : IClassFixture<MariaDbFixture>
|
public class CircleSearchProviderTests(CircleSearchProviderFixture fixture) : IClassFixture<CircleSearchProviderFixture>
|
||||||
{
|
{
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task Search_ByName_Filters_And_Sorts()
|
public async Task Filter_None()
|
||||||
{
|
{
|
||||||
await fixture.ResetAsync();
|
|
||||||
await using AppDbContext context = fixture.CreateDbContext();
|
await using AppDbContext context = fixture.CreateDbContext();
|
||||||
await Seed.SeedCirclesWithWorksAsync(context);
|
|
||||||
|
|
||||||
CircleSearchProvider provider = new(context);
|
CircleSearchProvider provider = new(context);
|
||||||
|
|
||||||
var options = new SearchOptions<CircleSearchCriteria, CircleSortField>
|
var options = new SearchOptions<CircleSearchCriteria, CircleSortField>()
|
||||||
{
|
{
|
||||||
PageNumber = 1,
|
Criteria = new()
|
||||||
PageSize = 50,
|
{
|
||||||
SortOptions = [new SortOption<CircleSortField>(CircleSortField.Name, SortDirection.Ascending)],
|
|
||||||
Criteria = new CircleSearchCriteria { Name = "Circle" }
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
var result = await provider.SearchAsync(options, CancellationToken.None);
|
var result = await provider.SearchAsync(options, CancellationToken.None);
|
||||||
|
|
||||||
Assert.True(result.TotalItems >= 2);
|
result.Items.Length.ShouldBe(4);
|
||||||
Assert.Equal("Circle A", result.Items[0].Name);
|
result.TotalItems.ShouldBe(4);
|
||||||
Assert.Equal("Circle B", result.Items[1].Name);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
//[Fact]
|
[Fact]
|
||||||
//public async Task Search_Status_Favorited_Only()
|
public async Task Filter_By_Status_Not_Blacklisted()
|
||||||
//{
|
{
|
||||||
// await fixture.ResetAsync();
|
await using AppDbContext context = fixture.CreateDbContext();
|
||||||
// await using var db = fixture.CreateDbContext();
|
CircleSearchProvider provider = new(context);
|
||||||
// await Seed.SeedCirclesWithWorksAsync(db);
|
|
||||||
|
|
||||||
// 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>
|
var result = await provider.SearchAsync(options, CancellationToken.None);
|
||||||
// {
|
|
||||||
// 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);
|
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");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -14,8 +14,9 @@
|
|||||||
</PackageReference>
|
</PackageReference>
|
||||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.1" />
|
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.1" />
|
||||||
<PackageReference Include="Pomelo.EntityFrameworkCore.MySql" Version="9.0.0" />
|
<PackageReference Include="Pomelo.EntityFrameworkCore.MySql" Version="9.0.0" />
|
||||||
<PackageReference Include="Testcontainers" Version="4.6.0" />
|
<PackageReference Include="Shouldly" Version="4.3.0" />
|
||||||
<PackageReference Include="Testcontainers.MariaDb" Version="4.6.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" Version="2.9.3" />
|
||||||
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.4">
|
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.4">
|
||||||
<PrivateAssets>all</PrivateAssets>
|
<PrivateAssets>all</PrivateAssets>
|
||||||
|
|||||||
Reference in New Issue
Block a user