diff --git a/src/core/tests/Deal.Tests.Unit/Contracts/CardsServiceTests.cs b/src/core/tests/Deal.Tests.Unit/Contracts/CardsServiceTests.cs index 8de4b0a..8ae48b4 100644 --- a/src/core/tests/Deal.Tests.Unit/Contracts/CardsServiceTests.cs +++ b/src/core/tests/Deal.Tests.Unit/Contracts/CardsServiceTests.cs @@ -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) либо без них. diff --git a/src/core/tests/Deal.Tests.Unit/Infrastructure/CardMoverTests.cs b/src/core/tests/Deal.Tests.Unit/Infrastructure/CardMoverTests.cs index b0760a3..a44505c 100644 --- a/src/core/tests/Deal.Tests.Unit/Infrastructure/CardMoverTests.cs +++ b/src/core/tests/Deal.Tests.Unit/Infrastructure/CardMoverTests.cs @@ -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); } } diff --git a/src/core/tests/Deal.Tests.Unit/Modules/Kanban/CardsServiceRemindersTests.cs b/src/core/tests/Deal.Tests.Unit/Modules/Kanban/CardsServiceRemindersTests.cs index 9577c28..af636eb 100644 --- a/src/core/tests/Deal.Tests.Unit/Modules/Kanban/CardsServiceRemindersTests.cs +++ b/src/core/tests/Deal.Tests.Unit/Modules/Kanban/CardsServiceRemindersTests.cs @@ -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) — для посева напоминаний в прошлом/будущем. diff --git a/src/core/tests/Deal.Tests.Unit/Modules/Kanban/MlReviewServiceTests.cs b/src/core/tests/Deal.Tests.Unit/Modules/Kanban/MlReviewServiceTests.cs index fbd0d38..8d12a2c 100644 --- a/src/core/tests/Deal.Tests.Unit/Modules/Kanban/MlReviewServiceTests.cs +++ b/src/core/tests/Deal.Tests.Unit/Modules/Kanban/MlReviewServiceTests.cs @@ -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); } diff --git a/src/core/tests/Deal.Tests.Unit/Support/AdminTickOrchestratorTests.cs b/src/core/tests/Deal.Tests.Unit/Support/AdminTickOrchestratorTests.cs index 05a0a6f..05c7bdf 100644 --- a/src/core/tests/Deal.Tests.Unit/Support/AdminTickOrchestratorTests.cs +++ b/src/core/tests/Deal.Tests.Unit/Support/AdminTickOrchestratorTests.cs @@ -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.Instance); diff --git a/src/core/tests/Deal.Tests.Unit/Support/CardReclassifierTests.cs b/src/core/tests/Deal.Tests.Unit/Support/CardReclassifierTests.cs index c767e19..900cd3f 100644 --- a/src/core/tests/Deal.Tests.Unit/Support/CardReclassifierTests.cs +++ b/src/core/tests/Deal.Tests.Unit/Support/CardReclassifierTests.cs @@ -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); diff --git a/src/core/tests/Deal.Tests.Unit/Support/CardsServiceFilesTests.cs b/src/core/tests/Deal.Tests.Unit/Support/CardsServiceFilesTests.cs index a2f7460..11ad5f7 100644 --- a/src/core/tests/Deal.Tests.Unit/Support/CardsServiceFilesTests.cs +++ b/src/core/tests/Deal.Tests.Unit/Support/CardsServiceFilesTests.cs @@ -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 пуст). diff --git a/src/core/tests/Deal.Tests.Unit/Support/CardsServiceSelectedTests.cs b/src/core/tests/Deal.Tests.Unit/Support/CardsServiceSelectedTests.cs index fcda807..75765cb 100644 --- a/src/core/tests/Deal.Tests.Unit/Support/CardsServiceSelectedTests.cs +++ b/src/core/tests/Deal.Tests.Unit/Support/CardsServiceSelectedTests.cs @@ -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). diff --git a/src/core/tests/Deal.Tests.Unit/Support/StorageTickSchedulerTests.cs b/src/core/tests/Deal.Tests.Unit/Support/StorageTickSchedulerTests.cs index aa40873..8f0538e 100644 --- a/src/core/tests/Deal.Tests.Unit/Support/StorageTickSchedulerTests.cs +++ b/src/core/tests/Deal.Tests.Unit/Support/StorageTickSchedulerTests.cs @@ -323,7 +323,7 @@ public sealed class StorageTickSchedulerTests services.AddScoped(provider => settingsByTenant[TenantOf(provider)]); services.AddScoped(provider => pipelineStoresByTenant[TenantOf(provider)]); services.AddSingleton(new FakeMlClient()); - services.AddSingleton(new FakeFileStorage()); + services.AddSingleton(new TestFileStorage().Storage); services.AddScoped(); services.AddScoped(); services.AddScoped(); diff --git a/src/core/tests/Deal.Tests.Unit/Support/TestFileStorage.cs b/src/core/tests/Deal.Tests.Unit/Support/TestFileStorage.cs new file mode 100644 index 0000000..5923fd4 --- /dev/null +++ b/src/core/tests/Deal.Tests.Unit/Support/TestFileStorage.cs @@ -0,0 +1,107 @@ +using Deal.Contracts.Integrations.Abstractions; +using Deal.Contracts.Integrations.Models; +using NSubstitute; + +namespace Deal.Tests.Unit.Support; + +/// +/// Подставка на словарях: сервисы получают NSubstitute-подставку +/// (), тесты сеют/проверяют объекты через , +/// , и . +/// +public sealed class TestFileStorage +{ + private readonly Dictionary _objects = new(StringComparer.Ordinal); + private readonly Dictionary _contentTypes = new(StringComparer.Ordinal); + private readonly List _deletedKeys = []; + + /// + /// Подставка порта файлового хранилища (создаётся в конструкторе). + /// + public IFileStorage Storage { get; } + + /// + /// objectKey всех объектов, сохранённых на данный момент + /// + public IReadOnlyList StoredObjectKeys => _objects.Keys.ToList(); + + /// + /// objectKey всех удалений в порядке вызовов DeleteAsync + /// + public IReadOnlyList DeletedKeys => _deletedKeys.ToList(); + + /// + /// Создаёт подставку с пустым хранилищем. + /// + public TestFileStorage() + { + Storage = Substitute.For(); + Storage.PutAsync(Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()) + .Returns(ci => Put(ci.ArgAt(0), ci.ArgAt(1), ci.ArgAt(2))); + Storage.GetAsync(Arg.Any(), Arg.Any()) + .Returns(ci => Get(ci.ArgAt(0))); + Storage.StatAsync(Arg.Any(), Arg.Any()) + .Returns(ci => Stat(ci.ArgAt(0))); + Storage.When(s => s.DeleteAsync(Arg.Any(), Arg.Any())) + .Do(ci => Delete(ci.ArgAt(0))); + } + + /// + /// Кладёт объект напрямую, минуя порт + /// + /// Ключ объекта (opaque). + /// Содержимое. + /// MIME-тип. + public void PutObject(string objectKey, byte[] content, string contentType = "application/octet-stream") + { + _objects[objectKey] = content; + _contentTypes[objectKey] = contentType; + } + + /// + /// Содержимое сохранённого объекта по ключу либо null — объекта нет + /// + /// Ключ объекта (opaque). + 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); + } +}