using System; using System.Collections.Generic; using System.Linq; using System.Text.Json; using System.Threading; using System.Threading.Tasks; using Jellyfin.Plugin.Floatplane.Api; using Jellyfin.Plugin.Floatplane.Api.Models; using Jellyfin.Plugin.Floatplane.Channel; using Jellyfin.Plugin.Floatplane.Configuration; using Microsoft.Extensions.Logging.Abstractions; using Xunit; namespace Jellyfin.Plugin.Floatplane.Tests; public class CatalogServiceTests { /// Builds distinct posts from the 5 fixture posts. private static List MakePosts(int count, int startIndex = 0, Func? locked = null) { var template = Fixture.Json>("content-creator.json"); var result = new List(); for (var i = 0; i < count; i++) { var n = startIndex + i; var p = Fixture.Json>("content-creator.json")[n % template.Count]; p.Id = $"post{n:D3}"; p.Guid = p.Id; p.IsAccessible = locked == null || !locked(n); result.Add(p); } return result; } private static string Json(List posts) => JsonSerializer.Serialize(posts, FloatplaneJson.Options); private static (CatalogService Catalog, FakeHttpHandler Handler) Build(PluginConfiguration config, Func> pageForOffset) { var handler = new FakeHttpHandler().On("/api/v3/content/creator", r => { var query = r.RequestUri!.Query.TrimStart('?').Split('&').Select(p => p.Split('=', 2)).ToDictionary(p => p[0], p => Uri.UnescapeDataString(p[1])); var offset = int.Parse(query["fetchAfter"], System.Globalization.CultureInfo.InvariantCulture); return FakeHttpHandler.Json(Json(pageForOffset(offset))); }); var api = new FloatplaneApiClient(new FakeHttpClientFactory(handler), NullLogger.Instance, () => config); var catalog = new CatalogService(api, NullLogger.Instance, () => config); return (catalog, handler); } [Fact] public async Task FetchPosts_pages_until_short_page() { var (catalog, handler) = Build( new PluginConfiguration(), offset => offset switch { 0 => MakePosts(20, 0), 20 => MakePosts(5, 20), _ => new List() }); var posts = await catalog.FetchPostsAsync("cre1", string.Empty, 100, CancellationToken.None); Assert.Equal(25, posts.Count); Assert.Equal(25, posts.Select(p => p.Id).Distinct().Count()); Assert.Equal(2, handler.Requests.Count); Assert.Contains("fetchAfter=0", handler.Requests[0].RequestUri!.Query); Assert.Contains("fetchAfter=20", handler.Requests[1].RequestUri!.Query); Assert.All(handler.Requests, r => Assert.Contains("hasVideo=true", r.RequestUri!.Query)); Assert.All(handler.Requests, r => Assert.DoesNotContain("channel=", r.RequestUri!.Query)); } [Fact] public async Task FetchPosts_stops_on_empty_page() { var (catalog, handler) = Build( new PluginConfiguration(), offset => offset == 0 ? MakePosts(20, 0) : new List()); var posts = await catalog.FetchPostsAsync("cre1", "chan1", 100, CancellationToken.None); Assert.Equal(20, posts.Count); Assert.Equal(2, handler.Requests.Count); Assert.All(handler.Requests, r => Assert.Contains("channel=chan1", r.RequestUri!.Query)); } [Fact] public async Task FetchPosts_respects_max() { var (catalog, handler) = Build( new PluginConfiguration(), offset => MakePosts(20, offset)); var posts = await catalog.FetchPostsAsync("cre1", string.Empty, 30, CancellationToken.None); Assert.Equal(30, posts.Count); Assert.Equal(2, handler.Requests.Count); } [Fact] public async Task FetchPosts_filters_locked_posts_unless_configured() { // Without a session Floatplane marks everything inaccessible, so the filter only applies when logged in. var config = new PluginConfiguration { ShowLockedPosts = false, SessionCookie = "sid" }; var (catalog, _) = Build(config, offset => offset == 0 ? MakePosts(10, 0, n => n % 2 == 1) : new List()); var visible = await catalog.FetchPostsAsync("cre1", string.Empty, 100, CancellationToken.None); Assert.Equal(5, visible.Count); Assert.All(visible, p => Assert.True(p.IsAccessible)); config.ShowLockedPosts = true; var all = await catalog.FetchPostsAsync("cre1", string.Empty, 100, CancellationToken.None); Assert.Equal(10, all.Count); Assert.Equal(5, all.Count(p => !p.IsAccessible)); // No session: locked posts are always listed (browsing is public; Floatplane reports everything locked). config.ShowLockedPosts = false; config.SessionCookie = string.Empty; var anonymous = await catalog.FetchPostsAsync("cre1", string.Empty, 100, CancellationToken.None); Assert.Equal(10, anonymous.Count); } [Fact] public async Task FetchPosts_passes_search_and_skips_posts_without_video() { var pages = MakePosts(3, 0); pages[1].Metadata!.HasVideo = false; var (catalog, handler) = Build(new PluginConfiguration(), offset => offset == 0 ? pages : new List()); var posts = await catalog.FetchPostsAsync("cre1", string.Empty, 100, CancellationToken.None, "framework"); Assert.Equal(2, posts.Count); Assert.Contains("search=framework", handler.Requests[0].RequestUri!.Query); } [Fact] public async Task GetPosts_caches_and_Invalidate_bumps_generation() { var (catalog, handler) = Build(new PluginConfiguration { CatalogCacheMinutes = 30, MaxPostsPerChannel = 20 }, offset => offset == 0 ? MakePosts(5, 0) : new List()); var first = await catalog.GetPostsAsync("cre1", "chan1", CancellationToken.None); var second = await catalog.GetPostsAsync("cre1", "chan1", CancellationToken.None); Assert.Same(first, second); Assert.Single(handler.Requests); Assert.Equal(1, catalog.CachedPostListCount); Assert.NotNull(catalog.FindCachedPost("post002")); Assert.Null(catalog.FindCachedPost("nope")); var generation = catalog.Generation; catalog.Invalidate(); Assert.Equal(generation + 1, catalog.Generation); Assert.Equal(0, catalog.CachedPostListCount); await catalog.GetPostsAsync("cre1", "chan1", CancellationToken.None); Assert.Equal(2, handler.Requests.Count); } [Fact] public async Task GetCreators_without_session_resolves_extra_creators_publicly() { var config = new PluginConfiguration { ExtraCreators = "linustechtips, , bitwit" }; var handler = new FakeHttpHandler() .On("/api/v3/creator/named", _ => FakeHttpHandler.Json("[" + Fixture.Text("creator-info.json") + "]")); var api = new FloatplaneApiClient(new FakeHttpClientFactory(handler), NullLogger.Instance, () => config); var catalog = new CatalogService(api, NullLogger.Instance, () => config); var creators = await catalog.GetCreatorsAsync(false, CancellationToken.None); var creator = Assert.Single(creators); Assert.Equal("LinusTechTips", creator.Title); Assert.Equal(11, creator.Channels.Count); Assert.Equal("none", catalog.SessionState); Assert.Contains("creatorURL%5B0%5D=linustechtips&creatorURL%5B1%5D=bitwit", Assert.Single(handler.Requests).RequestUri!.Query); Assert.Same(creator, catalog.FindCreator(creator.Id)); } [Fact] public async Task GetCreators_with_session_uses_subscriptions_and_marks_invalid_cookie() { var config = new PluginConfiguration { SessionCookie = "sid" }; var handler = new FakeHttpHandler() .On("/api/v3/user/subscriptions", _ => FakeHttpHandler.Json(Fixture.Text("user-subscriptions.json"))) .On("/api/v3/creator/info", _ => FakeHttpHandler.Json(Fixture.Text("creator-info.json"))); var api = new FloatplaneApiClient(new FakeHttpClientFactory(handler), NullLogger.Instance, () => config); var catalog = new CatalogService(api, NullLogger.Instance, () => config); var creators = await catalog.GetCreatorsAsync(false, CancellationToken.None); Assert.Single(creators); Assert.Equal("ok", catalog.SessionState); Assert.Contains("id=59f94c0bdd241b70349eb72b", handler.Requests[1].RequestUri!.Query); var rejected = new FakeHttpHandler() .On("/api/v3/user/subscriptions", _ => FakeHttpHandler.Json(Fixture.Text("error-not-logged-in.json"), System.Net.HttpStatusCode.Unauthorized)); var api2 = new FloatplaneApiClient(new FakeHttpClientFactory(rejected), NullLogger.Instance, () => config); var catalog2 = new CatalogService(api2, NullLogger.Instance, () => config); var none = await catalog2.GetCreatorsAsync(true, CancellationToken.None); Assert.Empty(none); Assert.Equal("invalid", catalog2.SessionState); Assert.NotNull(catalog2.LastError); } }