Save workspace changes
This commit is contained in:
@@ -0,0 +1,138 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Models\ActivityEvent;
|
||||
use App\Models\Artwork;
|
||||
use App\Models\User;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
// ── ActivityEvent::record() factory helper ────────────────────────────────────
|
||||
|
||||
it('creates a db row via record()', function () {
|
||||
$user = User::factory()->create();
|
||||
$artwork = Artwork::factory()->create();
|
||||
|
||||
ActivityEvent::record(
|
||||
actorId: $user->id,
|
||||
type: ActivityEvent::TYPE_FAVORITE,
|
||||
targetType: ActivityEvent::TARGET_ARTWORK,
|
||||
targetId: $artwork->id,
|
||||
meta: ['source' => 'test'],
|
||||
);
|
||||
|
||||
$this->assertDatabaseHas('activity_events', [
|
||||
'actor_id' => $user->id,
|
||||
'type' => 'favorite',
|
||||
'target_type' => 'artwork',
|
||||
'target_id' => $artwork->id,
|
||||
]);
|
||||
});
|
||||
|
||||
it('stores all five event types without error', function () {
|
||||
$user = User::factory()->create();
|
||||
$artwork = Artwork::factory()->create();
|
||||
|
||||
$events = [
|
||||
[ActivityEvent::TYPE_UPLOAD, ActivityEvent::TARGET_ARTWORK, $artwork->id],
|
||||
[ActivityEvent::TYPE_COMMENT, ActivityEvent::TARGET_ARTWORK, $artwork->id],
|
||||
[ActivityEvent::TYPE_FAVORITE, ActivityEvent::TARGET_ARTWORK, $artwork->id],
|
||||
[ActivityEvent::TYPE_AWARD, ActivityEvent::TARGET_ARTWORK, $artwork->id],
|
||||
[ActivityEvent::TYPE_FOLLOW, ActivityEvent::TARGET_USER, $user->id],
|
||||
];
|
||||
|
||||
foreach ($events as [$type, $targetType, $targetId]) {
|
||||
ActivityEvent::record($user->id, $type, $targetType, $targetId);
|
||||
}
|
||||
|
||||
expect(ActivityEvent::where('actor_id', $user->id)->count())->toBe(5);
|
||||
});
|
||||
|
||||
it('created_at is populated on the returned instance', function () {
|
||||
$user = User::factory()->create();
|
||||
$artwork = Artwork::factory()->create();
|
||||
|
||||
$event = ActivityEvent::record(
|
||||
$user->id,
|
||||
ActivityEvent::TYPE_COMMENT,
|
||||
ActivityEvent::TARGET_ARTWORK,
|
||||
$artwork->id,
|
||||
);
|
||||
|
||||
expect($event->created_at)->not->toBeNull();
|
||||
});
|
||||
|
||||
it('actor relation resolves after record()', function () {
|
||||
$user = User::factory()->create();
|
||||
$artwork = Artwork::factory()->create();
|
||||
|
||||
$event = ActivityEvent::record($user->id, ActivityEvent::TYPE_UPLOAD, ActivityEvent::TARGET_ARTWORK, $artwork->id);
|
||||
|
||||
expect($event->actor->id)->toBe($user->id);
|
||||
});
|
||||
|
||||
it('meta is null when empty array is passed', function () {
|
||||
$user = User::factory()->create();
|
||||
$artwork = Artwork::factory()->create();
|
||||
|
||||
$event = ActivityEvent::record($user->id, ActivityEvent::TYPE_UPLOAD, ActivityEvent::TARGET_ARTWORK, $artwork->id);
|
||||
|
||||
expect($event->meta)->toBeNull();
|
||||
});
|
||||
|
||||
it('meta is stored when non-empty array is passed', function () {
|
||||
$user = User::factory()->create();
|
||||
$artwork = Artwork::factory()->create();
|
||||
|
||||
$event = ActivityEvent::record(
|
||||
$user->id,
|
||||
ActivityEvent::TYPE_AWARD,
|
||||
ActivityEvent::TARGET_ARTWORK,
|
||||
$artwork->id,
|
||||
['medal' => 'gold'],
|
||||
);
|
||||
|
||||
expect($event->meta)->toBe(['medal' => 'gold']);
|
||||
});
|
||||
|
||||
// ── Community activity feed route ─────────────────────────────────────────────
|
||||
|
||||
it('global activity feed returns 200 for guests', function () {
|
||||
$this->get('/community/activity')->assertStatus(200);
|
||||
});
|
||||
|
||||
it('following tab returns 200 for users with no follows', function () {
|
||||
$user = User::factory()->create();
|
||||
|
||||
$this->actingAs($user)
|
||||
->get('/community/activity?filter=following')
|
||||
->assertStatus(200);
|
||||
});
|
||||
|
||||
it('following tab shows only events from followed users', function () {
|
||||
$user = User::factory()->create();
|
||||
$creator = User::factory()->create();
|
||||
$other = User::factory()->create();
|
||||
$artwork = Artwork::factory()->create();
|
||||
|
||||
// user_followers has no updated_at column
|
||||
DB::table('user_followers')->insert([
|
||||
'user_id' => $creator->id,
|
||||
'follower_id' => $user->id,
|
||||
'created_at' => now(),
|
||||
]);
|
||||
|
||||
// Event from followed creator
|
||||
ActivityEvent::record($creator->id, ActivityEvent::TYPE_UPLOAD, ActivityEvent::TARGET_ARTWORK, $artwork->id);
|
||||
// Event from non-followed user (should not appear)
|
||||
ActivityEvent::record($other->id, ActivityEvent::TYPE_UPLOAD, ActivityEvent::TARGET_ARTWORK, $artwork->id);
|
||||
|
||||
$response = $this->actingAs($user)->get('/community/activity?filter=following');
|
||||
$response->assertStatus(200);
|
||||
|
||||
$props = $response->viewData('props');
|
||||
$events = collect($props['initialActivities'] ?? []);
|
||||
|
||||
expect($events)->toHaveCount(1);
|
||||
expect(data_get($events->first(), 'user.id'))->toBe($creator->id);
|
||||
});
|
||||
@@ -0,0 +1,67 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Jobs\IngestUserDiscoveryEventJob;
|
||||
use App\Models\Artwork;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\Queue;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
it('queues discovery event ingestion asynchronously', function () {
|
||||
Queue::fake();
|
||||
|
||||
$user = User::factory()->create();
|
||||
$artwork = Artwork::factory()->create();
|
||||
|
||||
$response = $this->actingAs($user)->postJson('/api/discovery/events', [
|
||||
'event_type' => 'view',
|
||||
'artwork_id' => $artwork->id,
|
||||
'meta' => ['source' => 'artwork_show'],
|
||||
]);
|
||||
|
||||
$response
|
||||
->assertStatus(202)
|
||||
->assertJsonPath('queued', true)
|
||||
->assertJsonPath('algo_version', (string) config('discovery.algo_version'));
|
||||
|
||||
Queue::assertPushed(IngestUserDiscoveryEventJob::class, function (IngestUserDiscoveryEventJob $job) use ($user, $artwork): bool {
|
||||
return $job->userId === $user->id
|
||||
&& $job->artworkId === $artwork->id
|
||||
&& $job->eventType === 'view';
|
||||
});
|
||||
});
|
||||
|
||||
it('validates discovery event payload', function () {
|
||||
$user = User::factory()->create();
|
||||
$artwork = Artwork::factory()->create();
|
||||
|
||||
$response = $this->actingAs($user)->postJson('/api/discovery/events', [
|
||||
'event_type' => 'impression',
|
||||
'artwork_id' => $artwork->id,
|
||||
]);
|
||||
|
||||
$response->assertStatus(422);
|
||||
$response->assertJsonValidationErrors(['event_type']);
|
||||
});
|
||||
|
||||
it('accepts session-oriented discovery events', function () {
|
||||
Queue::fake();
|
||||
|
||||
$user = User::factory()->create();
|
||||
$artwork = Artwork::factory()->create();
|
||||
|
||||
$response = $this->actingAs($user)->postJson('/api/discovery/events', [
|
||||
'event_type' => 'dwell',
|
||||
'artwork_id' => $artwork->id,
|
||||
'meta' => ['duration_ms' => 4500],
|
||||
]);
|
||||
|
||||
$response->assertStatus(202);
|
||||
|
||||
Queue::assertPushed(IngestUserDiscoveryEventJob::class, function (IngestUserDiscoveryEventJob $job): bool {
|
||||
return $job->eventType === 'dwell';
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,95 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Models\Artwork;
|
||||
use App\Models\Tag;
|
||||
use App\Models\User;
|
||||
use App\Models\UserNegativeSignal;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
it('stores hidden artwork signals', function () {
|
||||
$user = User::factory()->create();
|
||||
$artwork = Artwork::factory()->create();
|
||||
|
||||
$response = $this->actingAs($user)->postJson('/api/discovery/feedback/hide-artwork', [
|
||||
'artwork_id' => $artwork->id,
|
||||
'source' => 'feed-test',
|
||||
]);
|
||||
|
||||
$response->assertStatus(202)->assertJsonPath('stored', true);
|
||||
|
||||
expect(UserNegativeSignal::query()
|
||||
->where('user_id', $user->id)
|
||||
->where('signal_type', 'hide_artwork')
|
||||
->where('artwork_id', $artwork->id)
|
||||
->exists())->toBeTrue();
|
||||
});
|
||||
|
||||
it('revokes hidden artwork signals', function () {
|
||||
$user = User::factory()->create();
|
||||
$artwork = Artwork::factory()->create();
|
||||
|
||||
UserNegativeSignal::query()->create([
|
||||
'user_id' => $user->id,
|
||||
'signal_type' => 'hide_artwork',
|
||||
'artwork_id' => $artwork->id,
|
||||
'source' => 'feed-test',
|
||||
]);
|
||||
|
||||
$response = $this->actingAs($user)->deleteJson('/api/discovery/feedback/hide-artwork', [
|
||||
'artwork_id' => $artwork->id,
|
||||
]);
|
||||
|
||||
$response->assertOk()->assertJsonPath('revoked', true);
|
||||
|
||||
expect(UserNegativeSignal::query()
|
||||
->where('user_id', $user->id)
|
||||
->where('signal_type', 'hide_artwork')
|
||||
->where('artwork_id', $artwork->id)
|
||||
->exists())->toBeFalse();
|
||||
});
|
||||
|
||||
it('stores disliked tag signals by slug', function () {
|
||||
$user = User::factory()->create();
|
||||
$tag = Tag::query()->create(['name' => 'Sci-Fi', 'slug' => 'sci-fi']);
|
||||
|
||||
$response = $this->actingAs($user)->postJson('/api/discovery/feedback/dislike-tag', [
|
||||
'tag_slug' => 'sci-fi',
|
||||
'source' => 'feed-test',
|
||||
]);
|
||||
|
||||
$response->assertStatus(202)->assertJsonPath('stored', true);
|
||||
|
||||
expect(UserNegativeSignal::query()
|
||||
->where('user_id', $user->id)
|
||||
->where('signal_type', 'dislike_tag')
|
||||
->where('tag_id', $tag->id)
|
||||
->exists())->toBeTrue();
|
||||
});
|
||||
|
||||
it('revokes disliked tag signals by slug', function () {
|
||||
$user = User::factory()->create();
|
||||
$tag = Tag::query()->create(['name' => 'Sci-Fi', 'slug' => 'sci-fi']);
|
||||
|
||||
UserNegativeSignal::query()->create([
|
||||
'user_id' => $user->id,
|
||||
'signal_type' => 'dislike_tag',
|
||||
'tag_id' => $tag->id,
|
||||
'source' => 'feed-test',
|
||||
]);
|
||||
|
||||
$response = $this->actingAs($user)->deleteJson('/api/discovery/feedback/dislike-tag', [
|
||||
'tag_slug' => 'sci-fi',
|
||||
]);
|
||||
|
||||
$response->assertOk()->assertJsonPath('revoked', true);
|
||||
|
||||
expect(UserNegativeSignal::query()
|
||||
->where('user_id', $user->id)
|
||||
->where('signal_type', 'dislike_tag')
|
||||
->where('tag_id', $tag->id)
|
||||
->exists())->toBeFalse();
|
||||
});
|
||||
@@ -0,0 +1,113 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Jobs\RegenerateUserRecommendationCacheJob;
|
||||
use App\Models\Artwork;
|
||||
use App\Models\User;
|
||||
use App\Models\UserRecommendationCache;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Queue;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
it('returns feed from cache with cursor pagination', function () {
|
||||
$user = User::factory()->create();
|
||||
|
||||
$artworkA = Artwork::factory()->create(['is_public' => true, 'is_approved' => true, 'published_at' => now()->subMinutes(3)]);
|
||||
$artworkB = Artwork::factory()->create(['is_public' => true, 'is_approved' => true, 'published_at' => now()->subMinutes(2)]);
|
||||
$artworkC = Artwork::factory()->create(['is_public' => true, 'is_approved' => true, 'published_at' => now()->subMinutes(1)]);
|
||||
|
||||
UserRecommendationCache::query()->create([
|
||||
'user_id' => $user->id,
|
||||
'algo_version' => (string) config('discovery.algo_version'),
|
||||
'cache_version' => (string) config('discovery.cache_version'),
|
||||
'recommendations_json' => [
|
||||
'items' => [
|
||||
['artwork_id' => $artworkA->id, 'score' => 0.9, 'source' => 'profile'],
|
||||
['artwork_id' => $artworkB->id, 'score' => 0.8, 'source' => 'profile'],
|
||||
['artwork_id' => $artworkC->id, 'score' => 0.7, 'source' => 'profile'],
|
||||
],
|
||||
],
|
||||
'generated_at' => now(),
|
||||
'expires_at' => now()->addMinutes(30),
|
||||
]);
|
||||
|
||||
$first = $this->actingAs($user)->getJson('/api/v1/feed?limit=2');
|
||||
|
||||
$first->assertOk();
|
||||
$first->assertJsonPath('meta.cache_status', 'hit');
|
||||
expect(count((array) $first->json('data')))->toBe(2);
|
||||
|
||||
$nextCursor = $first->json('meta.next_cursor');
|
||||
expect($nextCursor)->not->toBeNull();
|
||||
|
||||
$second = $this->actingAs($user)->getJson('/api/v1/feed?limit=2&cursor=' . urlencode((string) $nextCursor));
|
||||
|
||||
$second->assertOk();
|
||||
expect(count((array) $second->json('data')))->toBe(1);
|
||||
expect($second->json('meta.next_cursor'))->toBeNull();
|
||||
});
|
||||
|
||||
it('dispatches async regeneration on cache miss and returns cold start items', function () {
|
||||
Queue::fake();
|
||||
|
||||
$user = User::factory()->create();
|
||||
|
||||
$artworkA = Artwork::factory()->create(['is_public' => true, 'is_approved' => true, 'published_at' => now()->subMinutes(3)]);
|
||||
$artworkB = Artwork::factory()->create(['is_public' => true, 'is_approved' => true, 'published_at' => now()->subMinutes(2)]);
|
||||
|
||||
DB::table('artwork_stats')->insert([
|
||||
['artwork_id' => $artworkA->id, 'views' => 100, 'downloads' => 30, 'favorites' => 10, 'rating_avg' => 0, 'rating_count' => 0],
|
||||
['artwork_id' => $artworkB->id, 'views' => 80, 'downloads' => 10, 'favorites' => 5, 'rating_avg' => 0, 'rating_count' => 0],
|
||||
]);
|
||||
|
||||
$response = $this->actingAs($user)->getJson('/api/v1/feed?limit=10');
|
||||
|
||||
$response->assertOk();
|
||||
expect(count((array) $response->json('data')))->toBeGreaterThan(0);
|
||||
expect((string) $response->json('meta.cache_status'))->toContain('miss');
|
||||
|
||||
Queue::assertPushed(RegenerateUserRecommendationCacheJob::class, function (RegenerateUserRecommendationCacheJob $job) use ($user): bool {
|
||||
return $job->userId === $user->id;
|
||||
});
|
||||
});
|
||||
|
||||
it('applies diversity guard to avoid near-duplicates in cold start fallback', function () {
|
||||
Queue::fake();
|
||||
|
||||
$user = User::factory()->create();
|
||||
|
||||
$artworkA = Artwork::factory()->create(['is_public' => true, 'is_approved' => true, 'published_at' => now()->subMinutes(3)]);
|
||||
$artworkB = Artwork::factory()->create(['is_public' => true, 'is_approved' => true, 'published_at' => now()->subMinutes(2)]);
|
||||
$artworkC = Artwork::factory()->create(['is_public' => true, 'is_approved' => true, 'published_at' => now()->subMinutes(1)]);
|
||||
|
||||
DB::table('artwork_stats')->insert([
|
||||
['artwork_id' => $artworkA->id, 'views' => 200, 'downloads' => 20, 'favorites' => 5, 'rating_avg' => 0, 'rating_count' => 0],
|
||||
['artwork_id' => $artworkB->id, 'views' => 190, 'downloads' => 18, 'favorites' => 4, 'rating_avg' => 0, 'rating_count' => 0],
|
||||
['artwork_id' => $artworkC->id, 'views' => 180, 'downloads' => 12, 'favorites' => 3, 'rating_avg' => 0, 'rating_count' => 0],
|
||||
]);
|
||||
|
||||
DB::table('artwork_similarities')->insert([
|
||||
'artwork_id' => $artworkA->id,
|
||||
'similar_artwork_id' => $artworkB->id,
|
||||
'model' => 'clip',
|
||||
'model_version' => 'v1',
|
||||
'algo_version' => (string) config('discovery.algo_version'),
|
||||
'rank' => 1,
|
||||
'score' => 0.991,
|
||||
'generated_at' => now(),
|
||||
'created_at' => now(),
|
||||
'updated_at' => now(),
|
||||
]);
|
||||
|
||||
$response = $this->actingAs($user)->getJson('/api/v1/feed?limit=10');
|
||||
|
||||
$response->assertOk();
|
||||
|
||||
$ids = collect((array) $response->json('data'))->pluck('id')->all();
|
||||
|
||||
expect(in_array($artworkA->id, $ids, true) && in_array($artworkB->id, $ids, true))->toBeFalse();
|
||||
expect(in_array($artworkC->id, $ids, true))->toBeTrue();
|
||||
});
|
||||
@@ -0,0 +1,262 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Models\Artwork;
|
||||
use App\Models\ArtworkEmbedding;
|
||||
use App\Models\Tag;
|
||||
use App\Models\User;
|
||||
use App\Models\UserRecommendationCache;
|
||||
use App\Services\Recommendations\SessionRecoService;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use function Pest\Laravel\actingAs;
|
||||
use function Pest\Laravel\getJson;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
it('can serve the feed through the v2 selector path', function () {
|
||||
config()->set('discovery.v2.enabled', true);
|
||||
config()->set('discovery.v2.rollout_percentage', 100);
|
||||
config()->set('discovery.v2.algo_version', 'clip-cosine-v2-adaptive');
|
||||
|
||||
$user = User::factory()->create();
|
||||
$creator = User::factory()->create();
|
||||
$artwork = Artwork::factory()->create([
|
||||
'user_id' => $creator->id,
|
||||
'is_public' => true,
|
||||
'is_approved' => true,
|
||||
'published_at' => now()->subHour(),
|
||||
'trending_score_1h' => 5,
|
||||
'trending_score_24h' => 10,
|
||||
'trending_score_7d' => 20,
|
||||
]);
|
||||
|
||||
$tag = Tag::query()->create(['name' => 'Abstract', 'slug' => 'abstract']);
|
||||
DB::table('artwork_tag')->insert([
|
||||
'artwork_id' => $artwork->id,
|
||||
'tag_id' => $tag->id,
|
||||
'source' => 'user',
|
||||
'created_at' => now(),
|
||||
]);
|
||||
|
||||
DB::table('artwork_stats')->insert([
|
||||
'artwork_id' => $artwork->id,
|
||||
'views' => 150,
|
||||
'downloads' => 20,
|
||||
'favorites' => 15,
|
||||
'comments_count' => 5,
|
||||
'shares_count' => 2,
|
||||
'views_24h' => 150,
|
||||
'views_7d' => 150,
|
||||
'downloads_24h' => 20,
|
||||
'downloads_7d' => 20,
|
||||
'shares_24h' => 2,
|
||||
'comments_24h' => 5,
|
||||
'favourites_24h' => 15,
|
||||
'views_1h' => 40,
|
||||
'downloads_1h' => 5,
|
||||
'favourites_1h' => 4,
|
||||
'comments_1h' => 2,
|
||||
'shares_1h' => 1,
|
||||
'ranking_score' => 50,
|
||||
'engagement_velocity' => 12,
|
||||
'heat_score' => 30,
|
||||
'rating_avg' => 0,
|
||||
'rating_count' => 0,
|
||||
]);
|
||||
|
||||
UserRecommendationCache::query()->create([
|
||||
'user_id' => $user->id,
|
||||
'algo_version' => 'clip-cosine-v2-adaptive',
|
||||
'cache_version' => 'cache-v2',
|
||||
'recommendations_json' => [
|
||||
'items' => [
|
||||
['artwork_id' => $artwork->id, 'score' => 1.2, 'source' => 'trending', 'layer_sources' => ['trending']],
|
||||
],
|
||||
],
|
||||
'generated_at' => now(),
|
||||
'expires_at' => now()->addMinutes(10),
|
||||
]);
|
||||
|
||||
actingAs($user);
|
||||
$response = getJson('/api/v1/feed?algo_version=clip-cosine-v2-adaptive&limit=2');
|
||||
|
||||
$response->assertOk();
|
||||
$response->assertJsonPath('meta.engine', 'v2');
|
||||
$response->assertJsonPath('meta.algo_version', 'clip-cosine-v2-adaptive');
|
||||
$response->assertJsonPath('meta.local_embedding_count', 0);
|
||||
$response->assertJsonPath('meta.vector_indexed_count', 0);
|
||||
$response->assertJsonPath('data.0.primary_tag.slug', 'abstract');
|
||||
$response->assertJsonPath('data.0.has_local_embedding', false);
|
||||
$response->assertJsonPath('data.0.vector_indexed_at', null);
|
||||
$response->assertJsonPath('data.0.ranking_signals.local_embedding_present', false);
|
||||
$response->assertJsonPath('data.0.ranking_signals.vector_indexed_at', null);
|
||||
expect((array) $response->json('data'))->toHaveCount(1);
|
||||
});
|
||||
|
||||
it('boosts vector-similar candidates in the v3 hybrid feed', function () {
|
||||
config()->set('discovery.v2.enabled', true);
|
||||
config()->set('discovery.v2.rollout_percentage', 100);
|
||||
config()->set('discovery.v2.algo_version', 'clip-cosine-v2-adaptive');
|
||||
config()->set('discovery.v3.enabled', true);
|
||||
config()->set('discovery.v3.vector_similarity_weight', 2.0);
|
||||
config()->set('vision.vector_gateway.enabled', true);
|
||||
config()->set('vision.vector_gateway.base_url', 'https://vision.klevze.net');
|
||||
config()->set('vision.vector_gateway.api_key', 'test-key');
|
||||
config()->set('vision.vector_gateway.search_endpoint', '/vectors/search');
|
||||
config()->set('cdn.files_url', 'https://files.skinbase.org');
|
||||
|
||||
$user = User::factory()->create();
|
||||
$creator = User::factory()->create();
|
||||
$seedArtwork = Artwork::factory()->create([
|
||||
'user_id' => $creator->id,
|
||||
'hash' => 'aabbcc112233',
|
||||
'thumb_ext' => 'webp',
|
||||
'is_public' => true,
|
||||
'is_approved' => true,
|
||||
'published_at' => now()->subDays(10),
|
||||
]);
|
||||
|
||||
$vectorMatch = Artwork::factory()->create([
|
||||
'user_id' => $creator->id,
|
||||
'hash' => 'ddeeff445566',
|
||||
'thumb_ext' => 'webp',
|
||||
'title' => 'Vector winner',
|
||||
'slug' => 'vector-winner',
|
||||
'is_public' => true,
|
||||
'is_approved' => true,
|
||||
'published_at' => now()->subDays(10),
|
||||
'trending_score_1h' => 5,
|
||||
'trending_score_24h' => 5,
|
||||
'trending_score_7d' => 5,
|
||||
'last_vector_indexed_at' => now()->subMinutes(5),
|
||||
]);
|
||||
|
||||
$trendingLeader = Artwork::factory()->create([
|
||||
'user_id' => $creator->id,
|
||||
'hash' => '778899001122',
|
||||
'thumb_ext' => 'webp',
|
||||
'title' => 'Trending leader',
|
||||
'slug' => 'trending-leader',
|
||||
'is_public' => true,
|
||||
'is_approved' => true,
|
||||
'published_at' => now()->subDays(10),
|
||||
'trending_score_1h' => 30,
|
||||
'trending_score_24h' => 30,
|
||||
'trending_score_7d' => 30,
|
||||
]);
|
||||
|
||||
$sectionArtwork = Artwork::factory()->create([
|
||||
'user_id' => $creator->id,
|
||||
'hash' => '334455667788',
|
||||
'thumb_ext' => 'webp',
|
||||
'title' => 'Section artwork',
|
||||
'slug' => 'section-artwork',
|
||||
'is_public' => true,
|
||||
'is_approved' => true,
|
||||
'published_at' => now()->subDays(10),
|
||||
'trending_score_1h' => 6,
|
||||
'trending_score_24h' => 6,
|
||||
'trending_score_7d' => 6,
|
||||
]);
|
||||
|
||||
ArtworkEmbedding::query()->create([
|
||||
'artwork_id' => $vectorMatch->id,
|
||||
'model' => 'clip',
|
||||
'model_version' => 'v1',
|
||||
'algo_version' => 'clip-cosine-v1',
|
||||
'dim' => 2,
|
||||
'embedding_json' => json_encode([0.6, 0.8], JSON_THROW_ON_ERROR),
|
||||
'source_hash' => 'ddeeff445566',
|
||||
'is_normalized' => true,
|
||||
'generated_at' => now(),
|
||||
'meta' => ['source' => 'clip'],
|
||||
]);
|
||||
|
||||
foreach ([$seedArtwork, $vectorMatch, $trendingLeader, $sectionArtwork] as $artwork) {
|
||||
DB::table('artwork_stats')->insert([
|
||||
'artwork_id' => $artwork->id,
|
||||
'views' => 100,
|
||||
'downloads' => 10,
|
||||
'favorites' => 8,
|
||||
'comments_count' => 3,
|
||||
'shares_count' => 1,
|
||||
'views_24h' => 100,
|
||||
'views_7d' => 100,
|
||||
'downloads_24h' => 10,
|
||||
'downloads_7d' => 10,
|
||||
'shares_24h' => 1,
|
||||
'comments_24h' => 3,
|
||||
'favourites_24h' => 8,
|
||||
'views_1h' => 20,
|
||||
'downloads_1h' => 2,
|
||||
'favourites_1h' => 2,
|
||||
'comments_1h' => 1,
|
||||
'shares_1h' => 1,
|
||||
'ranking_score' => 25,
|
||||
'engagement_velocity' => 8,
|
||||
'heat_score' => 15,
|
||||
'rating_avg' => 0,
|
||||
'rating_count' => 0,
|
||||
]);
|
||||
}
|
||||
|
||||
Http::fake(function ($request) use ($seedArtwork, $vectorMatch, $sectionArtwork) {
|
||||
$payload = json_decode($request->body(), true);
|
||||
$url = (string) ($payload['url'] ?? '');
|
||||
|
||||
if (str_contains($url, 'aabbcc112233')) {
|
||||
return Http::response([
|
||||
'results' => [
|
||||
['id' => $seedArtwork->id, 'score' => 1.0],
|
||||
['id' => $vectorMatch->id, 'score' => 0.98],
|
||||
],
|
||||
], 200);
|
||||
}
|
||||
|
||||
if (str_contains($url, 'ddeeff445566')) {
|
||||
return Http::response([
|
||||
'results' => [
|
||||
['id' => $vectorMatch->id, 'score' => 1.0],
|
||||
['id' => $sectionArtwork->id, 'score' => 0.91],
|
||||
],
|
||||
], 200);
|
||||
}
|
||||
|
||||
return Http::response(['results' => []], 200);
|
||||
});
|
||||
|
||||
app(SessionRecoService::class)->applyEvent(
|
||||
userId: $user->id,
|
||||
eventType: 'view',
|
||||
artworkId: $seedArtwork->id,
|
||||
categoryId: null,
|
||||
occurredAt: now()->toIso8601String(),
|
||||
meta: []
|
||||
);
|
||||
|
||||
actingAs($user);
|
||||
$response = getJson('/api/v1/feed?algo_version=clip-cosine-v2-adaptive&limit=2');
|
||||
|
||||
$response->assertOk();
|
||||
$response->assertJsonPath('meta.engine', 'v2');
|
||||
$response->assertJsonPath('meta.vector_influenced_count', 1);
|
||||
$response->assertJsonPath('meta.local_embedding_count', 1);
|
||||
$response->assertJsonPath('meta.vector_indexed_count', 1);
|
||||
$response->assertJsonPath('data.0.id', $vectorMatch->id);
|
||||
$response->assertJsonPath('data.0.source', 'vector');
|
||||
$response->assertJsonPath('data.0.reason', 'Visually similar to art you engaged with');
|
||||
$response->assertJsonPath('data.0.vector_influenced', true);
|
||||
$response->assertJsonPath('data.0.has_local_embedding', true);
|
||||
expect($response->json('data.0.vector_indexed_at'))->not->toBeNull();
|
||||
$response->assertJsonPath('data.0.ranking_signals.vector_similarity_score', 0.98);
|
||||
$response->assertJsonPath('data.0.ranking_signals.local_embedding_present', true);
|
||||
expect($response->json('data.0.ranking_signals.vector_indexed_at'))->not->toBeNull();
|
||||
$response->assertJsonPath('sections.0.key', 'similar_style');
|
||||
$response->assertJsonPath('sections.1.key', 'you_may_also_like');
|
||||
$response->assertJsonPath('sections.2.key', 'visually_related');
|
||||
$response->assertJsonPath('sections.0.items.0.id', $sectionArtwork->id);
|
||||
$response->assertJsonPath('sections.2.items.0.id', $sectionArtwork->id);
|
||||
});
|
||||
@@ -0,0 +1,87 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Models\Artwork;
|
||||
use App\Models\User;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
beforeEach(function () {
|
||||
// Use null Scout driver so no Meilisearch calls are made
|
||||
config(['scout.driver' => 'null']);
|
||||
});
|
||||
|
||||
it('redirects unauthenticated users to login', function () {
|
||||
$this->get(route('discover.following'))
|
||||
->assertRedirect(route('login'));
|
||||
});
|
||||
|
||||
it('shows empty state with fallback data when user follows nobody', function () {
|
||||
$user = User::factory()->create();
|
||||
|
||||
$response = $this->actingAs($user)->get(route('discover.following'));
|
||||
|
||||
$response->assertStatus(200);
|
||||
$response->assertViewHas('empty', true);
|
||||
$response->assertViewHas('fallback_trending');
|
||||
$response->assertViewHas('fallback_creators');
|
||||
$response->assertViewHas('section', 'following');
|
||||
});
|
||||
|
||||
it('paginates artworks from followed creators', function () {
|
||||
$user = User::factory()->create();
|
||||
$creator = User::factory()->create();
|
||||
|
||||
// user_followers has no updated_at column
|
||||
DB::table('user_followers')->insert([
|
||||
'user_id' => $creator->id,
|
||||
'follower_id' => $user->id,
|
||||
'created_at' => now(),
|
||||
]);
|
||||
|
||||
Artwork::factory()->count(3)->create([
|
||||
'user_id' => $creator->id,
|
||||
'is_public' => true,
|
||||
'is_approved' => true,
|
||||
'published_at' => now()->subHour(),
|
||||
]);
|
||||
|
||||
$response = $this->actingAs($user)->get(route('discover.following'));
|
||||
|
||||
$response->assertStatus(200);
|
||||
$response->assertViewHas('section', 'following');
|
||||
$response->assertViewMissing('empty');
|
||||
});
|
||||
|
||||
it('does not include artworks from non-followed creators in the feed', function () {
|
||||
$user = User::factory()->create();
|
||||
$creator = User::factory()->create();
|
||||
$stranger = User::factory()->create();
|
||||
|
||||
DB::table('user_followers')->insert([
|
||||
'user_id' => $creator->id,
|
||||
'follower_id' => $user->id,
|
||||
'created_at' => now(),
|
||||
]);
|
||||
|
||||
// Only the stranger has an artwork — creator has none
|
||||
Artwork::factory()->create([
|
||||
'user_id' => $stranger->id,
|
||||
'is_public' => true,
|
||||
'is_approved' => true,
|
||||
'published_at' => now()->subHour(),
|
||||
]);
|
||||
|
||||
$response = $this->actingAs($user)->get(route('discover.following'));
|
||||
$response->assertStatus(200);
|
||||
|
||||
/** @var \Illuminate\Pagination\LengthAwarePaginator $artworks */
|
||||
$artworks = $response->original->gatherData()['artworks'];
|
||||
expect($artworks->total())->toBe(0);
|
||||
});
|
||||
|
||||
it('other discover routes return 200 without Meilisearch', function () {
|
||||
// Trending and fresh routes fall through to DB fallback with null driver
|
||||
$this->get(route('discover.trending'))->assertStatus(200);
|
||||
$this->get(route('discover.fresh'))->assertStatus(200);
|
||||
});
|
||||
@@ -0,0 +1,114 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Models\User;
|
||||
use App\Models\Artwork;
|
||||
use App\Models\Group;
|
||||
use App\Services\ArtworkService;
|
||||
use App\Services\HomepageService;
|
||||
use Illuminate\Pagination\LengthAwarePaginator;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
|
||||
beforeEach(function () {
|
||||
// Use null Scout driver — Meilisearch calls return empty results gracefully
|
||||
config(['scout.driver' => 'null']);
|
||||
|
||||
// ArtworkService is not final so it can be mocked
|
||||
$artworksMock = Mockery::mock(ArtworkService::class);
|
||||
$artworksMock->shouldReceive('getFeaturedArtworks')
|
||||
->andReturn(new LengthAwarePaginator(collect(), 0, 1))
|
||||
->byDefault();
|
||||
app()->instance(ArtworkService::class, $artworksMock);
|
||||
});
|
||||
|
||||
// ── Route integration ─────────────────────────────────────────────────────────
|
||||
|
||||
it('home page renders 200 for guests', function () {
|
||||
$this->get('/')->assertStatus(200);
|
||||
});
|
||||
|
||||
it('home page renders 200 for authenticated users', function () {
|
||||
$this->actingAs(User::factory()->create())
|
||||
->get('/')
|
||||
->assertStatus(200);
|
||||
});
|
||||
|
||||
// ── HomepageService section shape ─────────────────────────────────────────────
|
||||
|
||||
it('guest homepage has expected sections but no from_following', function () {
|
||||
$sections = app(HomepageService::class)->all();
|
||||
|
||||
expect($sections)->toHaveKeys(['hero', 'trending', 'fresh', 'tags', 'creators', 'news']);
|
||||
expect($sections)->not->toHaveKey('from_following');
|
||||
expect($sections)->not->toHaveKey('by_tags');
|
||||
expect($sections)->not->toHaveKey('by_categories');
|
||||
});
|
||||
|
||||
it('authenticated homepage contains all personalised sections', function () {
|
||||
$user = User::factory()->create();
|
||||
$sections = app(HomepageService::class)->allForUser($user);
|
||||
|
||||
expect($sections)->toHaveKeys([
|
||||
'hero',
|
||||
'from_following',
|
||||
'trending',
|
||||
'by_tags',
|
||||
'by_categories',
|
||||
'tags',
|
||||
'creators',
|
||||
'news',
|
||||
'preferences',
|
||||
]);
|
||||
});
|
||||
|
||||
it('preferences section exposes top_tags and top_categories arrays', function () {
|
||||
$user = User::factory()->create();
|
||||
$sections = app(HomepageService::class)->allForUser($user);
|
||||
|
||||
expect($sections['preferences'])->toHaveKeys(['top_tags', 'top_categories']);
|
||||
expect($sections['preferences']['top_tags'])->toBeArray();
|
||||
expect($sections['preferences']['top_categories'])->toBeArray();
|
||||
});
|
||||
|
||||
it('guest and auth homepages have different key sets', function () {
|
||||
$user = User::factory()->create();
|
||||
|
||||
$guest = array_keys(app(HomepageService::class)->all());
|
||||
$auth = array_keys(app(HomepageService::class)->allForUser($user));
|
||||
|
||||
expect($guest)->not->toEqual($auth);
|
||||
expect(in_array('from_following', $auth))->toBeTrue();
|
||||
expect(in_array('from_following', $guest))->toBeFalse();
|
||||
});
|
||||
|
||||
it('homepage artwork payload uses group name and avatar for group-published artworks', function () {
|
||||
$owner = User::factory()->create();
|
||||
$group = Group::factory()->create([
|
||||
'owner_user_id' => $owner->id,
|
||||
'name' => 'Skinbase Collective',
|
||||
'slug' => 'skinbase-collective',
|
||||
]);
|
||||
|
||||
Artwork::factory()->create([
|
||||
'user_id' => $owner->id,
|
||||
'group_id' => $group->id,
|
||||
'published_as_type' => Artwork::PUBLISHED_AS_GROUP,
|
||||
'title' => 'Group Published Artwork',
|
||||
'hash' => 'homepagegroupartwork',
|
||||
'thumb_ext' => 'webp',
|
||||
'published_at' => now()->subMinute(),
|
||||
]);
|
||||
|
||||
Cache::flush();
|
||||
|
||||
$items = app(HomepageService::class)->getFreshUploads(10);
|
||||
|
||||
expect($items)->not->toBeEmpty()
|
||||
->and($items[0]['author'])->toBe('Skinbase Collective')
|
||||
->and($items[0]['author_username'])->toBe('')
|
||||
->and($items[0]['published_as_type'])->toBe(Artwork::PUBLISHED_AS_GROUP)
|
||||
->and($items[0]['publisher']['type'])->toBe('group')
|
||||
->and($items[0]['publisher']['name'])->toBe('Skinbase Collective')
|
||||
->and($items[0]['publisher']['profile_url'])->toContain('/groups/skinbase-collective');
|
||||
});
|
||||
@@ -0,0 +1,165 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Models\Artwork;
|
||||
use App\Models\User;
|
||||
use App\Services\ArtworkStatsService;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
beforeEach(function () {
|
||||
// Disable Meilisearch and Redis during tests
|
||||
config(['scout.driver' => 'null']);
|
||||
});
|
||||
|
||||
// ── ArtworkViewController (POST /api/art/{id}/view) ──────────────────────────
|
||||
|
||||
it('returns 404 for a non-existent artwork on view', function () {
|
||||
$this->postJson('/api/art/99999/view')->assertStatus(404);
|
||||
});
|
||||
|
||||
it('returns 404 for a private artwork on view', function () {
|
||||
$artwork = Artwork::factory()->create(['is_public' => false]);
|
||||
$this->postJson("/api/art/{$artwork->id}/view")->assertStatus(404);
|
||||
});
|
||||
|
||||
it('returns 404 for an unapproved artwork on view', function () {
|
||||
$artwork = Artwork::factory()->create(['is_approved' => false]);
|
||||
$this->postJson("/api/art/{$artwork->id}/view")->assertStatus(404);
|
||||
});
|
||||
|
||||
it('records a view and returns ok=true on first call', function () {
|
||||
$artwork = Artwork::factory()->create([
|
||||
'is_public' => true,
|
||||
'is_approved' => true,
|
||||
'published_at' => now()->subDay(),
|
||||
]);
|
||||
|
||||
// Ensure a stats row exists with 0 views
|
||||
DB::table('artwork_stats')->insertOrIgnore([
|
||||
'artwork_id' => $artwork->id,
|
||||
'views' => 0,
|
||||
'downloads' => 0,
|
||||
'favorites' => 0,
|
||||
'rating_avg' => 0,
|
||||
'rating_count' => 0,
|
||||
]);
|
||||
|
||||
$mock = $this->mock(ArtworkStatsService::class);
|
||||
$mock->shouldReceive('logViewEvent')
|
||||
->once()
|
||||
->with($artwork->id, null); // null = guest (unauthenticated request)
|
||||
$mock->shouldReceive('incrementViews')
|
||||
->once()
|
||||
->with($artwork->id, 1, true);
|
||||
|
||||
$response = $this->postJson("/api/art/{$artwork->id}/view");
|
||||
|
||||
$response->assertStatus(200)
|
||||
->assertJsonPath('ok', true)
|
||||
->assertJsonPath('counted', true);
|
||||
});
|
||||
|
||||
it('skips DB increment and returns counted=false if artwork was already viewed this session', function () {
|
||||
$artwork = Artwork::factory()->create([
|
||||
'is_public' => true,
|
||||
'is_approved' => true,
|
||||
'published_at' => now()->subDay(),
|
||||
]);
|
||||
|
||||
// Mark as already viewed in the session
|
||||
session()->put("art_viewed.{$artwork->id}", true);
|
||||
|
||||
$mock = $this->mock(ArtworkStatsService::class);
|
||||
$mock->shouldReceive('incrementViews')->never();
|
||||
|
||||
$response = $this->postJson("/api/art/{$artwork->id}/view");
|
||||
|
||||
$response->assertStatus(200)
|
||||
->assertJsonPath('ok', true)
|
||||
->assertJsonPath('counted', false);
|
||||
});
|
||||
|
||||
// ── ArtworkDownloadController (POST /api/art/{id}/download) ──────────────────
|
||||
|
||||
it('returns 404 for a non-existent artwork on download', function () {
|
||||
$this->postJson('/api/art/99999/download')->assertStatus(404);
|
||||
});
|
||||
|
||||
it('returns 404 for a private artwork on download', function () {
|
||||
$artwork = Artwork::factory()->create(['is_public' => false]);
|
||||
$this->postJson("/api/art/{$artwork->id}/download")->assertStatus(404);
|
||||
});
|
||||
|
||||
it('records a download and returns ok=true with a url', function () {
|
||||
$artwork = Artwork::factory()->create([
|
||||
'is_public' => true,
|
||||
'is_approved' => true,
|
||||
'published_at' => now()->subDay(),
|
||||
]);
|
||||
|
||||
$mock = $this->mock(ArtworkStatsService::class);
|
||||
$mock->shouldReceive('incrementDownloads')
|
||||
->once()
|
||||
->with($artwork->id, 1, true);
|
||||
|
||||
$response = $this->postJson("/api/art/{$artwork->id}/download");
|
||||
|
||||
$response->assertStatus(200)
|
||||
->assertJsonPath('ok', true)
|
||||
->assertJsonStructure(['ok', 'url']);
|
||||
});
|
||||
|
||||
it('inserts a row in artwork_downloads on valid download', function () {
|
||||
$user = User::factory()->create();
|
||||
$artwork = Artwork::factory()->create([
|
||||
'is_public' => true,
|
||||
'is_approved' => true,
|
||||
'published_at' => now()->subDay(),
|
||||
]);
|
||||
|
||||
// Stub the stats service so we don't need Redis
|
||||
$mock = $this->mock(ArtworkStatsService::class);
|
||||
$mock->shouldReceive('incrementDownloads')->once();
|
||||
|
||||
$this->actingAs($user)->postJson("/api/art/{$artwork->id}/download");
|
||||
|
||||
$this->assertDatabaseHas('artwork_downloads', [
|
||||
'artwork_id' => $artwork->id,
|
||||
'user_id' => $user->id,
|
||||
]);
|
||||
});
|
||||
|
||||
it('records download as guest (no user_id) when unauthenticated', function () {
|
||||
$artwork = Artwork::factory()->create([
|
||||
'is_public' => true,
|
||||
'is_approved' => true,
|
||||
'published_at' => now()->subDay(),
|
||||
]);
|
||||
|
||||
$mock = $this->mock(ArtworkStatsService::class);
|
||||
$mock->shouldReceive('incrementDownloads')->once();
|
||||
|
||||
$this->postJson("/api/art/{$artwork->id}/download");
|
||||
|
||||
$this->assertDatabaseHas('artwork_downloads', [
|
||||
'artwork_id' => $artwork->id,
|
||||
'user_id' => null,
|
||||
]);
|
||||
});
|
||||
|
||||
// ── Route names ───────────────────────────────────────────────────────────────
|
||||
|
||||
it('view endpoint route is named api.art.view', function () {
|
||||
$artwork = Artwork::factory()->create([
|
||||
'is_public' => true, 'is_approved' => true, 'published_at' => now()->subDay(),
|
||||
]);
|
||||
expect(route('api.art.view', ['id' => $artwork->id]))->toContain("/api/art/{$artwork->id}/view");
|
||||
});
|
||||
|
||||
it('download endpoint route is named api.art.download', function () {
|
||||
$artwork = Artwork::factory()->create([
|
||||
'is_public' => true, 'is_approved' => true, 'published_at' => now()->subDay(),
|
||||
]);
|
||||
expect(route('api.art.download', ['id' => $artwork->id]))->toContain("/api/art/{$artwork->id}/download");
|
||||
});
|
||||
@@ -0,0 +1,117 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Models\Artwork;
|
||||
use App\Models\User;
|
||||
|
||||
beforeEach(function () {
|
||||
// Use null Scout driver so no Meilisearch calls are made
|
||||
config(['scout.driver' => 'null']);
|
||||
});
|
||||
|
||||
// ── 404 cases ─────────────────────────────────────────────────────────────────
|
||||
|
||||
it('returns 404 for a non-existent artwork id', function () {
|
||||
$this->getJson('/api/art/99999/similar')
|
||||
->assertStatus(404)
|
||||
->assertJsonPath('error', 'Artwork not found');
|
||||
});
|
||||
|
||||
it('returns 404 for a private artwork', function () {
|
||||
$artwork = Artwork::factory()->create(['is_public' => false]);
|
||||
|
||||
$this->getJson("/api/art/{$artwork->id}/similar")
|
||||
->assertStatus(404);
|
||||
});
|
||||
|
||||
it('returns 404 for an unapproved artwork', function () {
|
||||
$artwork = Artwork::factory()->create(['is_approved' => false]);
|
||||
|
||||
$this->getJson("/api/art/{$artwork->id}/similar")
|
||||
->assertStatus(404);
|
||||
});
|
||||
|
||||
it('returns 404 for an unpublished artwork', function () {
|
||||
$artwork = Artwork::factory()->unpublished()->create();
|
||||
|
||||
$this->getJson("/api/art/{$artwork->id}/similar")
|
||||
->assertStatus(404);
|
||||
});
|
||||
|
||||
// ── Success cases ─────────────────────────────────────────────────────────────
|
||||
|
||||
it('returns a data array for a valid public artwork', function () {
|
||||
$artwork = Artwork::factory()->create([
|
||||
'is_public' => true,
|
||||
'is_approved' => true,
|
||||
'published_at' => now()->subDay(),
|
||||
]);
|
||||
|
||||
$response = $this->getJson("/api/art/{$artwork->id}/similar");
|
||||
|
||||
$response->assertStatus(200);
|
||||
$response->assertJsonStructure(['data']);
|
||||
expect($response->json('data'))->toBeArray();
|
||||
});
|
||||
|
||||
it('the source artwork id is never present in results', function () {
|
||||
$artwork = Artwork::factory()->create([
|
||||
'is_public' => true,
|
||||
'is_approved' => true,
|
||||
'published_at' => now()->subDay(),
|
||||
]);
|
||||
|
||||
$ids = collect($this->getJson("/api/art/{$artwork->id}/similar")->json('data'))
|
||||
->pluck('id')
|
||||
->all();
|
||||
|
||||
expect($ids)->not->toContain($artwork->id);
|
||||
});
|
||||
|
||||
it('result count does not exceed 12', function () {
|
||||
$artwork = Artwork::factory()->create([
|
||||
'is_public' => true,
|
||||
'is_approved' => true,
|
||||
'published_at' => now()->subDay(),
|
||||
]);
|
||||
|
||||
$count = count($this->getJson("/api/art/{$artwork->id}/similar")->json('data'));
|
||||
|
||||
// null Scout driver returns 0 results; max is 12
|
||||
expect($count <= 12)->toBeTrue();
|
||||
});
|
||||
|
||||
it('results do not include artworks by the same creator', function () {
|
||||
$creatorA = User::factory()->create();
|
||||
$creatorB = User::factory()->create();
|
||||
|
||||
$source = Artwork::factory()->create([
|
||||
'user_id' => $creatorA->id,
|
||||
'is_public' => true,
|
||||
'is_approved' => true,
|
||||
'published_at' => now()->subDay(),
|
||||
]);
|
||||
|
||||
// A matching artwork from a different creator
|
||||
Artwork::factory()->create([
|
||||
'user_id' => $creatorB->id,
|
||||
'is_public' => true,
|
||||
'is_approved' => true,
|
||||
'published_at' => now()->subDay(),
|
||||
]);
|
||||
|
||||
$response = $this->getJson("/api/art/{$source->id}/similar");
|
||||
$response->assertStatus(200);
|
||||
|
||||
$items = $response->json('data');
|
||||
|
||||
// With null Scout driver the search returns 0 items; if items are present
|
||||
// none should belong to the source artwork's creator.
|
||||
foreach ($items as $item) {
|
||||
expect($item)->toHaveKeys(['id', 'title', 'slug', 'thumb', 'url', 'author_id']);
|
||||
expect($item['author_id'])->not->toBe($creatorA->id);
|
||||
}
|
||||
|
||||
expect(true)->toBeTrue(); // always at least one assertion
|
||||
});
|
||||
@@ -0,0 +1,98 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Models\Artwork;
|
||||
use App\Services\TrendingService;
|
||||
|
||||
// RefreshDatabase is applied automatically to all Feature tests via Pest.php
|
||||
|
||||
it('returns zero when no artworks exist', function () {
|
||||
expect(app(TrendingService::class)->recalculate('24h'))->toBe(0);
|
||||
expect(app(TrendingService::class)->recalculate('7d'))->toBe(0);
|
||||
});
|
||||
|
||||
it('updates trending_score_24h for artworks published within 7 days', function () {
|
||||
$artwork = Artwork::factory()->create([
|
||||
'is_public' => true,
|
||||
'is_approved' => true,
|
||||
'published_at' => now()->subHours(6),
|
||||
]);
|
||||
|
||||
$updated = app(TrendingService::class)->recalculate('24h');
|
||||
|
||||
expect($updated)->toBe(1);
|
||||
|
||||
$artwork->refresh();
|
||||
expect($artwork->trending_score_24h)->toBeFloat();
|
||||
expect($artwork->last_trending_calculated_at)->not->toBeNull();
|
||||
})->skip('Requires MySQL: uses GREATEST() and TIMESTAMPDIFF() which are not available in SQLite');
|
||||
|
||||
it('updates trending_score_7d for artworks published within 30 days', function () {
|
||||
$artwork = Artwork::factory()->create([
|
||||
'is_public' => true,
|
||||
'is_approved' => true,
|
||||
'published_at' => now()->subDays(10),
|
||||
]);
|
||||
|
||||
$updated = app(TrendingService::class)->recalculate('7d');
|
||||
|
||||
expect($updated)->toBe(1);
|
||||
|
||||
$artwork->refresh();
|
||||
expect($artwork->trending_score_7d)->toBeFloat();
|
||||
})->skip('Requires MySQL: uses GREATEST() and TIMESTAMPDIFF() which are not available in SQLite');
|
||||
|
||||
it('skips artworks published outside the look-back window', function () {
|
||||
Artwork::factory()->create([
|
||||
'is_public' => true,
|
||||
'is_approved' => true,
|
||||
'published_at' => now()->subDays(45), // outside 30-day window
|
||||
]);
|
||||
|
||||
expect(app(TrendingService::class)->recalculate('7d'))->toBe(0);
|
||||
});
|
||||
|
||||
it('skips private artworks', function () {
|
||||
Artwork::factory()->create([
|
||||
'is_public' => false,
|
||||
'is_approved' => true,
|
||||
'published_at' => now()->subDay(),
|
||||
]);
|
||||
|
||||
expect(app(TrendingService::class)->recalculate('24h'))->toBe(0);
|
||||
});
|
||||
|
||||
it('skips unapproved artworks', function () {
|
||||
Artwork::factory()->create([
|
||||
'is_public' => true,
|
||||
'is_approved' => false,
|
||||
'published_at' => now()->subDay(),
|
||||
]);
|
||||
|
||||
expect(app(TrendingService::class)->recalculate('24h'))->toBe(0);
|
||||
});
|
||||
|
||||
it('score is always non-negative (GREATEST clamp)', function () {
|
||||
// Artwork with no stats — time decay may be large, but score is clamped to ≥ 0
|
||||
$artwork = Artwork::factory()->create([
|
||||
'is_public' => true,
|
||||
'is_approved' => true,
|
||||
'published_at' => now()->subDays(6),
|
||||
]);
|
||||
|
||||
app(TrendingService::class)->recalculate('24h');
|
||||
|
||||
$artwork->refresh();
|
||||
expect($artwork->trending_score_24h)->toBeGreaterThanOrEqualTo(0.0);
|
||||
})->skip('Requires MySQL: uses GREATEST() and TIMESTAMPDIFF() which are not available in SQLite');
|
||||
|
||||
it('processes multiple artworks in a single run', function () {
|
||||
Artwork::factory()->count(5)->create([
|
||||
'is_public' => true,
|
||||
'is_approved' => true,
|
||||
'published_at' => now()->subDay(),
|
||||
]);
|
||||
|
||||
expect(app(TrendingService::class)->recalculate('7d'))->toBe(5);
|
||||
})->skip('Requires MySQL: uses GREATEST() and TIMESTAMPDIFF() which are not available in SQLite');
|
||||
@@ -0,0 +1,147 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Models\Artwork;
|
||||
use App\Services\ArtworkStatsService;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
beforeEach(function () {
|
||||
config(['scout.driver' => 'null']);
|
||||
});
|
||||
|
||||
// ── Helper: ensure a stats row exists ────────────────────────────────────────
|
||||
|
||||
function seedStats(int $artworkId, array $overrides = []): void
|
||||
{
|
||||
DB::table('artwork_stats')->insertOrIgnore(array_merge([
|
||||
'artwork_id' => $artworkId,
|
||||
'views' => 0,
|
||||
'views_24h' => 0,
|
||||
'views_7d' => 0,
|
||||
'downloads' => 0,
|
||||
'downloads_24h' => 0,
|
||||
'downloads_7d' => 0,
|
||||
'favorites' => 0,
|
||||
'rating_avg' => 0,
|
||||
'rating_count' => 0,
|
||||
], $overrides));
|
||||
}
|
||||
|
||||
// ── ArtworkStatsService ───────────────────────────────────────────────────────
|
||||
|
||||
it('incrementViews updates views, views_24h, and views_7d', function () {
|
||||
$artwork = Artwork::factory()->create(['is_public' => true, 'is_approved' => true, 'published_at' => now()->subDay()]);
|
||||
seedStats($artwork->id);
|
||||
|
||||
app(ArtworkStatsService::class)->incrementViews($artwork->id, 3, defer: false);
|
||||
|
||||
$row = DB::table('artwork_stats')->where('artwork_id', $artwork->id)->first();
|
||||
expect((int) $row->views)->toBe(3);
|
||||
expect((int) $row->views_24h)->toBe(3);
|
||||
expect((int) $row->views_7d)->toBe(3);
|
||||
});
|
||||
|
||||
it('incrementDownloads updates downloads, downloads_24h, and downloads_7d', function () {
|
||||
$artwork = Artwork::factory()->create(['is_public' => true, 'is_approved' => true, 'published_at' => now()->subDay()]);
|
||||
seedStats($artwork->id);
|
||||
|
||||
app(ArtworkStatsService::class)->incrementDownloads($artwork->id, 2, defer: false);
|
||||
|
||||
$row = DB::table('artwork_stats')->where('artwork_id', $artwork->id)->first();
|
||||
expect((int) $row->downloads)->toBe(2);
|
||||
expect((int) $row->downloads_24h)->toBe(2);
|
||||
expect((int) $row->downloads_7d)->toBe(2);
|
||||
});
|
||||
|
||||
it('multiple view increments accumulate across all three columns', function () {
|
||||
$artwork = Artwork::factory()->create(['is_public' => true, 'is_approved' => true, 'published_at' => now()->subDay()]);
|
||||
seedStats($artwork->id);
|
||||
|
||||
$svc = app(ArtworkStatsService::class);
|
||||
$svc->incrementViews($artwork->id, 1, defer: false);
|
||||
$svc->incrementViews($artwork->id, 1, defer: false);
|
||||
$svc->incrementViews($artwork->id, 1, defer: false);
|
||||
|
||||
$row = DB::table('artwork_stats')->where('artwork_id', $artwork->id)->first();
|
||||
expect((int) $row->views)->toBe(3);
|
||||
expect((int) $row->views_24h)->toBe(3);
|
||||
expect((int) $row->views_7d)->toBe(3);
|
||||
});
|
||||
|
||||
// ── ResetWindowedStatsCommand ─────────────────────────────────────────────────
|
||||
|
||||
it('reset-windowed-stats --period=24h zeros views_24h', function () {
|
||||
$artwork = Artwork::factory()->create(['is_public' => true, 'is_approved' => true, 'published_at' => now()->subDay()]);
|
||||
seedStats($artwork->id, ['views_24h' => 50, 'views_7d' => 200]);
|
||||
|
||||
$this->artisan('skinbase:reset-windowed-stats', ['--period' => '24h'])
|
||||
->assertExitCode(0);
|
||||
|
||||
$row = DB::table('artwork_stats')->where('artwork_id', $artwork->id)->first();
|
||||
expect((int) $row->views_24h)->toBe(0);
|
||||
// 7d column is NOT touched by a 24h reset
|
||||
expect((int) $row->views_7d)->toBe(200);
|
||||
});
|
||||
|
||||
it('reset-windowed-stats --period=7d zeros views_7d', function () {
|
||||
$artwork = Artwork::factory()->create(['is_public' => true, 'is_approved' => true, 'published_at' => now()->subDay()]);
|
||||
seedStats($artwork->id, ['views_24h' => 50, 'views_7d' => 200]);
|
||||
|
||||
$this->artisan('skinbase:reset-windowed-stats', ['--period' => '7d'])
|
||||
->assertExitCode(0);
|
||||
|
||||
$row = DB::table('artwork_stats')->where('artwork_id', $artwork->id)->first();
|
||||
expect((int) $row->views_7d)->toBe(0);
|
||||
// 24h column is NOT touched by a 7d reset
|
||||
expect((int) $row->views_24h)->toBe(50);
|
||||
});
|
||||
|
||||
it('reset-windowed-stats recomputes downloads_24h from artwork_downloads log', function () {
|
||||
$artwork = Artwork::factory()->create(['is_public' => true, 'is_approved' => true, 'published_at' => now()->subDay()]);
|
||||
seedStats($artwork->id, ['downloads_24h' => 99]); // stale value
|
||||
|
||||
// Insert 3 downloads within the last 24 hours
|
||||
$ip = inet_pton('127.0.0.1');
|
||||
DB::table('artwork_downloads')->insert([
|
||||
['artwork_id' => $artwork->id, 'user_id' => null, 'ip' => $ip, 'user_agent' => null, 'created_at' => now()->subHours(1)],
|
||||
['artwork_id' => $artwork->id, 'user_id' => null, 'ip' => $ip, 'user_agent' => null, 'created_at' => now()->subHours(6)],
|
||||
['artwork_id' => $artwork->id, 'user_id' => null, 'ip' => $ip, 'user_agent' => null, 'created_at' => now()->subHours(12)],
|
||||
]);
|
||||
|
||||
// Insert 2 old downloads outside the 24h window
|
||||
DB::table('artwork_downloads')->insert([
|
||||
['artwork_id' => $artwork->id, 'user_id' => null, 'ip' => $ip, 'user_agent' => null, 'created_at' => now()->subDays(2)],
|
||||
['artwork_id' => $artwork->id, 'user_id' => null, 'ip' => $ip, 'user_agent' => null, 'created_at' => now()->subDays(5)],
|
||||
]);
|
||||
|
||||
$this->artisan('skinbase:reset-windowed-stats', ['--period' => '24h'])
|
||||
->assertExitCode(0);
|
||||
|
||||
$row = DB::table('artwork_stats')->where('artwork_id', $artwork->id)->first();
|
||||
// Should equal exactly the 3 recent downloads, not the stale 99
|
||||
expect((int) $row->downloads_24h)->toBe(3);
|
||||
});
|
||||
|
||||
it('reset-windowed-stats recomputes downloads_7d including all downloads in 7-day window', function () {
|
||||
$artwork = Artwork::factory()->create(['is_public' => true, 'is_approved' => true, 'published_at' => now()->subDays(10)]);
|
||||
seedStats($artwork->id, ['downloads_7d' => 0]);
|
||||
|
||||
$ip = inet_pton('127.0.0.1');
|
||||
DB::table('artwork_downloads')->insert([
|
||||
['artwork_id' => $artwork->id, 'user_id' => null, 'ip' => $ip, 'user_agent' => null, 'created_at' => now()->subDays(1)],
|
||||
['artwork_id' => $artwork->id, 'user_id' => null, 'ip' => $ip, 'user_agent' => null, 'created_at' => now()->subDays(5)],
|
||||
['artwork_id' => $artwork->id, 'user_id' => null, 'ip' => $ip, 'user_agent' => null, 'created_at' => now()->subDays(8)], // outside 7d
|
||||
]);
|
||||
|
||||
$this->artisan('skinbase:reset-windowed-stats', ['--period' => '7d'])
|
||||
->assertExitCode(0);
|
||||
|
||||
$row = DB::table('artwork_stats')->where('artwork_id', $artwork->id)->first();
|
||||
expect((int) $row->downloads_7d)->toBe(2);
|
||||
});
|
||||
|
||||
it('reset-windowed-stats returns failure for invalid period', function () {
|
||||
$this->artisan('skinbase:reset-windowed-stats', ['--period' => 'bad'])
|
||||
->assertExitCode(1);
|
||||
});
|
||||
Reference in New Issue
Block a user