feat: add fnOS packaging, storage workflows and release pipeline

This commit is contained in:
2026-08-11 18:05:49 +08:00
parent c5922f9b08
commit 95932f0199
181 changed files with 24024 additions and 1164 deletions
@@ -0,0 +1,34 @@
using dy.net.model.entity;
using dy.net.service;
using dy.net.Tests.TestInfrastructure;
using Microsoft.AspNetCore.DataProtection;
namespace dy.net.Tests;
public class DataProtectionPersistenceTests
{
[Fact]
public async Task WebDavPassword_DecryptsAfterProviderReconstructionFromPersistentKeys()
{
using var temporaryDirectory = new TemporaryDirectory();
var keyDirectory = new DirectoryInfo(Path.Combine(temporaryDirectory.Path, "keys"));
const string password = "持久化-password-123";
var firstProvider = CreateProvider(keyDirectory);
var protectedPassword = firstProvider
.CreateProtector("dysync.webdav.password.v1")
.Protect(password);
var reconstructedProvider = CreateProvider(keyDirectory);
var service = new WebDavSettingsService(null, reconstructedProvider);
var decrypted = await service.GetPasswordAsync(new WebDavSettings
{
ProtectedPassword = protectedPassword
});
Assert.Equal(password, decrypted);
}
private static IDataProtectionProvider CreateProvider(DirectoryInfo directory) =>
DataProtectionProvider.Create(directory, builder => builder.SetApplicationName("dysync.net"));
}
+293
View File
@@ -0,0 +1,293 @@
using System.Net;
using System.Text;
using dy.net.model.dto;
using dy.net.model.entity;
using dy.net.service;
using dy.net.Tests.TestInfrastructure;
using dy.net.utils;
using Microsoft.AspNetCore.DataProtection;
using Newtonsoft.Json.Linq;
using SqlSugar;
namespace dy.net.Tests;
public class DouyinLiveStatusTests
{
[Fact]
public void Parser_ExtractsLiveRoomFromStringRoomData()
{
var roomData = JToken.FromObject("{\"status\":2,\"id_str\":\"room-1\",\"web_rid\":\"web-1\",\"title\":\"测试直播\",\"start_time\":1700000000}");
var result = DouyinLiveStatusParser.Parse(1, null, null, roomData);
Assert.Equal(DouyinLiveStatusState.Live, result.Status);
Assert.Equal("room-1", result.RoomId);
Assert.Equal("web-1", result.WebRid);
Assert.Equal("测试直播", result.Title);
Assert.NotNull(result.StartedAt);
}
[Fact]
public void Parser_DoesNotTreatMissingStatusAsOffline()
{
var result = DouyinLiveStatusParser.Parse(null, null, null, null);
Assert.Equal(DouyinLiveStatusState.Unknown, result.Status);
}
[Fact]
public async Task Client_SignsProfileRequestAndParsesLiveState()
{
var handler = new LiveProfileResponseHandler();
using var factory = new StubHttpClientFactory(handler, "https://www.douyin.com");
var client = new DouyinLiveStatusClient(factory);
var result = await client.ProbeAsync("sec-user-1", "msToken=token-value; UIFID=ui-value");
Assert.Equal(DouyinLiveStatusState.Live, result.Status);
Assert.Equal("web-live-1", result.WebRid);
Assert.NotNull(handler.Request);
Assert.Equal("/aweme/v1/web/user/profile/other/", handler.Request.RequestUri.AbsolutePath);
Assert.Contains("sec_user_id=sec-user-1", handler.Request.RequestUri.Query);
Assert.Contains("a_bogus=", handler.Request.RequestUri.Query);
Assert.Equal("msToken=token-value; UIFID=ui-value", handler.Cookie);
Assert.Equal("https://www.douyin.com/user/sec-user-1", handler.Request.Headers.Referrer?.ToString());
}
[Fact]
public async Task Monitor_SendsOnlyOncePerLiveRoomAndRearmsAfterOffline()
{
using var temporary = new TemporaryDirectory();
using var db = CreateDatabase(Path.Combine(temporary.Path, "live.sqlite"));
db.CodeFirst.InitTables<DouyinCookie, DouyinFollowed, EmailNotificationSettings>();
db.Insertable(new DouyinCookie
{
Id = "cookie-1", MyUserId = "my-user-1", UserName = "账号",
Cookies = "msToken=value", Status = 1, StatusCode = 0
}).ExecuteCommand();
db.Insertable(new DouyinFollowed
{
Id = "follow-1", mySelfId = "my-user-1", SecUid = "sec-1", UperName = "测试博主",
UperId = "uid-1", LastSyncTime = DateTime.UtcNow
}).ExecuteCommand();
db.Insertable(new EmailNotificationSettings
{
Id = "default", Enabled = true, Host = "smtp.test", Port = 465,
FromAddress = "from@example.test", Recipients = "to@example.test"
}).ExecuteCommand();
var liveClient = new FakeLiveStatusClient
{
Result = new DouyinLiveStatusProbe
{
Status = DouyinLiveStatusState.Live, RoomId = "room-1", WebRid = "web-1", Title = "第一场"
}
};
var emailSender = new FakeEmailSender();
var settings = new EmailNotificationSettingsService(db, new EphemeralDataProtectionProvider(), emailSender);
var service = new DouyinLiveStatusService(
db,
liveClient,
new LiveEmailNotificationService(settings, emailSender));
var enabled = await service.SetMonitorAsync(new FollowLiveMonitorUpdateDto { Id = "follow-1", Enabled = true });
Assert.Equal(DouyinLiveStatusState.Live, enabled.LiveStatus);
await service.SetEmailNotificationAsync(new FollowLiveEmailUpdateDto { Id = "follow-1", Enabled = true });
Assert.Equal(1, emailSender.SendCount);
await MakeDueAsync(db, "follow-1");
await service.RefreshDueAsync();
Assert.Equal(1, emailSender.SendCount);
liveClient.Result = new DouyinLiveStatusProbe { Status = DouyinLiveStatusState.Offline };
await MakeDueAsync(db, "follow-1");
await service.RefreshDueAsync();
liveClient.Result = new DouyinLiveStatusProbe
{
Status = DouyinLiveStatusState.Live, RoomId = "room-2", WebRid = "web-2", Title = "第二场"
};
await MakeDueAsync(db, "follow-1");
await service.RefreshDueAsync();
Assert.Equal(2, emailSender.SendCount);
var follow = await db.Queryable<DouyinFollowed>().InSingleAsync("follow-1");
Assert.Equal("web:web-2", follow.LastLiveNotificationKey);
Assert.Null(follow.LastLiveNotificationError);
}
[Fact]
public async Task EmailFailure_DoesNotChangeSuccessfulLiveState()
{
using var temporary = new TemporaryDirectory();
using var db = CreateDatabase(Path.Combine(temporary.Path, "mail-failure.sqlite"));
db.CodeFirst.InitTables<DouyinCookie, DouyinFollowed, EmailNotificationSettings>();
db.Insertable(new DouyinCookie
{
Id = "cookie-1", MyUserId = "my-user-1", UserName = "账号",
Cookies = "msToken=value", Status = 1, StatusCode = 0
}).ExecuteCommand();
db.Insertable(new DouyinFollowed
{
Id = "follow-1", mySelfId = "my-user-1", SecUid = "sec-1", UperName = "测试博主",
UperId = "uid-1", LastSyncTime = DateTime.UtcNow, LiveMonitorEnabled = true,
LiveEmailNotificationEnabled = true
}).ExecuteCommand();
db.Insertable(new EmailNotificationSettings
{
Id = "default", Enabled = true, Host = "smtp.test", Port = 465,
FromAddress = "from@example.test", Recipients = "to@example.test"
}).ExecuteCommand();
var sender = new FakeEmailSender { Error = new IOException("smtp unavailable") };
var settings = new EmailNotificationSettingsService(db, new EphemeralDataProtectionProvider(), sender);
var service = new DouyinLiveStatusService(
db,
new FakeLiveStatusClient
{
Result = new DouyinLiveStatusProbe { Status = DouyinLiveStatusState.Live, WebRid = "web-1" }
},
new LiveEmailNotificationService(settings, sender));
await service.RefreshDueAsync();
var follow = await db.Queryable<DouyinFollowed>().InSingleAsync("follow-1");
Assert.Equal(DouyinLiveStatusState.Live, follow.LiveStatus);
Assert.Null(follow.LiveCheckError);
Assert.Contains("smtp unavailable", follow.LastLiveNotificationError);
}
[Fact]
public async Task EmailSettings_EncryptsStoredPassword()
{
using var temporary = new TemporaryDirectory();
using var db = CreateDatabase(Path.Combine(temporary.Path, "email-settings.sqlite"));
db.CodeFirst.InitTables<EmailNotificationSettings>();
var service = new EmailNotificationSettingsService(
db,
new EphemeralDataProtectionProvider(),
new FakeEmailSender());
var dto = await service.SaveAsync(new EmailNotificationSettingsDto
{
Enabled = true,
Host = "smtp.example.test",
Port = 465,
SecurityMode = EmailSecurityMode.SslOnConnect,
UserName = "sender@example.test",
Password = "secret-auth-code",
FromAddress = "sender@example.test",
FromName = "dysync",
Recipients = "one@example.test;two@example.test"
});
var stored = await db.Queryable<EmailNotificationSettings>().InSingleAsync("default");
Assert.True(dto.HasPassword);
Assert.NotEqual("secret-auth-code", stored.ProtectedPassword);
Assert.Equal("secret-auth-code", await service.GetPasswordAsync(stored));
Assert.Equal("one@example.test;two@example.test", stored.Recipients);
}
[Fact]
public async Task ForbiddenResponse_KeepsLastStateAndStartsAccountCooldown()
{
using var temporary = new TemporaryDirectory();
using var db = CreateDatabase(Path.Combine(temporary.Path, "cooldown.sqlite"));
db.CodeFirst.InitTables<DouyinCookie, DouyinFollowed, EmailNotificationSettings>();
db.Insertable(new DouyinCookie
{
Id = "cookie-1", MyUserId = "my-user-1", UserName = "账号",
Cookies = "msToken=value", Status = 1, StatusCode = 0
}).ExecuteCommand();
db.Insertable(new DouyinFollowed
{
Id = "follow-1", mySelfId = "my-user-1", SecUid = "sec-1", UperName = "测试博主",
UperId = "uid-1", LastSyncTime = DateTime.UtcNow, LiveMonitorEnabled = true,
LiveStatus = DouyinLiveStatusState.Live, LiveWebRid = "existing-room",
LiveStatusUpdatedAt = DateTime.UtcNow.AddMinutes(-6)
}).ExecuteCommand();
var liveClient = new FakeLiveStatusClient
{
Error = new DouyinLiveStatusRequestException(
"抖音直播状态请求失败(HTTP 403",
HttpStatusCode.Forbidden,
requiresAccountCooldown: true)
};
var sender = new FakeEmailSender();
var settings = new EmailNotificationSettingsService(db, new EphemeralDataProtectionProvider(), sender);
var service = new DouyinLiveStatusService(db, liveClient, new LiveEmailNotificationService(settings, sender));
await service.RefreshDueAsync();
var follow = await db.Queryable<DouyinFollowed>().InSingleAsync("follow-1");
var cookie = await db.Queryable<DouyinCookie>().InSingleAsync("cookie-1");
Assert.Equal(DouyinLiveStatusState.Live, follow.LiveStatus);
Assert.Equal("existing-room", follow.LiveWebRid);
Assert.Contains("HTTP 403", follow.LiveCheckError);
Assert.True(cookie.LiveCheckCooldownUntil > DateTime.UtcNow);
Assert.Equal(1, cookie.ConsecutiveLiveCheckFailures);
}
private static async Task MakeDueAsync(ISqlSugarClient db, string id)
{
var follow = await db.Queryable<DouyinFollowed>().InSingleAsync(id);
follow.LiveCheckedAt = DateTime.UtcNow.AddMinutes(-6);
await db.Updateable(follow).UpdateColumns(x => x.LiveCheckedAt).ExecuteCommandAsync();
}
private static SqlSugarClient CreateDatabase(string path) => new(new ConnectionConfig
{
ConnectionString = $"Data Source={path}",
DbType = DbType.Sqlite,
IsAutoCloseConnection = true,
InitKeyType = InitKeyType.Attribute
});
private sealed class FakeLiveStatusClient : IDouyinLiveStatusClient
{
public DouyinLiveStatusProbe Result { get; set; }
public Exception Error { get; set; }
public int Calls { get; private set; }
public Task<DouyinLiveStatusProbe> ProbeAsync(string secUid, string cookie, CancellationToken cancellationToken = default)
{
Calls++;
if (Error != null) throw Error;
return Task.FromResult(Result);
}
}
private sealed class FakeEmailSender : IEmailNotificationSender
{
public int SendCount { get; private set; }
public Exception Error { get; set; }
public Task SendAsync(
EmailNotificationSettings settings,
string password,
string subject,
string htmlBody,
CancellationToken cancellationToken = default)
{
SendCount++;
if (Error != null) throw Error;
return Task.CompletedTask;
}
}
private sealed class LiveProfileResponseHandler : HttpMessageHandler
{
public HttpRequestMessage Request { get; private set; }
public string Cookie { get; private set; }
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
{
Request = request;
Cookie = request.Headers.GetValues("Cookie").Single();
const string json = "{\"status_code\":0,\"user\":{\"live_status\":1,\"room_id_str\":\"room-live-1\",\"room_data\":\"{\\\"status\\\":2,\\\"web_rid\\\":\\\"web-live-1\\\",\\\"title\\\":\\\"正在直播\\\"}\"}}";
return Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK)
{
Content = new StringContent(json, Encoding.UTF8, "application/json")
});
}
}
}
+208
View File
@@ -0,0 +1,208 @@
using System.Net;
using System.Text;
using dy.net.model.dto;
using dy.net.model.entity;
using dy.net.repository;
using dy.net.service;
using dy.net.Tests.TestInfrastructure;
using dy.net.utils;
using SqlSugar;
namespace dy.net.Tests;
public class DouyinUserLookupTests
{
[Fact]
public void ABogusSigner_MatchesMaintainedProtocolFixture()
{
const string query = "device_platform=webapp&aid=6383&keyword=test&count=10";
const string expected = "E7mhBdLkdD2kDDyh56KLfY3q65EVYhxI0SVkMD2f--dPqL39HMYh9exoIBGvXY8jwG/-Ieujy4hbT3ohrQ2y0Hwf9W0L/25ksDSkKl5Q5xSSs1X9eghgJ04qmkt5SMx2RvB-rOXmqhZHKRbp09oHmhK4bIOwu3GMbE==";
var actual = new DouyinABogusSigner().Sign(
query,
"GET",
1700000000000,
1700000000005,
1234.5,
2345.5,
3456.5);
Assert.Equal(expected, actual);
}
[Theory]
[InlineData("abc_123", "abc_123")]
[InlineData(" @abc_123 ", "abc_123")]
[InlineData("抖音号:abc_123 IP属地:上海", "abc_123")]
[InlineData("Douyin: creator-01", "creator-01")]
public void NormalizeDouyinNo_AcceptsCommonCopiedFormats(string input, string expected)
{
Assert.Equal(expected, DouyinUserLookupService.NormalizeDouyinNo(input));
}
[Theory]
[InlineData("")]
[InlineData(" ")]
[InlineData("@")]
[InlineData("抖音号:")]
[InlineData("abc 123")]
public void NormalizeDouyinNo_RejectsInvalidValues(string input)
{
Assert.Throws<InvalidOperationException>(() => DouyinUserLookupService.NormalizeDouyinNo(input));
}
[Fact]
public void NormalizeDouyinNo_RejectsMoreThan64Characters()
{
Assert.Throws<InvalidOperationException>(() =>
DouyinUserLookupService.NormalizeDouyinNo(new string('a', 65)));
}
[Fact]
public async Task SearchAsync_ParsesCandidatesAndPrefersUniqueIdOverShortId()
{
var handler = new SearchResponseHandler();
using var factory = new StubHttpClientFactory(handler, "https://www.douyin.com");
var client = new DouyinUserSearchClient(factory);
var candidates = await client.SearchAsync("creator_name", "msToken=token-value; UIFID=ui-value");
var candidate = Assert.Single(candidates);
Assert.Equal("sec-user-1", candidate.SecUid);
Assert.Equal("uid-1", candidate.UperId);
Assert.Equal("creator_name", candidate.UniqueId);
Assert.Equal("123456", candidate.ShortId);
Assert.Equal("creator_name", candidate.DouyinNo);
Assert.Equal("测试博主", candidate.UperName);
Assert.Equal("https://example.test/avatar.jpg", candidate.UperAvatar);
Assert.Equal(12345, candidate.FollowerCount);
Assert.NotNull(handler.Request);
Assert.Equal("/aweme/v1/web/discover/search/", handler.Request.RequestUri.AbsolutePath);
Assert.Contains("search_channel=aweme_user_web", handler.Request.RequestUri.Query);
Assert.Contains("keyword=creator_name", handler.Request.RequestUri.Query);
Assert.Contains("a_bogus=", handler.Request.RequestUri.Query);
Assert.Contains("msToken=token-value", handler.Request.RequestUri.Query);
Assert.Equal("https://www.douyin.com/root/search/creator_name?type=user", handler.Request.Headers.Referrer?.ToString());
Assert.Equal("msToken=token-value; UIFID=ui-value", handler.Cookie);
}
[Fact]
public async Task ResolveAsync_MarksExactMatchAndDuplicateForSelectedAccount()
{
using var temporary = new TemporaryDirectory();
using var db = CreateDatabase(Path.Combine(temporary.Path, "lookup.sqlite"));
db.CodeFirst.InitTables<DouyinCookie, DouyinFollowed>();
db.Insertable(new DouyinCookie
{
Id = "cookie-1",
MyUserId = "my-user-1",
UserName = "我的账号",
Cookies = "msToken=token-value",
Status = 1,
StatusCode = 0
}).ExecuteCommand();
db.Insertable(new DouyinFollowed
{
Id = "follow-1",
mySelfId = "my-user-1",
SecUid = "sec-user-1",
UperId = "uid-1",
UperName = "已有博主",
LastSyncTime = DateTime.UtcNow
}).ExecuteCommand();
using var factory = new StubHttpClientFactory(new SearchResponseHandler(), "https://www.douyin.com");
var service = new DouyinUserLookupService(db, new DouyinUserSearchClient(factory));
var result = await service.ResolveAsync(new DouyinFollowLookupRequest
{
CookieId = "cookie-1",
DouyinNo = "@creator_name"
});
var candidate = Assert.Single(result.Candidates);
Assert.Equal("creator_name", result.Query);
Assert.True(candidate.ExactMatch);
Assert.True(candidate.AlreadyExists);
}
[Fact]
public async Task FollowGroups_IncludeEnabledCookieWithNoFollowRows()
{
using var temporary = new TemporaryDirectory();
using var db = CreateDatabase(Path.Combine(temporary.Path, "groups.sqlite"));
db.CodeFirst.InitTables<DouyinCookie, DouyinFollowed>();
db.Insertable(new DouyinCookie
{
Id = "cookie-empty",
MyUserId = "my-empty-user",
UserName = "零关注账号",
Cookies = "msToken=token-value",
Status = 1,
StatusCode = 0,
StatusMsg = "正常"
}).ExecuteCommand();
var groups = await new DouyinFollowRepository(db).GetDouyinFollowGroup();
var group = Assert.Single(groups);
Assert.Equal("cookie-empty", group.CookieId);
Assert.Equal("my-empty-user", group.Key);
Assert.Equal(0, group.Total);
Assert.Equal(1, group.Status);
}
private static SqlSugarClient CreateDatabase(string path) => new(new ConnectionConfig
{
ConnectionString = $"Data Source={path}",
DbType = DbType.Sqlite,
IsAutoCloseConnection = true,
InitKeyType = InitKeyType.Attribute
});
private sealed class SearchResponseHandler : HttpMessageHandler
{
public HttpRequestMessage Request { get; private set; }
public string Cookie { get; private set; }
protected override Task<HttpResponseMessage> SendAsync(
HttpRequestMessage request,
CancellationToken cancellationToken)
{
Request = request;
Cookie = request.Headers.TryGetValues("Cookie", out var values)
? values.Single()
: string.Empty;
const string json = @"
{
""status_code"": 0,
""user_list"": [
{
""user_info"": {
""sec_uid"": ""sec-user-1"",
""uid"": ""uid-1"",
""unique_id"": ""creator_name"",
""short_id"": ""123456"",
""nickname"": ""测试博主"",
""signature"": ""测试签名"",
""enterprise_verify_reason"": ""认证信息"",
""follower_count"": 12345,
""avatar_thumb"": { ""url_list"": [""https://example.test/avatar.jpg""] }
}
},
{
""user_info"": {
""sec_uid"": ""sec-user-1"",
""uid"": ""duplicate"",
""short_id"": ""999""
}
}
]
}";
return Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK)
{
Content = new StringContent(json, Encoding.UTF8, "application/json")
});
}
}
}
+48
View File
@@ -0,0 +1,48 @@
using dy.net.utils;
using dy.net.Tests.TestInfrastructure;
namespace dy.net.Tests;
public class FFmpegHelperTests
{
[Fact]
public void ImageVideoArguments_WithoutAudio_ProduceVideoOnlyCommandWithoutNulls()
{
var arguments = FFmpegHelper.BuildImageVideoArguments(
"/tmp/frames/frame_%03d.webp",
null,
"/tmp/output.mp4",
0.33,
"[0:v]scale=1080:1920[v]",
1080,
1920);
Assert.DoesNotContain(arguments, value => value == null);
Assert.Contains("-an", arguments);
Assert.DoesNotContain("1:a", arguments);
Assert.Equal(1, arguments.Count(value => value == "-i"));
Assert.Equal("/tmp/output.mp4", arguments[^1]);
}
[Fact]
public void ImageVideoArguments_WithAudio_MapAudioAndUseShortestDuration()
{
using var temporary = new TemporaryDirectory();
var audioPath = Path.Combine(temporary.Path, "audio.mp3");
File.WriteAllBytes(audioPath, new byte[] { 1, 2, 3 });
var arguments = FFmpegHelper.BuildImageVideoArguments(
"/tmp/frames/frame_%03d.webp",
audioPath,
"/tmp/output.mp4",
0.5,
"[0:v]scale=1080:1920[v]",
1080,
1920);
Assert.DoesNotContain("-an", arguments);
Assert.Contains("1:a", arguments);
Assert.Contains("-shortest", arguments);
Assert.Equal(2, arguments.Count(value => value == "-i"));
Assert.Contains(audioPath, arguments);
}
}
+1
View File
@@ -0,0 +1 @@
global using Xunit;
@@ -0,0 +1,66 @@
using dy.net.extension;
using dy.net.model.entity;
using dy.net.service;
using dy.net.Tests.TestInfrastructure;
using Microsoft.Data.Sqlite;
using Microsoft.Extensions.DependencyInjection;
using SqlSugar;
namespace dy.net.Tests;
public class LegacyDatabaseUpgradeTests
{
[Fact]
public void LegacyDatabase_UpgradeBacksUpAndPreservesRowsWhileAddingMigrationSchema()
{
using var temporary = new TemporaryDirectory();
var dbRoot = Path.Combine(temporary.Path, "db");
Directory.CreateDirectory(Path.Combine(dbRoot, "keys"));
File.WriteAllText(Path.Combine(dbRoot, "keys", "key.xml"), "persistent-key");
var databasePath = Path.Combine(dbRoot, "dy.sqlite");
using (var connection = new SqliteConnection($"Data Source={databasePath}"))
{
connection.Open();
using var command = connection.CreateCommand();
command.CommandText = "CREATE TABLE dy_collect_video (Id TEXT PRIMARY KEY, AwemeId TEXT, VideoSavePath TEXT);" +
"INSERT INTO dy_collect_video (Id, AwemeId, VideoSavePath) VALUES ('old-1','aweme-old','/old/video.mp4');";
command.ExecuteNonQuery();
}
var backup = DatabaseUpgradeBackup.Prepare(temporary.Path);
var services = new ServiceCollection();
services.AddSqlsugar(temporary.Path);
using var provider = services.BuildServiceProvider();
using var scope = provider.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<ISqlSugarClient>();
Assert.Equal("/old/video.mp4", db.Queryable<DouyinVideo>().Where(x => x.Id == "old-1").Select(x => x.VideoSavePath).Single());
Assert.True(db.DbMaintenance.IsAnyTable("storage_migration_task", false));
Assert.True(db.DbMaintenance.IsAnyTable("storage_migration_item", false));
Assert.True(db.DbMaintenance.IsAnyTable("video_download_task", false));
Assert.True(db.DbMaintenance.IsAnyTable("video_download_task_item", false));
Assert.True(db.DbMaintenance.IsAnyTable("media_storage_health", false));
Assert.True(db.DbMaintenance.IsAnyTable("dy_openlist_settings", false));
Assert.True(db.DbMaintenance.IsAnyTable("openlist_transfer_job", false));
Assert.Contains(db.DbMaintenance.GetColumnInfosByTableName("storage_migration_task", false), x => x.DbColumnName == "RemovedCount");
Assert.Contains(db.DbMaintenance.GetColumnInfosByTableName("storage_migration_task", false), x => x.DbColumnName == "IsArchived");
Assert.Contains(db.DbMaintenance.GetColumnInfosByTableName("storage_migration_task", false), x => x.DbColumnName == "TargetStorageType");
Assert.Contains(db.DbMaintenance.GetColumnInfosByTableName("storage_migration_item", false), x => x.DbColumnName == "SourceStorageType");
Assert.Contains(db.DbMaintenance.GetColumnInfosByTableName("storage_migration_item", false), x => x.DbColumnName == "AdoptExistingTarget");
Assert.Contains(db.DbMaintenance.GetColumnInfosByTableName("dy_collect_video", false), x => x.DbColumnName == "StorageType");
Assert.Contains(db.DbMaintenance.GetColumnInfosByTableName("dy_rd_video", false), x => x.DbColumnName == "TaskId");
Assert.Contains(db.DbMaintenance.GetColumnInfosByTableName("dy_delete_video", false), x => x.DbColumnName == "RestoreSnapshotJson");
Assert.Contains(db.DbMaintenance.GetColumnInfosByTableName("dy_cookie", false), x => x.DbColumnName == "SourceCooldownUntil");
Assert.Contains(db.DbMaintenance.GetColumnInfosByTableName("dy_cookie", false), x => x.DbColumnName == "SourceRequiresAuthorization");
Assert.Contains(db.DbMaintenance.GetColumnInfosByTableName("dy_cookie", false), x => x.DbColumnName == "LiveCheckCooldownUntil");
Assert.Contains(db.DbMaintenance.GetColumnInfosByTableName("dy_follow", false), x => x.DbColumnName == "LiveMonitorEnabled");
Assert.Contains(db.DbMaintenance.GetColumnInfosByTableName("dy_follow", false), x => x.DbColumnName == "LiveStatus");
Assert.Contains(db.DbMaintenance.GetColumnInfosByTableName("dy_follow", false), x => x.DbColumnName == "LiveEmailNotificationEnabled");
Assert.True(db.DbMaintenance.IsAnyTable("dy_email_notification_settings", false));
Assert.Contains(db.DbMaintenance.GetColumnInfosByTableName("video_download_task_item", false), x => x.DbColumnName == "HttpStatusCode");
var backupDirectory = Directory.GetDirectories(Path.Combine(dbRoot, "upgrade-backups")).Single();
Assert.True(File.Exists(Path.Combine(backupDirectory, "dy.sqlite")));
Assert.Equal("persistent-key", File.ReadAllText(Path.Combine(backupDirectory, "keys", "key.xml")));
backup.MarkSchemaReady();
}
}
+65
View File
@@ -0,0 +1,65 @@
using dy.net.model.dto;
using dy.net.storage;
using dy.net.Tests.TestInfrastructure;
using Xunit;
namespace dy.net.Tests;
public class LiveWebDavTests
{
[LiveWebDavFact("ALIST")]
public Task AList_ProbeContract() => ProbeAsync("ALIST");
[LiveWebDavFact("OPENLIST")]
public Task OpenList_ProbeContract() => ProbeAsync("OPENLIST");
private static async Task ProbeAsync(string provider)
{
var prefix = $"DYSYNC_TEST_{provider}_";
var basePath = StoragePath.NormalizeRemote(Environment.GetEnvironmentVariable(prefix + "BASE_PATH"));
if (basePath == "/" || !basePath.Contains("dysync-test", StringComparison.OrdinalIgnoreCase))
throw new InvalidOperationException($"{prefix}BASE_PATH 必须是包含 dysync-test 的非根目录");
var runPath = StoragePath.CombineRemote(basePath, "run-" + Guid.NewGuid().ToString("N"));
var factory = new LiveHttpClientFactory();
using var host = await WebDavTestHost.CreateAsync(factory, factory, new WebDavTestRequest
{
Endpoint = Environment.GetEnvironmentVariable(prefix + "ENDPOINT"),
BasePath = runPath,
UserName = Environment.GetEnvironmentVariable(prefix + "USERNAME"),
Password = Environment.GetEnvironmentVariable(prefix + "PASSWORD"),
AllowInvalidCertificate = ReadBoolean(prefix + "ALLOW_INVALID_CERTIFICATE")
});
try
{
var result = await host.Storage.ProbeAsync(host.Settings);
Assert.True(result.Success, result.Message);
}
finally
{
// The host is configured to a random child of the explicitly safe test path.
// Deleting "/" here deletes only that random child, never the WebDAV account root.
await host.Storage.DeleteAsync("/");
}
}
private static bool ReadBoolean(string name)
{
var value = Environment.GetEnvironmentVariable(name);
return value == "1" || bool.TryParse(value, out var parsed) && parsed;
}
}
[AttributeUsage(AttributeTargets.Method)]
internal sealed class LiveWebDavFactAttribute : FactAttribute
{
public LiveWebDavFactAttribute(string provider)
{
var prefix = $"DYSYNC_TEST_{provider}_";
var required = new[] { "ENDPOINT", "BASE_PATH", "USERNAME", "PASSWORD" };
var missing = required.Where(name => string.IsNullOrWhiteSpace(Environment.GetEnvironmentVariable(prefix + name))).ToList();
if (missing.Count > 0)
Skip = $"未配置可选 {provider} 实测环境变量:{string.Join(", ", missing)}";
}
}
@@ -0,0 +1,73 @@
using System.Text;
using dy.net.storage;
using dy.net.Tests.TestInfrastructure;
namespace dy.net.Tests;
public class LocalMediaStorageTests
{
[Fact]
public async Task WriteAsync_CreatesAndAtomicallyOverwritesFile()
{
using var temporaryDirectory = new TemporaryDirectory();
var storage = new LocalMediaStorage();
var path = Path.Combine(temporaryDirectory.Path, "nested", "video.mp4");
await WriteAsync(storage, path, "first");
await WriteAsync(storage, path, "replacement");
Assert.Equal("replacement", await File.ReadAllTextAsync(path));
Assert.Empty(Directory.GetFiles(Path.GetDirectoryName(path), "*.part-*"));
}
[Fact]
public async Task ExistsLengthRangeAndDelete_FollowStorageContract()
{
using var temporaryDirectory = new TemporaryDirectory();
var storage = new LocalMediaStorage();
var path = Path.Combine(temporaryDirectory.Path, "sample.mp4");
await WriteAsync(storage, path, "0123456789");
Assert.True(await storage.ExistsAsync(path));
Assert.Equal(10, await storage.GetLengthAsync(path));
await using (var range = await storage.OpenReadAsync(path, 2, 5))
{
Assert.Equal(206, range.StatusCode);
Assert.Equal(4, range.ContentLength);
Assert.Equal("bytes 2-5/10", range.ContentRange);
var bytes = new byte[range.ContentLength.Value];
var read = await range.Stream.ReadAsync(bytes);
Assert.Equal(bytes.Length, read);
Assert.Equal("2345", Encoding.UTF8.GetString(bytes));
}
await storage.DeleteAsync(path);
Assert.False(await storage.ExistsAsync(path));
Assert.Null(await storage.GetLengthAsync(path));
}
[Fact]
public async Task WriteAsync_RejectsEmptyAndLengthMismatchWithoutReplacingExistingFile()
{
using var temporaryDirectory = new TemporaryDirectory();
var storage = new LocalMediaStorage();
var path = Path.Combine(temporaryDirectory.Path, "video.mp4");
await WriteAsync(storage, path, "original");
await using (var empty = new MemoryStream(Array.Empty<byte>(), false))
await Assert.ThrowsAsync<IOException>(() => storage.WriteAsync(path, empty, 0, "video/mp4"));
await using (var shortSource = new MemoryStream(Encoding.UTF8.GetBytes("short"), false))
await Assert.ThrowsAsync<IOException>(() => storage.WriteAsync(path, shortSource, 99, "video/mp4"));
Assert.Equal("original", await File.ReadAllTextAsync(path));
Assert.Empty(Directory.GetFiles(temporaryDirectory.Path, "*.part-*"));
}
private static async Task WriteAsync(LocalMediaStorage storage, string path, string content)
{
var bytes = Encoding.UTF8.GetBytes(content);
await using var source = new MemoryStream(bytes, false);
await storage.WriteAsync(path, source, bytes.Length, "video/mp4");
}
}
+212
View File
@@ -0,0 +1,212 @@
using System.Net;
using System.Net.Http.Headers;
using dy.net.model.dto;
using dy.net.service;
using dy.net.Tests.TestInfrastructure;
namespace dy.net.Tests;
public class MediaDownloadTests
{
[Fact]
public async Task RedirectToCdn_DoesNotForwardDouyinCookie()
{
using var temporary = new TemporaryDirectory();
var handler = new RecordingMediaHandler(request =>
{
if (request.RequestUri.Host == "www.douyin.com")
{
var redirect = new HttpResponseMessage(HttpStatusCode.Redirect);
redirect.Headers.Location = new Uri("https://v3.douyinvod.com/media/video.mp4");
return redirect;
}
var success = new HttpResponseMessage(HttpStatusCode.OK)
{
Content = new ByteArrayContent(new byte[] { 1, 2, 3, 4 })
};
success.Content.Headers.ContentType = new MediaTypeHeaderValue("video/mp4");
return success;
});
using var factory = new StubHttpClientFactory(handler);
var service = new DouyinHttpClientService(factory, null);
var result = await service.DownloadAsync(
"https://www.douyin.com/aweme/v1/play?id=signed-secret",
Path.Combine(temporary.Path, "video.mp4"),
"sessionid=secret-cookie");
Assert.True(result.Success);
Assert.Equal(2, handler.Requests.Count);
Assert.Equal("sessionid=secret-cookie", handler.Requests[0].Cookie);
Assert.Null(handler.Requests[1].Cookie);
Assert.Equal("v3.douyinvod.com", result.SourceHost);
}
[Fact]
public async Task DuplicateCandidates_AreTriedOnceAndAllForbiddenIsStructured()
{
using var temporary = new TemporaryDirectory();
var handler = new RecordingMediaHandler(_ => new HttpResponseMessage(HttpStatusCode.Forbidden));
using var factory = new StubHttpClientFactory(handler);
var service = new DouyinHttpClientService(factory, null);
var first = "https://www.douyin.com/media/one?signature=secret-one";
var second = "https://www.douyin.com/media/two?signature=secret-two";
var result = await service.DownloadAsync(first, Path.Combine(temporary.Path, "video.mp4"), "sessionid=secret",
new List<string> { first, second, second }, maxRetryCount: 12);
Assert.False(result.Success);
Assert.Equal(MediaDownloadFailureKind.SourceForbidden, result.FailureKind);
Assert.Equal(403, result.HttpStatusCode);
Assert.Equal(2, result.AttemptedUrlCount);
Assert.Equal(2, handler.Requests.Count);
}
[Fact]
public async Task MixedCandidateErrors_AreNotCountedAsAllForbidden()
{
using var temporary = new TemporaryDirectory();
var handler = new RecordingMediaHandler(request => new HttpResponseMessage(
request.RequestUri.AbsolutePath.EndsWith("one", StringComparison.Ordinal)
? HttpStatusCode.NotFound
: HttpStatusCode.Forbidden));
using var factory = new StubHttpClientFactory(handler);
var service = new DouyinHttpClientService(factory, null);
var result = await service.DownloadAsync("https://www.douyin.com/media/one",
Path.Combine(temporary.Path, "video.mp4"), "sessionid=secret",
new List<string> { "https://www.douyin.com/media/two" }, maxRetryCount: 12);
Assert.False(result.Success);
Assert.Equal(MediaDownloadFailureKind.SourceUnavailable, result.FailureKind);
Assert.Equal(2, result.AttemptedUrlCount);
}
[Fact]
public async Task Unauthorized_StopsWithoutTryingMoreCandidates()
{
using var temporary = new TemporaryDirectory();
var handler = new RecordingMediaHandler(_ => new HttpResponseMessage(HttpStatusCode.Unauthorized));
using var factory = new StubHttpClientFactory(handler);
var service = new DouyinHttpClientService(factory, null);
var result = await service.DownloadAsync("https://www.douyin.com/media/one",
Path.Combine(temporary.Path, "video.mp4"), "sessionid=secret",
new List<string> { "https://www.douyin.com/media/two" });
Assert.Equal(MediaDownloadFailureKind.SourceUnauthorized, result.FailureKind);
Assert.Single(handler.Requests);
}
[Theory]
[InlineData(HttpStatusCode.NotFound)]
[InlineData(HttpStatusCode.Gone)]
public async Task MissingOrGoneSource_IsAnItemLevelFailure(HttpStatusCode status)
{
using var temporary = new TemporaryDirectory();
var handler = new RecordingMediaHandler(_ => new HttpResponseMessage(status));
using var factory = new StubHttpClientFactory(handler);
var service = new DouyinHttpClientService(factory, null);
var result = await service.DownloadAsync("https://www.douyin.com/media/one",
Path.Combine(temporary.Path, "video.mp4"), "sessionid=secret");
Assert.Equal(MediaDownloadFailureKind.SourceNotFound, result.FailureKind);
Assert.Equal((int)status, result.HttpStatusCode);
}
[Fact]
public async Task RateLimit_HonorsRetryAfterOnceThenReturnsStructuredCooldown()
{
using var temporary = new TemporaryDirectory();
var handler = new RecordingMediaHandler(_ =>
{
var response = new HttpResponseMessage((HttpStatusCode)429);
response.Headers.RetryAfter = new RetryConditionHeaderValue(DateTimeOffset.UtcNow);
return response;
});
using var factory = new StubHttpClientFactory(handler);
var service = new DouyinHttpClientService(factory, null);
var result = await service.DownloadAsync("https://www.douyin.com/media/one",
Path.Combine(temporary.Path, "video.mp4"), "sessionid=secret");
Assert.Equal(MediaDownloadFailureKind.SourceRateLimited, result.FailureKind);
Assert.Equal(2, handler.Requests.Count);
Assert.NotNull(result.RetryAfter);
}
[Fact]
public async Task MediaConnectTimeout_RetriesTwiceWithBackoffThenSucceeds()
{
using var temporary = new TemporaryDirectory();
var requestCount = 0;
var delays = new List<TimeSpan>();
var handler = new RecordingMediaHandler(_ =>
{
requestCount++;
if (requestCount <= 2) throw new HttpRequestException("connect timeout");
var response = new HttpResponseMessage(HttpStatusCode.OK)
{
Content = new ByteArrayContent(new byte[] { 1, 2, 3 })
};
response.Content.Headers.ContentType = new MediaTypeHeaderValue("video/mp4");
return response;
});
using var factory = new StubHttpClientFactory(handler);
var service = new DouyinHttpClientService(factory, null, (delay, _) =>
{
delays.Add(delay);
return Task.CompletedTask;
});
var result = await service.DownloadAsync("https://www.douyin.com/media/retry",
Path.Combine(temporary.Path, "video.mp4"), "sessionid=secret");
Assert.True(result.Success);
Assert.Equal(3, requestCount);
Assert.Equal(2, delays.Count);
Assert.True(delays[1] > delays[0]);
}
[Fact]
public async Task FollowPostConnectTimeout_RetriesBeforeFailingTheAuthorScan()
{
var requestCount = 0;
var handler = new RecordingMediaHandler(_ =>
{
requestCount++;
if (requestCount <= 2) throw new TaskCanceledException("connect timeout");
return new HttpResponseMessage(HttpStatusCode.OK)
{
Content = new StringContent("{}")
};
});
using var factory = new StubHttpClientFactory(handler, "https://www.douyin.com");
var service = new DouyinHttpClientService(factory, null, (_, _) => Task.CompletedTask);
var result = await service.SyncUpderPostVideos("20", "0", "sec-user", "sessionid=secret");
Assert.NotNull(result);
Assert.Equal(3, requestCount);
}
private sealed record RecordedMediaRequest(string Host, string Cookie);
private sealed class RecordingMediaHandler : HttpMessageHandler
{
private readonly Func<HttpRequestMessage, HttpResponseMessage> _response;
public RecordingMediaHandler(Func<HttpRequestMessage, HttpResponseMessage> response) => _response = response;
public List<RecordedMediaRequest> Requests { get; } = new();
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
{
Requests.Add(new RecordedMediaRequest(
request.RequestUri.Host,
request.Headers.TryGetValues("Cookie", out var values) ? values.Single() : null));
return Task.FromResult(_response(request));
}
}
}
+477
View File
@@ -0,0 +1,477 @@
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);
}
}
@@ -0,0 +1,135 @@
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;
using MediaStorageType = dy.net.model.dto.StorageType;
namespace dy.net.Tests;
public sealed class OpenListDirectoryRepairServiceTests
{
[Fact]
public async Task Preflight_OnlyIncludesExactTimestampDirectoriesAndKeepsCanonicalDirectory()
{
using var temporary = new TemporaryDirectory();
using var database = new SqlSugarClient(new ConnectionConfig
{
ConnectionString = $"DataSource={Path.Combine(temporary.Path, "repair.sqlite")}",
DbType = DbType.Sqlite,
InitKeyType = InitKeyType.Attribute,
IsAutoCloseConnection = true
});
database.CodeFirst.InitTables<AppConfig, WebDavSettings, OpenListSettings,
OpenListDirectoryRepairTask, OpenListDirectoryRepairItem>();
await database.Insertable(new AppConfig
{
Id = "config",
StorageType = MediaStorageType.OpenList,
FollowedTitleTemplate = string.Empty,
FollowedTitleSeparator = string.Empty,
FullFollowedTitleTemplate = string.Empty
}).ExecuteCommandAsync();
var provider = DataProtectionProvider.Create(new DirectoryInfo(Path.Combine(temporary.Path, "keys")),
builder => builder.SetApplicationName("dysync.net"));
var legacy = new WebDavSettingsService(database, provider);
var settingsService = new OpenListSettingsService(database, provider, legacy);
var settings = await settingsService.BuildCandidateAsync(new OpenListTestRequest
{
Endpoint = "https://openlist.example.test",
BasePath = "/归档",
SourcePath = "/源挂载",
LocalStagingPath = Path.Combine(temporary.Path, "staging"),
UserName = "account",
Password = "password"
});
await settingsService.SaveAsync(settings, true, "tested");
using var factory = new RepairHttpClientFactory();
var service = new OpenListDirectoryRepairService(database, new DouyinCommonService(database),
settingsService, new OpenListClient(factory));
var preflight = await service.PreflightAsync(
new OpenListDirectoryRepairPreflightRequest { LogicalPath = "/collect/Kk" },
CancellationToken.None);
Assert.True(preflight.CanStart);
Assert.Equal("/collect/Kk", preflight.RequestedPath);
Assert.Equal("/collect/KK", preflight.CanonicalPath);
Assert.Equal(1, preflight.CandidateCount);
var created = await service.CreateAsync(new CreateOpenListDirectoryRepairRequest
{
LogicalPath = preflight.RequestedPath,
ConfigurationFingerprint = preflight.ConfigurationFingerprint
}, CancellationToken.None);
var items = await database.Queryable<OpenListDirectoryRepairItem>().ToListAsync();
Assert.Equal(1, created.Task.TotalCount);
var item = Assert.Single(items);
Assert.Equal("Kk_20260809_123456", item.DirectoryName);
Assert.DoesNotContain(items, x => x.DirectoryName == "KK");
}
private sealed class RepairHttpClientFactory : IHttpClientFactory, IDisposable
{
private readonly Handler _handler = new();
public HttpClient CreateClient(string name) => new(_handler, disposeHandler: false);
public void Dispose() => _handler.Dispose();
}
private sealed class Handler : HttpMessageHandler
{
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(new { code = 200, message = "success", data = new { token = "test-token" } });
var body = JsonDocument.Parse(await request.Content!.ReadAsStringAsync(cancellationToken)).RootElement;
var path = body.GetProperty("path").GetString();
if (api.EndsWith("/api/fs/get", StringComparison.Ordinal))
{
if (path == "/归档/collect/KK")
return Json(new { code = 200, message = "success", data = new { name = "KK", size = 0, is_dir = true, hash_info = new { } } });
return Json(new { code = 404, message = "object not found", data = (object)null });
}
Assert.EndsWith("/api/fs/list", api, StringComparison.Ordinal);
var entries = path switch
{
"/" => new[] { Entry("归档", true) },
"/归档" => new[] { Entry("collect", true) },
"/归档/collect" => new[]
{
Entry("KK", true),
Entry("Kk_20260809_123456", true),
Entry("Kk_20260809_12345", true),
Entry("Kk_20260809_123456_extra", true),
Entry("KK_20260809_123456", true),
Entry("Kkkk_20260809_123456", true),
Entry("Kk_20260810_123456", false)
},
_ => Array.Empty<object>()
};
return Json(new { code = 200, message = "success", data = new { content = entries, write = true } });
}
private static object Entry(string name, bool directory) => new { name, size = 0, is_dir = directory };
private static HttpResponseMessage Json(object value) => new(HttpStatusCode.OK)
{
Content = new StringContent(JsonSerializer.Serialize(value), Encoding.UTF8, "application/json")
};
}
}
@@ -0,0 +1,316 @@
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")
};
}
}
@@ -0,0 +1,84 @@
using dy.net.extension;
using Microsoft.Extensions.DependencyInjection;
using Quartz;
namespace dy.net.Tests;
public class QuartzPersistentStoreTests
{
[Fact]
public async Task ManualTrigger_WithStringTaskContext_IsPersistedWithoutBinaryFormatter()
{
var dataRoot = Path.Combine(Path.GetTempPath(), $"dysync-quartz-{Guid.NewGuid():N}");
Directory.CreateDirectory(dataRoot);
try
{
var schemaServices = new ServiceCollection();
schemaServices.AddSqlsugar(dataRoot);
var legacyJobKey = new JobKey($"legacy-{Guid.NewGuid():N}", "tests");
var legacyServices = new ServiceCollection();
legacyServices.AddLogging();
legacyServices.AddQuartz(q =>
{
q.SchedulerId = "DouyinQuartzScheduler";
q.SchedulerName = "DouyinSyncScheduler";
q.UsePersistentStore(store =>
{
store.UseMicrosoftSQLite(sqlite =>
{
sqlite.ConnectionString = $"DataSource={Path.Combine(dataRoot, "db", "dy.sqlite")}";
sqlite.TablePrefix = "QRTZ_";
});
store.UseProperties = false;
store.UseBinarySerializer();
});
});
await using var legacyProvider = legacyServices.BuildServiceProvider();
var legacyScheduler = await legacyProvider.GetRequiredService<ISchedulerFactory>().GetScheduler();
var legacyJob = JobBuilder.Create<NoOpJob>()
.WithIdentity(legacyJobKey)
.StoreDurably()
.Build();
await legacyScheduler.AddJob(legacyJob, replace: false);
await legacyScheduler.Shutdown(waitForJobsToComplete: false);
var services = new ServiceCollection();
services.AddLogging();
services.AddQuartzService(dataRoot);
await using var provider = services.BuildServiceProvider();
var scheduler = await provider.GetRequiredService<ISchedulerFactory>().GetScheduler();
Assert.True(await scheduler.CheckExists(legacyJobKey));
Assert.True(await scheduler.DeleteJob(legacyJobKey));
var jobKey = new JobKey($"manual-{Guid.NewGuid():N}", "tests");
var job = JobBuilder.Create<NoOpJob>()
.WithIdentity(jobKey)
.StoreDurably()
.Build();
await scheduler.AddJob(job, replace: false);
var exception = await Record.ExceptionAsync(() => scheduler.TriggerJob(jobKey, new JobDataMap
{
["video-task-id"] = "task-id",
["video-task-trigger"] = "manual"
}));
Assert.Null(exception);
await scheduler.DeleteJob(jobKey);
await scheduler.Shutdown(waitForJobsToComplete: false);
}
finally
{
if (Directory.Exists(dataRoot)) Directory.Delete(dataRoot, recursive: true);
}
}
private sealed class NoOpJob : IJob
{
public Task Execute(IJobExecutionContext context) => Task.CompletedTask;
}
}
@@ -0,0 +1,152 @@
using dy.net.job;
using dy.net.model.dto;
using dy.net.model.entity;
using dy.net.model.response;
using dy.net.service;
using Quartz;
namespace dy.net.Tests;
public class QuartzTaskContextTests
{
[Fact]
public void EmptyScheduledJobData_DoesNotRequireManualTaskKeys()
{
var context = DouyinBasicSyncJob.ResolveTaskContext(new JobDataMap());
Assert.Null(context.TaskId);
Assert.Equal(VideoTaskTrigger.Scheduled, context.Trigger);
}
[Fact]
public void ManualJobData_ReusesProvidedTaskId()
{
var data = new JobDataMap
{
["video-task-id"] = "task-id",
["video-task-trigger"] = "manual"
};
var context = DouyinBasicSyncJob.ResolveTaskContext(data);
Assert.Equal("task-id", context.TaskId);
Assert.Equal(VideoTaskTrigger.Manual, context.Trigger);
}
[Fact]
public void LocalRecord_IsReplacedWhenCurrentStorageIsWebDav()
{
var local = new dy.net.model.entity.DouyinVideo { StorageType = StorageType.Local };
var remote = new dy.net.model.entity.DouyinVideo { StorageType = StorageType.WebDav };
Assert.True(DouyinBasicSyncJob.ShouldReplaceOldStorageRecord(local, StorageType.WebDav));
Assert.False(DouyinBasicSyncJob.ShouldReplaceOldStorageRecord(remote, StorageType.WebDav));
Assert.False(DouyinBasicSyncJob.ShouldReplaceOldStorageRecord(remote, StorageType.Local));
Assert.False(DouyinBasicSyncJob.ShouldReplaceOldStorageRecord(null, StorageType.WebDav));
}
[Theory]
[InlineData(true, null, null)]
[InlineData(false, true, null)]
[InlineData(false, false, true)]
[InlineData(false, false, false)]
public void BestMatchedVideoUrl_MissingMediaData_ReturnsNull(
bool nullAweme,
bool? nullVideo,
bool? nullBitRates)
{
Aweme aweme = null;
if (!nullAweme)
{
aweme = new Aweme();
if (nullVideo != true)
aweme.Video = new Video { BitRate = nullBitRates == true ? null : new List<VideoBitRate>() };
}
var selected = DouyinBasicSyncJob.GetBestMatchedVideoUrl(aweme, new AppConfig());
Assert.Null(selected);
}
[Fact]
public void BestMatchedVideoUrl_IgnoresMalformedBitRatesAndEmptyUrls()
{
var aweme = new Aweme
{
Video = new Video
{
BitRate = new List<VideoBitRate>
{
null,
new() { PlayAddr = null },
new() { PlayAddr = new PlayAddr { UrlList = null } },
new() { PlayAddr = new PlayAddr { UrlList = new List<string> { "", " " } } }
}
}
};
Assert.Null(DouyinBasicSyncJob.GetBestMatchedVideoUrl(aweme, new AppConfig()));
Assert.Empty(DouyinBasicSyncJob.GetMediaCandidates(aweme, null));
}
[Fact]
public void BestMatchedVideoUrl_PrefersH265AndFallsBackToH264()
{
var h264Low = BitRate("https://media.test/h264-low", isH265: 0, value: 100);
var h264High = BitRate("https://media.test/h264-high", isH265: 0, value: 300);
var h265 = BitRate("https://media.test/h265", isH265: 1, value: 200);
var aweme = new Aweme { Video = new Video { BitRate = new List<VideoBitRate> { h264Low, h265, h264High } } };
Assert.Same(h265, DouyinBasicSyncJob.GetBestMatchedVideoUrl(aweme, new AppConfig { VideoEncoder = 265 }));
Assert.Same(h264High, DouyinBasicSyncJob.GetBestMatchedVideoUrl(aweme, new AppConfig { VideoEncoder = 264 }));
aweme.Video.BitRate = new List<VideoBitRate> { h264Low, h264High };
Assert.Same(h264High, DouyinBasicSyncJob.GetBestMatchedVideoUrl(aweme, new AppConfig { VideoEncoder = 265 }));
}
[Fact]
public void MediaCandidates_UsesSelectedFirstAndRemovesEmptyAndDuplicateUrls()
{
var selected = BitRate("https://media.test/preferred", isH265: 1, value: 200);
selected.PlayAddr.UrlList.Add("https://media.test/shared");
var fallback = BitRate("https://media.test/shared", isH265: 0, value: 100);
fallback.PlayAddr.UrlList.Add("");
fallback.PlayAddr.UrlList.Add("https://media.test/fallback");
var aweme = new Aweme
{
Video = new Video { BitRate = new List<VideoBitRate> { null, fallback, selected } }
};
var candidates = DouyinBasicSyncJob.GetMediaCandidates(aweme, selected);
Assert.Equal(new[]
{
"https://media.test/preferred",
"https://media.test/shared",
"https://media.test/fallback"
}, candidates);
}
[Theory]
[InlineData(VideoTypeEnum.dy_favorite, true)]
[InlineData(VideoTypeEnum.dy_collects, true)]
[InlineData(VideoTypeEnum.dy_follows, true)]
[InlineData(VideoTypeEnum.dy_custom_collect, true)]
[InlineData(VideoTypeEnum.dy_mix, true)]
[InlineData(VideoTypeEnum.dy_series, true)]
[InlineData(VideoTypeEnum.ImageVideo, false)]
[InlineData(VideoTypeEnum.dy_followuser, false)]
[InlineData(VideoTypeEnum.dy_followuser_once, false)]
[InlineData(VideoTypeEnum.dy_live_monitor, false)]
public void ManualVideoSyncType_OnlyAllowsDownloadJobs(VideoTypeEnum type, bool expected)
{
Assert.Equal(expected, DouyinQuartzJobService.IsVideoSyncType(type));
}
private static VideoBitRate BitRate(string url, int isH265, int value) => new()
{
IsH265 = isH265,
BitRateValue = value,
PlayAddr = new PlayAddr { UrlList = new List<string> { url } }
};
}
@@ -0,0 +1,63 @@
using dy.net.storage;
using dy.net.Tests.TestInfrastructure;
namespace dy.net.Tests;
public class SafeLocalMigrationFileTests
{
[Fact]
public void TryResolve_AllowsRegularFileInsideConfiguredRoot()
{
using var temporary = new TemporaryDirectory();
var root = Path.Combine(temporary.Path, "media");
Directory.CreateDirectory(root);
var file = Path.Combine(root, "video.mp4");
File.WriteAllText(file, "media");
Assert.True(SafeLocalMigrationFile.TryResolve(file, new[] { root }, out var resolved, out var error), error);
Assert.Equal(Path.GetFullPath(file), resolved);
}
[Fact]
public void TryResolve_RejectsFileOutsideConfiguredRoot()
{
using var temporary = new TemporaryDirectory();
var root = Path.Combine(temporary.Path, "media");
Directory.CreateDirectory(root);
var outside = Path.Combine(temporary.Path, "outside.mp4");
File.WriteAllText(outside, "media");
Assert.False(SafeLocalMigrationFile.TryResolve(outside, new[] { root }, out _, out var error));
Assert.Contains("根目录", error);
}
[Fact]
public void TryResolve_RejectsSymbolicLink()
{
using var temporary = new TemporaryDirectory();
var root = Path.Combine(temporary.Path, "media");
Directory.CreateDirectory(root);
var target = Path.Combine(root, "target.mp4");
var link = Path.Combine(root, "link.mp4");
File.WriteAllText(target, "media");
File.CreateSymbolicLink(link, target);
Assert.False(SafeLocalMigrationFile.TryResolve(link, new[] { root }, out _, out var error));
Assert.Contains("符号链接", error);
}
[Fact]
public void TryResolve_RejectsConfiguredRootThatIsSymbolicLink()
{
using var temporary = new TemporaryDirectory();
var actualRoot = Path.Combine(temporary.Path, "actual-media");
var linkedRoot = Path.Combine(temporary.Path, "linked-media");
Directory.CreateDirectory(actualRoot);
Directory.CreateSymbolicLink(linkedRoot, actualRoot);
var file = Path.Combine(linkedRoot, "video.mp4");
File.WriteAllText(file, "media");
Assert.False(SafeLocalMigrationFile.TryResolve(file, new[] { linkedRoot }, out _, out var error));
Assert.Contains("符号链接", error);
}
}
@@ -0,0 +1,77 @@
using dy.net.model.dto;
using dy.net.model.entity;
using dy.net.storage;
using Newtonsoft.Json;
namespace dy.net.Tests;
public class StorageArtifactCleanerTests
{
[Fact]
public async Task DeleteWebDavArtifacts_DeletesOwnedFilesButPreservesRootAndSharedTvShowNfo()
{
var storage = new TrackingStorage();
var video = new DouyinVideo
{
AwemeId = "video-1",
StorageType = StorageType.WebDav,
ViedoType = VideoTypeEnum.dy_series,
VideoSavePath = "/shows/demo/S01E01.mp4",
VideoCoverSavePath = "/shows/demo/S01E01.jpg",
VideoTitle = "Episode 1",
DynamicVideos = JsonConvert.SerializeObject(new[]
{
new DouyinMergeVideoDto { Path = "/shows/demo/parts/clip-1.mp4" },
new DouyinMergeVideoDto { Path = "/shows/demo/manifest.json" },
new DouyinMergeVideoDto { Path = "/" }
})
};
await StorageArtifactCleaner.DeleteWebDavArtifactsAsync(storage, video);
var expected = new HashSet<string>
{
"/shows/demo/S01E01.mp4",
"/shows/demo/S01E01.jpg",
"/shows/demo/S01E01.nfo",
"/shows/demo/parts/clip-1.mp4",
"/shows/demo/manifest.json"
};
Assert.True(expected.SetEquals(storage.DeletedPaths));
Assert.DoesNotContain("/", storage.DeletedPaths);
Assert.DoesNotContain("/shows/demo/tvshow.nfo", storage.DeletedPaths);
}
[Fact]
public async Task DeleteWebDavArtifacts_IgnoresLocalRecords()
{
var storage = new TrackingStorage();
var video = new DouyinVideo
{
StorageType = StorageType.Local,
VideoSavePath = "/local/video.mp4"
};
await StorageArtifactCleaner.DeleteWebDavArtifactsAsync(storage, video);
Assert.Empty(storage.DeletedPaths);
}
private sealed class TrackingStorage : IMediaStorage
{
public StorageType StorageType => StorageType.WebDav;
public HashSet<string> DeletedPaths { get; } = new(StringComparer.Ordinal);
public Task DeleteAsync(string path, CancellationToken cancellationToken = default)
{
DeletedPaths.Add(path);
return Task.CompletedTask;
}
public Task EnsureDirectoryAsync(string path, CancellationToken cancellationToken = default) => Task.CompletedTask;
public Task<bool> ExistsAsync(string path, CancellationToken cancellationToken = default) => Task.FromResult(false);
public Task<long?> GetLengthAsync(string path, CancellationToken cancellationToken = default) => Task.FromResult<long?>(null);
public Task<StorageReadResult> OpenReadAsync(string path, long? from = null, long? to = null, CancellationToken cancellationToken = default) => throw new NotSupportedException();
public Task WriteAsync(string path, Stream source, long? contentLength = null, string contentType = null, CancellationToken cancellationToken = default) => throw new NotSupportedException();
}
}
@@ -0,0 +1,42 @@
using dy.net.model.entity;
using dy.net.storage;
namespace dy.net.Tests;
public class StorageConfigurationFingerprintTests
{
[Fact]
public void Create_TracksTargetButIgnoresCredentialCiphertextRotation()
{
var settings = Settings();
var original = StorageConfigurationFingerprint.Create(settings);
settings.ProtectedPassword = "new-randomized-ciphertext";
settings.AllowInvalidCertificate = true;
Assert.Equal(original, StorageConfigurationFingerprint.Create(settings));
}
[Theory]
[InlineData("https://example.test/other-dav", "/media", "account")]
[InlineData("https://example.test/dav", "/other-media", "account")]
[InlineData("https://example.test/dav", "/media", "other-account")]
public void Create_ChangesWhenRemoteTargetChanges(string endpoint, string basePath, string userName)
{
var original = StorageConfigurationFingerprint.Create(Settings());
var changed = Settings();
changed.Endpoint = endpoint;
changed.BasePath = basePath;
changed.UserName = userName;
Assert.NotEqual(original, StorageConfigurationFingerprint.Create(changed));
}
private static WebDavSettings Settings() => new()
{
Endpoint = "https://example.test/dav/",
BasePath = "/media/",
UserName = "account",
ProtectedPassword = "ciphertext"
};
}
@@ -0,0 +1,134 @@
using dy.net.model.dto;
using dy.net.model.entity;
using dy.net.model.response;
using dy.net.storage;
using dy.net.utils;
namespace dy.net.Tests;
public class StorageMigrationPathPolicyTests
{
private readonly DouyinCookie _cookie = new()
{
Id = "cookie",
WebDavCollectPath = "/账号/收藏",
WebDavFavoritePath = "/账号/喜欢",
WebDavFollowPath = "/账号/关注",
WebDavMixPath = "/账号/合集",
WebDavSeriesPath = "/账号/短剧"
};
[Theory]
[InlineData(VideoTypeEnum.dy_collects, "/账号/收藏")]
[InlineData(VideoTypeEnum.dy_favorite, "/账号/喜欢")]
public void Build_CollectionTypes_UseAuthorTitleAndStableAwemeSuffix(VideoTypeEnum type, string root)
{
var plan = StorageMigrationPathPolicy.Build(Video(type), _cookie, null, null, new AppConfig());
Assert.StartsWith(root + "/测试博主/Unicode标题123_aweme-1/", plan.VideoPath);
Assert.EndsWith("/aweme-1.mp4", plan.VideoPath);
Assert.EndsWith("/aweme-1-poster.jpg", plan.CoverPath);
}
[Fact]
public void Build_CustomCollection_UsesCurrentCategoryFolderRule()
{
var video = Video(VideoTypeEnum.dy_custom_collect);
var category = new DouyinCollectCate { SaveFolder = "专题 / 一", Name = "fallback" };
var plan = StorageMigrationPathPolicy.Build(video, _cookie, category, null, new AppConfig());
Assert.StartsWith("/账号/收藏/专题一/Unicode标题123_aweme-1/", plan.VideoPath);
}
[Theory]
[InlineData(VideoTypeEnum.dy_mix, "/账号/合集")]
[InlineData(VideoTypeEnum.dy_series, "/账号/短剧")]
public void Build_SeriesTypes_PreserveExistingEpisodeNumber(VideoTypeEnum type, string root)
{
var video = Video(type);
video.VideoSavePath = "/old/S01E07.mp4";
var category = new DouyinCollectCate { SaveFolder = "第一季", Name = "剧名" };
var plan = StorageMigrationPathPolicy.Build(video, _cookie, category, null, new AppConfig(), 2);
Assert.Equal(root + "/第一季/S01E07.mp4", plan.VideoPath);
Assert.Equal(root + "/第一季/poster.jpg", plan.CoverPath);
}
[Fact]
public void Build_Followed_UsesCustomFolderAndCurrentTitleTemplate()
{
var video = Video(VideoTypeEnum.dy_follows);
var followed = new DouyinFollowed { SavePath = "自定义博主目录" };
var config = new AppConfig { FullFollowedTitleTemplate = "{ReleaseTime}-{Author}-{Id}" };
var plan = StorageMigrationPathPolicy.Build(video, _cookie, null, followed, config);
Assert.Contains("/账号/关注/自定义博主目录/Unicode标题123/", plan.VideoPath);
Assert.EndsWith("/20260728-测试博主-aweme-1.mp4", plan.VideoPath);
}
[Theory]
[InlineData(VideoTypeEnum.dy_collects)]
[InlineData(VideoTypeEnum.dy_favorite)]
[InlineData(VideoTypeEnum.dy_follows)]
[InlineData(VideoTypeEnum.dy_custom_collect)]
[InlineData(VideoTypeEnum.dy_mix)]
[InlineData(VideoTypeEnum.dy_series)]
public void Build_CurrentSyncAndMigrationUseTheSameRemotePolicy(VideoTypeEnum type)
{
var timestamp = new DateTimeOffset(2026, 7, 28, 0, 0, 0, TimeSpan.Zero).ToUnixTimeSeconds();
var aweme = new Aweme
{
AwemeId = "aweme-1",
Desc = "Unicode 标题 / 123",
CreateTime = timestamp,
Author = new Author { Uid = "author-1", Nickname = "测试 博主" },
MixInfo = new MixInfo { Statis = new MixStatis { CurrentEpisode = 7 } },
Video = new Video
{
BitRate = new List<VideoBitRate>
{
new() { Format = "mp4", PlayAddr = new PlayAddr { FileHash = "hash", Width = 1080, Height = 1920 } }
}
}
};
var category = new DouyinCollectCate { SaveFolder = "第一季", Name = "剧名", CateType = type };
var followed = new DouyinFollowed { SavePath = "自定义博主目录" };
var config = new AppConfig { FullFollowedTitleTemplate = "{ReleaseTime}-{Author}-{Id}" };
var currentSync = StorageMigrationPathPolicy.Build(aweme, type, _cookie, category, followed, config);
var persisted = new DouyinVideo
{
Id = "record-1",
AwemeId = aweme.AwemeId,
ViedoType = type,
VideoTitle = aweme.Desc,
VideoSavePath = currentSync.VideoPath,
Author = aweme.Author.Nickname,
AuthorId = aweme.Author.Uid,
CreateTime = DateTimeUtil.Convert10BitTimestamp(timestamp),
FileHash = "hash",
Resolution = "1080×1920"
};
var migration = StorageMigrationPathPolicy.Build(persisted, _cookie, category, followed, config, 7);
Assert.Equal(currentSync.VideoPath, migration.VideoPath);
Assert.Equal(currentSync.CoverPath, migration.CoverPath);
}
private static DouyinVideo Video(VideoTypeEnum type) => new()
{
Id = "record-1",
AwemeId = "aweme-1",
ViedoType = type,
VideoTitle = "Unicode 标题 / 123",
VideoSavePath = "/old/video.mp4",
Author = "测试 博主",
AuthorId = "author-1",
CreateTime = new DateTime(2026, 7, 28),
Resolution = "1080×1920"
};
}
+56
View File
@@ -0,0 +1,56 @@
using dy.net.storage;
namespace dy.net.Tests;
public class StoragePathTests
{
[Theory]
[InlineData(null, "/")]
[InlineData("", "/")]
[InlineData(" ", "/")]
[InlineData("/", "/")]
[InlineData("///", "/")]
public void NormalizeRemote_NormalizesEmptyAndRootPaths(string input, string expected)
{
Assert.Equal(expected, StoragePath.NormalizeRemote(input));
}
[Theory]
[InlineData("\\media\\author\\video.mp4", "/media/author/video.mp4")]
[InlineData("//media///author/video.mp4/", "/media/author/video.mp4")]
[InlineData(" media/author/video.mp4 ", "/media/author/video.mp4")]
public void NormalizeRemote_NormalizesSeparators(string input, string expected)
{
Assert.Equal(expected, StoragePath.NormalizeRemote(input));
}
[Fact]
public void CombineRemote_JoinsRemoteSegments()
{
Assert.Equal("/base/account/video.mp4", StoragePath.CombineRemote("/base/", null, "account", "/video.mp4"));
}
[Fact]
public void Encode_EncodesUnicodeSpacesAndReservedCharactersPerSegment()
{
Assert.Equal("/%E4%B8%AD%E6%96%87%20folder/video%20%231.mp4", StoragePath.Encode("/中文 folder/video #1.mp4"));
}
[Theory]
[InlineData("/", "/")]
[InlineData("/video.mp4", "/")]
[InlineData("/media/author/video.mp4", "/media/author")]
public void DirectoryName_ReturnsRemoteParent(string input, string expected)
{
Assert.Equal(expected, StoragePath.DirectoryName(input));
}
[Theory]
[InlineData("/media/./video.mp4")]
[InlineData("/media/../video.mp4")]
[InlineData("..\\video.mp4")]
public void NormalizeRemote_RejectsTraversalSegments(string input)
{
Assert.Throws<ArgumentException>(() => StoragePath.NormalizeRemote(input));
}
}
@@ -0,0 +1,215 @@
using System.Net;
using System.Net.Http.Headers;
using System.Text;
namespace dy.net.Tests.TestInfrastructure;
internal sealed record RecordedWebDavRequest(
string Method,
string RawUri,
string DecodedPath,
string Authorization,
string Range,
string Destination,
string CacheControl);
internal sealed class InMemoryWebDavHandler : HttpMessageHandler
{
private readonly Dictionary<string, byte[]> _files = new(StringComparer.Ordinal);
private readonly HashSet<string> _directories = new(StringComparer.Ordinal) { "/" };
private readonly Dictionary<string, int> _staleMetadataReads = new(StringComparer.Ordinal);
private readonly Dictionary<string, int> _staleAllReads = new(StringComparer.Ordinal);
private readonly Dictionary<string, StaleLengthState> _staleLengthReads = new(StringComparer.Ordinal);
public InMemoryWebDavHandler(string userName, string password)
{
var token = Convert.ToBase64String(Encoding.UTF8.GetBytes($"{userName}:{password}"));
ExpectedAuthorization = "Basic " + token;
}
public string ExpectedAuthorization { get; }
public bool SupportsRange { get; set; } = true;
public bool IncludeContentRange { get; set; } = true;
public HttpStatusCode ExistingDirectoryStatusCode { get; set; } = HttpStatusCode.MethodNotAllowed;
public bool FailNextMove { get; set; }
public int StaleMetadataReadsAfterMove { get; set; }
public int StaleAllReadsAfterMove { get; set; }
public int StaleLengthReadsAfterMove { get; set; }
public List<RecordedWebDavRequest> Requests { get; } = new();
public IReadOnlyDictionary<string, byte[]> Files => _files;
protected override async Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
{
var path = DecodePath(request.RequestUri);
var authorization = request.Headers.Authorization?.ToString();
var destination = request.Headers.TryGetValues("Destination", out var destinationValues)
? destinationValues.Single()
: null;
Requests.Add(new RecordedWebDavRequest(
request.Method.Method,
request.RequestUri.AbsoluteUri,
path,
authorization,
request.Headers.Range?.ToString(),
destination,
request.Headers.CacheControl?.ToString()));
if (authorization != ExpectedAuthorization) return Response(HttpStatusCode.Unauthorized);
return request.Method.Method switch
{
"MKCOL" => CreateDirectory(path),
"PUT" => await PutAsync(path, request, cancellationToken),
"HEAD" => Head(path),
"PROPFIND" => PropFind(path),
"MOVE" => Move(path, destination),
"GET" => Get(path, request.Headers.Range),
"DELETE" => Delete(path),
_ => Response(HttpStatusCode.MethodNotAllowed)
};
}
private HttpResponseMessage CreateDirectory(string path)
{
if (!_directories.Add(path)) return Response(ExistingDirectoryStatusCode);
return Response(HttpStatusCode.Created);
}
private async Task<HttpResponseMessage> PutAsync(string path, HttpRequestMessage request, CancellationToken cancellationToken)
{
_files[path] = await request.Content.ReadAsByteArrayAsync(cancellationToken);
return Response(HttpStatusCode.Created);
}
private HttpResponseMessage Head(string path)
{
if (ConsumeStaleRead(_staleAllReads, path) || ConsumeStaleRead(_staleMetadataReads, path))
return Response(HttpStatusCode.NotFound);
if (ConsumeStaleLength(path, out var staleLength)) return LengthResponse(staleLength);
if (!_files.TryGetValue(path, out var bytes)) return Response(HttpStatusCode.NotFound);
return LengthResponse(bytes.LongLength);
}
private HttpResponseMessage PropFind(string path)
{
if (!_files.TryGetValue(path, out var bytes) && !_directories.Contains(path))
return Response(HttpStatusCode.NotFound);
var length = bytes?.LongLength ?? 0;
var resourceType = _directories.Contains(path)
? "<resourcetype><collection/></resourcetype>"
: "<resourcetype/>";
var xml = $"<?xml version=\"1.0\"?><multistatus xmlns=\"DAV:\"><response><propstat><prop><getcontentlength>{length}</getcontentlength>{resourceType}</prop></propstat></response></multistatus>";
var response = Response((HttpStatusCode)207);
response.Content = new StringContent(xml, Encoding.UTF8, "application/xml");
return response;
}
private HttpResponseMessage Move(string sourcePath, string destination)
{
if (FailNextMove)
{
FailNextMove = false;
return Response(HttpStatusCode.InternalServerError);
}
if (!_files.Remove(sourcePath, out var bytes)) return Response(HttpStatusCode.NotFound);
var destinationPath = DecodePath(new Uri(destination, UriKind.Absolute));
var oldLength = _files.TryGetValue(destinationPath, out var oldBytes) ? oldBytes.LongLength : 0;
_files[destinationPath] = bytes;
if (StaleMetadataReadsAfterMove > 0)
_staleMetadataReads[destinationPath] = StaleMetadataReadsAfterMove;
if (StaleAllReadsAfterMove > 0)
_staleAllReads[destinationPath] = StaleAllReadsAfterMove;
if (StaleLengthReadsAfterMove > 0 && oldLength > 0)
_staleLengthReads[destinationPath] = new StaleLengthState(StaleLengthReadsAfterMove, oldLength);
return Response(HttpStatusCode.Created);
}
private HttpResponseMessage Get(string path, RangeHeaderValue range)
{
if (ConsumeStaleRead(_staleAllReads, path)) return Response(HttpStatusCode.NotFound);
if (ConsumeStaleLength(path, out var staleLength))
{
var stale = Response(HttpStatusCode.PartialContent);
stale.Content = new ByteArrayContent(new byte[] { 0 });
stale.Content.Headers.ContentRange = new ContentRangeHeaderValue(0, 0, staleLength);
return stale;
}
if (!_files.TryGetValue(path, out var bytes)) return Response(HttpStatusCode.NotFound);
if (!SupportsRange || range == null)
{
var full = Response(HttpStatusCode.OK);
full.Content = new ByteArrayContent(bytes);
return full;
}
var requested = range.Ranges.Single();
var start = requested.From ?? 0;
var end = Math.Min(requested.To ?? bytes.LongLength - 1, bytes.LongLength - 1);
if (start < 0 || start >= bytes.LongLength || end < start)
{
var invalid = Response(HttpStatusCode.RequestedRangeNotSatisfiable);
invalid.Content = new ByteArrayContent(Array.Empty<byte>());
invalid.Content.Headers.ContentRange = new ContentRangeHeaderValue(bytes.LongLength);
return invalid;
}
var content = bytes.Skip((int)start).Take((int)(end - start + 1)).ToArray();
var partial = Response(HttpStatusCode.PartialContent);
partial.Content = new ByteArrayContent(content);
if (IncludeContentRange)
partial.Content.Headers.ContentRange = new ContentRangeHeaderValue(start, end, bytes.LongLength);
return partial;
}
private HttpResponseMessage Delete(string path)
{
var deleted = _files.Remove(path);
foreach (var child in _files.Keys.Where(x => IsChildOf(x, path)).ToList())
{
deleted |= _files.Remove(child);
}
foreach (var child in _directories.Where(x => x == path || IsChildOf(x, path)).ToList())
{
if (child == "/") continue;
deleted |= _directories.Remove(child);
}
return Response(deleted ? HttpStatusCode.NoContent : HttpStatusCode.NotFound);
}
private static bool IsChildOf(string candidate, string parent) =>
candidate.StartsWith(parent.TrimEnd('/') + "/", StringComparison.Ordinal);
private static bool ConsumeStaleRead(Dictionary<string, int> reads, string path)
{
if (!reads.TryGetValue(path, out var remaining) || remaining <= 0) return false;
if (remaining == 1) reads.Remove(path);
else reads[path] = remaining - 1;
return true;
}
private bool ConsumeStaleLength(string path, out long length)
{
length = 0;
if (!_staleLengthReads.TryGetValue(path, out var state) || state.Remaining <= 0) return false;
length = state.Length;
if (state.Remaining == 1) _staleLengthReads.Remove(path);
else _staleLengthReads[path] = state with { Remaining = state.Remaining - 1 };
return true;
}
private static HttpResponseMessage LengthResponse(long length)
{
var response = Response(HttpStatusCode.OK);
response.Content = new ByteArrayContent(Array.Empty<byte>());
response.Content.Headers.ContentLength = length;
return response;
}
private static string DecodePath(Uri uri) => Uri.UnescapeDataString(uri.AbsolutePath);
private static HttpResponseMessage Response(HttpStatusCode statusCode) => new(statusCode);
private sealed record StaleLengthState(int Remaining, long Length);
}
@@ -0,0 +1,17 @@
namespace dy.net.Tests.TestInfrastructure;
internal sealed class TemporaryDirectory : IDisposable
{
public TemporaryDirectory()
{
Path = System.IO.Path.Combine(System.IO.Path.GetTempPath(), "dysync-tests", Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(Path);
}
public string Path { get; }
public void Dispose()
{
if (Directory.Exists(Path)) Directory.Delete(Path, true);
}
}
@@ -0,0 +1,36 @@
using System.Net;
namespace dy.net.Tests.TestInfrastructure;
internal sealed class StubHttpClientFactory : IHttpClientFactory, IDisposable
{
private readonly HttpClient _client;
public StubHttpClientFactory(HttpMessageHandler handler, string baseAddress = null)
{
_client = new HttpClient(handler, true);
if (!string.IsNullOrWhiteSpace(baseAddress))
_client.BaseAddress = new Uri(baseAddress);
}
public HttpClient CreateClient(string name) => _client;
public void Dispose() => _client.Dispose();
}
internal sealed class LiveHttpClientFactory : IHttpClientFactory, IDisposable
{
private readonly HttpClient _secureClient = new(new HttpClientHandler());
private readonly HttpClient _insecureClient = new(new HttpClientHandler
{
ServerCertificateCustomValidationCallback = HttpClientHandler.DangerousAcceptAnyServerCertificateValidator
});
public HttpClient CreateClient(string name) => name == "webdav-insecure" ? _insecureClient : _secureClient;
public void Dispose()
{
_secureClient.Dispose();
_insecureClient.Dispose();
}
}
@@ -0,0 +1,82 @@
using dy.net.model.dto;
using dy.net.model.entity;
using dy.net.service;
using dy.net.storage;
using Microsoft.AspNetCore.DataProtection;
using SqlSugar;
namespace dy.net.Tests.TestInfrastructure;
internal sealed class WebDavTestHost : IDisposable
{
private readonly TemporaryDirectory _temporaryDirectory;
private readonly IDisposable _clientFactory;
private WebDavTestHost(
TemporaryDirectory temporaryDirectory,
SqlSugarClient database,
IDisposable clientFactory,
WebDavSettingsService settingsService,
WebDavMediaStorage storage,
WebDavSettings settings)
{
_temporaryDirectory = temporaryDirectory;
Database = database;
_clientFactory = clientFactory;
SettingsService = settingsService;
Storage = storage;
Settings = settings;
}
public SqlSugarClient Database { get; }
public WebDavSettingsService SettingsService { get; }
public WebDavMediaStorage Storage { get; }
public WebDavSettings Settings { get; }
public static async Task<WebDavTestHost> CreateAsync(
IHttpClientFactory factory,
IDisposable disposableFactory,
WebDavTestRequest request,
IReadOnlyList<TimeSpan> uploadVisibilityRetryDelays = null,
IReadOnlyList<TimeSpan> finalVisibilityRetryDelays = null)
{
var temporaryDirectory = new TemporaryDirectory();
try
{
var databasePath = Path.Combine(temporaryDirectory.Path, "settings.sqlite");
var database = new SqlSugarClient(new ConnectionConfig
{
ConnectionString = $"DataSource={databasePath}",
DbType = DbType.Sqlite,
InitKeyType = InitKeyType.Attribute,
IsAutoCloseConnection = true
});
database.CodeFirst.InitTables<WebDavSettings>();
var provider = DataProtectionProvider.Create(
new DirectoryInfo(Path.Combine(temporaryDirectory.Path, "keys")),
builder => builder.SetApplicationName("dysync.net"));
var settingsService = new WebDavSettingsService(database, provider);
var settings = await settingsService.BuildCandidateAsync(request);
await settingsService.SaveAsync(settings, false, "test");
var storage = uploadVisibilityRetryDelays == null || finalVisibilityRetryDelays == null
? new WebDavMediaStorage(factory, settingsService)
: new WebDavMediaStorage(factory, settingsService,
uploadVisibilityRetryDelays, finalVisibilityRetryDelays);
return new WebDavTestHost(temporaryDirectory, database, disposableFactory, settingsService, storage, settings);
}
catch
{
disposableFactory.Dispose();
temporaryDirectory.Dispose();
throw;
}
}
public void Dispose()
{
Database.Dispose();
_clientFactory.Dispose();
_temporaryDirectory.Dispose();
}
}
+53
View File
@@ -0,0 +1,53 @@
using dy.net.model.dto;
using dy.net.model.entity;
using dy.net.repository;
using dy.net.Tests.TestInfrastructure;
using SqlSugar;
namespace dy.net.Tests;
public class VideoQueryTests
{
[Fact]
public async Task AuthorIdFilter_DoesNotMixAuthorsWithTheSameDisplayName()
{
using var temporary = new TemporaryDirectory();
using var db = new SqlSugarClient(new ConnectionConfig
{
ConnectionString = $"DataSource={Path.Combine(temporary.Path, "videos.sqlite")}",
DbType = DbType.Sqlite,
InitKeyType = InitKeyType.Attribute,
IsAutoCloseConnection = true
});
db.CodeFirst.InitTables<DouyinVideo, DouyinCookie>();
await db.Insertable(new[]
{
Video("one", "author-one"),
Video("two", "author-two")
}).ExecuteCommandAsync();
var (items, total) = await new DouyinVideoRepository(db).GetPagedAsync(new DouyinVideoPageRequestDto
{
PageIndex = 1,
PageSize = 20,
AuthorId = "author-two",
ViedoType = "*"
});
Assert.Equal(1, total);
Assert.Equal("two", Assert.Single(items).Id);
}
private static DouyinVideo Video(string id, string authorId) => new()
{
Id = id,
AwemeId = id + "-aweme",
Author = "同名博主",
AuthorId = authorId,
VideoTitle = id,
VideoSavePath = $"/{id}.mp4",
ViedoType = VideoTypeEnum.dy_follows,
CreateTime = DateTime.Now,
SyncTime = DateTime.Now
};
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,254 @@
using System.Net;
using System.Text;
using dy.net.model.dto;
using dy.net.Tests.TestInfrastructure;
namespace dy.net.Tests;
public class WebDavMediaStorageTests
{
private const string UserName = "test-user";
private const string Password = "test-password";
[Fact]
public async Task WriteReadAndDelete_UseBasicAuthEncodedPathsAndAtomicMove()
{
var handler = new InMemoryWebDavHandler(UserName, Password);
using var host = await CreateHostAsync(handler, "/媒体 库");
var path = "/作者 名/视频 #1.mp4";
var bytes = Encoding.UTF8.GetBytes("abcdefghij");
await using (var source = new MemoryStream(bytes, false))
await host.Storage.WriteAsync(path, source, bytes.Length, "video/mp4");
const string expectedServerPath = "/dav/媒体 库/作者 名/视频 #1.mp4";
Assert.True(await host.Storage.ExistsAsync(path));
Assert.Equal(bytes.Length, await host.Storage.GetLengthAsync(path));
Assert.Equal(bytes, handler.Files[expectedServerPath]);
Assert.DoesNotContain(handler.Files.Keys, x => x.Contains(".part-", StringComparison.Ordinal));
var put = Assert.Single(handler.Requests, x => x.Method == "PUT");
Assert.Contains(".part-", put.DecodedPath);
Assert.Contains("%E5%AA%92%E4%BD%93%20%E5%BA%93", put.RawUri);
Assert.Contains("%23", put.RawUri);
var move = Assert.Single(handler.Requests, x => x.Method == "MOVE");
Assert.EndsWith("/视频 #1.mp4", Uri.UnescapeDataString(new Uri(move.Destination).AbsolutePath));
Assert.All(handler.Requests, request => Assert.Equal(handler.ExpectedAuthorization, request.Authorization));
await using (var range = await host.Storage.OpenReadAsync(path, 2, 4))
{
Assert.Equal(206, range.StatusCode);
Assert.Equal("bytes 2-4/10", range.ContentRange);
using var reader = new StreamReader(range.Stream, Encoding.UTF8);
Assert.Equal("cde", await reader.ReadToEndAsync());
}
await host.Storage.DeleteAsync(path);
Assert.False(await host.Storage.ExistsAsync(path));
Assert.DoesNotContain(expectedServerPath, handler.Files.Keys);
}
[Fact]
public async Task WriteAsync_WhenMoveFails_CleansTemporaryUpload()
{
var handler = new InMemoryWebDavHandler(UserName, Password) { FailNextMove = true };
using var host = await CreateHostAsync(handler);
var bytes = Encoding.UTF8.GetBytes("upload that must be cleaned");
await using var source = new MemoryStream(bytes, false);
await Assert.ThrowsAsync<HttpRequestException>(() =>
host.Storage.WriteAsync("/failed/video.mp4", source, bytes.Length, "video/mp4"));
Assert.Empty(handler.Files);
Assert.Contains(handler.Requests, x => x.Method == "DELETE" && x.DecodedPath.Contains(".part-", StringComparison.Ordinal));
}
[Fact]
public async Task WriteAsync_WhenExistingDirectoriesReturnConflict_VerifiesAndReusesCollections()
{
var handler = new InMemoryWebDavHandler(UserName, Password)
{
ExistingDirectoryStatusCode = HttpStatusCode.Conflict
};
using var host = await CreateHostAsync(handler);
var first = Encoding.UTF8.GetBytes("first object");
var second = Encoding.UTF8.GetBytes("second object");
await using (var source = new MemoryStream(first, false))
await host.Storage.WriteAsync("/same-parent/first.mp4", source, first.Length, "video/mp4");
await using (var source = new MemoryStream(second, false))
await host.Storage.WriteAsync("/same-parent/second.mp4", source, second.Length, "video/mp4");
Assert.Equal(first, handler.Files["/dav/dysync-test/same-parent/first.mp4"]);
Assert.Equal(second, handler.Files["/dav/dysync-test/same-parent/second.mp4"]);
Assert.Contains(handler.Requests, request => request.Method == "PROPFIND"
&& request.DecodedPath == "/dav/dysync-test/same-parent");
}
[Fact]
public async Task WriteAsync_WhenMovedMetadataIsStale_UsesRangeReadToVerifyObject()
{
var handler = new InMemoryWebDavHandler(UserName, Password)
{
StaleMetadataReadsAfterMove = 1
};
using var host = await CreateHostAsync(handler);
var bytes = Encoding.UTF8.GetBytes("moved object is already readable");
await using (var source = new MemoryStream(bytes, false))
await host.Storage.WriteAsync("/eventual/video.mp4", source, bytes.Length, "video/mp4");
Assert.Equal(bytes.Length, await host.Storage.GetLengthAsync("/eventual/video.mp4"));
Assert.Contains(handler.Requests, request => request.Method == "GET" && request.Range == "bytes=0-0");
Assert.DoesNotContain(handler.Files.Keys, path => path.Contains(".part-", StringComparison.Ordinal));
}
[Fact]
public async Task WriteAsync_WhenMovedObjectIsBrieflyInvisible_RetriesVerification()
{
var handler = new InMemoryWebDavHandler(UserName, Password)
{
// The first HEAD and ranged GET both observe stale state. The next delayed
// verification sees the object and must complete without uploading again.
StaleAllReadsAfterMove = 2
};
using var host = await CreateHostAsync(handler);
var bytes = Encoding.UTF8.GetBytes("eventually visible object");
await using (var source = new MemoryStream(bytes, false))
await host.Storage.WriteAsync("/eventual/retry.mp4", source, bytes.Length, "video/mp4");
Assert.Equal(bytes, handler.Files["/dav/dysync-test/eventual/retry.mp4"]);
Assert.Single(handler.Requests, request => request.Method == "PUT");
Assert.Single(handler.Requests, request => request.Method == "MOVE");
}
[Fact]
public async Task WriteAsync_WhenMoveVisibilityExceedsOldRetryWindow_UsesExtendedVerification()
{
var handler = new InMemoryWebDavHandler(UserName, Password)
{
// Six HEAD + Range verification rounds all see stale state. The seventh
// round represents visibility beyond the 0.2.12 retry window.
StaleAllReadsAfterMove = 12
};
using var host = await CreateHostAsync(handler, finalVisibilityRetryDelays:
Enumerable.Repeat(TimeSpan.Zero, 7).ToArray());
var bytes = Encoding.UTF8.GetBytes("visible after the old retry window");
await using (var source = new MemoryStream(bytes, false))
await host.Storage.WriteAsync("/eventual/extended.mp4", source, bytes.Length, "video/mp4");
Assert.Equal(bytes, handler.Files["/dav/dysync-test/eventual/extended.mp4"]);
Assert.Single(handler.Requests, request => request.Method == "PUT");
Assert.Single(handler.Requests, request => request.Method == "MOVE");
Assert.True(handler.Requests.Count(request => request.Method is "HEAD" or "GET") >= 13);
}
[Fact]
public async Task WriteAsync_WhenOverwriteBrieflyReturnsOldLength_WaitsForNewObject()
{
var handler = new InMemoryWebDavHandler(UserName, Password);
using var host = await CreateHostAsync(handler, finalVisibilityRetryDelays:
Enumerable.Repeat(TimeSpan.Zero, 3).ToArray());
var path = "/eventual/overwrite.mp4";
var oldBytes = Encoding.UTF8.GetBytes("old-content");
await using (var oldSource = new MemoryStream(oldBytes, false))
await host.Storage.WriteAsync(path, oldSource, oldBytes.Length, "video/mp4");
handler.StaleLengthReadsAfterMove = 4;
var newBytes = Encoding.UTF8.GetBytes("new-content-with-a-different-length");
await using (var newSource = new MemoryStream(newBytes, false))
await host.Storage.WriteAsync(path, newSource, newBytes.Length, "video/mp4");
Assert.Equal(newBytes, handler.Files["/dav/dysync-test/eventual/overwrite.mp4"]);
Assert.Contains(handler.Requests, request => request.Method == "GET"
&& request.Range == "bytes=0-0"
&& request.CacheControl?.Contains("no-cache", StringComparison.OrdinalIgnoreCase) == true);
}
[Fact]
public async Task BoundStorage_KeepsOneTaskOnItsOriginalTargetAfterGlobalConfigChanges()
{
var handler = new InMemoryWebDavHandler(UserName, Password);
using var host = await CreateHostAsync(handler, "/original-target");
var bound = host.Storage.Bind(host.Settings);
var changed = await host.SettingsService.BuildCandidateAsync(new WebDavTestRequest
{
Endpoint = host.Settings.Endpoint,
BasePath = "/changed-target",
UserName = UserName,
Password = Password
});
await host.SettingsService.SaveAsync(changed, true, "changed");
var bytes = Encoding.UTF8.GetBytes("task snapshot");
await using (var source = new MemoryStream(bytes, false))
await bound.WriteAsync("/video.mp4", source, bytes.Length, "video/mp4");
Assert.Contains("/dav/original-target/video.mp4", handler.Files.Keys);
Assert.DoesNotContain("/dav/changed-target/video.mp4", handler.Files.Keys);
}
[Fact]
public async Task ProbeAsync_ExercisesRequiredWebDavCapabilitiesAndCleansProbeData()
{
var handler = new InMemoryWebDavHandler(UserName, Password);
using var host = await CreateHostAsync(handler);
var result = await host.Storage.ProbeAsync(host.Settings);
Assert.True(result.Success, result.Message);
Assert.Empty(handler.Files);
Assert.Contains(handler.Requests, x => x.Method == "MKCOL");
Assert.Contains(handler.Requests, x => x.Method == "PUT");
Assert.Contains(handler.Requests, x => x.Method == "HEAD");
Assert.Contains(handler.Requests, x => x.Method == "MOVE");
Assert.Contains(handler.Requests, x => x.Method == "GET" && x.Range == "bytes=0-0");
Assert.Contains(handler.Requests, x => x.Method == "DELETE");
Assert.All(handler.Requests, request => Assert.Equal(handler.ExpectedAuthorization, request.Authorization));
}
[Fact]
public async Task ProbeAsync_RejectsServerThatIgnoresRangeRequests()
{
var handler = new InMemoryWebDavHandler(UserName, Password) { SupportsRange = false };
using var host = await CreateHostAsync(handler);
var result = await host.Storage.ProbeAsync(host.Settings);
Assert.False(result.Success);
Assert.Contains("206", result.Message);
Assert.Empty(handler.Files);
}
[Fact]
public async Task ProbeAsync_RejectsPartialResponseWithoutContentRange()
{
var handler = new InMemoryWebDavHandler(UserName, Password) { IncludeContentRange = false };
using var host = await CreateHostAsync(handler);
var result = await host.Storage.ProbeAsync(host.Settings);
Assert.False(result.Success);
Assert.Contains("Content-Range", result.Message);
Assert.Empty(handler.Files);
}
private static async Task<WebDavTestHost> CreateHostAsync(
InMemoryWebDavHandler handler,
string basePath = "/dysync-test",
IReadOnlyList<TimeSpan> finalVisibilityRetryDelays = null)
{
var factory = new StubHttpClientFactory(handler);
return await WebDavTestHost.CreateAsync(factory, factory, new WebDavTestRequest
{
Endpoint = "https://dav.example.test/dav",
BasePath = basePath,
UserName = UserName,
Password = Password
},
finalVisibilityRetryDelays == null ? null : new[] { TimeSpan.Zero },
finalVisibilityRetryDelays);
}
}
+29
View File
@@ -0,0 +1,29 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net6.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>disable</Nullable>
<IsPackable>false</IsPackable>
<IsTestProject>true</IsTestProject>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.11.1" />
<PackageReference Include="xunit" Version="2.9.2" />
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageReference>
<PackageReference Include="coverlet.collector" Version="6.0.2">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageReference>
</ItemGroup>
<ItemGroup>
<FrameworkReference Include="Microsoft.AspNetCore.App" />
<ProjectReference Include="../../dy.net.csproj" />
</ItemGroup>
</Project>