Implement asynchronous bumper rendering and add integration tests: refactor BumperAsset to support async rendering, introduce BumperRenderBackgroundService for processing, and create TeleWave.Integration.Tests with Testcontainers-Postgres for comprehensive testing of scheduling and bumper generation scenarios. Update documentation and ensure tests skip if Docker is not available.
This commit is contained in:
@@ -25,12 +25,18 @@ public class DomainRecordFactoryTests
|
||||
[Fact]
|
||||
public void BumperAsset_Create_StoresPair()
|
||||
{
|
||||
var channel = Guid.NewGuid();
|
||||
var template = Guid.NewGuid();
|
||||
var variant = Guid.NewGuid();
|
||||
var from = Guid.NewGuid();
|
||||
var to = Guid.NewGuid();
|
||||
var asset = Guid.NewGuid();
|
||||
|
||||
var b = BumperAsset.Create(from, to, "sig", asset);
|
||||
var b = BumperAsset.Create(channel, template, variant, from, to, "sig", asset);
|
||||
|
||||
Assert.Equal(channel, b.ChannelId);
|
||||
Assert.Equal(template, b.TemplateId);
|
||||
Assert.Equal(variant, b.VariantId);
|
||||
Assert.Equal(from, b.FromShowId);
|
||||
Assert.Equal(to, b.ToShowId);
|
||||
Assert.Equal("sig", b.Signature);
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,207 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Options;
|
||||
using TeleWave.Application.Broadcast.Bumpers;
|
||||
using TeleWave.Application.Broadcast.Scheduling;
|
||||
using TeleWave.Application.Common.Interfaces;
|
||||
using TeleWave.Application.Streaming;
|
||||
using TeleWave.Domain.Broadcast;
|
||||
using TeleWave.Domain.Broadcast.Scheduling;
|
||||
using TeleWave.Domain.Library;
|
||||
using TeleWave.Domain.Media;
|
||||
using TeleWave.Infrastructure.Persistence;
|
||||
using Xunit;
|
||||
|
||||
namespace TeleWave.Integration.Tests;
|
||||
|
||||
[Collection("postgres")]
|
||||
public sealed class ScheduleGeneratorIntegrationTests(PostgresFixture fixture)
|
||||
{
|
||||
private static readonly DateTimeOffset Now = new(2026, 3, 1, 0, 0, 0, TimeSpan.Zero);
|
||||
private const int Seg = 2;
|
||||
|
||||
private static MediaAsset ReadyAsset(string name, int seconds)
|
||||
{
|
||||
var a = MediaAsset.Register(name, ".mkv", MediaSource.Upload);
|
||||
a.MarkReady(
|
||||
TimeSpan.FromSeconds(seconds),
|
||||
Seg,
|
||||
seconds / Seg,
|
||||
1920,
|
||||
1080,
|
||||
"h264",
|
||||
"aac",
|
||||
$"assets/{a.Id}"
|
||||
);
|
||||
return a;
|
||||
}
|
||||
|
||||
private static ScheduleGenerator NewGenerator(
|
||||
AppDbContext db,
|
||||
IRandomSource random,
|
||||
IBumperRenderQueue queue
|
||||
)
|
||||
{
|
||||
var streaming = Options.Create(new StreamingOptions { SegmentSeconds = Seg });
|
||||
var resolver = new ScheduleBumperResolver(
|
||||
db,
|
||||
queue,
|
||||
Options.Create(new BumperOptions()),
|
||||
streaming
|
||||
);
|
||||
return new ScheduleGenerator(
|
||||
db,
|
||||
random,
|
||||
resolver,
|
||||
Options.Create(new SchedulerOptions { HorizonDays = 1, RetentionHours = 24 }),
|
||||
streaming
|
||||
);
|
||||
}
|
||||
|
||||
private sealed class RecordingQueue : IBumperRenderQueue
|
||||
{
|
||||
public List<Guid> Enqueued { get; } = [];
|
||||
|
||||
public void Enqueue(Guid assetId) => Enqueued.Add(assetId);
|
||||
|
||||
public ValueTask WaitAsync(CancellationToken cancellationToken) => ValueTask.CompletedTask;
|
||||
}
|
||||
|
||||
[SkippableFact]
|
||||
public async Task GenerateAsync_MaterializesProgramSchedule()
|
||||
{
|
||||
Skip.IfNot(fixture.Available, "Docker недоступен");
|
||||
var channel = Channel.Create("Prog", $"prog-{Guid.NewGuid():N}", Now);
|
||||
var show = Show.Create("Show A", ShowKind.Series);
|
||||
var a1 = ReadyAsset("a1.mkv", 1200);
|
||||
var a2 = ReadyAsset("a2.mkv", 1200);
|
||||
show.AddEpisode(a1.Id);
|
||||
show.AddEpisode(a2.Id);
|
||||
channel.AddShow(show.Id, 1, BlockMode.Count, 1);
|
||||
|
||||
await using (var seed = fixture.CreateContext())
|
||||
{
|
||||
seed.Shows.Add(show);
|
||||
seed.MediaAssets.AddRange(a1, a2);
|
||||
seed.Channels.Add(channel);
|
||||
await seed.SaveChangesAsync(CancellationToken.None);
|
||||
}
|
||||
|
||||
int added;
|
||||
await using (var db = fixture.CreateContext())
|
||||
{
|
||||
added = await NewGenerator(db, new SequenceRandom(0), new RecordingQueue())
|
||||
.GenerateAsync(channel.Id, Now, regenerate: false, CancellationToken.None);
|
||||
}
|
||||
|
||||
Assert.True(added > 0);
|
||||
await using var verify = fixture.CreateContext();
|
||||
var entries = await verify
|
||||
.ScheduleEntries.AsNoTracking()
|
||||
.Where(e => e.ChannelId == channel.Id)
|
||||
.ToListAsync();
|
||||
Assert.NotEmpty(entries);
|
||||
Assert.Contains(entries, e => e.Kind == ScheduleEntryKind.Program);
|
||||
Assert.All(entries, e => Assert.True(e.EndsAtUtc <= Now.AddDays(1).AddSeconds(Seg)));
|
||||
}
|
||||
|
||||
[SkippableFact]
|
||||
public async Task GenerateAsync_CreatesPendingBumperAssets_ForShowTransitions()
|
||||
{
|
||||
Skip.IfNot(fixture.Available, "Docker недоступен");
|
||||
// Канал с двумя шоу и включёнными заставками — на смене шоу планировщик ставит заставку;
|
||||
// её ассет должен создаться в Pending (Generated) и попасть в очередь фонового рендера.
|
||||
var channel = Channel.Create("Bump", $"bump-{Guid.NewGuid():N}", Now);
|
||||
channel.UpdateSettings(channel.Name, true, AdInsertion.BetweenBlocks, 0, true, null);
|
||||
var showA = Show.Create("A", ShowKind.Series);
|
||||
var showB = Show.Create("B", ShowKind.Series);
|
||||
var a1 = ReadyAsset("a1.mkv", 1200);
|
||||
var b1 = ReadyAsset("b1.mkv", 1200);
|
||||
showA.AddEpisode(a1.Id);
|
||||
showB.AddEpisode(b1.Id);
|
||||
channel.AddShow(showA.Id, 1, BlockMode.Count, 1);
|
||||
channel.AddShow(showB.Id, 1, BlockMode.Count, 1);
|
||||
|
||||
await using (var seed = fixture.CreateContext())
|
||||
{
|
||||
seed.Shows.AddRange(showA, showB);
|
||||
seed.MediaAssets.AddRange(a1, b1);
|
||||
seed.Channels.Add(channel);
|
||||
await seed.SaveChangesAsync(CancellationToken.None);
|
||||
}
|
||||
|
||||
var queue = new RecordingQueue();
|
||||
await using (var db = fixture.CreateContext())
|
||||
{
|
||||
// Чередуем выбор шоу (0,1,0,1…), чтобы гарантированно были переходы «A→B».
|
||||
await NewGenerator(db, new SequenceRandom(0, 1), queue)
|
||||
.GenerateAsync(channel.Id, Now, regenerate: false, CancellationToken.None);
|
||||
}
|
||||
|
||||
await using var verify = fixture.CreateContext();
|
||||
var bumperCache = await verify
|
||||
.BumperAssets.AsNoTracking()
|
||||
.Where(b => b.ChannelId == channel.Id)
|
||||
.ToListAsync();
|
||||
Assert.NotEmpty(bumperCache);
|
||||
|
||||
var bumperAssetIds = bumperCache.Select(b => b.MediaAssetId).ToList();
|
||||
var bumperAssets = await verify
|
||||
.MediaAssets.AsNoTracking()
|
||||
.Where(a => bumperAssetIds.Contains(a.Id))
|
||||
.ToListAsync();
|
||||
Assert.All(
|
||||
bumperAssets,
|
||||
a =>
|
||||
{
|
||||
Assert.Equal(MediaSource.Generated, a.Source);
|
||||
Assert.Equal(MediaAssetStatus.Pending, a.Status);
|
||||
}
|
||||
);
|
||||
Assert.NotEmpty(queue.Enqueued);
|
||||
}
|
||||
|
||||
[SkippableFact]
|
||||
public async Task ConcurrentGenerate_SerializedByAdvisoryLock_NoOverlappingEntries()
|
||||
{
|
||||
Skip.IfNot(fixture.Available, "Docker недоступен");
|
||||
var channel = Channel.Create("Race", $"race-{Guid.NewGuid():N}", Now);
|
||||
var show = Show.Create("Show", ShowKind.Series);
|
||||
var a1 = ReadyAsset("r1.mkv", 1200);
|
||||
var a2 = ReadyAsset("r2.mkv", 1200);
|
||||
show.AddEpisode(a1.Id);
|
||||
show.AddEpisode(a2.Id);
|
||||
channel.AddShow(show.Id, 1, BlockMode.Count, 1);
|
||||
|
||||
await using (var seed = fixture.CreateContext())
|
||||
{
|
||||
seed.Shows.Add(show);
|
||||
seed.MediaAssets.AddRange(a1, a2);
|
||||
seed.Channels.Add(channel);
|
||||
await seed.SaveChangesAsync(CancellationToken.None);
|
||||
}
|
||||
|
||||
// Два параллельных прогона на РАЗНЫХ соединениях — advisory-lock должен их сериализовать,
|
||||
// иначе оба дописали бы хвост от одной точки продолжения и записи бы перекрылись.
|
||||
await using var db1 = fixture.CreateContext();
|
||||
await using var db2 = fixture.CreateContext();
|
||||
var g1 = NewGenerator(db1, new SequenceRandom(0), new RecordingQueue())
|
||||
.GenerateAsync(channel.Id, Now, regenerate: false, CancellationToken.None);
|
||||
var g2 = NewGenerator(db2, new SequenceRandom(0), new RecordingQueue())
|
||||
.GenerateAsync(channel.Id, Now, regenerate: false, CancellationToken.None);
|
||||
await Task.WhenAll(g1, g2);
|
||||
|
||||
await using var verify = fixture.CreateContext();
|
||||
var entries = await verify
|
||||
.ScheduleEntries.AsNoTracking()
|
||||
.Where(e => e.ChannelId == channel.Id)
|
||||
.OrderBy(e => e.StartsAtUtc)
|
||||
.ToListAsync();
|
||||
|
||||
Assert.NotEmpty(entries);
|
||||
for (var i = 1; i < entries.Count; i++)
|
||||
Assert.True(
|
||||
entries[i].StartsAtUtc >= entries[i - 1].EndsAtUtc,
|
||||
$"Записи перекрываются: [{entries[i - 1].StartsAtUtc:o}..{entries[i - 1].EndsAtUtc:o}] и [{entries[i].StartsAtUtc:o}..{entries[i].EndsAtUtc:o}]"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<IsPackable>false</IsPackable>
|
||||
<TreatWarningsAsErrors>false</TreatWarningsAsErrors>
|
||||
<EnforceCodeStyleInBuild>false</EnforceCodeStyleInBuild>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="coverlet.collector">
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
</PackageReference>
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" />
|
||||
<PackageReference Include="xunit" />
|
||||
<PackageReference Include="xunit.runner.visualstudio">
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
</PackageReference>
|
||||
<PackageReference Include="NSubstitute" />
|
||||
<PackageReference Include="Testcontainers.PostgreSql" />
|
||||
<PackageReference Include="Xunit.SkippableFact" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\src\TeleWave.Application\TeleWave.Application.csproj" />
|
||||
<ProjectReference Include="..\..\src\TeleWave.Infrastructure\TeleWave.Infrastructure.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
@@ -0,0 +1,100 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using NSubstitute;
|
||||
using TeleWave.Application.Common.Interfaces;
|
||||
using TeleWave.Application.Maintenance.DeleteShowMedia;
|
||||
using TeleWave.Application.Media.Register;
|
||||
using TeleWave.Domain.Broadcast;
|
||||
using TeleWave.Domain.Library;
|
||||
using TeleWave.Domain.Media;
|
||||
using Xunit;
|
||||
|
||||
namespace TeleWave.Integration.Tests;
|
||||
|
||||
[Collection("postgres")]
|
||||
public sealed class TransactionIntegrationTests(PostgresFixture fixture)
|
||||
{
|
||||
[SkippableFact]
|
||||
public async Task RegisterMediaAsset_RollsBack_WhenFilePromoteFails()
|
||||
{
|
||||
Skip.IfNot(fixture.Available, "Docker недоступен");
|
||||
var fileName = $"clip-{Guid.NewGuid():N}.mkv";
|
||||
var storage = Substitute.For<IMediaStorage>();
|
||||
storage
|
||||
.PromoteToOriginalAsync(
|
||||
Arg.Any<MediaSource>(),
|
||||
Arg.Any<string>(),
|
||||
Arg.Any<Guid>(),
|
||||
Arg.Any<string>(),
|
||||
Arg.Any<CancellationToken>()
|
||||
)
|
||||
.Returns(Task.FromException(new IOException("disk full")));
|
||||
|
||||
await using var db = fixture.CreateContext();
|
||||
var handler = new RegisterMediaAssetCommandHandler(db, storage);
|
||||
|
||||
await Assert.ThrowsAsync<IOException>(() =>
|
||||
handler.Handle(
|
||||
new RegisterMediaAssetCommand(fileName, MediaSource.Upload, fileName),
|
||||
CancellationToken.None
|
||||
)
|
||||
);
|
||||
|
||||
// Перенос файла упал — регистрация должна откатиться, чтобы не осталось orphan-строки.
|
||||
await using var verify = fixture.CreateContext();
|
||||
var exists = await verify.MediaAssets.AnyAsync(a => a.OriginalFileName == fileName);
|
||||
Assert.False(exists);
|
||||
}
|
||||
|
||||
[SkippableFact]
|
||||
public async Task DeleteShowMedia_AtomicallyRemovesEntriesAssetsAndEpisodes()
|
||||
{
|
||||
Skip.IfNot(fixture.Available, "Docker недоступен");
|
||||
var channel = Channel.Create("c", $"c-{Guid.NewGuid():N}", DateTimeOffset.UnixEpoch);
|
||||
var show = Show.Create("Show", ShowKind.Series);
|
||||
var asset = MediaAsset.Register("ep.mkv", ".mkv", MediaSource.Upload);
|
||||
asset.MarkReady(TimeSpan.FromMinutes(20), 2, 600, 1920, 1080, "h264", "aac", "assets/x");
|
||||
show.AddEpisode(asset.Id);
|
||||
var entry = ScheduleEntry.Program(
|
||||
channel.Id,
|
||||
asset.Id,
|
||||
DateTimeOffset.UnixEpoch,
|
||||
DateTimeOffset.UnixEpoch.AddMinutes(20),
|
||||
show.Id,
|
||||
0
|
||||
);
|
||||
|
||||
await using (var seed = fixture.CreateContext())
|
||||
{
|
||||
seed.Channels.Add(channel);
|
||||
seed.Shows.Add(show);
|
||||
seed.MediaAssets.Add(asset);
|
||||
seed.ScheduleEntries.Add(entry);
|
||||
await seed.SaveChangesAsync(CancellationToken.None);
|
||||
}
|
||||
|
||||
var storage = Substitute.For<IMediaStorage>();
|
||||
storage
|
||||
.DeleteAssetArtifactsAsync(Arg.Any<Guid>(), Arg.Any<string>(), Arg.Any<CancellationToken>())
|
||||
.Returns(Task.CompletedTask);
|
||||
|
||||
await using (var db = fixture.CreateContext())
|
||||
{
|
||||
var result = await new DeleteShowMediaCommandHandler(db, storage).Handle(
|
||||
new DeleteShowMediaCommand(show.Id),
|
||||
CancellationToken.None
|
||||
);
|
||||
Assert.True(result.IsSuccess);
|
||||
}
|
||||
|
||||
await using var verify = fixture.CreateContext();
|
||||
Assert.False(await verify.MediaAssets.AnyAsync(a => a.Id == asset.Id));
|
||||
Assert.False(await verify.ScheduleEntries.AnyAsync(e => e.MediaAssetId == asset.Id));
|
||||
var reloaded = await verify
|
||||
.Shows.Include(s => s.Episodes)
|
||||
.FirstAsync(s => s.Id == show.Id);
|
||||
Assert.Empty(reloaded.Episodes);
|
||||
await storage
|
||||
.Received()
|
||||
.DeleteAssetArtifactsAsync(asset.Id, ".mkv", Arg.Any<CancellationToken>());
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user