using System.Net; using System.Net.Http.Headers; using System.Text; using System.Text.Json; using dy.net.model.entity; using dy.net.storage; namespace dy.net.Tests; public sealed class OpenListClientTests { [Theory] [InlineData("https://openlist.example.test/", "https://openlist.example.test")] [InlineData("https://openlist.example.test/dav", "https://openlist.example.test")] [InlineData("https://openlist.example.test/base/dav/archive", "https://openlist.example.test/base")] [InlineData("https://openlist.example.test/davinci", "https://openlist.example.test/davinci")] public void NormalizeBaseUrl_RemovesOnlyTheDavEndpoint(string input, string expected) => Assert.Equal(expected, OpenListClient.NormalizeBaseUrl(input)); [Fact] public async Task Token_IsCachedAcrossRequests() { var loginCount = 0; using var factory = new PerRequestHttpClientFactory(async request => { if (request.RequestUri!.AbsolutePath.EndsWith("/api/auth/login", StringComparison.Ordinal)) { loginCount++; return Json("{\"code\":200,\"message\":\"success\",\"data\":{\"token\":\"cached-token\"}}"); } Assert.Equal("cached-token", request.Headers.GetValues("Authorization").Single()); if (request.RequestUri.AbsolutePath.EndsWith("/api/fs/list", StringComparison.Ordinal)) return EmptyListing(); return Json("{\"code\":200,\"message\":\"success\",\"data\":{\"name\":\"视频.mp4\",\"size\":18,\"is_dir\":false,\"hash_info\":{}}}"); }); var client = new OpenListClient(factory); Assert.NotNull(await client.TryGetObjectAsync(Settings(), "password", "/归档/作者/视频.mp4")); Assert.NotNull(await client.TryGetObjectAsync(Settings(), "password", "/归档/作者/视频.mp4")); Assert.Equal(1, loginCount); } [Fact] public async Task UnauthorizedResponse_InvalidatesTokenAndLogsInOnceMore() { var loginCount = 0; var getCount = 0; using var factory = new PerRequestHttpClientFactory(async request => { if (request.RequestUri!.AbsolutePath.EndsWith("/api/auth/login", StringComparison.Ordinal)) { loginCount++; return Json(JsonSerializer.Serialize(new { code = 200, message = "success", data = new { token = $"token-{loginCount}" } })); } if (request.RequestUri.AbsolutePath.EndsWith("/api/fs/list", StringComparison.Ordinal)) return EmptyListing(); getCount++; if (getCount == 1) { Assert.Equal("token-1", request.Headers.GetValues("Authorization").Single()); return Json("{\"code\":401,\"message\":\"token expired\",\"data\":null}", HttpStatusCode.Unauthorized); } Assert.Equal("token-2", request.Headers.GetValues("Authorization").Single()); return Json("{\"code\":200,\"message\":\"success\",\"data\":{\"name\":\"视频.mp4\",\"size\":18,\"is_dir\":false,\"hash_info\":{}}}"); }); var client = new OpenListClient(factory); var result = await client.TryGetObjectAsync(Settings(), "password", "/归档/作者/视频.mp4"); Assert.NotNull(result); Assert.Equal(2, loginCount); Assert.Equal(2, getCount); } [Fact] public async Task CopyAndTaskPolling_UseUnicodeSafeOpenListContract() { JsonElement copyPayload = default; using var factory = new PerRequestHttpClientFactory(async request => { var path = request.RequestUri!.AbsolutePath; if (path.EndsWith("/api/auth/login", StringComparison.Ordinal)) return Json("{\"code\":200,\"message\":\"success\",\"data\":{\"token\":\"test-token\"}}"); if (path.EndsWith("/api/fs/copy", StringComparison.Ordinal)) { copyPayload = JsonDocument.Parse(await request.Content!.ReadAsStringAsync()).RootElement.Clone(); return Json("{\"code\":200,\"message\":\"success\",\"data\":{\"tasks\":[{\"id\":\"copy-1\"}]}}"); } if (path.EndsWith("/api/fs/list", StringComparison.Ordinal)) { var body = JsonDocument.Parse(await request.Content!.ReadAsStringAsync()).RootElement; return body.GetProperty("path").GetString() switch { "/" => Listing(("本地挂载", true), ("移动云盘", true)), "/本地挂载" => Listing(("任务", true)), "/本地挂载/任务" => Listing(("作者 名", true)), "/移动云盘" => Listing(("归档", true)), "/移动云盘/归档" => Listing(("作者 名", true)), _ => EmptyListing() }; } Assert.EndsWith("/api/task/copy/info", path, StringComparison.Ordinal); Assert.Contains("tid=copy-1", request.RequestUri.Query, StringComparison.Ordinal); return Json("{\"code\":200,\"message\":\"success\",\"data\":{\"id\":\"copy-1\",\"state\":2,\"progress\":100,\"status\":\"done\",\"error\":\"\"}}"); }); var client = new OpenListClient(factory); var copy = await client.CopyFileAsync(Settings(), "password", "/本地挂载/任务/作者 名/视频 #1.mp4", "/移动云盘/归档/作者 名/视频 #1.mp4"); var task = await client.TryGetCopyTaskAsync(Settings(), "password", copy.TaskIds.Single()); Assert.Equal("/本地挂载/任务/作者 名", copyPayload.GetProperty("src_dir").GetString()); Assert.Equal("/移动云盘/归档/作者 名", copyPayload.GetProperty("dst_dir").GetString()); Assert.Equal("视频 #1.mp4", copyPayload.GetProperty("names")[0].GetString()); Assert.False(copyPayload.GetProperty("overwrite").GetBoolean()); Assert.Equal(2, task!.State); Assert.Equal(100, task.Progress); } [Fact] public async Task OpenRead_PreservesRangeAndOwnsResponseLifetime() { using var factory = new PerRequestHttpClientFactory(async request => { var path = request.RequestUri!.AbsolutePath; if (path.EndsWith("/api/auth/login", StringComparison.Ordinal)) return Json("{\"code\":200,\"message\":\"success\",\"data\":{\"token\":\"test-token\"}}"); if (path.EndsWith("/api/fs/get", StringComparison.Ordinal)) return Json("{\"code\":200,\"message\":\"success\",\"data\":{\"name\":\"视频.mp4\",\"size\":10,\"is_dir\":false,\"hash_info\":{},\"raw_url\":\"/raw/视频.mp4\"}}"); if (path.EndsWith("/api/fs/list", StringComparison.Ordinal)) return EmptyListing(); Assert.Equal("test-token", request.Headers.GetValues("Authorization").Single()); Assert.Equal(new RangeHeaderValue(2, 4).ToString(), request.Headers.Range?.ToString()); var response = new HttpResponseMessage(HttpStatusCode.PartialContent) { Content = new ByteArrayContent(Encoding.UTF8.GetBytes("cde")) }; response.Content.Headers.ContentLength = 3; response.Content.Headers.ContentRange = new ContentRangeHeaderValue(2, 4, 10); return response; }); var client = new OpenListClient(factory); await using var read = await client.OpenReadAsync(Settings(), "password", "/归档/视频.mp4", 2, 4); using var reader = new StreamReader(read.Stream, Encoding.UTF8); Assert.Equal("cde", await reader.ReadToEndAsync()); Assert.Equal(206, read.StatusCode); Assert.Equal("bytes 2-4/10", read.ContentRange); } [Fact] public async Task OpenRead_DoesNotForwardOpenListTokenToExternalSignedUrl() { using var factory = new PerRequestHttpClientFactory(request => { var path = request.RequestUri!.AbsolutePath; if (path.EndsWith("/api/auth/login", StringComparison.Ordinal)) return Task.FromResult(Json("{\"code\":200,\"message\":\"success\",\"data\":{\"token\":\"private-openlist-token\"}}")); if (path.EndsWith("/api/fs/get", StringComparison.Ordinal)) return Task.FromResult(Json("{\"code\":200,\"message\":\"success\",\"data\":{\"name\":\"视频.mp4\",\"size\":10,\"is_dir\":false,\"hash_info\":{},\"raw_url\":\"https://objects.example.test/signed/video.mp4?signature=test\"}}")); if (path.EndsWith("/api/fs/list", StringComparison.Ordinal)) return Task.FromResult(EmptyListing()); Assert.Equal("objects.example.test", request.RequestUri.Host); Assert.False(request.Headers.Contains("Authorization")); Assert.Equal("bytes=0-0", request.Headers.Range?.ToString()); var response = new HttpResponseMessage(HttpStatusCode.PartialContent) { Content = new ByteArrayContent(new byte[] { 42 }) }; response.Content.Headers.ContentLength = 1; response.Content.Headers.ContentRange = new ContentRangeHeaderValue(0, 0, 10); return Task.FromResult(response); }); var client = new OpenListClient(factory); await using var read = await client.OpenReadAsync(Settings(), "password", "/归档/视频.mp4", 0, 0); Assert.Equal(206, read.StatusCode); Assert.Equal(1, read.ContentLength); } [Fact] public async Task CanonicalResolver_ReusesExistingDirectoryWithDifferentCase() { var mkdirCalled = false; using var factory = new PerRequestHttpClientFactory(async request => { var api = request.RequestUri!.AbsolutePath; if (api.EndsWith("/api/auth/login", StringComparison.Ordinal)) return Json("{\"code\":200,\"message\":\"success\",\"data\":{\"token\":\"test-token\"}}"); if (api.EndsWith("/api/fs/mkdir", StringComparison.Ordinal)) { mkdirCalled = true; return Json("{\"code\":200,\"message\":\"success\",\"data\":null}"); } Assert.EndsWith("/api/fs/list", api, StringComparison.Ordinal); var body = JsonDocument.Parse(await request.Content!.ReadAsStringAsync()).RootElement; return body.GetProperty("path").GetString() switch { "/" => Listing(("归档", true)), "/归档" => Listing(("collect", true)), "/归档/collect" => Listing(("KK", true)), "/归档/collect/KK" => Listing(("视频.mp4", false)), _ => EmptyListing() }; }); var client = new OpenListClient(factory); var canonical = await client.ResolveCanonicalObjectPathAsync( Settings(), "password", "/归档/collect/Kk/视频.mp4", true); Assert.Equal("/归档/collect/KK/视频.mp4", canonical); Assert.False(mkdirCalled); } [Fact] public async Task CanonicalResolver_PreservesMissingVideoDirectoryUntilTransferCreatesIt() { var mkdirCalled = false; using var factory = new PerRequestHttpClientFactory(async request => { var api = request.RequestUri!.AbsolutePath; if (api.EndsWith("/api/auth/login", StringComparison.Ordinal)) return Json("{\"code\":200,\"message\":\"success\",\"data\":{\"token\":\"test-token\"}}"); if (api.EndsWith("/api/fs/mkdir", StringComparison.Ordinal)) { mkdirCalled = true; return Json("{\"code\":200,\"message\":\"success\",\"data\":null}"); } Assert.EndsWith("/api/fs/list", api, StringComparison.Ordinal); var body = JsonDocument.Parse(await request.Content!.ReadAsStringAsync()).RootElement; return body.GetProperty("path").GetString() switch { "/" => Listing(("归档", true)), "/归档" => Listing(("follow", true)), "/归档/follow" => Listing(("作者", true)), "/归档/follow/作者" => EmptyListing(), "/归档/follow/作者/新视频" => MissingDirectory(), _ => EmptyListing() }; }); var client = new OpenListClient(factory); var canonical = await client.ResolveCanonicalObjectPathAsync( Settings(), "password", "/归档/follow/作者/新视频/视频.mp4", false); Assert.Equal("/归档/follow/作者/新视频/视频.mp4", canonical); Assert.False(mkdirCalled); } [Fact] public async Task TryGetObject_ReturnsNullWhenMissingDirectoryRemainsMissingDuringForcedRefresh() { using var factory = new PerRequestHttpClientFactory(async request => { var api = request.RequestUri!.AbsolutePath; if (api.EndsWith("/api/auth/login", StringComparison.Ordinal)) return Json("{\"code\":200,\"message\":\"success\",\"data\":{\"token\":\"test-token\"}}"); if (api.EndsWith("/api/fs/get", StringComparison.Ordinal)) return MissingDirectory(); Assert.EndsWith("/api/fs/list", api, StringComparison.Ordinal); var body = JsonDocument.Parse(await request.Content!.ReadAsStringAsync()).RootElement; return body.GetProperty("path").GetString() switch { "/" => Listing(("归档", true)), "/归档" => Listing(("follow", true)), "/归档/follow" => Listing(("作者", true)), "/归档/follow/作者" => EmptyListing(), "/归档/follow/作者/新视频" => MissingDirectory(), _ => EmptyListing() }; }); var client = new OpenListClient(factory); var result = await client.TryGetObjectAsync( Settings(), "password", "/归档/follow/作者/新视频/视频.mp4"); Assert.Null(result); } [Fact] public async Task EnsureDirectory_WaitsUntilCreatedDirectoryIsVisible() { var created = false; var postCreateListings = 0; var mkdirCount = 0; using var factory = new PerRequestHttpClientFactory(async request => { var api = request.RequestUri!.AbsolutePath; if (api.EndsWith("/api/auth/login", StringComparison.Ordinal)) return Json("{\"code\":200,\"message\":\"success\",\"data\":{\"token\":\"test-token\"}}"); if (api.EndsWith("/api/fs/mkdir", StringComparison.Ordinal)) { mkdirCount++; created = true; return Json("{\"code\":200,\"message\":\"success\",\"data\":null}"); } var body = JsonDocument.Parse(await request.Content!.ReadAsStringAsync()).RootElement; return body.GetProperty("path").GetString() switch { "/" => Listing(("归档", true)), "/归档" when created && ++postCreateListings >= 2 => Listing(("作者", true)), _ => EmptyListing() }; }); var client = new OpenListClient(factory, (_, _) => Task.CompletedTask); var actual = await client.EnsureDirectoryAsync(Settings(), "password", "/归档/作者"); Assert.Equal("/归档/作者", actual); Assert.Equal(1, mkdirCount); Assert.True(postCreateListings >= 2); } [Fact] public async Task Delete_UsesCanonicalDirectoryName() { JsonElement removePayload = default; using var factory = new PerRequestHttpClientFactory(async request => { var api = request.RequestUri!.AbsolutePath; if (api.EndsWith("/api/auth/login", StringComparison.Ordinal)) return Json("{\"code\":200,\"message\":\"success\",\"data\":{\"token\":\"test-token\"}}"); if (api.EndsWith("/api/fs/remove", StringComparison.Ordinal)) { removePayload = JsonDocument.Parse(await request.Content!.ReadAsStringAsync()).RootElement.Clone(); return Json("{\"code\":200,\"message\":\"success\",\"data\":null}"); } var body = JsonDocument.Parse(await request.Content!.ReadAsStringAsync()).RootElement; return body.GetProperty("path").GetString() switch { "/" => Listing(("归档", true)), "/归档" => Listing(("collect", true)), "/归档/collect" => Listing(("KK", true)), _ => EmptyListing() }; }); var client = new OpenListClient(factory); await client.DeleteObjectAsync(Settings(), "password", "/归档/collect/Kk"); Assert.Equal("/归档/collect", removePayload.GetProperty("dir").GetString()); Assert.Equal("KK", removePayload.GetProperty("names")[0].GetString()); } [Fact] public async Task EnsureDirectory_RejectsProviderAutoRename() { var renamedExists = false; using var factory = new PerRequestHttpClientFactory(async request => { var api = request.RequestUri!.AbsolutePath; if (api.EndsWith("/api/auth/login", StringComparison.Ordinal)) return Json("{\"code\":200,\"message\":\"success\",\"data\":{\"token\":\"test-token\"}}"); if (api.EndsWith("/api/fs/mkdir", StringComparison.Ordinal)) { renamedExists = true; return Json("{\"code\":200,\"message\":\"success\",\"data\":null}"); } var body = JsonDocument.Parse(await request.Content!.ReadAsStringAsync()).RootElement; return body.GetProperty("path").GetString() switch { "/" => Listing(("归档", true)), "/归档" when renamedExists => Listing(("Kk_20260809_120000", true)), _ => EmptyListing() }; }); var client = new OpenListClient(factory, (_, _) => Task.CompletedTask); var error = await Assert.ThrowsAsync(() => client.EnsureDirectoryAsync(Settings(), "password", "/归档/Kk/子目录")); Assert.Contains("底层存储可能自动改名", error.Message); } [Fact] public async Task ConcurrentEnsure_CreatesDirectoryOnlyOnce() { var exists = 0; var mkdirCount = 0; using var factory = new PerRequestHttpClientFactory(async request => { var api = request.RequestUri!.AbsolutePath; if (api.EndsWith("/api/auth/login", StringComparison.Ordinal)) return Json("{\"code\":200,\"message\":\"success\",\"data\":{\"token\":\"test-token\"}}"); if (api.EndsWith("/api/fs/mkdir", StringComparison.Ordinal)) { Interlocked.Increment(ref mkdirCount); Interlocked.Exchange(ref exists, 1); return Json("{\"code\":200,\"message\":\"success\",\"data\":null}"); } var body = JsonDocument.Parse(await request.Content!.ReadAsStringAsync()).RootElement; return body.GetProperty("path").GetString() switch { "/" => Listing(("归档", true)), "/归档" when Volatile.Read(ref exists) == 1 => Listing(("作者", true)), _ => EmptyListing() }; }); var client = new OpenListClient(factory); var paths = await Task.WhenAll( client.EnsureDirectoryAsync(Settings(), "password", "/归档/作者"), client.EnsureDirectoryAsync(Settings(), "password", "/归档/作者")); Assert.All(paths, path => Assert.Equal("/归档/作者", path)); Assert.Equal(1, mkdirCount); } private static OpenListSettings Settings() => new() { Endpoint = "https://openlist.example.test/dav", UserName = "account", BasePath = "/归档", SourcePath = "/本地挂载", LocalStagingPath = "/tmp/dysync-openlist-tests" }; private static HttpResponseMessage Json(string body, HttpStatusCode status = HttpStatusCode.OK) => new(status) { Content = new StringContent(body, Encoding.UTF8, "application/json") }; private static HttpResponseMessage EmptyListing() => Listing(); private static HttpResponseMessage MissingDirectory() => Json( "{\"code\":500,\"message\":\"failed get objs: failed get dir: object not found\",\"data\":null}"); private static HttpResponseMessage Listing(params (string Name, bool IsDirectory)[] entries) => Json( JsonSerializer.Serialize(new { code = 200, message = "success", data = new { content = entries.Select(x => new { name = x.Name, is_dir = x.IsDirectory, size = 0 }).ToArray(), write = true } })); private sealed class PerRequestHttpClientFactory : IHttpClientFactory, IDisposable { private readonly CallbackHandler _handler; public PerRequestHttpClientFactory(Func> callback) => _handler = new CallbackHandler(callback); public HttpClient CreateClient(string name) => new(_handler, disposeHandler: false); public void Dispose() => _handler.Dispose(); } private sealed class CallbackHandler : HttpMessageHandler { private readonly Func> _callback; public CallbackHandler(Func> callback) => _callback = callback; protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) => _callback(request); } }