Прогон dotnet format (IDE0005) по 4 решениям: удалены лишние using, оставшиеся после миграции namespace (676 файлов).
536 lines
22 KiB
C#
536 lines
22 KiB
C#
using System.Diagnostics;
|
|
using Deal.Telegram.Sessions;
|
|
using Deal.Telegram.Tests.Telegram;
|
|
using Grpc.Core;
|
|
using Microsoft.Extensions.Logging.Abstractions;
|
|
|
|
namespace Deal.Telegram.Tests.Support;
|
|
|
|
/// <summary>
|
|
/// Тесты фазовой машины сессии тенанта (план Task 9 Acceptance: фазовые переходы на fake-клиенте
|
|
/// ISessionClient; изоляция тенантов; отсутствие сессии → отказ). Без сети — клиент фейковый,
|
|
/// хранилище реальное (temp-каталог): проверяется и сохранение сессии после авторизации.
|
|
/// </summary>
|
|
public sealed class TenantSessionTests
|
|
{
|
|
private const string TenantId = "tenant-unit";
|
|
private const int ApiId = 12345;
|
|
private const string ApiHash = "0123456789abcdef0123456789abcdef";
|
|
private const string Phone = "+79990001122";
|
|
private const string Code = "11111";
|
|
private const int PollTimeoutMilliseconds = 5000;
|
|
|
|
// Таймаут попытки переподключения в тестах (короткий, без реальных 10 с).
|
|
private static readonly TimeSpan ReconnectTestTimeout = TimeSpan.FromMilliseconds(200);
|
|
|
|
/// <summary>
|
|
/// StartPhone без ключей приложения → INVALID_ARGUMENT «Сначала сохраните…».
|
|
/// </summary>
|
|
[Fact]
|
|
public async Task StartPhone_NoApiKeys_ThrowsNoApiKeys()
|
|
{
|
|
await using SessionContext ctx = await SessionContext.CreateAsync();
|
|
try
|
|
{
|
|
SessionException exception = await Assert.ThrowsAsync<SessionException>(
|
|
() => ctx.Session.StartPhoneAsync(0, string.Empty, Phone, CancellationToken.None));
|
|
|
|
Assert.Equal(StatusCode.InvalidArgument, exception.Code);
|
|
Assert.Equal(SessionErrorMessages.NoApiKeys, exception.Message);
|
|
}
|
|
finally
|
|
{
|
|
ctx.Cleanup();
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// StartPhone: успех → фаза "code", код запрошен у клиента; файл сессии ещё не создан.
|
|
/// </summary>
|
|
[Fact]
|
|
public async Task StartPhone_Success_PhaseCode_AndCodeRequested()
|
|
{
|
|
await using SessionContext ctx = await SessionContext.CreateAsync();
|
|
try
|
|
{
|
|
TenantSessionSnapshot snapshot = await ctx.Session.StartPhoneAsync(ApiId, ApiHash, Phone, CancellationToken.None);
|
|
|
|
Assert.Equal(AuthPhase.Code, snapshot.Phase);
|
|
Assert.Equal([Phone], ctx.Factory.CreatedClients.Single().RequestedPhones);
|
|
Assert.Null(await ctx.Store.LoadAsync(TenantId, CancellationToken.None));
|
|
}
|
|
finally
|
|
{
|
|
ctx.Cleanup();
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// SendCode без начатой сессии → FAILED_PRECONDITION «Telegram не подключён».
|
|
/// </summary>
|
|
[Fact]
|
|
public async Task SendCode_WithoutSession_ThrowsNotConnected()
|
|
{
|
|
await using SessionContext ctx = await SessionContext.CreateAsync();
|
|
try
|
|
{
|
|
SessionException exception = await Assert.ThrowsAsync<SessionException>(
|
|
() => ctx.Session.SendCodeAsync("11111", CancellationToken.None));
|
|
|
|
Assert.Equal(StatusCode.FailedPrecondition, exception.Code);
|
|
Assert.Equal(SessionErrorMessages.NotConnected, exception.Message);
|
|
}
|
|
finally
|
|
{
|
|
ctx.Cleanup();
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// SendPassword вне фазы "password" → FAILED_PRECONDITION (фаза "code" после StartPhone).
|
|
/// </summary>
|
|
[Fact]
|
|
public async Task SendPassword_WhenPhaseIsCode_ThrowsPasswordNotRequested()
|
|
{
|
|
await using SessionContext ctx = await SessionContext.CreateAsync();
|
|
try
|
|
{
|
|
await ctx.Session.StartPhoneAsync(ApiId, ApiHash, Phone, CancellationToken.None);
|
|
|
|
SessionException exception = await Assert.ThrowsAsync<SessionException>(
|
|
() => ctx.Session.SendPasswordAsync("secret", CancellationToken.None));
|
|
|
|
Assert.Equal(StatusCode.FailedPrecondition, exception.Code);
|
|
Assert.Equal(SessionErrorMessages.PasswordNotRequested, exception.Message);
|
|
}
|
|
finally
|
|
{
|
|
ctx.Cleanup();
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// SendCode с неверным кодом → INVALID_ARGUMENT «Неверный код», фаза остаётся "code" (повтор).
|
|
/// </summary>
|
|
[Fact]
|
|
public async Task SendCode_InvalidCode_KeepsCodePhase_WithError()
|
|
{
|
|
await using SessionContext ctx = await SessionContext.CreateAsync();
|
|
ctx.ConfigureClient(client => client.CodeError = new SessionException(StatusCode.InvalidArgument, SessionErrorMessages.WrongCode));
|
|
try
|
|
{
|
|
await ctx.Session.StartPhoneAsync(ApiId, ApiHash, Phone, CancellationToken.None);
|
|
|
|
SessionException exception = await Assert.ThrowsAsync<SessionException>(
|
|
() => ctx.Session.SendCodeAsync("00000", CancellationToken.None));
|
|
|
|
Assert.Equal(StatusCode.InvalidArgument, exception.Code);
|
|
Assert.Equal(SessionErrorMessages.WrongCode, exception.Message);
|
|
|
|
TenantSessionSnapshot? snapshot = await ctx.Session.GetSnapshotAsync(CancellationToken.None);
|
|
Assert.Equal(AuthPhase.Code, snapshot?.Phase);
|
|
Assert.Equal(SessionErrorMessages.WrongCode, snapshot?.Error);
|
|
}
|
|
finally
|
|
{
|
|
ctx.Cleanup();
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// SendCode с истёкшим кодом → INVALID_ARGUMENT «Код истёк — запросите новый».
|
|
/// </summary>
|
|
[Fact]
|
|
public async Task SendCode_ExpiredCode_ThrowsCodeExpired()
|
|
{
|
|
await using SessionContext ctx = await SessionContext.CreateAsync();
|
|
ctx.ConfigureClient(client => client.CodeError = new SessionException(StatusCode.InvalidArgument, SessionErrorMessages.CodeExpired));
|
|
try
|
|
{
|
|
await ctx.Session.StartPhoneAsync(ApiId, ApiHash, Phone, CancellationToken.None);
|
|
|
|
SessionException exception = await Assert.ThrowsAsync<SessionException>(
|
|
() => ctx.Session.SendCodeAsync("99999", CancellationToken.None));
|
|
|
|
Assert.Equal(SessionErrorMessages.CodeExpired, exception.Message);
|
|
}
|
|
finally
|
|
{
|
|
ctx.Cleanup();
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// 2FA: верный код → фаза "password"; верный пароль → "ready" + файл сессии сохранён.
|
|
/// </summary>
|
|
[Fact]
|
|
public async Task SendCode_ThenPassword_Ready_AndSessionPersisted()
|
|
{
|
|
await using SessionContext ctx = await SessionContext.CreateAsync();
|
|
ctx.ConfigureClient(client => client.CodeResult = "password");
|
|
try
|
|
{
|
|
await ctx.Session.StartPhoneAsync(ApiId, ApiHash, Phone, CancellationToken.None);
|
|
TenantSessionSnapshot codeSnapshot = await ctx.Session.SendCodeAsync("33333", CancellationToken.None);
|
|
Assert.Equal(AuthPhase.Password, codeSnapshot.Phase);
|
|
|
|
TenantSessionSnapshot readySnapshot = await ctx.Session.SendPasswordAsync("cloud-pass", CancellationToken.None);
|
|
|
|
Assert.Equal(AuthPhase.Ready, readySnapshot.Phase);
|
|
Assert.Equal("@fake_user", readySnapshot.Account);
|
|
|
|
StoredSession? stored = await ctx.Store.LoadAsync(TenantId, CancellationToken.None);
|
|
Assert.NotNull(stored);
|
|
Assert.Equal(ApiId, stored.ApiId);
|
|
Assert.Equal(ApiHash, stored.ApiHash);
|
|
Assert.Equal(FakeSessionClient.SessionMarker, stored.SessionBytes);
|
|
Assert.True(File.Exists(Path.Combine(ctx.Directory, TenantId + ".session")));
|
|
}
|
|
finally
|
|
{
|
|
ctx.Cleanup();
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// QR: StartQr → фаза "qr" + URL; после сканирования — "ready" и сессия сохранена.
|
|
/// </summary>
|
|
[Fact]
|
|
public async Task StartQr_ReturnsUrl_ThenScan_ReadyAndPersisted()
|
|
{
|
|
await using SessionContext ctx = await SessionContext.CreateAsync();
|
|
try
|
|
{
|
|
TenantSessionSnapshot snapshot = await ctx.Session.StartQrAsync(ApiId, ApiHash, CancellationToken.None);
|
|
|
|
Assert.Equal(AuthPhase.Qr, snapshot.Phase);
|
|
Assert.Equal(FakeSessionClient.DefaultQrUrl, snapshot.QrUrl);
|
|
Assert.True(ctx.Factory.CreatedClients.Single().QrStarted);
|
|
|
|
ctx.Factory.CreatedClients.Single().CompleteQrScan();
|
|
|
|
TenantSessionSnapshot? ready = await WaitUntilReadyAsync(ctx);
|
|
Assert.Equal(AuthPhase.Ready, ready?.Phase);
|
|
Assert.Equal("@fake_user", ready?.Account);
|
|
Assert.NotNull(await ctx.Store.LoadAsync(TenantId, CancellationToken.None));
|
|
}
|
|
finally
|
|
{
|
|
ctx.Cleanup();
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Повторный StartQr во время активного QR возвращает тот же URL без нового клиента.
|
|
/// </summary>
|
|
[Fact]
|
|
public async Task StartQr_TwiceWhileActive_ReturnsSameUrl_ReusesClient()
|
|
{
|
|
await using SessionContext ctx = await SessionContext.CreateAsync();
|
|
try
|
|
{
|
|
await ctx.Session.StartQrAsync(ApiId, ApiHash, CancellationToken.None);
|
|
TenantSessionSnapshot second = await ctx.Session.StartQrAsync(ApiId, ApiHash, CancellationToken.None);
|
|
|
|
Assert.Equal(AuthPhase.Qr, second.Phase);
|
|
Assert.Equal(FakeSessionClient.DefaultQrUrl, second.QrUrl);
|
|
Assert.Single(ctx.Factory.CreatedClients);
|
|
}
|
|
finally
|
|
{
|
|
ctx.Cleanup();
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// TryResumeAsync (auto_resume): сохранённая авторизованная сессия → "ready" + account.
|
|
/// </summary>
|
|
[Fact]
|
|
public async Task Resume_StoredAuthorizedSession_BecomesReady()
|
|
{
|
|
await using SessionContext ctx = await SessionContext.CreateAsync();
|
|
try
|
|
{
|
|
var stored = new StoredSession
|
|
{
|
|
ApiId = ApiId,
|
|
ApiHash = ApiHash,
|
|
SessionBytes = FakeSessionClient.SessionMarker,
|
|
};
|
|
await ctx.Store.SaveAsync(TenantId, stored, CancellationToken.None);
|
|
|
|
bool resumed = await ctx.Session.TryResumeAsync(stored, CancellationToken.None);
|
|
Assert.True(resumed);
|
|
|
|
TenantSessionSnapshot? snapshot = await ctx.Session.GetSnapshotAsync(CancellationToken.None);
|
|
Assert.Equal(AuthPhase.Ready, snapshot?.Phase);
|
|
Assert.Equal("@fake_user", snapshot?.Account);
|
|
}
|
|
finally
|
|
{
|
|
ctx.Cleanup();
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// StartQr на уже авторизованной (возобновлённой) сессии → "ready", QR-URL пуст.
|
|
/// </summary>
|
|
[Fact]
|
|
public async Task StartQr_WhenAlreadyAuthorized_ReturnsReady_NoUrl()
|
|
{
|
|
await using SessionContext ctx = await SessionContext.CreateAsync();
|
|
try
|
|
{
|
|
var stored = new StoredSession { ApiId = ApiId, ApiHash = ApiHash, SessionBytes = FakeSessionClient.SessionMarker };
|
|
await ctx.Store.SaveAsync(TenantId, stored, CancellationToken.None);
|
|
await ctx.Session.TryResumeAsync(stored, CancellationToken.None);
|
|
|
|
TenantSessionSnapshot snapshot = await ctx.Session.StartQrAsync(ApiId, ApiHash, CancellationToken.None);
|
|
|
|
Assert.Equal(AuthPhase.Ready, snapshot.Phase);
|
|
Assert.Null(snapshot.QrUrl);
|
|
}
|
|
finally
|
|
{
|
|
ctx.Cleanup();
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Logout: фаза idle, сессия удалена, файл стёрт; последующие команды — «Telegram не подключён».
|
|
/// </summary>
|
|
[Fact]
|
|
public async Task Logout_RemovesSessionAndFile_ThenNotConnected()
|
|
{
|
|
await using SessionContext ctx = await SessionContext.CreateAsync();
|
|
ctx.ConfigureClient(client => client.CodeResult = "password");
|
|
try
|
|
{
|
|
await ctx.Session.StartPhoneAsync(ApiId, ApiHash, Phone, CancellationToken.None);
|
|
await ctx.Session.SendCodeAsync("33333", CancellationToken.None);
|
|
await ctx.Session.SendPasswordAsync("cloud-pass", CancellationToken.None);
|
|
Assert.NotNull(await ctx.Store.LoadAsync(TenantId, CancellationToken.None));
|
|
FakeSessionClient client = ctx.Factory.CreatedClients.Single();
|
|
|
|
TenantSessionSnapshot? afterLogout = await ctx.Session.LogoutAsync(CancellationToken.None);
|
|
|
|
Assert.Null(afterLogout);
|
|
Assert.True(client.LoggedOut);
|
|
Assert.Null(await ctx.Session.GetSnapshotAsync(CancellationToken.None));
|
|
Assert.Null(await ctx.Store.LoadAsync(TenantId, CancellationToken.None));
|
|
Assert.False(File.Exists(Path.Combine(ctx.Directory, TenantId + ".session")));
|
|
|
|
SessionException exception = await Assert.ThrowsAsync<SessionException>(
|
|
() => ctx.Session.SendCodeAsync("11111", CancellationToken.None));
|
|
Assert.Equal(StatusCode.FailedPrecondition, exception.Code);
|
|
Assert.Equal(SessionErrorMessages.NotConnected, exception.Message);
|
|
}
|
|
finally
|
|
{
|
|
ctx.Cleanup();
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// QR-отмены (переключение на phone-вход) не ломают новую фазу: после CancelQrFlow через StartPhone — code.
|
|
/// </summary>
|
|
[Fact]
|
|
public async Task StartPhone_AfterActiveQr_SwitchesToCodePhase()
|
|
{
|
|
await using SessionContext ctx = await SessionContext.CreateAsync();
|
|
try
|
|
{
|
|
await ctx.Session.StartQrAsync(ApiId, ApiHash, CancellationToken.None);
|
|
|
|
TenantSessionSnapshot phoneSnapshot = await ctx.Session.StartPhoneAsync(ApiId, ApiHash, Phone, CancellationToken.None);
|
|
|
|
Assert.Equal(AuthPhase.Code, phoneSnapshot.Phase);
|
|
}
|
|
finally
|
|
{
|
|
ctx.Cleanup();
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Отмена RPC StartQr до первого URL отменяет и фоновый QR-вход (не «скрытая» авторизация).
|
|
/// </summary>
|
|
[Fact]
|
|
public async Task StartQr_CancelledBeforeFirstUrl_CancelsBackgroundQrLogin()
|
|
{
|
|
await using SessionContext ctx = await SessionContext.CreateAsync();
|
|
try
|
|
{
|
|
// Первый URL не выдаётся, пока не случится сканирование/отмена (как в реальном QR-флоу).
|
|
ctx.ConfigureClient(client => client.DelayFirstQrUrl = true);
|
|
using var rpcCancellation = new CancellationTokenSource();
|
|
|
|
Task<TenantSessionSnapshot> startQr = ctx.Session.StartQrAsync(ApiId, ApiHash, rpcCancellation.Token);
|
|
await WaitUntilQrStartedAsync(ctx);
|
|
|
|
rpcCancellation.Cancel();
|
|
await Assert.ThrowsAnyAsync<OperationCanceledException>(() => startQr);
|
|
|
|
// Фоновая задача QR остановлена отменой, а не продолжает вход «скрыто».
|
|
Assert.True(await WaitUntilQrCancelledAsync(ctx), "фоновый QR-вход не отменён");
|
|
TenantSessionSnapshot? snapshot = await ctx.Session.GetSnapshotAsync(CancellationToken.None);
|
|
Assert.Equal(AuthPhase.Idle, snapshot?.Phase);
|
|
}
|
|
finally
|
|
{
|
|
ctx.Cleanup();
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Heartbeat-переподключение ограничено собственным таймаутом: зависший connect не блокирует цикл.
|
|
/// </summary>
|
|
[Fact]
|
|
public async Task TryReconnect_HangingConnect_TimesOutByAttemptTimeout()
|
|
{
|
|
await using SessionContext ctx = await SessionContext.CreateAsync(reconnectTimeout: ReconnectTestTimeout);
|
|
try
|
|
{
|
|
// Готовая сессия (StartPhone → SendCode), затем «обрыв соединения».
|
|
await ctx.Session.StartPhoneAsync(ApiId, ApiHash, Phone, CancellationToken.None);
|
|
await ctx.Session.SendCodeAsync(Code, CancellationToken.None);
|
|
FakeSessionClient client = ctx.Factory.CreatedClients.Single();
|
|
await client.DisposeAsync();
|
|
client.ConnectGate = new TaskCompletionSource<bool>(TaskCreationOptions.RunContinuationsAsynchronously);
|
|
|
|
var stopwatch = Stopwatch.StartNew();
|
|
await ctx.Session.TryReconnectAsync(CancellationToken.None);
|
|
stopwatch.Stop();
|
|
|
|
Assert.True(client.ConnectAttempts >= 1, "connect не вызывался");
|
|
Assert.False(client.ConnectGate.Task.IsCompleted, "connect завершился — ожидался «зависший» вызов");
|
|
Assert.True(
|
|
stopwatch.Elapsed >= ReconnectTestTimeout - TimeSpan.FromMilliseconds(50),
|
|
$"попытка вернулась слишком рано: {stopwatch.Elapsed}");
|
|
}
|
|
finally
|
|
{
|
|
ctx.Cleanup();
|
|
}
|
|
}
|
|
|
|
// Поллинг снимка до готовности (QR-сканирование обрабатывается фоновой задачей).
|
|
// ctx: Контекст сессии.
|
|
private static async Task<TenantSessionSnapshot?> WaitUntilReadyAsync(SessionContext ctx)
|
|
{
|
|
var deadline = DateTime.UtcNow.AddMilliseconds(PollTimeoutMilliseconds);
|
|
while (DateTime.UtcNow < deadline)
|
|
{
|
|
TenantSessionSnapshot? snapshot = await ctx.Session.GetSnapshotAsync(CancellationToken.None);
|
|
if (snapshot?.Phase == AuthPhase.Ready)
|
|
{
|
|
return snapshot;
|
|
}
|
|
|
|
await Task.Delay(25);
|
|
}
|
|
|
|
return await ctx.Session.GetSnapshotAsync(CancellationToken.None);
|
|
}
|
|
|
|
// Ждёт старта фонового QR-входа (клиент создан и ждёт первого URL/сканирования).
|
|
// ctx: Контекст сессии.
|
|
private static async Task WaitUntilQrStartedAsync(SessionContext ctx)
|
|
{
|
|
var deadline = DateTime.UtcNow.AddMilliseconds(PollTimeoutMilliseconds);
|
|
while (DateTime.UtcNow < deadline)
|
|
{
|
|
if (ctx.Factory.CreatedClients.Count > 0 && ctx.Factory.CreatedClients[0].QrStarted)
|
|
{
|
|
return;
|
|
}
|
|
|
|
await Task.Delay(10);
|
|
}
|
|
|
|
Assert.Fail("QR-вход не стартовал за отведённое время");
|
|
}
|
|
|
|
// Ждёт остановки фонового QR-входа отменой (см. FakeSessionClient.QrLoginCancelled).
|
|
// ctx: Контекст сессии.
|
|
private static async Task<bool> WaitUntilQrCancelledAsync(SessionContext ctx)
|
|
{
|
|
var deadline = DateTime.UtcNow.AddMilliseconds(PollTimeoutMilliseconds);
|
|
while (DateTime.UtcNow < deadline)
|
|
{
|
|
if (ctx.Factory.CreatedClients.Count > 0 && ctx.Factory.CreatedClients[0].QrLoginCancelled)
|
|
{
|
|
return true;
|
|
}
|
|
|
|
await Task.Delay(10);
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
// Контекст одного теста: реальное хранилище в temp-каталоге + фейковая фабрика и сессия тенанта.
|
|
private sealed class SessionContext : IAsyncDisposable
|
|
{
|
|
private readonly FakeClientFactory _factory;
|
|
|
|
private SessionContext(
|
|
string directory,
|
|
SessionStore store,
|
|
TenantSession session,
|
|
FakeClientFactory factory)
|
|
{
|
|
Directory = directory;
|
|
Store = store;
|
|
Session = session;
|
|
_factory = factory;
|
|
}
|
|
|
|
public string Directory { get; }
|
|
|
|
public SessionStore Store { get; }
|
|
|
|
public TenantSession Session { get; }
|
|
|
|
public FakeClientFactory Factory => _factory;
|
|
|
|
/// <summary>
|
|
/// Создаёт контекст: temp-каталог, хранилище, фейковая фабрика, сессия тенанта.
|
|
/// </summary>
|
|
/// <param name="reconnectTimeout">Таймаут попытки переподключения сессии (null — значение по умолчанию).</param>
|
|
public static Task<SessionContext> CreateAsync(TimeSpan? reconnectTimeout = null)
|
|
{
|
|
string dir = TestSessionFactory.NewSessionsDirectory();
|
|
var factory = new FakeClientFactory();
|
|
SessionStore store = TestSessionFactory.Store(dir);
|
|
var session = new TenantSession(
|
|
TenantId,
|
|
factory,
|
|
store,
|
|
NullLogger<TenantSession>.Instance,
|
|
reconnectTimeout);
|
|
return Task.FromResult(new SessionContext(dir, store, session, factory));
|
|
}
|
|
|
|
/// <summary>
|
|
/// Настраивает клиента, который создаст фабрика при первом обращении сессии.
|
|
/// </summary>
|
|
/// <param name="configure">Колбэк настройки FakeSessionClient.</param>
|
|
public void ConfigureClient(Action<FakeSessionClient> configure)
|
|
=> _factory.OnClientCreated = configure;
|
|
|
|
/// <summary>
|
|
/// Удаляет temp-каталог.
|
|
/// </summary>
|
|
public void Cleanup()
|
|
=> TestSessionFactory.Cleanup(Directory);
|
|
|
|
/// <inheritdoc />
|
|
public ValueTask DisposeAsync()
|
|
{
|
|
Cleanup();
|
|
return ValueTask.CompletedTask;
|
|
}
|
|
}
|
|
}
|