From 3a7bd59240aca29b0f3ecd8bc495451110392ce5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=90=D0=BB=D0=B5=D0=BA=D1=81=D0=B0=D0=BD=D0=B4=D1=80=20?= =?UTF-8?q?=D0=97=D0=B0=D0=B9=D1=86=D0=B5=D0=B2?= Date: Thu, 16 Jul 2026 10:50:56 +0300 Subject: [PATCH] feat(task-trackers): add custom HTTP provider MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Решения: - Добавлен встроенный provider key custom-http вместо vendor-specific адаптера для приватных систем. - Generic HTTP contract реализован поверх base_url + Bearer token и маппит upstream DTO в TaskTrackerCard, включая upstream web_url как неперсистентное поле browser response. - Kaiten остаётся зарегистрированным provider; registry/catalog теперь содержит оба provider-а. Отброшено: - Runtime plugin host и multi-instance generic providers не добавлялись, потому что текущая settings-модель keyed by tracker рассчитана на один endpoint. - Vendor-specific provider для Overtime не добавлялся, чтобы не делать приватный upstream публичной осью Throne. Ограничения: - Custom HTTP adapter read-only: не пишет в upstream, не зеркалит карточку в Intent.text и не меняет lifecycle Intent. --- .../TaskTrackers/TaskTrackerCardDtoMapper.cs | 2 +- .../TaskTrackers/TaskTrackerCard.cs | 3 +- .../GenericHttp/GenericHttpApiException.cs | 11 ++ .../GenericHttp/GenericHttpClient.cs | 70 +++++++++ .../GenericHttp/GenericHttpConnection.cs | 6 + .../GenericHttp/GenericHttpDtos.cs | 26 ++++ .../GenericHttp/GenericHttpFailureMap.cs | 24 +++ .../GenericHttp/GenericHttpJson.cs | 8 + .../GenericHttp/GenericHttpProjection.cs | 31 ++++ .../GenericHttpTaskTrackerProvider.cs | 147 ++++++++++++++++++ .../TaskTrackerInfrastructureModule.cs | 5 + .../GenericHttpTaskTrackerProviderTests.cs | 140 +++++++++++++++++ .../GenericHttp/GenericHttpTestHarness.cs | 66 ++++++++ .../KaitenTaskTrackerRegistrationTests.cs | 5 +- specs/contracts/task-trackers/generic-http.md | 69 ++++++++ 15 files changed, 610 insertions(+), 3 deletions(-) create mode 100644 apps/api/src/Throne.Infrastructure/TaskTrackers/GenericHttp/GenericHttpApiException.cs create mode 100644 apps/api/src/Throne.Infrastructure/TaskTrackers/GenericHttp/GenericHttpClient.cs create mode 100644 apps/api/src/Throne.Infrastructure/TaskTrackers/GenericHttp/GenericHttpConnection.cs create mode 100644 apps/api/src/Throne.Infrastructure/TaskTrackers/GenericHttp/GenericHttpDtos.cs create mode 100644 apps/api/src/Throne.Infrastructure/TaskTrackers/GenericHttp/GenericHttpFailureMap.cs create mode 100644 apps/api/src/Throne.Infrastructure/TaskTrackers/GenericHttp/GenericHttpJson.cs create mode 100644 apps/api/src/Throne.Infrastructure/TaskTrackers/GenericHttp/GenericHttpProjection.cs create mode 100644 apps/api/src/Throne.Infrastructure/TaskTrackers/GenericHttpTaskTrackerProvider.cs create mode 100644 apps/api/tests/Throne.Infrastructure.Tests/TaskTrackers/GenericHttp/GenericHttpTaskTrackerProviderTests.cs create mode 100644 apps/api/tests/Throne.Infrastructure.Tests/TaskTrackers/GenericHttp/GenericHttpTestHarness.cs create mode 100644 specs/contracts/task-trackers/generic-http.md diff --git a/apps/api/src/Throne.Api/TaskTrackers/TaskTrackerCardDtoMapper.cs b/apps/api/src/Throne.Api/TaskTrackers/TaskTrackerCardDtoMapper.cs index fecf7c0a..90402322 100644 --- a/apps/api/src/Throne.Api/TaskTrackers/TaskTrackerCardDtoMapper.cs +++ b/apps/api/src/Throne.Api/TaskTrackers/TaskTrackerCardDtoMapper.cs @@ -62,6 +62,6 @@ public async Task ToResponseAsync( Updated_at = card.UpdatedAt, Archived = card.Archived, Card_version = card.RevisionTag, - Web_url = webUrl, + Web_url = card.WebUrl ?? webUrl, }; } diff --git a/apps/api/src/Throne.Application/TaskTrackers/TaskTrackerCard.cs b/apps/api/src/Throne.Application/TaskTrackers/TaskTrackerCard.cs index 65809366..16180e3a 100644 --- a/apps/api/src/Throne.Application/TaskTrackers/TaskTrackerCard.cs +++ b/apps/api/src/Throne.Application/TaskTrackers/TaskTrackerCard.cs @@ -22,4 +22,5 @@ public sealed record TaskTrackerCard( DateTimeOffset? UpdatedAt, DateTimeOffset? ColumnChangedAt, bool Archived, - string? RevisionTag = null); + string? RevisionTag = null, + string? WebUrl = null); diff --git a/apps/api/src/Throne.Infrastructure/TaskTrackers/GenericHttp/GenericHttpApiException.cs b/apps/api/src/Throne.Infrastructure/TaskTrackers/GenericHttp/GenericHttpApiException.cs new file mode 100644 index 00000000..b5c6d50d --- /dev/null +++ b/apps/api/src/Throne.Infrastructure/TaskTrackers/GenericHttp/GenericHttpApiException.cs @@ -0,0 +1,11 @@ +using System.Net; + +namespace Throne.Infrastructure.TaskTrackers.GenericHttp; + +internal sealed class GenericHttpApiException(HttpStatusCode statusCode, string? body) + : Exception($"Generic task-tracker API request failed: HTTP {(int)statusCode} ({statusCode}).") +{ + public HttpStatusCode StatusCode { get; } = statusCode; + + public string? Body { get; } = body; +} diff --git a/apps/api/src/Throne.Infrastructure/TaskTrackers/GenericHttp/GenericHttpClient.cs b/apps/api/src/Throne.Infrastructure/TaskTrackers/GenericHttp/GenericHttpClient.cs new file mode 100644 index 00000000..7bc60a87 --- /dev/null +++ b/apps/api/src/Throne.Infrastructure/TaskTrackers/GenericHttp/GenericHttpClient.cs @@ -0,0 +1,70 @@ +using System.Net.Http.Headers; +using System.Text.Json; + +namespace Throne.Infrastructure.TaskTrackers.GenericHttp; + +internal sealed class GenericHttpClient(IHttpClientFactory httpClientFactory) +{ + public const string HttpClientName = "custom-http-task-tracker"; + + public Task ProbeAsync(GenericHttpConnection connection, CancellationToken ct) => + SendAsync(connection, "/health", ct); + + public async Task> ListBoardsAsync( + GenericHttpConnection connection, + CancellationToken ct) => + (await GetAsync(connection, "/boards", ct)).Boards; + + public async Task> ListCardsAsync( + GenericHttpConnection connection, + string boardId, + CancellationToken ct) => + (await GetAsync( + connection, $"/boards/{Uri.EscapeDataString(boardId)}/cards", ct)).Cards; + + public async Task> SearchCardsAsync( + GenericHttpConnection connection, + string boardId, + string? query, + int limit, + CancellationToken ct) + { + var path = $"/boards/{Uri.EscapeDataString(boardId)}/cards/search" + + $"?query={Uri.EscapeDataString(query ?? string.Empty)}&limit={limit}"; + return (await GetAsync(connection, path, ct)).Cards; + } + + public Task GetCardAsync( + GenericHttpConnection connection, + string cardId, + CancellationToken ct) => + GetAsync( + connection, + $"/cards/{Uri.EscapeDataString(cardId)}", + ct); + + private async Task GetAsync(GenericHttpConnection connection, string path, CancellationToken ct) => + JsonSerializer.Deserialize(await SendAsync(connection, path, ct), GenericHttpJson.Options) + ?? throw new InvalidOperationException("Generic task-tracker API returned an empty body."); + + private async Task SendAsync(GenericHttpConnection connection, string path, CancellationToken ct) + { + ArgumentException.ThrowIfNullOrWhiteSpace(connection.BaseUrl); + ArgumentException.ThrowIfNullOrWhiteSpace(connection.Token); + + var client = httpClientFactory.CreateClient(HttpClientName); + using var request = new HttpRequestMessage(HttpMethod.Get, connection.ApiBaseUrl + path); + request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", connection.Token); + request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json")); + + using var response = await client.SendAsync(request, ct); + if (response.IsSuccessStatusCode) + { + return await response.Content.ReadAsStringAsync(ct); + } + + throw new GenericHttpApiException( + response.StatusCode, + await response.Content.ReadAsStringAsync(ct)); + } +} diff --git a/apps/api/src/Throne.Infrastructure/TaskTrackers/GenericHttp/GenericHttpConnection.cs b/apps/api/src/Throne.Infrastructure/TaskTrackers/GenericHttp/GenericHttpConnection.cs new file mode 100644 index 00000000..feda3b07 --- /dev/null +++ b/apps/api/src/Throne.Infrastructure/TaskTrackers/GenericHttp/GenericHttpConnection.cs @@ -0,0 +1,6 @@ +namespace Throne.Infrastructure.TaskTrackers.GenericHttp; + +internal sealed record GenericHttpConnection(string BaseUrl, string Token) +{ + public string ApiBaseUrl => $"{BaseUrl.TrimEnd('/')}/api/task-tracker"; +} diff --git a/apps/api/src/Throne.Infrastructure/TaskTrackers/GenericHttp/GenericHttpDtos.cs b/apps/api/src/Throne.Infrastructure/TaskTrackers/GenericHttp/GenericHttpDtos.cs new file mode 100644 index 00000000..84859c52 --- /dev/null +++ b/apps/api/src/Throne.Infrastructure/TaskTrackers/GenericHttp/GenericHttpDtos.cs @@ -0,0 +1,26 @@ +using System.Text.Json.Serialization; + +namespace Throne.Infrastructure.TaskTrackers.GenericHttp; + +internal sealed record GenericHttpBoardsResponse( + [property: JsonPropertyName("boards")] IReadOnlyList Boards); + +internal sealed record GenericHttpCardsResponse( + [property: JsonPropertyName("cards")] IReadOnlyList Cards); + +internal sealed record GenericHttpBoardDto( + [property: JsonPropertyName("board_id")] string BoardId, + [property: JsonPropertyName("title")] string? Title, + [property: JsonPropertyName("board_title")] string? BoardTitle); + +internal sealed record GenericHttpCardDto( + [property: JsonPropertyName("card_id")] string CardId, + [property: JsonPropertyName("board_id")] string BoardId, + [property: JsonPropertyName("title")] string Title, + [property: JsonPropertyName("description")] string? Description, + [property: JsonPropertyName("column_id")] string? ColumnId, + [property: JsonPropertyName("column_title")] string? ColumnTitle, + [property: JsonPropertyName("updated_at")] DateTimeOffset? UpdatedAt, + [property: JsonPropertyName("archived")] bool Archived, + [property: JsonPropertyName("card_version")] string? CardVersion, + [property: JsonPropertyName("web_url")] string? WebUrl); diff --git a/apps/api/src/Throne.Infrastructure/TaskTrackers/GenericHttp/GenericHttpFailureMap.cs b/apps/api/src/Throne.Infrastructure/TaskTrackers/GenericHttp/GenericHttpFailureMap.cs new file mode 100644 index 00000000..e3d8aef5 --- /dev/null +++ b/apps/api/src/Throne.Infrastructure/TaskTrackers/GenericHttp/GenericHttpFailureMap.cs @@ -0,0 +1,24 @@ +using System.Net; +using Throne.Application.Errors; +using Throne.Application.TaskTrackers; + +namespace Throne.Infrastructure.TaskTrackers.GenericHttp; + +internal static class GenericHttpFailureMap +{ + public static TaskTrackerConnectionHealth Classify(HttpStatusCode status) => status switch + { + HttpStatusCode.Unauthorized or HttpStatusCode.Forbidden => TaskTrackerConnectionHealth.Auth, + HttpStatusCode.PaymentRequired => TaskTrackerConnectionHealth.Blocked, + _ => TaskTrackerConnectionHealth.Offline, + }; + + public static ApiException BoardReadFailure(string trackerKey, TaskTrackerConnectionHealth health, string detail) => health switch + { + TaskTrackerConnectionHealth.Auth => TaskTrackerFailures.ConnectionRejected(trackerKey, detail), + TaskTrackerConnectionHealth.Blocked => TaskTrackerFailures.ConnectionBlocked(trackerKey, detail), + _ => TaskTrackerFailures.UpstreamUnavailable(trackerKey, detail), + }; + + public static bool IsGone(HttpStatusCode status) => status is HttpStatusCode.NotFound; +} diff --git a/apps/api/src/Throne.Infrastructure/TaskTrackers/GenericHttp/GenericHttpJson.cs b/apps/api/src/Throne.Infrastructure/TaskTrackers/GenericHttp/GenericHttpJson.cs new file mode 100644 index 00000000..8434dfae --- /dev/null +++ b/apps/api/src/Throne.Infrastructure/TaskTrackers/GenericHttp/GenericHttpJson.cs @@ -0,0 +1,8 @@ +using System.Text.Json; + +namespace Throne.Infrastructure.TaskTrackers.GenericHttp; + +internal static class GenericHttpJson +{ + public static readonly JsonSerializerOptions Options = new(JsonSerializerDefaults.Web); +} diff --git a/apps/api/src/Throne.Infrastructure/TaskTrackers/GenericHttp/GenericHttpProjection.cs b/apps/api/src/Throne.Infrastructure/TaskTrackers/GenericHttp/GenericHttpProjection.cs new file mode 100644 index 00000000..f65694c6 --- /dev/null +++ b/apps/api/src/Throne.Infrastructure/TaskTrackers/GenericHttp/GenericHttpProjection.cs @@ -0,0 +1,31 @@ +using Throne.Application.TaskTrackers; + +namespace Throne.Infrastructure.TaskTrackers.GenericHttp; + +internal static class GenericHttpProjection +{ + public static TaskTrackerSpaceTopology ToTopology(IReadOnlyList boards) => + new( + SpaceId: "custom-http", + SpaceTitle: "Custom HTTP", + Boards: boards + .Select(b => new TaskTrackerBoardRef(b.BoardId, BoardTitle(b))) + .ToList()); + + public static TaskTrackerCard ToCard(GenericHttpCardDto card) => + new( + CardId: card.CardId, + BoardId: card.BoardId, + ColumnId: card.ColumnId, + ColumnTitle: card.ColumnTitle, + Title: card.Title, + Description: card.Description, + UpdatedAt: card.UpdatedAt, + ColumnChangedAt: null, + Archived: card.Archived, + RevisionTag: card.CardVersion, + WebUrl: string.IsNullOrWhiteSpace(card.WebUrl) ? null : card.WebUrl); + + private static string BoardTitle(GenericHttpBoardDto board) => + board.BoardTitle ?? board.Title ?? board.BoardId; +} diff --git a/apps/api/src/Throne.Infrastructure/TaskTrackers/GenericHttpTaskTrackerProvider.cs b/apps/api/src/Throne.Infrastructure/TaskTrackers/GenericHttpTaskTrackerProvider.cs new file mode 100644 index 00000000..7ca6fedf --- /dev/null +++ b/apps/api/src/Throne.Infrastructure/TaskTrackers/GenericHttpTaskTrackerProvider.cs @@ -0,0 +1,147 @@ +using Throne.Application.TaskTrackers; +using Throne.Infrastructure.TaskTrackers.GenericHttp; + +namespace Throne.Infrastructure.TaskTrackers; + +internal sealed class GenericHttpTaskTrackerProvider(GenericHttpClient client) : ITaskTrackerConnectionProvider +{ + public string TrackerKey => "custom-http"; + + public string DisplayName => "Custom HTTP"; + + public async Task ProbeAsync( + TaskTrackerConnectionDescriptor connection, + CancellationToken ct) + { + ArgumentNullException.ThrowIfNull(connection); + try + { + await client.ProbeAsync(ToConnection(connection), ct); + return TaskTrackerProbeResult.Connected(); + } + catch (GenericHttpApiException ex) + { + return TaskTrackerProbeResult.FromHealth(GenericHttpFailureMap.Classify(ex.StatusCode), ex.Message); + } + catch (HttpRequestException ex) + { + return TaskTrackerProbeResult.Offline(ex.Message); + } + catch (OperationCanceledException ex) when (!ct.IsCancellationRequested) + { + return TaskTrackerProbeResult.Offline($"Request timed out: {ex.Message}"); + } + } + + public async Task> ListBoardsAsync( + TaskTrackerConnectionDescriptor connection, + CancellationToken ct) + { + ArgumentNullException.ThrowIfNull(connection); + try + { + var boards = await client.ListBoardsAsync(ToConnection(connection), ct); + return [GenericHttpProjection.ToTopology(boards)]; + } + catch (GenericHttpApiException ex) + { + throw GenericHttpFailureMap.BoardReadFailure( + TrackerKey, GenericHttpFailureMap.Classify(ex.StatusCode), ex.Message); + } + catch (HttpRequestException ex) + { + throw TaskTrackerFailures.UpstreamUnavailable(TrackerKey, ex.Message); + } + catch (OperationCanceledException ex) when (!ct.IsCancellationRequested) + { + throw TaskTrackerFailures.UpstreamUnavailable(TrackerKey, $"Request timed out: {ex.Message}"); + } + } + + public async Task> ListBoardCardsAsync( + TaskTrackerConnectionDescriptor connection, + string boardId, + CancellationToken ct) => + await PullCardsAsync(connection, boardId, c => client.ListCardsAsync(c, boardId, ct), ct); + + public async Task> SearchCardsAsync( + TaskTrackerConnectionDescriptor connection, + string boardId, + string? query, + int limit, + CancellationToken ct) + { + ArgumentOutOfRangeException.ThrowIfNegativeOrZero(limit); + return await PullCardsAsync( + connection, + boardId, + c => client.SearchCardsAsync(c, boardId, query, limit, ct), + ct); + } + + public async Task GetCardAsync( + TaskTrackerConnectionDescriptor connection, + string cardId, + CancellationToken ct) + { + ArgumentNullException.ThrowIfNull(connection); + ArgumentException.ThrowIfNullOrWhiteSpace(cardId); + try + { + var card = await client.GetCardAsync(ToConnection(connection), cardId, ct); + return GenericHttpProjection.ToCard(card); + } + catch (GenericHttpApiException ex) when (GenericHttpFailureMap.IsGone(ex.StatusCode)) + { + return null; + } + catch (GenericHttpApiException ex) + { + throw new TaskTrackerConnectionException(GenericHttpFailureMap.Classify(ex.StatusCode), ex.Message); + } + catch (HttpRequestException ex) + { + throw new TaskTrackerConnectionException(TaskTrackerConnectionHealth.Offline, ex.Message); + } + catch (OperationCanceledException ex) when (!ct.IsCancellationRequested) + { + throw new TaskTrackerConnectionException( + TaskTrackerConnectionHealth.Offline, $"Request timed out: {ex.Message}"); + } + } + + public string? BuildCardWebUrl(TaskTrackerConnectionDescriptor connection, string cardId) => null; + + private static async Task> PullCardsAsync( + TaskTrackerConnectionDescriptor connection, + string boardId, + Func>> pull, + CancellationToken ct) + { + ArgumentNullException.ThrowIfNull(connection); + ArgumentException.ThrowIfNullOrWhiteSpace(boardId); + try + { + return (await pull(ToConnection(connection))) + .Where(card => !card.Archived) + .Select(GenericHttpProjection.ToCard) + .ToList(); + } + catch (GenericHttpApiException ex) + { + throw new TaskTrackerConnectionException(GenericHttpFailureMap.Classify(ex.StatusCode), ex.Message); + } + catch (HttpRequestException ex) + { + throw new TaskTrackerConnectionException(TaskTrackerConnectionHealth.Offline, ex.Message); + } + catch (OperationCanceledException ex) when (!ct.IsCancellationRequested) + { + throw new TaskTrackerConnectionException( + TaskTrackerConnectionHealth.Offline, $"Request timed out: {ex.Message}"); + } + } + + private static GenericHttpConnection ToConnection(TaskTrackerConnectionDescriptor connection) => + new(connection.BaseUrl, connection.Token); +} diff --git a/apps/api/src/Throne.Infrastructure/TaskTrackers/TaskTrackerInfrastructureModule.cs b/apps/api/src/Throne.Infrastructure/TaskTrackers/TaskTrackerInfrastructureModule.cs index 95a6d3ca..6e328091 100644 --- a/apps/api/src/Throne.Infrastructure/TaskTrackers/TaskTrackerInfrastructureModule.cs +++ b/apps/api/src/Throne.Infrastructure/TaskTrackers/TaskTrackerInfrastructureModule.cs @@ -2,6 +2,7 @@ using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Options; using Throne.Application.TaskTrackers; +using Throne.Infrastructure.TaskTrackers.GenericHttp; using Throne.Infrastructure.TaskTrackers.Kaiten; using Throne.Infrastructure.TaskTrackers.Kaiten.Http; @@ -29,6 +30,8 @@ public static void AddThroneTaskTrackerInfrastructure( services.AddHttpClient(KaitenHttpExecutor.HttpClientName, (sp, client) => client.Timeout = TimeSpan.FromSeconds(sp.GetRequiredService().RequestTimeoutSeconds)); + services.AddHttpClient(GenericHttpClient.HttpClientName, client => + client.Timeout = TimeSpan.FromSeconds(30)); services.AddSingleton(); services.AddSingleton(); @@ -39,8 +42,10 @@ public static void AddThroneTaskTrackerInfrastructure( services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); + services.AddSingleton(); services.AddSingleton(); + services.AddSingleton(); var healthProbeOptions = services.AddOptions(); if (configuration is not null) diff --git a/apps/api/tests/Throne.Infrastructure.Tests/TaskTrackers/GenericHttp/GenericHttpTaskTrackerProviderTests.cs b/apps/api/tests/Throne.Infrastructure.Tests/TaskTrackers/GenericHttp/GenericHttpTaskTrackerProviderTests.cs new file mode 100644 index 00000000..3fcf66d0 --- /dev/null +++ b/apps/api/tests/Throne.Infrastructure.Tests/TaskTrackers/GenericHttp/GenericHttpTaskTrackerProviderTests.cs @@ -0,0 +1,140 @@ +using System.Net; +using FluentAssertions; +using Throne.Application.Errors; +using Throne.Application.TaskTrackers; +using static Throne.Infrastructure.Tests.TaskTrackers.GenericHttp.GenericHttpTestHarness; + +namespace Throne.Infrastructure.Tests.TaskTrackers.GenericHttp; + +public sealed class GenericHttpTaskTrackerProviderTests +{ + [Fact(DisplayName = "Provider identity is the generic custom-http tracker key")] + public void Provider_identity() + { + var (provider, _) = NewProvider(); + + provider.TrackerKey.Should().Be("custom-http"); + provider.DisplayName.Should().Be("Custom HTTP"); + } + + [Fact(DisplayName = "Probe uses bearer auth and maps success to Connected")] + public async Task Probe_connected() + { + var (provider, handler) = NewProvider(); + handler.Enqueue(HttpStatusCode.OK, "{}"); + + var result = await provider.ProbeAsync(Descriptor, CancellationToken.None); + + result.Health.Should().Be(TaskTrackerConnectionHealth.Connected); + handler.Requests.Should().ContainSingle(); + handler.Requests[0].Uri.ToString().Should().Be("https://tasks.example.test/api/task-tracker/health"); + handler.Requests[0].Authorization.Should().Be("Bearer secret-token"); + } + + [Theory(DisplayName = "Probe classifies auth, blocked, and offline failures")] + [InlineData(HttpStatusCode.Unauthorized, TaskTrackerConnectionHealth.Auth)] + [InlineData(HttpStatusCode.Forbidden, TaskTrackerConnectionHealth.Auth)] + [InlineData(HttpStatusCode.PaymentRequired, TaskTrackerConnectionHealth.Blocked)] + [InlineData(HttpStatusCode.BadGateway, TaskTrackerConnectionHealth.Offline)] + public async Task Probe_classifies_failures(HttpStatusCode status, TaskTrackerConnectionHealth expected) + { + var (provider, handler) = NewProvider(); + handler.Enqueue(status, "{}"); + + var result = await provider.ProbeAsync(Descriptor, CancellationToken.None); + + result.Health.Should().Be(expected); + } + + [Fact(DisplayName = "ListBoards maps generic facets into a single Custom HTTP topology space")] + public async Task ListBoards_maps_topology() + { + var (provider, handler) = NewProvider(); + handler.Enqueue(HttpStatusCode.OK, """ + {"boards":[{"board_id":"coding","title":"Coding tasks"}]} + """); + + var topology = await provider.ListBoardsAsync(Descriptor, CancellationToken.None); + + topology.Should().ContainSingle(); + topology[0].SpaceId.Should().Be("custom-http"); + topology[0].Boards.Should().ContainSingle(); + topology[0].Boards[0].BoardId.Should().Be("coding"); + topology[0].Boards[0].BoardTitle.Should().Be("Coding tasks"); + } + + [Fact(DisplayName = "ListBoards translates auth failure into connection-rejected")] + public async Task ListBoards_auth_failure() + { + var (provider, handler) = NewProvider(); + handler.Enqueue(HttpStatusCode.Forbidden, "{}"); + + var act = () => provider.ListBoardsAsync(Descriptor, CancellationToken.None); + + (await act.Should().ThrowAsync()) + .Which.Code.Should().Be(ErrorCodes.TaskTrackerConnectionRejected); + } + + [Fact(DisplayName = "ListBoardCards maps cards and excludes archived rows defensively")] + public async Task ListBoardCards_maps_cards() + { + var (provider, handler) = NewProvider(); + handler.Enqueue(HttpStatusCode.OK, """ + {"cards":[ + {"card_id":"1","board_id":"coding","title":"Active","description":"body","updated_at":"2026-07-16T10:00:00Z","archived":false,"card_version":"v1","web_url":"https://tasks/ui/1"}, + {"card_id":"2","board_id":"coding","title":"Closed","archived":true} + ]} + """); + + var cards = await provider.ListBoardCardsAsync(Descriptor, "coding", CancellationToken.None); + + cards.Should().ContainSingle(); + cards[0].CardId.Should().Be("1"); + cards[0].BoardId.Should().Be("coding"); + cards[0].Description.Should().Be("body"); + cards[0].RevisionTag.Should().Be("v1"); + cards[0].WebUrl.Should().Be("https://tasks/ui/1"); + handler.Requests[0].Uri.PathAndQuery.Should().Be("/api/task-tracker/boards/coding/cards"); + } + + [Fact(DisplayName = "SearchCards forwards query and limit")] + public async Task SearchCards_forwards_query_and_limit() + { + var (provider, handler) = NewProvider(); + handler.Enqueue(HttpStatusCode.OK, """{"cards":[]}"""); + + _ = await provider.SearchCardsAsync( + Descriptor, "coding", "parser bug", limit: 7, CancellationToken.None); + + handler.Requests[0].Uri.PathAndQuery.Should() + .Be("/api/task-tracker/boards/coding/cards/search?query=parser%20bug&limit=7"); + } + + [Fact(DisplayName = "GetCard returns null only on 404")] + public async Task GetCard_gone_returns_null() + { + var (provider, handler) = NewProvider(); + handler.Enqueue(HttpStatusCode.NotFound, "{}"); + + var card = await provider.GetCardAsync(Descriptor, "missing", CancellationToken.None); + + card.Should().BeNull(); + handler.Requests[0].Uri.PathAndQuery.Should().Be("/api/task-tracker/cards/missing"); + } + + [Theory(DisplayName = "Card reads classify upstream failures per ADR-0053")] + [InlineData(HttpStatusCode.Unauthorized, TaskTrackerConnectionHealth.Auth)] + [InlineData(HttpStatusCode.Forbidden, TaskTrackerConnectionHealth.Auth)] + [InlineData(HttpStatusCode.PaymentRequired, TaskTrackerConnectionHealth.Blocked)] + [InlineData(HttpStatusCode.BadGateway, TaskTrackerConnectionHealth.Offline)] + public async Task Card_reads_classify_failures(HttpStatusCode status, TaskTrackerConnectionHealth expected) + { + var (provider, handler) = NewProvider(); + handler.Enqueue(status, "{}"); + + var act = () => provider.ListBoardCardsAsync(Descriptor, "coding", CancellationToken.None); + + (await act.Should().ThrowAsync()) + .Which.Health.Should().Be(expected); + } +} diff --git a/apps/api/tests/Throne.Infrastructure.Tests/TaskTrackers/GenericHttp/GenericHttpTestHarness.cs b/apps/api/tests/Throne.Infrastructure.Tests/TaskTrackers/GenericHttp/GenericHttpTestHarness.cs new file mode 100644 index 00000000..02155bbf --- /dev/null +++ b/apps/api/tests/Throne.Infrastructure.Tests/TaskTrackers/GenericHttp/GenericHttpTestHarness.cs @@ -0,0 +1,66 @@ +using System.Net; +using System.Text; +using Throne.Application.TaskTrackers; +using Throne.Infrastructure.TaskTrackers; +using Throne.Infrastructure.TaskTrackers.GenericHttp; + +namespace Throne.Infrastructure.Tests.TaskTrackers.GenericHttp; + +internal sealed record RecordedRequest(HttpMethod Method, Uri Uri, string? Authorization); + +internal sealed class StubHttpMessageHandler : HttpMessageHandler +{ + private readonly Queue> _responses = new(); + + public List Requests { get; } = []; + + public StubHttpMessageHandler Enqueue(HttpStatusCode status, string? json = null) => + Enqueue(() => + { + var response = new HttpResponseMessage(status); + if (json is not null) + { + response.Content = new StringContent(json, Encoding.UTF8, "application/json"); + } + return response; + }); + + public StubHttpMessageHandler Enqueue(Func factory) + { + _responses.Enqueue(factory); + return this; + } + + protected override Task SendAsync( + HttpRequestMessage request, + CancellationToken cancellationToken) + { + Requests.Add(new RecordedRequest( + request.Method, + request.RequestUri!, + request.Headers.Authorization?.ToString())); + if (_responses.Count == 0) + { + throw new InvalidOperationException("Stub HTTP handler ran out of responses."); + } + return Task.FromResult(_responses.Dequeue()()); + } +} + +internal sealed class FixedHttpClientFactory(HttpClient client) : IHttpClientFactory +{ + public HttpClient CreateClient(string name) => client; +} + +internal static class GenericHttpTestHarness +{ + public static readonly TaskTrackerConnectionDescriptor Descriptor = + new("https://tasks.example.test/", "secret-token"); + + public static (GenericHttpTaskTrackerProvider Provider, StubHttpMessageHandler Handler) NewProvider() + { + var handler = new StubHttpMessageHandler(); + var factory = new FixedHttpClientFactory(new HttpClient(handler)); + return (new GenericHttpTaskTrackerProvider(new GenericHttpClient(factory)), handler); + } +} diff --git a/apps/api/tests/Throne.Infrastructure.Tests/TaskTrackers/KaitenTaskTrackerRegistrationTests.cs b/apps/api/tests/Throne.Infrastructure.Tests/TaskTrackers/KaitenTaskTrackerRegistrationTests.cs index cd80b338..3559c120 100644 --- a/apps/api/tests/Throne.Infrastructure.Tests/TaskTrackers/KaitenTaskTrackerRegistrationTests.cs +++ b/apps/api/tests/Throne.Infrastructure.Tests/TaskTrackers/KaitenTaskTrackerRegistrationTests.cs @@ -2,6 +2,7 @@ using Microsoft.Extensions.DependencyInjection; using Throne.Application.TaskTrackers; using Throne.Infrastructure.TaskTrackers; +using Throne.Infrastructure.TaskTrackers.GenericHttp; using Throne.Infrastructure.TaskTrackers.Kaiten; namespace Throne.Infrastructure.Tests.TaskTrackers; @@ -27,11 +28,13 @@ public void Module_registers_provider_and_client() using var provider = services.BuildServiceProvider(); provider.GetServices() - .Should().ContainSingle(p => p.TrackerKey == "kaiten"); + .Select(p => p.TrackerKey) + .Should().ContainInOrder("kaiten", "custom-http"); var client = provider.GetRequiredService(); client.Cards.Should().NotBeNull(); client.CardChildren.Should().NotBeNull(); client.Topology.Should().NotBeNull(); + provider.GetRequiredService().Should().NotBeNull(); } } diff --git a/specs/contracts/task-trackers/generic-http.md b/specs/contracts/task-trackers/generic-http.md new file mode 100644 index 00000000..cb30ed0e --- /dev/null +++ b/specs/contracts/task-trackers/generic-http.md @@ -0,0 +1,69 @@ +# Generic HTTP Task Tracker Contract + +`custom-http` is the built-in adapter for private task systems that expose a small read-only HTTP +contract. The private system stays hidden behind `base_url + service token`; Throne stores only the +connection, selected board coordinates, and non-authoritative card snapshots. + +## Auth + +Every request uses machine-to-machine bearer auth: + +```http +Authorization: Bearer +``` + +Status mapping follows ADR-0053: + +- `401` / `403` -> auth problem. +- `402` -> blocked. +- `5xx`, timeout, transport failure -> offline. +- `404` is only a gone card on `GET /cards/{card_id}`. + +## Endpoints + +All paths are resolved under `{base_url}/api/task-tracker`. + +| Method | Path | Purpose | +| --- | --- | --- | +| `GET` | `/health` | Probe token and reachability. Any 2xx means connected. | +| `GET` | `/boards` | Return selectable board/facet list. | +| `GET` | `/boards/{board_id}/cards` | Return active cards for a board. | +| `GET` | `/boards/{board_id}/cards/search?query=&limit=10` | Return a first page of active cards; empty query means recent-first. | +| `GET` | `/cards/{card_id}` | Return one active card by id; `404` means gone or not readable as active. | + +## DTOs + +Boards: + +```json +{ + "boards": [ + { "board_id": "coding", "title": "Coding tasks" } + ] +} +``` + +Cards: + +```json +{ + "cards": [ + { + "card_id": "task-id", + "board_id": "coding", + "title": "Task title", + "description": "Optional markdown", + "column_id": null, + "column_title": null, + "updated_at": "2026-07-16T10:00:00Z", + "archived": false, + "card_version": "opaque-revision", + "web_url": "https://tracker.example/tasks/task-id" + } + ] +} +``` + +`GET /cards/{card_id}` returns the card object directly. `description`, column fields, +`updated_at`, `card_version`, and `web_url` may be null or omitted. List and search endpoints should +not return closed or archived cards; the adapter also drops rows with `archived = true`.