Keep similar-ai from tripping the global circuit on a lone URL 502, clamp Qdrant search to 100, and add Server-Timing plus slow-request logging. Studio shared props, Academy S3 exists caching, heat chunking, and Redis/scheduler hygiene stay in this rollout.
142 lines
4.3 KiB
PHP
142 lines
4.3 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace Tests\Feature\Recommendations;
|
|
|
|
use App\Jobs\RecComputeSimilarByBehaviorJob;
|
|
use App\Jobs\RecComputeSimilarByTagsJob;
|
|
use App\Jobs\RecComputeSimilarHybridJob;
|
|
use App\Models\Artwork;
|
|
use App\Models\RecArtworkRec;
|
|
use App\Models\User;
|
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
|
use Illuminate\Support\Facades\Cache;
|
|
use Illuminate\Support\Facades\DB;
|
|
use Illuminate\Support\Facades\Queue;
|
|
use Tests\TestCase;
|
|
|
|
final class RecComputeSimilarJobsTest extends TestCase
|
|
{
|
|
use RefreshDatabase;
|
|
|
|
public function test_redis_retry_after_exceeds_horizon_default_timeout(): void
|
|
{
|
|
$this->assertGreaterThan(
|
|
(int) config('horizon.defaults.supervisor-default.timeout'),
|
|
(int) config('queue.connections.redis.retry_after'),
|
|
);
|
|
}
|
|
|
|
public function test_behavior_job_processes_a_batch_and_dispatches_the_next_cursor(): void
|
|
{
|
|
$user = User::factory()->create();
|
|
$artworks = Artwork::factory()->count(3)->create(['user_id' => $user->id]);
|
|
|
|
DB::table('rec_item_pairs')->insert([
|
|
[
|
|
'a_artwork_id' => $artworks[0]->id,
|
|
'b_artwork_id' => $artworks[1]->id,
|
|
'weight' => 1.5,
|
|
'updated_at' => now(),
|
|
],
|
|
[
|
|
'a_artwork_id' => $artworks[0]->id,
|
|
'b_artwork_id' => $artworks[2]->id,
|
|
'weight' => 1.0,
|
|
'updated_at' => now(),
|
|
],
|
|
]);
|
|
|
|
Queue::fake();
|
|
|
|
(new RecComputeSimilarByBehaviorJob(null, 2))->handle();
|
|
|
|
$this->assertNotNull(
|
|
RecArtworkRec::query()
|
|
->where('artwork_id', $artworks[0]->id)
|
|
->where('rec_type', 'similar_behavior')
|
|
->first(),
|
|
);
|
|
|
|
$expectedCursor = (int) $artworks->sortBy('id')->values()[1]->id;
|
|
|
|
Queue::assertPushed(
|
|
RecComputeSimilarByBehaviorJob::class,
|
|
fn (RecComputeSimilarByBehaviorJob $job): bool => $job->cursorAfterArtworkId() === $expectedCursor,
|
|
);
|
|
}
|
|
|
|
public function test_behavior_job_does_not_dispatch_when_the_batch_is_partial(): void
|
|
{
|
|
Artwork::factory()->count(2)->create();
|
|
|
|
Queue::fake();
|
|
|
|
(new RecComputeSimilarByBehaviorJob(null, 10))->handle();
|
|
|
|
Queue::assertNothingPushed();
|
|
}
|
|
|
|
public function test_hybrid_job_is_idempotent_for_a_single_artwork(): void
|
|
{
|
|
$user = User::factory()->create();
|
|
$source = Artwork::factory()->create(['user_id' => $user->id]);
|
|
$related = Artwork::factory()->create(['user_id' => $user->id]);
|
|
|
|
RecArtworkRec::query()->create([
|
|
'artwork_id' => $source->id,
|
|
'rec_type' => 'similar_tags',
|
|
'model_version' => (string) config('recommendations.similarity.model_version', 'sim_v1'),
|
|
'recs' => [$related->id],
|
|
'computed_at' => now(),
|
|
]);
|
|
|
|
(new RecComputeSimilarHybridJob($source->id))->handle();
|
|
(new RecComputeSimilarHybridJob($source->id))->handle();
|
|
|
|
$this->assertSame(
|
|
1,
|
|
RecArtworkRec::query()
|
|
->where('artwork_id', $source->id)
|
|
->where('rec_type', 'similar_hybrid')
|
|
->count(),
|
|
);
|
|
}
|
|
|
|
public function test_hybrid_batch_dispatches_continuation_when_full(): void
|
|
{
|
|
Artwork::factory()->count(3)->create();
|
|
|
|
Queue::fake();
|
|
|
|
(new RecComputeSimilarHybridJob(null, 2))->handle();
|
|
|
|
Queue::assertPushed(RecComputeSimilarHybridJob::class);
|
|
}
|
|
|
|
public function test_tag_idf_group_by_is_cached_across_handle_calls(): void
|
|
{
|
|
Cache::flush();
|
|
|
|
Artwork::factory()->create([
|
|
'is_public' => true,
|
|
'is_approved' => true,
|
|
'published_at' => now(),
|
|
]);
|
|
|
|
$idfQueries = 0;
|
|
DB::listen(function ($query) use (&$idfQueries): void {
|
|
if (str_contains(strtolower($query->sql), 'artwork_tag')
|
|
&& str_contains(strtolower($query->sql), 'group by')) {
|
|
$idfQueries++;
|
|
}
|
|
});
|
|
|
|
(new RecComputeSimilarByTagsJob(null, 10))->handle();
|
|
(new RecComputeSimilarByTagsJob(null, 10))->handle();
|
|
|
|
$this->assertSame(1, $idfQueries);
|
|
}
|
|
}
|