Implement creator studio and upload updates

This commit is contained in:
2026-04-04 10:12:02 +02:00
parent 1da7d3bf88
commit 0b216b7ecd
15107 changed files with 31206 additions and 626514 deletions

View File

@@ -0,0 +1,152 @@
<?php
declare(strict_types=1);
use App\Data\Images\CropBoxData;
use App\Services\Images\Detectors\HeuristicSubjectDetector;
use App\Services\Images\Detectors\NullSubjectDetector;
use App\Services\Images\SquareThumbnailService;
use Illuminate\Support\Facades\File;
use Illuminate\Support\Str;
use Tests\TestCase;
uses(TestCase::class);
beforeEach(function () {
$this->imageTestRoot = storage_path('framework/testing/square-thumbnails-' . Str::lower(Str::random(10)));
File::deleteDirectory($this->imageTestRoot);
File::ensureDirectoryExists($this->imageTestRoot);
});
afterEach(function () {
File::deleteDirectory($this->imageTestRoot);
});
function squareThumbCreateImage(string $root, int $width, int $height, ?callable $drawer = null): string
{
$path = $root . DIRECTORY_SEPARATOR . Str::uuid()->toString() . '.png';
$image = imagecreatetruecolor($width, $height);
$background = imagecolorallocate($image, 22, 28, 36);
imagefilledrectangle($image, 0, 0, $width, $height, $background);
if ($drawer !== null) {
$drawer($image, $width, $height);
}
imagepng($image, $path);
imagedestroy($image);
return $path;
}
it('calculates a padded square crop and clamps it to image bounds', function () {
$service = new SquareThumbnailService(new NullSubjectDetector());
$crop = $service->calculateCropBox(1200, 800, new CropBoxData(10, 40, 260, 180), [
'padding_ratio' => 0.2,
]);
expect($crop->width)->toBe($crop->height)
->and($crop->x)->toBe(0)
->and($crop->y)->toBeGreaterThanOrEqual(0)
->and($crop->x + $crop->width)->toBeLessThanOrEqual(1200)
->and($crop->y + $crop->height)->toBeLessThanOrEqual(800);
});
it('falls back to a centered square crop when no focus box exists', function () {
$service = new SquareThumbnailService(new NullSubjectDetector());
$crop = $service->calculateCropBox(900, 500, null);
expect($crop->width)->toBe(500)
->and($crop->height)->toBe(500)
->and($crop->x)->toBe(200)
->and($crop->y)->toBe(0);
});
it('generates a valid square thumbnail when smart detection is unavailable', function () {
$source = squareThumbCreateImage($this->imageTestRoot, 640, 320, function ($image): void {
$accent = imagecolorallocate($image, 240, 200, 80);
imagefilledellipse($image, 320, 160, 180, 180, $accent);
});
$destination = $this->imageTestRoot . DIRECTORY_SEPARATOR . 'output.webp';
$service = new SquareThumbnailService(new NullSubjectDetector());
$result = $service->generateFromPath($source, $destination, ['target_size' => 256]);
$size = getimagesize($destination);
expect(File::exists($destination))->toBeTrue()
->and($result->cropMode)->toBe('center')
->and($size[0] ?? null)->toBe(256)
->and($size[1] ?? null)->toBe(256);
});
it('does not upscale tiny images when allow_upscale is disabled', function () {
$source = squareThumbCreateImage($this->imageTestRoot, 60, 40, function ($image): void {
$accent = imagecolorallocate($image, 220, 120, 120);
imagefilledrectangle($image, 4, 4, 36, 32, $accent);
});
$destination = $this->imageTestRoot . DIRECTORY_SEPARATOR . 'tiny.webp';
$service = new SquareThumbnailService(new NullSubjectDetector());
$result = $service->generateFromPath($source, $destination, [
'target_size' => 256,
'allow_upscale' => false,
]);
$size = getimagesize($destination);
expect($result->outputWidth)->toBe(40)
->and($result->outputHeight)->toBe(40)
->and($size[0] ?? null)->toBe(40)
->and($size[1] ?? null)->toBe(40);
});
it('can derive a saliency crop near the border', function () {
$source = squareThumbCreateImage($this->imageTestRoot, 900, 600, function ($image): void {
$subject = imagecolorallocate($image, 250, 240, 240);
imagefilledellipse($image, 120, 240, 180, 220, $subject);
});
$detector = new HeuristicSubjectDetector();
$size = getimagesize($source);
$result = $detector->detect($source, (int) $size[0], (int) $size[1]);
expect($result)->not->toBeNull()
->and($result?->strategy)->toBe('saliency')
->and($result?->cropBox->x)->toBeLessThan(220);
});
it('prefers a distinct subject over textured foliage', function () {
$source = squareThumbCreateImage($this->imageTestRoot, 1200, 700, function ($image): void {
$sky = imagecolorallocate($image, 165, 205, 255);
$leaf = imagecolorallocate($image, 42, 118, 34);
$leafDark = imagecolorallocate($image, 28, 88, 24);
$branch = imagecolorallocate($image, 96, 72, 52);
$fur = imagecolorallocate($image, 242, 236, 228);
$ginger = imagecolorallocate($image, 214, 152, 102);
$mouth = imagecolorallocate($image, 36, 20, 18);
imagefilledrectangle($image, 0, 0, 1200, 700, $sky);
for ($i = 0; $i < 28; $i++) {
imageline($image, rand(0, 520), rand(0, 340), rand(80, 620), rand(80, 420), $branch);
imagefilledellipse($image, rand(40, 560), rand(80, 620), rand(60, 120), rand(20, 60), $leaf);
imagefilledellipse($image, rand(40, 560), rand(80, 620), rand(40, 90), rand(16, 44), $leafDark);
}
imagefilledellipse($image, 890, 300, 420, 500, $fur);
imagefilledpolygon($image, [760, 130, 840, 10, 865, 165], 3, $ginger);
imagefilledpolygon($image, [930, 165, 1000, 10, 1070, 130], 3, $ginger);
imagefilledellipse($image, 885, 355, 150, 220, $mouth);
imagefilledellipse($image, 890, 520, 180, 130, $fur);
});
$detector = new HeuristicSubjectDetector();
$size = getimagesize($source);
$result = $detector->detect($source, (int) $size[0], (int) $size[1]);
expect($result)->not->toBeNull()
->and($result?->strategy)->toBe('saliency')
->and($result?->cropBox->x)->toBeGreaterThan(180);
});

