feat: add fnOS packaging, storage workflows and release pipeline
This commit is contained in:
@@ -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"));
|
||||
}
|
||||
@@ -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")
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
};
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
Reference in New Issue
Block a user