Ship production optimization M1-M12.5A: queues, metrics, HTTP observability, and vector search reliability.
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.
This commit is contained in:
@@ -0,0 +1,18 @@
|
||||
<?php
|
||||
|
||||
use App\Models\Collection;
|
||||
use App\Services\CollectionBackgroundJobService;
|
||||
use Illuminate\Support\Facades\Queue;
|
||||
|
||||
it('does not dispatch collection jobs when the producer gate is off', function () {
|
||||
config(['collections.v5.queue.dispatch_enabled' => false]);
|
||||
Queue::fake();
|
||||
|
||||
$collection = Collection::factory()->create();
|
||||
$result = app(CollectionBackgroundJobService::class)->dispatchHealthRefresh($collection);
|
||||
|
||||
expect($result['status'])->toBe('disabled')
|
||||
->and($result['count'])->toBe(0);
|
||||
|
||||
Queue::assertNothingPushed();
|
||||
});
|
||||
@@ -0,0 +1,119 @@
|
||||
<?php
|
||||
|
||||
use cPad\Plugins\Forum\Jobs\AnalyzeForumPostJob;
|
||||
use cPad\Plugins\Forum\Jobs\BotActivityMonitor;
|
||||
use cPad\Plugins\Forum\Jobs\FirewallActivityMonitor;
|
||||
use cPad\Plugins\Forum\Models\ForumPost;
|
||||
use cPad\Plugins\Forum\Services\ForumModerationService;
|
||||
use cPad\Plugins\Forum\Services\Security\BotProtectionService;
|
||||
use cPad\Plugins\Forum\Services\Security\ForumSecurityFirewallService;
|
||||
use Illuminate\Support\Facades\Queue;
|
||||
|
||||
it('does not dispatch AnalyzeForumPostJob when AI queue dispatch is disabled', function () {
|
||||
config(['skinbase_ai_moderation.queue.dispatch_enabled' => false]);
|
||||
Queue::fake();
|
||||
|
||||
$post = new ForumPost();
|
||||
$post->id = 42;
|
||||
|
||||
$queued = app(ForumModerationService::class)->dispatchAsyncScan($post);
|
||||
|
||||
expect($queued)->toBeFalse();
|
||||
Queue::assertNotPushed(AnalyzeForumPostJob::class);
|
||||
});
|
||||
|
||||
it('dispatches AnalyzeForumPostJob when AI queue dispatch is enabled', function () {
|
||||
config(['skinbase_ai_moderation.queue.dispatch_enabled' => true]);
|
||||
Queue::fake();
|
||||
|
||||
$post = new ForumPost();
|
||||
$post->id = 43;
|
||||
|
||||
$queued = app(ForumModerationService::class)->dispatchAsyncScan($post);
|
||||
|
||||
expect($queued)->toBeTrue();
|
||||
Queue::assertPushedOn(
|
||||
(string) config('skinbase_ai_moderation.queue.name', 'forum-moderation'),
|
||||
AnalyzeForumPostJob::class,
|
||||
);
|
||||
});
|
||||
|
||||
it('does not dispatch BotActivityMonitor when security queue dispatch is disabled', function () {
|
||||
config(['forum_security.queues.dispatch_enabled' => false]);
|
||||
Queue::fake();
|
||||
|
||||
$this->artisan('forum:bot-scan')->assertSuccessful();
|
||||
|
||||
Queue::assertNotPushed(BotActivityMonitor::class);
|
||||
});
|
||||
|
||||
it('dispatches BotActivityMonitor when security queue dispatch is enabled', function () {
|
||||
config(['forum_security.queues.dispatch_enabled' => true]);
|
||||
Queue::fake();
|
||||
|
||||
$this->artisan('forum:bot-scan')->assertSuccessful();
|
||||
|
||||
Queue::assertPushed(BotActivityMonitor::class);
|
||||
});
|
||||
|
||||
it('does not dispatch FirewallActivityMonitor when security queue dispatch is disabled', function () {
|
||||
config(['forum_security.queues.dispatch_enabled' => false]);
|
||||
Queue::fake();
|
||||
|
||||
$this->artisan('forum:firewall-scan')->assertSuccessful();
|
||||
|
||||
Queue::assertNotPushed(FirewallActivityMonitor::class);
|
||||
});
|
||||
|
||||
it('dispatches FirewallActivityMonitor when security queue dispatch is enabled', function () {
|
||||
config(['forum_security.queues.dispatch_enabled' => true]);
|
||||
Queue::fake();
|
||||
|
||||
$this->artisan('forum:firewall-scan')->assertSuccessful();
|
||||
|
||||
Queue::assertPushed(FirewallActivityMonitor::class);
|
||||
});
|
||||
|
||||
it('runs forum bot scan synchronously without queueing when --sync is passed', function () {
|
||||
config(['forum_security.queues.dispatch_enabled' => false]);
|
||||
Queue::fake();
|
||||
|
||||
$this->mock(BotProtectionService::class, function ($mock): void {
|
||||
$mock->shouldReceive('scanRecentActivity')->once()->andReturn(['processed' => 0]);
|
||||
});
|
||||
|
||||
$this->artisan('forum:bot-scan --sync')->assertSuccessful();
|
||||
|
||||
Queue::assertNothingPushed();
|
||||
});
|
||||
|
||||
it('runs forum firewall scan synchronously without queueing when --sync is passed', function () {
|
||||
config(['forum_security.queues.dispatch_enabled' => false]);
|
||||
Queue::fake();
|
||||
|
||||
$this->mock(ForumSecurityFirewallService::class, function ($mock): void {
|
||||
$mock->shouldReceive('scanRecentThreats')->once()->andReturn(['processed' => 0]);
|
||||
});
|
||||
|
||||
$this->artisan('forum:firewall-scan --sync')->assertSuccessful();
|
||||
|
||||
Queue::assertNothingPushed();
|
||||
});
|
||||
|
||||
it('does not enqueue AnalyzeForumPostJob from forum:ai-scan without --sync when dispatch is disabled', function () {
|
||||
config(['skinbase_ai_moderation.queue.dispatch_enabled' => false]);
|
||||
Queue::fake();
|
||||
|
||||
$this->artisan('forum:ai-scan')->assertSuccessful();
|
||||
|
||||
Queue::assertNotPushed(AnalyzeForumPostJob::class);
|
||||
});
|
||||
|
||||
it('keeps synchronous forum:scan-posts on the schedule and omits async forum enqueue commands when dispatch is disabled', function () {
|
||||
$this->artisan('schedule:list')
|
||||
->expectsOutputToContain('forum:scan-posts')
|
||||
->doesntExpectOutputToContain('forum:ai-scan')
|
||||
->doesntExpectOutputToContain('forum:bot-scan')
|
||||
->doesntExpectOutputToContain('forum:firewall-scan')
|
||||
->assertSuccessful();
|
||||
});
|
||||
@@ -0,0 +1,24 @@
|
||||
<?php
|
||||
|
||||
use App\Models\Artwork;
|
||||
use App\Services\ArtworkStatsService;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
it('does not increment download stats when the original file is missing', function () {
|
||||
$artwork = Artwork::factory()->create([
|
||||
'is_public' => true,
|
||||
'is_approved' => true,
|
||||
'published_at' => now()->subDay(),
|
||||
'file_name' => 'missing-original.jpg',
|
||||
'file_ext' => 'jpg',
|
||||
'hash' => str_repeat('ab', 32),
|
||||
]);
|
||||
|
||||
$this->mock(ArtworkStatsService::class, function ($mock) {
|
||||
$mock->shouldReceive('incrementDownloads')->never();
|
||||
});
|
||||
|
||||
$this->get('/download/artwork/'.$artwork->id)->assertNotFound();
|
||||
|
||||
expect(DB::table('artwork_downloads')->where('artwork_id', $artwork->id)->count())->toBe(0);
|
||||
});
|
||||
@@ -0,0 +1,11 @@
|
||||
<?php
|
||||
|
||||
use App\Services\Traffic\OnlineVisitorRepository;
|
||||
|
||||
it('does not record presence for RSS feed requests', function () {
|
||||
$this->mock(OnlineVisitorRepository::class, function ($mock): void {
|
||||
$mock->shouldReceive('track')->never();
|
||||
});
|
||||
|
||||
$this->get('/rss/latest-uploads.xml')->assertOk();
|
||||
});
|
||||
@@ -0,0 +1,113 @@
|
||||
<?php
|
||||
|
||||
use App\Models\Artwork;
|
||||
use App\Models\User;
|
||||
use Illuminate\Support\Facades\File;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
function slowHttpLogPath(): string
|
||||
{
|
||||
return storage_path('logs/slow-http-test.log');
|
||||
}
|
||||
|
||||
function configureSlowHttpLog(): void
|
||||
{
|
||||
config([
|
||||
'http_observability.slow_request.enabled' => true,
|
||||
'http_observability.slow_request.threshold_ms' => 750,
|
||||
'logging.channels.slow-http.driver' => 'single',
|
||||
'logging.channels.slow-http.path' => slowHttpLogPath(),
|
||||
'logging.channels.slow-http.level' => 'info',
|
||||
]);
|
||||
Log::forgetChannel('slow-http');
|
||||
File::delete(slowHttpLogPath());
|
||||
}
|
||||
|
||||
afterEach(function (): void {
|
||||
File::delete(slowHttpLogPath());
|
||||
});
|
||||
|
||||
it('does not write a slow-http record for a fast request', function () {
|
||||
configureSlowHttpLog();
|
||||
config(['http_observability.slow_request.test_duration_ms' => 120]);
|
||||
|
||||
$this->get('/leaderboard')->assertOk();
|
||||
|
||||
expect(File::exists(slowHttpLogPath()))->toBeFalse();
|
||||
});
|
||||
|
||||
it('writes one structured slow-http record using the route template', function () {
|
||||
configureSlowHttpLog();
|
||||
config(['http_observability.slow_request.test_duration_ms' => 900]);
|
||||
|
||||
$response = $this->get('/leaderboard');
|
||||
$response->assertOk();
|
||||
expect($response->headers->get('Server-Timing'))->toContain('app;desc="Laravel"');
|
||||
|
||||
$raw = File::get(slowHttpLogPath());
|
||||
expect(substr_count(trim($raw), "\n"))->toBe(0);
|
||||
|
||||
$line = trim($raw);
|
||||
preg_match('/\{.*\}\s*$/', $line, $matches);
|
||||
expect($matches[0] ?? null)->not->toBeNull();
|
||||
$json = json_decode($matches[0], true, flags: JSON_THROW_ON_ERROR);
|
||||
|
||||
expect($json['method'])->toBe('GET')
|
||||
->and($json['status'])->toBe(200)
|
||||
->and($json['duration_ms'])->toEqual(900)
|
||||
->and($json['route_uri'])->not->toContain('?')
|
||||
->and($json['route_uri'])->not->toContain('leaderboard/')
|
||||
->and($json)->not->toHaveKey('query')
|
||||
->and($json)->not->toHaveKey('ip')
|
||||
->and($json)->not->toHaveKey('email')
|
||||
->and($json)->not->toHaveKey('user_id')
|
||||
->and($json['authenticated'])->toBeFalse();
|
||||
});
|
||||
|
||||
it('does not log when slow-request logging is disabled', function () {
|
||||
configureSlowHttpLog();
|
||||
config([
|
||||
'http_observability.slow_request.enabled' => false,
|
||||
'http_observability.slow_request.test_duration_ms' => 5000,
|
||||
]);
|
||||
|
||||
$this->get('/leaderboard')->assertOk()->assertHeader('Server-Timing');
|
||||
|
||||
expect(File::exists(slowHttpLogPath()))->toBeFalse();
|
||||
});
|
||||
|
||||
it('does not break a missing-file download response and still sends Server-Timing', function () {
|
||||
configureSlowHttpLog();
|
||||
config(['http_observability.slow_request.test_duration_ms' => 100]);
|
||||
|
||||
$artwork = Artwork::factory()->create([
|
||||
'is_public' => true,
|
||||
'is_approved' => true,
|
||||
'published_at' => now()->subDay(),
|
||||
'file_ext' => 'jpg',
|
||||
'hash' => str_repeat('cd', 32),
|
||||
]);
|
||||
|
||||
$this->get('/download/artwork/'.$artwork->id)
|
||||
->assertNotFound()
|
||||
->assertHeader('Server-Timing');
|
||||
});
|
||||
|
||||
it('does not log query strings or user identifiers on an authenticated slow request', function () {
|
||||
configureSlowHttpLog();
|
||||
config(['http_observability.slow_request.test_duration_ms' => 800]);
|
||||
|
||||
$user = User::factory()->create([
|
||||
'email' => '[email protected]',
|
||||
'username' => 'slowlogsecret',
|
||||
]);
|
||||
|
||||
$this->actingAs($user)->get('/leaderboard?period=weekly&debug=1')->assertOk();
|
||||
|
||||
$raw = File::get(slowHttpLogPath());
|
||||
expect($raw)->not->toContain('[email protected]')
|
||||
->and($raw)->not->toContain('period=weekly')
|
||||
->and($raw)->not->toContain('debug=1')
|
||||
->and($raw)->not->toContain('"user_id"')
|
||||
->and($raw)->not->toContain($user->email);
|
||||
});
|
||||
@@ -0,0 +1,26 @@
|
||||
<?php
|
||||
|
||||
use App\Models\Group;
|
||||
use App\Models\User;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
it('does not load studio group memberships on non-studio Inertia pages', function () {
|
||||
$user = User::factory()->create();
|
||||
|
||||
Group::factory()->create([
|
||||
'owner_user_id' => $user->id,
|
||||
'status' => Group::LIFECYCLE_ACTIVE,
|
||||
]);
|
||||
|
||||
$groupQueries = 0;
|
||||
DB::listen(function ($query) use (&$groupQueries): void {
|
||||
$sql = strtolower($query->sql);
|
||||
if (str_contains($sql, 'from `groups`') || str_contains($sql, 'from groups') || str_contains($sql, 'group_members')) {
|
||||
$groupQueries++;
|
||||
}
|
||||
});
|
||||
|
||||
$this->actingAs($user)->get('/leaderboard')->assertOk();
|
||||
|
||||
expect($groupQueries)->toBe(0);
|
||||
});
|
||||
@@ -0,0 +1,175 @@
|
||||
<?php
|
||||
|
||||
use App\Models\ArtworkMetricSnapshotHourly;
|
||||
use App\Services\Metrics\ArtworkHourlySnapshotWindow;
|
||||
use App\Services\Studio\StudioMetricsService;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
function snapshotArtworkForUser(int $userId): \App\Models\Artwork
|
||||
{
|
||||
return \App\Models\Artwork::withoutEvents(fn () => \App\Models\Artwork::factory()->create([
|
||||
'user_id' => $userId,
|
||||
'is_approved' => true,
|
||||
'is_public' => true,
|
||||
'published_at' => now()->subDays(40),
|
||||
'deleted_at' => null,
|
||||
]));
|
||||
}
|
||||
|
||||
function insertHourlySnapshot(int $artworkId, $bucketHour, int $views, int $favourites = 0, int $shares = 0, int $comments = 0, int $downloads = 0): void
|
||||
{
|
||||
ArtworkMetricSnapshotHourly::create([
|
||||
'artwork_id' => $artworkId,
|
||||
'bucket_hour' => $bucketHour,
|
||||
'views_count' => $views,
|
||||
'downloads_count' => $downloads,
|
||||
'favourites_count' => $favourites,
|
||||
'comments_count' => $comments,
|
||||
'shares_count' => $shares,
|
||||
]);
|
||||
}
|
||||
|
||||
it('computes 30d views as max-min per artwork not sum of cumulatives', function () {
|
||||
$user = \App\Models\User::factory()->create();
|
||||
$artwork = snapshotArtworkForUser($user->id);
|
||||
|
||||
insertHourlySnapshot($artwork->id, now()->subDays(10)->startOfHour(), 100, 10, 1, 2, 4);
|
||||
insertHourlySnapshot($artwork->id, now()->subDays(1)->startOfHour(), 130, 16, 4, 5, 9);
|
||||
|
||||
$window = app(ArtworkHourlySnapshotWindow::class)->userWindow($user->id, 30);
|
||||
|
||||
expect($window['views'])->toBe(30)
|
||||
->and($window['favourites'])->toBe(6)
|
||||
->and($window['shares'])->toBe(3)
|
||||
->and($window['comments'])->toBe(3)
|
||||
->and($window['downloads'])->toBe(5)
|
||||
->and($window['views'])->not->toBe(230);
|
||||
});
|
||||
|
||||
it('sums window deltas across a creator\'s artworks', function () {
|
||||
$user = \App\Models\User::factory()->create();
|
||||
$a = snapshotArtworkForUser($user->id);
|
||||
$b = snapshotArtworkForUser($user->id);
|
||||
|
||||
insertHourlySnapshot($a->id, now()->subDays(5)->startOfHour(), 50);
|
||||
insertHourlySnapshot($a->id, now()->subHour()->startOfHour(), 80);
|
||||
insertHourlySnapshot($b->id, now()->subDays(5)->startOfHour(), 10);
|
||||
insertHourlySnapshot($b->id, now()->subHour()->startOfHour(), 15);
|
||||
|
||||
$window = app(ArtworkHourlySnapshotWindow::class)->userWindow($user->id, 30);
|
||||
|
||||
expect($window['views'])->toBe(35);
|
||||
});
|
||||
|
||||
it('does not treat zero growth as lifetime totals', function () {
|
||||
$user = \App\Models\User::factory()->create();
|
||||
$artwork = snapshotArtworkForUser($user->id);
|
||||
|
||||
DB::table('artwork_stats')->updateOrInsert(
|
||||
['artwork_id' => $artwork->id],
|
||||
['views' => 9999, 'favorites' => 50, 'shares_count' => 7]
|
||||
);
|
||||
|
||||
insertHourlySnapshot($artwork->id, now()->subDays(2)->startOfHour(), 40);
|
||||
insertHourlySnapshot($artwork->id, now()->subHour()->startOfHour(), 40);
|
||||
|
||||
Cache::flush();
|
||||
$kpis = app(StudioMetricsService::class)->getDashboardKpis($user->id);
|
||||
|
||||
expect($kpis['views_30d'])->toBe(0)
|
||||
->and($kpis['favourites_30d'])->toBe(0)
|
||||
->and($kpis['snapshot_window']['window_complete'])->toBeFalse();
|
||||
});
|
||||
|
||||
it('marks a short table history as an incomplete 30-day window', function () {
|
||||
$user = \App\Models\User::factory()->create();
|
||||
$artwork = snapshotArtworkForUser($user->id);
|
||||
|
||||
insertHourlySnapshot($artwork->id, now()->subDays(2)->startOfHour(), 10);
|
||||
insertHourlySnapshot($artwork->id, now()->subHour()->startOfHour(), 18);
|
||||
|
||||
$window = app(ArtworkHourlySnapshotWindow::class)->userWindow($user->id, 30);
|
||||
|
||||
expect($window['views'])->toBe(8)
|
||||
->and($window['window_complete'])->toBeFalse()
|
||||
->and($window['table_coverage_days'])->toBeLessThan(30)
|
||||
->and($window['requested_days'])->toBe(30);
|
||||
});
|
||||
|
||||
it('counts all views for artwork created during the window', function () {
|
||||
$user = \App\Models\User::factory()->create();
|
||||
$artwork = \App\Models\Artwork::withoutEvents(fn () => \App\Models\Artwork::factory()->create([
|
||||
'user_id' => $user->id,
|
||||
'is_approved' => true,
|
||||
'is_public' => true,
|
||||
'published_at' => now()->subDays(3),
|
||||
'created_at' => now()->subDays(3),
|
||||
'deleted_at' => null,
|
||||
]));
|
||||
|
||||
insertHourlySnapshot($artwork->id, now()->subDays(3)->startOfHour(), 40);
|
||||
insertHourlySnapshot($artwork->id, now()->subHour()->startOfHour(), 90);
|
||||
|
||||
$window = app(ArtworkHourlySnapshotWindow::class)->userWindow($user->id, 30);
|
||||
|
||||
expect($window['views'])->toBe(90);
|
||||
});
|
||||
|
||||
it('uses a pre-window snapshot as baseline for older artwork', function () {
|
||||
$user = \App\Models\User::factory()->create();
|
||||
$artwork = snapshotArtworkForUser($user->id);
|
||||
|
||||
insertHourlySnapshot($artwork->id, now()->subDays(30)->subHour()->startOfHour(), 200);
|
||||
insertHourlySnapshot($artwork->id, now()->subDays(10)->startOfHour(), 220);
|
||||
insertHourlySnapshot($artwork->id, now()->subHour()->startOfHour(), 250);
|
||||
|
||||
$window = app(ArtworkHourlySnapshotWindow::class)->userWindow($user->id, 30);
|
||||
|
||||
expect($window['views'])->toBe(50);
|
||||
});
|
||||
|
||||
it('does not treat a single in-window snapshot of older artwork as lifetime views', function () {
|
||||
$user = \App\Models\User::factory()->create();
|
||||
$artwork = snapshotArtworkForUser($user->id);
|
||||
|
||||
insertHourlySnapshot($artwork->id, now()->subDays(2)->startOfHour(), 400);
|
||||
|
||||
$window = app(ArtworkHourlySnapshotWindow::class)->userWindow($user->id, 30);
|
||||
|
||||
expect($window['views'])->toBe(0);
|
||||
});
|
||||
|
||||
it('treats a single snapshot of a new artwork as growth from zero', function () {
|
||||
$user = \App\Models\User::factory()->create();
|
||||
$artwork = \App\Models\Artwork::withoutEvents(fn () => \App\Models\Artwork::factory()->create([
|
||||
'user_id' => $user->id,
|
||||
'is_approved' => true,
|
||||
'is_public' => true,
|
||||
'published_at' => now()->subHours(2),
|
||||
'created_at' => now()->subHours(2),
|
||||
'deleted_at' => null,
|
||||
]));
|
||||
|
||||
insertHourlySnapshot($artwork->id, now()->subHour()->startOfHour(), 12);
|
||||
|
||||
$window = app(ArtworkHourlySnapshotWindow::class)->userWindow($user->id, 30);
|
||||
|
||||
expect($window['views'])->toBe(12);
|
||||
});
|
||||
|
||||
it('ignores creator-journey snapshots older than the retention window', function () {
|
||||
$user = \App\Models\User::factory()->create();
|
||||
$artwork = snapshotArtworkForUser($user->id);
|
||||
|
||||
insertHourlySnapshot($artwork->id, now()->subDays(40)->startOfHour(), 10, 0, 0, 0, 1);
|
||||
insertHourlySnapshot($artwork->id, now()->subDays(39)->startOfHour(), 10, 0, 0, 0, 500);
|
||||
|
||||
$lookbackDays = (int) config('metrics.hourly_snapshot_retention_days', 30);
|
||||
$count = DB::table('artwork_metric_snapshots_hourly')
|
||||
->where('artwork_id', $artwork->id)
|
||||
->where('bucket_hour', '>=', now()->subDays($lookbackDays))
|
||||
->count();
|
||||
|
||||
expect($count)->toBe(0);
|
||||
});
|
||||
@@ -0,0 +1,82 @@
|
||||
<?php
|
||||
|
||||
use App\Models\Artwork;
|
||||
use App\Models\ArtworkMetricSnapshotHourly;
|
||||
use App\Models\Leaderboard;
|
||||
use App\Services\LeaderboardService;
|
||||
|
||||
function leaderboardArtwork(array $attrs = []): Artwork
|
||||
{
|
||||
return Artwork::withoutEvents(fn () => Artwork::factory()->create(array_merge([
|
||||
'is_approved' => true,
|
||||
'is_public' => true,
|
||||
'published_at' => now()->subDays(40),
|
||||
'deleted_at' => null,
|
||||
], $attrs)));
|
||||
}
|
||||
|
||||
function leaderboardSnapshot(int $artworkId, $hour, int $views): void
|
||||
{
|
||||
ArtworkMetricSnapshotHourly::create([
|
||||
'artwork_id' => $artworkId,
|
||||
'bucket_hour' => $hour,
|
||||
'views_count' => $views,
|
||||
'downloads_count' => 0,
|
||||
'favourites_count' => 0,
|
||||
'comments_count' => 0,
|
||||
'shares_count' => 0,
|
||||
]);
|
||||
}
|
||||
|
||||
it('scores monthly artwork leaderboards from period view deltas not cumulative sums', function () {
|
||||
$old = leaderboardArtwork();
|
||||
$bornInMonth = leaderboardArtwork([
|
||||
'published_at' => now()->subDays(4),
|
||||
'created_at' => now()->subDays(4),
|
||||
]);
|
||||
|
||||
leaderboardSnapshot($old->id, now()->subDays(20)->startOfHour(), 100);
|
||||
leaderboardSnapshot($old->id, now()->subHour()->startOfHour(), 140);
|
||||
leaderboardSnapshot($bornInMonth->id, now()->subDays(3)->startOfHour(), 25);
|
||||
leaderboardSnapshot($bornInMonth->id, now()->subHour()->startOfHour(), 55);
|
||||
|
||||
app(LeaderboardService::class)->calculateArtworkLeaderboard(Leaderboard::PERIOD_MONTHLY);
|
||||
|
||||
$rows = Leaderboard::query()
|
||||
->where('type', Leaderboard::TYPE_ARTWORK)
|
||||
->where('period', Leaderboard::PERIOD_MONTHLY)
|
||||
->get()
|
||||
->keyBy('entity_id');
|
||||
|
||||
expect((int) $rows[$old->id]->score)->toBe(40)
|
||||
->and((int) $rows[$bornInMonth->id]->score)->toBe(55)
|
||||
->and((int) $rows[$old->id]->score)->not->toBe(240);
|
||||
});
|
||||
|
||||
it('scores daily and weekly artwork leaderboards from the same delta rules', function () {
|
||||
$artwork = leaderboardArtwork();
|
||||
|
||||
leaderboardSnapshot($artwork->id, now()->subDays(7)->subHour()->startOfHour(), 10);
|
||||
leaderboardSnapshot($artwork->id, now()->subDays(3)->startOfHour(), 18);
|
||||
leaderboardSnapshot($artwork->id, now()->subDay()->subHour()->startOfHour(), 18);
|
||||
leaderboardSnapshot($artwork->id, now()->subHours(2)->startOfHour(), 21);
|
||||
|
||||
$service = app(LeaderboardService::class);
|
||||
$service->calculateArtworkLeaderboard(Leaderboard::PERIOD_WEEKLY);
|
||||
$service->calculateArtworkLeaderboard(Leaderboard::PERIOD_DAILY);
|
||||
|
||||
$weekly = Leaderboard::query()
|
||||
->where('type', Leaderboard::TYPE_ARTWORK)
|
||||
->where('period', Leaderboard::PERIOD_WEEKLY)
|
||||
->where('entity_id', $artwork->id)
|
||||
->value('score');
|
||||
|
||||
$daily = Leaderboard::query()
|
||||
->where('type', Leaderboard::TYPE_ARTWORK)
|
||||
->where('period', Leaderboard::PERIOD_DAILY)
|
||||
->where('entity_id', $artwork->id)
|
||||
->value('score');
|
||||
|
||||
expect((int) $weekly)->toBe(11)
|
||||
->and((int) $daily)->toBe(3);
|
||||
});
|
||||
@@ -0,0 +1,47 @@
|
||||
<?php
|
||||
|
||||
use App\Models\Artwork;
|
||||
use App\Models\ArtworkMetricSnapshotHourly;
|
||||
use App\Models\User;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
it('writes heat_score from chunked hourly snapshot loads', function () {
|
||||
$user = User::factory()->create();
|
||||
$artwork = Artwork::withoutEvents(fn () => Artwork::factory()->create([
|
||||
'user_id' => $user->id,
|
||||
'is_approved' => true,
|
||||
'is_public' => true,
|
||||
'published_at' => now()->subDays(2),
|
||||
'deleted_at' => null,
|
||||
]));
|
||||
|
||||
$currentHour = now()->startOfHour();
|
||||
$prevHour = $currentHour->copy()->subHour();
|
||||
|
||||
ArtworkMetricSnapshotHourly::create([
|
||||
'artwork_id' => $artwork->id,
|
||||
'bucket_hour' => $prevHour,
|
||||
'views_count' => 10,
|
||||
'downloads_count' => 1,
|
||||
'favourites_count' => 1,
|
||||
'comments_count' => 0,
|
||||
'shares_count' => 0,
|
||||
]);
|
||||
|
||||
ArtworkMetricSnapshotHourly::create([
|
||||
'artwork_id' => $artwork->id,
|
||||
'bucket_hour' => $currentHour,
|
||||
'views_count' => 40,
|
||||
'downloads_count' => 4,
|
||||
'favourites_count' => 3,
|
||||
'comments_count' => 1,
|
||||
'shares_count' => 1,
|
||||
]);
|
||||
|
||||
$this->artisan('nova:recalculate-heat', ['--chunk' => 1])->assertExitCode(0);
|
||||
|
||||
$row = DB::table('artwork_stats')->where('artwork_id', $artwork->id)->first();
|
||||
|
||||
expect($row)->not->toBeNull()
|
||||
->and((float) $row->heat_score)->toBeGreaterThan(0);
|
||||
});
|
||||
@@ -135,7 +135,7 @@ function seedCreatorJourneyFixture(): User
|
||||
DB::table('artwork_metric_snapshots_hourly')->insert([
|
||||
[
|
||||
'artwork_id' => $breakthroughArtwork->id,
|
||||
'bucket_hour' => Carbon::parse('2024-05-10 10:00:00'),
|
||||
'bucket_hour' => now()->subHours(5)->startOfHour(),
|
||||
'views_count' => 1200,
|
||||
'downloads_count' => 40,
|
||||
'favourites_count' => 60,
|
||||
@@ -145,7 +145,7 @@ function seedCreatorJourneyFixture(): User
|
||||
],
|
||||
[
|
||||
'artwork_id' => $breakthroughArtwork->id,
|
||||
'bucket_hour' => Carbon::parse('2024-05-10 11:00:00'),
|
||||
'bucket_hour' => now()->subHours(4)->startOfHour(),
|
||||
'views_count' => 1320,
|
||||
'downloads_count' => 64,
|
||||
'favourites_count' => 68,
|
||||
@@ -155,7 +155,7 @@ function seedCreatorJourneyFixture(): User
|
||||
],
|
||||
[
|
||||
'artwork_id' => $lateYearArtwork->id,
|
||||
'bucket_hour' => Carbon::parse('2024-11-20 19:00:00'),
|
||||
'bucket_hour' => now()->subHours(3)->startOfHour(),
|
||||
'views_count' => 300,
|
||||
'downloads_count' => 10,
|
||||
'favourites_count' => 12,
|
||||
@@ -165,7 +165,7 @@ function seedCreatorJourneyFixture(): User
|
||||
],
|
||||
[
|
||||
'artwork_id' => $lateYearArtwork->id,
|
||||
'bucket_hour' => Carbon::parse('2024-11-20 20:00:00'),
|
||||
'bucket_hour' => now()->subHours(2)->startOfHour(),
|
||||
'views_count' => 360,
|
||||
'downloads_count' => 14,
|
||||
'favourites_count' => 15,
|
||||
@@ -276,4 +276,45 @@ it('hydrates the public profile page with creator journey props', function () {
|
||||
->where('journey.timeline', fn ($timeline) => collect($timeline)->pluck('headline')->contains('First Spectrum Pack'))
|
||||
->where('journeyApiUrl', route('api.profile.journey', ['username' => strtolower((string) $creator->username)]))
|
||||
);
|
||||
});
|
||||
|
||||
it('ignores download spikes older than the snapshot retention window', function () {
|
||||
$creator = seedCreatorJourneyFixture();
|
||||
$oldArtwork = Artwork::query()->where('user_id', $creator->id)->where('slug', 'sky-one')->firstOrFail();
|
||||
|
||||
DB::table('artwork_metric_snapshots_hourly')->insert([
|
||||
[
|
||||
'artwork_id' => $oldArtwork->id,
|
||||
'bucket_hour' => now()->subDays(40)->startOfHour(),
|
||||
'views_count' => 10,
|
||||
'downloads_count' => 1,
|
||||
'favourites_count' => 0,
|
||||
'comments_count' => 0,
|
||||
'shares_count' => 0,
|
||||
'created_at' => now(),
|
||||
],
|
||||
[
|
||||
'artwork_id' => $oldArtwork->id,
|
||||
'bucket_hour' => now()->subDays(40)->addHour()->startOfHour(),
|
||||
'views_count' => 10,
|
||||
'downloads_count' => 900,
|
||||
'favourites_count' => 0,
|
||||
'comments_count' => 0,
|
||||
'shares_count' => 0,
|
||||
'created_at' => now(),
|
||||
],
|
||||
]);
|
||||
|
||||
app(CreatorJourneyService::class)->rebuildForUser($creator);
|
||||
|
||||
$spike = DB::table('creator_milestones')
|
||||
->where('user_id', $creator->id)
|
||||
->where('type', 'biggest_download_spike')
|
||||
->first();
|
||||
|
||||
$payload = json_decode((string) $spike->payload_json, true);
|
||||
|
||||
expect($spike)->not->toBeNull()
|
||||
->and((int) data_get($payload, 'metrics.downloads_in_hour'))->toBe(24)
|
||||
->and((int) data_get($payload, 'metrics.downloads_in_hour'))->not->toBe(899);
|
||||
});
|
||||
@@ -0,0 +1,164 @@
|
||||
<?php
|
||||
|
||||
use App\Models\ArtworkMetricSnapshotHourly;
|
||||
|
||||
it('prunes snapshots older than keep-days and keeps recent ones', function () {
|
||||
$artwork = createArtworkWithoutObserver([
|
||||
'is_approved' => true,
|
||||
'is_public' => true,
|
||||
'published_at' => now()->subDays(40),
|
||||
]);
|
||||
|
||||
ArtworkMetricSnapshotHourly::create([
|
||||
'artwork_id' => $artwork->id,
|
||||
'bucket_hour' => now()->subDays(40)->startOfHour(),
|
||||
'views_count' => 10,
|
||||
'downloads_count' => 1,
|
||||
'favourites_count' => 0,
|
||||
'comments_count' => 0,
|
||||
'shares_count' => 0,
|
||||
]);
|
||||
|
||||
ArtworkMetricSnapshotHourly::create([
|
||||
'artwork_id' => $artwork->id,
|
||||
'bucket_hour' => now()->subDays(10)->startOfHour(),
|
||||
'views_count' => 20,
|
||||
'downloads_count' => 2,
|
||||
'favourites_count' => 0,
|
||||
'comments_count' => 0,
|
||||
'shares_count' => 0,
|
||||
]);
|
||||
|
||||
ArtworkMetricSnapshotHourly::create([
|
||||
'artwork_id' => $artwork->id,
|
||||
'bucket_hour' => now()->subHour()->startOfHour(),
|
||||
'views_count' => 30,
|
||||
'downloads_count' => 3,
|
||||
'favourites_count' => 0,
|
||||
'comments_count' => 0,
|
||||
'shares_count' => 0,
|
||||
]);
|
||||
|
||||
$this->artisan('nova:prune-metric-snapshots --keep-days=30 --sleep-ms=0')
|
||||
->assertSuccessful();
|
||||
|
||||
$hours = ArtworkMetricSnapshotHourly::where('artwork_id', $artwork->id)
|
||||
->orderBy('bucket_hour')
|
||||
->pluck('views_count')
|
||||
->all();
|
||||
|
||||
expect($hours)->toBe([20, 30]);
|
||||
});
|
||||
|
||||
it('deletes in bounded batches and is idempotent', function () {
|
||||
$artwork = createArtworkWithoutObserver([
|
||||
'is_approved' => true,
|
||||
'is_public' => true,
|
||||
'published_at' => now()->subDays(20),
|
||||
]);
|
||||
|
||||
foreach (range(1, 5) as $daysAgo) {
|
||||
ArtworkMetricSnapshotHourly::create([
|
||||
'artwork_id' => $artwork->id,
|
||||
'bucket_hour' => now()->subDays($daysAgo + 10)->startOfHour(),
|
||||
'views_count' => $daysAgo,
|
||||
'downloads_count' => 0,
|
||||
'favourites_count' => 0,
|
||||
'comments_count' => 0,
|
||||
'shares_count' => 0,
|
||||
]);
|
||||
}
|
||||
|
||||
$this->artisan('nova:prune-metric-snapshots --keep-days=7 --chunk=2 --sleep-ms=0')
|
||||
->assertSuccessful();
|
||||
|
||||
expect(ArtworkMetricSnapshotHourly::where('artwork_id', $artwork->id)->count())->toBe(0);
|
||||
|
||||
$this->artisan('nova:prune-metric-snapshots --keep-days=7 --chunk=2 --sleep-ms=0')
|
||||
->assertSuccessful();
|
||||
|
||||
expect(ArtworkMetricSnapshotHourly::where('artwork_id', $artwork->id)->count())->toBe(0);
|
||||
});
|
||||
|
||||
it('dry-run does not delete rows', function () {
|
||||
$artwork = createArtworkWithoutObserver([
|
||||
'is_approved' => true,
|
||||
'is_public' => true,
|
||||
'published_at' => now()->subDays(20),
|
||||
]);
|
||||
|
||||
ArtworkMetricSnapshotHourly::create([
|
||||
'artwork_id' => $artwork->id,
|
||||
'bucket_hour' => now()->subDays(14)->startOfHour(),
|
||||
'views_count' => 1,
|
||||
'downloads_count' => 0,
|
||||
'favourites_count' => 0,
|
||||
'comments_count' => 0,
|
||||
'shares_count' => 0,
|
||||
]);
|
||||
|
||||
$this->artisan('nova:prune-metric-snapshots --keep-days=7 --dry-run')
|
||||
->assertSuccessful();
|
||||
|
||||
expect(ArtworkMetricSnapshotHourly::where('artwork_id', $artwork->id)->count())->toBe(1);
|
||||
});
|
||||
|
||||
it('uses config retention when keep-days is omitted', function () {
|
||||
config(['metrics.hourly_snapshot_retention_days' => 7]);
|
||||
|
||||
$artwork = createArtworkWithoutObserver([
|
||||
'is_approved' => true,
|
||||
'is_public' => true,
|
||||
'published_at' => now()->subDays(20),
|
||||
]);
|
||||
|
||||
ArtworkMetricSnapshotHourly::create([
|
||||
'artwork_id' => $artwork->id,
|
||||
'bucket_hour' => now()->subDays(10)->startOfHour(),
|
||||
'views_count' => 1,
|
||||
'downloads_count' => 0,
|
||||
'favourites_count' => 0,
|
||||
'comments_count' => 0,
|
||||
'shares_count' => 0,
|
||||
]);
|
||||
|
||||
ArtworkMetricSnapshotHourly::create([
|
||||
'artwork_id' => $artwork->id,
|
||||
'bucket_hour' => now()->subDays(2)->startOfHour(),
|
||||
'views_count' => 2,
|
||||
'downloads_count' => 0,
|
||||
'favourites_count' => 0,
|
||||
'comments_count' => 0,
|
||||
'shares_count' => 0,
|
||||
]);
|
||||
|
||||
$this->artisan('nova:prune-metric-snapshots --sleep-ms=0')
|
||||
->assertSuccessful();
|
||||
|
||||
expect(ArtworkMetricSnapshotHourly::where('artwork_id', $artwork->id)->count())->toBe(1);
|
||||
});
|
||||
|
||||
it('honours max-batches so a run cannot delete the whole table at once', function () {
|
||||
$artwork = createArtworkWithoutObserver([
|
||||
'is_approved' => true,
|
||||
'is_public' => true,
|
||||
'published_at' => now()->subDays(20),
|
||||
]);
|
||||
|
||||
foreach (range(1, 4) as $n) {
|
||||
ArtworkMetricSnapshotHourly::create([
|
||||
'artwork_id' => $artwork->id,
|
||||
'bucket_hour' => now()->subDays(10 + $n)->startOfHour(),
|
||||
'views_count' => $n,
|
||||
'downloads_count' => 0,
|
||||
'favourites_count' => 0,
|
||||
'comments_count' => 0,
|
||||
'shares_count' => 0,
|
||||
]);
|
||||
}
|
||||
|
||||
$this->artisan('nova:prune-metric-snapshots --keep-days=7 --chunk=1 --max-batches=2 --sleep-ms=0')
|
||||
->assertSuccessful();
|
||||
|
||||
expect(ArtworkMetricSnapshotHourly::where('artwork_id', $artwork->id)->count())->toBe(2);
|
||||
});
|
||||
@@ -0,0 +1,55 @@
|
||||
<?php
|
||||
|
||||
use App\Jobs\IndexArtworkJob;
|
||||
use App\Jobs\IndexUserJob;
|
||||
use App\Jobs\RankBuildScopeListsJob;
|
||||
use Illuminate\Contracts\Queue\ShouldBeUnique;
|
||||
use Illuminate\Contracts\Queue\ShouldBeUniqueUntilProcessing;
|
||||
use Illuminate\Support\Facades\Queue;
|
||||
|
||||
it('assigns artwork and user search index jobs to the search queue', function () {
|
||||
expect((new IndexArtworkJob(15))->queue)->toBe('search')
|
||||
->and((new IndexUserJob(22))->queue)->toBe('search');
|
||||
|
||||
Queue::fake();
|
||||
IndexUserJob::dispatch(22);
|
||||
Queue::assertPushedOn('search', IndexUserJob::class);
|
||||
});
|
||||
|
||||
it('collapses duplicate index jobs with unique ids', function () {
|
||||
$artwork = new IndexArtworkJob(15);
|
||||
$user = new IndexUserJob(22);
|
||||
|
||||
expect($artwork)->toBeInstanceOf(ShouldBeUniqueUntilProcessing::class)
|
||||
->and($artwork->uniqueId())->toBe('15')
|
||||
->and($artwork->uniqueFor)->toBe(120)
|
||||
->and($user)->toBeInstanceOf(ShouldBeUniqueUntilProcessing::class)
|
||||
->and($user->uniqueId())->toBe('22')
|
||||
->and($user->uniqueFor)->toBe(60);
|
||||
});
|
||||
|
||||
it('uniques ranking scope rebuilds so hourly fan-out cannot stack the same scope', function () {
|
||||
$job = new RankBuildScopeListsJob('category', 44);
|
||||
|
||||
expect($job)->toBeInstanceOf(ShouldBeUnique::class)
|
||||
->and($job->uniqueId())->toBe('category:44')
|
||||
->and($job->uniqueFor)->toBe(21600)
|
||||
->and($job->uniqueFor)->toBeGreaterThan(360)
|
||||
->and((new RankBuildScopeListsJob('content_type', 3))->uniqueId())->toBe('content_type:3')
|
||||
->and((new RankBuildScopeListsJob('category', 44))->uniqueId())
|
||||
->not->toBe((new RankBuildScopeListsJob('category', 45))->uniqueId());
|
||||
});
|
||||
|
||||
it('does not enqueue the same ranking scope again after 360s while the unique lock remains', function () {
|
||||
Queue::fake();
|
||||
$this->freezeTime();
|
||||
|
||||
RankBuildScopeListsJob::dispatch('category', 88);
|
||||
$this->travel(361)->seconds();
|
||||
RankBuildScopeListsJob::dispatch('category', 88);
|
||||
RankBuildScopeListsJob::dispatch('category', 89);
|
||||
|
||||
Queue::assertPushed(RankBuildScopeListsJob::class, 2);
|
||||
Queue::assertPushed(RankBuildScopeListsJob::class, fn (RankBuildScopeListsJob $job): bool => $job->uniqueId() === 'category:88');
|
||||
Queue::assertPushed(RankBuildScopeListsJob::class, fn (RankBuildScopeListsJob $job): bool => $job->uniqueId() === 'category:89');
|
||||
});
|
||||
@@ -0,0 +1,56 @@
|
||||
<?php
|
||||
|
||||
use App\Jobs\IndexUserJob;
|
||||
use App\Jobs\RecComputeSimilarByTagsJob;
|
||||
use App\Support\Queues\QueuedJobClassMatcher;
|
||||
use Illuminate\Support\Facades\Redis;
|
||||
|
||||
it('matches only RecComputeSimilarByTagsJob payloads', function () {
|
||||
$tags = json_encode([
|
||||
'displayName' => RecComputeSimilarByTagsJob::class,
|
||||
'data' => ['commandName' => RecComputeSimilarByTagsJob::class],
|
||||
]);
|
||||
$other = json_encode([
|
||||
'displayName' => IndexUserJob::class,
|
||||
'data' => ['commandName' => IndexUserJob::class],
|
||||
]);
|
||||
|
||||
expect(QueuedJobClassMatcher::isClass($tags, RecComputeSimilarByTagsJob::class))->toBeTrue()
|
||||
->and(QueuedJobClassMatcher::isClass($other, RecComputeSimilarByTagsJob::class))->toBeFalse();
|
||||
});
|
||||
|
||||
it('dry-run counts stale tags jobs and does not remove them', function () {
|
||||
$tags = json_encode(['displayName' => RecComputeSimilarByTagsJob::class, 'uuid' => 'aaa']);
|
||||
$other = json_encode(['displayName' => IndexUserJob::class, 'uuid' => 'bbb']);
|
||||
|
||||
$redis = Mockery::mock();
|
||||
$redis->shouldReceive('llen')->once()->with('queues:default')->andReturn(2);
|
||||
$redis->shouldReceive('lrange')->once()->andReturn([$other, $tags]);
|
||||
$redis->shouldReceive('lrem')->never();
|
||||
Redis::partialMock()->shouldReceive('connection')->andReturn($redis);
|
||||
|
||||
$this->artisan('skinbase:purge-queued-rec-tags --dry-run')
|
||||
->expectsOutputToContain('matched=1')
|
||||
->assertSuccessful();
|
||||
});
|
||||
|
||||
it('execute removes only tags jobs and preserves unrelated payloads', function () {
|
||||
$tagsA = json_encode(['displayName' => RecComputeSimilarByTagsJob::class, 'uuid' => 't1']);
|
||||
$tagsB = json_encode(['displayName' => RecComputeSimilarByTagsJob::class, 'uuid' => 't2']);
|
||||
$other = json_encode(['displayName' => IndexUserJob::class, 'uuid' => 'keep']);
|
||||
|
||||
$redis = Mockery::mock();
|
||||
$redis->shouldReceive('llen')->once()->with('queues:default')->andReturn(3);
|
||||
$redis->shouldReceive('lrange')->once()->andReturn([$other, $tagsA, $tagsB]);
|
||||
$redis->shouldReceive('lrem')->twice()->andReturn(1);
|
||||
Redis::partialMock()->shouldReceive('connection')->andReturn($redis);
|
||||
|
||||
$this->artisan('skinbase:purge-queued-rec-tags --execute')
|
||||
->expectsOutputToContain('matched=2')
|
||||
->assertSuccessful();
|
||||
});
|
||||
|
||||
it('refuses to run without dry-run or execute', function () {
|
||||
$this->artisan('skinbase:purge-queued-rec-tags')
|
||||
->assertFailed();
|
||||
});
|
||||
@@ -0,0 +1,141 @@
|
||||
<?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);
|
||||
}
|
||||
}
|
||||
@@ -396,6 +396,59 @@ it('passes type query param from API endpoint', function () {
|
||||
->assertJsonCount(1, 'data');
|
||||
});
|
||||
|
||||
it('keeps other rec types and other artworks visible when one row is rebuilt', function () {
|
||||
$source = createPublicArtwork();
|
||||
$other = createPublicArtwork();
|
||||
$hybridA = createPublicArtwork();
|
||||
$hybridB = createPublicArtwork();
|
||||
$tagsA = createPublicArtwork();
|
||||
$behaviorNew = createPublicArtwork();
|
||||
|
||||
RecArtworkRec::create([
|
||||
'artwork_id' => $source->id,
|
||||
'rec_type' => 'similar_hybrid',
|
||||
'model_version' => 'sim_v1',
|
||||
'recs' => [$hybridA->id],
|
||||
'computed_at' => now()->subDay(),
|
||||
]);
|
||||
RecArtworkRec::create([
|
||||
'artwork_id' => $source->id,
|
||||
'rec_type' => 'similar_tags',
|
||||
'model_version' => 'sim_v1',
|
||||
'recs' => [$tagsA->id],
|
||||
'computed_at' => now()->subDay(),
|
||||
]);
|
||||
RecArtworkRec::create([
|
||||
'artwork_id' => $other->id,
|
||||
'rec_type' => 'similar_hybrid',
|
||||
'model_version' => 'sim_v1',
|
||||
'recs' => [$hybridB->id],
|
||||
'computed_at' => now()->subDay(),
|
||||
]);
|
||||
|
||||
RecArtworkRec::query()->updateOrCreate(
|
||||
[
|
||||
'artwork_id' => $source->id,
|
||||
'rec_type' => 'similar_behavior',
|
||||
'model_version' => 'sim_v1',
|
||||
],
|
||||
[
|
||||
'recs' => [$behaviorNew->id],
|
||||
'computed_at' => now(),
|
||||
],
|
||||
);
|
||||
|
||||
expect(RecArtworkRec::query()->where('artwork_id', $source->id)->where('rec_type', 'similar_hybrid')->value('recs'))
|
||||
->toEqual([$hybridA->id]);
|
||||
expect(RecArtworkRec::query()->where('artwork_id', $source->id)->where('rec_type', 'similar_tags')->value('recs'))
|
||||
->toEqual([$tagsA->id]);
|
||||
expect(RecArtworkRec::query()->where('artwork_id', $other->id)->where('rec_type', 'similar_hybrid')->value('recs'))
|
||||
->toEqual([$hybridB->id]);
|
||||
|
||||
$service = app(HybridSimilarArtworksService::class);
|
||||
expect($service->forArtwork($source->id, 12)->pluck('id')->all())->toEqual([$hybridA->id]);
|
||||
});
|
||||
|
||||
// ─── Cosine normalized pair weights ────────────────────────────────────────────
|
||||
|
||||
it('produces cosine-normalized weights in pair builder', function () {
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
<?php
|
||||
|
||||
use App\Jobs\IndexUserJob;
|
||||
use App\Support\Redis\OrphanQueueCleanup;
|
||||
use Illuminate\Support\Facades\Redis;
|
||||
|
||||
function orphanRedisMock(int $llen, array $payloads, int $reserved = 0, int $delayed = 0)
|
||||
{
|
||||
$redis = Mockery::mock();
|
||||
$redis->shouldReceive('llen')->with(Mockery::on(fn ($k) => str_ends_with((string) $k, ':notify')))->andReturn($llen);
|
||||
$redis->shouldReceive('llen')->andReturn($llen);
|
||||
$redis->shouldReceive('zcard')->andReturnUsing(function ($key) use ($reserved, $delayed) {
|
||||
return str_contains((string) $key, 'reserved') ? $reserved : $delayed;
|
||||
});
|
||||
$redis->shouldReceive('lindex')->andReturnUsing(function ($key, $i) use ($payloads) {
|
||||
return $payloads[$i % max(1, count($payloads))] ?? null;
|
||||
});
|
||||
Redis::shouldReceive('connection')->andReturn($redis);
|
||||
|
||||
return $redis;
|
||||
}
|
||||
|
||||
it('dry-run inspects forum-moderation without unlinking', function () {
|
||||
config(['skinbase_ai_moderation.queue.dispatch_enabled' => false]);
|
||||
$payload = json_encode(['displayName' => 'cPad\\Plugins\\Forum\\Jobs\\AnalyzeForumPostJob']);
|
||||
$redis = orphanRedisMock(5, [$payload]);
|
||||
$redis->shouldReceive('unlink')->never();
|
||||
|
||||
$plan = app(OrphanQueueCleanup::class)->plan('forum-moderation');
|
||||
|
||||
expect($plan['can_execute'])->toBeTrue()
|
||||
->and($plan['llen'])->toBe(5)
|
||||
->and($plan['classes'])->toHaveKey('cPad\\Plugins\\Forum\\Jobs\\AnalyzeForumPostJob');
|
||||
});
|
||||
|
||||
it('refuses forum cleanup when producer dispatch is enabled', function () {
|
||||
config(['skinbase_ai_moderation.queue.dispatch_enabled' => true]);
|
||||
$payload = json_encode(['displayName' => 'cPad\\Plugins\\Forum\\Jobs\\AnalyzeForumPostJob']);
|
||||
orphanRedisMock(2, [$payload]);
|
||||
|
||||
$plan = app(OrphanQueueCleanup::class)->plan('forum-moderation');
|
||||
|
||||
expect($plan['can_execute'])->toBeFalse()
|
||||
->and($plan['errors'])->toContain('producer_dispatch_enabled');
|
||||
});
|
||||
|
||||
it('refuses protected default/search/mail queues', function () {
|
||||
expect(fn () => app(OrphanQueueCleanup::class)->plan('default'))
|
||||
->toThrow(\RuntimeException::class);
|
||||
});
|
||||
|
||||
it('refuses unexpected job classes unless force is set', function () {
|
||||
config(['skinbase_ai_moderation.queue.dispatch_enabled' => false]);
|
||||
$payload = json_encode(['displayName' => IndexUserJob::class]);
|
||||
orphanRedisMock(2, [$payload]);
|
||||
|
||||
$plan = app(OrphanQueueCleanup::class)->plan('forum-moderation');
|
||||
expect($plan['errors'])->toContain('unexpected_job_classes');
|
||||
|
||||
$forced = app(OrphanQueueCleanup::class)->plan('forum-moderation', true);
|
||||
expect($forced['errors'])->not->toContain('unexpected_job_classes');
|
||||
});
|
||||
|
||||
it('execute unlinks only the intended orphan queue keys', function () {
|
||||
config(['skinbase_ai_moderation.queue.dispatch_enabled' => false]);
|
||||
$payload = json_encode(['displayName' => 'cPad\\Plugins\\Forum\\Jobs\\AnalyzeForumPostJob']);
|
||||
$redis = orphanRedisMock(3, [$payload]);
|
||||
$redis->shouldReceive('unlink')->once()->with('queues:forum-moderation')->andReturn(1);
|
||||
$redis->shouldReceive('unlink')->once()->with('queues:forum-moderation:notify')->andReturn(1);
|
||||
|
||||
$result = app(OrphanQueueCleanup::class)->execute('forum-moderation');
|
||||
|
||||
expect($result['unlinked'])->toBe(2)
|
||||
->and($result['unlinked_keys'])->toBe(['queues:forum-moderation', 'queues:forum-moderation:notify']);
|
||||
});
|
||||
|
||||
it('plans forum-security cleanup for monitor jobs', function () {
|
||||
config(['forum_security.queues.dispatch_enabled' => false]);
|
||||
$payload = json_encode(['displayName' => 'cPad\\Plugins\\Forum\\Jobs\\FirewallActivityMonitor']);
|
||||
orphanRedisMock(4, [$payload]);
|
||||
|
||||
$plan = app(OrphanQueueCleanup::class)->plan('forum-security');
|
||||
|
||||
expect($plan['can_execute'])->toBeTrue();
|
||||
});
|
||||
|
||||
it('artisan dry-run does not unlink', function () {
|
||||
config(['skinbase_ai_moderation.queue.dispatch_enabled' => false]);
|
||||
$payload = json_encode(['displayName' => 'cPad\\Plugins\\Forum\\Jobs\\AnalyzeForumPostJob']);
|
||||
$redis = orphanRedisMock(1, [$payload]);
|
||||
$redis->shouldReceive('unlink')->never();
|
||||
Redis::shouldReceive('connection')->andReturn($redis);
|
||||
|
||||
$this->artisan('skinbase:redis-cleanup-orphans forum-moderation --dry-run')
|
||||
->assertSuccessful();
|
||||
});
|
||||
@@ -0,0 +1,43 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Console\Scheduling\Schedule;
|
||||
|
||||
it('schedules flush-redis-stats away from leaderboards:refresh at minute 21', function () {
|
||||
$events = collect(app(Schedule::class)->events());
|
||||
$flush = $events->first(fn ($event) => str_contains((string) ($event->command ?? $event->description ?? ''), 'flush-redis-stats')
|
||||
|| ($event->mutexName() === 'flush-redis-stats')
|
||||
|| str_contains($event->getSummaryForDisplay(), 'flush-redis-stats'));
|
||||
|
||||
expect($flush)->not->toBeNull()
|
||||
->and($flush->expression)->toBe('1,11,18,31,41,51 * * * *');
|
||||
|
||||
$boards = $events->first(fn ($event) => str_contains($event->getSummaryForDisplay(), 'leaderboards:refresh')
|
||||
|| str_contains((string) ($event->command ?? ''), 'leaderboards:refresh'));
|
||||
|
||||
expect($boards)->not->toBeNull()
|
||||
->and($boards->expression)->toContain('21');
|
||||
});
|
||||
|
||||
it('keeps recommendation nightly offsets and does not register disabled forum async producers', function () {
|
||||
$display = collect(app(Schedule::class)->events())
|
||||
->map(fn ($event) => $event->getSummaryForDisplay())
|
||||
->implode("\n");
|
||||
|
||||
expect($display)->toContain('rec-compute-tags')
|
||||
->and($display)->toContain('rec-compute-behavior')
|
||||
->and($display)->toContain('rec-compute-hybrid')
|
||||
->and($display)->toContain('forum-post-scan')
|
||||
->and($display)->not->toContain('forum-ai-scan')
|
||||
->and($display)->not->toContain('forum-bot-scan')
|
||||
->and($display)->not->toContain('forum-firewall-scan');
|
||||
});
|
||||
|
||||
it('does not register presence prune unless enabled', function () {
|
||||
expect((bool) config('traffic.online_visitors.prune_enabled'))->toBeFalse();
|
||||
|
||||
$display = collect(app(Schedule::class)->events())
|
||||
->map(fn ($event) => $event->getSummaryForDisplay())
|
||||
->implode("\n");
|
||||
|
||||
expect($display)->not->toContain('prune-online-visitor-index');
|
||||
});
|
||||
@@ -431,6 +431,56 @@ it('can rollback sitemap delivery to the previous published release', function (
|
||||
->not->toContain('/art/' . $newArtwork->id . '/');
|
||||
});
|
||||
|
||||
it('generate command writes a canonical index.xml and public sitemap.xml', function (): void {
|
||||
Storage::fake('sitemaps_public');
|
||||
seedSitemapFixtures();
|
||||
|
||||
expect(Artisan::call('skinbase:sitemaps:generate', ['--disk' => 'sitemaps_public']))->toBe(0);
|
||||
|
||||
$disk = Storage::disk('sitemaps_public');
|
||||
$disk->assertExists('sitemaps/index.xml');
|
||||
$disk->assertExists('sitemaps/sitemap.xml');
|
||||
$disk->assertExists('sitemap.xml');
|
||||
|
||||
$index = $disk->get('sitemaps/index.xml');
|
||||
expect($index)
|
||||
->toContain('<sitemapindex')
|
||||
->toContain('/sitemaps/artworks')
|
||||
->toContain('/sitemaps/static-pages.xml');
|
||||
|
||||
expect($disk->get('sitemap.xml'))->toBe($index);
|
||||
expect(strlen($index))->toBeLessThan(50 * 1024 * 1024);
|
||||
});
|
||||
|
||||
it('serves the generated sitemaps/index.xml for /sitemap.xml when present', function (): void {
|
||||
config([
|
||||
'sitemaps.pre_generated.enabled' => true,
|
||||
'sitemaps.delivery.prefer_published_release' => false,
|
||||
'sitemaps.delivery.fallback_to_live_build' => false,
|
||||
]);
|
||||
|
||||
$indexPath = public_path('sitemaps/index.xml');
|
||||
$dir = dirname($indexPath);
|
||||
|
||||
if (! is_dir($dir)) {
|
||||
mkdir($dir, 0777, true);
|
||||
}
|
||||
|
||||
$marker = 'http://skinbase26.test/sitemaps/generated-index-marker.xml';
|
||||
file_put_contents($indexPath, '<?xml version="1.0" encoding="UTF-8"?><sitemapindex xmlns="http://www.sitemaps.org/schemas/sitemap/0.9"><sitemap><loc>'.$marker.'</loc></sitemap></sitemapindex>');
|
||||
|
||||
try {
|
||||
$response = $this->get('/sitemap.xml')->assertOk();
|
||||
$content = method_exists($response, 'streamedContent')
|
||||
? ($response->streamedContent() ?: $response->getContent())
|
||||
: $response->getContent();
|
||||
|
||||
expect($content)->toContain($marker);
|
||||
} finally {
|
||||
@unlink($indexPath);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* @return array<string, int|string>
|
||||
*/
|
||||
|
||||
@@ -71,7 +71,9 @@ test('studio dashboard loads for authenticated user', function () {
|
||||
->component('Studio/StudioDashboard')
|
||||
->has('overview.active_challenges')
|
||||
->has('overview.creator_health')
|
||||
->has('overview.featured_status'));
|
||||
->has('overview.featured_status')
|
||||
->has('overview.kpis.snapshot_window')
|
||||
->where('overview.kpis.snapshot_window.window_complete', false));
|
||||
});
|
||||
|
||||
test('studio dashboard honors saved landing page preference', function () {
|
||||
|
||||
@@ -0,0 +1,373 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Models\Artwork;
|
||||
use App\Services\Vision\AiArtworkVectorSearchService;
|
||||
use App\Services\Vision\VectorGatewayClient;
|
||||
use App\Services\Vision\VectorGatewayException;
|
||||
use Illuminate\Http\Client\ConnectionException;
|
||||
use Illuminate\Http\UploadedFile;
|
||||
use Illuminate\Log\Events\MessageLogged;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Support\Facades\Event;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use function Pest\Laravel\getJson;
|
||||
|
||||
uses(\Illuminate\Foundation\Testing\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_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);
|
||||
});
|
||||
@@ -0,0 +1,118 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Models\Artwork;
|
||||
use App\Services\Vision\AiArtworkVectorSearchService;
|
||||
use Illuminate\Http\UploadedFile;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
|
||||
uses(\Illuminate\Foundation\Testing\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_endpoint', '/vectors/search');
|
||||
config()->set('vision.vector_gateway.search_file_endpoint', '/vectors/search/file');
|
||||
config()->set('cdn.files_url', 'https://files.skinbase.org');
|
||||
Storage::fake('public');
|
||||
Cache::flush();
|
||||
});
|
||||
|
||||
function limitSourceArtwork(): Artwork
|
||||
{
|
||||
return Artwork::factory()->create([
|
||||
'title' => 'Limit source',
|
||||
'hash' => 'aabbccddeeff',
|
||||
'thumb_ext' => 'webp',
|
||||
'is_public' => true,
|
||||
'is_approved' => true,
|
||||
'published_at' => now()->subHour(),
|
||||
]);
|
||||
}
|
||||
|
||||
function fakeFileSearchOk(): void
|
||||
{
|
||||
Http::fake(function ($request) {
|
||||
$url = $request->url();
|
||||
if (str_contains($url, '/vectors/search')) {
|
||||
return Http::response(['results' => []], 200);
|
||||
}
|
||||
|
||||
return Http::response('image-bytes', 200, ['Content-Type' => 'image/webp']);
|
||||
});
|
||||
}
|
||||
|
||||
function gatewaySearchLimit($request): ?int
|
||||
{
|
||||
$url = $request->url();
|
||||
if (! str_contains($url, '/vectors/search')) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$data = $request->data();
|
||||
if (isset($data['limit']) && is_numeric($data['limit'])) {
|
||||
return (int) $data['limit'];
|
||||
}
|
||||
|
||||
foreach ($data as $part) {
|
||||
if (is_array($part) && ($part['name'] ?? null) === 'limit' && is_numeric($part['contents'] ?? null)) {
|
||||
return (int) $part['contents'];
|
||||
}
|
||||
}
|
||||
|
||||
$body = $request->body();
|
||||
if (preg_match('/name="limit".*?\r\n\r\n(\d+)/s', $body, $matches) === 1) {
|
||||
return (int) $matches[1];
|
||||
}
|
||||
if (preg_match('/"limit"\s*:\s*([0-9]+)/', $body, $matches) === 1) {
|
||||
return (int) $matches[1];
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
it('sends gateway limit 100 when similar-ai is requested at 99', function (): void {
|
||||
$source = limitSourceArtwork();
|
||||
fakeFileSearchOk();
|
||||
|
||||
app(AiArtworkVectorSearchService::class)->similarToArtwork($source, 99);
|
||||
|
||||
Http::assertSent(fn ($request): bool => gatewaySearchLimit($request) === 100);
|
||||
});
|
||||
|
||||
it('clamps similar-ai requests of 100 and 120 so the gateway never sees more than 100', function (): void {
|
||||
$source = limitSourceArtwork();
|
||||
|
||||
foreach ([100, 120, 121] as $requested) {
|
||||
fakeFileSearchOk();
|
||||
Cache::flush();
|
||||
|
||||
app(AiArtworkVectorSearchService::class)->similarToArtwork($source, $requested);
|
||||
|
||||
Http::assertSent(fn ($request): bool => gatewaySearchLimit($request) === 100);
|
||||
}
|
||||
});
|
||||
|
||||
it('sends gateway limit 13 for the default similar-ai request of 12', function (): void {
|
||||
$source = limitSourceArtwork();
|
||||
fakeFileSearchOk();
|
||||
|
||||
app(AiArtworkVectorSearchService::class)->similarToArtwork($source, 12);
|
||||
|
||||
Http::assertSent(fn ($request): bool => gatewaySearchLimit($request) === 13);
|
||||
});
|
||||
|
||||
it('clamps uploaded-image search to 100 without adding an extra candidate', function (): void {
|
||||
fakeFileSearchOk();
|
||||
|
||||
app(AiArtworkVectorSearchService::class)->searchByUploadedImage(
|
||||
UploadedFile::fake()->image('query.png', 32, 32),
|
||||
120,
|
||||
);
|
||||
|
||||
Http::assertSent(fn ($request): bool => gatewaySearchLimit($request) === 100);
|
||||
});
|
||||
Reference in New Issue
Block a user