Updated the GridScheduleGenerator to support rebuilding future schedules while correctly managing aired positions. Introduced a new AiredPosition record to track the last aired state of slots, ensuring that the cursor rewinds to the correct position during rebuilds. Refactored the BuildInputAsync method to accept aired positions, and modified the LoadAiredPositionsAsync method for accurate retrieval of past entries. Enhanced the SchedulePlanner to utilize shared cursor states between main and background loops, preventing duplicate series plays. Updated documentation to reflect these changes and added integration tests to verify the correct behavior of the new functionality.
241 lines
9.5 KiB
C#
241 lines
9.5 KiB
C#
using Microsoft.EntityFrameworkCore;
|
|
using NSubstitute;
|
|
using TeleWave.Application.Common.Interfaces;
|
|
using TeleWave.Application.Library.DeleteShow;
|
|
using TeleWave.Application.Maintenance.DeleteShowMedia;
|
|
using TeleWave.Application.Media;
|
|
using TeleWave.Application.Media.Register;
|
|
using TeleWave.Application.Programming.Groups;
|
|
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(
|
|
new MediaReadyInfo(
|
|
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,
|
|
new ShowMediaEraser(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>());
|
|
}
|
|
|
|
/// <summary>
|
|
/// Шоу, стоящее в будущем эфире, удаляется с флагом «вырезать из эфира»: пересборка канала
|
|
/// спасает не всегда — идущую запись она не трогает, а выключенный канал вещать не начнёт.
|
|
/// </summary>
|
|
[SkippableFact]
|
|
public async Task DeleteShow_WithSchedule_CutsFutureEntries()
|
|
{
|
|
Skip.IfNot(fixture.Available, "Docker недоступен");
|
|
var channel = Channel.Create("Канал", $"cut-{Guid.NewGuid():N}", DateTimeOffset.UnixEpoch);
|
|
var show = Show.Create("Шоу", ShowKind.Single);
|
|
var asset = MediaAsset.Register("live.mkv", ".mkv", MediaSource.Upload);
|
|
show.AddEpisode(asset.Id);
|
|
|
|
// Запись идёт прямо сейчас: пересборка её не удаляет, а значит и шоу не освобождает.
|
|
var running = ScheduleEntry.Program(
|
|
channel.Id,
|
|
asset.Id,
|
|
DateTimeOffset.UtcNow.AddMinutes(-5),
|
|
DateTimeOffset.UtcNow.AddMinutes(15),
|
|
show.Id,
|
|
0
|
|
);
|
|
|
|
await using (var seed = fixture.CreateContext())
|
|
{
|
|
seed.Channels.Add(channel);
|
|
seed.Shows.Add(show);
|
|
seed.MediaAssets.Add(asset);
|
|
seed.ScheduleEntries.Add(running);
|
|
await seed.SaveChangesAsync(CancellationToken.None);
|
|
}
|
|
|
|
await using (var db = fixture.CreateContext())
|
|
{
|
|
var result = await new DeleteShowCommandHandler(
|
|
db,
|
|
new GroupMembershipCleaner(
|
|
db,
|
|
new GroupStatsService(
|
|
db,
|
|
new GroupElementResolver(db),
|
|
new DynamicGroupResolver(
|
|
new GroupFilterMatcher(db),
|
|
new GroupElementResolver(db)
|
|
)
|
|
)
|
|
),
|
|
new ShowMediaEraser(db, Substitute.For<IMediaStorage>())
|
|
).Handle(new DeleteShowCommand(show.Id, WithSchedule: true), CancellationToken.None);
|
|
|
|
Assert.True(result.IsSuccess);
|
|
await db.SaveChangesAsync(CancellationToken.None);
|
|
}
|
|
|
|
await using var verify = fixture.CreateContext();
|
|
Assert.False(await verify.Shows.AnyAsync(s => s.Id == show.Id));
|
|
Assert.False(await verify.ScheduleEntries.AnyAsync(e => e.ShowId == show.Id));
|
|
// Медиа не просили — оно осталось.
|
|
Assert.True(await verify.MediaAssets.AnyAsync(a => a.Id == asset.Id));
|
|
}
|
|
|
|
/// <summary>
|
|
/// Удаление шоу «вместе с медиа»: ассеты, записи расписания и файлы уходят одной операцией.
|
|
/// Проверяется на настоящем Postgres — <c>ExecuteDelete</c> и транзакции InMemory не умеет.
|
|
/// </summary>
|
|
[SkippableFact]
|
|
public async Task DeleteShow_WithMedia_RemovesEverything()
|
|
{
|
|
Skip.IfNot(fixture.Available, "Docker недоступен");
|
|
var channel = Channel.Create("Канал", $"del-{Guid.NewGuid():N}", DateTimeOffset.UnixEpoch);
|
|
var show = Show.Create("Шоу", ShowKind.Single);
|
|
var asset = MediaAsset.Register("movie.mkv", ".mkv", MediaSource.Upload);
|
|
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>();
|
|
|
|
await using (var db = fixture.CreateContext())
|
|
{
|
|
var result = await new DeleteShowCommandHandler(
|
|
db,
|
|
new GroupMembershipCleaner(
|
|
db,
|
|
new GroupStatsService(
|
|
db,
|
|
new GroupElementResolver(db),
|
|
new DynamicGroupResolver(
|
|
new GroupFilterMatcher(db),
|
|
new GroupElementResolver(db)
|
|
)
|
|
)
|
|
),
|
|
new ShowMediaEraser(db, storage)
|
|
).Handle(new DeleteShowCommand(show.Id, WithMedia: true), CancellationToken.None);
|
|
|
|
Assert.True(result.IsSuccess);
|
|
await db.SaveChangesAsync(CancellationToken.None);
|
|
}
|
|
|
|
await using var verify = fixture.CreateContext();
|
|
Assert.False(await verify.Shows.AnyAsync(s => s.Id == show.Id));
|
|
Assert.False(await verify.MediaAssets.AnyAsync(a => a.Id == asset.Id));
|
|
Assert.False(await verify.ScheduleEntries.AnyAsync(e => e.MediaAssetId == asset.Id));
|
|
await storage
|
|
.Received()
|
|
.DeleteAssetArtifactsAsync(asset.Id, ".mkv", Arg.Any<CancellationToken>());
|
|
}
|
|
}
|