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_ignores_the_flag(): void { $this->fakeTaskStream(); $this->withHeaders($this->apiHeadersFor($this->editor())); $project = Project::factory()->create(); $this->patchJson("/api/v1/projects/{$project->id}", [ 'taskstream' => true, 'taskstream_key' => 'OTRA', 'needs_help' => true, ]) ->assertOk() ->assertJsonPath('data.taskstream_key', null); $this->assertSame(0, $this->sentToTaskStream()); } #[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')); } }