user = User::factory()->create(); $this->project = Project::factory()->create(['key' => 'CORE']); app(InitializeProjectTicketMetadata::class)->handle($this->project); $this->syncProjectPermissions($this->project, $this->user, ['view', 'write']); } public function test_discovery_exposes_the_shared_contract_for_every_writing_tool_and_field_for_any_key_owner(): void { foreach ([$this->user, User::factory()->create()] as $user) { $this->syncProjectPermissions($this->project, $user, ['view', 'write']); $this->withApplicationKey($user); $this->postJson('/mcp/kanbangit', [ 'jsonrpc' => '2.0', 'id' => 1, 'method' => 'initialize', 'params' => ['protocolVersion' => '2025-06-18', 'capabilities' => [], 'clientInfo' => ['name' => 'Contract test', 'version' => '1.0']], ])->assertOk()->assertJsonPath('result.instructions', fn (string $value): bool => str_contains($value, TicketDescriptionFormat::instructions())); $response = $this->postJson('/mcp/kanbangit', [ 'jsonrpc' => '2.0', 'id' => 2, 'method' => 'tools/list', 'params' => [], ])->assertOk(); $writers = []; foreach ($response->json('result.tools') as $tool) { $fields = $tool['inputSchema']['properties']; if (! ($tool['annotations']['readOnlyHint'] ?? false)) { $writers[] = $tool['name']; $this->assertStringContainsString(TicketDescriptionFormat::instructions(), $tool['description'], $tool['name']); } if (isset($fields['description'])) { $this->assertStringContainsString(TicketDescriptionFormat::instructions(), $fields['description']['description'], $tool['name']); } if (isset($fields['acceptance_criteria'])) { $this->assertStringContainsString(TicketDescriptionFormat::criteriaInstructions(), $fields['acceptance_criteria']['description'], $tool['name']); } } $this->assertContains('create_ticket', $writers); $this->assertContains('update_ticket', $writers); $this->assertContains('update_ticket_description', $writers); } } #[DataProvider('descriptionCases')] public function test_backend_and_editor_share_compatibility_cases(string $value, bool $supported): void { $this->assertSame($supported, TicketDescriptionFormat::unsupportedContent($value) === []); } public static function descriptionCases(): array { $cases = json_decode(file_get_contents(__DIR__.'/../../Fixtures/ticket-description-cases.json'), true, flags: JSON_THROW_ON_ERROR); return collect($cases)->mapWithKeys(fn (array $case): array => [$case['name'] => [$case['value'], $case['supported']]])->all(); } #[DataProvider('unsupportedDescriptions')] public function test_incompatible_description_rejects_creation_and_every_update_without_partial_changes(string $description): void { $projectBefore = $this->project->fresh()->getAttributes(); KanbanGitServer::actingAs($this->user)->tool(CreateTicket::class, $this->createPayload($description)) ->assertHasErrors(['description', 'unsupported', 'acceptance_criteria']); $this->assertDatabaseCount('tickets', 0); $this->assertDatabaseCount('ticket_worklog_entries', 0); $this->assertSame($projectBefore, $this->project->fresh()->getAttributes()); $ticket = $this->ticket(); $before = $ticket->fresh()->getAttributes(); foreach ([UpdateTicketDescription::class, UpdateTicket::class] as $tool) { KanbanGitServer::actingAs($this->user)->tool($tool, [ 'ticket_id' => $ticket->id, 'description' => $description, 'title' => 'Must not change', 'story_points' => 99, 'acceptance_criteria' => [['text' => 'Must not replace']], ])->assertHasErrors(['description', 'unsupported', 'acceptance_criteria']); $this->assertSame($before, $ticket->fresh()->getAttributes()); $this->assertSame('Separate criterion', $ticket->acceptanceCriteria()->sole()->text); $this->assertDatabaseCount('ticket_worklog_entries', 0); } } public static function unsupportedDescriptions(): array { return array_map(fn (array $case): array => [$case[0]], array_filter(self::descriptionCases(), fn (array $case): bool => ! $case[1])); } public function test_compatible_descriptions_are_stored_verbatim_and_criteria_remain_separate_and_null_clears(): void { $description = TicketDescriptionFormat::contract()['example']; KanbanGitServer::actingAs($this->user)->tool(CreateTicket::class, [ ...$this->createPayload($description), 'acceptance_criteria' => [['text' => 'Separate criterion']], ])->assertOk(); $ticket = $this->project->tickets()->sole(); $this->assertSame($description, $ticket->description); $this->assertSame('Separate criterion', $ticket->acceptanceCriteria()->sole()->text); $this->assertSame([], TicketDescriptionFormat::unsupportedContent($description)); $this->withoutVite(); $this->actingAs($this->user)->get(route('projects.tickets.show', [$this->project, $ticket])) ->assertInertia(fn (Assert $page) => $page->where('ticket.description', $description)->where('ticket.acceptance_criteria.0.text', 'Separate criterion')); $process = new Process(['node', 'tests/Fixtures/ticket-description.mjs', '--stored-description'], base_path()); $process->setInput(json_encode(['description' => $ticket->description, 'criteria' => $ticket->acceptanceCriteria()->get()->toArray()], JSON_THROW_ON_ERROR)); $process->setTimeout(60); $process->run(); $this->assertTrue($process->isSuccessful(), $process->getOutput().$process->getErrorOutput()); foreach ([UpdateTicketDescription::class, UpdateTicket::class] as $tool) { $updated = "> Quote\n\n**Updated**\n\n```markdown\n- [x] literal example\n```"; KanbanGitServer::actingAs($this->user)->tool($tool, ['ticket_id' => $ticket->id, 'description' => $updated])->assertOk() ->assertStructuredContent(fn (AssertableJson $json) => $json->where('data.ticket.description', $updated)->etc()); $this->assertSame($updated, $ticket->fresh()->description); KanbanGitServer::actingAs($this->user)->tool($tool, ['ticket_id' => $ticket->id, 'description' => null])->assertOk(); $this->assertNull($ticket->fresh()->description); $this->assertSame('Separate criterion', $ticket->acceptanceCriteria()->sole()->text); } } public function test_updates_without_description_preserve_legacy_source_and_checklist_identity(): void { $ticket = $this->ticket(); $legacy = "## Legacy\n\n- [x] Keep verbatim\n\n