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 { 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 { "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 { "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(); 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 _response; public RecordingMediaHandler(Func response) => _response = response; public List Requests { get; } = new(); protected override Task 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)); } } }