Upload beautify

This commit is contained in:
2026-02-14 15:14:12 +01:00
parent e129618910
commit 79192345e3
249 changed files with 24436 additions and 1021 deletions

View File

@@ -0,0 +1,104 @@
<?php
declare(strict_types=1);
use App\Models\Artwork;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\DB;
uses(RefreshDatabase::class);
it('admin report returns feed performance breakdown and top clicked artworks', function () {
$admin = User::factory()->create(['role' => 'admin']);
$artworkA = Artwork::factory()->create(['title' => 'Feed Artwork A']);
$artworkB = Artwork::factory()->create(['title' => 'Feed Artwork B']);
$metricDate = now()->subDay()->toDateString();
DB::table('feed_daily_metrics')->insert([
[
'metric_date' => $metricDate,
'algo_version' => 'clip-cosine-v1',
'source' => 'personalized',
'impressions' => 10,
'clicks' => 4,
'saves' => 2,
'ctr' => 0.4,
'save_rate' => 0.5,
'dwell_0_5' => 1,
'dwell_5_30' => 1,
'dwell_30_120' => 1,
'dwell_120_plus' => 1,
'created_at' => now(),
'updated_at' => now(),
],
]);
DB::table('feed_events')->insert([
[
'event_date' => $metricDate,
'event_type' => 'feed_impression',
'user_id' => $admin->id,
'artwork_id' => $artworkA->id,
'position' => 1,
'algo_version' => 'clip-cosine-v1',
'source' => 'personalized',
'dwell_seconds' => null,
'occurred_at' => now()->subDay(),
'created_at' => now(),
'updated_at' => now(),
],
[
'event_date' => $metricDate,
'event_type' => 'feed_click',
'user_id' => $admin->id,
'artwork_id' => $artworkA->id,
'position' => 1,
'algo_version' => 'clip-cosine-v1',
'source' => 'personalized',
'dwell_seconds' => 12,
'occurred_at' => now()->subDay(),
'created_at' => now(),
'updated_at' => now(),
],
[
'event_date' => $metricDate,
'event_type' => 'feed_click',
'user_id' => $admin->id,
'artwork_id' => $artworkB->id,
'position' => 2,
'algo_version' => 'clip-cosine-v1',
'source' => 'personalized',
'dwell_seconds' => 7,
'occurred_at' => now()->subDay(),
'created_at' => now(),
'updated_at' => now(),
],
]);
$response = $this->actingAs($admin)->getJson('/api/admin/reports/feed-performance?from=' . $metricDate . '&to=' . $metricDate);
$response->assertOk();
$response->assertJsonPath('meta.from', $metricDate);
$response->assertJsonPath('meta.to', $metricDate);
$rows = collect($response->json('by_algo_source'));
expect($rows->count())->toBe(1);
expect($rows->first()['algo_version'])->toBe('clip-cosine-v1');
expect($rows->first()['source'])->toBe('personalized');
expect((float) $rows->first()['ctr'])->toBe(0.4);
$top = collect($response->json('top_clicked_artworks'));
expect($top->isNotEmpty())->toBeTrue();
expect((int) $top->first()['artwork_id'])->toBe($artworkA->id);
});
it('non-admin is denied feed performance report endpoint', function () {
$user = User::factory()->create(['role' => 'user']);
$response = $this->actingAs($user)->getJson('/api/admin/reports/feed-performance');
$response->assertStatus(403);
});

View File

@@ -0,0 +1,99 @@
<?php
use App\Models\Artwork;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\DB;
uses(RefreshDatabase::class);
it('admin report returns date-filtered breakdown by algo and top similarities with ctr', function () {
$admin = User::factory()->create(['role' => 'admin']);
$sourceA = Artwork::factory()->create(['title' => 'Source A']);
$similarA = Artwork::factory()->create(['title' => 'Similar A']);
$sourceB = Artwork::factory()->create(['title' => 'Source B']);
$similarB = Artwork::factory()->create(['title' => 'Similar B']);
$inRangeDate = now()->subDay()->toDateString();
$outRangeDate = now()->subDays(5)->toDateString();
DB::table('similar_artwork_events')->insert([
[
'event_date' => $inRangeDate,
'event_type' => 'impression',
'algo_version' => 'clip-cosine-v1',
'source_artwork_id' => $sourceA->id,
'similar_artwork_id' => $similarA->id,
'position' => 1,
'items_count' => 4,
'occurred_at' => now()->subDay(),
'created_at' => now(),
'updated_at' => now(),
],
[
'event_date' => $inRangeDate,
'event_type' => 'click',
'algo_version' => 'clip-cosine-v1',
'source_artwork_id' => $sourceA->id,
'similar_artwork_id' => $similarA->id,
'position' => 1,
'items_count' => null,
'occurred_at' => now()->subDay(),
'created_at' => now(),
'updated_at' => now(),
],
[
'event_date' => $inRangeDate,
'event_type' => 'impression',
'algo_version' => 'clip-cosine-v2',
'source_artwork_id' => $sourceB->id,
'similar_artwork_id' => $similarB->id,
'position' => 2,
'items_count' => 4,
'occurred_at' => now()->subDay(),
'created_at' => now(),
'updated_at' => now(),
],
[
'event_date' => $outRangeDate,
'event_type' => 'click',
'algo_version' => 'clip-cosine-v1',
'source_artwork_id' => $sourceA->id,
'similar_artwork_id' => $similarA->id,
'position' => 1,
'items_count' => null,
'occurred_at' => now()->subDays(5),
'created_at' => now(),
'updated_at' => now(),
],
]);
$response = $this->actingAs($admin)->getJson('/api/admin/reports/similar-artworks?from=' . $inRangeDate . '&to=' . $inRangeDate);
$response->assertOk();
$response->assertJsonPath('meta.from', $inRangeDate);
$response->assertJsonPath('meta.to', $inRangeDate);
$byAlgo = collect($response->json('by_algo_version'));
expect($byAlgo->count())->toBe(2);
$v1 = $byAlgo->firstWhere('algo_version', 'clip-cosine-v1');
expect($v1['impressions'])->toBe(1);
expect($v1['clicks'])->toBe(1);
expect((float) $v1['ctr'])->toBe(1.0);
$top = collect($response->json('top_similarities'));
expect($top->isNotEmpty())->toBeTrue();
expect($top->first()['source_artwork_id'])->toBe($sourceA->id);
expect($top->first()['similar_artwork_id'])->toBe($similarA->id);
expect((float) $top->first()['ctr'])->toBe(1.0);
});
it('non-admin is denied similar artwork report endpoint', function () {
$user = User::factory()->create(['role' => 'user']);
$response = $this->actingAs($user)->getJson('/api/admin/reports/similar-artworks');
$response->assertStatus(403);
});

View File

@@ -0,0 +1,168 @@
<?php
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;
uses(RefreshDatabase::class);
function createModerationCategory(): int
{
$contentTypeId = DB::table('content_types')->insertGetId([
'name' => 'Photography',
'slug' => 'photography-' . Str::lower(Str::random(6)),
'description' => null,
'created_at' => now(),
'updated_at' => now(),
]);
return DB::table('categories')->insertGetId([
'content_type_id' => $contentTypeId,
'parent_id' => null,
'name' => 'Moderation',
'slug' => 'moderation-' . Str::lower(Str::random(6)),
'description' => null,
'image' => null,
'is_active' => true,
'sort_order' => 0,
'created_at' => now(),
'updated_at' => now(),
]);
}
function createModerationDraft(int $userId, int $categoryId, array $overrides = []): string
{
$uploadId = (string) Str::uuid();
DB::table('uploads')->insert(array_merge([
'id' => $uploadId,
'user_id' => $userId,
'type' => 'image',
'status' => 'draft',
'processing_state' => 'ready',
'moderation_status' => 'pending',
'title' => 'Pending Moderation Upload',
'category_id' => $categoryId,
'is_scanned' => true,
'has_tags' => true,
'preview_path' => "tmp/drafts/{$uploadId}/preview.webp",
'created_at' => now(),
'updated_at' => now(),
], $overrides));
return $uploadId;
}
function addReadyMainFile(string $uploadId, string $hash = 'aabbccddeeff00112233'): void
{
Storage::disk('local')->put("tmp/drafts/{$uploadId}/main/main.jpg", 'jpg');
Storage::disk('local')->put("tmp/drafts/{$uploadId}/preview.webp", 'preview');
DB::table('upload_files')->insert([
'upload_id' => $uploadId,
'path' => "tmp/drafts/{$uploadId}/main/main.jpg",
'type' => 'main',
'hash' => $hash,
'size' => 3,
'mime' => 'image/jpeg',
'created_at' => now(),
]);
}
it('admin sees pending uploads', function () {
$admin = User::factory()->create(['role' => 'admin']);
$owner = User::factory()->create();
$categoryId = createModerationCategory();
createModerationDraft($owner->id, $categoryId, ['title' => 'First Pending']);
createModerationDraft($owner->id, $categoryId, ['title' => 'Second Pending']);
$response = $this->actingAs($admin)->getJson('/api/admin/uploads/pending');
$response->assertOk();
$response->assertJsonCount(2, 'data');
});
it('non-admin is denied moderation API access', function () {
$user = User::factory()->create(['role' => 'user']);
$response = $this->actingAs($user)->getJson('/api/admin/uploads/pending');
$response->assertStatus(403);
});
it('approve works', function () {
$admin = User::factory()->create(['role' => 'moderator']);
$owner = User::factory()->create();
$categoryId = createModerationCategory();
$uploadId = createModerationDraft($owner->id, $categoryId);
$response = $this->actingAs($admin)->postJson("/api/admin/uploads/{$uploadId}/approve", [
'note' => 'Looks good.',
]);
$response->assertOk();
$row = DB::table('uploads')->where('id', $uploadId)->first([
'moderation_status',
'moderation_note',
'moderated_by',
'moderated_at',
]);
expect($row->moderation_status)->toBe('approved');
expect($row->moderation_note)->toBe('Looks good.');
expect((int) $row->moderated_by)->toBe((int) $admin->id);
expect($row->moderated_at)->not->toBeNull();
});
it('reject works', function () {
$admin = User::factory()->create(['role' => 'admin']);
$owner = User::factory()->create();
$categoryId = createModerationCategory();
$uploadId = createModerationDraft($owner->id, $categoryId);
$response = $this->actingAs($admin)->postJson("/api/admin/uploads/{$uploadId}/reject", [
'note' => 'Policy violation.',
]);
$response->assertOk();
$row = DB::table('uploads')->where('id', $uploadId)->first([
'status',
'processing_state',
'moderation_status',
'moderation_note',
'moderated_by',
'moderated_at',
]);
expect($row->status)->toBe('rejected');
expect($row->processing_state)->toBe('rejected');
expect($row->moderation_status)->toBe('rejected');
expect($row->moderation_note)->toBe('Policy violation.');
expect((int) $row->moderated_by)->toBe((int) $admin->id);
expect($row->moderated_at)->not->toBeNull();
});
it('user cannot publish without approval', function () {
Storage::fake('local');
$owner = User::factory()->create(['role' => 'user']);
$categoryId = createModerationCategory();
$uploadId = createModerationDraft($owner->id, $categoryId, [
'moderation_status' => 'pending',
'title' => 'Blocked Publish',
]);
addReadyMainFile($uploadId);
$response = $this->actingAs($owner)->postJson("/api/uploads/{$uploadId}/publish");
$response->assertStatus(422);
$response->assertJsonFragment([
'message' => 'Upload requires moderation approval before publish.',
]);
});

View File

@@ -0,0 +1,138 @@
<?php
declare(strict_types=1);
use App\Models\Artwork;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\DB;
uses(RefreshDatabase::class);
it('stores feed analytics events with required dimensions', function () {
$user = User::factory()->create();
$artwork = Artwork::factory()->create();
$response = $this->actingAs($user)->postJson('/api/analytics/feed', [
'event_type' => 'feed_click',
'artwork_id' => $artwork->id,
'position' => 3,
'algo_version' => 'clip-cosine-v1',
'source' => 'personalized',
'dwell_seconds' => 27,
]);
$response->assertOk()->assertJson(['success' => true]);
$this->assertDatabaseHas('feed_events', [
'user_id' => $user->id,
'artwork_id' => $artwork->id,
'event_type' => 'feed_click',
'position' => 3,
'algo_version' => 'clip-cosine-v1',
'source' => 'personalized',
'dwell_seconds' => 27,
]);
});
it('aggregates daily feed analytics with ctr save-rate and dwell buckets', function () {
$user = User::factory()->create();
$artworkA = Artwork::factory()->create();
$artworkB = Artwork::factory()->create();
$metricDate = now()->subDay()->toDateString();
DB::table('feed_events')->insert([
[
'event_date' => $metricDate,
'event_type' => 'feed_impression',
'user_id' => $user->id,
'artwork_id' => $artworkA->id,
'position' => 1,
'algo_version' => 'clip-cosine-v1',
'source' => 'personalized',
'dwell_seconds' => null,
'occurred_at' => now()->subDay(),
'created_at' => now(),
'updated_at' => now(),
],
[
'event_date' => $metricDate,
'event_type' => 'feed_impression',
'user_id' => $user->id,
'artwork_id' => $artworkB->id,
'position' => 2,
'algo_version' => 'clip-cosine-v1',
'source' => 'personalized',
'dwell_seconds' => null,
'occurred_at' => now()->subDay(),
'created_at' => now(),
'updated_at' => now(),
],
[
'event_date' => $metricDate,
'event_type' => 'feed_click',
'user_id' => $user->id,
'artwork_id' => $artworkA->id,
'position' => 1,
'algo_version' => 'clip-cosine-v1',
'source' => 'personalized',
'dwell_seconds' => 3,
'occurred_at' => now()->subDay(),
'created_at' => now(),
'updated_at' => now(),
],
[
'event_date' => $metricDate,
'event_type' => 'feed_click',
'user_id' => $user->id,
'artwork_id' => $artworkB->id,
'position' => 2,
'algo_version' => 'clip-cosine-v1',
'source' => 'personalized',
'dwell_seconds' => 35,
'occurred_at' => now()->subDay(),
'created_at' => now(),
'updated_at' => now(),
],
]);
DB::table('user_discovery_events')->insert([
'event_id' => '33333333-3333-3333-3333-333333333333',
'user_id' => $user->id,
'artwork_id' => $artworkA->id,
'category_id' => null,
'event_type' => 'favorite',
'event_version' => 'event-v1',
'algo_version' => 'clip-cosine-v1',
'weight' => 1,
'event_date' => $metricDate,
'occurred_at' => now()->subDay(),
'meta' => null,
'created_at' => now(),
'updated_at' => now(),
]);
$this->artisan('analytics:aggregate-feed', ['--date' => $metricDate])->assertSuccessful();
$this->assertDatabaseHas('feed_daily_metrics', [
'metric_date' => $metricDate,
'algo_version' => 'clip-cosine-v1',
'source' => 'personalized',
'impressions' => 2,
'clicks' => 2,
'saves' => 1,
'dwell_0_5' => 1,
'dwell_30_120' => 1,
]);
$metric = DB::table('feed_daily_metrics')
->where('metric_date', $metricDate)
->where('algo_version', 'clip-cosine-v1')
->where('source', 'personalized')
->first();
expect((float) $metric->ctr)->toBe(1.0);
expect((float) $metric->save_rate)->toBe(0.5);
});

