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('vision.vector_gateway.search_file_endpoint', '/vectors/search/file'); config()->set('vision.vector_gateway.circuit_breaker_seconds', 30); config()->set('cdn.files_url', 'https://files.skinbase.org'); config()->set('app.url', 'https://skinbase.test'); Storage::fake('public'); Cache::flush(); }); function similarPair(): array { $source = Artwork::factory()->create([ 'title' => 'Circuit source', 'hash' => 'aa11bb22cc33', 'thumb_ext' => 'webp', 'is_public' => true, 'is_approved' => true, 'published_at' => now()->subHour(), ]); $match = Artwork::factory()->create([ 'title' => 'Circuit match', 'hash' => 'dd44ee55ff66', 'thumb_ext' => 'webp', 'is_public' => true, 'is_approved' => true, 'published_at' => now()->subHour(), ]); return [$source, $match]; } function fakeSuccessfulUrlSearch(Artwork $source, Artwork $match): array { return [ 'https://files.skinbase.org/*' => Http::response('image-bytes', 200, ['Content-Type' => 'image/webp']), 'https://vision.klevze.net/vectors/search' => Http::response([ 'results' => [ ['id' => $source->id, 'score' => 1.0], ['id' => $match->id, 'score' => 0.91], ], ], 200), ]; } it('does not call URL search when file search succeeds and leaves the circuit closed', function (): void { [$source, $match] = similarPair(); Http::fake([ 'https://files.skinbase.org/*' => Http::response('image-bytes', 200, ['Content-Type' => 'image/webp']), 'https://vision.klevze.net/vectors/search/file' => Http::response([ 'results' => [ ['id' => $source->id, 'score' => 1.0], ['id' => $match->id, 'score' => 0.91], ], ], 200), 'https://vision.klevze.net/vectors/search' => Http::response(['error' => 'should not be called'], 500), ]); getJson('/api/art/'.$source->id.'/similar-ai')->assertOk()->assertJsonPath('data.0.id', $match->id); Http::assertNotSent(fn ($request): bool => $request->url() === 'https://vision.klevze.net/vectors/search'); expect(app(VectorGatewayClient::class)->circuitOpen())->toBeFalse(); }); it('falls back to URL search after file 400 and keeps the circuit closed', function (): void { [$source, $match] = similarPair(); Http::fake(array_merge(fakeSuccessfulUrlSearch($source, $match), [ 'https://vision.klevze.net/vectors/search/file' => Http::response(['error' => 'bad image'], 400), ])); getJson('/api/art/'.$source->id.'/similar-ai')->assertOk()->assertJsonPath('data.0.id', $match->id); Http::assertSent(fn ($request): bool => $request->url() === 'https://vision.klevze.net/vectors/search'); expect(app(VectorGatewayClient::class)->circuitOpen())->toBeFalse(); }); it('falls back to URL search after file 500 and keeps the circuit closed', function (): void { [$source, $match] = similarPair(); $logged = []; Event::listen(MessageLogged::class, function (MessageLogged $event) use (&$logged): void { $logged[] = $event; }); Http::fake(array_merge(fakeSuccessfulUrlSearch($source, $match), [ 'https://vision.klevze.net/vectors/search/file' => Http::response(['error' => 'upstream'], 500), ])); getJson('/api/art/'.$source->id.'/similar-ai')->assertOk()->assertJsonPath('data.0.id', $match->id); expect(app(VectorGatewayClient::class)->circuitOpen())->toBeFalse() ->and(openedCircuitLogs($logged))->toHaveCount(0); }); it('falls back to URL search after a file connection failure and keeps the circuit closed', function (): void { [$source, $match] = similarPair(); Http::fake(array_merge(fakeSuccessfulUrlSearch($source, $match), [ 'https://vision.klevze.net/vectors/search/file' => function () { throw new ConnectionException('cURL error 28: Operation timed out'); }, ])); getJson('/api/art/'.$source->id.'/similar-ai')->assertOk()->assertJsonPath('data.0.id', $match->id); expect(app(VectorGatewayClient::class)->circuitOpen())->toBeFalse(); }); function openedCircuitLogs(array $logged): array { return array_values(array_filter( $logged, static fn (MessageLogged $event): bool => $event->message === 'Vector gateway circuit opened', )); } it('opens the circuit when both search strategies fail transiently', function (): void { [$source] = similarPair(); $logged = []; Event::listen(MessageLogged::class, function (MessageLogged $event) use (&$logged): void { $logged[] = $event; }); Http::fake([ 'https://files.skinbase.org/*' => Http::response('image-bytes', 200, ['Content-Type' => 'image/webp']), 'https://vision.klevze.net/vectors/search/file' => Http::response(['error' => 'down'], 503), 'https://vision.klevze.net/vectors/search' => Http::response(['error' => 'down'], 503), ]); getJson('/api/art/'.$source->id.'/similar-ai') ->assertStatus(502) ->assertJson([ 'data' => [], 'reason' => 'vector_gateway_error', ]) ->assertJsonMissingPath('message'); expect(app(VectorGatewayClient::class)->circuitOpen())->toBeTrue(); $opened = openedCircuitLogs($logged); expect($opened)->toHaveCount(1) ->and($opened[0]->level)->toBe('warning') ->and($opened[0]->context['source'] ?? null)->toBe('similar_ai') ->and($opened[0]->context['artwork_id'] ?? null)->toBe($source->id) ->and($opened[0]->context['circuit_ttl_seconds'] ?? null)->toBe(30) ->and($opened[0]->context)->toHaveKey('php_sapi') ->and($opened[0]->context)->toHaveKey('running_in_console'); $operations = collect($opened[0]->context['failures'] ?? [])->pluck('operation')->all(); $statuses = collect($opened[0]->context['failures'] ?? [])->pluck('http_status')->all(); expect($operations)->toContain('search_file') ->and($operations)->toContain('search_url') ->and($statuses)->toBe([503, 503]); $encoded = json_encode($opened[0]->context); expect($encoded)->not->toContain('test-key') ->and($encoded)->not->toContain('image-bytes') ->and($encoded)->not->toContain('vision.klevze.net') ->and($encoded)->not->toContain('files.skinbase.org'); }); it('does not open the circuit when both strategies fail with permanent 4xx', function (): void { [$source] = similarPair(); $logged = []; Event::listen(MessageLogged::class, function (MessageLogged $event) use (&$logged): void { $logged[] = $event; }); Http::fake([ 'https://files.skinbase.org/*' => Http::response('image-bytes', 200, ['Content-Type' => 'image/webp']), 'https://vision.klevze.net/vectors/search/file' => Http::response(['error' => 'bad file'], 400), 'https://vision.klevze.net/vectors/search' => Http::response(['error' => 'bad url'], 422), ]); getJson('/api/art/'.$source->id.'/similar-ai')->assertStatus(502); expect(app(VectorGatewayClient::class)->circuitOpen())->toBeFalse() ->and(openedCircuitLogs($logged))->toHaveCount(0); }); it('fails fast when the circuit is already open without downloading or calling the gateway', function (): void { [$source] = similarPair(); Cache::put('vision.vector_gateway.circuit_open', true, 30); $logged = []; Event::listen(MessageLogged::class, function (MessageLogged $event) use (&$logged): void { $logged[] = $event; }); Http::fake(); getJson('/api/art/'.$source->id.'/similar-ai') ->assertStatus(502) ->assertJsonPath('reason', 'vector_gateway_error') ->assertJsonMissingPath('message'); Http::assertNothingSent(); expect(openedCircuitLogs($logged))->toHaveCount(0); $failFast = collect($logged)->first(fn (MessageLogged $event): bool => $event->message === 'Vector similarity search failed'); expect($failFast)->not->toBeNull() ->and($failFast->context['failure_stage'] ?? null)->toBe('circuit'); }); it('logs one structured warning on final similarity failure without leaking internals', function (): void { [$source] = similarPair(); $logged = []; Event::listen(MessageLogged::class, function (MessageLogged $event) use (&$logged): void { $logged[] = $event; }); Http::fake([ 'https://files.skinbase.org/*' => Http::response('image-bytes', 200, ['Content-Type' => 'image/webp']), 'https://vision.klevze.net/vectors/search/file' => Http::response(['error' => 'x'], 500), 'https://vision.klevze.net/vectors/search' => Http::response(['error' => 'y'], 500), ]); getJson('/api/art/'.$source->id.'/similar-ai')->assertStatus(502); $match = collect($logged)->first(fn (MessageLogged $event): bool => str_contains((string) $event->message, 'Vector similarity search failed')); expect($match)->not->toBeNull() ->and($match->level)->toBe('warning') ->and($match->context['artwork_id'] ?? null)->toBe($source->id) ->and($match->context['route'] ?? null)->toBe('api.art.similar-ai') ->and($match->context)->toHaveKey('circuit_worthy') ->and($match->context)->toHaveKey('circuit_open') ->and(json_encode($match->context))->not->toContain('test-key') ->and(json_encode($match->context))->not->toContain('image-bytes'); }); it('does not trip the circuit when file gateway was never attempted and URL returns 502', function (): void { [$source] = similarPair(); $logged = []; Event::listen(MessageLogged::class, function (MessageLogged $event) use (&$logged): void { $logged[] = $event; }); Http::fake([ 'https://files.skinbase.org/*' => Http::response('not-found', 404), 'https://vision.klevze.net/vectors/search' => Http::response(['error' => 'bad gateway'], 502), 'https://vision.klevze.net/vectors/search/file' => Http::response(['error' => 'should not be called'], 500), ]); getJson('/api/art/'.$source->id.'/similar-ai') ->assertStatus(502) ->assertJsonPath('reason', 'vector_gateway_error'); Http::assertNotSent(fn ($request): bool => $request->url() === 'https://vision.klevze.net/vectors/search/file'); expect(app(VectorGatewayClient::class)->circuitOpen())->toBeFalse() ->and(openedCircuitLogs($logged))->toHaveCount(0); }); it('does not trip the circuit when image download throws before the file gateway', function (): void { [$source] = similarPair(); $logged = []; Event::listen(MessageLogged::class, function (MessageLogged $event) use (&$logged): void { $logged[] = $event; }); Http::fake([ 'https://files.skinbase.org/*' => function () { throw new RuntimeException('cdn unavailable'); }, 'https://vision.klevze.net/vectors/search' => Http::response(['error' => 'bad gateway'], 502), ]); getJson('/api/art/'.$source->id.'/similar-ai')->assertStatus(502); expect(app(VectorGatewayClient::class)->circuitOpen())->toBeFalse() ->and(openedCircuitLogs($logged))->toHaveCount(0); }); it('does not trip the circuit when file gateway is 422 and URL is 502', function (): void { [$source] = similarPair(); $logged = []; Event::listen(MessageLogged::class, function (MessageLogged $event) use (&$logged): void { $logged[] = $event; }); Http::fake([ 'https://files.skinbase.org/*' => Http::response('image-bytes', 200, ['Content-Type' => 'image/webp']), 'https://vision.klevze.net/vectors/search/file' => Http::response(['error' => 'unprocessable'], 422), 'https://vision.klevze.net/vectors/search' => Http::response(['error' => 'bad gateway'], 502), ]); getJson('/api/art/'.$source->id.'/similar-ai')->assertStatus(502); expect(app(VectorGatewayClient::class)->circuitOpen())->toBeFalse() ->and(openedCircuitLogs($logged))->toHaveCount(0); }); it('does not trip the circuit when file gateway is 503 and URL is 422', function (): void { [$source] = similarPair(); $logged = []; Event::listen(MessageLogged::class, function (MessageLogged $event) use (&$logged): void { $logged[] = $event; }); Http::fake([ 'https://files.skinbase.org/*' => Http::response('image-bytes', 200, ['Content-Type' => 'image/webp']), 'https://vision.klevze.net/vectors/search/file' => Http::response(['error' => 'down'], 503), 'https://vision.klevze.net/vectors/search' => Http::response(['error' => 'bad url'], 422), ]); getJson('/api/art/'.$source->id.'/similar-ai')->assertStatus(502); expect(app(VectorGatewayClient::class)->circuitOpen())->toBeFalse() ->and(openedCircuitLogs($logged))->toHaveCount(0); }); it('attributes uploaded-image transient failures as uploaded_image', function (): void { $logged = []; Event::listen(MessageLogged::class, function (MessageLogged $event) use (&$logged): void { $logged[] = $event; }); Http::fake([ 'https://vision.klevze.net/vectors/search/file' => Http::response(['error' => 'down'], 503), ]); $file = UploadedFile::fake()->image('query.png', 64, 64); expect(fn () => app(AiArtworkVectorSearchService::class)->searchByUploadedImage($file, 12)) ->toThrow(VectorGatewayException::class); expect(app(VectorGatewayClient::class)->circuitOpen())->toBeTrue(); $opened = openedCircuitLogs($logged); expect($opened)->toHaveCount(1) ->and($opened[0]->context['source'] ?? null)->toBe('uploaded_image') ->and($opened[0]->context['failures'][0]['operation'] ?? null)->toBe('search_file') ->and($opened[0]->context['failures'][0]['http_status'] ?? null)->toBe(503) ->and(json_encode($opened[0]->context))->not->toContain('test-key'); }); it('serves similar-ai from cache without contacting the gateway', function (): void { [$source, $match] = similarPair(); Http::fake([ 'https://files.skinbase.org/*' => Http::response('image-bytes', 200, ['Content-Type' => 'image/webp']), 'https://vision.klevze.net/vectors/search/file' => Http::response([ 'results' => [ ['id' => $source->id, 'score' => 1.0], ['id' => $match->id, 'score' => 0.91], ], ], 200), ]); getJson('/api/art/'.$source->id.'/similar-ai')->assertOk(); Http::fake(function () { throw new RuntimeException('gateway should not be called on cache hit'); }); getJson('/api/art/'.$source->id.'/similar-ai') ->assertOk() ->assertJsonPath('data.0.id', $match->id); });