Перевести FakeFileStorage на NSubstitute
ci / build-test (push) Successful in 3m12s

Хелпер Support/TestFileStorage: словари объектов/типов + журнал удалений,
подставка читает состояние на момент вызова (позиция потока при Put — по
контракту порта). Потребители (9 файлов) перетипизированы на .Storage,
фейк удалён, тесты 1340 зелёные.
This commit is contained in:
Rustam Khalimov
2026-09-12 22:38:41 +03:00
parent 564cfd5b40
commit 3fb63489d6
10 changed files with 132 additions and 22 deletions
@@ -1,4 +1,5 @@
using Deal.Contracts.Integrations.Models;
using Deal.Tests.Unit.Support;
using Deal.Modules.Cards.Application.Sources;
using Deal.Modules.Kanban.Application.Models;
using Deal.Modules.Kanban.Application.Services;
@@ -715,7 +716,7 @@ public sealed class CardsServiceTests
var store = new FakeKanjStore();
var settings = new FakeSettingsStore();
var ml = new FakeMlClient();
return (new CardsService(store, settings, ml, new FakeFileStorage()), store, settings, ml);
return (new CardsService(store, settings, ml, new TestFileStorage().Storage), store, settings, ml);
}
// Доска с правилами (ContainerRulesDto) либо без них.
@@ -3,6 +3,7 @@ using Deal.Modules.Cards.Application.Dtos;
using Deal.Modules.Cards.Application.Models;
using Deal.Modules.Cards.Application.Sources;
using Deal.Modules.Kanban.Application.Models;
using Deal.Tests.Unit.Support;
using Deal.Modules.Kanban.Application.Services;
using Deal.Tests.Unit.Contracts;
using Deal.Tests.Unit.Modules.Cards;
@@ -81,7 +82,7 @@ public sealed class CardMoverTests
private static (CardMover Mover, FakeKanjStore Store) Create()
{
var store = new FakeKanjStore();
var cardsService = new CardsService(store, new FakeSettingsStore(), new FakeMlClient(), new FakeFileStorage());
var cardsService = new CardsService(store, new FakeSettingsStore(), new FakeMlClient(), new TestFileStorage().Storage);
return (new CardMover(cardsService), store);
}
}
@@ -1,4 +1,5 @@
using Deal.Modules.Kanban.Application.Models;
using Deal.Tests.Unit.Support;
using Deal.Modules.Kanban.Application.Services;
using Deal.Modules.Settings.Application.Models;
using Deal.Tests.Unit.Contracts;
@@ -194,7 +195,7 @@ public sealed class CardsServiceRemindersTests
settings.Preload(SettingsKeys.RemindersEnabled, "false");
}
return (new CardsService(store, settings, new FakeMlClient(), new FakeFileStorage()), store, settings);
return (new CardsService(store, settings, new FakeMlClient(), new TestFileStorage().Storage), store, settings);
}
// Текущее время в epoch-мс (UTC) — для посева напоминаний в прошлом/будущем.
@@ -25,7 +25,7 @@ public sealed class MlReviewServiceTests
out CardsService cards)
{
var settings = new FakeSettingsStore();
cards = new CardsService(kanj, settings, ml, new FakeFileStorage());
cards = new CardsService(kanj, settings, ml, new TestFileStorage().Storage);
var processing = new PipelineProcessingService(pipeline, ml, new PipelineIngestService(pipeline));
return new MlReviewService(pipeline, kanj, cards, processing, ml);
}
@@ -220,7 +220,7 @@ public sealed class AdminTickOrchestratorTests
var toastPublisher = new StorageToastPublisher(broker);
var pumpGate = new PipelinePumpGate();
FakeKanjStore reminderStore = withThrowingReminderCheck ? new ThrowingDueKanjStore() : kanjStore;
var cardsService = new CardsService(reminderStore, settings, mlClient, new FakeFileStorage());
var cardsService = new CardsService(reminderStore, settings, mlClient, new TestFileStorage().Storage);
var orchestrator = new AdminTickOrchestrator(
tickService, processing, worker, cardsService, toastPublisher, broker, pumpGate,
NullLogger<AdminTickOrchestrator>.Instance);
@@ -37,7 +37,7 @@ public sealed class CardReclassifierTests
var aiClassifier = new TestAiClassifier();
var fieldsParser = new LocalFieldsParser(settings);
var composer = new CardComposer(store, settings);
var cardsService = new CardsService(store, settings, mlClient, new FakeFileStorage());
var cardsService = new CardsService(store, settings, mlClient, new TestFileStorage().Storage);
var gate = new ReclassifyGate();
var reclassifier = new CardReclassifier(
store, settings, aiClassifier.Classifier, fieldsParser, composer, cardsService, mlClient, gate);
@@ -16,7 +16,7 @@ public sealed class CardsServiceFilesTests
[Fact]
public async Task Add_ImageMime_DetectsKindByMimeWritesObjectAndMeta()
{
(CardsService service, FakeKanjStore store, FakeFileStorage storage) = Create();
(CardsService service, FakeKanjStore store, TestFileStorage storage) = Create();
store.SeedCard(Card("c_1", updatedAtMs: 1));
byte[] content = Encoding.UTF8.GetBytes("данные-картинки");
using MemoryStream stream = new(content);
@@ -57,7 +57,7 @@ public sealed class CardsServiceFilesTests
[Fact]
public async Task Add_TwoFiles_AppendsPreservingOrderAndBothObjects()
{
(CardsService service, FakeKanjStore store, FakeFileStorage storage) = Create();
(CardsService service, FakeKanjStore store, TestFileStorage storage) = Create();
store.SeedCard(Card("c_1"));
CardFileDto? first = await service.AddFileAsync(
@@ -76,7 +76,7 @@ public sealed class CardsServiceFilesTests
[Fact]
public async Task Add_CardMissing_ReturnsNullAndDoesNotWriteObject()
{
(CardsService service, FakeKanjStore store, FakeFileStorage storage) = Create();
(CardsService service, FakeKanjStore store, TestFileStorage storage) = Create();
CardFileDto? entry = await service.AddFileAsync(
"c_missing", "photo.png", "image/png", new MemoryStream("x"u8.ToArray()), 1, CancellationToken.None);
@@ -105,7 +105,7 @@ public sealed class CardsServiceFilesTests
[Fact]
public async Task Add_FileNameWithPathAndQuoteChars_SanitizesObjectKeyButKeepsMetaName()
{
(CardsService service, FakeKanjStore store, FakeFileStorage storage) = Create();
(CardsService service, FakeKanjStore store, TestFileStorage storage) = Create();
store.SeedCard(Card("c_1"));
string rawName = "..\\файл\"отчёта v2.pdf";
@@ -123,7 +123,7 @@ public sealed class CardsServiceFilesTests
[Fact]
public async Task Add_StreamPositionNotZero_StoresWholeContent()
{
(CardsService service, FakeKanjStore store, FakeFileStorage storage) = Create();
(CardsService service, FakeKanjStore store, TestFileStorage storage) = Create();
store.SeedCard(Card("c_1"));
byte[] content = Encoding.UTF8.GetBytes("полное-содержимое-файла");
using MemoryStream stream = new(content) { Position = 5 }; // эндпоинт мог прочитать поток раньше
@@ -177,12 +177,12 @@ public sealed class CardsServiceFilesTests
[Fact]
public async Task Remove_Existing_DeletesObjectRemovesMetaAndReturnsCard()
{
(CardsService service, FakeKanjStore store, FakeFileStorage storage) = Create();
(CardsService service, FakeKanjStore store, TestFileStorage storage) = Create();
var first = new CardFileDto("pf_1", "tz.pdf", 100, "document", "Документ", "projects/c_1/1710000000001_tz.pdf");
var second = new CardFileDto("pf_2", "photo.png", 200, "image", "Изображение", "projects/c_1/1710000000002_photo.png");
store.SeedCard(Card("c_1", updatedAtMs: 1) with { Files = new[] { first, second } });
await storage.PutAsync(first.ObjectKey, new MemoryStream("t"u8.ToArray()), "application/pdf", CancellationToken.None);
await storage.PutAsync(second.ObjectKey, new MemoryStream("p"u8.ToArray()), "image/png", CancellationToken.None);
await storage.Storage.PutAsync(first.ObjectKey, new MemoryStream("t"u8.ToArray()), "application/pdf", CancellationToken.None);
await storage.Storage.PutAsync(second.ObjectKey, new MemoryStream("p"u8.ToArray()), "image/png", CancellationToken.None);
CardDto? card = await service.RemoveFileAsync("c_1", "pf_1", CancellationToken.None);
@@ -199,7 +199,7 @@ public sealed class CardsServiceFilesTests
[Fact]
public async Task Remove_UnknownFileId_ReturnsCardUnchangedWithoutStorageDelete()
{
(CardsService service, FakeKanjStore store, FakeFileStorage storage) = Create();
(CardsService service, FakeKanjStore store, TestFileStorage storage) = Create();
var file = new CardFileDto("pf_1", "tz.pdf", 100, "document", "Документ", "projects/c_1/1710000000001_tz.pdf");
store.SeedCard(Card("c_1") with { Files = new[] { file } });
@@ -214,7 +214,7 @@ public sealed class CardsServiceFilesTests
[Fact]
public async Task Remove_EntryWithEmptyObjectKey_SkipsStorageDelete()
{
(CardsService service, FakeKanjStore store, FakeFileStorage storage) = Create();
(CardsService service, FakeKanjStore store, TestFileStorage storage) = Create();
store.SeedCard(Card("c_1")
with { Files = new[] { new CardFileDto("pf_mock", "meta-only.pdf", 10, "document", "Документ", ObjectKey: string.Empty) } });
@@ -228,7 +228,7 @@ public sealed class CardsServiceFilesTests
[Fact]
public async Task Remove_CardMissing_ReturnsNullWithoutStorageDelete()
{
(CardsService service, _, FakeFileStorage storage) = Create();
(CardsService service, _, TestFileStorage storage) = Create();
CardDto? card = await service.RemoveFileAsync("c_missing", "pf_1", CancellationToken.None);
@@ -238,11 +238,11 @@ public sealed class CardsServiceFilesTests
// ─── Хелперы ──────────────────────────────────────────────────────────
private static (CardsService Service, FakeKanjStore Store, FakeFileStorage Storage) Create()
private static (CardsService Service, FakeKanjStore Store, TestFileStorage Storage) Create()
{
var store = new FakeKanjStore();
var storage = new FakeFileStorage();
return (new CardsService(store, new FakeSettingsStore(), new FakeMlClient(), storage), store, storage);
var storage = new TestFileStorage();
return (new CardsService(store, new FakeSettingsStore(), new FakeMlClient(), storage.Storage), store, storage);
}
// Карточка с полями по умолчанию (planned, CreatedAtMs=1, UpdatedAtMs=1; Files пуст).
@@ -536,7 +536,7 @@ public sealed class CardsServiceSelectedTests
var store = new FakeKanjStore();
var settings = new FakeSettingsStore();
var ml = new FakeMlClient();
return (new CardsService(store, settings, ml, new FakeFileStorage()), store, settings, ml);
return (new CardsService(store, settings, ml, new TestFileStorage().Storage), store, settings, ml);
}
// Тело PATCH из пар «ключ → значение» (presence = наличие пары; null — явный JSON-null).
@@ -323,7 +323,7 @@ public sealed class StorageTickSchedulerTests
services.AddScoped<ISettingsStore>(provider => settingsByTenant[TenantOf(provider)]);
services.AddScoped<IPipelineStore>(provider => pipelineStoresByTenant[TenantOf(provider)]);
services.AddSingleton<IMlClient>(new FakeMlClient());
services.AddSingleton<IFileStorage>(new FakeFileStorage());
services.AddSingleton<IFileStorage>(new TestFileStorage().Storage);
services.AddScoped<PipelineIngestService>();
services.AddScoped<PipelineProcessingService>();
services.AddScoped<StorageTickService>();
@@ -0,0 +1,107 @@
using Deal.Contracts.Integrations.Abstractions;
using Deal.Contracts.Integrations.Models;
using NSubstitute;
namespace Deal.Tests.Unit.Support;
/// <summary>
/// Подставка <see cref="IFileStorage"/> на словарях: сервисы получают NSubstitute-подставку
/// (<see cref="Storage"/>), тесты сеют/проверяют объекты через <see cref="PutObject"/>,
/// <see cref="ContentOf"/>, <see cref="StoredObjectKeys"/> и <see cref="DeletedKeys"/>.
/// </summary>
public sealed class TestFileStorage
{
private readonly Dictionary<string, byte[]> _objects = new(StringComparer.Ordinal);
private readonly Dictionary<string, string> _contentTypes = new(StringComparer.Ordinal);
private readonly List<string> _deletedKeys = [];
/// <summary>
/// Подставка порта файлового хранилища (создаётся в конструкторе).
/// </summary>
public IFileStorage Storage { get; }
/// <summary>
/// objectKey всех объектов, сохранённых на данный момент
/// </summary>
public IReadOnlyList<string> StoredObjectKeys => _objects.Keys.ToList();
/// <summary>
/// objectKey всех удалений в порядке вызовов DeleteAsync
/// </summary>
public IReadOnlyList<string> DeletedKeys => _deletedKeys.ToList();
/// <summary>
/// Создаёт подставку с пустым хранилищем.
/// </summary>
public TestFileStorage()
{
Storage = Substitute.For<IFileStorage>();
Storage.PutAsync(Arg.Any<string>(), Arg.Any<Stream>(), Arg.Any<string>(), Arg.Any<CancellationToken>())
.Returns(ci => Put(ci.ArgAt<string>(0), ci.ArgAt<Stream>(1), ci.ArgAt<string>(2)));
Storage.GetAsync(Arg.Any<string>(), Arg.Any<CancellationToken>())
.Returns(ci => Get(ci.ArgAt<string>(0)));
Storage.StatAsync(Arg.Any<string>(), Arg.Any<CancellationToken>())
.Returns(ci => Stat(ci.ArgAt<string>(0)));
Storage.When(s => s.DeleteAsync(Arg.Any<string>(), Arg.Any<CancellationToken>()))
.Do(ci => Delete(ci.ArgAt<string>(0)));
}
/// <summary>
/// Кладёт объект напрямую, минуя порт
/// </summary>
/// <param name="objectKey">Ключ объекта (opaque).</param>
/// <param name="content">Содержимое.</param>
/// <param name="contentType">MIME-тип.</param>
public void PutObject(string objectKey, byte[] content, string contentType = "application/octet-stream")
{
_objects[objectKey] = content;
_contentTypes[objectKey] = contentType;
}
/// <summary>
/// Содержимое сохранённого объекта по ключу либо null — объекта нет
/// </summary>
/// <param name="objectKey">Ключ объекта (opaque).</param>
public byte[]? ContentOf(string objectKey)
{
return _objects.TryGetValue(objectKey, out byte[]? content) ? content : null;
}
private string Put(string objectKey, Stream content, string contentType)
{
ArgumentNullException.ThrowIfNull(content);
using MemoryStream buffer = new();
if (content.CanSeek && content.Position != 0)
{
content.Position = 0; // контракт порта: Put читает всё содержимое с позиции 0 (Ruling T6)
}
content.CopyTo(buffer);
_objects[objectKey] = buffer.ToArray();
_contentTypes[objectKey] = contentType;
return objectKey;
}
private Stream? Get(string objectKey)
{
return _objects.TryGetValue(objectKey, out byte[]? content) ? new MemoryStream(content) : null;
}
private FileMeta? Stat(string objectKey)
{
if (!_objects.TryGetValue(objectKey, out byte[]? content))
{
return null;
}
string contentType = _contentTypes.TryGetValue(objectKey, out string? stored) ? stored : string.Empty;
return new FileMeta(objectKey, content.Length, contentType);
}
private void Delete(string objectKey)
{
_deletedKeys.Add(objectKey);
_objects.Remove(objectKey);
}
}