View File

@@ -0,0 +1,93 @@
<?php
declare(strict_types=1);
use Illuminate\Support\Facades\DB;
it('evaluates feed weights for all algos', function () {
$metricDate = now()->subDay()->toDateString();
DB::table('feed_daily_metrics')->insert([
[
'metric_date' => $metricDate,
'algo_version' => 'clip-cosine-v1',
'source' => 'personalized',
'impressions' => 100,
'clicks' => 20,
'saves' => 6,
'ctr' => 0.2,
'save_rate' => 0.3,
'dwell_0_5' => 4,
'dwell_5_30' => 8,
'dwell_30_120' => 5,
'dwell_120_plus' => 3,
'created_at' => now(),
'updated_at' => now(),
],
[
'metric_date' => $metricDate,
'algo_version' => 'clip-cosine-v2',
'source' => 'personalized',
'impressions' => 100,
'clicks' => 22,
'saves' => 8,
'ctr' => 0.22,
'save_rate' => 0.36,
'dwell_0_5' => 3,
'dwell_5_30' => 9,
'dwell_30_120' => 6,
'dwell_120_plus' => 4,
'created_at' => now(),
'updated_at' => now(),
],
]);
$this->artisan('analytics:evaluate-feed-weights', ['--from' => $metricDate, '--to' => $metricDate])
->assertSuccessful();
});
it('compares baseline and candidate feed algos', function () {
$metricDate = now()->subDay()->toDateString();
DB::table('feed_daily_metrics')->insert([
[
'metric_date' => $metricDate,
'algo_version' => 'clip-cosine-v1',
'source' => 'personalized',
'impressions' => 100,
'clicks' => 20,
'saves' => 6,
'ctr' => 0.2,
'save_rate' => 0.3,
'dwell_0_5' => 4,
'dwell_5_30' => 8,
'dwell_30_120' => 5,
'dwell_120_plus' => 3,
'created_at' => now(),
'updated_at' => now(),
],
[
'metric_date' => $metricDate,
'algo_version' => 'clip-cosine-v2',
'source' => 'personalized',
'impressions' => 100,
'clicks' => 24,
'saves' => 10,
'ctr' => 0.24,
'save_rate' => 0.416,
'dwell_0_5' => 3,
'dwell_5_30' => 8,
'dwell_30_120' => 7,
'dwell_120_plus' => 6,
'created_at' => now(),
'updated_at' => now(),
],
]);
$this->artisan('analytics:compare-feed-ab', [
'baseline' => 'clip-cosine-v1',
'candidate' => 'clip-cosine-v2',
'--from' => $metricDate,
'--to' => $metricDate,
])->assertSuccessful();
});

View File

@@ -0,0 +1,72 @@
<?php
use App\Models\Artwork;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\DB;
uses(RefreshDatabase::class);
it('stores similar artwork analytics events', function () {
$author = User::factory()->create();
$source = Artwork::factory()->create(['user_id' => $author->id]);
$similar = Artwork::factory()->create(['user_id' => $author->id]);
$response = $this->postJson('/api/analytics/similar-artworks', [
'event_type' => 'click',
'algo_version' => 'clip-cosine-v1',
'source_artwork_id' => $source->id,
'similar_artwork_id' => $similar->id,
'position' => 2,
]);
$response->assertOk()->assertJson(['success' => true]);
$this->assertDatabaseHas('similar_artwork_events', [
'event_type' => 'click',
'algo_version' => 'clip-cosine-v1',
'source_artwork_id' => $source->id,
'similar_artwork_id' => $similar->id,
'position' => 2,
]);
});
it('aggregates daily analytics counts by algo version', function () {
DB::table('similar_artwork_events')->insert([
[
'event_date' => now()->subDay()->toDateString(),
'event_type' => 'impression',
'algo_version' => 'clip-cosine-v1',
'source_artwork_id' => Artwork::factory()->create()->id,
'similar_artwork_id' => null,
'position' => null,
'items_count' => 8,
'occurred_at' => now()->subDay(),
'created_at' => now(),
'updated_at' => now(),
],
[
'event_date' => now()->subDay()->toDateString(),
'event_type' => 'click',
'algo_version' => 'clip-cosine-v1',
'source_artwork_id' => Artwork::factory()->create()->id,
'similar_artwork_id' => Artwork::factory()->create()->id,
'position' => 1,
'items_count' => null,
'occurred_at' => now()->subDay(),
'created_at' => now(),
'updated_at' => now(),
],
]);
$this->artisan('analytics:aggregate-similar-artworks', ['--date' => now()->subDay()->toDateString()])
->assertSuccessful();
$this->assertDatabaseHas('similar_artwork_daily_metrics', [
'metric_date' => now()->subDay()->toDateString(),
'algo_version' => 'clip-cosine-v1',
'impressions' => 1,
'clicks' => 1,
]);
});

View File

@@ -0,0 +1,117 @@
<?php
use App\Models\Artwork;
use App\Models\Category;
use App\Models\ContentType;
use App\Models\Tag;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Http\Request;
use Illuminate\Support\Str;
uses(RefreshDatabase::class);
it('renders JSON-LD structured data on published artwork page', function () {
$user = User::factory()->create(['name' => 'Schema Author']);
$contentType = ContentType::create([
'name' => 'Photography',
'slug' => 'photography',
'description' => 'Photography content',
]);
$category = Category::create([
'content_type_id' => $contentType->id,
'parent_id' => null,
'name' => 'Abstract',
'slug' => 'abstract-' . Str::lower(Str::random(5)),
'description' => 'Abstract works',
'is_active' => true,
'sort_order' => 0,
]);
$artwork = Artwork::factory()->create([
'user_id' => $user->id,
'title' => 'Schema Ready Artwork',
'slug' => 'schema-ready-artwork',
'description' => 'Artwork description for schema test.',
'mime_type' => 'image/jpeg',
'published_at' => now()->subMinute(),
'is_public' => true,
'is_approved' => true,
]);
$artwork->categories()->attach($category->id);
$tagA = Tag::create(['name' => 'neon', 'slug' => 'neon', 'usage_count' => 0, 'is_active' => true]);
$tagB = Tag::create(['name' => 'city', 'slug' => 'city', 'usage_count' => 0, 'is_active' => true]);
$artwork->tags()->attach([
$tagA->id => ['source' => 'user', 'confidence' => 0.9],
$tagB->id => ['source' => 'user', 'confidence' => 0.8],
]);
$html = view('artworks.show', ['artwork' => $artwork])->render();
expect($html)
->toContain('application/ld+json')
->toContain('"@type":"ImageObject"')
->toContain('"name":"Schema Ready Artwork"')
->toContain('"keywords":["neon","city"]');
});
it('renders JSON-LD via routed artwork show endpoint', function () {
$user = User::factory()->create(['name' => 'Schema Route Author']);
$contentType = ContentType::create([
'name' => 'Photography',
'slug' => 'photography',
'description' => 'Photography content',
]);
$category = Category::create([
'content_type_id' => $contentType->id,
'parent_id' => null,
'name' => 'Abstract',
'slug' => 'abstract-route',
'description' => 'Abstract works',
'is_active' => true,
'sort_order' => 0,
]);
$artwork = Artwork::factory()->create([
'user_id' => $user->id,
'title' => 'Schema Route Artwork',
'slug' => 'schema-route-artwork',
'description' => 'Artwork description for routed schema test.',
'mime_type' => 'image/png',
'published_at' => now()->subMinute(),
'is_public' => true,
'is_approved' => true,
]);
$artwork->categories()->attach($category->id);
$tag = Tag::create(['name' => 'route-tag', 'slug' => 'route-tag', 'usage_count' => 0, 'is_active' => true]);
$artwork->tags()->attach([
$tag->id => ['source' => 'user', 'confidence' => 0.95],
]);
$url = route('artworks.show', [
'contentTypeSlug' => $contentType->slug,
'categoryPath' => $category->slug,
'artwork' => $artwork->slug,
]);
expect($url)->toContain('/photography/abstract-route/schema-route-artwork');
$matchedRoute = app('router')->getRoutes()->match(Request::create($url, 'GET'));
expect($matchedRoute->getName())->toBe('artworks.show');
$response = $this->get($url);
$response->assertOk();
$response->assertSee('application/ld+json', false);
$response->assertSee('"@type":"ImageObject"', false);
$response->assertSee('"name":"Schema Route Artwork"', false);
$response->assertSee('"keywords":["route-tag"]', false);
});

View File

@@ -0,0 +1,76 @@
<?php
declare(strict_types=1);
use App\Jobs\AutoTagArtworkJob;
use App\Models\Artwork;
use App\Models\Category;
use App\Models\ContentType;
use App\Models\Tag;
use Illuminate\Support\Facades\Http;
it('calls CLIP analyze and attaches AI tags', function () {
config()->set('vision.enabled', true);
config()->set('vision.clip.base_url', 'https://clip.local');
config()->set('vision.clip.endpoint', '/analyze');
config()->set('vision.yolo.enabled', false);
config()->set('cdn.files_url', 'https://files.local');
Http::fake([
'https://clip.local/analyze' => Http::response([
['tag' => 'Cyber Punk', 'confidence' => 0.42],
['tag' => 'City', 'confidence' => 0.31],
], 200),
]);
$artwork = Artwork::factory()->create();
$hash = 'abcdef123456';
(new AutoTagArtworkJob($artwork->id, $hash))->handle(app(\App\Services\TagService::class), app(\App\Services\TagNormalizer::class));
expect(Tag::query()->whereIn('slug', ['cyber-punk', 'city'])->count())->toBe(2);
expect($artwork->tags()->pluck('slug')->all())->toContain('cyber-punk', 'city');
});
it('optionally calls YOLO for photography', function () {
config()->set('vision.enabled', true);
config()->set('vision.clip.base_url', 'https://clip.local');
config()->set('vision.yolo.base_url', 'https://yolo.local');
config()->set('vision.yolo.enabled', true);
config()->set('vision.yolo.photography_only', true);
config()->set('cdn.files_url', 'https://files.local');
Http::fake([
'https://clip.local/analyze' => Http::response([['tag' => 'tree', 'confidence' => 0.2]], 200),
'https://yolo.local/analyze' => Http::response(['objects' => [['label' => 'person', 'confidence' => 0.9]]], 200),
]);
$photoType = ContentType::query()->create(['name' => 'Photography', 'slug' => 'photography', 'description' => '']);
$cat = Category::query()->create(['content_type_id' => $photoType->id, 'parent_id' => null, 'name' => 'Test', 'slug' => 'test', 'description' => '', 'image' => null, 'is_active' => true, 'sort_order' => 0]);
$artwork = Artwork::factory()->create();
$artwork->categories()->attach($cat->id);
$hash = 'abcdef123456';
(new AutoTagArtworkJob($artwork->id, $hash))->handle(app(\App\Services\TagService::class), app(\App\Services\TagNormalizer::class));
expect($artwork->tags()->pluck('slug')->all())->toContain('tree', 'person');
});
it('does not throw on CLIP 4xx and never blocks publish', function () {
config()->set('vision.enabled', true);
config()->set('vision.clip.base_url', 'https://clip.local');
config()->set('vision.yolo.enabled', false);
config()->set('cdn.files_url', 'https://files.local');
Http::fake([
'https://clip.local/analyze' => Http::response(['message' => 'bad'], 422),
]);
$artwork = Artwork::factory()->create();
$hash = 'abcdef123456';
(new AutoTagArtworkJob($artwork->id, $hash))->handle(app(\App\Services\TagService::class), app(\App\Services\TagNormalizer::class));
expect($artwork->tags()->count())->toBe(0);
});

View File

@@ -0,0 +1,15 @@
<?php
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
class AvatarUploadTest extends TestCase
{
use RefreshDatabase;
public function test_upload_requires_authentication()
{
$response = $this->postJson(route('avatar.upload'));
$response->assertStatus(401);
}
}

View File

@@ -0,0 +1,56 @@
<?php
use App\Http\Controllers\ContentRouterController;
use App\Models\Artwork;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Http\Request;
use Illuminate\View\View;
uses(RefreshDatabase::class);
it('handles model-bound artwork parameter without 404 fallback', function () {
$artwork = Artwork::factory()->create([
'slug' => 'bound-model-artwork',
'is_public' => true,
'is_approved' => true,
'published_at' => now()->subMinute(),
]);
$request = Request::create('/photography/abstract/bound-model-artwork', 'GET');
$response = app(ContentRouterController::class)->handle(
$request,
'photography',
'abstract',
$artwork
);
expect($response)->toBeInstanceOf(View::class);
expect($response->name())->toBe('artworks.show');
expect($response->getData()['artwork']->id)->toBe($artwork->id);
});
it('binds routed artwork model and renders published artwork page', function () {
$artwork = Artwork::factory()->create([
'title' => 'Routed Binding Artwork',
'slug' => 'routed-binding-artwork',
'is_public' => true,
'is_approved' => true,
'published_at' => now()->subMinute(),
]);
$url = route('artworks.show', [
'contentTypeSlug' => 'photography',
'categoryPath' => 'abstract',
'artwork' => $artwork->slug,
]);
$matchedRoute = app('router')->getRoutes()->match(Request::create($url, 'GET'));
app('router')->substituteBindings($matchedRoute);
expect($matchedRoute->parameter('artwork'))->toBeInstanceOf(Artwork::class);
expect($matchedRoute->parameter('artwork')->id)->toBe($artwork->id);
$this->get($url)
->assertOk()
->assertSee('Routed Binding Artwork');
});

View File

