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);
}
}