diff --git a/backend/tests/Taskdeck.Api.Tests/CaptureToBoardGoldenPathIntegrationTests.cs b/backend/tests/Taskdeck.Api.Tests/CaptureToBoardGoldenPathIntegrationTests.cs new file mode 100644 index 000000000..2ab61f570 --- /dev/null +++ b/backend/tests/Taskdeck.Api.Tests/CaptureToBoardGoldenPathIntegrationTests.cs @@ -0,0 +1,412 @@ +using System.Net; +using System.Net.Http.Json; +using FluentAssertions; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using Taskdeck.Api.Tests.Support; +using Taskdeck.Application.DTOs; +using Taskdeck.Domain.Entities; +using Taskdeck.Domain.Enums; +using Taskdeck.Infrastructure.Persistence; +using Xunit; + +namespace Taskdeck.Api.Tests; + +/// +/// Golden path integration tests that exercise the full capture-to-board pipeline: +/// capture input -> triage -> proposal generation -> review/approval -> board mutation. +/// This is the single most important integration test class in the system (GP-06). +/// +public class CaptureToBoardGoldenPathIntegrationTests : IClassFixture +{ + private readonly TestWebApplicationFactory _factory; + + public CaptureToBoardGoldenPathIntegrationTests(TestWebApplicationFactory factory) + { + _factory = factory; + } + + [Fact] + public async Task HappyPath_CaptureTriageApproveExecute_ShouldCreateCardOnBoard() + { + // Arrange: authenticated user with a board and a column + using var client = _factory.CreateClient(); + var user = await ApiTestHarness.AuthenticateAsync(client, "golden-happy"); + var board = await ApiTestHarness.CreateBoardAsync(client, "golden-happy-board"); + + var columnResponse = await client.PostAsJsonAsync( + $"/api/boards/{board.Id}/columns", + new CreateColumnDto(board.Id, "Backlog", null, null)); + columnResponse.StatusCode.Should().Be(HttpStatusCode.Created); + var column = await columnResponse.Content.ReadFromJsonAsync(); + column.Should().NotBeNull(); + + // Act 1: Create a capture item with structured checklist text + var captureResponse = await client.PostAsJsonAsync( + "/api/capture/items", + new CreateCaptureItemDto( + board.Id, + "- [ ] Fix login bug")); + captureResponse.StatusCode.Should().Be(HttpStatusCode.Created); + var capture = await captureResponse.Content.ReadFromJsonAsync(); + capture.Should().NotBeNull(); + capture!.Status.Should().Be(CaptureStatus.New); + + // Act 2: Trigger triage (enqueues for worker processing) + var triageResponse = await client.PostAsync($"/api/capture/items/{capture.Id}/triage", null); + triageResponse.StatusCode.Should().Be(HttpStatusCode.Accepted); + + // Act 3: Wait for worker to generate proposal + var triaged = await WaitForCaptureStatusAsync(client, capture.Id, CaptureStatus.ProposalCreated); + triaged.Status.Should().Be(CaptureStatus.ProposalCreated); + triaged.Provenance.Should().NotBeNull(); + triaged.Provenance!.ProposalId.Should().NotBeNull(); + var proposalId = triaged.Provenance.ProposalId!.Value; + + // Act 4: Verify proposal exists with correct structure + var proposalResponse = await client.GetAsync($"/api/automation/proposals/{proposalId}"); + proposalResponse.StatusCode.Should().Be(HttpStatusCode.OK); + var proposal = await proposalResponse.Content.ReadFromJsonAsync(); + proposal.Should().NotBeNull(); + proposal!.Status.Should().Be(ProposalStatus.PendingReview); + proposal.BoardId.Should().Be(board.Id); + proposal.SourceType.Should().Be(ProposalSourceType.Queue); + proposal.SourceReferenceId.Should().Be(capture.Id.ToString()); + proposal.Operations.Should().ContainSingle(); + proposal.Operations[0].ActionType.Should().Be("create"); + proposal.Operations[0].TargetType.Should().Be("card"); + + // Act 5: Approve the proposal + var approveResponse = await client.PostAsync($"/api/automation/proposals/{proposalId}/approve", null); + approveResponse.StatusCode.Should().Be(HttpStatusCode.OK); + var approved = await approveResponse.Content.ReadFromJsonAsync(); + approved!.Status.Should().Be(ProposalStatus.Approved); + + // Act 6: Execute the proposal + var executeRequest = new HttpRequestMessage(HttpMethod.Post, $"/api/automation/proposals/{proposalId}/execute"); + executeRequest.Headers.Add("Idempotency-Key", Guid.NewGuid().ToString()); + var executeResponse = await client.SendAsync(executeRequest); + executeResponse.StatusCode.Should().Be(HttpStatusCode.OK); + var executed = await executeResponse.Content.ReadFromJsonAsync(); + executed!.Status.Should().Be(ProposalStatus.Applied); + executed.AppliedAt.Should().NotBeNull(); + + // Assert: Card now exists on the board + var cardsResponse = await client.GetAsync($"/api/boards/{board.Id}/cards"); + cardsResponse.StatusCode.Should().Be(HttpStatusCode.OK); + var cards = await cardsResponse.Content.ReadFromJsonAsync>(); + cards.Should().NotBeNull(); + cards!.Should().ContainSingle(); + cards[0].Title.Should().Be("Fix login bug"); + cards[0].BoardId.Should().Be(board.Id); + cards[0].ColumnId.Should().Be(column!.Id, "card should be placed in the first (Backlog) column"); + + // Assert: Capture item is now Converted + var converted = await WaitForCaptureStatusAsync(client, capture.Id, CaptureStatus.Converted); + converted.Status.Should().Be(CaptureStatus.Converted); + converted.Provenance!.ProposalId.Should().Be(proposalId); + converted.Provenance.ConvertedAt.Should().NotBeNull(); + + // Assert: Provenance chain is intact + converted.Provenance.CaptureItemId.Should().Be(capture.Id); + converted.Provenance.BoardId.Should().Be(board.Id); + converted.Provenance.Provider.Should().Be("Mock"); + converted.Provenance.SourceSurface.Should().Be("capture"); + converted.Provenance.CorrelationId.Should().NotBeNullOrWhiteSpace(); + } + + [Fact] + public async Task MultiOperation_CaptureWithMultipleTasks_ShouldCreateMultipleCards() + { + using var client = _factory.CreateClient(); + await ApiTestHarness.AuthenticateAsync(client, "golden-multi"); + var board = await ApiTestHarness.CreateBoardAsync(client, "golden-multi-board"); + + var columnResponse = await client.PostAsJsonAsync( + $"/api/boards/{board.Id}/columns", + new CreateColumnDto(board.Id, "Backlog", null, null)); + columnResponse.StatusCode.Should().Be(HttpStatusCode.Created); + var column = await columnResponse.Content.ReadFromJsonAsync(); + column.Should().NotBeNull(); + + // Capture with multiple checklist items + var captureResponse = await client.PostAsJsonAsync( + "/api/capture/items", + new CreateCaptureItemDto( + board.Id, + """ + - [ ] Implement user authentication + - [ ] Write API integration tests + - [ ] Update deployment docs + """)); + captureResponse.StatusCode.Should().Be(HttpStatusCode.Created); + var capture = await captureResponse.Content.ReadFromJsonAsync(); + capture.Should().NotBeNull(); + + // Triage and wait for proposal + var triageResponse = await client.PostAsync($"/api/capture/items/{capture!.Id}/triage", null); + triageResponse.StatusCode.Should().Be(HttpStatusCode.Accepted); + + var triaged = await WaitForCaptureStatusAsync(client, capture.Id, CaptureStatus.ProposalCreated); + var proposalId = triaged.Provenance!.ProposalId!.Value; + + // Verify proposal has multiple operations + var proposalResponse = await client.GetAsync($"/api/automation/proposals/{proposalId}"); + proposalResponse.StatusCode.Should().Be(HttpStatusCode.OK); + var proposal = await proposalResponse.Content.ReadFromJsonAsync(); + proposal!.Operations.Should().HaveCount(3); + proposal.Operations.Should().OnlyContain(op => op.ActionType == "create" && op.TargetType == "card"); + + // Approve and execute + var approveResponse = await client.PostAsync($"/api/automation/proposals/{proposalId}/approve", null); + approveResponse.StatusCode.Should().Be(HttpStatusCode.OK); + + var executeRequest = new HttpRequestMessage(HttpMethod.Post, $"/api/automation/proposals/{proposalId}/execute"); + executeRequest.Headers.Add("Idempotency-Key", Guid.NewGuid().ToString()); + var executeResponse = await client.SendAsync(executeRequest); + executeResponse.StatusCode.Should().Be(HttpStatusCode.OK); + + // All three cards should exist on the board + var cardsResponse = await client.GetAsync($"/api/boards/{board.Id}/cards"); + cardsResponse.StatusCode.Should().Be(HttpStatusCode.OK); + var cards = await cardsResponse.Content.ReadFromJsonAsync>(); + cards.Should().NotBeNull(); + cards!.Should().HaveCount(3); + cards.Select(c => c.Title).Should().BeEquivalentTo( + new[] { "Implement user authentication", "Write API integration tests", "Update deployment docs" }); + cards.Should().OnlyContain(c => c.ColumnId == column!.Id, "all cards should be placed in the Backlog column"); + } + + [Fact] + public async Task Rejection_ShouldLeaveBoardUnchanged() + { + using var client = _factory.CreateClient(); + await ApiTestHarness.AuthenticateAsync(client, "golden-reject"); + var board = await ApiTestHarness.CreateBoardAsync(client, "golden-reject-board"); + + var columnResponse = await client.PostAsJsonAsync( + $"/api/boards/{board.Id}/columns", + new CreateColumnDto(board.Id, "Backlog", null, null)); + columnResponse.StatusCode.Should().Be(HttpStatusCode.Created); + + // Create and triage capture + var captureResponse = await client.PostAsJsonAsync( + "/api/capture/items", + new CreateCaptureItemDto(board.Id, "- [ ] Unwanted task")); + captureResponse.StatusCode.Should().Be(HttpStatusCode.Created); + var capture = await captureResponse.Content.ReadFromJsonAsync(); + + var triageResponse = await client.PostAsync($"/api/capture/items/{capture!.Id}/triage", null); + triageResponse.StatusCode.Should().Be(HttpStatusCode.Accepted); + + var triaged = await WaitForCaptureStatusAsync(client, capture.Id, CaptureStatus.ProposalCreated); + var proposalId = triaged.Provenance!.ProposalId!.Value; + + // Reject the proposal + var rejectResponse = await client.PostAsJsonAsync( + $"/api/automation/proposals/{proposalId}/reject", + new UpdateProposalStatusDto("Not needed")); + rejectResponse.StatusCode.Should().Be(HttpStatusCode.OK); + var rejected = await rejectResponse.Content.ReadFromJsonAsync(); + rejected!.Status.Should().Be(ProposalStatus.Rejected); + + // Board should have no cards + var cardsResponse = await client.GetAsync($"/api/boards/{board.Id}/cards"); + cardsResponse.StatusCode.Should().Be(HttpStatusCode.OK); + var cards = await cardsResponse.Content.ReadFromJsonAsync>(); + cards.Should().NotBeNull(); + cards!.Should().BeEmpty(); + } + + [Fact] + public async Task CrossUserIsolation_UserBCannotSeeOrApproveUserAProposal() + { + using var clientA = _factory.CreateClient(); + using var clientB = _factory.CreateClient(); + + await ApiTestHarness.AuthenticateAsync(clientA, "golden-isolation-a"); + await ApiTestHarness.AuthenticateAsync(clientB, "golden-isolation-b"); + + var boardA = await ApiTestHarness.CreateBoardAsync(clientA, "golden-isolation-board"); + + var columnResponse = await clientA.PostAsJsonAsync( + $"/api/boards/{boardA.Id}/columns", + new CreateColumnDto(boardA.Id, "Backlog", null, null)); + columnResponse.StatusCode.Should().Be(HttpStatusCode.Created); + + // User A creates and triages a capture + var captureResponse = await clientA.PostAsJsonAsync( + "/api/capture/items", + new CreateCaptureItemDto(boardA.Id, "- [ ] Secret task for user A")); + captureResponse.StatusCode.Should().Be(HttpStatusCode.Created); + var capture = await captureResponse.Content.ReadFromJsonAsync(); + + var triageResponse = await clientA.PostAsync($"/api/capture/items/{capture!.Id}/triage", null); + triageResponse.StatusCode.Should().Be(HttpStatusCode.Accepted); + + var triaged = await WaitForCaptureStatusAsync(clientA, capture.Id, CaptureStatus.ProposalCreated); + var proposalId = triaged.Provenance!.ProposalId!.Value; + + // User B cannot read the proposal + var getResponse = await clientB.GetAsync($"/api/automation/proposals/{proposalId}"); + getResponse.StatusCode.Should().BeOneOf(HttpStatusCode.Forbidden, HttpStatusCode.NotFound); + + // User B cannot approve the proposal + var approveResponse = await clientB.PostAsync($"/api/automation/proposals/{proposalId}/approve", null); + approveResponse.StatusCode.Should().BeOneOf(HttpStatusCode.Forbidden, HttpStatusCode.NotFound); + + // User B cannot execute the proposal + // First approve as User A so we can test execution isolation + var approveAsA = await clientA.PostAsync($"/api/automation/proposals/{proposalId}/approve", null); + approveAsA.StatusCode.Should().Be(HttpStatusCode.OK); + + var executeRequest = new HttpRequestMessage(HttpMethod.Post, $"/api/automation/proposals/{proposalId}/execute"); + executeRequest.Headers.Add("Idempotency-Key", Guid.NewGuid().ToString()); + var executeResponse = await clientB.SendAsync(executeRequest); + executeResponse.StatusCode.Should().BeOneOf(HttpStatusCode.Forbidden, HttpStatusCode.NotFound); + } + + [Fact] + public async Task AuditTrail_ShouldRecordBoardMutationAfterExecution() + { + using var client = _factory.CreateClient(); + var user = await ApiTestHarness.AuthenticateAsync(client, "golden-audit"); + var board = await ApiTestHarness.CreateBoardAsync(client, "golden-audit-board"); + + var columnResponse = await client.PostAsJsonAsync( + $"/api/boards/{board.Id}/columns", + new CreateColumnDto(board.Id, "Backlog", null, null)); + columnResponse.StatusCode.Should().Be(HttpStatusCode.Created); + + // Full pipeline: capture -> triage -> approve -> execute + var captureResponse = await client.PostAsJsonAsync( + "/api/capture/items", + new CreateCaptureItemDto(board.Id, "- [ ] Audit trail test card")); + captureResponse.StatusCode.Should().Be(HttpStatusCode.Created); + var capture = await captureResponse.Content.ReadFromJsonAsync(); + + await client.PostAsync($"/api/capture/items/{capture!.Id}/triage", null); + var triaged = await WaitForCaptureStatusAsync(client, capture.Id, CaptureStatus.ProposalCreated); + var proposalId = triaged.Provenance!.ProposalId!.Value; + + await client.PostAsync($"/api/automation/proposals/{proposalId}/approve", null); + + var executeRequest = new HttpRequestMessage(HttpMethod.Post, $"/api/automation/proposals/{proposalId}/execute"); + executeRequest.Headers.Add("Idempotency-Key", Guid.NewGuid().ToString()); + await client.SendAsync(executeRequest); + + // Verify audit trail records the card creation + var auditResponse = await client.GetAsync($"/api/audit/boards/{board.Id}"); + auditResponse.StatusCode.Should().Be(HttpStatusCode.OK); + var auditLogs = await auditResponse.Content.ReadFromJsonAsync>(); + auditLogs.Should().NotBeNull(); + + // There should be at least one audit entry for card creation on this board + auditLogs!.Should().Contain(log => + string.Equals(log.EntityType, "card", StringComparison.OrdinalIgnoreCase) && + log.Action == AuditAction.Created); + } + + [Fact] + public async Task ProvenanceIntegrity_BackwardTraceable_CardToProposalToCapture() + { + using var client = _factory.CreateClient(); + var user = await ApiTestHarness.AuthenticateAsync(client, "golden-provenance"); + var board = await ApiTestHarness.CreateBoardAsync(client, "golden-provenance-board"); + + var columnResponse = await client.PostAsJsonAsync( + $"/api/boards/{board.Id}/columns", + new CreateColumnDto(board.Id, "Backlog", null, null)); + columnResponse.StatusCode.Should().Be(HttpStatusCode.Created); + + var captureResponse = await client.PostAsJsonAsync( + "/api/capture/items", + new CreateCaptureItemDto(board.Id, "- [ ] Provenance test card")); + captureResponse.StatusCode.Should().Be(HttpStatusCode.Created); + var capture = await captureResponse.Content.ReadFromJsonAsync(); + + await client.PostAsync($"/api/capture/items/{capture!.Id}/triage", null); + var triaged = await WaitForCaptureStatusAsync(client, capture.Id, CaptureStatus.ProposalCreated); + var proposalId = triaged.Provenance!.ProposalId!.Value; + + await client.PostAsync($"/api/automation/proposals/{proposalId}/approve", null); + var executeRequest = new HttpRequestMessage(HttpMethod.Post, $"/api/automation/proposals/{proposalId}/execute"); + executeRequest.Headers.Add("Idempotency-Key", Guid.NewGuid().ToString()); + await client.SendAsync(executeRequest); + + // Wait for capture to be marked Converted + var converted = await WaitForCaptureStatusAsync(client, capture.Id, CaptureStatus.Converted); + + // Verify the full provenance chain + // 1. Capture -> Proposal link via provenance + converted.Provenance!.CaptureItemId.Should().Be(capture.Id); + converted.Provenance.ProposalId.Should().Be(proposalId); + converted.Provenance.ConvertedAt.Should().NotBeNull(); + converted.Provenance.CorrelationId.Should().NotBeNullOrWhiteSpace(); + + // 2. Proposal -> Capture link via SourceReferenceId + var proposalResponse = await client.GetAsync($"/api/automation/proposals/{proposalId}"); + proposalResponse.StatusCode.Should().Be(HttpStatusCode.OK); + var proposal = await proposalResponse.Content.ReadFromJsonAsync(); + proposal!.SourceReferenceId.Should().Be(capture.Id.ToString()); + proposal.SourceType.Should().Be(ProposalSourceType.Queue); + proposal.CorrelationId.Should().NotBeNullOrWhiteSpace(); + + // 3. Persisted payload provenance is consistent + using var scope = _factory.Services.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + var persistedItem = await db.LlmRequests.SingleAsync(r => r.Id == capture.Id); + var payload = CaptureRequestContract.ParsePayload(persistedItem.Payload, allowServerAttributionFields: true); + payload.IsSuccess.Should().BeTrue(); + payload.Value.Provenance.Should().NotBeNull(); + payload.Value.Provenance!.ProposalId.Should().Be(proposalId); + payload.Value.Provenance.ConvertedAt.Should().NotBeNull(); + payload.Value.Provenance.RequestedByUserId.Should().Be(user.UserId); + } + + [Fact] + public async Task TriageFailure_NoBoardId_ShouldFailDeterministically() + { + using var client = _factory.CreateClient(); + await ApiTestHarness.AuthenticateAsync(client, "golden-fail"); + + // Capture without a board should fail during triage + var captureResponse = await client.PostAsJsonAsync( + "/api/capture/items", + new CreateCaptureItemDto(null, "- [ ] Task without a board")); + captureResponse.StatusCode.Should().Be(HttpStatusCode.Created); + var capture = await captureResponse.Content.ReadFromJsonAsync(); + + var triageResponse = await client.PostAsync($"/api/capture/items/{capture!.Id}/triage", null); + triageResponse.StatusCode.Should().Be(HttpStatusCode.Accepted); + + var failed = await WaitForCaptureStatusAsync(client, capture.Id, CaptureStatus.Failed); + failed.Status.Should().Be(CaptureStatus.Failed); + failed.Provenance.Should().NotBeNull(); + failed.Provenance!.ProposalId.Should().BeNull(); + } + + private static async Task WaitForCaptureStatusAsync( + HttpClient client, + Guid itemId, + CaptureStatus expectedStatus) + { + return await ApiTestHarness.PollUntilAsync( + async () => + { + var response = await client.GetAsync($"/api/capture/items/{itemId}"); + response.StatusCode.Should().Be(HttpStatusCode.OK); + var item = await response.Content.ReadFromJsonAsync(); + item.Should().NotBeNull(); + return item!; + }, + item => item.Status == expectedStatus || + (item.Status == CaptureStatus.Failed && expectedStatus != CaptureStatus.Failed), + $"capture item {itemId} status to become {expectedStatus}", + maxAttempts: 40, + interval: TimeSpan.FromMilliseconds(250), + diagnostics: item => item is null + ? "item=null" + : $"status={item.Status}, proposalId={item.Provenance?.ProposalId?.ToString() ?? "null"}"); + } +}