@@ -0,0 +1,48 @@
<?php
declare(strict_types=1);
use App\Jobs\IngestUserDiscoveryEventJob;
use App\Models\Artwork;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Queue;
uses(RefreshDatabase::class);
it('queues discovery event ingestion asynchronously', function () {
Queue::fake();
$user = User::factory()->create();
$artwork = Artwork::factory()->create();
$response = $this->actingAs($user)->postJson('/api/discovery/events', [
'event_type' => 'view',
'artwork_id' => $artwork->id,
'meta' => ['source' => 'artwork_show'],
]);
$response
->assertStatus(202)
->assertJsonPath('queued', true)
->assertJsonPath('algo_version', (string) config('discovery.algo_version'));
Queue::assertPushed(IngestUserDiscoveryEventJob::class, function (IngestUserDiscoveryEventJob $job) use ($user, $artwork): bool {
return $job->userId === $user->id
&& $job->artworkId === $artwork->id
&& $job->eventType === 'view';
});
});
it('validates discovery event payload', function () {
$user = User::factory()->create();
$artwork = Artwork::factory()->create();
$response = $this->actingAs($user)->postJson('/api/discovery/events', [
'event_type' => 'impression',
'artwork_id' => $artwork->id,
]);
$response->assertStatus(422);
$response->assertJsonValidationErrors(['event_type']);
});

View File

@@ -0,0 +1,113 @@
<?php
declare(strict_types=1);
use App\Jobs\RegenerateUserRecommendationCacheJob;
use App\Models\Artwork;
use App\Models\User;
use App\Models\UserRecommendationCache;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Queue;
uses(RefreshDatabase::class);
it('returns feed from cache with cursor pagination', function () {
$user = User::factory()->create();
$artworkA = Artwork::factory()->create(['is_public' => true, 'is_approved' => true, 'published_at' => now()->subMinutes(3)]);
$artworkB = Artwork::factory()->create(['is_public' => true, 'is_approved' => true, 'published_at' => now()->subMinutes(2)]);
$artworkC = Artwork::factory()->create(['is_public' => true, 'is_approved' => true, 'published_at' => now()->subMinutes(1)]);
UserRecommendationCache::query()->create([
'user_id' => $user->id,
'algo_version' => (string) config('discovery.algo_version'),
'cache_version' => (string) config('discovery.cache_version'),
'recommendations_json' => [
'items' => [
['artwork_id' => $artworkA->id, 'score' => 0.9, 'source' => 'profile'],
['artwork_id' => $artworkB->id, 'score' => 0.8, 'source' => 'profile'],
['artwork_id' => $artworkC->id, 'score' => 0.7, 'source' => 'profile'],
],
],
'generated_at' => now(),
'expires_at' => now()->addMinutes(30),
]);
$first = $this->actingAs($user)->getJson('/api/v1/feed?limit=2');
$first->assertOk();
$first->assertJsonPath('meta.cache_status', 'hit');
expect(count((array) $first->json('data')))->toBe(2);
$nextCursor = $first->json('meta.next_cursor');
expect($nextCursor)->not->toBeNull();
$second = $this->actingAs($user)->getJson('/api/v1/feed?limit=2&cursor=' . urlencode((string) $nextCursor));
$second->assertOk();
expect(count((array) $second->json('data')))->toBe(1);
expect($second->json('meta.next_cursor'))->toBeNull();
});
it('dispatches async regeneration on cache miss and returns cold start items', function () {
Queue::fake();
$user = User::factory()->create();
$artworkA = Artwork::factory()->create(['is_public' => true, 'is_approved' => true, 'published_at' => now()->subMinutes(3)]);
$artworkB = Artwork::factory()->create(['is_public' => true, 'is_approved' => true, 'published_at' => now()->subMinutes(2)]);
DB::table('artwork_stats')->insert([
['artwork_id' => $artworkA->id, 'views' => 100, 'downloads' => 30, 'favorites' => 10, 'rating_avg' => 0, 'rating_count' => 0],
['artwork_id' => $artworkB->id, 'views' => 80, 'downloads' => 10, 'favorites' => 5, 'rating_avg' => 0, 'rating_count' => 0],
]);
$response = $this->actingAs($user)->getJson('/api/v1/feed?limit=10');
$response->assertOk();
expect(count((array) $response->json('data')))->toBeGreaterThan(0);
expect((string) $response->json('meta.cache_status'))->toContain('miss');
Queue::assertPushed(RegenerateUserRecommendationCacheJob::class, function (RegenerateUserRecommendationCacheJob $job) use ($user): bool {
return $job->userId === $user->id;
});
});
it('applies diversity guard to avoid near-duplicates in cold start fallback', function () {
Queue::fake();
$user = User::factory()->create();
$artworkA = Artwork::factory()->create(['is_public' => true, 'is_approved' => true, 'published_at' => now()->subMinutes(3)]);
$artworkB = Artwork::factory()->create(['is_public' => true, 'is_approved' => true, 'published_at' => now()->subMinutes(2)]);
$artworkC = Artwork::factory()->create(['is_public' => true, 'is_approved' => true, 'published_at' => now()->subMinutes(1)]);
DB::table('artwork_stats')->insert([
['artwork_id' => $artworkA->id, 'views' => 200, 'downloads' => 20, 'favorites' => 5, 'rating_avg' => 0, 'rating_count' => 0],
['artwork_id' => $artworkB->id, 'views' => 190, 'downloads' => 18, 'favorites' => 4, 'rating_avg' => 0, 'rating_count' => 0],
['artwork_id' => $artworkC->id, 'views' => 180, 'downloads' => 12, 'favorites' => 3, 'rating_avg' => 0, 'rating_count' => 0],
]);
DB::table('artwork_similarities')->insert([
'artwork_id' => $artworkA->id,
'similar_artwork_id' => $artworkB->id,
'model' => 'clip',
'model_version' => 'v1',
'algo_version' => (string) config('discovery.algo_version'),
'rank' => 1,
'score' => 0.991,
'generated_at' => now(),
'created_at' => now(),
'updated_at' => now(),
]);
$response = $this->actingAs($user)->getJson('/api/v1/feed?limit=10');
$response->assertOk();
$ids = collect((array) $response->json('data'))->pluck('id')->all();
expect(in_array($artworkA->id, $ids, true) && in_array($artworkB->id, $ids, true))->toBeFalse();
expect(in_array($artworkC->id, $ids, true))->toBeTrue();
});

View File

