Перевести FakeTelegramGateway и FakeDiscoveryGateway на NSubstitute
ci / build-test (push) Canceled after 0s

Хелперы Support/TestTelegramGateway (журналы SetMonitor/Backfill/ReadSource,
сценарии Status/Catalog/сбоев по диалогам) и Support/TestDiscoveryGateway
(поиск/инфо/чтения/Join + NotFoundException на неожиданных методах).
Потребители (7 файлов) перетипизированы на .Gateway, фейк-классы удалены,
тесты 1340 зелёные.
This commit is contained in:
Rustam Khalimov
2026-09-12 21:31:28 +03:00
parent 9fd0df3c95
commit 668c5b416d
11 changed files with 336 additions and 429 deletions
@@ -37,8 +37,8 @@ public sealed class DiscoveryWorkerSchedulerTests
DiscoveryWorkerScheduler Scheduler,
FakeDiscoveryStore StoreA,
FakeDiscoveryStore StoreB,
FakeDiscoveryGateway GatewayA,
FakeDiscoveryGateway GatewayB,
TestDiscoveryGateway GatewayA,
TestDiscoveryGateway GatewayB,
TenantContext TenantContext,
ListLogger Logs);
@@ -96,8 +96,8 @@ public sealed class DiscoveryWorkerSchedulerTests
var storeB = new FakeDiscoveryStore();
var settingsA = new FakeSettingsStore();
var settingsB = new FakeSettingsStore();
var gatewayA = new FakeDiscoveryGateway();
var gatewayB = new FakeDiscoveryGateway();
var gatewayA = new TestDiscoveryGateway();
var gatewayB = new TestDiscoveryGateway();
PreloadDiscoveryEvalOff(settingsA);
PreloadDiscoveryEvalOff(settingsB);
@@ -110,7 +110,7 @@ public sealed class DiscoveryWorkerSchedulerTests
// (эталон PipelineWorkerSchedulerTests/ConnectionStringProvider.ForTenant).
services.AddScoped<IDiscoveryStore>(provider => TenantOf(provider) == TenantA ? storeA : storeB);
services.AddScoped<ISettingsStore>(provider => TenantOf(provider) == TenantA ? settingsA : settingsB);
services.AddScoped<ITelegramGateway>(provider => TenantOf(provider) == TenantA ? gatewayA : gatewayB);
services.AddScoped<ITelegramGateway>(provider => TenantOf(provider) == TenantA ? gatewayA.Gateway : gatewayB.Gateway);
// Реальные сервисы модуля Discovery — как AddDiscoveryModule в Program.cs: цикл резолвит их в tenant-scope.
services.AddDiscoveryModule();
@@ -42,7 +42,7 @@ public sealed class TgStatusServiceTests
[Fact]
public async Task GetAsync_GatewayUnavailable_ReturnsIdleFormWithKvAccountCountAndKeys()
{
(TgStatusService service, FakeTelegramStore store, FakeSettingsStore settings, FakeTelegramGateway gateway, _, TestGlobalSettingsStore globalSettings) = Create();
(TgStatusService service, FakeTelegramStore store, FakeSettingsStore settings, TestTelegramGateway gateway, _, TestGlobalSettingsStore globalSettings) = Create();
store.Seed(Dialog("d_1", "Канал", "channel", Monitor: true));
store.Seed(Dialog("d_2", "Группа", "group", Monitor: true));
store.Seed(Dialog("d_off", "Выключен", "channel", Monitor: false));
@@ -68,7 +68,7 @@ public sealed class TgStatusServiceTests
[Fact]
public async Task GetAsync_ReadyGateway_ComposesLiveFieldsWithKvAccountMonitoredAndKeys()
{
(TgStatusService service, FakeTelegramStore store, FakeSettingsStore settings, FakeTelegramGateway gateway, _, TestGlobalSettingsStore globalSettings) = Create();
(TgStatusService service, FakeTelegramStore store, FakeSettingsStore settings, TestTelegramGateway gateway, _, TestGlobalSettingsStore globalSettings) = Create();
store.Seed(Dialog("d_1", "Канал", "channel", Monitor: true));
settings.Preload(SettingsKeys.TgAccount, "\"@realuser\"");
PreloadKeys(globalSettings, "123456", "abcdefghijklmnop");
@@ -91,7 +91,7 @@ public sealed class TgStatusServiceTests
[Fact]
public async Task GetAsync_QrPhase_ReturnsQrUrlFromGateway()
{
(TgStatusService service, _, _, FakeTelegramGateway gateway, _, _) = Create();
(TgStatusService service, _, _, TestTelegramGateway gateway, _, _) = Create();
gateway.Status = new TelegramAccountStatusDto(
Phase: "qr", Connected: false, Listener: false, Account: string.Empty, Error: null, QrUrl: "https://t.me/qr/test");
@@ -105,16 +105,16 @@ public sealed class TgStatusServiceTests
// ─── Хелперы ────────────────────────────────────────────────────────────
// Собирает сервис на фейках (FakeTelegramGateway/Store/SettingsStore/GlobalSettingsStore + FakeSecretCipher).
private static (TgStatusService Service, FakeTelegramStore Store, FakeSettingsStore Settings, FakeTelegramGateway Gateway, FakeSecretCipher Cipher, TestGlobalSettingsStore GlobalSettings) Create()
private static (TgStatusService Service, FakeTelegramStore Store, FakeSettingsStore Settings, TestTelegramGateway Gateway, FakeSecretCipher Cipher, TestGlobalSettingsStore GlobalSettings) Create()
{
var store = new FakeTelegramStore();
var settings = new FakeSettingsStore();
var gateway = new FakeTelegramGateway();
var gateway = new TestTelegramGateway();
var cipher = new FakeSecretCipher();
var globalSettings = new TestGlobalSettingsStore();
var keys = new TelegramKeysService(globalSettings.Store, cipher);
var dialogs = new DialogsService(store, settings, gateway, NullLogger<DialogsService>.Instance);
var service = new TgStatusService(gateway, dialogs, settings, keys);
var dialogs = new DialogsService(store, settings, gateway.Gateway, NullLogger<DialogsService>.Instance);
var service = new TgStatusService(gateway.Gateway, dialogs, settings, keys);
return (service, store, settings, gateway, cipher, globalSettings);
}
@@ -110,7 +110,7 @@ public sealed class DialogsServiceTests
[Fact]
public async Task SetMonitor_EnableFirstTimeNotBackfilled_SetsFlagCallsGatewayAndNeedsBackfill()
{
(DialogsService service, FakeTelegramStore store, _, FakeTelegramGateway gateway) = Create();
(DialogsService service, FakeTelegramStore store, _, TestTelegramGateway gateway) = Create();
store.Seed(Dialog("d_1", "Канал", "channel", Monitor: false, Backfilled: false));
TelegramMonitorToggleDto result = await service.SetMonitorAsync("d_1", enabled: true, CancellationToken.None);
@@ -127,7 +127,7 @@ public sealed class DialogsServiceTests
[Fact]
public async Task SetMonitor_EnableAlreadyBackfilled_NoBackfillNeeded()
{
(DialogsService service, FakeTelegramStore store, _, FakeTelegramGateway gateway) = Create();
(DialogsService service, FakeTelegramStore store, _, TestTelegramGateway gateway) = Create();
store.Seed(Dialog("d_1", "Канал", "channel", Monitor: false, Backfilled: true));
TelegramMonitorToggleDto result = await service.SetMonitorAsync("d_1", enabled: true, CancellationToken.None);
@@ -143,7 +143,7 @@ public sealed class DialogsServiceTests
[Fact]
public async Task SetMonitor_Disable_TurnsOffAndNotifiesMirror()
{
(DialogsService service, FakeTelegramStore store, _, FakeTelegramGateway gateway) = Create();
(DialogsService service, FakeTelegramStore store, _, TestTelegramGateway gateway) = Create();
store.Seed(Dialog("d_1", "Канал", "channel", Monitor: true, Backfilled: true));
TelegramMonitorToggleDto result = await service.SetMonitorAsync("d_1", enabled: false, CancellationToken.None);
@@ -160,7 +160,7 @@ public sealed class DialogsServiceTests
[Fact]
public async Task SetMonitor_UnknownDialog_NoOpWithoutGatewayCall()
{
(DialogsService service, FakeTelegramStore store, _, FakeTelegramGateway gateway) = Create();
(DialogsService service, FakeTelegramStore store, _, TestTelegramGateway gateway) = Create();
TelegramMonitorToggleDto result = await service.SetMonitorAsync("missing", enabled: true, CancellationToken.None);
@@ -178,7 +178,7 @@ public sealed class DialogsServiceTests
[Fact]
public async Task SetMonitorAll_Enable_ReturnsCountAndNotBackfilledIdsAndNotifiesMirror()
{
(DialogsService service, FakeTelegramStore store, _, FakeTelegramGateway gateway) = Create();
(DialogsService service, FakeTelegramStore store, _, TestTelegramGateway gateway) = Create();
store.Seed(Dialog("d_a", "А", "channel", Monitor: false, Backfilled: true));
store.Seed(Dialog("d_b", "Б", "channel", Monitor: false, Backfilled: false));
store.Seed(Dialog("d_c", "В", "group", Monitor: false, Backfilled: false));
@@ -197,7 +197,7 @@ public sealed class DialogsServiceTests
[Fact]
public async Task SetMonitorAll_Disable_TurnsAllOffAndEmptyBackfillList()
{
(DialogsService service, FakeTelegramStore store, _, FakeTelegramGateway gateway) = Create();
(DialogsService service, FakeTelegramStore store, _, TestTelegramGateway gateway) = Create();
store.Seed(Dialog("d_a", "А", "channel", Monitor: true, Backfilled: false));
store.Seed(Dialog("d_b", "Б", "channel", Monitor: true, Backfilled: true));
@@ -230,7 +230,7 @@ public sealed class DialogsServiceTests
[Fact]
public async Task ReadRecent_OneDialogFails_ContinuesWithOthersAndMarksOnlySuccessful()
{
(DialogsService service, FakeTelegramStore store, _, FakeTelegramGateway gateway) = Create();
(DialogsService service, FakeTelegramStore store, _, TestTelegramGateway gateway) = Create();
store.Seed(Dialog("d_1", "Канал", "channel", Monitor: true, Backfilled: false));
store.Seed(Dialog("d_2", "Группа", "group", Monitor: true, Backfilled: false));
store.Seed(Dialog("d_3", "Форум", "forum", Monitor: true, Backfilled: false));
@@ -251,7 +251,7 @@ public sealed class DialogsServiceTests
[Fact]
public async Task BackfillOne_UnknownOrAlreadyBackfilledWithoutForce_ReturnsZeroWithoutRpc()
{
(DialogsService service, FakeTelegramStore store, _, FakeTelegramGateway gateway) = Create();
(DialogsService service, FakeTelegramStore store, _, TestTelegramGateway gateway) = Create();
store.Seed(Dialog("d_known", "Канал", "channel", Monitor: true, Backfilled: true));
store.Seed(Dialog("d_fresh", "Группа", "group", Monitor: false, Backfilled: false));
@@ -272,7 +272,7 @@ public sealed class DialogsServiceTests
[Fact]
public async Task BackfillOne_ForceTrue_BackfillsAlreadyBackfilledDialogAndMarksAgain()
{
(DialogsService service, FakeTelegramStore store, _, FakeTelegramGateway gateway) = Create();
(DialogsService service, FakeTelegramStore store, _, TestTelegramGateway gateway) = Create();
store.Seed(Dialog("d_1", "Канал", "channel", Monitor: true, Backfilled: true));
int processed = await service.BackfillOneAsync("d_1", force: true, CancellationToken.None);
@@ -290,7 +290,7 @@ public sealed class DialogsServiceTests
[Fact]
public async Task ReadRecent_OnlyMonitoredDialogs_BackfillsEachMonitoredWithForce()
{
(DialogsService service, FakeTelegramStore store, _, FakeTelegramGateway gateway) = Create();
(DialogsService service, FakeTelegramStore store, _, TestTelegramGateway gateway) = Create();
store.Seed(Dialog("d_1", "Канал", "channel", Monitor: true, Backfilled: true));
store.Seed(Dialog("d_2", "Группа", "group", Monitor: true, Backfilled: false));
store.Seed(Dialog("d_off", "Выключен", "channel", Monitor: false, Backfilled: false));
@@ -309,7 +309,7 @@ public sealed class DialogsServiceTests
[Fact]
public async Task ReadRecent_NoMonitoredDialogs_ReturnsZeroWithoutGatewayCalls()
{
(DialogsService service, FakeTelegramStore store, _, FakeTelegramGateway gateway) = Create();
(DialogsService service, FakeTelegramStore store, _, TestTelegramGateway gateway) = Create();
store.Seed(Dialog("d_off", "Выключен", "channel", Monitor: false, Backfilled: false));
int count = await service.ReadRecentAsync(CancellationToken.None);
@@ -426,7 +426,7 @@ public sealed class DialogsServiceTests
[Fact]
public async Task AddDiscoveredMonitored_NewDialog_CreatesMonitoredRowAndNotifiesMirror()
{
(DialogsService service, FakeTelegramStore store, _, FakeTelegramGateway gateway) = Create();
(DialogsService service, FakeTelegramStore store, _, TestTelegramGateway gateway) = Create();
await service.AddDiscoveredMonitoredAsync("d_100", "Канал", "channel_handle", "channel", "#a33", CancellationToken.None);
@@ -447,7 +447,7 @@ public sealed class DialogsServiceTests
[Fact]
public async Task AddDiscoveredMonitored_ExistingDialog_UpdatesMetadataAndForcesMonitorOn()
{
(DialogsService service, FakeTelegramStore store, _, FakeTelegramGateway gateway) = Create();
(DialogsService service, FakeTelegramStore store, _, TestTelegramGateway gateway) = Create();
store.Seed(Dialog("d_100", "Старое имя", "group", Monitor: false, Backfilled: true));
await service.AddDiscoveredMonitoredAsync("d_100", "Новое имя", "new_handle", "channel", "#c21", CancellationToken.None);
@@ -485,7 +485,7 @@ public sealed class DialogsServiceTests
[Fact]
public async Task AddDiscoveredMonitored_GatewayMirrorFails_RowStillWritten()
{
(DialogsService service, FakeTelegramStore store, _, FakeTelegramGateway gateway) = Create();
(DialogsService service, FakeTelegramStore store, _, TestTelegramGateway gateway) = Create();
gateway.SetMonitorFailures.Add("d_100");
await service.AddDiscoveredMonitoredAsync("d_100", "Канал", string.Empty, "channel", "#666", CancellationToken.None);
@@ -497,12 +497,12 @@ public sealed class DialogsServiceTests
// ─── Хелперы ────────────────────────────────────────────────────────────
// Собирает сервис на фейках (FakeTelegramStore — семантика адаптера; настройки/гейт чистые).
private static (DialogsService Service, FakeTelegramStore Store, FakeSettingsStore Settings, FakeTelegramGateway Gateway) Create()
private static (DialogsService Service, FakeTelegramStore Store, FakeSettingsStore Settings, TestTelegramGateway Gateway) Create()
{
var store = new FakeTelegramStore();
var settings = new FakeSettingsStore();
var gateway = new FakeTelegramGateway();
return (new DialogsService(store, settings, gateway, NullLogger<DialogsService>.Instance), store, settings, gateway);
var gateway = new TestTelegramGateway();
return (new DialogsService(store, settings, gateway.Gateway, NullLogger<DialogsService>.Instance), store, settings, gateway);
}
// Строка каталога минимально (id/имя/kind + флаги/хвост каталога).
@@ -420,7 +420,7 @@ public sealed class DiscoveryWorkerServiceTests
private sealed record Fixture(
FakeDiscoveryStore Store,
FakeSettingsStore Settings,
FakeDiscoveryGateway Gateway,
TestDiscoveryGateway Gateway,
IDiscoveryPacer Pacer,
DiscoveryWorkerService Worker,
DiscoveryTasksService Tasks);
@@ -441,9 +441,9 @@ public sealed class DiscoveryWorkerServiceTests
var tasks = new DiscoveryTasksService(store, planGuard, settings);
var banGuard = new DiscoveryBanGuard(store, settings);
var pacer = Substitute.For<IDiscoveryPacer>();
var gateway = new FakeDiscoveryGateway();
var gateway = new TestDiscoveryGateway();
var worker = new DiscoveryWorkerService(
store, tasks, candidates, log, new DiscoveryEvaluator(settings, ml, ai), banGuard, pacer, gateway);
store, tasks, candidates, log, new DiscoveryEvaluator(settings, ml, ai), banGuard, pacer, gateway.Gateway);
return new Fixture(store, settings, gateway, pacer, worker, tasks);
}
@@ -1,192 +0,0 @@
using Deal.Contracts.Integrations.Abstractions;
using Deal.Contracts.Integrations.Models;
namespace Deal.Tests.Unit.Contracts;
/// <summary>
/// Фейковый <see cref="ITelegramGateway"/> для тестов Discovery-воркера
/// </summary>
public sealed class FakeDiscoveryGateway : ITelegramGateway
{
/// <summary>
/// Результат глобального поиска
/// </summary>
public IReadOnlyList<TelegramDialogEntryDto> SearchResults { get; set; } = [];
/// <summary>
/// Исключение SearchAsync
/// </summary>
public Exception? SearchError { get; set; }
/// <summary>
/// Инфо об источниках по dialogId; отсутствующий — fallback как Local
/// </summary>
public Dictionary<string, TelegramChannelInfoDto> InfoByDialog { get; } = new(StringComparer.Ordinal);
/// <summary>
/// Выборки сообщений по dialogId; отсутствующий — ok=false "no_history"
/// </summary>
public Dictionary<string, TelegramEvalReadDto> ReadsByDialog { get; } = new(StringComparer.Ordinal);
/// <summary>
/// Исключение JoinAsync
/// </summary>
public Exception? JoinError { get; set; }
/// <summary>
/// Сколько сообщений «разобрал» Backfill
/// </summary>
public int BackfillResult { get; set; } = 3;
/// <summary>
/// Поисковые запросы в порядке вызовов.
/// </summary>
public List<string> SearchedKeywords { get; } = [];
/// <summary>
/// Запросы Info
/// </summary>
public List<string> InfoRequests { get; } = [];
/// <summary>
/// Запросы ReadForEval
/// </summary>
public List<(string DialogId, int Limit)> ReadRequests { get; } = [];
/// <summary>
/// Вызовы Join (username без «@») в порядке вызовов.
/// </summary>
public List<string> JoinedUsernames { get; } = [];
/// <summary>
/// Вызовы SetMonitor
/// </summary>
public List<(string DialogId, bool Enabled)> SetMonitorCalls { get; } = [];
/// <summary>
/// Вызовы Backfill
/// </summary>
public List<(string DialogId, bool Force)> BackfillCalls { get; } = [];
/// <inheritdoc />
public Task<TelegramAccountStatusDto> StatusAsync(CancellationToken ct)
=> Task.FromResult(new TelegramAccountStatusDto("ready", Connected: true, Listener: true, "@test", Error: null, QrUrl: null));
/// <inheritdoc />
public Task<TelegramAuthResultDto> StartPhoneAsync(
string phone,
int apiId,
string apiHash,
CancellationToken ct)
=> throw new NotSupportedException();
/// <inheritdoc />
public Task<TelegramAuthResultDto> StartQrAsync(
int apiId,
string apiHash,
CancellationToken ct)
=> throw new NotSupportedException();
/// <inheritdoc />
public Task<string> SendCodeAsync(string code, CancellationToken ct) => throw new NotSupportedException();
/// <inheritdoc />
public Task<string> SendPasswordAsync(string password, CancellationToken ct) => throw new NotSupportedException();
/// <inheritdoc />
public Task LogoutAsync(CancellationToken ct) => throw new NotSupportedException();
/// <inheritdoc />
public Task<IReadOnlyList<TelegramDialogEntryDto>> RefreshDialogsAsync(CancellationToken ct)
=> throw new NotSupportedException();
/// <inheritdoc />
public Task SetMonitorAsync(
string dialogId,
bool enabled,
CancellationToken ct)
{
SetMonitorCalls.Add((dialogId, enabled));
return Task.CompletedTask;
}
/// <inheritdoc />
public Task SetMonitorAllAsync(bool enabled, CancellationToken ct) => throw new NotSupportedException();
/// <inheritdoc />
public Task<int> BackfillAsync(
string dialogId,
bool force,
CancellationToken ct)
{
BackfillCalls.Add((dialogId, force));
return Task.FromResult(BackfillResult);
}
/// <inheritdoc />
public Task<IReadOnlyList<TelegramRecentMessageDto>> ReadRecentAsync(
string dialogId,
int limit,
CancellationToken ct)
=> throw new NotSupportedException();
/// <inheritdoc />
public Task<TelegramSourceContentDto> ReadSourceAsync(
string dialogId,
long msgId,
CancellationToken ct)
=> throw new NotSupportedException();
/// <inheritdoc />
public Task<IReadOnlyList<TelegramDialogEntryDto>> SearchAsync(
string query,
int limit,
CancellationToken ct)
{
SearchedKeywords.Add(query);
if (SearchError is not null)
{
throw SearchError;
}
return Task.FromResult(SearchResults);
}
/// <inheritdoc />
public Task<TelegramChannelInfoDto> InfoAsync(string dialogId, CancellationToken ct)
{
InfoRequests.Add(dialogId);
return Task.FromResult(
InfoByDialog.TryGetValue(dialogId, out TelegramChannelInfoDto? info)
? info
: new TelegramChannelInfoDto(dialogId, string.Empty, string.Empty, string.Empty, "#666", Participants: null, IsForum: false));
}
/// <inheritdoc />
public Task<TelegramEvalReadDto> ReadForEvalAsync(
string dialogId,
int limit,
CancellationToken ct)
{
ReadRequests.Add((dialogId, limit));
return Task.FromResult(
ReadsByDialog.TryGetValue(dialogId, out TelegramEvalReadDto? read)
? read
: new TelegramEvalReadDto(Ok: false, "no_history", []));
}
/// <inheritdoc />
public Task JoinAsync(string username, CancellationToken ct)
{
JoinedUsernames.Add(username);
if (JoinError is not null)
{
throw JoinError;
}
return Task.CompletedTask;
}
/// <inheritdoc />
public Task LeaveAsync(string dialogId, CancellationToken ct) => throw new NotSupportedException();
}
@@ -1,189 +0,0 @@
using Deal.Contracts.Integrations.Abstractions;
using Deal.Contracts.Integrations.Models;
using Grpc.Core;
namespace Deal.Tests.Unit.Contracts;
/// <summary>
/// In-memory реализация <see cref="ITelegramGateway"/> для юнит-тестов модуля Telegram/ингресса.
/// </summary>
public sealed class FakeTelegramGateway : ITelegramGateway
{
// Фаза idle-формы (по умолчанию не подключён).
private const string IdlePhase = "idle";
private const string NotConnectedDetail = "Telegram не подключён";
/// <summary>
/// Вызовы SetMonitor
/// </summary>
public List<(string DialogId, bool Enabled)> SetMonitorCalls { get; } = [];
/// <summary>
/// Вызовы SetMonitorAll
/// </summary>
public List<bool> SetMonitorAllCalls { get; } = [];
/// <summary>
/// Вызовы Backfill
/// </summary>
public List<(string DialogId, bool Force)> BackfillCalls { get; } = [];
/// <summary>
/// Вызовы ReadSource
/// </summary>
public List<(string DialogId, long MsgId)> ReadSourceCalls { get; } = [];
/// <summary>
/// Результат ReadSourceAsync
/// </summary>
public TelegramSourceContentDto SourceContent { get; set; } = new(false, null, null);
/// <summary>
/// Сколько сообщений «разобрал» Backfill
/// </summary>
public int BackfillResult { get; set; } = 3;
/// <summary>
/// Каталог, который возвращает RefreshDialogsAsync/SearchAsync
/// </summary>
public IReadOnlyList<TelegramDialogEntryDto> Catalog { get; set; } = [];
/// <summary>
/// Статус, который возвращает StatusAsync
/// </summary>
public TelegramAccountStatusDto Status { get; set; } =
new(IdlePhase, Connected: false, Listener: false, Account: string.Empty, Error: null, QrUrl: null);
/// <summary>
/// Заданное исключение StatusAsync
/// </summary>
public RpcException? StatusFailure { get; set; }
/// <summary>
/// Диалоги, чей BackfillAsync падает
/// </summary>
public HashSet<string> BackfillFailures { get; } = new(StringComparer.Ordinal);
/// <summary>
/// Диалоги, чей SetMonitorAsync падает
/// </summary>
public HashSet<string> SetMonitorFailures { get; } = new(StringComparer.Ordinal);
/// <inheritdoc />
public Task<TelegramAccountStatusDto> StatusAsync(CancellationToken ct)
{
if (StatusFailure is not null)
{
throw StatusFailure;
}
return Task.FromResult(Status);
}
/// <inheritdoc />
public Task<TelegramAuthResultDto> StartPhoneAsync(
string phone,
int apiId,
string apiHash,
CancellationToken ct)
=> Task.FromResult(new TelegramAuthResultDto("code", null));
/// <inheritdoc />
public Task<TelegramAuthResultDto> StartQrAsync(
int apiId,
string apiHash,
CancellationToken ct)
=> Task.FromResult(new TelegramAuthResultDto("qr", "https://t.me/qr/test"));
/// <inheritdoc />
public Task<string> SendCodeAsync(string code, CancellationToken ct) => Task.FromResult("ready");
/// <inheritdoc />
public Task<string> SendPasswordAsync(string password, CancellationToken ct) => Task.FromResult("ready");
/// <inheritdoc />
public Task LogoutAsync(CancellationToken ct) => Task.CompletedTask;
/// <inheritdoc />
public Task<IReadOnlyList<TelegramDialogEntryDto>> RefreshDialogsAsync(CancellationToken ct)
=> Task.FromResult(Catalog);
/// <inheritdoc />
public Task SetMonitorAsync(
string dialogId,
bool enabled,
CancellationToken ct)
{
if (SetMonitorFailures.Contains(dialogId))
{
throw new RpcException(new Status(StatusCode.Unavailable, NotConnectedDetail));
}
SetMonitorCalls.Add((dialogId, enabled));
return Task.CompletedTask;
}
/// <inheritdoc />
public Task SetMonitorAllAsync(bool enabled, CancellationToken ct)
{
SetMonitorAllCalls.Add(enabled);
return Task.CompletedTask;
}
/// <inheritdoc />
public Task<int> BackfillAsync(
string dialogId,
bool force,
CancellationToken ct)
{
BackfillCalls.Add((dialogId, force));
if (BackfillFailures.Contains(dialogId))
{
throw new RpcException(new Status(StatusCode.Unavailable, NotConnectedDetail));
}
return Task.FromResult(BackfillResult);
}
/// <inheritdoc />
public Task<IReadOnlyList<TelegramRecentMessageDto>> ReadRecentAsync(
string dialogId,
int limit,
CancellationToken ct)
=> Task.FromResult<IReadOnlyList<TelegramRecentMessageDto>>([]);
/// <inheritdoc />
public Task<TelegramSourceContentDto> ReadSourceAsync(
string dialogId,
long msgId,
CancellationToken ct)
{
ReadSourceCalls.Add((dialogId, msgId));
return Task.FromResult(SourceContent);
}
/// <inheritdoc />
public Task<IReadOnlyList<TelegramDialogEntryDto>> SearchAsync(
string query,
int limit,
CancellationToken ct)
=> Task.FromResult(Catalog);
/// <inheritdoc />
public Task<TelegramChannelInfoDto> InfoAsync(string dialogId, CancellationToken ct)
=> Task.FromResult(new TelegramChannelInfoDto(dialogId, string.Empty, string.Empty, string.Empty, "#666", null, false));
/// <inheritdoc />
public Task<TelegramEvalReadDto> ReadForEvalAsync(
string dialogId,
int limit,
CancellationToken ct)
=> Task.FromResult(new TelegramEvalReadDto(false, "no_history", []));
/// <inheritdoc />
public Task JoinAsync(string username, CancellationToken ct) => Task.CompletedTask;
/// <inheritdoc />
public Task LeaveAsync(string dialogId, CancellationToken ct) => Task.CompletedTask;
}
@@ -101,7 +101,7 @@ internal static class TelegramIngressTestHost
builder.Services.AddScoped<ISettingsStore>(_ => new FakeSettingsStore());
builder.Services.AddScoped<ITelegramStore>(_ => new FakeTelegramStore());
builder.Services.AddScoped<ITelegramGateway>(_ => new FakeTelegramGateway());
builder.Services.AddScoped<ITelegramGateway>(_ => new TestTelegramGateway().Gateway);
builder.Services.AddTelegramModule();
configureServices(builder.Services);
@@ -13,8 +13,8 @@ public sealed class TelegramSourceContentProviderTests
[Fact]
public async Task LoadAsync_ForeignKind_ReturnsNullWithoutGatewayCall()
{
var gateway = new FakeTelegramGateway();
var provider = new TelegramSourceContentProvider(gateway);
var gateway = new TestTelegramGateway();
var provider = new TelegramSourceContentProvider(gateway.Gateway);
SourceContent? content = await provider.LoadAsync(
SourceItemFactory.Source(kind: "local"), CancellationToken.None);
@@ -26,11 +26,11 @@ public sealed class TelegramSourceContentProviderTests
[Fact]
public async Task LoadAsync_TelegramKindCaseInsensitive_LoadsContent()
{
var gateway = new FakeTelegramGateway
var gateway = new TestTelegramGateway
{
SourceContent = new TelegramSourceContentDto(true, "текст", null),
};
var provider = new TelegramSourceContentProvider(gateway);
var provider = new TelegramSourceContentProvider(gateway.Gateway);
SourceContent? content = await provider.LoadAsync(
SourceItemFactory.Source(kind: "Telegram", origin: "d_1", externalId: "100"), CancellationToken.None);
@@ -42,8 +42,8 @@ public sealed class TelegramSourceContentProviderTests
[Fact]
public async Task LoadAsync_EmptyOriginRef_ReturnsNullWithoutGatewayCall()
{
var gateway = new FakeTelegramGateway();
var provider = new TelegramSourceContentProvider(gateway);
var gateway = new TestTelegramGateway();
var provider = new TelegramSourceContentProvider(gateway.Gateway);
SourceContent? content = await provider.LoadAsync(
SourceItemFactory.Source(kind: "telegram", origin: null, externalId: "100"), CancellationToken.None);
@@ -55,8 +55,8 @@ public sealed class TelegramSourceContentProviderTests
[Fact]
public async Task LoadAsync_NonNumericExternalId_ReturnsNullWithoutGatewayCall()
{
var gateway = new FakeTelegramGateway();
var provider = new TelegramSourceContentProvider(gateway);
var gateway = new TestTelegramGateway();
var provider = new TelegramSourceContentProvider(gateway.Gateway);
SourceContent? content = await provider.LoadAsync(
SourceItemFactory.Source(kind: "telegram", origin: "d_1", externalId: "not-a-number"), CancellationToken.None);
@@ -68,11 +68,11 @@ public sealed class TelegramSourceContentProviderTests
[Fact]
public async Task LoadAsync_GatewayNotFound_ReturnsNull()
{
var gateway = new FakeTelegramGateway
var gateway = new TestTelegramGateway
{
SourceContent = new TelegramSourceContentDto(false, null, null),
};
var provider = new TelegramSourceContentProvider(gateway);
var provider = new TelegramSourceContentProvider(gateway.Gateway);
SourceContent? content = await provider.LoadAsync(
SourceItemFactory.Source(kind: "telegram", origin: "d_1", externalId: "100"), CancellationToken.None);
@@ -83,11 +83,11 @@ public sealed class TelegramSourceContentProviderTests
[Fact]
public async Task LoadAsync_Found_ReturnsText()
{
var gateway = new FakeTelegramGateway
var gateway = new TestTelegramGateway
{
SourceContent = new TelegramSourceContentDto(true, "исходный текст", 1_700_000_000_000),
};
var provider = new TelegramSourceContentProvider(gateway);
var provider = new TelegramSourceContentProvider(gateway.Gateway);
SourceContent? content = await provider.LoadAsync(
SourceItemFactory.Source(kind: "telegram", origin: "d_42", externalId: "777"), CancellationToken.None);
@@ -99,8 +99,8 @@ public sealed class TelegramSourceContentProviderTests
[Fact]
public async Task LoadAsync_NumericExternalIdAndOriginRef_CallsGatewayWithParsedArguments()
{
var gateway = new FakeTelegramGateway();
var provider = new TelegramSourceContentProvider(gateway);
var gateway = new TestTelegramGateway();
var provider = new TelegramSourceContentProvider(gateway.Gateway);
await provider.LoadAsync(
SourceItemFactory.Source(kind: "telegram", origin: "d_42", externalId: "777"), CancellationToken.None);
@@ -72,7 +72,7 @@ public sealed class TelegramSourceIngestObserverTests
var dialogs = new DialogsService(
store,
new FakeSettingsStore(),
new FakeTelegramGateway(),
new TestTelegramGateway().Gateway,
NullLogger<DialogsService>.Instance);
var observer = new TelegramSourceIngestObserver(dialogs, NullLogger<TelegramSourceIngestObserver>.Instance);
return (observer, store);
@@ -0,0 +1,147 @@
using Deal.Contracts.Integrations.Abstractions;
using Deal.Contracts.Integrations.Models;
using NSubstitute;
namespace Deal.Tests.Unit.Support;
/// <summary>
/// Подставка <see cref="ITelegramGateway"/> для тестов Discovery-воркера: сервисы получают
/// NSubstitute-подставку (<see cref="Gateway"/>), тесты управляют каталогом/сбоями и читают
/// журналы вызовов (как раньше — через фейк). Неожиданные методы бросают <see cref="NotSupportedException"/>.
/// </summary>
public sealed class TestDiscoveryGateway
{
/// <summary>
/// Подставка порта гейтвея (создаётся в конструкторе).
/// </summary>
public ITelegramGateway Gateway { get; }
/// <summary>
/// Результат SearchAsync
/// </summary>
public IReadOnlyList<TelegramDialogEntryDto> SearchResults { get; set; } = [];
/// <summary>
/// Заданное исключение SearchAsync
/// </summary>
public Exception? SearchError { get; set; }
/// <summary>
/// Инфо по конкретным диалогам
/// </summary>
public Dictionary<string, TelegramChannelInfoDto> InfoByDialog { get; } = new(StringComparer.Ordinal);
/// <summary>
/// Чтения для оценки по конкретным диалогам
/// </summary>
public Dictionary<string, TelegramEvalReadDto> ReadsByDialog { get; } = new(StringComparer.Ordinal);
/// <summary>
/// Заданное исключение JoinAsync
/// </summary>
public Exception? JoinError { get; set; }
/// <summary>
/// Сколько сообщений «разобрал» Backfill
/// </summary>
public int BackfillResult { get; set; } = 3;
/// <summary>
/// Ключи, по которым был Search
/// </summary>
public List<string> SearchedKeywords { get; } = [];
/// <summary>
/// Запросы Info
/// </summary>
public List<string> InfoRequests { get; } = [];
/// <summary>
/// Запросы ReadForEval
/// </summary>
public List<(string DialogId, int Limit)> ReadRequests { get; } = [];
/// <summary>
/// Юзернеймы, к которым был Join
/// </summary>
public List<string> JoinedUsernames { get; } = [];
/// <summary>
/// Вызовы SetMonitor
/// </summary>
public List<(string DialogId, bool Enabled)> SetMonitorCalls { get; } = [];
/// <summary>
/// Вызовы Backfill
/// </summary>
public List<(string DialogId, bool Force)> BackfillCalls { get; } = [];
/// <summary>
/// Создаёт подставку с поведением по умолчанию.
/// </summary>
public TestDiscoveryGateway()
{
Gateway = Substitute.For<ITelegramGateway>();
Gateway.StatusAsync(Arg.Any<CancellationToken>())
.Returns(new TelegramAccountStatusDto("ready", Connected: true, Listener: true, "@test", Error: null, QrUrl: null));
Gateway.StartPhoneAsync(Arg.Any<string>(), Arg.Any<int>(), Arg.Any<string>(), Arg.Any<CancellationToken>())
.Returns<TelegramAuthResultDto>(_ => throw new NotSupportedException());
Gateway.StartQrAsync(Arg.Any<int>(), Arg.Any<string>(), Arg.Any<CancellationToken>())
.Returns<TelegramAuthResultDto>(_ => throw new NotSupportedException());
Gateway.SendCodeAsync(Arg.Any<string>(), Arg.Any<CancellationToken>())
.Returns<string>(_ => throw new NotSupportedException());
Gateway.SendPasswordAsync(Arg.Any<string>(), Arg.Any<CancellationToken>())
.Returns<string>(_ => throw new NotSupportedException());
Gateway.When(g => g.LogoutAsync(Arg.Any<CancellationToken>()))
.Do(_ => throw new NotSupportedException());
Gateway.RefreshDialogsAsync(Arg.Any<CancellationToken>())
.Returns<IReadOnlyList<TelegramDialogEntryDto>>(_ => throw new NotSupportedException());
Gateway.When(g => g.SetMonitorAsync(Arg.Any<string>(), Arg.Any<bool>(), Arg.Any<CancellationToken>()))
.Do(ci => SetMonitorCalls.Add((ci.ArgAt<string>(0), ci.ArgAt<bool>(1))));
Gateway.SetMonitorAllAsync(Arg.Any<bool>(), Arg.Any<CancellationToken>())
.Returns<Task>(_ => throw new NotSupportedException());
Gateway.When(g => g.BackfillAsync(Arg.Any<string>(), Arg.Any<bool>(), Arg.Any<CancellationToken>()))
.Do(ci => BackfillCalls.Add((ci.ArgAt<string>(0), ci.ArgAt<bool>(1))));
Gateway.BackfillAsync(Arg.Any<string>(), Arg.Any<bool>(), Arg.Any<CancellationToken>())
.Returns(BackfillResult);
Gateway.ReadRecentAsync(Arg.Any<string>(), Arg.Any<int>(), Arg.Any<CancellationToken>())
.Returns<IReadOnlyList<TelegramRecentMessageDto>>(_ => throw new NotSupportedException());
Gateway.ReadSourceAsync(Arg.Any<string>(), Arg.Any<long>(), Arg.Any<CancellationToken>())
.Returns<TelegramSourceContentDto>(_ => throw new NotSupportedException());
Gateway.When(g => g.SearchAsync(Arg.Any<string>(), Arg.Any<int>(), Arg.Any<CancellationToken>()))
.Do(ci => SearchedKeywords.Add(ci.ArgAt<string>(0)));
Gateway.SearchAsync(Arg.Any<string>(), Arg.Any<int>(), Arg.Any<CancellationToken>())
.Returns(ci =>
{
if (SearchError is not null)
{
throw SearchError;
}
return SearchResults;
});
Gateway.When(g => g.InfoAsync(Arg.Any<string>(), Arg.Any<CancellationToken>()))
.Do(ci => InfoRequests.Add(ci.ArgAt<string>(0)));
Gateway.InfoAsync(Arg.Any<string>(), Arg.Any<CancellationToken>())
.Returns(ci => InfoByDialog.TryGetValue(ci.ArgAt<string>(0), out TelegramChannelInfoDto? info)
? info
: new TelegramChannelInfoDto(ci.ArgAt<string>(0), string.Empty, string.Empty, string.Empty, "#666", Participants: null, IsForum: false));
Gateway.When(g => g.ReadForEvalAsync(Arg.Any<string>(), Arg.Any<int>(), Arg.Any<CancellationToken>()))
.Do(ci => ReadRequests.Add((ci.ArgAt<string>(0), ci.ArgAt<int>(1))));
Gateway.ReadForEvalAsync(Arg.Any<string>(), Arg.Any<int>(), Arg.Any<CancellationToken>())
.Returns(ci => ReadsByDialog.TryGetValue(ci.ArgAt<string>(0), out TelegramEvalReadDto? read)
? read
: new TelegramEvalReadDto(Ok: false, "no_history", []));
Gateway.When(g => g.JoinAsync(Arg.Any<string>(), Arg.Any<CancellationToken>()))
.Do(ci =>
{
JoinedUsernames.Add(ci.ArgAt<string>(0));
if (JoinError is not null)
{
throw JoinError;
}
});
Gateway.LeaveAsync(Arg.Any<string>(), Arg.Any<CancellationToken>())
.Returns<Task>(_ => throw new NotSupportedException());
}
}
@@ -0,0 +1,141 @@
using Deal.Contracts.Integrations.Abstractions;
using Deal.Contracts.Integrations.Models;
using Grpc.Core;
using NSubstitute;
namespace Deal.Tests.Unit.Support;
/// <summary>
/// Подставка <see cref="ITelegramGateway"/> для юнит-тестов модуля Telegram/ингресса: сервисы получают
/// NSubstitute-подставку (<see cref="Gateway"/>), тесты управляют поведением свойствами и читают
/// журналы вызовов (как раньше — через фейк).
/// </summary>
public sealed class TestTelegramGateway
{
// Фаза idle-формы (по умолчанию не подключён).
private const string IdlePhase = "idle";
private const string NotConnectedDetail = "Telegram не подключён";
/// <summary>
/// Подставка порта гейтвея (создаётся в конструкторе).
/// </summary>
public ITelegramGateway Gateway { get; }
/// <summary>
/// Вызовы SetMonitor
/// </summary>
public List<(string DialogId, bool Enabled)> SetMonitorCalls { get; } = [];
/// <summary>
/// Вызовы SetMonitorAll
/// </summary>
public List<bool> SetMonitorAllCalls { get; } = [];
/// <summary>
/// Вызовы Backfill
/// </summary>
public List<(string DialogId, bool Force)> BackfillCalls { get; } = [];
/// <summary>
/// Вызовы ReadSource
/// </summary>
public List<(string DialogId, long MsgId)> ReadSourceCalls { get; } = [];
/// <summary>
/// Результат ReadSourceAsync
/// </summary>
public TelegramSourceContentDto SourceContent { get; set; } = new(false, null, null);
/// <summary>
/// Сколько сообщений «разобрал» Backfill
/// </summary>
public int BackfillResult { get; set; } = 3;
/// <summary>
/// Каталог, который возвращает RefreshDialogsAsync/SearchAsync
/// </summary>
public IReadOnlyList<TelegramDialogEntryDto> Catalog { get; set; } = [];
/// <summary>
/// Статус, который возвращает StatusAsync
/// </summary>
public TelegramAccountStatusDto Status { get; set; } =
new(IdlePhase, Connected: false, Listener: false, Account: string.Empty, Error: null, QrUrl: null);
/// <summary>
/// Заданное исключение StatusAsync
/// </summary>
public RpcException? StatusFailure { get; set; }
/// <summary>
/// Диалоги, чей BackfillAsync падает
/// </summary>
public HashSet<string> BackfillFailures { get; } = new(StringComparer.Ordinal);
/// <summary>
/// Диалоги, чей SetMonitorAsync падает
/// </summary>
public HashSet<string> SetMonitorFailures { get; } = new(StringComparer.Ordinal);
/// <summary>
/// Создаёт подставку с поведением по умолчанию.
/// </summary>
public TestTelegramGateway()
{
Gateway = Substitute.For<ITelegramGateway>();
Gateway.StatusAsync(Arg.Any<CancellationToken>())
.Returns(ci =>
{
if (StatusFailure is not null)
{
throw StatusFailure;
}
return Status;
});
Gateway.StartPhoneAsync(Arg.Any<string>(), Arg.Any<int>(), Arg.Any<string>(), Arg.Any<CancellationToken>())
.Returns(new TelegramAuthResultDto("code", null));
Gateway.StartQrAsync(Arg.Any<int>(), Arg.Any<string>(), Arg.Any<CancellationToken>())
.Returns(new TelegramAuthResultDto("qr", "https://t.me/qr/test"));
Gateway.SendCodeAsync(Arg.Any<string>(), Arg.Any<CancellationToken>()).Returns("ready");
Gateway.SendPasswordAsync(Arg.Any<string>(), Arg.Any<CancellationToken>()).Returns("ready");
Gateway.RefreshDialogsAsync(Arg.Any<CancellationToken>()).Returns(ci => Catalog);
Gateway.When(g => g.SetMonitorAsync(Arg.Any<string>(), Arg.Any<bool>(), Arg.Any<CancellationToken>()))
.Do(ci =>
{
if (SetMonitorFailures.Contains(ci.ArgAt<string>(0)))
{
throw new RpcException(new Status(StatusCode.Unavailable, NotConnectedDetail));
}
SetMonitorCalls.Add((ci.ArgAt<string>(0), ci.ArgAt<bool>(1)));
});
Gateway.When(g => g.SetMonitorAllAsync(Arg.Any<bool>(), Arg.Any<CancellationToken>()))
.Do(ci => SetMonitorAllCalls.Add(ci.ArgAt<bool>(0)));
Gateway.When(g => g.BackfillAsync(Arg.Any<string>(), Arg.Any<bool>(), Arg.Any<CancellationToken>()))
.Do(ci => BackfillCalls.Add((ci.ArgAt<string>(0), ci.ArgAt<bool>(1))));
Gateway.BackfillAsync(Arg.Any<string>(), Arg.Any<bool>(), Arg.Any<CancellationToken>())
.Returns(ci =>
{
if (BackfillFailures.Contains(ci.ArgAt<string>(0)))
{
throw new RpcException(new Status(StatusCode.Unavailable, NotConnectedDetail));
}
return BackfillResult;
});
Gateway.ReadRecentAsync(Arg.Any<string>(), Arg.Any<int>(), Arg.Any<CancellationToken>())
.Returns<IReadOnlyList<TelegramRecentMessageDto>>([]);
Gateway.When(g => g.ReadSourceAsync(Arg.Any<string>(), Arg.Any<long>(), Arg.Any<CancellationToken>()))
.Do(ci => ReadSourceCalls.Add((ci.ArgAt<string>(0), ci.ArgAt<long>(1))));
Gateway.ReadSourceAsync(Arg.Any<string>(), Arg.Any<long>(), Arg.Any<CancellationToken>())
.Returns(ci => SourceContent);
Gateway.SearchAsync(Arg.Any<string>(), Arg.Any<int>(), Arg.Any<CancellationToken>())
.Returns(ci => Catalog);
Gateway.InfoAsync(Arg.Any<string>(), Arg.Any<CancellationToken>())
.Returns(ci => new TelegramChannelInfoDto(ci.ArgAt<string>(0), string.Empty, string.Empty, string.Empty, "#666", null, false));
Gateway.ReadForEvalAsync(Arg.Any<string>(), Arg.Any<int>(), Arg.Any<CancellationToken>())
.Returns(new TelegramEvalReadDto(false, "no_history", []));
}
}