using System; using System.Linq; using System.Net; using System.Net.Http; using System.Net.Http.Headers; using System.Threading; using System.Threading.Tasks; using Jellyfin.Plugin.Floatplane.Api; using Jellyfin.Plugin.Floatplane.Configuration; using Microsoft.Extensions.Logging.Abstractions; using Xunit; namespace Jellyfin.Plugin.Floatplane.Tests; public class ApiClientTests { private static (FloatplaneApiClient Client, FakeHttpHandler Handler) Build(PluginConfiguration config, Action? routes = null) { var handler = new FakeHttpHandler(); routes?.Invoke(handler); var client = new FloatplaneApiClient(new FakeHttpClientFactory(handler), NullLogger.Instance, () => config); return (client, handler); } [Fact] public async Task ListPosts_builds_query_and_sends_cookie_and_user_agent() { var (client, handler) = Build( new PluginConfiguration { SessionCookie = "s%3Aabc.def" }, h => h.On("/api/v3/content/creator", _ => FakeHttpHandler.Json(Fixture.Text("content-creator.json")))); var posts = await client.ListPostsAsync( new PostQuery { CreatorId = "cre1", ChannelId = "ch1", Limit = 20, FetchAfter = 40, HasVideo = true, Search = "frame work" }, CancellationToken.None); Assert.Equal(5, posts.Count); var request = Assert.Single(handler.Requests); Assert.Equal("www.floatplane.com", request.RequestUri!.Host); Assert.Equal("/api/v3/content/creator", request.RequestUri.AbsolutePath); var query = request.RequestUri.Query.TrimStart('?').Split('&'); Assert.Contains("id=cre1", query); Assert.Contains("limit=20", query); Assert.Contains("fetchAfter=40", query); Assert.Contains("sort=DESC", query); Assert.Contains("hasVideo=true", query); Assert.Contains("search=frame%20work", query); Assert.Contains("channel=ch1", query); Assert.True(request.Headers.TryGetValues("Cookie", out var cookies)); Assert.Equal("sails.sid=s%3Aabc.def", Assert.Single(cookies)); Assert.Contains("Jellyfin-Floatplane/", request.Headers.UserAgent.ToString()); Assert.True(client.HasSession); } [Fact] public async Task ListPosts_omits_cookie_and_optional_params_when_unset() { var (client, handler) = Build( new PluginConfiguration(), h => h.On("/api/v3/content/creator", _ => FakeHttpHandler.Json("[]"))); var posts = await client.ListPostsAsync(new PostQuery { CreatorId = "cre1", Limit = 50 }, CancellationToken.None); Assert.Empty(posts); var request = Assert.Single(handler.Requests); Assert.False(request.Headers.Contains("Cookie")); var query = request.RequestUri!.Query; Assert.Contains("limit=20", query); // clamped to Floatplane's max Assert.DoesNotContain("hasVideo", query); Assert.DoesNotContain("search", query); Assert.DoesNotContain("channel", query); Assert.False(client.HasSession); } [Fact] public async Task GetSelf_401_with_envelope_throws_auth_exception() { var (client, _) = Build( new PluginConfiguration { SessionCookie = "expired" }, h => h.On("/api/v3/user/self", _ => FakeHttpHandler.Json(Fixture.Text("error-not-logged-in.json"), HttpStatusCode.Unauthorized))); var ex = await Assert.ThrowsAsync(() => client.GetSelfAsync(CancellationToken.None)); Assert.Equal(HttpStatusCode.Unauthorized, ex.Status); Assert.Equal("notLoggedInError", ex.ErrorName); Assert.Equal("You must be logged-in to access this resource.", ex.Message); } [Fact] public async Task NotLoggedIn_envelope_with_200_family_status_is_still_auth_error() { // Floatplane sometimes answers 403 for notSubscribedError; make sure the name alone is enough. var (client, _) = Build( new PluginConfiguration { SessionCookie = "x" }, h => h.On("/api/v3/delivery/info", _ => FakeHttpHandler.Json("""{"errors":[{"name":"notSubscribedError","message":"nope"}],"message":"nope"}""", HttpStatusCode.BadRequest))); var ex = await Assert.ThrowsAsync(() => client.GetDeliveryInfoAsync("onDemand", "v1", CancellationToken.None)); Assert.Equal("notSubscribedError", ex.ErrorName); } [Fact] public async Task Cookie_override_is_used_instead_of_configuration() { var (client, handler) = Build( new PluginConfiguration { SessionCookie = "configured" }, h => h.On("/api/v3/user/self", _ => FakeHttpHandler.Json(Fixture.Text("user-self.json")))); var self = await client.GetSelfAsync(CancellationToken.None, "sails.sid=override"); Assert.False(string.IsNullOrEmpty(self.Username)); var cookie = Assert.Single(handler.Requests).Headers.GetValues("Cookie").Single(); Assert.Equal("sails.sid=sails.sid=override", cookie); // raw passthrough: callers strip the prefix (admin controller does) } [Fact] public async Task Rate_limit_throws_with_retry_after() { var (client, _) = Build( new PluginConfiguration { SessionCookie = "x" }, h => h.On("/api/v3/delivery/info", _ => { var r = new HttpResponseMessage(HttpStatusCode.TooManyRequests) { Content = new StringContent("error code: 1015") }; r.Headers.RetryAfter = new RetryConditionHeaderValue(TimeSpan.FromSeconds(42)); return r; })); var ex = await Assert.ThrowsAsync(() => client.GetDeliveryInfoAsync("onDemand", "v1", CancellationToken.None)); Assert.Equal(TimeSpan.FromSeconds(42), ex.RetryAfter); Assert.Equal(HttpStatusCode.TooManyRequests, ex.Status); } [Fact] public async Task Other_errors_throw_generic_api_exception() { var (client, _) = Build( new PluginConfiguration(), h => h.On("/api/v3/content/creator", _ => FakeHttpHandler.Json("""{"errors":[{"name":"paramValidationError","message":"\"limit\" must be less than or equal to 20"}],"message":"\"limit\" must be less than or equal to 20"}""", HttpStatusCode.BadRequest))); var ex = await Assert.ThrowsAsync(() => client.ListPostsAsync(new PostQuery { CreatorId = "c" }, CancellationToken.None)); Assert.IsNotType(ex); Assert.Equal("paramValidationError", ex.ErrorName); } [Fact] public async Task GetCreatorsByUrlName_and_channels_use_indexed_array_params() { var (client, handler) = Build( new PluginConfiguration(), h => h.On("/api/v3/creator/named", _ => FakeHttpHandler.Json("[" + Fixture.Text("creator-info.json") + "]")) .On("/api/v3/creator/channels/list", _ => FakeHttpHandler.Json("[]"))); var creators = await client.GetCreatorsByUrlNameAsync(new[] { "linustechtips", "bitwit" }, CancellationToken.None); await client.GetCreatorChannelsAsync(new[] { "a", "b" }, CancellationToken.None); Assert.Single(creators); Assert.Contains("creatorURL%5B0%5D=linustechtips&creatorURL%5B1%5D=bitwit", handler.Requests[0].RequestUri!.Query); Assert.Contains("ids%5B0%5D=a&ids%5B1%5D=b", handler.Requests[1].RequestUri!.Query); } [Fact] public async Task GetWatchKey_sends_cookie_and_referer_and_returns_bytes() { var key = new byte[] { 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16 }; var (client, handler) = Build( new PluginConfiguration { SessionCookie = "sid" }, h => h.On("/api/video/watchKey", _ => new HttpResponseMessage(HttpStatusCode.OK) { Content = new ByteArrayContent(key) })); var result = await client.GetWatchKeyAsync("https://www.floatplane.com/api/video/watchKey?token=T", CancellationToken.None); Assert.Equal(key, result); var request = Assert.Single(handler.Requests); Assert.Equal("sails.sid=sid", request.Headers.GetValues("Cookie").Single()); Assert.Equal("https://www.floatplane.com/", request.Headers.Referrer!.ToString()); } }