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:
2026-08-25 07:58:47 +02:00
parent f52879edbb
commit 8a80aae21e
114 changed files with 9293 additions and 284 deletions
@@ -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);
});
+45 -4
View File
@@ -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');
});
+50
View File
@@ -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>
*/
+3 -1
View File
@@ -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);
});