using System.Reflection; using System.Security.Cryptography; using System.Text; using Newtonsoft.Json; using Newtonsoft.Json.Linq; using OF_DL.Enumerations; using OF_DL.Models; using OF_DL.Models.Config; using OF_DL.Models.Entities.Common; using OF_DL.Models.OfdlApi; using OF_DL.Services; using UserEntities = OF_DL.Models.Entities.Users; namespace OF_DL.Tests.Services; public class ApiServiceTests { [Fact] public void GetDynamicHeaders_ReturnsSignedHeaders() { FakeAuthService authService = new() { CurrentAuth = new Auth { UserId = "123", UserAgent = "unit-test-agent", XBc = "xbc-token", Cookie = "auth_cookie=abc;" } }; ApiService service = CreateService(authService); DynamicRules rules = new() { AppToken = "app-token", StaticParam = "static", Prefix = "prefix", Suffix = "suffix", ChecksumConstant = 7, ChecksumIndexes = [0, 5, 10, 15] }; using DynamicRulesCacheScope _ = new(rules); Dictionary headers = service.GetDynamicHeaders("/api2/v2/users", "?limit=1"); Assert.Equal("application/json, text/plain", headers["accept"]); Assert.Equal("app-token", headers["app-token"]); Assert.Equal("auth_cookie=abc;", headers["cookie"]); Assert.Equal("unit-test-agent", headers["user-agent"]); Assert.Equal("xbc-token", headers["x-bc"]); Assert.Equal("123", headers["user-id"]); Assert.True(long.TryParse(headers["time"], out long timestamp)); string expectedSign = BuildSign(rules, timestamp, "/api2/v2/users?limit=1", "123"); Assert.Equal(expectedSign, headers["sign"]); } [Fact] public void GetDynamicHeaders_ThrowsWhenRulesInvalid() { FakeAuthService authService = new() { CurrentAuth = new Auth { UserId = "123", UserAgent = "unit-test-agent", XBc = "xbc-token", Cookie = "auth_cookie=abc;" } }; ApiService service = CreateService(authService); DynamicRules rules = new() { AppToken = null, StaticParam = "static", Prefix = null, Suffix = "suffix", ChecksumConstant = null, ChecksumIndexes = [] }; using DynamicRulesCacheScope _ = new(rules); Exception ex = Assert.Throws(() => service.GetDynamicHeaders("/api2/v2/users", "?limit=1")); Assert.Contains("Invalid dynamic rules", ex.Message); } [Fact] public void GetDynamicHeaders_ThrowsWhenAuthMissingFields() { FakeAuthService authService = new() { CurrentAuth = new Auth { UserId = "123", UserAgent = "unit-test-agent", XBc = "xbc-token", Cookie = null } }; ApiService service = CreateService(authService); DynamicRules rules = new() { AppToken = "app-token", StaticParam = "static", Prefix = "prefix", Suffix = "suffix", ChecksumConstant = 1, ChecksumIndexes = [0] }; using DynamicRulesCacheScope _ = new(rules); Exception ex = Assert.Throws(() => service.GetDynamicHeaders("/api2/v2/users", "?limit=1")); Assert.Contains("Auth service is missing required fields", ex.Message); } [Fact] public async Task BuildHttpRequestMessage_BuildsUrlAndAddsHeaders() { FakeAuthService authService = new() { CurrentAuth = new Auth { UserId = "123", UserAgent = "unit-test-agent", XBc = "xbc-token", Cookie = "auth_cookie=abc;" } }; ApiService service = CreateService(authService); DynamicRules rules = new() { AppToken = "app-token", StaticParam = "static", Prefix = "prefix", Suffix = "suffix", ChecksumConstant = 7, ChecksumIndexes = [0] }; using DynamicRulesCacheScope _ = new(rules); Dictionary getParams = new() { { "limit", "10" }, { "offset", "5" } }; HttpRequestMessage request = await InvokeBuildHttpRequestMessage(service, getParams, "/users"); Assert.Equal("https://onlyfans.com/api2/v2/users?limit=10&offset=5", request.RequestUri?.ToString()); Assert.True(request.Headers.Contains("app-token")); Assert.True(request.Headers.Contains("sign")); Assert.True(request.Headers.Contains("user-id")); } [Fact] public void DeserializeJson_ReturnsDefaultForWhitespace() { object? result = InvokeDeserializeJson(typeof(Dictionary), " ", null); Assert.Null(result); } [Fact] public void DeserializeJson_ParsesValidJson() { object? result = InvokeDeserializeJson(typeof(Dictionary), "{\"a\":1}", null); Assert.NotNull(result); Dictionary dict = Assert.IsType>(result); Assert.Equal(1, dict["a"]); } [Fact] public void UpdateGetParamsForDateSelection_BeforeAddsBeforePublishTime() { Dictionary getParams = new(); InvokeUpdateGetParamsForDateSelection(DownloadDateSelection.before, getParams, "123.000000"); Assert.Equal("123.000000", getParams["beforePublishTime"]); } [Fact] public void UpdateGetParamsForDateSelection_AfterAddsAfterPublishTimeAndOrder() { Dictionary getParams = new(); InvokeUpdateGetParamsForDateSelection(DownloadDateSelection.after, getParams, "456.000000"); Assert.Equal("publish_date_asc", getParams["order"]); Assert.Equal("456.000000", getParams["afterPublishTime"]); } [Theory] [InlineData("photo", "Images")] [InlineData("video", "Videos")] [InlineData("gif", "Videos")] [InlineData("audio", "Audios")] [InlineData("unknown", null)] public void ResolveMediaType_ReturnsExpectedValue(string input, string? expected) { string? result = InvokeResolveMediaType(input); Assert.Equal(expected, result); } [Fact] public void IsMediaTypeDownloadEnabled_RespectsConfigFlags() { Config config = new() { DownloadImages = false, DownloadVideos = false, DownloadAudios = false }; ApiService service = new(new FakeAuthService(), new FakeConfigService(config), new MediaTrackingDbService()); Assert.False(InvokeIsMediaTypeDownloadEnabled(service, "photo")); Assert.False(InvokeIsMediaTypeDownloadEnabled(service, "video")); Assert.False(InvokeIsMediaTypeDownloadEnabled(service, "audio")); Assert.True(InvokeIsMediaTypeDownloadEnabled(service, "other")); } [Fact] public void TryGetDrmInfo_ReturnsTrueWhenComplete() { Files files = new() { Drm = new Drm { Manifest = new Manifest { Dash = "dash" }, Signature = new Signature { Dash = new Dash { CloudFrontPolicy = "policy", CloudFrontSignature = "signature", CloudFrontKeyPairId = "kvp" } } } }; bool result = InvokeTryGetDrmInfo(files, out string manifestDash, out string policy, out string signature, out string kvp); Assert.True(result); Assert.Equal("dash", manifestDash); Assert.Equal("policy", policy); Assert.Equal("signature", signature); Assert.Equal("kvp", kvp); } [Fact] public void TryGetDrmInfo_ReturnsFalseWhenMissingFields() { Files files = new() { Drm = new Drm { Manifest = new Manifest { Dash = null }, Signature = new Signature { Dash = new Dash { CloudFrontPolicy = "policy" } } } }; bool result = InvokeTryGetDrmInfo(files, out _, out _, out _, out _); Assert.False(result); } [Fact] public void GetCurrentUserIdOrDefault_ReturnsMinValueWhenMissingOrInvalid() { ApiService serviceMissing = new(new FakeAuthService(), new FakeConfigService(new Config()), new MediaTrackingDbService()); Assert.Equal(int.MinValue, InvokeGetCurrentUserIdOrDefault(serviceMissing)); FakeAuthService authService = new() { CurrentAuth = new Auth { UserId = "not-a-number" } }; ApiService serviceInvalid = new(authService, new FakeConfigService(new Config()), new MediaTrackingDbService()); Assert.Equal(int.MinValue, InvokeGetCurrentUserIdOrDefault(serviceInvalid)); } [Fact] public void GetCurrentUserIdOrDefault_ReturnsParsedUserId() { FakeAuthService authService = new() { CurrentAuth = new Auth { UserId = "42" } }; ApiService service = new(authService, new FakeConfigService(new Config()), new MediaTrackingDbService()); Assert.Equal(42, InvokeGetCurrentUserIdOrDefault(service)); } [Fact] public void ConvertToUnixTimestampWithMicrosecondPrecision_ReturnsExpectedSeconds() { double epoch = InvokeConvertToUnixTimestampWithMicrosecondPrecision( new DateTime(1970, 1, 1, 0, 0, 0, DateTimeKind.Utc)); double oneSecond = InvokeConvertToUnixTimestampWithMicrosecondPrecision( new DateTime(1970, 1, 1, 0, 0, 1, DateTimeKind.Utc)); Assert.Equal(0, epoch, 6); Assert.Equal(1, oneSecond, 6); } [Fact] public async Task GetDrmMpdPssh_ReturnsSecondPssh() { string mpd = """ FIRST SECOND """; using SimpleHttpServer server = new(mpd); FakeAuthService authService = new() { CurrentAuth = new Auth { UserId = "123", UserAgent = "unit-test-agent", XBc = "xbc-token", Cookie = "auth_id=1; sess=2;" } }; ApiService service = CreateService(authService); string pssh = await service.GetDrmMpdPssh(server.Url.ToString(), "policy", "signature", "kvp"); await server.Completion; Assert.Equal("SECOND", pssh); } [Fact] public async Task GetDrmMpdLastModified_ReturnsLastModifiedHeader() { DateTime lastModifiedUtc = new(2024, 2, 3, 4, 5, 6, DateTimeKind.Utc); using SimpleHttpServer server = new("", lastModifiedUtc); FakeAuthService authService = new() { CurrentAuth = new Auth { UserId = "123", UserAgent = "unit-test-agent", XBc = "xbc-token", Cookie = "auth_id=1; sess=2;" } }; ApiService service = CreateService(authService); DateTime result = await service.GetDrmMpdLastModified(server.Url.ToString(), "policy", "signature", "kvp"); await server.Completion; DateTime expectedLocal = lastModifiedUtc.ToLocalTime(); Assert.True((result - expectedLocal).Duration() < TimeSpan.FromSeconds(1)); } [Fact] public async Task GetUserInfo_ReturnsNullWhenAuthMissing() { ApiService service = CreateService(new FakeAuthService()); using DynamicRulesCacheScope _ = new(BuildTestRules()); UserEntities.User? user = await service.GetUserInfo("/users/me"); Assert.Null(user); } [Fact] public async Task GetUserInfoById_ReturnsNullWhenAuthMissing() { ApiService service = CreateService(new FakeAuthService()); using DynamicRulesCacheScope _ = new(BuildTestRules()); JObject? user = await service.GetUserInfoById("/users/list?x[]=1"); Assert.Null(user); } [Fact] public async Task GetActiveSubscriptions_ReturnsNullWhenAuthMissing() { ApiService service = CreateService(new FakeAuthService()); using DynamicRulesCacheScope _ = new(BuildTestRules()); Dictionary? result = await service.GetActiveSubscriptions("/subscriptions", false); Assert.Null(result); } [Fact] public async Task GetExpiredSubscriptions_ReturnsNullWhenAuthMissing() { ApiService service = CreateService(new FakeAuthService()); using DynamicRulesCacheScope _ = new(BuildTestRules()); Dictionary? result = await service.GetExpiredSubscriptions("/subscriptions", false); Assert.Null(result); } [Fact] public async Task GetLists_ReturnsNullWhenAuthMissing() { ApiService service = CreateService(new FakeAuthService()); using DynamicRulesCacheScope _ = new(BuildTestRules()); Dictionary? result = await service.GetLists("/lists"); Assert.Null(result); } [Fact] public async Task GetListUsers_ReturnsNullWhenAuthMissing() { ApiService service = CreateService(new FakeAuthService()); using DynamicRulesCacheScope _ = new(BuildTestRules()); List? result = await service.GetListUsers("/lists/1/users"); Assert.Null(result); } [Fact] public async Task GetMedia_ReturnsNullWhenAuthMissing() { ApiService service = CreateService(new FakeAuthService()); using DynamicRulesCacheScope _ = new(BuildTestRules()); Dictionary? result = await service.GetMedia(MediaType.Stories, "/users/1/stories", null, "/tmp"); Assert.Null(result); } private static ApiService CreateService(FakeAuthService authService) => new(authService, new FakeConfigService(new Config()), new MediaTrackingDbService()); private static DynamicRules BuildTestRules() => new() { AppToken = "app-token", StaticParam = "static", Prefix = "prefix", Suffix = "suffix", ChecksumConstant = 7, ChecksumIndexes = [0] }; private static async Task InvokeBuildHttpRequestMessage(ApiService service, Dictionary getParams, string endpoint) { MethodInfo method = typeof(ApiService).GetMethod("BuildHttpRequestMessage", BindingFlags.NonPublic | BindingFlags.Instance) ?? throw new InvalidOperationException("BuildHttpRequestMessage not found."); Task task = (Task)method.Invoke(service, [getParams, endpoint])!; return await task; } private static object? InvokeDeserializeJson(Type type, string? body, JsonSerializerSettings? settings) { MethodInfo method = typeof(ApiService).GetMethod("DeserializeJson", BindingFlags.NonPublic | BindingFlags.Static) ?? throw new InvalidOperationException("DeserializeJson not found."); MethodInfo generic = method.MakeGenericMethod(type); return generic.Invoke(null, [body, settings]); } private static void InvokeUpdateGetParamsForDateSelection(DownloadDateSelection selection, Dictionary getParams, string? timestamp) { MethodInfo method = typeof(ApiService).GetMethod("UpdateGetParamsForDateSelection", BindingFlags.NonPublic | BindingFlags.Static, null, [ typeof(DownloadDateSelection), typeof(Dictionary).MakeByRefType(), typeof(string) ], null) ?? throw new InvalidOperationException("UpdateGetParamsForDateSelection not found."); object?[] args = { selection, getParams, timestamp }; method.Invoke(null, args); } private static string? InvokeResolveMediaType(string? type) { MethodInfo method = typeof(ApiService).GetMethod("ResolveMediaType", BindingFlags.NonPublic | BindingFlags.Static) ?? throw new InvalidOperationException("ResolveMediaType not found."); return (string?)method.Invoke(null, new object?[] { type }); } private static bool InvokeIsMediaTypeDownloadEnabled(ApiService service, string? type) { MethodInfo method = typeof(ApiService).GetMethod("IsMediaTypeDownloadEnabled", BindingFlags.NonPublic | BindingFlags.Instance) ?? throw new InvalidOperationException("IsMediaTypeDownloadEnabled not found."); return (bool)method.Invoke(service, [type])!; } private static bool InvokeTryGetDrmInfo(Files files, out string manifestDash, out string cloudFrontPolicy, out string cloudFrontSignature, out string cloudFrontKeyPairId) { MethodInfo method = typeof(ApiService).GetMethod("TryGetDrmInfo", BindingFlags.NonPublic | BindingFlags.Static) ?? throw new InvalidOperationException("TryGetDrmInfo not found."); object?[] args = { files, null, null, null, null }; bool result = (bool)method.Invoke(null, args)!; manifestDash = (string)args[1]!; cloudFrontPolicy = (string)args[2]!; cloudFrontSignature = (string)args[3]!; cloudFrontKeyPairId = (string)args[4]!; return result; } private static int InvokeGetCurrentUserIdOrDefault(ApiService service) { MethodInfo method = typeof(ApiService).GetMethod("GetCurrentUserIdOrDefault", BindingFlags.NonPublic | BindingFlags.Instance) ?? throw new InvalidOperationException("GetCurrentUserIdOrDefault not found."); return (int)method.Invoke(service, null)!; } private static double InvokeConvertToUnixTimestampWithMicrosecondPrecision(DateTime dateTime) { MethodInfo method = typeof(ApiService).GetMethod("ConvertToUnixTimestampWithMicrosecondPrecision", BindingFlags.NonPublic | BindingFlags.Static) ?? throw new InvalidOperationException( "ConvertToUnixTimestampWithMicrosecondPrecision not found."); return (double)method.Invoke(null, [dateTime])!; } private static string BuildSign(DynamicRules rules, long timestamp, string pathWithQuery, string userId) { string input = $"{rules.StaticParam}\n{timestamp}\n{pathWithQuery}\n{userId}"; byte[] hashBytes = SHA1.HashData(Encoding.UTF8.GetBytes(input)); string hashString = BitConverter.ToString(hashBytes).Replace("-", "").ToLowerInvariant(); Assert.NotNull(rules.ChecksumConstant); int checksum = rules.ChecksumIndexes.Aggregate(0, (current, index) => current + hashString[index]) + rules.ChecksumConstant.Value; string checksumHex = checksum.ToString("X").ToLowerInvariant(); return $"{rules.Prefix}:{hashString}:{checksumHex}:{rules.Suffix}"; } }