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

294 lines
12 KiB
C#
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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")
});
}
}
}