478 lines
22 KiB
C#
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);
|
|
}
|
|
}
|