diff --git a/ErsatzTV.Application/Playouts/Mapper.cs b/ErsatzTV.Application/Playouts/Mapper.cs
index c869aaddb..876b8a11d 100644
--- a/ErsatzTV.Application/Playouts/Mapper.cs
+++ b/ErsatzTV.Application/Playouts/Mapper.cs
@@ -1,5 +1,6 @@
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Domain.Scheduling;
+using ErsatzTV.Core.Scheduling;
namespace ErsatzTV.Application.Playouts;
@@ -40,9 +41,15 @@ internal static class Mapper
programScheduleAlternate.Id,
programScheduleAlternate.Index,
programScheduleAlternate.ProgramScheduleId,
- programScheduleAlternate.DaysOfWeek,
- programScheduleAlternate.DaysOfMonth,
- programScheduleAlternate.MonthsOfYear,
+ // ersatztv#823: these three are NULLABLE columns and a legacy row can hold NULL. Substitute the
+ // SAME unrestricted defaults AlternateScheduleSelector.GetScheduleForDate reads, so the DTO the
+ // SPA renders agrees with what actually gets scheduled -- web/src/screens/playoutTemplateCalendar.ts
+ // `appliesToDate` is an exact port of that method, and it would otherwise both mispreview and
+ // throw (`[...template.daysOfMonth]` on a null is a TypeError). Never assigned back onto the
+ // entity (`media.nullable-primitive-collection-mutation`).
+ programScheduleAlternate.DaysOfWeek ?? AlternateScheduleSelector.AllDaysOfWeek(),
+ programScheduleAlternate.DaysOfMonth ?? AlternateScheduleSelector.AllDaysOfMonth(),
+ programScheduleAlternate.MonthsOfYear ?? AlternateScheduleSelector.AllMonthsOfYear(),
programScheduleAlternate.LimitToDateRange,
programScheduleAlternate.StartMonth,
programScheduleAlternate.StartDay,
diff --git a/ErsatzTV.Application/Scheduling/Mapper.cs b/ErsatzTV.Application/Scheduling/Mapper.cs
index 91e6356b5..4ae54728c 100644
--- a/ErsatzTV.Application/Scheduling/Mapper.cs
+++ b/ErsatzTV.Application/Scheduling/Mapper.cs
@@ -1,6 +1,7 @@
-using ErsatzTV.Application.Tree;
+using ErsatzTV.Application.Tree;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Domain.Scheduling;
+using ErsatzTV.Core.Scheduling;
namespace ErsatzTV.Application.Scheduling;
@@ -190,9 +191,15 @@ internal static class Mapper
ProjectToViewModel(playoutTemplate.Template),
ProjectToViewModel(playoutTemplate.DecoTemplate),
playoutTemplate.Index,
- playoutTemplate.DaysOfWeek,
- playoutTemplate.DaysOfMonth,
- playoutTemplate.MonthsOfYear,
+ // ersatztv#823: these three are NULLABLE columns and a legacy row can hold NULL. Substitute the
+ // SAME unrestricted defaults AlternateScheduleSelector.GetScheduleForDate reads, so the DTO the
+ // SPA renders agrees with what actually gets scheduled -- web/src/screens/playoutTemplateCalendar.ts
+ // `appliesToDate` is an exact port of that method, and it would otherwise both mispreview and
+ // throw (`[...template.daysOfMonth]` on a null is a TypeError). Never assigned back onto the
+ // entity (`media.nullable-primitive-collection-mutation`).
+ playoutTemplate.DaysOfWeek ?? AlternateScheduleSelector.AllDaysOfWeek(),
+ playoutTemplate.DaysOfMonth ?? AlternateScheduleSelector.AllDaysOfMonth(),
+ playoutTemplate.MonthsOfYear ?? AlternateScheduleSelector.AllMonthsOfYear(),
playoutTemplate.LimitToDateRange,
playoutTemplate.StartMonth,
playoutTemplate.StartDay,
diff --git a/ErsatzTV.Core.Tests/Scheduling/AlternateScheduleSelectorTests.cs b/ErsatzTV.Core.Tests/Scheduling/AlternateScheduleSelectorTests.cs
index 34af2a3f9..4c584ee8b 100644
--- a/ErsatzTV.Core.Tests/Scheduling/AlternateScheduleSelectorTests.cs
+++ b/ErsatzTV.Core.Tests/Scheduling/AlternateScheduleSelectorTests.cs
@@ -1,3 +1,4 @@
+using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Domain.Scheduling;
using ErsatzTV.Core.Scheduling;
using NUnit.Framework;
@@ -864,4 +865,241 @@ public static class AlternateScheduleSelectorTests
result.IsNone.ShouldBeFalse();
}
}
+
+ ///
+ /// ersatztv#823. DaysOfWeek, DaysOfMonth and MonthsOfYear on
+ /// and are six
+ /// single-column primitive collections whose columns are nullable: true on both providers.
+ /// A NULL column materializes as CLR null — EF does not invoke the value converter for a
+ /// NULL at all — so unguarded, each .Contains in
+ /// throws
+ /// . These tests are RED without the read-site guard.
+ ///
+ /// A null reads as UNRESTRICTED (the All*() sets), not as empty. The deciding case is
+ /// SQLite's 20240113140741_Add_PlayoutTemplate_DaysOfMonth, which adds the column
+ /// nullable: true with NO default: a row inserted before it had no day-of-month
+ /// restriction, so reading its NULL as empty would INVERT its meaning and silently stop the
+ /// template applying. That is the one NULL reachable without any code writing one.
+ ///
+ ///
+ /// Reachability itself is pinned by
+ /// ErsatzTV.Tests.Integration.SchedulingCollectionColumnNullTests against a real
+ /// TvContext; these tests pin what the selector does once the null is there.
+ ///
+ ///
+ [TestFixture]
+ public class GetScheduleForDate_NullCollections
+ {
+ private static readonly TimeSpan Offset = TimeSpan.FromHours(-5);
+
+ // A Wednesday in March, so no All*() member is coincidentally excluded — and deliberately the
+ // 20th rather than the 6th. With a day <= 12 a CROSS-WIRED substitution survives the whole
+ // fixture: `DaysOfMonth ?? AllMonthsOfYear()` hands back 1..12, which still contains day 6, so
+ // every assertion here passes while the guard substitutes the wrong set. Day 20 is outside 1..12
+ // and kills it.
+ private static readonly DateTimeOffset AnyDate = new(2024, 3, 20, 0, 0, 0, Offset);
+
+ private static PlayoutTemplate Unrestricted() =>
+ new()
+ {
+ DaysOfWeek = AlternateScheduleSelector.AllDaysOfWeek(),
+ DaysOfMonth = AlternateScheduleSelector.AllDaysOfMonth(),
+ MonthsOfYear = AlternateScheduleSelector.AllMonthsOfYear()
+ };
+
+ private static Option Select(params PlayoutTemplate[] templates) =>
+ AlternateScheduleSelector.GetScheduleForDate(templates.ToList(), AnyDate);
+
+ [Test]
+ public void Null_DaysOfWeek_Reads_As_Unrestricted()
+ {
+ PlayoutTemplate template = Unrestricted();
+ template.DaysOfWeek = null!;
+
+ Select(template).IsSome.ShouldBeTrue(
+ "a NULL DaysOfWeek means no weekday restriction was recorded, so the template still applies");
+ }
+
+ [Test]
+ public void Null_DaysOfMonth_Reads_As_Unrestricted()
+ {
+ PlayoutTemplate template = Unrestricted();
+ template.DaysOfMonth = null!;
+
+ Select(template).IsSome.ShouldBeTrue();
+ }
+
+ [Test]
+ public void Null_MonthsOfYear_Reads_As_Unrestricted()
+ {
+ PlayoutTemplate template = Unrestricted();
+ template.MonthsOfYear = null!;
+
+ Select(template).IsSome.ShouldBeTrue();
+ }
+
+ [Test]
+ public void All_Three_Null_On_ProgramScheduleAlternate_Reads_As_Unrestricted()
+ {
+ var alternate = new ProgramScheduleAlternate
+ {
+ DaysOfWeek = null!,
+ DaysOfMonth = null!,
+ MonthsOfYear = null!
+ };
+
+ AlternateScheduleSelector.GetScheduleForDate(
+ new List { alternate },
+ AnyDate)
+ .IsSome.ShouldBeTrue();
+ }
+
+ ///
+ /// THE DISCRIMINATING CONTROL. Every test above sets a NULL and expects the item to be selected,
+ /// so all of them pass equally under "NULL means unrestricted" and under the much broader
+ /// "any NULL makes this item match unconditionally" — a refactor that short-circuits the whole
+ /// date check when any dimension is null keeps them green. Here the nulled dimension is paired
+ /// with a RESTRICTIVE non-null one that the date fails, so only the narrow reading passes.
+ ///
+ [Test]
+ public void A_Null_Dimension_Does_Not_Relax_The_Other_Dimensions()
+ {
+ PlayoutTemplate template = Unrestricted();
+ template.DaysOfWeek = null!;
+
+ // AnyDate is in MARCH; restrict to January only.
+ template.MonthsOfYear = [1];
+
+ Select(template).IsNone.ShouldBeTrue(
+ "a NULL DaysOfWeek relaxes ONLY the weekday dimension — the January restriction still "
+ + "excludes a March date");
+ }
+
+ ///
+ /// One arrangement is not enough: with only the DaysOfWeek case above, a PER-DIMENSION
+ /// mutant survives the whole fixture — e.g. if (item.MonthsOfYear is null) return item;
+ /// placed ahead of the checks is never reached by that test, because its MonthsOfYear is
+ /// non-null. So each of the three dimensions is nulled in turn against a restriction on a
+ /// DIFFERENT dimension.
+ ///
+ [Test]
+ public void A_Null_MonthsOfYear_Does_Not_Relax_The_Other_Dimensions()
+ {
+ PlayoutTemplate template = Unrestricted();
+ template.MonthsOfYear = null!;
+
+ // AnyDate is a WEDNESDAY; restrict to Monday only.
+ template.DaysOfWeek = [DayOfWeek.Monday];
+
+ Select(template).IsNone.ShouldBeTrue(
+ "a NULL MonthsOfYear relaxes ONLY the month dimension — the Monday restriction still "
+ + "excludes a Wednesday");
+ }
+
+ ///
+ /// The third of the per-dimension controls — see
+ /// for why one
+ /// arrangement is not enough. Here the nulled dimension is DaysOfMonth and the
+ /// restriction that must still bite is on MonthsOfYear.
+ ///
+ [Test]
+ public void A_Null_DaysOfMonth_Does_Not_Relax_The_Other_Dimensions()
+ {
+ PlayoutTemplate template = Unrestricted();
+ template.DaysOfMonth = null!;
+
+ // AnyDate is in MARCH; restrict to January only.
+ template.MonthsOfYear = [1];
+
+ Select(template).IsNone.ShouldBeTrue(
+ "a NULL DaysOfMonth relaxes ONLY the day-of-month dimension — the January restriction "
+ + "still excludes a March date");
+ }
+
+ ///
+ /// A null must not be confused with an explicitly EMPTY collection. Empty is a legal, reachable
+ /// state meaning "matches no day", and it keeps that meaning — which is exactly why a NULL
+ /// cannot be normalized to it.
+ ///
+ [Test]
+ public void An_Explicitly_Empty_Collection_Still_Matches_Nothing()
+ {
+ PlayoutTemplate template = Unrestricted();
+ template.DaysOfWeek = [];
+
+ Select(template).IsNone.ShouldBeTrue(
+ "an empty DaysOfWeek is a recorded restriction of NO days, unlike a NULL");
+ }
+
+ ///
+ /// The guard resolves PER ITEM: a null on the first item must not decide the second. Making the
+ /// nulled item genuinely non-matching is what measures that — with an unrestricted nulled item
+ /// at index 0 it simply wins on ordering and the second item is never evaluated, so the
+ /// invariant would go unmeasured while the test passed.
+ ///
+ [Test]
+ public void A_Null_On_One_Item_Does_Not_Decide_A_Later_Item()
+ {
+ PlayoutTemplate nulled = Unrestricted();
+ nulled.DaysOfWeek = null!;
+ nulled.MonthsOfYear = [1]; // AnyDate is in March, so this item must NOT match
+ nulled.Index = 0;
+
+ PlayoutTemplate second = Unrestricted();
+ second.Index = 1;
+
+ foreach (PlayoutTemplate selected in Select(nulled, second))
+ {
+ selected.ShouldBeSameAs(second);
+ return;
+ }
+
+ Assert.Fail("the loop stopped at the null-collection item instead of continuing to the next");
+ }
+
+ ///
+ /// ...and when the nulled item IS unrestricted it legitimately wins on ordering. Paired with the
+ /// test above so "index 0 wins" and "the loop continues past a non-matching null item" are
+ /// separately pinned.
+ ///
+ [Test]
+ public void An_Unrestricted_Null_Item_Wins_On_Index_Order()
+ {
+ PlayoutTemplate nulled = Unrestricted();
+ nulled.DaysOfWeek = null!;
+ nulled.Index = 0;
+
+ PlayoutTemplate second = Unrestricted();
+ second.Index = 1;
+
+ foreach (PlayoutTemplate selected in Select(nulled, second))
+ {
+ selected.ShouldBeSameAs(nulled);
+ return;
+ }
+
+ Assert.Fail("the null-collection item was skipped instead of read as unrestricted");
+ }
+
+ ///
+ /// The read-site guard must not be written BACK onto the item. These are single-column
+ /// primitive collections, so assigning the guard would flip a tracked entity to
+ /// Modified and the next SaveChanges would persist the substituted collection
+ /// over the NULL — the mechanism recorded as media.nullable-primitive-collection-mutation.
+ ///
+ [Test]
+ public void Guard_Must_Not_Be_Written_Back_Onto_The_Item()
+ {
+ PlayoutTemplate template = Unrestricted();
+ template.DaysOfWeek = null!;
+ template.DaysOfMonth = null!;
+ template.MonthsOfYear = null!;
+
+ Select(template);
+
+ template.DaysOfWeek.ShouldBeNull();
+ template.DaysOfMonth.ShouldBeNull();
+ template.MonthsOfYear.ShouldBeNull();
+ }
+ }
}
diff --git a/ErsatzTV.Core/Scheduling/AlternateScheduleSelector.cs b/ErsatzTV.Core/Scheduling/AlternateScheduleSelector.cs
index c9ca6b162..672829d0b 100644
--- a/ErsatzTV.Core/Scheduling/AlternateScheduleSelector.cs
+++ b/ErsatzTV.Core/Scheduling/AlternateScheduleSelector.cs
@@ -85,19 +85,46 @@ public static class AlternateScheduleSelector
}
}
- bool daysOfWeek = item.DaysOfWeek.Contains(date.DayOfWeek);
+ // These three are NULLABLE single-column primitive collections, and a runtime null IS
+ // reachable (ersatztv#823, measured against a real TvContext on SQLite and MySQL 8.4): EF does
+ // NOT invoke the value converter for a NULL column, so it materializes as CLR null rather than
+ // through IntCollectionValueConverter's null-to-empty branch, which never runs on this path.
+ // Unguarded, each .Contains below throws NullReferenceException.
+ //
+ // A NULL reads as UNRESTRICTED -- the All*() sets -- NOT as empty. This is the whole semantic
+ // question and it is decided by the one NULL that is reachable WITHOUT any code writing one:
+ // Sqlite's 20240113140741_Add_PlayoutTemplate_DaysOfMonth adds DaysOfMonth with
+ // `nullable: true` and NO defaultValue, so a PlayoutTemplate row inserted before it holds NULL
+ // and, by construction, had NO day-of-month restriction. Reading that as empty would INVERT
+ // the row's meaning and silently stop the template applying at all. All*() preserves it, and
+ // it is how "no restriction recorded" is already represented elsewhere in this domain
+ // (GetPlayoutAlternateSchedulesHandler, PreviewBlockPlayoutHandler). Note what does NOT decide
+ // it: the API request records normalize an omitted field with `?? []`, but that is a client
+ // omitting a field on a WRITE and says nothing about what a legacy database NULL meant.
+ //
+ // Guarded at the READ SITE, into locals, and NEVER assigned back onto `item`: the property IS
+ // the column value, so writing the guard back would flip a tracked entry to Modified and
+ // persist the substituted collection over the NULL
+ // (`media.nullable-primitive-collection-mutation`). The matching substitution happens at the
+ // entity->DTO boundary in the two Mapper.ProjectToViewModel overloads, so the SPA's
+ // appliesToDate -- an exact port of this method -- previews what this actually schedules.
+ ICollection itemDaysOfWeek = item.DaysOfWeek ?? AllDaysOfWeek();
+ ICollection itemDaysOfMonth = item.DaysOfMonth ?? AllDaysOfMonth();
+ ICollection itemMonthsOfYear = item.MonthsOfYear ?? AllMonthsOfYear();
+
+ bool daysOfWeek = itemDaysOfWeek.Contains(date.DayOfWeek);
if (!daysOfWeek)
{
continue;
}
- bool daysOfMonth = item.DaysOfMonth.Contains(date.Day);
+ bool daysOfMonth = itemDaysOfMonth.Contains(date.Day);
if (!daysOfMonth)
{
continue;
}
- bool monthOfYear = item.MonthsOfYear.Contains(date.Month);
+ bool monthOfYear = itemMonthsOfYear.Contains(date.Month);
if (monthOfYear)
{
return item;
diff --git a/ErsatzTV.Tests/Application/Scheduling/RecurrenceLimitsMapperNullTests.cs b/ErsatzTV.Tests/Application/Scheduling/RecurrenceLimitsMapperNullTests.cs
new file mode 100644
index 000000000..3a4d613b3
--- /dev/null
+++ b/ErsatzTV.Tests/Application/Scheduling/RecurrenceLimitsMapperNullTests.cs
@@ -0,0 +1,151 @@
+using ErsatzTV.Application.Playouts;
+using ErsatzTV.Application.Scheduling;
+using ErsatzTV.Core.Domain;
+using ErsatzTV.Core.Domain.Scheduling;
+using ErsatzTV.Core.Scheduling;
+using NUnit.Framework;
+using Shouldly;
+using PlayoutsMapper = ErsatzTV.Application.Playouts.Mapper;
+using SchedulingMapper = ErsatzTV.Application.Scheduling.Mapper;
+
+namespace ErsatzTV.Tests.Application.Scheduling;
+
+///
+/// ersatztv#823. Guarding alone would have left the OTHER read
+/// of the same six columns unguarded — the entity→view-model mappers, which feed
+/// PlayoutController's response models and therefore the SPA.
+///
+/// Two things break without the guard, and neither is a C# exception, which is why the selector
+/// tests cannot see them. web/src/screens/PlayoutScheduleEditors.tsx spreads the collection
+/// (daysOfMonth: [...template.daysOfMonth]) and throws TypeError: not iterable on a
+/// JSON null; and web/src/screens/playoutTemplateCalendar.ts's appliesToDate —
+/// an exact TypeScript port of —
+/// calls .includes on it.
+///
+///
+/// So the mappers substitute the SAME unrestricted defaults the selector reads. That agreement is
+/// the point: a DTO that said "empty" while the selector scheduled "unrestricted" would make the
+/// preview calendar disagree with the playout it is previewing.
+///
+///
+[TestFixture]
+public class RecurrenceLimitsMapperNullTests
+{
+ private static Template MinimalTemplate() =>
+ new()
+ {
+ Id = 7,
+ Name = "T",
+ TemplateGroupId = 1,
+ TemplateGroup = new TemplateGroup { Name = "G" },
+ Items = []
+ };
+
+ [Test]
+ public void ProgramScheduleAlternate_Null_Collections_Map_To_Unrestricted()
+ {
+ var alternate = new ProgramScheduleAlternate
+ {
+ Id = 1,
+ Index = 0,
+ ProgramScheduleId = 2,
+ DaysOfWeek = null!,
+ DaysOfMonth = null!,
+ MonthsOfYear = null!
+ };
+
+ PlayoutAlternateScheduleViewModel vm = PlayoutsMapper.ProjectToViewModel(alternate);
+
+ vm.DaysOfWeek.ShouldBe(AlternateScheduleSelector.AllDaysOfWeek());
+ vm.DaysOfMonth.ShouldBe(AlternateScheduleSelector.AllDaysOfMonth());
+ vm.MonthsOfYear.ShouldBe(AlternateScheduleSelector.AllMonthsOfYear());
+
+ // Never assigned back: these are single-column primitive collections, so writing the guard onto a
+ // tracked entity would persist the substituted set over the NULL
+ // (media.nullable-primitive-collection-mutation).
+ alternate.DaysOfWeek.ShouldBeNull();
+ alternate.DaysOfMonth.ShouldBeNull();
+ alternate.MonthsOfYear.ShouldBeNull();
+ }
+
+ [Test]
+ public void PlayoutTemplate_Null_Collections_Map_To_Unrestricted()
+ {
+ var template = new PlayoutTemplate
+ {
+ Id = 1,
+ Index = 0,
+ Template = MinimalTemplate(),
+ DecoTemplate = null,
+ DaysOfWeek = null!,
+ DaysOfMonth = null!,
+ MonthsOfYear = null!
+ };
+
+ PlayoutTemplateViewModel vm = SchedulingMapper.ProjectToViewModel(template);
+
+ vm.DaysOfWeek.ShouldBe(AlternateScheduleSelector.AllDaysOfWeek());
+ vm.DaysOfMonth.ShouldBe(AlternateScheduleSelector.AllDaysOfMonth());
+ vm.MonthsOfYear.ShouldBe(AlternateScheduleSelector.AllMonthsOfYear());
+
+ template.DaysOfWeek.ShouldBeNull();
+ template.DaysOfMonth.ShouldBeNull();
+ template.MonthsOfYear.ShouldBeNull();
+ }
+
+ ///
+ /// An explicitly EMPTY collection is a recorded restriction of no days and must survive the mapper
+ /// unchanged. Without this, a guard written as "empty or null becomes All*" would pass the two tests
+ /// above while silently rewriting real user data on the way out.
+ ///
+ /// There is one of these per MAPPER, not one in total. The two overloads are byte-identical
+ /// triples in different files, so a defensive edit to one alone is exactly the "one helper, two
+ /// callers" shape this repo has been bitten by: covering only the Playouts mapper would leave
+ /// the PlayoutTemplate one free to acquire an `empty-or-null` guard with the suite still green.
+ ///
+ ///
+ [Test]
+ public void An_Explicitly_Empty_Collection_Is_Not_Rewritten_By_The_PlayoutTemplate_Mapper()
+ {
+ var template = new PlayoutTemplate
+ {
+ Id = 1,
+ Index = 0,
+ Template = MinimalTemplate(),
+ DecoTemplate = null,
+ DaysOfWeek = [],
+ DaysOfMonth = [],
+ MonthsOfYear = []
+ };
+
+ PlayoutTemplateViewModel vm = SchedulingMapper.ProjectToViewModel(template);
+
+ vm.DaysOfWeek.ShouldBeEmpty();
+ vm.DaysOfMonth.ShouldBeEmpty();
+ vm.MonthsOfYear.ShouldBeEmpty();
+ }
+
+ ///
+ /// The ProgramScheduleAlternate half of the same pair — see the PlayoutTemplate one above
+ /// for why there is one per MAPPER rather than one in total.
+ ///
+ [Test]
+ public void An_Explicitly_Empty_Collection_Is_Not_Rewritten()
+ {
+ var alternate = new ProgramScheduleAlternate
+ {
+ Id = 1,
+ Index = 0,
+ ProgramScheduleId = 2,
+ DaysOfWeek = [],
+ DaysOfMonth = [],
+ MonthsOfYear = []
+ };
+
+ PlayoutAlternateScheduleViewModel vm = PlayoutsMapper.ProjectToViewModel(alternate);
+
+ vm.DaysOfWeek.ShouldBeEmpty();
+ vm.DaysOfMonth.ShouldBeEmpty();
+ vm.MonthsOfYear.ShouldBeEmpty();
+ }
+}
diff --git a/ErsatzTV.Tests/Integration/ElasticSongIndexerMetadataMutationTests.cs b/ErsatzTV.Tests/Integration/ElasticSongIndexerMetadataMutationTests.cs
new file mode 100644
index 000000000..1e82cb022
--- /dev/null
+++ b/ErsatzTV.Tests/Integration/ElasticSongIndexerMetadataMutationTests.cs
@@ -0,0 +1,299 @@
+using System.Reflection;
+using System.Text;
+using Elastic.Clients.Elasticsearch;
+using Elastic.Transport;
+using ErsatzTV.Core.Domain;
+using ErsatzTV.Core.Interfaces.Metadata;
+using ErsatzTV.Core.Interfaces.Repositories;
+using ErsatzTV.Core.Search;
+using ErsatzTV.Infrastructure.Data;
+using ErsatzTV.Infrastructure.Extensions;
+using ErsatzTV.Infrastructure.Search;
+using ErsatzTV.Tests.Support;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.Extensions.Logging;
+using NSubstitute;
+using NUnit.Framework;
+using Shouldly;
+
+namespace ErsatzTV.Tests.Integration;
+
+///
+/// ersatztv#824 — the gap ersatztv#701 named rather than papered over.
+///
+/// ElasticSearchIndex.UpdateSong holds an INDEPENDENT copy of the logic
+/// pins on LuceneSearchIndex. #701 removed
+/// metadata.AlbumArtists ??= []; metadata.Artists ??= []; from both, but only Lucene gained
+/// a regression test — so reintroducing the mutation in the Elastic copy ALONE left the whole
+/// suite green. This fixture closes that: the assertions are the same three, driven through the
+/// real ElasticSearchIndex against a real .
+///
+///
+/// Why a stubbed transport rather than a live server. #824 listed "inject a non-network
+/// ElasticsearchClient transport" as option 1 and it is what shipped:
+/// Elastic.Transport.InMemoryRequestInvoker is public in the pinned Elastic.Transport, and
+/// ElasticsearchClientSettings(NodePool, IRequestInvoker) accepts it. No server, no socket,
+/// no new package.
+///
+///
+/// The canned response body is load-bearing, not decoration. A bare
+/// InMemoryRequestInvoker() answers with an EMPTY body, which the client cannot deserialize
+/// into an IndexResponse. That throw lands in UpdateSong's catch, which logs a
+/// warning and assigns metadata.Song = null — so the fixture would measure the ERROR path
+/// while every "did not mutate" assertion below still passed, vacuously. The
+/// is the belt to that brace: it fails the test if the
+/// catch ran at all.
+///
+///
+/// The client is injected into the private _client field rather than obtained normally,
+/// because CreateClient reads the process-wide static ElasticSearchIndex.Uri and
+/// would open a real socket. UpdateItems — unlike IndexExists and
+/// Initialize — never runs _client ??= CreateClient(), so the injected instance is
+/// the one used and an uninjected one would simply be null.
+///
+///
+[TestFixture]
+[NonParallelizable]
+public class ElasticSongIndexerMetadataMutationTests
+{
+ private const string TestIndexName = "etv-824-test";
+
+ private string? _originalIndexName;
+
+ ///
+ /// ElasticSearchIndex.IndexName is a process-wide static. Only Startup reads it today,
+ /// so leaving it set leaks nothing that currently runs — but a static this fixture writes and never
+ /// restores is a cross-test hazard waiting for the first test that does read it.
+ ///
+ [SetUp]
+ public void SetUp() => _originalIndexName = ElasticSearchIndex.IndexName;
+
+ [TearDown]
+ public void TearDown() => ElasticSearchIndex.IndexName = _originalIndexName;
+
+ ///
+ /// A well-formed IndexResponse. See the fixture docstring: an empty body diverts the run
+ /// into UpdateSong's catch and makes every assertion below vacuous.
+ ///
+ private const string IndexResponseBody =
+ """
+ {"_index":"etv-824-test","_id":"1","_version":1,"result":"created",
+ "_shards":{"total":1,"successful":1,"failed":0},"_seq_no":0,"_primary_term":1}
+ """;
+
+ [Test]
+ public async Task UpdateSong_Must_Not_Mutate_Nullable_Artists_On_A_Tracked_Entity()
+ {
+ await using var harness = await InMemoryTvContext.CreateAsync();
+
+ int metadataId;
+ int songId;
+ await using (TvContext context = harness.CreateContext())
+ {
+ var library = new LocalLibrary { Name = "Music", MediaKind = LibraryMediaKind.Songs };
+ context.Add(library);
+ await context.SaveChangesAsync();
+
+ var libraryPath = new LibraryPath { Path = "/music", LibraryId = library.Id };
+ context.Add(libraryPath);
+ await context.SaveChangesAsync();
+
+ var song = new Song
+ {
+ LibraryPathId = libraryPath.Id,
+ MediaVersions = [],
+ SongMetadata =
+ [
+ new SongMetadata
+ {
+ MetadataKind = MetadataKind.Fallback,
+ Title = "Untagged Track",
+ SortTitle = "untagged track",
+ DateAdded = new DateTime(2026, 1, 1, 0, 0, 0, DateTimeKind.Utc),
+
+ // The shape FallbackMetadataProvider.GetSongMetadata leaves behind: it never
+ // assigns either primitive collection, so both columns persist as NULL.
+ Artists = null!,
+ AlbumArtists = null!,
+
+ Genres = [],
+ Tags = [],
+ Studios = [],
+ Actors = [],
+ Artwork = [],
+ Guids = []
+ }
+ ]
+ };
+
+ context.Add(song);
+ await context.SaveChangesAsync();
+ metadataId = song.SongMetadata[0].Id;
+ songId = song.Id;
+ }
+
+ // The seed must actually have produced NULL columns, or every assertion below is vacuous.
+ (await ReadRawArtists(harness, metadataId)).ShouldBeNull();
+
+ await using (TvContext context = harness.CreateContext())
+ {
+ // Deliberately TRACKED -- the indexer's own contract is what is being pinned, not the
+ // AsNoTracking() habit of today's two callers. See SongIndexerMetadataMutationTests.
+ Song tracked = await context.Songs
+ .IncludeForSearch()
+ .AsSplitQuery()
+ .SingleAsync();
+
+ SongMetadata metadata = tracked.SongMetadata[0];
+ metadata.Artists.ShouldBeNull("EF must materialize the NULL column as null, not as an empty list");
+
+ var logger = new ThrowOnWarningLogger();
+ var index = new ElasticSearchIndex(
+ new SearchQueryParser(
+ Substitute.For(),
+ Substitute.For>()),
+ logger);
+
+ var invoker = new CapturingRequestInvoker(
+ new InMemoryRequestInvoker(
+ Encoding.UTF8.GetBytes(IndexResponseBody),
+ 200,
+ exception: null,
+ contentType: "application/json",
+ // The X-Elastic-Product header is REQUIRED, not cosmetic. The client runs a product
+ // check on its first response and throws UnsupportedProductException ("the server is
+ // not a supported distribution of Elasticsearch") without it -- which lands in
+ // UpdateSong's catch and makes the fixture measure the error path. Measured: this is
+ // exactly how this fixture first failed.
+ headers: ProductCheckHeaders()));
+
+ var settings = new ElasticsearchClientSettings(
+ new SingleNodePool(new Uri("http://localhost:9200")),
+ invoker)
+ .DefaultIndex(TestIndexName);
+
+ ElasticSearchIndex.IndexName = TestIndexName;
+
+ typeof(ElasticSearchIndex)
+ .GetField("_client", BindingFlags.NonPublic | BindingFlags.Instance)!
+ .SetValue(index, new ElasticsearchClient(settings));
+
+ // A bare substitute returns null from GetAllLanguageCodes, which NPEs inside AddLanguages and
+ // would divert the run into UpdateSong's catch.
+ var languageCodeService = Substitute.For();
+ languageCodeService.GetAllLanguageCodes(Arg.Any>()).Returns([]);
+ languageCodeService.GetAllLanguageCodes(Arg.Any()).Returns([]);
+
+ await index.UpdateItems(
+ Substitute.For(),
+ Substitute.For(),
+ languageCodeService,
+ [tracked]);
+
+ // Surfacing the exception rather than asserting ShouldBeNull: the catch is the fixture's
+ // most likely failure mode (see the canned-response note above), and "expected null but was
+ // " without the message sends the next reader hunting for a cause the fixture
+ // already had in its hand.
+ if (logger.Failure is not null)
+ {
+ Assert.Fail("UpdateSong threw and its catch ran, so this probe measured the error path "
+ + $"rather than the indexing path: {logger.Failure}");
+ }
+
+ // POSITIVE CONTROL, and it is not optional: every assertion below says something did NOT
+ // happen, so all of them hold vacuously if UpdateSong never ran. The Lucene fixture uses
+ // `writer.NumDocs == 1` for exactly this; the transport-level equivalent is that the indexer
+ // actually issued the index request for THIS song. Like NumDocs, it proves the song-indexing
+ // path ran -- it does NOT prove the artist reads specifically ran.
+ invoker.Requests.Count.ShouldBe(
+ 1,
+ "UpdateSong did not issue exactly one index request, so the assertions below would pass "
+ + $"without exercising the code under test. Captured: [{string.Join(", ", invoker.Requests)}]");
+
+ // The document id is compared as the LAST PATH SEGMENT, not with ShouldContain. A substring
+ // test is a false-pass vector here: the index name itself carries digits ("etv-824-test"), so
+ // ShouldContain("2") or ShouldContain("4") would be satisfied by the index name alone for a
+ // song whose id happened to be 2 or 4, and the assertion would stop discriminating without
+ // ever failing.
+ string path = invoker.Requests[0].Split(' ')[^1].Split('?')[0];
+ path.Split('/')[^1].ShouldBe(
+ songId.ToString(),
+ $"the index request was not for the seeded song. Captured: {invoker.Requests[0]}");
+
+ // 1. The indexer left the entity alone.
+ metadata.Artists.ShouldBeNull();
+ metadata.AlbumArtists.ShouldBeNull();
+
+ // 2. ...so EF has nothing to persist. This is the assertion that fails loudly the day the
+ // mutation returns, even if a later refactor stopped the value from being observable above.
+ context.Entry(metadata).State.ShouldBe(EntityState.Unchanged);
+
+ // 3. And the save that a real caller would go on to make does not rewrite the column.
+ await context.SaveChangesAsync();
+ }
+
+ (await ReadRawArtists(harness, metadataId)).ShouldBeNull();
+ }
+
+ private static Dictionary> ProductCheckHeaders() =>
+ new(StringComparer.OrdinalIgnoreCase) { ["x-elastic-product"] = ["Elasticsearch"] };
+
+ ///
+ /// Records every request the client actually issues, so the fixture can prove the code under test
+ /// ran. Delegates the answering to a real rather than
+ /// hand-building a response.
+ ///
+ private sealed class CapturingRequestInvoker(InMemoryRequestInvoker inner) : IRequestInvoker
+ {
+ public List Requests { get; } = [];
+
+ public ResponseFactory ResponseFactory => inner.ResponseFactory;
+
+ public TResponse Request(
+ Endpoint endpoint,
+ BoundConfiguration boundConfiguration,
+ PostData? postData)
+ where TResponse : TransportResponse, new()
+ {
+ Requests.Add($"{endpoint.Method} {endpoint.PathAndQuery}");
+ return inner.Request(endpoint, boundConfiguration, postData);
+ }
+
+ public Task RequestAsync(
+ Endpoint endpoint,
+ BoundConfiguration boundConfiguration,
+ PostData? postData,
+ CancellationToken cancellationToken)
+ where TResponse : TransportResponse, new()
+ {
+ Requests.Add($"{endpoint.Method} {endpoint.PathAndQuery}");
+ return inner.RequestAsync(endpoint, boundConfiguration, postData, cancellationToken);
+ }
+
+ // IRequestInvoker extends IDisposable, but InMemoryRequestInvoker holds no disposable state and
+ // exposes no Dispose of its own -- there is nothing to forward to.
+ public void Dispose()
+ {
+ }
+ }
+
+ private static async Task