From 94e135f16827f4354c2e484b256ccf11e66edacd Mon Sep 17 00:00:00 2001 From: whimsical-c4lic0 Date: Tue, 10 Feb 2026 16:14:31 -0600 Subject: [PATCH] Add additional AI generated unit tests --- OF DL.Tests/Services/ApiServiceTests.cs | 434 ++++++++++++++++-- OF DL.Tests/Services/AuthServiceTests.cs | 89 ++++ OF DL.Tests/Services/ConfigServiceTests.cs | 84 ++++ .../DownloadOrchestrationServiceTests.cs | 302 ++++++++++++ OF DL.Tests/Services/DownloadServiceTests.cs | 27 +- .../Services/FileNameServiceTestModels.cs | 30 ++ OF DL.Tests/Services/FileNameServiceTests.cs | 79 ++++ OF DL.Tests/Services/SimpleHttpServer.cs | 83 ++++ OF DL.Tests/Services/TestDoubles.cs | 318 ++++++++++++- OF DL.Tests/Services/TestScopes.cs | 53 +++ 10 files changed, 1450 insertions(+), 49 deletions(-) create mode 100644 OF DL.Tests/Services/AuthServiceTests.cs create mode 100644 OF DL.Tests/Services/ConfigServiceTests.cs create mode 100644 OF DL.Tests/Services/DownloadOrchestrationServiceTests.cs create mode 100644 OF DL.Tests/Services/FileNameServiceTestModels.cs create mode 100644 OF DL.Tests/Services/FileNameServiceTests.cs create mode 100644 OF DL.Tests/Services/SimpleHttpServer.cs create mode 100644 OF DL.Tests/Services/TestScopes.cs diff --git a/OF DL.Tests/Services/ApiServiceTests.cs b/OF DL.Tests/Services/ApiServiceTests.cs index af711bf..54b90dc 100644 --- a/OF DL.Tests/Services/ApiServiceTests.cs +++ b/OF DL.Tests/Services/ApiServiceTests.cs @@ -1,10 +1,15 @@ 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; @@ -101,8 +106,405 @@ public class ApiServiceTests 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 FakeDbService()); + 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) { @@ -118,34 +520,4 @@ public class ApiServiceTests return $"{rules.Prefix}:{hashString}:{checksumHex}:{rules.Suffix}"; } - - private sealed class DynamicRulesCacheScope : IDisposable - { - private static readonly FieldInfo s_rulesField = - typeof(ApiService).GetField("s_cachedDynamicRules", BindingFlags.NonPublic | BindingFlags.Static) ?? - throw new InvalidOperationException("Unable to access cached rules field."); - - private static readonly FieldInfo s_expirationField = - typeof(ApiService).GetField("s_cachedDynamicRulesExpiration", - BindingFlags.NonPublic | BindingFlags.Static) ?? - throw new InvalidOperationException("Unable to access cached rules expiration field."); - - private readonly object? _priorRules; - private readonly DateTime? _priorExpiration; - - public DynamicRulesCacheScope(DynamicRules rules) - { - _priorRules = s_rulesField.GetValue(null); - _priorExpiration = (DateTime?)s_expirationField.GetValue(null); - - s_rulesField.SetValue(null, rules); - s_expirationField.SetValue(null, DateTime.UtcNow.AddHours(1)); - } - - public void Dispose() - { - s_rulesField.SetValue(null, _priorRules); - s_expirationField.SetValue(null, _priorExpiration); - } - } } diff --git a/OF DL.Tests/Services/AuthServiceTests.cs b/OF DL.Tests/Services/AuthServiceTests.cs new file mode 100644 index 0000000..b25a576 --- /dev/null +++ b/OF DL.Tests/Services/AuthServiceTests.cs @@ -0,0 +1,89 @@ +using Microsoft.Extensions.DependencyInjection; +using Newtonsoft.Json; +using OF_DL.Models; +using OF_DL.Services; + +namespace OF_DL.Tests.Services; + +[Collection("NonParallel")] +public class AuthServiceTests +{ + [Fact] + public async Task LoadFromFileAsync_ReturnsFalseWhenMissing() + { + using TempFolder temp = new(); + using CurrentDirectoryScope _ = new(temp.Path); + AuthService service = CreateService(); + + bool result = await service.LoadFromFileAsync(); + + Assert.False(result); + Assert.Null(service.CurrentAuth); + } + + [Fact] + public async Task SaveToFileAsync_WritesAuthFile() + { + using TempFolder temp = new(); + using CurrentDirectoryScope _ = new(temp.Path); + AuthService service = CreateService(); + service.CurrentAuth = new Auth + { + UserId = "123", + UserAgent = "agent", + XBc = "xbc", + Cookie = "auth_id=123; sess=abc;" + }; + + await service.SaveToFileAsync(); + + Assert.True(File.Exists("auth.json")); + string json = await File.ReadAllTextAsync("auth.json"); + Auth? saved = JsonConvert.DeserializeObject(json); + Assert.NotNull(saved); + Assert.Equal("123", saved.UserId); + Assert.Equal("agent", saved.UserAgent); + Assert.Equal("xbc", saved.XBc); + Assert.Equal("auth_id=123; sess=abc;", saved.Cookie); + } + + [Fact] + public void ValidateCookieString_NormalizesAndPersists() + { + using TempFolder temp = new(); + using CurrentDirectoryScope _ = new(temp.Path); + AuthService service = CreateService(); + service.CurrentAuth = new Auth + { + Cookie = "auth_id=123; other=1; sess=abc" + }; + + service.ValidateCookieString(); + + Assert.Equal("auth_id=123; sess=abc;", service.CurrentAuth.Cookie); + Assert.True(File.Exists("auth.json")); + string json = File.ReadAllText("auth.json"); + Auth? saved = JsonConvert.DeserializeObject(json); + Assert.NotNull(saved); + Assert.Equal("auth_id=123; sess=abc;", saved.Cookie); + } + + [Fact] + public void Logout_DeletesAuthAndChromeData() + { + using TempFolder temp = new(); + using CurrentDirectoryScope _ = new(temp.Path); + AuthService service = CreateService(); + Directory.CreateDirectory("chrome-data"); + File.WriteAllText("chrome-data/test.txt", "x"); + File.WriteAllText("auth.json", "{}"); + + service.Logout(); + + Assert.False(Directory.Exists("chrome-data")); + Assert.False(File.Exists("auth.json")); + } + + private static AuthService CreateService() => + new(new ServiceCollection().BuildServiceProvider()); +} diff --git a/OF DL.Tests/Services/ConfigServiceTests.cs b/OF DL.Tests/Services/ConfigServiceTests.cs new file mode 100644 index 0000000..7157ee9 --- /dev/null +++ b/OF DL.Tests/Services/ConfigServiceTests.cs @@ -0,0 +1,84 @@ +using OF_DL.Enumerations; +using OF_DL.Models.Config; +using OF_DL.Services; + +namespace OF_DL.Tests.Services; + +[Collection("NonParallel")] +public class ConfigServiceTests +{ + [Fact] + public async Task LoadConfigurationAsync_CreatesDefaultConfigWhenMissing() + { + using TempFolder temp = new(); + using CurrentDirectoryScope _ = new(temp.Path); + FakeLoggingService loggingService = new(); + ConfigService service = new(loggingService); + + bool result = await service.LoadConfigurationAsync([]); + + Assert.True(result); + Assert.True(File.Exists("config.conf")); + Assert.True(loggingService.UpdateCount > 0); + Assert.Equal(service.CurrentConfig.LoggingLevel, loggingService.LastLevel); + } + + [Fact] + public async Task LoadConfigurationAsync_OverridesNonInteractiveFromCli() + { + using TempFolder temp = new(); + using CurrentDirectoryScope _ = new(temp.Path); + FakeLoggingService loggingService = new(); + ConfigService service = new(loggingService); + await service.SaveConfigurationAsync(); + + bool result = await service.LoadConfigurationAsync(["--non-interactive"]); + + Assert.True(result); + Assert.True(service.IsCliNonInteractive); + Assert.True(service.CurrentConfig.NonInteractiveMode); + } + + [Fact] + public async Task LoadConfigurationAsync_ReturnsFalseWhenInvalidFilenameFormat() + { + using TempFolder temp = new(); + using CurrentDirectoryScope _ = new(temp.Path); + FakeLoggingService loggingService = new(); + ConfigService service = new(loggingService); + await service.SaveConfigurationAsync(); + + string hocon = await File.ReadAllTextAsync("config.conf"); + hocon = hocon.Replace("PaidPostFileNameFormat = \"\"", + "PaidPostFileNameFormat = \"invalid-format\""); + await File.WriteAllTextAsync("config.conf", hocon); + + bool result = await service.LoadConfigurationAsync([]); + + Assert.False(result); + } + + [Fact] + public void ApplyToggleableSelections_UpdatesConfigAndReturnsChange() + { + FakeLoggingService loggingService = new(); + ConfigService service = new(loggingService); + Config initialConfig = new() + { + DownloadPosts = true, + DownloadMessages = true, + DownloadPath = "/downloads", + LoggingLevel = LoggingLevel.Warning + }; + service.UpdateConfig(initialConfig); + + bool changed = service.ApplyToggleableSelections(["DownloadPosts"]); + + Assert.True(changed); + Assert.True(service.CurrentConfig.DownloadPosts); + Assert.False(service.CurrentConfig.DownloadMessages); + Assert.Equal("/downloads", service.CurrentConfig.DownloadPath); + Assert.Equal(LoggingLevel.Warning, loggingService.LastLevel); + } + +} diff --git a/OF DL.Tests/Services/DownloadOrchestrationServiceTests.cs b/OF DL.Tests/Services/DownloadOrchestrationServiceTests.cs new file mode 100644 index 0000000..9167b52 --- /dev/null +++ b/OF DL.Tests/Services/DownloadOrchestrationServiceTests.cs @@ -0,0 +1,302 @@ +using Newtonsoft.Json.Linq; +using OF_DL.Models.Config; +using OF_DL.Models.Downloads; +using OF_DL.Services; +using MessageEntities = OF_DL.Models.Entities.Messages; +using PostEntities = OF_DL.Models.Entities.Posts; +using PurchasedEntities = OF_DL.Models.Entities.Purchased; + +namespace OF_DL.Tests.Services; + +public class DownloadOrchestrationServiceTests +{ + [Fact] + public async Task GetAvailableUsersAsync_FiltersIgnoredUsers() + { + Config config = CreateConfig(c => + { + c.IncludeExpiredSubscriptions = true; + c.IgnoredUsersListName = "ignored"; + }); + FakeConfigService configService = new(config); + ConfigurableApiService apiService = new() + { + ActiveSubscriptionsHandler = (_, _) => + Task.FromResult?>(new Dictionary { { "alice", 1 } }), + ExpiredSubscriptionsHandler = (_, _) => + Task.FromResult?>(new Dictionary { { "bob", 2 } }), + ListsHandler = + _ => Task.FromResult?>(new Dictionary { { "ignored", 10 } }), + ListUsersHandler = _ => Task.FromResult?>(["alice"]) + }; + UserTrackingDbService dbService = new(); + DownloadOrchestrationService service = + new(apiService, configService, new OrchestrationDownloadServiceStub(), dbService); + + UserListResult result = await service.GetAvailableUsersAsync(); + + Assert.Single(result.Users); + Assert.True(result.Users.ContainsKey("bob")); + Assert.Null(result.IgnoredListError); + Assert.NotNull(dbService.CreatedUsers); + Assert.True(dbService.CreatedUsers.ContainsKey("bob")); + Assert.False(dbService.CreatedUsers.ContainsKey("alice")); + } + + [Fact] + public async Task GetAvailableUsersAsync_SetsIgnoredListErrorWhenMissing() + { + Config config = CreateConfig(c => + { + c.IncludeExpiredSubscriptions = false; + c.IgnoredUsersListName = "missing"; + }); + FakeConfigService configService = new(config); + ConfigurableApiService apiService = new() + { + ActiveSubscriptionsHandler = (_, _) => + Task.FromResult?>(new Dictionary { { "alice", 1 } }), + ListsHandler = _ => Task.FromResult?>(new Dictionary()) + }; + UserTrackingDbService dbService = new(); + DownloadOrchestrationService service = + new(apiService, configService, new OrchestrationDownloadServiceStub(), dbService); + + UserListResult result = await service.GetAvailableUsersAsync(); + + Assert.NotNull(result.IgnoredListError); + Assert.Single(result.Users); + Assert.True(result.Users.ContainsKey("alice")); + } + + [Fact] + public async Task GetUsersForListAsync_ReturnsUsersInList() + { + FakeConfigService configService = new(CreateConfig()); + ConfigurableApiService apiService = new() { ListUsersHandler = _ => Task.FromResult?>(["bob"]) }; + DownloadOrchestrationService service = + new(apiService, configService, new OrchestrationDownloadServiceStub(), new UserTrackingDbService()); + Dictionary allUsers = new() { { "alice", 1 }, { "bob", 2 } }; + Dictionary lists = new() { { "mylist", 5 } }; + + Dictionary result = await service.GetUsersForListAsync("mylist", allUsers, lists); + + Assert.Single(result); + Assert.Equal(2, result["bob"]); + } + + [Fact] + public void ResolveDownloadPath_UsesConfiguredPathWhenSet() + { + Config config = CreateConfig(c => c.DownloadPath = "C:\\Downloads"); + DownloadOrchestrationService service = + new(new ConfigurableApiService(), new FakeConfigService(config), new OrchestrationDownloadServiceStub(), + new UserTrackingDbService()); + + string path = service.ResolveDownloadPath("creator"); + + Assert.Equal(Path.Combine("C:\\Downloads", "creator"), path); + } + + [Fact] + public void ResolveDownloadPath_UsesDefaultWhenBlank() + { + Config config = CreateConfig(c => c.DownloadPath = ""); + DownloadOrchestrationService service = + new(new ConfigurableApiService(), new FakeConfigService(config), new OrchestrationDownloadServiceStub(), + new UserTrackingDbService()); + + string path = service.ResolveDownloadPath("creator"); + + Assert.Equal("__user_data__/sites/OnlyFans/creator", path); + } + + [Fact] + public async Task PrepareUserFolderAsync_CreatesFolderAndDb() + { + using TempFolder temp = new(); + string userPath = Path.Combine(temp.Path, "creator"); + UserTrackingDbService dbService = new(); + DownloadOrchestrationService service = + new(new ConfigurableApiService(), new FakeConfigService(CreateConfig()), + new OrchestrationDownloadServiceStub(), dbService); + + await service.PrepareUserFolderAsync("creator", 99, userPath); + + Assert.True(Directory.Exists(userPath)); + Assert.True(dbService.CheckedUser.HasValue); + Assert.Equal("creator", dbService.CheckedUser.Value.user.Key); + Assert.Equal(99, dbService.CheckedUser.Value.user.Value); + Assert.Equal(userPath, dbService.CheckedUser.Value.path); + Assert.Contains(userPath, dbService.CreatedDbs); + } + + [Fact] + public async Task DownloadSinglePostAsync_WhenMissingPost_SendsMessage() + { + ConfigurableApiService apiService = new() + { + PostHandler = (_, _) => Task.FromResult(new PostEntities.SinglePostCollection()) + }; + OrchestrationDownloadServiceStub downloadService = new(); + RecordingDownloadEventHandler eventHandler = new(); + DownloadOrchestrationService service = + new(apiService, new FakeConfigService(CreateConfig()), downloadService, new UserTrackingDbService()); + + await service.DownloadSinglePostAsync("creator", 42, "/tmp", new Dictionary(), + true, true, eventHandler); + + Assert.Contains("Getting Post", eventHandler.Messages); + Assert.Contains("Couldn't find post", eventHandler.Messages); + Assert.False(downloadService.SinglePostCalled); + } + + [Fact] + public async Task DownloadSinglePostAsync_WhenDownloaded_SendsDownloadedMessage() + { + PostEntities.SinglePostCollection collection = new() + { + SinglePosts = new Dictionary { { 1, "https://example.com/post.jpg" } } + }; + ConfigurableApiService apiService = new() { PostHandler = (_, _) => Task.FromResult(collection) }; + OrchestrationDownloadServiceStub downloadService = new() + { + SinglePostResult = new DownloadResult { NewDownloads = 1, TotalCount = 1 } + }; + RecordingDownloadEventHandler eventHandler = new(); + DownloadOrchestrationService service = + new(apiService, new FakeConfigService(CreateConfig()), downloadService, new UserTrackingDbService()); + + await service.DownloadSinglePostAsync("creator", 99, "/tmp", new Dictionary(), + true, true, eventHandler); + + Assert.Contains("Post 99 downloaded", eventHandler.Messages); + Assert.True(downloadService.SinglePostCalled); + Assert.True(eventHandler.ProgressCalls.Count > 0); + } + + [Fact] + public async Task DownloadSinglePaidMessageAsync_WithPreviewDownloads() + { + PurchasedEntities.SinglePaidMessageCollection collection = new() + { + PreviewSingleMessages = new Dictionary { { 1, "https://example.com/preview.jpg" } }, + SingleMessages = new Dictionary { { 2, "https://example.com/full.jpg" } }, + SingleMessageObjects = [new MessageEntities.SingleMessage()] + }; + ConfigurableApiService apiService = new() { PaidMessageHandler = (_, _) => Task.FromResult(collection) }; + OrchestrationDownloadServiceStub downloadService = new() + { + SinglePaidMessageResult = new DownloadResult { TotalCount = 1, NewDownloads = 1 } + }; + RecordingDownloadEventHandler eventHandler = new(); + DownloadOrchestrationService service = + new(apiService, new FakeConfigService(CreateConfig()), downloadService, new UserTrackingDbService()); + + await service.DownloadSinglePaidMessageAsync("creator", 5, "/tmp", new Dictionary(), + true, true, eventHandler); + + Assert.Contains(eventHandler.ContentFound, entry => entry.contentType == "Preview Paid Messages"); + Assert.True(downloadService.SinglePaidMessageCalled); + } + + [Fact] + public async Task DownloadCreatorContentAsync_DownloadsStoriesWhenEnabled() + { + using TempFolder temp = new(); + string path = Path.Combine(temp.Path, "creator"); + Config config = CreateConfig(c => + { + c.DownloadStories = true; + c.ShowScrapeSize = false; + }); + FakeConfigService configService = new(config); + ConfigurableApiService apiService = new() + { + MediaHandler = (_, _, _, _) => Task.FromResult?>( + new Dictionary + { + { 1, "https://example.com/one.jpg" }, { 2, "https://example.com/two.jpg" } + }) + }; + OrchestrationDownloadServiceStub downloadService = new() + { + StoriesResult = new DownloadResult { TotalCount = 2, NewDownloads = 2 } + }; + RecordingDownloadEventHandler eventHandler = new(); + DownloadOrchestrationService service = + new(apiService, configService, downloadService, new UserTrackingDbService()); + + CreatorDownloadResult result = await service.DownloadCreatorContentAsync("creator", 1, path, + new Dictionary(), true, true, eventHandler); + + Assert.Equal(2, result.StoriesCount); + Assert.Contains(eventHandler.ContentFound, entry => entry.contentType == "Stories"); + Assert.Contains(eventHandler.DownloadCompletes, entry => entry.contentType == "Stories"); + } + + [Fact] + public async Task ResolveUsernameAsync_ReturnsDeletedPlaceholderWhenMissing() + { + ConfigurableApiService apiService = new() { UserInfoByIdHandler = _ => Task.FromResult(null) }; + DownloadOrchestrationService service = + new(apiService, new FakeConfigService(CreateConfig()), new OrchestrationDownloadServiceStub(), + new UserTrackingDbService()); + + string? result = await service.ResolveUsernameAsync(123); + + Assert.Equal("Deleted User - 123", result); + } + + [Fact] + public async Task ResolveUsernameAsync_ReturnsUsernameWhenPresent() + { + JObject payload = new() { ["5"] = new JObject { ["username"] = "creator" } }; + ConfigurableApiService apiService = new() { UserInfoByIdHandler = _ => Task.FromResult(payload) }; + DownloadOrchestrationService service = + new(apiService, new FakeConfigService(CreateConfig()), new OrchestrationDownloadServiceStub(), + new UserTrackingDbService()); + + string? result = await service.ResolveUsernameAsync(5); + + Assert.Equal("creator", result); + } + + private static Config CreateConfig(Action? configure = null) + { + Config config = new() + { + DownloadAvatarHeaderPhoto = false, + DownloadPaidPosts = false, + DownloadPosts = false, + DownloadArchived = false, + DownloadStreams = false, + DownloadStories = false, + DownloadHighlights = false, + DownloadMessages = false, + DownloadPaidMessages = false, + DownloadImages = false, + DownloadVideos = false, + DownloadAudios = false, + IncludeExpiredSubscriptions = false, + IncludeRestrictedSubscriptions = false, + SkipAds = false, + IgnoreOwnMessages = false, + DownloadPostsIncrementally = false, + BypassContentForCreatorsWhoNoLongerExist = false, + DownloadDuplicatedMedia = false, + DownloadOnlySpecificDates = false, + NonInteractiveModePurchasedTab = false, + LimitDownloadRate = false, + FolderPerPaidPost = false, + FolderPerPost = false, + FolderPerPaidMessage = false, + FolderPerMessage = false, + ShowScrapeSize = false, + DisableBrowserAuth = false + }; + + configure?.Invoke(config); + return config; + } +} diff --git a/OF DL.Tests/Services/DownloadServiceTests.cs b/OF DL.Tests/Services/DownloadServiceTests.cs index 87d4a38..e9ee291 100644 --- a/OF DL.Tests/Services/DownloadServiceTests.cs +++ b/OF DL.Tests/Services/DownloadServiceTests.cs @@ -19,7 +19,7 @@ public class DownloadServiceTests Directory.CreateDirectory(Path.GetDirectoryName(serverFilePath) ?? throw new InvalidOperationException()); await File.WriteAllTextAsync(serverFilePath, "abc"); - FakeDbService dbService = new() { CheckDownloadedResult = false }; + MediaTrackingDbService dbService = new() { CheckDownloadedResult = false }; FakeConfigService configService = new(new Config { ShowScrapeSize = false }); DownloadService service = CreateService(configService, dbService); ProgressRecorder progress = new(); @@ -51,7 +51,7 @@ public class DownloadServiceTests Directory.CreateDirectory(Path.GetDirectoryName(serverFilePath) ?? throw new InvalidOperationException()); await File.WriteAllTextAsync(serverFilePath, "abc"); - FakeDbService dbService = new() { CheckDownloadedResult = true, StoredFileSize = 123 }; + MediaTrackingDbService dbService = new() { CheckDownloadedResult = true, StoredFileSize = 123 }; FakeConfigService configService = new(new Config { ShowScrapeSize = false, RenameExistingFilesWhenCustomFormatIsSelected = true }); DownloadService service = CreateService(configService, dbService); @@ -73,9 +73,9 @@ public class DownloadServiceTests [Fact] public async Task GetDecryptionInfo_UsesOfdlWhenCdmMissing() { - FakeApiService apiService = new(); + StaticApiService apiService = new(); DownloadService service = - CreateService(new FakeConfigService(new Config()), new FakeDbService(), apiService); + CreateService(new FakeConfigService(new Config()), new MediaTrackingDbService(), apiService); (string decryptionKey, DateTime lastModified)? result = await service.GetDecryptionInfo( "https://example.com/file.mpd", "policy", "signature", "kvp", "1", "2", "post", @@ -91,9 +91,9 @@ public class DownloadServiceTests [Fact] public async Task GetDecryptionInfo_UsesCdmWhenAvailable() { - FakeApiService apiService = new(); + StaticApiService apiService = new(); DownloadService service = - CreateService(new FakeConfigService(new Config()), new FakeDbService(), apiService); + CreateService(new FakeConfigService(new Config()), new MediaTrackingDbService(), apiService); (string decryptionKey, DateTime lastModified)? result = await service.GetDecryptionInfo( "https://example.com/file.mpd", "policy", "signature", "kvp", "1", "2", "post", @@ -109,9 +109,9 @@ public class DownloadServiceTests [Fact] public async Task DownloadHighlights_ReturnsZeroWhenNoMedia() { - FakeApiService apiService = new() { MediaToReturn = new Dictionary() }; + StaticApiService apiService = new() { MediaToReturn = new Dictionary() }; DownloadService service = - CreateService(new FakeConfigService(new Config()), new FakeDbService(), apiService); + CreateService(new FakeConfigService(new Config()), new MediaTrackingDbService(), apiService); DownloadResult result = await service.DownloadHighlights("user", 1, "/tmp/creator", new HashSet(), new ProgressRecorder()); @@ -128,14 +128,14 @@ public class DownloadServiceTests { using TempFolder temp = new(); string folder = NormalizeFolder(Path.Combine(temp.Path, "creator")); - FakeApiService apiService = new() + StaticApiService apiService = new() { MediaToReturn = new Dictionary { { 1, "https://example.com/one.jpg" }, { 2, "https://example.com/two.jpg" } } }; - FakeDbService dbService = new() { CheckDownloadedResult = true }; + MediaTrackingDbService dbService = new() { CheckDownloadedResult = true }; FakeConfigService configService = new(new Config { ShowScrapeSize = false }); ProgressRecorder progress = new(); DownloadService service = CreateService(configService, dbService, apiService); @@ -150,10 +150,11 @@ public class DownloadServiceTests Assert.Equal(2, progress.Total); } - private static DownloadService CreateService(FakeConfigService configService, FakeDbService dbService, - FakeApiService? apiService = null) => + private static DownloadService CreateService(FakeConfigService configService, MediaTrackingDbService dbService, + StaticApiService? apiService = null) => new(new FakeAuthService(), configService, dbService, new FakeFileNameService(), - apiService ?? new FakeApiService()); + apiService ?? new StaticApiService()); private static string NormalizeFolder(string folder) => folder.Replace("\\", "/"); } + diff --git a/OF DL.Tests/Services/FileNameServiceTestModels.cs b/OF DL.Tests/Services/FileNameServiceTestModels.cs new file mode 100644 index 0000000..9d1098b --- /dev/null +++ b/OF DL.Tests/Services/FileNameServiceTestModels.cs @@ -0,0 +1,30 @@ +namespace OF_DL.Tests.Services; + +internal sealed class TestInfo +{ + public long Id { get; set; } + public string? Text { get; set; } + public DateTime CreatedAt { get; set; } +} + +internal sealed class TestAuthor +{ + public long Id { get; set; } +} + +internal sealed class TestMedia +{ + public long Id { get; set; } + public TestMediaFiles Files { get; set; } = new(); +} + +internal sealed class TestMediaFiles +{ + public TestMediaFull Full { get; set; } = new(); + public object? Drm { get; set; } +} + +internal sealed class TestMediaFull +{ + public string? Url { get; set; } +} diff --git a/OF DL.Tests/Services/FileNameServiceTests.cs b/OF DL.Tests/Services/FileNameServiceTests.cs new file mode 100644 index 0000000..3d8805d --- /dev/null +++ b/OF DL.Tests/Services/FileNameServiceTests.cs @@ -0,0 +1,79 @@ +using OF_DL.Services; + +namespace OF_DL.Tests.Services; + +public class FileNameServiceTests +{ + [Fact] + public async Task GetFilename_ReturnsExpectedValues() + { + TestInfo info = new() { Id = 7, Text = "
hello world
", CreatedAt = new DateTime(2024, 1, 2) }; + TestMedia media = new() + { + Id = 99, + Files = new TestMediaFiles + { + Full = new TestMediaFull { Url = "https://cdn.test/file-name.jpg" }, Drm = new object() + } + }; + TestAuthor author = new() { Id = 123 }; + FileNameService service = new(new FakeAuthService()); + + List selectedProperties = ["mediaId", "filename", "username", "text", "createdAt", "id"]; + Dictionary values = + await service.GetFilename(info, media, author, selectedProperties, "creator"); + + Assert.Equal("99", values["mediaId"]); + Assert.Equal("file-name", values["filename"]); + Assert.Equal("creator", values["username"]); + Assert.Equal("hello world", values["text"]); + Assert.Equal("2024-01-02", values["createdAt"]); + Assert.Equal("7", values["id"]); + } + + [Fact] + public async Task GetFilename_TruncatesTextTo100Chars() + { + string longText = new('a', 120); + TestInfo info = new() { Text = $"

{longText}

" }; + TestMedia media = new() + { + Id = 1, + Files = new TestMediaFiles + { + Full = new TestMediaFull { Url = "https://cdn.test/short.jpg" }, Drm = null + } + }; + FileNameService service = new(new FakeAuthService()); + + Dictionary values = + await service.GetFilename(info, media, new TestAuthor(), ["text"], "creator"); + + Assert.Equal(100, values["text"].Length); + Assert.Equal(new string('a', 100), values["text"]); + } + + [Fact] + public async Task GetFilename_UsesUserLookupWhenUsernameMissing() + { + TestAuthor author = new() { Id = 55 }; + Dictionary users = new() { { "mapped", 55 } }; + FileNameService service = new(new FakeAuthService()); + + Dictionary values = + await service.GetFilename(new TestInfo(), new TestMedia(), author, ["username"], "", users); + + Assert.Equal("mapped", values["username"]); + } + + [Fact] + public async Task BuildFilename_ReplacesTokensAndRemovesInvalidChars() + { + FileNameService service = new(new FakeAuthService()); + Dictionary values = new() { { "username", "creator" }, { "mediaId", "99" } }; + + string result = await service.BuildFilename("{username}_{mediaId}:*?", values); + + Assert.Equal("creator_99", result); + } +} diff --git a/OF DL.Tests/Services/SimpleHttpServer.cs b/OF DL.Tests/Services/SimpleHttpServer.cs new file mode 100644 index 0000000..f6a3396 --- /dev/null +++ b/OF DL.Tests/Services/SimpleHttpServer.cs @@ -0,0 +1,83 @@ +using System.Net; +using System.Net.Sockets; +using System.Text; + +namespace OF_DL.Tests.Services; + +internal sealed class SimpleHttpServer : IDisposable +{ + private readonly TcpListener _listener; + private readonly Task _handlerTask; + private readonly byte[] _responseBytes; + + public SimpleHttpServer(string body, DateTime? lastModifiedUtc = null) + { + _listener = new TcpListener(IPAddress.Loopback, 0); + _listener.Start(); + int port = ((IPEndPoint)_listener.LocalEndpoint).Port; + Url = new Uri($"http://127.0.0.1:{port}/"); + _responseBytes = BuildResponse(body, lastModifiedUtc); + _handlerTask = Task.Run(HandleOnceAsync); + } + + public Uri Url { get; } + + public Task Completion => _handlerTask; + + public void Dispose() + { + _listener.Stop(); + try + { + _handlerTask.Wait(TimeSpan.FromSeconds(1)); + } + catch + { + // ignored + } + } + + private async Task HandleOnceAsync() + { + using TcpClient client = await _listener.AcceptTcpClientAsync(); + await using NetworkStream stream = client.GetStream(); + await ReadHeadersAsync(stream); + await stream.WriteAsync(_responseBytes); + } + + private static async Task ReadHeadersAsync(NetworkStream stream) + { + byte[] buffer = new byte[1024]; + int read; + string data = ""; + while ((read = await stream.ReadAsync(buffer)) > 0) + { + data += Encoding.ASCII.GetString(buffer, 0, read); + if (data.Contains("\r\n\r\n", StringComparison.Ordinal)) + { + break; + } + } + } + + private static byte[] BuildResponse(string body, DateTime? lastModifiedUtc) + { + byte[] bodyBytes = Encoding.UTF8.GetBytes(body); + StringBuilder header = new(); + header.Append("HTTP/1.1 200 OK\r\n"); + header.Append("Content-Type: application/xml\r\n"); + header.Append($"Content-Length: {bodyBytes.Length}\r\n"); + if (lastModifiedUtc.HasValue) + { + header.Append($"Last-Modified: {lastModifiedUtc.Value.ToUniversalTime():R}\r\n"); + } + + header.Append("Connection: close\r\n\r\n"); + byte[] headerBytes = Encoding.ASCII.GetBytes(header.ToString()); + + byte[] response = new byte[headerBytes.Length + bodyBytes.Length]; + Buffer.BlockCopy(headerBytes, 0, response, 0, headerBytes.Length); + Buffer.BlockCopy(bodyBytes, 0, response, headerBytes.Length, bodyBytes.Length); + return response; + } +} diff --git a/OF DL.Tests/Services/TestDoubles.cs b/OF DL.Tests/Services/TestDoubles.cs index 0cfce8b..ba8797c 100644 --- a/OF DL.Tests/Services/TestDoubles.cs +++ b/OF DL.Tests/Services/TestDoubles.cs @@ -2,8 +2,16 @@ using Newtonsoft.Json.Linq; using OF_DL.Enumerations; using OF_DL.Models; using OF_DL.Models.Config; -using OF_DL.Models.Entities.Users; +using OF_DL.Models.Downloads; using OF_DL.Services; +using Serilog.Core; +using Serilog.Events; +using ArchivedEntities = OF_DL.Models.Entities.Archived; +using MessageEntities = OF_DL.Models.Entities.Messages; +using PostEntities = OF_DL.Models.Entities.Posts; +using PurchasedEntities = OF_DL.Models.Entities.Purchased; +using StreamEntities = OF_DL.Models.Entities.Streams; +using UserEntities = OF_DL.Models.Entities.Users; namespace OF_DL.Tests.Services; @@ -54,7 +62,7 @@ internal sealed class FakeConfigService(Config config) : IConfigService public bool ApplyToggleableSelections(List selectedNames) => false; } -internal sealed class FakeDbService : IDbService +internal sealed class MediaTrackingDbService : IDbService { public bool CheckDownloadedResult { get; init; } @@ -98,7 +106,7 @@ internal sealed class FakeDbService : IDbService public Task GetMostRecentPostDate(string folder) => throw new NotImplementedException(); } -internal sealed class FakeApiService : IApiService +internal sealed class StaticApiService : IApiService { public Dictionary? MediaToReturn { get; init; } @@ -167,7 +175,7 @@ internal sealed class FakeApiService : IApiService public Task> GetPurchasedTab(string endpoint, string folder, Dictionary users) => throw new NotImplementedException(); - public Task GetUserInfo(string endpoint) => + public Task GetUserInfo(string endpoint) => throw new NotImplementedException(); public Task GetUserInfoById(string endpoint) => @@ -180,6 +188,290 @@ internal sealed class FakeApiService : IApiService bool includeRestrictedSubscriptions) => throw new NotImplementedException(); } +internal sealed class ConfigurableApiService : IApiService +{ + public Func?>>? ActiveSubscriptionsHandler { get; init; } + public Func?>>? ExpiredSubscriptionsHandler { get; init; } + public Func?>>? ListsHandler { get; init; } + public Func?>>? ListUsersHandler { get; init; } + public Func?>>? MediaHandler { get; init; } + public Func>? PostHandler { get; init; } + public Func>? PaidMessageHandler { get; init; } + public Func>? UserInfoHandler { get; init; } + public Func>? UserInfoByIdHandler { get; init; } + + public Task?> GetActiveSubscriptions(string endpoint, + bool includeRestrictedSubscriptions) => + ActiveSubscriptionsHandler?.Invoke(endpoint, includeRestrictedSubscriptions) ?? + Task.FromResult?>(null); + + public Task?> GetExpiredSubscriptions(string endpoint, + bool includeRestrictedSubscriptions) => + ExpiredSubscriptionsHandler?.Invoke(endpoint, includeRestrictedSubscriptions) ?? + Task.FromResult?>(null); + + public Task?> GetLists(string endpoint) => + ListsHandler?.Invoke(endpoint) ?? Task.FromResult?>(null); + + public Task?> GetListUsers(string endpoint) => + ListUsersHandler?.Invoke(endpoint) ?? Task.FromResult?>(null); + + public Task?> GetMedia(MediaType mediaType, string endpoint, string? username, + string folder) => + MediaHandler?.Invoke(mediaType, endpoint, username, folder) ?? + Task.FromResult?>(null); + + public Task GetPost(string endpoint, string folder) => + PostHandler?.Invoke(endpoint, folder) ?? Task.FromResult(new PostEntities.SinglePostCollection()); + + public Task GetPaidMessage(string endpoint, string folder) => + PaidMessageHandler?.Invoke(endpoint, folder) ?? + Task.FromResult(new PurchasedEntities.SinglePaidMessageCollection()); + + public Task GetUserInfo(string endpoint) => + UserInfoHandler?.Invoke(endpoint) ?? Task.FromResult(null); + + public Task GetUserInfoById(string endpoint) => + UserInfoByIdHandler?.Invoke(endpoint) ?? Task.FromResult(null); + + public Task GetPaidPosts(string endpoint, string folder, string username, + List paidPostIds, IStatusReporter statusReporter) => + throw new NotImplementedException(); + + public Task GetPosts(string endpoint, string folder, List paidPostIds, + IStatusReporter statusReporter) => + throw new NotImplementedException(); + + public Task GetStreams(string endpoint, string folder, List paidPostIds, + IStatusReporter statusReporter) => + throw new NotImplementedException(); + + public Task GetArchived(string endpoint, string folder, + IStatusReporter statusReporter) => + throw new NotImplementedException(); + + public Task GetMessages(string endpoint, string folder, + IStatusReporter statusReporter) => + throw new NotImplementedException(); + + public Task GetPaidMessages(string endpoint, string folder, + string username, IStatusReporter statusReporter) => + throw new NotImplementedException(); + + public Task> GetPurchasedTabUsers(string endpoint, Dictionary users) => + throw new NotImplementedException(); + + public Task> GetPurchasedTab(string endpoint, string folder, + Dictionary users) => + throw new NotImplementedException(); + + public Dictionary GetDynamicHeaders(string path, string queryParam) => + throw new NotImplementedException(); + + public Task GetDecryptionKeyCdm(Dictionary drmHeaders, string licenceUrl, string pssh) => + throw new NotImplementedException(); + + public Task GetDrmMpdLastModified(string mpdUrl, string policy, string signature, string kvp) => + throw new NotImplementedException(); + + public Task GetDrmMpdPssh(string mpdUrl, string policy, string signature, string kvp) => + throw new NotImplementedException(); + + public Task GetDecryptionKeyOfdl(Dictionary drmHeaders, string licenceUrl, string pssh) => + throw new NotImplementedException(); +} + +internal sealed class OrchestrationDownloadServiceStub : IDownloadService +{ + public bool SinglePostCalled { get; private set; } + public bool SinglePaidMessageCalled { get; private set; } + + public DownloadResult? SinglePostResult { get; init; } + public DownloadResult? SinglePaidMessageResult { get; init; } + public DownloadResult? StoriesResult { get; init; } + + public Task CalculateTotalFileSize(List urls) => Task.FromResult((long)urls.Count); + + public Task ProcessMediaDownload(string folder, long mediaId, string apiType, string url, string path, + string serverFileName, string resolvedFileName, string extension, IProgressReporter progressReporter) => + throw new NotImplementedException(); + + public Task<(string decryptionKey, DateTime lastModified)?> GetDecryptionInfo(string mpdUrl, string policy, + string signature, string kvp, string mediaId, string contentId, string drmType, bool clientIdBlobMissing, + bool devicePrivateKeyMissing) => + throw new NotImplementedException(); + + public Task DownloadAvatarHeader(string? avatarUrl, string? headerUrl, string folder, string username) => + Task.CompletedTask; + + public Task DownloadHighlights(string username, long userId, string path, + HashSet paidPostIds, IProgressReporter progressReporter) => + Task.FromResult(new DownloadResult()); + + public Task DownloadStories(string username, long userId, string path, + HashSet paidPostIds, IProgressReporter progressReporter) => + Task.FromResult(StoriesResult ?? new DownloadResult()); + + public Task DownloadArchived(string username, long userId, string path, + Dictionary users, bool clientIdBlobMissing, bool devicePrivateKeyMissing, + ArchivedEntities.ArchivedCollection archived, IProgressReporter progressReporter) => + throw new NotImplementedException(); + + public Task DownloadMessages(string username, long userId, string path, + Dictionary users, bool clientIdBlobMissing, bool devicePrivateKeyMissing, + MessageEntities.MessageCollection messages, IProgressReporter progressReporter) => + throw new NotImplementedException(); + + public Task DownloadPaidMessages(string username, string path, Dictionary users, + bool clientIdBlobMissing, bool devicePrivateKeyMissing, + PurchasedEntities.PaidMessageCollection paidMessageCollection, IProgressReporter progressReporter) => + throw new NotImplementedException(); + + public Task DownloadStreams(string username, long userId, string path, + Dictionary users, bool clientIdBlobMissing, bool devicePrivateKeyMissing, + StreamEntities.StreamsCollection streams, IProgressReporter progressReporter) => + throw new NotImplementedException(); + + public Task DownloadFreePosts(string username, long userId, string path, + Dictionary users, bool clientIdBlobMissing, bool devicePrivateKeyMissing, + PostEntities.PostCollection posts, IProgressReporter progressReporter) => + throw new NotImplementedException(); + + public Task DownloadPaidPosts(string username, long userId, string path, + Dictionary users, bool clientIdBlobMissing, bool devicePrivateKeyMissing, + PurchasedEntities.PaidPostCollection purchasedPosts, IProgressReporter progressReporter) => + throw new NotImplementedException(); + + public Task DownloadPaidPostsPurchasedTab(string username, string path, + Dictionary users, bool clientIdBlobMissing, bool devicePrivateKeyMissing, + PurchasedEntities.PaidPostCollection purchasedPosts, IProgressReporter progressReporter) => + throw new NotImplementedException(); + + public Task DownloadPaidMessagesPurchasedTab(string username, string path, + Dictionary users, bool clientIdBlobMissing, bool devicePrivateKeyMissing, + PurchasedEntities.PaidMessageCollection paidMessageCollection, IProgressReporter progressReporter) => + throw new NotImplementedException(); + + public Task DownloadSinglePost(string username, string path, Dictionary users, + bool clientIdBlobMissing, bool devicePrivateKeyMissing, PostEntities.SinglePostCollection post, + IProgressReporter progressReporter) + { + SinglePostCalled = true; + return Task.FromResult(SinglePostResult ?? new DownloadResult()); + } + + public Task DownloadSinglePaidMessage(string username, string path, + Dictionary users, bool clientIdBlobMissing, bool devicePrivateKeyMissing, + PurchasedEntities.SinglePaidMessageCollection singlePaidMessageCollection, + IProgressReporter progressReporter) + { + SinglePaidMessageCalled = true; + return Task.FromResult(SinglePaidMessageResult ?? new DownloadResult()); + } +} + +internal sealed class UserTrackingDbService : IDbService +{ + public Dictionary? CreatedUsers { get; private set; } + public List CreatedDbs { get; } = []; + public (KeyValuePair user, string path)? CheckedUser { get; private set; } + + public Task CreateDb(string folder) + { + CreatedDbs.Add(folder); + return Task.CompletedTask; + } + + public Task CreateUsersDb(Dictionary users) + { + CreatedUsers = new Dictionary(users); + return Task.CompletedTask; + } + + public Task CheckUsername(KeyValuePair user, string path) + { + CheckedUser = (user, path); + return Task.CompletedTask; + } + + public Task AddMessage(string folder, long postId, string messageText, string price, bool isPaid, + bool isArchived, DateTime createdAt, long userId) => throw new NotImplementedException(); + + public Task AddPost(string folder, long postId, string messageText, string price, bool isPaid, + bool isArchived, DateTime createdAt) => throw new NotImplementedException(); + + public Task AddStory(string folder, long postId, string messageText, string price, bool isPaid, + bool isArchived, DateTime createdAt) => throw new NotImplementedException(); + + public Task AddMedia(string folder, long mediaId, long postId, string link, string? directory, + string? filename, long? size, string apiType, string mediaType, bool preview, bool downloaded, + DateTime? createdAt) => throw new NotImplementedException(); + + public Task UpdateMedia(string folder, long mediaId, string apiType, string directory, string filename, + long size, bool downloaded, DateTime createdAt) => throw new NotImplementedException(); + + public Task GetStoredFileSize(string folder, long mediaId, string apiType) => + throw new NotImplementedException(); + + public Task CheckDownloaded(string folder, long mediaId, string apiType) => + throw new NotImplementedException(); + + public Task GetMostRecentPostDate(string folder) => throw new NotImplementedException(); +} + +internal sealed class RecordingDownloadEventHandler : IDownloadEventHandler +{ + public List Messages { get; } = []; + public List<(string contentType, int mediaCount, int objectCount)> ContentFound { get; } = []; + public List NoContent { get; } = []; + public List<(string contentType, DownloadResult result)> DownloadCompletes { get; } = []; + public List<(string description, long maxValue, bool showSize)> ProgressCalls { get; } = []; + + public Task WithStatusAsync(string statusMessage, Func> work) => + work(new RecordingStatusReporter(statusMessage)); + + public Task WithProgressAsync(string description, long maxValue, bool showSize, + Func> work) + { + ProgressCalls.Add((description, maxValue, showSize)); + return work(new ProgressRecorder()); + } + + public void OnContentFound(string contentType, int mediaCount, int objectCount) => + ContentFound.Add((contentType, mediaCount, objectCount)); + + public void OnNoContentFound(string contentType) => NoContent.Add(contentType); + + public void OnDownloadComplete(string contentType, DownloadResult result) => + DownloadCompletes.Add((contentType, result)); + + public void OnUserStarting(string username) => Messages.Add($"Starting {username}"); + + public void OnUserComplete(string username, CreatorDownloadResult result) => + Messages.Add($"Completed {username}"); + + public void OnPurchasedTabUserComplete(string username, int paidPostCount, int paidMessagesCount) => + Messages.Add($"Purchased {username}"); + + public void OnScrapeComplete(TimeSpan elapsed) => Messages.Add("Scrape complete"); + + public void OnMessage(string message) => Messages.Add(message); +} + +internal sealed class RecordingStatusReporter : IStatusReporter +{ + private readonly List _statuses; + + public RecordingStatusReporter(string initialStatus) + { + _statuses = [initialStatus]; + } + + public IReadOnlyList Statuses => _statuses; + + public void ReportStatus(string message) => _statuses.Add(message); +} + internal sealed class FakeFileNameService : IFileNameService { public Task BuildFilename(string fileFormat, Dictionary values) => @@ -202,7 +494,23 @@ internal sealed class FakeAuthService : IAuthService public void ValidateCookieString() => throw new NotImplementedException(); - public Task ValidateAuthAsync() => throw new NotImplementedException(); + public Task ValidateAuthAsync() => throw new NotImplementedException(); public void Logout() => throw new NotImplementedException(); } + +internal sealed class FakeLoggingService : ILoggingService +{ + public LoggingLevelSwitch LevelSwitch { get; } = new(); + public LoggingLevel LastLevel { get; private set; } = LoggingLevel.Error; + public int UpdateCount { get; private set; } + + public void UpdateLoggingLevel(LoggingLevel newLevel) + { + UpdateCount++; + LastLevel = newLevel; + LevelSwitch.MinimumLevel = (LogEventLevel)newLevel; + } + + public LoggingLevel GetCurrentLoggingLevel() => (LoggingLevel)LevelSwitch.MinimumLevel; +} diff --git a/OF DL.Tests/Services/TestScopes.cs b/OF DL.Tests/Services/TestScopes.cs new file mode 100644 index 0000000..ea96707 --- /dev/null +++ b/OF DL.Tests/Services/TestScopes.cs @@ -0,0 +1,53 @@ +using System.Reflection; +using OF_DL.Models.OfdlApi; +using OF_DL.Services; + +namespace OF_DL.Tests.Services; + +[CollectionDefinition("NonParallel", DisableParallelization = true)] +public class NonParallelCollection +{ +} + +internal sealed class DynamicRulesCacheScope : IDisposable +{ + private static readonly FieldInfo s_rulesField = + typeof(ApiService).GetField("s_cachedDynamicRules", BindingFlags.NonPublic | BindingFlags.Static) ?? + throw new InvalidOperationException("Unable to access cached rules field."); + + private static readonly FieldInfo s_expirationField = + typeof(ApiService).GetField("s_cachedDynamicRulesExpiration", + BindingFlags.NonPublic | BindingFlags.Static) ?? + throw new InvalidOperationException("Unable to access cached rules expiration field."); + + private readonly object? _priorRules; + private readonly DateTime? _priorExpiration; + + public DynamicRulesCacheScope(DynamicRules rules) + { + _priorRules = s_rulesField.GetValue(null); + _priorExpiration = (DateTime?)s_expirationField.GetValue(null); + + s_rulesField.SetValue(null, rules); + s_expirationField.SetValue(null, DateTime.UtcNow.AddHours(1)); + } + + public void Dispose() + { + s_rulesField.SetValue(null, _priorRules); + s_expirationField.SetValue(null, _priorExpiration); + } +} + +internal sealed class CurrentDirectoryScope : IDisposable +{ + private readonly string _original; + + public CurrentDirectoryScope(string path) + { + _original = Environment.CurrentDirectory; + Environment.CurrentDirectory = path; + } + + public void Dispose() => Environment.CurrentDirectory = _original; +}