Slice C of the async-op contract normalization:
- channel reset (POST /api/channels/{channelNumber}/playout/reset) now
returns 202 Accepted (was 200 Ok) — it only queues a background rebuild
- reset-all (POST /api/playouts/reset-all) still 202 but now returns a
ResetAllPlayoutsResponseModel body reporting QueuedPlayoutIds /
SkippedLocked / SkippedUnsupported instead of silently swallowing skips;
handler returns a new ResetAllPlayoutsResult record
- single-playout GET (GET /api/playouts/{id}) now exposes IsLocked on
PlayoutResponseModel, set from IEntityLocker.IsPlayoutLocked mirroring
the list projection — gives a polling client the lock flag
Tests: channel reset asserts 202; reset-all asserts 202 + skipped-body
shape; single GET asserts IsLocked; new ResetAllPlayoutsHandlerTests
(in-memory SQLite) asserts locked/ExternalJson/None land in skipped lists
and eligible playouts in queued. docs/api-conventions.md §3a updated.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
83 lines
2.9 KiB
C#
83 lines
2.9 KiB
C#
using System.Collections.Generic;
|
|
using System.Threading;
|
|
using System.Threading.Channels;
|
|
using System.Threading.Tasks;
|
|
using ErsatzTV.Application;
|
|
using ErsatzTV.Application.Playouts;
|
|
using ErsatzTV.Core.Domain;
|
|
using ErsatzTV.Core.Interfaces.Locking;
|
|
using ErsatzTV.Core.Scheduling;
|
|
using ErsatzTV.Infrastructure.Data;
|
|
using ErsatzTV.Tests.Support;
|
|
using NSubstitute;
|
|
using NUnit.Framework;
|
|
using Shouldly;
|
|
using Channel = System.Threading.Channels.Channel;
|
|
|
|
namespace ErsatzTV.Tests.Application.Playouts;
|
|
|
|
[TestFixture]
|
|
public class ResetAllPlayoutsHandlerTests
|
|
{
|
|
private InMemoryTvContext _db = null!;
|
|
private Channel<IBackgroundServiceRequest> _worker = null!;
|
|
private IEntityLocker _entityLocker = null!;
|
|
|
|
[SetUp]
|
|
public async Task SetUp()
|
|
{
|
|
_db = await InMemoryTvContext.CreateAsync();
|
|
_worker = Channel.CreateUnbounded<IBackgroundServiceRequest>();
|
|
_entityLocker = Substitute.For<IEntityLocker>();
|
|
}
|
|
|
|
[TearDown]
|
|
public async Task TearDown() => await _db.DisposeAsync();
|
|
|
|
private ResetAllPlayoutsHandler CreateHandler() =>
|
|
new(_entityLocker, _worker.Writer, _db.Factory);
|
|
|
|
private async Task<int> SeedPlayout(PlayoutScheduleKind kind)
|
|
{
|
|
await using TvContext context = _db.CreateContext();
|
|
var playout = new Playout { ChannelId = 0, ScheduleKind = kind };
|
|
context.Playouts.Add(playout);
|
|
await context.SaveChangesAsync();
|
|
return playout.Id;
|
|
}
|
|
|
|
[Test]
|
|
public async Task Handle_Should_Queue_Eligible_And_Report_Skipped()
|
|
{
|
|
int classicId = await SeedPlayout(PlayoutScheduleKind.Classic);
|
|
int blockId = await SeedPlayout(PlayoutScheduleKind.Block);
|
|
int lockedId = await SeedPlayout(PlayoutScheduleKind.Sequential);
|
|
int externalJsonId = await SeedPlayout(PlayoutScheduleKind.ExternalJson);
|
|
int noneId = await SeedPlayout(PlayoutScheduleKind.None);
|
|
|
|
_entityLocker.IsPlayoutLocked(lockedId).Returns(true);
|
|
|
|
ResetAllPlayoutsResult result =
|
|
await CreateHandler().Handle(new ResetAllPlayouts(), CancellationToken.None);
|
|
|
|
// eligible, unlocked playouts are queued
|
|
result.QueuedPlayoutIds.ShouldBe(new List<int> { classicId, blockId }, ignoreOrder: true);
|
|
|
|
// locked playout lands in SkippedLocked, not queued
|
|
result.SkippedLocked.ShouldBe(new List<int> { lockedId });
|
|
|
|
// ExternalJson + None land in SkippedUnsupported
|
|
result.SkippedUnsupported.ShouldBe(new List<int> { externalJsonId, noneId }, ignoreOrder: true);
|
|
|
|
// exactly one BuildPlayout message per queued playout was enqueued
|
|
var enqueued = new List<int>();
|
|
while (_worker.Reader.TryRead(out IBackgroundServiceRequest? request))
|
|
{
|
|
var build = request.ShouldBeOfType<BuildPlayout>();
|
|
enqueued.Add(build.PlayoutId);
|
|
}
|
|
|
|
enqueued.ShouldBe(new List<int> { classicId, blockId }, ignoreOrder: true);
|
|
}
|
|
}
|