Добавить загрузку исходника карточки по провайдерам
Введены ISourceContentProvider и SourceContentResolver, эндпоинт GET /api/cards/{id}/source отдаёт содержимое источника (провайдер по kind либо сохранённое в карточке).
This commit is contained in:
+1
-1
@@ -57,7 +57,7 @@
|
|||||||
| TD-DBL-CLICK | Двойная перезагрузка доски у инициатора batch-reclassify (ответ + SSE) | этап 12, E | P3 | TECHDEBT |
|
| TD-DBL-CLICK | Двойная перезагрузка доски у инициатора batch-reclassify (ответ + SSE) | этап 12, E | P3 | TECHDEBT |
|
||||||
| TD-TEST-HARNESS | Историческая гонка `FreeTcpPort` — устранена; следить за новыми хост-хелперами | этап 12, E | P3 | TECHDEBT |
|
| TD-TEST-HARNESS | Историческая гонка `FreeTcpPort` — устранена; следить за новыми хост-хелперами | этап 12, E | P3 | TECHDEBT |
|
||||||
| TD-OLD-DOCS | Исторические доки несут старые термины под пометками (переписывать не нужно) | docs sweep | P3 | TECHDEBT |
|
| TD-OLD-DOCS | Исторические доки несут старые термины под пометками (переписывать не нужно) | docs sweep | P3 | TECHDEBT |
|
||||||
| TD-SOURCE-PROVIDER | `GET /api/cards/{id}/source` + `ISourceContentProvider` + реестр провайдеров; ленивая догрузка `SourceContent` (сейчас контент отдаётся прямо в `CardDto`) | generic source 2026-09-11 | P1 | BACKLOG |
|
| TD-SOURCE-PROVIDER | `ISourceContentProvider`/`SourceContentResolver` и `GET /api/cards/{id}/source` добавлены; осталось — реализовать провайдеры источников (telegram/local/file) с ленивой догрузкой | generic source 2026-09-11 | P1 | BACKLOG |
|
||||||
| TD-STORE-ATTACH | Выгрузка вложений источника в Storage-сервис адаптером + `TelegramSourceContentProvider` (только telegram-сервис) + рендер `DataRef` в UI | generic source 2026-09-11 | P1 | BACKLOG |
|
| TD-STORE-ATTACH | Выгрузка вложений источника в Storage-сервис адаптером + `TelegramSourceContentProvider` (только telegram-сервис) + рендер `DataRef` в UI | generic source 2026-09-11 | P1 | BACKLOG |
|
||||||
| TD-TG-CORE-SPLIT | Перенести оставшуюся Telegram-специфику ядра (`TelegramStore`, таблицы `Dialogs`/`TgMessages`, Discovery, ingress-proto) в telegram-сервис; generic ingress-контракт источников | generic source 2026-09-11 | P2 | TECHDEBT |
|
| TD-TG-CORE-SPLIT | Перенести оставшуюся Telegram-специфику ядра (`TelegramStore`, таблицы `Dialogs`/`TgMessages`, Discovery, ingress-proto) в telegram-сервис; generic ingress-контракт источников | generic source 2026-09-11 | P2 | TECHDEBT |
|
||||||
| TD-SOURCE-CONTACTS | Квалификатор контактов знает форматы профилей (t.me/`@handle`) — вынести в расширяемые правила источников | generic source 2026-09-11 | P3 | BACKLOG |
|
| TD-SOURCE-CONTACTS | Квалификатор контактов знает форматы профилей (t.me/`@handle`) — вынести в расширяемые правила источников | generic source 2026-09-11 | P3 | BACKLOG |
|
||||||
|
|||||||
+5
-4
@@ -162,6 +162,7 @@
|
|||||||
| `POST /{cardId}/files` | Загрузить файлы (multipart, поле `files`) | FormData `files` | → карточка (с обновлённым `files`) |
|
| `POST /{cardId}/files` | Загрузить файлы (multipart, поле `files`) | FormData `files` | → карточка (с обновлённым `files`) |
|
||||||
| `GET /{cardId}/files/{fileId}/download` | Скачать (stream из MinIO/локального store) | — | `application/octet-stream`, `Content-Disposition: attachment`; 410/404 |
|
| `GET /{cardId}/files/{fileId}/download` | Скачать (stream из MinIO/локального store) | — | `application/octet-stream`, `Content-Disposition: attachment`; 410/404 |
|
||||||
| `DELETE /{cardId}/files/{fileId}` | Открепить файл | — | → карточка |
|
| `DELETE /{cardId}/files/{fileId}` | Открепить файл | — | → карточка |
|
||||||
|
| `GET /{cardId}/source` | Содержимое источника: провайдер по `source.kind` либо сохранённое в карточке | — | `SourceContent`; 404 «Карточка не найдена» |
|
||||||
| `POST /{cardId}/reminder` | Напоминание карточке | `{at: <epoch ms>}` | → карточка; 400 «Поле at (epoch-ms) обязательно» |
|
| `POST /{cardId}/reminder` | Напоминание карточке | `{at: <epoch ms>}` | → карточка; 400 «Поле at (epoch-ms) обязательно» |
|
||||||
| `DELETE /{cardId}/reminder` | Снять напоминание | — | → карточка |
|
| `DELETE /{cardId}/reminder` | Снять напоминание | — | → карточка |
|
||||||
| `POST /{cardId}/reminder/snooze` | Отложить на +24 ч | — | → карточка |
|
| `POST /{cardId}/reminder/snooze` | Отложить на +24 ч | — | → карточка |
|
||||||
@@ -318,12 +319,12 @@ links/files/history/tzText/reminder` — модули; `isNew/prevCol/isVacancy/
|
|||||||
|
|
||||||
### 4.5 Очередь и отсев (вкладка «Обработка»)
|
### 4.5 Очередь и отсев (вкладка «Обработка»)
|
||||||
|
|
||||||
Очередь (`GET /pipeline/queue` item): `{id, dialogId, msgId: int|null, text, status: "new"|"filtered", ch:{name,handle,hue}, msgAt: ms, queuedAt: ms}` — фронт читает все (кроме dialogId/msgId/queuedAt, которые используются только как справочные; UI показывает text, статус-бейдж, канал).
|
Очередь (`GET /pipeline/queue` item): `{id, source: SourceRef, content: SourceContent, text, status: "new"|"filtered", msgAt: ms, queuedAt: ms}` — UI показывает `text`, статус-бейдж и подпись источника (`source.displayName`, цвет `source.extra.hue`).
|
||||||
|
|
||||||
Отсев (`GET /pipeline/rejected` item): `{id, dialogId, msgId, text, stage, stageLabel, reason, kw, source, sourceLabel, ch:{name,handle,hue}, msgAt, rejectedAt, returned: bool, returnedAt: ms|null, returnReason: string}`.
|
Отсев (`GET /pipeline/rejected` item): `{id, source: SourceRef, content: SourceContent, text, stage, stageLabel, reason, kw, decidedBy, decidedByLabel, msgAt, rejectedAt, returned: bool, returnedAt: ms|null, returnReason: string}`.
|
||||||
- `stage` ∈ `length|stop|resume|type|budget|stale|spam_ml|spam_ai|filter_ai|dup`; `stageLabel` — подпись («короткое сообщение», «стоп-фраза», «спам (ML)», …).
|
- `stage` ∈ `length|stop|resume|type|budget|stale|spam_ml|spam_ai|filter_ai|dup`; `stageLabel` — подпись («короткое сообщение», «стоп-фраза», «спам (ML)», …).
|
||||||
- `source` ∈ `stop|ml|ai|stale|dup`; `sourceLabel` ∈ «правила|ML|ИИ|система».
|
- `decidedBy` ∈ `stop|ml|ai|stale|dup`; `decidedByLabel` ∈ «правила|ML|ИИ|система».
|
||||||
- Фронт читает: `id, stageLabel, kw, reason, source, sourceLabel, text, ch, rejectedAt, returned, returnedAt, returnReason`. «Возврат» неактивен при `source==='dup'` или `returned`.
|
- Фронт читает: `id, stageLabel, kw, reason, decidedBy, decidedByLabel, text, source, rejectedAt, returned, returnedAt, returnReason`. «Возврат» неактивен при `decidedBy==='dup'` или `returned`.
|
||||||
|
|
||||||
### 4.6 Настройки (settings) — все ключи ответа `GET/PATCH /api/settings` (camelCase; значения по умолчанию из `constants.DEFAULT_SETTINGS`)
|
### 4.6 Настройки (settings) — все ключи ответа `GET/PATCH /api/settings` (camelCase; значения по умолчанию из `constants.DEFAULT_SETTINGS`)
|
||||||
|
|
||||||
|
|||||||
@@ -7,7 +7,8 @@
|
|||||||
> персистентность, конвейер, wire, фронт) переведено с Telegram-полей карточки на generic-тип
|
> персистентность, конвейер, wire, фронт) переведено с Telegram-полей карточки на generic-тип
|
||||||
> `SourceItem` (`SourceRef` + `SourceContent`, вложения — `DataRef` → общий Storage). Дашборды/канбан/конвейер
|
> `SourceItem` (`SourceRef` + `SourceContent`, вложения — `DataRef` → общий Storage). Дашборды/канбан/конвейер
|
||||||
> больше не знают о Telegram; Telegram-специфика — только в тонком адаптере приёма. Tenant-миграции
|
> больше не знают о Telegram; Telegram-специфика — только в тонком адаптере приёма. Tenant-миграции
|
||||||
> пересозданы с нуля (init). Ядро: build 4 sln 0/0, `Deal.Tests.Unit` **1277/1277 PASS**, фронт `build` +
|
> пересозданы с нуля (init). Добавлены extension-point `ISourceContentProvider`/`SourceContentResolver` и
|
||||||
|
> `GET /api/cards/{id}/source`. Ядро: build 4 sln 0/0, `Deal.Tests.Unit` **1280/1280 PASS**, фронт `build` +
|
||||||
> `lint:i18n` зелёные. Детали — `docs/superpowers/specs/2026-09-11-source-contract-design.md`.
|
> `lint:i18n` зелёные. Детали — `docs/superpowers/specs/2026-09-11-source-contract-design.md`.
|
||||||
> Осталось (в backlog): `GET /api/cards/{id}/source` + `ISourceContentProvider`, выгрузка вложений
|
> Осталось (в backlog): `GET /api/cards/{id}/source` + `ISourceContentProvider`, выгрузка вложений
|
||||||
> telegram-адаптером в Storage, `TelegramSourceContentProvider`, перенос оставшейся Telegram-специфики
|
> telegram-адаптером в Storage, `TelegramSourceContentProvider`, перенос оставшейся Telegram-специфики
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ using Deal.Api.Extensions;
|
|||||||
using Deal.Api.Services;
|
using Deal.Api.Services;
|
||||||
using Deal.Contracts.Integrations.Abstractions;
|
using Deal.Contracts.Integrations.Abstractions;
|
||||||
using Deal.Contracts.Integrations.Models;
|
using Deal.Contracts.Integrations.Models;
|
||||||
|
using Deal.Modules.Cards.Application.Sources;
|
||||||
using Deal.Modules.Kanban.Application.Models;
|
using Deal.Modules.Kanban.Application.Models;
|
||||||
using Deal.Modules.Kanban.Application.Services;
|
using Deal.Modules.Kanban.Application.Services;
|
||||||
using Deal.Modules.Tenants.Application.Models;
|
using Deal.Modules.Tenants.Application.Models;
|
||||||
@@ -45,6 +46,9 @@ public static class CardDetailsEndpoints
|
|||||||
// Вложенный путь установки/снятия напоминания.
|
// Вложенный путь установки/снятия напоминания.
|
||||||
private const string ReminderPath = "/{cardId}/reminder";
|
private const string ReminderPath = "/{cardId}/reminder";
|
||||||
|
|
||||||
|
// Вложенный путь ленивой загрузки содержимого источника.
|
||||||
|
private const string SourcePath = "/{cardId}/source";
|
||||||
|
|
||||||
// Вложенный путь «напомнить позже».
|
// Вложенный путь «напомнить позже».
|
||||||
private const string ReminderSnoozePath = "/{cardId}/reminder/snooze";
|
private const string ReminderSnoozePath = "/{cardId}/reminder/snooze";
|
||||||
|
|
||||||
@@ -94,6 +98,7 @@ public static class CardDetailsEndpoints
|
|||||||
cards.MapPost(FilesPath, UploadFilesAsync);
|
cards.MapPost(FilesPath, UploadFilesAsync);
|
||||||
cards.MapGet(FileDownloadPath, DownloadFileAsync);
|
cards.MapGet(FileDownloadPath, DownloadFileAsync);
|
||||||
cards.MapDelete(FileItemPath, RemoveFileAsync);
|
cards.MapDelete(FileItemPath, RemoveFileAsync);
|
||||||
|
cards.MapGet(SourcePath, ReadSourceAsync);
|
||||||
cards.MapPost(ReminderPath, SetReminderAsync);
|
cards.MapPost(ReminderPath, SetReminderAsync);
|
||||||
cards.MapDelete(ReminderPath, ClearReminderAsync);
|
cards.MapDelete(ReminderPath, ClearReminderAsync);
|
||||||
cards.MapPost(ReminderSnoozePath, SnoozeReminderAsync);
|
cards.MapPost(ReminderSnoozePath, SnoozeReminderAsync);
|
||||||
@@ -408,6 +413,27 @@ public static class CardDetailsEndpoints
|
|||||||
// cardId: Id карточки.
|
// cardId: Id карточки.
|
||||||
// ct: Токен отмены.
|
// ct: Токен отмены.
|
||||||
// Возвращает: 200 с единой карточкой либо 404.
|
// Возвращает: 200 с единой карточкой либо 404.
|
||||||
|
// Читает содержимое источника карточки: провайдер по виду источника либо сохранённое в карточке.
|
||||||
|
// context: Контекст запроса (для резолва CardsService).
|
||||||
|
// cardId: Id карточки.
|
||||||
|
// ct: Токен отмены.
|
||||||
|
// Возвращает: 200 с SourceContent либо 404.
|
||||||
|
private static async Task<IResult> ReadSourceAsync(
|
||||||
|
HttpContext context,
|
||||||
|
string cardId,
|
||||||
|
CancellationToken ct)
|
||||||
|
{
|
||||||
|
CardsService cardsService = context.RequestServices.GetRequiredService<CardsService>();
|
||||||
|
CardDto? card = await cardsService.GetCardAsync(cardId, ct);
|
||||||
|
if (card is null)
|
||||||
|
{
|
||||||
|
return EndpointResults.NotFound(CardNotFoundDetail);
|
||||||
|
}
|
||||||
|
|
||||||
|
SourceContent content = await cardsService.ResolveSourceAsync(card, ct);
|
||||||
|
return Results.Ok(content);
|
||||||
|
}
|
||||||
|
|
||||||
private static async Task<IResult> ReadCardAsync(
|
private static async Task<IResult> ReadCardAsync(
|
||||||
HttpContext context,
|
HttpContext context,
|
||||||
string cardId,
|
string cardId,
|
||||||
|
|||||||
@@ -0,0 +1,20 @@
|
|||||||
|
namespace Deal.Modules.Cards.Application.Sources;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Загрузка содержимого записи источника по ссылке.
|
||||||
|
/// </summary>
|
||||||
|
public interface ISourceContentProvider
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Вид источника, который обслуживает провайдер.
|
||||||
|
/// </summary>
|
||||||
|
public string Kind { get; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Загружает содержимое записи источника.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="source">Ссылка на источник.</param>
|
||||||
|
/// <param name="ct">Токен отмены.</param>
|
||||||
|
/// <returns>Содержимое записи либо null, если источник не отдал данные.</returns>
|
||||||
|
public Task<SourceContent?> LoadAsync(SourceRef source, CancellationToken ct);
|
||||||
|
}
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
namespace Deal.Modules.Cards.Application.Sources;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Выбирает провайдера содержимого по виду источника.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class SourceContentResolver
|
||||||
|
{
|
||||||
|
private readonly Dictionary<string, ISourceContentProvider> _providers;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Создаёт реестр провайдеров содержимого.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="providers">Зарегистрированные провайдеры; последний по виду перекрывает предыдущих.</param>
|
||||||
|
public SourceContentResolver(IEnumerable<ISourceContentProvider> providers)
|
||||||
|
{
|
||||||
|
_providers = new Dictionary<string, ISourceContentProvider>(StringComparer.OrdinalIgnoreCase);
|
||||||
|
foreach (ISourceContentProvider provider in providers)
|
||||||
|
{
|
||||||
|
_providers[provider.Kind] = provider;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Есть ли провайдер для вида источника.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="source">Ссылка на источник.</param>
|
||||||
|
/// <returns>true — содержимое можно загрузить лениво.</returns>
|
||||||
|
public bool CanResolve(SourceRef source) => _providers.ContainsKey(source.Kind);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Загружает содержимое записи источника.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="source">Ссылка на источник.</param>
|
||||||
|
/// <param name="ct">Токен отмены.</param>
|
||||||
|
/// <returns>Содержимое записи либо null — провайдера нет/источник не отдал данные.</returns>
|
||||||
|
public Task<SourceContent?> ResolveAsync(SourceRef source, CancellationToken ct)
|
||||||
|
{
|
||||||
|
return _providers.TryGetValue(source.Kind, out ISourceContentProvider? provider)
|
||||||
|
? provider.LoadAsync(source, ct)
|
||||||
|
: Task.FromResult<SourceContent?>(null);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
using Deal.Modules.Cards.Application.Sources;
|
||||||
using Deal.Modules.Kanban.Application.Services;
|
using Deal.Modules.Kanban.Application.Services;
|
||||||
using Deal.Modules.Settings.Application.Abstractions;
|
using Deal.Modules.Settings.Application.Abstractions;
|
||||||
using Microsoft.Extensions.DependencyInjection;
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
@@ -17,6 +18,7 @@ public static class KanbanModuleRegistrar
|
|||||||
services.AddScoped<ContainersService>();
|
services.AddScoped<ContainersService>();
|
||||||
services.AddScoped<CardsService>();
|
services.AddScoped<CardsService>();
|
||||||
services.AddScoped<StorageTickService>();
|
services.AddScoped<StorageTickService>();
|
||||||
|
services.AddScoped<SourceContentResolver>();
|
||||||
|
|
||||||
services.AddScoped<IRatesChangedListener, ConversionRecomputer>();
|
services.AddScoped<IRatesChangedListener, ConversionRecomputer>();
|
||||||
return services;
|
return services;
|
||||||
|
|||||||
@@ -0,0 +1,30 @@
|
|||||||
|
using Deal.Modules.Cards.Application.Sources;
|
||||||
|
using Deal.Modules.Kanban.Application.Models;
|
||||||
|
|
||||||
|
namespace Deal.Modules.Kanban.Application.Services;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Содержимое источника карточки — partial-часть <see cref="CardsService"/>
|
||||||
|
/// </summary>
|
||||||
|
public sealed partial class CardsService
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Загружает содержимое источника карточки.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="card">Карточка, для которой нужно содержимое.</param>
|
||||||
|
/// <param name="ct">Токен отмены.</param>
|
||||||
|
/// <returns>Содержимое от провайдера источника либо сохранённое в карточке.</returns>
|
||||||
|
public async Task<SourceContent> ResolveSourceAsync(CardDto card, CancellationToken ct)
|
||||||
|
{
|
||||||
|
if (_sourceResolver is not null && _sourceResolver.CanResolve(card.Source))
|
||||||
|
{
|
||||||
|
SourceContent? loaded = await _sourceResolver.ResolveAsync(card.Source, ct);
|
||||||
|
if (loaded is not null)
|
||||||
|
{
|
||||||
|
return loaded;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return card.Content;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
using Deal.Contracts.Integrations.Abstractions;
|
using Deal.Contracts.Integrations.Abstractions;
|
||||||
|
using Deal.Modules.Cards.Application.Sources;
|
||||||
using Deal.Modules.Kanban.Application.Abstractions;
|
using Deal.Modules.Kanban.Application.Abstractions;
|
||||||
using Deal.Modules.Settings.Application.Abstractions;
|
using Deal.Modules.Settings.Application.Abstractions;
|
||||||
// Алиас: статический класс ColumnRules лежит в одноимённом пространстве имён — внутри пространства имён
|
// Алиас: статический класс ColumnRules лежит в одноимённом пространстве имён — внутри пространства имён
|
||||||
@@ -19,6 +20,7 @@ public sealed partial class CardsService
|
|||||||
private readonly ISettingsStore _settings;
|
private readonly ISettingsStore _settings;
|
||||||
private readonly IMlClient _mlClient;
|
private readonly IMlClient _mlClient;
|
||||||
private readonly IFileStorage _storage;
|
private readonly IFileStorage _storage;
|
||||||
|
private readonly SourceContentResolver? _sourceResolver;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Создаёт сервис карточек над портами модуля
|
/// Создаёт сервис карточек над портами модуля
|
||||||
@@ -27,16 +29,19 @@ public sealed partial class CardsService
|
|||||||
/// <param name="settings">KV-хранилище настроек тенанта (курсы, напоминания).</param>
|
/// <param name="settings">KV-хранилище настроек тенанта (курсы, напоминания).</param>
|
||||||
/// <param name="mlClient">Клиент ML: PushAsync — обучающий сигнал действия, StatusAsync — счётчики counts.</param>
|
/// <param name="mlClient">Клиент ML: PushAsync — обучающий сигнал действия, StatusAsync — счётчики counts.</param>
|
||||||
/// <param name="storage">Файловое хранилище вложений карточки (объекты файлов).</param>
|
/// <param name="storage">Файловое хранилище вложений карточки (объекты файлов).</param>
|
||||||
|
/// <param name="sourceResolver">Реестр провайдеров содержимого источника; null — только содержимое карточки.</param>
|
||||||
public CardsService(
|
public CardsService(
|
||||||
ICardStore store,
|
ICardStore store,
|
||||||
ISettingsStore settings,
|
ISettingsStore settings,
|
||||||
IMlClient mlClient,
|
IMlClient mlClient,
|
||||||
IFileStorage storage)
|
IFileStorage storage,
|
||||||
|
SourceContentResolver? sourceResolver = null)
|
||||||
{
|
{
|
||||||
_store = store;
|
_store = store;
|
||||||
_settings = settings;
|
_settings = settings;
|
||||||
_mlClient = mlClient;
|
_mlClient = mlClient;
|
||||||
_storage = storage;
|
_storage = storage;
|
||||||
|
_sourceResolver = sourceResolver;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,56 @@
|
|||||||
|
using Deal.Modules.Cards.Application.Sources;
|
||||||
|
|
||||||
|
namespace Deal.Tests.Unit.Support;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Тесты выбора провайдера содержимого источника — SourceContentResolver.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class SourceContentResolverTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public async Task ResolveAsync_KnownKind_ReturnsProviderContent()
|
||||||
|
{
|
||||||
|
var provider = new FakeProvider("test");
|
||||||
|
var resolver = new SourceContentResolver([provider]);
|
||||||
|
SourceRef source = SourceItemFactory.Source();
|
||||||
|
SourceContent expected = SourceItemFactory.Content("из провайдера");
|
||||||
|
|
||||||
|
provider.Content = expected;
|
||||||
|
|
||||||
|
Assert.True(resolver.CanResolve(source));
|
||||||
|
SourceContent? actual = await resolver.ResolveAsync(source, CancellationToken.None);
|
||||||
|
Assert.Same(expected, actual);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task ResolveAsync_UnknownKind_ReturnsNull()
|
||||||
|
{
|
||||||
|
var resolver = new SourceContentResolver([]);
|
||||||
|
SourceRef source = SourceItemFactory.Source();
|
||||||
|
|
||||||
|
Assert.False(resolver.CanResolve(source));
|
||||||
|
Assert.Null(await resolver.ResolveAsync(source, CancellationToken.None));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task ResolveAsync_KindMatchIsCaseInsensitive()
|
||||||
|
{
|
||||||
|
var provider = new FakeProvider("Telegram");
|
||||||
|
var resolver = new SourceContentResolver([provider]);
|
||||||
|
SourceRef source = SourceItemFactory.Source(kind: "telegram");
|
||||||
|
provider.Content = SourceItemFactory.Content("из провайдера");
|
||||||
|
|
||||||
|
Assert.True(resolver.CanResolve(source));
|
||||||
|
Assert.NotNull(await resolver.ResolveAsync(source, CancellationToken.None));
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class FakeProvider(string kind) : ISourceContentProvider
|
||||||
|
{
|
||||||
|
public string Kind { get; } = kind;
|
||||||
|
|
||||||
|
public SourceContent? Content { get; set; }
|
||||||
|
|
||||||
|
public Task<SourceContent?> LoadAsync(SourceRef source, CancellationToken ct) =>
|
||||||
|
Task.FromResult(Content);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user