Upload beautify
This commit is contained in:
48
tests/Feature/Discovery/DiscoveryEventIngestionTest.php
Normal file
48
tests/Feature/Discovery/DiscoveryEventIngestionTest.php
Normal file
@@ -0,0 +1,48 @@
|
||||
<?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']);
|
||||
});
|
||||
113
tests/Feature/Discovery/FeedEndpointTest.php
Normal file
113
tests/Feature/Discovery/FeedEndpointTest.php
Normal file
@@ -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();
|
||||
});
|
||||
Reference in New Issue
Block a user