From 93ec46e9dc9f2d4334fd386fdefaa8505f56d08e Mon Sep 17 00:00:00 2001 From: Nevin Morgan Date: Mon, 28 Oct 2024 11:14:11 -0400 Subject: [PATCH 01/23] feat: add Concept model unit test --- tests/Unit/.gitkeep | 0 tests/Unit/Models/ConceptTest.php | 9 +++++++++ 2 files changed, 9 insertions(+) delete mode 100644 tests/Unit/.gitkeep create mode 100644 tests/Unit/Models/ConceptTest.php diff --git a/tests/Unit/.gitkeep b/tests/Unit/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/tests/Unit/Models/ConceptTest.php b/tests/Unit/Models/ConceptTest.php new file mode 100644 index 0000000..dfc25c3 --- /dev/null +++ b/tests/Unit/Models/ConceptTest.php @@ -0,0 +1,9 @@ + Date: Mon, 28 Oct 2024 11:16:11 -0400 Subject: [PATCH 02/23] feat: Add unit tests for Concept model relationships --- tests/Unit/Models/ConceptTest.php | 38 ++++++++++++++++++++++++++++++- 1 file changed, 37 insertions(+), 1 deletion(-) diff --git a/tests/Unit/Models/ConceptTest.php b/tests/Unit/Models/ConceptTest.php index dfc25c3..e691099 100644 --- a/tests/Unit/Models/ConceptTest.php +++ b/tests/Unit/Models/ConceptTest.php @@ -2,8 +2,44 @@ namespace Tests\Unit\Models; -use PHPUnit\Framework\TestCase; +use Tests\TestCase; +use App\Models\Concept; +use Illuminate\Foundation\Testing\RefreshDatabase; class ConceptTest extends TestCase { + use RefreshDatabase; + + public function test_concept_can_have_broader_relationships() + { + $concept = Concept::factory()->create(); + $broaderConcept = Concept::factory()->create(); + + $concept->addBroader($broaderConcept->id); + + $this->assertTrue($concept->broader->contains($broaderConcept)); + $this->assertTrue($broaderConcept->narrower->contains($concept)); + } + + public function test_concept_can_have_narrower_relationships() + { + $concept = Concept::factory()->create(); + $narrowerConcept = Concept::factory()->create(); + + $concept->addNarrower($narrowerConcept->id); + + $this->assertTrue($concept->narrower->contains($narrowerConcept)); + $this->assertTrue($narrowerConcept->broader->contains($concept)); + } + + public function test_concept_can_have_related_relationships() + { + $concept = Concept::factory()->create(); + $relatedConcept = Concept::factory()->create(); + + $concept->addRelated($relatedConcept->id); + + $this->assertTrue($concept->related->contains($relatedConcept)); + $this->assertTrue($relatedConcept->related->contains($concept)); + } } From aa8e2f6ab5103780ed7e045134991e1f60e8d588 Mon Sep 17 00:00:00 2001 From: Nevin Morgan Date: Mon, 28 Oct 2024 12:13:29 -0400 Subject: [PATCH 03/23] feat: fix concept relationships --- app/Models/Concept.php | 25 +++++++++---------------- tests/Unit/Models/ConceptTest.php | 4 ++-- 2 files changed, 11 insertions(+), 18 deletions(-) diff --git a/app/Models/Concept.php b/app/Models/Concept.php index 1b6457b..cda2871 100644 --- a/app/Models/Concept.php +++ b/app/Models/Concept.php @@ -52,13 +52,6 @@ public function broader() ->wherePivot("relationship_type", "broader"); } - // This version requires insert of the two relationships (both ways, narrower and broader) - //public function narrower() { - //return $this->belongsToMany("App\Models\Concept", "concept_relationships", "concept_id", "related_concept_id") - //->withPivot("relationship_type") - //->wherePivot("relationship_type", "narrower"); - //} - // This version requires insert only one relationships, the other relationship is inferred from concept_id or related_concept_id // coming from other direction public function narrower() @@ -70,7 +63,7 @@ public function narrower() public function related() { - return $this->belongsToMany("App\Models\Concept", "concept_relationships", "related_concept_id", "concept_id") + return $this->belongsToMany("App\Models\Concept", "concept_relationships", "concept_id", "related_concept_id") ->withPivot("relationship_type") ->wherePivot("relationship_type", "related"); } @@ -82,18 +75,18 @@ public function addBroader($conceptId) public function addRelated($conceptId) { - return $this->related()->attach([$conceptId => ["relationship_type" => "related"]]); - } + // Add both directions of the relationship + $this->belongsToMany("App\Models\Concept", "concept_relationships", "concept_id", "related_concept_id") + ->attach([$conceptId => ["relationship_type" => "related"]]); + + $this->belongsToMany("App\Models\Concept", "concept_relationships", "related_concept_id", "concept_id") + ->attach([$conceptId => ["relationship_type" => "related"]]); - // This version requires insert of the two relationships (both ways, narrower and broader) - // public function addNarrower($concept) { - // // TODO : check it works for new narrower enum - // return $this->narrower()->attach([$concept => ["relationship_type" => "narrower"]]); - // } + return true; + } public function addNarrower($conceptId) { - // TODO : check it works for new narrower enum return $this->narrower()->attach([$conceptId => ["relationship_type" => "broader"]]); } diff --git a/tests/Unit/Models/ConceptTest.php b/tests/Unit/Models/ConceptTest.php index e691099..54ab096 100644 --- a/tests/Unit/Models/ConceptTest.php +++ b/tests/Unit/Models/ConceptTest.php @@ -4,11 +4,11 @@ use Tests\TestCase; use App\Models\Concept; -use Illuminate\Foundation\Testing\RefreshDatabase; +use Illuminate\Foundation\Testing\DatabaseTransactions; class ConceptTest extends TestCase { - use RefreshDatabase; + use DatabaseTransactions; public function test_concept_can_have_broader_relationships() { From df564b0f1cdc259a80b6b9aa5dee010dad000fd1 Mon Sep 17 00:00:00 2001 From: Nevin Morgan Date: Mon, 28 Oct 2024 12:23:21 -0400 Subject: [PATCH 04/23] feat: add new alternate terms to concept --- tests/Feature/API/ConceptsTest.php | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/tests/Feature/API/ConceptsTest.php b/tests/Feature/API/ConceptsTest.php index dc10ed3..4045507 100644 --- a/tests/Feature/API/ConceptsTest.php +++ b/tests/Feature/API/ConceptsTest.php @@ -56,7 +56,7 @@ public function test_authorized_user_can_create_concept(): void 'alternate_terms' => [ 'term1', 'term2', - 'term3' + 'term3', ], ]); @@ -74,12 +74,12 @@ public function test_authorized_user_can_update_concept(): void $concept = Concept::factory()->create(); $conceptCategories = Vocabulary::where('type', 'concept_category')->get()->random(2)->toArray(); $response = $this->patchJson("/api/concepts/{$concept->id}", [ - 'conceptCategories' => $conceptCategories + 'conceptCategories' => $conceptCategories, ]); $updatedCategories = Concept::find($concept->id)->conceptCategories->toArray(); $keysToRemove = ["pivot"]; - $cleanedCategories = array_map(function($item) use ($keysToRemove) { + $cleanedCategories = array_map(function ($item) use ($keysToRemove) { return array_diff_key($item, array_flip($keysToRemove)); }, $updatedCategories); $this->assertEqualsCanonicalizing($conceptCategories, $cleanedCategories); @@ -99,7 +99,7 @@ public function test_authorized_user_can_update_concept_relationships(): void $relatedConcept = Concept::factory()->create(); $response = $this->putJson("/api/concepts/{$concept->id}/relate_concept", [ 'relation_type' => 'broader', - 'related_id' => $relatedConcept, + 'related_id' => $relatedConcept->id, ]); $response->assertStatus(200); From f93c79db5a35304e3b2c26f3876621779d36b897 Mon Sep 17 00:00:00 2001 From: Nevin Morgan Date: Mon, 28 Oct 2024 12:23:22 -0400 Subject: [PATCH 05/23] feat: Add tests for broader, narrower, and related concept relationships --- tests/Feature/API/ConceptsTest.php | 22 +++++++++++++++++++++- 1 file changed, 21 insertions(+), 1 deletion(-) diff --git a/tests/Feature/API/ConceptsTest.php b/tests/Feature/API/ConceptsTest.php index 4045507..20c906e 100644 --- a/tests/Feature/API/ConceptsTest.php +++ b/tests/Feature/API/ConceptsTest.php @@ -96,13 +96,33 @@ public function test_authorized_user_can_update_concept_relationships(): void Sanctum::actingAs($user); $concept = Concept::factory()->create(); + $broaderConcept = Concept::factory()->create(); + $narrowerConcept = Concept::factory()->create(); $relatedConcept = Concept::factory()->create(); + + // Test broader relationship $response = $this->putJson("/api/concepts/{$concept->id}/relate_concept", [ 'relation_type' => 'broader', - 'related_id' => $relatedConcept->id, + 'related_id' => $broaderConcept->id, + ]); + $response->assertStatus(200); + $this->assertTrue($concept->broader->contains($broaderConcept)); + + // Test narrower relationship + $response = $this->putJson("/api/concepts/{$concept->id}/relate_concept", [ + 'relation_type' => 'narrower', + 'related_id' => $narrowerConcept->id, ]); + $response->assertStatus(200); + $this->assertTrue($concept->narrower->contains($narrowerConcept)); + // Test related relationship + $response = $this->putJson("/api/concepts/{$concept->id}/relate_concept", [ + 'relation_type' => 'related', + 'related_id' => $relatedConcept->id, + ]); $response->assertStatus(200); + $this->assertTrue($concept->related->contains($relatedConcept)); } public function test_authorized_user_can_deprecate_concept(): void From 21d02753405d2fa73751f211428eecab438b1768 Mon Sep 17 00:00:00 2001 From: Nevin Morgan Date: Mon, 28 Oct 2024 12:45:15 -0400 Subject: [PATCH 06/23] fix: correct unauthorized user relationship updating to pass related_id instead of whole model --- tests/Feature/API/ConceptsTest.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/Feature/API/ConceptsTest.php b/tests/Feature/API/ConceptsTest.php index 20c906e..dcf4d03 100644 --- a/tests/Feature/API/ConceptsTest.php +++ b/tests/Feature/API/ConceptsTest.php @@ -197,7 +197,7 @@ public function test_unauthorized_user_cannot_update_concept_relationships(): vo $relatedConcept = Concept::factory()->create(); $response = $this->putJson("/api/concepts/{$concept->id}/relate_concept", [ 'relation_type' => 'broader', - 'related_id' => $relatedConcept, + 'related_id' => $relatedConcept->id, ]); $response->assertStatus(403); From d7cdd90d0d000fadcbbd97849b6409da384813c7 Mon Sep 17 00:00:00 2001 From: Nevin Morgan Date: Mon, 28 Oct 2024 12:46:10 -0400 Subject: [PATCH 07/23] chore: phpfmt updates --- app/Http/Controllers/API/ConceptController.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/Http/Controllers/API/ConceptController.php b/app/Http/Controllers/API/ConceptController.php index 2a0933b..fe60b21 100644 --- a/app/Http/Controllers/API/ConceptController.php +++ b/app/Http/Controllers/API/ConceptController.php @@ -174,7 +174,7 @@ public function relateConcepts(Request $request, Concept $concept) if ($request->user()->cannot('update', $concept)) { abort(403); } - + $relation_type = $request->input('relation_type'); $related_id = $request->input('related_id'); @@ -205,7 +205,7 @@ public function deprecate(Request $request, Concept $concept) if ($request->user()->cannot('update', $concept)) { abort(403); } - + $to = $request->input('to'); if ($to) { $replaceConcept = Concept::findOrFail($to); From ec2549c29692b5554d534c8c561797a16533b48b Mon Sep 17 00:00:00 2001 From: Nevin Morgan Date: Mon, 28 Oct 2024 12:46:37 -0400 Subject: [PATCH 08/23] fix: correct api route model binding --- routes/api.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/routes/api.php b/routes/api.php index a4caa45..6ab7e63 100644 --- a/routes/api.php +++ b/routes/api.php @@ -22,8 +22,8 @@ Route::get('concepts/reconcile/{id}', 'API\ConceptController@reconcile'); Route::get('concepts/reconcile', 'API\ConceptController@reconcile'); -Route::put('concepts/{id}/relate_concept', 'API\ConceptController@relateConcepts'); -Route::put('concepts/{id}/deprecate', 'API\ConceptController@deprecate'); +Route::put('concepts/{concept}/relate_concept', 'API\ConceptController@relateConcepts'); +Route::put('concepts/{concept}/deprecate', 'API\ConceptController@deprecate'); Route::apiResource('concepts', 'API\ConceptController'); Route::get('concepts_summary', function () { From c5a95734d7f9c83c34e38deadd8fc0109b675c81 Mon Sep 17 00:00:00 2001 From: Nevin Morgan Date: Mon, 28 Oct 2024 12:57:29 -0400 Subject: [PATCH 09/23] feat: add relateConcept method to ConceptService --- resources/js/api/ConceptService.js | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/resources/js/api/ConceptService.js b/resources/js/api/ConceptService.js index 94beea5..d880f26 100644 --- a/resources/js/api/ConceptService.js +++ b/resources/js/api/ConceptService.js @@ -56,4 +56,16 @@ export default { return [error, null]; } }, + + async relateConcept(conceptId, relationshipData) { + try { + const { data } = await apiClient.put(`/${conceptId}/relate_concept`, { + relation_type: relationshipData.type, + related_id: relationshipData.relatedId + }); + return [null, data]; + } catch (error) { + return [error, null]; + } + }, }; From fe54725f9ba52ebe703fa62c4b74231f39b5e33b Mon Sep 17 00:00:00 2001 From: Nevin Morgan Date: Mon, 28 Oct 2024 13:00:24 -0400 Subject: [PATCH 10/23] feat: Add relationship management functionality --- resources/js/components/Concept/Default.js | 3 +- resources/js/components/Concept/Default.vue | 99 +++++++++++++++++++ .../components/Concept/mixins/Relationship.js | 69 +++++++++++++ 3 files changed, 170 insertions(+), 1 deletion(-) create mode 100644 resources/js/components/Concept/mixins/Relationship.js diff --git a/resources/js/components/Concept/Default.js b/resources/js/components/Concept/Default.js index 4fbb04b..41afed7 100644 --- a/resources/js/components/Concept/Default.js +++ b/resources/js/components/Concept/Default.js @@ -7,9 +7,10 @@ import MixinDirty from './mixins/Dirty'; import MixinEditMode from './mixins/EditMode'; import MixinSource from './mixins/Source'; import MixinTerm from './mixins/Term'; +import MixinRelationship from './mixins/Relationship'; export default { - mixins: [MixinCategory, MixinDirty, MixinEditMode, MixinSource, MixinTerm], + mixins: [MixinCategory, MixinDirty, MixinEditMode, MixinSource, MixinTerm, MixinRelationship], components: { BModal, BButton, diff --git a/resources/js/components/Concept/Default.vue b/resources/js/components/Concept/Default.vue index c4cb515..927b5be 100644 --- a/resources/js/components/Concept/Default.vue +++ b/resources/js/components/Concept/Default.vue @@ -158,6 +158,89 @@ > + +
+

