Studio: make grid checkbox rectangular and commit table changes
This commit is contained in:
42
tests/Feature/DiscoverRisingTest.php
Normal file
42
tests/Feature/DiscoverRisingTest.php
Normal file
@@ -0,0 +1,42 @@
|
||||
<?php
|
||||
|
||||
use App\Services\ArtworkService;
|
||||
use Illuminate\Pagination\LengthAwarePaginator;
|
||||
|
||||
beforeEach(function () {
|
||||
$this->artworksMock = Mockery::mock(ArtworkService::class);
|
||||
$this->artworksMock->shouldReceive('getFeaturedArtworks')
|
||||
->andReturn(new LengthAwarePaginator(collect(), 0, 20, 1))
|
||||
->byDefault();
|
||||
$this->artworksMock->shouldReceive('getLatestArtworks')
|
||||
->andReturn(collect())
|
||||
->byDefault();
|
||||
$this->app->instance(ArtworkService::class, $this->artworksMock);
|
||||
});
|
||||
|
||||
it('GET /discover/rising returns 200', function () {
|
||||
$this->get('/discover/rising')
|
||||
->assertStatus(200);
|
||||
});
|
||||
|
||||
it('/discover/rising page contains Rising Now heading', function () {
|
||||
$this->get('/discover/rising')
|
||||
->assertStatus(200)
|
||||
->assertSee('Rising Now', false);
|
||||
});
|
||||
|
||||
it('/discover/rising page includes the rising section pill as active', function () {
|
||||
$this->get('/discover/rising')
|
||||
->assertStatus(200)
|
||||
->assertSee('bg-sky-600', false);
|
||||
});
|
||||
|
||||
it('GET /discover/trending still returns 200', function () {
|
||||
$this->get('/discover/trending')
|
||||
->assertStatus(200);
|
||||
});
|
||||
|
||||
it('home page still renders with rising section data', function () {
|
||||
$this->get('/')
|
||||
->assertStatus(200);
|
||||
});
|
||||
430
tests/Feature/Recommendations/SimilarArtworksHybridTest.php
Normal file
430
tests/Feature/Recommendations/SimilarArtworksHybridTest.php
Normal file
@@ -0,0 +1,430 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Jobs\RecBuildItemPairsFromFavouritesJob;
|
||||
use App\Models\Artwork;
|
||||
use App\Models\ArtworkFavourite;
|
||||
use App\Models\Category;
|
||||
use App\Models\RecArtworkRec;
|
||||
use App\Models\RecItemPair;
|
||||
use App\Models\Tag;
|
||||
use App\Models\User;
|
||||
use App\Services\Recommendations\HybridSimilarArtworksService;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
beforeEach(function () {
|
||||
config(['scout.driver' => 'null']);
|
||||
Cache::flush();
|
||||
});
|
||||
|
||||
// ─── Helper ────────────────────────────────────────────────────────────────────
|
||||
|
||||
function createPublicArtwork(array $attrs = []): Artwork
|
||||
{
|
||||
return Artwork::withoutEvents(function () use ($attrs) {
|
||||
return Artwork::factory()->create(array_merge([
|
||||
'is_public' => true,
|
||||
'is_approved' => true,
|
||||
'published_at' => now()->subHour(),
|
||||
], $attrs));
|
||||
});
|
||||
}
|
||||
|
||||
// ─── API returns fallback if precomputed list is missing ───────────────────────
|
||||
|
||||
it('returns fallback results when no precomputed similar list exists', function () {
|
||||
$artwork = createPublicArtwork();
|
||||
|
||||
// Create some other artworks so the trending fallback can find them
|
||||
$other1 = createPublicArtwork(['published_at' => now()->subMinutes(10)]);
|
||||
$other2 = createPublicArtwork(['published_at' => now()->subMinutes(20)]);
|
||||
|
||||
$service = app(HybridSimilarArtworksService::class);
|
||||
$result = $service->forArtwork($artwork->id, 12);
|
||||
|
||||
// Should still return artworks via trending fallback, not an empty set
|
||||
expect($result)->toBeInstanceOf(\Illuminate\Support\Collection::class)
|
||||
->and($result)->not->toBeEmpty();
|
||||
});
|
||||
|
||||
it('returns empty collection for non-existent artwork', function () {
|
||||
$service = app(HybridSimilarArtworksService::class);
|
||||
$result = $service->forArtwork(999999, 12);
|
||||
|
||||
expect($result)->toBeEmpty();
|
||||
});
|
||||
|
||||
it('returns similar_tags list when hybrid is missing', function () {
|
||||
$artwork = createPublicArtwork();
|
||||
$similar1 = createPublicArtwork();
|
||||
$similar2 = createPublicArtwork();
|
||||
|
||||
RecArtworkRec::create([
|
||||
'artwork_id' => $artwork->id,
|
||||
'rec_type' => 'similar_tags',
|
||||
'model_version' => 'sim_v1',
|
||||
'recs' => [$similar1->id, $similar2->id],
|
||||
'computed_at' => now(),
|
||||
]);
|
||||
|
||||
$service = app(HybridSimilarArtworksService::class);
|
||||
$result = $service->forArtwork($artwork->id, 12);
|
||||
|
||||
expect($result)->toHaveCount(2)
|
||||
->and($result->pluck('id')->all())->toEqual([$similar1->id, $similar2->id]);
|
||||
});
|
||||
|
||||
// ─── Ordering is preserved ─────────────────────────────────────────────────────
|
||||
|
||||
it('preserves precomputed ordering exactly', function () {
|
||||
$artwork = createPublicArtwork();
|
||||
$a = createPublicArtwork();
|
||||
$b = createPublicArtwork();
|
||||
$c = createPublicArtwork();
|
||||
$d = createPublicArtwork();
|
||||
|
||||
// Deliberate non-sequential order
|
||||
$orderedIds = [$c->id, $a->id, $d->id, $b->id];
|
||||
|
||||
RecArtworkRec::create([
|
||||
'artwork_id' => $artwork->id,
|
||||
'rec_type' => 'similar_hybrid',
|
||||
'model_version' => 'sim_v1',
|
||||
'recs' => $orderedIds,
|
||||
'computed_at' => now(),
|
||||
]);
|
||||
|
||||
$service = app(HybridSimilarArtworksService::class);
|
||||
$result = $service->forArtwork($artwork->id, 12);
|
||||
|
||||
expect($result->pluck('id')->all())->toEqual($orderedIds);
|
||||
});
|
||||
|
||||
it('falls through from hybrid to tags preserving order', function () {
|
||||
$artwork = createPublicArtwork();
|
||||
$a = createPublicArtwork();
|
||||
$b = createPublicArtwork();
|
||||
|
||||
RecArtworkRec::create([
|
||||
'artwork_id' => $artwork->id,
|
||||
'rec_type' => 'similar_tags',
|
||||
'model_version' => 'sim_v1',
|
||||
'recs' => [$b->id, $a->id],
|
||||
'computed_at' => now(),
|
||||
]);
|
||||
|
||||
$service = app(HybridSimilarArtworksService::class);
|
||||
$result = $service->forArtwork($artwork->id, 12);
|
||||
|
||||
expect($result->pluck('id')->all())->toEqual([$b->id, $a->id]);
|
||||
});
|
||||
|
||||
// ─── Diversity cap (max per author) is enforced ────────────────────────────────
|
||||
|
||||
it('enforces author diversity cap at runtime', function () {
|
||||
$artwork = createPublicArtwork();
|
||||
|
||||
// One author with 4 artworks
|
||||
$author = User::factory()->create();
|
||||
$sameAuthor1 = createPublicArtwork(['user_id' => $author->id]);
|
||||
$sameAuthor2 = createPublicArtwork(['user_id' => $author->id]);
|
||||
$sameAuthor3 = createPublicArtwork(['user_id' => $author->id]);
|
||||
$sameAuthor4 = createPublicArtwork(['user_id' => $author->id]);
|
||||
|
||||
// Another author with 1 artwork
|
||||
$otherAuthor = User::factory()->create();
|
||||
$diffAuthor = createPublicArtwork(['user_id' => $otherAuthor->id]);
|
||||
|
||||
// Put all 5 in the precomputed list — same author dominates
|
||||
RecArtworkRec::create([
|
||||
'artwork_id' => $artwork->id,
|
||||
'rec_type' => 'similar_hybrid',
|
||||
'model_version' => 'sim_v1',
|
||||
'recs' => [
|
||||
$sameAuthor1->id,
|
||||
$sameAuthor2->id,
|
||||
$sameAuthor3->id,
|
||||
$sameAuthor4->id,
|
||||
$diffAuthor->id,
|
||||
],
|
||||
'computed_at' => now(),
|
||||
]);
|
||||
|
||||
config(['recommendations.similarity.max_per_author' => 2]);
|
||||
|
||||
$service = app(HybridSimilarArtworksService::class);
|
||||
$result = $service->forArtwork($artwork->id, 12);
|
||||
|
||||
// Max 2 from same author, 1 from different author = 3 total
|
||||
$resultByAuthor = $result->groupBy('user_id');
|
||||
foreach ($resultByAuthor as $authorId => $artworks) {
|
||||
expect($artworks->count())->toBeLessThanOrEqual(2);
|
||||
}
|
||||
expect($result)->toHaveCount(3);
|
||||
});
|
||||
|
||||
// ─── Pair building doesn't explode per user ────────────────────────────────────
|
||||
|
||||
it('caps pairs per user to avoid combinatorial explosion', function () {
|
||||
$user = User::factory()->create();
|
||||
|
||||
// Create exactly 5 artworks with favourites (bypass observers to avoid SQLite GREATEST issue)
|
||||
$artworks = [];
|
||||
for ($i = 0; $i < 5; $i++) {
|
||||
$art = createPublicArtwork();
|
||||
$artworks[] = $art;
|
||||
DB::table('artwork_favourites')->insert([
|
||||
'user_id' => $user->id,
|
||||
'artwork_id' => $art->id,
|
||||
'created_at' => now()->subMinutes($i),
|
||||
'updated_at' => now()->subMinutes($i),
|
||||
]);
|
||||
}
|
||||
|
||||
$job = new RecBuildItemPairsFromFavouritesJob();
|
||||
$pairs = $job->pairsForUser($user->id, 5);
|
||||
|
||||
// C(5,2) = 10 pairs max
|
||||
expect($pairs)->toHaveCount(10);
|
||||
|
||||
// Verify each pair is ordered (a < b)
|
||||
foreach ($pairs as [$a, $b]) {
|
||||
expect($a)->toBeLessThan($b);
|
||||
}
|
||||
});
|
||||
|
||||
it('respects the favourites cap for pair generation', function () {
|
||||
$user = User::factory()->create();
|
||||
|
||||
// Create 10 favourites (bypass observers)
|
||||
for ($i = 0; $i < 10; $i++) {
|
||||
$art = createPublicArtwork();
|
||||
DB::table('artwork_favourites')->insert([
|
||||
'user_id' => $user->id,
|
||||
'artwork_id' => $art->id,
|
||||
'created_at' => now()->subMinutes($i),
|
||||
'updated_at' => now()->subMinutes($i),
|
||||
]);
|
||||
}
|
||||
|
||||
// Cap at 3 → C(3,2) = 3 pairs
|
||||
$job = new RecBuildItemPairsFromFavouritesJob();
|
||||
$pairs = $job->pairsForUser($user->id, 3);
|
||||
|
||||
expect($pairs)->toHaveCount(3);
|
||||
});
|
||||
|
||||
it('returns empty pairs for user with only one favourite', function () {
|
||||
$user = User::factory()->create();
|
||||
$art = createPublicArtwork();
|
||||
DB::table('artwork_favourites')->insert([
|
||||
'user_id' => $user->id,
|
||||
'artwork_id' => $art->id,
|
||||
'created_at' => now(),
|
||||
'updated_at' => now(),
|
||||
]);
|
||||
|
||||
$job = new RecBuildItemPairsFromFavouritesJob();
|
||||
$pairs = $job->pairsForUser($user->id, 50);
|
||||
|
||||
expect($pairs)->toBeEmpty();
|
||||
});
|
||||
|
||||
// ─── API endpoint integration ──────────────────────────────────────────────────
|
||||
|
||||
it('returns JSON response from API endpoint with precomputed data', function () {
|
||||
$artwork = createPublicArtwork();
|
||||
$similar = createPublicArtwork();
|
||||
|
||||
RecArtworkRec::create([
|
||||
'artwork_id' => $artwork->id,
|
||||
'rec_type' => 'similar_hybrid',
|
||||
'model_version' => 'sim_v1',
|
||||
'recs' => [$similar->id],
|
||||
'computed_at' => now(),
|
||||
]);
|
||||
|
||||
$response = $this->getJson("/api/art/{$artwork->id}/similar");
|
||||
|
||||
$response->assertOk()
|
||||
->assertJsonStructure(['data'])
|
||||
->assertJsonCount(1, 'data');
|
||||
});
|
||||
|
||||
it('returns 404 for non-existent artwork in API', function () {
|
||||
$response = $this->getJson('/api/art/999999/similar');
|
||||
|
||||
$response->assertNotFound();
|
||||
});
|
||||
|
||||
// ─── RecArtworkRec model ───────────────────────────────────────────────────────
|
||||
|
||||
it('stores and retrieves rec list with correct types', function () {
|
||||
$artwork = createPublicArtwork();
|
||||
$ids = [10, 20, 30];
|
||||
|
||||
$rec = RecArtworkRec::create([
|
||||
'artwork_id' => $artwork->id,
|
||||
'rec_type' => 'similar_hybrid',
|
||||
'model_version' => 'sim_v1',
|
||||
'recs' => $ids,
|
||||
'computed_at' => now(),
|
||||
]);
|
||||
|
||||
$fresh = RecArtworkRec::find($rec->id);
|
||||
expect($fresh->recs)->toBeArray()
|
||||
->and($fresh->recs)->toEqual($ids)
|
||||
->and($fresh->artwork_id)->toBe($artwork->id);
|
||||
});
|
||||
|
||||
// ─── Fallback priority ─────────────────────────────────────────────────────────
|
||||
|
||||
it('chooses similar_behavior when tags and hybrid are missing', function () {
|
||||
$artwork = createPublicArtwork();
|
||||
$beh1 = createPublicArtwork();
|
||||
$beh2 = createPublicArtwork();
|
||||
|
||||
RecArtworkRec::create([
|
||||
'artwork_id' => $artwork->id,
|
||||
'rec_type' => 'similar_behavior',
|
||||
'model_version' => 'sim_v1',
|
||||
'recs' => [$beh1->id, $beh2->id],
|
||||
'computed_at' => now(),
|
||||
]);
|
||||
|
||||
$service = app(HybridSimilarArtworksService::class);
|
||||
$result = $service->forArtwork($artwork->id, 12);
|
||||
|
||||
expect($result->pluck('id')->all())->toEqual([$beh1->id, $beh2->id]);
|
||||
});
|
||||
|
||||
it('filters out unpublished artworks from precomputed list', function () {
|
||||
$artwork = createPublicArtwork();
|
||||
$published = createPublicArtwork();
|
||||
$unpublished = Artwork::withoutEvents(function () {
|
||||
return Artwork::factory()->unpublished()->create();
|
||||
});
|
||||
|
||||
RecArtworkRec::create([
|
||||
'artwork_id' => $artwork->id,
|
||||
'rec_type' => 'similar_hybrid',
|
||||
'model_version' => 'sim_v1',
|
||||
'recs' => [$unpublished->id, $published->id],
|
||||
'computed_at' => now(),
|
||||
]);
|
||||
|
||||
$service = app(HybridSimilarArtworksService::class);
|
||||
$result = $service->forArtwork($artwork->id, 12);
|
||||
|
||||
expect($result->pluck('id')->all())->toEqual([$published->id]);
|
||||
});
|
||||
|
||||
// ─── Type query param support (spec §8) ────────────────────────────────────────
|
||||
|
||||
it('returns specific rec type when ?type=tags is passed', function () {
|
||||
$artwork = createPublicArtwork();
|
||||
$tagSimilar = createPublicArtwork();
|
||||
$behSimilar = createPublicArtwork();
|
||||
|
||||
RecArtworkRec::create([
|
||||
'artwork_id' => $artwork->id,
|
||||
'rec_type' => 'similar_tags',
|
||||
'model_version' => 'sim_v1',
|
||||
'recs' => [$tagSimilar->id],
|
||||
'computed_at' => now(),
|
||||
]);
|
||||
RecArtworkRec::create([
|
||||
'artwork_id' => $artwork->id,
|
||||
'rec_type' => 'similar_behavior',
|
||||
'model_version' => 'sim_v1',
|
||||
'recs' => [$behSimilar->id],
|
||||
'computed_at' => now(),
|
||||
]);
|
||||
|
||||
$service = app(HybridSimilarArtworksService::class);
|
||||
$result = $service->forArtwork($artwork->id, 12, 'tags');
|
||||
|
||||
expect($result->pluck('id')->all())->toEqual([$tagSimilar->id]);
|
||||
});
|
||||
|
||||
it('returns behavior list when ?type=behavior is passed', function () {
|
||||
$artwork = createPublicArtwork();
|
||||
$behSimilar = createPublicArtwork();
|
||||
$tagSimilar = createPublicArtwork();
|
||||
|
||||
RecArtworkRec::create([
|
||||
'artwork_id' => $artwork->id,
|
||||
'rec_type' => 'similar_tags',
|
||||
'model_version' => 'sim_v1',
|
||||
'recs' => [$tagSimilar->id],
|
||||
'computed_at' => now(),
|
||||
]);
|
||||
RecArtworkRec::create([
|
||||
'artwork_id' => $artwork->id,
|
||||
'rec_type' => 'similar_behavior',
|
||||
'model_version' => 'sim_v1',
|
||||
'recs' => [$behSimilar->id],
|
||||
'computed_at' => now(),
|
||||
]);
|
||||
|
||||
$service = app(HybridSimilarArtworksService::class);
|
||||
$result = $service->forArtwork($artwork->id, 12, 'behavior');
|
||||
|
||||
expect($result->pluck('id')->all())->toEqual([$behSimilar->id]);
|
||||
});
|
||||
|
||||
it('passes type query param from API endpoint', function () {
|
||||
$artwork = createPublicArtwork();
|
||||
$tagSimilar = createPublicArtwork();
|
||||
|
||||
RecArtworkRec::create([
|
||||
'artwork_id' => $artwork->id,
|
||||
'rec_type' => 'similar_tags',
|
||||
'model_version' => 'sim_v1',
|
||||
'recs' => [$tagSimilar->id],
|
||||
'computed_at' => now(),
|
||||
]);
|
||||
|
||||
$response = $this->getJson("/api/art/{$artwork->id}/similar?type=tags");
|
||||
|
||||
$response->assertOk()
|
||||
->assertJsonCount(1, 'data');
|
||||
});
|
||||
|
||||
// ─── Cosine normalized pair weights ────────────────────────────────────────────
|
||||
|
||||
it('produces cosine-normalized weights in pair builder', function () {
|
||||
// User A: likes artwork 1, 2
|
||||
$userA = User::factory()->create();
|
||||
$art1 = createPublicArtwork();
|
||||
$art2 = createPublicArtwork();
|
||||
DB::table('artwork_favourites')->insert([
|
||||
['user_id' => $userA->id, 'artwork_id' => $art1->id, 'created_at' => now(), 'updated_at' => now()],
|
||||
['user_id' => $userA->id, 'artwork_id' => $art2->id, 'created_at' => now(), 'updated_at' => now()],
|
||||
]);
|
||||
|
||||
// User B: also likes artwork 1, 2
|
||||
$userB = User::factory()->create();
|
||||
DB::table('artwork_favourites')->insert([
|
||||
['user_id' => $userB->id, 'artwork_id' => $art1->id, 'created_at' => now(), 'updated_at' => now()],
|
||||
['user_id' => $userB->id, 'artwork_id' => $art2->id, 'created_at' => now(), 'updated_at' => now()],
|
||||
]);
|
||||
|
||||
$job = new RecBuildItemPairsFromFavouritesJob();
|
||||
$job->handle();
|
||||
|
||||
$pair = RecItemPair::query()
|
||||
->where('a_artwork_id', min($art1->id, $art2->id))
|
||||
->where('b_artwork_id', max($art1->id, $art2->id))
|
||||
->first();
|
||||
|
||||
expect($pair)->not->toBeNull();
|
||||
// co_like = 2 (both users liked both), likes_A = 2, likes_B = 2
|
||||
// S_beh = 2 / sqrt(2 * 2) = 2 / 2 = 1.0
|
||||
expect($pair->weight)->toBe(1.0);
|
||||
});
|
||||
278
tests/Feature/RisingEngineTest.php
Normal file
278
tests/Feature/RisingEngineTest.php
Normal file
@@ -0,0 +1,278 @@
|
||||
<?php
|
||||
|
||||
use App\Models\Artwork;
|
||||
use App\Models\ArtworkStats;
|
||||
use App\Models\ArtworkMetricSnapshotHourly;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
|
||||
/**
|
||||
* Helper: create an artwork row without triggering observers (avoids GREATEST() SQLite issue).
|
||||
*/
|
||||
function createArtworkWithoutObserver(array $attrs = []): Artwork
|
||||
{
|
||||
return Artwork::withoutEvents(function () use ($attrs) {
|
||||
return Artwork::factory()->create($attrs);
|
||||
});
|
||||
}
|
||||
|
||||
// ─── Snapshot Collection Command ───────────────────────────────────────────
|
||||
|
||||
it('nova:metrics-snapshot-hourly runs without errors', function () {
|
||||
$this->artisan('nova:metrics-snapshot-hourly --dry-run')
|
||||
->assertSuccessful();
|
||||
});
|
||||
|
||||
it('creates snapshot rows for eligible artworks', function () {
|
||||
$artwork = createArtworkWithoutObserver([
|
||||
'is_approved' => true,
|
||||
'is_public' => true,
|
||||
'published_at' => now()->subDay(),
|
||||
]);
|
||||
|
||||
ArtworkStats::upsert([
|
||||
[
|
||||
'artwork_id' => $artwork->id,
|
||||
'views' => 100,
|
||||
'downloads' => 10,
|
||||
'favorites' => 5,
|
||||
'comments_count' => 2,
|
||||
'shares_count' => 1,
|
||||
],
|
||||
], ['artwork_id']);
|
||||
|
||||
$this->artisan('nova:metrics-snapshot-hourly')
|
||||
->assertSuccessful();
|
||||
|
||||
$snapshot = ArtworkMetricSnapshotHourly::where('artwork_id', $artwork->id)->first();
|
||||
expect($snapshot)->not->toBeNull();
|
||||
expect((int) $snapshot->views_count)->toBe(100);
|
||||
expect((int) $snapshot->downloads_count)->toBe(10);
|
||||
expect((int) $snapshot->favourites_count)->toBe(5);
|
||||
});
|
||||
|
||||
it('upserts on duplicate bucket_hour', function () {
|
||||
$artwork = createArtworkWithoutObserver([
|
||||
'is_approved' => true,
|
||||
'is_public' => true,
|
||||
'published_at' => now()->subDay(),
|
||||
]);
|
||||
|
||||
ArtworkStats::upsert([
|
||||
[
|
||||
'artwork_id' => $artwork->id,
|
||||
'views' => 50,
|
||||
'downloads' => 5,
|
||||
'favorites' => 2,
|
||||
],
|
||||
], ['artwork_id']);
|
||||
|
||||
// Run twice — should not throw
|
||||
$this->artisan('nova:metrics-snapshot-hourly')->assertSuccessful();
|
||||
|
||||
// Update stats and run again
|
||||
ArtworkStats::where('artwork_id', $artwork->id)->update(['views' => 75]);
|
||||
$this->artisan('nova:metrics-snapshot-hourly')->assertSuccessful();
|
||||
|
||||
$count = ArtworkMetricSnapshotHourly::where('artwork_id', $artwork->id)->count();
|
||||
expect($count)->toBe(1); // upserted, not duplicated
|
||||
|
||||
$snapshot = ArtworkMetricSnapshotHourly::where('artwork_id', $artwork->id)->first();
|
||||
expect((int) $snapshot->views_count)->toBe(75);
|
||||
});
|
||||
|
||||
// ─── Heat Recalculation Command ────────────────────────────────────────────
|
||||
|
||||
it('nova:recalculate-heat runs without errors', function () {
|
||||
$this->artisan('nova:recalculate-heat --dry-run')
|
||||
->assertSuccessful();
|
||||
});
|
||||
|
||||
it('computes heat_score from snapshot deltas', function () {
|
||||
$artwork = createArtworkWithoutObserver([
|
||||
'is_approved' => true,
|
||||
'is_public' => true,
|
||||
'published_at' => now()->subHours(2),
|
||||
]);
|
||||
|
||||
ArtworkStats::upsert([
|
||||
['artwork_id' => $artwork->id, 'views' => 0, 'downloads' => 0, 'favorites' => 0],
|
||||
], ['artwork_id']);
|
||||
|
||||
$prevHour = now()->startOfHour()->subHour();
|
||||
$currentHour = now()->startOfHour();
|
||||
|
||||
// Previous hour snapshot
|
||||
ArtworkMetricSnapshotHourly::create([
|
||||
'artwork_id' => $artwork->id,
|
||||
'bucket_hour' => $prevHour,
|
||||
'views_count' => 10,
|
||||
'downloads_count' => 2,
|
||||
'favourites_count' => 1,
|
||||
'comments_count' => 0,
|
||||
'shares_count' => 0,
|
||||
]);
|
||||
|
||||
// Current hour snapshot (engagement grew)
|
||||
ArtworkMetricSnapshotHourly::create([
|
||||
'artwork_id' => $artwork->id,
|
||||
'bucket_hour' => $currentHour,
|
||||
'views_count' => 30,
|
||||
'downloads_count' => 5,
|
||||
'favourites_count' => 4,
|
||||
'comments_count' => 2,
|
||||
'shares_count' => 1,
|
||||
]);
|
||||
|
||||
$this->artisan('nova:recalculate-heat')
|
||||
->assertSuccessful();
|
||||
|
||||
$stat = ArtworkStats::where('artwork_id', $artwork->id)->first();
|
||||
expect((float) $stat->heat_score)->toBeGreaterThan(0);
|
||||
|
||||
// Verify delta values cached on stats
|
||||
expect((int) $stat->views_1h)->toBe(20); // 30 - 10
|
||||
expect((int) $stat->downloads_1h)->toBe(3); // 5 - 2
|
||||
expect((int) $stat->favourites_1h)->toBe(3); // 4 - 1
|
||||
expect((int) $stat->comments_1h)->toBe(2); // 2 - 0
|
||||
expect((int) $stat->shares_1h)->toBe(1); // 1 - 0
|
||||
});
|
||||
|
||||
it('handles negative deltas gracefully by clamping to zero', function () {
|
||||
$artwork = createArtworkWithoutObserver([
|
||||
'is_approved' => true,
|
||||
'is_public' => true,
|
||||
'published_at' => now()->subHour(),
|
||||
]);
|
||||
|
||||
ArtworkStats::upsert([
|
||||
['artwork_id' => $artwork->id, 'views' => 0, 'downloads' => 0, 'favorites' => 0],
|
||||
], ['artwork_id']);
|
||||
|
||||
$prevHour = now()->startOfHour()->subHour();
|
||||
$currentHour = now()->startOfHour();
|
||||
|
||||
// Simulate counter reset: current < previous
|
||||
ArtworkMetricSnapshotHourly::create([
|
||||
'artwork_id' => $artwork->id,
|
||||
'bucket_hour' => $prevHour,
|
||||
'views_count' => 100,
|
||||
'downloads_count' => 50,
|
||||
'favourites_count' => 20,
|
||||
'comments_count' => 10,
|
||||
'shares_count' => 5,
|
||||
]);
|
||||
|
||||
ArtworkMetricSnapshotHourly::create([
|
||||
'artwork_id' => $artwork->id,
|
||||
'bucket_hour' => $currentHour,
|
||||
'views_count' => 50, // < prev
|
||||
'downloads_count' => 30, // < prev
|
||||
'favourites_count' => 10, // < prev
|
||||
'comments_count' => 5, // < prev
|
||||
'shares_count' => 2, // < prev
|
||||
]);
|
||||
|
||||
$this->artisan('nova:recalculate-heat')
|
||||
->assertSuccessful();
|
||||
|
||||
$stat = ArtworkStats::where('artwork_id', $artwork->id)->first();
|
||||
expect((float) $stat->heat_score)->toBe(0.0); // all deltas negative → clamped to 0
|
||||
expect((int) $stat->views_1h)->toBe(0);
|
||||
expect((int) $stat->downloads_1h)->toBe(0);
|
||||
});
|
||||
|
||||
// ─── Pruning Command ──────────────────────────────────────────────────────
|
||||
|
||||
it('nova:prune-metric-snapshots removes old data', function () {
|
||||
$artwork = createArtworkWithoutObserver([
|
||||
'is_approved' => true,
|
||||
'is_public' => true,
|
||||
'published_at' => now()->subDays(30),
|
||||
]);
|
||||
|
||||
// Old snapshot (10 days ago)
|
||||
ArtworkMetricSnapshotHourly::create([
|
||||
'artwork_id' => $artwork->id,
|
||||
'bucket_hour' => now()->subDays(10)->startOfHour(),
|
||||
'views_count' => 50,
|
||||
'downloads_count' => 5,
|
||||
'favourites_count' => 2,
|
||||
'comments_count' => 0,
|
||||
'shares_count' => 0,
|
||||
]);
|
||||
|
||||
// Recent snapshot (1 hour ago)
|
||||
ArtworkMetricSnapshotHourly::create([
|
||||
'artwork_id' => $artwork->id,
|
||||
'bucket_hour' => now()->subHour()->startOfHour(),
|
||||
'views_count' => 100,
|
||||
'downloads_count' => 10,
|
||||
'favourites_count' => 5,
|
||||
'comments_count' => 1,
|
||||
'shares_count' => 0,
|
||||
]);
|
||||
|
||||
$this->artisan('nova:prune-metric-snapshots --keep-days=7')
|
||||
->assertSuccessful();
|
||||
|
||||
$remaining = ArtworkMetricSnapshotHourly::where('artwork_id', $artwork->id)->count();
|
||||
expect($remaining)->toBe(1); // only the recent one survives
|
||||
});
|
||||
|
||||
// ─── Heat Formula Unit Check ───────────────────────────────────────────────
|
||||
|
||||
it('heat formula applies age factor correctly', function () {
|
||||
// Newer artwork should get higher heat than older one with same deltas
|
||||
$newArtwork = createArtworkWithoutObserver([
|
||||
'is_approved' => true,
|
||||
'is_public' => true,
|
||||
'published_at' => now()->subHours(1),
|
||||
'created_at' => now()->subHours(1),
|
||||
]);
|
||||
|
||||
$oldArtwork = createArtworkWithoutObserver([
|
||||
'is_approved' => true,
|
||||
'is_public' => true,
|
||||
'published_at' => now()->subDays(30),
|
||||
'created_at' => now()->subDays(30),
|
||||
]);
|
||||
|
||||
$prevHour = now()->startOfHour()->subHour();
|
||||
$currentHour = now()->startOfHour();
|
||||
|
||||
foreach ([$newArtwork, $oldArtwork] as $art) {
|
||||
ArtworkStats::upsert([
|
||||
['artwork_id' => $art->id, 'views' => 0, 'downloads' => 0, 'favorites' => 0],
|
||||
], ['artwork_id']);
|
||||
|
||||
ArtworkMetricSnapshotHourly::create([
|
||||
'artwork_id' => $art->id,
|
||||
'bucket_hour' => $prevHour,
|
||||
'views_count' => 0,
|
||||
'downloads_count' => 0,
|
||||
'favourites_count' => 0,
|
||||
'comments_count' => 0,
|
||||
'shares_count' => 0,
|
||||
]);
|
||||
|
||||
ArtworkMetricSnapshotHourly::create([
|
||||
'artwork_id' => $art->id,
|
||||
'bucket_hour' => $currentHour,
|
||||
'views_count' => 100,
|
||||
'downloads_count' => 10,
|
||||
'favourites_count' => 5,
|
||||
'comments_count' => 3,
|
||||
'shares_count' => 2,
|
||||
]);
|
||||
}
|
||||
|
||||
$this->artisan('nova:recalculate-heat')->assertSuccessful();
|
||||
|
||||
$newStat = ArtworkStats::where('artwork_id', $newArtwork->id)->first();
|
||||
$oldStat = ArtworkStats::where('artwork_id', $oldArtwork->id)->first();
|
||||
|
||||
expect((float) $newStat->heat_score)->toBeGreaterThan(0);
|
||||
expect((float) $oldStat->heat_score)->toBeGreaterThan(0);
|
||||
// Newer artwork should have higher heat score due to age factor
|
||||
expect((float) $newStat->heat_score)->toBeGreaterThan((float) $oldStat->heat_score);
|
||||
});
|
||||
236
tests/Feature/StudioTest.php
Normal file
236
tests/Feature/StudioTest.php
Normal file
@@ -0,0 +1,236 @@
|
||||
<?php
|
||||
|
||||
use App\Models\User;
|
||||
use App\Models\Artwork;
|
||||
use App\Models\ArtworkStats;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
/**
|
||||
* Helper: create an artwork without triggering observers (avoids GREATEST() SQLite issue).
|
||||
*/
|
||||
function studioArtwork(array $attrs = []): Artwork
|
||||
{
|
||||
return Artwork::withoutEvents(fn () => Artwork::factory()->create($attrs));
|
||||
}
|
||||
|
||||
beforeEach(function () {
|
||||
// Register GREATEST() polyfill for SQLite (used by observers on user_statistics)
|
||||
if (DB::connection()->getDriverName() === 'sqlite') {
|
||||
DB::connection()->getPdo()->sqliteCreateFunction('GREATEST', function (...$args) {
|
||||
return max($args);
|
||||
}, -1);
|
||||
}
|
||||
|
||||
$this->user = User::factory()->create();
|
||||
$this->actingAs($this->user);
|
||||
});
|
||||
|
||||
// ── Route Auth Tests ──────────────────────────────────────────────────────────
|
||||
|
||||
test('studio routes require authentication', function () {
|
||||
auth()->logout();
|
||||
|
||||
$routes = [
|
||||
'/studio',
|
||||
'/studio/artworks',
|
||||
'/studio/artworks/drafts',
|
||||
'/studio/artworks/archived',
|
||||
];
|
||||
|
||||
foreach ($routes as $route) {
|
||||
$this->get($route)->assertRedirect('/login');
|
||||
}
|
||||
});
|
||||
|
||||
test('studio dashboard loads for authenticated user', function () {
|
||||
$this->get('/studio')
|
||||
->assertStatus(200);
|
||||
});
|
||||
|
||||
test('studio artworks page loads', function () {
|
||||
$this->get('/studio/artworks')
|
||||
->assertStatus(200);
|
||||
});
|
||||
|
||||
test('studio drafts page loads', function () {
|
||||
$this->get('/studio/artworks/drafts')
|
||||
->assertStatus(200);
|
||||
});
|
||||
|
||||
test('studio archived page loads', function () {
|
||||
$this->get('/studio/artworks/archived')
|
||||
->assertStatus(200);
|
||||
});
|
||||
|
||||
// ── API Tests ─────────────────────────────────────────────────────────────────
|
||||
|
||||
test('studio api requires authentication', function () {
|
||||
auth()->logout();
|
||||
|
||||
$this->getJson('/api/studio/artworks')
|
||||
->assertStatus(401);
|
||||
});
|
||||
|
||||
test('studio api returns artworks for authenticated user', function () {
|
||||
// Create artworks for this user
|
||||
$artwork = studioArtwork([
|
||||
'user_id' => $this->user->id,
|
||||
'is_public' => true,
|
||||
'is_approved' => true,
|
||||
]);
|
||||
|
||||
ArtworkStats::create([
|
||||
'artwork_id' => $artwork->id,
|
||||
'views' => 100,
|
||||
'downloads' => 10,
|
||||
'favorites' => 5,
|
||||
]);
|
||||
|
||||
$this->getJson('/api/studio/artworks')
|
||||
->assertStatus(200)
|
||||
->assertJsonStructure([
|
||||
'data' => [['id', 'title', 'slug', 'views', 'favourites']],
|
||||
'meta' => ['current_page', 'last_page', 'per_page', 'total'],
|
||||
]);
|
||||
});
|
||||
|
||||
test('studio api does not return other users artworks', function () {
|
||||
$otherUser = User::factory()->create();
|
||||
studioArtwork([
|
||||
'user_id' => $otherUser->id,
|
||||
'is_public' => true,
|
||||
'is_approved' => true,
|
||||
]);
|
||||
|
||||
$this->getJson('/api/studio/artworks')
|
||||
->assertStatus(200)
|
||||
->assertJsonCount(0, 'data');
|
||||
});
|
||||
|
||||
// ── Bulk Action Tests ─────────────────────────────────────────────────────────
|
||||
|
||||
test('bulk archive works on owned artworks', function () {
|
||||
$artwork = studioArtwork([
|
||||
'user_id' => $this->user->id,
|
||||
'is_public' => true,
|
||||
]);
|
||||
|
||||
$this->postJson('/api/studio/artworks/bulk', [
|
||||
'action' => 'archive',
|
||||
'artwork_ids' => [$artwork->id],
|
||||
])
|
||||
->assertStatus(200)
|
||||
->assertJsonPath('success', 1);
|
||||
|
||||
expect($artwork->fresh()->trashed())->toBeTrue();
|
||||
});
|
||||
|
||||
test('bulk delete requires confirmation', function () {
|
||||
$artwork = studioArtwork(['user_id' => $this->user->id]);
|
||||
|
||||
$this->postJson('/api/studio/artworks/bulk', [
|
||||
'action' => 'delete',
|
||||
'artwork_ids' => [$artwork->id],
|
||||
])
|
||||
->assertStatus(422);
|
||||
});
|
||||
|
||||
test('bulk delete with confirmation works', function () {
|
||||
$artwork = studioArtwork(['user_id' => $this->user->id]);
|
||||
|
||||
$this->postJson('/api/studio/artworks/bulk', [
|
||||
'action' => 'delete',
|
||||
'artwork_ids' => [$artwork->id],
|
||||
'confirm' => 'DELETE',
|
||||
])
|
||||
->assertStatus(200)
|
||||
->assertJsonPath('success', 1);
|
||||
});
|
||||
|
||||
test('bulk publish on owned artworks', function () {
|
||||
$artwork = studioArtwork([
|
||||
'user_id' => $this->user->id,
|
||||
'is_public' => false,
|
||||
]);
|
||||
|
||||
$this->postJson('/api/studio/artworks/bulk', [
|
||||
'action' => 'publish',
|
||||
'artwork_ids' => [$artwork->id],
|
||||
])
|
||||
->assertStatus(200)
|
||||
->assertJsonPath('success', 1);
|
||||
|
||||
expect($artwork->fresh()->is_public)->toBeTrue();
|
||||
});
|
||||
|
||||
test('bulk action cannot modify other users artworks', function () {
|
||||
$otherUser = User::factory()->create();
|
||||
$artwork = studioArtwork(['user_id' => $otherUser->id]);
|
||||
|
||||
$this->postJson('/api/studio/artworks/bulk', [
|
||||
'action' => 'archive',
|
||||
'artwork_ids' => [$artwork->id],
|
||||
])
|
||||
->assertStatus(422)
|
||||
->assertJsonPath('success', 0)
|
||||
->assertJsonPath('failed', 1);
|
||||
});
|
||||
|
||||
// ── Toggle Tests ──────────────────────────────────────────────────────────────
|
||||
|
||||
test('toggle publish on single artwork', function () {
|
||||
$artwork = studioArtwork([
|
||||
'user_id' => $this->user->id,
|
||||
'is_public' => false,
|
||||
]);
|
||||
|
||||
$this->postJson("/api/studio/artworks/{$artwork->id}/toggle", [
|
||||
'action' => 'publish',
|
||||
])
|
||||
->assertStatus(200)
|
||||
->assertJsonPath('success', true);
|
||||
|
||||
expect($artwork->fresh()->is_public)->toBeTrue();
|
||||
});
|
||||
|
||||
test('toggle on non-owned artwork returns 404', function () {
|
||||
$otherUser = User::factory()->create();
|
||||
$artwork = studioArtwork(['user_id' => $otherUser->id]);
|
||||
|
||||
$this->postJson("/api/studio/artworks/{$artwork->id}/toggle", [
|
||||
'action' => 'archive',
|
||||
])
|
||||
->assertStatus(404);
|
||||
});
|
||||
|
||||
// ── Analytics API Tests ───────────────────────────────────────────────────────
|
||||
|
||||
test('analytics api returns artwork stats', function () {
|
||||
$artwork = studioArtwork(['user_id' => $this->user->id]);
|
||||
ArtworkStats::create([
|
||||
'artwork_id' => $artwork->id,
|
||||
'views' => 500,
|
||||
'downloads' => 20,
|
||||
'favorites' => 30,
|
||||
'shares_count' => 10,
|
||||
'comments_count' => 5,
|
||||
'ranking_score' => 42.5,
|
||||
'heat_score' => 8.3,
|
||||
]);
|
||||
|
||||
$this->getJson("/api/studio/artworks/{$artwork->id}/analytics")
|
||||
->assertStatus(200)
|
||||
->assertJsonStructure([
|
||||
'artwork' => ['id', 'title', 'slug'],
|
||||
'analytics' => ['views', 'favourites', 'shares', 'comments', 'downloads', 'ranking_score', 'heat_score'],
|
||||
]);
|
||||
});
|
||||
|
||||
test('analytics api denies access to other users artwork', function () {
|
||||
$otherUser = User::factory()->create();
|
||||
$artwork = studioArtwork(['user_id' => $otherUser->id]);
|
||||
|
||||
$this->getJson("/api/studio/artworks/{$artwork->id}/analytics")
|
||||
->assertStatus(404);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user