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:
Leonid Pershin
2026-07-25 23:42:40 +03:00
parent 6c18a9da79
commit d6ad8e11a4
22 changed files with 1967 additions and 197 deletions
@@ -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>());
}
}