From af77410fa742c98ec90b3fdd91b9322e57c55357 Mon Sep 17 00:00:00 2001 From: Allan Kong Date: Sat, 12 Apr 2025 15:51:57 -0600 Subject: [PATCH 1/9] Fixing faking for resources and reviews --- config/comment.php | 2 +- database/factories/ResourceEditsFactory.php | 13 +- .../seeders/ComputerScienceResourceSeeder.php | 1 - tests/Feature/ResourceEditsTest.php | 190 ++++++++++++++++++ .../ComputerScienceResourceTestResource.php | 3 +- .../ResourceReviewTestResource.php | 11 +- 6 files changed, 209 insertions(+), 11 deletions(-) create mode 100644 tests/Feature/ResourceEditsTest.php diff --git a/config/comment.php b/config/comment.php index 2bca98fc..3d6b4997 100644 --- a/config/comment.php +++ b/config/comment.php @@ -3,6 +3,6 @@ return [ 'max_replies' => 150, 'max_depth' => 7, // 1-indexed, 1 is the start - 'pagination_limit' => 2, + 'pagination_limit' => 3, 'commentable_types' => ['review', 'comment', 'edit', 'resource'] ]; \ No newline at end of file diff --git a/database/factories/ResourceEditsFactory.php b/database/factories/ResourceEditsFactory.php index a565691f..3bd842da 100644 --- a/database/factories/ResourceEditsFactory.php +++ b/database/factories/ResourceEditsFactory.php @@ -21,9 +21,16 @@ public function definition(): array $pricings = config('computerScienceResource.pricings'); return [ - 'computer_science_resource_id' => ComputerScienceResource::factory(), - 'user_id' => User::factory(), - + 'computer_science_resource_id' => function () { + return ComputerScienceResource::inRandomOrder()->firstOr(function () { + return ComputerScienceResource::factory()->create(); + })->id; + }, + 'user_id' => function () { + return User::inRandomOrder()->firstOr(function () { + return User::factory()->create(); + })->id; + }, 'edit_title' => $this->faker->sentence, 'edit_description' => $this->faker->paragraph, diff --git a/database/seeders/ComputerScienceResourceSeeder.php b/database/seeders/ComputerScienceResourceSeeder.php index af3defe5..340c9edd 100644 --- a/database/seeders/ComputerScienceResourceSeeder.php +++ b/database/seeders/ComputerScienceResourceSeeder.php @@ -14,7 +14,6 @@ class ComputerScienceResourceSeeder extends Seeder */ public function run(): void { - Log::info('Running ComputerScienceResourceSeeder'); ComputerScienceResource::factory(1)->create(); } } diff --git a/tests/Feature/ResourceEditsTest.php b/tests/Feature/ResourceEditsTest.php new file mode 100644 index 00000000..5c544935 --- /dev/null +++ b/tests/Feature/ResourceEditsTest.php @@ -0,0 +1,190 @@ +user = User::factory()->create(); + } + + /** + * Test that invalid edit payloads are rejected. + */ + public function test_cannot_post_resource_with_invalid_fields(): void + { + $this->actingAs($this->user); + + $validData = ComputerScienceResourceTestResource::fake(); + + $invalidDataSets = [ + 'name' => str_repeat('a', 101), // Too long + 'description' => str_repeat('a', 10001), // Too long + 'platforms' => ['invalid_platform'], + 'page_url' => 'not-a-url', + 'difficulty' => 'invalid_difficulty', + 'pricing' => 'invalid_pricing', + 'topic_tags' => ['tag1', 'tag2'], // Fewer than required minimum of 3 + 'image_url' => 'not-a-url', + 'general_tags' => 'not-an-array', + 'programming_language_tags' => 'not-an-array' + ]; + + foreach ($invalidDataSets as $field => $invalidValue) { + $testData = $validData; + $testData[$field] = $invalidValue; + + $response = $this->postJson(route('resource_edits.store', [ + 'computerScienceResource' => $validData['id'], + ]), $testData); + + $this->assertTrue( + $response->status() === 422, + "Failed asserting that the server responded with a 422 status code for invalid '$field'. Response status: " . $response->status() + ); + + $notCreatedEdit = ResourceEdits::first(); + $this->assertNull( + $notCreatedEdit, + "Failed asserting that a resource edit with name '{$testData['name']}' was not created. Invalid field: " . $field + ); + } + } + + /** + * Test that submitting an edit that has no changes (relative to the original resource) is not allowed. + */ + public function test_cannot_post_resource_edit_with_no_changes(): void + { + $this->actingAs($this->user); + + // Create the original resource. + $resource = ComputerScienceResource::factory()->create(); + + // Create valid edit payload, then set fields to exactly match the resource. + $editData = ComputerScienceResourceTestResource::fake(); + $editData['name'] = $resource->name; + $editData['description'] = $resource->description; + $editData['page_url'] = $resource->page_url; + $editData['platforms'] = $resource->platforms; + $editData['difficulty'] = $resource->difficulty; + $editData['pricing'] = $resource->pricing; + $editData['topic_tags'] = $resource->topic_tags; + $editData['programming_language_tags'] = $resource->programming_language_tags; + $editData['general_tags'] = $resource->general_tags; + // Add required edit-specific fields. + $editData['edit_title'] = 'Proposed edit with no changes'; + $editData['edit_description'] = 'This edit does nothing.'; + + $response = $this->postJson(route('resource_edits.store', $resource), $editData); + + $response->assertStatus(422); + } + + /** + * Test that a valid resource edit can be posted. + */ + public function test_can_post_valid_resource_edit(): void + { + $this->actingAs($this->user); + + // Create the original resource. + $resource = ComputerScienceResource::factory()->create(); + + // Create valid edit payload and change at least one attribute. + $editData = ComputerScienceResourceTestResource::fake(); + $editData['name'] = $resource->name . ' Updated'; + $editData['edit_title'] = 'Proposed Update'; + $editData['edit_description'] = 'Proposing an update to the resource'; + + $response = $this->post(route('resource_edits.store', $resource), $editData); + + // Expect redirection to the edit show page with a success message. + $response->assertRedirect() + ->assertSessionHas('success', "The proposed edits were created. Other's can now view it."); + + $this->assertDatabaseHas('resource_edits', [ + 'computer_science_resource_id' => $resource->id, + 'name' => $editData['name'], + ]); + } + + /** + * Test that merging an edit updates the original resource. + * We run multiple merges to simulate multiple edit merges. + */ + public function test_merged_edit_reflects_changes_on_original_resource(): void + { + // Create a resource. + $resource = ComputerScienceResource::factory()->create([ + 'name' => 'Original Resource', + 'description' => 'Original description', + 'image_url' => 'http://example.com/original.png', + 'page_url' => 'http://example.com', + 'platforms' => ['web'], + 'difficulty' => 'beginner', + 'pricing' => 'free', + 'topic_tags' => ['laravel', 'php', 'testing'], + 'programming_language_tags' => ['php'], + 'general_tags' => ['resource'] + ]); + + // Stub the ResourceEditsService to always allow merging. + $this->instance(ResourceEditsService::class, new class { + public function canMergeEdits($resourceEdits) + { + return true; + } + }); + + $mergeAttempts = 3; + + for ($i = 0; $i < $mergeAttempts; $i++) { + // Create a unique edit for the same resource. + $editData = ComputerScienceResourceTestResource::fake(); + $editData['name'] = $resource->name . ' Edited ' . $i; + $editData['description'] = $resource->description . ' Now with change ' . $i; + $editData['edit_title'] = "Edit #$i"; + $editData['edit_description'] = "This is change number $i."; + + // Post the edit. + $this->actingAs($this->user)->post(route('resource_edits.store', $resource), $editData); + // Retrieve the edit record. + $resourceEdit = ResourceEdits::latest()->first(); + + // Simulate merging the edit. + $mergeResponse = $this->post(route('resource_edits.merge', $resourceEdit)); + + $mergeResponse->assertRedirect(route('resources.show', ['computerScienceResource' => $resource->id])) + ->assertSessionHas('success', 'Successfully merged new changed!'); + + // Refresh the original resource + $resource->refresh(); + + // Assert that the original resource reflects the changes from the merged edit. + $this->assertEquals($editData['name'], $resource->name); + $this->assertEquals($editData['description'], $resource->description); + + // Check that the edit was deleted. + $this->assertDatabaseMissing('resource_edits', [ + 'id' => $resourceEdit->id, + ]); + } + } +} diff --git a/tests/TestResources/ComputerScienceResourceTestResource.php b/tests/TestResources/ComputerScienceResourceTestResource.php index 77f0f3c1..c3d03cea 100644 --- a/tests/TestResources/ComputerScienceResourceTestResource.php +++ b/tests/TestResources/ComputerScienceResourceTestResource.php @@ -3,7 +3,6 @@ namespace Tests\TestResources; use App\Models\ComputerScienceResource; -use Event; use Illuminate\Http\Request; use Illuminate\Http\Resources\Json\JsonResource; @@ -28,7 +27,7 @@ public function toArray(Request $request): array public static function fake(): array { // Create the model with disabled events - $model = Event::fakeFor(fn() => ComputerScienceResource::factory()->create()); + $model = ComputerScienceResource::factory()->create(); // Transform it to API form $formData = (new self($model))->toArray(request()); diff --git a/tests/TestResources/ResourceReviewTestResource.php b/tests/TestResources/ResourceReviewTestResource.php index d9a5f8eb..d563ca80 100644 --- a/tests/TestResources/ResourceReviewTestResource.php +++ b/tests/TestResources/ResourceReviewTestResource.php @@ -2,6 +2,7 @@ namespace Tests\TestResources; +use App\Events\ResourceReviewProcessed; use App\Models\ResourceReview; use Illuminate\Http\Request; use Illuminate\Http\Resources\Json\JsonResource; @@ -27,13 +28,15 @@ public function toArray(Request $request): array public static function fake(): array { - // Create the model with disabled events - $model = Event::fakeFor(fn() => ResourceReview::factory()->create()); - + // Fake all events except the ones you still want to fire + $model = Event::fakeFor(function () { + return ResourceReview::factory()->create(); + }, [ResourceReviewProcessed::class]); + // Transform it to API form $formData = (new self($model))->toArray(request()); - // Delete after getting the array to avoid polluting the DB + // Delete after getting the array $model->delete(); return $formData; From 3892268f264106938912e1a6164f4057ebfd420f Mon Sep 17 00:00:00 2001 From: Allan Kong Date: Sat, 12 Apr 2025 20:45:41 -0600 Subject: [PATCH 2/9] Work in progress --- app/Http/Controllers/CommentController.php | 2 +- .../ComputerScienceResourceController.php | 2 +- .../Controllers/ResourceEditsController.php | 5 +- .../StoreResourceRequest.php | 22 +-- .../ResourceEdit/StoreResourceEdit.php | 15 +- .../ResourceReview/StoreResourceReview.php | 1 - .../Shared/ComputerScienceResourceRequest.php | 25 ++++ .../ComputerScienceResourceResource.php | 30 ++++ ...reate_computer_science_resources_table.php | 4 +- .../ComputerScienceResourceControllerTest.php | 8 +- tests/Feature/ResourceEditsTest.php | 129 +++++++++++------- .../ComputerScienceResourceTestResource.php | 2 +- 12 files changed, 161 insertions(+), 84 deletions(-) create mode 100644 app/Http/Requests/Shared/ComputerScienceResourceRequest.php create mode 100644 app/Http/Resources/ComputerScienceResourceResource.php diff --git a/app/Http/Controllers/CommentController.php b/app/Http/Controllers/CommentController.php index 535d56f7..5f68fb24 100644 --- a/app/Http/Controllers/CommentController.php +++ b/app/Http/Controllers/CommentController.php @@ -190,7 +190,7 @@ public function show(string $commentableType, int $commentableId, int $index) } } - \Log::debug("Returned comments: " . json_encode($flattenedComments)); + Log::debug("Returned comments: " . json_encode($flattenedComments)); return [ 'comments' => $flattenedComments, 'users' => $users->values(), diff --git a/app/Http/Controllers/ComputerScienceResourceController.php b/app/Http/Controllers/ComputerScienceResourceController.php index d0c5a8c6..6c294062 100644 --- a/app/Http/Controllers/ComputerScienceResourceController.php +++ b/app/Http/Controllers/ComputerScienceResourceController.php @@ -46,7 +46,7 @@ public function store(StoreResourceRequest $request) 'user_id' => Auth::id(), 'name' => $validatedData['name'], 'description' => $validatedData['description'], - 'image_url' => $validatedData['image_url'] ?? null, + 'image_url' => $validatedData['image_url'], 'page_url' => $validatedData['page_url'], 'platforms' => $validatedData['platforms'], 'difficulty' => $validatedData['difficulty'], diff --git a/app/Http/Controllers/ResourceEditsController.php b/app/Http/Controllers/ResourceEditsController.php index 9389ca59..811b9057 100644 --- a/app/Http/Controllers/ResourceEditsController.php +++ b/app/Http/Controllers/ResourceEditsController.php @@ -2,10 +2,10 @@ namespace App\Http\Controllers; -use App\Http\Requests\ResourceEdit\StoreResourceEdit; use App\Models\ComputerScienceResource; use App\Models\ResourceEdits; use App\Services\ResourceEditsService; +use App\Http\Requests\ResourceEdit\StoreResourceEdit; use Auth; use Inertia\Inertia; use Log; @@ -52,10 +52,9 @@ public function store(ComputerScienceResource $computerScienceResource, StoreRes 'computer_science_resource_id' => $computerScienceResource->id, 'edit_title' => $validatedData['edit_title'], 'edit_description' => $validatedData['edit_description'], - 'name' => $validatedData['name'], 'description' => $validatedData['description'], - 'image_url' => $validatedData['image_url'] ?? null, + 'image_url' => $validatedData['image_url'], 'page_url' => $validatedData['page_url'], 'platforms' => $validatedData['platforms'], 'difficulty' => $validatedData['difficulty'], diff --git a/app/Http/Requests/ComputerScienceResource/StoreResourceRequest.php b/app/Http/Requests/ComputerScienceResource/StoreResourceRequest.php index dde55e1b..56b18dbf 100644 --- a/app/Http/Requests/ComputerScienceResource/StoreResourceRequest.php +++ b/app/Http/Requests/ComputerScienceResource/StoreResourceRequest.php @@ -2,12 +2,14 @@ namespace App\Http\Requests\ComputerScienceResource; +use App\Http\Requests\Shared\ComputerScienceResourceRequest; use Auth; use Illuminate\Foundation\Http\FormRequest; -use Illuminate\Validation\Rule; class StoreResourceRequest extends FormRequest { + use ComputerScienceResourceRequest; + /** * Determine if the user is authorized to make this request. */ @@ -23,23 +25,11 @@ public function authorize(): bool */ public function rules(): array { - return [ - 'name' => ['required', 'string', 'max:100'], - 'description' => ['required', 'string', 'max:10000'], - 'platforms' => ['required', 'array'], - 'platforms.*' => ['distinct', 'string', Rule::in(config('computerScienceResource.platforms'))], - 'page_url' => ['required', 'string', 'url:http,https', 'max:255'], - 'difficulty' => ['required', 'string', Rule::in(config('computerScienceResource.difficulties'))], - 'pricing' => ['required', 'string', Rule::in(config('computerScienceResource.pricings'))], - 'topic_tags' => ['required', 'array', 'min:3'], - 'topic_tags.*' => ['required', 'distinct', 'string', 'max:50'], - - // Optional fields - 'image_url' => ['nullable', 'string', 'url:http,https', 'max:255'], + return array_merge($this->baseResourceRules(), [ 'general_tags' => ['nullable', 'array'], 'general_tags.*' => ['distinct', 'string', 'max:50'], 'programming_language_tags' => ['nullable', 'array'], - 'programming_language_tags.*' => ['distinct', 'string', 'max:50'] - ]; + 'programming_language_tags.*' => ['distinct', 'string', 'max:50'], + ]); } } diff --git a/app/Http/Requests/ResourceEdit/StoreResourceEdit.php b/app/Http/Requests/ResourceEdit/StoreResourceEdit.php index 80ee44e9..d125371e 100644 --- a/app/Http/Requests/ResourceEdit/StoreResourceEdit.php +++ b/app/Http/Requests/ResourceEdit/StoreResourceEdit.php @@ -2,12 +2,13 @@ namespace App\Http\Requests\ResourceEdit; -use App\Http\Requests\ComputerScienceResource\StoreResourceRequest; +use App\Http\Requests\Shared\ComputerScienceResourceRequest; use Auth; use Illuminate\Foundation\Http\FormRequest; class StoreResourceEdit extends FormRequest { + use ComputerScienceResourceRequest; /** * Determine if the user is authorized to make this request. */ @@ -23,12 +24,14 @@ public function authorize(): bool */ public function rules(): array { - // Have the same validation rules as a resource - $storeResourceRequest = new StoreResourceRequest(); - - return array_merge([ + return array_merge($this->baseResourceRules(), [ 'edit_title' => ['required', 'string', 'max:100'], 'edit_description' => ['required', 'string', 'max:10000'], - ], $storeResourceRequest->rules()); + + 'general_tags' => ['required', 'array'], + 'general_tags.*' => ['required', 'string', 'max:50'], + 'programming_language_tags' => ['required', 'array'], + 'programming_language_tags.*' => ['distinct', 'string', 'max:50'], + ]); } } diff --git a/app/Http/Requests/ResourceReview/StoreResourceReview.php b/app/Http/Requests/ResourceReview/StoreResourceReview.php index 7109ff75..de979421 100644 --- a/app/Http/Requests/ResourceReview/StoreResourceReview.php +++ b/app/Http/Requests/ResourceReview/StoreResourceReview.php @@ -3,7 +3,6 @@ namespace App\Http\Requests\ResourceReview; use Illuminate\Foundation\Http\FormRequest; -use Illuminate\Validation\Rule; class StoreResourceReview extends FormRequest { diff --git a/app/Http/Requests/Shared/ComputerScienceResourceRequest.php b/app/Http/Requests/Shared/ComputerScienceResourceRequest.php new file mode 100644 index 00000000..aa447c7c --- /dev/null +++ b/app/Http/Requests/Shared/ComputerScienceResourceRequest.php @@ -0,0 +1,25 @@ + ['required', 'string', 'max:100'], + 'description' => ['required', 'string', 'max:10000'], + 'platforms' => ['required', 'array'], + 'platforms.*' => ['distinct', 'string', Rule::in(config('computerScienceResource.platforms'))], + 'page_url' => ['required', 'string', 'url:http,https', 'max:255'], + 'image_url' => ['required', 'string', 'url:http,https', 'max:255'], + 'difficulty' => ['required', 'string', Rule::in(config('computerScienceResource.difficulties'))], + 'pricing' => ['required', 'string', Rule::in(config('computerScienceResource.pricings'))], + 'topic_tags' => ['required', 'array', 'min:3'], + 'topic_tags.*' => ['required', 'distinct', 'string', 'max:50'], + ]; + } + +} \ No newline at end of file diff --git a/app/Http/Resources/ComputerScienceResourceResource.php b/app/Http/Resources/ComputerScienceResourceResource.php new file mode 100644 index 00000000..dcaa9fc2 --- /dev/null +++ b/app/Http/Resources/ComputerScienceResourceResource.php @@ -0,0 +1,30 @@ + + */ + public function toArray(Request $request): array + { + return [ + 'name' => $this->name, + 'description' => $this->description, + 'page_url' => $this->page_url, + 'image_url' => $this->image_url ?? '', + 'platforms' => $this->platforms, + 'difficulty' => $this->difficulty, + 'pricing' => $this->pricing, + 'topic_tags' => $this->topic_tags, + 'programming_language_tags' => $this->programming_language_tags, + 'general_tags' => $this->general_tags, + ]; + } +} diff --git a/database/migrations/2025_01_24_171530_create_computer_science_resources_table.php b/database/migrations/2025_01_24_171530_create_computer_science_resources_table.php index 0ce7f7b5..8e806871 100644 --- a/database/migrations/2025_01_24_171530_create_computer_science_resources_table.php +++ b/database/migrations/2025_01_24_171530_create_computer_science_resources_table.php @@ -21,8 +21,8 @@ public function up(): void $table->string('name')->fulltext(); $table->text('description')->fulltext(); - $table->string('image_url'); - + $table->string('image_url')->default(''); + // TODO: Have a url for each platform the resource is on. $table->string('page_url'); diff --git a/tests/Feature/ComputerScienceResourceControllerTest.php b/tests/Feature/ComputerScienceResourceControllerTest.php index 3f71e031..985b1faa 100644 --- a/tests/Feature/ComputerScienceResourceControllerTest.php +++ b/tests/Feature/ComputerScienceResourceControllerTest.php @@ -28,6 +28,7 @@ public function test_can_post_resource() $formData = ComputerScienceResourceTestResource::fake(); + Log::debug("Form data: ". json_encode($formData)); $response = $this->postJson(route('resources.store'), $formData); $response->assertStatus(302); // a redirect after successful creation @@ -36,11 +37,6 @@ public function test_can_post_resource() // Check it is created $createdResource = ComputerScienceResource::where('name', $formData['name'])->first(); $this->assertNotNull($createdResource); - - // Check tags - $this->assertEquals($formData['topic_tags'], $createdResource->topic_tags); - $this->assertEquals($formData['programming_language_tags'], $createdResource->programming_language_tags); - $this->assertEquals($formData['general_tags'], $createdResource->general_tags); } public function test_cannot_post_resource_unauthed() @@ -84,8 +80,6 @@ public function test_cannot_post_resource_with_invalid_fields() 'pricing' => 'invalid_pricing', 'topic_tags' => ['tag1', 'tag2'], // Less than required minimum of 3 'image_url' => 'not-a-url', - 'general_tags' => 'not-an-array', - 'programming_language_tags' => 'not-an-array' ]; // Choose from one of the invalid fields diff --git a/tests/Feature/ResourceEditsTest.php b/tests/Feature/ResourceEditsTest.php index 5c544935..b5a1d1e5 100644 --- a/tests/Feature/ResourceEditsTest.php +++ b/tests/Feature/ResourceEditsTest.php @@ -7,6 +7,8 @@ use App\Models\User; use App\Services\ResourceEditsService; use Illuminate\Foundation\Testing\RefreshDatabase; +use Mockery; +use Mockery\MockInterface; use Tests\TestCase; use Tests\TestResources\ComputerScienceResourceTestResource; @@ -31,8 +33,12 @@ public function test_cannot_post_resource_with_invalid_fields(): void { $this->actingAs($this->user); + $resource = ComputerScienceResource::factory()->create(); $validData = ComputerScienceResourceTestResource::fake(); - + + $validData['edit_title'] = 'title'; + $validData['edit_description'] = 'description'; + $invalidDataSets = [ 'name' => str_repeat('a', 101), // Too long 'description' => str_repeat('a', 10001), // Too long @@ -51,7 +57,7 @@ public function test_cannot_post_resource_with_invalid_fields(): void $testData[$field] = $invalidValue; $response = $this->postJson(route('resource_edits.store', [ - 'computerScienceResource' => $validData['id'], + 'computerScienceResource' => $resource->id, ]), $testData); $this->assertTrue( @@ -78,22 +84,30 @@ public function test_cannot_post_resource_edit_with_no_changes(): void $resource = ComputerScienceResource::factory()->create(); // Create valid edit payload, then set fields to exactly match the resource. - $editData = ComputerScienceResourceTestResource::fake(); + + $editData = array(); $editData['name'] = $resource->name; $editData['description'] = $resource->description; $editData['page_url'] = $resource->page_url; + $editData['image_url'] = $resource->image_url; $editData['platforms'] = $resource->platforms; $editData['difficulty'] = $resource->difficulty; $editData['pricing'] = $resource->pricing; $editData['topic_tags'] = $resource->topic_tags; $editData['programming_language_tags'] = $resource->programming_language_tags; $editData['general_tags'] = $resource->general_tags; + // Add required edit-specific fields. $editData['edit_title'] = 'Proposed edit with no changes'; $editData['edit_description'] = 'This edit does nothing.'; $response = $this->postJson(route('resource_edits.store', $resource), $editData); + if ($response->status() !== 422) { + dump('Unexpected response status: ' . $response->status()); + dump('Response JSON:', $response->json()); + } + $response->assertStatus(422); } @@ -109,10 +123,11 @@ public function test_can_post_valid_resource_edit(): void // Create valid edit payload and change at least one attribute. $editData = ComputerScienceResourceTestResource::fake(); - $editData['name'] = $resource->name . ' Updated'; $editData['edit_title'] = 'Proposed Update'; $editData['edit_description'] = 'Proposing an update to the resource'; - + + $editData['name'] = $resource->name . ' Updated'; + $response = $this->post(route('resource_edits.store', $resource), $editData); // Expect redirection to the edit show page with a success message. @@ -132,59 +147,81 @@ public function test_can_post_valid_resource_edit(): void public function test_merged_edit_reflects_changes_on_original_resource(): void { // Create a resource. - $resource = ComputerScienceResource::factory()->create([ - 'name' => 'Original Resource', - 'description' => 'Original description', - 'image_url' => 'http://example.com/original.png', - 'page_url' => 'http://example.com', - 'platforms' => ['web'], - 'difficulty' => 'beginner', - 'pricing' => 'free', - 'topic_tags' => ['laravel', 'php', 'testing'], - 'programming_language_tags' => ['php'], - 'general_tags' => ['resource'] - ]); - + $resource = ComputerScienceResource::factory()->create(); + // Stub the ResourceEditsService to always allow merging. - $this->instance(ResourceEditsService::class, new class { - public function canMergeEdits($resourceEdits) - { - return true; - } - }); + $this->instance( + ResourceEditsService::class, + Mockery::mock(ResourceEditsService::class, function (MockInterface $mock) { + $mock->shouldReceive('canMergeEdits') + ->andReturnTrue(); + }) + ); $mergeAttempts = 3; - + for ($i = 0; $i < $mergeAttempts; $i++) { + // Create a fresh copy of the resource every loop to avoid stale values. + $resource->refresh(); + // Create a unique edit for the same resource. $editData = ComputerScienceResourceTestResource::fake(); - $editData['name'] = $resource->name . ' Edited ' . $i; - $editData['description'] = $resource->description . ' Now with change ' . $i; + // Required for edit $editData['edit_title'] = "Edit #$i"; - $editData['edit_description'] = "This is change number $i."; - - // Post the edit. - $this->actingAs($this->user)->post(route('resource_edits.store', $resource), $editData); - // Retrieve the edit record. - $resourceEdit = ResourceEdits::latest()->first(); - - // Simulate merging the edit. - $mergeResponse = $this->post(route('resource_edits.merge', $resourceEdit)); - - $mergeResponse->assertRedirect(route('resources.show', ['computerScienceResource' => $resource->id])) + $editData['edit_description'] = "This is edit number $i."; + + // Change all the fields + $editData['name'] = $resource->name . " Edited {$i}"; + $editData['description'] = $resource->description . " Changed {$i}"; + $editData['platforms'] = ['course', 'bootcamp']; + $editData['page_url'] = "https://example.com/edited-url-{$i}"; + $editData['image_url'] = "https://example.com/edited-url-{$i}"; + $editData['difficulty'] = 'beginner'; + $editData['pricing'] = 'free'; + $editData['topic_tags'] = ["Tag {$i}-A", "Tag {$i}-B", "Tag {$i}-C"]; + + $editData['general_tags'] = ["Tag {$i}-A", "Tag {$i}-B", "Tag {$i}-C"]; + $editData['programming_language_tags'] = ["Tag {$i}-A", "Tag {$i}-B", "Tag {$i}-C"]; + + // Randomly unset optional fields + if (rand(0, 1)) $editData['image_url'] = ''; + if (rand(0, 1)) $editData['general_tags'] = []; + if (rand(0, 1)) $editData['programming_language_tags'] = []; + + // Post the edit + $response = $this->actingAs($this->user)->post(route('resource_edits.store', ['computerScienceResource' => $resource->id]), $editData); + $response->assertStatus(302); + + // Retrieve the edit + $resourceEdits = ResourceEdits::latest()->first(); + $this->assertNotNull($resourceEdits); + + // Merge the edit + $mergeResponse = $this->post(route('resource_edits.merge', ['resourceEdits' => $resourceEdits->id])); + + $mergeResponse + ->assertRedirect(route('resources.show', ['computerScienceResource' => $resource->id])) ->assertSessionHas('success', 'Successfully merged new changed!'); - - // Refresh the original resource + + // Refresh and assert that changes took effect $resource->refresh(); - - // Assert that the original resource reflects the changes from the merged edit. + // Check all required fields $this->assertEquals($editData['name'], $resource->name); $this->assertEquals($editData['description'], $resource->description); + $this->assertEquals($editData['platforms'], $resource->platforms); + $this->assertEquals($editData['page_url'], $resource->page_url); + $this->assertEquals($editData['difficulty'], $resource->difficulty); + $this->assertEquals($editData['pricing'], $resource->pricing); + $this->assertEquals($editData['topic_tags'], $resource->topic_tags); + + // Check optional fields + $this->assertEquals($editData['image_url'], $resource->image_url); + $this->assertEquals($editData['general_tags'], $resource->general_tags); + $this->assertEquals($editData['programming_language_tags'], $resource->programming_language_tags); + + $this->assertDatabaseMissing('resource_edits', ['id' => $resourceEdits->id]); - // Check that the edit was deleted. - $this->assertDatabaseMissing('resource_edits', [ - 'id' => $resourceEdit->id, - ]); } } + } diff --git a/tests/TestResources/ComputerScienceResourceTestResource.php b/tests/TestResources/ComputerScienceResourceTestResource.php index c3d03cea..d1a2eec8 100644 --- a/tests/TestResources/ComputerScienceResourceTestResource.php +++ b/tests/TestResources/ComputerScienceResourceTestResource.php @@ -19,7 +19,7 @@ public function toArray(Request $request): array 'difficulty' => $this->difficulty, 'pricing' => $this->pricing, 'topic_tags' => $this->topic_tags, - 'programming_language_tags' => $this->programming_language_tags, + 'programming_language_tags' => $this->programming_language_tags, 'general_tags' => $this->general_tags, ]; } From 92faed06324f917f8bd2c46297ff7dce37f78647 Mon Sep 17 00:00:00 2001 From: Allan Kong Date: Sat, 12 Apr 2025 21:05:36 -0600 Subject: [PATCH 3/9] Tests are going well, but need to deal with more edge cases and null images --- .../Controllers/ResourceEditsController.php | 11 +++ .../ComputerScienceResourceResource.php | 2 +- ..._24_154128_create_resource_edits_table.php | 2 + tests/Feature/ResourceEditsTest.php | 97 ++++++++----------- 4 files changed, 54 insertions(+), 58 deletions(-) diff --git a/app/Http/Controllers/ResourceEditsController.php b/app/Http/Controllers/ResourceEditsController.php index 811b9057..b24af07d 100644 --- a/app/Http/Controllers/ResourceEditsController.php +++ b/app/Http/Controllers/ResourceEditsController.php @@ -6,6 +6,8 @@ use App\Models\ResourceEdits; use App\Services\ResourceEditsService; use App\Http\Requests\ResourceEdit\StoreResourceEdit; +use App\Http\Resources\ComputerScienceResourceResource; +use Arr; use Auth; use Inertia\Inertia; use Log; @@ -47,6 +49,15 @@ public function store(ComputerScienceResource $computerScienceResource, StoreRes $validatedData = $request->validated(); Log::debug("Creating a resource edit: " . json_encode($validatedData)); + // Ensure that they are not the same + $originalData = (new ComputerScienceResourceResource($computerScienceResource))->resolve(); + $editData = Arr::only($validatedData, array_keys($originalData)); + + // Compare the two arrays. + if ($originalData == $editData) { + return response()->json(['message' => 'No changes detected'], 422); + } + $resourceEdit = ResourceEdits::create([ 'user_id' => Auth::id(), 'computer_science_resource_id' => $computerScienceResource->id, diff --git a/app/Http/Resources/ComputerScienceResourceResource.php b/app/Http/Resources/ComputerScienceResourceResource.php index dcaa9fc2..c347f3f3 100644 --- a/app/Http/Resources/ComputerScienceResourceResource.php +++ b/app/Http/Resources/ComputerScienceResourceResource.php @@ -18,7 +18,7 @@ public function toArray(Request $request): array 'name' => $this->name, 'description' => $this->description, 'page_url' => $this->page_url, - 'image_url' => $this->image_url ?? '', + 'image_url' => $this->image_url, 'platforms' => $this->platforms, 'difficulty' => $this->difficulty, 'pricing' => $this->pricing, diff --git a/database/migrations/2025_03_24_154128_create_resource_edits_table.php b/database/migrations/2025_03_24_154128_create_resource_edits_table.php index c6c9a374..f2a35fa0 100644 --- a/database/migrations/2025_03_24_154128_create_resource_edits_table.php +++ b/database/migrations/2025_03_24_154128_create_resource_edits_table.php @@ -29,6 +29,8 @@ public function up(): void // Copied Schema of Computer Science Resource $table->string('name')->fulltext(); $table->text('description')->fulltext(); + + // TODO: have it be nullable or something $table->string('image_url'); $table->string('page_url'); diff --git a/tests/Feature/ResourceEditsTest.php b/tests/Feature/ResourceEditsTest.php index b5a1d1e5..24803379 100644 --- a/tests/Feature/ResourceEditsTest.php +++ b/tests/Feature/ResourceEditsTest.php @@ -105,7 +105,7 @@ public function test_cannot_post_resource_edit_with_no_changes(): void if ($response->status() !== 422) { dump('Unexpected response status: ' . $response->status()); - dump('Response JSON:', $response->json()); + dump('Response:', $response); } $response->assertStatus(422); @@ -146,82 +146,65 @@ public function test_can_post_valid_resource_edit(): void */ public function test_merged_edit_reflects_changes_on_original_resource(): void { - // Create a resource. $resource = ComputerScienceResource::factory()->create(); - - // Stub the ResourceEditsService to always allow merging. + + // Stub the ResourceEditsService to always allow merging $this->instance( ResourceEditsService::class, Mockery::mock(ResourceEditsService::class, function (MockInterface $mock) { - $mock->shouldReceive('canMergeEdits') - ->andReturnTrue(); + $mock->shouldReceive('canMergeEdits')->andReturnTrue(); }) ); $mergeAttempts = 3; - + for ($i = 0; $i < $mergeAttempts; $i++) { - // Create a fresh copy of the resource every loop to avoid stale values. $resource->refresh(); - - // Create a unique edit for the same resource. + $editData = ComputerScienceResourceTestResource::fake(); - // Required for edit + $editData['edit_title'] = "Edit #$i"; $editData['edit_description'] = "This is edit number $i."; - - // Change all the fields - $editData['name'] = $resource->name . " Edited {$i}"; - $editData['description'] = $resource->description . " Changed {$i}"; - $editData['platforms'] = ['course', 'bootcamp']; - $editData['page_url'] = "https://example.com/edited-url-{$i}"; - $editData['image_url'] = "https://example.com/edited-url-{$i}"; - $editData['difficulty'] = 'beginner'; - $editData['pricing'] = 'free'; - $editData['topic_tags'] = ["Tag {$i}-A", "Tag {$i}-B", "Tag {$i}-C"]; - - $editData['general_tags'] = ["Tag {$i}-A", "Tag {$i}-B", "Tag {$i}-C"]; - $editData['programming_language_tags'] = ["Tag {$i}-A", "Tag {$i}-B", "Tag {$i}-C"]; - - // Randomly unset optional fields - if (rand(0, 1)) $editData['image_url'] = ''; - if (rand(0, 1)) $editData['general_tags'] = []; - if (rand(0, 1)) $editData['programming_language_tags'] = []; - - // Post the edit - $response = $this->actingAs($this->user)->post(route('resource_edits.store', ['computerScienceResource' => $resource->id]), $editData); + + $editData['name'] = "Resource Name Edited {$i}"; + $editData['description'] = "Resource Description Changed {$i}"; + + // Submit the edit + $this->actingAs($this->user); + $response = $this->post( + route('resource_edits.store', ['computerScienceResource' => $resource->id]), + $editData + ); $response->assertStatus(302); - - // Retrieve the edit - $resourceEdits = ResourceEdits::latest()->first(); - $this->assertNotNull($resourceEdits); - + + $edit = ResourceEdits::latest()->first(); + $this->assertNotNull($edit, 'Failed to create resource edit'); + // Merge the edit - $mergeResponse = $this->post(route('resource_edits.merge', ['resourceEdits' => $resourceEdits->id])); - + $mergeResponse = $this->post(route('resource_edits.merge', ['resourceEdits' => $edit->id])); $mergeResponse ->assertRedirect(route('resources.show', ['computerScienceResource' => $resource->id])) ->assertSessionHas('success', 'Successfully merged new changed!'); - - // Refresh and assert that changes took effect + + // Refresh and assert $resource->refresh(); - // Check all required fields - $this->assertEquals($editData['name'], $resource->name); - $this->assertEquals($editData['description'], $resource->description); - $this->assertEquals($editData['platforms'], $resource->platforms); - $this->assertEquals($editData['page_url'], $resource->page_url); - $this->assertEquals($editData['difficulty'], $resource->difficulty); - $this->assertEquals($editData['pricing'], $resource->pricing); - $this->assertEquals($editData['topic_tags'], $resource->topic_tags); - - // Check optional fields - $this->assertEquals($editData['image_url'], $resource->image_url); - $this->assertEquals($editData['general_tags'], $resource->general_tags); - $this->assertEquals($editData['programming_language_tags'], $resource->programming_language_tags); - - $this->assertDatabaseMissing('resource_edits', ['id' => $resourceEdits->id]); + $this->assertEquals($editData['name'], $resource->name, "Name did not update"); + $this->assertEquals($editData['description'], $resource->description, "Description did not update"); + + $this->assertEquals( + $editData['general_tags'], + $resource->general_tags, + "General tags did not update (expected " . json_encode($editData['general_tags']) . ", got " . json_encode($resource->general_tags) . ")" + ); + + $this->assertEquals( + $editData['programming_language_tags'], + $resource->programming_language_tags, + "Programming language tags did not update (expected " . json_encode($editData['programming_language_tags']) . ", got " . json_encode($resource->programming_language_tags) . ")" + ); + + $this->assertDatabaseMissing('resource_edits', ['id' => $edit->id]); } } - } From 6692ae9d4296e157a3027b54763578a6e7acee59 Mon Sep 17 00:00:00 2001 From: Allan Kong Date: Sat, 12 Apr 2025 22:48:49 -0600 Subject: [PATCH 4/9] Better test validation for no duplicates --- .../ComputerScienceResourceController.php | 6 +- .../Controllers/ResourceEditsController.php | 26 +++++-- .../Shared/ComputerScienceResourceRequest.php | 2 +- ...reate_computer_science_resources_table.php | 2 +- ..._24_154128_create_resource_edits_table.php | 2 +- tests/Feature/ResourceEditsTest.php | 71 +++++++++++-------- 6 files changed, 71 insertions(+), 38 deletions(-) diff --git a/app/Http/Controllers/ComputerScienceResourceController.php b/app/Http/Controllers/ComputerScienceResourceController.php index 6c294062..fb5aaca7 100644 --- a/app/Http/Controllers/ComputerScienceResourceController.php +++ b/app/Http/Controllers/ComputerScienceResourceController.php @@ -46,7 +46,6 @@ public function store(StoreResourceRequest $request) 'user_id' => Auth::id(), 'name' => $validatedData['name'], 'description' => $validatedData['description'], - 'image_url' => $validatedData['image_url'], 'page_url' => $validatedData['page_url'], 'platforms' => $validatedData['platforms'], 'difficulty' => $validatedData['difficulty'], @@ -55,6 +54,11 @@ public function store(StoreResourceRequest $request) // Add topics as tags $resource->topic_tags = $validatedData['topic_tags']; + + // Add optional image url + if (isset($validatedData['image_url'])) { + $resource->image_url = $validatedData['image_url']; + } // Add programming languages as tags (if provided) if (isset($validatedData['programming_language_tags'])) { diff --git a/app/Http/Controllers/ResourceEditsController.php b/app/Http/Controllers/ResourceEditsController.php index b24af07d..1ab427ac 100644 --- a/app/Http/Controllers/ResourceEditsController.php +++ b/app/Http/Controllers/ResourceEditsController.php @@ -41,6 +41,18 @@ public function create(ComputerScienceResource $computerScienceResource) ]); } + + // TODO: Make an array facade + function normalize($array) { + ksort($array); + foreach ($array as &$value) { + if (is_array($value)) { + sort($value); // Assumes it's a flat array of values + } + } + return $array; + } + /** * Store the edits request. */ @@ -50,10 +62,10 @@ public function store(ComputerScienceResource $computerScienceResource, StoreRes Log::debug("Creating a resource edit: " . json_encode($validatedData)); // Ensure that they are not the same - $originalData = (new ComputerScienceResourceResource($computerScienceResource))->resolve(); - $editData = Arr::only($validatedData, array_keys($originalData)); - - // Compare the two arrays. + $originalData = $this->normalize((new ComputerScienceResourceResource($computerScienceResource))->resolve()); + $editData = $this->normalize($validatedData); + unset($editData['edit_title'], $editData['edit_description']); + if ($originalData == $editData) { return response()->json(['message' => 'No changes detected'], 422); } @@ -65,7 +77,6 @@ public function store(ComputerScienceResource $computerScienceResource, StoreRes 'edit_description' => $validatedData['edit_description'], 'name' => $validatedData['name'], 'description' => $validatedData['description'], - 'image_url' => $validatedData['image_url'], 'page_url' => $validatedData['page_url'], 'platforms' => $validatedData['platforms'], 'difficulty' => $validatedData['difficulty'], @@ -75,6 +86,11 @@ public function store(ComputerScienceResource $computerScienceResource, StoreRes 'general_tags' => $validatedData['general_tags'], ]); + // Add optional image url + if (isset($validatedData['image_url'])) { + $resourceEdit->image_url = $validatedData['image_url']; + } + return redirect()->route('resource_edits.show', ['resourceEdits' => $resourceEdit->id]) ->with('success', 'The proposed edits were created. Other\'s can now view it.'); } diff --git a/app/Http/Requests/Shared/ComputerScienceResourceRequest.php b/app/Http/Requests/Shared/ComputerScienceResourceRequest.php index aa447c7c..f85d4ed4 100644 --- a/app/Http/Requests/Shared/ComputerScienceResourceRequest.php +++ b/app/Http/Requests/Shared/ComputerScienceResourceRequest.php @@ -14,7 +14,7 @@ public function baseResourceRules(): array 'platforms' => ['required', 'array'], 'platforms.*' => ['distinct', 'string', Rule::in(config('computerScienceResource.platforms'))], 'page_url' => ['required', 'string', 'url:http,https', 'max:255'], - 'image_url' => ['required', 'string', 'url:http,https', 'max:255'], + 'image_url' => ['nullable', 'string', 'url:http,https', 'max:255'], 'difficulty' => ['required', 'string', Rule::in(config('computerScienceResource.difficulties'))], 'pricing' => ['required', 'string', Rule::in(config('computerScienceResource.pricings'))], 'topic_tags' => ['required', 'array', 'min:3'], diff --git a/database/migrations/2025_01_24_171530_create_computer_science_resources_table.php b/database/migrations/2025_01_24_171530_create_computer_science_resources_table.php index 8e806871..1a97f74e 100644 --- a/database/migrations/2025_01_24_171530_create_computer_science_resources_table.php +++ b/database/migrations/2025_01_24_171530_create_computer_science_resources_table.php @@ -21,7 +21,7 @@ public function up(): void $table->string('name')->fulltext(); $table->text('description')->fulltext(); - $table->string('image_url')->default(''); + $table->string('image_url')->nullable(); // TODO: Have a url for each platform the resource is on. $table->string('page_url'); diff --git a/database/migrations/2025_03_24_154128_create_resource_edits_table.php b/database/migrations/2025_03_24_154128_create_resource_edits_table.php index f2a35fa0..74886f2e 100644 --- a/database/migrations/2025_03_24_154128_create_resource_edits_table.php +++ b/database/migrations/2025_03_24_154128_create_resource_edits_table.php @@ -31,7 +31,7 @@ public function up(): void $table->text('description')->fulltext(); // TODO: have it be nullable or something - $table->string('image_url'); + $table->string('image_url')->nullable(); $table->string('page_url'); diff --git a/tests/Feature/ResourceEditsTest.php b/tests/Feature/ResourceEditsTest.php index 24803379..3880090e 100644 --- a/tests/Feature/ResourceEditsTest.php +++ b/tests/Feature/ResourceEditsTest.php @@ -2,11 +2,13 @@ namespace Tests\Feature; +use App\Http\Resources\ComputerScienceResourceResource; use App\Models\ComputerScienceResource; use App\Models\ResourceEdits; use App\Models\User; use App\Services\ResourceEditsService; use Illuminate\Foundation\Testing\RefreshDatabase; +use Illuminate\Support\Facades\Log; use Mockery; use Mockery\MockInterface; use Tests\TestCase; @@ -80,38 +82,49 @@ public function test_cannot_post_resource_edit_with_no_changes(): void { $this->actingAs($this->user); - // Create the original resource. - $resource = ComputerScienceResource::factory()->create(); + $times = 7; - // Create valid edit payload, then set fields to exactly match the resource. - - $editData = array(); - $editData['name'] = $resource->name; - $editData['description'] = $resource->description; - $editData['page_url'] = $resource->page_url; - $editData['image_url'] = $resource->image_url; - $editData['platforms'] = $resource->platforms; - $editData['difficulty'] = $resource->difficulty; - $editData['pricing'] = $resource->pricing; - $editData['topic_tags'] = $resource->topic_tags; - $editData['programming_language_tags'] = $resource->programming_language_tags; - $editData['general_tags'] = $resource->general_tags; - - // Add required edit-specific fields. - $editData['edit_title'] = 'Proposed edit with no changes'; - $editData['edit_description'] = 'This edit does nothing.'; - - $response = $this->postJson(route('resource_edits.store', $resource), $editData); - - if ($response->status() !== 422) { - dump('Unexpected response status: ' . $response->status()); - dump('Response:', $response); + for ($i = 0; $i < $times; $i++) + { + // Create the original resource. + $resource = ComputerScienceResource::factory()->create(); + + // Create valid edit payload, then set fields to exactly match the resource. + + $editData = array(); + $editData['name'] = $resource->name; + $editData['description'] = $resource->description; + $editData['page_url'] = $resource->page_url; + + if ($resource->image_url) + { + $editData['image_url'] = $resource->image_url; + } + + $editData['platforms'] = $resource->platforms; + $editData['difficulty'] = $resource->difficulty; + $editData['pricing'] = $resource->pricing; + $editData['topic_tags'] = $resource->topic_tags; + $editData['programming_language_tags'] = $resource->programming_language_tags; + $editData['general_tags'] = $resource->general_tags; + + // Add required edit-specific fields. + $editData['edit_title'] = 'Proposed edit with no changes'; + $editData['edit_description'] = 'This edit does nothing.'; + + $response = $this->postJson(route('resource_edits.store', $resource), $editData); + + if ($response->status() !== 422) { + Log::debug("here"); + Log::debug('editData:'. json_encode($editData)); + Log::debug('resource:'. json_encode(new ComputerScienceResourceResource($resource))); + } + + $response->assertStatus(422); } - - $response->assertStatus(422); } - - /** + + /** * Test that a valid resource edit can be posted. */ public function test_can_post_valid_resource_edit(): void From afc167509f007044916e48062e2dc580afec0bcb Mon Sep 17 00:00:00 2001 From: Allan Kong Date: Sun, 13 Apr 2025 09:26:28 -0600 Subject: [PATCH 5/9] Resource edits finished tests --- .../ComputerScienceResourceController.php | 6 +- .../Controllers/ResourceEditsController.php | 6 +- .../StoreResourceRequest.php | 7 +- .../ResourceEdit/StoreResourceEdit.php | 5 - .../Shared/ComputerScienceResourceRequest.php | 7 ++ config/computerScienceResource.php | 2 - .../ComputerScienceResourceControllerTest.php | 2 + tests/Feature/ResourceEditsTest.php | 109 ++++++++++++++---- 8 files changed, 99 insertions(+), 45 deletions(-) diff --git a/app/Http/Controllers/ComputerScienceResourceController.php b/app/Http/Controllers/ComputerScienceResourceController.php index fb5aaca7..b63ea7d1 100644 --- a/app/Http/Controllers/ComputerScienceResourceController.php +++ b/app/Http/Controllers/ComputerScienceResourceController.php @@ -45,6 +45,7 @@ public function store(StoreResourceRequest $request) $resource = ComputerScienceResource::create([ 'user_id' => Auth::id(), 'name' => $validatedData['name'], + 'image_url' => $validatedData['image_url'], 'description' => $validatedData['description'], 'page_url' => $validatedData['page_url'], 'platforms' => $validatedData['platforms'], @@ -55,11 +56,6 @@ public function store(StoreResourceRequest $request) // Add topics as tags $resource->topic_tags = $validatedData['topic_tags']; - // Add optional image url - if (isset($validatedData['image_url'])) { - $resource->image_url = $validatedData['image_url']; - } - // Add programming languages as tags (if provided) if (isset($validatedData['programming_language_tags'])) { $resource->programming_language_tags = $validatedData['programming_language_tags']; diff --git a/app/Http/Controllers/ResourceEditsController.php b/app/Http/Controllers/ResourceEditsController.php index 1ab427ac..1d5a2b5b 100644 --- a/app/Http/Controllers/ResourceEditsController.php +++ b/app/Http/Controllers/ResourceEditsController.php @@ -75,6 +75,7 @@ public function store(ComputerScienceResource $computerScienceResource, StoreRes 'computer_science_resource_id' => $computerScienceResource->id, 'edit_title' => $validatedData['edit_title'], 'edit_description' => $validatedData['edit_description'], + 'image_url' => $validatedData['image_url'], 'name' => $validatedData['name'], 'description' => $validatedData['description'], 'page_url' => $validatedData['page_url'], @@ -86,11 +87,6 @@ public function store(ComputerScienceResource $computerScienceResource, StoreRes 'general_tags' => $validatedData['general_tags'], ]); - // Add optional image url - if (isset($validatedData['image_url'])) { - $resourceEdit->image_url = $validatedData['image_url']; - } - return redirect()->route('resource_edits.show', ['resourceEdits' => $resourceEdit->id]) ->with('success', 'The proposed edits were created. Other\'s can now view it.'); } diff --git a/app/Http/Requests/ComputerScienceResource/StoreResourceRequest.php b/app/Http/Requests/ComputerScienceResource/StoreResourceRequest.php index 56b18dbf..3923bf33 100644 --- a/app/Http/Requests/ComputerScienceResource/StoreResourceRequest.php +++ b/app/Http/Requests/ComputerScienceResource/StoreResourceRequest.php @@ -25,11 +25,6 @@ public function authorize(): bool */ public function rules(): array { - return array_merge($this->baseResourceRules(), [ - 'general_tags' => ['nullable', 'array'], - 'general_tags.*' => ['distinct', 'string', 'max:50'], - 'programming_language_tags' => ['nullable', 'array'], - 'programming_language_tags.*' => ['distinct', 'string', 'max:50'], - ]); + return $this->baseResourceRules(); } } diff --git a/app/Http/Requests/ResourceEdit/StoreResourceEdit.php b/app/Http/Requests/ResourceEdit/StoreResourceEdit.php index d125371e..52e2065a 100644 --- a/app/Http/Requests/ResourceEdit/StoreResourceEdit.php +++ b/app/Http/Requests/ResourceEdit/StoreResourceEdit.php @@ -27,11 +27,6 @@ public function rules(): array return array_merge($this->baseResourceRules(), [ 'edit_title' => ['required', 'string', 'max:100'], 'edit_description' => ['required', 'string', 'max:10000'], - - 'general_tags' => ['required', 'array'], - 'general_tags.*' => ['required', 'string', 'max:50'], - 'programming_language_tags' => ['required', 'array'], - 'programming_language_tags.*' => ['distinct', 'string', 'max:50'], ]); } } diff --git a/app/Http/Requests/Shared/ComputerScienceResourceRequest.php b/app/Http/Requests/Shared/ComputerScienceResourceRequest.php index f85d4ed4..0ca2e0a6 100644 --- a/app/Http/Requests/Shared/ComputerScienceResourceRequest.php +++ b/app/Http/Requests/Shared/ComputerScienceResourceRequest.php @@ -17,8 +17,15 @@ public function baseResourceRules(): array 'image_url' => ['nullable', 'string', 'url:http,https', 'max:255'], 'difficulty' => ['required', 'string', Rule::in(config('computerScienceResource.difficulties'))], 'pricing' => ['required', 'string', Rule::in(config('computerScienceResource.pricings'))], + 'topic_tags' => ['required', 'array', 'min:3'], 'topic_tags.*' => ['required', 'distinct', 'string', 'max:50'], + + // Optional + 'general_tags' => ['array'], + 'general_tags.*' => ['required', 'distinct', 'string', 'max:50'], + 'programming_language_tags' => ['array'], + 'programming_language_tags.*' => ['required', 'distinct', 'string', 'max:50'], ]; } diff --git a/config/computerScienceResource.php b/config/computerScienceResource.php index 09e461c8..e7a66c61 100644 --- a/config/computerScienceResource.php +++ b/config/computerScienceResource.php @@ -1,7 +1,5 @@ ['book', 'podcast', 'youtube_channel', 'blog', 'website', 'organization', 'bootcamp', 'newsletter', 'workshop', 'course', 'forum', 'mobile_app', 'desktop_app', 'magazine'], 'difficulties' => ['beginner', 'industry_simple', 'industry_standard', 'industry_professional', 'academic'], diff --git a/tests/Feature/ComputerScienceResourceControllerTest.php b/tests/Feature/ComputerScienceResourceControllerTest.php index 985b1faa..270401b8 100644 --- a/tests/Feature/ComputerScienceResourceControllerTest.php +++ b/tests/Feature/ComputerScienceResourceControllerTest.php @@ -80,6 +80,8 @@ public function test_cannot_post_resource_with_invalid_fields() 'pricing' => 'invalid_pricing', 'topic_tags' => ['tag1', 'tag2'], // Less than required minimum of 3 'image_url' => 'not-a-url', + 'programming_language_tags' => null, + 'general_tags' => ['a','a','a'], // Not distinct ]; // Choose from one of the invalid fields diff --git a/tests/Feature/ResourceEditsTest.php b/tests/Feature/ResourceEditsTest.php index 3880090e..8ba301ef 100644 --- a/tests/Feature/ResourceEditsTest.php +++ b/tests/Feature/ResourceEditsTest.php @@ -95,12 +95,7 @@ public function test_cannot_post_resource_edit_with_no_changes(): void $editData['name'] = $resource->name; $editData['description'] = $resource->description; $editData['page_url'] = $resource->page_url; - - if ($resource->image_url) - { - $editData['image_url'] = $resource->image_url; - } - + $editData['image_url'] = $resource->image_url; $editData['platforms'] = $resource->platforms; $editData['difficulty'] = $resource->difficulty; $editData['pricing'] = $resource->pricing; @@ -157,6 +152,9 @@ public function test_can_post_valid_resource_edit(): void * Test that merging an edit updates the original resource. * We run multiple merges to simulate multiple edit merges. */ + + // TODO: Handle null images + // TODO: Handle all fields and attributes public function test_merged_edit_reflects_changes_on_original_resource(): void { $resource = ComputerScienceResource::factory()->create(); @@ -169,7 +167,7 @@ public function test_merged_edit_reflects_changes_on_original_resource(): void }) ); - $mergeAttempts = 3; + $mergeAttempts = 10; for ($i = 0; $i < $mergeAttempts; $i++) { $resource->refresh(); @@ -181,7 +179,16 @@ public function test_merged_edit_reflects_changes_on_original_resource(): void $editData['name'] = "Resource Name Edited {$i}"; $editData['description'] = "Resource Description Changed {$i}"; - + $editData['image_url'] = fake()->randomElement(["http://{$i}.com", null]); + $editData['page_url'] = "http://{$i}.com"; + $editData['difficulty'] = fake()->randomElement(config('computerScienceResource.difficulties')); + $editData['platforms'] = fake()->randomElements(config('computerScienceResource.platforms'), fake()->numberBetween(1, 3)); + $editData['pricing'] = fake()->randomElement(config('computerScienceResource.pricings')); + $editData['topic_tags'] = ["{$i}_a", "{$i}_b", "{$i}_c"]; + + $editData['programming_language_tags'] = ["{$i}_a", "{$i}_b", "{$i}_c"]; + $editData['general_tags'] = ["{$i}_a", "{$i}_b", "{$i}_c"]; + // Submit the edit $this->actingAs($this->user); $response = $this->post( @@ -202,22 +209,80 @@ public function test_merged_edit_reflects_changes_on_original_resource(): void // Refresh and assert $resource->refresh(); - $this->assertEquals($editData['name'], $resource->name, "Name did not update"); - $this->assertEquals($editData['description'], $resource->description, "Description did not update"); - - $this->assertEquals( - $editData['general_tags'], - $resource->general_tags, - "General tags did not update (expected " . json_encode($editData['general_tags']) . ", got " . json_encode($resource->general_tags) . ")" - ); - - $this->assertEquals( - $editData['programming_language_tags'], - $resource->programming_language_tags, - "Programming language tags did not update (expected " . json_encode($editData['programming_language_tags']) . ", got " . json_encode($resource->programming_language_tags) . ")" - ); + $this->assertEquals($editData['name'], $resource->name); + $this->assertEquals($editData['description'], $resource->description); + $this->assertEquals($editData['image_url'], $resource->image_url); + $this->assertEquals($editData['page_url'], $resource->page_url); + $this->assertEquals($editData['difficulty'], $resource->difficulty); + $this->assertEquals($editData['pricing'], $resource->pricing); + + // Arrays + $this->assertEqualsCanonicalizing($editData['platforms'], $resource->platforms); + $this->assertEqualsCanonicalizing($editData['topic_tags'], $resource->topic_tags); + $this->assertEqualsCanonicalizing($editData['programming_language_tags'], $resource->programming_language_tags); + $this->assertEqualsCanonicalizing($editData['general_tags'], $resource->general_tags); $this->assertDatabaseMissing('resource_edits', ['id' => $edit->id]); } } + + public function test_merged_delete_edit_reflects_changes_on_original_resource(): void + { + $resource = ComputerScienceResource::factory()->create(); + + // Stub the ResourceEditsService to always allow merging + $this->instance( + ResourceEditsService::class, + Mockery::mock(ResourceEditsService::class, function (MockInterface $mock) { + $mock->shouldReceive('canMergeEdits')->andReturnTrue(); + }) + ); + + $editData = (new ComputerScienceResourceTestResource($resource))->resolve(); + + $editData['edit_title'] = "Edit"; + $editData['edit_description'] = "This is edit"; + + $editData['name'] = "Resource Name Edited"; + $editData['description'] = "Resource Description Changed"; + $editData['image_url'] = null; // Delete operation + $editData['topic_tags'] = ['1', '2', '3']; + $editData['programming_language_tags'] = []; + $editData['general_tags'] = []; + + // Submit the edit + $this->actingAs($this->user); + $response = $this->post( + route('resource_edits.store', ['computerScienceResource' => $resource->id]), + $editData + ); + $response->assertStatus(302); + + $edit = ResourceEdits::latest()->first(); + $this->assertNotNull($edit, 'Failed to create resource edit'); + + // Merge the edit + $mergeResponse = $this->post(route('resource_edits.merge', ['resourceEdits' => $edit->id])); + $mergeResponse + ->assertRedirect(route('resources.show', ['computerScienceResource' => $resource->id])) + ->assertSessionHas('success', 'Successfully merged new changed!'); + + // Refresh and assert + $resource->refresh(); + + $this->assertEquals("Resource Name Edited", $resource->name); + $this->assertEquals("Resource Description Changed", $resource->description); + $this->assertNull($resource->image_url); // Since we unset it + $this->assertEquals($editData['page_url'], $resource->page_url); + $this->assertEquals($editData['difficulty'], $resource->difficulty); + $this->assertEquals($editData['pricing'], $resource->pricing); + + // Arrays + $this->assertEqualsCanonicalizing($editData['platforms'], $resource->platforms); + $this->assertEqualsCanonicalizing($editData['topic_tags'], $resource->topic_tags); + $this->assertEqualsCanonicalizing($editData['programming_language_tags'], $resource->programming_language_tags); + $this->assertEqualsCanonicalizing($editData['general_tags'], $resource->general_tags); + + $this->assertDatabaseMissing('resource_edits', ['id' => $edit->id]); + } } From 24fbfbd7b6eb018f1e6456500dced84b39013d3d Mon Sep 17 00:00:00 2001 From: Allan Kong Date: Sun, 13 Apr 2025 09:27:52 -0600 Subject: [PATCH 6/9] 1 more validation rule in computer science resource --- app/Http/Requests/Shared/ComputerScienceResourceRequest.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/Http/Requests/Shared/ComputerScienceResourceRequest.php b/app/Http/Requests/Shared/ComputerScienceResourceRequest.php index 0ca2e0a6..79780869 100644 --- a/app/Http/Requests/Shared/ComputerScienceResourceRequest.php +++ b/app/Http/Requests/Shared/ComputerScienceResourceRequest.php @@ -11,8 +11,8 @@ public function baseResourceRules(): array return [ 'name' => ['required', 'string', 'max:100'], 'description' => ['required', 'string', 'max:10000'], - 'platforms' => ['required', 'array'], - 'platforms.*' => ['distinct', 'string', Rule::in(config('computerScienceResource.platforms'))], + 'platforms' => ['required', 'array', 'min:1'], + 'platforms.*' => ['required', 'distinct', 'string', Rule::in(config('computerScienceResource.platforms'))], 'page_url' => ['required', 'string', 'url:http,https', 'max:255'], 'image_url' => ['nullable', 'string', 'url:http,https', 'max:255'], 'difficulty' => ['required', 'string', Rule::in(config('computerScienceResource.difficulties'))], From 47bd99807a3596099f7fd14db92feaeaeb863e4f Mon Sep 17 00:00:00 2001 From: Allan Kong Date: Tue, 15 Apr 2025 16:33:06 -0600 Subject: [PATCH 7/9] Can only have 1 resource review --- .../Controllers/ResourceReviewController.php | 17 +++++++++-- database/factories/ResourceReviewFactory.php | 6 +--- ...4_225555_create_resource_reviews_table.php | 5 ++++ tests/Feature/ResourceReviewsTest.php | 29 ++++++++++++++++--- 4 files changed, 46 insertions(+), 11 deletions(-) diff --git a/app/Http/Controllers/ResourceReviewController.php b/app/Http/Controllers/ResourceReviewController.php index 8702c70d..6673ad51 100644 --- a/app/Http/Controllers/ResourceReviewController.php +++ b/app/Http/Controllers/ResourceReviewController.php @@ -8,17 +8,29 @@ use App\Models\ResourceReview; use Auth; use Illuminate\Support\Facades\Log; +use Redirect; class ResourceReviewController extends Controller { // Store the review on the resource public function store(StoreResourceReview $request, ComputerScienceResource $computerScienceResource) { - Log::debug("Storing resource review: " . json_encode($request)); - // Validate the request data $validatedData = $request->validated(); + $existingReview = ResourceReview::where([ + 'user_id' => Auth::id(), + 'computer_science_resource_id' => $computerScienceResource->id, + ])->first(); + + if ($existingReview) { + Log::debug("User has already posted a review"); + // TODO: Make it a json with errors instead + return back()->with('warning', 'You already have a review posted, you should edit your existing one instead.'); + } + + Log::debug("Storing resource review: " . json_encode($validatedData)); + // Create the resource review $review = ResourceReview::create([ 'user_id' => Auth::id(), @@ -37,6 +49,7 @@ public function store(StoreResourceReview $request, ComputerScienceResource $com ResourceReviewProcessed::dispatch($computerScienceResource->id, null, $review->attributesToArray()); + // Json with success return to_route('resources.show', ['computerScienceResource' => $review->computer_science_resource_id]) ->with('success', 'Review created successfully!'); } diff --git a/database/factories/ResourceReviewFactory.php b/database/factories/ResourceReviewFactory.php index d1829ed4..16f2a035 100644 --- a/database/factories/ResourceReviewFactory.php +++ b/database/factories/ResourceReviewFactory.php @@ -20,11 +20,7 @@ class ResourceReviewFactory extends Factory public function definition(): array { return [ - 'user_id' => function () { - return User::inRandomOrder()->firstOr(function () { - return User::factory()->create(); - })->id; - }, + 'user_id' => User::factory()->create()->id, // Is creating a new user since we need users to be unique per review 'computer_science_resource_id' => function () { return ComputerScienceResource::firstOr(function () { return ComputerScienceResource::factory()->create(); diff --git a/database/migrations/2025_02_14_225555_create_resource_reviews_table.php b/database/migrations/2025_02_14_225555_create_resource_reviews_table.php index 62d26a70..f58acb54 100644 --- a/database/migrations/2025_02_14_225555_create_resource_reviews_table.php +++ b/database/migrations/2025_02_14_225555_create_resource_reviews_table.php @@ -17,6 +17,11 @@ public function up(): void $table->id(); $table->foreignIdFor(User::class)->index(); $table->foreignIdFor(ComputerScienceResource::class)->index(); + + $table->unique([ + 'user_id', + 'computer_science_resource_id' + ]); // Text $table->string('title'); diff --git a/tests/Feature/ResourceReviewsTest.php b/tests/Feature/ResourceReviewsTest.php index 09d58ab1..bc751d0a 100644 --- a/tests/Feature/ResourceReviewsTest.php +++ b/tests/Feature/ResourceReviewsTest.php @@ -67,18 +67,39 @@ public function test_resource_review_can_be_posted(): void $data = ResourceReviewTestResource::fake(); - $response = $this->actingAs($user) + $this->actingAs($user) ->post(route('reviews.store', $resource), $data); - $response->assertRedirect(route('resources.show', ['computerScienceResource' => $resource->id])) - ->assertSessionHas('success', 'Review created successfully!'); - $this->assertDatabaseHas('resource_reviews', [ 'computer_science_resource_id' => $resource->id, 'title' => $data['title'] ]); } + public function test_resource_review_cannot_be_posted_twice(): void + { + $user = User::factory()->create(); + $resource = ComputerScienceResource::factory()->create(); + + $data1 = ResourceReviewTestResource::fake(); + $this->actingAs($user) + ->post(route('reviews.store', $resource), $data1); + + $this->assertDatabaseHas('resource_reviews', [ + 'computer_science_resource_id' => $resource->id, + 'title' => $data1['title'] + ]); + + $data2 = ResourceReviewTestResource::fake(); + $this->actingAs($user) + ->post(route('reviews.store', $resource), $data2); + + $this->assertDatabaseMissing('resource_reviews', [ + 'computer_science_resource_id' => $resource->id, + 'title' => $data2['title'] + ]); + } + public function test_resource_average_has_changed(): void { $user = User::factory()->create(); From 004ab084ec5299bfd5cf7117bc21ffb0076f1131 Mon Sep 17 00:00:00 2001 From: Allan Kong Date: Tue, 15 Apr 2025 18:04:30 -0600 Subject: [PATCH 8/9] Upvote test, will delete previous upvote --- tests/Feature/UpvoteTest.php | 36 ++++++++++++++++++++++++++++++++++++ 1 file changed, 36 insertions(+) diff --git a/tests/Feature/UpvoteTest.php b/tests/Feature/UpvoteTest.php index 369ab700..b491edee 100644 --- a/tests/Feature/UpvoteTest.php +++ b/tests/Feature/UpvoteTest.php @@ -149,6 +149,42 @@ public function test_multiple_upvotes() $this->assertEquals(0, $resource->upvoteSummary->downvotes); // No downvotes } + /** + * Test two upvotes from the same user is actually deleting the original vote. + */ + public function test_same_user_double_upvotes() + { + $user = User::factory()->create(); + $resource = ComputerScienceResource::factory()->create(); + + // Upvote twice + $this->actingAs($user); + $this->postJson(route('upvote', ['type' => 'resource', 'id' => $resource->id])); + $this->postJson(route('upvote', ['type' => 'resource', 'id' => $resource->id])); + + // Check if the votes have been deleted correctly + $this->assertEquals(0, $resource->upvoteSummary->upvotes); // No upvotes + $this->assertEquals(0, $resource->upvoteSummary->downvotes); // No downvotes + } + + /** + * Test two upvotes from the same user is actually deleting the original vote. + */ + public function test_same_user_double_downvotes() + { + $user = User::factory()->create(); + $resource = ComputerScienceResource::factory()->create(); + + // Upvote twice + $this->actingAs($user); + $this->postJson(route('downvote', ['type' => 'resource', 'id' => $resource->id])); + $this->postJson(route('downvote', ['type' => 'resource', 'id' => $resource->id])); + + // Check if the votes have been deleted correctly + $this->assertEquals(0, $resource->upvoteSummary->upvotes); // No upvotes + $this->assertEquals(0, $resource->upvoteSummary->downvotes); // No downvotes + } + /** * Test upvote after downvote makes the score 0. */ From 599d0618cc1364cf680ef6049aff63ab02c4f322 Mon Sep 17 00:00:00 2001 From: Allan Kong Date: Tue, 15 Apr 2025 18:05:34 -0600 Subject: [PATCH 9/9] Renaming a test --- tests/Feature/UpvoteTest.php | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/tests/Feature/UpvoteTest.php b/tests/Feature/UpvoteTest.php index b491edee..3a087e45 100644 --- a/tests/Feature/UpvoteTest.php +++ b/tests/Feature/UpvoteTest.php @@ -186,9 +186,9 @@ public function test_same_user_double_downvotes() } /** - * Test upvote after downvote makes the score 0. + * Test upvote after downvote makes the score 1. */ - public function test_upvote_after_downvote_resets_to_zero() + public function test_upvote_after_downvote_is_1() { $user = User::factory()->create(); $resource = ComputerScienceResource::factory()->create(); @@ -207,9 +207,9 @@ public function test_upvote_after_downvote_resets_to_zero() } /** - * Test downvote after upvote makes the score 0. + * Test downvote after upvote makes the score -1. */ - public function test_downvote_after_upvote_resets_to_zero() + public function test_downvote_after_upvote_is_negative_1() { $user = User::factory()->create(); $resource = ComputerScienceResource::factory()->create();