Upload beautify
This commit is contained in:
104
tests/Feature/Admin/FeedPerformanceReportTest.php
Normal file
104
tests/Feature/Admin/FeedPerformanceReportTest.php
Normal 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);
|
||||
});
|
||||
99
tests/Feature/Admin/SimilarArtworkReportTest.php
Normal file
99
tests/Feature/Admin/SimilarArtworkReportTest.php
Normal 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);
|
||||
});
|
||||
168
tests/Feature/Admin/UploadModerationTest.php
Normal file
168
tests/Feature/Admin/UploadModerationTest.php
Normal 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.',
|
||||
]);
|
||||
});
|
||||
138
tests/Feature/Analytics/FeedAnalyticsTest.php
Normal file
138
tests/Feature/Analytics/FeedAnalyticsTest.php
Normal 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);
|
||||
});
|
||||
93
tests/Feature/Analytics/FeedEvaluationCommandsTest.php
Normal file
93
tests/Feature/Analytics/FeedEvaluationCommandsTest.php
Normal 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();
|
||||
});
|
||||
72
tests/Feature/Analytics/SimilarArtworkAnalyticsTest.php
Normal file
72
tests/Feature/Analytics/SimilarArtworkAnalyticsTest.php
Normal 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,
|
||||
]);
|
||||
});
|
||||
117
tests/Feature/ArtworkJsonLdTest.php
Normal file
117
tests/Feature/ArtworkJsonLdTest.php
Normal 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);
|
||||
});
|
||||
76
tests/Feature/AutoTagArtworkJobTest.php
Normal file
76
tests/Feature/AutoTagArtworkJobTest.php
Normal 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);
|
||||
});
|
||||
15
tests/Feature/AvatarUploadTest.php
Normal file
15
tests/Feature/AvatarUploadTest.php
Normal 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);
|
||||
}
|
||||
}
|
||||
56
tests/Feature/ContentRouterControllerTest.php
Normal file
56
tests/Feature/ContentRouterControllerTest.php
Normal 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');
|
||||
});
|
||||
48
tests/Feature/Discovery/DiscoveryEventIngestionTest.php
Normal file
48
tests/Feature/Discovery/DiscoveryEventIngestionTest.php
Normal 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']);
|
||||
});
|
||||
113
tests/Feature/Discovery/FeedEndpointTest.php
Normal file
113
tests/Feature/Discovery/FeedEndpointTest.php
Normal 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();
|
||||
});
|
||||
@@ -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());
|
||||
});
|
||||
|
||||
120
tests/Feature/SimilarArtworksBlockTest.php
Normal file
120
tests/Feature/SimilarArtworksBlockTest.php
Normal 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);
|
||||
});
|
||||
100
tests/Feature/TagSystemTest.php
Normal file
100
tests/Feature/TagSystemTest.php
Normal 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);
|
||||
});
|
||||
212
tests/Feature/Uploads/ArchiveUploadSecurityTest.php
Normal file
212
tests/Feature/Uploads/ArchiveUploadSecurityTest.php
Normal 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',
|
||||
]);
|
||||
});
|
||||
47
tests/Feature/Uploads/PreviewGenerationJobTest.php
Normal file
47
tests/Feature/Uploads/PreviewGenerationJobTest.php
Normal 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;
|
||||
});
|
||||
});
|
||||
165
tests/Feature/Uploads/UploadAutosaveTest.php
Normal file
165
tests/Feature/Uploads/UploadAutosaveTest.php
Normal 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']);
|
||||
});
|
||||
89
tests/Feature/Uploads/UploadCleanupCommandTest.php
Normal file
89
tests/Feature/Uploads/UploadCleanupCommandTest.php
Normal 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);
|
||||
});
|
||||
30
tests/Feature/Uploads/UploadFeatureFlagTest.php
Normal file
30
tests/Feature/Uploads/UploadFeatureFlagTest.php
Normal 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);
|
||||
});
|
||||
191
tests/Feature/Uploads/UploadPreloadTest.php
Normal file
191
tests/Feature/Uploads/UploadPreloadTest.php
Normal 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;
|
||||
});
|
||||
});
|
||||
108
tests/Feature/Uploads/UploadProcessingLifecycleTest.php
Normal file
108
tests/Feature/Uploads/UploadProcessingLifecycleTest.php
Normal 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');
|
||||
});
|
||||
143
tests/Feature/Uploads/UploadPublishEndpointTest.php
Normal file
143
tests/Feature/Uploads/UploadPublishEndpointTest.php
Normal 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);
|
||||
});
|
||||
264
tests/Feature/Uploads/UploadPublishTest.php
Normal file
264
tests/Feature/Uploads/UploadPublishTest.php
Normal 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');
|
||||
});
|
||||
196
tests/Feature/Uploads/UploadQuotaTest.php
Normal file
196
tests/Feature/Uploads/UploadQuotaTest.php
Normal 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',
|
||||
]);
|
||||
});
|
||||
129
tests/Feature/Uploads/UploadStatusTest.php
Normal file
129
tests/Feature/Uploads/UploadStatusTest.php
Normal 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');
|
||||
});
|
||||
60
tests/Feature/Uploads/VirusScanJobTest.php
Normal file
60
tests/Feature/Uploads/VirusScanJobTest.php
Normal 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;
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user