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(); 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().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(); 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().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(); 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().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(); 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().InSingleAsync("follow-1"); var cookie = await db.Queryable().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().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 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 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") }); } } }