The project form gets «Crear en TaskStream» while the project has no board, and `taskstream: true` on a PATCH does the same through the API. A refusal leaves the edit unsaved; a project with a board keeps it, and `taskstream_key` without the flag is still ignored. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
286 lines
10 KiB
PHP
286 lines
10 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace Tests\Feature\Api;
|
|
|
|
use App\Actions\Projects\OpenInTaskStream;
|
|
use App\Models\ApiToken;
|
|
use App\Models\Project;
|
|
use App\Models\User;
|
|
use App\Services\TaskStream\TaskStreamClient;
|
|
use Database\Seeders\RolePermissionSeeder;
|
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
|
use Illuminate\Http\Client\Request;
|
|
use Illuminate\Support\Facades\Http;
|
|
use PHPUnit\Framework\Attributes\Test;
|
|
use Tests\FakesPireon;
|
|
use Tests\TestCase;
|
|
|
|
/**
|
|
* `taskstream: true` on a new project, or on one without a board yet, opens
|
|
* its board in TaskStream too, and the answer carries the link.
|
|
*/
|
|
class TaskStreamProjectTest extends TestCase
|
|
{
|
|
use FakesPireon, RefreshDatabase;
|
|
|
|
protected string $taskstream = 'https://api.taskstream.test';
|
|
|
|
protected function setUp(): void
|
|
{
|
|
parent::setUp();
|
|
|
|
$this->seed(RolePermissionSeeder::class);
|
|
|
|
config([
|
|
'services.taskstream.api_url' => $this->taskstream,
|
|
'services.taskstream.web_url' => 'https://taskstream.test',
|
|
'services.taskstream.token' => null,
|
|
]);
|
|
}
|
|
|
|
protected function editor(): User
|
|
{
|
|
$user = User::factory()->create();
|
|
$user->syncRoles(['editor']);
|
|
|
|
return $user;
|
|
}
|
|
|
|
/**
|
|
* TaskStream creating whatever it is asked for, or answering $status.
|
|
*/
|
|
protected function fakeTaskStream(int $status = 201, array $body = []): void
|
|
{
|
|
Http::fake([
|
|
"{$this->taskstream}/api/projects" => fn (Request $request) => Http::response(
|
|
$status === 201 ? ['id' => 'ts-1', 'key' => $request['key'], 'version' => 1] : $body,
|
|
$status,
|
|
),
|
|
]);
|
|
}
|
|
|
|
protected function sentToTaskStream(): int
|
|
{
|
|
return Http::recorded(fn (Request $request) => str_starts_with($request->url(), $this->taskstream))->count();
|
|
}
|
|
|
|
#[Test]
|
|
public function a_new_project_gets_a_public_board_as_the_person_who_asked(): void
|
|
{
|
|
$user = $this->editor();
|
|
$this->fakeTaskStream();
|
|
$this->withHeaders($this->apiHeadersFor($user));
|
|
|
|
$this->postJson('/api/v1/projects', [
|
|
'title' => ['es' => 'Catálogo abierto'],
|
|
'slug' => ['es' => 'catalogo'],
|
|
'tagline' => ['es' => 'Una herramienta pequeña.'],
|
|
'taskstream' => true,
|
|
])
|
|
->assertCreated()
|
|
->assertJsonPath('data.taskstream_key', 'CATALOGO')
|
|
->assertJsonPath('data.taskstream_url', 'https://taskstream.test/p/CATALOGO');
|
|
|
|
Http::assertSent(fn (Request $request) => $request->url() === "{$this->taskstream}/api/projects"
|
|
&& $request->hasHeader('Authorization', 'Bearer token-for-'.$user->oauth_id)
|
|
&& $request->data() === [
|
|
'key' => 'CATALOGO',
|
|
'name' => 'Catálogo abierto',
|
|
'description' => 'Una herramienta pequeña.',
|
|
'isPublic' => true,
|
|
]);
|
|
|
|
$project = Project::query()->sole();
|
|
$this->assertSame('ts-1', $project->taskstream_project_id);
|
|
$this->assertSame('CATALOGO', $project->taskstream_key);
|
|
|
|
$this->getJson("/api/v1/projects/{$project->id}")
|
|
->assertJsonPath('data.taskstream_url', 'https://taskstream.test/p/CATALOGO');
|
|
}
|
|
|
|
#[Test]
|
|
public function the_key_can_be_chosen(): void
|
|
{
|
|
$this->fakeTaskStream();
|
|
$this->withHeaders($this->apiHeadersFor($this->editor()));
|
|
|
|
$this->postJson('/api/v1/projects', [
|
|
'title' => ['es' => 'Web'],
|
|
'slug' => ['es' => 'web'],
|
|
'taskstream' => true,
|
|
'taskstream_key' => 'web2',
|
|
])
|
|
->assertCreated()
|
|
->assertJsonPath('data.taskstream_key', 'WEB2');
|
|
|
|
$this->postJson('/api/v1/projects', [
|
|
'title' => ['es' => 'Otra'],
|
|
'slug' => ['es' => 'otra'],
|
|
'taskstream' => true,
|
|
'taskstream_key' => '1-no',
|
|
])
|
|
->assertUnprocessable()
|
|
->assertJsonValidationErrors('taskstream_key');
|
|
}
|
|
|
|
#[Test]
|
|
public function without_the_flag_taskstream_is_left_alone(): void
|
|
{
|
|
$this->fakeTaskStream();
|
|
$this->withHeaders($this->apiHeadersFor($this->editor()));
|
|
|
|
$this->postJson('/api/v1/projects', [
|
|
'title' => ['es' => 'Solo aquí'],
|
|
'slug' => ['es' => 'solo-aqui'],
|
|
'taskstream_key' => 'SOLO',
|
|
])
|
|
->assertCreated()
|
|
->assertJsonPath('data.taskstream_key', null)
|
|
->assertJsonPath('data.taskstream_url', null);
|
|
|
|
$this->assertSame(0, $this->sentToTaskStream());
|
|
}
|
|
|
|
#[Test]
|
|
public function a_refusal_from_taskstream_creates_nothing_here(): void
|
|
{
|
|
$this->fakeTaskStream(409, ['statusCode' => 409, 'message' => 'Project key CATALOGO is already in use']);
|
|
$this->withHeaders($this->apiHeadersFor($this->editor()));
|
|
|
|
$response = $this->postJson('/api/v1/projects', [
|
|
'title' => ['es' => 'Catálogo'],
|
|
'slug' => ['es' => 'catalogo'],
|
|
'taskstream' => true,
|
|
])
|
|
->assertUnprocessable()
|
|
->assertJsonValidationErrors('taskstream_key');
|
|
|
|
// Whatever language the person reads, TaskStream's reason is passed on.
|
|
$this->assertStringContainsString('Project key CATALOGO is already in use', $response->json('errors.taskstream_key.0'));
|
|
|
|
$this->assertSame(0, Project::withTrashed()->count());
|
|
}
|
|
|
|
#[Test]
|
|
public function taskstream_being_down_creates_nothing_here(): void
|
|
{
|
|
Http::fake(["{$this->taskstream}/*" => Http::failedConnection()]);
|
|
$this->withHeaders($this->apiHeadersFor($this->editor()));
|
|
|
|
$this->postJson('/api/v1/projects', [
|
|
'title' => ['es' => 'Catálogo'],
|
|
'slug' => ['es' => 'catalogo'],
|
|
'taskstream' => true,
|
|
])->assertStatus(502);
|
|
|
|
$this->assertSame(0, Project::withTrashed()->count());
|
|
}
|
|
|
|
#[Test]
|
|
public function a_machine_token_needs_the_servers_own_taskstream_token(): void
|
|
{
|
|
$this->fakeTaskStream();
|
|
[, $plain] = ApiToken::issue($this->editor(), 'CI', ['projects.*'], now()->addDay());
|
|
$this->withHeaders(['Authorization' => 'Bearer '.$plain, 'Accept' => 'application/json']);
|
|
|
|
$this->postJson('/api/v1/projects', [
|
|
'title' => ['es' => 'Catálogo'],
|
|
'slug' => ['es' => 'catalogo'],
|
|
'taskstream' => true,
|
|
])
|
|
->assertUnprocessable()
|
|
->assertJsonValidationErrors('taskstream');
|
|
|
|
$this->assertSame(0, $this->sentToTaskStream());
|
|
$this->assertSame(0, Project::withTrashed()->count());
|
|
|
|
// With one configured, that token is the one TaskStream sees — never ours.
|
|
config(['services.taskstream.token' => 'pireon-service-token']);
|
|
$this->app->forgetInstance(TaskStreamClient::class);
|
|
|
|
$this->postJson('/api/v1/projects', [
|
|
'title' => ['es' => 'Catálogo'],
|
|
'slug' => ['es' => 'catalogo'],
|
|
'taskstream' => true,
|
|
])->assertCreated();
|
|
|
|
Http::assertSent(fn (Request $request) => str_starts_with($request->url(), $this->taskstream)
|
|
&& $request->hasHeader('Authorization', 'Bearer pireon-service-token'));
|
|
}
|
|
|
|
#[Test]
|
|
public function an_update_opens_a_board_for_a_project_without_one(): void
|
|
{
|
|
$this->fakeTaskStream();
|
|
$this->withHeaders($this->apiHeadersFor($this->editor()));
|
|
$project = Project::factory()->create(['slug' => ['es' => 'catalogo']]);
|
|
|
|
$this->patchJson("/api/v1/projects/{$project->id}", ['taskstream' => true, 'needs_help' => true])
|
|
->assertOk()
|
|
->assertJsonPath('data.taskstream_key', 'CATALOGO')
|
|
->assertJsonPath('data.taskstream_url', 'https://taskstream.test/p/CATALOGO')
|
|
->assertJsonPath('data.needs_help', true);
|
|
|
|
$this->assertSame('ts-1', $project->fresh()->taskstream_project_id);
|
|
}
|
|
|
|
#[Test]
|
|
public function a_project_with_a_board_keeps_it(): void
|
|
{
|
|
$this->fakeTaskStream();
|
|
$this->withHeaders($this->apiHeadersFor($this->editor()));
|
|
$project = Project::factory()->create(['taskstream_project_id' => 'ts-0', 'taskstream_key' => 'CAT']);
|
|
|
|
$this->patchJson("/api/v1/projects/{$project->id}", ['taskstream' => true, 'taskstream_key' => 'OTRA'])
|
|
->assertOk()
|
|
->assertJsonPath('data.taskstream_key', 'CAT');
|
|
|
|
$this->assertSame(0, $this->sentToTaskStream());
|
|
}
|
|
|
|
#[Test]
|
|
public function an_update_without_the_flag_ignores_the_key(): void
|
|
{
|
|
$this->fakeTaskStream();
|
|
$this->withHeaders($this->apiHeadersFor($this->editor()));
|
|
$project = Project::factory()->create();
|
|
|
|
// What a client sends back after reading a project.
|
|
$this->patchJson("/api/v1/projects/{$project->id}", ['taskstream_key' => 'OTRA', 'needs_help' => true])
|
|
->assertOk()
|
|
->assertJsonPath('data.taskstream_key', null);
|
|
|
|
$this->assertSame(0, $this->sentToTaskStream());
|
|
}
|
|
|
|
#[Test]
|
|
public function a_refused_board_leaves_the_update_unsaved(): void
|
|
{
|
|
$this->fakeTaskStream(409, ['statusCode' => 409, 'message' => 'Project key WEB is already in use']);
|
|
$this->withHeaders($this->apiHeadersFor($this->editor()));
|
|
$project = Project::factory()->create(['needs_help' => false]);
|
|
|
|
$this->patchJson("/api/v1/projects/{$project->id}", ['taskstream' => true, 'taskstream_key' => 'WEB', 'needs_help' => true])
|
|
->assertUnprocessable()
|
|
->assertJsonValidationErrors('taskstream_key');
|
|
|
|
$this->assertFalse($project->fresh()->needs_help);
|
|
}
|
|
|
|
#[Test]
|
|
public function keys_are_made_from_the_slug_in_taskstreams_style(): void
|
|
{
|
|
$action = app(OpenInTaskStream::class);
|
|
|
|
$this->assertSame('CATALOGO', $action->keyFrom('catalogo'));
|
|
$this->assertSame('TRANSPAREN', $action->keyFrom('transparencia'));
|
|
$this->assertSame('MWN', $action->keyFrom('mi-web-nueva'));
|
|
$this->assertSame('ANO', $action->keyFrom('año-nuevo-ok'));
|
|
$this->assertSame('WEB', $action->keyFrom('2026web'));
|
|
$this->assertNull($action->keyFrom('2026-web'));
|
|
$this->assertNull($action->keyFrom('x'));
|
|
}
|
|
}
|