Files
Deal/src/telegram-service/Deal.Telegram.Tests/Support/TenantSessionTests.cs
T
Rustam Khalimov e8b9fab860 Убрать неиспользуемые using по код-стайлу
Прогон dotnet format (IDE0005) по 4 решениям: удалены лишние using,
оставшиеся после миграции namespace (676 файлов).
2026-09-11 13:30:57 +03:00

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;
}
}
}