Refactor AdminUserEndpoints and MediaEndpoints to include default parameter values for pagination and filtering, improving API usability. Update ManualInboxDialog to enhance show selection logic with auto-detection based on file names, and improve user feedback with new translations. Refactor PostgresFixture for better container management in integration tests.
This commit is contained in:
@@ -32,16 +32,20 @@ public static class AdminUserEndpoints
|
||||
return app;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Все параметры со значениями по умолчанию: обязательный <c>bool</c> в query превращает
|
||||
/// запрос без него в 400 ещё до хендлера — ошибку в таком виде отладить тяжело.
|
||||
/// </summary>
|
||||
private static async Task<IResult> ListUsers(
|
||||
int page,
|
||||
int pageSize,
|
||||
string? search,
|
||||
Guid? roleId,
|
||||
bool? isBlocked,
|
||||
string? sort,
|
||||
bool desc,
|
||||
ISender sender,
|
||||
CancellationToken cancellationToken
|
||||
CancellationToken cancellationToken,
|
||||
int page = 1,
|
||||
int pageSize = 20,
|
||||
string? search = null,
|
||||
Guid? roleId = null,
|
||||
bool? isBlocked = null,
|
||||
string? sort = null,
|
||||
bool desc = false
|
||||
)
|
||||
{
|
||||
var result = await sender.Send(
|
||||
|
||||
@@ -109,15 +109,20 @@ public static class MediaEndpoints
|
||||
);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Все параметры со значениями по умолчанию: обязательный <c>bool</c> в query заставлял
|
||||
/// минимальный API отвечать 400 на запросы без него — например, из выборок «все готовые ассеты»,
|
||||
/// которым сортировка не нужна.
|
||||
/// </summary>
|
||||
private static async Task<IResult> List(
|
||||
int page,
|
||||
int pageSize,
|
||||
MediaAssetStatus[]? status,
|
||||
string? search,
|
||||
string? sort,
|
||||
bool desc,
|
||||
ISender sender,
|
||||
CancellationToken cancellationToken
|
||||
CancellationToken cancellationToken,
|
||||
int page = 1,
|
||||
int pageSize = 20,
|
||||
MediaAssetStatus[]? status = null,
|
||||
string? search = null,
|
||||
string? sort = null,
|
||||
bool desc = false
|
||||
)
|
||||
{
|
||||
var result = await sender.Send(
|
||||
|
||||
@@ -74,6 +74,8 @@ public sealed class GetShowQueryHandler(IAppDbContext dbContext)
|
||||
})
|
||||
.ToList();
|
||||
|
||||
// Сортировка идёт до сборки DTO: по свойству record'а EF порядок не переводит и падает
|
||||
// на компиляции запроса («The LINQ expression could not be translated»).
|
||||
var collections = await dbContext
|
||||
.CollectionItems.AsNoTracking()
|
||||
.Where(i => i.ShowId == show.Id)
|
||||
@@ -82,9 +84,15 @@ public sealed class GetShowQueryHandler(IAppDbContext dbContext)
|
||||
item => item.CollectionId,
|
||||
collection => collection.Id,
|
||||
(item, collection) =>
|
||||
new ShowCollectionRefDto(collection.Id, collection.Name, item.Position)
|
||||
new
|
||||
{
|
||||
collection.Id,
|
||||
collection.Name,
|
||||
item.Position,
|
||||
}
|
||||
)
|
||||
.OrderBy(c => c.Name)
|
||||
.Select(c => new ShowCollectionRefDto(c.Id, c.Name, c.Position))
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
return Result.Success(
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
using TeleWave.Application.Library.GetShow;
|
||||
using TeleWave.Domain.Library;
|
||||
using TeleWave.Domain.Media;
|
||||
using TeleWave.Infrastructure.Persistence;
|
||||
using Xunit;
|
||||
|
||||
namespace TeleWave.Integration.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Карточка шоу против настоящей БД: запрос собирает серии, жанры и коллекции несколькими
|
||||
/// проекциями, и часть из них EF может не суметь перевести — на InMemory такое не всплывает.
|
||||
/// </summary>
|
||||
[Collection("postgres")]
|
||||
public sealed class GetShowIntegrationTests(PostgresFixture fixture)
|
||||
{
|
||||
[SkippableFact]
|
||||
public async Task GetShow_ReturnsEpisodesGenresAndCollections()
|
||||
{
|
||||
Skip.IfNot(fixture.Available, "Docker недоступен");
|
||||
|
||||
await using var seed = fixture.CreateContext();
|
||||
var showId = await SeedAsync(seed);
|
||||
|
||||
await using var db = fixture.CreateContext();
|
||||
var result = await new GetShowQueryHandler(db).Handle(new GetShowQuery(showId), default);
|
||||
|
||||
Assert.True(result.IsSuccess);
|
||||
Assert.Equal(2, result.Value.Episodes.Count);
|
||||
Assert.Equal(2, result.Value.Genres.Count);
|
||||
Assert.Single(result.Value.Collections);
|
||||
Assert.True(result.Value.Genres[0].IsPrimary);
|
||||
Assert.All(result.Value.Episodes, e => Assert.NotNull(e.AssetName));
|
||||
}
|
||||
|
||||
[SkippableFact]
|
||||
public async Task GetShow_WithoutGenresAndCollections_Works()
|
||||
{
|
||||
Skip.IfNot(fixture.Available, "Docker недоступен");
|
||||
|
||||
await using var seed = fixture.CreateContext();
|
||||
var show = Show.Create($"Голое шоу {Guid.NewGuid():N}"[..20], ShowKind.Series);
|
||||
seed.Shows.Add(show);
|
||||
await seed.SaveChangesAsync();
|
||||
|
||||
await using var db = fixture.CreateContext();
|
||||
var result = await new GetShowQueryHandler(db).Handle(new GetShowQuery(show.Id), default);
|
||||
|
||||
Assert.True(result.IsSuccess);
|
||||
Assert.Empty(result.Value.Episodes);
|
||||
Assert.Empty(result.Value.Genres);
|
||||
Assert.Empty(result.Value.Collections);
|
||||
}
|
||||
|
||||
private static async Task<Guid> SeedAsync(AppDbContext db)
|
||||
{
|
||||
var tag = Guid.NewGuid().ToString("N")[..8];
|
||||
|
||||
var action = Genre.Create($"Боевик {tag}", $"action-{tag}");
|
||||
var comedy = Genre.Create($"Комедия {tag}", $"comedy-{tag}");
|
||||
db.Genres.AddRange(action, comedy);
|
||||
|
||||
var show = Show.Create($"Шоу {tag}", ShowKind.Series);
|
||||
for (var i = 0; i < 2; i++)
|
||||
{
|
||||
var asset = MediaAsset.Register($"{tag}-s01e0{i}.mkv", ".mkv", MediaSource.Upload);
|
||||
db.MediaAssets.Add(asset);
|
||||
show.AddEpisode(asset.Id);
|
||||
}
|
||||
show.SetGenres([action.Id, comedy.Id], action.Id);
|
||||
db.Shows.Add(show);
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
var collection = Collection.Create($"Франшиза {tag}");
|
||||
collection.AddShow(show.Id);
|
||||
db.Collections.Add(collection);
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
return show.Id;
|
||||
}
|
||||
}
|
||||
@@ -1,63 +1,70 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using TeleWave.Infrastructure.Persistence;
|
||||
using Testcontainers.PostgreSql;
|
||||
using Xunit;
|
||||
|
||||
namespace TeleWave.Integration.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Поднимает одноразовый Postgres в контейнере и применяет к нему реальные миграции. Даёт свежие
|
||||
/// экземпляры <see cref="AppDbContext"/> (каждый — своё соединение), чтобы тестировать то, что InMemory
|
||||
/// не умеет: транзакции, advisory-lock, ExecuteDelete, raw SQL. Требует запущенного Docker.
|
||||
/// </summary>
|
||||
public sealed class PostgresFixture : IAsyncLifetime
|
||||
{
|
||||
private readonly PostgreSqlContainer _container = new PostgreSqlBuilder()
|
||||
.WithImage("postgres:16-alpine")
|
||||
.Build();
|
||||
|
||||
/// <summary>false, если Docker недоступен (напр. CI-раннер без Docker) — тогда тесты пропускаются.</summary>
|
||||
public bool Available { get; private set; }
|
||||
|
||||
public string ConnectionString => _container.GetConnectionString();
|
||||
|
||||
public async Task InitializeAsync()
|
||||
{
|
||||
try
|
||||
{
|
||||
await _container.StartAsync();
|
||||
await using var db = CreateContext();
|
||||
await db.Database.MigrateAsync();
|
||||
Available = true;
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
// Docker не запущен/недоступен — интеграционные тесты будут пропущены (Skip), а не упадут.
|
||||
Available = false;
|
||||
}
|
||||
}
|
||||
|
||||
public AppDbContext CreateContext() =>
|
||||
new(new DbContextOptionsBuilder<AppDbContext>().UseNpgsql(ConnectionString).Options);
|
||||
|
||||
public async Task DisposeAsync() => await _container.DisposeAsync();
|
||||
}
|
||||
|
||||
[CollectionDefinition("postgres")]
|
||||
public sealed class PostgresCollection : ICollectionFixture<PostgresFixture>;
|
||||
|
||||
/// <summary>Детерминированный источник случайности для планировщика в тестах.</summary>
|
||||
internal sealed class SequenceRandom(params int[] sequence)
|
||||
: Domain.Broadcast.Scheduling.IRandomSource
|
||||
{
|
||||
private readonly int[] _sequence = sequence.Length == 0 ? [0] : sequence;
|
||||
private int _i;
|
||||
|
||||
public int Next(int maxExclusive)
|
||||
{
|
||||
if (maxExclusive <= 0)
|
||||
return 0;
|
||||
var value = _sequence[_i++ % _sequence.Length];
|
||||
return ((value % maxExclusive) + maxExclusive) % maxExclusive;
|
||||
}
|
||||
}
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using TeleWave.Infrastructure.Persistence;
|
||||
using Testcontainers.PostgreSql;
|
||||
using Xunit;
|
||||
|
||||
namespace TeleWave.Integration.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Поднимает одноразовый Postgres в контейнере и применяет к нему реальные миграции. Даёт свежие
|
||||
/// экземпляры <see cref="AppDbContext"/> (каждый — своё соединение), чтобы тестировать то, что InMemory
|
||||
/// не умеет: транзакции, advisory-lock, ExecuteDelete, raw SQL. Требует запущенного Docker.
|
||||
/// </summary>
|
||||
public sealed class PostgresFixture : IAsyncLifetime
|
||||
{
|
||||
// Контейнер создаётся внутри InitializeAsync: сам билдер бросает, когда Docker недоступен,
|
||||
// и в инициализаторе поля это ронял бы всю коллекцию тестов вместо честного пропуска.
|
||||
private PostgreSqlContainer? _container;
|
||||
|
||||
/// <summary>false, если Docker недоступен (напр. CI-раннер без Docker) — тогда тесты пропускаются.</summary>
|
||||
public bool Available { get; private set; }
|
||||
|
||||
public string ConnectionString =>
|
||||
_container?.GetConnectionString()
|
||||
?? throw new InvalidOperationException("Контейнер не запущен — проверяйте Available.");
|
||||
|
||||
public async Task InitializeAsync()
|
||||
{
|
||||
try
|
||||
{
|
||||
_container = new PostgreSqlBuilder().WithImage("postgres:16-alpine").Build();
|
||||
await _container.StartAsync();
|
||||
await using var db = CreateContext();
|
||||
await db.Database.MigrateAsync();
|
||||
Available = true;
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
// Docker не запущен/недоступен — интеграционные тесты будут пропущены (Skip), а не упадут.
|
||||
Available = false;
|
||||
}
|
||||
}
|
||||
|
||||
public AppDbContext CreateContext() =>
|
||||
new(new DbContextOptionsBuilder<AppDbContext>().UseNpgsql(ConnectionString).Options);
|
||||
|
||||
public async Task DisposeAsync()
|
||||
{
|
||||
if (_container is not null)
|
||||
await _container.DisposeAsync();
|
||||
}
|
||||
}
|
||||
|
||||
[CollectionDefinition("postgres")]
|
||||
public sealed class PostgresCollection : ICollectionFixture<PostgresFixture>;
|
||||
|
||||
/// <summary>Детерминированный источник случайности для планировщика в тестах.</summary>
|
||||
internal sealed class SequenceRandom(params int[] sequence)
|
||||
: Domain.Broadcast.Scheduling.IRandomSource
|
||||
{
|
||||
private readonly int[] _sequence = sequence.Length == 0 ? [0] : sequence;
|
||||
private int _i;
|
||||
|
||||
public int Next(int maxExclusive)
|
||||
{
|
||||
if (maxExclusive <= 0)
|
||||
return 0;
|
||||
var value = _sequence[_i++ % _sequence.Length];
|
||||
return ((value % maxExclusive) + maxExclusive) % maxExclusive;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using TeleWave.Application.Library.Collections;
|
||||
using TeleWave.Infrastructure.Persistence;
|
||||
using Xunit;
|
||||
|
||||
namespace TeleWave.Integration.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Проверка переводимости запросов в SQL. Соединение не открывается: <c>ToQueryString</c> компилирует
|
||||
/// запрос и падает ровно там, где падал бы прод («The LINQ expression could not be translated»).
|
||||
/// Поэтому Docker не нужен, и такие ошибки ловятся быстро.
|
||||
///
|
||||
/// Повод: карточка шоу отвечала 500, потому что сортировка стояла после сборки DTO — EF не переводит
|
||||
/// порядок по свойству record'а.
|
||||
/// </summary>
|
||||
public sealed class QueryTranslationTests : IDisposable
|
||||
{
|
||||
private readonly AppDbContext _db = new(
|
||||
new DbContextOptionsBuilder<AppDbContext>()
|
||||
// Сервера по адресу нет и не требуется — запрос только компилируется.
|
||||
.UseNpgsql("Host=127.0.0.1;Port=1;Database=telewave;Username=u;Password=p")
|
||||
.Options
|
||||
);
|
||||
|
||||
[Fact]
|
||||
public void ShowCollections_Translates()
|
||||
{
|
||||
var showId = Guid.NewGuid();
|
||||
|
||||
var sql = _db
|
||||
.CollectionItems.AsNoTracking()
|
||||
.Where(i => i.ShowId == showId)
|
||||
.Join(
|
||||
_db.Collections.AsNoTracking(),
|
||||
item => item.CollectionId,
|
||||
collection => collection.Id,
|
||||
(item, collection) =>
|
||||
new
|
||||
{
|
||||
collection.Id,
|
||||
collection.Name,
|
||||
item.Position,
|
||||
}
|
||||
)
|
||||
.OrderBy(c => c.Name)
|
||||
.Select(c => new ShowCollectionRefDto(c.Id, c.Name, c.Position))
|
||||
.ToQueryString();
|
||||
|
||||
Assert.Contains("ORDER BY", sql, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ShowCollections_OrderingAfterProjection_DoesNotTranslate()
|
||||
{
|
||||
// Зафиксировано намеренно: именно эта форма роняла карточку шоу в 500. Если EF когда-нибудь
|
||||
// научится её переводить, тест упадёт — и это будет поводом упростить запрос обратно.
|
||||
var showId = Guid.NewGuid();
|
||||
|
||||
var query = _db
|
||||
.CollectionItems.AsNoTracking()
|
||||
.Where(i => i.ShowId == showId)
|
||||
.Join(
|
||||
_db.Collections.AsNoTracking(),
|
||||
item => item.CollectionId,
|
||||
collection => collection.Id,
|
||||
(item, collection) =>
|
||||
new ShowCollectionRefDto(collection.Id, collection.Name, item.Position)
|
||||
)
|
||||
.OrderBy(c => c.Name);
|
||||
|
||||
Assert.Throws<InvalidOperationException>(() => query.ToQueryString());
|
||||
}
|
||||
|
||||
public void Dispose() => _db.Dispose();
|
||||
}
|
||||
Reference in New Issue
Block a user