Files
claude 2a883c02de
All checks were successful
release / build-release (push) Successful in 1m19s
Tests: xUnit project with HAR fixtures (50 tests); fix relative delivery/HLS URLs resolving to file:// on Linux
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-25 09:42:50 +01:00

179 lines
8.2 KiB
C#

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<FakeHttpHandler>? routes = null)
{
var handler = new FakeHttpHandler();
routes?.Invoke(handler);
var client = new FloatplaneApiClient(new FakeHttpClientFactory(handler), NullLogger<FloatplaneApiClient>.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<FloatplaneAuthException>(() => 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<FloatplaneAuthException>(() => 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<FloatplaneRateLimitException>(() => 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<FloatplaneApiException>(() => client.ListPostsAsync(new PostQuery { CreatorId = "c" }, CancellationToken.None));
Assert.IsNotType<FloatplaneAuthException>(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());
}
}