Major refactor #141

Merged
sim0n00ps merged 55 commits from whimsical-c4lic0/OF-DL:refactor-architecture into master 2026-02-13 00:21:58 +00:00
10 changed files with 1450 additions and 49 deletions
Showing only changes of commit 94e135f168 - Show all commits

View File

@ -1,10 +1,15 @@
using System.Reflection; using System.Reflection;
using System.Security.Cryptography; using System.Security.Cryptography;
using System.Text; using System.Text;
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
using OF_DL.Enumerations;
using OF_DL.Models; using OF_DL.Models;
using OF_DL.Models.Config; using OF_DL.Models.Config;
using OF_DL.Models.Entities.Common;
using OF_DL.Models.OfdlApi; using OF_DL.Models.OfdlApi;
using OF_DL.Services; using OF_DL.Services;
using UserEntities = OF_DL.Models.Entities.Users;
namespace OF_DL.Tests.Services; namespace OF_DL.Tests.Services;
@ -101,8 +106,405 @@ public class ApiServiceTests
Assert.Contains("Auth service is missing required fields", ex.Message); 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<string, string> 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<string, int>), " ", null);
Assert.Null(result);
}
[Fact]
public void DeserializeJson_ParsesValidJson()
{
object? result = InvokeDeserializeJson(typeof(Dictionary<string, int>), "{\"a\":1}", null);
Assert.NotNull(result);
Dictionary<string, int> dict = Assert.IsType<Dictionary<string, int>>(result);
Assert.Equal(1, dict["a"]);
}
[Fact]
public void UpdateGetParamsForDateSelection_BeforeAddsBeforePublishTime()
{
Dictionary<string, string> getParams = new();
InvokeUpdateGetParamsForDateSelection(DownloadDateSelection.before, getParams, "123.000000");
Assert.Equal("123.000000", getParams["beforePublishTime"]);
}
[Fact]
public void UpdateGetParamsForDateSelection_AfterAddsAfterPublishTimeAndOrder()
{
Dictionary<string, string> 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 = """
<?xml version="1.0" encoding="UTF-8"?>
<MPD xmlns:cenc="urn:mpeg:cenc:2013">
<Period>
<ContentProtection>
<cenc:pssh>FIRST</cenc:pssh>
<cenc:pssh>SECOND</cenc:pssh>
</ContentProtection>
</Period>
</MPD>
""";
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("<MPD />", 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<string, long>? 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<string, long>? 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<string, long>? result = await service.GetLists("/lists");
Assert.Null(result);
}
[Fact]
public async Task GetListUsers_ReturnsNullWhenAuthMissing()
{
ApiService service = CreateService(new FakeAuthService());
using DynamicRulesCacheScope _ = new(BuildTestRules());
List<string>? 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<long, string>? result = await service.GetMedia(MediaType.Stories, "/users/1/stories", null, "/tmp");
Assert.Null(result);
}
private static ApiService CreateService(FakeAuthService authService) => 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<HttpRequestMessage> InvokeBuildHttpRequestMessage(ApiService service,
Dictionary<string, string> getParams, string endpoint)
{
MethodInfo method = typeof(ApiService).GetMethod("BuildHttpRequestMessage",
BindingFlags.NonPublic | BindingFlags.Instance)
?? throw new InvalidOperationException("BuildHttpRequestMessage not found.");
Task<HttpRequestMessage> task = (Task<HttpRequestMessage>)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<string, string> getParams, string? timestamp)
{
MethodInfo method = typeof(ApiService).GetMethod("UpdateGetParamsForDateSelection",
BindingFlags.NonPublic | BindingFlags.Static, null,
[
typeof(DownloadDateSelection), typeof(Dictionary<string, string>).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) 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}"; 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);
}
}
} }

View File

@ -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<Auth>(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<Auth>(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());
}

View File

@ -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);
}
}

View File

