294 lines
12 KiB
C#
294 lines
12 KiB
C#
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")
|
||
});
|
||
}
|
||
}
|
||
}
|