Files
SkinbaseNova/tests/Feature/ArtworkAwardTest.php

435 lines
15 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
declare(strict_types=1);
use App\Models\Artwork;
use App\Models\ArtworkAward;
use App\Models\ArtworkAwardStat;
use App\Models\User;
use App\Jobs\IndexArtworkJob;
use App\Services\HomepageService;
use App\Services\ArtworkAwardService;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Foundation\Http\Middleware\VerifyCsrfToken;
use Illuminate\Support\Facades\Config;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Queue;
uses(RefreshDatabase::class);
beforeEach(function () {
$this->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();
});