Harden vision vector search and embedding jobs.
Add point search, circuit-breaking, and safer gateway limits so artwork similarity lookups fail closed instead of hanging the request path.
This commit is contained in:
@@ -0,0 +1,109 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Models\Artwork;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Http\Client\ConnectionException;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use function Pest\Laravel\getJson;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
beforeEach(function (): void {
|
||||
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_point_endpoint', '/vectors/search/point');
|
||||
config()->set('cdn.files_url', 'https://files.skinbase.org');
|
||||
config()->set('app.url', 'https://skinbase.test');
|
||||
Cache::flush();
|
||||
Storage::fake('public');
|
||||
});
|
||||
|
||||
function pointSearchArtwork(): Artwork
|
||||
{
|
||||
return Artwork::factory()->create([
|
||||
'hash' => 'point-source-hash',
|
||||
'thumb_ext' => 'webp',
|
||||
'is_public' => true,
|
||||
'is_approved' => true,
|
||||
'published_at' => now()->subHour(),
|
||||
]);
|
||||
}
|
||||
|
||||
it('uses the stored point and does not fetch or embed the source image', function (): void {
|
||||
$source = pointSearchArtwork();
|
||||
$match = Artwork::factory()->create(['title' => 'Point match']);
|
||||
|
||||
Http::fake([
|
||||
'https://vision.klevze.net/vectors/search/point' => Http::response([
|
||||
'results' => [
|
||||
['id' => $source->id, 'score' => 1.0],
|
||||
['id' => $match->id, 'score' => 0.91],
|
||||
],
|
||||
], 200),
|
||||
'https://files.skinbase.org/*' => Http::response('should not download', 500),
|
||||
'https://vision.klevze.net/vectors/search/file' => Http::response(['error' => 'should not embed'], 500),
|
||||
'https://vision.klevze.net/vectors/search' => Http::response(['error' => 'should not search by URL'], 500),
|
||||
]);
|
||||
|
||||
getJson('/api/art/' . $source->id . '/similar-ai')
|
||||
->assertOk()
|
||||
->assertJsonPath('data.0.id', $match->id);
|
||||
|
||||
Http::assertSent(fn ($request): bool => str_contains($request->url(), '/vectors/search/point')
|
||||
&& ($request->data()['id'] ?? null) === $source->id);
|
||||
Http::assertNotSent(fn ($request): bool => str_contains($request->url(), '/vectors/search/file'));
|
||||
Http::assertNotSent(fn ($request): bool => str_contains($request->url(), '/vectors/search') && ! str_contains($request->url(), '/point'));
|
||||
Http::assertNotSent(fn ($request): bool => str_contains($request->url(), 'files.skinbase.org'));
|
||||
});
|
||||
|
||||
it('falls back to the existing image path only when the point is missing', function (): void {
|
||||
$source = pointSearchArtwork();
|
||||
$match = Artwork::factory()->create(['title' => 'Fallback match']);
|
||||
|
||||
Http::fake([
|
||||
'https://vision.klevze.net/vectors/search/point' => Http::response(['detail' => 'missing'], 404),
|
||||
'https://files.skinbase.org/*' => Http::response('image-bytes', 200, ['Content-Type' => 'image/webp']),
|
||||
'https://vision.klevze.net/vectors/search/file' => Http::response([
|
||||
'results' => [['id' => $match->id, 'score' => 0.8]],
|
||||
], 200),
|
||||
]);
|
||||
|
||||
getJson('/api/art/' . $source->id . '/similar-ai')
|
||||
->assertOk()
|
||||
->assertJsonPath('data.0.id', $match->id);
|
||||
});
|
||||
|
||||
it('does not fall back to CLIP after a point-search server failure', function (): void {
|
||||
$source = pointSearchArtwork();
|
||||
|
||||
Http::fake([
|
||||
'https://vision.klevze.net/vectors/search/point' => Http::response(['error' => 'upstream'], 500),
|
||||
'https://files.skinbase.org/*' => Http::response('should not download', 500),
|
||||
'https://vision.klevze.net/vectors/search/file' => Http::response(['error' => 'should not embed'], 500),
|
||||
'https://vision.klevze.net/vectors/search' => Http::response(['error' => 'should not search by URL'], 500),
|
||||
]);
|
||||
|
||||
getJson('/api/art/' . $source->id . '/similar-ai')->assertStatus(502);
|
||||
Http::assertNotSent(fn ($request): bool => str_contains($request->url(), 'files.skinbase.org'));
|
||||
Http::assertNotSent(fn ($request): bool => str_contains($request->url(), '/vectors/search/file'));
|
||||
});
|
||||
|
||||
it('does not fall back to CLIP after a point-search timeout', function (): void {
|
||||
$source = pointSearchArtwork();
|
||||
|
||||
Http::fake(function ($request) {
|
||||
if (str_contains($request->url(), '/vectors/search/point')) {
|
||||
throw new ConnectionException('cURL error 28: Operation timed out');
|
||||
}
|
||||
|
||||
throw new RuntimeException('fallback must not be called');
|
||||
});
|
||||
|
||||
getJson('/api/art/' . $source->id . '/similar-ai')->assertStatus(502);
|
||||
Http::assertNothingSent();
|
||||
});
|
||||
Reference in New Issue
Block a user