withoutMiddleware(VerifyCsrfToken::class); }); // --------------------------------------------------------------------------- // Helper // --------------------------------------------------------------------------- function makePublishedArtwork(array $attrs = []): Artwork { return Artwork::factory()->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(5) ->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(9) ->and($stat->score_7d)->toBe(9) ->and($stat->score_30d)->toBe(9); }); 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×5 + silver×3 + 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 * 5) + (1 * 3) + (1 * 1)); }); test('recent medal scores only count medals inside the rolling windows', function () { $owner = User::factory()->create(); $artwork = makePublishedArtwork(['user_id' => $owner->id]); $service = app(ArtworkAwardService::class); DB::table('artwork_medals')->insert([ [ 'artwork_id' => $artwork->id, 'user_id' => User::factory()->create()->id, 'medal_type' => 'gold', 'weight' => 5, 'created_at' => now()->subDays(2), 'updated_at' => now()->subDays(2), ], [ 'artwork_id' => $artwork->id, 'user_id' => User::factory()->create()->id, 'medal_type' => 'silver', 'weight' => 3, 'created_at' => now()->subDays(10), 'updated_at' => now()->subDays(10), ], [ 'artwork_id' => $artwork->id, 'user_id' => User::factory()->create()->id, 'medal_type' => 'bronze', 'weight' => 1, 'created_at' => now()->subDays(40), 'updated_at' => now()->subDays(40), ], ]); $service->recalcStats($artwork->id); $stat = ArtworkAwardStat::find($artwork->id); expect($stat->score_total)->toBe(9) ->and($stat->score_7d)->toBe(5) ->and($stat->score_30d)->toBe(8); }); // --------------------------------------------------------------------------- // 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}/medal upserts medal state and returns fresh stats', 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}/medal", ['medal_type' => 'gold']) ->assertCreated() ->assertJsonPath('medals.gold', 1) ->assertJsonPath('medals.score', 5) ->assertJsonPath('current_user_medal', 'gold'); $this->actingAs($user) ->postJson("/api/artworks/{$artwork->id}/medal", ['medal_type' => 'silver']) ->assertOk() ->assertJsonPath('medals.gold', 0) ->assertJsonPath('medals.silver', 1) ->assertJsonPath('medals.score', 3) ->assertJsonPath('current_user_medal', 'silver'); expect(ArtworkAward::query() ->where('artwork_id', $artwork->id) ->where('user_id', $user->id) ->count())->toBe(1); }); test('DELETE /api/artworks/{id}/medal removes the current medal idempotently', 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' => 3, ]); $this->actingAs($user) ->deleteJson("/api/artworks/{$artwork->id}/medal") ->assertOk() ->assertJsonPath('current_user_medal', null) ->assertJsonPath('medals.score', 0); $this->actingAs($user) ->deleteJson("/api/artworks/{$artwork->id}/medal") ->assertOk() ->assertJsonPath('current_user_medal', null) ->assertJsonPath('medals.score', 0); }); 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' => 5, ]); $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' => 5, ]); $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' => 3, ]); $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' => 16, 'score_7d' => 8, 'score_30d' => 12, 'created_at' => now(), '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', 16) ->assertJsonPath('awards.score_7d', 8) ->assertJsonPath('awards.score_30d', 12); }); 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' => 5, ]); $stat = ArtworkAwardStat::find($artwork->id); expect($stat->gold_count)->toBe(1) ->and($stat->score_total)->toBe(5); }); // --------------------------------------------------------------------------- // Abuse / security tests // --------------------------------------------------------------------------- test('new account below minimum age 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() ->assertJsonPath('message', 'Your account must be at least 24 hours old before giving medals.'); }); test('unverified account is rejected from the medal endpoint with a clear reason', function () { $user = User::factory()->unverified()->create(['created_at' => now()->subDays(30)]); $artwork = makePublishedArtwork(['user_id' => User::factory()->create()->id]); $this->actingAs($user) ->postJson("/api/artworks/{$artwork->id}/medal", ['medal_type' => 'gold']) ->assertForbidden() ->assertJsonPath('message', 'Verify your email address before giving medals.'); }); 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() ->assertJsonPath('message', 'You cannot medal your own artwork.'); }); // --------------------------------------------------------------------------- // Meilisearch sync test // --------------------------------------------------------------------------- test('awarding a medal dispatches artwork reindexing', function () { Queue::fake(); $service = app(ArtworkAwardService::class); $user = User::factory()->create(); $artwork = makePublishedArtwork(['user_id' => User::factory()->create()->id]); $service->award($artwork, $user, 'silver'); Queue::assertPushed(IndexArtworkJob::class); }); test('removing a medal dispatches artwork reindexing', function () { Queue::fake(); $service = app(ArtworkAwardService::class); $user = User::factory()->create(); $artwork = makePublishedArtwork(['user_id' => User::factory()->create()->id]); DB::table('artwork_medals')->insert([ 'artwork_id' => $artwork->id, 'user_id' => $user->id, 'medal_type' => 'gold', 'weight' => 5, 'created_at' => now(), 'updated_at' => now(), ]); $service->removeAward($artwork, $user); Queue::assertPushed(IndexArtworkJob::class); }); test('cache invalidation occurs after medal updates', function () { $homepage = app(HomepageService::class); $service = app(ArtworkAwardService::class); $user = User::factory()->create(['created_at' => now()->subDays(30), 'email_verified_at' => now()]); $artwork = makePublishedArtwork(['user_id' => User::factory()->create()->id]); $guestPayloadKey = 'homepage.payload.guest.test.' . $artwork->id; Config::set('homepage.guest_payload_key', $guestPayloadKey); Cache::put('homepage.hero', ['stale' => true], 600); Cache::put('homepage.community-favorites.8', ['stale' => true], 600); Cache::put('homepage.hall-of-fame.8', ['stale' => true], 600); Cache::store($homepage->guestPayloadCacheStoreName())->put($guestPayloadKey, ['stale' => true], 600); $service->award($artwork, $user, 'gold'); expect(Cache::get('homepage.hero'))->toBeNull() ->and(Cache::get('homepage.community-favorites.8'))->toBeNull() ->and(Cache::get('homepage.hall-of-fame.8'))->toBeNull() ->and(Cache::store($homepage->guestPayloadCacheStoreName())->get($guestPayloadKey))->toBeNull(); });