View File

@@ -0,0 +1,208 @@
<?php
declare(strict_types=1);
use App\Enums\ModerationDomainStatus;
use App\Enums\ModerationRuleType;
use App\Enums\ModerationContentType;
use App\Enums\ModerationSeverity;
use App\Enums\ModerationStatus;
use App\Models\ContentModerationDomain;
use App\Models\ContentModerationFinding;
use App\Models\ContentModerationRule;
use App\Models\User;
use App\Services\Moderation\ContentModerationService;
use App\Services\Moderation\ContentModerationSourceService;
use App\Services\Moderation\Rules\LinkPresenceRule;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
uses(TestCase::class, RefreshDatabase::class);
it('extracts explicit and www urls from content', function (): void {
$rule = app(LinkPresenceRule::class);
$urls = $rule->extractUrls('Visit https://spam.example.com and www.short.test/offer now.');
expect($urls)->toContain('https://spam.example.com')
->and($urls)->toContain('www.short.test/offer');
});
it('detects suspicious keywords and domains with a queued status', function (): void {
$result = app(ContentModerationService::class)->analyze(
'Buy followers today at https://promo.pornsite.com right now',
['content_type' => 'artwork_comment', 'content_id' => 1]
);
expect($result->score)->toBeGreaterThanOrEqual(110)
->and(in_array($result->severity, [ModerationSeverity::High, ModerationSeverity::Critical], true))->toBeTrue()
->and($result->matchedDomains)->toContain('promo.pornsite.com')
->and($result->matchedKeywords)->toContain('buy followers');
});
it('detects unicode obfuscation patterns', function (): void {
$result = app(ContentModerationService::class)->analyze(
'раypal giveaway click here',
['content_type' => 'artwork_comment', 'content_id' => 1]
);
expect($result->score)->toBeGreaterThan(0)
->and(collect($result->reasons)->implode(' '))->toContain('Unicode');
});
it('produces stable hashes for semantically identical whitespace variants', function (): void {
$service = app(ContentModerationService::class);
$first = $service->analyze("Visit my site\n\nnow", ['content_type' => 'artwork_comment', 'content_id' => 1]);
$second = $service->analyze(' visit my site now ', ['content_type' => 'artwork_comment', 'content_id' => 2]);
expect($first->contentHash)->toBe($second->contentHash);
});
it('detects keyword stuffing patterns', function (): void {
$result = app(ContentModerationService::class)->analyze(
'seo seo seo seo seo seo seo seo seo seo seo seo seo service service service service service cheap cheap cheap traffic traffic traffic',
['content_type' => 'artwork_description', 'content_id' => 1]
);
expect($result->score)->toBeGreaterThan(0)
->and($result->matchedKeywords)->toContain('seo');
});
it('maps score thresholds to the expected severities', function (): void {
expect(ModerationSeverity::fromScore(0))->toBe(ModerationSeverity::Low)
->and(ModerationSeverity::fromScore(30))->toBe(ModerationSeverity::Medium)
->and(ModerationSeverity::fromScore(60))->toBe(ModerationSeverity::High)
->and(ModerationSeverity::fromScore(90))->toBe(ModerationSeverity::Critical);
});
it('applies db managed moderation rules alongside config rules', function (): void {
ContentModerationRule::query()->create([
'type' => ModerationRuleType::SuspiciousKeyword,
'value' => 'rare promo blast',
'enabled' => true,
'weight' => 22,
]);
$result = app(ContentModerationService::class)->analyze(
'This rare promo blast just dropped.',
['content_type' => 'artwork_comment', 'content_id' => 1]
);
expect($result->matchedKeywords)->toContain('rare promo blast')
->and($result->score)->toBeGreaterThan(0);
});
it('uses domain reputation to escalate blocked domains and auto hide recommendations', function (): void {
ContentModerationDomain::query()->create([
'domain' => 'campaign.spam.test',
'status' => ModerationDomainStatus::Blocked,
]);
$result = app(ContentModerationService::class)->analyze(
'Buy followers now at https://campaign.spam.test and claim your giveaway',
['content_type' => 'artwork_comment', 'content_id' => 1]
);
expect($result->matchedDomains)->toContain('campaign.spam.test')
->and($result->autoHideRecommended)->toBeTrue()
->and($result->score)->toBeGreaterThanOrEqual(95);
});
it('applies user risk modifiers conservatively', function (): void {
$user = User::factory()->create();
ContentModerationFinding::query()->create([
'content_type' => 'artwork_comment',
'content_id' => 11,
'user_id' => $user->id,
'status' => ModerationStatus::ConfirmedSpam->value,
'severity' => 'high',
'score' => 85,
'content_hash' => hash('sha256', 'a'),
'scanner_version' => '2.0',
'content_snapshot' => 'spam one',
]);
ContentModerationFinding::query()->create([
'content_type' => 'artwork_comment',
'content_id' => 12,
'user_id' => $user->id,
'status' => ModerationStatus::ConfirmedSpam->value,
'severity' => 'critical',
'score' => 120,
'content_hash' => hash('sha256', 'b'),
'scanner_version' => '2.0',
'content_snapshot' => 'spam two',
]);
$base = app(ContentModerationService::class)->analyze(
'Visit https://safe.example.test for more info',
['content_type' => 'artwork_comment', 'content_id' => 1]
);
$risky = app(ContentModerationService::class)->analyze(
'Visit https://safe.example.test for more info',
['content_type' => 'artwork_comment', 'content_id' => 2, 'user_id' => $user->id]
);
expect($risky->score)->toBeGreaterThan($base->score)
->and($risky->userRiskScore)->toBeGreaterThan(0)
->and($risky->ruleHits)->toHaveKey('user_risk_modifier');
});
it('applies strict seo policy and v3 assistive fields for link-heavy profile content', function (): void {
$result = app(ContentModerationService::class)->analyze(
'Visit https://promo.cluster-test.com now for a limited time offer and boost your traffic',
[
'content_type' => ModerationContentType::UserProfileLink->value,
'content_id' => 77,
'content_target_type' => 'user_social_link',
'content_target_id' => 77,
'is_publicly_exposed' => true,
]
);
expect($result->policyName)->toBe('strict_seo_protection')
->and($result->status)->toBe(ModerationStatus::Pending)
->and($result->priorityScore)->toBeGreaterThan($result->score)
->and(in_array($result->reviewBucket, ['urgent', 'high'], true))->toBeTrue()
->and($result->aiProvider)->toBe('heuristic_assist')
->and($result->aiLabel)->not->toBeNull()
->and($result->contentTargetType)->toBe('user_social_link')
->and($result->contentTargetId)->toBe(77)
->and($result->scoreBreakdown)->not->toBeEmpty();
});
it('builds v3 moderation source context for profile links and card text', function (): void {
$service = app(ContentModerationSourceService::class);
$profileLinkContext = $service->buildContext(ModerationContentType::UserProfileLink, (object) [
'id' => 14,
'user_id' => 5,
'url' => 'https://promo.example.test',
]);
$cardTextContext = $service->buildContext(ModerationContentType::CardText, (object) [
'id' => 9,
'user_id' => 3,
'quote_text' => 'Promo headline',
'description' => 'Additional landing page copy',
'quote_author' => 'Campaign Bot',
'quote_source' => 'promo.example.test',
'visibility' => 'public',
]);
expect($profileLinkContext['content_type'])->toBe(ModerationContentType::UserProfileLink->value)
->and($profileLinkContext['content_target_type'])->toBe('user_social_link')
->and($profileLinkContext['content_target_id'])->toBe(14)
->and($profileLinkContext['user_id'])->toBe(5)
->and($profileLinkContext['is_publicly_exposed'])->toBeTrue()
->and($cardTextContext['content_type'])->toBe(ModerationContentType::CardText->value)
->and($cardTextContext['content_target_type'])->toBe('nova_card')
->and($cardTextContext['content_target_id'])->toBe(9)
->and($cardTextContext['user_id'])->toBe(3)
->and($cardTextContext['is_publicly_exposed'])->toBeTrue()
->and($cardTextContext['content_snapshot'])->toContain('Promo headline')
->and($cardTextContext['content_snapshot'])->toContain('promo.example.test');
});

