Files
ersatztv/ErsatzTV.Tests/Application/Playouts/ResetAllPlayoutsHandlerTests.cs
T
timothyandClaude Opus 4.8 9b73b62527 feat(235): async-op API contract normalization — playouts slice C (#235)
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>
2026-07-11 17:58:15 +02:00

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);
}
}