Files
ersatztv/ErsatzTV.Tests/Infrastructure/GraphicsElementSeederUpgradeTests.cs
T
timothyandtimothy ba6a4b08aa
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (push) Skipped
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (push) Skipped
Build ErsatzTV Image / CI toolchain image resolves (push) Successful in 9s
Build ErsatzTV Image / Delimiter ban (release path) (push) Successful in 25s
Build ErsatzTV Image / Build & test (.NET) (push) Successful in 8m40s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (push) Successful in 6m18s
Build ErsatzTV Image / Functional E2E (curl + UI contracts) (push) Successful in 6m12s
Build ErsatzTV Image / Build & push image (amd64) (push) Successful in 4m13s
probe742/combined-newest SECOND
feat(732): On Now / Next gets a background box, and is on by default (#843)
Co-authored-by: Timothy <timothy@noreply.gitea.tblindustries.be>
2026-08-26 19:28:25 +00:00

366 lines
15 KiB
C#

using System.IO.Abstractions;
using ErsatzTV.Core;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Graphics;
using ErsatzTV.Infrastructure.Data;
using ErsatzTV.Infrastructure.Streaming.Graphics;
using ErsatzTV.Tests.Support;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.EntityFrameworkCore;
using NUnit.Framework;
using Shouldly;
using Testably.Abstractions.Testing;
using Testably.Abstractions.Testing.FileSystem;
using YamlDotNet.Serialization;
using YamlDotNet.Serialization.NamingConventions;
namespace ErsatzTV.Tests.Infrastructure;
/// <summary>
/// #732: the seeder writes the On Now / Next template once and never revisits it, so a change to the
/// shipped default would reach new databases only. These pin the upgrade path that fixes that, and
/// the boundary that keeps it from clobbering an operator's edits.
/// </summary>
[TestFixture]
public class GraphicsElementSeederUpgradeTests
{
// Byte-for-byte the default shipped before #732. Verified 2026-08-26 against the live prod
// install at 192.168.1.29 (md5 ef9afc088cf6dba252f725babbf3334f), so this is a real
// fingerprint rather than a copy of the constant it is meant to detect.
private const string OnNowNextYamlV1 =
"""
name: On Now / Next
epg_entries: 2
location: BottomLeft
horizontal_margin_percent: 4
vertical_margin_percent: 8
width_percent: 42
text_fit: Wrap
text_align: Left
z_index: 100
# transparent until 4s in, fade in 1s, hold 6s, fade out 1s
opacity_expression: "LinearFadeDuration(content_seconds, 4, 1, 6)"
base_style: now
styles:
- name: now
font_family: "Noto Sans"
font_size: 30
font_weight: 700
text_color: "#FFFFFF"
halo_color: "#000000"
halo_width: 2
- name: sub
font_family: "Noto Sans"
font_size: 22
font_weight: 400
text_color: "#DDDDDD"
halo_color: "#000000"
halo_width: 2
- name: next
font_family: "Noto Sans"
font_size: 22
font_weight: 400
text_color: "#DDDDDD"
halo_color: "#000000"
halo_width: 2
text: |
[now]NOW {{ Epg[0].Title }}[/now]
{{ if Epg[0].SubTitle }}[sub]{{ Epg[0].SubTitle }}[/sub]{{ end }}
{{ if (array.size Epg) > 1 }}[next]NEXT {{ Epg[1].Title }}[/next]{{ end }}
""";
private InMemoryTvContext _db = null!;
private string _target = null!;
[SetUp]
public async Task SetUp()
{
_db = await InMemoryTvContext.CreateAsync();
_target = Path.Combine(
FileSystemLayout.GraphicsElementsTextTemplatesFolder,
GraphicsElementDefaults.OnNowNextFileName);
}
[TearDown]
public async Task TearDown() => await _db.DisposeAsync();
private async Task<MockFileSystem> RunSeederOverAlreadySeededDatabase(string existingContent)
{
var fs = new MockFileSystem();
fs.Directory.CreateDirectory(FileSystemLayout.GraphicsElementsTextTemplatesFolder);
if (existingContent is not null)
{
await fs.File.WriteAllTextAsync(_target, existingContent);
}
await using TvContext context = _db.CreateContext();
// The fixture DB is shared across calls within a test, so only seed the marker once.
string key = ConfigElementKey.GraphicsOnNowNextSeeded.Key;
if (!context.ConfigElements.Any(c => c.Key == key))
{
context.ConfigElements.Add(new ConfigElement { Key = key, Value = "true" });
await context.SaveChangesAsync();
}
await GraphicsElementSeeder.SeedOnNowNext(context, fs, NullLogger.Instance, CancellationToken.None);
return fs;
}
[Test]
public async Task Upgrades_An_Untouched_Previous_Default()
{
MockFileSystem fs = await RunSeederOverAlreadySeededDatabase(OnNowNextYamlV1);
string result = await fs.File.ReadAllTextAsync(_target);
result.ShouldNotBe(OnNowNextYamlV1);
result.ShouldContain("background_color");
result.ShouldContain("background_padding");
}
[Test]
public async Task Upgrades_An_Untouched_Previous_Default_With_Windows_Line_Endings()
{
MockFileSystem fs = await RunSeederOverAlreadySeededDatabase(
OnNowNextYamlV1.Replace("\n", "\r\n"));
string result = await fs.File.ReadAllTextAsync(_target);
result.ShouldContain("background_color");
}
[Test]
public async Task Leaves_An_Operator_Modified_File_Alone()
{
// One changed value is enough to stop matching the fingerprint.
string edited = OnNowNextYamlV1.Replace("width_percent: 42", "width_percent: 30");
MockFileSystem fs = await RunSeederOverAlreadySeededDatabase(edited);
string result = await fs.File.ReadAllTextAsync(_target);
result.ShouldBe(edited);
result.ShouldNotContain("background_color");
}
[Test]
public async Task Leaves_The_Current_Default_Alone_So_The_Upgrade_Is_Idempotent()
{
MockFileSystem first = await RunSeederOverAlreadySeededDatabase(OnNowNextYamlV1);
string upgraded = await first.File.ReadAllTextAsync(_target);
MockFileSystem second = await RunSeederOverAlreadySeededDatabase(upgraded);
string again = await second.File.ReadAllTextAsync(_target);
again.ShouldBe(upgraded);
}
// The upgrade runs inside DatabaseMigratorService, ahead of DatabaseIsReady(). Before #732 the
// already-seeded branch touched the filesystem not at all, so a template the app cannot read --
// e.g. edited as root via `docker exec` while the app runs as PUID/PGID -- used to boot fine.
// It must not become a failure to start.
[Test]
public async Task An_Unwritable_Template_Does_Not_Fail_Startup()
{
var fs = new MockFileSystem();
fs.Directory.CreateDirectory(FileSystemLayout.GraphicsElementsTextTemplatesFolder);
await fs.File.WriteAllTextAsync(_target, OnNowNextYamlV1);
// the file matches a shipped default, so the upgrade WILL try to rewrite it -- and that write
// is denied, standing in for a root-owned or read-only template
var intercepted = 0;
fs.Intercept.Changing(
FileSystemTypes.File,
_ =>
{
intercepted++;
throw new UnauthorizedAccessException("simulated permission denial");
});
await using TvContext context = _db.CreateContext();
string key = ConfigElementKey.GraphicsOnNowNextSeeded.Key;
context.ConfigElements.Add(new ConfigElement { Key = key, Value = "true" });
await context.SaveChangesAsync();
await Should.NotThrowAsync(
() => GraphicsElementSeeder.SeedOnNowNext(context, fs, NullLogger.Instance, CancellationToken.None));
// Without this the test would pass just as happily if the upgrade never reached the write.
intercepted.ShouldBeGreaterThan(0, "the write interceptor never fired");
// and the original template survives the denied write
(await fs.File.ReadAllTextAsync(_target)).ShouldBe(OnNowNextYamlV1);
}
// The write path is not the only one that can fault. A template the app cannot READ used to be
// harmless on an already-seeded install; it must stay that way. An exclusive lock produces a
// genuine ReadAllTextAsync failure rather than an intercepted write dressed up as one.
[Test]
public async Task A_Read_Failure_On_The_Template_Does_Not_Fail_Startup()
{
var fs = new MockFileSystem();
fs.Directory.CreateDirectory(FileSystemLayout.GraphicsElementsTextTemplatesFolder);
await fs.File.WriteAllTextAsync(_target, OnNowNextYamlV1);
await using TvContext context = _db.CreateContext();
context.ConfigElements.Add(
new ConfigElement { Key = ConfigElementKey.GraphicsOnNowNextSeeded.Key, Value = "true" });
await context.SaveChangesAsync();
await using Stream exclusive = fs.File.Open(_target, FileMode.Open, FileAccess.Read, FileShare.None);
// prove the lock actually denies a read, so the test cannot pass by never hitting one
Should.Throw<IOException>(() => fs.File.ReadAllText(_target));
await Should.NotThrowAsync(
() => GraphicsElementSeeder.SeedOnNowNext(context, fs, NullLogger.Instance, CancellationToken.None));
}
[Test]
public async Task Does_Not_Create_The_File_When_It_Is_Absent()
{
MockFileSystem fs = await RunSeederOverAlreadySeededDatabase(null);
fs.File.Exists(_target).ShouldBeFalse();
}
[Test]
public async Task The_Upgraded_Template_Still_Deserializes_With_A_Resolvable_Base_Style()
{
MockFileSystem fs = await RunSeederOverAlreadySeededDatabase(OnNowNextYamlV1);
string yaml = await fs.File.ReadAllTextAsync(_target);
IDeserializer deserializer = new DeserializerBuilder()
.WithNamingConvention(CamelCaseNamingConvention.Instance)
.Build();
var element = deserializer.Deserialize<TextGraphicsElement>(yaml);
element.ShouldNotBeNull();
element.BackgroundColor.ShouldBe("#000000");
element.BackgroundOpacityPercent.ShouldBe(65);
element.BackgroundPadding.ShouldBe(14);
element.BackgroundCornerRadius.ShouldBe(8);
// The border is what makes the box visible over dark content; without it the translucent
// black fill is indistinguishable from the frame behind it.
element.BorderColor.ShouldBe("#59FFFFFF");
element.BorderWidth.ShouldBe(1);
// #570: every style needs a font_family, and base_style must resolve.
element.Styles.ShouldNotBeEmpty();
element.Styles.ShouldAllBe(s => s.FontFamily != null);
element.Styles.ShouldContain(s => s.Name == element.BaseStyle);
}
// Without the duplicate guard in EnsureBuiltInElementRow the already-seeded branch inserts a
// fresh row on EVERY boot: RefreshGraphicsElements will neither reap them (the file exists) nor
// dedupe them, so the row set grows without bound. Idempotence of the FILE is not idempotence
// of the ROW, and the existing idempotence test only looks at the file.
[Test]
public async Task Repeated_Seeding_Does_Not_Accumulate_Element_Rows()
{
var fs = new MockFileSystem();
fs.Directory.CreateDirectory(FileSystemLayout.GraphicsElementsTextTemplatesFolder);
await fs.File.WriteAllTextAsync(_target, OnNowNextYamlV1);
await using TvContext context = _db.CreateContext();
context.ConfigElements.Add(
new ConfigElement { Key = ConfigElementKey.GraphicsOnNowNextSeeded.Key, Value = "true" });
await context.SaveChangesAsync();
for (var i = 0; i < 3; i++)
{
await GraphicsElementSeeder.SeedOnNowNext(context, fs, NullLogger.Instance, CancellationToken.None);
}
(await context.GraphicsElements.ToListAsync()).Count.ShouldBe(1);
}
// A failed write must not leave a truncated template behind: it would match no fingerprint, so
// the upgrade could never repair it, and the loader rejects malformed YAML outright.
[Test]
public async Task A_Failed_Write_Leaves_The_Original_Template_Intact()
{
var fs = new MockFileSystem();
fs.Directory.CreateDirectory(FileSystemLayout.GraphicsElementsTextTemplatesFolder);
await fs.File.WriteAllTextAsync(_target, OnNowNextYamlV1);
// Capture WHICH path the write targets. Asserting only "the original survived" cannot tell
// an atomic write from an in-place one here: Testably raises the interception BEFORE it
// truncates, so a plain WriteAllTextAsync(target) would leave the file intact too -- on a
// real filesystem it would not. The path is what actually distinguishes them.
var writtenPaths = new List<string>();
fs.Intercept.Changing(
FileSystemTypes.File,
c =>
{
writtenPaths.Add(c.Path);
throw new IOException("simulated disk full");
});
await using TvContext context = _db.CreateContext();
context.ConfigElements.Add(
new ConfigElement { Key = ConfigElementKey.GraphicsOnNowNextSeeded.Key, Value = "true" });
await context.SaveChangesAsync();
await Should.NotThrowAsync(
() => GraphicsElementSeeder.SeedOnNowNext(context, fs, NullLogger.Instance, CancellationToken.None));
writtenPaths.ShouldNotBeEmpty("the write interceptor never fired");
writtenPaths.ShouldAllBe(path => path.EndsWith(".upgrade.tmp"), "the upgrade wrote the live template in place instead of a temp file");
(await fs.File.ReadAllTextAsync(_target)).ShouldBe(OnNowNextYamlV1);
fs.Directory.GetFiles(FileSystemLayout.GraphicsElementsTextTemplatesFolder, "*.upgrade.tmp")
.ShouldBeEmpty("a temp file was left behind");
}
// The sibling test faults on the FIRST write, so it never reaches File.Move or the cleanup. Fault
// the replace instead, after a complete temp write: that is the path where a non-atomic
// implementation would already have truncated the live template.
[Test]
public async Task A_Failed_Replace_After_A_Complete_Temp_Write_Leaves_The_Original_Intact()
{
var fs = new MockFileSystem();
fs.Directory.CreateDirectory(FileSystemLayout.GraphicsElementsTextTemplatesFolder);
await fs.File.WriteAllTextAsync(_target, OnNowNextYamlV1);
var seenPaths = new List<string>();
fs.Intercept.Event(
c =>
{
seenPaths.Add($"{c.ChangeType}:{c.Path}");
// let the temp file be written in full; fail only when the live template is touched
if (c.Path == _target)
{
throw new IOException("simulated replace failure");
}
},
_ => true);
await using TvContext context = _db.CreateContext();
context.ConfigElements.Add(
new ConfigElement { Key = ConfigElementKey.GraphicsOnNowNextSeeded.Key, Value = "true" });
await context.SaveChangesAsync();
await Should.NotThrowAsync(
() => GraphicsElementSeeder.SeedOnNowNext(context, fs, NullLogger.Instance, CancellationToken.None));
seenPaths.ShouldContain(path => path.EndsWith(".upgrade.tmp"), "no temp file was ever written");
// Assert the replace is a RENAME, not merely "the target was touched after the temp was".
// A File.Copy(temp, target, true) also touches both in that order and would leave the
// original intact under this mock (interception runs before the change), so path ordering
// alone cannot tell an atomic replace from a truncating one -- the change TYPE can.
seenPaths.ShouldContain($"Renamed:{_target}", "the replace was not an atomic rename");
// The whole point of write-then-move: the live template is untouched by a failed replace.
(await fs.File.ReadAllTextAsync(_target)).ShouldBe(OnNowNextYamlV1);
// and nothing this call created is left behind
fs.Directory.GetFiles(FileSystemLayout.GraphicsElementsTextTemplatesFolder, "*.upgrade.tmp")
.ShouldBeEmpty("a temp file was left behind");
}
}