create(['role' => UserRole::CourseDesigner]); Sanctum::actingAs($designer); $typeId = $this->postJson('/api/v1/taxonomy-types', [ 'key' => 'skill', 'name' => 'مهارت', ])->assertCreated()->json('data.id'); $parentId = $this->postJson('/api/v1/taxonomy-nodes', [ 'taxonomyTypeId' => $typeId, 'name' => 'ارتباطات', 'code' => 'COMMUNICATION', ])->assertCreated()->json('data.id'); $this->postJson('/api/v1/taxonomy-nodes', [ 'taxonomyTypeId' => $typeId, 'parentId' => $parentId, 'name' => 'بازخورد مؤثر', 'code' => 'GIVING_FEEDBACK', ])->assertCreated()->assertJsonPath('data.parentId', $parentId); } public function test_circular_hierarchy_is_rejected(): void { [$designer, $type] = $this->designerAndType(); $root = $this->node($designer->organization_id, $type->getKey(), 'Leadership'); $child = $this->node($designer->organization_id, $type->getKey(), 'Coaching', $root->getKey()); Sanctum::actingAs($designer); $this->patchJson('/api/v1/taxonomy-nodes/'.$root->getKey(), ['parentId' => $child->getKey()]) ->assertUnprocessable() ->assertJsonValidationErrors('parentId'); } public function test_taxonomy_listing_is_tenant_isolated_even_with_client_organization_input(): void { [$designer, $type] = $this->designerAndType(); $ownNode = $this->node($designer->organization_id, $type->getKey(), 'Own skill'); $otherOrganization = Organization::factory()->create(); $otherType = TaxonomyType::query()->create([ 'organization_id' => $otherOrganization->getKey(), 'key' => 'skill', 'name' => 'Skill', 'status' => TaxonomyStatus::Active, ]); $otherNode = $this->node($otherOrganization->getKey(), $otherType->getKey(), 'Private skill'); Sanctum::actingAs($designer); $response = $this->getJson('/api/v1/taxonomy-nodes?organization_id='.$otherOrganization->getKey())->assertOk(); $response->assertJsonFragment(['id' => $ownNode->getKey()]); $response->assertJsonMissing(['id' => $otherNode->getKey()]); } public function test_manager_may_read_but_cannot_manage_taxonomy(): void { $manager = User::factory()->create(['role' => UserRole::Manager]); Sanctum::actingAs($manager); $this->getJson('/api/v1/taxonomy-types')->assertOk(); $this->postJson('/api/v1/taxonomy-types', ['key' => 'skill', 'name' => 'Skill'])->assertForbidden(); } public function test_archived_node_remains_available_for_history(): void { [$designer, $type] = $this->designerAndType(); $node = $this->node($designer->organization_id, $type->getKey(), 'Historical skill'); Sanctum::actingAs($designer); $this->patchJson('/api/v1/taxonomy-nodes/'.$node->getKey(), ['status' => 'archived']) ->assertOk() ->assertJsonPath('data.status', 'archived'); $this->getJson('/api/v1/taxonomy-nodes?status=archived') ->assertOk() ->assertJsonFragment(['id' => $node->getKey(), 'status' => 'archived']); } private function designerAndType(): array { $designer = User::factory()->create(['role' => UserRole::CourseDesigner]); $type = TaxonomyType::query()->create([ 'organization_id' => $designer->organization_id, 'key' => 'skill', 'name' => 'Skill', 'status' => TaxonomyStatus::Active, ]); return [$designer, $type]; } private function node(string $organizationId, string $typeId, string $name, ?string $parentId = null): TaxonomyNode { return TaxonomyNode::query()->create([ 'organization_id' => $organizationId, 'taxonomy_type_id' => $typeId, 'parent_id' => $parentId, 'name' => $name, 'status' => TaxonomyStatus::Active, ]); } }