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

Хелпер Support/TestTelegramStore: словари каталога/превью + NSubstitute-подставка,
все методы порта читают состояние на момент вызова (семантика sync/upsert/
мониторов/бэкфилла сохранена). Потребители (5 файлов) перетипизированы на
.Store, фейк удалён, тесты 1340 зелёные.
This commit is contained in:
Rustam Khalimov
2026-09-12 21:55:43 +03:00
parent 25954c9fad
commit 435c473df4
6 changed files with 114 additions and 127 deletions
@@ -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, TestTelegramGateway gateway, _, TestGlobalSettingsStore globalSettings) = Create(); (TgStatusService service, TestTelegramStore 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, TestTelegramGateway gateway, _, TestGlobalSettingsStore globalSettings) = Create(); (TgStatusService service, TestTelegramStore 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");
@@ -105,15 +105,15 @@ public sealed class TgStatusServiceTests
// ─── Хелперы ──────────────────────────────────────────────────────────── // ─── Хелперы ────────────────────────────────────────────────────────────
// Собирает сервис на фейках (FakeTelegramGateway/Store/SettingsStore/GlobalSettingsStore + FakeSecretCipher). // Собирает сервис на фейках (FakeTelegramGateway/Store/SettingsStore/GlobalSettingsStore + FakeSecretCipher).
private static (TgStatusService Service, FakeTelegramStore Store, FakeSettingsStore Settings, TestTelegramGateway Gateway, FakeSecretCipher Cipher, TestGlobalSettingsStore GlobalSettings) Create() private static (TgStatusService Service, TestTelegramStore Store, FakeSettingsStore Settings, TestTelegramGateway Gateway, FakeSecretCipher Cipher, TestGlobalSettingsStore GlobalSettings) Create()
{ {
var store = new FakeTelegramStore(); var store = new TestTelegramStore();
var settings = new FakeSettingsStore(); var settings = new FakeSettingsStore();
var gateway = new TestTelegramGateway(); 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.Gateway, NullLogger<DialogsService>.Instance); var dialogs = new DialogsService(store.Store, settings, gateway.Gateway, NullLogger<DialogsService>.Instance);
var service = new TgStatusService(gateway.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);
} }
@@ -21,7 +21,7 @@ public sealed class DialogsServiceTests
[Fact] [Fact]
public async Task Sync_NewDialogsAutoMonitorNewDefaultTrue_TurnMonitoringOn() public async Task Sync_NewDialogsAutoMonitorNewDefaultTrue_TurnMonitoringOn()
{ {
(DialogsService service, FakeTelegramStore store, _, _) = Create(); (DialogsService service, TestTelegramStore store, _, _) = Create();
int count = await service.SyncFromTelegramAsync(Catalog( int count = await service.SyncFromTelegramAsync(Catalog(
Entry("d_1", "Канал", "channel"), Entry("d_1", "Канал", "channel"),
@@ -38,7 +38,7 @@ public sealed class DialogsServiceTests
[Fact] [Fact]
public async Task Sync_AutoMonitorNewFalse_AddsNewDialogsUnmonitored() public async Task Sync_AutoMonitorNewFalse_AddsNewDialogsUnmonitored()
{ {
(DialogsService service, FakeTelegramStore store, FakeSettingsStore settings, _) = Create(); (DialogsService service, TestTelegramStore store, FakeSettingsStore settings, _) = Create();
settings.Preload(SettingsKeys.AutoMonitorNew, "false"); settings.Preload(SettingsKeys.AutoMonitorNew, "false");
int count = await service.SyncFromTelegramAsync(Catalog(Entry("d_1", "Канал", "channel")), CancellationToken.None); int count = await service.SyncFromTelegramAsync(Catalog(Entry("d_1", "Канал", "channel")), CancellationToken.None);
@@ -55,7 +55,7 @@ public sealed class DialogsServiceTests
[Fact] [Fact]
public async Task Sync_ExistingDialog_UpdatesMetadataAndKeepsUserMonitor() public async Task Sync_ExistingDialog_UpdatesMetadataAndKeepsUserMonitor()
{ {
(DialogsService service, FakeTelegramStore store, _, _) = Create(); (DialogsService service, TestTelegramStore store, _, _) = Create();
store.Seed(Dialog("d_1", "Старое имя", "channel", Monitor: false, Backfilled: true)); store.Seed(Dialog("d_1", "Старое имя", "channel", Monitor: false, Backfilled: true));
await service.SyncFromTelegramAsync( await service.SyncFromTelegramAsync(
@@ -76,7 +76,7 @@ public sealed class DialogsServiceTests
[Fact] [Fact]
public async Task Sync_DialogLeftCatalog_IsDeleted() public async Task Sync_DialogLeftCatalog_IsDeleted()
{ {
(DialogsService service, FakeTelegramStore store, _, _) = Create(); (DialogsService service, TestTelegramStore store, _, _) = Create();
store.Seed(Dialog("d_left", "Ушёл", "channel", Monitor: true, Backfilled: true)); store.Seed(Dialog("d_left", "Ушёл", "channel", Monitor: true, Backfilled: true));
int count = await service.SyncFromTelegramAsync(Catalog(Entry("d_stay", "Остался", "group")), CancellationToken.None); int count = await service.SyncFromTelegramAsync(Catalog(Entry("d_stay", "Остался", "group")), CancellationToken.None);
@@ -93,7 +93,7 @@ public sealed class DialogsServiceTests
[Fact] [Fact]
public async Task Sync_EmptyCatalog_ReturnsZeroAndKeepsRows() public async Task Sync_EmptyCatalog_ReturnsZeroAndKeepsRows()
{ {
(DialogsService service, FakeTelegramStore store, _, _) = Create(); (DialogsService service, TestTelegramStore store, _, _) = Create();
store.Seed(Dialog("d_1", "Канал", "channel", Monitor: true, Backfilled: true)); store.Seed(Dialog("d_1", "Канал", "channel", Monitor: true, Backfilled: true));
int count = await service.SyncFromTelegramAsync([], CancellationToken.None); int count = await service.SyncFromTelegramAsync([], CancellationToken.None);
@@ -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, _, TestTelegramGateway gateway) = Create(); (DialogsService service, TestTelegramStore 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, _, TestTelegramGateway gateway) = Create(); (DialogsService service, TestTelegramStore 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, _, TestTelegramGateway gateway) = Create(); (DialogsService service, TestTelegramStore 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, _, TestTelegramGateway gateway) = Create(); (DialogsService service, TestTelegramStore 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, _, TestTelegramGateway gateway) = Create(); (DialogsService service, TestTelegramStore 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, _, TestTelegramGateway gateway) = Create(); (DialogsService service, TestTelegramStore 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));
@@ -215,7 +215,7 @@ public sealed class DialogsServiceTests
[Fact] [Fact]
public async Task MarkBackfilled_SetsFlagOnDialog() public async Task MarkBackfilled_SetsFlagOnDialog()
{ {
(DialogsService service, FakeTelegramStore store, _, _) = Create(); (DialogsService service, TestTelegramStore store, _, _) = Create();
store.Seed(Dialog("d_1", "Канал", "channel", Monitor: true, Backfilled: false)); store.Seed(Dialog("d_1", "Канал", "channel", Monitor: true, Backfilled: false));
await service.MarkBackfilledAsync("d_1", CancellationToken.None); await service.MarkBackfilledAsync("d_1", CancellationToken.None);
@@ -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, _, TestTelegramGateway gateway) = Create(); (DialogsService service, TestTelegramStore 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, _, TestTelegramGateway gateway) = Create(); (DialogsService service, TestTelegramStore 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, _, TestTelegramGateway gateway) = Create(); (DialogsService service, TestTelegramStore 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, _, TestTelegramGateway gateway) = Create(); (DialogsService service, TestTelegramStore 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, _, TestTelegramGateway gateway) = Create(); (DialogsService service, TestTelegramStore 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);
@@ -326,7 +326,7 @@ public sealed class DialogsServiceTests
[Fact] [Fact]
public async Task List_ReturnsMonitoredFirstByNameOrderWithLastMessage() public async Task List_ReturnsMonitoredFirstByNameOrderWithLastMessage()
{ {
(DialogsService service, FakeTelegramStore store, _, _) = Create(); (DialogsService service, TestTelegramStore store, _, _) = Create();
DateTimeOffset lastAt = DateTimeOffset.UtcNow.AddMinutes(-5); DateTimeOffset lastAt = DateTimeOffset.UtcNow.AddMinutes(-5);
store.Seed(Dialog("d_gamma", "Гамма", "channel", Monitor: true, Backfilled: true, LastText: "привет", LastAt: lastAt)); store.Seed(Dialog("d_gamma", "Гамма", "channel", Monitor: true, Backfilled: true, LastText: "привет", LastAt: lastAt));
store.Seed(Dialog("d_alpha", "Альфа", "group", Monitor: false, Backfilled: false)); store.Seed(Dialog("d_alpha", "Альфа", "group", Monitor: false, Backfilled: false));
@@ -352,7 +352,7 @@ public sealed class DialogsServiceTests
[Fact] [Fact]
public async Task SavePreview_PersistsPreviewRowAndTouchesDialogLast() public async Task SavePreview_PersistsPreviewRowAndTouchesDialogLast()
{ {
(DialogsService service, FakeTelegramStore store, _, _) = Create(); (DialogsService service, TestTelegramStore store, _, _) = Create();
store.Seed(Dialog("d_1", "Канал", "channel", Monitor: true, Backfilled: false)); store.Seed(Dialog("d_1", "Канал", "channel", Monitor: true, Backfilled: false));
string longText = new('т', 5000); string longText = new('т', 5000);
DateTimeOffset msgAt = DateTimeOffset.UtcNow.AddMinutes(-1); DateTimeOffset msgAt = DateTimeOffset.UtcNow.AddMinutes(-1);
@@ -375,7 +375,7 @@ public sealed class DialogsServiceTests
[Fact] [Fact]
public async Task SavePreview_EmptyText_DoesNotTouchStore() public async Task SavePreview_EmptyText_DoesNotTouchStore()
{ {
(DialogsService service, FakeTelegramStore store, _, _) = Create(); (DialogsService service, TestTelegramStore store, _, _) = Create();
store.Seed(Dialog("d_1", "Канал", "channel", Monitor: true, Backfilled: false)); store.Seed(Dialog("d_1", "Канал", "channel", Monitor: true, Backfilled: false));
await service.SavePreviewAsync("d_1", msgId: 7, " ", DateTimeOffset.UtcNow, CancellationToken.None); await service.SavePreviewAsync("d_1", msgId: 7, " ", DateTimeOffset.UtcNow, CancellationToken.None);
@@ -391,7 +391,7 @@ public sealed class DialogsServiceTests
[Fact] [Fact]
public async Task SavePreview_NoMsgId_UpdatesLastOnly() public async Task SavePreview_NoMsgId_UpdatesLastOnly()
{ {
(DialogsService service, FakeTelegramStore store, _, _) = Create(); (DialogsService service, TestTelegramStore store, _, _) = Create();
store.Seed(Dialog("d_1", "Канал", "channel", Monitor: true, Backfilled: false)); store.Seed(Dialog("d_1", "Канал", "channel", Monitor: true, Backfilled: false));
await service.SavePreviewAsync("d_1", msgId: null, "Новое сообщение", null, CancellationToken.None); await service.SavePreviewAsync("d_1", msgId: null, "Новое сообщение", null, CancellationToken.None);
@@ -407,7 +407,7 @@ public sealed class DialogsServiceTests
[Fact] [Fact]
public async Task SavePreview_DuplicateMessageId_KeepsFirstRow() public async Task SavePreview_DuplicateMessageId_KeepsFirstRow()
{ {
(DialogsService service, FakeTelegramStore store, _, _) = Create(); (DialogsService service, TestTelegramStore store, _, _) = Create();
store.Seed(Dialog("d_1", "Канал", "channel", Monitor: true, Backfilled: false)); store.Seed(Dialog("d_1", "Канал", "channel", Monitor: true, Backfilled: false));
DateTimeOffset firstAt = DateTimeOffset.UtcNow.AddMinutes(-2); DateTimeOffset firstAt = DateTimeOffset.UtcNow.AddMinutes(-2);
@@ -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, _, TestTelegramGateway gateway) = Create(); (DialogsService service, TestTelegramStore 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, _, TestTelegramGateway gateway) = Create(); (DialogsService service, TestTelegramStore 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);
@@ -468,7 +468,7 @@ public sealed class DialogsServiceTests
[Fact] [Fact]
public async Task AddDiscoveredMonitored_EmptyNameAndHue_FallBackToDialogIdAndDefaultHue() public async Task AddDiscoveredMonitored_EmptyNameAndHue_FallBackToDialogIdAndDefaultHue()
{ {
(DialogsService service, FakeTelegramStore store, _, _) = Create(); (DialogsService service, TestTelegramStore store, _, _) = Create();
await service.AddDiscoveredMonitoredAsync("d_100", " ", string.Empty, string.Empty, string.Empty, CancellationToken.None); await service.AddDiscoveredMonitoredAsync("d_100", " ", string.Empty, string.Empty, string.Empty, 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, _, TestTelegramGateway gateway) = Create(); (DialogsService service, TestTelegramStore 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);
@@ -496,13 +496,13 @@ public sealed class DialogsServiceTests
// ─── Хелперы ──────────────────────────────────────────────────────────── // ─── Хелперы ────────────────────────────────────────────────────────────
// Собирает сервис на фейках (FakeTelegramStore — семантика адаптера; настройки/гейт чистые). // Собирает сервис на фейках (TestTelegramStore — семантика адаптера; настройки/гейт чистые).
private static (DialogsService Service, FakeTelegramStore Store, FakeSettingsStore Settings, TestTelegramGateway Gateway) Create() private static (DialogsService Service, TestTelegramStore Store, FakeSettingsStore Settings, TestTelegramGateway Gateway) Create()
{ {
var store = new FakeTelegramStore(); var store = new TestTelegramStore();
var settings = new FakeSettingsStore(); var settings = new FakeSettingsStore();
var gateway = new TestTelegramGateway(); var gateway = new TestTelegramGateway();
return (new DialogsService(store, settings, gateway.Gateway, NullLogger<DialogsService>.Instance), store, settings, gateway); return (new DialogsService(store.Store, settings, gateway.Gateway, NullLogger<DialogsService>.Instance), store, settings, gateway);
} }
// Строка каталога минимально (id/имя/kind + флаги/хвост каталога). // Строка каталога минимально (id/имя/kind + флаги/хвост каталога).
@@ -35,11 +35,11 @@ public sealed class TelegramIngressServiceTests
public async Task SyncDialogs_KnownTenant_AppliesCatalogAndReturnsMonitoredIds() public async Task SyncDialogs_KnownTenant_AppliesCatalogAndReturnsMonitoredIds()
{ {
var registry = TestRegistry.With(Tenant(TenantA)); var registry = TestRegistry.With(Tenant(TenantA));
var dialogs = new FakeTelegramStore(); var dialogs = new TestTelegramStore();
await RunAsync( await RunAsync(
registry, registry,
services => services.AddScoped<ITelegramStore>(_ => dialogs), services => services.AddScoped<ITelegramStore>(_ => dialogs.Store),
async channel => async channel =>
{ {
var request = new SyncDialogsRequest(); var request = new SyncDialogsRequest();
@@ -63,14 +63,14 @@ public sealed class TelegramIngressServiceTests
var registry = TestRegistry.With(Tenant(TenantA)); var registry = TestRegistry.With(Tenant(TenantA));
var settings = new FakeSettingsStore(); var settings = new FakeSettingsStore();
settings.Preload(SettingsKeys.AutoMonitorNew, "false"); settings.Preload(SettingsKeys.AutoMonitorNew, "false");
var dialogs = new FakeTelegramStore(); var dialogs = new TestTelegramStore();
await RunAsync( await RunAsync(
registry, registry,
services => services =>
{ {
services.AddScoped<ISettingsStore>(_ => settings); services.AddScoped<ISettingsStore>(_ => settings);
services.AddScoped<ITelegramStore>(_ => dialogs); services.AddScoped<ITelegramStore>(_ => dialogs.Store);
}, },
async channel => async channel =>
{ {
@@ -100,7 +100,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 TestTelegramStore().Store);
builder.Services.AddScoped<ITelegramGateway>(_ => new TestTelegramGateway().Gateway); builder.Services.AddScoped<ITelegramGateway>(_ => new TestTelegramGateway().Gateway);
builder.Services.AddTelegramModule(); builder.Services.AddTelegramModule();
@@ -20,7 +20,7 @@ public sealed class TelegramSourceIngestObserverTests
[Fact] [Fact]
public async Task OnIngestedAsync_KnownKind_SavesPreview() public async Task OnIngestedAsync_KnownKind_SavesPreview()
{ {
(TelegramSourceIngestObserver observer, FakeTelegramStore store) = Create(); (TelegramSourceIngestObserver observer, TestTelegramStore store) = Create();
store.Seed(Dialog()); store.Seed(Dialog());
DateTimeOffset receivedAt = DateTimeOffset.UtcNow.AddMinutes(-1); DateTimeOffset receivedAt = DateTimeOffset.UtcNow.AddMinutes(-1);
@@ -40,7 +40,7 @@ public sealed class TelegramSourceIngestObserverTests
[Fact] [Fact]
public async Task OnIngestedAsync_ForeignKind_DoesNotTouchStore() public async Task OnIngestedAsync_ForeignKind_DoesNotTouchStore()
{ {
(TelegramSourceIngestObserver observer, FakeTelegramStore store) = Create(); (TelegramSourceIngestObserver observer, TestTelegramStore store) = Create();
store.Seed(Dialog()); store.Seed(Dialog());
await observer.OnIngestedAsync(Item("avito", DialogId, "7", "Текст", DateTimeOffset.UtcNow), CancellationToken.None); await observer.OnIngestedAsync(Item("avito", DialogId, "7", "Текст", DateTimeOffset.UtcNow), CancellationToken.None);
@@ -56,7 +56,7 @@ public sealed class TelegramSourceIngestObserverTests
[Fact] [Fact]
public async Task OnIngestedAsync_NoOriginRef_DoesNotTouchStore() public async Task OnIngestedAsync_NoOriginRef_DoesNotTouchStore()
{ {
(TelegramSourceIngestObserver observer, FakeTelegramStore store) = Create(); (TelegramSourceIngestObserver observer, TestTelegramStore store) = Create();
store.Seed(Dialog()); store.Seed(Dialog());
await observer.OnIngestedAsync(Item("telegram", null, "7", "Текст", DateTimeOffset.UtcNow), CancellationToken.None); await observer.OnIngestedAsync(Item("telegram", null, "7", "Текст", DateTimeOffset.UtcNow), CancellationToken.None);
@@ -66,11 +66,11 @@ public sealed class TelegramSourceIngestObserverTests
} }
// Собирает наблюдателя на фейковом хранилище диалогов. // Собирает наблюдателя на фейковом хранилище диалогов.
private static (TelegramSourceIngestObserver Observer, FakeTelegramStore Store) Create() private static (TelegramSourceIngestObserver Observer, TestTelegramStore Store) Create()
{ {
var store = new FakeTelegramStore(); var store = new TestTelegramStore();
var dialogs = new DialogsService( var dialogs = new DialogsService(
store, store.Store,
new FakeSettingsStore(), new FakeSettingsStore(),
new TestTelegramGateway().Gateway, new TestTelegramGateway().Gateway,
NullLogger<DialogsService>.Instance); NullLogger<DialogsService>.Instance);
@@ -1,28 +1,70 @@
using Deal.Contracts.Integrations.Models; using Deal.Contracts.Integrations.Models;
using Deal.Modules.Telegram.Application; using Deal.Modules.Telegram.Application;
using Deal.Modules.Telegram.Application.Models; using Deal.Modules.Telegram.Application.Models;
using Deal.Tests.Unit.Support; using NSubstitute;
namespace Deal.Tests.Unit.Contracts; namespace Deal.Tests.Unit.Support;
/// <summary> /// <summary>
/// In-memory реализация <see cref="ITelegramStore"/> для юнит-тестов DialogsService/ингресса. /// Подставка <see cref="ITelegramStore"/> на словарях: сервисы получают NSubstitute-подставку
/// (<see cref="Store"/>), тесты сеют данные через <see cref="Seed"/>/<see cref="SeedMessage"/>
/// и читают состояние через <see cref="Dialogs"/>/<see cref="Messages"/>.
/// </summary> /// </summary>
public sealed class FakeTelegramStore : ITelegramStore public sealed class TestTelegramStore
{ {
private readonly Dictionary<string, FakeTelegramDialogRow> _dialogs = new(StringComparer.Ordinal); private readonly Dictionary<string, FakeTelegramDialogRow> _dialogs = new(StringComparer.Ordinal);
private readonly Dictionary<string, FakeTelegramMessageRow> _messages = new(StringComparer.Ordinal); private readonly Dictionary<string, FakeTelegramMessageRow> _messages = new(StringComparer.Ordinal);
/// <summary> /// <summary>
/// Строки каталога фейка /// Подставка порта Telegram-хранилища (создаётся в конструкторе).
/// </summary>
public ITelegramStore Store { get; }
/// <summary>
/// Строки каталога подставки
/// </summary> /// </summary>
public IReadOnlyList<FakeTelegramDialogRow> Dialogs => _dialogs.Values.ToList(); public IReadOnlyList<FakeTelegramDialogRow> Dialogs => _dialogs.Values.ToList();
/// <summary> /// <summary>
/// Строки превью фейка /// Строки превью подставки
/// </summary> /// </summary>
public IReadOnlyList<FakeTelegramMessageRow> Messages => _messages.Values.ToList(); public IReadOnlyList<FakeTelegramMessageRow> Messages => _messages.Values.ToList();
/// <summary>
/// Создаёт подставку с пустыми словарями.
/// </summary>
public TestTelegramStore()
{
Store = Substitute.For<ITelegramStore>();
Store.SyncFromTelegramAsync(Arg.Any<IReadOnlyCollection<TelegramDialogEntryDto>>(), Arg.Any<bool>(), Arg.Any<CancellationToken>())
.Returns(ci => SyncFromTelegram(
ci.ArgAt<IReadOnlyCollection<TelegramDialogEntryDto>>(0), ci.ArgAt<bool>(1)));
Store.ListAsync(Arg.Any<CancellationToken>()).Returns(ci => ListDialogs());
Store.ListMonitoredIdsAsync(Arg.Any<CancellationToken>()).Returns(ci => ListMonitoredIds());
Store.When(s => s.SetMonitorAsync(Arg.Any<string>(), Arg.Any<bool>(), Arg.Any<CancellationToken>()))
.Do(ci => SetMonitor(ci.ArgAt<string>(0), ci.ArgAt<bool>(1)));
Store.SetMonitorAllAsync(Arg.Any<bool>(), Arg.Any<CancellationToken>())
.Returns(ci => SetMonitorAll(ci.ArgAt<bool>(0)));
Store.GetBackfilledAsync(Arg.Any<string>(), Arg.Any<CancellationToken>())
.Returns(ci => GetBackfilled(ci.ArgAt<string>(0)));
Store.ListNotBackfilledIdsAsync(Arg.Any<CancellationToken>()).Returns(ci => ListNotBackfilledIds());
Store.When(s => s.SetBackfilledAsync(Arg.Any<string>(), Arg.Any<CancellationToken>()))
.Do(ci => SetBackfilled(ci.ArgAt<string>(0)));
Store.When(s => s.UpsertDiscoveredMonitoredAsync(
Arg.Any<string>(), Arg.Any<string>(), Arg.Any<string>(), Arg.Any<string>(), Arg.Any<string>(), Arg.Any<CancellationToken>()))
.Do(ci => UpsertDiscoveredMonitored(
ci.ArgAt<string>(0), ci.ArgAt<string>(1), ci.ArgAt<string>(2), ci.ArgAt<string>(3), ci.ArgAt<string>(4)));
Store.When(s => s.SavePreviewAsync(
Arg.Any<string>(), Arg.Any<string>(), Arg.Any<string>(), Arg.Any<DateTimeOffset>(), Arg.Any<CancellationToken>()))
.Do(ci => SavePreview(
ci.ArgAt<string>(0), ci.ArgAt<string>(1), ci.ArgAt<string>(2), ci.ArgAt<DateTimeOffset>(3)));
Store.When(s => s.TouchDialogLastAsync(
Arg.Any<string>(), Arg.Any<string>(), Arg.Any<DateTimeOffset>(), Arg.Any<CancellationToken>()))
.Do(ci => TouchDialogLast(ci.ArgAt<string>(0), ci.ArgAt<string>(1), ci.ArgAt<DateTimeOffset>(2)));
Store.ListMessagesAsync(Arg.Any<string>(), Arg.Any<int>(), Arg.Any<CancellationToken>())
.Returns(ci => ListMessages(ci.ArgAt<string>(0), ci.ArgAt<int>(1)));
}
/// <summary> /// <summary>
/// Кладёт строку каталога напрямую /// Кладёт строку каталога напрямую
/// </summary> /// </summary>
@@ -41,15 +83,11 @@ public sealed class FakeTelegramStore : ITelegramStore
_messages[row.Id] = row; _messages[row.Id] = row;
} }
/// <inheritdoc /> private int SyncFromTelegram(IReadOnlyCollection<TelegramDialogEntryDto> entries, bool autoMonitorNew)
public Task<int> SyncFromTelegramAsync(
IReadOnlyCollection<TelegramDialogEntryDto> entries,
bool autoMonitorNew,
CancellationToken ct)
{ {
if (entries.Count == 0) if (entries.Count == 0)
{ {
return Task.FromResult(0); return 0;
} }
DateTimeOffset now = DateTimeOffset.UtcNow; DateTimeOffset now = DateTimeOffset.UtcNow;
@@ -81,47 +119,36 @@ public sealed class FakeTelegramStore : ITelegramStore
_dialogs.Remove(id); _dialogs.Remove(id);
} }
return Task.FromResult(entries.Count); return entries.Count;
} }
/// <inheritdoc /> private IReadOnlyList<TelegramDialogDto> ListDialogs()
public Task<IReadOnlyList<TelegramDialogDto>> ListAsync(CancellationToken ct)
{ {
IReadOnlyList<TelegramDialogDto> items = _dialogs.Values return _dialogs.Values
.OrderByDescending(row => row.Monitor) .OrderByDescending(row => row.Monitor)
.ThenBy(row => row.Name) .ThenBy(row => row.Name)
.Select(ToDialogDto) .Select(ToDialogDto)
.ToList(); .ToList();
return Task.FromResult(items);
} }
/// <inheritdoc /> private IReadOnlyCollection<string> ListMonitoredIds()
public Task<IReadOnlyCollection<string>> ListMonitoredIdsAsync(CancellationToken ct)
{ {
IReadOnlyCollection<string> ids = _dialogs.Values return _dialogs.Values
.Where(row => row.Monitor) .Where(row => row.Monitor)
.OrderBy(row => row.Id) .OrderBy(row => row.Id)
.Select(row => row.Id) .Select(row => row.Id)
.ToList(); .ToList();
return Task.FromResult(ids);
} }
/// <inheritdoc /> private void SetMonitor(string dialogId, bool enabled)
public Task SetMonitorAsync(
string dialogId,
bool enabled,
CancellationToken ct)
{ {
if (_dialogs.TryGetValue(dialogId, out FakeTelegramDialogRow? existing)) if (_dialogs.TryGetValue(dialogId, out FakeTelegramDialogRow? existing))
{ {
_dialogs[dialogId] = existing with { Monitor = enabled, UpdatedAt = DateTimeOffset.UtcNow }; _dialogs[dialogId] = existing with { Monitor = enabled, UpdatedAt = DateTimeOffset.UtcNow };
} }
return Task.CompletedTask;
} }
/// <inheritdoc /> private int SetMonitorAll(bool enabled)
public Task<int> SetMonitorAllAsync(bool enabled, CancellationToken ct)
{ {
int count = _dialogs.Count; int count = _dialogs.Count;
DateTimeOffset now = DateTimeOffset.UtcNow; DateTimeOffset now = DateTimeOffset.UtcNow;
@@ -130,50 +157,32 @@ public sealed class FakeTelegramStore : ITelegramStore
_dialogs[id] = _dialogs[id] with { Monitor = enabled, UpdatedAt = now }; _dialogs[id] = _dialogs[id] with { Monitor = enabled, UpdatedAt = now };
} }
return Task.FromResult(count); return count;
} }
/// <inheritdoc /> private bool? GetBackfilled(string dialogId)
public Task<bool?> GetBackfilledAsync(string dialogId, CancellationToken ct)
{ {
if (_dialogs.TryGetValue(dialogId, out FakeTelegramDialogRow? row)) return _dialogs.TryGetValue(dialogId, out FakeTelegramDialogRow? row) ? row.Backfilled : null;
{
return Task.FromResult<bool?>(row.Backfilled);
}
return Task.FromResult<bool?>(null);
} }
/// <inheritdoc /> private IReadOnlyCollection<string> ListNotBackfilledIds()
public Task<IReadOnlyCollection<string>> ListNotBackfilledIdsAsync(CancellationToken ct)
{ {
IReadOnlyCollection<string> ids = _dialogs.Values return _dialogs.Values
.Where(row => !row.Backfilled) .Where(row => !row.Backfilled)
.OrderBy(row => row.Id) .OrderBy(row => row.Id)
.Select(row => row.Id) .Select(row => row.Id)
.ToList(); .ToList();
return Task.FromResult(ids);
} }
/// <inheritdoc /> private void SetBackfilled(string dialogId)
public Task SetBackfilledAsync(string dialogId, CancellationToken ct)
{ {
if (_dialogs.TryGetValue(dialogId, out FakeTelegramDialogRow? existing)) if (_dialogs.TryGetValue(dialogId, out FakeTelegramDialogRow? existing))
{ {
_dialogs[dialogId] = existing with { Backfilled = true }; _dialogs[dialogId] = existing with { Backfilled = true };
} }
return Task.CompletedTask;
} }
/// <inheritdoc /> private void UpsertDiscoveredMonitored(string dialogId, string name, string handle, string kind, string hue)
public Task UpsertDiscoveredMonitoredAsync(
string dialogId,
string name,
string handle,
string kind,
string hue,
CancellationToken ct)
{ {
if (_dialogs.TryGetValue(dialogId, out FakeTelegramDialogRow? existing)) if (_dialogs.TryGetValue(dialogId, out FakeTelegramDialogRow? existing))
{ {
@@ -194,54 +203,32 @@ public sealed class FakeTelegramStore : ITelegramStore
dialogId, name, handle, kind, hue, dialogId, name, handle, kind, hue,
Monitor: true, LastText: string.Empty, LastAt: null, Backfilled: false, UpdatedAt: DateTimeOffset.UtcNow); Monitor: true, LastText: string.Empty, LastAt: null, Backfilled: false, UpdatedAt: DateTimeOffset.UtcNow);
} }
return Task.CompletedTask;
} }
/// <inheritdoc /> private void SavePreview(string messageId, string dialogId, string text, DateTimeOffset msgAt)
public Task SavePreviewAsync(
string messageId,
string dialogId,
string text,
DateTimeOffset msgAt,
CancellationToken ct)
{ {
if (!_messages.ContainsKey(messageId)) if (!_messages.ContainsKey(messageId))
{ {
_messages[messageId] = new FakeTelegramMessageRow(messageId, dialogId, text, msgAt, LeadId: null); _messages[messageId] = new FakeTelegramMessageRow(messageId, dialogId, text, msgAt, LeadId: null);
} }
return Task.CompletedTask;
} }
/// <inheritdoc /> private void TouchDialogLast(string dialogId, string text, DateTimeOffset at)
public Task TouchDialogLastAsync(
string dialogId,
string text,
DateTimeOffset at,
CancellationToken ct)
{ {
if (_dialogs.TryGetValue(dialogId, out FakeTelegramDialogRow? existing)) if (_dialogs.TryGetValue(dialogId, out FakeTelegramDialogRow? existing))
{ {
_dialogs[dialogId] = existing with { LastText = text, LastAt = at, UpdatedAt = at }; _dialogs[dialogId] = existing with { LastText = text, LastAt = at, UpdatedAt = at };
} }
return Task.CompletedTask;
} }
/// <inheritdoc /> private IReadOnlyList<TelegramMessageDto> ListMessages(string dialogId, int limit)
public Task<IReadOnlyList<TelegramMessageDto>> ListMessagesAsync(
string dialogId,
int limit,
CancellationToken ct)
{ {
IReadOnlyList<TelegramMessageDto> items = _messages.Values return _messages.Values
.Where(row => row.DialogId == dialogId) .Where(row => row.DialogId == dialogId)
.OrderByDescending(row => row.MsgAt) .OrderByDescending(row => row.MsgAt)
.Take(limit) .Take(limit)
.Select(row => new TelegramMessageDto(row.Id, row.Text, row.MsgAt.ToUnixTimeMilliseconds(), row.LeadId is not null)) .Select(row => new TelegramMessageDto(row.Id, row.Text, row.MsgAt.ToUnixTimeMilliseconds(), row.LeadId is not null))
.ToList(); .ToList();
return Task.FromResult(items);
} }
private static TelegramDialogDto ToDialogDto(FakeTelegramDialogRow row) private static TelegramDialogDto ToDialogDto(FakeTelegramDialogRow row)