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

317 lines
14 KiB
C#

using System.Net;
using System.Text;
using System.Text.Json;
using dy.net.model.dto;
using dy.net.model.entity;
using dy.net.service;
using dy.net.storage;
using dy.net.Tests.TestInfrastructure;
using Microsoft.AspNetCore.DataProtection;
using SqlSugar;
namespace dy.net.Tests;
public sealed class OpenListTransferServiceTests
{
[Fact]
public async Task Transfer_UsesServerSideCopyAndReusesVerifiedTarget()
{
using var host = await OpenListTransferTestHost.CreateAsync();
var bytes = Encoding.UTF8.GetBytes("OpenList 原生复制测试内容");
await using (var source = new MemoryStream(bytes, writable: false))
Assert.Equal(bytes.Length, await host.Transfers.TransferAsync(
"/关注/作者 名/视频 #1.mp4", source, bytes.Length, CancellationToken.None));
Assert.Equal(bytes, host.Handler.Files["/归档/关注/作者 名/视频 #1.mp4"]);
Assert.Equal(1, host.Handler.CopyCount);
var job = await host.Database.Queryable<OpenListTransferJob>().SingleAsync();
Assert.Equal(OpenListTransferStatus.Succeeded, job.Status);
Assert.False(File.Exists(job.LocalSourcePath));
Assert.DoesNotContain(host.Handler.Files.Keys, x => x.Contains(".dysync-staging-", StringComparison.Ordinal));
await using (var duplicate = new MemoryStream(bytes, writable: false))
Assert.Equal(bytes.Length, await host.Transfers.TransferAsync(
"/关注/作者 名/视频 #1.mp4", duplicate, bytes.Length, CancellationToken.None));
Assert.Equal(1, host.Handler.CopyCount);
}
[Fact]
public async Task RecoverOne_ResumesPersistedQueuedTransferAfterRestart()
{
using var host = await OpenListTransferTestHost.CreateAsync();
var bytes = Encoding.UTF8.GetBytes("restart-safe-transfer");
var id = "restartjob";
var localDirectory = Path.Combine(host.Settings.LocalStagingPath, id);
Directory.CreateDirectory(localDirectory);
var localPath = Path.Combine(localDirectory, "恢复.mp4");
await File.WriteAllBytesAsync(localPath, bytes);
var now = DateTime.Now;
await host.Database.Insertable(new OpenListTransferJob
{
Id = id,
Status = OpenListTransferStatus.Queued,
ConfigurationFingerprint = StorageConfigurationFingerprint.Create(host.Settings),
LogicalTargetPath = "/恢复/恢复.mp4",
ActualTargetPath = "/归档/恢复/恢复.mp4",
LocalSourcePath = localPath,
OpenListSourcePath = "/源挂载/restartjob/恢复.mp4",
StagedTargetPath = "/归档/.dysync-staging-restartjob/恢复.mp4",
ExpectedLength = bytes.Length,
CreatedAt = now,
UpdatedAt = now
}).ExecuteCommandAsync();
Assert.True(await host.Transfers.RecoverOneAsync(CancellationToken.None));
var job = await host.Database.Queryable<OpenListTransferJob>().InSingleAsync(id);
Assert.Equal(OpenListTransferStatus.Succeeded, job.Status);
Assert.Equal(bytes, host.Handler.Files["/归档/恢复/恢复.mp4"]);
Assert.False(File.Exists(localPath));
}
private sealed class OpenListTransferTestHost : IDisposable
{
private readonly TemporaryDirectory _temporary;
private readonly PerRequestFactory _factory;
private OpenListTransferTestHost(
TemporaryDirectory temporary,
SqlSugarClient database,
PerRequestFactory factory,
InMemoryOpenListHandler handler,
OpenListSettings settings,
OpenListTransferService transfers)
{
_temporary = temporary;
Database = database;
_factory = factory;
Handler = handler;
Settings = settings;
Transfers = transfers;
}
public SqlSugarClient Database { get; }
public InMemoryOpenListHandler Handler { get; }
public OpenListSettings Settings { get; }
public OpenListTransferService Transfers { get; }
public static async Task<OpenListTransferTestHost> CreateAsync()
{
var temporary = new TemporaryDirectory();
try
{
var database = new SqlSugarClient(new ConnectionConfig
{
ConnectionString = $"DataSource={Path.Combine(temporary.Path, "openlist.sqlite")}",
DbType = DbType.Sqlite,
InitKeyType = InitKeyType.Attribute,
IsAutoCloseConnection = true
});
database.CodeFirst.InitTables(typeof(WebDavSettings), typeof(OpenListSettings), typeof(OpenListTransferJob));
var localStaging = Path.Combine(temporary.Path, "staging");
Directory.CreateDirectory(localStaging);
var handler = new InMemoryOpenListHandler(localStaging, "/源挂载");
var factory = new PerRequestFactory(handler);
var protection = DataProtectionProvider.Create(
new DirectoryInfo(Path.Combine(temporary.Path, "keys")),
builder => builder.SetApplicationName("dysync.net"));
var legacy = new WebDavSettingsService(database, protection);
var settingsService = new OpenListSettingsService(database, protection, legacy);
var settings = await settingsService.BuildCandidateAsync(new OpenListTestRequest
{
Endpoint = "https://openlist.example.test",
BasePath = "/归档",
LocalStagingPath = localStaging,
SourcePath = "/源挂载",
UserName = "account",
Password = "password"
});
await settingsService.SaveAsync(settings, true, "tested");
var client = new OpenListClient(factory);
var transfers = new OpenListTransferService(database, settingsService, client);
return new OpenListTransferTestHost(temporary, database, factory, handler, settings, transfers);
}
catch
{
temporary.Dispose();
throw;
}
}
public void Dispose()
{
Database.Dispose();
_factory.Dispose();
_temporary.Dispose();
}
}
private sealed class PerRequestFactory : IHttpClientFactory, IDisposable
{
private readonly HttpMessageHandler _handler;
public PerRequestFactory(HttpMessageHandler handler) => _handler = handler;
public HttpClient CreateClient(string name) => new(_handler, disposeHandler: false);
public void Dispose() => _handler.Dispose();
}
private sealed class InMemoryOpenListHandler : HttpMessageHandler
{
private readonly string _localStaging;
private readonly string _sourceRoot;
private readonly HashSet<string> _directories = new(StringComparer.Ordinal) { "/" };
public InMemoryOpenListHandler(string localStaging, string sourceRoot)
{
_localStaging = Path.GetFullPath(localStaging);
_sourceRoot = StoragePath.NormalizeRemote(sourceRoot);
_directories.Add(_sourceRoot);
}
public Dictionary<string, byte[]> Files { get; } = new(StringComparer.Ordinal);
public int CopyCount { get; private set; }
protected override async Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
{
var api = request.RequestUri!.AbsolutePath;
if (api.EndsWith("/api/auth/login", StringComparison.Ordinal))
return Json("{\"code\":200,\"message\":\"success\",\"data\":{\"token\":\"test-token\"}}");
var body = request.Content == null
? default
: JsonDocument.Parse(await request.Content.ReadAsStringAsync(cancellationToken)).RootElement.Clone();
if (api.EndsWith("/api/fs/get", StringComparison.Ordinal))
return Get(body.GetProperty("path").GetString()!);
if (api.EndsWith("/api/fs/list", StringComparison.Ordinal))
return List(body.GetProperty("path").GetString()!);
if (api.EndsWith("/api/fs/mkdir", StringComparison.Ordinal))
{
AddDirectory(body.GetProperty("path").GetString()!);
return Success();
}
if (api.EndsWith("/api/fs/copy", StringComparison.Ordinal))
{
CopyCount++;
var sourceDirectory = body.GetProperty("src_dir").GetString()!;
var targetDirectory = body.GetProperty("dst_dir").GetString()!;
var name = body.GetProperty("names")[0].GetString()!;
var bytes = ReadFile(StoragePath.CombineRemote(sourceDirectory, name));
Files[StoragePath.CombineRemote(targetDirectory, name)] = bytes;
AddDirectory(targetDirectory);
return Json("{\"code\":200,\"message\":\"success\",\"data\":{\"tasks\":[]}}");
}
if (api.EndsWith("/api/fs/move", StringComparison.Ordinal))
{
var sourceDirectory = body.GetProperty("src_dir").GetString()!;
var targetDirectory = body.GetProperty("dst_dir").GetString()!;
var name = body.GetProperty("names")[0].GetString()!;
var source = StoragePath.CombineRemote(sourceDirectory, name);
var target = StoragePath.CombineRemote(targetDirectory, name);
Files[target] = Files[source];
Files.Remove(source);
AddDirectory(targetDirectory);
return Success();
}
if (api.EndsWith("/api/fs/rename", StringComparison.Ordinal))
{
var source = StoragePath.NormalizeRemote(body.GetProperty("path").GetString());
var target = StoragePath.CombineRemote(StoragePath.DirectoryName(source), body.GetProperty("name").GetString());
Files[target] = Files[source];
Files.Remove(source);
return Success();
}
if (api.EndsWith("/api/fs/remove", StringComparison.Ordinal))
{
var path = StoragePath.CombineRemote(body.GetProperty("dir").GetString(), body.GetProperty("names")[0].GetString());
Files.Remove(path);
foreach (var child in Files.Keys.Where(x => x.StartsWith(path + "/", StringComparison.Ordinal)).ToList())
Files.Remove(child);
_directories.Remove(path);
return Success();
}
throw new InvalidOperationException($"Unexpected OpenList API request: {api}");
}
private HttpResponseMessage Get(string rawPath)
{
var path = StoragePath.NormalizeRemote(rawPath);
if (Files.TryGetValue(path, out var bytes)) return Object(path, bytes.Length, false);
if (TryMapSource(path, out var local) && File.Exists(local)) return Object(path, new FileInfo(local).Length, false);
if (_directories.Contains(path)) return Object(path, 0, true);
return Json("{\"code\":500,\"message\":\"object not found\",\"data\":null}");
}
private HttpResponseMessage List(string rawPath)
{
var path = StoragePath.NormalizeRemote(rawPath);
var content = _directories
.Where(x => x != path && StoragePath.DirectoryName(x) == path)
.Select(x => new { name = Path.GetFileName(x), size = 0L, is_dir = true })
.Concat(Files.Where(x => StoragePath.DirectoryName(x.Key) == path)
.Select(x => new { name = Path.GetFileName(x.Key), size = (long)x.Value.Length, is_dir = false }))
.ToArray();
return Json(JsonSerializer.Serialize(new
{
code = 200,
message = "success",
data = new { content, write = true }
}));
}
private byte[] ReadFile(string path)
{
if (Files.TryGetValue(path, out var bytes)) return bytes;
if (TryMapSource(path, out var local) && File.Exists(local)) return File.ReadAllBytes(local);
throw new FileNotFoundException("OpenList source missing", path);
}
private bool TryMapSource(string path, out string local)
{
if (path.Equals(_sourceRoot, StringComparison.Ordinal))
{
local = _localStaging;
return true;
}
if (!path.StartsWith(_sourceRoot + "/", StringComparison.Ordinal))
{
local = string.Empty;
return false;
}
var segments = path[(_sourceRoot.Length + 1)..].Split('/');
local = segments.Aggregate(_localStaging, Path.Combine);
local = Path.GetFullPath(local);
return local.StartsWith(_localStaging + Path.DirectorySeparatorChar, StringComparison.Ordinal);
}
private void AddDirectory(string rawPath)
{
var path = StoragePath.NormalizeRemote(rawPath);
var current = string.Empty;
foreach (var segment in path.Split('/', StringSplitOptions.RemoveEmptyEntries))
{
current = StoragePath.CombineRemote(current, segment);
_directories.Add(current);
}
}
private static HttpResponseMessage Object(string path, long size, bool directory) => Json(
JsonSerializer.Serialize(new
{
code = 200,
message = "success",
data = new { name = Path.GetFileName(path), size, is_dir = directory, hash_info = new { } }
}));
private static HttpResponseMessage Success() => Json("{\"code\":200,\"message\":\"success\",\"data\":null}");
private static HttpResponseMessage Json(string body) => new(HttpStatusCode.OK)
{
Content = new StringContent(body, Encoding.UTF8, "application/json")
};
}
}