@ -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<Dictionary<string, long>?>(new Dictionary<string, long> { { "alice", 1 } }),
ExpiredSubscriptionsHandler = (_, _) =>
Task.FromResult<Dictionary<string, long>?>(new Dictionary<string, long> { { "bob", 2 } }),
ListsHandler =
_ => Task.FromResult<Dictionary<string, long>?>(new Dictionary<string, long> { { "ignored", 10 } }),
ListUsersHandler = _ => Task.FromResult<List<string>?>(["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<Dictionary<string, long>?>(new Dictionary<string, long> { { "alice", 1 } }),
ListsHandler = _ => Task.FromResult<Dictionary<string, long>?>(new Dictionary<string, long>())
};
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<List<string>?>(["bob"]) };
DownloadOrchestrationService service =
new(apiService, configService, new OrchestrationDownloadServiceStub(), new UserTrackingDbService());
Dictionary<string, long> allUsers = new() { { "alice", 1 }, { "bob", 2 } };
Dictionary<string, long> lists = new() { { "mylist", 5 } };
Dictionary<string, long> 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<string, long>(),
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<long, string> { { 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<string, long>(),
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<long, string> { { 1, "https://example.com/preview.jpg" } },
SingleMessages = new Dictionary<long, string> { { 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<string, long>(),
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<Dictionary<long, string>?>(
new Dictionary<long, string>
{
{ 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<string, long>(), 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<JObject?>(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<JObject?>(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<Config>? 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;
}
}

View File

@ -19,7 +19,7 @@ public class DownloadServiceTests
Directory.CreateDirectory(Path.GetDirectoryName(serverFilePath) ?? throw new InvalidOperationException()); Directory.CreateDirectory(Path.GetDirectoryName(serverFilePath) ?? throw new InvalidOperationException());
await File.WriteAllTextAsync(serverFilePath, "abc"); await File.WriteAllTextAsync(serverFilePath, "abc");
FakeDbService dbService = new() { CheckDownloadedResult = false }; MediaTrackingDbService dbService = new() { CheckDownloadedResult = false };
FakeConfigService configService = new(new Config { ShowScrapeSize = false }); FakeConfigService configService = new(new Config { ShowScrapeSize = false });
DownloadService service = CreateService(configService, dbService); DownloadService service = CreateService(configService, dbService);
ProgressRecorder progress = new(); ProgressRecorder progress = new();
@ -51,7 +51,7 @@ public class DownloadServiceTests
Directory.CreateDirectory(Path.GetDirectoryName(serverFilePath) ?? throw new InvalidOperationException()); Directory.CreateDirectory(Path.GetDirectoryName(serverFilePath) ?? throw new InvalidOperationException());
await File.WriteAllTextAsync(serverFilePath, "abc"); await File.WriteAllTextAsync(serverFilePath, "abc");
FakeDbService dbService = new() { CheckDownloadedResult = true, StoredFileSize = 123 }; MediaTrackingDbService dbService = new() { CheckDownloadedResult = true, StoredFileSize = 123 };
FakeConfigService configService = FakeConfigService configService =
new(new Config { ShowScrapeSize = false, RenameExistingFilesWhenCustomFormatIsSelected = true }); new(new Config { ShowScrapeSize = false, RenameExistingFilesWhenCustomFormatIsSelected = true });
DownloadService service = CreateService(configService, dbService); DownloadService service = CreateService(configService, dbService);
@ -73,9 +73,9 @@ public class DownloadServiceTests
[Fact] [Fact]
public async Task GetDecryptionInfo_UsesOfdlWhenCdmMissing() public async Task GetDecryptionInfo_UsesOfdlWhenCdmMissing()
{ {
FakeApiService apiService = new(); StaticApiService apiService = new();
DownloadService service = 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( (string decryptionKey, DateTime lastModified)? result = await service.GetDecryptionInfo(
"https://example.com/file.mpd", "policy", "signature", "kvp", "1", "2", "post", "https://example.com/file.mpd", "policy", "signature", "kvp", "1", "2", "post",
@ -91,9 +91,9 @@ public class DownloadServiceTests
[Fact] [Fact]
public async Task GetDecryptionInfo_UsesCdmWhenAvailable() public async Task GetDecryptionInfo_UsesCdmWhenAvailable()
{ {
FakeApiService apiService = new(); StaticApiService apiService = new();
DownloadService service = 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( (string decryptionKey, DateTime lastModified)? result = await service.GetDecryptionInfo(
"https://example.com/file.mpd", "policy", "signature", "kvp", "1", "2", "post", "https://example.com/file.mpd", "policy", "signature", "kvp", "1", "2", "post",
@ -109,9 +109,9 @@ public class DownloadServiceTests
[Fact] [Fact]
public async Task DownloadHighlights_ReturnsZeroWhenNoMedia() public async Task DownloadHighlights_ReturnsZeroWhenNoMedia()
{ {
FakeApiService apiService = new() { MediaToReturn = new Dictionary<long, string>() }; StaticApiService apiService = new() { MediaToReturn = new Dictionary<long, string>() };
DownloadService service = 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<long>(), DownloadResult result = await service.DownloadHighlights("user", 1, "/tmp/creator", new HashSet<long>(),
new ProgressRecorder()); new ProgressRecorder());
@ -128,14 +128,14 @@ public class DownloadServiceTests
{ {
using TempFolder temp = new(); using TempFolder temp = new();
string folder = NormalizeFolder(Path.Combine(temp.Path, "creator")); string folder = NormalizeFolder(Path.Combine(temp.Path, "creator"));
FakeApiService apiService = new() StaticApiService apiService = new()
{ {
MediaToReturn = new Dictionary<long, string> MediaToReturn = new Dictionary<long, string>
{ {
{ 1, "https://example.com/one.jpg" }, { 2, "https://example.com/two.jpg" } { 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 }); FakeConfigService configService = new(new Config { ShowScrapeSize = false });
ProgressRecorder progress = new(); ProgressRecorder progress = new();
DownloadService service = CreateService(configService, dbService, apiService); DownloadService service = CreateService(configService, dbService, apiService);
@ -150,10 +150,11 @@ public class DownloadServiceTests
Assert.Equal(2, progress.Total); Assert.Equal(2, progress.Total);
} }
private static DownloadService CreateService(FakeConfigService configService, FakeDbService dbService, private static DownloadService CreateService(FakeConfigService configService, MediaTrackingDbService dbService,
FakeApiService? apiService = null) => StaticApiService? apiService = null) =>
new(new FakeAuthService(), configService, dbService, new FakeFileNameService(), new(new FakeAuthService(), configService, dbService, new FakeFileNameService(),
apiService ?? new FakeApiService()); apiService ?? new StaticApiService());
private static string NormalizeFolder(string folder) => folder.Replace("\\", "/"); private static string NormalizeFolder(string folder) => folder.Replace("\\", "/");
} }

View File

@ -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; }
}

View File

@ -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 = "<div>hello <b>world</b></div>", 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<string> selectedProperties = ["mediaId", "filename", "username", "text", "createdAt", "id"];
Dictionary<string, string> 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 = $"<p>{longText}</p>" };
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<string, string> 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<string, long> users = new() { { "mapped", 55 } };
FileNameService service = new(new FakeAuthService());
Dictionary<string, string> 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<string, string> values = new() { { "username", "creator" }, { "mediaId", "99" } };
string result = await service.BuildFilename("{username}_{mediaId}:*?", values);
Assert.Equal("creator_99", result);
}
}

View File

@ -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;
}
}

View File

@ -2,8 +2,16 @@ using Newtonsoft.Json.Linq;
using OF_DL.Enumerations; using OF_DL.Enumerations;
using OF_DL.Models; using OF_DL.Models;
using OF_DL.Models.Config; using OF_DL.Models.Config;
using OF_DL.Models.Entities.Users; using OF_DL.Models.Downloads;
using OF_DL.Services; 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; namespace OF_DL.Tests.Services;
@ -54,7 +62,7 @@ internal sealed class FakeConfigService(Config config) : IConfigService
public bool ApplyToggleableSelections(List<string> selectedNames) => false; public bool ApplyToggleableSelections(List<string> selectedNames) => false;
} }
internal sealed class FakeDbService : IDbService internal sealed class MediaTrackingDbService : IDbService
{ {
public bool CheckDownloadedResult { get; init; } public bool CheckDownloadedResult { get; init; }
@ -98,7 +106,7 @@ internal sealed class FakeDbService : IDbService
public Task<DateTime?> GetMostRecentPostDate(string folder) => throw new NotImplementedException(); public Task<DateTime?> GetMostRecentPostDate(string folder) => throw new NotImplementedException();
} }
internal sealed class FakeApiService : IApiService internal sealed class StaticApiService : IApiService
{ {
public Dictionary<long, string>? MediaToReturn { get; init; } public Dictionary<long, string>? MediaToReturn { get; init; }
@ -167,7 +175,7 @@ internal sealed class FakeApiService : IApiService
public Task<List<OF_DL.Models.Entities.Purchased.PurchasedTabCollection>> GetPurchasedTab(string endpoint, public Task<List<OF_DL.Models.Entities.Purchased.PurchasedTabCollection>> GetPurchasedTab(string endpoint,
string folder, Dictionary<string, long> users) => throw new NotImplementedException(); string folder, Dictionary<string, long> users) => throw new NotImplementedException();
public Task<User?> GetUserInfo(string endpoint) => public Task<UserEntities.User?> GetUserInfo(string endpoint) =>
throw new NotImplementedException(); throw new NotImplementedException();
public Task<JObject?> GetUserInfoById(string endpoint) => public Task<JObject?> GetUserInfoById(string endpoint) =>
@ -180,6 +188,290 @@ internal sealed class FakeApiService : IApiService
bool includeRestrictedSubscriptions) => throw new NotImplementedException(); bool includeRestrictedSubscriptions) => throw new NotImplementedException();
} }
internal sealed class ConfigurableApiService : IApiService
{
public Func<string, bool, Task<Dictionary<string, long>?>>? ActiveSubscriptionsHandler { get; init; }
public Func<string, bool, Task<Dictionary<string, long>?>>? ExpiredSubscriptionsHandler { get; init; }
public Func<string, Task<Dictionary<string, long>?>>? ListsHandler { get; init; }
public Func<string, Task<List<string>?>>? ListUsersHandler { get; init; }
public Func<MediaType, string, string?, string, Task<Dictionary<long, string>?>>? MediaHandler { get; init; }
public Func<string, string, Task<PostEntities.SinglePostCollection>>? PostHandler { get; init; }
public Func<string, string, Task<PurchasedEntities.SinglePaidMessageCollection>>? PaidMessageHandler { get; init; }
public Func<string, Task<UserEntities.User?>>? UserInfoHandler { get; init; }
public Func<string, Task<JObject?>>? UserInfoByIdHandler { get; init; }
public Task<Dictionary<string, long>?> GetActiveSubscriptions(string endpoint,
bool includeRestrictedSubscriptions) =>
ActiveSubscriptionsHandler?.Invoke(endpoint, includeRestrictedSubscriptions) ??
Task.FromResult<Dictionary<string, long>?>(null);
public Task<Dictionary<string, long>?> GetExpiredSubscriptions(string endpoint,
bool includeRestrictedSubscriptions) =>
ExpiredSubscriptionsHandler?.Invoke(endpoint, includeRestrictedSubscriptions) ??
Task.FromResult<Dictionary<string, long>?>(null);
public Task<Dictionary<string, long>?> GetLists(string endpoint) =>
ListsHandler?.Invoke(endpoint) ?? Task.FromResult<Dictionary<string, long>?>(null);
public Task<List<string>?> GetListUsers(string endpoint) =>
ListUsersHandler?.Invoke(endpoint) ?? Task.FromResult<List<string>?>(null);
public Task<Dictionary<long, string>?> GetMedia(MediaType mediaType, string endpoint, string? username,
string folder) =>
MediaHandler?.Invoke(mediaType, endpoint, username, folder) ??
Task.FromResult<Dictionary<long, string>?>(null);
public Task<PostEntities.SinglePostCollection> GetPost(string endpoint, string folder) =>
PostHandler?.Invoke(endpoint, folder) ?? Task.FromResult(new PostEntities.SinglePostCollection());
public Task<PurchasedEntities.SinglePaidMessageCollection> GetPaidMessage(string endpoint, string folder) =>
PaidMessageHandler?.Invoke(endpoint, folder) ??
Task.FromResult(new PurchasedEntities.SinglePaidMessageCollection());
public Task<UserEntities.User?> GetUserInfo(string endpoint) =>
UserInfoHandler?.Invoke(endpoint) ?? Task.FromResult<UserEntities.User?>(null);
public Task<JObject?> GetUserInfoById(string endpoint) =>
UserInfoByIdHandler?.Invoke(endpoint) ?? Task.FromResult<JObject?>(null);
public Task<PurchasedEntities.PaidPostCollection> GetPaidPosts(string endpoint, string folder, string username,
List<long> paidPostIds, IStatusReporter statusReporter) =>
throw new NotImplementedException();
public Task<PostEntities.PostCollection> GetPosts(string endpoint, string folder, List<long> paidPostIds,
IStatusReporter statusReporter) =>
throw new NotImplementedException();
public Task<StreamEntities.StreamsCollection> GetStreams(string endpoint, string folder, List<long> paidPostIds,
IStatusReporter statusReporter) =>
throw new NotImplementedException();
public Task<ArchivedEntities.ArchivedCollection> GetArchived(string endpoint, string folder,
IStatusReporter statusReporter) =>
throw new NotImplementedException();
public Task<MessageEntities.MessageCollection> GetMessages(string endpoint, string folder,
IStatusReporter statusReporter) =>
throw new NotImplementedException();
public Task<PurchasedEntities.PaidMessageCollection> GetPaidMessages(string endpoint, string folder,
string username, IStatusReporter statusReporter) =>
throw new NotImplementedException();
public Task<Dictionary<string, long>> GetPurchasedTabUsers(string endpoint, Dictionary<string, long> users) =>
throw new NotImplementedException();
public Task<List<PurchasedEntities.PurchasedTabCollection>> GetPurchasedTab(string endpoint, string folder,
Dictionary<string, long> users) =>
throw new NotImplementedException();
public Dictionary<string, string> GetDynamicHeaders(string path, string queryParam) =>
throw new NotImplementedException();
public Task<string> GetDecryptionKeyCdm(Dictionary<string, string> drmHeaders, string licenceUrl, string pssh) =>
throw new NotImplementedException();
public Task<DateTime> GetDrmMpdLastModified(string mpdUrl, string policy, string signature, string kvp) =>
throw new NotImplementedException();
public Task<string> GetDrmMpdPssh(string mpdUrl, string policy, string signature, string kvp) =>
throw new NotImplementedException();
public Task<string> GetDecryptionKeyOfdl(Dictionary<string, string> 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<long> CalculateTotalFileSize(List<string> urls) => Task.FromResult((long)urls.Count);
public Task<bool> 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<DownloadResult> DownloadHighlights(string username, long userId, string path,
HashSet<long> paidPostIds, IProgressReporter progressReporter) =>
Task.FromResult(new DownloadResult());
public Task<DownloadResult> DownloadStories(string username, long userId, string path,
HashSet<long> paidPostIds, IProgressReporter progressReporter) =>
Task.FromResult(StoriesResult ?? new DownloadResult());
public Task<DownloadResult> DownloadArchived(string username, long userId, string path,
Dictionary<string, long> users, bool clientIdBlobMissing, bool devicePrivateKeyMissing,
ArchivedEntities.ArchivedCollection archived, IProgressReporter progressReporter) =>
throw new NotImplementedException();
public Task<DownloadResult> DownloadMessages(string username, long userId, string path,
Dictionary<string, long> users, bool clientIdBlobMissing, bool devicePrivateKeyMissing,
MessageEntities.MessageCollection messages, IProgressReporter progressReporter) =>
throw new NotImplementedException();
public Task<DownloadResult> DownloadPaidMessages(string username, string path, Dictionary<string, long> users,
bool clientIdBlobMissing, bool devicePrivateKeyMissing,
PurchasedEntities.PaidMessageCollection paidMessageCollection, IProgressReporter progressReporter) =>
throw new NotImplementedException();
public Task<DownloadResult> DownloadStreams(string username, long userId, string path,
Dictionary<string, long> users, bool clientIdBlobMissing, bool devicePrivateKeyMissing,
StreamEntities.StreamsCollection streams, IProgressReporter progressReporter) =>
throw new NotImplementedException();
public Task<DownloadResult> DownloadFreePosts(string username, long userId, string path,
Dictionary<string, long> users, bool clientIdBlobMissing, bool devicePrivateKeyMissing,
PostEntities.PostCollection posts, IProgressReporter progressReporter) =>
throw new NotImplementedException();
public Task<DownloadResult> DownloadPaidPosts(string username, long userId, string path,
Dictionary<string, long> users, bool clientIdBlobMissing, bool devicePrivateKeyMissing,
PurchasedEntities.PaidPostCollection purchasedPosts, IProgressReporter progressReporter) =>
throw new NotImplementedException();
public Task<DownloadResult> DownloadPaidPostsPurchasedTab(string username, string path,
Dictionary<string, long> users, bool clientIdBlobMissing, bool devicePrivateKeyMissing,
PurchasedEntities.PaidPostCollection purchasedPosts, IProgressReporter progressReporter) =>
throw new NotImplementedException();
public Task<DownloadResult> DownloadPaidMessagesPurchasedTab(string username, string path,
Dictionary<string, long> users, bool clientIdBlobMissing, bool devicePrivateKeyMissing,
PurchasedEntities.PaidMessageCollection paidMessageCollection, IProgressReporter progressReporter) =>
throw new NotImplementedException();
public Task<DownloadResult> DownloadSinglePost(string username, string path, Dictionary<string, long> users,
bool clientIdBlobMissing, bool devicePrivateKeyMissing, PostEntities.SinglePostCollection post,
IProgressReporter progressReporter)
{
SinglePostCalled = true;
return Task.FromResult(SinglePostResult ?? new DownloadResult());
}
public Task<DownloadResult> DownloadSinglePaidMessage(string username, string path,
Dictionary<string, long> 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<string, long>? CreatedUsers { get; private set; }
public List<string> CreatedDbs { get; } = [];
public (KeyValuePair<string, long> user, string path)? CheckedUser { get; private set; }
public Task CreateDb(string folder)
{
CreatedDbs.Add(folder);
return Task.CompletedTask;
}
public Task CreateUsersDb(Dictionary<string, long> users)
{
CreatedUsers = new Dictionary<string, long>(users);
return Task.CompletedTask;
}
public Task CheckUsername(KeyValuePair<string, long> 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<long> GetStoredFileSize(string folder, long mediaId, string apiType) =>
throw new NotImplementedException();
public Task<bool> CheckDownloaded(string folder, long mediaId, string apiType) =>
throw new NotImplementedException();
public Task<DateTime?> GetMostRecentPostDate(string folder) => throw new NotImplementedException();
}
internal sealed class RecordingDownloadEventHandler : IDownloadEventHandler
{
public List<string> Messages { get; } = [];
public List<(string contentType, int mediaCount, int objectCount)> ContentFound { get; } = [];
public List<string> NoContent { get; } = [];
public List<(string contentType, DownloadResult result)> DownloadCompletes { get; } = [];
public List<(string description, long maxValue, bool showSize)> ProgressCalls { get; } = [];
public Task<T> WithStatusAsync<T>(string statusMessage, Func<IStatusReporter, Task<T>> work) =>
work(new RecordingStatusReporter(statusMessage));
public Task<T> WithProgressAsync<T>(string description, long maxValue, bool showSize,
Func<IProgressReporter, Task<T>> 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<string> _statuses;
public RecordingStatusReporter(string initialStatus)
{
_statuses = [initialStatus];
}
public IReadOnlyList<string> Statuses => _statuses;
public void ReportStatus(string message) => _statuses.Add(message);
}
internal sealed class FakeFileNameService : IFileNameService internal sealed class FakeFileNameService : IFileNameService
{ {
public Task<string> BuildFilename(string fileFormat, Dictionary<string, string> values) => public Task<string> BuildFilename(string fileFormat, Dictionary<string, string> values) =>
@ -202,7 +494,23 @@ internal sealed class FakeAuthService : IAuthService
public void ValidateCookieString() => throw new NotImplementedException(); public void ValidateCookieString() => throw new NotImplementedException();
public Task<User?> ValidateAuthAsync() => throw new NotImplementedException(); public Task<UserEntities.User?> ValidateAuthAsync() => throw new NotImplementedException();
public void Logout() => 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;
}

View File

@ -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;
}