View File

@@ -0,0 +1,57 @@
<?php
declare(strict_types=1);
use App\Services\Cdn\ArtworkCdnPurgeService;
use Illuminate\Support\Facades\Http;
use Tests\TestCase;
uses(TestCase::class);
it('purges canonical artwork urls through the Cloudflare api', function (): void {
Http::fake([
'https://api.cloudflare.com/client/v4/zones/test-zone/purge_cache' => Http::response(['success' => true], 200),
]);
config()->set('cdn.files_url', 'https://cdn.skinbase.org');
config()->set('cdn.cloudflare.zone_id', 'test-zone');
config()->set('cdn.cloudflare.api_token', 'test-token');
$result = app(ArtworkCdnPurgeService::class)->purgeArtworkObjectPaths([
'artworks/sq/61/83/6183c98975512ee6bff4657043067953a33769c7.webp',
], ['reason' => 'test']);
expect($result)->toBeTrue();
Http::assertSent(function ($request): bool {
return $request->url() === 'https://api.cloudflare.com/client/v4/zones/test-zone/purge_cache'
&& $request->hasHeader('Authorization', 'Bearer test-token')
&& $request['files'] === [
'https://cdn.skinbase.org/artworks/sq/61/83/6183c98975512ee6bff4657043067953a33769c7.webp',
];
});
});
it('falls back to the legacy purge webhook when Cloudflare credentials are absent', function (): void {
Http::fake([
'https://purge.internal.example/purge' => Http::response(['ok' => true], 200),
]);
config()->set('cdn.files_url', 'https://cdn.skinbase.org');
config()->set('cdn.cloudflare.zone_id', null);
config()->set('cdn.cloudflare.api_token', null);
config()->set('cdn.purge_url', 'https://purge.internal.example/purge');
$result = app(ArtworkCdnPurgeService::class)->purgeArtworkObjectPaths([
'artworks/sq/61/83/6183c98975512ee6bff4657043067953a33769c7.webp',
], ['reason' => 'test']);
expect($result)->toBeTrue();
Http::assertSent(function ($request): bool {
return $request->url() === 'https://purge.internal.example/purge'
&& $request['paths'] === [
'/artworks/sq/61/83/6183c98975512ee6bff4657043067953a33769c7.webp',
];
});
});

