Save workspace changes
This commit is contained in:
@@ -0,0 +1,95 @@
|
||||
<?php
|
||||
|
||||
use cPad\Plugins\Forum\Services\Security\AccountFarmDetector;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
uses(Tests\TestCase::class);
|
||||
|
||||
it('flags repeated posting patterns across multiple accounts', function () {
|
||||
config()->set('forum_bot_protection.account_farm', [
|
||||
'window_minutes' => 10,
|
||||
'register_attempt_threshold' => 10,
|
||||
'same_ip_users_threshold' => 5,
|
||||
'same_fingerprint_users_threshold' => 3,
|
||||
'same_pattern_users_threshold' => 2,
|
||||
'register_attempt_penalty' => 50,
|
||||
'same_ip_penalty' => 35,
|
||||
'same_fingerprint_penalty' => 40,
|
||||
'same_pattern_penalty' => 45,
|
||||
]);
|
||||
|
||||
Schema::dropIfExists('forum_posts');
|
||||
Schema::dropIfExists('forum_bot_device_fingerprints');
|
||||
Schema::dropIfExists('forum_bot_logs');
|
||||
|
||||
Schema::create('forum_bot_logs', function (Blueprint $table): void {
|
||||
$table->id();
|
||||
$table->unsignedBigInteger('user_id')->nullable();
|
||||
$table->string('ip_address', 45)->nullable();
|
||||
$table->string('action', 80);
|
||||
$table->unsignedTinyInteger('risk_score')->default(0);
|
||||
$table->string('decision', 20)->default('allow');
|
||||
$table->json('metadata')->nullable();
|
||||
$table->timestamp('created_at')->nullable();
|
||||
});
|
||||
|
||||
Schema::create('forum_bot_device_fingerprints', function (Blueprint $table): void {
|
||||
$table->id();
|
||||
$table->unsignedBigInteger('user_id')->nullable();
|
||||
$table->string('fingerprint', 128)->nullable();
|
||||
$table->timestamp('first_seen')->nullable();
|
||||
$table->timestamp('last_seen')->nullable();
|
||||
$table->unsignedTinyInteger('risk_score')->default(0);
|
||||
$table->string('user_agent_hash', 64)->nullable();
|
||||
$table->timestamps();
|
||||
});
|
||||
|
||||
Schema::create('forum_posts', function (Blueprint $table): void {
|
||||
$table->id();
|
||||
$table->unsignedBigInteger('thread_id')->nullable();
|
||||
$table->unsignedBigInteger('topic_id')->nullable();
|
||||
$table->string('source_ip_hash', 64)->nullable();
|
||||
$table->unsignedBigInteger('user_id')->nullable();
|
||||
$table->longText('content')->nullable();
|
||||
$table->string('content_hash', 64)->nullable();
|
||||
$table->boolean('is_edited')->default(false);
|
||||
$table->timestamp('edited_at')->nullable();
|
||||
$table->unsignedInteger('spam_score')->default(0);
|
||||
$table->unsignedInteger('quality_score')->default(0);
|
||||
$table->unsignedInteger('ai_spam_score')->default(0);
|
||||
$table->unsignedInteger('ai_toxicity_score')->default(0);
|
||||
$table->unsignedInteger('behavior_score')->default(0);
|
||||
$table->unsignedInteger('link_score')->default(0);
|
||||
$table->integer('learning_score')->default(0);
|
||||
$table->unsignedInteger('risk_score')->default(0);
|
||||
$table->integer('trust_modifier')->default(0);
|
||||
$table->boolean('flagged')->default(false);
|
||||
$table->string('flagged_reason')->nullable();
|
||||
$table->boolean('moderation_checked')->default(false);
|
||||
$table->string('moderation_status')->nullable();
|
||||
$table->json('moderation_labels')->nullable();
|
||||
$table->json('moderation_meta')->nullable();
|
||||
$table->timestamp('last_ai_scan_at')->nullable();
|
||||
$table->timestamps();
|
||||
$table->softDeletes();
|
||||
});
|
||||
|
||||
$hash = hash('sha256', 'buy cheap backlinks now');
|
||||
|
||||
foreach ([1, 2, 3] as $userId) {
|
||||
DB::table('forum_posts')->insert([
|
||||
'user_id' => $userId,
|
||||
'content' => 'buy cheap backlinks now',
|
||||
'content_hash' => $hash,
|
||||
'created_at' => now()->subMinutes(2),
|
||||
'updated_at' => now()->subMinutes(2),
|
||||
]);
|
||||
}
|
||||
|
||||
$result = app(AccountFarmDetector::class)->analyze(1, '203.0.113.10', null, 'forum_reply_create');
|
||||
|
||||
expect($result['score'])->toBe(45)
|
||||
->and($result['reasons'])->toContain('Posting patterns or repeated content overlap across multiple accounts.');
|
||||
});
|
||||
@@ -0,0 +1,60 @@
|
||||
<?php
|
||||
|
||||
use App\Models\ContentType;
|
||||
use App\Models\ContentTypeSlugHistory;
|
||||
use App\Services\ArtworkService;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Tests\TestCase;
|
||||
|
||||
uses(TestCase::class, RefreshDatabase::class);
|
||||
|
||||
it('resolves historical content type slugs in artwork service content type browsing', function () {
|
||||
$contentType = ContentType::query()->create([
|
||||
'name' => 'Digital Illustration',
|
||||
'slug' => 'digital-illustration',
|
||||
'description' => 'Digital illustration uploads',
|
||||
'order' => 1,
|
||||
]);
|
||||
|
||||
ContentTypeSlugHistory::query()->create([
|
||||
'content_type_id' => $contentType->id,
|
||||
'old_slug' => 'digital-art',
|
||||
]);
|
||||
|
||||
$paginator = app(ArtworkService::class)->getArtworksByContentType('digital-art', 24);
|
||||
|
||||
expect($paginator->count())->toBe(0)
|
||||
->and($paginator->items())->toBeArray();
|
||||
});
|
||||
|
||||
it('resolves historical content type slugs in artwork service category path browsing', function () {
|
||||
$contentType = ContentType::query()->create([
|
||||
'name' => 'Digital Illustration',
|
||||
'slug' => 'digital-illustration',
|
||||
'description' => 'Digital illustration uploads',
|
||||
'order' => 1,
|
||||
]);
|
||||
|
||||
ContentTypeSlugHistory::query()->create([
|
||||
'content_type_id' => $contentType->id,
|
||||
'old_slug' => 'digital-art',
|
||||
]);
|
||||
|
||||
$rootCategory = $contentType->categories()->create([
|
||||
'parent_id' => null,
|
||||
'name' => 'Concepts',
|
||||
'slug' => 'concepts',
|
||||
'description' => null,
|
||||
'image' => null,
|
||||
'is_active' => true,
|
||||
'sort_order' => 0,
|
||||
]);
|
||||
|
||||
$paginator = app(ArtworkService::class)->getArtworksByCategoryPath([
|
||||
'digital-art',
|
||||
$rootCategory->slug,
|
||||
], 24);
|
||||
|
||||
expect($paginator->count())->toBe(0)
|
||||
->and($paginator->items())->toBeArray();
|
||||
});
|
||||
@@ -0,0 +1,52 @@
|
||||
<?php
|
||||
|
||||
use cPad\Plugins\Forum\Services\Security\BotRiskScorer;
|
||||
|
||||
uses(Tests\TestCase::class);
|
||||
|
||||
it('maps bot risk thresholds to the expected decisions', function () {
|
||||
config()->set('forum_bot_protection.thresholds', [
|
||||
'allow' => 20,
|
||||
'log' => 20,
|
||||
'captcha' => 40,
|
||||
'moderate' => 60,
|
||||
'block' => 80,
|
||||
]);
|
||||
|
||||
$scorer = app(BotRiskScorer::class);
|
||||
|
||||
expect($scorer->score(['behavior' => 10]))->toMatchArray([
|
||||
'risk_score' => 10,
|
||||
'decision' => 'allow',
|
||||
'requires_review' => false,
|
||||
'blocked' => false,
|
||||
]);
|
||||
|
||||
expect($scorer->score(['behavior' => 20]))->toMatchArray([
|
||||
'risk_score' => 20,
|
||||
'decision' => 'log',
|
||||
'requires_review' => false,
|
||||
'blocked' => false,
|
||||
]);
|
||||
|
||||
expect($scorer->score(['behavior' => 40]))->toMatchArray([
|
||||
'risk_score' => 40,
|
||||
'decision' => 'captcha',
|
||||
'requires_review' => false,
|
||||
'blocked' => false,
|
||||
]);
|
||||
|
||||
expect($scorer->score(['behavior' => 60]))->toMatchArray([
|
||||
'risk_score' => 60,
|
||||
'decision' => 'moderate',
|
||||
'requires_review' => true,
|
||||
'blocked' => false,
|
||||
]);
|
||||
|
||||
expect($scorer->score(['behavior' => 80]))->toMatchArray([
|
||||
'risk_score' => 80,
|
||||
'decision' => 'block',
|
||||
'requires_review' => false,
|
||||
'blocked' => true,
|
||||
]);
|
||||
});
|
||||
@@ -0,0 +1,37 @@
|
||||
<?php
|
||||
|
||||
use App\Services\Sitemaps\Builders\CategoriesSitemapBuilder;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
uses(Tests\TestCase::class, RefreshDatabase::class);
|
||||
|
||||
it('builds category sitemap entries from dynamic content types', function () {
|
||||
$contentTypeId = DB::table('content_types')->insertGetId([
|
||||
'name' => 'Pixel Art',
|
||||
'slug' => 'pixel-art',
|
||||
'description' => 'Pixel art uploads',
|
||||
'order' => 1,
|
||||
'created_at' => now(),
|
||||
'updated_at' => now(),
|
||||
]);
|
||||
|
||||
DB::table('categories')->insert([
|
||||
'content_type_id' => $contentTypeId,
|
||||
'parent_id' => null,
|
||||
'name' => 'Characters',
|
||||
'slug' => 'characters',
|
||||
'description' => null,
|
||||
'image' => null,
|
||||
'is_active' => true,
|
||||
'sort_order' => 1,
|
||||
'created_at' => now(),
|
||||
'updated_at' => now(),
|
||||
]);
|
||||
|
||||
$items = app(CategoriesSitemapBuilder::class)->items();
|
||||
$locations = array_map(static fn ($item) => $item->loc, $items);
|
||||
|
||||
expect($locations)->toContain(url('/pixel-art'))
|
||||
->and($locations)->toContain(url('/pixel-art/characters'));
|
||||
});
|
||||
@@ -0,0 +1,134 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Models\Artwork;
|
||||
use App\Models\Collection;
|
||||
use App\Models\User;
|
||||
use App\Services\CollectionService;
|
||||
use App\Services\SmartCollectionService;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Tests\TestCase;
|
||||
|
||||
uses(TestCase::class, RefreshDatabase::class);
|
||||
|
||||
it('generates unique slugs per owner while allowing reuse across different owners', function (): void {
|
||||
$firstUser = User::factory()->create();
|
||||
$secondUser = User::factory()->create();
|
||||
$service = app(CollectionService::class);
|
||||
|
||||
Collection::factory()->for($firstUser)->create([
|
||||
'title' => 'Portal Vault',
|
||||
'slug' => 'portal-vault',
|
||||
]);
|
||||
|
||||
expect($service->makeUniqueSlugForUser($firstUser, 'Portal Vault'))->toBe('portal-vault-2');
|
||||
expect($service->makeUniqueSlugForUser($secondUser, 'Portal Vault'))->toBe('portal-vault');
|
||||
});
|
||||
|
||||
it('falls back to the first attached artwork when an explicit cover is removed', function (): void {
|
||||
$user = User::factory()->create();
|
||||
$collection = Collection::factory()->for($user)->create();
|
||||
$firstArtwork = Artwork::factory()->for($user)->create();
|
||||
$secondArtwork = Artwork::factory()->for($user)->create();
|
||||
$service = app(CollectionService::class);
|
||||
|
||||
$service->attachArtworks($collection, $user, [$firstArtwork->id, $secondArtwork->id]);
|
||||
$collection->refresh();
|
||||
|
||||
$service->updateCollection($collection->loadMissing('user'), [
|
||||
'title' => $collection->title,
|
||||
'slug' => $collection->slug,
|
||||
'description' => $collection->description,
|
||||
'visibility' => $collection->visibility,
|
||||
'sort_mode' => $collection->sort_mode,
|
||||
'cover_artwork_id' => $secondArtwork->id,
|
||||
]);
|
||||
|
||||
$collection->refresh();
|
||||
expect($collection->resolvedCoverArtwork()?->id)->toBe($secondArtwork->id);
|
||||
|
||||
$service->removeArtwork($collection->loadMissing('user'), $secondArtwork);
|
||||
$collection->refresh();
|
||||
|
||||
expect($collection->cover_artwork_id)->toBeNull();
|
||||
expect($collection->resolvedCoverArtwork()?->id)->toBe($firstArtwork->id);
|
||||
});
|
||||
|
||||
it('keeps artworks_count in sync while attaching and removing artworks', function (): void {
|
||||
$user = User::factory()->create();
|
||||
$collection = Collection::factory()->for($user)->create(['artworks_count' => 0]);
|
||||
$artworkA = Artwork::factory()->for($user)->create();
|
||||
$artworkB = Artwork::factory()->for($user)->create();
|
||||
$service = app(CollectionService::class);
|
||||
|
||||
$service->attachArtworks($collection, $user, [$artworkA->id, $artworkB->id]);
|
||||
$collection->refresh();
|
||||
|
||||
expect($collection->artworks_count)->toBe(2);
|
||||
|
||||
$service->removeArtwork($collection->loadMissing('user'), $artworkA);
|
||||
$collection->refresh();
|
||||
|
||||
expect($collection->artworks_count)->toBe(1);
|
||||
expect($collection->artworks()->pluck('artworks.id')->all())->toBe([$artworkB->id]);
|
||||
});
|
||||
|
||||
it('builds a human readable smart summary for medium rules', function (): void {
|
||||
$service = app(SmartCollectionService::class);
|
||||
|
||||
$summary = $service->smartSummary([
|
||||
'match' => 'all',
|
||||
'sort' => 'newest',
|
||||
'rules' => [
|
||||
[
|
||||
'field' => 'medium',
|
||||
'operator' => 'equals',
|
||||
'value' => 'wallpapers',
|
||||
],
|
||||
],
|
||||
]);
|
||||
|
||||
expect($summary)->toBe('Includes artworks in medium wallpapers.');
|
||||
});
|
||||
|
||||
it('builds a human readable smart summary for style and color rules', function (): void {
|
||||
$service = app(SmartCollectionService::class);
|
||||
|
||||
$summary = $service->smartSummary([
|
||||
'match' => 'any',
|
||||
'sort' => 'newest',
|
||||
'rules' => [
|
||||
[
|
||||
'field' => 'style',
|
||||
'operator' => 'equals',
|
||||
'value' => 'digital painting',
|
||||
],
|
||||
[
|
||||
'field' => 'color',
|
||||
'operator' => 'equals',
|
||||
'value' => 'blue tones',
|
||||
],
|
||||
],
|
||||
]);
|
||||
|
||||
expect($summary)->toBe('Includes artworks matching style digital painting or using color palette blue tones.');
|
||||
});
|
||||
|
||||
it('builds a human readable smart summary for mature rules', function (): void {
|
||||
$service = app(SmartCollectionService::class);
|
||||
|
||||
$summary = $service->smartSummary([
|
||||
'match' => 'all',
|
||||
'sort' => 'newest',
|
||||
'rules' => [
|
||||
[
|
||||
'field' => 'is_mature',
|
||||
'operator' => 'equals',
|
||||
'value' => true,
|
||||
],
|
||||
],
|
||||
]);
|
||||
|
||||
expect($summary)->toBe('Includes artworks marked as mature artworks.');
|
||||
});
|
||||
@@ -0,0 +1,43 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Models\Collection;
|
||||
use App\Models\User;
|
||||
use App\Services\CollectionWorkflowService;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
use Tests\TestCase;
|
||||
|
||||
uses(TestCase::class, RefreshDatabase::class);
|
||||
|
||||
it('rejects invalid workflow transitions', function (): void {
|
||||
$user = User::factory()->create();
|
||||
$collection = Collection::factory()->for($user)->create([
|
||||
'workflow_state' => Collection::WORKFLOW_DRAFT,
|
||||
]);
|
||||
|
||||
$service = app(CollectionWorkflowService::class);
|
||||
|
||||
expect(fn () => $service->update($collection, [
|
||||
'workflow_state' => Collection::WORKFLOW_ARCHIVED,
|
||||
], $user))->toThrow(ValidationException::class);
|
||||
});
|
||||
|
||||
it('allows approved collections to become programmed', function (): void {
|
||||
$user = User::factory()->create(['role' => 'admin']);
|
||||
$collection = Collection::factory()->for($user)->create([
|
||||
'workflow_state' => Collection::WORKFLOW_APPROVED,
|
||||
'visibility' => Collection::VISIBILITY_PUBLIC,
|
||||
'lifecycle_state' => Collection::LIFECYCLE_PUBLISHED,
|
||||
]);
|
||||
|
||||
$service = app(CollectionWorkflowService::class);
|
||||
$updated = $service->update($collection, [
|
||||
'workflow_state' => Collection::WORKFLOW_PROGRAMMED,
|
||||
'program_key' => 'frontpage-hero',
|
||||
], $user);
|
||||
|
||||
expect($updated->workflow_state)->toBe(Collection::WORKFLOW_PROGRAMMED);
|
||||
expect($updated->program_key)->toBe('frontpage-hero');
|
||||
});
|
||||
@@ -0,0 +1,144 @@
|
||||
<?php
|
||||
|
||||
use App\Services\ContentSanitizer;
|
||||
|
||||
// ── Rendering ─────────────────────────────────────────────────────────────────
|
||||
|
||||
test('render converts bold markdown to strong tag', function () {
|
||||
$html = ContentSanitizer::render('**bold text**');
|
||||
expect($html)->toContain('<strong>bold text</strong>');
|
||||
});
|
||||
|
||||
test('render converts italic markdown to em tag', function () {
|
||||
$html = ContentSanitizer::render('*italic text*');
|
||||
expect($html)->toContain('<em>italic text</em>');
|
||||
});
|
||||
|
||||
test('render converts inline code to code tag', function () {
|
||||
$html = ContentSanitizer::render('use `code`');
|
||||
expect($html)->toContain('<code>code</code>');
|
||||
});
|
||||
|
||||
test('render auto-links URLs', function () {
|
||||
$html = ContentSanitizer::render('visit https://example.com for more');
|
||||
expect($html)->toContain('<a');
|
||||
expect($html)->toContain('href="https://example.com"');
|
||||
});
|
||||
|
||||
test('render returns empty string for null input', function () {
|
||||
expect(ContentSanitizer::render(null))->toBe('');
|
||||
});
|
||||
|
||||
test('render returns empty string for whitespace-only input', function () {
|
||||
expect(ContentSanitizer::render(' '))->toBe('');
|
||||
});
|
||||
|
||||
// ── XSS Prevention ────────────────────────────────────────────────────────────
|
||||
|
||||
test('render strips script tags', function () {
|
||||
$html = ContentSanitizer::render('<script>alert("xss")</script>hello');
|
||||
// The <script> tag itself must be gone (cannot execute)
|
||||
expect($html)->not()->toContain('<script>');
|
||||
// The word "hello" after the script block should appear
|
||||
expect($html)->toContain('hello');
|
||||
// Text inside script is rendered as harmless plain text — acceptable
|
||||
// Critical: no executable script element exists in the output
|
||||
});
|
||||
|
||||
test('render strips iframe tags', function () {
|
||||
$html = ContentSanitizer::render('<iframe src="evil.com"></iframe>');
|
||||
expect($html)->not()->toContain('<iframe');
|
||||
});
|
||||
|
||||
test('render strips javascript: links', function () {
|
||||
$html = ContentSanitizer::render('[click me](javascript:alert(1))');
|
||||
expect($html)->not()->toContain('javascript:');
|
||||
});
|
||||
|
||||
test('render strips style attributes', function () {
|
||||
$html = ContentSanitizer::render('<b style="color:red">red</b>');
|
||||
expect($html)->not()->toContain('style=');
|
||||
});
|
||||
|
||||
test('render strips onclick attributes', function () {
|
||||
$html = ContentSanitizer::render('<b onclick="evil()">text</b>');
|
||||
expect($html)->not()->toContain('onclick');
|
||||
});
|
||||
|
||||
test('render adds rel=noopener to external links', function () {
|
||||
$html = ContentSanitizer::render('[link](https://example.com)');
|
||||
expect($html)->toContain('rel="noopener noreferrer nofollow"');
|
||||
});
|
||||
|
||||
// ── Legacy HTML conversion ────────────────────────────────────────────────────
|
||||
|
||||
test('render converts legacy bold HTML to markdown output', function () {
|
||||
$html = ContentSanitizer::render('<b>old bold</b>');
|
||||
expect($html)->toContain('<strong>');
|
||||
expect($html)->not()->toContain('<script>');
|
||||
});
|
||||
|
||||
test('render converts br tags to line breaks', function () {
|
||||
$html = ContentSanitizer::render("line one<br>line two");
|
||||
// Should not contain the raw <br> tag in unexpected ways
|
||||
expect($html)->toContain('line one');
|
||||
expect($html)->toContain('line two');
|
||||
});
|
||||
|
||||
// ── Plain text ────────────────────────────────────────────────────────────────
|
||||
|
||||
test('stripToPlain removes all HTML', function () {
|
||||
$plain = ContentSanitizer::stripToPlain('<p>Hello <b>world</b>!</p>');
|
||||
expect($plain)->toBe('Hello world!');
|
||||
});
|
||||
|
||||
test('stripToPlain converts br to newline', function () {
|
||||
$plain = ContentSanitizer::stripToPlain("line one<br>line two");
|
||||
expect($plain)->toContain("\n");
|
||||
});
|
||||
|
||||
// ── Validation ────────────────────────────────────────────────────────────────
|
||||
|
||||
test('validate returns error for raw HTML tags', function () {
|
||||
$errors = ContentSanitizer::validate('<script>evil</script>');
|
||||
expect($errors)->not()->toBeEmpty();
|
||||
expect(implode(' ', $errors))->toContain('HTML');
|
||||
});
|
||||
|
||||
test('validate passes for clean markdown', function () {
|
||||
$errors = ContentSanitizer::validate('**bold** and *italic* and `code`');
|
||||
expect($errors)->toBeEmpty();
|
||||
});
|
||||
|
||||
test('validate returns error when content is too long', function () {
|
||||
$errors = ContentSanitizer::validate(str_repeat('a', 10_001));
|
||||
expect($errors)->not()->toBeEmpty();
|
||||
});
|
||||
|
||||
test('validate returns error when emoji density exceeds threshold', function () {
|
||||
// 10 fire emoji + 4 spaces = 14 chars; emoji count = 10; density = 10/14 ≈ 0.71 > 0.40
|
||||
$floodContent = implode(' ', array_fill(0, 10, '🔥'));
|
||||
$errors = ContentSanitizer::validate($floodContent);
|
||||
expect($errors)->not()->toBeEmpty();
|
||||
expect(implode(' ', $errors))->toContain('emoji');
|
||||
});
|
||||
|
||||
test('validate accepts content with reasonable emoji usage', function () {
|
||||
// 3 emoji in a 50-char string — density ≈ 0.06, well below threshold
|
||||
$errors = ContentSanitizer::validate('Great work on this piece 🎨 love the colours ❤️ keep it up 👏');
|
||||
expect($errors)->toBeEmpty();
|
||||
});
|
||||
|
||||
// ── collapseFlood ─────────────────────────────────────────────────────────────
|
||||
|
||||
test('collapseFlood delegates to LegacySmileyMapper and collapses runs', function () {
|
||||
$input = implode(' ', array_fill(0, 8, '🍺'));
|
||||
$result = ContentSanitizer::collapseFlood($input);
|
||||
expect($result)->toContain('×8');
|
||||
expect(substr_count($result, '🍺'))->toBeLessThanOrEqual(5);
|
||||
});
|
||||
|
||||
test('collapseFlood returns unchanged string when no flood present', function () {
|
||||
$input = 'Nice art 🎨 love it ❤️';
|
||||
expect(ContentSanitizer::collapseFlood($input))->toBe($input);
|
||||
});
|
||||
@@ -0,0 +1,36 @@
|
||||
<?php
|
||||
|
||||
use App\Models\ContentType;
|
||||
use App\Services\ContentTypeAssetService;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Http\UploadedFile;
|
||||
use Illuminate\Support\Facades\Config;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Tests\TestCase;
|
||||
|
||||
uses(TestCase::class, RefreshDatabase::class);
|
||||
|
||||
it('stores and deletes content type assets on the configured object storage disk', function () {
|
||||
Storage::fake('s3');
|
||||
Config::set('uploads.object_storage.disk', 's3');
|
||||
|
||||
$contentType = ContentType::query()->create([
|
||||
'name' => 'Digital Art',
|
||||
'slug' => 'digital-art',
|
||||
'description' => 'Digital art uploads',
|
||||
'order' => 1,
|
||||
]);
|
||||
|
||||
$path = app(ContentTypeAssetService::class)->storeUploadedAsset(
|
||||
$contentType,
|
||||
UploadedFile::fake()->image('mascot.webp', 240, 320),
|
||||
'mascot'
|
||||
);
|
||||
|
||||
expect($path)->toStartWith('content-types/' . $contentType->id . '/mascot-');
|
||||
expect(Storage::disk('s3')->exists($path))->toBeTrue();
|
||||
|
||||
app(ContentTypeAssetService::class)->deleteIfManaged($path);
|
||||
|
||||
expect(Storage::disk('s3')->exists($path))->toBeFalse();
|
||||
});
|
||||
@@ -0,0 +1,65 @@
|
||||
<?php
|
||||
|
||||
use App\Models\ContentType;
|
||||
use App\Models\ContentTypeSlugHistory;
|
||||
use App\Services\ContentTypes\ContentTypeSlugResolver;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
|
||||
uses(Tests\TestCase::class, RefreshDatabase::class);
|
||||
|
||||
beforeEach(function () {
|
||||
Cache::flush();
|
||||
});
|
||||
|
||||
it('resolves current, historical, and virtual content type slugs', function () {
|
||||
$contentType = ContentType::query()->create([
|
||||
'name' => 'Digital Art',
|
||||
'slug' => 'digital-art',
|
||||
'description' => 'Digital art uploads',
|
||||
'order' => 1,
|
||||
]);
|
||||
|
||||
ContentTypeSlugHistory::query()->create([
|
||||
'content_type_id' => $contentType->id,
|
||||
'old_slug' => 'concept-art',
|
||||
]);
|
||||
|
||||
$resolver = app(ContentTypeSlugResolver::class);
|
||||
|
||||
$current = $resolver->resolve('digital-art');
|
||||
$historical = $resolver->resolve('concept-art');
|
||||
$virtual = $resolver->resolve('artworks', allowVirtual: true);
|
||||
|
||||
expect($current->found())->toBeTrue()
|
||||
->and($current->requiresRedirect())->toBeFalse()
|
||||
->and($current->contentType?->slug)->toBe('digital-art')
|
||||
->and($historical->found())->toBeTrue()
|
||||
->and($historical->requiresRedirect())->toBeTrue()
|
||||
->and($historical->redirectSlug)->toBe('digital-art')
|
||||
->and($historical->contentType?->id)->toBe($contentType->id)
|
||||
->and($virtual->found())->toBeTrue()
|
||||
->and($virtual->isVirtual)->toBeTrue()
|
||||
->and($virtual->virtualType)->toBe('artworks');
|
||||
});
|
||||
|
||||
it('reports reserved and historical slug conflicts', function () {
|
||||
$contentType = ContentType::query()->create([
|
||||
'name' => 'Photography',
|
||||
'slug' => 'photography',
|
||||
'description' => 'Photography uploads',
|
||||
'order' => 1,
|
||||
]);
|
||||
|
||||
ContentTypeSlugHistory::query()->create([
|
||||
'content_type_id' => $contentType->id,
|
||||
'old_slug' => 'photos',
|
||||
]);
|
||||
|
||||
$resolver = app(ContentTypeSlugResolver::class);
|
||||
|
||||
expect($resolver->isReservedSlug('help'))->toBeTrue()
|
||||
->and($resolver->isReservedSlug('photography'))->toBeFalse()
|
||||
->and($resolver->historicalSlugExists('photos'))->toBeTrue()
|
||||
->and($resolver->historicalSlugExists('photos', $contentType->id))->toBeFalse();
|
||||
});
|
||||
@@ -0,0 +1,120 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Services\Recommendations\FeedOfflineEvaluationService;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Tests\TestCase;
|
||||
|
||||
uses(TestCase::class, RefreshDatabase::class);
|
||||
|
||||
it('evaluates objective metrics for an algo from feed_daily_metrics', function () {
|
||||
$metricDate = now()->subDay()->toDateString();
|
||||
|
||||
DB::table('feed_daily_metrics')->insert([
|
||||
'metric_date' => $metricDate,
|
||||
'algo_version' => 'clip-cosine-v1',
|
||||
'source' => 'personalized',
|
||||
'impressions' => 100,
|
||||
'clicks' => 20,
|
||||
'saves' => 8,
|
||||
'ctr' => 0.2,
|
||||
'save_rate' => 0.4,
|
||||
'dwell_0_5' => 3,
|
||||
'dwell_5_30' => 7,
|
||||
'dwell_30_120' => 6,
|
||||
'dwell_120_plus' => 4,
|
||||
'created_at' => now(),
|
||||
'updated_at' => now(),
|
||||
]);
|
||||
|
||||
$result = app(FeedOfflineEvaluationService::class)->evaluateAlgo('clip-cosine-v1', $metricDate, $metricDate);
|
||||
|
||||
expect((string) $result['algo_version'])->toBe('clip-cosine-v1');
|
||||
expect((float) $result['ctr'])->toBe(0.2);
|
||||
expect((float) $result['save_rate'])->toBe(0.4);
|
||||
expect((float) $result['long_dwell_share'])->toBe(0.5);
|
||||
expect((float) $result['bounce_rate'])->toBe(0.15);
|
||||
expect((float) $result['objective_score'])->toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('compares baseline vs candidate with delta and lift', function () {
|
||||
$metricDate = now()->subDay()->toDateString();
|
||||
|
||||
DB::table('feed_daily_metrics')->insert([
|
||||
[
|
||||
'metric_date' => $metricDate,
|
||||
'algo_version' => 'clip-cosine-v1',
|
||||
'source' => 'personalized',
|
||||
'impressions' => 100,
|
||||
'clicks' => 20,
|
||||
'saves' => 6,
|
||||
'ctr' => 0.2,
|
||||
'save_rate' => 0.3,
|
||||
'dwell_0_5' => 4,
|
||||
'dwell_5_30' => 8,
|
||||
'dwell_30_120' => 5,
|
||||
'dwell_120_plus' => 3,
|
||||
'created_at' => now(),
|
||||
'updated_at' => now(),
|
||||
],
|
||||
[
|
||||
'metric_date' => $metricDate,
|
||||
'algo_version' => 'clip-cosine-v2',
|
||||
'source' => 'personalized',
|
||||
'impressions' => 100,
|
||||
'clicks' => 25,
|
||||
'saves' => 10,
|
||||
'ctr' => 0.25,
|
||||
'save_rate' => 0.4,
|
||||
'dwell_0_5' => 3,
|
||||
'dwell_5_30' => 8,
|
||||
'dwell_30_120' => 8,
|
||||
'dwell_120_plus' => 6,
|
||||
'created_at' => now(),
|
||||
'updated_at' => now(),
|
||||
],
|
||||
]);
|
||||
|
||||
$comparison = app(FeedOfflineEvaluationService::class)
|
||||
->compareBaselineCandidate('clip-cosine-v1', 'clip-cosine-v2', $metricDate, $metricDate);
|
||||
|
||||
expect((float) $comparison['delta']['objective_score'])->toBeGreaterThan(0.0);
|
||||
expect((float) $comparison['delta']['ctr'])->toBeGreaterThan(0.0);
|
||||
expect((float) $comparison['delta']['save_rate'])->toBeGreaterThan(0.0);
|
||||
});
|
||||
|
||||
it('treats save_rate as informational when configured', function () {
|
||||
$metricDate = now()->subDay()->toDateString();
|
||||
|
||||
config()->set('discovery.evaluation.objective_weights', [
|
||||
'ctr' => 0.45,
|
||||
'save_rate' => 0.35,
|
||||
'long_dwell_share' => 0.25,
|
||||
'bounce_rate_penalty' => 0.15,
|
||||
]);
|
||||
config()->set('discovery.evaluation.save_rate_informational', true);
|
||||
|
||||
DB::table('feed_daily_metrics')->insert([
|
||||
'metric_date' => $metricDate,
|
||||
'algo_version' => 'clip-cosine-v1',
|
||||
'source' => 'personalized',
|
||||
'impressions' => 100,
|
||||
'clicks' => 20,
|
||||
'saves' => 8,
|
||||
'ctr' => 0.2,
|
||||
'save_rate' => 0.4,
|
||||
'dwell_0_5' => 3,
|
||||
'dwell_5_30' => 7,
|
||||
'dwell_30_120' => 6,
|
||||
'dwell_120_plus' => 4,
|
||||
'created_at' => now(),
|
||||
'updated_at' => now(),
|
||||
]);
|
||||
|
||||
$result = app(FeedOfflineEvaluationService::class)->evaluateAlgo('clip-cosine-v1', $metricDate, $metricDate);
|
||||
|
||||
expect((float) $result['save_rate'])->toBe(0.4);
|
||||
expect((float) $result['objective_score'])->toBe(0.226471);
|
||||
});
|
||||
@@ -0,0 +1,76 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Models\Artwork;
|
||||
use App\Models\User;
|
||||
use App\Models\UserRecommendationCache;
|
||||
use App\Services\Recommendations\PersonalizedFeedService;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Tests\TestCase;
|
||||
|
||||
uses(TestCase::class, RefreshDatabase::class);
|
||||
|
||||
it('regenerates recommendation cache with items and expiry', function () {
|
||||
$user = User::factory()->create();
|
||||
|
||||
$artworkA = Artwork::factory()->create(['is_public' => true, 'is_approved' => true, 'published_at' => now()->subMinutes(2)]);
|
||||
$artworkB = Artwork::factory()->create(['is_public' => true, 'is_approved' => true, 'published_at' => now()->subMinutes(1)]);
|
||||
|
||||
DB::table('artwork_stats')->insert([
|
||||
['artwork_id' => $artworkA->id, 'views' => 120, 'downloads' => 30, 'favorites' => 2, 'rating_avg' => 0, 'rating_count' => 0],
|
||||
['artwork_id' => $artworkB->id, 'views' => 100, 'downloads' => 20, 'favorites' => 1, 'rating_avg' => 0, 'rating_count' => 0],
|
||||
]);
|
||||
|
||||
app(PersonalizedFeedService::class)->regenerateCacheForUser($user->id, (string) config('discovery.algo_version'));
|
||||
|
||||
$cache = UserRecommendationCache::query()
|
||||
->where('user_id', $user->id)
|
||||
->where('algo_version', (string) config('discovery.algo_version'))
|
||||
->first();
|
||||
|
||||
expect($cache)->not->toBeNull();
|
||||
expect($cache?->generated_at)->not->toBeNull();
|
||||
expect($cache?->expires_at)->not->toBeNull();
|
||||
|
||||
$items = (array) ($cache?->recommendations_json['items'] ?? []);
|
||||
expect(count($items))->toBeGreaterThan(0);
|
||||
expect((int) ($items[0]['artwork_id'] ?? 0))->toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('uses rollout gate g100 to select candidate algo version', function () {
|
||||
$user = User::factory()->create();
|
||||
|
||||
config()->set('discovery.rollout.enabled', true);
|
||||
config()->set('discovery.rollout.baseline_algo_version', 'clip-cosine-v1');
|
||||
config()->set('discovery.rollout.candidate_algo_version', 'clip-cosine-v2');
|
||||
config()->set('discovery.rollout.active_gate', 'g100');
|
||||
config()->set('discovery.rollout.gates.g100.percentage', 100);
|
||||
config()->set('discovery.rollout.force_algo_version', '');
|
||||
|
||||
app(PersonalizedFeedService::class)->regenerateCacheForUser($user->id);
|
||||
|
||||
$cache = UserRecommendationCache::query()->where('user_id', $user->id)->first();
|
||||
|
||||
expect($cache)->not->toBeNull();
|
||||
expect((string) $cache?->algo_version)->toBe('clip-cosine-v2');
|
||||
});
|
||||
|
||||
it('forces rollback algo version when force toggle is set', function () {
|
||||
$user = User::factory()->create();
|
||||
|
||||
config()->set('discovery.rollout.enabled', true);
|
||||
config()->set('discovery.rollout.baseline_algo_version', 'clip-cosine-v1');
|
||||
config()->set('discovery.rollout.candidate_algo_version', 'clip-cosine-v2');
|
||||
config()->set('discovery.rollout.active_gate', 'g100');
|
||||
config()->set('discovery.rollout.gates.g100.percentage', 100);
|
||||
config()->set('discovery.rollout.force_algo_version', 'clip-cosine-v1');
|
||||
|
||||
app(PersonalizedFeedService::class)->regenerateCacheForUser($user->id);
|
||||
|
||||
$cache = UserRecommendationCache::query()->where('user_id', $user->id)->first();
|
||||
|
||||
expect($cache)->not->toBeNull();
|
||||
expect((string) $cache?->algo_version)->toBe('clip-cosine-v1');
|
||||
});
|
||||
@@ -0,0 +1,86 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Models\Artwork;
|
||||
use App\Models\Category;
|
||||
use App\Models\ContentType;
|
||||
use App\Models\User;
|
||||
use App\Services\Recommendations\UserInterestProfileService;
|
||||
use Carbon\CarbonImmutable;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Tests\TestCase;
|
||||
|
||||
uses(TestCase::class, RefreshDatabase::class);
|
||||
|
||||
it('applies recency decay and normalizes profile scores', function () {
|
||||
config()->set('discovery.decay.half_life_hours', 72);
|
||||
config()->set('discovery.weights.view', 1.0);
|
||||
|
||||
$service = app(UserInterestProfileService::class);
|
||||
|
||||
$user = User::factory()->create();
|
||||
|
||||
$contentType = ContentType::create([
|
||||
'name' => 'Digital Art',
|
||||
'slug' => 'digital-art',
|
||||
'description' => 'Digital artworks',
|
||||
]);
|
||||
|
||||
$categoryA = Category::create([
|
||||
'content_type_id' => $contentType->id,
|
||||
'parent_id' => null,
|
||||
'name' => 'Sci-Fi',
|
||||
'slug' => 'sci-fi',
|
||||
'description' => 'Sci-Fi category',
|
||||
'is_active' => true,
|
||||
'sort_order' => 0,
|
||||
]);
|
||||
|
||||
$categoryB = Category::create([
|
||||
'content_type_id' => $contentType->id,
|
||||
'parent_id' => null,
|
||||
'name' => 'Fantasy',
|
||||
'slug' => 'fantasy',
|
||||
'description' => 'Fantasy category',
|
||||
'is_active' => true,
|
||||
'sort_order' => 0,
|
||||
]);
|
||||
|
||||
$artworkA = Artwork::factory()->create();
|
||||
$artworkB = Artwork::factory()->create();
|
||||
|
||||
$t0 = CarbonImmutable::parse('2026-02-14 00:00:00');
|
||||
|
||||
$service->applyEvent(
|
||||
userId: $user->id,
|
||||
eventType: 'view',
|
||||
artworkId: $artworkA->id,
|
||||
categoryId: $categoryA->id,
|
||||
occurredAt: $t0,
|
||||
eventId: '11111111-1111-1111-1111-111111111111',
|
||||
algoVersion: 'clip-cosine-v1'
|
||||
);
|
||||
|
||||
$service->applyEvent(
|
||||
userId: $user->id,
|
||||
eventType: 'view',
|
||||
artworkId: $artworkB->id,
|
||||
categoryId: $categoryB->id,
|
||||
occurredAt: $t0->addHours(72),
|
||||
eventId: '22222222-2222-2222-2222-222222222222',
|
||||
algoVersion: 'clip-cosine-v1'
|
||||
);
|
||||
|
||||
$profile = \App\Models\UserInterestProfile::query()->where('user_id', $user->id)->firstOrFail();
|
||||
|
||||
expect((int) $profile->event_count)->toBe(2);
|
||||
|
||||
$normalized = (array) $profile->normalized_scores_json;
|
||||
|
||||
expect($normalized)->toHaveKey('category:' . $categoryA->id);
|
||||
expect($normalized)->toHaveKey('category:' . $categoryB->id);
|
||||
|
||||
expect((float) $normalized['category:' . $categoryA->id])->toBeGreaterThan(0.30)->toBeLessThan(0.35);
|
||||
expect((float) $normalized['category:' . $categoryB->id])->toBeGreaterThan(0.65)->toBeLessThan(0.70);
|
||||
});
|
||||
@@ -0,0 +1,298 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Services\EarlyGrowth\AdaptiveTimeWindow;
|
||||
use App\Services\EarlyGrowth\EarlyGrowth;
|
||||
use App\Services\EarlyGrowth\FeedBlender;
|
||||
use App\Services\EarlyGrowth\GridFiller;
|
||||
use App\Services\EarlyGrowth\SpotlightEngineInterface;
|
||||
use Illuminate\Pagination\LengthAwarePaginator;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Tests\TestCase;
|
||||
|
||||
uses(TestCase::class);
|
||||
|
||||
// ─── EarlyGrowth feature-flag guard ──────────────────────────────────────────
|
||||
|
||||
it('EarlyGrowth::enabled returns false when config disabled', function () {
|
||||
config()->set('early_growth.enabled', false);
|
||||
config()->set('early_growth.mode', 'light');
|
||||
|
||||
expect(EarlyGrowth::enabled())->toBeFalse();
|
||||
});
|
||||
|
||||
it('EarlyGrowth::enabled returns false when mode is off', function () {
|
||||
config()->set('early_growth.enabled', true);
|
||||
config()->set('early_growth.mode', 'off');
|
||||
|
||||
expect(EarlyGrowth::enabled())->toBeFalse();
|
||||
});
|
||||
|
||||
it('EarlyGrowth::enabled returns true when enabled and mode is light', function () {
|
||||
config()->set('early_growth.enabled', true);
|
||||
config()->set('early_growth.mode', 'light');
|
||||
config()->set('early_growth.auto_disable.enabled', false);
|
||||
|
||||
expect(EarlyGrowth::enabled())->toBeTrue();
|
||||
});
|
||||
|
||||
it('EarlyGrowth::enabled returns true when mode is aggressive', function () {
|
||||
config()->set('early_growth.enabled', true);
|
||||
config()->set('early_growth.mode', 'aggressive');
|
||||
config()->set('early_growth.auto_disable.enabled', false);
|
||||
|
||||
expect(EarlyGrowth::enabled())->toBeTrue();
|
||||
});
|
||||
|
||||
it('EarlyGrowth::mode rejects unknown values and returns off', function () {
|
||||
config()->set('early_growth.mode', 'extreme_turbo');
|
||||
|
||||
expect(EarlyGrowth::mode())->toBe('off');
|
||||
});
|
||||
|
||||
it('EarlyGrowth::status returns all keys', function () {
|
||||
config()->set('early_growth.enabled', false);
|
||||
config()->set('early_growth.mode', 'off');
|
||||
|
||||
$status = EarlyGrowth::status();
|
||||
|
||||
expect($status)->toHaveKeys(['enabled', 'mode', 'adaptive_window', 'grid_filler', 'spotlight', 'activity_layer']);
|
||||
});
|
||||
|
||||
it('module toggles are false when EGS is disabled', function () {
|
||||
config()->set('early_growth.enabled', false);
|
||||
|
||||
expect(EarlyGrowth::adaptiveWindowEnabled())->toBeFalse();
|
||||
expect(EarlyGrowth::gridFillerEnabled())->toBeFalse();
|
||||
expect(EarlyGrowth::spotlightEnabled())->toBeFalse();
|
||||
expect(EarlyGrowth::activityLayerEnabled())->toBeFalse();
|
||||
});
|
||||
|
||||
it('module toggles respect individual flags when EGS is on', function () {
|
||||
config()->set('early_growth.enabled', true);
|
||||
config()->set('early_growth.mode', 'light');
|
||||
config()->set('early_growth.auto_disable.enabled', false);
|
||||
config()->set('early_growth.adaptive_time_window', false);
|
||||
config()->set('early_growth.grid_filler', true);
|
||||
config()->set('early_growth.spotlight', false);
|
||||
config()->set('early_growth.activity_layer', true);
|
||||
|
||||
expect(EarlyGrowth::adaptiveWindowEnabled())->toBeFalse();
|
||||
expect(EarlyGrowth::gridFillerEnabled())->toBeTrue();
|
||||
expect(EarlyGrowth::spotlightEnabled())->toBeFalse();
|
||||
expect(EarlyGrowth::activityLayerEnabled())->toBeTrue();
|
||||
});
|
||||
|
||||
it('EarlyGrowth returns correct blend ratios per mode', function () {
|
||||
config()->set('early_growth.enabled', true);
|
||||
config()->set('early_growth.mode', 'light');
|
||||
config()->set('early_growth.auto_disable.enabled', false);
|
||||
config()->set('early_growth.blend_ratios.light', ['fresh' => 0.60, 'curated' => 0.25, 'spotlight' => 0.15]);
|
||||
|
||||
$ratios = EarlyGrowth::blendRatios();
|
||||
|
||||
expect($ratios['fresh'])->toBe(0.60);
|
||||
expect($ratios['curated'])->toBe(0.25);
|
||||
expect($ratios['spotlight'])->toBe(0.15);
|
||||
});
|
||||
|
||||
// ─── AdaptiveTimeWindow ───────────────────────────────────────────────────────
|
||||
|
||||
it('AdaptiveTimeWindow returns default when EGS disabled', function () {
|
||||
config()->set('early_growth.enabled', false);
|
||||
|
||||
$tw = new AdaptiveTimeWindow();
|
||||
|
||||
expect($tw->getTrendingWindowDays(30))->toBe(30);
|
||||
expect($tw->getTrendingWindowDays(7))->toBe(7);
|
||||
});
|
||||
|
||||
it('AdaptiveTimeWindow expands to medium window when uploads below narrow threshold', function () {
|
||||
config()->set('early_growth.enabled', true);
|
||||
config()->set('early_growth.mode', 'light');
|
||||
config()->set('early_growth.auto_disable.enabled', false);
|
||||
config()->set('early_growth.adaptive_time_window', true);
|
||||
config()->set('early_growth.thresholds.uploads_per_day_narrow', 10);
|
||||
config()->set('early_growth.thresholds.uploads_per_day_wide', 3);
|
||||
config()->set('early_growth.thresholds.window_narrow_days', 7);
|
||||
config()->set('early_growth.thresholds.window_medium_days', 30);
|
||||
config()->set('early_growth.thresholds.window_wide_days', 90);
|
||||
|
||||
// Mock: 5 uploads/day (between narrow=10 and wide=3) → 30 days
|
||||
Cache::put('egs.uploads_per_day', 5.0, 60);
|
||||
|
||||
$tw = new AdaptiveTimeWindow();
|
||||
expect($tw->getTrendingWindowDays(7))->toBe(30);
|
||||
});
|
||||
|
||||
it('AdaptiveTimeWindow expands to wide window when uploads below wide threshold', function () {
|
||||
config()->set('early_growth.enabled', true);
|
||||
config()->set('early_growth.mode', 'light');
|
||||
config()->set('early_growth.auto_disable.enabled', false);
|
||||
config()->set('early_growth.adaptive_time_window', true);
|
||||
config()->set('early_growth.thresholds.uploads_per_day_narrow', 10);
|
||||
config()->set('early_growth.thresholds.uploads_per_day_wide', 3);
|
||||
config()->set('early_growth.thresholds.window_narrow_days', 7);
|
||||
config()->set('early_growth.thresholds.window_medium_days', 30);
|
||||
config()->set('early_growth.thresholds.window_wide_days', 90);
|
||||
|
||||
// Mock: 1 upload/day (below wide=3) → 90 days
|
||||
Cache::put('egs.uploads_per_day', 1.0, 60);
|
||||
|
||||
$tw = new AdaptiveTimeWindow();
|
||||
expect($tw->getTrendingWindowDays(7))->toBe(90);
|
||||
});
|
||||
|
||||
it('AdaptiveTimeWindow keeps narrow window when uploads above narrow threshold', function () {
|
||||
config()->set('early_growth.enabled', true);
|
||||
config()->set('early_growth.mode', 'light');
|
||||
config()->set('early_growth.auto_disable.enabled', false);
|
||||
config()->set('early_growth.adaptive_time_window', true);
|
||||
config()->set('early_growth.thresholds.uploads_per_day_narrow', 10);
|
||||
config()->set('early_growth.thresholds.uploads_per_day_wide', 3);
|
||||
config()->set('early_growth.thresholds.window_narrow_days', 7);
|
||||
config()->set('early_growth.thresholds.window_medium_days', 30);
|
||||
config()->set('early_growth.thresholds.window_wide_days', 90);
|
||||
|
||||
// Mock: 15 uploads/day (above narrow=10) → normal 7-day window
|
||||
Cache::put('egs.uploads_per_day', 15.0, 60);
|
||||
|
||||
$tw = new AdaptiveTimeWindow();
|
||||
expect($tw->getTrendingWindowDays(7))->toBe(7);
|
||||
});
|
||||
|
||||
// ─── GridFiller ───────────────────────────────────────────────────────────────
|
||||
|
||||
it('GridFiller does nothing when EGS disabled', function () {
|
||||
config()->set('early_growth.enabled', false);
|
||||
|
||||
$original = make_paginator(3);
|
||||
$gf = new GridFiller();
|
||||
|
||||
$result = $gf->fill($original, 12, 1);
|
||||
|
||||
expect($result->getCollection()->count())->toBe(3);
|
||||
});
|
||||
|
||||
it('GridFiller does not fill pages beyond page 1', function () {
|
||||
config()->set('early_growth.enabled', true);
|
||||
config()->set('early_growth.mode', 'light');
|
||||
config()->set('early_growth.auto_disable.enabled', false);
|
||||
config()->set('early_growth.grid_filler', true);
|
||||
|
||||
$original = make_paginator(3, perPage: 12, page: 2);
|
||||
$gf = new GridFiller();
|
||||
|
||||
$result = $gf->fill($original, 12, 2);
|
||||
|
||||
// Page > 1 → leave untouched
|
||||
expect($result->getCollection()->count())->toBe(3);
|
||||
});
|
||||
|
||||
it('GridFiller fillCollection does nothing when EGS disabled', function () {
|
||||
config()->set('early_growth.enabled', false);
|
||||
|
||||
$items = collect(range(1, 3))->map(fn ($i) => (object) ['id' => $i]);
|
||||
$gf = new GridFiller();
|
||||
|
||||
expect($gf->fillCollection($items, 12)->count())->toBe(3);
|
||||
});
|
||||
|
||||
// ─── FeedBlender ─────────────────────────────────────────────────────────────
|
||||
|
||||
it('FeedBlender returns original paginator when EGS disabled', function () {
|
||||
config()->set('early_growth.enabled', false);
|
||||
|
||||
$spotlight = Mockery::mock(SpotlightEngineInterface::class);
|
||||
$blender = new FeedBlender($spotlight);
|
||||
|
||||
$original = make_paginator(8);
|
||||
$result = $blender->blend($original, 24, 1);
|
||||
|
||||
expect($result->getCollection()->count())->toBe(8);
|
||||
});
|
||||
|
||||
it('FeedBlender returns original paginator on page > 1', function () {
|
||||
config()->set('early_growth.enabled', true);
|
||||
config()->set('early_growth.mode', 'light');
|
||||
config()->set('early_growth.auto_disable.enabled', false);
|
||||
|
||||
$spotlight = Mockery::mock(SpotlightEngineInterface::class);
|
||||
$blender = new FeedBlender($spotlight);
|
||||
|
||||
$original = make_paginator(8, page: 2);
|
||||
$result = $blender->blend($original, 24, 2);
|
||||
|
||||
expect($result->getCollection()->count())->toBe(8);
|
||||
});
|
||||
|
||||
it('FeedBlender preserves original total in blended paginator', function () {
|
||||
config()->set('early_growth.enabled', true);
|
||||
config()->set('early_growth.mode', 'light');
|
||||
config()->set('early_growth.auto_disable.enabled', false);
|
||||
config()->set('early_growth.blend_ratios.light', ['fresh' => 0.60, 'curated' => 0.25, 'spotlight' => 0.15]);
|
||||
|
||||
$spotlight = Mockery::mock(SpotlightEngineInterface::class);
|
||||
$spotlight->allows('getCurated')->andReturn(collect());
|
||||
$spotlight->allows('getSpotlight')->andReturn(collect());
|
||||
|
||||
$blender = new FeedBlender($spotlight);
|
||||
$original = make_paginator(6, total: 100);
|
||||
|
||||
$result = $blender->blend($original, 24, 1);
|
||||
|
||||
// Original total must be preserved so pagination links are stable
|
||||
expect($result->total())->toBe(100);
|
||||
});
|
||||
|
||||
it('FeedBlender removes duplicate IDs across sources', function () {
|
||||
config()->set('early_growth.enabled', true);
|
||||
config()->set('early_growth.mode', 'aggressive');
|
||||
config()->set('early_growth.auto_disable.enabled', false);
|
||||
config()->set('early_growth.blend_ratios.aggressive', ['fresh' => 0.30, 'curated' => 0.50, 'spotlight' => 0.20]);
|
||||
|
||||
// Curated returns some IDs that overlap with fresh
|
||||
$freshItems = collect(range(1, 10))->map(fn ($i) => make_artwork_stub($i));
|
||||
$curatedItems = collect(range(5, 15))->map(fn ($i) => make_artwork_stub($i)); // IDs 5-10 overlap
|
||||
$spotlightItems = collect(range(20, 25))->map(fn ($i) => make_artwork_stub($i));
|
||||
|
||||
$spotlight = Mockery::mock(SpotlightEngineInterface::class);
|
||||
$spotlight->allows('getCurated')->andReturn($curatedItems);
|
||||
$spotlight->allows('getSpotlight')->andReturn($spotlightItems);
|
||||
|
||||
$blender = new FeedBlender($spotlight);
|
||||
$original = make_paginator(10, total: 100, items: $freshItems);
|
||||
|
||||
$result = $blender->blend($original, 24, 1);
|
||||
$ids = $result->getCollection()->pluck('id')->toArray();
|
||||
$uniqueIds = array_unique($ids);
|
||||
|
||||
expect(count($ids))->toBe(count($uniqueIds));
|
||||
});
|
||||
|
||||
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
function make_paginator(
|
||||
int $count = 12,
|
||||
int $total = 0,
|
||||
int $perPage = 24,
|
||||
int $page = 1,
|
||||
?\Illuminate\Support\Collection $items = null,
|
||||
): LengthAwarePaginator {
|
||||
$collection = $items ?? collect(range(1, $count))->map(fn ($i) => make_artwork_stub($i));
|
||||
$total = $total > 0 ? $total : $count;
|
||||
|
||||
return new LengthAwarePaginator($collection->all(), $total, $perPage, $page, [
|
||||
'path' => '/discover/fresh',
|
||||
]);
|
||||
}
|
||||
|
||||
function make_artwork_stub(int $id): object
|
||||
{
|
||||
return (object) [
|
||||
'id' => $id,
|
||||
'title' => "Artwork {$id}",
|
||||
'published_at' => now()->subDays($id),
|
||||
];
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
<?php
|
||||
|
||||
test('that true is true', function () {
|
||||
expect(true)->toBeTrue();
|
||||
});
|
||||
@@ -0,0 +1,166 @@
|
||||
<?php
|
||||
|
||||
use App\Http\Middleware\ForumRateLimitMiddleware;
|
||||
use cPad\Plugins\Forum\Services\Security\BotProtectionService;
|
||||
use Illuminate\Http\Exceptions\ThrottleRequestsException;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Routing\Middleware\ThrottleRequests;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
uses(Tests\TestCase::class);
|
||||
|
||||
it('reports forum throttle violations to bot protection before rethrowing', function () {
|
||||
$request = Request::create('/forum/topic/example-topic/reply', 'POST');
|
||||
$request->setRouteResolver(static fn (): object => new class {
|
||||
public function getName(): string
|
||||
{
|
||||
return 'forum.topic.reply';
|
||||
}
|
||||
});
|
||||
|
||||
$throttle = \Mockery::mock(ThrottleRequests::class);
|
||||
$botProtection = \Mockery::mock(BotProtectionService::class);
|
||||
|
||||
$exception = new ThrottleRequestsException('Too Many Attempts.', null, [
|
||||
'Retry-After' => '42',
|
||||
'X-RateLimit-Limit' => '3',
|
||||
'X-RateLimit-Remaining' => '0',
|
||||
]);
|
||||
|
||||
$throttle->shouldReceive('handle')
|
||||
->once()
|
||||
->with($request, \Mockery::type(Closure::class), 'forum-post-write')
|
||||
->andThrow($exception);
|
||||
|
||||
$botProtection->shouldReceive('recordRateLimitViolation')
|
||||
->once()
|
||||
->with(
|
||||
$request,
|
||||
'forum_reply_create',
|
||||
\Mockery::on(static function (array $context): bool {
|
||||
return $context['limiter'] === 'forum-post-write'
|
||||
&& $context['bucket'] === 'minute'
|
||||
&& $context['max_attempts'] === 3
|
||||
&& $context['retry_after'] === 42;
|
||||
})
|
||||
);
|
||||
|
||||
$middleware = new ForumRateLimitMiddleware($throttle, $botProtection);
|
||||
|
||||
$next = static fn (): Response => response('ok');
|
||||
|
||||
$middleware->handle($request, $next);
|
||||
})->throws(ThrottleRequestsException::class);
|
||||
|
||||
it('classifies forum hourly limiter violations using the actual limit bucket', function () {
|
||||
$request = Request::create('/forum/topic/example-topic/reply', 'POST');
|
||||
$request->setRouteResolver(static fn (): object => new class {
|
||||
public function getName(): string
|
||||
{
|
||||
return 'forum.topic.reply';
|
||||
}
|
||||
});
|
||||
|
||||
$throttle = \Mockery::mock(ThrottleRequests::class);
|
||||
$botProtection = \Mockery::mock(BotProtectionService::class);
|
||||
|
||||
$exception = new ThrottleRequestsException('Too Many Attempts.', null, [
|
||||
'Retry-After' => '120',
|
||||
'X-RateLimit-Limit' => '10',
|
||||
'X-RateLimit-Remaining' => '0',
|
||||
]);
|
||||
|
||||
$throttle->shouldReceive('handle')
|
||||
->once()
|
||||
->with($request, \Mockery::type(Closure::class), 'forum-post-write')
|
||||
->andThrow($exception);
|
||||
|
||||
$botProtection->shouldReceive('recordRateLimitViolation')
|
||||
->once()
|
||||
->with(
|
||||
$request,
|
||||
'forum_reply_create',
|
||||
\Mockery::on(static function (array $context): bool {
|
||||
return $context['bucket'] === 'hour'
|
||||
&& $context['max_attempts'] === 10
|
||||
&& $context['retry_after'] === 120;
|
||||
})
|
||||
);
|
||||
|
||||
$middleware = new ForumRateLimitMiddleware($throttle, $botProtection);
|
||||
|
||||
$next = static fn (): Response => response('ok');
|
||||
|
||||
$middleware->handle($request, $next);
|
||||
})->throws(ThrottleRequestsException::class);
|
||||
|
||||
it('classifies thread creation minute and hour limiter buckets correctly', function () {
|
||||
$request = Request::create('/forum/example-board/new', 'POST');
|
||||
$request->setRouteResolver(static fn (): object => new class {
|
||||
public function getName(): string
|
||||
{
|
||||
return 'forum.topic.store';
|
||||
}
|
||||
});
|
||||
|
||||
$throttle = \Mockery::mock(ThrottleRequests::class);
|
||||
$botProtection = \Mockery::mock(BotProtectionService::class);
|
||||
|
||||
$minuteException = new ThrottleRequestsException('Too Many Attempts.', null, [
|
||||
'Retry-After' => '30',
|
||||
'X-RateLimit-Limit' => '3',
|
||||
'X-RateLimit-Remaining' => '0',
|
||||
]);
|
||||
|
||||
$hourException = new ThrottleRequestsException('Too Many Attempts.', null, [
|
||||
'Retry-After' => '120',
|
||||
'X-RateLimit-Limit' => '10',
|
||||
'X-RateLimit-Remaining' => '0',
|
||||
]);
|
||||
|
||||
$throttle->shouldReceive('handle')
|
||||
->once()
|
||||
->with($request, \Mockery::type(Closure::class), 'forum-thread-create')
|
||||
->andThrow($minuteException);
|
||||
|
||||
$throttle->shouldReceive('handle')
|
||||
->once()
|
||||
->with($request, \Mockery::type(Closure::class), 'forum-thread-create')
|
||||
->andThrow($hourException);
|
||||
|
||||
$botProtection->shouldReceive('recordRateLimitViolation')
|
||||
->once()
|
||||
->with(
|
||||
$request,
|
||||
'forum_topic_create',
|
||||
\Mockery::on(static function (array $context): bool {
|
||||
return $context['limiter'] === 'forum-thread-create'
|
||||
&& $context['bucket'] === 'minute'
|
||||
&& $context['max_attempts'] === 3
|
||||
&& $context['retry_after'] === 30;
|
||||
})
|
||||
);
|
||||
|
||||
$botProtection->shouldReceive('recordRateLimitViolation')
|
||||
->once()
|
||||
->with(
|
||||
$request,
|
||||
'forum_topic_create',
|
||||
\Mockery::on(static function (array $context): bool {
|
||||
return $context['limiter'] === 'forum-thread-create'
|
||||
&& $context['bucket'] === 'hour'
|
||||
&& $context['max_attempts'] === 10
|
||||
&& $context['retry_after'] === 120;
|
||||
})
|
||||
);
|
||||
|
||||
$middleware = new ForumRateLimitMiddleware($throttle, $botProtection);
|
||||
$next = static fn (): Response => response('ok');
|
||||
|
||||
try {
|
||||
$middleware->handle($request, $next);
|
||||
} catch (ThrottleRequestsException) {
|
||||
}
|
||||
|
||||
$middleware->handle($request, $next);
|
||||
})->throws(ThrottleRequestsException::class);
|
||||
@@ -0,0 +1,482 @@
|
||||
<?php
|
||||
|
||||
use App\Models\User;
|
||||
use cPad\Plugins\Forum\Models\ForumBoard;
|
||||
use cPad\Plugins\Forum\Models\ForumCategory;
|
||||
use cPad\Plugins\Forum\Models\ForumPost;
|
||||
use cPad\Plugins\Forum\Models\ForumTopic;
|
||||
use cPad\Plugins\Forum\Services\ForumModerationService;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Queue;
|
||||
use Illuminate\Support\Facades\RateLimiter;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
uses(Tests\TestCase::class);
|
||||
|
||||
it('enforces forum write rate limits for thread creation and replies', function () {
|
||||
ensureForumRateLimitModelClassesLoaded();
|
||||
|
||||
Queue::fake();
|
||||
|
||||
config()->set('forum_bot_protection.enabled', false);
|
||||
config()->set('forum_bot_protection.behavior.new_account_days', 0);
|
||||
config()->set('skinbase_ai_moderation.enabled', false);
|
||||
|
||||
$moderationService = \Mockery::mock(ForumModerationService::class);
|
||||
$moderationService->shouldReceive('preflight')->andReturnUsing(static function ($user, $content, $sourceIp): array {
|
||||
return [
|
||||
'spam_score' => 0,
|
||||
'quality_score' => 0,
|
||||
'ai_spam_score' => 0,
|
||||
'ai_toxicity_score' => 0,
|
||||
'behavior_score' => 0,
|
||||
'link_score' => 0,
|
||||
'learning_score' => 0,
|
||||
'risk_score' => 0,
|
||||
'trust_modifier' => 0,
|
||||
'decision' => 'allowed',
|
||||
'captcha_required' => false,
|
||||
'blocked' => false,
|
||||
'requires_review' => false,
|
||||
'flagged' => false,
|
||||
'reason' => null,
|
||||
'content_hash' => hash('sha256', (string) $content),
|
||||
'pattern_signature' => null,
|
||||
'source_ip_hash' => $sourceIp ? hash('sha256', $sourceIp) : null,
|
||||
'moderation_labels' => ['preflight', 'allowed'],
|
||||
'provider' => 'none',
|
||||
'provider_available' => false,
|
||||
'language' => null,
|
||||
];
|
||||
});
|
||||
$moderationService->shouldReceive('applyPreflightAssessment')->andReturnUsing(static function (ForumPost $post, array $assessment): void {
|
||||
$post->forceFill([
|
||||
'source_ip_hash' => $assessment['source_ip_hash'] ?? $post->source_ip_hash,
|
||||
'content_hash' => $assessment['content_hash'] ?? $post->content_hash,
|
||||
'spam_score' => (int) ($assessment['spam_score'] ?? 0),
|
||||
'quality_score' => (int) ($assessment['quality_score'] ?? 0),
|
||||
'ai_spam_score' => (int) ($assessment['ai_spam_score'] ?? 0),
|
||||
'ai_toxicity_score' => (int) ($assessment['ai_toxicity_score'] ?? 0),
|
||||
'behavior_score' => (int) ($assessment['behavior_score'] ?? 0),
|
||||
'link_score' => (int) ($assessment['link_score'] ?? 0),
|
||||
'learning_score' => (int) ($assessment['learning_score'] ?? 0),
|
||||
'risk_score' => (int) ($assessment['risk_score'] ?? 0),
|
||||
'trust_modifier' => (int) ($assessment['trust_modifier'] ?? 0),
|
||||
'flagged' => (bool) ($assessment['flagged'] ?? false),
|
||||
'flagged_reason' => $assessment['reason'] ?? null,
|
||||
'moderation_checked' => false,
|
||||
'moderation_status' => 'pending_ai_scan',
|
||||
'moderation_labels' => (array) ($assessment['moderation_labels'] ?? []),
|
||||
'moderation_meta' => [
|
||||
'provider' => $assessment['provider'] ?? 'none',
|
||||
'provider_available' => (bool) ($assessment['provider_available'] ?? false),
|
||||
'language' => $assessment['language'] ?? null,
|
||||
],
|
||||
])->save();
|
||||
});
|
||||
$moderationService->shouldReceive('logRequestSecurity')->andReturnNull();
|
||||
$moderationService->shouldReceive('dispatchAsyncScan')->andReturnNull();
|
||||
|
||||
$this->app->instance(ForumModerationService::class, $moderationService);
|
||||
|
||||
createForumRateLimitTestSchema();
|
||||
|
||||
$user = User::query()->create([
|
||||
'username' => 'ratelimit-user',
|
||||
'username_changed_at' => now()->subDays(120),
|
||||
'last_username_change_at' => now()->subDays(120),
|
||||
'onboarding_step' => 'complete',
|
||||
'name' => 'Rate Limit User',
|
||||
'email' => 'ratelimit@example.com',
|
||||
'email_verified_at' => now(),
|
||||
'password' => 'password',
|
||||
'is_active' => true,
|
||||
]);
|
||||
|
||||
markForumRateLimitUserAsEstablished($user);
|
||||
|
||||
$board = makeForumBoard('thread-limit');
|
||||
|
||||
clearForumRateLimiters((string) $user->id);
|
||||
|
||||
for ($attempt = 1; $attempt <= 3; $attempt++) {
|
||||
$response = $this->actingAs($user)->post(route('forum.topic.store', ['boardSlug' => $board->slug]), [
|
||||
'title' => 'Rate limit topic ' . $attempt,
|
||||
'content' => 'Thread body ' . $attempt,
|
||||
]);
|
||||
|
||||
$response->assertRedirect();
|
||||
}
|
||||
|
||||
$this->actingAs($user)->post(route('forum.topic.store', ['boardSlug' => $board->slug]), [
|
||||
'title' => 'Rate limit topic 4',
|
||||
'content' => 'Thread body 4',
|
||||
])->assertStatus(429);
|
||||
|
||||
expect(ForumTopic::query()->count())->toBe(3)
|
||||
->and(ForumPost::query()->count())->toBe(3);
|
||||
|
||||
clearForumRateLimiters((string) $user->id);
|
||||
|
||||
$replyUser = User::query()->create([
|
||||
'username' => 'reply-limit-user',
|
||||
'username_changed_at' => now()->subDays(120),
|
||||
'last_username_change_at' => now()->subDays(120),
|
||||
'onboarding_step' => 'complete',
|
||||
'name' => 'Reply Limit User',
|
||||
'email' => 'replylimit@example.com',
|
||||
'email_verified_at' => now(),
|
||||
'password' => 'password',
|
||||
'is_active' => true,
|
||||
]);
|
||||
|
||||
markForumRateLimitUserAsEstablished($replyUser);
|
||||
|
||||
clearForumRateLimiters((string) $replyUser->id);
|
||||
|
||||
$topic = makeForumTopic($replyUser);
|
||||
|
||||
for ($attempt = 1; $attempt <= 3; $attempt++) {
|
||||
$response = $this->actingAs($replyUser)->post(route('forum.topic.reply', ['topic' => $topic->slug]), [
|
||||
'content' => 'Reply burst ' . $attempt,
|
||||
]);
|
||||
|
||||
$response->assertRedirect();
|
||||
}
|
||||
|
||||
$this->actingAs($replyUser)->post(route('forum.topic.reply', ['topic' => $topic->slug]), [
|
||||
'content' => 'Reply burst 4',
|
||||
])->assertStatus(429);
|
||||
|
||||
expect(ForumPost::query()->where('topic_id', $topic->id)->count())->toBe(4);
|
||||
|
||||
clearForumRateLimiters((string) $user->id);
|
||||
clearForumRateLimiters((string) $replyUser->id);
|
||||
});
|
||||
|
||||
function createForumRateLimitTestSchema(): void
|
||||
{
|
||||
foreach ([
|
||||
'forum_security_logs',
|
||||
'forum_firewall_logs',
|
||||
'forum_bot_ip_blacklist',
|
||||
'forum_spam_signatures',
|
||||
'forum_spam_learning',
|
||||
'forum_spam_domains',
|
||||
'forum_spam_keywords',
|
||||
'forum_topic_tags',
|
||||
'forum_tags',
|
||||
'forum_posts',
|
||||
'forum_topics',
|
||||
'forum_threads',
|
||||
'forum_boards',
|
||||
'forum_categories',
|
||||
'users',
|
||||
] as $table) {
|
||||
Schema::dropIfExists($table);
|
||||
}
|
||||
|
||||
Schema::create('forum_bot_ip_blacklist', function (Blueprint $table): void {
|
||||
$table->id();
|
||||
$table->string('ip_address', 45)->unique();
|
||||
$table->string('reason', 255)->nullable();
|
||||
$table->unsignedTinyInteger('risk_score')->default(100);
|
||||
$table->timestamp('expires_at')->nullable();
|
||||
$table->timestamp('created_at')->nullable();
|
||||
});
|
||||
|
||||
Schema::create('forum_firewall_logs', function (Blueprint $table): void {
|
||||
$table->id();
|
||||
$table->unsignedBigInteger('user_id')->nullable();
|
||||
$table->string('ip_address', 45)->nullable();
|
||||
$table->string('action', 80);
|
||||
$table->unsignedTinyInteger('risk_score')->default(0);
|
||||
$table->string('decision', 20)->default('allow');
|
||||
$table->string('threat_type', 80)->nullable();
|
||||
$table->string('reason', 255)->nullable();
|
||||
$table->json('metadata')->nullable();
|
||||
$table->timestamps();
|
||||
});
|
||||
|
||||
Schema::create('forum_security_logs', function (Blueprint $table): void {
|
||||
$table->id();
|
||||
$table->unsignedBigInteger('user_id')->nullable();
|
||||
$table->unsignedBigInteger('post_id')->nullable();
|
||||
$table->string('ip_address', 45)->nullable();
|
||||
$table->string('action', 80);
|
||||
$table->unsignedTinyInteger('risk_score')->default(0);
|
||||
$table->string('decision', 20)->default('allow');
|
||||
$table->string('reason', 255)->nullable();
|
||||
$table->json('layer_scores')->nullable();
|
||||
$table->json('metadata')->nullable();
|
||||
$table->timestamps();
|
||||
});
|
||||
|
||||
Schema::create('forum_spam_signatures', function (Blueprint $table): void {
|
||||
$table->id();
|
||||
$table->string('content_hash', 64)->nullable()->index();
|
||||
$table->string('pattern_signature', 191)->nullable()->index();
|
||||
$table->string('source', 32)->nullable();
|
||||
$table->string('reason', 255)->nullable();
|
||||
$table->unsignedInteger('confidence')->default(0);
|
||||
$table->unsignedBigInteger('reviewed_by')->nullable();
|
||||
$table->json('metadata')->nullable();
|
||||
$table->timestamp('created_at')->nullable();
|
||||
});
|
||||
|
||||
Schema::create('users', function (Blueprint $table): void {
|
||||
$table->id();
|
||||
$table->string('username')->nullable();
|
||||
$table->timestamp('username_changed_at')->nullable();
|
||||
$table->timestamp('last_username_change_at')->nullable();
|
||||
$table->string('onboarding_step')->nullable();
|
||||
$table->string('name')->nullable();
|
||||
$table->string('email')->nullable();
|
||||
$table->timestamp('email_verified_at')->nullable();
|
||||
$table->string('password')->nullable();
|
||||
$table->boolean('is_active')->default(true);
|
||||
$table->unsignedInteger('trust_score')->default(0);
|
||||
$table->unsignedInteger('approved_posts')->default(0);
|
||||
$table->unsignedInteger('flagged_posts')->default(0);
|
||||
$table->string('role')->nullable();
|
||||
$table->rememberToken();
|
||||
$table->timestamps();
|
||||
$table->softDeletes();
|
||||
});
|
||||
|
||||
Schema::create('forum_categories', function (Blueprint $table): void {
|
||||
$table->id();
|
||||
$table->string('name')->nullable();
|
||||
$table->string('title')->nullable();
|
||||
$table->string('slug')->unique();
|
||||
$table->text('description')->nullable();
|
||||
$table->unsignedBigInteger('parent_id')->nullable();
|
||||
$table->integer('position')->default(0);
|
||||
$table->boolean('is_active')->default(true);
|
||||
$table->timestamps();
|
||||
});
|
||||
|
||||
Schema::create('forum_boards', function (Blueprint $table): void {
|
||||
$table->id();
|
||||
$table->unsignedBigInteger('category_id');
|
||||
$table->unsignedBigInteger('legacy_category_id')->nullable();
|
||||
$table->string('title');
|
||||
$table->string('slug')->unique();
|
||||
$table->text('description')->nullable();
|
||||
$table->string('icon')->nullable();
|
||||
$table->string('image')->nullable();
|
||||
$table->integer('position')->default(0);
|
||||
$table->boolean('is_active')->default(true);
|
||||
$table->boolean('is_read_only')->default(false);
|
||||
$table->timestamps();
|
||||
});
|
||||
|
||||
Schema::create('forum_threads', function (Blueprint $table): void {
|
||||
$table->id();
|
||||
$table->unsignedBigInteger('category_id');
|
||||
$table->unsignedBigInteger('user_id');
|
||||
$table->string('title');
|
||||
$table->string('slug')->unique();
|
||||
$table->longText('content');
|
||||
$table->unsignedInteger('views')->default(0);
|
||||
$table->boolean('is_locked')->default(false);
|
||||
$table->boolean('is_pinned')->default(false);
|
||||
$table->string('visibility')->default('public');
|
||||
$table->timestamp('last_post_at')->nullable();
|
||||
$table->timestamps();
|
||||
$table->softDeletes();
|
||||
});
|
||||
|
||||
Schema::create('forum_topics', function (Blueprint $table): void {
|
||||
$table->id();
|
||||
$table->unsignedBigInteger('board_id');
|
||||
$table->unsignedBigInteger('user_id');
|
||||
$table->unsignedBigInteger('artwork_id')->nullable();
|
||||
$table->unsignedBigInteger('legacy_thread_id')->nullable();
|
||||
$table->string('title');
|
||||
$table->string('slug')->unique();
|
||||
$table->unsignedInteger('views')->default(0);
|
||||
$table->unsignedInteger('replies_count')->default(0);
|
||||
$table->boolean('is_pinned')->default(false);
|
||||
$table->boolean('is_locked')->default(false);
|
||||
$table->boolean('is_deleted')->default(false);
|
||||
$table->unsignedBigInteger('last_post_id')->nullable();
|
||||
$table->timestamp('last_post_at')->nullable();
|
||||
$table->timestamps();
|
||||
});
|
||||
|
||||
Schema::create('forum_posts', function (Blueprint $table): void {
|
||||
$table->id();
|
||||
$table->unsignedBigInteger('thread_id');
|
||||
$table->unsignedBigInteger('topic_id')->nullable();
|
||||
$table->string('source_ip_hash', 64)->nullable();
|
||||
$table->unsignedBigInteger('user_id');
|
||||
$table->longText('content');
|
||||
$table->string('content_hash', 64)->nullable();
|
||||
$table->boolean('is_edited')->default(false);
|
||||
$table->timestamp('edited_at')->nullable();
|
||||
$table->unsignedInteger('spam_score')->default(0);
|
||||
$table->unsignedInteger('quality_score')->default(0);
|
||||
$table->unsignedInteger('ai_spam_score')->default(0);
|
||||
$table->unsignedInteger('ai_toxicity_score')->default(0);
|
||||
$table->unsignedInteger('behavior_score')->default(0);
|
||||
$table->unsignedInteger('link_score')->default(0);
|
||||
$table->integer('learning_score')->default(0);
|
||||
$table->unsignedInteger('risk_score')->default(0);
|
||||
$table->integer('trust_modifier')->default(0);
|
||||
$table->boolean('flagged')->default(false);
|
||||
$table->string('flagged_reason')->nullable();
|
||||
$table->boolean('moderation_checked')->default(false);
|
||||
$table->string('moderation_status')->nullable();
|
||||
$table->json('moderation_labels')->nullable();
|
||||
$table->json('moderation_meta')->nullable();
|
||||
$table->timestamp('last_ai_scan_at')->nullable();
|
||||
$table->timestamps();
|
||||
$table->softDeletes();
|
||||
});
|
||||
|
||||
Schema::create('forum_tags', function (Blueprint $table): void {
|
||||
$table->id();
|
||||
$table->string('name')->unique();
|
||||
$table->string('slug')->unique();
|
||||
$table->timestamps();
|
||||
});
|
||||
|
||||
Schema::create('forum_topic_tags', function (Blueprint $table): void {
|
||||
$table->id();
|
||||
$table->unsignedBigInteger('topic_id');
|
||||
$table->unsignedBigInteger('tag_id');
|
||||
$table->timestamps();
|
||||
|
||||
$table->unique(['topic_id', 'tag_id']);
|
||||
});
|
||||
|
||||
Schema::create('forum_spam_domains', function (Blueprint $table): void {
|
||||
$table->id();
|
||||
$table->string('domain')->unique();
|
||||
$table->timestamps();
|
||||
});
|
||||
|
||||
Schema::create('forum_spam_keywords', function (Blueprint $table): void {
|
||||
$table->id();
|
||||
$table->string('keyword', 120)->unique();
|
||||
$table->timestamp('created_at')->nullable();
|
||||
});
|
||||
|
||||
Schema::create('forum_spam_learning', function (Blueprint $table): void {
|
||||
$table->id();
|
||||
$table->string('content_hash', 64)->index();
|
||||
$table->string('decision', 32);
|
||||
$table->string('pattern_signature', 191)->nullable();
|
||||
$table->unsignedBigInteger('reviewed_by')->nullable();
|
||||
$table->json('metadata')->nullable();
|
||||
$table->timestamps();
|
||||
});
|
||||
}
|
||||
|
||||
function ensureForumRateLimitModelClassesLoaded(): void
|
||||
{
|
||||
foreach ([
|
||||
'packages/klevze/Plugins/Forum/Models/ForumPost.php',
|
||||
'packages/klevze/Plugins/Forum/Models/ForumSpamLearning.php',
|
||||
'packages/klevze/Plugins/Forum/Models/ForumAiLog.php',
|
||||
'packages/klevze/Plugins/Forum/Models/ForumModerationQueue.php',
|
||||
] as $relativePath) {
|
||||
require_once base_path($relativePath);
|
||||
}
|
||||
}
|
||||
|
||||
function makeForumBoard(string $suffix): ForumBoard
|
||||
{
|
||||
$category = ForumCategory::query()->create([
|
||||
'name' => 'Rate Limit Category ' . $suffix,
|
||||
'title' => 'Rate Limit Category ' . $suffix,
|
||||
'slug' => 'rate-limit-category-' . $suffix,
|
||||
'description' => 'Test category',
|
||||
'position' => 1,
|
||||
'is_active' => true,
|
||||
'parent_id' => null,
|
||||
]);
|
||||
|
||||
return ForumBoard::query()->create([
|
||||
'category_id' => $category->id,
|
||||
'title' => 'Rate Limit Board ' . $suffix,
|
||||
'slug' => 'rate-limit-board-' . $suffix,
|
||||
'description' => 'Test board',
|
||||
'position' => 1,
|
||||
'is_active' => true,
|
||||
'is_read_only' => false,
|
||||
]);
|
||||
}
|
||||
|
||||
function makeForumTopic(User $user): ForumTopic
|
||||
{
|
||||
$board = makeForumBoard('reply-limit');
|
||||
|
||||
$legacyThreadId = DB::table('forum_threads')->insertGetId([
|
||||
'category_id' => $board->category_id,
|
||||
'user_id' => $user->id,
|
||||
'title' => 'Existing topic',
|
||||
'slug' => 'existing-topic-reply-limit',
|
||||
'content' => 'Opening post',
|
||||
'views' => 0,
|
||||
'is_locked' => false,
|
||||
'is_pinned' => false,
|
||||
'visibility' => 'public',
|
||||
'last_post_at' => now(),
|
||||
'created_at' => now(),
|
||||
'updated_at' => now(),
|
||||
]);
|
||||
|
||||
$topic = ForumTopic::query()->create([
|
||||
'board_id' => $board->id,
|
||||
'user_id' => $user->id,
|
||||
'legacy_thread_id' => $legacyThreadId,
|
||||
'title' => 'Existing topic',
|
||||
'slug' => 'existing-topic-reply-limit',
|
||||
'views' => 0,
|
||||
'replies_count' => 0,
|
||||
'is_pinned' => false,
|
||||
'is_locked' => false,
|
||||
'is_deleted' => false,
|
||||
'last_post_at' => now(),
|
||||
]);
|
||||
|
||||
$post = ForumPost::query()->create([
|
||||
'thread_id' => $legacyThreadId,
|
||||
'topic_id' => $topic->id,
|
||||
'user_id' => $user->id,
|
||||
'content' => 'Opening post',
|
||||
'is_edited' => false,
|
||||
]);
|
||||
|
||||
$topic->forceFill([
|
||||
'last_post_id' => $post->id,
|
||||
'last_post_at' => $post->created_at,
|
||||
])->save();
|
||||
|
||||
return $topic;
|
||||
}
|
||||
|
||||
function clearForumRateLimiters(string $key): void
|
||||
{
|
||||
foreach ([
|
||||
'forum-thread-minute:' . $key,
|
||||
'forum-thread-hour:' . $key,
|
||||
'forum-post-minute:' . $key,
|
||||
'forum-post-hour:' . $key,
|
||||
] as $limiterKey) {
|
||||
RateLimiter::clear($limiterKey);
|
||||
}
|
||||
}
|
||||
|
||||
function markForumRateLimitUserAsEstablished(User $user): void
|
||||
{
|
||||
$timestamp = now()->subDays(30);
|
||||
|
||||
$user->forceFill([
|
||||
'created_at' => $timestamp,
|
||||
'updated_at' => $timestamp,
|
||||
])->save();
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
<?php
|
||||
|
||||
use App\Models\User;
|
||||
use cPad\Plugins\Forum\Models\ForumBotLog;
|
||||
use cPad\Plugins\Forum\Services\Security\GeoBehaviorAnalyzer;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
uses(Tests\TestCase::class);
|
||||
|
||||
it('scores only rapid login country changes for the same account', function () {
|
||||
config()->set('forum_bot_protection.geo_behavior', [
|
||||
'enabled' => true,
|
||||
'login_actions' => ['login'],
|
||||
'country_headers' => ['CF-IPCountry'],
|
||||
'recent_login_window_minutes' => 60,
|
||||
'country_change_penalty' => 50,
|
||||
]);
|
||||
|
||||
Schema::dropIfExists('forum_bot_logs');
|
||||
Schema::dropIfExists('users');
|
||||
|
||||
Schema::create('users', function (Blueprint $table): void {
|
||||
$table->id();
|
||||
$table->string('email')->nullable();
|
||||
$table->string('password')->nullable();
|
||||
$table->timestamps();
|
||||
$table->softDeletes();
|
||||
});
|
||||
|
||||
Schema::create('forum_bot_logs', function (Blueprint $table): void {
|
||||
$table->id();
|
||||
$table->unsignedBigInteger('user_id')->nullable();
|
||||
$table->string('ip_address', 45)->nullable();
|
||||
$table->string('action', 80);
|
||||
$table->unsignedTinyInteger('risk_score')->default(0);
|
||||
$table->string('decision', 20)->default('allow');
|
||||
$table->json('metadata')->nullable();
|
||||
$table->timestamp('created_at')->nullable();
|
||||
});
|
||||
|
||||
DB::table('users')->insert([
|
||||
'id' => 1,
|
||||
'email' => 'geo@example.com',
|
||||
'password' => 'secret',
|
||||
'created_at' => now(),
|
||||
'updated_at' => now(),
|
||||
]);
|
||||
|
||||
$user = User::query()->findOrFail(1);
|
||||
|
||||
ForumBotLog::query()->create([
|
||||
'user_id' => $user->id,
|
||||
'ip_address' => '127.0.0.1',
|
||||
'action' => 'login',
|
||||
'risk_score' => 0,
|
||||
'decision' => 'allow',
|
||||
'metadata' => ['country_code' => 'SI'],
|
||||
'created_at' => now()->subMinutes(10),
|
||||
]);
|
||||
|
||||
$request = Request::create('/login', 'POST');
|
||||
$request->headers->set('CF-IPCountry', 'SI');
|
||||
|
||||
$unchanged = app(GeoBehaviorAnalyzer::class)->analyze($user, 'login', $request);
|
||||
|
||||
expect($unchanged)->toMatchArray([
|
||||
'score' => 0,
|
||||
'country_code' => 'SI',
|
||||
])->and($unchanged['reasons'])->toBe([]);
|
||||
|
||||
$request->headers->set('CF-IPCountry', 'JP');
|
||||
|
||||
$analysis = app(GeoBehaviorAnalyzer::class)->analyze($user, 'login', $request);
|
||||
|
||||
expect($analysis['score'])->toBe(50)
|
||||
->and($analysis['country_code'])->toBe('JP')
|
||||
->and($analysis['reasons'])->toHaveCount(1)
|
||||
->and($analysis['reasons'][0])->toContain('SI')
|
||||
->and($analysis['reasons'][0])->toContain('JP');
|
||||
});
|
||||
@@ -0,0 +1,70 @@
|
||||
<?php
|
||||
|
||||
use cPad\Plugins\Forum\Services\Security\IPReputationService;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
uses(Tests\TestCase::class);
|
||||
|
||||
it('scores CIDR datacenter and proxy ranges in IP reputation analysis', function () {
|
||||
Cache::flush();
|
||||
|
||||
config()->set('forum_bot_protection.ip', [
|
||||
'cache_ttl_minutes' => 15,
|
||||
'recent_high_risk_window_hours' => 24,
|
||||
'recent_high_risk_threshold' => 3,
|
||||
'recent_high_risk_penalty' => 20,
|
||||
'known_proxy_penalty' => 20,
|
||||
'datacenter_penalty' => 25,
|
||||
'tor_penalty' => 40,
|
||||
'blacklist_penalty' => 100,
|
||||
'known_proxies' => ['198.51.100.0/24'],
|
||||
'datacenter_ranges' => ['203.0.113.0/24'],
|
||||
'provider_ranges' => [
|
||||
'aws' => ['54.240.0.0/12'],
|
||||
],
|
||||
'tor_exit_nodes' => [],
|
||||
]);
|
||||
|
||||
Schema::dropIfExists('forum_bot_ip_blacklist');
|
||||
Schema::dropIfExists('forum_bot_logs');
|
||||
|
||||
Schema::create('forum_bot_ip_blacklist', function (Blueprint $table): void {
|
||||
$table->id();
|
||||
$table->string('ip_address', 45)->unique();
|
||||
$table->string('reason', 255)->nullable();
|
||||
$table->unsignedTinyInteger('risk_score')->default(100);
|
||||
$table->timestamp('expires_at')->nullable();
|
||||
$table->timestamp('created_at')->nullable();
|
||||
});
|
||||
|
||||
Schema::create('forum_bot_logs', function (Blueprint $table): void {
|
||||
$table->id();
|
||||
$table->unsignedBigInteger('user_id')->nullable();
|
||||
$table->string('ip_address', 45)->nullable();
|
||||
$table->string('action', 80);
|
||||
$table->unsignedTinyInteger('risk_score')->default(0);
|
||||
$table->string('decision', 20)->default('allow');
|
||||
$table->json('metadata')->nullable();
|
||||
$table->timestamp('created_at')->nullable();
|
||||
});
|
||||
|
||||
$service = app(IPReputationService::class);
|
||||
|
||||
$proxyResult = $service->analyze('198.51.100.23');
|
||||
$datacenterResult = $service->analyze('203.0.113.77');
|
||||
$providerResult = $service->analyze('54.240.10.20');
|
||||
|
||||
expect($proxyResult['score'])->toBe(20)
|
||||
->and($proxyResult['reasons'])->toContain('IP address is in the proxy watch list.')
|
||||
->and($proxyResult['blocked'])->toBeFalse();
|
||||
|
||||
expect($datacenterResult['score'])->toBe(25)
|
||||
->and($datacenterResult['reasons'])->toContain('IP address belongs to a datacenter or hosting network range.')
|
||||
->and($datacenterResult['blocked'])->toBeFalse();
|
||||
|
||||
expect($providerResult['score'])->toBe(25)
|
||||
->and($providerResult['reasons'])->toContain('IP address belongs to the configured AWS provider range.')
|
||||
->and($providerResult['blocked'])->toBeFalse();
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -0,0 +1,110 @@
|
||||
<?php
|
||||
|
||||
use App\Services\LegacySmileyMapper;
|
||||
|
||||
test('convert beer smiley to emoji', function () {
|
||||
$result = LegacySmileyMapper::convert('great work :beer');
|
||||
expect($result)->toContain('🍺');
|
||||
expect($result)->not()->toContain(':beer');
|
||||
});
|
||||
|
||||
test('convert multiple smileys in one string', function () {
|
||||
$result = LegacySmileyMapper::convert(':clap great art :love it');
|
||||
expect($result)->toContain('👏');
|
||||
expect($result)->toContain('❤️');
|
||||
});
|
||||
|
||||
test('does not replace smiley codes embedded in words', function () {
|
||||
// ":lol" should only be replaced when it stands alone
|
||||
$result = LegacySmileyMapper::convert('something:lol mixed');
|
||||
// The code is embedded in a word, so it should NOT be replaced
|
||||
expect($result)->toContain(':lol');
|
||||
});
|
||||
|
||||
test('detect returns found codes', function () {
|
||||
$found = LegacySmileyMapper::detect('nice :beer and :lol');
|
||||
expect($found)->toContain(':beer');
|
||||
expect($found)->toContain(':lol');
|
||||
});
|
||||
|
||||
test('detect returns empty array for clean text', function () {
|
||||
$found = LegacySmileyMapper::detect('hello world, no smileys here');
|
||||
expect($found)->toBeEmpty();
|
||||
});
|
||||
|
||||
test('convert returns original string when no codes present', function () {
|
||||
$input = 'This has no smiley codes.';
|
||||
$result = LegacySmileyMapper::convert($input);
|
||||
expect($result)->toBe($input);
|
||||
});
|
||||
|
||||
test('getMap returns non-empty array', function () {
|
||||
$map = LegacySmileyMapper::getMap();
|
||||
expect($map)->toBeArray()->not()->toBeEmpty();
|
||||
expect($map[':beer'])->toBe('🍺');
|
||||
});
|
||||
|
||||
test('convert handles empty string', function () {
|
||||
expect(LegacySmileyMapper::convert(''))->toBe('');
|
||||
});
|
||||
|
||||
// ── collapseFlood ─────────────────────────────────────────────────────────────
|
||||
|
||||
test('collapseFlood leaves short runs untouched', function () {
|
||||
// 5 beer mugs with spaces — at or below the maxRun=5 default, no collapse.
|
||||
$input = '\ud83c\udf7a \ud83c\udf7a \ud83c\udf7a \ud83c\udf7a \ud83c\udf7a';
|
||||
expect(LegacySmileyMapper::collapseFlood($input))->toBe($input);
|
||||
});
|
||||
|
||||
test('collapseFlood collapses a run-together flood', function () {
|
||||
// 8 fire emoji in a row (no spaces) — should produce 5 + ×8
|
||||
$input = str_repeat('🔥', 8);
|
||||
$result = LegacySmileyMapper::collapseFlood($input);
|
||||
expect($result)->toContain('×8');
|
||||
// output has at most maxRun (5) copies of the emoji
|
||||
$count = substr_count($result, '🔥');
|
||||
expect($count)->toBeLessThanOrEqual(5);
|
||||
});
|
||||
|
||||
test('collapseFlood collapses a space-separated flood', function () {
|
||||
// 10 clap emoji separated by single spaces
|
||||
$input = implode(' ', array_fill(0, 10, '👏'));
|
||||
$result = LegacySmileyMapper::collapseFlood($input);
|
||||
expect($result)->toContain('×10');
|
||||
$count = substr_count($result, '👏');
|
||||
expect($count)->toBeLessThanOrEqual(5);
|
||||
});
|
||||
|
||||
test('collapseFlood respects custom maxRun', function () {
|
||||
$input = implode(' ', array_fill(0, 8, '❤️'));
|
||||
$result = LegacySmileyMapper::collapseFlood($input, maxRun: 3);
|
||||
expect($result)->toContain('×8');
|
||||
$count = substr_count($result, '❤️');
|
||||
expect($count)->toBeLessThanOrEqual(3);
|
||||
});
|
||||
|
||||
test('collapseFlood does not affect regular text or mixed content', function () {
|
||||
$input = 'Nice work \ud83d\udc4d really cool \ud83d\udd25';
|
||||
$result = LegacySmileyMapper::collapseFlood($input);
|
||||
expect($result)->toBe($input); // nothing to collapse
|
||||
});
|
||||
|
||||
test('collapseFlood handles empty string', function () {
|
||||
expect(LegacySmileyMapper::collapseFlood(''))->toBe('');
|
||||
});
|
||||
|
||||
// ── detect() colon-lookahead regression ──────────────────────────────────────
|
||||
|
||||
test('detect does not match smiley code with trailing colon', function () {
|
||||
// ':sad:' used to partially match ':sad' leaving a stray ':'.
|
||||
// After the fix, ':' is not in the lookahead, so ':sad:' should be
|
||||
// detected only if followed by whitespace / punctuation (not ':').
|
||||
// A bare ':sad:' surrounded by spaces must still be detected.
|
||||
$found = LegacySmileyMapper::detect(':sad and more text');
|
||||
expect($found)->toContain(':sad');
|
||||
|
||||
// ':sad:' where the colon immediately follows should NOT be detected
|
||||
// because ':' is no longer in [.,!?;] lookahead.
|
||||
$foundColon = LegacySmileyMapper::detect('text:sad: more');
|
||||
expect($foundColon)->not()->toContain(':sad');
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
@@ -0,0 +1,33 @@
|
||||
<?php
|
||||
|
||||
use App\Support\Seo\SeoDataBuilder;
|
||||
use Tests\TestCase;
|
||||
|
||||
uses(TestCase::class);
|
||||
|
||||
it('normalizes a single associative structured data schema', function () {
|
||||
$seo = SeoDataBuilder::fromArray([
|
||||
'title' => 'Categories',
|
||||
'structured_data' => [
|
||||
'@context' => 'https://schema.org',
|
||||
'@type' => 'CollectionPage',
|
||||
'name' => 'Categories',
|
||||
],
|
||||
])->build()->toArray();
|
||||
|
||||
expect($seo['json_ld'] ?? [])
|
||||
->toHaveCount(1)
|
||||
->and($seo['json_ld'][0]['@type'] ?? null)->toBe('CollectionPage');
|
||||
});
|
||||
|
||||
it('normalizes JSON string structured data schemas', function () {
|
||||
$seo = SeoDataBuilder::fromArray([
|
||||
'title' => 'Categories',
|
||||
'structured_data' => '{"@context":"https://schema.org","@type":"CollectionPage","name":"Categories"}',
|
||||
])->build()->toArray();
|
||||
|
||||
expect($seo['json_ld'] ?? [])
|
||||
->toHaveCount(1)
|
||||
->and($seo['json_ld'][0]['@context'] ?? null)->toBe('https://schema.org')
|
||||
->and($seo['json_ld'][0]['@type'] ?? null)->toBe('CollectionPage');
|
||||
});
|
||||
@@ -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',
|
||||
];
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,165 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Models\Artwork;
|
||||
use App\Models\Collection;
|
||||
use App\Models\User;
|
||||
use App\Services\CollectionHealthService;
|
||||
use App\Services\CollectionService;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Tests\TestCase;
|
||||
|
||||
uses(TestCase::class, RefreshDatabase::class);
|
||||
|
||||
it('flags public collections as stale when freshness falls to zero', function (): void {
|
||||
$user = User::factory()->create();
|
||||
$service = app(CollectionHealthService::class);
|
||||
$collection = Collection::factory()->make([
|
||||
'user_id' => $user->id,
|
||||
'title' => 'Stale Health Candidate',
|
||||
'slug' => 'stale-health-candidate',
|
||||
'mode' => Collection::MODE_SMART,
|
||||
'artworks_count' => 6,
|
||||
'visibility' => Collection::VISIBILITY_PUBLIC,
|
||||
'moderation_status' => Collection::MODERATION_ACTIVE,
|
||||
'lifecycle_state' => Collection::LIFECYCLE_PUBLISHED,
|
||||
'summary' => 'A focused summary for stale health coverage.',
|
||||
'description' => 'A longer description that gives the stale collection enough metadata depth for the health service.',
|
||||
'likes_count' => 45,
|
||||
'followers_count' => 20,
|
||||
'saves_count' => 24,
|
||||
'comments_count' => 6,
|
||||
'shares_count' => 4,
|
||||
'views_count' => 1000,
|
||||
'last_activity_at' => now()->subDays(60),
|
||||
'updated_at' => now()->subDays(60),
|
||||
'published_at' => now()->subDays(60),
|
||||
'workflow_state' => Collection::WORKFLOW_APPROVED,
|
||||
]);
|
||||
|
||||
$collection->cover_artwork_id = 999;
|
||||
|
||||
$collection->setRelation('coverArtwork', Artwork::factory()->for($user)->make([
|
||||
'width' => 1400,
|
||||
'height' => 900,
|
||||
'published_at' => now()->subDays(61),
|
||||
]));
|
||||
|
||||
$flagsMethod = new ReflectionMethod(CollectionHealthService::class, 'flags');
|
||||
$flagsMethod->setAccessible(true);
|
||||
|
||||
$flags = $flagsMethod->invoke($service, $collection, 80.0, 0.0, 80.0, 80.0);
|
||||
|
||||
expect($flags)->toContain(Collection::HEALTH_STALE);
|
||||
});
|
||||
|
||||
it('flags collections with fewer than six artworks as low content', function (): void {
|
||||
$user = User::factory()->create();
|
||||
$service = app(CollectionHealthService::class);
|
||||
$collection = seededHealthyCollection($user, 'Thin Collection Candidate', 5);
|
||||
|
||||
$payload = $service->evaluate($collection->fresh(['coverArtwork']));
|
||||
|
||||
expect($payload['health_flags_json'])->toContain(Collection::HEALTH_LOW_CONTENT);
|
||||
});
|
||||
|
||||
it('flags broken items when too many attached artworks are not publicly visible', function (): void {
|
||||
$user = User::factory()->create();
|
||||
$service = app(CollectionHealthService::class);
|
||||
$collection = Collection::factory()->for($user)->create([
|
||||
'title' => 'Broken Items Candidate',
|
||||
'slug' => 'broken-items-candidate',
|
||||
'visibility' => Collection::VISIBILITY_PUBLIC,
|
||||
'moderation_status' => Collection::MODERATION_ACTIVE,
|
||||
'lifecycle_state' => Collection::LIFECYCLE_PUBLISHED,
|
||||
'summary' => 'A collection used to validate broken item detection.',
|
||||
'description' => 'A detailed curatorial description for broken item health coverage.',
|
||||
'likes_count' => 40,
|
||||
'followers_count' => 20,
|
||||
'saves_count' => 15,
|
||||
'views_count' => 900,
|
||||
'published_at' => now()->subDays(10),
|
||||
'updated_at' => now()->subDay(),
|
||||
'last_activity_at' => now()->subDay(),
|
||||
]);
|
||||
|
||||
$visible = Artwork::factory()->for($user)->create(['width' => 1280, 'height' => 720]);
|
||||
$private = Artwork::factory()->for($user)->private()->create();
|
||||
$unapproved = Artwork::factory()->for($user)->unapproved()->create();
|
||||
$unpublished = Artwork::factory()->for($user)->unpublished()->create();
|
||||
app(CollectionService::class)->attachArtworks($collection, $user, [$visible->id, $private->id, $unapproved->id, $unpublished->id]);
|
||||
|
||||
$collection->forceFill([
|
||||
'cover_artwork_id' => $visible->id,
|
||||
'updated_at' => now()->subDay(),
|
||||
'last_activity_at' => now()->subDay(),
|
||||
])->save();
|
||||
|
||||
$payload = $service->evaluate($collection->fresh(['coverArtwork']));
|
||||
|
||||
expect($payload['health_flags_json'])->toContain(Collection::HEALTH_BROKEN_ITEMS)
|
||||
->and($payload['placement_eligibility'])->toBeFalse();
|
||||
});
|
||||
|
||||
it('flags collections without an explicit cover as weak cover', function (): void {
|
||||
$user = User::factory()->create();
|
||||
$service = app(CollectionHealthService::class);
|
||||
$collection = seededHealthyCollection($user, 'Weak Cover Candidate');
|
||||
|
||||
$collection->forceFill(['cover_artwork_id' => null])->save();
|
||||
|
||||
$payload = $service->evaluate($collection->fresh(['coverArtwork']));
|
||||
|
||||
expect($payload['health_flags_json'])->toContain(Collection::HEALTH_WEAK_COVER);
|
||||
});
|
||||
|
||||
it('keeps strong active collections healthy and placement eligible', function (): void {
|
||||
$user = User::factory()->create();
|
||||
$service = app(CollectionHealthService::class);
|
||||
$collection = seededHealthyCollection($user, 'Healthy Cover Candidate');
|
||||
|
||||
$payload = $service->evaluate($collection->fresh(['coverArtwork']));
|
||||
|
||||
expect($payload['health_state'])->toBe(Collection::HEALTH_HEALTHY)
|
||||
->and($payload['health_flags_json'])->toBe([])
|
||||
->and($payload['placement_eligibility'])->toBeTrue();
|
||||
});
|
||||
|
||||
function seededHealthyCollection(User $user, string $title, int $artworksCount = 6): Collection
|
||||
{
|
||||
$collection = Collection::factory()->for($user)->create([
|
||||
'title' => $title,
|
||||
'slug' => str($title)->slug()->value(),
|
||||
'visibility' => Collection::VISIBILITY_PUBLIC,
|
||||
'moderation_status' => Collection::MODERATION_ACTIVE,
|
||||
'lifecycle_state' => Collection::LIFECYCLE_PUBLISHED,
|
||||
'summary' => 'A focused summary for health scoring coverage.',
|
||||
'description' => 'A longer description that provides enough metadata depth for health scoring and editorial readiness calculations.',
|
||||
'likes_count' => 45,
|
||||
'followers_count' => 18,
|
||||
'saves_count' => 24,
|
||||
'comments_count' => 8,
|
||||
'shares_count' => 5,
|
||||
'views_count' => 1400,
|
||||
'published_at' => now()->subDays(5),
|
||||
'updated_at' => now()->subDay(),
|
||||
'last_activity_at' => now()->subDay(),
|
||||
'workflow_state' => Collection::WORKFLOW_APPROVED,
|
||||
]);
|
||||
|
||||
$artworkIds = Artwork::factory()->count($artworksCount)->for($user)->create([
|
||||
'width' => 1400,
|
||||
'height' => 900,
|
||||
])->pluck('id')->all();
|
||||
|
||||
app(CollectionService::class)->attachArtworks($collection, $user, $artworkIds);
|
||||
|
||||
$collection->forceFill([
|
||||
'cover_artwork_id' => $artworkIds[0] ?? null,
|
||||
'updated_at' => now()->subDay(),
|
||||
'last_activity_at' => now()->subDay(),
|
||||
])->save();
|
||||
|
||||
return $collection->fresh(['coverArtwork']);
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Models\User;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
use Tests\TestCase;
|
||||
|
||||
uses(TestCase::class);
|
||||
|
||||
beforeEach(function (): void {
|
||||
Schema::dropIfExists('tag_interaction_daily_metrics');
|
||||
Schema::dropIfExists('tags');
|
||||
Schema::dropIfExists('users');
|
||||
|
||||
Schema::create('users', function (Blueprint $table): void {
|
||||
$table->id();
|
||||
$table->string('username')->nullable();
|
||||
$table->timestamp('username_changed_at')->nullable();
|
||||
$table->timestamp('last_username_change_at')->nullable();
|
||||
$table->string('onboarding_step')->nullable();
|
||||
$table->string('name')->nullable();
|
||||
$table->string('email')->nullable();
|
||||
$table->timestamp('email_verified_at')->nullable();
|
||||
$table->string('password')->nullable();
|
||||
$table->boolean('is_active')->default(true);
|
||||
$table->string('role')->nullable();
|
||||
$table->rememberToken();
|
||||
$table->timestamps();
|
||||
$table->softDeletes();
|
||||
});
|
||||
|
||||
Schema::create('tags', function (Blueprint $table): void {
|
||||
$table->id();
|
||||
$table->string('name');
|
||||
$table->string('slug')->unique();
|
||||
$table->unsignedInteger('usage_count')->default(0);
|
||||
$table->boolean('is_active')->default(true);
|
||||
$table->timestamps();
|
||||
});
|
||||
|
||||
Schema::create('tag_interaction_daily_metrics', function (Blueprint $table): void {
|
||||
$table->id();
|
||||
$table->date('metric_date');
|
||||
$table->string('surface', 32);
|
||||
$table->string('tag_slug', 120)->default('');
|
||||
$table->string('source_tag_slug', 120)->default('');
|
||||
$table->string('query', 120)->default('');
|
||||
$table->unsignedInteger('clicks')->default(0);
|
||||
$table->unsignedInteger('unique_users')->default(0);
|
||||
$table->unsignedInteger('unique_sessions')->default(0);
|
||||
$table->decimal('avg_position', 8, 2)->default(0);
|
||||
$table->timestamps();
|
||||
});
|
||||
});
|
||||
|
||||
it('redirects guests away from the studio tag search route', function (): void {
|
||||
$this->get('/api/studio/tags/search?q=hi')
|
||||
->assertRedirect('/login');
|
||||
});
|
||||
|
||||
it('returns momentum-ranked tag suggestions for authenticated studio users', function (): void {
|
||||
$now = now();
|
||||
|
||||
$user = new User([
|
||||
'username' => 'studio-user',
|
||||
'username_changed_at' => now()->subDays(120),
|
||||
'last_username_change_at' => now()->subDays(120),
|
||||
'onboarding_step' => 'complete',
|
||||
'name' => 'Studio User',
|
||||
'email' => 'studio@example.com',
|
||||
'email_verified_at' => now(),
|
||||
'password' => 'password',
|
||||
'is_active' => true,
|
||||
]);
|
||||
$user->id = 1;
|
||||
|
||||
DB::table('tags')->insert([
|
||||
['id' => 1, 'name' => 'High Usage', 'slug' => 'high-usage', 'usage_count' => 500, 'is_active' => true, 'created_at' => $now, 'updated_at' => $now],
|
||||
['id' => 2, 'name' => 'High Contrast', 'slug' => 'high-contrast', 'usage_count' => 120, 'is_active' => true, 'created_at' => $now, 'updated_at' => $now],
|
||||
['id' => 3, 'name' => 'Hidden Draft', 'slug' => 'hidden-draft', 'usage_count' => 900, 'is_active' => false, 'created_at' => $now, 'updated_at' => $now],
|
||||
]);
|
||||
|
||||
DB::table('tag_interaction_daily_metrics')->insert([
|
||||
[
|
||||
'metric_date' => $now->toDateString(),
|
||||
'surface' => 'tag_search',
|
||||
'tag_slug' => 'high-contrast',
|
||||
'source_tag_slug' => '',
|
||||
'query' => 'hi',
|
||||
'clicks' => 30,
|
||||
'unique_users' => 0,
|
||||
'unique_sessions' => 11,
|
||||
'avg_position' => 1.1,
|
||||
'created_at' => $now,
|
||||
'updated_at' => $now,
|
||||
],
|
||||
[
|
||||
'metric_date' => $now->toDateString(),
|
||||
'surface' => 'tag_search',
|
||||
'tag_slug' => 'high-usage',
|
||||
'source_tag_slug' => '',
|
||||
'query' => 'hi',
|
||||
'clicks' => 3,
|
||||
'unique_users' => 0,
|
||||
'unique_sessions' => 2,
|
||||
'avg_position' => 2.8,
|
||||
'created_at' => $now,
|
||||
'updated_at' => $now,
|
||||
],
|
||||
]);
|
||||
|
||||
$response = $this->actingAs($user)->getJson('/api/studio/tags/search?q=hi');
|
||||
|
||||
$response->assertOk();
|
||||
|
||||
$data = $response->json();
|
||||
|
||||
expect($data)->toHaveCount(2);
|
||||
expect(array_column($data, 'slug'))->toBe(['high-contrast', 'high-usage']);
|
||||
expect((int) $data[0]['recent_clicks'])->toBe(30);
|
||||
expect($data[0])->toHaveKeys(['id', 'name', 'slug', 'usage_count', 'recent_clicks']);
|
||||
expect($data[0])->not->toHaveKeys(['created_at', 'updated_at', 'is_active']);
|
||||
});
|
||||
@@ -0,0 +1,219 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Services\Tags\TagDiscoveryService;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
use Tests\TestCase;
|
||||
|
||||
uses(TestCase::class);
|
||||
|
||||
beforeEach(function (): void {
|
||||
Schema::dropIfExists('tag_interaction_daily_metrics');
|
||||
Schema::dropIfExists('artwork_tag');
|
||||
Schema::dropIfExists('artworks');
|
||||
Schema::dropIfExists('tags');
|
||||
|
||||
Schema::create('tags', function (Blueprint $table): void {
|
||||
$table->id();
|
||||
$table->string('name');
|
||||
$table->string('slug')->unique();
|
||||
$table->unsignedInteger('usage_count')->default(0);
|
||||
$table->boolean('is_active')->default(true);
|
||||
$table->timestamps();
|
||||
});
|
||||
|
||||
Schema::create('artworks', function (Blueprint $table): void {
|
||||
$table->id();
|
||||
$table->unsignedBigInteger('user_id')->nullable();
|
||||
$table->string('title')->nullable();
|
||||
$table->string('slug')->nullable();
|
||||
$table->boolean('is_public')->default(true);
|
||||
$table->boolean('is_approved')->default(true);
|
||||
$table->timestamp('published_at')->nullable();
|
||||
$table->softDeletes();
|
||||
$table->timestamps();
|
||||
});
|
||||
|
||||
Schema::create('artwork_tag', function (Blueprint $table): void {
|
||||
$table->unsignedBigInteger('artwork_id');
|
||||
$table->unsignedBigInteger('tag_id');
|
||||
$table->string('source')->nullable();
|
||||
$table->unsignedInteger('confidence')->nullable();
|
||||
});
|
||||
|
||||
Schema::create('tag_interaction_daily_metrics', function (Blueprint $table): void {
|
||||
$table->id();
|
||||
$table->date('metric_date');
|
||||
$table->string('surface', 32);
|
||||
$table->string('tag_slug', 120)->default('');
|
||||
$table->string('source_tag_slug', 120)->default('');
|
||||
$table->string('query', 120)->default('');
|
||||
$table->unsignedInteger('clicks')->default(0);
|
||||
$table->unsignedInteger('unique_users')->default(0);
|
||||
$table->unsignedInteger('unique_sessions')->default(0);
|
||||
$table->decimal('avg_position', 8, 2)->default(0);
|
||||
$table->timestamps();
|
||||
});
|
||||
});
|
||||
|
||||
it('keeps slug in related tag payload when transition metrics are joined', function (): void {
|
||||
$now = now();
|
||||
|
||||
DB::table('tags')->insert([
|
||||
['id' => 1, 'name' => 'Primary', 'slug' => 'primary', 'usage_count' => 100, 'is_active' => true, 'created_at' => $now, 'updated_at' => $now],
|
||||
['id' => 2, 'name' => 'Beta', 'slug' => 'beta', 'usage_count' => 80, 'is_active' => true, 'created_at' => $now, 'updated_at' => $now],
|
||||
['id' => 3, 'name' => 'Gamma', 'slug' => 'gamma', 'usage_count' => 60, 'is_active' => true, 'created_at' => $now, 'updated_at' => $now],
|
||||
]);
|
||||
|
||||
DB::table('artworks')->insert([
|
||||
['id' => 10, 'title' => 'One', 'slug' => 'one', 'is_public' => true, 'is_approved' => true, 'published_at' => $now, 'created_at' => $now, 'updated_at' => $now],
|
||||
['id' => 11, 'title' => 'Two', 'slug' => 'two', 'is_public' => true, 'is_approved' => true, 'published_at' => $now, 'created_at' => $now, 'updated_at' => $now],
|
||||
['id' => 12, 'title' => 'Three', 'slug' => 'three', 'is_public' => true, 'is_approved' => true, 'published_at' => $now, 'created_at' => $now, 'updated_at' => $now],
|
||||
]);
|
||||
|
||||
DB::table('artwork_tag')->insert([
|
||||
['artwork_id' => 10, 'tag_id' => 1, 'source' => 'user', 'confidence' => 100],
|
||||
['artwork_id' => 10, 'tag_id' => 2, 'source' => 'user', 'confidence' => 100],
|
||||
['artwork_id' => 11, 'tag_id' => 1, 'source' => 'user', 'confidence' => 100],
|
||||
['artwork_id' => 11, 'tag_id' => 2, 'source' => 'user', 'confidence' => 100],
|
||||
['artwork_id' => 12, 'tag_id' => 1, 'source' => 'user', 'confidence' => 100],
|
||||
['artwork_id' => 12, 'tag_id' => 3, 'source' => 'user', 'confidence' => 100],
|
||||
]);
|
||||
|
||||
DB::table('tag_interaction_daily_metrics')->insert([
|
||||
[
|
||||
'metric_date' => $now->toDateString(),
|
||||
'surface' => 'related_chip',
|
||||
'tag_slug' => 'beta',
|
||||
'source_tag_slug' => 'primary',
|
||||
'query' => '',
|
||||
'clicks' => 25,
|
||||
'unique_users' => 0,
|
||||
'unique_sessions' => 10,
|
||||
'avg_position' => 1.4,
|
||||
'created_at' => $now,
|
||||
'updated_at' => $now,
|
||||
],
|
||||
[
|
||||
'metric_date' => $now->toDateString(),
|
||||
'surface' => 'related_chip',
|
||||
'tag_slug' => 'gamma',
|
||||
'source_tag_slug' => 'primary',
|
||||
'query' => '',
|
||||
'clicks' => 4,
|
||||
'unique_users' => 0,
|
||||
'unique_sessions' => 3,
|
||||
'avg_position' => 2.2,
|
||||
'created_at' => $now,
|
||||
'updated_at' => $now,
|
||||
],
|
||||
]);
|
||||
|
||||
$primary = \App\Models\Tag::query()->findOrFail(1);
|
||||
|
||||
$relatedTags = app(TagDiscoveryService::class)->relatedTags($primary, 8);
|
||||
|
||||
expect($relatedTags)->toHaveCount(2);
|
||||
expect(isset($relatedTags[0]->slug))->toBeTrue();
|
||||
expect($relatedTags[0]->slug)->toBe('beta');
|
||||
expect((int) $relatedTags[0]->shared_artworks_count)->toBe(2);
|
||||
expect((int) $relatedTags[0]->transition_clicks)->toBe(25);
|
||||
expect(collect($relatedTags)->pluck('slug')->all())->toBe(['beta', 'gamma']);
|
||||
});
|
||||
|
||||
it('orders featured tags by recent clicks before usage count', function (): void {
|
||||
$now = now();
|
||||
|
||||
DB::table('tags')->insert([
|
||||
['id' => 1, 'name' => 'Legacy Heavy', 'slug' => 'legacy-heavy', 'usage_count' => 900, 'is_active' => true, 'created_at' => $now, 'updated_at' => $now],
|
||||
['id' => 2, 'name' => 'Momentum First', 'slug' => 'momentum-first', 'usage_count' => 120, 'is_active' => true, 'created_at' => $now, 'updated_at' => $now],
|
||||
['id' => 3, 'name' => 'Quiet', 'slug' => 'quiet', 'usage_count' => 80, 'is_active' => true, 'created_at' => $now, 'updated_at' => $now],
|
||||
]);
|
||||
|
||||
DB::table('tag_interaction_daily_metrics')->insert([
|
||||
[
|
||||
'metric_date' => $now->toDateString(),
|
||||
'surface' => 'tag_search',
|
||||
'tag_slug' => 'legacy-heavy',
|
||||
'source_tag_slug' => '',
|
||||
'query' => 'legacy',
|
||||
'clicks' => 8,
|
||||
'unique_users' => 0,
|
||||
'unique_sessions' => 4,
|
||||
'avg_position' => 2.5,
|
||||
'created_at' => $now,
|
||||
'updated_at' => $now,
|
||||
],
|
||||
[
|
||||
'metric_date' => $now->toDateString(),
|
||||
'surface' => 'tag_search',
|
||||
'tag_slug' => 'momentum-first',
|
||||
'source_tag_slug' => '',
|
||||
'query' => 'momentum',
|
||||
'clicks' => 42,
|
||||
'unique_users' => 0,
|
||||
'unique_sessions' => 18,
|
||||
'avg_position' => 1.2,
|
||||
'created_at' => $now,
|
||||
'updated_at' => $now,
|
||||
],
|
||||
]);
|
||||
|
||||
$featured = app(TagDiscoveryService::class)->featuredTags(3);
|
||||
|
||||
expect($featured)->toHaveCount(3);
|
||||
expect(collect($featured)->pluck('slug')->all())->toBe(['momentum-first', 'legacy-heavy', 'quiet']);
|
||||
expect((int) $featured[0]->recent_clicks)->toBe(42);
|
||||
expect((int) $featured[1]->recent_clicks)->toBe(8);
|
||||
});
|
||||
|
||||
it('fills rising tags from usage fallback without duplicating featured tags', function (): void {
|
||||
$now = now();
|
||||
|
||||
DB::table('tags')->insert([
|
||||
['id' => 1, 'name' => 'Featured Momentum', 'slug' => 'featured-momentum', 'usage_count' => 800, 'is_active' => true, 'created_at' => $now, 'updated_at' => $now],
|
||||
['id' => 2, 'name' => 'Rising Momentum', 'slug' => 'rising-momentum', 'usage_count' => 120, 'is_active' => true, 'created_at' => $now, 'updated_at' => $now],
|
||||
['id' => 3, 'name' => 'Fallback Alpha', 'slug' => 'fallback-alpha', 'usage_count' => 450, 'is_active' => true, 'created_at' => $now, 'updated_at' => $now],
|
||||
['id' => 4, 'name' => 'Fallback Beta', 'slug' => 'fallback-beta', 'usage_count' => 300, 'is_active' => true, 'created_at' => $now, 'updated_at' => $now],
|
||||
]);
|
||||
|
||||
DB::table('tag_interaction_daily_metrics')->insert([
|
||||
[
|
||||
'metric_date' => $now->toDateString(),
|
||||
'surface' => 'tag_search',
|
||||
'tag_slug' => 'featured-momentum',
|
||||
'source_tag_slug' => '',
|
||||
'query' => 'featured',
|
||||
'clicks' => 50,
|
||||
'unique_users' => 0,
|
||||
'unique_sessions' => 20,
|
||||
'avg_position' => 1.1,
|
||||
'created_at' => $now,
|
||||
'updated_at' => $now,
|
||||
],
|
||||
[
|
||||
'metric_date' => $now->toDateString(),
|
||||
'surface' => 'tag_search',
|
||||
'tag_slug' => 'rising-momentum',
|
||||
'source_tag_slug' => '',
|
||||
'query' => 'rising',
|
||||
'clicks' => 12,
|
||||
'unique_users' => 0,
|
||||
'unique_sessions' => 6,
|
||||
'avg_position' => 1.8,
|
||||
'created_at' => $now,
|
||||
'updated_at' => $now,
|
||||
],
|
||||
]);
|
||||
|
||||
$service = app(TagDiscoveryService::class);
|
||||
$featured = $service->featuredTags(1);
|
||||
$rising = $service->risingTags($featured, 3);
|
||||
|
||||
expect(collect($featured)->pluck('slug')->all())->toBe(['featured-momentum']);
|
||||
expect(collect($rising)->pluck('slug')->all())->toBe(['rising-momentum', 'fallback-alpha', 'fallback-beta']);
|
||||
expect(collect($rising)->contains(fn ($tag) => $tag->slug === 'featured-momentum'))->toBeFalse();
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -0,0 +1,168 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Unit\Uploads;
|
||||
|
||||
use App\Uploads\Services\ArchiveInspectorService;
|
||||
use App\Uploads\Services\InspectionResult;
|
||||
use Tests\TestCase;
|
||||
use ZipArchive;
|
||||
|
||||
class ArchiveInspectorServiceTest extends TestCase
|
||||
{
|
||||
/** @var array<int, string> */
|
||||
private array $tempFiles = [];
|
||||
|
||||
protected function tearDown(): void
|
||||
{
|
||||
foreach ($this->tempFiles as $file) {
|
||||
if (is_file($file)) {
|
||||
@unlink($file);
|
||||
}
|
||||
}
|
||||
|
||||
parent::tearDown();
|
||||
}
|
||||
|
||||
public function test_rejects_zip_slip_path(): void
|
||||
{
|
||||
$archive = $this->makeZip([
|
||||
'../evil.txt' => 'x',
|
||||
]);
|
||||
|
||||
$result = app(ArchiveInspectorService::class)->inspect($archive);
|
||||
|
||||
$this->assertFalse($result->valid);
|
||||
$this->assertStringContainsString('path traversal', (string) $result->reason);
|
||||
}
|
||||
|
||||
public function test_rejects_symlink_entries(): void
|
||||
{
|
||||
$archive = $this->makeZipWithCallback([
|
||||
'safe/file.txt' => 'ok',
|
||||
'safe/link' => 'target',
|
||||
], function (ZipArchive $zip, string $entryName): void {
|
||||
if ($entryName === 'safe/link') {
|
||||
$zip->setExternalAttributesName($entryName, ZipArchive::OPSYS_UNIX, 0120777 << 16);
|
||||
}
|
||||
});
|
||||
|
||||
$result = app(ArchiveInspectorService::class)->inspect($archive);
|
||||
|
||||
$this->assertFalse($result->valid);
|
||||
$this->assertStringContainsString('symlink', strtolower((string) $result->reason));
|
||||
}
|
||||
|
||||
public function test_rejects_deep_nesting(): void
|
||||
{
|
||||
$archive = $this->makeZip([
|
||||
'a/b/c/d/e/f/file.txt' => 'too deep',
|
||||
]);
|
||||
|
||||
$result = app(ArchiveInspectorService::class)->inspect($archive);
|
||||
|
||||
$this->assertFalse($result->valid);
|
||||
$this->assertStringContainsString('depth', strtolower((string) $result->reason));
|
||||
}
|
||||
|
||||
public function test_rejects_too_many_files(): void
|
||||
{
|
||||
$entries = [];
|
||||
for ($index = 0; $index < 5001; $index++) {
|
||||
$entries['f' . $index . '.txt'] = 'x';
|
||||
}
|
||||
|
||||
$archive = $this->makeZip($entries);
|
||||
|
||||
$result = app(ArchiveInspectorService::class)->inspect($archive);
|
||||
|
||||
$this->assertFalse($result->valid);
|
||||
$this->assertStringContainsString('5000', (string) $result->reason);
|
||||
}
|
||||
|
||||
public function test_rejects_executable_extensions(): void
|
||||
{
|
||||
$archive = $this->makeZip([
|
||||
'skins/readme.txt' => 'ok',
|
||||
'skins/run.exe' => 'MZ',
|
||||
]);
|
||||
|
||||
$result = app(ArchiveInspectorService::class)->inspect($archive);
|
||||
|
||||
$this->assertFalse($result->valid);
|
||||
$this->assertStringContainsString('blocked', strtolower((string) $result->reason));
|
||||
}
|
||||
|
||||
public function test_rejects_zip_bomb_ratio(): void
|
||||
{
|
||||
$archive = $this->makeZip([
|
||||
'payload.txt' => str_repeat('A', 6 * 1024 * 1024),
|
||||
]);
|
||||
|
||||
$result = app(ArchiveInspectorService::class)->inspect($archive);
|
||||
|
||||
$this->assertFalse($result->valid);
|
||||
$this->assertStringContainsString('ratio', strtolower((string) $result->reason));
|
||||
}
|
||||
|
||||
public function test_valid_archive_passes(): void
|
||||
{
|
||||
$archive = $this->makeZip([
|
||||
'skins/theme/readme.txt' => 'safe',
|
||||
'skins/theme/colors.ini' => 'accent=blue',
|
||||
]);
|
||||
|
||||
$result = app(ArchiveInspectorService::class)->inspect($archive);
|
||||
|
||||
$this->assertInstanceOf(InspectionResult::class, $result);
|
||||
$this->assertTrue($result->valid);
|
||||
$this->assertNull($result->reason);
|
||||
$this->assertIsArray($result->stats);
|
||||
$this->assertArrayHasKey('files', $result->stats);
|
||||
$this->assertArrayHasKey('depth', $result->stats);
|
||||
$this->assertArrayHasKey('size', $result->stats);
|
||||
$this->assertArrayHasKey('ratio', $result->stats);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, string> $entries
|
||||
*/
|
||||
private function makeZip(array $entries): string
|
||||
{
|
||||
return $this->makeZipWithCallback($entries, null);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, string> $entries
|
||||
* @param (callable(ZipArchive,string):void)|null $entryCallback
|
||||
*/
|
||||
private function makeZipWithCallback(array $entries, ?callable $entryCallback): string
|
||||
{
|
||||
if (! class_exists(ZipArchive::class)) {
|
||||
$this->markTestSkipped('ZipArchive extension is required.');
|
||||
}
|
||||
|
||||
$path = tempnam(sys_get_temp_dir(), 'sb_zip_');
|
||||
if ($path === false) {
|
||||
throw new \RuntimeException('Unable to create temporary zip path.');
|
||||
}
|
||||
|
||||
$this->tempFiles[] = $path;
|
||||
|
||||
$zip = new ZipArchive();
|
||||
if ($zip->open($path, ZipArchive::OVERWRITE | ZipArchive::CREATE) !== true) {
|
||||
throw new \RuntimeException('Unable to open temporary zip for writing.');
|
||||
}
|
||||
|
||||
foreach ($entries as $name => $content) {
|
||||
$zip->addFromString($name, $content);
|
||||
|
||||
if ($entryCallback !== null) {
|
||||
$entryCallback($zip, $name);
|
||||
}
|
||||
}
|
||||
|
||||
$zip->close();
|
||||
|
||||
return $path;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Unit\Uploads;
|
||||
|
||||
use App\Models\User;
|
||||
use App\Uploads\Services\CleanupService;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Illuminate\Support\Str;
|
||||
use Tests\TestCase;
|
||||
|
||||
class CleanupServiceTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
private function insertUploadRow(array $overrides = []): string
|
||||
{
|
||||
$id = (string) Str::uuid();
|
||||
|
||||
$defaults = [
|
||||
'id' => $id,
|
||||
'user_id' => User::factory()->create()->id,
|
||||
'type' => 'image',
|
||||
'status' => 'draft',
|
||||
'title' => null,
|
||||
'slug' => null,
|
||||
'category_id' => null,
|
||||
'description' => null,
|
||||
'tags' => null,
|
||||
'license' => null,
|
||||
'nsfw' => false,
|
||||
'is_scanned' => false,
|
||||
'has_tags' => false,
|
||||
'preview_path' => null,
|
||||
'published_at' => null,
|
||||
'final_path' => null,
|
||||
'expires_at' => null,
|
||||
'created_at' => now(),
|
||||
'updated_at' => now(),
|
||||
];
|
||||
|
||||
DB::table('uploads')->insert(array_merge($defaults, $overrides));
|
||||
|
||||
return $id;
|
||||
}
|
||||
|
||||
public function test_deletes_expired_draft_uploads_and_returns_count(): void
|
||||
{
|
||||
Storage::fake('local');
|
||||
|
||||
$uploadId = $this->insertUploadRow([
|
||||
'status' => 'draft',
|
||||
'expires_at' => now()->subMinute(),
|
||||
]);
|
||||
|
||||
Storage::disk('local')->put("tmp/drafts/{$uploadId}/meta.json", '{}');
|
||||
|
||||
$deleted = app(CleanupService::class)->cleanupStaleDrafts();
|
||||
|
||||
$this->assertSame(1, $deleted);
|
||||
$this->assertFalse(DB::table('uploads')->where('id', $uploadId)->exists());
|
||||
}
|
||||
|
||||
public function test_keeps_active_drafts_untouched(): void
|
||||
{
|
||||
Storage::fake('local');
|
||||
|
||||
$uploadId = $this->insertUploadRow([
|
||||
'status' => 'draft',
|
||||
'expires_at' => now()->addDay(),
|
||||
'updated_at' => now()->subHours(2),
|
||||
]);
|
||||
|
||||
Storage::disk('local')->put("tmp/drafts/{$uploadId}/meta.json", '{}');
|
||||
|
||||
$deleted = app(CleanupService::class)->cleanupStaleDrafts();
|
||||
|
||||
$this->assertSame(0, $deleted);
|
||||
$this->assertTrue(DB::table('uploads')->where('id', $uploadId)->exists());
|
||||
$this->assertTrue(Storage::disk('local')->exists("tmp/drafts/{$uploadId}/meta.json"));
|
||||
}
|
||||
|
||||
public function test_removes_temp_folder_when_deleting_stale_drafts(): void
|
||||
{
|
||||
Storage::fake('local');
|
||||
|
||||
$uploadId = $this->insertUploadRow([
|
||||
'status' => 'draft',
|
||||
'updated_at' => now()->subHours(25),
|
||||
]);
|
||||
|
||||
Storage::disk('local')->put("tmp/drafts/{$uploadId}/main/file.bin", 'x');
|
||||
$this->assertTrue(Storage::disk('local')->exists("tmp/drafts/{$uploadId}/main/file.bin"));
|
||||
|
||||
$deleted = app(CleanupService::class)->cleanupStaleDrafts();
|
||||
|
||||
$this->assertSame(1, $deleted);
|
||||
$this->assertFalse(Storage::disk('local')->exists("tmp/drafts/{$uploadId}/main/file.bin"));
|
||||
}
|
||||
|
||||
public function test_enforces_hard_cleanup_limit_of_100_per_run(): void
|
||||
{
|
||||
Storage::fake('local');
|
||||
|
||||
for ($index = 0; $index < 120; $index++) {
|
||||
$uploadId = $this->insertUploadRow([
|
||||
'status' => 'draft',
|
||||
'updated_at' => now()->subHours(30),
|
||||
]);
|
||||
|
||||
Storage::disk('local')->put("tmp/drafts/{$uploadId}/meta.json", '{}');
|
||||
}
|
||||
|
||||
$deleted = app(CleanupService::class)->cleanupStaleDrafts(999);
|
||||
|
||||
$this->assertSame(100, $deleted);
|
||||
$this->assertSame(20, DB::table('uploads')->count());
|
||||
}
|
||||
|
||||
public function test_never_deletes_published_uploads(): void
|
||||
{
|
||||
Storage::fake('local');
|
||||
|
||||
$uploadId = $this->insertUploadRow([
|
||||
'status' => 'published',
|
||||
'updated_at' => now()->subDays(5),
|
||||
'published_at' => now()->subDays(4),
|
||||
]);
|
||||
|
||||
Storage::disk('local')->put("tmp/drafts/{$uploadId}/meta.json", '{}');
|
||||
|
||||
$deleted = app(CleanupService::class)->cleanupStaleDrafts();
|
||||
|
||||
$this->assertSame(0, $deleted);
|
||||
$this->assertTrue(DB::table('uploads')->where('id', $uploadId)->where('status', 'published')->exists());
|
||||
$this->assertTrue(Storage::disk('local')->exists("tmp/drafts/{$uploadId}/meta.json"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
<?php
|
||||
|
||||
use App\Models\User;
|
||||
use App\Uploads\Services\PublishService;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Illuminate\Support\Str;
|
||||
use Tests\TestCase;
|
||||
|
||||
uses(TestCase::class, RefreshDatabase::class);
|
||||
|
||||
function createCategoryForPublishTests(): int
|
||||
{
|
||||
$contentTypeId = DB::table('content_types')->insertGetId([
|
||||
'name' => 'Skins',
|
||||
'slug' => 'skins-' . Str::lower(Str::random(6)),
|
||||
'description' => null,
|
||||
'created_at' => now(),
|
||||
'updated_at' => now(),
|
||||
]);
|
||||
|
||||
return DB::table('categories')->insertGetId([
|
||||
'content_type_id' => $contentTypeId,
|
||||
'parent_id' => null,
|
||||
'name' => 'Winamp',
|
||||
'slug' => 'winamp-' . Str::lower(Str::random(6)),
|
||||
'description' => null,
|
||||
'image' => null,
|
||||
'is_active' => true,
|
||||
'sort_order' => 0,
|
||||
'created_at' => now(),
|
||||
'updated_at' => now(),
|
||||
]);
|
||||
}
|
||||
|
||||
it('rejects publish when user is not owner', function () {
|
||||
Storage::fake('local');
|
||||
|
||||
$owner = User::factory()->create();
|
||||
$other = User::factory()->create();
|
||||
$categoryId = createCategoryForPublishTests();
|
||||
$uploadId = (string) Str::uuid();
|
||||
|
||||
DB::table('uploads')->insert([
|
||||
'id' => $uploadId,
|
||||
'user_id' => $owner->id,
|
||||
'type' => 'image',
|
||||
'status' => 'draft',
|
||||
'moderation_status' => 'approved',
|
||||
'title' => 'City Lights',
|
||||
'category_id' => $categoryId,
|
||||
'is_scanned' => true,
|
||||
'has_tags' => true,
|
||||
'preview_path' => "tmp/drafts/{$uploadId}/preview.webp",
|
||||
'created_at' => now(),
|
||||
'updated_at' => now(),
|
||||
]);
|
||||
|
||||
$service = app(PublishService::class);
|
||||
|
||||
expect(fn () => $service->publish($uploadId, $other))
|
||||
->toThrow(RuntimeException::class, 'You do not own this upload.');
|
||||
});
|
||||
|
||||
it('rejects archive publish without screenshots', function () {
|
||||
Storage::fake('local');
|
||||
|
||||
$owner = User::factory()->create();
|
||||
$categoryId = createCategoryForPublishTests();
|
||||
$uploadId = (string) Str::uuid();
|
||||
|
||||
DB::table('uploads')->insert([
|
||||
'id' => $uploadId,
|
||||
'user_id' => $owner->id,
|
||||
'type' => 'archive',
|
||||
'status' => 'draft',
|
||||
'moderation_status' => 'approved',
|
||||
'title' => 'Skin Pack',
|
||||
'category_id' => $categoryId,
|
||||
'is_scanned' => true,
|
||||
'has_tags' => true,
|
||||
'preview_path' => "tmp/drafts/{$uploadId}/preview.webp",
|
||||
'created_at' => now(),
|
||||
'updated_at' => now(),
|
||||
]);
|
||||
|
||||
DB::table('upload_files')->insert([
|
||||
'upload_id' => $uploadId,
|
||||
'path' => "tmp/drafts/{$uploadId}/main/pack.zip",
|
||||
'type' => 'main',
|
||||
'hash' => 'aabbccddeeff0011',
|
||||
'size' => 1024,
|
||||
'mime' => 'application/zip',
|
||||
'created_at' => now(),
|
||||
]);
|
||||
|
||||
$service = app(PublishService::class);
|
||||
|
||||
expect(fn () => $service->publish($uploadId, $owner))
|
||||
->toThrow(RuntimeException::class, 'Archive uploads require at least one screenshot.');
|
||||
});
|
||||
@@ -0,0 +1,130 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Unit\Uploads;
|
||||
|
||||
use Tests\TestCase;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Illuminate\Http\UploadedFile;
|
||||
use App\Services\Upload\UploadDraftService;
|
||||
use Illuminate\Contracts\Filesystem\Filesystem as FilesystemContract;
|
||||
use Illuminate\Filesystem\FilesystemManager;
|
||||
use Carbon\Carbon;
|
||||
use App\Models\User;
|
||||
|
||||
class UploadDraftServiceTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
protected UploadDraftService $service;
|
||||
protected User $user;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
|
||||
// Use fake storage so we don't touch the real filesystem
|
||||
Storage::fake('local');
|
||||
|
||||
$this->user = User::factory()->create();
|
||||
|
||||
// Provide a dummy clamav scanner binding so any scanning calls are mocked
|
||||
$this->app->instance('clamav', new class {
|
||||
public function scan(string $path): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
});
|
||||
|
||||
$filesystem = $this->app->make(FilesystemManager::class);
|
||||
$this->service = new UploadDraftService($filesystem, 'local');
|
||||
}
|
||||
|
||||
public function test_createDraft_creates_directory_and_writes_meta()
|
||||
{
|
||||
$result = $this->service->createDraft(['title' => 'Test Draft', 'user_id' => $this->user->id, 'type' => 'image']);
|
||||
|
||||
$this->assertArrayHasKey('id', $result);
|
||||
$id = $result['id'];
|
||||
|
||||
Storage::disk('local')->assertExists("tmp/drafts/{$id}");
|
||||
Storage::disk('local')->assertExists("tmp/drafts/{$id}/meta.json");
|
||||
|
||||
$meta = json_decode(Storage::disk('local')->get("tmp/drafts/{$id}/meta.json"), true);
|
||||
$this->assertSame('Test Draft', $meta['title']);
|
||||
$this->assertSame($id, $meta['id']);
|
||||
}
|
||||
|
||||
public function test_storeMainFile_saves_file_and_updates_meta()
|
||||
{
|
||||
$draft = $this->service->createDraft(['user_id' => $this->user->id, 'type' => 'image']);
|
||||
$id = $draft['id'];
|
||||
|
||||
$file = UploadedFile::fake()->create('song.mp3', 1500, 'audio/mpeg');
|
||||
|
||||
$info = $this->service->storeMainFile($id, $file);
|
||||
|
||||
$this->assertArrayHasKey('path', $info);
|
||||
Storage::disk('local')->assertExists($info['path']);
|
||||
|
||||
$meta = json_decode(Storage::disk('local')->get("tmp/drafts/{$id}/meta.json"), true);
|
||||
$this->assertArrayHasKey('main_file', $meta);
|
||||
$this->assertSame($info['hash'], $meta['main_file']['hash']);
|
||||
}
|
||||
|
||||
public function test_storeScreenshot_saves_file_and_appends_meta()
|
||||
{
|
||||
$draft = $this->service->createDraft(['user_id' => $this->user->id, 'type' => 'image']);
|
||||
$id = $draft['id'];
|
||||
|
||||
$img = UploadedFile::fake()->image('thumb.jpg', 640, 480);
|
||||
|
||||
$info = $this->service->storeScreenshot($id, $img);
|
||||
|
||||
$this->assertArrayHasKey('path', $info);
|
||||
Storage::disk('local')->assertExists($info['path']);
|
||||
|
||||
$meta = json_decode(Storage::disk('local')->get("tmp/drafts/{$id}/meta.json"), true);
|
||||
$this->assertArrayHasKey('screenshots', $meta);
|
||||
$this->assertCount(1, $meta['screenshots']);
|
||||
$this->assertSame($info['hash'], $meta['screenshots'][0]['hash']);
|
||||
}
|
||||
|
||||
public function test_calculateHash_for_local_file_and_storage_path()
|
||||
{
|
||||
$file = UploadedFile::fake()->create('doc.pdf', 10);
|
||||
$realPath = $file->getRealPath();
|
||||
|
||||
$expected = hash_file('sha256', $realPath);
|
||||
$this->assertSame($expected, $this->service->calculateHash($realPath));
|
||||
|
||||
// Store into drafts and calculate by storage path
|
||||
$draft = $this->service->createDraft(['user_id' => $this->user->id, 'type' => 'image']);
|
||||
$id = $draft['id'];
|
||||
$info = $this->service->storeMainFile($id, $file);
|
||||
|
||||
$storageHash = $this->service->calculateHash($info['path']);
|
||||
$storedContents = Storage::disk('local')->get($info['path']);
|
||||
$this->assertSame(hash('sha256', $storedContents), $storageHash);
|
||||
}
|
||||
|
||||
public function test_setExpiration_writes_expires_at_in_meta()
|
||||
{
|
||||
$draft = $this->service->createDraft(['user_id' => $this->user->id, 'type' => 'image']);
|
||||
$id = $draft['id'];
|
||||
|
||||
$when = Carbon::now()->addDays(3);
|
||||
$ok = $this->service->setExpiration($id, $when);
|
||||
$this->assertTrue($ok);
|
||||
|
||||
$meta = json_decode(Storage::disk('local')->get("tmp/drafts/{$id}/meta.json"), true);
|
||||
$this->assertArrayHasKey('expires_at', $meta);
|
||||
$this->assertSame($when->toISOString(), $meta['expires_at']);
|
||||
}
|
||||
|
||||
public function test_calculateHash_throws_for_missing_file()
|
||||
{
|
||||
$this->expectException(\RuntimeException::class);
|
||||
$this->service->calculateHash('this/path/does/not/exist');
|
||||
}
|
||||
}
|
||||
@@ -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