create(array_merge([ 'is_public' => true, 'is_approved' => true, ], $attrs)); } // --------------------------------------------------------------------------- // Service-layer tests // --------------------------------------------------------------------------- test('user can award an artwork', function () { $service = app(ArtworkAwardService::class); $user = User::factory()->create(['created_at' => now()->subDays(30)]); $artwork = makePublishedArtwork(['user_id' => User::factory()->create()->id]); $award = $service->award($artwork, $user, 'gold'); expect($award->medal)->toBe('gold') ->and($award->weight)->toBe(3) ->and($award->artwork_id)->toBe($artwork->id) ->and($award->user_id)->toBe($user->id); }); test('stats are recalculated after awarding', function () { $service = app(ArtworkAwardService::class); $owner = User::factory()->create(); $artwork = makePublishedArtwork(['user_id' => $owner->id]); $userA = User::factory()->create(); $userB = User::factory()->create(); $userC = User::factory()->create(); $service->award($artwork, $userA, 'gold'); $service->award($artwork, $userB, 'silver'); $service->award($artwork, $userC, 'bronze'); $stat = ArtworkAwardStat::find($artwork->id); expect($stat->gold_count)->toBe(1) ->and($stat->silver_count)->toBe(1) ->and($stat->bronze_count)->toBe(1) ->and($stat->score_total)->toBe(6); // 3+2+1 }); test('duplicate award is rejected', function () { $service = app(ArtworkAwardService::class); $user = User::factory()->create(); $artwork = makePublishedArtwork(['user_id' => User::factory()->create()->id]); $service->award($artwork, $user, 'gold'); expect(fn () => $service->award($artwork, $user, 'silver')) ->toThrow(Illuminate\Validation\ValidationException::class); }); test('user can change their award', function () { $service = app(ArtworkAwardService::class); $user = User::factory()->create(); $artwork = makePublishedArtwork(['user_id' => User::factory()->create()->id]); $service->award($artwork, $user, 'gold'); $updated = $service->changeAward($artwork, $user, 'bronze'); expect($updated->medal)->toBe('bronze') ->and($updated->weight)->toBe(1); $stat = ArtworkAwardStat::find($artwork->id); expect($stat->gold_count)->toBe(0) ->and($stat->bronze_count)->toBe(1) ->and($stat->score_total)->toBe(1); }); test('user can remove their award', function () { $service = app(ArtworkAwardService::class); $user = User::factory()->create(); $artwork = makePublishedArtwork(['user_id' => User::factory()->create()->id]); $service->award($artwork, $user, 'silver'); $service->removeAward($artwork, $user); expect(ArtworkAward::where('artwork_id', $artwork->id)->where('user_id', $user->id)->exists()) ->toBeFalse(); $stat = ArtworkAwardStat::find($artwork->id); expect($stat)->not->toBeNull() ->and($stat->silver_count)->toBe(0) ->and($stat->score_total)->toBe(0); }); test('score formula is gold×3 + silver×2 + bronze×1', function () { $service = app(ArtworkAwardService::class); $owner = User::factory()->create(); $artwork = makePublishedArtwork(['user_id' => $owner->id]); foreach (['gold', 'gold', 'silver', 'bronze'] as $medal) { $service->award($artwork, User::factory()->create(), $medal); } $stat = ArtworkAwardStat::find($artwork->id); expect($stat->score_total)->toBe((2 * 3) + (1 * 2) + (1 * 1)); // 9 }); // --------------------------------------------------------------------------- // API endpoint tests // --------------------------------------------------------------------------- test('POST /api/artworks/{id}/award — guest is rejected', function () { $artwork = makePublishedArtwork(['user_id' => User::factory()->create()->id]); $this->postJson("/api/artworks/{$artwork->id}/award", ['medal' => 'gold']) ->assertUnauthorized(); }); test('POST /api/artworks/{id}/award — authenticated user can award', function () { $user = User::factory()->create(['created_at' => now()->subDays(30)]); $artwork = makePublishedArtwork(['user_id' => User::factory()->create()->id]); $this->actingAs($user) ->postJson("/api/artworks/{$artwork->id}/award", ['medal' => 'gold']) ->assertCreated() ->assertJsonPath('awards.gold', 1) ->assertJsonPath('viewer_award', 'gold'); }); test('POST /api/artworks/{id}/award — duplicate is rejected with 422', function () { $user = User::factory()->create(['created_at' => now()->subDays(30)]); $artwork = makePublishedArtwork(['user_id' => User::factory()->create()->id]); ArtworkAward::create([ 'artwork_id' => $artwork->id, 'user_id' => $user->id, 'medal' => 'gold', 'weight' => 3, ]); $this->actingAs($user) ->postJson("/api/artworks/{$artwork->id}/award", ['medal' => 'silver']) ->assertUnprocessable(); }); test('PUT /api/artworks/{id}/award — user can change their award', function () { $user = User::factory()->create(['created_at' => now()->subDays(30)]); $artwork = makePublishedArtwork(['user_id' => User::factory()->create()->id]); ArtworkAward::create([ 'artwork_id' => $artwork->id, 'user_id' => $user->id, 'medal' => 'gold', 'weight' => 3, ]); $this->actingAs($user) ->putJson("/api/artworks/{$artwork->id}/award", ['medal' => 'bronze']) ->assertOk() ->assertJsonPath('viewer_award', 'bronze'); }); test('DELETE /api/artworks/{id}/award — user can remove their award', function () { $user = User::factory()->create(['created_at' => now()->subDays(30)]); $artwork = makePublishedArtwork(['user_id' => User::factory()->create()->id]); ArtworkAward::create([ 'artwork_id' => $artwork->id, 'user_id' => $user->id, 'medal' => 'silver', 'weight' => 2, ]); $this->actingAs($user) ->deleteJson("/api/artworks/{$artwork->id}/award") ->assertOk() ->assertJsonPath('viewer_award', null); expect(ArtworkAward::where('artwork_id', $artwork->id)->exists())->toBeFalse(); }); test('GET /api/artworks/{id}/awards — returns stats publicly', function () { $owner = User::factory()->create(); $artwork = makePublishedArtwork(['user_id' => $owner->id]); ArtworkAwardStat::create([ 'artwork_id' => $artwork->id, 'gold_count' => 2, 'silver_count' => 1, 'bronze_count' => 3, 'score_total' => 11, 'updated_at' => now(), ]); $this->getJson("/api/artworks/{$artwork->id}/awards") ->assertOk() ->assertJsonPath('awards.gold', 2) ->assertJsonPath('awards.silver', 1) ->assertJsonPath('awards.bronze', 3) ->assertJsonPath('awards.score', 11); }); test('observer recalculates stats when award is created', function () { $user = User::factory()->create(); $artwork = makePublishedArtwork(['user_id' => User::factory()->create()->id]); ArtworkAward::create([ 'artwork_id' => $artwork->id, 'user_id' => $user->id, 'medal' => 'gold', 'weight' => 3, ]); $stat = ArtworkAwardStat::find($artwork->id); expect($stat->gold_count)->toBe(1) ->and($stat->score_total)->toBe(3); }); // --------------------------------------------------------------------------- // Abuse / security tests // --------------------------------------------------------------------------- test('new account (< 7 days) is rejected with 403', function () { $user = User::factory()->create(['created_at' => now()->subHours(12)]); $artwork = makePublishedArtwork(['user_id' => User::factory()->create()->id]); $this->actingAs($user) ->postJson("/api/artworks/{$artwork->id}/award", ['medal' => 'gold']) ->assertForbidden(); }); test('user cannot award their own artwork', function () { $user = User::factory()->create(['created_at' => now()->subDays(30)]); $artwork = makePublishedArtwork(['user_id' => $user->id]); $this->actingAs($user) ->postJson("/api/artworks/{$artwork->id}/award", ['medal' => 'gold']) ->assertForbidden(); }); // --------------------------------------------------------------------------- // Meilisearch sync test // --------------------------------------------------------------------------- test('syncToSearch is called when an award is given', function () { $service = $this->partialMock( \App\Services\ArtworkAwardService::class, function (\Mockery\MockInterface $mock) { $mock->shouldReceive('syncToSearch')->atLeast()->once(); } ); $user = User::factory()->create(); $artwork = makePublishedArtwork(['user_id' => User::factory()->create()->id]); $service->award($artwork, $user, 'silver'); }); test('syncToSearch is called when an award is removed', function () { $service = $this->partialMock( \App\Services\ArtworkAwardService::class, function (\Mockery\MockInterface $mock) { $mock->shouldReceive('syncToSearch')->atLeast()->once(); } ); $user = User::factory()->create(); $artwork = makePublishedArtwork(['user_id' => User::factory()->create()->id]); ArtworkAward::create([ 'artwork_id' => $artwork->id, 'user_id' => $user->id, 'medal' => 'gold', 'weight' => 3, ]); $service->removeAward($artwork, $user); });