@@ -3,28 +3,28 @@
use App\Models\User;
test('profile page is displayed', function () {
$user = User::factory()->create();
$user = User::factory()->create(['email' => null]);
$response = $this
->actingAs($user)
->get('/profile');
$response = $this
->actingAs($user)
->get('/profile');
$response->assertOk();
});
test('profile information can be updated', function () {
$user = User::factory()->create();
$user = User::factory()->create(['email' => null]);
$response = $this
->actingAs($user)
->patch('/profile', [
->patch('/profile', [
'name' => 'Test User',
'email' => 'test@example.com',
]);
$response
->assertSessionHasNoErrors()
->assertRedirect('/profile');
$response
->assertSessionHasNoErrors()
->assertRedirect('/user');
$user->refresh();
@@ -38,14 +38,14 @@ test('email verification status is unchanged when the email address is unchanged
$response = $this
->actingAs($user)
->patch('/profile', [
->patch('/profile', [
'name' => 'Test User',
'email' => $user->email,
]);
$response
->assertSessionHasNoErrors()
->assertRedirect('/profile');
$response
->assertSessionHasNoErrors()
->assertRedirect('/user');
$this->assertNotNull($user->refresh()->email_verified_at);
});
@@ -55,13 +55,13 @@ test('user can delete their account', function () {
$response = $this
->actingAs($user)
->delete('/profile', [
->delete('/profile', [
'password' => 'password',
]);
$response
->assertSessionHasNoErrors()
->assertRedirect('/');
$response
->assertSessionHasNoErrors()
->assertRedirect('/');
$this->assertGuest();
// User should be soft-deleted, not permanently removed
@@ -74,13 +74,13 @@ test('correct password must be provided to delete account', function () {
$response = $this
->actingAs($user)
->from('/profile')
->delete('/profile', [
->delete('/profile', [
'password' => 'wrong-password',
]);
$response
->assertSessionHasErrorsIn('userDeletion', 'password')
->assertRedirect('/profile');
$response
->assertSessionHasErrorsIn('userDeletion', 'password')
->assertRedirect('/profile');
$this->assertNotNull($user->fresh());
});

View File

@@ -0,0 +1,120 @@
<?php
use App\Models\Artwork;
use App\Models\Category;
use App\Models\ContentType;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\DB;
uses(RefreshDatabase::class);
function createContentTypeAndCategoryForSimilarTests(): array
{
$contentType = ContentType::create([
'name' => 'Photography',
'slug' => 'photography',
'description' => 'Photography content',
]);
$category = Category::create([
'content_type_id' => $contentType->id,
'parent_id' => null,
'name' => 'Abstract',
'slug' => 'abstract-similar',
'description' => 'Abstract works',
'is_active' => true,
'sort_order' => 0,
]);
return [$contentType, $category];
}
it('renders similar artworks block when similarities exist with algo version and CDN thumbnails', function () {
[$contentType, $category] = createContentTypeAndCategoryForSimilarTests();
$author = User::factory()->create();
$source = Artwork::factory()->create([
'user_id' => $author->id,
'title' => 'Source Artwork',
'slug' => 'source-artwork',
'hash' => 'aa11bb22cc33',
'thumb_ext' => 'webp',
'published_at' => now()->subMinute(),
'is_public' => true,
'is_approved' => true,
]);
$similar = Artwork::factory()->create([
'user_id' => $author->id,
'title' => 'Similar Artwork',
'slug' => 'similar-artwork',
'hash' => 'bb22cc33dd44',
'thumb_ext' => 'webp',
'published_at' => now()->subMinute(),
'is_public' => true,
'is_approved' => true,
]);
$source->categories()->attach($category->id);
$similar->categories()->attach($category->id);
DB::table('artwork_similarities')->insert([
'artwork_id' => $source->id,
'similar_artwork_id' => $similar->id,
'model' => 'clip',
'model_version' => 'v1',
'algo_version' => 'clip-cosine-v1',
'rank' => 1,
'score' => 0.9234567,
'generated_at' => now(),
'created_at' => now(),
'updated_at' => now(),
]);
$url = route('artworks.show', [
'contentTypeSlug' => $contentType->slug,
'categoryPath' => $category->slug,
'artwork' => $source->slug,
]);
$response = $this->get($url);
$response->assertOk();
$response->assertSee('Similar artworks');
$response->assertSee('data-algo-version="clip-cosine-v1"', false);
$response->assertSee((string) $similar->thumb_url, false);
$response->assertSee('https://files.skinbase.org', false);
});
it('hides similar artworks block when no similarities exist', function () {
[$contentType, $category] = createContentTypeAndCategoryForSimilarTests();
$author = User::factory()->create();
$source = Artwork::factory()->create([
'user_id' => $author->id,
'title' => 'Lonely Artwork',
'slug' => 'lonely-artwork',
'hash' => 'cc33dd44ee55',
'thumb_ext' => 'webp',
'published_at' => now()->subMinute(),
'is_public' => true,
'is_approved' => true,
]);
$source->categories()->attach($category->id);
$url = route('artworks.show', [
'contentTypeSlug' => $contentType->slug,
'categoryPath' => $category->slug,
'artwork' => $source->slug,
]);
$response = $this->get($url);
$response->assertOk();
$response->assertDontSee('Similar artworks', false);
$response->assertDontSee('data-similar-analytics', false);
});

View File

@@ -0,0 +1,100 @@
<?php
declare(strict_types=1);
use App\Models\Artwork;
use App\Models\Tag;
use App\Services\TagNormalizer;
use App\Services\TagService;
use Illuminate\Support\Facades\DB;
it('normalizes tags consistently', function () {
$n = app(TagNormalizer::class);
expect($n->normalize(' Cyber Punk!! '))->toBe('cyber-punk');
expect($n->normalize("🚀 Rocket "))->toBe('rocket');
expect($n->normalize(''))->toBe('');
});
it('prevents duplicate tags and duplicate pivots for user tags', function () {
$service = app(TagService::class);
$artwork = Artwork::factory()->create();
$service->attachUserTags($artwork, ['CyberPunk', ' cyberpunk ', 'CYBERPUNK']);
expect(Tag::query()->count())->toBe(1);
expect($artwork->tags()->count())->toBe(1);
expect(Tag::first()->usage_count)->toBe(1);
});
it('attaches AI tags with source and confidence without blocking existing user tags', function () {
$service = app(TagService::class);
$artwork = Artwork::factory()->create();
$service->attachUserTags($artwork, ['city']);
$service->attachAiTags($artwork, [
['tag' => 'city', 'confidence' => 0.2],
['tag' => 'cyberpunk', 'confidence' => 0.42],
]);
$artwork->load('tags');
$city = $artwork->tags->firstWhere('slug', 'city');
$cyber = $artwork->tags->firstWhere('slug', 'cyberpunk');
expect($city)->not->toBeNull();
expect($city->pivot->source)->toBe('user');
expect($city->pivot->confidence)->toBeNull();
expect($cyber)->not->toBeNull();
expect($cyber->pivot->source)->toBe('ai');
expect($cyber->pivot->confidence)->toBeFloat();
});
it('syncs user tags and maintains usage counts', function () {
$service = app(TagService::class);
$artwork = Artwork::factory()->create();
$service->attachUserTags($artwork, ['one', 'two']);
expect(Tag::query()->whereIn('slug', ['one', 'two'])->pluck('usage_count', 'slug')->all())
->toMatchArray(['one' => 1, 'two' => 1]);
$service->syncTags($artwork, ['two', 'three']);
$artwork->refresh();
expect($artwork->tags()->pluck('slug')->all())->toContain('two', 'three');
$counts = Tag::query()->whereIn('slug', ['one', 'two', 'three'])->pluck('usage_count', 'slug')->all();
expect($counts['one'])->toBe(0);
expect($counts['two'])->toBe(1);
expect($counts['three'])->toBe(1);
});
it('enforces pivot integrity (single row per artwork-tag) and user precedence', function () {
$service = app(TagService::class);
$artwork = Artwork::factory()->create();
$service->attachAiTags($artwork, [['tag' => 'future', 'confidence' => 0.31]]);
$service->attachUserTags($artwork, ['future']);
$artwork->load('tags');
$future = $artwork->tags->firstWhere('slug', 'future');
expect($future)->not->toBeNull();
expect($future->pivot->source)->toBe('user');
expect($future->pivot->confidence)->toBeNull();
expect(DB::table('artwork_tag')->where('artwork_id', $artwork->id)->where('tag_id', $future->id)->count())->toBe(1);
});
it('cleans up pivots and decrements usage counts when artwork is force deleted', function () {
$service = app(TagService::class);
$artwork = Artwork::factory()->create();
$service->attachUserTags($artwork, ['a', 'b']);
$tagIds = Tag::query()->pluck('id')->all();
expect(DB::table('artwork_tag')->where('artwork_id', $artwork->id)->count())->toBe(2);
$artwork->forceDelete();
expect(DB::table('artwork_tag')->whereIn('tag_id', $tagIds)->count())->toBe(0);
expect(Tag::query()->whereIn('id', $tagIds)->sum('usage_count'))->toBe(0);
});

View File

@@ -0,0 +1,212 @@
<?php
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\Storage;
uses(RefreshDatabase::class);
/** @var array<int, string> */
$tempArchives = [];
afterEach(function () use (&$tempArchives): void {
foreach ($tempArchives as $path) {
if (is_file($path)) {
@unlink($path);
}
}
$tempArchives = [];
});
/**
* @param array<string, string> $entries
* @param (callable(\ZipArchive,string):void)|null $entryCallback
*/
function makeArchiveUpload(array $entries, array &$tempArchives, ?callable $entryCallback = null): UploadedFile
{
if (! class_exists(\ZipArchive::class)) {
test()->markTestSkipped('ZipArchive extension is required.');
}
$path = tempnam(sys_get_temp_dir(), 'sb_upload_zip_');
if ($path === false) {
throw new \RuntimeException('Failed to allocate temp archive path.');
}
$tempArchives[] = $path;
$zip = new \ZipArchive();
if ($zip->open($path, \ZipArchive::OVERWRITE | \ZipArchive::CREATE) !== true) {
throw new \RuntimeException('Failed to create test zip archive.');
}
foreach ($entries as $name => $content) {
$zip->addFromString($name, $content);
if ($entryCallback !== null) {
$entryCallback($zip, $name);
}
}
$zip->close();
return new UploadedFile($path, 'archive.zip', 'application/zip', null, true);
}
it('rejects archive with zip slip path during preload', function () use (&$tempArchives) {
Storage::fake('local');
$user = User::factory()->create();
$archive = makeArchiveUpload([
'../evil.txt' => 'x',
], $tempArchives);
$screenshot = UploadedFile::fake()->image('screen.jpg', 800, 600);
$response = $this->actingAs($user)->postJson('/api/uploads/preload', [
'main' => $archive,
'screenshots' => [$screenshot],
]);
$response->assertStatus(422)
->assertJsonPath('message', 'Archive inspection failed.');
expect(strtolower((string) $response->json('reason')))->toContain('path');
});
it('rejects archive with symlink during preload', function () use (&$tempArchives) {
Storage::fake('local');
$user = User::factory()->create();
$archive = makeArchiveUpload([
'safe/readme.txt' => 'ok',
'safe/link' => 'target',
], $tempArchives, function (\ZipArchive $zip, string $entryName): void {
if ($entryName === 'safe/link') {
$zip->setExternalAttributesName($entryName, \ZipArchive::OPSYS_UNIX, 0120777 << 16);
}
});
$screenshot = UploadedFile::fake()->image('screen.jpg', 800, 600);
$response = $this->actingAs($user)->postJson('/api/uploads/preload', [
'main' => $archive,
'screenshots' => [$screenshot],
]);
$response->assertStatus(422)
->assertJsonPath('message', 'Archive inspection failed.');
expect(strtolower((string) $response->json('reason')))->toContain('symlink');
});
it('rejects archive with deep nesting during preload', function () use (&$tempArchives) {
Storage::fake('local');
$user = User::factory()->create();
$archive = makeArchiveUpload([
'a/b/c/d/e/f/file.txt' => 'deep',
], $tempArchives);
$screenshot = UploadedFile::fake()->image('screen.jpg', 800, 600);
$response = $this->actingAs($user)->postJson('/api/uploads/preload', [
'main' => $archive,
'screenshots' => [$screenshot],
]);
$response->assertStatus(422)
->assertJsonPath('message', 'Archive inspection failed.');
expect(strtolower((string) $response->json('reason')))->toContain('depth');
});
it('rejects archive with too many files during preload', function () use (&$tempArchives) {
Storage::fake('local');
$user = User::factory()->create();
$entries = [];
for ($index = 0; $index < 5001; $index++) {
$entries['f' . $index . '.txt'] = 'x';
}
$archive = makeArchiveUpload($entries, $tempArchives);
$screenshot = UploadedFile::fake()->image('screen.jpg', 800, 600);
$response = $this->actingAs($user)->postJson('/api/uploads/preload', [
'main' => $archive,
'screenshots' => [$screenshot],
]);
$response->assertStatus(422)
->assertJsonPath('message', 'Archive inspection failed.');
expect((string) $response->json('reason'))->toContain('5000');
});
it('rejects archive with executable inside during preload', function () use (&$tempArchives) {
Storage::fake('local');
$user = User::factory()->create();
$archive = makeArchiveUpload([
'safe/readme.txt' => 'ok',
'safe/run.exe' => 'MZ',
], $tempArchives);
$screenshot = UploadedFile::fake()->image('screen.jpg', 800, 600);
$response = $this->actingAs($user)->postJson('/api/uploads/preload', [
'main' => $archive,
'screenshots' => [$screenshot],
]);
$response->assertStatus(422)
->assertJsonPath('message', 'Archive inspection failed.');
expect(strtolower((string) $response->json('reason')))->toContain('blocked');
});
it('rejects archive with zip bomb ratio during preload', function () use (&$tempArchives) {
Storage::fake('local');
$user = User::factory()->create();
$archive = makeArchiveUpload([
'payload.txt' => str_repeat('A', 6 * 1024 * 1024),
], $tempArchives);
$screenshot = UploadedFile::fake()->image('screen.jpg', 800, 600);
$response = $this->actingAs($user)->postJson('/api/uploads/preload', [
'main' => $archive,
'screenshots' => [$screenshot],
]);
$response->assertStatus(422)
->assertJsonPath('message', 'Archive inspection failed.');
expect(strtolower((string) $response->json('reason')))->toContain('ratio');
});
it('accepts valid archive during preload', function () use (&$tempArchives) {
Storage::fake('local');
$user = User::factory()->create();
$archive = makeArchiveUpload([
'skins/theme/readme.txt' => 'hello',
'skins/theme/layout.ini' => 'v=1',
], $tempArchives);
$screenshot = UploadedFile::fake()->image('screen.jpg', 800, 600);
$response = $this->actingAs($user)->postJson('/api/uploads/preload', [
'main' => $archive,
'screenshots' => [$screenshot],
]);
$response->assertOk()->assertJsonStructure([
'upload_id',
'status',
'expires_at',
]);
});

View File

@@ -0,0 +1,47 @@
<?php
use App\Models\User;
use App\Uploads\Jobs\PreviewGenerationJob;
use App\Uploads\Jobs\TagAnalysisJob;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Bus;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;
uses(RefreshDatabase::class);
it('PreviewGenerationJob dispatches TagAnalysisJob', function () {
Storage::fake('local');
Bus::fake();
$user = User::factory()->create();
$uploadId = (string) Str::uuid();
DB::table('uploads')->insert([
'id' => $uploadId,
'user_id' => $user->id,
'type' => 'archive',
'status' => 'draft',
'is_scanned' => true,
'has_tags' => false,
'created_at' => now(),
'updated_at' => now(),
]);
// archive path with no screenshot uses placeholder path in PreviewService
$job = new PreviewGenerationJob($uploadId);
$job->handle(app(\App\Services\Upload\PreviewService::class));
$this->assertDatabaseHas('uploads', [
'id' => $uploadId,
]);
Bus::assertDispatched(TagAnalysisJob::class, function (TagAnalysisJob $queuedJob) use ($uploadId) {
$reflect = new ReflectionClass($queuedJob);
$property = $reflect->getProperty('uploadId');
$property->setAccessible(true);
return $property->getValue($queuedJob) === $uploadId;
});
});

View File

@@ -0,0 +1,165 @@
<?php
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;
uses(RefreshDatabase::class);
function createCategoryForAutosaveTests(): int
{
$contentTypeId = DB::table('content_types')->insertGetId([
'name' => 'Photography',
'slug' => 'photography-' . Str::lower(Str::random(6)),
'description' => null,
'created_at' => now(),
'updated_at' => now(),
]);
return DB::table('categories')->insertGetId([
'content_type_id' => $contentTypeId,
'parent_id' => null,
'name' => 'Nature',
'slug' => 'nature-' . Str::lower(Str::random(6)),
'description' => null,
'image' => null,
'is_active' => true,
'sort_order' => 0,
'created_at' => now(),
'updated_at' => now(),
]);
}
function createDraftUploadForAutosave(int $userId, string $status = 'draft'): string
{
$id = (string) Str::uuid();
DB::table('uploads')->insert([
'id' => $id,
'user_id' => $userId,
'type' => 'image',
'status' => $status,
'title' => 'Original Title',
'description' => 'Original Description',
'license' => 'default',
'nsfw' => false,
'created_at' => now(),
'updated_at' => now(),
]);
return $id;
}
it('owner can autosave', function () {
Storage::fake('local');
$owner = User::factory()->create();
$categoryId = createCategoryForAutosaveTests();
$uploadId = createDraftUploadForAutosave($owner->id);
$response = $this->actingAs($owner)->postJson("/api/uploads/{$uploadId}/autosave", [
'title' => 'Updated Title',
'category_id' => $categoryId,
'description' => 'Updated Description',
'tags' => ['night', 'city'],
'license' => 'cc-by',
'nsfw' => true,
]);
$response->assertOk()->assertJsonStructure([
'success',
'updated_at',
])->assertJson([
'success' => true,
]);
$this->assertDatabaseHas('uploads', [
'id' => $uploadId,
'title' => 'Updated Title',
'category_id' => $categoryId,
'description' => 'Updated Description',
'license' => 'cc-by',
'nsfw' => 1,
]);
$row = DB::table('uploads')->where('id', $uploadId)->first();
expect(json_decode((string) $row->tags, true))->toBe(['night', 'city']);
});
it('partial update works', function () {
Storage::fake('local');
$owner = User::factory()->create();
$uploadId = createDraftUploadForAutosave($owner->id);
$before = DB::table('uploads')->where('id', $uploadId)->first();
$response = $this->actingAs($owner)->postJson("/api/uploads/{$uploadId}/autosave", [
'title' => 'Only Title Changed',
]);
$response->assertOk()->assertJson([
'success' => true,
]);
$after = DB::table('uploads')->where('id', $uploadId)->first();
expect($after->title)->toBe('Only Title Changed');
expect($after->description)->toBe($before->description);
expect($after->license)->toBe($before->license);
});
it('guest denied', function () {
Storage::fake('local');
$owner = User::factory()->create();
$uploadId = createDraftUploadForAutosave($owner->id);
$response = $this->postJson("/api/uploads/{$uploadId}/autosave", [
'title' => 'Nope',
]);
expect(in_array($response->getStatusCode(), [401, 403]))->toBeTrue();
});
it('other user denied', function () {
Storage::fake('local');
$owner = User::factory()->create();
$other = User::factory()->create();
$uploadId = createDraftUploadForAutosave($owner->id);
$response = $this->actingAs($other)->postJson("/api/uploads/{$uploadId}/autosave", [
'title' => 'Hacker Update',
]);
$response->assertStatus(403);
});
it('published upload rejected', function () {
Storage::fake('local');
$owner = User::factory()->create();
$uploadId = createDraftUploadForAutosave($owner->id, 'published');
$response = $this->actingAs($owner)->postJson("/api/uploads/{$uploadId}/autosave", [
'title' => 'Should Not Save',
]);
$response->assertStatus(422);
});
it('invalid category rejected', function () {
Storage::fake('local');
$owner = User::factory()->create();
$uploadId = createDraftUploadForAutosave($owner->id);
$response = $this->actingAs($owner)->postJson("/api/uploads/{$uploadId}/autosave", [
'category_id' => 999999,
]);
$response->assertStatus(422)->assertJsonValidationErrors(['category_id']);
});

View File

@@ -0,0 +1,89 @@
<?php
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Artisan;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;
uses(RefreshDatabase::class);
function createCleanupUpload(array $overrides = []): string
{
$id = (string) Str::uuid();
$defaults = [
'id' => $id,
'user_id' => User::factory()->create()->id,
'type' => 'image',
'status' => 'draft',
'title' => null,
'slug' => null,
'category_id' => null,
'description' => null,
'tags' => null,
'license' => null,
'nsfw' => false,
'is_scanned' => false,
'has_tags' => false,
'preview_path' => null,
'published_at' => null,
'final_path' => null,
'expires_at' => null,
'created_at' => now(),
'updated_at' => now(),
];
DB::table('uploads')->insert(array_merge($defaults, $overrides));
return $id;
}
it('runs uploads cleanup command and deletes stale drafts', function () {
Storage::fake('local');
$expiredId = createCleanupUpload([
'status' => 'draft',
'expires_at' => now()->subMinute(),
]);
$activeId = createCleanupUpload([
'status' => 'draft',
'expires_at' => now()->addHours(2),
'updated_at' => now()->subHours(2),
]);
Storage::disk('local')->put("tmp/drafts/{$expiredId}/meta.json", '{}');
Storage::disk('local')->put("tmp/drafts/{$activeId}/meta.json", '{}');
$code = Artisan::call('uploads:cleanup');
expect($code)->toBe(0);
expect(Artisan::output())->toContain('Uploads cleanup deleted 1 draft(s).');
expect(DB::table('uploads')->where('id', $expiredId)->exists())->toBeFalse();
expect(DB::table('uploads')->where('id', $activeId)->where('status', 'draft')->exists())->toBeTrue();
expect(Storage::disk('local')->exists("tmp/drafts/{$expiredId}/meta.json"))->toBeFalse();
expect(Storage::disk('local')->exists("tmp/drafts/{$activeId}/meta.json"))->toBeTrue();
});
it('respects command limit option', function () {
Storage::fake('local');
for ($index = 0; $index < 5; $index++) {
$uploadId = createCleanupUpload([
'status' => 'draft',
'updated_at' => now()->subHours(26),
]);
Storage::disk('local')->put("tmp/drafts/{$uploadId}/meta.json", '{}');
}
$code = Artisan::call('uploads:cleanup', ['--limit' => 2]);
expect($code)->toBe(0);
expect(Artisan::output())->toContain('Uploads cleanup deleted 2 draft(s).');
expect(DB::table('uploads')->count())->toBe(3);
});

View File

@@ -0,0 +1,30 @@
<?php
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class);
it('injects uploads v2 flag as false when disabled', function () {
config(['features.uploads_v2' => false]);
$user = User::factory()->create();
$response = $this->actingAs($user)->get('/upload');
$response->assertOk();
$response->assertSee('window.SKINBASE_FLAGS', false);
$response->assertSee('uploads_v2: false', false);
});
it('injects uploads v2 flag as true when enabled', function () {
config(['features.uploads_v2' => true]);
$user = User::factory()->create();
$response = $this->actingAs($user)->get('/upload');
$response->assertOk();
$response->assertSee('window.SKINBASE_FLAGS', false);
$response->assertSee('uploads_v2: true', false);
});

View File

@@ -0,0 +1,191 @@
<?php
use App\Models\User;
use App\Uploads\Jobs\VirusScanJob;
use Illuminate\Support\Facades\Bus;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\Storage;
uses(RefreshDatabase::class);
function makeValidArchiveUpload(): UploadedFile
{
$path = tempnam(sys_get_temp_dir(), 'sb_preload_zip_');
if ($path === false) {
throw new \RuntimeException('Unable to allocate temporary zip path.');
}
$zip = new \ZipArchive();
if ($zip->open($path, \ZipArchive::OVERWRITE | \ZipArchive::CREATE) !== true) {
throw new \RuntimeException('Unable to create temporary zip file.');
}
$zip->addFromString('skins/theme/readme.txt', 'safe');
$zip->addFromString('skins/theme/colors.ini', 'accent=blue');
$zip->close();
return new UploadedFile($path, 'pack.zip', 'application/zip', null, true);
}
it('authenticated user can preload image', function () {
Storage::fake('local');
$user = User::factory()->create();
$main = UploadedFile::fake()->image('main.jpg', 1200, 800);
$response = $this
->actingAs($user)
->postJson('/api/uploads/preload', [
'main' => $main,
]);
$response->assertOk()->assertJsonStructure([
'upload_id',
'status',
'expires_at',
]);
$uploadId = $response->json('upload_id');
$this->assertDatabaseHas('uploads', [
'id' => $uploadId,
'user_id' => $user->id,
'type' => 'image',
'status' => 'draft',
]);
$this->assertDatabaseHas('upload_files', [
'upload_id' => $uploadId,
'type' => 'main',
]);
Storage::disk('local')->assertExists("tmp/drafts/{$uploadId}/meta.json");
expect(Storage::disk('local')->allFiles("tmp/drafts/{$uploadId}"))->not->toBeEmpty();
});
it('authenticated user can preload archive with screenshot', function () {
Storage::fake('local');
$user = User::factory()->create();
$main = makeValidArchiveUpload();
$screenshot = UploadedFile::fake()->image('screen1.jpg', 800, 600);
$response = $this
->actingAs($user)
->postJson('/api/uploads/preload', [
'main' => $main,
'screenshots' => [$screenshot],
]);
$response->assertOk()->assertJsonStructure([
'upload_id',
'status',
'expires_at',
]);
$uploadId = $response->json('upload_id');
$this->assertDatabaseHas('uploads', [
'id' => $uploadId,
'user_id' => $user->id,
'type' => 'archive',
'status' => 'draft',
]);
$this->assertDatabaseHas('upload_files', [
'upload_id' => $uploadId,
'type' => 'main',
]);
$this->assertDatabaseHas('upload_files', [
'upload_id' => $uploadId,
'type' => 'screenshot',
]);
Storage::disk('local')->assertExists("tmp/drafts/{$uploadId}/meta.json");
expect(Storage::disk('local')->allFiles("tmp/drafts/{$uploadId}"))->not->toBeEmpty();
});
it('guest is rejected', function () {
Storage::fake('local');
$main = UploadedFile::fake()->image('main.jpg', 1200, 800);
$response = $this->postJson('/api/uploads/preload', [
'main' => $main,
]);
expect(in_array($response->getStatusCode(), [401, 403]))->toBeTrue();
});
it('missing main file fails', function () {
Storage::fake('local');
$user = User::factory()->create();
$response = $this
->actingAs($user)
->postJson('/api/uploads/preload', []);
$response->assertStatus(422)->assertJsonValidationErrors(['main']);
});
it('archive without screenshot fails', function () {
Storage::fake('local');
$user = User::factory()->create();
$main = UploadedFile::fake()->create('pack.zip', 1024, 'application/zip');
$response = $this
->actingAs($user)
->postJson('/api/uploads/preload', [
'main' => $main,
]);
$response->assertStatus(422)->assertJsonValidationErrors(['screenshots']);
});
it('invalid file type is rejected', function () {
Storage::fake('local');
$user = User::factory()->create();
$main = UploadedFile::fake()->create('notes.txt', 4, 'text/plain');
$response = $this
->actingAs($user)
->postJson('/api/uploads/preload', [
'main' => $main,
]);
$response->assertStatus(422)->assertJsonValidationErrors(['main']);
});
it('preload dispatches VirusScanJob', function () {
Storage::fake('local');
Bus::fake();
$user = User::factory()->create();
$main = UploadedFile::fake()->image('main.jpg', 1200, 800);
$response = $this
->actingAs($user)
->postJson('/api/uploads/preload', [
'main' => $main,
]);
$response->assertOk()->assertJsonStructure([
'upload_id',
'status',
'expires_at',
]);
$uploadId = $response->json('upload_id');
Bus::assertDispatched(VirusScanJob::class, function (VirusScanJob $job) use ($uploadId) {
$reflect = new \ReflectionClass($job);
$property = $reflect->getProperty('uploadId');
$property->setAccessible(true);
return $property->getValue($job) === $uploadId;
});
});

View File

@@ -0,0 +1,108 @@
<?php
use App\Models\User;
use App\Services\Upload\PreviewService;
use App\Services\Upload\TagAnalysisService;
use App\Services\Uploads\UploadScanService;
use App\Uploads\Jobs\PreviewGenerationJob;
use App\Uploads\Jobs\TagAnalysisJob;
use App\Uploads\Jobs\VirusScanJob;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Bus;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;
uses(RefreshDatabase::class);
function createUploadForLifecycle(int $userId, array $overrides = []): string
{
$id = (string) Str::uuid();
$defaults = [
'id' => $id,
'user_id' => $userId,
'type' => 'archive',
'status' => 'draft',
'processing_state' => 'pending_scan',
'is_scanned' => false,
'has_tags' => false,
'created_at' => now(),
'updated_at' => now(),
];
DB::table('uploads')->insert(array_merge($defaults, $overrides));
return $id;
}
it('moves through explicit processing state lifecycle', function () {
Storage::fake('local');
Bus::fake();
$user = User::factory()->create();
$uploadId = createUploadForLifecycle($user->id);
$mainPath = "tmp/drafts/{$uploadId}/main/archive.zip";
Storage::disk('local')->put($mainPath, 'archive-bytes');
DB::table('upload_files')->insert([
'upload_id' => $uploadId,
'path' => $mainPath,
'type' => 'main',
'hash' => 'aa11bb22cc33dd44',
'size' => 100,
'mime' => 'application/zip',
'created_at' => now(),
]);
(new VirusScanJob($uploadId))->handle(app(UploadScanService::class));
expect(DB::table('uploads')->where('id', $uploadId)->value('processing_state'))->toBe('generating_preview');
(new PreviewGenerationJob($uploadId))->handle(app(PreviewService::class));
expect(DB::table('uploads')->where('id', $uploadId)->value('processing_state'))->toBe('analyzing_tags');
(new TagAnalysisJob($uploadId))->handle(app(TagAnalysisService::class));
expect(DB::table('uploads')->where('id', $uploadId)->value('processing_state'))->toBe('ready');
expect((bool) DB::table('uploads')->where('id', $uploadId)->value('has_tags'))->toBeTrue();
});
it('does not regress processing state when jobs rerun', function () {
Storage::fake('local');
Bus::fake();
$user = User::factory()->create();
$uploadId = (string) Str::uuid();
DB::table('uploads')->insert([
'id' => $uploadId,
'user_id' => $user->id,
'type' => 'archive',
'status' => 'draft',
'processing_state' => 'ready',
'is_scanned' => true,
'has_tags' => true,
'preview_path' => "tmp/drafts/{$uploadId}/preview.webp",
'created_at' => now(),
'updated_at' => now(),
]);
$mainPath = "tmp/drafts/{$uploadId}/main/archive.zip";
Storage::disk('local')->put($mainPath, 'archive-bytes');
DB::table('upload_files')->insert([
'upload_id' => $uploadId,
'path' => $mainPath,
'type' => 'main',
'hash' => 'ee11bb22cc33dd44',
'size' => 100,
'mime' => 'application/zip',
'created_at' => now(),
]);
(new VirusScanJob($uploadId))->handle(app(UploadScanService::class));
(new PreviewGenerationJob($uploadId))->handle(app(PreviewService::class));
(new TagAnalysisJob($uploadId))->handle(app(TagAnalysisService::class));
expect(DB::table('uploads')->where('id', $uploadId)->value('processing_state'))->toBe('ready');
});

View File

@@ -0,0 +1,143 @@
<?php
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;
uses(RefreshDatabase::class);
function createCategoryForPublishEndpointTests(): int
{
$contentTypeId = DB::table('content_types')->insertGetId([
'name' => 'Photography',
'slug' => 'photography-' . Str::lower(Str::random(6)),
'description' => null,
'created_at' => now(),
'updated_at' => now(),
]);
return DB::table('categories')->insertGetId([
'content_type_id' => $contentTypeId,
'parent_id' => null,
'name' => 'Street',
'slug' => 'street-' . Str::lower(Str::random(6)),
'description' => null,
'image' => null,
'is_active' => true,
'sort_order' => 0,
'created_at' => now(),
'updated_at' => now(),
]);
}
function createReadyDraftForPublishEndpoint(int $ownerId, int $categoryId, string $status = 'draft'): array
{
$uploadId = (string) Str::uuid();
$hash = 'aabbccddeeff00112233';
DB::table('uploads')->insert([
'id' => $uploadId,
'user_id' => $ownerId,
'type' => 'image',
'status' => $status,
'moderation_status' => 'approved',
'title' => 'Publish Endpoint Test',
'category_id' => $categoryId,
'is_scanned' => true,
'has_tags' => true,
'preview_path' => "tmp/drafts/{$uploadId}/preview.webp",
'created_at' => now(),
'updated_at' => now(),
]);
Storage::disk('local')->put("tmp/drafts/{$uploadId}/main/main.jpg", 'jpg');
Storage::disk('local')->put("tmp/drafts/{$uploadId}/preview.webp", 'preview');
DB::table('upload_files')->insert([
'upload_id' => $uploadId,
'path' => "tmp/drafts/{$uploadId}/main/main.jpg",
'type' => 'main',
'hash' => $hash,
'size' => 3,
'mime' => 'image/jpeg',
'created_at' => now(),
]);
return [$uploadId, $hash];
}
it('owner can publish valid draft', function () {
Storage::fake('local');
$owner = User::factory()->create();
$categoryId = createCategoryForPublishEndpointTests();
[$uploadId, $hash] = createReadyDraftForPublishEndpoint($owner->id, $categoryId);
$response = $this->actingAs($owner)->postJson("/api/uploads/{$uploadId}/publish");
$response->assertOk()->assertJsonStructure([
'success',
'upload_id',
'status',
'published_at',
'final_path',
])->assertJson([
'success' => true,
'upload_id' => $uploadId,
'status' => 'published',
'final_path' => 'files/artworks/aa/bb/' . $hash,
]);
});
it('guest denied', function () {
Storage::fake('local');
$owner = User::factory()->create();
$categoryId = createCategoryForPublishEndpointTests();
[$uploadId] = createReadyDraftForPublishEndpoint($owner->id, $categoryId);
$response = $this->postJson("/api/uploads/{$uploadId}/publish");
expect(in_array($response->getStatusCode(), [401, 403]))->toBeTrue();
});
it('other user denied', function () {
Storage::fake('local');
$owner = User::factory()->create();
$other = User::factory()->create();
$categoryId = createCategoryForPublishEndpointTests();
[$uploadId] = createReadyDraftForPublishEndpoint($owner->id, $categoryId);
$response = $this->actingAs($other)->postJson("/api/uploads/{$uploadId}/publish");
$response->assertStatus(403);
});
it('incomplete draft rejected', function () {
Storage::fake('local');
$owner = User::factory()->create();
$categoryId = createCategoryForPublishEndpointTests();
[$uploadId] = createReadyDraftForPublishEndpoint($owner->id, $categoryId);
DB::table('uploads')->where('id', $uploadId)->update(['title' => null]);
$response = $this->actingAs($owner)->postJson("/api/uploads/{$uploadId}/publish");
$response->assertStatus(422);
});
it('already published rejected', function () {
Storage::fake('local');
$owner = User::factory()->create();
$categoryId = createCategoryForPublishEndpointTests();
[$uploadId] = createReadyDraftForPublishEndpoint($owner->id, $categoryId, 'published');
$response = $this->actingAs($owner)->postJson("/api/uploads/{$uploadId}/publish");
$response->assertStatus(422);
});

View File

@@ -0,0 +1,264 @@
<?php
use App\Models\Upload;
use App\Models\User;
use App\Uploads\Services\PublishService;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;
uses(RefreshDatabase::class);
function createCategoryForUploadPublishFeatureTests(): int
{
$contentTypeId = DB::table('content_types')->insertGetId([
'name' => 'Photography',
'slug' => 'photography-' . Str::lower(Str::random(6)),
'description' => null,
'created_at' => now(),
'updated_at' => now(),
]);
return DB::table('categories')->insertGetId([
'content_type_id' => $contentTypeId,
'parent_id' => null,
'name' => 'Urban',
'slug' => 'urban-' . Str::lower(Str::random(6)),
'description' => null,
'image' => null,
'is_active' => true,
'sort_order' => 0,
'created_at' => now(),
'updated_at' => now(),
]);
}
it('publishes upload and moves draft files', function () {
Storage::fake('local');
$user = User::factory()->create();
$categoryId = createCategoryForUploadPublishFeatureTests();
$uploadId = (string) Str::uuid();
$hash = 'aabbccddeeff00112233';
DB::table('uploads')->insert([
'id' => $uploadId,
'user_id' => $user->id,
'type' => 'image',
'status' => 'draft',
'moderation_status' => 'approved',
'title' => 'Night City',
'category_id' => $categoryId,
'is_scanned' => true,
'has_tags' => true,
'preview_path' => "tmp/drafts/{$uploadId}/preview.webp",
'created_at' => now(),
'updated_at' => now(),
]);
Storage::disk('local')->put("tmp/drafts/{$uploadId}/main/night-city.jpg", 'jpg-binary');
Storage::disk('local')->put("tmp/drafts/{$uploadId}/preview.webp", 'preview');
Storage::disk('local')->put("tmp/drafts/{$uploadId}/thumb.webp", 'thumb');
DB::table('upload_files')->insert([
[
'upload_id' => $uploadId,
'path' => "tmp/drafts/{$uploadId}/main/night-city.jpg",
'type' => 'main',
'hash' => $hash,
'size' => 10,
'mime' => 'image/jpeg',
'created_at' => now(),
],
[
'upload_id' => $uploadId,
'path' => "tmp/drafts/{$uploadId}/preview.webp",
'type' => 'preview',
'hash' => null,
'size' => 7,
'mime' => 'image/webp',
'created_at' => now(),
],
]);
$published = app(PublishService::class)->publish($uploadId, $user);
expect($published)->toBeInstanceOf(Upload::class);
expect($published->status)->toBe('published');
expect($published->published_at)->not->toBeNull();
expect($published->final_path)->toBe('files/artworks/aa/bb/' . $hash);
Storage::disk('local')->assertMissing("tmp/drafts/{$uploadId}/main/night-city.jpg");
Storage::disk('local')->assertExists('files/artworks/aa/bb/' . $hash . '/main/night-city.jpg');
$updatedMain = DB::table('upload_files')
->where('upload_id', $uploadId)
->where('type', 'main')
->value('path');
expect($updatedMain)->toBe('files/artworks/aa/bb/' . $hash . '/main/night-city.jpg');
});
it('does not delete temp files on publish failure', function () {
Storage::fake('local');
$user = User::factory()->create();
$categoryId = createCategoryForUploadPublishFeatureTests();
$uploadId = (string) Str::uuid();
DB::table('uploads')->insert([
'id' => $uploadId,
'user_id' => $user->id,
'type' => 'image',
'status' => 'draft',
'moderation_status' => 'approved',
'title' => 'Will Fail',
'category_id' => $categoryId,
'is_scanned' => true,
'has_tags' => true,
'preview_path' => "tmp/drafts/{$uploadId}/preview.webp",
'created_at' => now(),
'updated_at' => now(),
]);
Storage::disk('local')->put("tmp/drafts/{$uploadId}/main/file.jpg", 'jpg-binary');
Storage::disk('local')->put("tmp/drafts/{$uploadId}/preview.webp", 'preview');
// missing hash should trigger failure and preserve temp files
DB::table('upload_files')->insert([
'upload_id' => $uploadId,
'path' => "tmp/drafts/{$uploadId}/main/file.jpg",
'type' => 'main',
'hash' => null,
'size' => 10,
'mime' => 'image/jpeg',
'created_at' => now(),
]);
expect(fn () => app(PublishService::class)->publish($uploadId, $user))
->toThrow(RuntimeException::class);
Storage::disk('local')->assertExists("tmp/drafts/{$uploadId}/main/file.jpg");
$status = DB::table('uploads')->where('id', $uploadId)->value('status');
expect($status)->toBe('draft');
});
it('publish persists generated slug when missing', function () {
Storage::fake('local');
$user = User::factory()->create();
$categoryId = createCategoryForUploadPublishFeatureTests();
$uploadId = (string) Str::uuid();
$hash = '0011aabbccddeeff2233';
DB::table('uploads')->insert([
'id' => $uploadId,
'user_id' => $user->id,
'type' => 'image',
'status' => 'draft',
'moderation_status' => 'approved',
'processing_state' => 'ready',
'title' => 'My Amazing Artwork',
'slug' => null,
'category_id' => $categoryId,
'is_scanned' => true,
'has_tags' => true,
'preview_path' => "tmp/drafts/{$uploadId}/preview.webp",
'created_at' => now(),
'updated_at' => now(),
]);
Storage::disk('local')->put("tmp/drafts/{$uploadId}/main/file.jpg", 'jpg-binary');
Storage::disk('local')->put("tmp/drafts/{$uploadId}/preview.webp", 'preview');
DB::table('upload_files')->insert([
'upload_id' => $uploadId,
'path' => "tmp/drafts/{$uploadId}/main/file.jpg",
'type' => 'main',
'hash' => $hash,
'size' => 10,
'mime' => 'image/jpeg',
'created_at' => now(),
]);
app(PublishService::class)->publish($uploadId, $user);
expect(DB::table('uploads')->where('id', $uploadId)->value('slug'))->toBe('my-amazing-artwork');
});
it('publish slug uniqueness appends numeric suffix for published uploads', function () {
Storage::fake('local');
$user = User::factory()->create();
$categoryId = createCategoryForUploadPublishFeatureTests();
$firstUploadId = (string) Str::uuid();
$secondUploadId = (string) Str::uuid();
DB::table('uploads')->insert([
[
'id' => $firstUploadId,
'user_id' => $user->id,
'type' => 'image',
'status' => 'draft',
'moderation_status' => 'approved',
'processing_state' => 'ready',
'title' => 'Duplicate Title',
'slug' => null,
'category_id' => $categoryId,
'is_scanned' => true,
'has_tags' => true,
'preview_path' => "tmp/drafts/{$firstUploadId}/preview.webp",
'created_at' => now(),
'updated_at' => now(),
],
[
'id' => $secondUploadId,
'user_id' => $user->id,
'type' => 'image',
'status' => 'draft',
'moderation_status' => 'approved',
'processing_state' => 'ready',
'title' => 'Duplicate Title',
'slug' => null,
'category_id' => $categoryId,
'is_scanned' => true,
'has_tags' => true,
'preview_path' => "tmp/drafts/{$secondUploadId}/preview.webp",
'created_at' => now(),
'updated_at' => now(),
],
]);
Storage::disk('local')->put("tmp/drafts/{$firstUploadId}/main/file.jpg", 'first');
Storage::disk('local')->put("tmp/drafts/{$firstUploadId}/preview.webp", 'preview');
Storage::disk('local')->put("tmp/drafts/{$secondUploadId}/main/file.jpg", 'second');
Storage::disk('local')->put("tmp/drafts/{$secondUploadId}/preview.webp", 'preview');
DB::table('upload_files')->insert([
[
'upload_id' => $firstUploadId,
'path' => "tmp/drafts/{$firstUploadId}/main/file.jpg",
'type' => 'main',
'hash' => 'aa11bb22cc33dd44',
'size' => 10,
'mime' => 'image/jpeg',
'created_at' => now(),
],
[
'upload_id' => $secondUploadId,
'path' => "tmp/drafts/{$secondUploadId}/main/file.jpg",
'type' => 'main',
'hash' => 'ee11ff22cc33dd44',
'size' => 10,
'mime' => 'image/jpeg',
'created_at' => now(),
],
]);
app(PublishService::class)->publish($firstUploadId, $user);
app(PublishService::class)->publish($secondUploadId, $user);
expect(DB::table('uploads')->where('id', $firstUploadId)->value('slug'))->toBe('duplicate-title');
expect(DB::table('uploads')->where('id', $secondUploadId)->value('slug'))->toBe('duplicate-title-2');
});

View File

@@ -0,0 +1,196 @@
<?php
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;
uses(RefreshDatabase::class);
function createUploadRowForQuota(int $userId, array $overrides = []): string
{
$id = (string) Str::uuid();
$defaults = [
'id' => $id,
'user_id' => $userId,
'type' => 'image',
'status' => 'draft',
'title' => null,
'slug' => null,
'category_id' => null,
'description' => null,
'tags' => null,
'license' => null,
'nsfw' => false,
'is_scanned' => false,
'has_tags' => false,
'preview_path' => null,
'published_at' => null,
'final_path' => null,
'expires_at' => null,
'created_at' => now(),
'updated_at' => now(),
];
DB::table('uploads')->insert(array_merge($defaults, $overrides));
return $id;
}
function attachMainUploadFileForQuota(string $uploadId, int $size, string $hash = 'hash-main'): void
{
DB::table('upload_files')->insert([
'upload_id' => $uploadId,
'path' => "tmp/drafts/{$uploadId}/main/file.bin",
'type' => 'main',
'hash' => $hash,
'size' => $size,
'mime' => 'application/octet-stream',
'created_at' => now(),
]);
}
it('enforces draft count limit', function () {
Storage::fake('local');
config(['uploads.draft_quota.max_drafts_per_user' => 1]);
$user = User::factory()->create();
createUploadRowForQuota($user->id, ['status' => 'draft']);
$main = UploadedFile::fake()->image('wallpaper.jpg', 600, 400);
$response = $this->actingAs($user)->postJson('/api/uploads/preload', [
'main' => $main,
]);
$response->assertStatus(429)
->assertJsonPath('message', 'draft_limit')
->assertJsonPath('code', 'draft_limit');
});
it('enforces draft storage limit', function () {
Storage::fake('local');
config([
'uploads.draft_quota.max_drafts_per_user' => 20,
'uploads.draft_quota.max_draft_storage_mb_per_user' => 1,
]);
$user = User::factory()->create();
$existingDraftId = createUploadRowForQuota($user->id, ['status' => 'draft']);
attachMainUploadFileForQuota($existingDraftId, 400 * 1024, 'existing-hash');
$main = UploadedFile::fake()->create('large.jpg', 700, 'image/jpeg');
$response = $this->actingAs($user)->postJson('/api/uploads/preload', [
'main' => $main,
]);
$response->assertStatus(413)
->assertJsonPath('message', 'storage_limit')
->assertJsonPath('code', 'storage_limit');
});
it('blocks duplicate hash when policy is block', function () {
Storage::fake('local');
config([
'uploads.draft_quota.max_drafts_per_user' => 20,
'uploads.draft_quota.duplicate_hash_policy' => 'block',
]);
$owner = User::factory()->create();
$uploader = User::factory()->create();
$main = UploadedFile::fake()->image('dupe.jpg', 400, 400);
$hash = hash_file('sha256', $main->getPathname());
$publishedUploadId = createUploadRowForQuota($owner->id, [
'status' => 'published',
'published_at' => now()->subMinute(),
]);
attachMainUploadFileForQuota($publishedUploadId, (int) $main->getSize(), $hash);
$response = $this->actingAs($uploader)->postJson('/api/uploads/preload', [
'main' => $main,
]);
$response->assertStatus(422)
->assertJsonPath('message', 'duplicate_upload')
->assertJsonPath('code', 'duplicate_upload');
});
it('allows duplicate hash and returns warning when policy is warn', function () {
Storage::fake('local');
config([
'uploads.draft_quota.max_drafts_per_user' => 20,
'uploads.draft_quota.duplicate_hash_policy' => 'warn',
]);
$owner = User::factory()->create();
$uploader = User::factory()->create();
$main = UploadedFile::fake()->image('dupe-warn.jpg', 400, 400);
$hash = hash_file('sha256', $main->getPathname());
$publishedUploadId = createUploadRowForQuota($owner->id, [
'status' => 'published',
'published_at' => now()->subMinute(),
]);
attachMainUploadFileForQuota($publishedUploadId, (int) $main->getSize(), $hash);
$response = $this->actingAs($uploader)->postJson('/api/uploads/preload', [
'main' => $main,
]);
$response->assertOk()
->assertJsonStructure(['upload_id', 'status', 'expires_at', 'warnings'])
->assertJsonPath('warnings.0', 'duplicate_hash');
});
it('does not count published uploads as drafts', function () {
Storage::fake('local');
config(['uploads.draft_quota.max_drafts_per_user' => 1]);
$user = User::factory()->create();
createUploadRowForQuota($user->id, [
'status' => 'published',
'published_at' => now()->subHour(),
]);
$main = UploadedFile::fake()->image('new.jpg', 640, 480);
$response = $this->actingAs($user)->postJson('/api/uploads/preload', [
'main' => $main,
]);
$response->assertOk()->assertJsonStructure([
'upload_id',
'status',
'expires_at',
]);
});
it('returns stable machine codes for quota errors', function () {
Storage::fake('local');
config(['uploads.draft_quota.max_drafts_per_user' => 1]);
$user = User::factory()->create();
createUploadRowForQuota($user->id, ['status' => 'draft']);
$main = UploadedFile::fake()->image('machine-code.jpg', 600, 400);
$response = $this->actingAs($user)->postJson('/api/uploads/preload', [
'main' => $main,
]);
$response->assertStatus(429)
->assertJson([
'message' => 'draft_limit',
'code' => 'draft_limit',
]);
});

View File

@@ -0,0 +1,129 @@
<?php
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;
uses(RefreshDatabase::class);
function createUploadForStatusTests(int $userId, array $overrides = []): string
{
$id = (string) Str::uuid();
$defaults = [
'id' => $id,
'user_id' => $userId,
'type' => 'image',
'status' => 'draft',
'title' => null,
'slug' => null,
'category_id' => null,
'description' => null,
'tags' => null,
'license' => null,
'nsfw' => false,
'is_scanned' => false,
'has_tags' => false,
'preview_path' => null,
'published_at' => null,
'final_path' => null,
'expires_at' => null,
'created_at' => now(),
'updated_at' => now(),
];
DB::table('uploads')->insert(array_merge($defaults, $overrides));
return $id;
}
it('owner sees processing status payload', function () {
$owner = User::factory()->create();
$uploadId = createUploadForStatusTests($owner->id, [
'status' => 'draft',
'processing_state' => 'analyzing_tags',
'is_scanned' => true,
'preview_path' => 'tmp/drafts/preview.webp',
'has_tags' => false,
]);
$response = $this->actingAs($owner)->getJson("/api/uploads/{$uploadId}/status");
$response->assertOk()->assertJson([
'id' => $uploadId,
'status' => 'draft',
'is_scanned' => true,
'preview_ready' => true,
'has_tags' => false,
'processing_state' => 'analyzing_tags',
]);
});
it('other user is denied', function () {
$owner = User::factory()->create();
$other = User::factory()->create();
$uploadId = createUploadForStatusTests($owner->id);
$response = $this->actingAs($other)->getJson("/api/uploads/{$uploadId}/status");
$response->assertStatus(403);
});
it('returns explicit processing states', function (array $input, string $expectedState) {
$owner = User::factory()->create();
$uploadId = createUploadForStatusTests($owner->id, $input);
$response = $this->actingAs($owner)->getJson("/api/uploads/{$uploadId}/status");
$response->assertOk()->assertJsonPath('processing_state', $expectedState);
})
->with([
'pending scan' => [[
'status' => 'draft',
'processing_state' => 'pending_scan',
], 'pending_scan'],
'scanning status' => [[
'status' => 'scanning',
'processing_state' => 'scanning',
], 'scanning'],
'generating preview' => [[
'status' => 'draft',
'processing_state' => 'generating_preview',
], 'generating_preview'],
'analyzing tags' => [[
'status' => 'draft',
'processing_state' => 'analyzing_tags',
], 'analyzing_tags'],
'ready' => [[
'status' => 'draft',
'processing_state' => 'ready',
], 'ready'],
]);
it('returns rejected processing step when upload is rejected', function () {
$owner = User::factory()->create();
$uploadId = createUploadForStatusTests($owner->id, [
'status' => 'rejected',
'processing_state' => 'rejected',
]);
$response = $this->actingAs($owner)->getJson("/api/uploads/{$uploadId}/status");
$response->assertOk()->assertJsonPath('processing_state', 'rejected');
});
it('returns published processing step when upload is published', function () {
$owner = User::factory()->create();
$uploadId = createUploadForStatusTests($owner->id, [
'status' => 'published',
'processing_state' => 'published',
'published_at' => now()->subMinute(),
]);
$response = $this->actingAs($owner)->getJson("/api/uploads/{$uploadId}/status");
$response->assertOk()->assertJsonPath('processing_state', 'published');
});

View File

@@ -0,0 +1,60 @@
<?php
use App\Models\User;
use App\Uploads\Jobs\PreviewGenerationJob;
use App\Uploads\Jobs\VirusScanJob;
use App\Services\Uploads\UploadScanService;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Bus;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;
uses(RefreshDatabase::class);
it('dispatches PreviewGenerationJob when VirusScanJob marks upload clean', function () {
Storage::fake('local');
Bus::fake();
$user = User::factory()->create();
$uploadId = (string) Str::uuid();
DB::table('uploads')->insert([
'id' => $uploadId,
'user_id' => $user->id,
'type' => 'image',
'status' => 'draft',
'is_scanned' => false,
'created_at' => now(),
'updated_at' => now(),
]);
$mainPath = "tmp/drafts/{$uploadId}/main/main.jpg";
Storage::disk('local')->put($mainPath, 'fake-image-content');
DB::table('upload_files')->insert([
'upload_id' => $uploadId,
'path' => $mainPath,
'type' => 'main',
'hash' => null,
'size' => 18,
'mime' => 'image/jpeg',
'created_at' => now(),
]);
$job = new VirusScanJob($uploadId);
$job->handle(app(UploadScanService::class));
$this->assertDatabaseHas('uploads', [
'id' => $uploadId,
'is_scanned' => 1,
]);
Bus::assertDispatched(PreviewGenerationJob::class, function (PreviewGenerationJob $queuedJob) use ($uploadId) {
$reflect = new ReflectionClass($queuedJob);
$property = $reflect->getProperty('uploadId');
$property->setAccessible(true);
return $property->getValue($queuedJob) === $uploadId;
});
});

View File

@@ -0,0 +1,120 @@
<?php
declare(strict_types=1);
use App\Services\Recommendations\FeedOfflineEvaluationService;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\DB;
use Tests\TestCase;
uses(TestCase::class, RefreshDatabase::class);
it('evaluates objective metrics for an algo from feed_daily_metrics', function () {
$metricDate = now()->subDay()->toDateString();
DB::table('feed_daily_metrics')->insert([
'metric_date' => $metricDate,
'algo_version' => 'clip-cosine-v1',
'source' => 'personalized',
'impressions' => 100,
'clicks' => 20,
'saves' => 8,
'ctr' => 0.2,
'save_rate' => 0.4,
'dwell_0_5' => 3,
'dwell_5_30' => 7,
'dwell_30_120' => 6,
'dwell_120_plus' => 4,
'created_at' => now(),
'updated_at' => now(),
]);
$result = app(FeedOfflineEvaluationService::class)->evaluateAlgo('clip-cosine-v1', $metricDate, $metricDate);
expect((string) $result['algo_version'])->toBe('clip-cosine-v1');
expect((float) $result['ctr'])->toBe(0.2);
expect((float) $result['save_rate'])->toBe(0.4);
expect((float) $result['long_dwell_share'])->toBe(0.5);
expect((float) $result['bounce_rate'])->toBe(0.15);
expect((float) $result['objective_score'])->toBeGreaterThan(0);
});
it('compares baseline vs candidate with delta and lift', function () {
$metricDate = now()->subDay()->toDateString();
DB::table('feed_daily_metrics')->insert([
[
'metric_date' => $metricDate,
'algo_version' => 'clip-cosine-v1',
'source' => 'personalized',
'impressions' => 100,
'clicks' => 20,
'saves' => 6,
'ctr' => 0.2,
'save_rate' => 0.3,
'dwell_0_5' => 4,
'dwell_5_30' => 8,
'dwell_30_120' => 5,
'dwell_120_plus' => 3,
'created_at' => now(),
'updated_at' => now(),
],
[
'metric_date' => $metricDate,
'algo_version' => 'clip-cosine-v2',
'source' => 'personalized',
'impressions' => 100,
'clicks' => 25,
'saves' => 10,
'ctr' => 0.25,
'save_rate' => 0.4,
'dwell_0_5' => 3,
'dwell_5_30' => 8,
'dwell_30_120' => 8,
'dwell_120_plus' => 6,
'created_at' => now(),
'updated_at' => now(),
],
]);
$comparison = app(FeedOfflineEvaluationService::class)
->compareBaselineCandidate('clip-cosine-v1', 'clip-cosine-v2', $metricDate, $metricDate);
expect((float) $comparison['delta']['objective_score'])->toBeGreaterThan(0.0);
expect((float) $comparison['delta']['ctr'])->toBeGreaterThan(0.0);
expect((float) $comparison['delta']['save_rate'])->toBeGreaterThan(0.0);
});
it('treats save_rate as informational when configured', function () {
$metricDate = now()->subDay()->toDateString();
config()->set('discovery.evaluation.objective_weights', [
'ctr' => 0.45,
'save_rate' => 0.35,
'long_dwell_share' => 0.25,
'bounce_rate_penalty' => 0.15,
]);
config()->set('discovery.evaluation.save_rate_informational', true);
DB::table('feed_daily_metrics')->insert([
'metric_date' => $metricDate,
'algo_version' => 'clip-cosine-v1',
'source' => 'personalized',
'impressions' => 100,
'clicks' => 20,
'saves' => 8,
'ctr' => 0.2,
'save_rate' => 0.4,
'dwell_0_5' => 3,
'dwell_5_30' => 7,
'dwell_30_120' => 6,
'dwell_120_plus' => 4,
'created_at' => now(),
'updated_at' => now(),
]);
$result = app(FeedOfflineEvaluationService::class)->evaluateAlgo('clip-cosine-v1', $metricDate, $metricDate);
expect((float) $result['save_rate'])->toBe(0.4);
expect((float) $result['objective_score'])->toBe(0.226471);
});

View File

@@ -0,0 +1,76 @@
<?php
declare(strict_types=1);
use App\Models\Artwork;
use App\Models\User;
use App\Models\UserRecommendationCache;
use App\Services\Recommendations\PersonalizedFeedService;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\DB;
use Tests\TestCase;
uses(TestCase::class, RefreshDatabase::class);
it('regenerates recommendation cache with items and expiry', function () {
$user = User::factory()->create();
$artworkA = Artwork::factory()->create(['is_public' => true, 'is_approved' => true, 'published_at' => now()->subMinutes(2)]);
$artworkB = Artwork::factory()->create(['is_public' => true, 'is_approved' => true, 'published_at' => now()->subMinutes(1)]);
DB::table('artwork_stats')->insert([
['artwork_id' => $artworkA->id, 'views' => 120, 'downloads' => 30, 'favorites' => 2, 'rating_avg' => 0, 'rating_count' => 0],
['artwork_id' => $artworkB->id, 'views' => 100, 'downloads' => 20, 'favorites' => 1, 'rating_avg' => 0, 'rating_count' => 0],
]);
app(PersonalizedFeedService::class)->regenerateCacheForUser($user->id, (string) config('discovery.algo_version'));
$cache = UserRecommendationCache::query()
->where('user_id', $user->id)
->where('algo_version', (string) config('discovery.algo_version'))
->first();
expect($cache)->not->toBeNull();
expect($cache?->generated_at)->not->toBeNull();
expect($cache?->expires_at)->not->toBeNull();
$items = (array) ($cache?->recommendations_json['items'] ?? []);
expect(count($items))->toBeGreaterThan(0);
expect((int) ($items[0]['artwork_id'] ?? 0))->toBeGreaterThan(0);
});
it('uses rollout gate g100 to select candidate algo version', function () {
$user = User::factory()->create();
config()->set('discovery.rollout.enabled', true);
config()->set('discovery.rollout.baseline_algo_version', 'clip-cosine-v1');
config()->set('discovery.rollout.candidate_algo_version', 'clip-cosine-v2');
config()->set('discovery.rollout.active_gate', 'g100');
config()->set('discovery.rollout.gates.g100.percentage', 100);
config()->set('discovery.rollout.force_algo_version', '');
app(PersonalizedFeedService::class)->regenerateCacheForUser($user->id);
$cache = UserRecommendationCache::query()->where('user_id', $user->id)->first();
expect($cache)->not->toBeNull();
expect((string) $cache?->algo_version)->toBe('clip-cosine-v2');
});
it('forces rollback algo version when force toggle is set', function () {
$user = User::factory()->create();
config()->set('discovery.rollout.enabled', true);
config()->set('discovery.rollout.baseline_algo_version', 'clip-cosine-v1');
config()->set('discovery.rollout.candidate_algo_version', 'clip-cosine-v2');
config()->set('discovery.rollout.active_gate', 'g100');
config()->set('discovery.rollout.gates.g100.percentage', 100);
config()->set('discovery.rollout.force_algo_version', 'clip-cosine-v1');
app(PersonalizedFeedService::class)->regenerateCacheForUser($user->id);
$cache = UserRecommendationCache::query()->where('user_id', $user->id)->first();
expect($cache)->not->toBeNull();
expect((string) $cache?->algo_version)->toBe('clip-cosine-v1');
});

View File

@@ -0,0 +1,86 @@
<?php
declare(strict_types=1);
use App\Models\Artwork;
use App\Models\Category;
use App\Models\ContentType;
use App\Models\User;
use App\Services\Recommendations\UserInterestProfileService;
use Carbon\CarbonImmutable;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
uses(TestCase::class, RefreshDatabase::class);
it('applies recency decay and normalizes profile scores', function () {
config()->set('discovery.decay.half_life_hours', 72);
config()->set('discovery.weights.view', 1.0);
$service = app(UserInterestProfileService::class);
$user = User::factory()->create();
$contentType = ContentType::create([
'name' => 'Digital Art',
'slug' => 'digital-art',
'description' => 'Digital artworks',
]);
$categoryA = Category::create([
'content_type_id' => $contentType->id,
'parent_id' => null,
'name' => 'Sci-Fi',
'slug' => 'sci-fi',
'description' => 'Sci-Fi category',
'is_active' => true,
'sort_order' => 0,
]);
$categoryB = Category::create([
'content_type_id' => $contentType->id,
'parent_id' => null,
'name' => 'Fantasy',
'slug' => 'fantasy',
'description' => 'Fantasy category',
'is_active' => true,
'sort_order' => 0,
]);
$artworkA = Artwork::factory()->create();
$artworkB = Artwork::factory()->create();
$t0 = CarbonImmutable::parse('2026-02-14 00:00:00');
$service->applyEvent(
userId: $user->id,
eventType: 'view',
artworkId: $artworkA->id,
categoryId: $categoryA->id,
occurredAt: $t0,
eventId: '11111111-1111-1111-1111-111111111111',
algoVersion: 'clip-cosine-v1'
);
$service->applyEvent(
userId: $user->id,
eventType: 'view',
artworkId: $artworkB->id,
categoryId: $categoryB->id,
occurredAt: $t0->addHours(72),
eventId: '22222222-2222-2222-2222-222222222222',
algoVersion: 'clip-cosine-v1'
);
$profile = \App\Models\UserInterestProfile::query()->where('user_id', $user->id)->firstOrFail();
expect((int) $profile->event_count)->toBe(2);
$normalized = (array) $profile->normalized_scores_json;
expect($normalized)->toHaveKey('category:' . $categoryA->id);
expect($normalized)->toHaveKey('category:' . $categoryB->id);
expect((float) $normalized['category:' . $categoryA->id])->toBeGreaterThan(0.30)->toBeLessThan(0.35);
expect((float) $normalized['category:' . $categoryB->id])->toBeGreaterThan(0.65)->toBeLessThan(0.70);
});

View File

@@ -0,0 +1,168 @@
<?php
namespace Tests\Unit\Uploads;
use App\Uploads\Services\ArchiveInspectorService;
use App\Uploads\Services\InspectionResult;
use Tests\TestCase;
use ZipArchive;
class ArchiveInspectorServiceTest extends TestCase
{
/** @var array<int, string> */
private array $tempFiles = [];
protected function tearDown(): void
{
foreach ($this->tempFiles as $file) {
if (is_file($file)) {
@unlink($file);
}
}
parent::tearDown();
}
public function test_rejects_zip_slip_path(): void
{
$archive = $this->makeZip([
'../evil.txt' => 'x',
]);
$result = app(ArchiveInspectorService::class)->inspect($archive);
$this->assertFalse($result->valid);
$this->assertStringContainsString('path traversal', (string) $result->reason);
}
public function test_rejects_symlink_entries(): void
{
$archive = $this->makeZipWithCallback([
'safe/file.txt' => 'ok',
'safe/link' => 'target',
], function (ZipArchive $zip, string $entryName): void {
if ($entryName === 'safe/link') {
$zip->setExternalAttributesName($entryName, ZipArchive::OPSYS_UNIX, 0120777 << 16);
}
});
$result = app(ArchiveInspectorService::class)->inspect($archive);
$this->assertFalse($result->valid);
$this->assertStringContainsString('symlink', strtolower((string) $result->reason));
}
public function test_rejects_deep_nesting(): void
{
$archive = $this->makeZip([
'a/b/c/d/e/f/file.txt' => 'too deep',
]);
$result = app(ArchiveInspectorService::class)->inspect($archive);
$this->assertFalse($result->valid);
$this->assertStringContainsString('depth', strtolower((string) $result->reason));
}
public function test_rejects_too_many_files(): void
{
$entries = [];
for ($index = 0; $index < 5001; $index++) {
$entries['f' . $index . '.txt'] = 'x';
}
$archive = $this->makeZip($entries);
$result = app(ArchiveInspectorService::class)->inspect($archive);
$this->assertFalse($result->valid);
$this->assertStringContainsString('5000', (string) $result->reason);
}
public function test_rejects_executable_extensions(): void
{
$archive = $this->makeZip([
'skins/readme.txt' => 'ok',
'skins/run.exe' => 'MZ',
]);
$result = app(ArchiveInspectorService::class)->inspect($archive);
$this->assertFalse($result->valid);
$this->assertStringContainsString('blocked', strtolower((string) $result->reason));
}
public function test_rejects_zip_bomb_ratio(): void
{
$archive = $this->makeZip([
'payload.txt' => str_repeat('A', 6 * 1024 * 1024),
]);
$result = app(ArchiveInspectorService::class)->inspect($archive);
$this->assertFalse($result->valid);
$this->assertStringContainsString('ratio', strtolower((string) $result->reason));
}
public function test_valid_archive_passes(): void
{
$archive = $this->makeZip([
'skins/theme/readme.txt' => 'safe',
'skins/theme/colors.ini' => 'accent=blue',
]);
$result = app(ArchiveInspectorService::class)->inspect($archive);
$this->assertInstanceOf(InspectionResult::class, $result);
$this->assertTrue($result->valid);
$this->assertNull($result->reason);
$this->assertIsArray($result->stats);
$this->assertArrayHasKey('files', $result->stats);
$this->assertArrayHasKey('depth', $result->stats);
$this->assertArrayHasKey('size', $result->stats);
$this->assertArrayHasKey('ratio', $result->stats);
}
/**
* @param array<string, string> $entries
*/
private function makeZip(array $entries): string
{
return $this->makeZipWithCallback($entries, null);
}
/**
* @param array<string, string> $entries
* @param (callable(ZipArchive,string):void)|null $entryCallback
*/
private function makeZipWithCallback(array $entries, ?callable $entryCallback): string
{
if (! class_exists(ZipArchive::class)) {
$this->markTestSkipped('ZipArchive extension is required.');
}
$path = tempnam(sys_get_temp_dir(), 'sb_zip_');
if ($path === false) {
throw new \RuntimeException('Unable to create temporary zip path.');
}
$this->tempFiles[] = $path;
$zip = new ZipArchive();
if ($zip->open($path, ZipArchive::OVERWRITE | ZipArchive::CREATE) !== true) {
throw new \RuntimeException('Unable to open temporary zip for writing.');
}
foreach ($entries as $name => $content) {
$zip->addFromString($name, $content);
if ($entryCallback !== null) {
$entryCallback($zip, $name);
}
}
$zip->close();
return $path;
}
}

View File

@@ -0,0 +1,139 @@
<?php
namespace Tests\Unit\Uploads;
use App\Models\User;
use App\Uploads\Services\CleanupService;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;
use Tests\TestCase;
class CleanupServiceTest extends TestCase
{
use RefreshDatabase;
private function insertUploadRow(array $overrides = []): string
{
$id = (string) Str::uuid();
$defaults = [
'id' => $id,
'user_id' => User::factory()->create()->id,
'type' => 'image',
'status' => 'draft',
'title' => null,
'slug' => null,
'category_id' => null,
'description' => null,
'tags' => null,
'license' => null,
'nsfw' => false,
'is_scanned' => false,
'has_tags' => false,
'preview_path' => null,
'published_at' => null,
'final_path' => null,
'expires_at' => null,
'created_at' => now(),
'updated_at' => now(),
];
DB::table('uploads')->insert(array_merge($defaults, $overrides));
return $id;
}
public function test_deletes_expired_draft_uploads_and_returns_count(): void
{
Storage::fake('local');
$uploadId = $this->insertUploadRow([
'status' => 'draft',
'expires_at' => now()->subMinute(),
]);
Storage::disk('local')->put("tmp/drafts/{$uploadId}/meta.json", '{}');
$deleted = app(CleanupService::class)->cleanupStaleDrafts();
$this->assertSame(1, $deleted);
$this->assertFalse(DB::table('uploads')->where('id', $uploadId)->exists());
}
public function test_keeps_active_drafts_untouched(): void
{
Storage::fake('local');
$uploadId = $this->insertUploadRow([
'status' => 'draft',
'expires_at' => now()->addDay(),
'updated_at' => now()->subHours(2),
]);
Storage::disk('local')->put("tmp/drafts/{$uploadId}/meta.json", '{}');
$deleted = app(CleanupService::class)->cleanupStaleDrafts();
$this->assertSame(0, $deleted);
$this->assertTrue(DB::table('uploads')->where('id', $uploadId)->exists());
$this->assertTrue(Storage::disk('local')->exists("tmp/drafts/{$uploadId}/meta.json"));
}
public function test_removes_temp_folder_when_deleting_stale_drafts(): void
{
Storage::fake('local');
$uploadId = $this->insertUploadRow([
'status' => 'draft',
'updated_at' => now()->subHours(25),
]);
Storage::disk('local')->put("tmp/drafts/{$uploadId}/main/file.bin", 'x');
$this->assertTrue(Storage::disk('local')->exists("tmp/drafts/{$uploadId}/main/file.bin"));
$deleted = app(CleanupService::class)->cleanupStaleDrafts();
$this->assertSame(1, $deleted);
$this->assertFalse(Storage::disk('local')->exists("tmp/drafts/{$uploadId}/main/file.bin"));
}
public function test_enforces_hard_cleanup_limit_of_100_per_run(): void
{
Storage::fake('local');
for ($index = 0; $index < 120; $index++) {
$uploadId = $this->insertUploadRow([
'status' => 'draft',
'updated_at' => now()->subHours(30),
]);
Storage::disk('local')->put("tmp/drafts/{$uploadId}/meta.json", '{}');
}
$deleted = app(CleanupService::class)->cleanupStaleDrafts(999);
$this->assertSame(100, $deleted);
$this->assertSame(20, DB::table('uploads')->count());
}
public function test_never_deletes_published_uploads(): void
{
Storage::fake('local');
$uploadId = $this->insertUploadRow([
'status' => 'published',
'updated_at' => now()->subDays(5),
'published_at' => now()->subDays(4),
]);
Storage::disk('local')->put("tmp/drafts/{$uploadId}/meta.json", '{}');
$deleted = app(CleanupService::class)->cleanupStaleDrafts();
$this->assertSame(0, $deleted);
$this->assertTrue(DB::table('uploads')->where('id', $uploadId)->where('status', 'published')->exists());
$this->assertTrue(Storage::disk('local')->exists("tmp/drafts/{$uploadId}/meta.json"));
}
}

View File

@@ -0,0 +1,102 @@
<?php
use App\Models\User;
use App\Uploads\Services\PublishService;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;
use Tests\TestCase;
uses(TestCase::class, RefreshDatabase::class);
function createCategoryForPublishTests(): int
{
$contentTypeId = DB::table('content_types')->insertGetId([
'name' => 'Skins',
'slug' => 'skins-' . Str::lower(Str::random(6)),
'description' => null,
'created_at' => now(),
'updated_at' => now(),
]);
return DB::table('categories')->insertGetId([
'content_type_id' => $contentTypeId,
'parent_id' => null,
'name' => 'Winamp',
'slug' => 'winamp-' . Str::lower(Str::random(6)),
'description' => null,
'image' => null,
'is_active' => true,
'sort_order' => 0,
'created_at' => now(),
'updated_at' => now(),
]);
}
it('rejects publish when user is not owner', function () {
Storage::fake('local');
$owner = User::factory()->create();
$other = User::factory()->create();
$categoryId = createCategoryForPublishTests();
$uploadId = (string) Str::uuid();
DB::table('uploads')->insert([
'id' => $uploadId,
'user_id' => $owner->id,
'type' => 'image',
'status' => 'draft',
'moderation_status' => 'approved',
'title' => 'City Lights',
'category_id' => $categoryId,
'is_scanned' => true,
'has_tags' => true,
'preview_path' => "tmp/drafts/{$uploadId}/preview.webp",
'created_at' => now(),
'updated_at' => now(),
]);
$service = app(PublishService::class);
expect(fn () => $service->publish($uploadId, $other))
->toThrow(RuntimeException::class, 'You do not own this upload.');
});
it('rejects archive publish without screenshots', function () {
Storage::fake('local');
$owner = User::factory()->create();
$categoryId = createCategoryForPublishTests();
$uploadId = (string) Str::uuid();
DB::table('uploads')->insert([
'id' => $uploadId,
'user_id' => $owner->id,
'type' => 'archive',
'status' => 'draft',
'moderation_status' => 'approved',
'title' => 'Skin Pack',
'category_id' => $categoryId,
'is_scanned' => true,
'has_tags' => true,
'preview_path' => "tmp/drafts/{$uploadId}/preview.webp",
'created_at' => now(),
'updated_at' => now(),
]);
DB::table('upload_files')->insert([
'upload_id' => $uploadId,
'path' => "tmp/drafts/{$uploadId}/main/pack.zip",
'type' => 'main',
'hash' => 'aabbccddeeff0011',
'size' => 1024,
'mime' => 'application/zip',
'created_at' => now(),
]);
$service = app(PublishService::class);
expect(fn () => $service->publish($uploadId, $owner))
->toThrow(RuntimeException::class, 'Archive uploads require at least one screenshot.');
});

View File

@@ -0,0 +1,130 @@
<?php
namespace Tests\Unit\Uploads;
use Tests\TestCase;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Storage;
use Illuminate\Http\UploadedFile;
use App\Services\Upload\UploadDraftService;
use Illuminate\Contracts\Filesystem\Filesystem as FilesystemContract;
use Illuminate\Filesystem\FilesystemManager;
use Carbon\Carbon;
use App\Models\User;
class UploadDraftServiceTest extends TestCase
{
use RefreshDatabase;
protected UploadDraftService $service;
protected User $user;
protected function setUp(): void
{
parent::setUp();
// Use fake storage so we don't touch the real filesystem
Storage::fake('local');
$this->user = User::factory()->create();
// Provide a dummy clamav scanner binding so any scanning calls are mocked
$this->app->instance('clamav', new class {
public function scan(string $path): bool
{
return true;
}
});
$filesystem = $this->app->make(FilesystemManager::class);
$this->service = new UploadDraftService($filesystem, 'local');
}
public function test_createDraft_creates_directory_and_writes_meta()
{
$result = $this->service->createDraft(['title' => 'Test Draft', 'user_id' => $this->user->id, 'type' => 'image']);
$this->assertArrayHasKey('id', $result);
$id = $result['id'];
Storage::disk('local')->assertExists("tmp/drafts/{$id}");
Storage::disk('local')->assertExists("tmp/drafts/{$id}/meta.json");
$meta = json_decode(Storage::disk('local')->get("tmp/drafts/{$id}/meta.json"), true);
$this->assertSame('Test Draft', $meta['title']);
$this->assertSame($id, $meta['id']);
}
public function test_storeMainFile_saves_file_and_updates_meta()
{
$draft = $this->service->createDraft(['user_id' => $this->user->id, 'type' => 'image']);
$id = $draft['id'];
$file = UploadedFile::fake()->create('song.mp3', 1500, 'audio/mpeg');
$info = $this->service->storeMainFile($id, $file);
$this->assertArrayHasKey('path', $info);
Storage::disk('local')->assertExists($info['path']);
$meta = json_decode(Storage::disk('local')->get("tmp/drafts/{$id}/meta.json"), true);
$this->assertArrayHasKey('main_file', $meta);
$this->assertSame($info['hash'], $meta['main_file']['hash']);
}
public function test_storeScreenshot_saves_file_and_appends_meta()
{
$draft = $this->service->createDraft(['user_id' => $this->user->id, 'type' => 'image']);
$id = $draft['id'];
$img = UploadedFile::fake()->image('thumb.jpg', 640, 480);
$info = $this->service->storeScreenshot($id, $img);
$this->assertArrayHasKey('path', $info);
Storage::disk('local')->assertExists($info['path']);
$meta = json_decode(Storage::disk('local')->get("tmp/drafts/{$id}/meta.json"), true);
$this->assertArrayHasKey('screenshots', $meta);
$this->assertCount(1, $meta['screenshots']);
$this->assertSame($info['hash'], $meta['screenshots'][0]['hash']);
}
public function test_calculateHash_for_local_file_and_storage_path()
{
$file = UploadedFile::fake()->create('doc.pdf', 10);
$realPath = $file->getRealPath();
$expected = hash_file('sha256', $realPath);
$this->assertSame($expected, $this->service->calculateHash($realPath));
// Store into drafts and calculate by storage path
$draft = $this->service->createDraft(['user_id' => $this->user->id, 'type' => 'image']);
$id = $draft['id'];
$info = $this->service->storeMainFile($id, $file);
$storageHash = $this->service->calculateHash($info['path']);
$storedContents = Storage::disk('local')->get($info['path']);
$this->assertSame(hash('sha256', $storedContents), $storageHash);
}
public function test_setExpiration_writes_expires_at_in_meta()
{
$draft = $this->service->createDraft(['user_id' => $this->user->id, 'type' => 'image']);
$id = $draft['id'];
$when = Carbon::now()->addDays(3);
$ok = $this->service->setExpiration($id, $when);
$this->assertTrue($ok);
$meta = json_decode(Storage::disk('local')->get("tmp/drafts/{$id}/meta.json"), true);
$this->assertArrayHasKey('expires_at', $meta);
$this->assertSame($when->toISOString(), $meta['expires_at']);
}
public function test_calculateHash_throws_for_missing_file()
{
$this->expectException(\RuntimeException::class);
$this->service->calculateHash('this/path/does/not/exist');
}
}