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>
109 lines
3.9 KiB
C#
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>());
|
|
}
|
|
}
|