Files
KanbanGit/tests/Feature/Mcp/TicketDescriptionContractTest.php
T

179 lines
9.6 KiB
PHP

<?php
namespace Tests\Feature\Mcp;
use App\Actions\ProjectTicketMetadata\InitializeProjectTicketMetadata;
use App\Actions\Tickets\Support\TicketDescriptionFormat;
use App\Mcp\Servers\KanbanGitServer;
use App\Mcp\Tools\CreateTicket;
use App\Mcp\Tools\GetTicket;
use App\Mcp\Tools\UpdateTicket;
use App\Mcp\Tools\UpdateTicketDescription;
use App\Models\Project;
use App\Models\Ticket;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Testing\Fluent\AssertableJson;
use Inertia\Testing\AssertableInertia as Assert;
use PHPUnit\Framework\Attributes\DataProvider;
use Symfony\Component\Process\Process;
use Tests\TestCase;
class TicketDescriptionContractTest extends TestCase
{
use RefreshDatabase;
private Project $project;
private User $user;
protected function setUp(): void
{
parent::setUp();
$this->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<table></table>";
$ticket->update(['description' => $legacy]);
$criterion = $ticket->acceptanceCriteria()->sole();
KanbanGitServer::actingAs($this->user)->tool(UpdateTicket::class, ['ticket_id' => $ticket->id, 'title' => 'Updated title'])
->assertOk();
$this->assertSame($legacy, $ticket->fresh()->description);
$this->assertSame($criterion->id, $ticket->acceptanceCriteria()->sole()->id);
KanbanGitServer::actingAs($this->user)->tool(UpdateTicketDescription::class, ['ticket_id' => $ticket->id])->assertOk();
$this->assertSame($legacy, $ticket->fresh()->description);
KanbanGitServer::actingAs($this->user)->tool(GetTicket::class, ['ticket_id' => $ticket->id])->assertOk()
->assertStructuredContent(fn (AssertableJson $json) => $json->where('data.ticket.description', $legacy)->etc());
}
private function createPayload(?string $description): array
{
return [
'project_id' => $this->project->id, 'title' => 'Formatted ticket', 'description' => $description,
'ticket_type_id' => $this->project->ticketTypes()->where('is_parent', false)->firstOrFail()->id,
'ticket_status_id' => $this->project->ticketStatuses()->firstOrFail()->id,
'ticket_priority_id' => $this->project->ticketPriorities()->firstOrFail()->id,
];
}
private function ticket(): Ticket
{
$ticket = Ticket::factory()->create([
...collect($this->createPayload('Original description'))->except('project_id')->all(),
'project_id' => $this->project->id, 'ticket_key' => 'CORE-1', 'sequence_number' => 1,
]);
$ticket->acceptanceCriteria()->create(['text' => 'Separate criterion', 'checked' => false, 'indent' => 0, 'position' => 1]);
return $ticket;
}
}