Relationships

+ +
+ + Add Relationship + +
+ + + +
+ + +
+ +
+ + + +
+ Searching... +
+ +
+
+ {{ concept.preferredTerm.text }} +
+
+
+
+ + +
+
Broader
+
    +
  • + {{ relation.preferredTerm.text }} +
  • +
+
+ +
+
Narrower
+
    +
  • + {{ relation.preferredTerm.text }} +
  • +
+
+ +
+
Related
+
    +
  • + {{ relation.preferredTerm.text }} +
  • +
+
+
@@ -197,4 +280,20 @@ header.sticky-top { top: 56px; /* offset for top navigation */ padding-top: 0.5rem; } + +.search-result { + cursor: pointer; + border: 1px solid #ddd; + margin-bottom: 4px; + border-radius: 4px; +} + +.search-result:hover { + background-color: #f8f9fa; +} + +.search-result.selected { + background-color: #e9ecef; + border-color: #007bff; +} diff --git a/resources/js/components/Concept/mixins/Relationship.js b/resources/js/components/Concept/mixins/Relationship.js new file mode 100644 index 0000000..2ef43bd --- /dev/null +++ b/resources/js/components/Concept/mixins/Relationship.js @@ -0,0 +1,69 @@ +import ConceptService from '../../../api/ConceptService'; + +export default { + data() { + return { + showRelationshipModal: false, + searchTerm: '', + searchResults: [], + selectedConcept: null, + relationshipType: null, + relationshipTypes: [ + { value: 'broader', text: 'Broader' }, + { value: 'narrower', text: 'Narrower' }, + { value: 'related', text: 'Related' } + ], + isSearching: false + }; + }, + + methods: { + showAddRelationship() { + this.showRelationshipModal = true; + this.searchTerm = ''; + this.searchResults = []; + this.selectedConcept = null; + this.relationshipType = null; + }, + + async searchConcepts() { + if (this.searchTerm.length < 2) return; + + this.isSearching = true; + try { + const response = await axios.get(`/api/concepts`, { + params: { + search: this.searchTerm + } + }); + this.searchResults = response.data.data.filter(c => c.id !== this.conceptId); + } catch (error) { + console.error('Search failed:', error); + } + this.isSearching = false; + }, + + selectConcept(concept) { + this.selectedConcept = concept; + }, + + async saveRelationship() { + if (!this.selectedConcept || !this.relationshipType) return; + + const relationshipData = { + type: this.relationshipType, + relatedId: this.selectedConcept.id + }; + + const [error, data] = await ConceptService.relateConcept(this.conceptId, relationshipData); + + if (error) { + console.error('Failed to create relationship:', error); + return; + } + + this.showRelationshipModal = false; + this.flashSuccessAlert(); + } + } +}; From 04941471c184b3f990a729a3bd3370871e4f6c8b Mon Sep 17 00:00:00 2001 From: Nevin Morgan Date: Mon, 28 Oct 2024 13:01:01 -0400 Subject: [PATCH 11/23] feat: Improve search functionality and data loading in Relationship mixin --- .../components/Concept/mixins/Relationship.js | 23 ++++++++++++++++--- 1 file changed, 20 insertions(+), 3 deletions(-) diff --git a/resources/js/components/Concept/mixins/Relationship.js b/resources/js/components/Concept/mixins/Relationship.js index 2ef43bd..1a9f62c 100644 --- a/resources/js/components/Concept/mixins/Relationship.js +++ b/resources/js/components/Concept/mixins/Relationship.js @@ -17,6 +17,16 @@ export default { }; }, + computed: { + relationships() { + return { + broader: this.concept?.broader || [], + narrower: this.concept?.narrower || [], + related: this.concept?.related || [] + }; + } + }, + methods: { showAddRelationship() { this.showRelationshipModal = true; @@ -27,18 +37,25 @@ export default { }, async searchConcepts() { - if (this.searchTerm.length < 2) return; + if (this.searchTerm.length < 2) { + this.searchResults = []; + return; + } this.isSearching = true; try { const response = await axios.get(`/api/concepts`, { params: { - search: this.searchTerm + term: this.searchTerm, + per_page: 10 } }); - this.searchResults = response.data.data.filter(c => c.id !== this.conceptId); + this.searchResults = response.data.data.filter(c => + c.id !== this.conceptId && !c.deprecated + ); } catch (error) { console.error('Search failed:', error); + this.searchResults = []; } this.isSearching = false; }, From 14bfbc271ecbe85784359bb3d2612e32a555ca1e Mon Sep 17 00:00:00 2001 From: Nevin Morgan Date: Mon, 28 Oct 2024 15:25:30 -0400 Subject: [PATCH 12/23] feat: Add searchConcepts method to ConceptService --- resources/js/api/ConceptService.js | 14 +++++++++++++ .../components/Concept/mixins/Relationship.js | 20 +++++++++---------- 2 files changed, 23 insertions(+), 11 deletions(-) diff --git a/resources/js/api/ConceptService.js b/resources/js/api/ConceptService.js index d880f26..709fed2 100644 --- a/resources/js/api/ConceptService.js +++ b/resources/js/api/ConceptService.js @@ -57,6 +57,20 @@ export default { } }, + async searchConcepts(searchTerm, perPage = 10) { + try { + const { data } = await apiClient.get('', { + params: { + term: searchTerm, + per_page: perPage + } + }); + return [null, data]; + } catch (error) { + return [error, null]; + } + }, + async relateConcept(conceptId, relationshipData) { try { const { data } = await apiClient.put(`/${conceptId}/relate_concept`, { diff --git a/resources/js/components/Concept/mixins/Relationship.js b/resources/js/components/Concept/mixins/Relationship.js index 1a9f62c..900c193 100644 --- a/resources/js/components/Concept/mixins/Relationship.js +++ b/resources/js/components/Concept/mixins/Relationship.js @@ -43,20 +43,18 @@ export default { } this.isSearching = true; - try { - const response = await axios.get(`/api/concepts`, { - params: { - term: this.searchTerm, - per_page: 10 - } - }); - this.searchResults = response.data.data.filter(c => - c.id !== this.conceptId && !c.deprecated - ); - } catch (error) { + const [error, response] = await ConceptService.searchConcepts(this.searchTerm); + + if (error) { console.error('Search failed:', error); this.searchResults = []; + } else { + // Filter out current concept and deprecated concepts + this.searchResults = response.data.filter(c => + c.id !== this.conceptId && !c.deprecated + ); } + this.isSearching = false; }, From 7b5f40ce1fc470c28c20b792f54c08f064daee9a Mon Sep 17 00:00:00 2001 From: Nevin Morgan Date: Mon, 28 Oct 2024 16:20:08 -0400 Subject: [PATCH 13/23] feat: Add search endpoint to ConceptController --- .../Controllers/API/ConceptController.php | 40 +++++++++++++++++++ routes/api.php | 1 + 2 files changed, 41 insertions(+) diff --git a/app/Http/Controllers/API/ConceptController.php b/app/Http/Controllers/API/ConceptController.php index fe60b21..27b917b 100644 --- a/app/Http/Controllers/API/ConceptController.php +++ b/app/Http/Controllers/API/ConceptController.php @@ -233,6 +233,46 @@ public function destroy(Concept $concept) return response('Deleted ' . $concept->id, 204); } + /** + * Search concepts by term. + * + * @param \Illuminate\Http\Request $request + * @return \Illuminate\Http\Resources\Json\AnonymousResourceCollection + */ + public function search(Request $request) + { + $request->validate([ + 'term' => 'required|string|min:2', + 'all_terms' => 'boolean', + 'category' => 'nullable|string', + 'per_page' => 'nullable|integer|min:1|max:100' + ]); + + $query = Concept::with(['terms', 'conceptCategories']) + ->select('concepts.*') + ->join('terms', 'concepts.id', '=', 'terms.concept_id') + ->leftJoin('concept_categories', 'concepts.id', '=', 'concept_categories.concept_id') + ->leftJoin('vocabulary', 'concept_categories.category_id', '=', 'vocabulary.id') + ->where('terms.text', 'ILIKE', '%' . $request->term . '%') + ->where('concepts.deprecated', false); + + if (!$request->boolean('all_terms', false)) { + $query->where('terms.preferred', true); + } + + if ($request->filled('category')) { + $query->where('vocabulary.value', 'ILIKE', $request->category); + } + + $query->distinct(); + + $perPage = $request->input('per_page', 15); + + return ConceptResource::collection( + $query->paginate($perPage) + ); + } + /** * Reconcile Concept for OpenRefine * diff --git a/routes/api.php b/routes/api.php index 6ab7e63..3a2682b 100644 --- a/routes/api.php +++ b/routes/api.php @@ -19,6 +19,7 @@ return $request->user(); }); +Route::get('concepts/search', 'API\ConceptController@search'); Route::get('concepts/reconcile/{id}', 'API\ConceptController@reconcile'); Route::get('concepts/reconcile', 'API\ConceptController@reconcile'); From 6ae79598dd1232ca40b79bc141bb9648acee085a Mon Sep 17 00:00:00 2001 From: Nevin Morgan Date: Mon, 28 Oct 2024 16:23:37 -0400 Subject: [PATCH 14/23] feat: add comprehensive concept search test --- tests/Feature/API/ConceptsTest.php | 42 ++++++++++++++++++++++++++++-- 1 file changed, 40 insertions(+), 2 deletions(-) diff --git a/tests/Feature/API/ConceptsTest.php b/tests/Feature/API/ConceptsTest.php index dcf4d03..db94424 100644 --- a/tests/Feature/API/ConceptsTest.php +++ b/tests/Feature/API/ConceptsTest.php @@ -4,6 +4,7 @@ use App\Models\Concept; use App\Models\Role; +use App\Models\Term; use App\Models\User; use App\Models\Vocabulary; use Illuminate\Foundation\Testing\DatabaseTransactions; @@ -23,8 +24,45 @@ public function test_any_can_list_concepts(): void public function test_any_can_search_concepts(): void { - $response = $this->getJson('/api/concepts?search=example'); - $response->assertStatus(200); + // Create test concepts with terms + $concept1 = Concept::factory()->create(['deprecated' => false]); + $concept2 = Concept::factory()->create(['deprecated' => false]); + $deprecatedConcept = Concept::factory()->create(['deprecated' => true]); + + // Create terms for the concepts + $term1 = Term::create([ + 'concept_id' => $concept1->id, + 'text' => 'test search term', + 'preferred' => true + ]); + + $term2 = Term::create([ + 'concept_id' => $concept2->id, + 'text' => 'another test term', + 'preferred' => false + ]); + + $term3 = Term::create([ + 'concept_id' => $deprecatedConcept->id, + 'text' => 'test deprecated term', + 'preferred' => true + ]); + + // Test basic search + $response = $this->getJson('/api/concepts/search?term=test'); + $response->assertStatus(200) + ->assertJsonCount(1, 'data') // Only preferred terms by default + ->assertJsonPath('data.0.id', $concept1->id); + + // Test search with all_terms=true + $response = $this->getJson('/api/concepts/search?term=test&all_terms=true'); + $response->assertStatus(200) + ->assertJsonCount(2, 'data') // Both preferred and non-preferred terms + ->assertJsonMissing(['id' => $deprecatedConcept->id]); // Deprecated concepts should not appear + + // Test validation + $response = $this->getJson('/api/concepts/search?term=a'); + $response->assertStatus(422); // Should fail validation for min:2 } public function test_any_can_get_concept(): void From fcf604f2b822c0f2c4edf368ac42fca0635a2d36 Mon Sep 17 00:00:00 2001 From: Nevin Morgan Date: Mon, 28 Oct 2024 16:53:01 -0400 Subject: [PATCH 15/23] feat: update search endpoint testing, change conceptservice to use new search endpoint --- .../Controllers/API/ConceptController.php | 6 +- resources/js/api/ConceptService.js | 8 +-- resources/js/components/Concept/Default.vue | 58 +++++++++++-------- tests/Feature/API/ConceptsTest.php | 22 +++---- 4 files changed, 51 insertions(+), 43 deletions(-) diff --git a/app/Http/Controllers/API/ConceptController.php b/app/Http/Controllers/API/ConceptController.php index 27b917b..590119b 100644 --- a/app/Http/Controllers/API/ConceptController.php +++ b/app/Http/Controllers/API/ConceptController.php @@ -20,7 +20,7 @@ class ConceptController extends Controller */ public function __construct() { - $this->middleware('auth:sanctum')->except(['index', 'show', 'reconcile']); + $this->middleware('auth:sanctum')->except(['index', 'show', 'reconcile', 'search']); $this->authorizeResource(Concept::class); } @@ -245,7 +245,7 @@ public function search(Request $request) 'term' => 'required|string|min:2', 'all_terms' => 'boolean', 'category' => 'nullable|string', - 'per_page' => 'nullable|integer|min:1|max:100' + 'per_page' => 'nullable|integer|min:1|max:100', ]); $query = Concept::with(['terms', 'conceptCategories']) @@ -267,7 +267,7 @@ public function search(Request $request) $query->distinct(); $perPage = $request->input('per_page', 15); - + return ConceptResource::collection( $query->paginate($perPage) ); diff --git a/resources/js/api/ConceptService.js b/resources/js/api/ConceptService.js index 709fed2..de5072e 100644 --- a/resources/js/api/ConceptService.js +++ b/resources/js/api/ConceptService.js @@ -59,11 +59,11 @@ export default { async searchConcepts(searchTerm, perPage = 10) { try { - const { data } = await apiClient.get('', { + const { data } = await apiClient.get('/search', { params: { term: searchTerm, - per_page: perPage - } + per_page: perPage, + }, }); return [null, data]; } catch (error) { @@ -75,7 +75,7 @@ export default { try { const { data } = await apiClient.put(`/${conceptId}/relate_concept`, { relation_type: relationshipData.type, - related_id: relationshipData.relatedId + related_id: relationshipData.relatedId, }); return [null, data]; } catch (error) { diff --git a/resources/js/components/Concept/Default.vue b/resources/js/components/Concept/Default.vue index 927b5be..bb3eb40 100644 --- a/resources/js/components/Concept/Default.vue +++ b/resources/js/components/Concept/Default.vue @@ -29,8 +29,15 @@ hide-footer >
-

You have unsaved changes. Are you sure you want to exit Edit Mode?

- Yes, exit +

+ You have unsaved changes. Are you sure you want to exit Edit Mode? +

+ Yes, exit No
@@ -64,7 +71,9 @@

{{ preferredTerm.text }}

+ > + {{ preferredTerm.text }} +

Alternate Terms @@ -129,9 +138,7 @@ v-bind:index="index" >

- + {{ cat.value }} Add Category @@ -161,13 +170,9 @@

Relationships

- +
- + Add Relationship
@@ -194,48 +199,51 @@ @input="searchConcepts" placeholder="Type to search..." > - +
Searching...
-
- {{ concept.preferredTerm.text }} + {{ concept.preferred_term.text }}
-
+
Broader
    -
  • +
  • {{ relation.preferredTerm.text }}
-
+
Narrower
    -
  • +
  • {{ relation.preferredTerm.text }}
-
+
Related
    -
  • +
  • {{ relation.preferredTerm.text }}
diff --git a/tests/Feature/API/ConceptsTest.php b/tests/Feature/API/ConceptsTest.php index db94424..797937c 100644 --- a/tests/Feature/API/ConceptsTest.php +++ b/tests/Feature/API/ConceptsTest.php @@ -28,34 +28,34 @@ public function test_any_can_search_concepts(): void $concept1 = Concept::factory()->create(['deprecated' => false]); $concept2 = Concept::factory()->create(['deprecated' => false]); $deprecatedConcept = Concept::factory()->create(['deprecated' => true]); - + // Create terms for the concepts $term1 = Term::create([ 'concept_id' => $concept1->id, - 'text' => 'test search term', - 'preferred' => true + 'text' => 'test search term 1', + 'preferred' => true, ]); - + $term2 = Term::create([ 'concept_id' => $concept2->id, - 'text' => 'another test term', - 'preferred' => false + 'text' => 'test search term 2', + 'preferred' => false, ]); - + $term3 = Term::create([ 'concept_id' => $deprecatedConcept->id, - 'text' => 'test deprecated term', - 'preferred' => true + 'text' => 'test search term deprecated', + 'preferred' => true, ]); // Test basic search - $response = $this->getJson('/api/concepts/search?term=test'); + $response = $this->getJson('/api/concepts/search?term=test%20search%20term'); $response->assertStatus(200) ->assertJsonCount(1, 'data') // Only preferred terms by default ->assertJsonPath('data.0.id', $concept1->id); // Test search with all_terms=true - $response = $this->getJson('/api/concepts/search?term=test&all_terms=true'); + $response = $this->getJson('/api/concepts/search?term=test%20search%20term&all_terms=1'); $response->assertStatus(200) ->assertJsonCount(2, 'data') // Both preferred and non-preferred terms ->assertJsonMissing(['id' => $deprecatedConcept->id]); // Deprecated concepts should not appear From 8c3f29c2ad751ad91ff59e6b3b5c406f33ab7a1d Mon Sep 17 00:00:00 2001 From: Nevin Morgan Date: Mon, 28 Oct 2024 18:52:26 -0400 Subject: [PATCH 16/23] fix: update preferred_term property in Concept/Default.vue --- resources/js/components/Concept/Default.vue | 6 +-- .../components/Concept/mixins/Relationship.js | 43 +++++++++++-------- 2 files changed, 29 insertions(+), 20 deletions(-) diff --git a/resources/js/components/Concept/Default.vue b/resources/js/components/Concept/Default.vue index bb3eb40..9f371d6 100644 --- a/resources/js/components/Concept/Default.vue +++ b/resources/js/components/Concept/Default.vue @@ -226,7 +226,7 @@
Broader
  • - {{ relation.preferredTerm.text }} + {{ relation.preferred_term.text }}
@@ -235,7 +235,7 @@
Narrower
  • - {{ relation.preferredTerm.text }} + {{ relation.preferred_term.text }}
@@ -244,7 +244,7 @@
Related
  • - {{ relation.preferredTerm.text }} + {{ relation.preferred_term.text }}
diff --git a/resources/js/components/Concept/mixins/Relationship.js b/resources/js/components/Concept/mixins/Relationship.js index 900c193..1c5e8ed 100644 --- a/resources/js/components/Concept/mixins/Relationship.js +++ b/resources/js/components/Concept/mixins/Relationship.js @@ -11,20 +11,20 @@ export default { relationshipTypes: [ { value: 'broader', text: 'Broader' }, { value: 'narrower', text: 'Narrower' }, - { value: 'related', text: 'Related' } + { value: 'related', text: 'Related' }, ], - isSearching: false + isSearching: false, }; }, computed: { relationships() { return { - broader: this.concept?.broader || [], - narrower: this.concept?.narrower || [], - related: this.concept?.related || [] + broader: this.conceptProps?.broader || [], + narrower: this.conceptProps?.narrower || [], + related: this.conceptProps?.related || [], }; - } + }, }, methods: { @@ -41,20 +41,22 @@ export default { this.searchResults = []; return; } - + this.isSearching = true; - const [error, response] = await ConceptService.searchConcepts(this.searchTerm); - + const [error, response] = await ConceptService.searchConcepts( + this.searchTerm, + ); + if (error) { console.error('Search failed:', error); this.searchResults = []; } else { // Filter out current concept and deprecated concepts - this.searchResults = response.data.filter(c => - c.id !== this.conceptId && !c.deprecated + this.searchResults = response.data.filter( + (c) => c.id !== this.conceptId && !c.deprecated, ); } - + this.isSearching = false; }, @@ -67,18 +69,25 @@ export default { const relationshipData = { type: this.relationshipType, - relatedId: this.selectedConcept.id + relatedId: this.selectedConcept.id, }; - const [error, data] = await ConceptService.relateConcept(this.conceptId, relationshipData); - + const [error, data] = await ConceptService.relateConcept( + this.conceptId, + relationshipData, + ); + if (error) { console.error('Failed to create relationship:', error); return; } + this.concept.broader = data.broader; + + console.log(data); + this.showRelationshipModal = false; this.flashSuccessAlert(); - } - } + }, + }, }; From 532a1135c938cb78db9dac3625ba7a417695e1db Mon Sep 17 00:00:00 2001 From: Nevin Morgan Date: Mon, 28 Oct 2024 18:52:27 -0400 Subject: [PATCH 17/23] feat: Update relationships display in Default.vue --- resources/js/components/Concept/Default.vue | 62 ++++++++++++------- .../components/Concept/mixins/Relationship.js | 7 +++ 2 files changed, 46 insertions(+), 23 deletions(-) diff --git a/resources/js/components/Concept/Default.vue b/resources/js/components/Concept/Default.vue index 9f371d6..c5b2bcb 100644 --- a/resources/js/components/Concept/Default.vue +++ b/resources/js/components/Concept/Default.vue @@ -222,31 +222,27 @@ -
-
Broader
-
    -
  • - {{ relation.preferred_term.text }} -
  • -
-
+
+ -
-
Narrower
-
    -
  • - {{ relation.preferred_term.text }} -
  • -
-
+ -
-
Related
-
    -
  • - {{ relation.preferred_term.text }} -
  • -
+
@@ -304,4 +300,24 @@ header.sticky-top { background-color: #e9ecef; border-color: #007bff; } + +.relations { + margin-top: 1rem; +} + +.relations h3 { + font-size: 1.25rem; + margin-bottom: 1rem; +} + +.relations a { + display: block; + margin-bottom: 0.5rem; + color: #2c5282; + text-decoration: none; +} + +.relations a:hover { + text-decoration: underline; +} diff --git a/resources/js/components/Concept/mixins/Relationship.js b/resources/js/components/Concept/mixins/Relationship.js index 1c5e8ed..b77ba33 100644 --- a/resources/js/components/Concept/mixins/Relationship.js +++ b/resources/js/components/Concept/mixins/Relationship.js @@ -25,6 +25,13 @@ export default { related: this.conceptProps?.related || [], }; }, + hasAnyRelationships() { + return ( + (this.relationships.broader && this.relationships.broader.length > 0) || + (this.relationships.narrower && this.relationships.narrower.length > 0) || + (this.relationships.related && this.relationships.related.length > 0) + ); + } }, methods: { From d36129fa2e83377ccc5fda961d90dafd9589c4ff Mon Sep 17 00:00:00 2001 From: Nevin Morgan Date: Mon, 28 Oct 2024 18:57:04 -0400 Subject: [PATCH 18/23] feat: Update show view to allow vue to handle relationship rendering --- .../Controllers/API/ConceptController.php | 2 +- .../components/Concept/mixins/Relationship.js | 23 +++++++++---------- resources/views/concepts/show.blade.php | 16 ------------- 3 files changed, 12 insertions(+), 29 deletions(-) diff --git a/app/Http/Controllers/API/ConceptController.php b/app/Http/Controllers/API/ConceptController.php index 590119b..b07b5c6 100644 --- a/app/Http/Controllers/API/ConceptController.php +++ b/app/Http/Controllers/API/ConceptController.php @@ -190,7 +190,7 @@ public function relateConcepts(Request $request, Concept $concept) break; } - return $concept; + return $concept->loadMissing(['broader', 'narrower', 'related']); } /** diff --git a/resources/js/components/Concept/mixins/Relationship.js b/resources/js/components/Concept/mixins/Relationship.js index b77ba33..741615f 100644 --- a/resources/js/components/Concept/mixins/Relationship.js +++ b/resources/js/components/Concept/mixins/Relationship.js @@ -13,25 +13,24 @@ export default { { value: 'narrower', text: 'Narrower' }, { value: 'related', text: 'Related' }, ], + relationships: { + broader: this.conceptProps?.broader || [], + narrower: this.conceptProps?.narrower || [], + related: this.conceptProps?.related || [], + }, isSearching: false, }; }, computed: { - relationships() { - return { - broader: this.conceptProps?.broader || [], - narrower: this.conceptProps?.narrower || [], - related: this.conceptProps?.related || [], - }; - }, hasAnyRelationships() { return ( (this.relationships.broader && this.relationships.broader.length > 0) || - (this.relationships.narrower && this.relationships.narrower.length > 0) || + (this.relationships.narrower && + this.relationships.narrower.length > 0) || (this.relationships.related && this.relationships.related.length > 0) ); - } + }, }, methods: { @@ -89,9 +88,9 @@ export default { return; } - this.concept.broader = data.broader; - - console.log(data); + this.relationships.broader = data.broader; + this.relationships.narrower = data.narrower; + this.relationships.related = data.related; this.showRelationshipModal = false; this.flashSuccessAlert(); diff --git a/resources/views/concepts/show.blade.php b/resources/views/concepts/show.blade.php index d9d8310..c10cb8c 100644 --- a/resources/views/concepts/show.blade.php +++ b/resources/views/concepts/show.blade.php @@ -31,21 +31,5 @@ can-edit-vocabulary="{{ json_encode($isVocabularyEditor) }}" > - - @if ( count($relations) ) -
-

Relations

- -
- @foreach($relations as $title => $terms) -
-

{{ $title }}

- @foreach($terms as $term) - - @endforeach -
- @endforeach -
- @endif @endsection From 331d2b261157d775c0a22057e5575529d2356cf5 Mon Sep 17 00:00:00 2001 From: Nevin Morgan Date: Mon, 28 Oct 2024 19:03:16 -0400 Subject: [PATCH 19/23] feat: Add relationship removal functionality --- .../Controllers/API/ConceptController.php | 35 +++++++++++++++++++ resources/js/api/ConceptService.js | 14 ++++++++ routes/api.php | 1 + 3 files changed, 50 insertions(+) diff --git a/app/Http/Controllers/API/ConceptController.php b/app/Http/Controllers/API/ConceptController.php index b07b5c6..3668763 100644 --- a/app/Http/Controllers/API/ConceptController.php +++ b/app/Http/Controllers/API/ConceptController.php @@ -193,6 +193,41 @@ public function relateConcepts(Request $request, Concept $concept) return $concept->loadMissing(['broader', 'narrower', 'related']); } + /** + * Remove a relationship between concepts. + * + * @param \Illuminate\Http\Request $request + * @param \App\Concept $concept + * @return \Illuminate\Http\Response + */ + public function removeRelationship(Request $request, Concept $concept) + { + if ($request->user()->cannot('update', $concept)) { + abort(403); + } + + $relation_type = $request->input('relation_type'); + $related_id = $request->input('related_id'); + + switch ($relation_type) { + case "broader": + $concept->broader()->detach($related_id); + break; + case "narrower": + $concept->narrower()->detach($related_id); + break; + case "related": + // Remove both directions for related relationships + $concept->related()->detach($related_id); + $concept->belongsToMany("App\Models\Concept", "concept_relationships", "related_concept_id", "concept_id") + ->wherePivot("relationship_type", "related") + ->detach($related_id); + break; + } + + return $concept->loadMissing(['broader', 'narrower', 'related']); + } + /** * Display the specified resource. * diff --git a/resources/js/api/ConceptService.js b/resources/js/api/ConceptService.js index de5072e..1b87fe8 100644 --- a/resources/js/api/ConceptService.js +++ b/resources/js/api/ConceptService.js @@ -82,4 +82,18 @@ export default { return [error, null]; } }, + + async removeRelationship(conceptId, relationshipData) { + try { + const { data } = await apiClient.delete(`/${conceptId}/relate_concept`, { + data: { // Using data property for DELETE request body + relation_type: relationshipData.type, + related_id: relationshipData.relatedId, + } + }); + return [null, data]; + } catch (error) { + return [error, null]; + } + }, }; diff --git a/routes/api.php b/routes/api.php index 3a2682b..20cea04 100644 --- a/routes/api.php +++ b/routes/api.php @@ -24,6 +24,7 @@ Route::get('concepts/reconcile', 'API\ConceptController@reconcile'); Route::put('concepts/{concept}/relate_concept', 'API\ConceptController@relateConcepts'); +Route::delete('concepts/{concept}/relate_concept', 'API\ConceptController@removeRelationship'); Route::put('concepts/{concept}/deprecate', 'API\ConceptController@deprecate'); Route::apiResource('concepts', 'API\ConceptController'); From 855c9b9db8cd654151bc8d20000d52914c6995af Mon Sep 17 00:00:00 2001 From: Nevin Morgan Date: Mon, 28 Oct 2024 19:03:58 -0400 Subject: [PATCH 20/23] feat: Add ability to remove concept relationships --- resources/js/components/Concept/Default.vue | 45 +++++++++++++++++-- .../components/Concept/mixins/Relationship.js | 27 +++++++++++ 2 files changed, 69 insertions(+), 3 deletions(-) diff --git a/resources/js/components/Concept/Default.vue b/resources/js/components/Concept/Default.vue index c5b2bcb..ec99113 100644 --- a/resources/js/components/Concept/Default.vue +++ b/resources/js/components/Concept/Default.vue @@ -226,21 +226,51 @@ @@ -320,4 +350,13 @@ header.sticky-top { .relations a:hover { text-decoration: underline; } + +.relations .d-flex { + margin-bottom: 0.5rem; +} + +.relations .btn-sm { + padding: 0.25rem 0.5rem; + margin-left: 0.5rem; +} diff --git a/resources/js/components/Concept/mixins/Relationship.js b/resources/js/components/Concept/mixins/Relationship.js index 741615f..8d65fbf 100644 --- a/resources/js/components/Concept/mixins/Relationship.js +++ b/resources/js/components/Concept/mixins/Relationship.js @@ -95,5 +95,32 @@ export default { this.showRelationshipModal = false; this.flashSuccessAlert(); }, + + async removeRelationship(type, relatedId) { + if (!confirm('Are you sure you want to remove this relationship?')) { + return; + } + + const relationshipData = { + type: type, + relatedId: relatedId, + }; + + const [error, data] = await ConceptService.removeRelationship( + this.conceptId, + relationshipData, + ); + + if (error) { + console.error('Failed to remove relationship:', error); + return; + } + + this.relationships.broader = data.broader; + this.relationships.narrower = data.narrower; + this.relationships.related = data.related; + + this.flashSuccessAlert(); + }, }, }; From 5d8c18551fdff2a0ad2f2aacfecb48a901adfbbe Mon Sep 17 00:00:00 2001 From: Nevin Morgan Date: Mon, 28 Oct 2024 19:59:09 -0400 Subject: [PATCH 21/23] feat: Add test coverage for remove concept relationships --- tests/Feature/API/ConceptsTest.php | 109 +++++++++++++++++++++++++++++ 1 file changed, 109 insertions(+) diff --git a/tests/Feature/API/ConceptsTest.php b/tests/Feature/API/ConceptsTest.php index 797937c..d885a03 100644 --- a/tests/Feature/API/ConceptsTest.php +++ b/tests/Feature/API/ConceptsTest.php @@ -268,4 +268,113 @@ public function test_unauthorized_user_cannot_delete_concept(): void $response->assertStatus(403); } + + public function test_authorized_user_can_remove_concept_relationships(): void + { + $reviewerRole = Role::whereHas('permissions', function ($query) { + $query->where('label', 'Edit Vocabulary'); + })->first(); + $user = User::factory()->hasAttached($reviewerRole)->create(); + Sanctum::actingAs($user); + + // Create test concepts + $concept = Concept::factory()->create(); + $broaderConcept = Concept::factory()->create(); + $narrowerConcept = Concept::factory()->create(); + $relatedConcept = Concept::factory()->create(); + + // Create relationships first + $concept->addBroader($broaderConcept->id); + $concept->addNarrower($narrowerConcept->id); + $concept->addRelated($relatedConcept->id); + + // Test removing broader relationship + $response = $this->deleteJson("/api/concepts/{$concept->id}/relate_concept", [ + 'relation_type' => 'broader', + 'related_id' => $broaderConcept->id, + ]); + $response->assertStatus(200); + $this->assertFalse($concept->fresh()->broader->contains($broaderConcept)); + + // Test removing narrower relationship + $response = $this->deleteJson("/api/concepts/{$concept->id}/relate_concept", [ + 'relation_type' => 'narrower', + 'related_id' => $narrowerConcept->id, + ]); + $response->assertStatus(200); + $this->assertFalse($concept->fresh()->narrower->contains($narrowerConcept)); + + // Test removing related relationship + $response = $this->deleteJson("/api/concepts/{$concept->id}/relate_concept", [ + 'relation_type' => 'related', + 'related_id' => $relatedConcept->id, + ]); + $response->assertStatus(200); + $this->assertFalse($concept->fresh()->related->contains($relatedConcept)); + } + + public function test_unauthorized_user_cannot_remove_concept_relationships(): void + { + $nonReviewerRole = Role::whereDoesntHave('permissions', function ($query) { + $query->where('label', 'Edit Vocabulary'); + })->first(); + $user = User::factory()->hasAttached($nonReviewerRole)->create(); + Sanctum::actingAs($user); + + // Create test concepts + $concept = Concept::factory()->create(); + $relatedConcept = Concept::factory()->create(); + + // Create a relationship first + $concept->addRelated($relatedConcept->id); + + // Attempt to remove the relationship + $response = $this->deleteJson("/api/concepts/{$concept->id}/relate_concept", [ + 'relation_type' => 'related', + 'related_id' => $relatedConcept->id, + ]); + + $response->assertStatus(403); + $this->assertTrue($concept->fresh()->related->contains($relatedConcept)); + } + + public function test_removing_nonexistent_relationship_returns_success(): void + { + $reviewerRole = Role::whereHas('permissions', function ($query) { + $query->where('label', 'Edit Vocabulary'); + })->first(); + $user = User::factory()->hasAttached($reviewerRole)->create(); + Sanctum::actingAs($user); + + $concept = Concept::factory()->create(); + $nonRelatedConcept = Concept::factory()->create(); + + // Attempt to remove a relationship that doesn't exist + $response = $this->deleteJson("/api/concepts/{$concept->id}/relate_concept", [ + 'relation_type' => 'related', + 'related_id' => $nonRelatedConcept->id, + ]); + + $response->assertStatus(200); + } + + public function test_removing_relationship_with_invalid_type_returns_error(): void + { + $reviewerRole = Role::whereHas('permissions', function ($query) { + $query->where('label', 'Edit Vocabulary'); + })->first(); + $user = User::factory()->hasAttached($reviewerRole)->create(); + Sanctum::actingAs($user); + + $concept = Concept::factory()->create(); + $relatedConcept = Concept::factory()->create(); + + // Attempt to remove a relationship with invalid type + $response = $this->deleteJson("/api/concepts/{$concept->id}/relate_concept", [ + 'relation_type' => 'invalid_type', + 'related_id' => $relatedConcept->id, + ]); + + $response->assertStatus(422); + } } From b03a5ff328d476b1250276c33ef740ddb84b2eb4 Mon Sep 17 00:00:00 2001 From: Nevin Morgan Date: Mon, 28 Oct 2024 20:02:06 -0400 Subject: [PATCH 22/23] feat: add request validation to relationship removal --- app/Http/Controllers/API/ConceptController.php | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/app/Http/Controllers/API/ConceptController.php b/app/Http/Controllers/API/ConceptController.php index 3668763..fe479d4 100644 --- a/app/Http/Controllers/API/ConceptController.php +++ b/app/Http/Controllers/API/ConceptController.php @@ -206,6 +206,11 @@ public function removeRelationship(Request $request, Concept $concept) abort(403); } + $request->validate([ + 'relation_type' => 'required|in:broader,narrower,related', + 'related_id' => 'required|exists:concepts,id', + ]); + $relation_type = $request->input('relation_type'); $related_id = $request->input('related_id'); From dada3310d560b995c00d590e7fda546fb0fce9c4 Mon Sep 17 00:00:00 2001 From: Nevin Morgan Date: Mon, 28 Oct 2024 20:11:11 -0400 Subject: [PATCH 23/23] feat: add request validation and testing to relateConcepts method --- .../Controllers/API/ConceptController.php | 5 +++ tests/Feature/API/ConceptsTest.php | 39 +++++++++++++++++++ 2 files changed, 44 insertions(+) diff --git a/app/Http/Controllers/API/ConceptController.php b/app/Http/Controllers/API/ConceptController.php index fe479d4..55ede21 100644 --- a/app/Http/Controllers/API/ConceptController.php +++ b/app/Http/Controllers/API/ConceptController.php @@ -175,6 +175,11 @@ public function relateConcepts(Request $request, Concept $concept) abort(403); } + $request->validate([ + 'relation_type' => 'required|in:broader,narrower,related', + 'related_id' => 'required|exists:concepts,id', + ]); + $relation_type = $request->input('relation_type'); $related_id = $request->input('related_id'); diff --git a/tests/Feature/API/ConceptsTest.php b/tests/Feature/API/ConceptsTest.php index d885a03..1fb6354 100644 --- a/tests/Feature/API/ConceptsTest.php +++ b/tests/Feature/API/ConceptsTest.php @@ -163,6 +163,45 @@ public function test_authorized_user_can_update_concept_relationships(): void $this->assertTrue($concept->related->contains($relatedConcept)); } + public function test_relate_concepts_validates_relation_type(): void + { + $reviewerRole = Role::whereHas('permissions', function ($query) { + $query->where('label', 'Edit Vocabulary'); + })->first(); + $user = User::factory()->hasAttached($reviewerRole)->create(); + Sanctum::actingAs($user); + + $concept = Concept::factory()->create(); + $relatedConcept = Concept::factory()->create(); + + $response = $this->putJson("/api/concepts/{$concept->id}/relate_concept", [ + 'relation_type' => 'invalid_type', + 'related_id' => $relatedConcept->id, + ]); + + $response->assertStatus(422) + ->assertJsonValidationErrors(['relation_type']); + } + + public function test_relate_concepts_validates_related_id(): void + { + $reviewerRole = Role::whereHas('permissions', function ($query) { + $query->where('label', 'Edit Vocabulary'); + })->first(); + $user = User::factory()->hasAttached($reviewerRole)->create(); + Sanctum::actingAs($user); + + $concept = Concept::factory()->create(); + + $response = $this->putJson("/api/concepts/{$concept->id}/relate_concept", [ + 'relation_type' => 'broader', + 'related_id' => 99999999999999, // Non-existent ID + ]); + + $response->assertStatus(422) + ->assertJsonValidationErrors(['related_id']); + } + public function test_authorized_user_can_deprecate_concept(): void { $reviewerRole = Role::whereHas('permissions', function ($query) {