From d30dd971d71e21660e94d518faeae20867c40fd0 Mon Sep 17 00:00:00 2001 From: Josep Piguillem Date: Wed, 7 Oct 2026 21:12:36 +0200 Subject: [PATCH] Open a TaskStream board when a project is created through the API `taskstream: true` on POST /v1/projects also creates the project in TaskStream, public, as the caller (their Pireon token is passed on), and the answer carries `taskstream_key` and `taskstream_url`. The key is `taskstream_key` or one made from the default-language slug. The board is opened before the project is saved: if TaskStream refuses it, nothing is created here and the reason comes back as a validation error (502 when TaskStream does not answer). Machine tokens need the server's own TASKSTREAM_TOKEN. Updates ignore the flag, and duplicates do not inherit the link. Co-Authored-By: Claude Opus 5.5 --- app/Actions/Projects/OpenInTaskStream.php | 111 ++++++++ .../Controllers/Api/V1/ProjectController.php | 33 ++- app/Http/Requests/Api/V1/ProjectRequest.php | 8 + app/Http/Resources/ProjectResource.php | 5 + app/Providers/AppServiceProvider.php | 3 + app/Services/TaskStream/TaskStreamClient.php | 88 +++++++ .../TaskStream/TaskStreamException.php | 41 +++ app/Support/Duplicator.php | 4 +- config/services.php | 19 ++ ..._07_090000_link_projects_to_taskstream.php | 29 +++ lang/ca/rules.php | 8 + lang/en/rules.php | 8 + lang/es/rules.php | 8 + public/openapi.yaml | 22 ++ tests/Feature/Api/TaskStreamProjectTest.php | 244 ++++++++++++++++++ 15 files changed, 621 insertions(+), 10 deletions(-) create mode 100644 app/Actions/Projects/OpenInTaskStream.php create mode 100644 app/Services/TaskStream/TaskStreamClient.php create mode 100644 app/Services/TaskStream/TaskStreamException.php create mode 100644 database/migrations/2026_10_07_090000_link_projects_to_taskstream.php create mode 100644 tests/Feature/Api/TaskStreamProjectTest.php diff --git a/app/Actions/Projects/OpenInTaskStream.php b/app/Actions/Projects/OpenInTaskStream.php new file mode 100644 index 0000000..c8eb396 --- /dev/null +++ b/app/Actions/Projects/OpenInTaskStream.php @@ -0,0 +1,111 @@ +keyFrom((string) $project->getTranslation('slug', $locale, false)); + + if ($key === null) { + throw ValidationException::withMessages(['taskstream_key' => __('rules.taskstream.key_needed')]); + } + + try { + $created = $this->client->createProject( + token: $this->token($user, $bearer), + key: $key, + name: Str::limit((string) $project->getTranslation('title', $locale, false), 80, '…'), + description: $project->getTranslation('tagline', $locale, false) ?: null, + public: true, + ); + } catch (TaskStreamException $e) { + throw $this->explain($e); + } + + $project->forceFill([ + 'taskstream_project_id' => $created['id'], + 'taskstream_key' => $created['key'], + ]); + } + + /** + * A Pireon token is passed on, so the board belongs to whoever asked. A + * machine token is this application's alone and means nothing there. + */ + protected function token(User $user, ?string $bearer): string + { + $token = $user->currentAccessToken() === null ? $bearer : $this->client->serviceToken(); + + if (blank($token)) { + throw ValidationException::withMessages(['taskstream' => __('rules.taskstream.machine')]); + } + + return $token; + } + + /** + * A key in TaskStream's style: one word gives its first ten characters + * ("catalogo" → CATALOGO), several give their initials ("mi-web-nueva" + * → MWN). Null when nothing usable is left. + */ + public function keyFrom(string $slug): ?string + { + $words = array_values(array_filter(preg_split('/[^A-Z0-9]+/', Str::upper(Str::ascii($slug))) ?: [])); + + $key = count($words) === 1 + ? $words[0] + : implode('', array_map(fn (string $word) => $word[0], $words)); + + // It must start with a letter. + $key = substr((string) preg_replace('/^[0-9]+/', '', $key), 0, 10); + + return preg_match(self::KEY_PATTERN, $key) === 1 ? $key : null; + } + + protected function explain(TaskStreamException $e): ValidationException|HttpException + { + if ($e->isRefusal()) { + return ValidationException::withMessages([ + 'taskstream_key' => __('rules.taskstream.refused', ['reason' => $e->getMessage()]), + ]); + } + + if ($e->isUnauthorised()) { + return ValidationException::withMessages(['taskstream' => __('rules.taskstream.unauthorised')]); + } + + return new HttpException(502, __('rules.taskstream.unavailable'), $e); + } +} diff --git a/app/Http/Controllers/Api/V1/ProjectController.php b/app/Http/Controllers/Api/V1/ProjectController.php index 94225b1..365551f 100644 --- a/app/Http/Controllers/Api/V1/ProjectController.php +++ b/app/Http/Controllers/Api/V1/ProjectController.php @@ -4,6 +4,7 @@ declare(strict_types=1); namespace App\Http\Controllers\Api\V1; +use App\Actions\Projects\OpenInTaskStream; use App\Http\Controllers\Controller; use App\Http\Requests\Api\V1\ProjectRequest; use App\Http\Resources\ProjectResource; @@ -45,11 +46,21 @@ class ProjectController extends Controller return ProjectResource::collection($projects); } - public function store(ProjectRequest $request): JsonResponse + /** + * With `taskstream: true`, the project's board is opened in TaskStream + * first; if that fails, nothing is created here either. + */ + public function store(ProjectRequest $request, OpenInTaskStream $openInTaskStream): JsonResponse { $this->authorize('create', Project::class); - $project = $this->save(new Project, $request); + $project = $this->fill(new Project, $request); + + if ($request->boolean('taskstream')) { + $openInTaskStream->handle($project, $request->user(), $request->bearerToken(), $request->validated('taskstream_key')); + } + + $project = $this->persist($project, $request); return ProjectResource::make($project)->response()->setStatusCode(201); } @@ -65,7 +76,7 @@ class ProjectController extends Controller { $this->authorize('update', $project); - return ProjectResource::make($this->save($project, $request)); + return ProjectResource::make($this->persist($this->fill($project, $request), $request)); } public function destroy(Project $project): JsonResponse @@ -86,20 +97,24 @@ class ProjectController extends Controller return ProjectResource::make($project->load(self::WITH)); } + protected function fill(Project $project, ProjectRequest $request): Project + { + return $project->fill(TranslatablePayload::merge( + $project, + collect($request->validated())->except(['technology_ids', 'service_ids', 'members', 'taskstream', 'taskstream_key'])->all(), + $project->translatable, + )); + } + /** * The record and its relations in one transaction: a project that saved * but lost its team halfway would be worse than one that did not save. */ - protected function save(Project $project, ProjectRequest $request): Project + protected function persist(Project $project, ProjectRequest $request): Project { $data = $request->validated(); DB::transaction(function () use ($project, $data): void { - $project->fill(TranslatablePayload::merge( - $project, - collect($data)->except(['technology_ids', 'service_ids', 'members'])->all(), - $project->translatable, - )); $project->save(); if (array_key_exists('technology_ids', $data)) { diff --git a/app/Http/Requests/Api/V1/ProjectRequest.php b/app/Http/Requests/Api/V1/ProjectRequest.php index 7ab01ba..ff53595 100644 --- a/app/Http/Requests/Api/V1/ProjectRequest.php +++ b/app/Http/Requests/Api/V1/ProjectRequest.php @@ -19,6 +19,9 @@ use Illuminate\Validation\Validator; * * Relations travel as ids and are replaced whole when sent: `technology_ids`, * `service_ids`, and `members` with each one's role on the project. + * + * `taskstream` (on creation only) opens a public board in TaskStream for the + * project, keyed `taskstream_key` or one derived from the slug. */ class ProjectRequest extends FormRequest { @@ -74,6 +77,11 @@ class ProjectRequest extends FormRequest 'members' => ['sometimes', 'array'], 'members.*.id' => ['required', 'integer', 'distinct', Rule::exists('members', 'id')->whereNull('deleted_at')], 'members.*.role' => ['nullable', 'string', 'max:120'], + + // Only when creating; a client PATCHing back what it read sends + // the key it got, which is ignored rather than refused. + 'taskstream' => [$creating ? 'sometimes' : 'exclude', 'boolean'], + 'taskstream_key' => [$creating ? 'sometimes' : 'exclude', 'nullable', 'string', 'regex:/^[A-Za-z][A-Za-z0-9]{1,9}$/'], ]; } diff --git a/app/Http/Resources/ProjectResource.php b/app/Http/Resources/ProjectResource.php index 9c59b60..cbd9cda 100644 --- a/app/Http/Resources/ProjectResource.php +++ b/app/Http/Resources/ProjectResource.php @@ -5,6 +5,7 @@ declare(strict_types=1); namespace App\Http\Resources; use App\Models\Project; +use App\Services\TaskStream\TaskStreamClient; use App\Support\ImagePayload; use App\Support\TranslatablePayload; use Illuminate\Http\Request; @@ -33,6 +34,10 @@ class ProjectResource extends JsonResource 'is_featured' => (bool) $this->is_featured, 'needs_help' => (bool) $this->needs_help, 'demo_url' => $this->demo_url, + 'taskstream_key' => $this->taskstream_key, + 'taskstream_url' => $this->taskstream_key !== null + ? app(TaskStreamClient::class)->projectUrl($this->taskstream_key) + : null, // The stored path, which is what a client PATCHes back; what was // derived from it travels alongside, as on a page. 'cover_image' => $this->cover_image, diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index a033e9f..493e5e5 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -13,6 +13,7 @@ use App\Models\User; use App\Services\Git\GitManager; use App\Services\Pireon\PireonClient; use App\Services\Pireon\TokenStore; +use App\Services\TaskStream\TaskStreamClient; use App\Support\Audit\Auditor; use App\Support\Webhooks\Webhooks; use Illuminate\Auth\Events\Login; @@ -43,6 +44,8 @@ class AppServiceProvider extends ServiceProvider // Resolves git hosting drivers; see config/git.php. $this->app->singleton(GitManager::class); + + $this->app->singleton(TaskStreamClient::class, fn () => TaskStreamClient::fromConfig(config('services.taskstream'))); } /** diff --git a/app/Services/TaskStream/TaskStreamClient.php b/app/Services/TaskStream/TaskStreamClient.php new file mode 100644 index 0000000..817b671 --- /dev/null +++ b/app/Services/TaskStream/TaskStreamClient.php @@ -0,0 +1,88 @@ + $config + */ + public static function fromConfig(array $config): self + { + return new self( + apiUrl: rtrim((string) ($config['api_url'] ?? ''), '/'), + webUrl: rtrim((string) ($config['web_url'] ?? ''), '/'), + serviceToken: filled($config['token'] ?? null) ? $config['token'] : null, + timeout: (int) ($config['timeout'] ?? 10), + ); + } + + /** + * The token for requests that do not carry a Pireon token of their own. + */ + public function serviceToken(): ?string + { + return $this->serviceToken; + } + + /** + * @return array{id: string, key: string} + * + * @throws TaskStreamException + */ + public function createProject(string $token, string $key, string $name, ?string $description, bool $public): array + { + try { + $response = Http::withToken($token) + ->acceptJson() + ->timeout($this->timeout) + ->post("{$this->apiUrl}/api/projects", array_filter([ + 'key' => $key, + 'name' => $name, + 'description' => $description, + 'isPublic' => $public, + ], fn ($value) => $value !== null)); + } catch (ConnectionException $e) { + throw TaskStreamException::unreachable($this->apiUrl, $e); + } + + if ($response->failed()) { + // Nest answers `message` as a string, or as a list from validation. + $message = $response->json('message'); + + throw new TaskStreamException( + is_array($message) ? implode(' ', $message) : (string) ($message ?: $response->reason()), + status: $response->status(), + ); + } + + return ['id' => (string) $response->json('id'), 'key' => (string) $response->json('key')]; + } + + /** + * Where people open a project's board. + */ + public function projectUrl(string $key): string + { + return "{$this->webUrl}/p/{$key}"; + } +} diff --git a/app/Services/TaskStream/TaskStreamException.php b/app/Services/TaskStream/TaskStreamException.php new file mode 100644 index 0000000..170c306 --- /dev/null +++ b/app/Services/TaskStream/TaskStreamException.php @@ -0,0 +1,41 @@ +status, [400, 409, 422], true); + } + + /** + * The token was not good enough there: expired, or without access. + */ + public function isUnauthorised(): bool + { + return in_array($this->status, [401, 403], true); + } + + public static function unreachable(string $url, ?Throwable $previous = null): self + { + return new self("TaskStream could not be reached at [{$url}].", previous: $previous); + } +} diff --git a/app/Support/Duplicator.php b/app/Support/Duplicator.php index 67889ac..ee5d84a 100644 --- a/app/Support/Duplicator.php +++ b/app/Support/Duplicator.php @@ -32,10 +32,12 @@ final class Duplicator ]; /** - * Never copied: where it stands rather than what it says. + * Never copied: where it stands rather than what it says — and the + * TaskStream board, which belongs to the original alone. */ protected const RESET = [ 'published_at', 'unpublish_at', 'review_requested_at', 'review_requested_by', 'reviewer_id', + 'taskstream_project_id', 'taskstream_key', ]; public static function supports(Model $record): bool diff --git a/config/services.php b/config/services.php index ec226a4..27f429a 100644 --- a/config/services.php +++ b/config/services.php @@ -35,4 +35,23 @@ return [ ], ], + /* + | TaskStream, the collective's issue tracker. Creating a project here can + | open its board there too (`taskstream: true`); it is made public. + */ + 'taskstream' => [ + 'api_url' => env('TASKSTREAM_API_URL', 'https://api.taskstream.pireon.dev'), + 'web_url' => env('TASKSTREAM_WEB_URL', 'https://taskstream.pireon.dev'), + + /* + | TaskStream only understands Pireon tokens. A person signed in with + | one has it forwarded, so the board is theirs; a machine token means + | nothing there, so its requests use this one instead — a Pireon + | personal access token. Without it, they cannot ask for a board. + */ + 'token' => env('TASKSTREAM_TOKEN'), + + 'timeout' => (int) env('TASKSTREAM_HTTP_TIMEOUT', 10), + ], + ]; diff --git a/database/migrations/2026_10_07_090000_link_projects_to_taskstream.php b/database/migrations/2026_10_07_090000_link_projects_to_taskstream.php new file mode 100644 index 0000000..0fb6451 --- /dev/null +++ b/database/migrations/2026_10_07_090000_link_projects_to_taskstream.php @@ -0,0 +1,29 @@ +string('taskstream_project_id', 64)->nullable()->after('demo_url'); + $table->string('taskstream_key', 10)->nullable()->after('taskstream_project_id'); + }); + } + + public function down(): void + { + Schema::table('projects', function (Blueprint $table) { + $table->dropColumn(['taskstream_project_id', 'taskstream_key']); + }); + } +}; diff --git a/lang/ca/rules.php b/lang/ca/rules.php index 86796e4..f34b49d 100644 --- a/lang/ca/rules.php +++ b/lang/ca/rules.php @@ -48,6 +48,14 @@ return [ 'event' => '«:event» no és un esdeveniment al qual subscriure\'s.', ], + 'taskstream' => [ + 'machine' => 'TaskStream només entén tokens de Pireon: entra amb un, o configura TASKSTREAM_TOKEN per als tokens de màquina.', + 'key_needed' => 'No es pot treure una clau de l\'slug: indica\'n una a «taskstream_key» (de 2 a 10 lletres o xifres, començant per lletra).', + 'refused' => 'TaskStream no ha creat el projecte: :reason', + 'unauthorised' => 'TaskStream ha rebutjat el token: potser ha caducat o no hi té accés.', + 'unavailable' => 'TaskStream no respon ara mateix; no s\'ha creat res, torna-ho a provar d\'aquí a una estona.', + ], + 'tokens' => [ 'ability' => '«:ability» no és un permís que tinguis, així que un token teu no el pot tenir.', 'machine' => 'Els tokens els gestiona una persona, no un altre token.', diff --git a/lang/en/rules.php b/lang/en/rules.php index efd9ae6..bcd8206 100644 --- a/lang/en/rules.php +++ b/lang/en/rules.php @@ -48,6 +48,14 @@ return [ 'event' => '":event" is not an event that can be subscribed to.', ], + 'taskstream' => [ + 'machine' => 'TaskStream only understands Pireon tokens: sign in with one, or set TASKSTREAM_TOKEN for machine tokens.', + 'key_needed' => 'No key can be made from the slug: give one in "taskstream_key" (2 to 10 letters or digits, starting with a letter).', + 'refused' => 'TaskStream did not create the project: :reason', + 'unauthorised' => 'TaskStream rejected the token: it may have expired or lack access.', + 'unavailable' => 'TaskStream is not answering right now; nothing was created, try again in a while.', + ], + 'tokens' => [ 'ability' => '":ability" is not a permission you hold, so a token of yours cannot hold it.', 'machine' => 'Tokens are managed by a person, not by another token.', diff --git a/lang/es/rules.php b/lang/es/rules.php index 24e146e..18983b0 100644 --- a/lang/es/rules.php +++ b/lang/es/rules.php @@ -48,6 +48,14 @@ return [ 'event' => '«:event» no es un evento al que suscribirse.', ], + 'taskstream' => [ + 'machine' => 'TaskStream solo entiende tokens de Pireon: entra con uno, o configura TASKSTREAM_TOKEN para los tokens de máquina.', + 'key_needed' => 'No se puede sacar una clave del slug: indica una en «taskstream_key» (de 2 a 10 letras o cifras, empezando por letra).', + 'refused' => 'TaskStream no ha creado el proyecto: :reason', + 'unauthorised' => 'TaskStream ha rechazado el token: puede que haya caducado o que no tenga acceso.', + 'unavailable' => 'TaskStream no responde ahora mismo; no se ha creado nada, prueba otra vez en un rato.', + ], + 'tokens' => [ 'ability' => '«:ability» no es un permiso que tengas, así que un token tuyo no puede tenerlo.', 'machine' => 'Los tokens los gestiona una persona, no otro token.', diff --git a/public/openapi.yaml b/public/openapi.yaml index cf58a91..45a43c4 100644 --- a/public/openapi.yaml +++ b/public/openapi.yaml @@ -1566,6 +1566,16 @@ paths: `technology_ids`, `service_ids`, and `members` with each one's role. `published_at` and `unpublish_at` require `projects.publish`. For a cover, upload it with `POST /v1/media` and `for: projects`. + + With `taskstream: true` the project also gets a **public** board in + TaskStream, the collective's issue tracker, and the answer carries its + link in `taskstream_url`. Its key is `taskstream_key`, or one made from + the slug in the default language (one word: its first ten characters; + several: their initials). The board is opened first: if TaskStream + refuses it (a key in use, say), nothing is created here either and the + reason comes back as a validation error. It is opened as the caller, + whose Pireon token is passed on; a machine token cannot ask for one + unless the server has a `TASKSTREAM_TOKEN` of its own. requestBody: required: true content: @@ -1582,6 +1592,8 @@ paths: data: { $ref: "#/components/schemas/Project" } "403": { $ref: "#/components/responses/Forbidden" } "422": { $ref: "#/components/responses/ValidationError" } + "502": + description: TaskStream did not answer; nothing was created. /v1/projects/{project}: parameters: @@ -5503,6 +5515,8 @@ components: is_featured: { type: boolean } needs_help: { type: boolean } demo_url: { type: string, nullable: true, format: uri } + taskstream_key: { type: string, nullable: true, description: "The project's key in TaskStream, when it has a board there." } + taskstream_url: { type: string, nullable: true, format: uri, readOnly: true, description: Where its TaskStream board opens. } cover_image: { type: string, nullable: true, description: The stored path — what a client PATCHes back. } cover_image_media: allOf: [{ $ref: "#/components/schemas/Image" }] @@ -5549,6 +5563,14 @@ components: is_featured: { type: boolean } needs_help: { type: boolean } demo_url: { type: string, format: uri, nullable: true } + taskstream: + type: boolean + description: On creation only. Also open a public board for it in TaskStream; see `POST /v1/projects`. + taskstream_key: + type: string + nullable: true + pattern: "^[A-Za-z][A-Za-z0-9]{1,9}$" + description: On creation only, with `taskstream`. The key in TaskStream; made from the slug when left out. cover_image: { type: string, nullable: true, description: A path from `POST /v1/media` with `for=projects`. } started_at: { type: string, format: date, nullable: true } ended_at: { type: string, format: date, nullable: true } diff --git a/tests/Feature/Api/TaskStreamProjectTest.php b/tests/Feature/Api/TaskStreamProjectTest.php new file mode 100644 index 0000000..04862bd --- /dev/null +++ b/tests/Feature/Api/TaskStreamProjectTest.php @@ -0,0 +1,244 @@ +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')); + } +}