Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 7m9s
Build ErsatzTV Image / Docs update reminder (pull_request) Successful in 25s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 10m46s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
Blazor disabled per-playout Reset/Erase/Delete/Edit while a BuildPlayout was
in flight (EntityLocker.IsPlayoutLocked); the REST API had no equivalent, so a
client could race an in-flight build with a destructive ExecuteDelete and leave
a half-built playout. After Blazor removal this safety invariant would vanish
entirely (adversarial-reviewer#18 removal gate).
Server:
- Add public ApiResults.ConflictProblem(title, detail) (409, mirrors NotFoundProblem).
- Inject IEntityLocker into PlayoutController; guard every id-keyed mutation
(PUT {id}, PUT .../deco, PUT .../alternate-schedules, PUT .../templates,
POST .../erase-items, POST .../erase-items-and-history, DELETE {id}) → 409
when IsPlayoutLocked(id); add [ProducesResponseType(...409)] to each.
- Guard ChannelController.ResetPlayout the same way after resolving the id.
- reset-all stays 202 (ResetAllPlayoutsHandler already skips locked playouts).
- Stamp IsLocked onto PlayoutListItemResponseModel from IsPlayoutLocked.
SPA:
- Disable Reset/Erase/Erase-and-history/Delete for a locked row + show a
"Building…" Badge; on a 409 surface the error and refresh the list.
Tests: controller-level 409 guard tests (delete/erase/PUT/deco/channel-reset)
+ IsLocked projection test; new OpenAPI contract + metadata 409 rows.
Docs: api-conventions §3a, blazor-route-parity playouts verdict, decisions.md.
Regenerated v1.json + web types.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
170 lines
17 KiB
C#
170 lines
17 KiB
C#
using System.Reflection;
|
|
using ErsatzTV.Controllers.Api;
|
|
using Microsoft.AspNetCore.Http;
|
|
using Microsoft.AspNetCore.Mvc;
|
|
using NUnit.Framework;
|
|
using Shouldly;
|
|
|
|
namespace ErsatzTV.Tests.Controllers;
|
|
|
|
[TestFixture]
|
|
public class ApiErrorResponseMetadataTests
|
|
{
|
|
[TestCase(typeof(ChannelController), nameof(ChannelController.GetById), StatusCodes.Status404NotFound)]
|
|
[TestCase(typeof(ChannelController), nameof(ChannelController.Create), StatusCodes.Status404NotFound)]
|
|
[TestCase(typeof(ChannelController), nameof(ChannelController.Create), StatusCodes.Status422UnprocessableEntity)]
|
|
[TestCase(typeof(ChannelController), nameof(ChannelController.CreateFromLineup), StatusCodes.Status404NotFound)]
|
|
[TestCase(typeof(ChannelController), nameof(ChannelController.CreateFromLineup), StatusCodes.Status422UnprocessableEntity)]
|
|
[TestCase(typeof(ChannelController), nameof(ChannelController.Update), StatusCodes.Status404NotFound)]
|
|
[TestCase(typeof(ChannelController), nameof(ChannelController.Update), StatusCodes.Status422UnprocessableEntity)]
|
|
[TestCase(typeof(ChannelController), nameof(ChannelController.Delete), StatusCodes.Status404NotFound)]
|
|
[TestCase(typeof(ChannelController), nameof(ChannelController.Delete), StatusCodes.Status422UnprocessableEntity)]
|
|
[TestCase(typeof(ChannelController), nameof(ChannelController.BulkRenumber), StatusCodes.Status404NotFound)]
|
|
[TestCase(typeof(ChannelController), nameof(ChannelController.BulkRenumber), StatusCodes.Status422UnprocessableEntity)]
|
|
[TestCase(typeof(ChannelController), nameof(ChannelController.BulkMoveToGroup), StatusCodes.Status404NotFound)]
|
|
[TestCase(typeof(ChannelController), nameof(ChannelController.BulkMoveToGroup), StatusCodes.Status422UnprocessableEntity)]
|
|
[TestCase(typeof(ChannelController), nameof(ChannelController.BulkDelete), StatusCodes.Status404NotFound)]
|
|
[TestCase(typeof(ChannelController), nameof(ChannelController.BulkDelete), StatusCodes.Status422UnprocessableEntity)]
|
|
[TestCase(typeof(ChannelController), nameof(ChannelController.ResetPlayout), StatusCodes.Status404NotFound)]
|
|
[TestCase(typeof(ChannelController), nameof(ChannelController.ResetPlayout), StatusCodes.Status409Conflict)]
|
|
[TestCase(typeof(ChannelTemplateController), nameof(ChannelTemplateController.GetDefault), StatusCodes.Status404NotFound)]
|
|
[TestCase(typeof(ChannelTemplateController), nameof(ChannelTemplateController.SetDefault), StatusCodes.Status404NotFound)]
|
|
[TestCase(typeof(ChannelTemplateController), nameof(ChannelTemplateController.SetDefault), StatusCodes.Status422UnprocessableEntity)]
|
|
[TestCase(typeof(ChannelTemplateController), nameof(ChannelTemplateController.GetById), StatusCodes.Status404NotFound)]
|
|
[TestCase(typeof(ChannelTemplateController), nameof(ChannelTemplateController.Create), StatusCodes.Status404NotFound)]
|
|
[TestCase(typeof(ChannelTemplateController), nameof(ChannelTemplateController.Create), StatusCodes.Status422UnprocessableEntity)]
|
|
[TestCase(typeof(ChannelTemplateController), nameof(ChannelTemplateController.Update), StatusCodes.Status404NotFound)]
|
|
[TestCase(typeof(ChannelTemplateController), nameof(ChannelTemplateController.Update), StatusCodes.Status422UnprocessableEntity)]
|
|
[TestCase(typeof(ChannelTemplateController), nameof(ChannelTemplateController.Delete), StatusCodes.Status404NotFound)]
|
|
[TestCase(typeof(ChannelTemplateController), nameof(ChannelTemplateController.Delete), StatusCodes.Status422UnprocessableEntity)]
|
|
[TestCase(typeof(CollectionController), nameof(CollectionController.GetById), StatusCodes.Status404NotFound)]
|
|
[TestCase(typeof(CollectionController), nameof(CollectionController.Create), StatusCodes.Status404NotFound)]
|
|
[TestCase(typeof(CollectionController), nameof(CollectionController.Create), StatusCodes.Status422UnprocessableEntity)]
|
|
[TestCase(typeof(CollectionController), nameof(CollectionController.Update), StatusCodes.Status404NotFound)]
|
|
[TestCase(typeof(CollectionController), nameof(CollectionController.Update), StatusCodes.Status422UnprocessableEntity)]
|
|
[TestCase(typeof(CollectionController), nameof(CollectionController.Delete), StatusCodes.Status404NotFound)]
|
|
[TestCase(typeof(CollectionController), nameof(CollectionController.Delete), StatusCodes.Status422UnprocessableEntity)]
|
|
[TestCase(typeof(CollectionController), nameof(CollectionController.AddItems), StatusCodes.Status404NotFound)]
|
|
[TestCase(typeof(CollectionController), nameof(CollectionController.AddItems), StatusCodes.Status422UnprocessableEntity)]
|
|
[TestCase(typeof(CollectionController), nameof(CollectionController.RemoveItem), StatusCodes.Status404NotFound)]
|
|
[TestCase(typeof(CollectionController), nameof(CollectionController.RemoveItem), StatusCodes.Status422UnprocessableEntity)]
|
|
[TestCase(typeof(SmartCollectionController), nameof(SmartCollectionController.GetById), StatusCodes.Status404NotFound)]
|
|
[TestCase(typeof(SmartCollectionController), nameof(SmartCollectionController.Create), StatusCodes.Status404NotFound)]
|
|
[TestCase(typeof(SmartCollectionController), nameof(SmartCollectionController.Create), StatusCodes.Status422UnprocessableEntity)]
|
|
[TestCase(typeof(SmartCollectionController), nameof(SmartCollectionController.Update), StatusCodes.Status404NotFound)]
|
|
[TestCase(typeof(SmartCollectionController), nameof(SmartCollectionController.Update), StatusCodes.Status422UnprocessableEntity)]
|
|
[TestCase(typeof(SmartCollectionController), nameof(SmartCollectionController.Delete), StatusCodes.Status404NotFound)]
|
|
[TestCase(typeof(SmartCollectionController), nameof(SmartCollectionController.Delete), StatusCodes.Status422UnprocessableEntity)]
|
|
[TestCase(typeof(ScheduleController), nameof(ScheduleController.GetById), StatusCodes.Status404NotFound)]
|
|
[TestCase(typeof(ScheduleController), nameof(ScheduleController.Create), StatusCodes.Status404NotFound)]
|
|
[TestCase(typeof(ScheduleController), nameof(ScheduleController.Create), StatusCodes.Status422UnprocessableEntity)]
|
|
[TestCase(typeof(ScheduleController), nameof(ScheduleController.Update), StatusCodes.Status404NotFound)]
|
|
[TestCase(typeof(ScheduleController), nameof(ScheduleController.Update), StatusCodes.Status422UnprocessableEntity)]
|
|
[TestCase(typeof(ScheduleController), nameof(ScheduleController.Delete), StatusCodes.Status404NotFound)]
|
|
[TestCase(typeof(ScheduleController), nameof(ScheduleController.Delete), StatusCodes.Status422UnprocessableEntity)]
|
|
[TestCase(typeof(ScheduleController), nameof(ScheduleController.GetItems), StatusCodes.Status404NotFound)]
|
|
[TestCase(typeof(ScheduleController), nameof(ScheduleController.AddItem), StatusCodes.Status404NotFound)]
|
|
[TestCase(typeof(ScheduleController), nameof(ScheduleController.AddItem), StatusCodes.Status422UnprocessableEntity)]
|
|
[TestCase(typeof(ScheduleController), nameof(ScheduleController.ReplaceItems), StatusCodes.Status404NotFound)]
|
|
[TestCase(typeof(ScheduleController), nameof(ScheduleController.ReplaceItems), StatusCodes.Status422UnprocessableEntity)]
|
|
[TestCase(typeof(ScheduleController), nameof(ScheduleController.DeleteItem), StatusCodes.Status404NotFound)]
|
|
[TestCase(typeof(ScheduleController), nameof(ScheduleController.DeleteItem), StatusCodes.Status422UnprocessableEntity)]
|
|
[TestCase(typeof(BlockController), nameof(BlockController.GetById), StatusCodes.Status404NotFound)]
|
|
[TestCase(typeof(BlockController), nameof(BlockController.CreateGroup), StatusCodes.Status404NotFound)]
|
|
[TestCase(typeof(BlockController), nameof(BlockController.CreateGroup), StatusCodes.Status422UnprocessableEntity)]
|
|
[TestCase(typeof(BlockController), nameof(BlockController.DeleteGroup), StatusCodes.Status404NotFound)]
|
|
[TestCase(typeof(BlockController), nameof(BlockController.Create), StatusCodes.Status404NotFound)]
|
|
[TestCase(typeof(BlockController), nameof(BlockController.Create), StatusCodes.Status422UnprocessableEntity)]
|
|
[TestCase(typeof(BlockController), nameof(BlockController.Delete), StatusCodes.Status404NotFound)]
|
|
[TestCase(typeof(BlockController), nameof(BlockController.GetItems), StatusCodes.Status404NotFound)]
|
|
[TestCase(typeof(BlockController), nameof(BlockController.Replace), StatusCodes.Status404NotFound)]
|
|
[TestCase(typeof(BlockController), nameof(BlockController.Replace), StatusCodes.Status422UnprocessableEntity)]
|
|
[TestCase(typeof(BlockController), nameof(BlockController.Preview), StatusCodes.Status404NotFound)]
|
|
[TestCase(typeof(BlockController), nameof(BlockController.Copy), StatusCodes.Status404NotFound)]
|
|
[TestCase(typeof(BlockController), nameof(BlockController.Copy), StatusCodes.Status422UnprocessableEntity)]
|
|
[TestCase(typeof(TemplateController), nameof(TemplateController.GetById), StatusCodes.Status404NotFound)]
|
|
[TestCase(typeof(TemplateController), nameof(TemplateController.CreateGroup), StatusCodes.Status404NotFound)]
|
|
[TestCase(typeof(TemplateController), nameof(TemplateController.CreateGroup), StatusCodes.Status422UnprocessableEntity)]
|
|
[TestCase(typeof(TemplateController), nameof(TemplateController.DeleteGroup), StatusCodes.Status404NotFound)]
|
|
[TestCase(typeof(TemplateController), nameof(TemplateController.Create), StatusCodes.Status404NotFound)]
|
|
[TestCase(typeof(TemplateController), nameof(TemplateController.Create), StatusCodes.Status422UnprocessableEntity)]
|
|
[TestCase(typeof(TemplateController), nameof(TemplateController.Delete), StatusCodes.Status404NotFound)]
|
|
[TestCase(typeof(TemplateController), nameof(TemplateController.GetItems), StatusCodes.Status404NotFound)]
|
|
[TestCase(typeof(TemplateController), nameof(TemplateController.Replace), StatusCodes.Status404NotFound)]
|
|
[TestCase(typeof(TemplateController), nameof(TemplateController.Replace), StatusCodes.Status422UnprocessableEntity)]
|
|
[TestCase(typeof(TemplateController), nameof(TemplateController.Copy), StatusCodes.Status404NotFound)]
|
|
[TestCase(typeof(TemplateController), nameof(TemplateController.Copy), StatusCodes.Status422UnprocessableEntity)]
|
|
[TestCase(typeof(DecoController), nameof(DecoController.GetById), StatusCodes.Status404NotFound)]
|
|
[TestCase(typeof(DecoController), nameof(DecoController.CreateGroup), StatusCodes.Status404NotFound)]
|
|
[TestCase(typeof(DecoController), nameof(DecoController.CreateGroup), StatusCodes.Status422UnprocessableEntity)]
|
|
[TestCase(typeof(DecoController), nameof(DecoController.DeleteGroup), StatusCodes.Status404NotFound)]
|
|
[TestCase(typeof(DecoController), nameof(DecoController.Create), StatusCodes.Status404NotFound)]
|
|
[TestCase(typeof(DecoController), nameof(DecoController.Create), StatusCodes.Status422UnprocessableEntity)]
|
|
[TestCase(typeof(DecoController), nameof(DecoController.Delete), StatusCodes.Status404NotFound)]
|
|
[TestCase(typeof(DecoController), nameof(DecoController.Replace), StatusCodes.Status404NotFound)]
|
|
[TestCase(typeof(DecoController), nameof(DecoController.Replace), StatusCodes.Status422UnprocessableEntity)]
|
|
[TestCase(typeof(DecoTemplateController), nameof(DecoTemplateController.GetById), StatusCodes.Status404NotFound)]
|
|
[TestCase(typeof(DecoTemplateController), nameof(DecoTemplateController.CreateGroup), StatusCodes.Status404NotFound)]
|
|
[TestCase(typeof(DecoTemplateController), nameof(DecoTemplateController.CreateGroup), StatusCodes.Status422UnprocessableEntity)]
|
|
[TestCase(typeof(DecoTemplateController), nameof(DecoTemplateController.DeleteGroup), StatusCodes.Status404NotFound)]
|
|
[TestCase(typeof(DecoTemplateController), nameof(DecoTemplateController.Create), StatusCodes.Status404NotFound)]
|
|
[TestCase(typeof(DecoTemplateController), nameof(DecoTemplateController.Create), StatusCodes.Status422UnprocessableEntity)]
|
|
[TestCase(typeof(DecoTemplateController), nameof(DecoTemplateController.Delete), StatusCodes.Status404NotFound)]
|
|
[TestCase(typeof(DecoTemplateController), nameof(DecoTemplateController.GetItems), StatusCodes.Status404NotFound)]
|
|
[TestCase(typeof(DecoTemplateController), nameof(DecoTemplateController.Replace), StatusCodes.Status404NotFound)]
|
|
[TestCase(typeof(DecoTemplateController), nameof(DecoTemplateController.Replace), StatusCodes.Status422UnprocessableEntity)]
|
|
[TestCase(typeof(PlayoutController), nameof(PlayoutController.UpdateDefaultDeco), StatusCodes.Status404NotFound)]
|
|
[TestCase(typeof(PlayoutController), nameof(PlayoutController.UpdateDefaultDeco), StatusCodes.Status409Conflict)]
|
|
[TestCase(typeof(PlayoutController), nameof(PlayoutController.UpdateDefaultDeco), StatusCodes.Status422UnprocessableEntity)]
|
|
[TestCase(typeof(PlayoutController), nameof(PlayoutController.GetById), StatusCodes.Status404NotFound)]
|
|
[TestCase(typeof(PlayoutController), nameof(PlayoutController.Create), StatusCodes.Status404NotFound)]
|
|
[TestCase(typeof(PlayoutController), nameof(PlayoutController.Create), StatusCodes.Status422UnprocessableEntity)]
|
|
[TestCase(typeof(PlayoutController), nameof(PlayoutController.Update), StatusCodes.Status404NotFound)]
|
|
[TestCase(typeof(PlayoutController), nameof(PlayoutController.Update), StatusCodes.Status409Conflict)]
|
|
[TestCase(typeof(PlayoutController), nameof(PlayoutController.Update), StatusCodes.Status422UnprocessableEntity)]
|
|
[TestCase(typeof(PlayoutController), nameof(PlayoutController.Delete), StatusCodes.Status404NotFound)]
|
|
[TestCase(typeof(PlayoutController), nameof(PlayoutController.Delete), StatusCodes.Status409Conflict)]
|
|
[TestCase(typeof(PlayoutController), nameof(PlayoutController.Delete), StatusCodes.Status422UnprocessableEntity)]
|
|
[TestCase(typeof(PlayoutController), nameof(PlayoutController.GetAlternateSchedules), StatusCodes.Status404NotFound)]
|
|
[TestCase(typeof(PlayoutController), nameof(PlayoutController.GetAlternateSchedules), StatusCodes.Status422UnprocessableEntity)]
|
|
[TestCase(typeof(PlayoutController), nameof(PlayoutController.ReplaceAlternateSchedules), StatusCodes.Status404NotFound)]
|
|
[TestCase(typeof(PlayoutController), nameof(PlayoutController.ReplaceAlternateSchedules), StatusCodes.Status409Conflict)]
|
|
[TestCase(typeof(PlayoutController), nameof(PlayoutController.ReplaceAlternateSchedules), StatusCodes.Status422UnprocessableEntity)]
|
|
[TestCase(typeof(PlayoutController), nameof(PlayoutController.GetTemplates), StatusCodes.Status404NotFound)]
|
|
[TestCase(typeof(PlayoutController), nameof(PlayoutController.GetTemplates), StatusCodes.Status422UnprocessableEntity)]
|
|
[TestCase(typeof(PlayoutController), nameof(PlayoutController.ReplaceTemplates), StatusCodes.Status404NotFound)]
|
|
[TestCase(typeof(PlayoutController), nameof(PlayoutController.ReplaceTemplates), StatusCodes.Status409Conflict)]
|
|
[TestCase(typeof(PlayoutController), nameof(PlayoutController.ReplaceTemplates), StatusCodes.Status422UnprocessableEntity)]
|
|
[TestCase(typeof(PlayoutController), nameof(PlayoutController.EraseItems), StatusCodes.Status404NotFound)]
|
|
[TestCase(typeof(PlayoutController), nameof(PlayoutController.EraseItems), StatusCodes.Status409Conflict)]
|
|
[TestCase(typeof(PlayoutController), nameof(PlayoutController.EraseItems), StatusCodes.Status422UnprocessableEntity)]
|
|
[TestCase(typeof(PlayoutController), nameof(PlayoutController.EraseItemsAndHistory), StatusCodes.Status404NotFound)]
|
|
[TestCase(typeof(PlayoutController), nameof(PlayoutController.EraseItemsAndHistory), StatusCodes.Status409Conflict)]
|
|
[TestCase(typeof(PlayoutController), nameof(PlayoutController.EraseItemsAndHistory), StatusCodes.Status422UnprocessableEntity)]
|
|
[TestCase(typeof(FFmpegProfileController), nameof(FFmpegProfileController.GetById), StatusCodes.Status404NotFound)]
|
|
[TestCase(typeof(FFmpegProfileController), nameof(FFmpegProfileController.AddOne), StatusCodes.Status404NotFound)]
|
|
[TestCase(typeof(FFmpegProfileController), nameof(FFmpegProfileController.AddOne), StatusCodes.Status401Unauthorized)]
|
|
[TestCase(typeof(FFmpegProfileController), nameof(FFmpegProfileController.AddOne), StatusCodes.Status422UnprocessableEntity)]
|
|
[TestCase(typeof(FFmpegProfileController), nameof(FFmpegProfileController.UpdateOne), StatusCodes.Status404NotFound)]
|
|
[TestCase(typeof(FFmpegProfileController), nameof(FFmpegProfileController.UpdateOne), StatusCodes.Status401Unauthorized)]
|
|
[TestCase(typeof(FFmpegProfileController), nameof(FFmpegProfileController.UpdateOne), StatusCodes.Status422UnprocessableEntity)]
|
|
[TestCase(typeof(FFmpegProfileController), nameof(FFmpegProfileController.DeleteProfileAsync), StatusCodes.Status404NotFound)]
|
|
[TestCase(typeof(FFmpegProfileController), nameof(FFmpegProfileController.DeleteProfileAsync), StatusCodes.Status401Unauthorized)]
|
|
[TestCase(typeof(FFmpegProfileController), nameof(FFmpegProfileController.DeleteProfileAsync), StatusCodes.Status422UnprocessableEntity)]
|
|
public void Api_Error_Response_Metadata_Should_Document_ProblemDetails(
|
|
Type controllerType,
|
|
string actionName,
|
|
int statusCode)
|
|
{
|
|
MethodInfo action = controllerType.GetMethods().Single(m => m.Name == actionName);
|
|
|
|
ProducesResponseTypeAttribute? metadata = action
|
|
.GetCustomAttributes<ProducesResponseTypeAttribute>(inherit: true)
|
|
.SingleOrDefault(a => a.StatusCode == statusCode);
|
|
|
|
metadata.ShouldNotBeNull($"{controllerType.Name}.{actionName} should document HTTP {statusCode}");
|
|
metadata.Type.ShouldBe(typeof(ProblemDetails));
|
|
}
|
|
|
|
}
|