aboutsummaryrefslogtreecommitdiff
path: root/tests
diff options
context:
space:
mode:
Diffstat (limited to 'tests')
-rw-r--r--tests/Jellyfin.LiveTv.Tests/Listings/SchedulesDirectLineupTests.cs130
-rw-r--r--tests/Jellyfin.LiveTv.Tests/Listings/XmlTvListingsProviderCacheTests.cs137
-rw-r--r--tests/Jellyfin.LiveTv.Tests/Listings/XmlTvListingsProviderTests.cs43
-rw-r--r--tests/Jellyfin.LiveTv.Tests/Test Data/LiveTv/Listings/XmlTv/no-optional-elements.xml10
4 files changed, 320 insertions, 0 deletions
diff --git a/tests/Jellyfin.LiveTv.Tests/Listings/SchedulesDirectLineupTests.cs b/tests/Jellyfin.LiveTv.Tests/Listings/SchedulesDirectLineupTests.cs
new file mode 100644
index 0000000000..04c7b05c3d
--- /dev/null
+++ b/tests/Jellyfin.LiveTv.Tests/Listings/SchedulesDirectLineupTests.cs
@@ -0,0 +1,130 @@
+using System;
+using System.IO;
+using System.Net;
+using System.Net.Http;
+using System.Threading;
+using System.Threading.Tasks;
+using MediaBrowser.Common.Configuration;
+using MediaBrowser.Model.LiveTv;
+using Microsoft.Extensions.Logging.Abstractions;
+using Moq;
+using Moq.Protected;
+using Xunit;
+using SchedulesDirectProvider = Jellyfin.LiveTv.Listings.SchedulesDirect;
+
+namespace Jellyfin.LiveTv.Tests.Listings;
+
+public class SchedulesDirectLineupTests
+{
+ private const string InvalidUserResponse = "{\"response\":\"INVALID_USER\",\"code\":4003,\"message\":\"Invalid user.\",\"serverID\":\"AWS-SD-web.1\"}";
+
+ private static readonly ListingsProviderInfo _info = new() { Username = "user", Password = "password" };
+
+ [Fact]
+ public async Task GetLineups_ValidCredentials_ReturnsLineups()
+ {
+ var tokenResponse = await CreateSuccessfulLogin();
+ using var provider = CreateProvider(tokenResponse, await GetHeadendsResponse());
+
+ var lineups = await provider.GetLineups(_info, "USA", "90210");
+
+ Assert.NotEmpty(lineups);
+ Assert.Contains(lineups, i => string.Equals(i.Id, "USA-OTA-90210", StringComparison.Ordinal));
+ Assert.Contains(lineups, i => string.Equals(i.Name, "Antenna", StringComparison.Ordinal));
+ }
+
+ [Fact]
+ public async Task GetLineups_LoginFails_Throws()
+ {
+ using var provider = CreateProvider(CreateFailedLogin(), await GetHeadendsResponse());
+
+ // An empty lineup list is indistinguishable from "no lineups for this location", so a
+ // failed login has to surface as an error instead.
+ await Assert.ThrowsAnyAsync<Exception>(() => provider.GetLineups(_info, "USA", "90210"));
+ }
+
+ [Fact]
+ public async Task Validate_LoginFails_Throws()
+ {
+ using var provider = CreateProvider(CreateFailedLogin(), await GetHeadendsResponse());
+
+ await Assert.ThrowsAnyAsync<Exception>(() => provider.Validate(_info, true, false));
+ }
+
+ [Fact]
+ public async Task Validate_AfterAccountError_RecoversWithoutRestart()
+ {
+ var login = CreateFailedLogin();
+ using var provider = CreateProvider(login, await GetHeadendsResponse());
+
+ await Assert.ThrowsAnyAsync<Exception>(() => provider.GetLineups(_info, "USA", "90210"));
+
+ // The account error disables Schedules Direct; saving the provider again is the user
+ // correcting their credentials, and that has to recover without a server restart.
+ login.Status = HttpStatusCode.OK;
+ login.Body = await GetTokenResponse();
+
+ await provider.Validate(_info, true, false);
+
+ Assert.NotEmpty(await provider.GetLineups(_info, "USA", "90210"));
+ }
+
+ private static async Task<Response> CreateSuccessfulLogin()
+ => new() { Status = HttpStatusCode.OK, Body = await GetTokenResponse() };
+
+ private static Response CreateFailedLogin()
+ => new() { Status = HttpStatusCode.BadRequest, Body = InvalidUserResponse };
+
+ private static Task<string> GetTokenResponse()
+ => File.ReadAllTextAsync("Test Data/SchedulesDirect/token_live_response.json", TestContext.Current.CancellationToken);
+
+ private static Task<string> GetHeadendsResponse()
+ => File.ReadAllTextAsync("Test Data/SchedulesDirect/headends_response.json", TestContext.Current.CancellationToken);
+
+ private static SchedulesDirectProvider CreateProvider(Response login, string headendsResponse)
+ {
+ var messageHandler = new Mock<HttpMessageHandler>();
+ messageHandler.Protected()
+ .Setup<Task<HttpResponseMessage>>("SendAsync", ItExpr.IsAny<HttpRequestMessage>(), ItExpr.IsAny<CancellationToken>())
+ .Returns<HttpRequestMessage, CancellationToken>((m, _) =>
+ {
+ var path = m.RequestUri!.AbsolutePath;
+ if (path.EndsWith("/token", StringComparison.Ordinal))
+ {
+ return Task.FromResult(new HttpResponseMessage(login.Status)
+ {
+ Content = new StringContent(login.Body)
+ });
+ }
+
+ if (path.EndsWith("/headends", StringComparison.Ordinal))
+ {
+ return Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK)
+ {
+ Content = new StringContent(headendsResponse)
+ });
+ }
+
+ return Task.FromResult(new HttpResponseMessage(HttpStatusCode.NotFound));
+ });
+
+ var httpClientFactory = new Mock<IHttpClientFactory>();
+ httpClientFactory.Setup(x => x.CreateClient(It.IsAny<string>()))
+ .Returns(() => new HttpClient(messageHandler.Object));
+
+ var appPaths = new Mock<IApplicationPaths>();
+ appPaths.SetupGet(x => x.CachePath).Returns(Path.GetTempPath());
+
+ return new SchedulesDirectProvider(
+ NullLogger<SchedulesDirectProvider>.Instance,
+ httpClientFactory.Object,
+ appPaths.Object);
+ }
+
+ private sealed class Response
+ {
+ public HttpStatusCode Status { get; set; }
+
+ public string Body { get; set; } = string.Empty;
+ }
+}
diff --git a/tests/Jellyfin.LiveTv.Tests/Listings/XmlTvListingsProviderCacheTests.cs b/tests/Jellyfin.LiveTv.Tests/Listings/XmlTvListingsProviderCacheTests.cs
new file mode 100644
index 0000000000..1d96c5a958
--- /dev/null
+++ b/tests/Jellyfin.LiveTv.Tests/Listings/XmlTvListingsProviderCacheTests.cs
@@ -0,0 +1,137 @@
+using System;
+using System.IO;
+using System.Linq;
+using System.Net;
+using System.Net.Http;
+using System.Threading;
+using System.Threading.Tasks;
+using Jellyfin.LiveTv.Listings;
+using MediaBrowser.Controller;
+using MediaBrowser.Controller.Configuration;
+using MediaBrowser.Controller.LiveTv;
+using MediaBrowser.Model.Configuration;
+using MediaBrowser.Model.LiveTv;
+using Microsoft.Extensions.Logging.Abstractions;
+using Moq;
+using Moq.Protected;
+using Xunit;
+
+namespace Jellyfin.LiveTv.Tests.Listings;
+
+public sealed class XmlTvListingsProviderCacheTests : IDisposable
+{
+ private const string ChannelId = "3297";
+
+ private readonly string _cachePath = Path.Combine(Path.GetTempPath(), "jellyfin-xmltv-tests-" + Guid.NewGuid().ToString("N"));
+
+ private readonly ListingsProviderInfo _info = new()
+ {
+ Id = "cachetests",
+ Path = "https://example.com/notitle.xml"
+ };
+
+ private bool _downloadsFail;
+ private Exception? _downloadException;
+
+ public void Dispose()
+ {
+ if (Directory.Exists(_cachePath))
+ {
+ Directory.Delete(_cachePath, true);
+ }
+ }
+
+ [Fact]
+ public async Task GetProgramsAsync_DownloadFailsAfterASuccess_KeepsUsingTheCachedListings()
+ {
+ var provider = CreateProvider();
+
+ Assert.NotEmpty(await GetPrograms(provider));
+
+ // Age the cached copy out, so the next call goes back to the (now broken) source.
+ var cacheFile = Path.Combine(_cachePath, "xmltv", _info.Id + ".xml");
+ File.SetLastWriteTimeUtc(cacheFile, DateTime.UtcNow.AddDays(-1));
+ _downloadsFail = true;
+
+ // Losing the listings entirely because a single download failed empties the whole guide.
+ Assert.NotEmpty(await GetPrograms(provider));
+ Assert.True(File.Exists(cacheFile));
+ }
+
+ [Fact]
+ public async Task GetProgramsAsync_DownloadTimesOut_DoesNotSurfaceAsCancellation()
+ {
+ var provider = CreateProvider();
+
+ // This is how HttpClient reports its own timeout. Left as an OperationCanceledException it
+ // aborts the guide refresh for every channel and provider instead of only this one.
+ _downloadsFail = true;
+ _downloadException = new TaskCanceledException("timeout", new TimeoutException());
+
+ await Assert.ThrowsAsync<TimeoutException>(() => GetPrograms(provider));
+ }
+
+ [Fact]
+ public async Task GetProgramsAsync_ProviderSavedAfterAFailure_DownloadsAgain()
+ {
+ var provider = CreateProvider();
+
+ _downloadsFail = true;
+ await Assert.ThrowsAnyAsync<Exception>(() => GetPrograms(provider));
+
+ // Without clearing the backoff the guide stays empty for an hour, even though saving the
+ // provider deletes the cached file and is the user asking for another attempt.
+ _downloadsFail = false;
+ await provider.Validate(_info, true, true);
+
+ Assert.NotEmpty(await GetPrograms(provider));
+ }
+
+ private async Task<ProgramInfo[]> GetPrograms(XmlTvListingsProvider provider)
+ {
+ var startDate = new DateTime(2022, 11, 4, 0, 0, 0, DateTimeKind.Utc);
+ var programs = await provider.GetProgramsAsync(_info, ChannelId, startDate, startDate.AddDays(1), CancellationToken.None);
+
+ return programs.ToArray();
+ }
+
+ private XmlTvListingsProvider CreateProvider()
+ {
+ var messageHandler = new Mock<HttpMessageHandler>();
+ messageHandler.Protected()
+ .Setup<Task<HttpResponseMessage>>("SendAsync", ItExpr.IsAny<HttpRequestMessage>(), ItExpr.IsAny<CancellationToken>())
+ .Returns<HttpRequestMessage, CancellationToken>((m, _) =>
+ {
+ if (_downloadException is not null)
+ {
+ return Task.FromException<HttpResponseMessage>(_downloadException);
+ }
+
+ if (_downloadsFail)
+ {
+ return Task.FromResult(new HttpResponseMessage(HttpStatusCode.InternalServerError));
+ }
+
+ return Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK)
+ {
+ Content = new StreamContent(File.OpenRead(Path.Combine("Test Data/LiveTv/Listings/XmlTv", m.RequestUri!.Segments[^1])))
+ });
+ });
+
+ var httpClientFactory = new Mock<IHttpClientFactory>();
+ httpClientFactory.Setup(x => x.CreateClient(It.IsAny<string>()))
+ .Returns(() => new HttpClient(messageHandler.Object));
+
+ var appPaths = new Mock<IServerApplicationPaths>();
+ appPaths.SetupGet(x => x.CachePath).Returns(_cachePath);
+
+ var config = new Mock<IServerConfigurationManager>();
+ config.SetupGet(x => x.ApplicationPaths).Returns(appPaths.Object);
+ config.SetupGet(x => x.Configuration).Returns(new ServerConfiguration());
+
+ return new XmlTvListingsProvider(
+ config.Object,
+ httpClientFactory.Object,
+ NullLogger<XmlTvListingsProvider>.Instance);
+ }
+}
diff --git a/tests/Jellyfin.LiveTv.Tests/Listings/XmlTvListingsProviderTests.cs b/tests/Jellyfin.LiveTv.Tests/Listings/XmlTvListingsProviderTests.cs
index f698edc637..a8ebc8c9b9 100644
--- a/tests/Jellyfin.LiveTv.Tests/Listings/XmlTvListingsProviderTests.cs
+++ b/tests/Jellyfin.LiveTv.Tests/Listings/XmlTvListingsProviderTests.cs
@@ -90,6 +90,49 @@ public class XmlTvListingsProviderTests
AssertXmlTvEtag(program.Etag);
}
+ [Theory]
+ [InlineData("Test Data/LiveTv/Listings/XmlTv/no-optional-elements.xml")]
+ [InlineData("https://example.com/no-optional-elements.xml")]
+ public async Task GetProgramsAsync_NoOptionalElements_Success(string path)
+ {
+ var info = new ListingsProviderInfo()
+ {
+ Id = "no-optional-elements-programs",
+ Path = path
+ };
+
+ var startDate = new DateTime(2022, 11, 4, 0, 0, 0, DateTimeKind.Utc);
+ var programs = await _xmlTvListingsProvider.GetProgramsAsync(info, "3297", startDate, startDate.AddDays(1), CancellationToken.None);
+ var program = Assert.Single(programs.ToList());
+ Assert.Equal("Programme Without Icon Or Rating", program.Name);
+ Assert.False(program.HasImage);
+ Assert.Null(program.ImageUrl);
+ Assert.Null(program.ThumbImageUrl);
+ Assert.Null(program.BackdropImageUrl);
+ Assert.Null(program.OfficialRating);
+ Assert.Null(program.CommunityRating);
+ AssertXmlTvEtag(program.Etag);
+ }
+
+ [Theory]
+ [InlineData("Test Data/LiveTv/Listings/XmlTv/no-optional-elements.xml")]
+ [InlineData("https://example.com/no-optional-elements.xml")]
+ public async Task GetChannels_NoOptionalElements_Success(string path)
+ {
+ var info = new ListingsProviderInfo()
+ {
+ Id = "no-optional-elements-channels",
+ Path = path
+ };
+
+ var channels = await _xmlTvListingsProvider.GetChannels(info, CancellationToken.None);
+ var channel = Assert.Single(channels);
+ Assert.Equal("3297", channel.Id);
+ Assert.Equal("Channel Without Icon", channel.Name);
+ Assert.Equal("3297", channel.Number);
+ Assert.Null(channel.ImageUrl);
+ }
+
[Fact]
public async Task GetProgramsAsync_Etag_SameContentIsStable()
{
diff --git a/tests/Jellyfin.LiveTv.Tests/Test Data/LiveTv/Listings/XmlTv/no-optional-elements.xml b/tests/Jellyfin.LiveTv.Tests/Test Data/LiveTv/Listings/XmlTv/no-optional-elements.xml
new file mode 100644
index 0000000000..e82d00c259
--- /dev/null
+++ b/tests/Jellyfin.LiveTv.Tests/Test Data/LiveTv/Listings/XmlTv/no-optional-elements.xml
@@ -0,0 +1,10 @@
+<tv date="20221104">
+ <channel id="3297">
+ <display-name>Channel Without Icon</display-name>
+ </channel>
+ <programme channel="3297" start="20221104130000 +0000" stop="20221105235959 +0000">
+ <title lang="en">Programme Without Icon Or Rating</title>
+ <desc lang="en">A programme that only uses the required XMLTV elements.</desc>
+ <category lang="en">sports</category>
+ </programme>
+</tv>