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().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().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 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 _directories = new(StringComparer.Ordinal) { "/" }; public InMemoryOpenListHandler(string localStaging, string sourceRoot) { _localStaging = Path.GetFullPath(localStaging); _sourceRoot = StoragePath.NormalizeRemote(sourceRoot); _directories.Add(_sourceRoot); } public Dictionary Files { get; } = new(StringComparer.Ordinal); public int CopyCount { get; private set; } protected override async Task 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") }; } }