Files
douyin/tests/dy.net.Tests/OpenListClientTests.cs

478 lines
22 KiB
C#

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<InvalidOperationException>(() =>
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<HttpRequestMessage, Task<HttpResponseMessage>> 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<HttpRequestMessage, Task<HttpResponseMessage>> _callback;
public CallbackHandler(Func<HttpRequestMessage, Task<HttpResponseMessage>> callback) => _callback = callback;
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) =>
_callback(request);
}
}