Хелпер Support/TestFileStorage: словари объектов/типов + журнал удалений, подставка читает состояние на момент вызова (позиция потока при Put — по контракту порта). Потребители (9 файлов) перетипизированы на .Storage, фейк удалён, тесты 1340 зелёные.
This commit is contained in:
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user