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
@@ -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}]"
);
}
}