Files
ersatztv/ErsatzTV.Tests/Application/Configuration/IptvSettingsHandlerTests.cs
T
timothyandClaude Opus 4.8 3bc8192d3b feat(iptv): add configurable advertised base URL for M3U/XMLTV (fixes #340)
ErsatzTV built every absolute M3U/XMLTV URL from the incoming request's
Scheme/Host/PathBase, so a client fetching via a host that downstream
consumers can't resolve (e.g. Dispatcharr over Docker DNS → Kodi) baked
that internal host into programme-image/stream URLs.

Add an optional advertised IPTV base URL, backed by the existing
ConfigElement key/value store (key `iptv.base_url`, no EF migration):

- Central pure Core helper `AdvertisedBaseUrl` (TryParse/Resolve):
  validates absolute http(s), no credentials/query/fragment, preserves
  port + path prefix, normalizes trailing slash. Blank/invalid falls
  back to the request-derived values, so unset output is byte-identical.
- Resolved inside `GetChannelPlaylistHandler` (M3U guide/logo/stream) and
  `GetChannelGuideHandler` (both XMLTV {RequestBase} sites) — controllers
  stay thin, golden tests untouched.
- New `iptv` settings group: GET/PUT /api/v1/settings/iptv (blank clears,
  malformed → 422) + a new IPTV section on the SPA Settings screen.
- Scoped to M3U + XMLTV; HDHomeRun deliberately out of scope. Distinct
  from ETV_BASE_URL (which only sets ASP.NET PathBase).

Tests: AdvertisedBaseUrl unit tests (override/fallback/port/path/invalid),
handler override tests for both generators, settings controller + handler
tests, SPA client + screen tests. Docs: m3u-xmltv, decisions, domain-model,
regenerated OpenAPI v1.json + v1.d.ts + endpoint-index.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-16 21:08:42 +02:00

109 lines
3.9 KiB
C#

using ErsatzTV.Application.Configuration;
using ErsatzTV.Core;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.Repositories;
using LanguageExt;
using NSubstitute;
using NUnit.Framework;
using Shouldly;
using Unit = LanguageExt.Unit;
namespace ErsatzTV.Tests.Application.Configuration;
[TestFixture]
public class IptvSettingsHandlerTests
{
private const string Key = "iptv.base_url";
private IConfigElementRepository _configElementRepository = null!;
[SetUp]
public void SetUp() => _configElementRepository = Substitute.For<IConfigElementRepository>();
[Test]
public async Task Get_Returns_Empty_When_Unset()
{
_configElementRepository
.GetValue<string>(Arg.Any<ConfigElementKey>(), Arg.Any<CancellationToken>())
.Returns(Option<string>.None);
var handler = new GetIptvSettingsHandler(_configElementRepository);
IptvSettingsViewModel result = await handler.Handle(new GetIptvSettings(), CancellationToken.None);
result.BaseUrl.ShouldBe(string.Empty);
}
[Test]
public async Task Get_Returns_Stored_Value()
{
_configElementRepository
.GetValue<string>(Arg.Any<ConfigElementKey>(), Arg.Any<CancellationToken>())
.Returns(Option<string>.Some("https://tv.example.com/etv"));
var handler = new GetIptvSettingsHandler(_configElementRepository);
IptvSettingsViewModel result = await handler.Handle(new GetIptvSettings(), CancellationToken.None);
result.BaseUrl.ShouldBe("https://tv.example.com/etv");
}
[Test]
public async Task Update_Upserts_Trimmed_Value_When_Valid()
{
var handler = new UpdateIptvSettingsHandler(_configElementRepository);
Either<BaseError, Unit> result = await handler.Handle(
new UpdateIptvSettings(new IptvSettingsViewModel { BaseUrl = " https://public.example.com/etv " }),
CancellationToken.None);
result.IsRight.ShouldBeTrue();
await _configElementRepository.Received(1).Upsert(
Arg.Is<ConfigElementKey>(k => k.Key == Key),
"https://public.example.com/etv",
Arg.Any<CancellationToken>());
await _configElementRepository.DidNotReceive().Delete(Arg.Any<ConfigElementKey>(), Arg.Any<CancellationToken>());
}
[TestCase(null)]
[TestCase("")]
[TestCase(" ")]
public async Task Update_Clears_Setting_When_Blank(string blank)
{
var handler = new UpdateIptvSettingsHandler(_configElementRepository);
Either<BaseError, Unit> result = await handler.Handle(
new UpdateIptvSettings(new IptvSettingsViewModel { BaseUrl = blank }),
CancellationToken.None);
result.IsRight.ShouldBeTrue();
await _configElementRepository.Received(1).Delete(
Arg.Is<ConfigElementKey>(k => k.Key == Key),
Arg.Any<CancellationToken>());
await _configElementRepository.DidNotReceive().Upsert(
Arg.Any<ConfigElementKey>(),
Arg.Any<string>(),
Arg.Any<CancellationToken>());
}
[TestCase("not a url")]
[TestCase("ftp://tv.example.com")]
[TestCase("http://user:pass@tv.example.com")]
[TestCase("http://tv.example.com?foo=bar")]
public async Task Update_Returns_Error_And_Persists_Nothing_When_Invalid(string invalid)
{
var handler = new UpdateIptvSettingsHandler(_configElementRepository);
Either<BaseError, Unit> result = await handler.Handle(
new UpdateIptvSettings(new IptvSettingsViewModel { BaseUrl = invalid }),
CancellationToken.None);
result.IsLeft.ShouldBeTrue();
await _configElementRepository.DidNotReceive().Upsert(
Arg.Any<ConfigElementKey>(),
Arg.Any<string>(),
Arg.Any<CancellationToken>());
await _configElementRepository.DidNotReceive().Delete(Arg.Any<ConfigElementKey>(), Arg.Any<CancellationToken>());
}
}