All checks were successful
release / build-release (push) Successful in 1m19s
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
207 lines
9.3 KiB
C#
207 lines
9.3 KiB
C#
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
|
|
{
|
|
/// <summary>Builds <paramref name="count"/> distinct posts from the 5 fixture posts.</summary>
|
|
private static List<PostModel> MakePosts(int count, int startIndex = 0, Func<int, bool>? locked = null)
|
|
{
|
|
var template = Fixture.Json<List<PostModel>>("content-creator.json");
|
|
var result = new List<PostModel>();
|
|
for (var i = 0; i < count; i++)
|
|
{
|
|
var n = startIndex + i;
|
|
var p = Fixture.Json<List<PostModel>>("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<PostModel> posts) => JsonSerializer.Serialize(posts, FloatplaneJson.Options);
|
|
|
|
private static (CatalogService Catalog, FakeHttpHandler Handler) Build(PluginConfiguration config, Func<int, List<PostModel>> 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<FloatplaneApiClient>.Instance, () => config);
|
|
var catalog = new CatalogService(api, NullLogger<CatalogService>.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<PostModel>()
|
|
});
|
|
|
|
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<PostModel>());
|
|
|
|
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<PostModel>());
|
|
|
|
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<PostModel>());
|
|
|
|
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<PostModel>());
|
|
|
|
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<FloatplaneApiClient>.Instance, () => config);
|
|
var catalog = new CatalogService(api, NullLogger<CatalogService>.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<FloatplaneApiClient>.Instance, () => config);
|
|
var catalog = new CatalogService(api, NullLogger<CatalogService>.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<FloatplaneApiClient>.Instance, () => config);
|
|
var catalog2 = new CatalogService(api2, NullLogger<CatalogService>.Instance, () => config);
|
|
|
|
var none = await catalog2.GetCreatorsAsync(true, CancellationToken.None);
|
|
|
|
Assert.Empty(none);
|
|
Assert.Equal("invalid", catalog2.SessionState);
|
|
Assert.NotNull(catalog2.LastError);
|
|
}
|
|
}
|