Перевести 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, DiscoveryWorkerScheduler Scheduler,
FakeDiscoveryStore StoreA, FakeDiscoveryStore StoreA,
FakeDiscoveryStore StoreB, FakeDiscoveryStore StoreB,
FakeDiscoveryGateway GatewayA, TestDiscoveryGateway GatewayA,
FakeDiscoveryGateway GatewayB, TestDiscoveryGateway GatewayB,
TenantContext TenantContext, TenantContext TenantContext,
ListLogger Logs); ListLogger Logs);
@@ -96,8 +96,8 @@ public sealed class DiscoveryWorkerSchedulerTests
var storeB = new FakeDiscoveryStore(); var storeB = new FakeDiscoveryStore();
var settingsA = new FakeSettingsStore(); var settingsA = new FakeSettingsStore();
var settingsB = new FakeSettingsStore(); var settingsB = new FakeSettingsStore();
var gatewayA = new FakeDiscoveryGateway(); var gatewayA = new TestDiscoveryGateway();
var gatewayB = new FakeDiscoveryGateway(); var gatewayB = new TestDiscoveryGateway();
PreloadDiscoveryEvalOff(settingsA); PreloadDiscoveryEvalOff(settingsA);
PreloadDiscoveryEvalOff(settingsB); PreloadDiscoveryEvalOff(settingsB);
@@ -110,7 +110,7 @@ public sealed class DiscoveryWorkerSchedulerTests
// (эталон PipelineWorkerSchedulerTests/ConnectionStringProvider.ForTenant). // (эталон PipelineWorkerSchedulerTests/ConnectionStringProvider.ForTenant).
services.AddScoped<IDiscoveryStore>(provider => TenantOf(provider) == TenantA ? storeA : storeB); services.AddScoped<IDiscoveryStore>(provider => TenantOf(provider) == TenantA ? storeA : storeB);
services.AddScoped<ISettingsStore>(provider => TenantOf(provider) == TenantA ? settingsA : settingsB); 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. // Реальные сервисы модуля Discovery — как AddDiscoveryModule в Program.cs: цикл резолвит их в tenant-scope.
services.AddDiscoveryModule(); services.AddDiscoveryModule();
@@ -42,7 +42,7 @@ public sealed class TgStatusServiceTests
[Fact] [Fact]
public async Task GetAsync_GatewayUnavailable_ReturnsIdleFormWithKvAccountCountAndKeys() 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_1", "Канал", "channel", Monitor: true));
store.Seed(Dialog("d_2", "Группа", "group", Monitor: true)); store.Seed(Dialog("d_2", "Группа", "group", Monitor: true));
store.Seed(Dialog("d_off", "Выключен", "channel", Monitor: false)); store.Seed(Dialog("d_off", "Выключен", "channel", Monitor: false));
@@ -68,7 +68,7 @@ public sealed class TgStatusServiceTests
[Fact] [Fact]
public async Task GetAsync_ReadyGateway_ComposesLiveFieldsWithKvAccountMonitoredAndKeys() 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)); store.Seed(Dialog("d_1", "Канал", "channel", Monitor: true));
settings.Preload(SettingsKeys.TgAccount, "\"@realuser\""); settings.Preload(SettingsKeys.TgAccount, "\"@realuser\"");
PreloadKeys(globalSettings, "123456", "abcdefghijklmnop"); PreloadKeys(globalSettings, "123456", "abcdefghijklmnop");
@@ -91,7 +91,7 @@ public sealed class TgStatusServiceTests
[Fact] [Fact]
public async Task GetAsync_QrPhase_ReturnsQrUrlFromGateway() public async Task GetAsync_QrPhase_ReturnsQrUrlFromGateway()
{ {
(TgStatusService service, _, _, FakeTelegramGateway gateway, _, _) = Create(); (TgStatusService service, _, _, TestTelegramGateway gateway, _, _) = Create();
gateway.Status = new TelegramAccountStatusDto( gateway.Status = new TelegramAccountStatusDto(
Phase: "qr", Connected: false, Listener: false, Account: string.Empty, Error: null, QrUrl: "https://t.me/qr/test"); 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). // Собирает сервис на фейках (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 store = new FakeTelegramStore();
var settings = new FakeSettingsStore(); var settings = new FakeSettingsStore();
var gateway = new FakeTelegramGateway(); var gateway = new TestTelegramGateway();
var cipher = new FakeSecretCipher(); var cipher = new FakeSecretCipher();
var globalSettings = new TestGlobalSettingsStore(); var globalSettings = new TestGlobalSettingsStore();
var keys = new TelegramKeysService(globalSettings.Store, cipher); var keys = new TelegramKeysService(globalSettings.Store, cipher);
var dialogs = new DialogsService(store, settings, gateway, NullLogger<DialogsService>.Instance); var dialogs = new DialogsService(store, settings, gateway.Gateway, NullLogger<DialogsService>.Instance);
var service = new TgStatusService(gateway, dialogs, settings, keys); var service = new TgStatusService(gateway.Gateway, dialogs, settings, keys);
return (service, store, settings, gateway, cipher, globalSettings); return (service, store, settings, gateway, cipher, globalSettings);
} }
@@ -110,7 +110,7 @@ public sealed class DialogsServiceTests
[Fact] [Fact]
public async Task SetMonitor_EnableFirstTimeNotBackfilled_SetsFlagCallsGatewayAndNeedsBackfill() 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)); store.Seed(Dialog("d_1", "Канал", "channel", Monitor: false, Backfilled: false));
TelegramMonitorToggleDto result = await service.SetMonitorAsync("d_1", enabled: true, CancellationToken.None); TelegramMonitorToggleDto result = await service.SetMonitorAsync("d_1", enabled: true, CancellationToken.None);
@@ -127,7 +127,7 @@ public sealed class DialogsServiceTests
[Fact] [Fact]
public async Task SetMonitor_EnableAlreadyBackfilled_NoBackfillNeeded() 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)); store.Seed(Dialog("d_1", "Канал", "channel", Monitor: false, Backfilled: true));
TelegramMonitorToggleDto result = await service.SetMonitorAsync("d_1", enabled: true, CancellationToken.None); TelegramMonitorToggleDto result = await service.SetMonitorAsync("d_1", enabled: true, CancellationToken.None);
@@ -143,7 +143,7 @@ public sealed class DialogsServiceTests
[Fact] [Fact]
public async Task SetMonitor_Disable_TurnsOffAndNotifiesMirror() 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)); store.Seed(Dialog("d_1", "Канал", "channel", Monitor: true, Backfilled: true));
TelegramMonitorToggleDto result = await service.SetMonitorAsync("d_1", enabled: false, CancellationToken.None); TelegramMonitorToggleDto result = await service.SetMonitorAsync("d_1", enabled: false, CancellationToken.None);
@@ -160,7 +160,7 @@ public sealed class DialogsServiceTests
[Fact] [Fact]
public async Task SetMonitor_UnknownDialog_NoOpWithoutGatewayCall() 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); TelegramMonitorToggleDto result = await service.SetMonitorAsync("missing", enabled: true, CancellationToken.None);
@@ -178,7 +178,7 @@ public sealed class DialogsServiceTests
[Fact] [Fact]
public async Task SetMonitorAll_Enable_ReturnsCountAndNotBackfilledIdsAndNotifiesMirror() 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_a", "А", "channel", Monitor: false, Backfilled: true));
store.Seed(Dialog("d_b", "Б", "channel", Monitor: false, Backfilled: false)); store.Seed(Dialog("d_b", "Б", "channel", Monitor: false, Backfilled: false));
store.Seed(Dialog("d_c", "В", "group", Monitor: false, Backfilled: false)); store.Seed(Dialog("d_c", "В", "group", Monitor: false, Backfilled: false));
@@ -197,7 +197,7 @@ public sealed class DialogsServiceTests
[Fact] [Fact]
public async Task SetMonitorAll_Disable_TurnsAllOffAndEmptyBackfillList() 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_a", "А", "channel", Monitor: true, Backfilled: false));
store.Seed(Dialog("d_b", "Б", "channel", Monitor: true, Backfilled: true)); store.Seed(Dialog("d_b", "Б", "channel", Monitor: true, Backfilled: true));
@@ -230,7 +230,7 @@ public sealed class DialogsServiceTests
[Fact] [Fact]
public async Task ReadRecent_OneDialogFails_ContinuesWithOthersAndMarksOnlySuccessful() 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_1", "Канал", "channel", Monitor: true, Backfilled: false));
store.Seed(Dialog("d_2", "Группа", "group", Monitor: true, Backfilled: false)); store.Seed(Dialog("d_2", "Группа", "group", Monitor: true, Backfilled: false));
store.Seed(Dialog("d_3", "Форум", "forum", Monitor: true, Backfilled: false)); store.Seed(Dialog("d_3", "Форум", "forum", Monitor: true, Backfilled: false));
@@ -251,7 +251,7 @@ public sealed class DialogsServiceTests
[Fact] [Fact]
public async Task BackfillOne_UnknownOrAlreadyBackfilledWithoutForce_ReturnsZeroWithoutRpc() 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_known", "Канал", "channel", Monitor: true, Backfilled: true));
store.Seed(Dialog("d_fresh", "Группа", "group", Monitor: false, Backfilled: false)); store.Seed(Dialog("d_fresh", "Группа", "group", Monitor: false, Backfilled: false));
@@ -272,7 +272,7 @@ public sealed class DialogsServiceTests
[Fact] [Fact]
public async Task BackfillOne_ForceTrue_BackfillsAlreadyBackfilledDialogAndMarksAgain() 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)); store.Seed(Dialog("d_1", "Канал", "channel", Monitor: true, Backfilled: true));
int processed = await service.BackfillOneAsync("d_1", force: true, CancellationToken.None); int processed = await service.BackfillOneAsync("d_1", force: true, CancellationToken.None);
@@ -290,7 +290,7 @@ public sealed class DialogsServiceTests
[Fact] [Fact]
public async Task ReadRecent_OnlyMonitoredDialogs_BackfillsEachMonitoredWithForce() 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_1", "Канал", "channel", Monitor: true, Backfilled: true));
store.Seed(Dialog("d_2", "Группа", "group", Monitor: true, Backfilled: false)); store.Seed(Dialog("d_2", "Группа", "group", Monitor: true, Backfilled: false));
store.Seed(Dialog("d_off", "Выключен", "channel", Monitor: false, Backfilled: false)); store.Seed(Dialog("d_off", "Выключен", "channel", Monitor: false, Backfilled: false));
@@ -309,7 +309,7 @@ public sealed class DialogsServiceTests
[Fact] [Fact]
public async Task ReadRecent_NoMonitoredDialogs_ReturnsZeroWithoutGatewayCalls() 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)); store.Seed(Dialog("d_off", "Выключен", "channel", Monitor: false, Backfilled: false));
int count = await service.ReadRecentAsync(CancellationToken.None); int count = await service.ReadRecentAsync(CancellationToken.None);
@@ -426,7 +426,7 @@ public sealed class DialogsServiceTests
[Fact] [Fact]
public async Task AddDiscoveredMonitored_NewDialog_CreatesMonitoredRowAndNotifiesMirror() 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); await service.AddDiscoveredMonitoredAsync("d_100", "Канал", "channel_handle", "channel", "#a33", CancellationToken.None);
@@ -447,7 +447,7 @@ public sealed class DialogsServiceTests
[Fact] [Fact]
public async Task AddDiscoveredMonitored_ExistingDialog_UpdatesMetadataAndForcesMonitorOn() 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)); store.Seed(Dialog("d_100", "Старое имя", "group", Monitor: false, Backfilled: true));
await service.AddDiscoveredMonitoredAsync("d_100", "Новое имя", "new_handle", "channel", "#c21", CancellationToken.None); await service.AddDiscoveredMonitoredAsync("d_100", "Новое имя", "new_handle", "channel", "#c21", CancellationToken.None);
@@ -485,7 +485,7 @@ public sealed class DialogsServiceTests
[Fact] [Fact]
public async Task AddDiscoveredMonitored_GatewayMirrorFails_RowStillWritten() 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"); gateway.SetMonitorFailures.Add("d_100");
await service.AddDiscoveredMonitoredAsync("d_100", "Канал", string.Empty, "channel", "#666", CancellationToken.None); await service.AddDiscoveredMonitoredAsync("d_100", "Канал", string.Empty, "channel", "#666", CancellationToken.None);
@@ -497,12 +497,12 @@ public sealed class DialogsServiceTests
// ─── Хелперы ──────────────────────────────────────────────────────────── // ─── Хелперы ────────────────────────────────────────────────────────────
// Собирает сервис на фейках (FakeTelegramStore — семантика адаптера; настройки/гейт чистые). // Собирает сервис на фейках (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 store = new FakeTelegramStore();
var settings = new FakeSettingsStore(); var settings = new FakeSettingsStore();
var gateway = new FakeTelegramGateway(); var gateway = new TestTelegramGateway();
return (new DialogsService(store, settings, gateway, NullLogger<DialogsService>.Instance), store, settings, gateway); return (new DialogsService(store, settings, gateway.Gateway, NullLogger<DialogsService>.Instance), store, settings, gateway);
} }
// Строка каталога минимально (id/имя/kind + флаги/хвост каталога). // Строка каталога минимально (id/имя/kind + флаги/хвост каталога).
@@ -420,7 +420,7 @@ public sealed class DiscoveryWorkerServiceTests
private sealed record Fixture( private sealed record Fixture(
FakeDiscoveryStore Store, FakeDiscoveryStore Store,
FakeSettingsStore Settings, FakeSettingsStore Settings,
FakeDiscoveryGateway Gateway, TestDiscoveryGateway Gateway,
IDiscoveryPacer Pacer, IDiscoveryPacer Pacer,
DiscoveryWorkerService Worker, DiscoveryWorkerService Worker,
DiscoveryTasksService Tasks); DiscoveryTasksService Tasks);
@@ -441,9 +441,9 @@ public sealed class DiscoveryWorkerServiceTests
var tasks = new DiscoveryTasksService(store, planGuard, settings); var tasks = new DiscoveryTasksService(store, planGuard, settings);
var banGuard = new DiscoveryBanGuard(store, settings); var banGuard = new DiscoveryBanGuard(store, settings);
var pacer = Substitute.For<IDiscoveryPacer>(); var pacer = Substitute.For<IDiscoveryPacer>();
var gateway = new FakeDiscoveryGateway(); var gateway = new TestDiscoveryGateway();
var worker = new DiscoveryWorkerService( 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); 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<ISettingsStore>(_ => new FakeSettingsStore());
builder.Services.AddScoped<ITelegramStore>(_ => new FakeTelegramStore()); builder.Services.AddScoped<ITelegramStore>(_ => new FakeTelegramStore());
builder.Services.AddScoped<ITelegramGateway>(_ => new FakeTelegramGateway()); builder.Services.AddScoped<ITelegramGateway>(_ => new TestTelegramGateway().Gateway);
builder.Services.AddTelegramModule(); builder.Services.AddTelegramModule();
configureServices(builder.Services); configureServices(builder.Services);
@@ -13,8 +13,8 @@ public sealed class TelegramSourceContentProviderTests
[Fact] [Fact]
public async Task LoadAsync_ForeignKind_ReturnsNullWithoutGatewayCall() public async Task LoadAsync_ForeignKind_ReturnsNullWithoutGatewayCall()
{ {
var gateway = new FakeTelegramGateway(); var gateway = new TestTelegramGateway();
var provider = new TelegramSourceContentProvider(gateway); var provider = new TelegramSourceContentProvider(gateway.Gateway);
SourceContent? content = await provider.LoadAsync( SourceContent? content = await provider.LoadAsync(
SourceItemFactory.Source(kind: "local"), CancellationToken.None); SourceItemFactory.Source(kind: "local"), CancellationToken.None);
@@ -26,11 +26,11 @@ public sealed class TelegramSourceContentProviderTests
[Fact] [Fact]
public async Task LoadAsync_TelegramKindCaseInsensitive_LoadsContent() public async Task LoadAsync_TelegramKindCaseInsensitive_LoadsContent()
{ {
var gateway = new FakeTelegramGateway var gateway = new TestTelegramGateway
{ {
SourceContent = new TelegramSourceContentDto(true, "текст", null), SourceContent = new TelegramSourceContentDto(true, "текст", null),
}; };
var provider = new TelegramSourceContentProvider(gateway); var provider = new TelegramSourceContentProvider(gateway.Gateway);
SourceContent? content = await provider.LoadAsync( SourceContent? content = await provider.LoadAsync(
SourceItemFactory.Source(kind: "Telegram", origin: "d_1", externalId: "100"), CancellationToken.None); SourceItemFactory.Source(kind: "Telegram", origin: "d_1", externalId: "100"), CancellationToken.None);
@@ -42,8 +42,8 @@ public sealed class TelegramSourceContentProviderTests
[Fact] [Fact]
public async Task LoadAsync_EmptyOriginRef_ReturnsNullWithoutGatewayCall() public async Task LoadAsync_EmptyOriginRef_ReturnsNullWithoutGatewayCall()
{ {
var gateway = new FakeTelegramGateway(); var gateway = new TestTelegramGateway();
var provider = new TelegramSourceContentProvider(gateway); var provider = new TelegramSourceContentProvider(gateway.Gateway);
SourceContent? content = await provider.LoadAsync( SourceContent? content = await provider.LoadAsync(
SourceItemFactory.Source(kind: "telegram", origin: null, externalId: "100"), CancellationToken.None); SourceItemFactory.Source(kind: "telegram", origin: null, externalId: "100"), CancellationToken.None);
@@ -55,8 +55,8 @@ public sealed class TelegramSourceContentProviderTests
[Fact] [Fact]
public async Task LoadAsync_NonNumericExternalId_ReturnsNullWithoutGatewayCall() public async Task LoadAsync_NonNumericExternalId_ReturnsNullWithoutGatewayCall()
{ {
var gateway = new FakeTelegramGateway(); var gateway = new TestTelegramGateway();
var provider = new TelegramSourceContentProvider(gateway); var provider = new TelegramSourceContentProvider(gateway.Gateway);
SourceContent? content = await provider.LoadAsync( SourceContent? content = await provider.LoadAsync(
SourceItemFactory.Source(kind: "telegram", origin: "d_1", externalId: "not-a-number"), CancellationToken.None); SourceItemFactory.Source(kind: "telegram", origin: "d_1", externalId: "not-a-number"), CancellationToken.None);
@@ -68,11 +68,11 @@ public sealed class TelegramSourceContentProviderTests
[Fact] [Fact]
public async Task LoadAsync_GatewayNotFound_ReturnsNull() public async Task LoadAsync_GatewayNotFound_ReturnsNull()
{ {
var gateway = new FakeTelegramGateway var gateway = new TestTelegramGateway
{ {
SourceContent = new TelegramSourceContentDto(false, null, null), SourceContent = new TelegramSourceContentDto(false, null, null),
}; };
var provider = new TelegramSourceContentProvider(gateway); var provider = new TelegramSourceContentProvider(gateway.Gateway);
SourceContent? content = await provider.LoadAsync( SourceContent? content = await provider.LoadAsync(
SourceItemFactory.Source(kind: "telegram", origin: "d_1", externalId: "100"), CancellationToken.None); SourceItemFactory.Source(kind: "telegram", origin: "d_1", externalId: "100"), CancellationToken.None);
@@ -83,11 +83,11 @@ public sealed class TelegramSourceContentProviderTests
[Fact] [Fact]
public async Task LoadAsync_Found_ReturnsText() public async Task LoadAsync_Found_ReturnsText()
{ {
var gateway = new FakeTelegramGateway var gateway = new TestTelegramGateway
{ {
SourceContent = new TelegramSourceContentDto(true, "исходный текст", 1_700_000_000_000), 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( SourceContent? content = await provider.LoadAsync(
SourceItemFactory.Source(kind: "telegram", origin: "d_42", externalId: "777"), CancellationToken.None); SourceItemFactory.Source(kind: "telegram", origin: "d_42", externalId: "777"), CancellationToken.None);
@@ -99,8 +99,8 @@ public sealed class TelegramSourceContentProviderTests
[Fact] [Fact]
public async Task LoadAsync_NumericExternalIdAndOriginRef_CallsGatewayWithParsedArguments() public async Task LoadAsync_NumericExternalIdAndOriginRef_CallsGatewayWithParsedArguments()
{ {
var gateway = new FakeTelegramGateway(); var gateway = new TestTelegramGateway();
var provider = new TelegramSourceContentProvider(gateway); var provider = new TelegramSourceContentProvider(gateway.Gateway);
await provider.LoadAsync( await provider.LoadAsync(
SourceItemFactory.Source(kind: "telegram", origin: "d_42", externalId: "777"), CancellationToken.None); SourceItemFactory.Source(kind: "telegram", origin: "d_42", externalId: "777"), CancellationToken.None);
@@ -72,7 +72,7 @@ public sealed class TelegramSourceIngestObserverTests
var dialogs = new DialogsService( var dialogs = new DialogsService(
store, store,
new FakeSettingsStore(), new FakeSettingsStore(),
new FakeTelegramGateway(), new TestTelegramGateway().Gateway,
NullLogger<DialogsService>.Instance); NullLogger<DialogsService>.Instance);
var observer = new TelegramSourceIngestObserver(dialogs, NullLogger<TelegramSourceIngestObserver>.Instance); var observer = new TelegramSourceIngestObserver(dialogs, NullLogger<TelegramSourceIngestObserver>.Instance);
return (observer, store); 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", []));
}
}