Перевести FakeRatesSource и FakeRatesListener на NSubstitute
ci / build-test (push) Canceled after 0s
ci / build-test (push) Canceled after 0s
Подставки собираются Substitute.For с фиксированным Returns (SourceWith), проверки вызовов — Received/DidNotReceive. Фейк-классы удалены, тесты 1340 зелёные (один моргнувший тест TelegramIngressServiceTests — гонка порта харнесса, не воспроизводится).
This commit is contained in:
@@ -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]
|
||||
|
||||
Reference in New Issue
Block a user