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

Подставки собираются Substitute.For с фиксированным Returns (SourceWith),
проверки вызовов — Received/DidNotReceive. Фейк-классы удалены, тесты 1340
зелёные (один моргнувший тест TelegramIngressServiceTests — гонка порта
харнесса, не воспроизводится).
This commit is contained in:
Rustam Khalimov
2026-09-12 19:47:55 +03:00
parent c6332158fe
commit 688d24ce87
4 changed files with 43 additions and 107 deletions
@@ -1,26 +0,0 @@
using Deal.Modules.Settings.Application.Abstractions;
namespace Deal.Tests.Unit.Modules.Settings;
/// <summary>
/// In-memory реализация <see cref="IRatesChangedListener"/> для тестов триггеров пересчёта.
/// </summary>
public sealed class FakeRatesListener : IRatesChangedListener
{
/// <summary>
/// Вызовы события: fullRecompute каждого оповещения.
/// </summary>
public List<bool> Calls { get; } = [];
/// <summary>
/// Сколько раз слушатель был оповещён.
/// </summary>
public int CallCount => Calls.Count;
/// <inheritdoc />
public Task OnRatesChangedAsync(bool fullRecompute, CancellationToken ct)
{
Calls.Add(fullRecompute);
return Task.CompletedTask;
}
}
@@ -1,47 +0,0 @@
using Deal.Modules.Settings.Application.Abstractions;
namespace Deal.Tests.Unit.Modules.Settings;
/// <summary>
/// Фейковый источник курсов для тестов RatesService.
/// </summary>
public sealed class FakeRatesSource : IRatesSource
{
private readonly Dictionary<string, double>? _result;
private readonly Exception? _failure;
/// <summary>
/// Создаёт источник с фиксированным результатом
/// </summary>
/// <param name="result">Курсы к рублю или null, если источник «недоступен».</param>
public FakeRatesSource(Dictionary<string, double>? result)
{
_result = result;
}
/// <summary>
/// Создаёт источник, бросающий исключение
/// </summary>
/// <param name="failure">Исключение, которое бросит FetchAsync.</param>
public FakeRatesSource(Exception failure)
{
_failure = failure;
}
/// <summary>
/// Число вызовов FetchAsync.
/// </summary>
public int Calls { get; private set; }
/// <inheritdoc />
public Task<Dictionary<string, double>?> FetchAsync(CancellationToken ct)
{
Calls++;
if (_failure is not null)
{
return Task.FromException<Dictionary<string, double>?>(_failure);
}
return Task.FromResult(_result);
}
}
@@ -3,6 +3,7 @@ using Deal.Modules.Settings.Application.Abstractions;
using Deal.Modules.Settings.Application.Models;
using Deal.Modules.Settings.Application.Services;
using Deal.Tests.Unit.Modules.Settings;
using NSubstitute;
namespace Deal.Tests.Unit.Support;
@@ -67,13 +68,13 @@ public sealed class RatesServiceTests
public async Task RefreshAsync_RateSourceMock_SavesMockRatesWithoutSourceCall()
{
_store.Preload(SettingsKeys.RateSource, "\"mock\"");
FakeRatesSource source = new(new Dictionary<string, double> { ["USD"] = 1.0 });
IRatesSource source = SourceWith(new Dictionary<string, double> { ["USD"] = 1.0 });
RatesService service = CreateService(source);
bool ok = await service.RefreshAsync(CancellationToken.None);
Assert.True(ok);
Assert.Equal(0, source.Calls); // мок-режим не ходит в сеть (порт не вызывается)
await source.DidNotReceive().FetchAsync(Arg.Any<CancellationToken>()); // мок-режим не ходит в сеть (порт не вызывается)
string cacheJson = _store.GetStoredJson(SettingsKeys.RatesCache)!;
Assert.Contains("\"source\":\"mock\"", cacheJson);
Assert.Contains("\"USD\":92.5", cacheJson);
@@ -87,13 +88,13 @@ public sealed class RatesServiceTests
{
_store.Preload(SettingsKeys.RateSource, "\"cbr\"");
var fetched = new Dictionary<string, double> { ["RUB"] = 1.0, ["USD"] = 90.5, ["EUR"] = 100.0 };
FakeRatesSource source = new(fetched);
IRatesSource source = SourceWith(fetched);
RatesService service = CreateService(source);
bool ok = await service.RefreshAsync(CancellationToken.None);
Assert.True(ok);
Assert.Equal(1, source.Calls);
await source.Received(1).FetchAsync(Arg.Any<CancellationToken>());
string cacheJson = _store.GetStoredJson(SettingsKeys.RatesCache)!;
Assert.Contains("\"source\":\"cbr\"", cacheJson);
Assert.Contains("\"USD\":90.5", cacheJson);
@@ -109,13 +110,13 @@ public sealed class RatesServiceTests
_store.Preload(SettingsKeys.RateSource, "\"cbr\"");
string oldCache = CacheJson(nowMs: FixedTimestampMs, source: "mock", "USD", 92.5);
_store.Preload(SettingsKeys.RatesCache, oldCache);
FakeRatesSource source = new(result: null); // сбой ЦБ (адаптер вернул null)
IRatesSource source = SourceWith(null); // сбой ЦБ (адаптер вернул null)
RatesService service = CreateService(source);
bool ok = await service.RefreshAsync(CancellationToken.None);
Assert.False(ok);
Assert.Equal(1, source.Calls);
await source.Received(1).FetchAsync(Arg.Any<CancellationToken>());
Assert.Equal(oldCache, _store.GetStoredJson(SettingsKeys.RatesCache)); // кэш не тронут
}
@@ -123,13 +124,13 @@ public sealed class RatesServiceTests
public async Task RefreshAsync_NoRateSourceSetting_DefaultsToCbr()
{
// Настройки rateSource нет → дефолт "cbr" (SettingsDefaults): refresh идёт в источник.
FakeRatesSource source = new(new Dictionary<string, double> { ["RUB"] = 1.0, ["USD"] = 88.0 });
IRatesSource source = SourceWith(new Dictionary<string, double> { ["RUB"] = 1.0, ["USD"] = 88.0 });
RatesService service = CreateService(source);
bool ok = await service.RefreshAsync(CancellationToken.None);
Assert.True(ok);
Assert.Equal(1, source.Calls);
await source.Received(1).FetchAsync(Arg.Any<CancellationToken>());
Assert.Contains("\"source\":\"cbr\"", _store.GetStoredJson(SettingsKeys.RatesCache)!);
}
@@ -137,13 +138,13 @@ public sealed class RatesServiceTests
public async Task RefreshAsync_UnknownRateSource_TreatedAsCbr()
{
_store.Preload(SettingsKeys.RateSource, "\"garbage\"");
FakeRatesSource source = new(new Dictionary<string, double> { ["RUB"] = 1.0 });
IRatesSource source = SourceWith(new Dictionary<string, double> { ["RUB"] = 1.0 });
RatesService service = CreateService(source);
bool ok = await service.RefreshAsync(CancellationToken.None);
Assert.True(ok);
Assert.Equal(1, source.Calls);
await source.Received(1).FetchAsync(Arg.Any<CancellationToken>());
Assert.Contains("\"source\":\"cbr\"", _store.GetStoredJson(SettingsKeys.RatesCache)!);
}
@@ -152,50 +153,49 @@ public sealed class RatesServiceTests
public async Task RefreshAsync_MockSuccess_NotifiesListenersAfterCacheWrite()
{
_store.Preload(SettingsKeys.RateSource, "\"mock\"");
FakeRatesListener listener = new();
RatesService service = CreateService(new FakeRatesSource(new Dictionary<string, double> { ["RUB"] = 1.0 }), new[] { listener });
var listener = Substitute.For<IRatesChangedListener>();
RatesService service = CreateService(SourceWith(new Dictionary<string, double> { ["RUB"] = 1.0 }), new[] { listener });
bool ok = await service.RefreshAsync(CancellationToken.None);
Assert.True(ok);
Assert.NotNull(_store.GetStoredJson(SettingsKeys.RatesCache)); // кэш записан до оповещения
Assert.Equal(1, listener.CallCount);
Assert.True(listener.Calls.Single()); // fullRecompute=true: курсы сменились — полный пересчёт
await listener.Received(1).OnRatesChangedAsync(true, Arg.Any<CancellationToken>()); // fullRecompute=true: курсы сменились — полный пересчёт
}
[Fact]
public async Task RefreshAsync_CbrSuccess_NotifiesListenersAfterCacheWrite()
{
_store.Preload(SettingsKeys.RateSource, "\"cbr\"");
FakeRatesListener listener = new();
var listener = Substitute.For<IRatesChangedListener>();
var fetched = new Dictionary<string, double> { ["RUB"] = 1.0, ["USD"] = 90.5 };
RatesService service = CreateService(new FakeRatesSource(fetched), new[] { listener });
RatesService service = CreateService(SourceWith(fetched), new[] { listener });
bool ok = await service.RefreshAsync(CancellationToken.None);
Assert.True(ok);
Assert.Contains("\"source\":\"cbr\"", _store.GetStoredJson(SettingsKeys.RatesCache)!);
Assert.Equal(1, listener.CallCount);
await listener.Received(1).OnRatesChangedAsync(Arg.Any<bool>(), Arg.Any<CancellationToken>());
}
[Fact]
public async Task RefreshAsync_CbrFailure_DoesNotNotifyListeners()
{
_store.Preload(SettingsKeys.RateSource, "\"cbr\"");
FakeRatesListener listener = new();
RatesService service = CreateService(new FakeRatesSource(result: null), new[] { listener });
var listener = Substitute.For<IRatesChangedListener>();
RatesService service = CreateService(SourceWith(null), new[] { listener });
bool ok = await service.RefreshAsync(CancellationToken.None);
Assert.False(ok);
Assert.Equal(0, listener.CallCount);
await listener.DidNotReceive().OnRatesChangedAsync(Arg.Any<bool>(), Arg.Any<CancellationToken>());
}
[Fact]
public async Task RefreshAsync_EmptyListenersList_IsNoOp()
{
_store.Preload(SettingsKeys.RateSource, "\"mock\"");
RatesService service = CreateService(new FakeRatesSource(new Dictionary<string, double> { ["RUB"] = 1.0 }));
RatesService service = CreateService(SourceWith(new Dictionary<string, double> { ["RUB"] = 1.0 }));
bool ok = await service.RefreshAsync(CancellationToken.None);
@@ -397,17 +397,26 @@ public sealed class RatesServiceTests
// Сервис на пустом хранилище с успешным фейк-источником (для тестов, не трогающих порт).
private RatesService CreateService()
{
return CreateService(new FakeRatesSource(new Dictionary<string, double> { ["RUB"] = 1.0 }));
return CreateService(SourceWith(new Dictionary<string, double> { ["RUB"] = 1.0 }));
}
// Подставка IRatesSource с фиксированным ответом (null — источник недоступен).
// rates: Курсы к рублю или null.
private static IRatesSource SourceWith(Dictionary<string, double>? rates)
{
IRatesSource source = Substitute.For<IRatesSource>();
source.FetchAsync(Arg.Any<CancellationToken>()).Returns(rates);
return source;
}
// Сервис на пустом хранилище с заданным источником и без слушателей пересчёта.
// source: Фейк-источник курсов.
private RatesService CreateService(FakeRatesSource source)
// source: Подставка источника курсов.
private RatesService CreateService(IRatesSource source)
{
return CreateService(source, Array.Empty<IRatesChangedListener>());
}
private RatesService CreateService(FakeRatesSource source, IReadOnlyList<IRatesChangedListener> listeners)
private RatesService CreateService(IRatesSource source, IReadOnlyList<IRatesChangedListener> listeners)
{
return new RatesService(_store, source, listeners);
}
@@ -3,6 +3,7 @@ using Deal.Modules.Settings.Application.Abstractions;
using Deal.Modules.Settings.Application.Models;
using Deal.Modules.Settings.Application.Services;
using Deal.Tests.Unit.Modules.Settings;
using NSubstitute;
namespace Deal.Tests.Unit.Support;
@@ -590,50 +591,49 @@ public sealed class SettingsServiceTests
[Fact]
public async Task ApplyPatchAsync_TargetCurrencyInBody_NotifiesListenersAfterSave()
{
FakeRatesListener listener = new();
var listener = Substitute.For<IRatesChangedListener>();
SettingsService local = new(_store, _cipher, new[] { listener });
PublicSettingsDto snapshot = await local.ApplyPatchAsync(JsonBody(new { targetCurrency = "usd" }), CancellationToken.None);
Assert.Equal("USD", snapshot.TargetCurrency);
Assert.Equal("\"USD\"", _store.GetStoredJson(SettingsKeys.TargetCurrency)); // сохранено до оповещения
Assert.Equal(1, listener.CallCount);
Assert.True(listener.Calls.Single()); // полный пересчёт (Ruling 7)
await listener.Received(1).OnRatesChangedAsync(true, Arg.Any<CancellationToken>()); // полный пересчёт (Ruling 7)
}
[Fact]
public async Task ApplyPatchAsync_ConversionOnInBody_NotifiesListeners()
{
FakeRatesListener listener = new();
var listener = Substitute.For<IRatesChangedListener>();
SettingsService local = new(_store, _cipher, new[] { listener });
await local.ApplyPatchAsync(JsonBody(new { conversionOn = false }), CancellationToken.None);
Assert.Equal("false", _store.GetStoredJson(SettingsKeys.ConversionOn));
Assert.Equal(1, listener.CallCount);
await listener.Received(1).OnRatesChangedAsync(Arg.Any<bool>(), Arg.Any<CancellationToken>());
}
[Fact]
public async Task ApplyPatchAsync_UnrelatedKeys_DoesNotNotifyListeners()
{
FakeRatesListener listener = new();
var listener = Substitute.For<IRatesChangedListener>();
SettingsService local = new(_store, _cipher, new[] { listener });
await local.ApplyPatchAsync(JsonBody(new { minLen = 100, rateSource = "mock" }), CancellationToken.None);
Assert.Equal(0, listener.CallCount);
await listener.DidNotReceive().OnRatesChangedAsync(Arg.Any<bool>(), Arg.Any<CancellationToken>());
}
[Fact]
public async Task ApplyPatchAsync_TargetCurrencyJsonNull_DoesNotNotifyListeners()
{
FakeRatesListener listener = new();
var listener = Substitute.For<IRatesChangedListener>();
SettingsService local = new(_store, _cipher, new[] { listener });
await local.ApplyPatchAsync(JsonBody(new Dictionary<string, object?> { ["targetCurrency"] = null }), CancellationToken.None);
Assert.Empty(_store.Keys);
Assert.Equal(0, listener.CallCount);
await listener.DidNotReceive().OnRatesChangedAsync(Arg.Any<bool>(), Arg.Any<CancellationToken>());
}
[Fact]