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
Co-authored-by: Timothy <timothy@noreply.gitea.tblindustries.be>
366 lines
15 KiB
C#
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");
|
|
}
|
|
}
|