#253 PR2 — optimistic-concurrency fan-out (Template, DecoTemplate, Playlist, schedule-items) #268
@@ -32,6 +32,10 @@ public class AddEpisodeToPlaylistHandler(IDbContextFactory<TvContext> dbContextF
|
||||
};
|
||||
|
||||
parameters.Playlist.Items.Add(playlistItem);
|
||||
|
||||
// Mutates the playlist's editor-visible item list, so bump the concurrency token (issue #253).
|
||||
parameters.Playlist.Version++;
|
||||
|
||||
await dbContext.SaveChangesAsync();
|
||||
return Unit.Default;
|
||||
}
|
||||
|
||||
@@ -69,6 +69,9 @@ public class AddItemsToPlaylistHandler : IRequestHandler<AddItemsToPlaylist, Eit
|
||||
}
|
||||
}
|
||||
|
||||
// Mutates the playlist's editor-visible item list, so bump the concurrency token (issue #253).
|
||||
playlist.Version++;
|
||||
|
||||
await dbContext.SaveChangesAsync(cancellationToken);
|
||||
|
||||
return Unit.Default;
|
||||
|
||||
@@ -32,6 +32,10 @@ public class AddMovieToPlaylistHandler(IDbContextFactory<TvContext> dbContextFac
|
||||
};
|
||||
|
||||
parameters.Playlist.Items.Add(playlistItem);
|
||||
|
||||
// Mutates the playlist's editor-visible item list, so bump the concurrency token (issue #253).
|
||||
parameters.Playlist.Version++;
|
||||
|
||||
await dbContext.SaveChangesAsync();
|
||||
return Unit.Default;
|
||||
}
|
||||
|
||||
@@ -32,6 +32,10 @@ public class AddSeasonToPlaylistHandler(IDbContextFactory<TvContext> dbContextFa
|
||||
};
|
||||
|
||||
parameters.Playlist.Items.Add(playlistItem);
|
||||
|
||||
// Mutates the playlist's editor-visible item list, so bump the concurrency token (issue #253).
|
||||
parameters.Playlist.Version++;
|
||||
|
||||
await dbContext.SaveChangesAsync();
|
||||
return Unit.Default;
|
||||
}
|
||||
|
||||
@@ -32,6 +32,10 @@ public class AddShowToPlaylistHandler(IDbContextFactory<TvContext> dbContextFact
|
||||
};
|
||||
|
||||
parameters.Playlist.Items.Add(playlistItem);
|
||||
|
||||
// Mutates the playlist's editor-visible item list, so bump the concurrency token (issue #253).
|
||||
parameters.Playlist.Version++;
|
||||
|
||||
await dbContext.SaveChangesAsync();
|
||||
return Unit.Default;
|
||||
}
|
||||
|
||||
@@ -2,5 +2,9 @@ using ErsatzTV.Core;
|
||||
|
||||
namespace ErsatzTV.Application.MediaCollections;
|
||||
|
||||
public record ReplacePlaylistItems(int PlaylistId, string Name, List<ReplacePlaylistItem> Items)
|
||||
public record ReplacePlaylistItems(
|
||||
int PlaylistId,
|
||||
string Name,
|
||||
List<ReplacePlaylistItem> Items,
|
||||
Option<int> ExpectedVersion = default)
|
||||
: IRequest<Either<BaseError, List<PlaylistItemViewModel>>>;
|
||||
|
||||
@@ -15,10 +15,21 @@ public class ReplacePlaylistItemsHandler(IDbContextFactory<TvContext> dbContextF
|
||||
{
|
||||
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);
|
||||
Validation<BaseError, Playlist> validation = await Validate(dbContext, request, cancellationToken);
|
||||
return await validation.Apply(ps => Persist(dbContext, request, ps, cancellationToken));
|
||||
|
||||
// Introduce the optimistic-concurrency check as a standalone Either AFTER the validation
|
||||
// pipeline (never via Apply) so a PreconditionFailedError survives to a 412 and is not
|
||||
// flattened to a generic 422 by Join() (issue #253 / api-conventions §7a).
|
||||
// LanguageExtensions.ToEither joins the Seq<BaseError> to a single BaseError (the native
|
||||
// Validation.ToEither() would keep Seq and is called explicitly here to avoid that shadow).
|
||||
Either<BaseError, Playlist> validated = LanguageExtensions.ToEither(validation)
|
||||
.Bind(playlist => playlist.CheckVersion(request.ExpectedVersion));
|
||||
|
||||
return await validated.Match(
|
||||
Right: playlist => Persist(dbContext, request, playlist, cancellationToken),
|
||||
Left: error => Task.FromResult<Either<BaseError, List<PlaylistItemViewModel>>>(error));
|
||||
}
|
||||
|
||||
private static async Task<List<PlaylistItemViewModel>> Persist(
|
||||
private static async Task<Either<BaseError, List<PlaylistItemViewModel>>> Persist(
|
||||
TvContext dbContext,
|
||||
ReplacePlaylistItems request,
|
||||
Playlist playlist,
|
||||
@@ -30,9 +41,15 @@ public class ReplacePlaylistItemsHandler(IDbContextFactory<TvContext> dbContextF
|
||||
dbContext.RemoveRange(playlist.Items);
|
||||
playlist.Items = request.Items.Map(i => BuildItem(playlist, i.Index, i)).ToList();
|
||||
|
||||
await dbContext.SaveChangesAsync(cancellationToken);
|
||||
// Unconditional bump: EF emits the root UPDATE only when a scalar actually differs, so a
|
||||
// same-value/no-op save would otherwise write no root row and neither fire the concurrency
|
||||
// token nor rotate other clients' ETags. Bumping guarantees both on every save (issue #253).
|
||||
playlist.Version++;
|
||||
|
||||
return playlist.Items.Map(Mapper.ProjectToViewModel).ToList();
|
||||
// Save through the guard so an EF concurrency failure (a racing writer won between our load
|
||||
// and save) maps to 412 rather than surfacing as a 500.
|
||||
Either<BaseError, Unit> saved = await dbContext.SaveChangesWithConcurrencyGuard(cancellationToken);
|
||||
return saved.Map(_ => playlist.Items.Map(Mapper.ProjectToViewModel).ToList());
|
||||
}
|
||||
|
||||
private static PlaylistItem BuildItem(Playlist playlist, int index, ReplacePlaylistItem item) =>
|
||||
|
||||
@@ -89,7 +89,7 @@ internal static class Mapper
|
||||
new(playlistGroup.Id, playlistGroup.Name, playlistGroup.Playlists.Count, playlistGroup.IsSystem);
|
||||
|
||||
internal static PlaylistViewModel ProjectToViewModel(Playlist playlist) =>
|
||||
new(playlist.Id, playlist.PlaylistGroupId, playlist.Name, playlist.IsSystem);
|
||||
new(playlist.Id, playlist.PlaylistGroupId, playlist.Name, playlist.IsSystem, playlist.Version);
|
||||
|
||||
internal static PlaylistItemViewModel ProjectToViewModel(PlaylistItem playlistItem) =>
|
||||
new(
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
namespace ErsatzTV.Application.MediaCollections;
|
||||
|
||||
public record PlaylistViewModel(int Id, int PlaylistGroupId, string Name, bool IsSystem);
|
||||
public record PlaylistViewModel(int Id, int PlaylistGroupId, string Name, bool IsSystem, int Version);
|
||||
|
||||
@@ -52,6 +52,9 @@ public class AddProgramScheduleItemHandler : ProgramScheduleItemCommandBase,
|
||||
ProgramScheduleItem item = BuildItem(programSchedule, nextIndex, request);
|
||||
programSchedule.Items.Add(item);
|
||||
|
||||
// bump the optimistic-concurrency token so this config edit rotates other clients' ETags (#253)
|
||||
programSchedule.Version++;
|
||||
|
||||
await dbContext.SaveChangesAsync(cancellationToken);
|
||||
|
||||
// refresh any playouts that use this schedule
|
||||
|
||||
@@ -34,6 +34,10 @@ public class DeleteProgramScheduleItemHandler(
|
||||
|
||||
List<Playout> playouts = item.ProgramSchedule.Playouts;
|
||||
dbContext.ProgramScheduleItems.Remove(item);
|
||||
|
||||
// bump the optimistic-concurrency token so this config edit rotates other clients' ETags (#253)
|
||||
item.ProgramSchedule.Version++;
|
||||
|
||||
await dbContext.SaveChangesAsync(cancellationToken);
|
||||
|
||||
// post-commit side effect runs on CancellationToken.None so a late request cancellation
|
||||
|
||||
@@ -44,5 +44,8 @@ public record ReplaceProgramScheduleItem(
|
||||
string PreferredSubtitleLanguageCode,
|
||||
ChannelSubtitleMode? SubtitleMode) : IProgramScheduleItemRequest;
|
||||
|
||||
public record ReplaceProgramScheduleItems(int ProgramScheduleId, List<ReplaceProgramScheduleItem> Items) : IRequest<
|
||||
public record ReplaceProgramScheduleItems(
|
||||
int ProgramScheduleId,
|
||||
List<ReplaceProgramScheduleItem> Items,
|
||||
Option<int> ExpectedVersion = default) : IRequest<
|
||||
Either<BaseError, IEnumerable<ProgramScheduleItemViewModel>>>;
|
||||
|
||||
+26
-3
@@ -26,13 +26,23 @@ public class ReplaceProgramScheduleItemsHandler(
|
||||
Some: async programSchedule =>
|
||||
{
|
||||
Validation<BaseError, ProgramSchedule> validation = await Validate(dbContext, request, programSchedule);
|
||||
return await validation.Apply(ps => PersistItems(dbContext, request, ps, cancellationToken));
|
||||
|
||||
// Introduce the optimistic-concurrency check as a standalone Either AFTER the validation
|
||||
// pipeline (never via Apply) so a PreconditionFailedError survives to a 412 and is not
|
||||
// flattened to a generic 422 by Join() (issue #253 / api-conventions §7a).
|
||||
Either<BaseError, ProgramSchedule> validated = LanguageExtensions.ToEither(validation)
|
||||
.Bind(ps => ps.CheckVersion(request.ExpectedVersion));
|
||||
|
||||
return await validated.Match(
|
||||
Right: ps => PersistItems(dbContext, request, ps, cancellationToken),
|
||||
Left: error =>
|
||||
Task.FromResult<Either<BaseError, IEnumerable<ProgramScheduleItemViewModel>>>(error));
|
||||
},
|
||||
None: () => Task.FromResult<Either<BaseError, IEnumerable<ProgramScheduleItemViewModel>>>(
|
||||
new NotFoundError("[ProgramScheduleId] does not exist.")));
|
||||
}
|
||||
|
||||
private async Task<IEnumerable<ProgramScheduleItemViewModel>> PersistItems(
|
||||
private async Task<Either<BaseError, IEnumerable<ProgramScheduleItemViewModel>>> PersistItems(
|
||||
TvContext dbContext,
|
||||
ReplaceProgramScheduleItems request,
|
||||
ProgramSchedule programSchedule,
|
||||
@@ -92,7 +102,20 @@ public class ReplaceProgramScheduleItemsHandler(
|
||||
programSchedule.Items.Add(BuildItem(programSchedule, i, orderedItems[i]));
|
||||
}
|
||||
|
||||
await dbContext.SaveChangesAsync(cancellationToken);
|
||||
// Unconditional bump: this handler frequently saves with only CHILD changes and no root-scalar
|
||||
// change, so without an explicit bump EF would emit no root UPDATE and the concurrency token
|
||||
// would never fire (nor rotate other clients' ETags). Bumping guarantees both on every save,
|
||||
// including a no-op same-items PUT-back (issue #253 / api-conventions §7a).
|
||||
programSchedule.Version++;
|
||||
|
||||
// Save through the guard so an EF concurrency failure (a racing writer won between our load and
|
||||
// save) maps to 412 rather than surfacing as a 500. On failure, propagate the error WITHOUT
|
||||
// running the post-save reload/enqueue below.
|
||||
Either<BaseError, Unit> saved = await dbContext.SaveChangesWithConcurrencyGuard(cancellationToken);
|
||||
if (saved.IsLeft)
|
||||
{
|
||||
return saved.Map(_ => (IEnumerable<ProgramScheduleItemViewModel>)[]);
|
||||
}
|
||||
|
||||
// refresh any playouts that use this schedule
|
||||
// post-commit side effect runs on CancellationToken.None so a late request cancellation
|
||||
|
||||
@@ -54,6 +54,9 @@ public class UpdateProgramScheduleHandler(
|
||||
programSchedule.RandomStartPoint = request.RandomStartPoint;
|
||||
programSchedule.FixedStartTimeBehavior = request.FixedStartTimeBehavior;
|
||||
|
||||
// bump the optimistic-concurrency token so this config edit rotates other clients' ETags (#253)
|
||||
programSchedule.Version++;
|
||||
|
||||
await dbContext.SaveChangesAsync();
|
||||
|
||||
if (needToRefreshPlayout)
|
||||
|
||||
@@ -12,7 +12,8 @@ internal static class Mapper
|
||||
programSchedule.TreatCollectionsAsShows,
|
||||
programSchedule.ShuffleScheduleItems,
|
||||
programSchedule.RandomStartPoint,
|
||||
programSchedule.FixedStartTimeBehavior);
|
||||
programSchedule.FixedStartTimeBehavior,
|
||||
programSchedule.Version);
|
||||
|
||||
internal static ProgramScheduleItemViewModel ProjectToViewModel(ProgramScheduleItem programScheduleItem) =>
|
||||
programScheduleItem switch
|
||||
|
||||
@@ -9,4 +9,5 @@ public record ProgramScheduleViewModel(
|
||||
bool TreatCollectionsAsShows,
|
||||
bool ShuffleScheduleItems,
|
||||
bool RandomStartPoint,
|
||||
FixedStartTimeBehavior FixedStartTimeBehavior);
|
||||
FixedStartTimeBehavior FixedStartTimeBehavior,
|
||||
int Version);
|
||||
|
||||
@@ -19,7 +19,8 @@ public class GetAllProgramSchedulesHandler(IDbContextFactory<TvContext> dbContex
|
||||
ps.TreatCollectionsAsShows,
|
||||
ps.ShuffleScheduleItems,
|
||||
ps.RandomStartPoint,
|
||||
ps.FixedStartTimeBehavior))
|
||||
ps.FixedStartTimeBehavior,
|
||||
ps.Version))
|
||||
.ToListAsync(cancellationToken);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,5 +6,6 @@ public record ReplaceDecoTemplateItems(
|
||||
int DecoTemplateId,
|
||||
int DecoTemplateGroupId,
|
||||
string Name,
|
||||
List<ReplaceDecoTemplateItem> Items)
|
||||
List<ReplaceDecoTemplateItem> Items,
|
||||
Option<int> ExpectedVersion = default)
|
||||
: IRequest<Either<BaseError, List<DecoTemplateItemViewModel>>>;
|
||||
|
||||
@@ -20,10 +20,19 @@ public class ReplaceDecoTemplateItemsHandler(
|
||||
{
|
||||
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);
|
||||
Validation<BaseError, DecoTemplate> validation = await Validate(dbContext, request, cancellationToken);
|
||||
return await validation.Apply(ps => Persist(dbContext, request, ps, cancellationToken));
|
||||
|
||||
// Introduce the optimistic-concurrency check as a standalone Either AFTER the validation
|
||||
// pipeline (never via Apply) so a PreconditionFailedError survives to a 412 and is not
|
||||
// flattened to a generic 422 by Join() (issue #253 / api-conventions §7a).
|
||||
Either<BaseError, DecoTemplate> validated = LanguageExtensions.ToEither(validation)
|
||||
.Bind(decoTemplate => decoTemplate.CheckVersion(request.ExpectedVersion));
|
||||
|
||||
return await validated.Match(
|
||||
Right: decoTemplate => Persist(dbContext, request, decoTemplate, cancellationToken),
|
||||
Left: error => Task.FromResult<Either<BaseError, List<DecoTemplateItemViewModel>>>(error));
|
||||
}
|
||||
|
||||
private async Task<List<DecoTemplateItemViewModel>> Persist(
|
||||
private async Task<Either<BaseError, List<DecoTemplateItemViewModel>>> Persist(
|
||||
TvContext dbContext,
|
||||
ReplaceDecoTemplateItems request,
|
||||
DecoTemplate decoTemplate,
|
||||
@@ -36,33 +45,49 @@ public class ReplaceDecoTemplateItemsHandler(
|
||||
|
||||
decoTemplate.Items = request.Items.Map(i => BuildItem(decoTemplate, i)).ToList();
|
||||
|
||||
await dbContext.SaveChangesAsync(cancellationToken);
|
||||
// Unconditional bump: EF emits the root UPDATE only when a scalar actually differs, so a
|
||||
// same-value/no-op save would otherwise write no root row and neither fire the concurrency
|
||||
// token nor rotate other clients' ETags. Bumping guarantees both on every save (issue #253).
|
||||
decoTemplate.Version++;
|
||||
|
||||
// Deco/break/default-filler content is only (re)applied during a Reset build (a Continue keeps the
|
||||
// frozen DecoDefault filler items), and BlockKey change-detection has no deco dimension, so nothing
|
||||
// self-heals a deco-template edit — the editor returned 200 but built filler stayed stale until a
|
||||
// manual Reset (#251). Enqueue a Reset for every playout that references this deco template. This
|
||||
// whole post-commit invalidation runs with CancellationToken.None (audit #22 policy): once the edit
|
||||
// is committed, a late request cancellation must not be able to abort the affected-playout query OR
|
||||
// the enqueue and leave content stale.
|
||||
List<int> playoutIds = await dbContext.PlayoutTemplates
|
||||
.Where(pt => pt.DecoTemplateId == decoTemplate.Id)
|
||||
.Select(pt => pt.PlayoutId)
|
||||
.Distinct()
|
||||
.ToListAsync(CancellationToken.None);
|
||||
// Save through the guard so an EF concurrency failure (a racing writer won between our load
|
||||
// and save) maps to 412 rather than surfacing as a 500.
|
||||
Either<BaseError, Unit> saved = await dbContext.SaveChangesWithConcurrencyGuard(cancellationToken);
|
||||
return await saved.Match(
|
||||
Right: async _ =>
|
||||
{
|
||||
// Deco/break/default-filler content is only (re)applied during a Reset build (a Continue keeps
|
||||
// the frozen DecoDefault filler items), and BlockKey change-detection has no deco dimension, so
|
||||
// nothing self-heals a deco-template edit — the editor returned 200 but built filler stayed
|
||||
// stale until a manual Reset (#251). Enqueue a Reset for every playout that references this
|
||||
// deco template. This whole post-commit invalidation runs with CancellationToken.None (audit
|
||||
// #22 policy): once the edit is committed, a late request cancellation must not be able to
|
||||
// abort the affected-playout query OR the enqueue and leave content stale. Only runs after a
|
||||
// successful save (issue #253) — a 412/422 must not enqueue a Reset for content that was never
|
||||
// persisted.
|
||||
List<int> playoutIds = await dbContext.PlayoutTemplates
|
||||
.Where(pt => pt.DecoTemplateId == decoTemplate.Id)
|
||||
.Select(pt => pt.PlayoutId)
|
||||
.Distinct()
|
||||
.ToListAsync(CancellationToken.None);
|
||||
|
||||
foreach (int playoutId in playoutIds)
|
||||
{
|
||||
await channel.WriteAsync(new BuildPlayout(playoutId, PlayoutBuildMode.Reset), CancellationToken.None);
|
||||
}
|
||||
foreach (int playoutId in playoutIds)
|
||||
{
|
||||
await channel.WriteAsync(
|
||||
new BuildPlayout(playoutId, PlayoutBuildMode.Reset),
|
||||
CancellationToken.None);
|
||||
}
|
||||
|
||||
await dbContext.Entry(decoTemplate)
|
||||
.Collection(t => t.Items)
|
||||
.Query()
|
||||
.Include(i => i.Deco)
|
||||
.LoadAsync(cancellationToken);
|
||||
await dbContext.Entry(decoTemplate)
|
||||
.Collection(t => t.Items)
|
||||
.Query()
|
||||
.Include(i => i.Deco)
|
||||
.LoadAsync(cancellationToken);
|
||||
|
||||
return decoTemplate.Items.Map(Mapper.ProjectToViewModel).ToList();
|
||||
return Right<BaseError, List<DecoTemplateItemViewModel>>(
|
||||
decoTemplate.Items.Map(Mapper.ProjectToViewModel).ToList());
|
||||
},
|
||||
Left: error => Task.FromResult(Left<BaseError, List<DecoTemplateItemViewModel>>(error)));
|
||||
}
|
||||
|
||||
private static DecoTemplateItem BuildItem(DecoTemplate decoTemplate, ReplaceDecoTemplateItem item) =>
|
||||
|
||||
@@ -2,5 +2,10 @@ using ErsatzTV.Core;
|
||||
|
||||
namespace ErsatzTV.Application.Scheduling;
|
||||
|
||||
public record ReplaceTemplateItems(int TemplateGroupId, int TemplateId, string Name, List<ReplaceTemplateItem> Items)
|
||||
public record ReplaceTemplateItems(
|
||||
int TemplateGroupId,
|
||||
int TemplateId,
|
||||
string Name,
|
||||
List<ReplaceTemplateItem> Items,
|
||||
Option<int> ExpectedVersion = default)
|
||||
: IRequest<Either<BaseError, List<TemplateItemViewModel>>>;
|
||||
|
||||
@@ -15,10 +15,19 @@ public class ReplaceTemplateItemsHandler(IDbContextFactory<TvContext> dbContextF
|
||||
{
|
||||
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);
|
||||
Validation<BaseError, Template> validation = await Validate(dbContext, request, cancellationToken);
|
||||
return await validation.Apply(ps => Persist(dbContext, request, ps, cancellationToken));
|
||||
|
||||
// Introduce the optimistic-concurrency check as a standalone Either AFTER the validation
|
||||
// pipeline (never via Apply) so a PreconditionFailedError survives to a 412 and is not
|
||||
// flattened to a generic 422 by Join() (issue #253 / api-conventions §7a).
|
||||
Either<BaseError, Template> validated = LanguageExtensions.ToEither(validation)
|
||||
.Bind(template => template.CheckVersion(request.ExpectedVersion));
|
||||
|
||||
return await validated.Match(
|
||||
Right: template => Persist(dbContext, request, template, cancellationToken),
|
||||
Left: error => Task.FromResult<Either<BaseError, List<TemplateItemViewModel>>>(error));
|
||||
}
|
||||
|
||||
private static async Task<List<TemplateItemViewModel>> Persist(
|
||||
private static async Task<Either<BaseError, List<TemplateItemViewModel>>> Persist(
|
||||
TvContext dbContext,
|
||||
ReplaceTemplateItems request,
|
||||
Template template,
|
||||
@@ -30,7 +39,10 @@ public class ReplaceTemplateItemsHandler(IDbContextFactory<TvContext> dbContextF
|
||||
dbContext.RemoveRange(template.Items);
|
||||
template.Items = request.Items.Map(i => BuildItem(template, i)).ToList();
|
||||
|
||||
await dbContext.SaveChangesAsync(cancellationToken);
|
||||
// Unconditional bump: EF emits the root UPDATE only when a scalar actually differs, so a
|
||||
// same-value/no-op save would otherwise write no root row and neither fire the concurrency
|
||||
// token nor rotate other clients' ETags. Bumping guarantees both on every save (issue #253).
|
||||
template.Version++;
|
||||
|
||||
// TODO: refresh any playouts that use this schedule
|
||||
// foreach (Playout playout in programSchedule.Playouts)
|
||||
@@ -38,13 +50,22 @@ public class ReplaceTemplateItemsHandler(IDbContextFactory<TvContext> dbContextF
|
||||
// await _channel.WriteAsync(new BuildPlayout(playout.Id, PlayoutBuildMode.Refresh));
|
||||
// }
|
||||
|
||||
await dbContext.Entry(template)
|
||||
.Collection(t => t.Items)
|
||||
.Query()
|
||||
.Include(i => i.Block)
|
||||
.LoadAsync(cancellationToken);
|
||||
// Save through the guard so an EF concurrency failure (a racing writer won between our load
|
||||
// and save) maps to 412 rather than surfacing as a 500.
|
||||
Either<BaseError, Unit> saved = await dbContext.SaveChangesWithConcurrencyGuard(cancellationToken);
|
||||
return await saved.Match(
|
||||
Right: async _ =>
|
||||
{
|
||||
await dbContext.Entry(template)
|
||||
.Collection(t => t.Items)
|
||||
.Query()
|
||||
.Include(i => i.Block)
|
||||
.LoadAsync(cancellationToken);
|
||||
|
||||
return template.Items.Map(Mapper.ProjectToViewModel).ToList();
|
||||
return Right<BaseError, List<TemplateItemViewModel>>(
|
||||
template.Items.Map(Mapper.ProjectToViewModel).ToList());
|
||||
},
|
||||
Left: error => Task.FromResult(Left<BaseError, List<TemplateItemViewModel>>(error)));
|
||||
}
|
||||
|
||||
private static TemplateItem BuildItem(Template template, ReplaceTemplateItem item) =>
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
namespace ErsatzTV.Application.Scheduling;
|
||||
|
||||
public record DecoTemplateViewModel(int Id, int DecoTemplateGroupId, string GroupName, string Name);
|
||||
public record DecoTemplateViewModel(int Id, int DecoTemplateGroupId, string GroupName, string Name, int Version);
|
||||
|
||||
@@ -65,7 +65,7 @@ internal static class Mapper
|
||||
new(templateGroup.Id, templateGroup.Name, templateGroup.Templates.Count);
|
||||
|
||||
internal static TemplateViewModel ProjectToViewModel(Template template) =>
|
||||
new(template.Id, template.TemplateGroupId, template.TemplateGroup.Name, template.Name);
|
||||
new(template.Id, template.TemplateGroupId, template.TemplateGroup.Name, template.Name, template.Version);
|
||||
|
||||
internal static TemplateItemViewModel ProjectToViewModel(TemplateItem templateItem)
|
||||
{
|
||||
@@ -168,7 +168,8 @@ internal static class Mapper
|
||||
decoTemplate.Id,
|
||||
decoTemplate.DecoTemplateGroupId,
|
||||
decoTemplate.DecoTemplateGroup.Name,
|
||||
decoTemplate.Name);
|
||||
decoTemplate.Name,
|
||||
decoTemplate.Version);
|
||||
}
|
||||
|
||||
internal static DecoTemplateItemViewModel ProjectToViewModel(DecoTemplateItem decoTemplateItem)
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
namespace ErsatzTV.Application.Scheduling;
|
||||
|
||||
public record TemplateViewModel(int Id, int TemplateGroupId, string GroupName, string Name);
|
||||
public record TemplateViewModel(int Id, int TemplateGroupId, string GroupName, string Name, int Version);
|
||||
|
||||
+155
@@ -0,0 +1,155 @@
|
||||
using ErsatzTV.Application;
|
||||
using ErsatzTV.Application.MediaCollections;
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Errors;
|
||||
using ErsatzTV.Infrastructure.Data;
|
||||
using ErsatzTV.Tests.Support;
|
||||
using LanguageExt;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using NUnit.Framework;
|
||||
using Shouldly;
|
||||
using static LanguageExt.Prelude;
|
||||
|
||||
namespace ErsatzTV.Tests.Application.MediaCollections;
|
||||
|
||||
/// <summary>
|
||||
/// Contract tests for the #253 optimistic-concurrency mechanic on the Playlist aggregate (mirrors
|
||||
/// ReplaceBlockItemsHandlerConcurrencyTests, the Block reference implementation): the handler
|
||||
/// pre-check (stale If-Match → 412), the force-write path (no If-Match), the unconditional Version
|
||||
/// bump on every save, and the EF concurrency-token backstop that catches a writer that lost the
|
||||
/// load→save race. The backstop test is non-vacuous by construction — remove the
|
||||
/// <c>IsConcurrencyToken()</c> config on Playlist and the losing save silently succeeds instead of
|
||||
/// mapping to a <see cref="PreconditionFailedError" />.
|
||||
/// </summary>
|
||||
[TestFixture]
|
||||
public class ReplacePlaylistItemsHandlerConcurrencyTests
|
||||
{
|
||||
private InMemoryTvContext _db = null!;
|
||||
|
||||
[SetUp]
|
||||
public async Task SetUp() => _db = await InMemoryTvContext.CreateAsync();
|
||||
|
||||
[TearDown]
|
||||
public async Task TearDown() => await _db.DisposeAsync();
|
||||
|
||||
private async Task SeedPlaylistAsync(int version)
|
||||
{
|
||||
await using TvContext ctx = _db.CreateContext();
|
||||
ctx.Playlists.Add(
|
||||
new Playlist
|
||||
{
|
||||
Id = 1,
|
||||
PlaylistGroupId = 1,
|
||||
Name = "Kids",
|
||||
IsSystem = false,
|
||||
Version = version,
|
||||
Items = new List<PlaylistItem>()
|
||||
});
|
||||
await ctx.SaveChangesAsync();
|
||||
}
|
||||
|
||||
private static ReplacePlaylistItems Command(Option<int> expectedVersion) =>
|
||||
new(
|
||||
1,
|
||||
"Kids",
|
||||
new List<ReplacePlaylistItem>
|
||||
{
|
||||
new(0, CollectionType.Movie, null, null, null, 55, PlaybackOrder.Shuffle, null, false, true)
|
||||
},
|
||||
expectedVersion);
|
||||
|
||||
private async Task<int> ReadVersionAsync()
|
||||
{
|
||||
await using TvContext ctx = _db.CreateContext();
|
||||
return await ctx.Playlists.Where(p => p.Id == 1).Select(p => p.Version).SingleAsync();
|
||||
}
|
||||
|
||||
private static BaseError? LeftOrNull<T>(Either<BaseError, T> result) =>
|
||||
result.Match<BaseError?>(Right: _ => null, Left: e => e);
|
||||
|
||||
[Test]
|
||||
public async Task Stale_If_Match_Should_Fail_Precondition_And_Not_Mutate()
|
||||
{
|
||||
await SeedPlaylistAsync(version: 2);
|
||||
var handler = new ReplacePlaylistItemsHandler(_db.Factory);
|
||||
|
||||
Either<BaseError, List<PlaylistItemViewModel>> result =
|
||||
await handler.Handle(Command(Some(1)), CancellationToken.None);
|
||||
|
||||
LeftOrNull(result).ShouldBeOfType<PreconditionFailedError>();
|
||||
|
||||
// The pre-check runs before any mutation: version unchanged, no items written.
|
||||
(await ReadVersionAsync()).ShouldBe(2);
|
||||
await using TvContext ctx = _db.CreateContext();
|
||||
(await ctx.PlaylistItems.CountAsync(i => i.PlaylistId == 1)).ShouldBe(0);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Matching_If_Match_Should_Succeed_And_Bump_Version()
|
||||
{
|
||||
await SeedPlaylistAsync(version: 2);
|
||||
var handler = new ReplacePlaylistItemsHandler(_db.Factory);
|
||||
|
||||
Either<BaseError, List<PlaylistItemViewModel>> result =
|
||||
await handler.Handle(Command(Some(2)), CancellationToken.None);
|
||||
|
||||
result.IsRight.ShouldBeTrue();
|
||||
(await ReadVersionAsync()).ShouldBe(3);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Absent_If_Match_Should_Force_Write_And_Bump_Version()
|
||||
{
|
||||
await SeedPlaylistAsync(version: 2);
|
||||
var handler = new ReplacePlaylistItemsHandler(_db.Factory);
|
||||
|
||||
// None expected version = Phase-1 force-write regardless of the stored version.
|
||||
Either<BaseError, List<PlaylistItemViewModel>> result =
|
||||
await handler.Handle(Command(None), CancellationToken.None);
|
||||
|
||||
result.IsRight.ShouldBeTrue();
|
||||
(await ReadVersionAsync()).ShouldBe(3);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Save_Should_Bump_Version_Even_When_Content_Is_Unchanged()
|
||||
{
|
||||
await SeedPlaylistAsync(version: 5);
|
||||
var handler = new ReplacePlaylistItemsHandler(_db.Factory);
|
||||
|
||||
// Same content twice: the unconditional bump (M1) must still rotate the version each time,
|
||||
// otherwise a no-op PUT-back would not fire the token or rotate other clients' ETags.
|
||||
(await handler.Handle(Command(Some(5)), CancellationToken.None)).IsRight.ShouldBeTrue();
|
||||
(await ReadVersionAsync()).ShouldBe(6);
|
||||
(await handler.Handle(Command(Some(6)), CancellationToken.None)).IsRight.ShouldBeTrue();
|
||||
(await ReadVersionAsync()).ShouldBe(7);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Racing_Save_Should_Map_Concurrency_Failure_To_412()
|
||||
{
|
||||
await SeedPlaylistAsync(version: 1);
|
||||
|
||||
// Two writers load the same version, then both bump-and-save. The IsConcurrencyToken guard on
|
||||
// Playlist makes the second UPDATE key on the original version; it matches zero rows and throws
|
||||
// DbUpdateConcurrencyException, which the shared save helper maps to a PreconditionFailedError.
|
||||
await using TvContext ctxWinner = _db.CreateContext();
|
||||
await using TvContext ctxLoser = _db.CreateContext();
|
||||
|
||||
Playlist winner = await ctxWinner.Playlists.SingleAsync(p => p.Id == 1);
|
||||
Playlist loser = await ctxLoser.Playlists.SingleAsync(p => p.Id == 1);
|
||||
|
||||
winner.Version++;
|
||||
Either<BaseError, Unit> winnerResult = await ctxWinner.SaveChangesWithConcurrencyGuard(CancellationToken.None);
|
||||
winnerResult.IsRight.ShouldBeTrue();
|
||||
|
||||
loser.Version++;
|
||||
Either<BaseError, Unit> loserResult = await ctxLoser.SaveChangesWithConcurrencyGuard(CancellationToken.None);
|
||||
|
||||
LeftOrNull(loserResult).ShouldBeOfType<PreconditionFailedError>();
|
||||
|
||||
// Non-vacuous: the winner's write stuck at exactly its +1; the loser did not overwrite it.
|
||||
(await ReadVersionAsync()).ShouldBe(2);
|
||||
}
|
||||
}
|
||||
+231
@@ -0,0 +1,231 @@
|
||||
using System.Threading.Channels;
|
||||
using ErsatzTV.Application;
|
||||
using ErsatzTV.Application.ProgramSchedules;
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Errors;
|
||||
using ErsatzTV.Core.Scheduling;
|
||||
using ErsatzTV.Infrastructure.Data;
|
||||
using ErsatzTV.Tests.Support;
|
||||
using LanguageExt;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using NUnit.Framework;
|
||||
using Shouldly;
|
||||
using static LanguageExt.Prelude;
|
||||
|
||||
namespace ErsatzTV.Tests.Application.ProgramSchedules;
|
||||
|
||||
/// <summary>
|
||||
/// Contract tests for the #253 optimistic-concurrency mechanic on the ProgramSchedule / schedule-items
|
||||
/// aggregate: the handler pre-check (stale If-Match → 412, before the positional reconcile runs — so the
|
||||
/// item rows AND persisted fill-group/shuffle state are left untouched), the force-write path (no
|
||||
/// If-Match), the unconditional Version bump on every save (including a no-op same-items PUT-back where
|
||||
/// only child rows change), and the EF concurrency-token backstop that catches a writer that lost the
|
||||
/// load→save race. The backstop test is non-vacuous by construction — remove the
|
||||
/// <c>IsConcurrencyToken()</c> config on ProgramSchedule and the losing save silently succeeds instead
|
||||
/// of mapping to a <see cref="PreconditionFailedError" />.
|
||||
/// </summary>
|
||||
[TestFixture]
|
||||
public class ReplaceProgramScheduleItemsHandlerConcurrencyTests
|
||||
{
|
||||
private InMemoryTvContext _db = null!;
|
||||
private ChannelWriter<IBackgroundServiceRequest> _worker = null!;
|
||||
|
||||
[SetUp]
|
||||
public async Task SetUp()
|
||||
{
|
||||
_db = await InMemoryTvContext.CreateAsync();
|
||||
_worker = System.Threading.Channels.Channel.CreateUnbounded<IBackgroundServiceRequest>().Writer;
|
||||
}
|
||||
|
||||
[TearDown]
|
||||
public async Task TearDown() => await _db.DisposeAsync();
|
||||
|
||||
// Seeds a schedule (Id=1) with a single One/SearchQuery item (Id=1) and a persisted fill-group
|
||||
// enumerator state pointing at that item, so the stale-If-Match test can prove the reconcile never ran.
|
||||
private async Task SeedScheduleAsync(int version)
|
||||
{
|
||||
await using TvContext ctx = _db.CreateContext();
|
||||
ctx.ProgramSchedules.Add(
|
||||
new ProgramSchedule
|
||||
{
|
||||
Id = 1,
|
||||
Name = "Concurrency",
|
||||
Version = version,
|
||||
Items = new List<ProgramScheduleItem>
|
||||
{
|
||||
new ProgramScheduleItemOne
|
||||
{
|
||||
Id = 1,
|
||||
Index = 0,
|
||||
CollectionType = CollectionType.SearchQuery,
|
||||
SearchTitle = "a",
|
||||
SearchQuery = "a",
|
||||
PlaybackOrder = PlaybackOrder.Shuffle,
|
||||
GuideMode = GuideMode.Normal
|
||||
}
|
||||
},
|
||||
Playouts = [],
|
||||
ProgramScheduleAlternates = []
|
||||
});
|
||||
await ctx.SaveChangesAsync();
|
||||
|
||||
ctx.Add(new PlayoutScheduleItemFillGroupIndex
|
||||
{
|
||||
PlayoutId = 1,
|
||||
ProgramScheduleItemId = 1,
|
||||
EnumeratorState = new CollectionEnumeratorState { Seed = 12345, Index = 7 }
|
||||
});
|
||||
await ctx.SaveChangesAsync();
|
||||
}
|
||||
|
||||
private static ReplaceProgramScheduleItems Command(
|
||||
Option<int> expectedVersion,
|
||||
List<ReplaceProgramScheduleItem>? items = null) =>
|
||||
new(1, items ?? [MakeItem(0, PlayoutMode.One, "a")], expectedVersion);
|
||||
|
||||
private async Task<int> ReadVersionAsync()
|
||||
{
|
||||
await using TvContext ctx = _db.CreateContext();
|
||||
return await ctx.ProgramSchedules.Where(s => s.Id == 1).Select(s => s.Version).SingleAsync();
|
||||
}
|
||||
|
||||
private static BaseError? LeftOrNull(Either<BaseError, IEnumerable<ProgramScheduleItemViewModel>> result) =>
|
||||
result.Match<BaseError?>(Right: _ => null, Left: e => e);
|
||||
|
||||
[Test]
|
||||
public async Task Stale_If_Match_Should_Fail_Precondition_And_Not_Mutate()
|
||||
{
|
||||
await SeedScheduleAsync(version: 2);
|
||||
var handler = new ReplaceProgramScheduleItemsHandler(_db.Factory, _worker);
|
||||
|
||||
// An empty item list WOULD delete the existing item (and cascade its fill-group state) if the
|
||||
// reconcile ran. A stale If-Match must reject before that, leaving everything untouched.
|
||||
Either<BaseError, IEnumerable<ProgramScheduleItemViewModel>> result =
|
||||
await handler.Handle(Command(Some(1), []), CancellationToken.None);
|
||||
|
||||
LeftOrNull(result).ShouldBeOfType<PreconditionFailedError>();
|
||||
|
||||
(await ReadVersionAsync()).ShouldBe(2);
|
||||
await using TvContext ctx = _db.CreateContext();
|
||||
(await ctx.ProgramScheduleItems.CountAsync(i => i.ProgramScheduleId == 1)).ShouldBe(1);
|
||||
|
||||
// The reconcile never ran: the fill-group enumerator state is exactly as seeded.
|
||||
PlayoutScheduleItemFillGroupIndex fillGroup = await ctx.Set<PlayoutScheduleItemFillGroupIndex>()
|
||||
.Include(x => x.EnumeratorState)
|
||||
.SingleAsync();
|
||||
fillGroup.ProgramScheduleItemId.ShouldBe(1);
|
||||
fillGroup.EnumeratorState.Seed.ShouldBe(12345);
|
||||
fillGroup.EnumeratorState.Index.ShouldBe(7);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Matching_If_Match_Should_Succeed_And_Bump_Version()
|
||||
{
|
||||
await SeedScheduleAsync(version: 2);
|
||||
var handler = new ReplaceProgramScheduleItemsHandler(_db.Factory, _worker);
|
||||
|
||||
Either<BaseError, IEnumerable<ProgramScheduleItemViewModel>> result =
|
||||
await handler.Handle(Command(Some(2)), CancellationToken.None);
|
||||
|
||||
result.IsRight.ShouldBeTrue();
|
||||
(await ReadVersionAsync()).ShouldBe(3);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Absent_If_Match_Should_Force_Write_And_Bump_Version()
|
||||
{
|
||||
await SeedScheduleAsync(version: 2);
|
||||
var handler = new ReplaceProgramScheduleItemsHandler(_db.Factory, _worker);
|
||||
|
||||
// None expected version = Phase-1 force-write regardless of the stored version.
|
||||
Either<BaseError, IEnumerable<ProgramScheduleItemViewModel>> result =
|
||||
await handler.Handle(Command(None), CancellationToken.None);
|
||||
|
||||
result.IsRight.ShouldBeTrue();
|
||||
(await ReadVersionAsync()).ShouldBe(3);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task NoOp_Save_Should_Bump_Version_Even_With_Only_Child_Changes()
|
||||
{
|
||||
await SeedScheduleAsync(version: 5);
|
||||
var handler = new ReplaceProgramScheduleItemsHandler(_db.Factory, _worker);
|
||||
|
||||
// Same content twice: this handler saves with only CHILD changes and no root-scalar change, so the
|
||||
// unconditional bump (M1) must still rotate the version each time, otherwise a no-op PUT-back would
|
||||
// neither fire the token nor rotate other clients' ETags.
|
||||
(await handler.Handle(Command(Some(5)), CancellationToken.None)).IsRight.ShouldBeTrue();
|
||||
(await ReadVersionAsync()).ShouldBe(6);
|
||||
(await handler.Handle(Command(Some(6)), CancellationToken.None)).IsRight.ShouldBeTrue();
|
||||
(await ReadVersionAsync()).ShouldBe(7);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Racing_Save_Should_Map_Concurrency_Failure_To_412()
|
||||
{
|
||||
await SeedScheduleAsync(version: 1);
|
||||
|
||||
// Two writers load the same version, then both bump-and-save. The IsConcurrencyToken guard on
|
||||
// ProgramSchedule makes the second UPDATE key on the original version; it matches zero rows and
|
||||
// throws DbUpdateConcurrencyException, which the shared save helper maps to a PreconditionFailedError.
|
||||
await using TvContext ctxWinner = _db.CreateContext();
|
||||
await using TvContext ctxLoser = _db.CreateContext();
|
||||
|
||||
ProgramSchedule winner = await ctxWinner.ProgramSchedules.SingleAsync(s => s.Id == 1);
|
||||
ProgramSchedule loser = await ctxLoser.ProgramSchedules.SingleAsync(s => s.Id == 1);
|
||||
|
||||
winner.Version++;
|
||||
Either<BaseError, Unit> winnerResult = await ctxWinner.SaveChangesWithConcurrencyGuard(CancellationToken.None);
|
||||
winnerResult.IsRight.ShouldBeTrue();
|
||||
|
||||
loser.Version++;
|
||||
Either<BaseError, Unit> loserResult = await ctxLoser.SaveChangesWithConcurrencyGuard(CancellationToken.None);
|
||||
|
||||
loserResult.Match<BaseError?>(Right: _ => null, Left: e => e).ShouldBeOfType<PreconditionFailedError>();
|
||||
|
||||
// Non-vacuous: the winner's write stuck at exactly its +1; the loser did not overwrite it.
|
||||
(await ReadVersionAsync()).ShouldBe(2);
|
||||
}
|
||||
|
||||
private static ReplaceProgramScheduleItem MakeItem(int index, PlayoutMode mode, string searchQuery) =>
|
||||
new(
|
||||
index,
|
||||
StartType.Dynamic,
|
||||
StartTime: null,
|
||||
FixedStartTimeBehavior: null,
|
||||
mode,
|
||||
CollectionType.SearchQuery,
|
||||
CollectionId: null,
|
||||
MultiCollectionId: null,
|
||||
SmartCollectionId: null,
|
||||
RerunCollectionId: null,
|
||||
MediaItemId: null,
|
||||
PlaylistId: null,
|
||||
SearchTitle: searchQuery,
|
||||
SearchQuery: searchQuery,
|
||||
PlaybackOrder.Shuffle,
|
||||
MarathonGroupBy.None,
|
||||
MarathonShuffleGroups: false,
|
||||
MarathonShuffleItems: false,
|
||||
MarathonBatchSize: null,
|
||||
FillWithGroupMode.None,
|
||||
MultipleMode.Count,
|
||||
MultipleCount: "1",
|
||||
PlayoutDuration: null,
|
||||
TailMode.None,
|
||||
DiscardToFillAttempts: null,
|
||||
CustomTitle: null,
|
||||
GuideMode.Normal,
|
||||
PreRollFillerId: null,
|
||||
MidRollFillerId: null,
|
||||
PostRollFillerId: null,
|
||||
TailFillerId: null,
|
||||
FallbackFillerId: null,
|
||||
WatermarkIds: [],
|
||||
GraphicsElementIds: [],
|
||||
PreferredAudioLanguageCode: null,
|
||||
PreferredAudioTitle: null,
|
||||
PreferredSubtitleLanguageCode: null,
|
||||
SubtitleMode: null);
|
||||
}
|
||||
+164
@@ -0,0 +1,164 @@
|
||||
using System.Threading.Channels;
|
||||
using ErsatzTV.Application;
|
||||
using ErsatzTV.Application.Playouts;
|
||||
using ErsatzTV.Application.Scheduling;
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Domain.Scheduling;
|
||||
using ErsatzTV.Core.Errors;
|
||||
using ErsatzTV.Infrastructure.Data;
|
||||
using ErsatzTV.Tests.Support;
|
||||
using LanguageExt;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using NUnit.Framework;
|
||||
using Shouldly;
|
||||
using static LanguageExt.Prelude;
|
||||
|
||||
namespace ErsatzTV.Tests.Application.Scheduling;
|
||||
|
||||
/// <summary>
|
||||
/// Contract tests for the #253 optimistic-concurrency mechanic on the DecoTemplate aggregate,
|
||||
/// mirroring <see cref="ReplaceBlockItemsHandlerConcurrencyTests" /> (the Block reference
|
||||
/// implementation): the handler pre-check (stale If-Match → 412), the force-write path (no
|
||||
/// If-Match), the unconditional Version bump on every save, and the EF concurrency-token backstop
|
||||
/// that catches a writer that lost the load→save race. The backstop test is non-vacuous by
|
||||
/// construction — remove the <c>IsConcurrencyToken()</c> config on DecoTemplate and the losing save
|
||||
/// silently succeeds instead of mapping to a <see cref="PreconditionFailedError" />.
|
||||
/// </summary>
|
||||
[TestFixture]
|
||||
public class ReplaceDecoTemplateItemsHandlerConcurrencyTests
|
||||
{
|
||||
private InMemoryTvContext _db = null!;
|
||||
private Channel<IBackgroundServiceRequest> _channel = null!;
|
||||
|
||||
[SetUp]
|
||||
public async Task SetUp()
|
||||
{
|
||||
_db = await InMemoryTvContext.CreateAsync();
|
||||
_channel = System.Threading.Channels.Channel.CreateUnbounded<IBackgroundServiceRequest>();
|
||||
}
|
||||
|
||||
[TearDown]
|
||||
public async Task TearDown() => await _db.DisposeAsync();
|
||||
|
||||
private async Task SeedDecoTemplateAsync(int version)
|
||||
{
|
||||
await using TvContext ctx = _db.CreateContext();
|
||||
ctx.Decos.Add(new Deco { Id = 10, DecoGroupId = 1, Name = "D" });
|
||||
ctx.DecoTemplates.Add(
|
||||
new DecoTemplate
|
||||
{
|
||||
Id = 1,
|
||||
DecoTemplateGroupId = 1,
|
||||
Name = "Weekday",
|
||||
Version = version,
|
||||
Items = new List<DecoTemplateItem>()
|
||||
});
|
||||
await ctx.SaveChangesAsync();
|
||||
}
|
||||
|
||||
private ReplaceDecoTemplateItemsHandler CreateHandler() => new(_db.Factory, _channel.Writer);
|
||||
|
||||
private static ReplaceDecoTemplateItems Command(Option<int> expectedVersion) =>
|
||||
new(
|
||||
DecoTemplateId: 1,
|
||||
DecoTemplateGroupId: 1,
|
||||
Name: "Weekday",
|
||||
Items: [new ReplaceDecoTemplateItem(DecoId: 10, StartTime: TimeSpan.Zero, EndTime: TimeSpan.FromHours(1))],
|
||||
ExpectedVersion: expectedVersion);
|
||||
|
||||
private async Task<int> ReadVersionAsync()
|
||||
{
|
||||
await using TvContext ctx = _db.CreateContext();
|
||||
return await ctx.DecoTemplates.Where(t => t.Id == 1).Select(t => t.Version).SingleAsync();
|
||||
}
|
||||
|
||||
private static BaseError? LeftOrNull<T>(Either<BaseError, T> result) =>
|
||||
result.Match<BaseError?>(Right: _ => null, Left: e => e);
|
||||
|
||||
[Test]
|
||||
public async Task Stale_If_Match_Should_Fail_Precondition_And_Not_Mutate()
|
||||
{
|
||||
await SeedDecoTemplateAsync(version: 2);
|
||||
ReplaceDecoTemplateItemsHandler handler = CreateHandler();
|
||||
|
||||
Either<BaseError, List<DecoTemplateItemViewModel>> result =
|
||||
await handler.Handle(Command(Some(1)), CancellationToken.None);
|
||||
|
||||
LeftOrNull(result).ShouldBeOfType<PreconditionFailedError>();
|
||||
|
||||
// The pre-check runs before any mutation: version unchanged, no items written.
|
||||
(await ReadVersionAsync()).ShouldBe(2);
|
||||
await using TvContext ctx = _db.CreateContext();
|
||||
(await ctx.DecoTemplateItems.CountAsync(i => i.DecoTemplateId == 1)).ShouldBe(0);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Matching_If_Match_Should_Succeed_And_Bump_Version()
|
||||
{
|
||||
await SeedDecoTemplateAsync(version: 2);
|
||||
ReplaceDecoTemplateItemsHandler handler = CreateHandler();
|
||||
|
||||
Either<BaseError, List<DecoTemplateItemViewModel>> result =
|
||||
await handler.Handle(Command(Some(2)), CancellationToken.None);
|
||||
|
||||
result.IsRight.ShouldBeTrue();
|
||||
(await ReadVersionAsync()).ShouldBe(3);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Absent_If_Match_Should_Force_Write_And_Bump_Version()
|
||||
{
|
||||
await SeedDecoTemplateAsync(version: 2);
|
||||
ReplaceDecoTemplateItemsHandler handler = CreateHandler();
|
||||
|
||||
// None expected version = Phase-1 force-write regardless of the stored version.
|
||||
Either<BaseError, List<DecoTemplateItemViewModel>> result =
|
||||
await handler.Handle(Command(None), CancellationToken.None);
|
||||
|
||||
result.IsRight.ShouldBeTrue();
|
||||
(await ReadVersionAsync()).ShouldBe(3);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Save_Should_Bump_Version_Even_When_Content_Is_Unchanged()
|
||||
{
|
||||
await SeedDecoTemplateAsync(version: 5);
|
||||
ReplaceDecoTemplateItemsHandler handler = CreateHandler();
|
||||
|
||||
// Same content twice: the unconditional bump (M1) must still rotate the version each time,
|
||||
// otherwise a no-op PUT-back would not fire the token or rotate other clients' ETags.
|
||||
(await handler.Handle(Command(Some(5)), CancellationToken.None)).IsRight.ShouldBeTrue();
|
||||
(await ReadVersionAsync()).ShouldBe(6);
|
||||
(await handler.Handle(Command(Some(6)), CancellationToken.None)).IsRight.ShouldBeTrue();
|
||||
(await ReadVersionAsync()).ShouldBe(7);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Racing_Save_Should_Map_Concurrency_Failure_To_412()
|
||||
{
|
||||
await SeedDecoTemplateAsync(version: 1);
|
||||
|
||||
// Two writers load the same version, then both bump-and-save. The IsConcurrencyToken guard on
|
||||
// DecoTemplate makes the second UPDATE key on the original version; it matches zero rows and
|
||||
// throws DbUpdateConcurrencyException, which the shared save helper maps to a
|
||||
// PreconditionFailedError.
|
||||
await using TvContext ctxWinner = _db.CreateContext();
|
||||
await using TvContext ctxLoser = _db.CreateContext();
|
||||
|
||||
DecoTemplate winner = await ctxWinner.DecoTemplates.SingleAsync(t => t.Id == 1);
|
||||
DecoTemplate loser = await ctxLoser.DecoTemplates.SingleAsync(t => t.Id == 1);
|
||||
|
||||
winner.Version++;
|
||||
Either<BaseError, Unit> winnerResult = await ctxWinner.SaveChangesWithConcurrencyGuard(CancellationToken.None);
|
||||
winnerResult.IsRight.ShouldBeTrue();
|
||||
|
||||
loser.Version++;
|
||||
Either<BaseError, Unit> loserResult = await ctxLoser.SaveChangesWithConcurrencyGuard(CancellationToken.None);
|
||||
|
||||
LeftOrNull(loserResult).ShouldBeOfType<PreconditionFailedError>();
|
||||
|
||||
// Non-vacuous: the winner's write stuck at exactly its +1; the loser did not overwrite it.
|
||||
(await ReadVersionAsync()).ShouldBe(2);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,163 @@
|
||||
using ErsatzTV.Application;
|
||||
using ErsatzTV.Application.Scheduling;
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core.Domain.Scheduling;
|
||||
using ErsatzTV.Core.Errors;
|
||||
using ErsatzTV.Infrastructure.Data;
|
||||
using ErsatzTV.Tests.Support;
|
||||
using LanguageExt;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using NUnit.Framework;
|
||||
using Shouldly;
|
||||
using static LanguageExt.Prelude;
|
||||
|
||||
namespace ErsatzTV.Tests.Application.Scheduling;
|
||||
|
||||
/// <summary>
|
||||
/// Contract tests for the #253 optimistic-concurrency mechanic on the Template aggregate,
|
||||
/// mirroring <see cref="ReplaceBlockItemsHandlerConcurrencyTests" /> (the Block reference
|
||||
/// implementation): the handler pre-check (stale If-Match → 412), the force-write path (no
|
||||
/// If-Match), the unconditional Version bump on every save, and the EF concurrency-token backstop
|
||||
/// that catches a writer that lost the load→save race. The backstop test is non-vacuous by
|
||||
/// construction — remove the <c>IsConcurrencyToken()</c> config on Template and the losing save
|
||||
/// silently succeeds instead of mapping to a <see cref="PreconditionFailedError" />.
|
||||
/// </summary>
|
||||
[TestFixture]
|
||||
public class ReplaceTemplateItemsHandlerConcurrencyTests
|
||||
{
|
||||
private InMemoryTvContext _db = null!;
|
||||
|
||||
[SetUp]
|
||||
public async Task SetUp() => _db = await InMemoryTvContext.CreateAsync();
|
||||
|
||||
[TearDown]
|
||||
public async Task TearDown() => await _db.DisposeAsync();
|
||||
|
||||
private async Task SeedTemplateAsync(int version)
|
||||
{
|
||||
await using TvContext ctx = _db.CreateContext();
|
||||
ctx.Blocks.Add(
|
||||
new Block
|
||||
{
|
||||
Id = 10,
|
||||
BlockGroupId = 1,
|
||||
Name = "Morning",
|
||||
Minutes = 30,
|
||||
StopScheduling = BlockStopScheduling.AfterDurationEnd,
|
||||
Items = new List<BlockItem>()
|
||||
});
|
||||
ctx.Templates.Add(
|
||||
new Template
|
||||
{
|
||||
Id = 1,
|
||||
TemplateGroupId = 1,
|
||||
Name = "Weekday",
|
||||
Version = version,
|
||||
Items = new List<TemplateItem>()
|
||||
});
|
||||
await ctx.SaveChangesAsync();
|
||||
}
|
||||
|
||||
private static ReplaceTemplateItems Command(Option<int> expectedVersion) =>
|
||||
new(
|
||||
1,
|
||||
1,
|
||||
"Weekday",
|
||||
new List<ReplaceTemplateItem> { new(10, TimeSpan.Zero) },
|
||||
expectedVersion);
|
||||
|
||||
private async Task<int> ReadVersionAsync()
|
||||
{
|
||||
await using TvContext ctx = _db.CreateContext();
|
||||
return await ctx.Templates.Where(t => t.Id == 1).Select(t => t.Version).SingleAsync();
|
||||
}
|
||||
|
||||
private static BaseError? LeftOrNull<T>(Either<BaseError, T> result) =>
|
||||
result.Match<BaseError?>(Right: _ => null, Left: e => e);
|
||||
|
||||
[Test]
|
||||
public async Task Stale_If_Match_Should_Fail_Precondition_And_Not_Mutate()
|
||||
{
|
||||
await SeedTemplateAsync(version: 2);
|
||||
var handler = new ReplaceTemplateItemsHandler(_db.Factory);
|
||||
|
||||
Either<BaseError, List<TemplateItemViewModel>> result =
|
||||
await handler.Handle(Command(Some(1)), CancellationToken.None);
|
||||
|
||||
LeftOrNull(result).ShouldBeOfType<PreconditionFailedError>();
|
||||
|
||||
// The pre-check runs before any mutation: version unchanged, no items written.
|
||||
(await ReadVersionAsync()).ShouldBe(2);
|
||||
await using TvContext ctx = _db.CreateContext();
|
||||
(await ctx.TemplateItems.CountAsync(i => i.TemplateId == 1)).ShouldBe(0);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Matching_If_Match_Should_Succeed_And_Bump_Version()
|
||||
{
|
||||
await SeedTemplateAsync(version: 2);
|
||||
var handler = new ReplaceTemplateItemsHandler(_db.Factory);
|
||||
|
||||
Either<BaseError, List<TemplateItemViewModel>> result =
|
||||
await handler.Handle(Command(Some(2)), CancellationToken.None);
|
||||
|
||||
result.IsRight.ShouldBeTrue();
|
||||
(await ReadVersionAsync()).ShouldBe(3);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Absent_If_Match_Should_Force_Write_And_Bump_Version()
|
||||
{
|
||||
await SeedTemplateAsync(version: 2);
|
||||
var handler = new ReplaceTemplateItemsHandler(_db.Factory);
|
||||
|
||||
// None expected version = Phase-1 force-write regardless of the stored version.
|
||||
Either<BaseError, List<TemplateItemViewModel>> result =
|
||||
await handler.Handle(Command(None), CancellationToken.None);
|
||||
|
||||
result.IsRight.ShouldBeTrue();
|
||||
(await ReadVersionAsync()).ShouldBe(3);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Save_Should_Bump_Version_Even_When_Content_Is_Unchanged()
|
||||
{
|
||||
await SeedTemplateAsync(version: 5);
|
||||
var handler = new ReplaceTemplateItemsHandler(_db.Factory);
|
||||
|
||||
// Same content twice: the unconditional bump (M1) must still rotate the version each time,
|
||||
// otherwise a no-op PUT-back would not fire the token or rotate other clients' ETags.
|
||||
(await handler.Handle(Command(Some(5)), CancellationToken.None)).IsRight.ShouldBeTrue();
|
||||
(await ReadVersionAsync()).ShouldBe(6);
|
||||
(await handler.Handle(Command(Some(6)), CancellationToken.None)).IsRight.ShouldBeTrue();
|
||||
(await ReadVersionAsync()).ShouldBe(7);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Racing_Save_Should_Map_Concurrency_Failure_To_412()
|
||||
{
|
||||
await SeedTemplateAsync(version: 1);
|
||||
|
||||
// Two writers load the same version, then both bump-and-save. The IsConcurrencyToken guard on
|
||||
// Template makes the second UPDATE key on the original version; it matches zero rows and
|
||||
// throws DbUpdateConcurrencyException, which the shared save helper maps to a
|
||||
// PreconditionFailedError.
|
||||
await using TvContext ctxWinner = _db.CreateContext();
|
||||
await using TvContext ctxLoser = _db.CreateContext();
|
||||
|
||||
Template winner = await ctxWinner.Templates.SingleAsync(t => t.Id == 1);
|
||||
Template loser = await ctxLoser.Templates.SingleAsync(t => t.Id == 1);
|
||||
|
||||
winner.Version++;
|
||||
Either<BaseError, Unit> winnerResult = await ctxWinner.SaveChangesWithConcurrencyGuard(CancellationToken.None);
|
||||
winnerResult.IsRight.ShouldBeTrue();
|
||||
|
||||
loser.Version++;
|
||||
Either<BaseError, Unit> loserResult = await ctxLoser.SaveChangesWithConcurrencyGuard(CancellationToken.None);
|
||||
|
||||
LeftOrNull(loserResult).ShouldBeOfType<PreconditionFailedError>();
|
||||
|
||||
// Non-vacuous: the winner's write stuck at exactly its +1; the loser did not overwrite it.
|
||||
(await ReadVersionAsync()).ShouldBe(2);
|
||||
}
|
||||
}
|
||||
@@ -152,7 +152,7 @@ public class DecoControllerTests
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
new ErsatzTV.Application.MediaCollections.PlaylistViewModel(9, 3, "Idents", false),
|
||||
new ErsatzTV.Application.MediaCollections.PlaylistViewModel(9, 3, "Idents", false, 1),
|
||||
DecoBreakPlacement.BlockStart)
|
||||
]);
|
||||
_mediator.Send(Arg.Any<GetDecoById>(), Arg.Any<CancellationToken>())
|
||||
|
||||
@@ -4,8 +4,10 @@ using ErsatzTV.Controllers.Api;
|
||||
using ErsatzTV.Controllers.Api.Requests;
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core.Api.Scheduling;
|
||||
using ErsatzTV.Core.Errors;
|
||||
using LanguageExt;
|
||||
using MediatR;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.AspNetCore.Mvc.Routing;
|
||||
using NSubstitute;
|
||||
@@ -25,7 +27,12 @@ public class DecoTemplateControllerTests
|
||||
public void SetUp()
|
||||
{
|
||||
_mediator = Substitute.For<IMediator>();
|
||||
_controller = new DecoTemplateController(_mediator);
|
||||
_controller = new DecoTemplateController(_mediator)
|
||||
{
|
||||
// Provide a real HttpContext so the ETag/If-Match concurrency headers (#253) can be
|
||||
// read from Request and written to Response.
|
||||
ControllerContext = new ControllerContext { HttpContext = new DefaultHttpContext() }
|
||||
};
|
||||
}
|
||||
|
||||
[Test]
|
||||
@@ -259,12 +266,10 @@ public class DecoTemplateControllerTests
|
||||
_mediator.Send(Arg.Any<GetDecoTemplateById>(), Arg.Any<CancellationToken>())
|
||||
.Returns(Option<DecoTemplateViewModel>.Some(MakeDecoTemplate(4, 7, "Morning")));
|
||||
_mediator.Send(Arg.Any<ReplaceDecoTemplateItems>(), Arg.Any<CancellationToken>())
|
||||
.Returns(Right<BaseError, List<DecoTemplateItemViewModel>>([]));
|
||||
.Returns(Right<BaseError, List<DecoTemplateItemViewModel>>(
|
||||
[MakeItem(10, "Morning Bumper", TimeSpan.FromHours(6), TimeSpan.FromHours(7))]));
|
||||
_mediator.Send(Arg.Any<GetDecoTemplateItems>(), Arg.Any<CancellationToken>())
|
||||
.Returns(new List<DecoTemplateItemViewModel>
|
||||
{
|
||||
MakeItem(10, "Morning Bumper", TimeSpan.FromHours(6), TimeSpan.FromHours(7))
|
||||
});
|
||||
.Returns([MakeItem(10, "Morning Bumper", TimeSpan.FromHours(6), TimeSpan.FromHours(7))]);
|
||||
|
||||
IActionResult result = await _controller.Replace(
|
||||
4,
|
||||
@@ -318,11 +323,100 @@ public class DecoTemplateControllerTests
|
||||
CancellationToken.None);
|
||||
|
||||
result.ShouldBeOfType<UnprocessableEntityObjectResult>();
|
||||
await _mediator.DidNotReceive().Send(Arg.Any<GetDecoTemplateItems>(), Arg.Any<CancellationToken>());
|
||||
}
|
||||
|
||||
private static DecoTemplateViewModel MakeDecoTemplate(int id, int groupId, string name) =>
|
||||
new(id, groupId, "Group", name);
|
||||
[Test]
|
||||
public async Task GetItems_Should_Set_ETag_From_DecoTemplate_Version()
|
||||
{
|
||||
_mediator.Send(Arg.Any<GetDecoTemplateById>(), Arg.Any<CancellationToken>())
|
||||
.Returns(Option<DecoTemplateViewModel>.Some(MakeDecoTemplate(4, 2, "Morning", version: 9)));
|
||||
_mediator.Send(Arg.Any<GetDecoTemplateItems>(), Arg.Any<CancellationToken>())
|
||||
.Returns(new List<DecoTemplateItemViewModel>());
|
||||
|
||||
await _controller.GetItems(4, CancellationToken.None);
|
||||
|
||||
_controller.Response.Headers.ETag.ToString().ShouldBe("\"9\"");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Replace_Should_Return_400_On_Malformed_If_Match()
|
||||
{
|
||||
_controller.Request.Headers.IfMatch = "not-an-etag";
|
||||
|
||||
IActionResult result = await _controller.Replace(
|
||||
4,
|
||||
new ReplaceDecoTemplateRequest("Morning", []),
|
||||
CancellationToken.None);
|
||||
|
||||
result.ShouldBeOfType<BadRequestObjectResult>();
|
||||
await _mediator.DidNotReceive().Send(Arg.Any<GetDecoTemplateById>(), Arg.Any<CancellationToken>());
|
||||
await _mediator.DidNotReceive().Send(Arg.Any<ReplaceDecoTemplateItems>(), Arg.Any<CancellationToken>());
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Replace_Should_Thread_If_Match_Version_Into_Command()
|
||||
{
|
||||
_controller.Request.Headers.IfMatch = "\"3\"";
|
||||
_mediator.Send(Arg.Any<GetDecoTemplateById>(), Arg.Any<CancellationToken>())
|
||||
.Returns(Option<DecoTemplateViewModel>.Some(MakeDecoTemplate(4, 7, "Morning", version: 4)));
|
||||
_mediator.Send(Arg.Any<ReplaceDecoTemplateItems>(), Arg.Any<CancellationToken>())
|
||||
.Returns(Right<BaseError, List<DecoTemplateItemViewModel>>([]));
|
||||
_mediator.Send(Arg.Any<GetDecoTemplateItems>(), Arg.Any<CancellationToken>())
|
||||
.Returns(new List<DecoTemplateItemViewModel>());
|
||||
|
||||
IActionResult result = await _controller.Replace(
|
||||
4,
|
||||
new ReplaceDecoTemplateRequest("Morning", []),
|
||||
CancellationToken.None);
|
||||
|
||||
result.ShouldBeOfType<OkObjectResult>();
|
||||
// On success the response carries the refreshed deco template's ETag.
|
||||
_controller.Response.Headers.ETag.ToString().ShouldBe("\"4\"");
|
||||
await _mediator.Received(1).Send(
|
||||
Arg.Is<ReplaceDecoTemplateItems>(c => c.ExpectedVersion == Option<int>.Some(3)),
|
||||
Arg.Any<CancellationToken>());
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Replace_Without_If_Match_Should_Force_Write()
|
||||
{
|
||||
_mediator.Send(Arg.Any<GetDecoTemplateById>(), Arg.Any<CancellationToken>())
|
||||
.Returns(Option<DecoTemplateViewModel>.Some(MakeDecoTemplate(4, 7, "Morning")));
|
||||
_mediator.Send(Arg.Any<ReplaceDecoTemplateItems>(), Arg.Any<CancellationToken>())
|
||||
.Returns(Right<BaseError, List<DecoTemplateItemViewModel>>([]));
|
||||
_mediator.Send(Arg.Any<GetDecoTemplateItems>(), Arg.Any<CancellationToken>())
|
||||
.Returns(new List<DecoTemplateItemViewModel>());
|
||||
|
||||
await _controller.Replace(
|
||||
4,
|
||||
new ReplaceDecoTemplateRequest("Morning", []),
|
||||
CancellationToken.None);
|
||||
|
||||
await _mediator.Received(1).Send(
|
||||
Arg.Is<ReplaceDecoTemplateItems>(c => c.ExpectedVersion == Option<int>.None),
|
||||
Arg.Any<CancellationToken>());
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Replace_Should_Return_412_On_Precondition_Failed()
|
||||
{
|
||||
_controller.Request.Headers.IfMatch = "\"2\"";
|
||||
_mediator.Send(Arg.Any<GetDecoTemplateById>(), Arg.Any<CancellationToken>())
|
||||
.Returns(Option<DecoTemplateViewModel>.Some(MakeDecoTemplate(4, 7, "Morning", version: 5)));
|
||||
_mediator.Send(Arg.Any<ReplaceDecoTemplateItems>(), Arg.Any<CancellationToken>())
|
||||
.Returns(Left<BaseError, List<DecoTemplateItemViewModel>>(new PreconditionFailedError("stale")));
|
||||
|
||||
IActionResult result = await _controller.Replace(
|
||||
4,
|
||||
new ReplaceDecoTemplateRequest("Morning", []),
|
||||
CancellationToken.None);
|
||||
|
||||
var objectResult = result.ShouldBeOfType<ObjectResult>();
|
||||
objectResult.StatusCode.ShouldBe(StatusCodes.Status412PreconditionFailed);
|
||||
}
|
||||
|
||||
private static DecoTemplateViewModel MakeDecoTemplate(int id, int groupId, string name, int version = 1) =>
|
||||
new(id, groupId, "Group", name, version);
|
||||
|
||||
private static DecoTemplateItemViewModel MakeItem(int decoId, string decoName, TimeSpan startTime, TimeSpan endTime)
|
||||
{
|
||||
|
||||
@@ -7,8 +7,10 @@ using ErsatzTV.Controllers.Api.Requests;
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core.Api.MediaCollections;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Errors;
|
||||
using LanguageExt;
|
||||
using MediatR;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.AspNetCore.Mvc.Routing;
|
||||
using NSubstitute;
|
||||
@@ -26,7 +28,12 @@ public class PlaylistControllerTests
|
||||
public void SetUp()
|
||||
{
|
||||
_mediator = Substitute.For<IMediator>();
|
||||
_controller = new PlaylistController(_mediator);
|
||||
_controller = new PlaylistController(_mediator)
|
||||
{
|
||||
// Provide a real HttpContext so the ETag/If-Match concurrency headers (#253) can be
|
||||
// read from Request and written to Response.
|
||||
ControllerContext = new ControllerContext { HttpContext = new DefaultHttpContext() }
|
||||
};
|
||||
}
|
||||
|
||||
private PlaylistController _controller = null!;
|
||||
@@ -204,7 +211,7 @@ public class PlaylistControllerTests
|
||||
public async Task GetById_Should_Return_200_For_Some()
|
||||
{
|
||||
_mediator.Send(Arg.Any<GetPlaylistById>(), Arg.Any<CancellationToken>())
|
||||
.Returns(Option<PlaylistViewModel>.Some(new PlaylistViewModel(4, 1, "Kids", false)));
|
||||
.Returns(Option<PlaylistViewModel>.Some(new PlaylistViewModel(4, 1, "Kids", false, 1)));
|
||||
|
||||
IActionResult result = await _controller.GetById(4, CancellationToken.None);
|
||||
|
||||
@@ -226,7 +233,7 @@ public class PlaylistControllerTests
|
||||
public async Task GetItems_Should_Return_200_And_Flatten_Names()
|
||||
{
|
||||
_mediator.Send(Arg.Any<GetPlaylistById>(), Arg.Any<CancellationToken>())
|
||||
.Returns(Option<PlaylistViewModel>.Some(new PlaylistViewModel(4, 1, "Kids", false)));
|
||||
.Returns(Option<PlaylistViewModel>.Some(new PlaylistViewModel(4, 1, "Kids", false, 1)));
|
||||
_mediator.Send(Arg.Any<GetPlaylistItems>(), Arg.Any<CancellationToken>())
|
||||
.Returns(new List<PlaylistItemViewModel>
|
||||
{
|
||||
@@ -280,11 +287,24 @@ public class PlaylistControllerTests
|
||||
await _mediator.DidNotReceive().Send(Arg.Any<GetPlaylistItems>(), Arg.Any<CancellationToken>());
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task GetItems_Should_Set_ETag_From_Playlist_Version()
|
||||
{
|
||||
_mediator.Send(Arg.Any<GetPlaylistById>(), Arg.Any<CancellationToken>())
|
||||
.Returns(Option<PlaylistViewModel>.Some(new PlaylistViewModel(4, 1, "Kids", false, 9)));
|
||||
_mediator.Send(Arg.Any<GetPlaylistItems>(), Arg.Any<CancellationToken>())
|
||||
.Returns(new List<PlaylistItemViewModel>());
|
||||
|
||||
await _controller.GetItems(4, CancellationToken.None);
|
||||
|
||||
_controller.Response.Headers.ETag.ToString().ShouldBe("\"9\"");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Create_Should_Return_201_And_Map_Request()
|
||||
{
|
||||
_mediator.Send(Arg.Any<CreatePlaylist>(), Arg.Any<CancellationToken>())
|
||||
.Returns(Right<BaseError, PlaylistViewModel>(new PlaylistViewModel(9, 1, "Kids", false)));
|
||||
.Returns(Right<BaseError, PlaylistViewModel>(new PlaylistViewModel(9, 1, "Kids", false, 1)));
|
||||
|
||||
IActionResult result = await _controller.Create(
|
||||
new CreatePlaylistRequest(1, "Kids"),
|
||||
@@ -313,8 +333,12 @@ public class PlaylistControllerTests
|
||||
[Test]
|
||||
public async Task Update_Should_Return_200_With_Items_And_Map_Request_By_Array_Order()
|
||||
{
|
||||
// Existence pre-check reads version 1; the post-save re-query reads the bumped version 2 —
|
||||
// the response ETag must carry the refreshed value (issue #253).
|
||||
_mediator.Send(Arg.Any<GetPlaylistById>(), Arg.Any<CancellationToken>())
|
||||
.Returns(Option<PlaylistViewModel>.Some(new PlaylistViewModel(4, 1, "Kids", false)));
|
||||
.Returns(
|
||||
Option<PlaylistViewModel>.Some(new PlaylistViewModel(4, 1, "Kids", false, 1)),
|
||||
Option<PlaylistViewModel>.Some(new PlaylistViewModel(4, 1, "Kids", false, 2)));
|
||||
_mediator.Send(Arg.Any<ReplacePlaylistItems>(), Arg.Any<CancellationToken>())
|
||||
.Returns(Right<BaseError, List<PlaylistItemViewModel>>(new List<PlaylistItemViewModel>
|
||||
{
|
||||
@@ -331,6 +355,22 @@ public class PlaylistControllerTests
|
||||
false,
|
||||
true)
|
||||
}));
|
||||
_mediator.Send(Arg.Any<GetPlaylistItems>(), Arg.Any<CancellationToken>())
|
||||
.Returns(new List<PlaylistItemViewModel>
|
||||
{
|
||||
new(
|
||||
100,
|
||||
0,
|
||||
CollectionType.Movie,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
new NamedMediaItemViewModel(55, "The Movie"),
|
||||
PlaybackOrder.Shuffle,
|
||||
null,
|
||||
false,
|
||||
true)
|
||||
});
|
||||
|
||||
IActionResult result = await _controller.Update(
|
||||
4,
|
||||
@@ -346,6 +386,8 @@ public class PlaylistControllerTests
|
||||
|
||||
result.ShouldBeOfType<OkObjectResult>().Value.ShouldBeOfType<List<PlaylistItemResponseModel>>().Count
|
||||
.ShouldBe(1);
|
||||
// On success the response carries the refreshed playlist's ETag.
|
||||
_controller.Response.Headers.ETag.ToString().ShouldBe("\"2\"");
|
||||
await _mediator.Received(1).Send(
|
||||
Arg.Is<ReplacePlaylistItems>(c =>
|
||||
c.PlaylistId == 4 &&
|
||||
@@ -358,6 +400,81 @@ public class PlaylistControllerTests
|
||||
Arg.Any<CancellationToken>());
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Update_Should_Return_400_On_Malformed_If_Match()
|
||||
{
|
||||
_controller.Request.Headers.IfMatch = "not-an-etag";
|
||||
|
||||
IActionResult result = await _controller.Update(
|
||||
4,
|
||||
new ReplacePlaylistRequest("Kids", new List<PlaylistItemRequest>()),
|
||||
CancellationToken.None);
|
||||
|
||||
result.ShouldBeOfType<BadRequestObjectResult>();
|
||||
await _mediator.DidNotReceive().Send(Arg.Any<GetPlaylistById>(), Arg.Any<CancellationToken>());
|
||||
await _mediator.DidNotReceive().Send(Arg.Any<ReplacePlaylistItems>(), Arg.Any<CancellationToken>());
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Update_Should_Thread_If_Match_Version_Into_Command()
|
||||
{
|
||||
_controller.Request.Headers.IfMatch = "\"3\"";
|
||||
_mediator.Send(Arg.Any<GetPlaylistById>(), Arg.Any<CancellationToken>())
|
||||
.Returns(Option<PlaylistViewModel>.Some(new PlaylistViewModel(4, 1, "Kids", false, 3)));
|
||||
_mediator.Send(Arg.Any<ReplacePlaylistItems>(), Arg.Any<CancellationToken>())
|
||||
.Returns(Right<BaseError, List<PlaylistItemViewModel>>(new List<PlaylistItemViewModel>()));
|
||||
_mediator.Send(Arg.Any<GetPlaylistItems>(), Arg.Any<CancellationToken>())
|
||||
.Returns(new List<PlaylistItemViewModel>());
|
||||
|
||||
IActionResult result = await _controller.Update(
|
||||
4,
|
||||
new ReplacePlaylistRequest("Kids", new List<PlaylistItemRequest>()),
|
||||
CancellationToken.None);
|
||||
|
||||
result.ShouldBeOfType<OkObjectResult>();
|
||||
await _mediator.Received(1).Send(
|
||||
Arg.Is<ReplacePlaylistItems>(c => c.ExpectedVersion == Option<int>.Some(3)),
|
||||
Arg.Any<CancellationToken>());
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Update_Without_If_Match_Should_Force_Write()
|
||||
{
|
||||
_mediator.Send(Arg.Any<GetPlaylistById>(), Arg.Any<CancellationToken>())
|
||||
.Returns(Option<PlaylistViewModel>.Some(new PlaylistViewModel(4, 1, "Kids", false, 1)));
|
||||
_mediator.Send(Arg.Any<ReplacePlaylistItems>(), Arg.Any<CancellationToken>())
|
||||
.Returns(Right<BaseError, List<PlaylistItemViewModel>>(new List<PlaylistItemViewModel>()));
|
||||
_mediator.Send(Arg.Any<GetPlaylistItems>(), Arg.Any<CancellationToken>())
|
||||
.Returns(new List<PlaylistItemViewModel>());
|
||||
|
||||
await _controller.Update(
|
||||
4,
|
||||
new ReplacePlaylistRequest("Kids", new List<PlaylistItemRequest>()),
|
||||
CancellationToken.None);
|
||||
|
||||
await _mediator.Received(1).Send(
|
||||
Arg.Is<ReplacePlaylistItems>(c => c.ExpectedVersion == Option<int>.None),
|
||||
Arg.Any<CancellationToken>());
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Update_Should_Return_412_On_Precondition_Failed()
|
||||
{
|
||||
_controller.Request.Headers.IfMatch = "\"2\"";
|
||||
_mediator.Send(Arg.Any<GetPlaylistById>(), Arg.Any<CancellationToken>())
|
||||
.Returns(Option<PlaylistViewModel>.Some(new PlaylistViewModel(4, 1, "Kids", false, 5)));
|
||||
_mediator.Send(Arg.Any<ReplacePlaylistItems>(), Arg.Any<CancellationToken>())
|
||||
.Returns(Left<BaseError, List<PlaylistItemViewModel>>(new PreconditionFailedError("stale")));
|
||||
|
||||
IActionResult result = await _controller.Update(
|
||||
4,
|
||||
new ReplacePlaylistRequest("Kids", new List<PlaylistItemRequest>()),
|
||||
CancellationToken.None);
|
||||
|
||||
var objectResult = result.ShouldBeOfType<ObjectResult>();
|
||||
objectResult.StatusCode.ShouldBe(StatusCodes.Status412PreconditionFailed);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Update_Should_Return_404_When_Playlist_Missing()
|
||||
{
|
||||
@@ -377,7 +494,7 @@ public class PlaylistControllerTests
|
||||
public async Task Update_Should_Return_422_On_Validation_Error()
|
||||
{
|
||||
_mediator.Send(Arg.Any<GetPlaylistById>(), Arg.Any<CancellationToken>())
|
||||
.Returns(Option<PlaylistViewModel>.Some(new PlaylistViewModel(4, 1, "Kids", false)));
|
||||
.Returns(Option<PlaylistViewModel>.Some(new PlaylistViewModel(4, 1, "Kids", false, 1)));
|
||||
_mediator.Send(Arg.Any<ReplacePlaylistItems>(), Arg.Any<CancellationToken>())
|
||||
.Returns(Left<BaseError, List<PlaylistItemViewModel>>(BaseError.New("bad item")));
|
||||
|
||||
@@ -393,7 +510,7 @@ public class PlaylistControllerTests
|
||||
public async Task Update_Should_Return_422_On_System_Playlist_And_Not_Replace_Items()
|
||||
{
|
||||
_mediator.Send(Arg.Any<GetPlaylistById>(), Arg.Any<CancellationToken>())
|
||||
.Returns(Option<PlaylistViewModel>.Some(new PlaylistViewModel(4, 1, "System", true)));
|
||||
.Returns(Option<PlaylistViewModel>.Some(new PlaylistViewModel(4, 1, "System", true, 1)));
|
||||
|
||||
IActionResult result = await _controller.Update(
|
||||
4,
|
||||
@@ -410,7 +527,7 @@ public class PlaylistControllerTests
|
||||
public async Task Delete_Should_Return_204_On_Success()
|
||||
{
|
||||
_mediator.Send(Arg.Any<GetPlaylistById>(), Arg.Any<CancellationToken>())
|
||||
.Returns(Option<PlaylistViewModel>.Some(new PlaylistViewModel(4, 1, "Kids", false)));
|
||||
.Returns(Option<PlaylistViewModel>.Some(new PlaylistViewModel(4, 1, "Kids", false, 1)));
|
||||
_mediator.Send(Arg.Any<DeletePlaylist>(), Arg.Any<CancellationToken>())
|
||||
.Returns(Option<BaseError>.None);
|
||||
|
||||
@@ -438,7 +555,7 @@ public class PlaylistControllerTests
|
||||
public async Task Delete_Should_Return_422_On_System_Playlist()
|
||||
{
|
||||
_mediator.Send(Arg.Any<GetPlaylistById>(), Arg.Any<CancellationToken>())
|
||||
.Returns(Option<PlaylistViewModel>.Some(new PlaylistViewModel(4, 1, "System", true)));
|
||||
.Returns(Option<PlaylistViewModel>.Some(new PlaylistViewModel(4, 1, "System", true, 1)));
|
||||
_mediator.Send(Arg.Any<DeletePlaylist>(), Arg.Any<CancellationToken>())
|
||||
.Returns(Option<BaseError>.Some(BaseError.New("Cannot delete system (generated) playlist")));
|
||||
|
||||
@@ -451,7 +568,7 @@ public class PlaylistControllerTests
|
||||
public async Task AddItems_Should_Return_204_And_Map_Request()
|
||||
{
|
||||
_mediator.Send(Arg.Any<GetPlaylistById>(), Arg.Any<CancellationToken>())
|
||||
.Returns(Option<PlaylistViewModel>.Some(new PlaylistViewModel(4, 1, "Kids", false)));
|
||||
.Returns(Option<PlaylistViewModel>.Some(new PlaylistViewModel(4, 1, "Kids", false, 1)));
|
||||
_mediator.Send(Arg.Any<AddItemsToPlaylist>(), Arg.Any<CancellationToken>())
|
||||
.Returns(Right<BaseError, Unit>(Unit.Default));
|
||||
|
||||
@@ -488,7 +605,7 @@ public class PlaylistControllerTests
|
||||
public async Task AddItems_Should_Return_422_On_System_Playlist()
|
||||
{
|
||||
_mediator.Send(Arg.Any<GetPlaylistById>(), Arg.Any<CancellationToken>())
|
||||
.Returns(Option<PlaylistViewModel>.Some(new PlaylistViewModel(4, 1, "System", true)));
|
||||
.Returns(Option<PlaylistViewModel>.Some(new PlaylistViewModel(4, 1, "System", true, 1)));
|
||||
_mediator.Send(Arg.Any<AddItemsToPlaylist>(), Arg.Any<CancellationToken>())
|
||||
.Returns(Left<BaseError, Unit>(BaseError.New("Cannot add items to system (generated) playlist")));
|
||||
|
||||
@@ -504,7 +621,7 @@ public class PlaylistControllerTests
|
||||
public async Task AddItems_Should_Return_422_On_Validation_Error()
|
||||
{
|
||||
_mediator.Send(Arg.Any<GetPlaylistById>(), Arg.Any<CancellationToken>())
|
||||
.Returns(Option<PlaylistViewModel>.Some(new PlaylistViewModel(4, 1, "Kids", false)));
|
||||
.Returns(Option<PlaylistViewModel>.Some(new PlaylistViewModel(4, 1, "Kids", false, 1)));
|
||||
_mediator.Send(Arg.Any<AddItemsToPlaylist>(), Arg.Any<CancellationToken>())
|
||||
.Returns(Left<BaseError, Unit>(BaseError.New("Movie does not exist")));
|
||||
|
||||
|
||||
@@ -1131,7 +1131,7 @@ public class PlayoutControllerTests
|
||||
_mediator.Send(Arg.Any<GetAllTemplates>(), Arg.Any<CancellationToken>())
|
||||
.Returns([MakeTemplateViewModel(7), MakeTemplateViewModel(8)]);
|
||||
_mediator.Send(Arg.Any<GetDecoTemplateById>(), Arg.Any<CancellationToken>())
|
||||
.Returns(Option<DecoTemplateViewModel>.Some(new DecoTemplateViewModel(5, 1, "G", "DT")));
|
||||
.Returns(Option<DecoTemplateViewModel>.Some(new DecoTemplateViewModel(5, 1, "G", "DT", 0)));
|
||||
_mediator.Send(Arg.Any<ReplacePlayoutTemplateItems>(), Arg.Any<CancellationToken>())
|
||||
.Returns(Option<BaseError>.None);
|
||||
_mediator.Send(Arg.Any<GetPlayoutTemplates>(), Arg.Any<CancellationToken>())
|
||||
@@ -1270,16 +1270,16 @@ public class PlayoutControllerTests
|
||||
new(0, programScheduleId, [], [], [], false, 1, 1, null, 12, 31, null);
|
||||
|
||||
private static ProgramScheduleViewModel MakeScheduleVm(int id) =>
|
||||
new(id, $"Schedule {id}", false, false, false, false, FixedStartTimeBehavior.Strict);
|
||||
new(id, $"Schedule {id}", false, false, false, false, FixedStartTimeBehavior.Strict, 0);
|
||||
|
||||
private static TemplateViewModel MakeTemplateViewModel(int id) =>
|
||||
new(id, 1, "Group", $"Template {id}");
|
||||
new(id, 1, "Group", $"Template {id}", 0);
|
||||
|
||||
private static PlayoutTemplateViewModel MakeTemplateVm(int id, int index, int templateId, int? decoTemplateId) =>
|
||||
new(
|
||||
id,
|
||||
new TemplateViewModel(templateId, 1, "Group", $"Template {templateId}"),
|
||||
decoTemplateId is { } dtId ? new DecoTemplateViewModel(dtId, 1, "DGroup", $"Deco {dtId}") : null,
|
||||
new TemplateViewModel(templateId, 1, "Group", $"Template {templateId}", 0),
|
||||
decoTemplateId is { } dtId ? new DecoTemplateViewModel(dtId, 1, "DGroup", $"Deco {dtId}", 0) : null,
|
||||
index,
|
||||
[],
|
||||
[],
|
||||
|
||||
@@ -10,6 +10,7 @@ using ErsatzTV.Core.Errors;
|
||||
using ErsatzTV.Core.Scheduling;
|
||||
using LanguageExt;
|
||||
using MediatR;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.AspNetCore.Mvc.Routing;
|
||||
using NSubstitute;
|
||||
@@ -30,7 +31,12 @@ public class ScheduleControllerTests
|
||||
public void SetUp()
|
||||
{
|
||||
_mediator = Substitute.For<IMediator>();
|
||||
_controller = new ScheduleController(_mediator);
|
||||
_controller = new ScheduleController(_mediator)
|
||||
{
|
||||
// Provide a real HttpContext so the ETag/If-Match concurrency headers (#253) can be
|
||||
// read from Request and written to Response.
|
||||
ControllerContext = new ControllerContext { HttpContext = new DefaultHttpContext() }
|
||||
};
|
||||
}
|
||||
|
||||
[Test]
|
||||
@@ -253,6 +259,10 @@ public class ScheduleControllerTests
|
||||
List<ProgramScheduleItemViewModel> items = [MakeOneItem(21), MakeOneItem(22)];
|
||||
_mediator.Send(Arg.Any<ReplaceProgramScheduleItems>(), Arg.Any<CancellationToken>())
|
||||
.Returns(Right<BaseError, IEnumerable<ProgramScheduleItemViewModel>>(items));
|
||||
_mediator.Send(Arg.Any<GetProgramScheduleById>(), Arg.Any<CancellationToken>())
|
||||
.Returns(Option<ProgramScheduleViewModel>.Some(MakeSchedule(4, "Daily")));
|
||||
_mediator.Send(Arg.Any<GetProgramScheduleItems>(), Arg.Any<CancellationToken>())
|
||||
.Returns(items);
|
||||
|
||||
IActionResult result = await _controller.ReplaceItems(
|
||||
4,
|
||||
@@ -271,6 +281,94 @@ public class ScheduleControllerTests
|
||||
Arg.Any<CancellationToken>());
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task GetItems_Should_Set_ETag_From_Schedule_Version()
|
||||
{
|
||||
var response = new ProgramScheduleItemsWithDurationViewModel([], null);
|
||||
_mediator.Send(Arg.Any<GetProgramScheduleById>(), Arg.Any<CancellationToken>())
|
||||
.Returns(Option<ProgramScheduleViewModel>.Some(MakeSchedule(4, "Daily", version: 9)));
|
||||
_mediator.Send(Arg.Any<GetProgramScheduleItemsWithDurations>(), Arg.Any<CancellationToken>())
|
||||
.Returns(response);
|
||||
|
||||
await _controller.GetItems(4, CancellationToken.None);
|
||||
|
||||
_controller.Response.Headers.ETag.ToString().ShouldBe("\"9\"");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task ReplaceItems_Should_Return_400_On_Malformed_If_Match()
|
||||
{
|
||||
_controller.Request.Headers.IfMatch = "not-an-etag";
|
||||
|
||||
IActionResult result = await _controller.ReplaceItems(
|
||||
4,
|
||||
new ReplaceScheduleItemsRequest([MakeItemRequest(PlayoutMode.One)]),
|
||||
CancellationToken.None);
|
||||
|
||||
result.ShouldBeOfType<BadRequestObjectResult>();
|
||||
await _mediator.DidNotReceive().Send(Arg.Any<ReplaceProgramScheduleItems>(), Arg.Any<CancellationToken>());
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task ReplaceItems_Should_Thread_If_Match_Version_Into_Command_And_Set_New_ETag()
|
||||
{
|
||||
_controller.Request.Headers.IfMatch = "\"3\"";
|
||||
_mediator.Send(Arg.Any<ReplaceProgramScheduleItems>(), Arg.Any<CancellationToken>())
|
||||
.Returns(Right<BaseError, IEnumerable<ProgramScheduleItemViewModel>>([MakeOneItem(21)]));
|
||||
_mediator.Send(Arg.Any<GetProgramScheduleById>(), Arg.Any<CancellationToken>())
|
||||
.Returns(Option<ProgramScheduleViewModel>.Some(MakeSchedule(4, "Daily", version: 4)));
|
||||
_mediator.Send(Arg.Any<GetProgramScheduleItems>(), Arg.Any<CancellationToken>())
|
||||
.Returns([MakeOneItem(21)]);
|
||||
|
||||
IActionResult result = await _controller.ReplaceItems(
|
||||
4,
|
||||
new ReplaceScheduleItemsRequest([MakeItemRequest(PlayoutMode.One)]),
|
||||
CancellationToken.None);
|
||||
|
||||
result.ShouldBeOfType<OkObjectResult>();
|
||||
// On success the response carries the refreshed schedule's ETag.
|
||||
_controller.Response.Headers.ETag.ToString().ShouldBe("\"4\"");
|
||||
await _mediator.Received(1).Send(
|
||||
Arg.Is<ReplaceProgramScheduleItems>(c => c.ExpectedVersion == Option<int>.Some(3)),
|
||||
Arg.Any<CancellationToken>());
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task ReplaceItems_Without_If_Match_Should_Force_Write()
|
||||
{
|
||||
_mediator.Send(Arg.Any<ReplaceProgramScheduleItems>(), Arg.Any<CancellationToken>())
|
||||
.Returns(Right<BaseError, IEnumerable<ProgramScheduleItemViewModel>>([MakeOneItem(21)]));
|
||||
_mediator.Send(Arg.Any<GetProgramScheduleById>(), Arg.Any<CancellationToken>())
|
||||
.Returns(Option<ProgramScheduleViewModel>.Some(MakeSchedule(4, "Daily", version: 1)));
|
||||
_mediator.Send(Arg.Any<GetProgramScheduleItems>(), Arg.Any<CancellationToken>())
|
||||
.Returns([MakeOneItem(21)]);
|
||||
|
||||
await _controller.ReplaceItems(
|
||||
4,
|
||||
new ReplaceScheduleItemsRequest([MakeItemRequest(PlayoutMode.One)]),
|
||||
CancellationToken.None);
|
||||
|
||||
await _mediator.Received(1).Send(
|
||||
Arg.Is<ReplaceProgramScheduleItems>(c => c.ExpectedVersion == Option<int>.None),
|
||||
Arg.Any<CancellationToken>());
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task ReplaceItems_Should_Return_412_On_Precondition_Failed()
|
||||
{
|
||||
_controller.Request.Headers.IfMatch = "\"2\"";
|
||||
_mediator.Send(Arg.Any<ReplaceProgramScheduleItems>(), Arg.Any<CancellationToken>())
|
||||
.Returns(Left<BaseError, IEnumerable<ProgramScheduleItemViewModel>>(new PreconditionFailedError("stale")));
|
||||
|
||||
IActionResult result = await _controller.ReplaceItems(
|
||||
4,
|
||||
new ReplaceScheduleItemsRequest([MakeItemRequest(PlayoutMode.One)]),
|
||||
CancellationToken.None);
|
||||
|
||||
var objectResult = result.ShouldBeOfType<ObjectResult>();
|
||||
objectResult.StatusCode.ShouldBe(StatusCodes.Status412PreconditionFailed);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task DeleteItem_Should_Return_204_And_Map_Route_Ids()
|
||||
{
|
||||
@@ -343,8 +441,8 @@ public class ScheduleControllerTests
|
||||
PreferredSubtitleLanguageCode: null,
|
||||
SubtitleMode: null);
|
||||
|
||||
private static ProgramScheduleViewModel MakeSchedule(int id, string name) =>
|
||||
new(id, name, true, true, false, false, FixedStartTimeBehavior.Flexible);
|
||||
private static ProgramScheduleViewModel MakeSchedule(int id, string name, int version = 0) =>
|
||||
new(id, name, true, true, false, false, FixedStartTimeBehavior.Flexible, version);
|
||||
|
||||
private static ProgramScheduleItemOneViewModel MakeOneItem(int id) =>
|
||||
new(
|
||||
|
||||
@@ -4,8 +4,10 @@ using ErsatzTV.Controllers.Api;
|
||||
using ErsatzTV.Controllers.Api.Requests;
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core.Api.Scheduling;
|
||||
using ErsatzTV.Core.Errors;
|
||||
using LanguageExt;
|
||||
using MediatR;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.AspNetCore.Mvc.Routing;
|
||||
using NSubstitute;
|
||||
@@ -25,7 +27,12 @@ public class TemplateControllerTests
|
||||
public void SetUp()
|
||||
{
|
||||
_mediator = Substitute.For<IMediator>();
|
||||
_controller = new TemplateController(_mediator);
|
||||
_controller = new TemplateController(_mediator)
|
||||
{
|
||||
// Provide a real HttpContext so the ETag/If-Match concurrency headers (#253) can be
|
||||
// read from Request and written to Response.
|
||||
ControllerContext = new ControllerContext { HttpContext = new DefaultHttpContext() }
|
||||
};
|
||||
}
|
||||
|
||||
[Test]
|
||||
@@ -248,12 +255,10 @@ public class TemplateControllerTests
|
||||
_mediator.Send(Arg.Any<GetTemplateById>(), Arg.Any<CancellationToken>())
|
||||
.Returns(Option<TemplateViewModel>.Some(MakeTemplate(4, 7, "Morning")));
|
||||
_mediator.Send(Arg.Any<ReplaceTemplateItems>(), Arg.Any<CancellationToken>())
|
||||
.Returns(Right<BaseError, List<TemplateItemViewModel>>([]));
|
||||
.Returns(Right<BaseError, List<TemplateItemViewModel>>(
|
||||
[MakeItem(10, "Cartoons", TimeSpan.FromHours(6), 60)]));
|
||||
_mediator.Send(Arg.Any<GetTemplateItems>(), Arg.Any<CancellationToken>())
|
||||
.Returns(new List<TemplateItemViewModel>
|
||||
{
|
||||
MakeItem(10, "Cartoons", TimeSpan.FromHours(6), 60)
|
||||
});
|
||||
.Returns([MakeItem(10, "Cartoons", TimeSpan.FromHours(6), 60)]);
|
||||
|
||||
IActionResult result = await _controller.Replace(
|
||||
4,
|
||||
@@ -305,7 +310,96 @@ public class TemplateControllerTests
|
||||
CancellationToken.None);
|
||||
|
||||
result.ShouldBeOfType<UnprocessableEntityObjectResult>();
|
||||
await _mediator.DidNotReceive().Send(Arg.Any<GetTemplateItems>(), Arg.Any<CancellationToken>());
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task GetItems_Should_Set_ETag_From_Template_Version()
|
||||
{
|
||||
_mediator.Send(Arg.Any<GetTemplateById>(), Arg.Any<CancellationToken>())
|
||||
.Returns(Option<TemplateViewModel>.Some(MakeTemplate(4, 2, "Morning", version: 9)));
|
||||
_mediator.Send(Arg.Any<GetTemplateItems>(), Arg.Any<CancellationToken>())
|
||||
.Returns(new List<TemplateItemViewModel>());
|
||||
|
||||
await _controller.GetItems(4, CancellationToken.None);
|
||||
|
||||
_controller.Response.Headers.ETag.ToString().ShouldBe("\"9\"");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Replace_Should_Return_400_On_Malformed_If_Match()
|
||||
{
|
||||
_controller.Request.Headers.IfMatch = "not-an-etag";
|
||||
|
||||
IActionResult result = await _controller.Replace(
|
||||
4,
|
||||
new ReplaceTemplateRequest("Morning", []),
|
||||
CancellationToken.None);
|
||||
|
||||
result.ShouldBeOfType<BadRequestObjectResult>();
|
||||
await _mediator.DidNotReceive().Send(Arg.Any<GetTemplateById>(), Arg.Any<CancellationToken>());
|
||||
await _mediator.DidNotReceive().Send(Arg.Any<ReplaceTemplateItems>(), Arg.Any<CancellationToken>());
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Replace_Should_Thread_If_Match_Version_Into_Command()
|
||||
{
|
||||
_controller.Request.Headers.IfMatch = "\"3\"";
|
||||
_mediator.Send(Arg.Any<GetTemplateById>(), Arg.Any<CancellationToken>())
|
||||
.Returns(Option<TemplateViewModel>.Some(MakeTemplate(4, 7, "Morning", version: 4)));
|
||||
_mediator.Send(Arg.Any<ReplaceTemplateItems>(), Arg.Any<CancellationToken>())
|
||||
.Returns(Right<BaseError, List<TemplateItemViewModel>>([]));
|
||||
_mediator.Send(Arg.Any<GetTemplateItems>(), Arg.Any<CancellationToken>())
|
||||
.Returns(new List<TemplateItemViewModel>());
|
||||
|
||||
IActionResult result = await _controller.Replace(
|
||||
4,
|
||||
new ReplaceTemplateRequest("Morning", []),
|
||||
CancellationToken.None);
|
||||
|
||||
result.ShouldBeOfType<OkObjectResult>();
|
||||
// On success the response carries the refreshed template's ETag.
|
||||
_controller.Response.Headers.ETag.ToString().ShouldBe("\"4\"");
|
||||
await _mediator.Received(1).Send(
|
||||
Arg.Is<ReplaceTemplateItems>(c => c.ExpectedVersion == Option<int>.Some(3)),
|
||||
Arg.Any<CancellationToken>());
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Replace_Without_If_Match_Should_Force_Write()
|
||||
{
|
||||
_mediator.Send(Arg.Any<GetTemplateById>(), Arg.Any<CancellationToken>())
|
||||
.Returns(Option<TemplateViewModel>.Some(MakeTemplate(4, 7, "Morning")));
|
||||
_mediator.Send(Arg.Any<ReplaceTemplateItems>(), Arg.Any<CancellationToken>())
|
||||
.Returns(Right<BaseError, List<TemplateItemViewModel>>([]));
|
||||
_mediator.Send(Arg.Any<GetTemplateItems>(), Arg.Any<CancellationToken>())
|
||||
.Returns(new List<TemplateItemViewModel>());
|
||||
|
||||
await _controller.Replace(
|
||||
4,
|
||||
new ReplaceTemplateRequest("Morning", []),
|
||||
CancellationToken.None);
|
||||
|
||||
await _mediator.Received(1).Send(
|
||||
Arg.Is<ReplaceTemplateItems>(c => c.ExpectedVersion == Option<int>.None),
|
||||
Arg.Any<CancellationToken>());
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Replace_Should_Return_412_On_Precondition_Failed()
|
||||
{
|
||||
_controller.Request.Headers.IfMatch = "\"2\"";
|
||||
_mediator.Send(Arg.Any<GetTemplateById>(), Arg.Any<CancellationToken>())
|
||||
.Returns(Option<TemplateViewModel>.Some(MakeTemplate(4, 7, "Morning", version: 5)));
|
||||
_mediator.Send(Arg.Any<ReplaceTemplateItems>(), Arg.Any<CancellationToken>())
|
||||
.Returns(Left<BaseError, List<TemplateItemViewModel>>(new PreconditionFailedError("stale")));
|
||||
|
||||
IActionResult result = await _controller.Replace(
|
||||
4,
|
||||
new ReplaceTemplateRequest("Morning", []),
|
||||
CancellationToken.None);
|
||||
|
||||
var objectResult = result.ShouldBeOfType<ObjectResult>();
|
||||
objectResult.StatusCode.ShouldBe(StatusCodes.Status412PreconditionFailed);
|
||||
}
|
||||
|
||||
[Test]
|
||||
@@ -359,8 +453,8 @@ public class TemplateControllerTests
|
||||
result.ShouldBeOfType<UnprocessableEntityObjectResult>();
|
||||
}
|
||||
|
||||
private static TemplateViewModel MakeTemplate(int id, int groupId, string name) =>
|
||||
new(id, groupId, "Group", name);
|
||||
private static TemplateViewModel MakeTemplate(int id, int groupId, string name, int version = 1) =>
|
||||
new(id, groupId, "Group", name, version);
|
||||
|
||||
private static TemplateItemViewModel MakeItem(int blockId, string blockName, TimeSpan startTime, int minutes)
|
||||
{
|
||||
|
||||
@@ -143,6 +143,9 @@ public class DecoTemplateController(IMediator mediator) : ControllerBase
|
||||
[HttpGet("/api/deco-templates/{id:int}/items")]
|
||||
[Tags("DecoTemplates")]
|
||||
[EndpointSummary("Get deco template items")]
|
||||
[EndpointDescription(
|
||||
"Returns the deco template's items and a strong ETag of the deco template's version. Pass that ETag " +
|
||||
"back as If-Match on the replace (PUT) to detect a concurrent edit (issue #253).")]
|
||||
[EndpointGroupName("general")]
|
||||
[ProducesResponseType(typeof(List<DecoTemplateItemResponseModel>), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)]
|
||||
@@ -154,6 +157,9 @@ public class DecoTemplateController(IMediator mediator) : ControllerBase
|
||||
return ApiResults.NotFoundProblem();
|
||||
}
|
||||
|
||||
// The items GET returns children, not the root, so read the deco template's version for the ETag.
|
||||
ConcurrencyHeaders.SetETag(Response, decoTemplate.Map(t => t.Version).IfNone(0));
|
||||
|
||||
List<DecoTemplateItemViewModel> items = await mediator.Send(new GetDecoTemplateItems(id), cancellationToken);
|
||||
return new OkObjectResult(
|
||||
items.OrderBy(i => i.StartTime.TimeOfDay).Map(ProjectToResponseModel).ToList());
|
||||
@@ -164,16 +170,32 @@ public class DecoTemplateController(IMediator mediator) : ControllerBase
|
||||
[EndpointSummary("Replace a deco template and its items")]
|
||||
[EndpointDescription(
|
||||
"Replaces the deco template's name and its full item list. Each item assigns a deco to a start/end time " +
|
||||
"of day; an end time of 00:00:00 means the item runs to the end of the day. Items must not overlap.")]
|
||||
"of day; an end time of 00:00:00 means the item runs to the end of the day. Items must not overlap. " +
|
||||
"Send the ETag from the items GET as If-Match to reject a stale overwrite with 412 (issue #253); " +
|
||||
"a successful response carries the new ETag.")]
|
||||
[EndpointGroupName("general")]
|
||||
[ProducesResponseType(typeof(DecoTemplateWithItemsResponseModel), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status400BadRequest)]
|
||||
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)]
|
||||
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status412PreconditionFailed)]
|
||||
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status422UnprocessableEntity)]
|
||||
public async Task<IActionResult> Replace(
|
||||
int id,
|
||||
[Required] [FromBody] ReplaceDecoTemplateRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
IfMatchCondition ifMatch = ConcurrencyHeaders.ParseIfMatch(Request);
|
||||
if (ifMatch.Kind is IfMatchKind.Malformed)
|
||||
{
|
||||
return new BadRequestObjectResult(
|
||||
new ProblemDetails
|
||||
{
|
||||
Status = StatusCodes.Status400BadRequest,
|
||||
Title = "Invalid If-Match header",
|
||||
Detail = "If-Match must be a strong ETag of the resource version (e.g. \"3\") or \"*\"."
|
||||
});
|
||||
}
|
||||
|
||||
Option<DecoTemplateViewModel> maybeDecoTemplate =
|
||||
await mediator.Send(new GetDecoTemplateById(id), cancellationToken);
|
||||
if (maybeDecoTemplate.IsNone)
|
||||
@@ -184,18 +206,27 @@ public class DecoTemplateController(IMediator mediator) : ControllerBase
|
||||
int decoTemplateGroupId = maybeDecoTemplate.Map(t => t.DecoTemplateGroupId).IfNone(0);
|
||||
|
||||
Either<BaseError, List<DecoTemplateItemViewModel>> result =
|
||||
await mediator.Send(request.ToCommand(decoTemplateGroupId, id), cancellationToken);
|
||||
await mediator.Send(
|
||||
request.ToCommand(decoTemplateGroupId, id, ifMatch.ExpectedVersion),
|
||||
cancellationToken);
|
||||
|
||||
return await result.Match(
|
||||
Left: error => Task.FromResult(error.ToErrorResult()),
|
||||
Right: async _ =>
|
||||
{
|
||||
// Reload the root (version) FIRST, then the items, so the emitted ETag is never newer than
|
||||
// the returned items (issue #253 fail-safe ordering; matches BlockController).
|
||||
Option<DecoTemplateViewModel> refreshed =
|
||||
await mediator.Send(new GetDecoTemplateById(id), cancellationToken);
|
||||
List<DecoTemplateItemViewModel> items =
|
||||
await mediator.Send(new GetDecoTemplateItems(id), cancellationToken);
|
||||
return refreshed.Match(
|
||||
Some: vm => (IActionResult)new OkObjectResult(ProjectToWithItemsResponseModel(vm, items)),
|
||||
Some: vm =>
|
||||
{
|
||||
// Return the new ETag so a same-tab second save doesn't 412 against its own write.
|
||||
ConcurrencyHeaders.SetETag(Response, vm.Version);
|
||||
return (IActionResult)new OkObjectResult(ProjectToWithItemsResponseModel(vm, items));
|
||||
},
|
||||
None: () => ApiResults.NotFoundProblem());
|
||||
});
|
||||
}
|
||||
|
||||
@@ -134,6 +134,9 @@ public class PlaylistController(IMediator mediator) : ControllerBase
|
||||
[HttpGet("/api/playlists/{id:int}/items", Name = "GetPlaylistItems")]
|
||||
[Tags("Playlists")]
|
||||
[EndpointSummary("Get the items in a playlist")]
|
||||
[EndpointDescription(
|
||||
"Returns the playlist's items and a strong ETag of the playlist's version. Pass that ETag back " +
|
||||
"as If-Match on the replace (PUT) to detect a concurrent edit (issue #253).")]
|
||||
[EndpointGroupName("general")]
|
||||
[ProducesResponseType(typeof(List<PlaylistItemResponseModel>), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)]
|
||||
@@ -145,6 +148,9 @@ public class PlaylistController(IMediator mediator) : ControllerBase
|
||||
return ApiResults.NotFoundProblem();
|
||||
}
|
||||
|
||||
// The items GET returns children, not the root, so read the playlist's version for the ETag.
|
||||
ConcurrencyHeaders.SetETag(Response, maybePlaylist.Map(p => p.Version).IfNone(0));
|
||||
|
||||
List<PlaylistItemViewModel> items = await mediator.Send(new GetPlaylistItems(id), cancellationToken);
|
||||
return new OkObjectResult(items.Map(ProjectToItemResponse).ToList());
|
||||
}
|
||||
@@ -168,15 +174,33 @@ public class PlaylistController(IMediator mediator) : ControllerBase
|
||||
[HttpPut("/api/playlists/{id:int}", Name = "UpdatePlaylist")]
|
||||
[Tags("Playlists")]
|
||||
[EndpointSummary("Update a playlist (rename and replace its items)")]
|
||||
[EndpointDescription(
|
||||
"Replaces the playlist's name and its full item list. Item indexes are assigned from the array " +
|
||||
"order. Send the ETag from the items GET as If-Match to reject a stale overwrite with 412 " +
|
||||
"(issue #253); a successful response carries the new ETag.")]
|
||||
[EndpointGroupName("general")]
|
||||
[ProducesResponseType(typeof(List<PlaylistItemResponseModel>), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status400BadRequest)]
|
||||
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)]
|
||||
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status412PreconditionFailed)]
|
||||
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status422UnprocessableEntity)]
|
||||
public async Task<IActionResult> Update(
|
||||
int id,
|
||||
[Required] [FromBody] ReplacePlaylistRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
IfMatchCondition ifMatch = ConcurrencyHeaders.ParseIfMatch(Request);
|
||||
if (ifMatch.Kind is IfMatchKind.Malformed)
|
||||
{
|
||||
return new BadRequestObjectResult(
|
||||
new ProblemDetails
|
||||
{
|
||||
Status = StatusCodes.Status400BadRequest,
|
||||
Title = "Invalid If-Match header",
|
||||
Detail = "If-Match must be a strong ETag of the resource version (e.g. \"3\") or \"*\"."
|
||||
});
|
||||
}
|
||||
|
||||
Option<PlaylistViewModel> maybePlaylist = await mediator.Send(new GetPlaylistById(id), cancellationToken);
|
||||
if (maybePlaylist.IsNone)
|
||||
{
|
||||
@@ -195,10 +219,25 @@ public class PlaylistController(IMediator mediator) : ControllerBase
|
||||
}
|
||||
|
||||
Either<BaseError, List<PlaylistItemViewModel>> result =
|
||||
await mediator.Send(request.ToCommand(id), cancellationToken);
|
||||
return result.Match(
|
||||
Left: error => error.ToErrorResult(),
|
||||
Right: items => (IActionResult)new OkObjectResult(items.Map(ProjectToItemResponse).ToList()));
|
||||
await mediator.Send(request.ToCommand(id, ifMatch.ExpectedVersion), cancellationToken);
|
||||
return await result.Match(
|
||||
Left: error => Task.FromResult(error.ToErrorResult()),
|
||||
Right: async _ =>
|
||||
{
|
||||
// Reload the root (version) FIRST, then the items, so the emitted ETag is never newer than
|
||||
// the returned items (issue #253 fail-safe ordering; matches BlockController). A None root
|
||||
// (deleted between commit and reload) is a 404, never a 200 without an ETag.
|
||||
Option<PlaylistViewModel> refreshed =
|
||||
await mediator.Send(new GetPlaylistById(id), cancellationToken);
|
||||
List<PlaylistItemViewModel> items = await mediator.Send(new GetPlaylistItems(id), cancellationToken);
|
||||
return refreshed.Match(
|
||||
Some: vm =>
|
||||
{
|
||||
ConcurrencyHeaders.SetETag(Response, vm.Version);
|
||||
return (IActionResult)new OkObjectResult(items.Map(ProjectToItemResponse).ToList());
|
||||
},
|
||||
None: () => ApiResults.NotFoundProblem());
|
||||
});
|
||||
}
|
||||
|
||||
[HttpDelete("/api/playlists/{id:int}", Name = "DeletePlaylist")]
|
||||
|
||||
@@ -4,10 +4,14 @@ namespace ErsatzTV.Controllers.Api.Requests;
|
||||
|
||||
public record ReplaceDecoTemplateRequest(string Name, List<DecoTemplateItemRequest> Items)
|
||||
{
|
||||
public ReplaceDecoTemplateItems ToCommand(int decoTemplateGroupId, int decoTemplateId) =>
|
||||
public ReplaceDecoTemplateItems ToCommand(
|
||||
int decoTemplateGroupId,
|
||||
int decoTemplateId,
|
||||
Option<int> expectedVersion = default) =>
|
||||
new(
|
||||
decoTemplateId,
|
||||
decoTemplateGroupId,
|
||||
Name,
|
||||
(Items ?? []).Select(item => item.ToReplaceItem()).ToList());
|
||||
(Items ?? []).Select(item => item.ToReplaceItem()).ToList(),
|
||||
expectedVersion);
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using ErsatzTV.Application.MediaCollections;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using LanguageExt;
|
||||
|
||||
namespace ErsatzTV.Controllers.Api.Requests;
|
||||
|
||||
@@ -56,8 +57,8 @@ public record PlaylistItemRequest(
|
||||
|
||||
public record ReplacePlaylistRequest(string? Name, List<PlaylistItemRequest>? Items)
|
||||
{
|
||||
public ReplacePlaylistItems ToCommand(int id) =>
|
||||
new(id, Name ?? string.Empty, BuildItems());
|
||||
public ReplacePlaylistItems ToCommand(int id, Option<int> expectedVersion = default) =>
|
||||
new(id, Name ?? string.Empty, BuildItems(), expectedVersion);
|
||||
|
||||
// Preview operates on the posted draft, so there is no persisted playlist id (0).
|
||||
public ReplacePlaylistItems ToReplaceCommand() =>
|
||||
|
||||
@@ -4,8 +4,9 @@ namespace ErsatzTV.Controllers.Api.Requests;
|
||||
|
||||
public record ReplaceScheduleItemsRequest(List<ScheduleItemRequest> Items)
|
||||
{
|
||||
public ReplaceProgramScheduleItems ToCommand(int scheduleId) =>
|
||||
public ReplaceProgramScheduleItems ToCommand(int scheduleId, Option<int> expectedVersion = default) =>
|
||||
new(
|
||||
scheduleId,
|
||||
(Items ?? []).Select((item, index) => item.ToReplaceCommand(index)).ToList());
|
||||
(Items ?? []).Select((item, index) => item.ToReplaceCommand(index)).ToList(),
|
||||
expectedVersion);
|
||||
}
|
||||
|
||||
@@ -4,10 +4,11 @@ namespace ErsatzTV.Controllers.Api.Requests;
|
||||
|
||||
public record ReplaceTemplateRequest(string Name, List<TemplateItemRequest> Items)
|
||||
{
|
||||
public ReplaceTemplateItems ToCommand(int templateGroupId, int templateId) =>
|
||||
public ReplaceTemplateItems ToCommand(int templateGroupId, int templateId, Option<int> expectedVersion = default) =>
|
||||
new(
|
||||
templateGroupId,
|
||||
templateId,
|
||||
Name,
|
||||
(Items ?? []).Select(item => item.ToReplaceItem()).ToList());
|
||||
(Items ?? []).Select(item => item.ToReplaceItem()).ToList(),
|
||||
expectedVersion);
|
||||
}
|
||||
|
||||
@@ -104,7 +104,9 @@ public class ScheduleController(IMediator mediator) : ControllerBase
|
||||
[EndpointDescription(
|
||||
"Returns the schedule's items plus a computed, best-effort runtime estimate: each item carries a " +
|
||||
"nullable durationEstimate and the envelope carries a nullable totalDurationEstimate. Estimates are " +
|
||||
"derived from referenced collection/media runtimes and are null when unbounded or unknown.")]
|
||||
"derived from referenced collection/media runtimes and are null when unbounded or unknown. The " +
|
||||
"response also carries a strong ETag of the schedule's version; pass that ETag back as If-Match on " +
|
||||
"the replace (PUT) to detect a concurrent edit (issue #253).")]
|
||||
[EndpointGroupName("general")]
|
||||
[ProducesResponseType(typeof(ScheduleItemsResponseModel), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)]
|
||||
@@ -116,6 +118,9 @@ public class ScheduleController(IMediator mediator) : ControllerBase
|
||||
return ApiResults.NotFoundProblem();
|
||||
}
|
||||
|
||||
// The items GET returns children, not the root, so read the schedule's version for the ETag.
|
||||
ConcurrencyHeaders.SetETag(Response, schedule.Map(s => s.Version).IfNone(0));
|
||||
|
||||
ProgramScheduleItemsWithDurationViewModel items =
|
||||
await mediator.Send(new GetProgramScheduleItemsWithDurations(id), cancellationToken);
|
||||
return new OkObjectResult(ScheduleItemResponseMapper.ProjectToResponseModel(items));
|
||||
@@ -143,20 +148,57 @@ public class ScheduleController(IMediator mediator) : ControllerBase
|
||||
[HttpPut("/api/schedules/{id:int}/items")]
|
||||
[Tags("Schedules")]
|
||||
[EndpointSummary("Replace schedule items")]
|
||||
[EndpointDescription(
|
||||
"Replaces the schedule's full item list; item indexes are assigned from the array order. Send the " +
|
||||
"ETag from the items GET as If-Match to reject a stale overwrite with 412 (issue #253); a successful " +
|
||||
"response carries the new ETag.")]
|
||||
[EndpointGroupName("general")]
|
||||
[ProducesResponseType(typeof(List<ScheduleItemResponseModel>), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status400BadRequest)]
|
||||
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)]
|
||||
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status412PreconditionFailed)]
|
||||
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status422UnprocessableEntity)]
|
||||
public async Task<IActionResult> ReplaceItems(
|
||||
int id,
|
||||
[Required] [FromBody] ReplaceScheduleItemsRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
IfMatchCondition ifMatch = ConcurrencyHeaders.ParseIfMatch(Request);
|
||||
if (ifMatch.Kind is IfMatchKind.Malformed)
|
||||
{
|
||||
return new BadRequestObjectResult(
|
||||
new ProblemDetails
|
||||
{
|
||||
Status = StatusCodes.Status400BadRequest,
|
||||
Title = "Invalid If-Match header",
|
||||
Detail = "If-Match must be a strong ETag of the resource version (e.g. \"3\") or \"*\"."
|
||||
});
|
||||
}
|
||||
|
||||
Either<BaseError, IEnumerable<ProgramScheduleItemViewModel>> result =
|
||||
await mediator.Send(request.ToCommand(id), cancellationToken);
|
||||
return result
|
||||
.Map(items => items.Select(ScheduleItemResponseMapper.ProjectToResponseModel).ToList())
|
||||
.ToUpdatedResult();
|
||||
await mediator.Send(request.ToCommand(id, ifMatch.ExpectedVersion), cancellationToken);
|
||||
|
||||
return await result.Match(
|
||||
Left: error => Task.FromResult(error.ToErrorResult()),
|
||||
Right: async _ =>
|
||||
{
|
||||
// Reload the root (version) FIRST, then the items, so the emitted ETag is never newer than
|
||||
// the returned items (issue #253 fail-safe ordering; matches BlockController). A None root
|
||||
// (deleted between commit and reload) is a 404, never a 200 without an ETag.
|
||||
Option<ProgramScheduleViewModel> refreshed =
|
||||
await mediator.Send(new GetProgramScheduleById(id), cancellationToken);
|
||||
List<ProgramScheduleItemViewModel> items =
|
||||
await mediator.Send(new GetProgramScheduleItems(id), cancellationToken);
|
||||
return refreshed.Match(
|
||||
Some: vm =>
|
||||
{
|
||||
// Return the new ETag so a same-tab second save doesn't 412 against its own write.
|
||||
ConcurrencyHeaders.SetETag(Response, vm.Version);
|
||||
return (IActionResult)new OkObjectResult(
|
||||
items.Select(ScheduleItemResponseMapper.ProjectToResponseModel).ToList());
|
||||
},
|
||||
None: () => ApiResults.NotFoundProblem());
|
||||
});
|
||||
}
|
||||
|
||||
[HttpDelete("/api/schedules/{id:int}/items/{itemId:int}")]
|
||||
|
||||
@@ -134,6 +134,9 @@ public class TemplateController(IMediator mediator) : ControllerBase
|
||||
[HttpGet("/api/templates/{id:int}/items")]
|
||||
[Tags("Templates")]
|
||||
[EndpointSummary("Get template items")]
|
||||
[EndpointDescription(
|
||||
"Returns the template's items and a strong ETag of the template's version. Pass that ETag back as " +
|
||||
"If-Match on the replace (PUT) to detect a concurrent edit (issue #253).")]
|
||||
[EndpointGroupName("general")]
|
||||
[ProducesResponseType(typeof(List<TemplateItemResponseModel>), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)]
|
||||
@@ -145,6 +148,9 @@ public class TemplateController(IMediator mediator) : ControllerBase
|
||||
return ApiResults.NotFoundProblem();
|
||||
}
|
||||
|
||||
// The items GET returns children, not the root, so read the template's version for the ETag.
|
||||
ConcurrencyHeaders.SetETag(Response, template.Map(t => t.Version).IfNone(0));
|
||||
|
||||
List<TemplateItemViewModel> items = await mediator.Send(new GetTemplateItems(id), cancellationToken);
|
||||
return new OkObjectResult(
|
||||
items.OrderBy(i => i.StartTime.TimeOfDay).Map(ProjectToResponseModel).ToList());
|
||||
@@ -155,16 +161,32 @@ public class TemplateController(IMediator mediator) : ControllerBase
|
||||
[EndpointSummary("Replace a template and its items")]
|
||||
[EndpointDescription(
|
||||
"Replaces the template's name and its full item list. Each item assigns a block to a start time of day; " +
|
||||
"items must not overlap (an item's end time is its start time plus the assigned block's duration).")]
|
||||
"items must not overlap (an item's end time is its start time plus the assigned block's duration). " +
|
||||
"Send the ETag from the items GET as If-Match to reject a stale overwrite with 412 (issue #253); " +
|
||||
"a successful response carries the new ETag.")]
|
||||
[EndpointGroupName("general")]
|
||||
[ProducesResponseType(typeof(TemplateWithItemsResponseModel), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status400BadRequest)]
|
||||
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)]
|
||||
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status412PreconditionFailed)]
|
||||
[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status422UnprocessableEntity)]
|
||||
public async Task<IActionResult> Replace(
|
||||
int id,
|
||||
[Required] [FromBody] ReplaceTemplateRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
IfMatchCondition ifMatch = ConcurrencyHeaders.ParseIfMatch(Request);
|
||||
if (ifMatch.Kind is IfMatchKind.Malformed)
|
||||
{
|
||||
return new BadRequestObjectResult(
|
||||
new ProblemDetails
|
||||
{
|
||||
Status = StatusCodes.Status400BadRequest,
|
||||
Title = "Invalid If-Match header",
|
||||
Detail = "If-Match must be a strong ETag of the resource version (e.g. \"3\") or \"*\"."
|
||||
});
|
||||
}
|
||||
|
||||
Option<TemplateViewModel> maybeTemplate = await mediator.Send(new GetTemplateById(id), cancellationToken);
|
||||
if (maybeTemplate.IsNone)
|
||||
{
|
||||
@@ -174,16 +196,25 @@ public class TemplateController(IMediator mediator) : ControllerBase
|
||||
int templateGroupId = maybeTemplate.Map(t => t.TemplateGroupId).IfNone(0);
|
||||
|
||||
Either<BaseError, List<TemplateItemViewModel>> result =
|
||||
await mediator.Send(request.ToCommand(templateGroupId, id), cancellationToken);
|
||||
await mediator.Send(request.ToCommand(templateGroupId, id, ifMatch.ExpectedVersion), cancellationToken);
|
||||
|
||||
return await result.Match(
|
||||
Left: error => Task.FromResult(error.ToErrorResult()),
|
||||
Right: async _ =>
|
||||
{
|
||||
// Reload the root (version) FIRST, then the items, so the emitted ETag is never newer than
|
||||
// the returned items (issue #253 fail-safe ordering; matches BlockController). Returning the
|
||||
// handler's item snapshot alongside a separately re-queried version could pair stale items
|
||||
// with a newer ETag — a client would then silently overwrite the interleaving write.
|
||||
Option<TemplateViewModel> refreshed = await mediator.Send(new GetTemplateById(id), cancellationToken);
|
||||
List<TemplateItemViewModel> items = await mediator.Send(new GetTemplateItems(id), cancellationToken);
|
||||
return refreshed.Match(
|
||||
Some: vm => (IActionResult)new OkObjectResult(ProjectToWithItemsResponseModel(vm, items)),
|
||||
Some: vm =>
|
||||
{
|
||||
// Return the new ETag so a same-tab second save doesn't 412 against its own write.
|
||||
ConcurrencyHeaders.SetETag(Response, vm.Version);
|
||||
return (IActionResult)new OkObjectResult(ProjectToWithItemsResponseModel(vm, items));
|
||||
},
|
||||
None: () => ApiResults.NotFoundProblem());
|
||||
});
|
||||
}
|
||||
|
||||
@@ -4039,7 +4039,7 @@
|
||||
"DecoTemplates"
|
||||
],
|
||||
"summary": "Replace a deco template and its items",
|
||||
"description": "Replaces the deco template's name and its full item list. Each item assigns a deco to a start/end time of day; an end time of 00:00:00 means the item runs to the end of the day. Items must not overlap.",
|
||||
"description": "Replaces the deco template's name and its full item list. Each item assigns a deco to a start/end time of day; an end time of 00:00:00 means the item runs to the end of the day. Items must not overlap. Send the ETag from the items GET as If-Match to reject a stale overwrite with 412 (issue #253); a successful response carries the new ETag.",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "id",
|
||||
@@ -4097,6 +4097,26 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"description": "Bad Request",
|
||||
"content": {
|
||||
"text/plain": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ProblemDetails"
|
||||
}
|
||||
},
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ProblemDetails"
|
||||
}
|
||||
},
|
||||
"text/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ProblemDetails"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"404": {
|
||||
"description": "Not Found",
|
||||
"content": {
|
||||
@@ -4117,6 +4137,26 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"412": {
|
||||
"description": "Precondition Failed",
|
||||
"content": {
|
||||
"text/plain": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ProblemDetails"
|
||||
}
|
||||
},
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ProblemDetails"
|
||||
}
|
||||
},
|
||||
"text/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ProblemDetails"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"422": {
|
||||
"description": "Unprocessable Entity",
|
||||
"content": {
|
||||
@@ -4146,6 +4186,7 @@
|
||||
"DecoTemplates"
|
||||
],
|
||||
"summary": "Get deco template items",
|
||||
"description": "Returns the deco template's items and a strong ETag of the deco template's version. Pass that ETag back as If-Match on the replace (PUT) to detect a concurrent edit (issue #253).",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "id",
|
||||
@@ -8901,6 +8942,7 @@
|
||||
"Playlists"
|
||||
],
|
||||
"summary": "Update a playlist (rename and replace its items)",
|
||||
"description": "Replaces the playlist's name and its full item list. Item indexes are assigned from the array order. Send the ETag from the items GET as If-Match to reject a stale overwrite with 412 (issue #253); a successful response carries the new ETag.",
|
||||
"operationId": "UpdatePlaylist",
|
||||
"parameters": [
|
||||
{
|
||||
@@ -8968,6 +9010,26 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"description": "Bad Request",
|
||||
"content": {
|
||||
"text/plain": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ProblemDetails"
|
||||
}
|
||||
},
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ProblemDetails"
|
||||
}
|
||||
},
|
||||
"text/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ProblemDetails"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"404": {
|
||||
"description": "Not Found",
|
||||
"content": {
|
||||
@@ -8988,6 +9050,26 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"412": {
|
||||
"description": "Precondition Failed",
|
||||
"content": {
|
||||
"text/plain": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ProblemDetails"
|
||||
}
|
||||
},
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ProblemDetails"
|
||||
}
|
||||
},
|
||||
"text/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ProblemDetails"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"422": {
|
||||
"description": "Unprocessable Entity",
|
||||
"content": {
|
||||
@@ -9080,6 +9162,7 @@
|
||||
"Playlists"
|
||||
],
|
||||
"summary": "Get the items in a playlist",
|
||||
"description": "Returns the playlist's items and a strong ETag of the playlist's version. Pass that ETag back as If-Match on the replace (PUT) to detect a concurrent edit (issue #253).",
|
||||
"operationId": "GetPlaylistItems",
|
||||
"parameters": [
|
||||
{
|
||||
@@ -12609,7 +12692,7 @@
|
||||
"Schedules"
|
||||
],
|
||||
"summary": "Get schedule items",
|
||||
"description": "Returns the schedule's items plus a computed, best-effort runtime estimate: each item carries a nullable durationEstimate and the envelope carries a nullable totalDurationEstimate. Estimates are derived from referenced collection/media runtimes and are null when unbounded or unknown.",
|
||||
"description": "Returns the schedule's items plus a computed, best-effort runtime estimate: each item carries a nullable durationEstimate and the envelope carries a nullable totalDurationEstimate. Estimates are derived from referenced collection/media runtimes and are null when unbounded or unknown. The response also carries a strong ETag of the schedule's version; pass that ETag back as If-Match on the replace (PUT) to detect a concurrent edit (issue #253).",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "id",
|
||||
@@ -12773,6 +12856,7 @@
|
||||
"Schedules"
|
||||
],
|
||||
"summary": "Replace schedule items",
|
||||
"description": "Replaces the schedule's full item list; item indexes are assigned from the array order. Send the ETag from the items GET as If-Match to reject a stale overwrite with 412 (issue #253); a successful response carries the new ETag.",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "id",
|
||||
@@ -12839,6 +12923,26 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"description": "Bad Request",
|
||||
"content": {
|
||||
"text/plain": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ProblemDetails"
|
||||
}
|
||||
},
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ProblemDetails"
|
||||
}
|
||||
},
|
||||
"text/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ProblemDetails"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"404": {
|
||||
"description": "Not Found",
|
||||
"content": {
|
||||
@@ -12859,6 +12963,26 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"412": {
|
||||
"description": "Precondition Failed",
|
||||
"content": {
|
||||
"text/plain": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ProblemDetails"
|
||||
}
|
||||
},
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ProblemDetails"
|
||||
}
|
||||
},
|
||||
"text/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ProblemDetails"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"422": {
|
||||
"description": "Unprocessable Entity",
|
||||
"content": {
|
||||
@@ -15244,7 +15368,7 @@
|
||||
"Templates"
|
||||
],
|
||||
"summary": "Replace a template and its items",
|
||||
"description": "Replaces the template's name and its full item list. Each item assigns a block to a start time of day; items must not overlap (an item's end time is its start time plus the assigned block's duration).",
|
||||
"description": "Replaces the template's name and its full item list. Each item assigns a block to a start time of day; items must not overlap (an item's end time is its start time plus the assigned block's duration). Send the ETag from the items GET as If-Match to reject a stale overwrite with 412 (issue #253); a successful response carries the new ETag.",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "id",
|
||||
@@ -15302,6 +15426,26 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"description": "Bad Request",
|
||||
"content": {
|
||||
"text/plain": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ProblemDetails"
|
||||
}
|
||||
},
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ProblemDetails"
|
||||
}
|
||||
},
|
||||
"text/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ProblemDetails"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"404": {
|
||||
"description": "Not Found",
|
||||
"content": {
|
||||
@@ -15322,6 +15466,26 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"412": {
|
||||
"description": "Precondition Failed",
|
||||
"content": {
|
||||
"text/plain": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ProblemDetails"
|
||||
}
|
||||
},
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ProblemDetails"
|
||||
}
|
||||
},
|
||||
"text/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ProblemDetails"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"422": {
|
||||
"description": "Unprocessable Entity",
|
||||
"content": {
|
||||
@@ -15351,6 +15515,7 @@
|
||||
"Templates"
|
||||
],
|
||||
"summary": "Get template items",
|
||||
"description": "Returns the template's items and a strong ETag of the template's version. Pass that ETag back as If-Match on the replace (PUT) to detect a concurrent edit (issue #253).",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "id",
|
||||
@@ -23309,7 +23474,8 @@
|
||||
"treatCollectionsAsShows",
|
||||
"shuffleScheduleItems",
|
||||
"randomStartPoint",
|
||||
"fixedStartTimeBehavior"
|
||||
"fixedStartTimeBehavior",
|
||||
"version"
|
||||
],
|
||||
"type": "object",
|
||||
"properties": {
|
||||
@@ -23337,6 +23503,10 @@
|
||||
},
|
||||
"fixedStartTimeBehavior": {
|
||||
"$ref": "#/components/schemas/FixedStartTimeBehavior"
|
||||
},
|
||||
"version": {
|
||||
"type": "integer",
|
||||
"format": "int32"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
+15
-2
@@ -311,8 +311,21 @@ GET handlers that feed an ordered list must also `.OrderBy(i => i.Index)` — id
|
||||
|
||||
The replace-all aggregate PUTs (blocks, templates, schedules items, playlists, collections, playouts,
|
||||
etc.) carry an **optimistic-concurrency contract** so a stale second tab can't silently overwrite a
|
||||
fresher edit (issue #253). The Block endpoints are the reference implementation; PRs 2–4 fan the same
|
||||
recipe across the other aggregates.
|
||||
fresher edit (issue #253). The Block endpoints are the reference implementation; **PR2 fanned the same
|
||||
recipe onto Template, DecoTemplate, Playlist, and schedule-items** (`PUT /api/templates/{id}`,
|
||||
`/api/deco-templates/{id}`, `/api/playlists/{id}`, `/api/schedules/{id}/items`); PR3 covers the
|
||||
Diff/Scalar aggregates (Collection, Playout ×2, MultiCollection, RerunCollection) and PR4 is the Phase-2
|
||||
428 flip.
|
||||
|
||||
Each replace PUT keeps its **own** existing 200 body shape (Template/DecoTemplate return a
|
||||
`…WithItemsResponseModel`, Block likewise; Playlist and schedule-items return the item array) and adds the
|
||||
ETag as a **header only** — except schedules: `ScheduleController` returns the Application-layer
|
||||
`ProgramScheduleViewModel` *directly* (no `ProgramScheduleResponseModel` exists), so the added
|
||||
`int Version` also surfaces as a redundant `version` field in the `GET /api/schedules[/{id}]` bodies. That
|
||||
is intentional and harmless (the ETag remains the authority); introducing a ResponseModel purely to hide
|
||||
one field was judged disproportionate. Every replace PUT's *sibling config writers* bump `Version` too
|
||||
(Playlist: the five `Add*ToPlaylist` handlers; schedule: `AddProgramScheduleItem` / `DeleteProgramScheduleItem`
|
||||
/ `UpdateProgramSchedule`; Template/DecoTemplate have none).
|
||||
|
||||
**Token.** Each versioned root implements `IVersionedAggregate` (`int Version`, EF-mapped with
|
||||
`.IsConcurrencyToken()` in its `IEntityTypeConfiguration`). A single dual-provider migration
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { ApiError, request } from './client';
|
||||
import { ApiError, request, requestWithMeta, type ResponseWithMeta } from './client';
|
||||
import type { components } from './generated/v1';
|
||||
|
||||
export type DecoTemplateGroup = components['schemas']['DecoTemplateGroupResponseModel'];
|
||||
@@ -46,8 +46,25 @@ export function getDecoTemplateItems(id: number): Promise<DecoTemplateItem[]> {
|
||||
return request<DecoTemplateItem[]>(`/api/deco-templates/${id}/items`);
|
||||
}
|
||||
|
||||
export function replaceDecoTemplate(id: number, body: ReplaceDecoTemplateRequest): Promise<DecoTemplateWithItems> {
|
||||
return request<DecoTemplateWithItems>(`/api/deco-templates/${id}`, { body, method: 'PUT' });
|
||||
/** Load deco template items together with the deco template's concurrency ETag (issue #253). */
|
||||
export function getDecoTemplateItemsWithMeta(id: number): Promise<ResponseWithMeta<DecoTemplateItem[]>> {
|
||||
return requestWithMeta<DecoTemplateItem[]>(`/api/deco-templates/${id}/items`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Replace a deco template. Pass the last-seen ETag as `If-Match` to reject a stale overwrite with
|
||||
* 412; the resolved value carries the new ETag for a subsequent save (issue #253).
|
||||
*/
|
||||
export function replaceDecoTemplate(
|
||||
id: number,
|
||||
body: ReplaceDecoTemplateRequest,
|
||||
ifMatch?: string | null
|
||||
): Promise<ResponseWithMeta<DecoTemplateWithItems>> {
|
||||
return requestWithMeta<DecoTemplateWithItems>(`/api/deco-templates/${id}`, {
|
||||
body,
|
||||
method: 'PUT',
|
||||
headers: ifMatch ? { 'If-Match': ifMatch } : undefined
|
||||
});
|
||||
}
|
||||
|
||||
export function messageFromDecoTemplateError(error: unknown, fallback = 'Unable to load deco templates'): string {
|
||||
|
||||
Vendored
+1
@@ -1154,6 +1154,7 @@ export interface components {
|
||||
"shuffleScheduleItems": boolean;
|
||||
"randomStartPoint": boolean;
|
||||
"fixedStartTimeBehavior": components["schemas"]["FixedStartTimeBehavior"];
|
||||
"version": number;
|
||||
};
|
||||
"RemoteConnectionResponseModel": {
|
||||
"address": null | string;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { ApiError, request } from './client';
|
||||
import { ApiError, request, requestWithMeta, type ResponseWithMeta } from './client';
|
||||
import type { components } from './generated/v1';
|
||||
|
||||
export type AddItemsToPlaylistRequest = components['schemas']['AddItemsToPlaylistRequest'];
|
||||
@@ -41,13 +41,28 @@ export function getPlaylistItems(id: number): Promise<PlaylistItem[]> {
|
||||
return request<PlaylistItem[]>(`/api/playlists/${id}/items`);
|
||||
}
|
||||
|
||||
/** Load playlist items together with the playlist's concurrency ETag (issue #253). */
|
||||
export function getPlaylistItemsWithMeta(id: number): Promise<ResponseWithMeta<PlaylistItem[]>> {
|
||||
return requestWithMeta<PlaylistItem[]>(`/api/playlists/${id}/items`);
|
||||
}
|
||||
|
||||
export function createPlaylist(body: CreatePlaylistRequest): Promise<Playlist> {
|
||||
return request<Playlist>('/api/playlists', { body, method: 'POST' });
|
||||
}
|
||||
|
||||
// PUT = rename + replace the full item list; returns the persisted (re-indexed) items.
|
||||
export function updatePlaylist(id: number, body: ReplacePlaylistRequest): Promise<PlaylistItem[]> {
|
||||
return request<PlaylistItem[]>(`/api/playlists/${id}`, { body, method: 'PUT' });
|
||||
// PUT = rename + replace the full item list; returns the persisted (re-indexed) items. Pass the
|
||||
// last-seen ETag as `If-Match` to reject a stale overwrite with 412; the resolved value carries
|
||||
// the new ETag for a subsequent save (issue #253).
|
||||
export function updatePlaylist(
|
||||
id: number,
|
||||
body: ReplacePlaylistRequest,
|
||||
ifMatch?: string | null
|
||||
): Promise<ResponseWithMeta<PlaylistItem[]>> {
|
||||
return requestWithMeta<PlaylistItem[]>(`/api/playlists/${id}`, {
|
||||
body,
|
||||
method: 'PUT',
|
||||
headers: ifMatch ? { 'If-Match': ifMatch } : undefined
|
||||
});
|
||||
}
|
||||
|
||||
export function deletePlaylist(id: number): Promise<void> {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { ApiError, request } from './client';
|
||||
import { ApiError, request, requestWithMeta, type ResponseWithMeta } from './client';
|
||||
import type { components } from './generated/v1';
|
||||
// FillerPreset is exported by ./pickers — import (don't re-export) to avoid a duplicate `export *`
|
||||
// name in api/index.ts. LanguageCode/getLanguages moved to ./languages (also re-exported via
|
||||
@@ -46,17 +46,31 @@ export function getScheduleItems(scheduleId: number): Promise<ScheduleItemsRespo
|
||||
return request<ScheduleItemsResponse>(`/api/schedules/${scheduleId}/items`);
|
||||
}
|
||||
|
||||
/** Load schedule items together with the schedule's concurrency ETag (issue #253). */
|
||||
export function getScheduleItemsWithMeta(
|
||||
scheduleId: number
|
||||
): Promise<ResponseWithMeta<ScheduleItemsResponse>> {
|
||||
return requestWithMeta<ScheduleItemsResponse>(`/api/schedules/${scheduleId}/items`);
|
||||
}
|
||||
|
||||
export function addScheduleItem(scheduleId: number, body: ScheduleItemRequest): Promise<ScheduleItem> {
|
||||
return request<ScheduleItem>(`/api/schedules/${scheduleId}/items`, { body, method: 'POST' });
|
||||
}
|
||||
|
||||
// Destructive replace: the server deletes+recreates every item row (new ids) and triggers playout
|
||||
// rebuilds. The editor batches all local draft edits into this single call. See docs/decisions.md.
|
||||
// Positional in-place reconcile: the server reuses same-typed item rows (keeping fill-group state) and
|
||||
// triggers playout rebuilds. The editor batches all local draft edits into this single call. Pass the
|
||||
// last-seen ETag as `If-Match` to reject a stale overwrite with 412; the resolved value carries the new
|
||||
// ETag for a subsequent save (issue #253). See docs/decisions.md.
|
||||
export function replaceScheduleItems(
|
||||
scheduleId: number,
|
||||
body: ReplaceScheduleItemsRequest
|
||||
): Promise<ScheduleItem[]> {
|
||||
return request<ScheduleItem[]>(`/api/schedules/${scheduleId}/items`, { body, method: 'PUT' });
|
||||
body: ReplaceScheduleItemsRequest,
|
||||
ifMatch?: string | null
|
||||
): Promise<ResponseWithMeta<ScheduleItem[]>> {
|
||||
return requestWithMeta<ScheduleItem[]>(`/api/schedules/${scheduleId}/items`, {
|
||||
body,
|
||||
method: 'PUT',
|
||||
headers: ifMatch ? { 'If-Match': ifMatch } : undefined
|
||||
});
|
||||
}
|
||||
|
||||
export function deleteScheduleItem(scheduleId: number, itemId: number): Promise<void> {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { ApiError, request } from './client';
|
||||
import { ApiError, request, requestWithMeta, type ResponseWithMeta } from './client';
|
||||
import type { components } from './generated/v1';
|
||||
|
||||
export type TemplateGroup = components['schemas']['TemplateGroupResponseModel'];
|
||||
@@ -47,8 +47,25 @@ export function getTemplateItems(id: number): Promise<TemplateItem[]> {
|
||||
return request<TemplateItem[]>(`/api/templates/${id}/items`);
|
||||
}
|
||||
|
||||
export function replaceTemplate(id: number, body: ReplaceTemplateRequest): Promise<TemplateWithItems> {
|
||||
return request<TemplateWithItems>(`/api/templates/${id}`, { body, method: 'PUT' });
|
||||
/** Load template items together with the template's concurrency ETag (issue #253). */
|
||||
export function getTemplateItemsWithMeta(id: number): Promise<ResponseWithMeta<TemplateItem[]>> {
|
||||
return requestWithMeta<TemplateItem[]>(`/api/templates/${id}/items`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Replace a template. Pass the last-seen ETag as `If-Match` to reject a stale overwrite with 412;
|
||||
* the resolved value carries the new ETag for a subsequent save (issue #253).
|
||||
*/
|
||||
export function replaceTemplate(
|
||||
id: number,
|
||||
body: ReplaceTemplateRequest,
|
||||
ifMatch?: string | null
|
||||
): Promise<ResponseWithMeta<TemplateWithItems>> {
|
||||
return requestWithMeta<TemplateWithItems>(`/api/templates/${id}`, {
|
||||
body,
|
||||
method: 'PUT',
|
||||
headers: ifMatch ? { 'If-Match': ifMatch } : undefined
|
||||
});
|
||||
}
|
||||
|
||||
export function copyTemplate(id: number, body: CopyTemplateRequest): Promise<Template> {
|
||||
|
||||
@@ -185,4 +185,42 @@ describe('DecoTemplatesScreen', () => {
|
||||
expect(screen.queryByText(/must start before it ends/)).not.toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: /Save deco template/ })).not.toBeDisabled();
|
||||
});
|
||||
|
||||
it('shows a conflict dialog and reloads when the deco template changed elsewhere (412)', async () => {
|
||||
window.history.pushState({}, '', '/app/deco-templates/4');
|
||||
let putCount = 0;
|
||||
const fetchMock = mockApi({
|
||||
items: [{ decoId: 10, decoName: 'Sunrise', startTime: '06:00:00', endTime: '07:00:00' }],
|
||||
onRequest: (url, method) => {
|
||||
if (url === '/api/deco-templates/4' && method === 'PUT') {
|
||||
putCount += 1;
|
||||
if (putCount === 1) {
|
||||
return new Response(
|
||||
JSON.stringify({ status: 412, title: 'Precondition Failed', detail: 'stale' }),
|
||||
{ headers: { 'Content-Type': 'application/json' }, status: 412 }
|
||||
);
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
});
|
||||
render(<DecoTemplatesScreen />);
|
||||
|
||||
await waitFor(() => expect(screen.getByDisplayValue('Bumpers')).toBeInTheDocument());
|
||||
|
||||
const itemsGetCount = () =>
|
||||
fetchMock.mock.calls.filter(
|
||||
([u, init]) => u === '/api/deco-templates/4/items' && (init?.method ?? 'GET') === 'GET'
|
||||
).length;
|
||||
const before = itemsGetCount();
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /Save deco template/ }));
|
||||
|
||||
// A 412 opens the "changed elsewhere" dialog rather than showing a generic save error.
|
||||
expect(await screen.findByText(/Reload to get the latest version/i)).toBeInTheDocument();
|
||||
|
||||
// Reloading re-fetches the deco template items.
|
||||
fireEvent.click(screen.getByRole('button', { name: /^Reload$/ }));
|
||||
await waitFor(() => expect(itemsGetCount()).toBeGreaterThan(before));
|
||||
});
|
||||
});
|
||||
|
||||
@@ -3,6 +3,7 @@ import { ArrowLeft, Check, FolderPlus, Plus, Trash2, TriangleAlert } from 'lucid
|
||||
import { navigateToPath } from '../routing';
|
||||
import { Badge, Button, Card, ConfirmDialog, Dialog, IconButton, Input, Select, Spinner } from '../components';
|
||||
import {
|
||||
ApiError,
|
||||
createDecoTemplate,
|
||||
createDecoTemplateGroup,
|
||||
deleteDecoTemplate,
|
||||
@@ -10,7 +11,7 @@ import {
|
||||
getDecoGroups,
|
||||
getDecoTemplate,
|
||||
getDecoTemplateGroups,
|
||||
getDecoTemplateItems,
|
||||
getDecoTemplateItemsWithMeta,
|
||||
getDecoTemplates,
|
||||
getDecos,
|
||||
messageFromDecoTemplateError,
|
||||
@@ -477,6 +478,11 @@ function DecoTemplateEditor({ decoTemplateId }: { decoTemplateId: number }) {
|
||||
const [loadError, setLoadError] = useState<null | string>(null);
|
||||
const [saveError, setSaveError] = useState<null | string>(null);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [conflictOpen, setConflictOpen] = useState(false);
|
||||
const [reloadKey, setReloadKey] = useState(0);
|
||||
// Concurrency ETag (issue #253): captured from the items GET, sent as If-Match on save, and
|
||||
// replaced from the PUT response on every successful save.
|
||||
const etagRef = useRef<string | null>(null);
|
||||
|
||||
const [addGroupId, setAddGroupId] = useState('');
|
||||
const [addDecoId, setAddDecoId] = useState('');
|
||||
@@ -487,34 +493,38 @@ function DecoTemplateEditor({ decoTemplateId }: { decoTemplateId: number }) {
|
||||
useEffect(() => {
|
||||
let active = true;
|
||||
|
||||
Promise.all([
|
||||
getDecoTemplate(decoTemplateId),
|
||||
getDecoTemplateItems(decoTemplateId),
|
||||
getDecoGroups(),
|
||||
getDecos()
|
||||
])
|
||||
.then(([decoTemplateData, items, groupData, decoData]) => {
|
||||
// Read items + ETag FIRST, then the root metadata, mirroring BlockEditor (issue #253): the ETag
|
||||
// must be no newer than every piece of versioned data the draft is built from.
|
||||
void (async () => {
|
||||
try {
|
||||
const itemsMeta = await getDecoTemplateItemsWithMeta(decoTemplateId);
|
||||
const [decoTemplateData, groupData, decoData] = await Promise.all([
|
||||
getDecoTemplate(decoTemplateId),
|
||||
getDecoGroups(),
|
||||
getDecos()
|
||||
]);
|
||||
if (!active) {
|
||||
return;
|
||||
}
|
||||
etagRef.current = itemsMeta.etag;
|
||||
setDecoTemplate(decoTemplateData);
|
||||
setDecoGroups(groupData);
|
||||
setDecos(decoData);
|
||||
setDraft({
|
||||
name: decoTemplateData.name,
|
||||
items: items.map(itemFromResponse)
|
||||
items: itemsMeta.data.map(itemFromResponse)
|
||||
});
|
||||
})
|
||||
.catch((error: unknown) => {
|
||||
} catch (error) {
|
||||
if (active) {
|
||||
setLoadError(messageFromDecoTemplateError(error, 'Unable to load deco template'));
|
||||
}
|
||||
});
|
||||
}
|
||||
})();
|
||||
|
||||
return () => {
|
||||
active = false;
|
||||
};
|
||||
}, [decoTemplateId]);
|
||||
}, [decoTemplateId, reloadKey]);
|
||||
|
||||
if (loadError) {
|
||||
return (
|
||||
@@ -590,22 +600,40 @@ function DecoTemplateEditor({ decoTemplateId }: { decoTemplateId: number }) {
|
||||
setSaving(true);
|
||||
setSaveError(null);
|
||||
try {
|
||||
await replaceDecoTemplate(decoTemplateId, {
|
||||
name: draft.name.trim(),
|
||||
items: draft.items.map((item) => ({
|
||||
decoId: item.decoId,
|
||||
startTime: item.startTime,
|
||||
endTime: item.endTime
|
||||
}))
|
||||
});
|
||||
const { etag } = await replaceDecoTemplate(
|
||||
decoTemplateId,
|
||||
{
|
||||
name: draft.name.trim(),
|
||||
items: draft.items.map((item) => ({
|
||||
decoId: item.decoId,
|
||||
startTime: item.startTime,
|
||||
endTime: item.endTime
|
||||
}))
|
||||
},
|
||||
etagRef.current
|
||||
);
|
||||
etagRef.current = etag;
|
||||
navigateToPath(BASE_PATH);
|
||||
} catch (error) {
|
||||
setSaveError(messageFromDecoTemplateError(error, 'Unable to save deco template'));
|
||||
if (error instanceof ApiError && error.status === 412) {
|
||||
// Another edit landed since we loaded — force a reload rather than overwriting it (#253).
|
||||
setConflictOpen(true);
|
||||
} else {
|
||||
setSaveError(messageFromDecoTemplateError(error, 'Unable to save deco template'));
|
||||
}
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const reloadAfterConflict = () => {
|
||||
setConflictOpen(false);
|
||||
setSaveError(null);
|
||||
setDraft(null);
|
||||
setDecoTemplate(null);
|
||||
setReloadKey((key) => key + 1);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="ctv-collections">
|
||||
<div className="ctv-channels-actionbar">
|
||||
@@ -758,6 +786,17 @@ function DecoTemplateEditor({ decoTemplateId }: { decoTemplateId: number }) {
|
||||
</table>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
<ConfirmDialog
|
||||
cancelLabel="Keep editing"
|
||||
confirmLabel="Reload"
|
||||
message="This deco template was changed elsewhere since you opened it. Reload to get the latest version — your unsaved changes will be discarded."
|
||||
onCancel={() => setConflictOpen(false)}
|
||||
onConfirm={reloadAfterConflict}
|
||||
open={conflictOpen}
|
||||
title="Deco template changed elsewhere"
|
||||
tone="danger"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -206,6 +206,44 @@ describe('PlaylistsScreen', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('shows a conflict dialog and reloads when the playlist changed elsewhere (412)', async () => {
|
||||
let putCount = 0;
|
||||
const fetchMock = mockApi({
|
||||
onRequest: (url, method) => {
|
||||
if (url === '/api/playlists/10' && method === 'PUT') {
|
||||
putCount += 1;
|
||||
if (putCount === 1) {
|
||||
return new Response(
|
||||
JSON.stringify({ status: 412, title: 'Precondition Failed', detail: 'stale' }),
|
||||
{ headers: { 'Content-Type': 'application/json' }, status: 412 }
|
||||
);
|
||||
}
|
||||
return jsonResponse([], 200);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
});
|
||||
|
||||
render(<PlaylistsScreen />);
|
||||
|
||||
fireEvent.click(await screen.findByText('Bumps'));
|
||||
expect(await screen.findByText('Favorites')).toBeInTheDocument();
|
||||
|
||||
const itemsGetCount = () =>
|
||||
fetchMock.mock.calls.filter(([u, init]) => u === '/api/playlists/10/items' && (init?.method ?? 'GET') === 'GET')
|
||||
.length;
|
||||
const before = itemsGetCount();
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Save playlist' }));
|
||||
|
||||
// A 412 opens the "changed elsewhere" dialog rather than showing a generic save error.
|
||||
expect(await screen.findByText(/Reload to get the latest version/i)).toBeInTheDocument();
|
||||
|
||||
// Reloading re-fetches the playlist items.
|
||||
fireEvent.click(screen.getByRole('button', { name: /^Reload$/ }));
|
||||
await waitFor(() => expect(itemsGetCount()).toBeGreaterThan(before));
|
||||
});
|
||||
|
||||
it('disables the playback-order select for single media-item types', async () => {
|
||||
mockApi();
|
||||
render(<PlaylistsScreen />);
|
||||
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
import { Badge, Button, Card, ConfirmDialog, Dialog, IconButton, Input, Select, Spinner, Switch } from '../components';
|
||||
import type { SelectOption } from '../components';
|
||||
import {
|
||||
ApiError,
|
||||
createPlaylist,
|
||||
createPlaylistGroup,
|
||||
deletePlaylist,
|
||||
@@ -27,7 +28,7 @@ import {
|
||||
getMultiCollections,
|
||||
getPlaylistById,
|
||||
getPlaylistGroups,
|
||||
getPlaylistItems,
|
||||
getPlaylistItemsWithMeta,
|
||||
getPlaylists,
|
||||
getSmartCollections,
|
||||
messageFromPlaylistError,
|
||||
@@ -379,17 +380,32 @@ function PlaylistEditor({ playlistId, onBack, onSaved }: { playlistId: number; o
|
||||
const [previewItems, setPreviewItems] = useState<PlaylistPreviewItem[] | null>(null);
|
||||
const [previewMessage, setPreviewMessage] = useState<string | null>(null);
|
||||
const [previewing, setPreviewing] = useState(false);
|
||||
const [conflictOpen, setConflictOpen] = useState(false);
|
||||
const [reloadKey, setReloadKey] = useState(0);
|
||||
const activeRef = useRef(true);
|
||||
// Concurrency ETag (issue #253): captured from the items GET, sent as If-Match on save, and
|
||||
// replaced from the PUT response on every successful save.
|
||||
const etagRef = useRef<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
activeRef.current = true;
|
||||
Promise.all([getPlaylistById(playlistId), getPlaylistItems(playlistId)])
|
||||
.then(([playlist, loaded]) => {
|
||||
// Read items + ETag FIRST, then the root metadata, so the ETag is never newer than the data
|
||||
// the draft is built from (issue #253) — any resulting inconsistency fails safe via a 412 on
|
||||
// save rather than a silent overwrite. These MUST be sequential, not Promise.all: a concurrent
|
||||
// root read could return a stale name paired with the current items ETag, and a later save would
|
||||
// then silently restore that stale name.
|
||||
getPlaylistItemsWithMeta(playlistId)
|
||||
.then(async (itemsMeta) => {
|
||||
const playlist = await getPlaylistById(playlistId);
|
||||
return [itemsMeta, playlist] as const;
|
||||
})
|
||||
.then(([itemsMeta, playlist]) => {
|
||||
if (!activeRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
const drafts = loaded.map(draftFromItem);
|
||||
etagRef.current = itemsMeta.etag;
|
||||
const drafts = itemsMeta.data.map(draftFromItem);
|
||||
setName(playlist.name);
|
||||
setItems(drafts);
|
||||
setSelectedKey(drafts.length === 1 ? drafts[0].key : null);
|
||||
@@ -404,7 +420,7 @@ function PlaylistEditor({ playlistId, onBack, onSaved }: { playlistId: number; o
|
||||
return () => {
|
||||
activeRef.current = false;
|
||||
};
|
||||
}, [playlistId]);
|
||||
}, [playlistId, reloadKey]);
|
||||
|
||||
const selectedItem = items.find((item) => item.key === selectedKey) ?? null;
|
||||
const selectedType = selectedItem?.collectionType;
|
||||
@@ -506,15 +522,28 @@ function PlaylistEditor({ playlistId, onBack, onSaved }: { playlistId: number; o
|
||||
setSaveError(null);
|
||||
|
||||
try {
|
||||
await updatePlaylist(playlistId, buildRequest());
|
||||
const { etag } = await updatePlaylist(playlistId, buildRequest(), etagRef.current);
|
||||
etagRef.current = etag;
|
||||
onSaved();
|
||||
} catch (error) {
|
||||
setSaveError(messageFromPlaylistError(error, 'Unable to save playlist'));
|
||||
if (error instanceof ApiError && error.status === 412) {
|
||||
// Another edit landed since we loaded — force a reload rather than overwriting it (#253).
|
||||
setConflictOpen(true);
|
||||
} else {
|
||||
setSaveError(messageFromPlaylistError(error, 'Unable to save playlist'));
|
||||
}
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const reloadAfterConflict = () => {
|
||||
setConflictOpen(false);
|
||||
setSaveError(null);
|
||||
setState({ status: 'loading' });
|
||||
setReloadKey((key) => key + 1);
|
||||
};
|
||||
|
||||
const runPreview = async () => {
|
||||
setPreviewing(true);
|
||||
setPreviewMessage(null);
|
||||
@@ -774,6 +803,17 @@ function PlaylistEditor({ playlistId, onBack, onSaved }: { playlistId: number; o
|
||||
)}
|
||||
</Card>
|
||||
)}
|
||||
|
||||
<ConfirmDialog
|
||||
cancelLabel="Keep editing"
|
||||
confirmLabel="Reload"
|
||||
message="This playlist was changed elsewhere since you opened it. Reload to get the latest version — your unsaved changes will be discarded."
|
||||
onCancel={() => setConflictOpen(false)}
|
||||
onConfirm={reloadAfterConflict}
|
||||
open={conflictOpen}
|
||||
title="Playlist changed elsewhere"
|
||||
tone="danger"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -267,6 +267,36 @@ describe('SchedulesScreen — error + dirty handling', () => {
|
||||
expect((screen.getByRole('button', { name: /^Save$/ }) as HTMLButtonElement).disabled).toBe(false);
|
||||
});
|
||||
|
||||
it('412 on Save opens the conflict dialog; Reload discards the draft and reloads', async () => {
|
||||
let putCount = 0;
|
||||
const handle = await renderReady({
|
||||
onRequest: (url, method) => {
|
||||
if (url === '/api/schedules/1/items' && method === 'PUT') {
|
||||
putCount += 1;
|
||||
return jsonResponse({ title: 'Precondition Failed', detail: 'stale' }, 412);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
});
|
||||
// Make dirty, then Save → 412.
|
||||
fireEvent.click(screen.getByRole('button', { name: /Add item/ }));
|
||||
const getsBefore = handle.requests.filter((r) => r.url === '/api/schedules/1/items' && r.method === 'GET').length;
|
||||
fireEvent.click(screen.getByRole('button', { name: /^Save$/ }));
|
||||
|
||||
// The conflict dialog appears (distinct from the generic save-error path).
|
||||
await screen.findByText('Schedule changed elsewhere');
|
||||
expect(putCount).toBe(1);
|
||||
|
||||
// Reload discards the draft and re-fetches the active schedule's items.
|
||||
fireEvent.click(screen.getByRole('button', { name: /^Reload$/ }));
|
||||
await waitFor(() =>
|
||||
expect(handle.requests.filter((r) => r.url === '/api/schedules/1/items' && r.method === 'GET').length)
|
||||
.toBeGreaterThan(getsBefore));
|
||||
// Dialog closed; draft reset to the (single-item) server baseline.
|
||||
await waitFor(() => expect(screen.queryByText('Schedule changed elsewhere')).toBeNull());
|
||||
expect(within(screen.getByLabelText('Schedule lineup')).getAllByRole('listitem')).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('guards a schedule switch when dirty (confirm=false aborts)', async () => {
|
||||
vi.spyOn(window, 'confirm').mockReturnValue(false);
|
||||
const handle = await renderReady({ schedules: [schedule, { ...schedule, id: 2, name: 'Late' }] });
|
||||
|
||||
@@ -10,12 +10,13 @@ import {
|
||||
Spinner
|
||||
} from '../components';
|
||||
import {
|
||||
ApiError,
|
||||
deleteSchedule,
|
||||
getFillerPresetsByKind,
|
||||
getLanguages,
|
||||
getPlaylistGroups,
|
||||
getRerunCollections,
|
||||
getScheduleItems,
|
||||
getScheduleItemsWithMeta,
|
||||
getSchedules,
|
||||
getGraphicsElements,
|
||||
getWatermarks,
|
||||
@@ -87,11 +88,16 @@ export function SchedulesScreen() {
|
||||
const [mutationError, setMutationError] = useState<string | null>(null);
|
||||
const [form, setForm] = useState<null | 'create' | 'edit'>(null);
|
||||
const [confirmDelete, setConfirmDelete] = useState(false);
|
||||
// Opened when a save 412s because the schedule was changed elsewhere since it was loaded (#253).
|
||||
const [conflictOpen, setConflictOpen] = useState(false);
|
||||
|
||||
const activeRef = useRef(true);
|
||||
const dirtyRef = useRef(false);
|
||||
const baselineRef = useRef<DraftItem[]>([]);
|
||||
const itemSeq = useRef(0);
|
||||
// Last-seen concurrency ETag: set from the items GET, replaced by every successful save's response
|
||||
// ETag (a same-tab second save must use the new tag or it would 412 against its own write) (#253).
|
||||
const etagRef = useRef<string | null>(null);
|
||||
|
||||
const setDirtyState = useCallback((value: boolean) => {
|
||||
dirtyRef.current = value;
|
||||
@@ -131,11 +137,13 @@ export function SchedulesScreen() {
|
||||
// ---- Load items for the active schedule --------------------------------
|
||||
const loadItems = useCallback((scheduleId: number) => {
|
||||
const seq = ++itemSeq.current;
|
||||
getScheduleItems(scheduleId)
|
||||
.then((response) => {
|
||||
getScheduleItemsWithMeta(scheduleId)
|
||||
.then(({ data: response, etag }) => {
|
||||
if (!activeRef.current || itemSeq.current !== seq) {
|
||||
return;
|
||||
}
|
||||
// Only the current load owns the ETag (same stale-guard as the items below).
|
||||
etagRef.current = etag;
|
||||
// Defensive: array position becomes the persisted index on the next PUT, so ingest strictly by the
|
||||
// server-provided `index` rather than trusting response row order (see #229 — the API now orders, but
|
||||
// the SPA must not silently reshuffle the lineup if that guarantee ever regresses).
|
||||
@@ -318,11 +326,14 @@ export function SchedulesScreen() {
|
||||
}
|
||||
setSaving(true);
|
||||
setMutationError(null);
|
||||
replaceScheduleItems(activeId, { items: items.map(normalizeForSave) })
|
||||
.then((response) => {
|
||||
replaceScheduleItems(activeId, { items: items.map(normalizeForSave) }, etagRef.current)
|
||||
.then(({ data: response, etag }) => {
|
||||
if (!activeRef.current) {
|
||||
return;
|
||||
}
|
||||
// Load-bearing: a same-tab second save must use the ETag this write produced, or it would 412
|
||||
// against its own change (#253).
|
||||
etagRef.current = etag;
|
||||
const drafts = [...response].sort((a, b) => a.index - b.index).map(fromResponse);
|
||||
baselineRef.current = drafts;
|
||||
setItems(drafts);
|
||||
@@ -337,11 +348,35 @@ export function SchedulesScreen() {
|
||||
if (!activeRef.current) {
|
||||
return;
|
||||
}
|
||||
setMutationError(messageFromScheduleError(error, 'Unable to save schedule items'));
|
||||
if (error instanceof ApiError && error.status === 412) {
|
||||
// The schedule was changed elsewhere since we loaded it — force a reload rather than
|
||||
// overwriting the fresher edit (#253). Distinct from all other errors.
|
||||
setConflictOpen(true);
|
||||
} else {
|
||||
setMutationError(messageFromScheduleError(error, 'Unable to save schedule items'));
|
||||
}
|
||||
setSaving(false);
|
||||
});
|
||||
};
|
||||
|
||||
// Conflict "Reload": discard the dirty draft and re-run the load for the active schedule. loadItems
|
||||
// already bumps itemSeq (stale-guard), re-seeds baselineRef, clears dirty, and captures the new ETag —
|
||||
// so there's no separate reloadKey to invent (#253).
|
||||
const reloadAfterConflict = () => {
|
||||
setConflictOpen(false);
|
||||
setMutationError(null);
|
||||
if (activeId != null) {
|
||||
// Mark not-loaded/loading before kicking off the reload so `canEdit` (itemsLoaded && !saving) is
|
||||
// false for the whole conflict-reload window. Otherwise a slow reload GET would leave the rejected
|
||||
// stale draft editable, and edits made during that window would be silently discarded when the
|
||||
// reload re-seeds the baseline (issue #253 / #242 mutation gating). Set here (an event handler)
|
||||
// rather than inside loadItems, which the activeId effect calls synchronously (react-hooks lint).
|
||||
setItemsLoading(true);
|
||||
setItemsLoaded(false);
|
||||
loadItems(activeId);
|
||||
}
|
||||
};
|
||||
|
||||
// Opening the properties editor is blocked while the item draft is dirty (confirm-to-discard, same
|
||||
// semantics as guardedSwitch). This is the smaller, fully-consistent fix for the shuffle-toggle
|
||||
// normalization gap (#230 finding 2): if editing shuffleScheduleItems could change under a dirty
|
||||
@@ -562,6 +597,16 @@ export function SchedulesScreen() {
|
||||
onConfirm={onDeleteSchedule}
|
||||
onCancel={() => setConfirmDelete(false)}
|
||||
/>
|
||||
|
||||
<ConfirmDialog
|
||||
open={conflictOpen}
|
||||
title="Schedule changed elsewhere"
|
||||
message="This schedule was changed elsewhere since you opened it. Reload to get the latest version — your unsaved changes will be discarded."
|
||||
confirmLabel="Reload"
|
||||
cancelLabel="Keep editing"
|
||||
onConfirm={reloadAfterConflict}
|
||||
onCancel={() => setConflictOpen(false)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -242,4 +242,41 @@ describe('TemplatesScreen', () => {
|
||||
expect(await screen.findByText(/overlaps/)).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: /Save template/ })).toBeDisabled();
|
||||
});
|
||||
|
||||
it('shows a conflict dialog and reloads when the template changed elsewhere (412)', async () => {
|
||||
window.history.pushState({}, '', '/app/templates/4');
|
||||
let putCount = 0;
|
||||
const fetchMock = mockApi({
|
||||
items: [{ blockId: 10, blockName: 'Cartoons', blockMinutes: 60, startTime: '06:00:00' }],
|
||||
onRequest: (url, method) => {
|
||||
if (url === '/api/templates/4' && method === 'PUT') {
|
||||
putCount += 1;
|
||||
if (putCount === 1) {
|
||||
return new Response(
|
||||
JSON.stringify({ status: 412, title: 'Precondition Failed', detail: 'stale' }),
|
||||
{ headers: { 'Content-Type': 'application/json' }, status: 412 }
|
||||
);
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
});
|
||||
render(<TemplatesScreen />);
|
||||
|
||||
await waitFor(() => expect(screen.getByDisplayValue('Weekdays')).toBeInTheDocument());
|
||||
|
||||
const itemsGetCount = () =>
|
||||
fetchMock.mock.calls.filter(([u, init]) => u === '/api/templates/4/items' && (init?.method ?? 'GET') === 'GET')
|
||||
.length;
|
||||
const before = itemsGetCount();
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /Save template/ }));
|
||||
|
||||
// A 412 opens the "changed elsewhere" dialog rather than showing a generic save error.
|
||||
expect(await screen.findByText(/Reload to get the latest version/i)).toBeInTheDocument();
|
||||
|
||||
// Reloading re-fetches the template items.
|
||||
fireEvent.click(screen.getByRole('button', { name: /^Reload$/ }));
|
||||
await waitFor(() => expect(itemsGetCount()).toBeGreaterThan(before));
|
||||
});
|
||||
});
|
||||
|
||||
@@ -3,6 +3,7 @@ import { ArrowLeft, Check, Copy, FolderPlus, Plus, Search, Trash2, TriangleAlert
|
||||
import { navigateToPath } from '../routing';
|
||||
import { Badge, Button, Card, ConfirmDialog, Dialog, IconButton, Input, Select, Spinner } from '../components';
|
||||
import {
|
||||
ApiError,
|
||||
copyTemplate,
|
||||
createTemplate,
|
||||
createTemplateGroup,
|
||||
@@ -12,7 +13,7 @@ import {
|
||||
getBlocks,
|
||||
getTemplate,
|
||||
getTemplateGroups,
|
||||
getTemplateItems,
|
||||
getTemplateItemsWithMeta,
|
||||
getTemplates,
|
||||
messageFromTemplateError,
|
||||
replaceTemplate,
|
||||
@@ -554,6 +555,11 @@ function TemplateEditor({ templateId }: { templateId: number }) {
|
||||
const [loadError, setLoadError] = useState<null | string>(null);
|
||||
const [saveError, setSaveError] = useState<null | string>(null);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [conflictOpen, setConflictOpen] = useState(false);
|
||||
const [reloadKey, setReloadKey] = useState(0);
|
||||
// Concurrency ETag (issue #253): captured from the items GET, sent as If-Match on save, and
|
||||
// replaced from the PUT response on every successful save.
|
||||
const etagRef = useRef<string | null>(null);
|
||||
|
||||
const [addGroupId, setAddGroupId] = useState('');
|
||||
const [addBlockId, setAddBlockId] = useState('');
|
||||
@@ -562,29 +568,38 @@ function TemplateEditor({ templateId }: { templateId: number }) {
|
||||
useEffect(() => {
|
||||
let active = true;
|
||||
|
||||
Promise.all([getTemplate(templateId), getTemplateItems(templateId), getBlockGroups(), getBlocks()])
|
||||
.then(([templateData, items, groupData, blockData]) => {
|
||||
// Read items + ETag FIRST, then the root metadata, mirroring BlockEditor (issue #253): the ETag
|
||||
// must be no newer than every piece of versioned data the draft is built from.
|
||||
void (async () => {
|
||||
try {
|
||||
const itemsMeta = await getTemplateItemsWithMeta(templateId);
|
||||
const [templateData, groupData, blockData] = await Promise.all([
|
||||
getTemplate(templateId),
|
||||
getBlockGroups(),
|
||||
getBlocks()
|
||||
]);
|
||||
if (!active) {
|
||||
return;
|
||||
}
|
||||
etagRef.current = itemsMeta.etag;
|
||||
setTemplate(templateData);
|
||||
setBlockGroups(groupData);
|
||||
setBlocks(blockData);
|
||||
setDraft({
|
||||
name: templateData.name,
|
||||
items: items.map(itemFromResponse)
|
||||
items: itemsMeta.data.map(itemFromResponse)
|
||||
});
|
||||
})
|
||||
.catch((error: unknown) => {
|
||||
} catch (error) {
|
||||
if (active) {
|
||||
setLoadError(messageFromTemplateError(error, 'Unable to load template'));
|
||||
}
|
||||
});
|
||||
}
|
||||
})();
|
||||
|
||||
return () => {
|
||||
active = false;
|
||||
};
|
||||
}, [templateId]);
|
||||
}, [templateId, reloadKey]);
|
||||
|
||||
if (loadError) {
|
||||
return (
|
||||
@@ -659,18 +674,36 @@ function TemplateEditor({ templateId }: { templateId: number }) {
|
||||
setSaving(true);
|
||||
setSaveError(null);
|
||||
try {
|
||||
await replaceTemplate(templateId, {
|
||||
name: draft.name.trim(),
|
||||
items: draft.items.map((item) => ({ blockId: item.blockId, startTime: item.startTime }))
|
||||
});
|
||||
const { etag } = await replaceTemplate(
|
||||
templateId,
|
||||
{
|
||||
name: draft.name.trim(),
|
||||
items: draft.items.map((item) => ({ blockId: item.blockId, startTime: item.startTime }))
|
||||
},
|
||||
etagRef.current
|
||||
);
|
||||
etagRef.current = etag;
|
||||
navigateToPath(BASE_PATH);
|
||||
} catch (error) {
|
||||
setSaveError(messageFromTemplateError(error, 'Unable to save template'));
|
||||
if (error instanceof ApiError && error.status === 412) {
|
||||
// Another edit landed since we loaded — force a reload rather than overwriting it (#253).
|
||||
setConflictOpen(true);
|
||||
} else {
|
||||
setSaveError(messageFromTemplateError(error, 'Unable to save template'));
|
||||
}
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const reloadAfterConflict = () => {
|
||||
setConflictOpen(false);
|
||||
setSaveError(null);
|
||||
setDraft(null);
|
||||
setTemplate(null);
|
||||
setReloadKey((key) => key + 1);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="ctv-collections">
|
||||
<div className="ctv-channels-actionbar">
|
||||
@@ -796,6 +829,17 @@ function TemplateEditor({ templateId }: { templateId: number }) {
|
||||
</table>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
<ConfirmDialog
|
||||
cancelLabel="Keep editing"
|
||||
confirmLabel="Reload"
|
||||
message="This template was changed elsewhere since you opened it. Reload to get the latest version — your unsaved changes will be discarded."
|
||||
onCancel={() => setConflictOpen(false)}
|
||||
onConfirm={reloadAfterConflict}
|
||||
open={conflictOpen}
|
||||
title="Template changed elsewhere"
|
||||
tone="danger"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user