Implement creator studio and upload updates
This commit is contained in:
152
tests/Unit/Images/SquareThumbnailServiceTest.php
Normal file
152
tests/Unit/Images/SquareThumbnailServiceTest.php
Normal 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);
|
||||
});
|
||||
208
tests/Unit/Moderation/ContentModerationServiceTest.php
Normal file
208
tests/Unit/Moderation/ContentModerationServiceTest.php
Normal 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');
|
||||
});
|
||||
57
tests/Unit/Services/ArtworkCdnPurgeServiceTest.php
Normal file
57
tests/Unit/Services/ArtworkCdnPurgeServiceTest.php
Normal 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',
|
||||
];
|
||||
});
|
||||
});
|
||||
30
tests/Unit/Upload/PreviewServiceTest.php
Normal file
30
tests/Unit/Upload/PreviewServiceTest.php
Normal 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);
|
||||
});
|
||||
263
tests/Unit/Uploads/UploadPipelineServiceTest.php
Normal file
263
tests/Unit/Uploads/UploadPipelineServiceTest.php
Normal 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}");
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user