View File

@@ -0,0 +1,30 @@
<?php
declare(strict_types=1);
use App\Services\Upload\PreviewService;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;
use Tests\TestCase;
uses(TestCase::class, RefreshDatabase::class);
it('generates a smart square thumb during preview creation', function () {
Storage::fake('local');
$uploadId = (string) Str::uuid();
$file = UploadedFile::fake()->image('preview-source.jpg', 1200, 800);
$path = $file->storeAs("tmp/drafts/{$uploadId}/main", 'preview-source.jpg', 'local');
$result = app(PreviewService::class)->generateFromImage($uploadId, $path);
expect(Storage::disk('local')->exists($result['preview_path']))->toBeTrue()
->and(Storage::disk('local')->exists($result['thumb_path']))->toBeTrue();
$thumbSize = getimagesizefromstring(Storage::disk('local')->get($result['thumb_path']));
expect($thumbSize[0] ?? null)->toBe(320)
->and($thumbSize[1] ?? null)->toBe(320);
});

View File

@@ -0,0 +1,263 @@
<?php
declare(strict_types=1);
use App\Models\Artwork;
use App\Models\User;
use App\Repositories\Uploads\UploadSessionRepository;
use App\Services\Uploads\UploadPipelineService;
use App\Services\Uploads\UploadSessionStatus;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\File;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;
use Tests\TestCase;
uses(TestCase::class, RefreshDatabase::class);
beforeEach(function () {
Storage::fake('s3');
$suffix = Str::lower(Str::random(10));
$this->tempUploadRoot = storage_path('framework/testing/uploads-pipeline-' . $suffix);
$this->localOriginalsRoot = storage_path('framework/testing/originals-artworks-' . $suffix);
File::deleteDirectory($this->tempUploadRoot);
File::deleteDirectory($this->localOriginalsRoot);
config()->set('uploads.storage_root', $this->tempUploadRoot);
config()->set('uploads.local_originals_root', $this->localOriginalsRoot);
config()->set('uploads.object_storage.disk', 's3');
config()->set('uploads.object_storage.prefix', 'artworks');
config()->set('uploads.derivatives', [
'xs' => ['max' => 320],
'sm' => ['max' => 680],
'md' => ['max' => 1024],
'lg' => ['max' => 1920],
'xl' => ['max' => 2560],
'sq' => ['size' => 512],
]);
config()->set('uploads.square_thumbnails', [
'width' => 512,
'height' => 512,
'quality' => 82,
'smart_crop' => true,
'padding_ratio' => 0.18,
'allow_upscale' => false,
'fallback_strategy' => 'center',
'log' => false,
'preview_size' => 320,
'subject_detector' => [
'preferred_labels' => ['person', 'portrait', 'animal', 'face'],
],
'saliency' => [
'sample_max_dimension' => 96,
'min_total_energy' => 2400,
'window_ratios' => [0.55, 0.7, 0.82, 1.0],
],
]);
});
afterEach(function () {
File::deleteDirectory($this->tempUploadRoot);
File::deleteDirectory($this->localOriginalsRoot);
});
function pipelineTestCreateTempImage(string $root, string $name = 'preview.jpg'): string
{
$tmpDir = $root . DIRECTORY_SEPARATOR . 'tmp';
if (! File::exists($tmpDir)) {
File::makeDirectory($tmpDir, 0755, true);
}
$upload = UploadedFile::fake()->image($name, 1800, 1200);
$path = $tmpDir . DIRECTORY_SEPARATOR . Str::uuid()->toString() . '.jpg';
if (! File::copy($upload->getPathname(), $path)) {
throw new RuntimeException('Failed to copy fake image into upload temp path.');
}
return $path;
}
function pipelineTestCreateTempArchive(string $root, string $name = 'pack.zip'): string
{
$tmpDir = $root . DIRECTORY_SEPARATOR . 'tmp';
if (! File::exists($tmpDir)) {
File::makeDirectory($tmpDir, 0755, true);
}
$path = $tmpDir . DIRECTORY_SEPARATOR . Str::uuid()->toString() . '.zip';
File::put($path, 'fake-archive-binary-' . Str::random(24));
return $path;
}
it('stores image originals locally and in object storage with all derivatives', function () {
$user = User::factory()->create();
$artwork = Artwork::factory()->for($user)->unpublished()->create();
$tempImage = pipelineTestCreateTempImage($this->tempUploadRoot, 'sunset.jpg');
$hash = hash_file('sha256', $tempImage);
$sessionId = (string) Str::uuid();
app(UploadSessionRepository::class)->create($sessionId, $user->id, $tempImage, UploadSessionStatus::TMP, '127.0.0.1');
$result = app(UploadPipelineService::class)->processAndPublish($sessionId, $hash, $artwork->id, 'sunset.jpg');
$localOriginal = $this->localOriginalsRoot . DIRECTORY_SEPARATOR . substr($hash, 0, 2) . DIRECTORY_SEPARATOR . substr($hash, 2, 2) . DIRECTORY_SEPARATOR . $hash . '.jpg';
expect(File::exists($localOriginal))->toBeTrue();
Storage::disk('s3')->assertExists("artworks/original/" . substr($hash, 0, 2) . "/" . substr($hash, 2, 2) . "/{$hash}.jpg");
foreach (['xs', 'sm', 'md', 'lg', 'xl', 'sq'] as $variant) {
Storage::disk('s3')->assertExists("artworks/{$variant}/" . substr($hash, 0, 2) . "/" . substr($hash, 2, 2) . "/{$hash}.webp");
}
$sqBytes = Storage::disk('s3')->get("artworks/sq/" . substr($hash, 0, 2) . "/" . substr($hash, 2, 2) . "/{$hash}.webp");
$sqSize = getimagesizefromstring($sqBytes);
expect($sqSize[0] ?? null)->toBe(512)
->and($sqSize[1] ?? null)->toBe(512);
$mdBytes = Storage::disk('s3')->get("artworks/md/" . substr($hash, 0, 2) . "/" . substr($hash, 2, 2) . "/{$hash}.webp");
$mdSize = getimagesizefromstring($mdBytes);
expect($mdSize[0] ?? 0)->toBeGreaterThan(($mdSize[1] ?? 0));
$storedVariants = DB::table('artwork_files')
->where('artwork_id', $artwork->id)
->pluck('path', 'variant')
->all();
expect($storedVariants['orig'])->toBe("artworks/original/" . substr($hash, 0, 2) . "/" . substr($hash, 2, 2) . "/{$hash}.jpg")
->and($storedVariants['orig_image'])->toBe("artworks/original/" . substr($hash, 0, 2) . "/" . substr($hash, 2, 2) . "/{$hash}.jpg")
->and($storedVariants['sq'])->toBe("artworks/sq/" . substr($hash, 0, 2) . "/" . substr($hash, 2, 2) . "/{$hash}.webp");
$artwork->refresh();
expect($artwork->hash)->toBe($hash)
->and($artwork->thumb_ext)->toBe('webp')
->and($artwork->file_ext)->toBe('jpg')
->and($artwork->file_path)->toBe("artworks/original/" . substr($hash, 0, 2) . "/" . substr($hash, 2, 2) . "/{$hash}.jpg")
->and($result['orig'])->toBe("artworks/original/" . substr($hash, 0, 2) . "/" . substr($hash, 2, 2) . "/{$hash}.jpg");
});
it('stores preview image and archive originals separately when an archive session is provided', function () {
$user = User::factory()->create();
$artwork = Artwork::factory()->for($user)->unpublished()->create();
$tempImage = pipelineTestCreateTempImage($this->tempUploadRoot, 'cover.jpg');
$imageHash = hash_file('sha256', $tempImage);
$imageSessionId = (string) Str::uuid();
app(UploadSessionRepository::class)->create($imageSessionId, $user->id, $tempImage, UploadSessionStatus::TMP, '127.0.0.1');
$tempArchive = pipelineTestCreateTempArchive($this->tempUploadRoot, 'skin-pack.zip');
$archiveHash = hash_file('sha256', $tempArchive);
$archiveSessionId = (string) Str::uuid();
app(UploadSessionRepository::class)->create($archiveSessionId, $user->id, $tempArchive, UploadSessionStatus::TMP, '127.0.0.1');
$result = app(UploadPipelineService::class)->processAndPublish(
$imageSessionId,
$imageHash,
$artwork->id,
'cover.jpg',
$archiveSessionId,
$archiveHash,
'skin-pack.zip'
);
Storage::disk('s3')->assertExists("artworks/original/" . substr($imageHash, 0, 2) . "/" . substr($imageHash, 2, 2) . "/{$imageHash}.jpg");
Storage::disk('s3')->assertExists("artworks/original/" . substr($archiveHash, 0, 2) . "/" . substr($archiveHash, 2, 2) . "/{$archiveHash}.zip");
$variants = DB::table('artwork_files')
->where('artwork_id', $artwork->id)
->pluck('path', 'variant')
->all();
expect($variants['orig'])->toBe("artworks/original/" . substr($archiveHash, 0, 2) . "/" . substr($archiveHash, 2, 2) . "/{$archiveHash}.zip")
->and($variants['orig_image'])->toBe("artworks/original/" . substr($imageHash, 0, 2) . "/" . substr($imageHash, 2, 2) . "/{$imageHash}.jpg")
->and($variants['orig_archive'])->toBe("artworks/original/" . substr($archiveHash, 0, 2) . "/" . substr($archiveHash, 2, 2) . "/{$archiveHash}.zip");
$artwork->refresh();
expect($artwork->hash)->toBe($imageHash)
->and($artwork->file_ext)->toBe('zip')
->and($artwork->file_path)->toBe("artworks/original/" . substr($archiveHash, 0, 2) . "/" . substr($archiveHash, 2, 2) . "/{$archiveHash}.zip")
->and($result['orig_archive'])->toBe("artworks/original/" . substr($archiveHash, 0, 2) . "/" . substr($archiveHash, 2, 2) . "/{$archiveHash}.zip");
});
it('stores additional archive screenshots as dedicated artwork file variants', function () {
$user = User::factory()->create();
$artwork = Artwork::factory()->for($user)->unpublished()->create();
$tempImage = pipelineTestCreateTempImage($this->tempUploadRoot, 'cover.jpg');
$imageHash = hash_file('sha256', $tempImage);
$imageSessionId = (string) Str::uuid();
app(UploadSessionRepository::class)->create($imageSessionId, $user->id, $tempImage, UploadSessionStatus::TMP, '127.0.0.1');
$tempArchive = pipelineTestCreateTempArchive($this->tempUploadRoot, 'skin-pack.zip');
$archiveHash = hash_file('sha256', $tempArchive);
$archiveSessionId = (string) Str::uuid();
app(UploadSessionRepository::class)->create($archiveSessionId, $user->id, $tempArchive, UploadSessionStatus::TMP, '127.0.0.1');
$tempScreenshot = pipelineTestCreateTempImage($this->tempUploadRoot, 'screen-2.jpg');
$screenshotHash = hash_file('sha256', $tempScreenshot);
$screenshotSessionId = (string) Str::uuid();
app(UploadSessionRepository::class)->create($screenshotSessionId, $user->id, $tempScreenshot, UploadSessionStatus::TMP, '127.0.0.1');
$result = app(UploadPipelineService::class)->processAndPublish(
$imageSessionId,
$imageHash,
$artwork->id,
'cover.jpg',
$archiveSessionId,
$archiveHash,
'skin-pack.zip',
[
[
'session_id' => $screenshotSessionId,
'hash' => $screenshotHash,
'file_name' => 'screen-2.jpg',
],
]
);
Storage::disk('s3')->assertExists("artworks/original/" . substr($screenshotHash, 0, 2) . "/" . substr($screenshotHash, 2, 2) . "/{$screenshotHash}.jpg");
$variants = DB::table('artwork_files')
->where('artwork_id', $artwork->id)
->pluck('path', 'variant')
->all();
expect($variants['shot01'])->toBe("artworks/original/" . substr($screenshotHash, 0, 2) . "/" . substr($screenshotHash, 2, 2) . "/{$screenshotHash}.jpg")
->and($result['screenshots'])->toBe([
[
'variant' => 'shot01',
'path' => "artworks/original/" . substr($screenshotHash, 0, 2) . "/" . substr($screenshotHash, 2, 2) . "/{$screenshotHash}.jpg",
],
]);
});
it('cleans up local and object storage when publish persistence fails', function () {
$user = User::factory()->create();
$tempImage = pipelineTestCreateTempImage($this->tempUploadRoot, 'broken.jpg');
$hash = hash_file('sha256', $tempImage);
$sessionId = (string) Str::uuid();
app(UploadSessionRepository::class)->create($sessionId, $user->id, $tempImage, UploadSessionStatus::TMP, '127.0.0.1');
try {
app(UploadPipelineService::class)->processAndPublish($sessionId, $hash, 999999, 'broken.jpg');
$this->fail('Expected upload pipeline publish to fail for a missing artwork.');
} catch (\Throwable) {
// Expected: DB persistence fails and the pipeline must clean up written files.
}
$localOriginal = $this->localOriginalsRoot . DIRECTORY_SEPARATOR . substr($hash, 0, 2) . DIRECTORY_SEPARATOR . substr($hash, 2, 2) . DIRECTORY_SEPARATOR . $hash . '.jpg';
expect(File::exists($localOriginal))->toBeFalse();
foreach (['original', 'xs', 'sm', 'md', 'lg', 'xl', 'sq'] as $variant) {
$filename = $variant === 'original' ? "{$hash}.jpg" : "{$hash}.webp";
Storage::disk('s3')->assertMissing("artworks/{$variant}/" . substr($hash, 0, 2) . "/" . substr($hash, 2, 2) . "/{$filename}");
}
});