optimizations

This commit is contained in:
2026-03-28 19:15:39 +01:00
parent 0b25d9570a
commit cab4fbd83e
509 changed files with 1016804 additions and 1605 deletions

View File

@@ -2,20 +2,20 @@
"cookies": [
{
"name": "XSRF-TOKEN",
"value": "eyJpdiI6IlIyTWVLc1doRWZmL0xhWmpIbFp5akE9PSIsInZhbHVlIjoiY2Q0ZzA1blBXRFpxR2pDdlZ5S2E5YmVYUWdyOHJsc3RYR0ZYZUllYjFZeHRZNmFMUXFuamd3NlZwVnNtaW10Qjhsd0JaRDVRYUg2cGFUTmE4ZjZIMlN0bmdSYzB3NnRveHcwMHI1QVdMSUJaQ3ZZNTZHeUxxUXBkbEFqYTkyZ0QiLCJtYWMiOiIyODg0YzY5NmVhNTVlMWMyMjA0YmMxMjcxYWJkZmJlZWU2ZGI1Mzg5NmY4Y2IzMGJmZWE5NGViN2I5NzRmYTY5IiwidGFnIjoiIn0%3D",
"value": "eyJpdiI6IllHU2pQYXRJODYwT3BSb0EvVzlpNEE9PSIsInZhbHVlIjoiNS9UWEIwM3VSVGZjVmtGNm50Q1lnM1FudlJwWnZlMW1GZGZrdUhUZ0JaT2FHMWF0cHhDeGJjanJXSnY5SWR1cjFCMGFlTGRLWlF3UGYxUzkrZ2JRQ1psVmxWTzc3eENUVG5pQ3Roa2NVL3RzTXEzZk5LblBNVEQzS3ZuWEJrWlIiLCJtYWMiOiI5YTcyZWE3Y2NjZWJiMmRhNzVmNmYwM2QxZGIwYTA4YjIxOGEwZTYzOGNlYzBiMjU2ZDVlMzQwOWFlY2FjMzQzIiwidGFnIjoiIn0%3D",
"domain": "skinbase26.test",
"path": "/",
"expires": 1772951912.063718,
"expires": 1774549455.73563,
"httpOnly": false,
"secure": false,
"sameSite": "Lax"
},
{
"name": "skinbasenova-session",
"value": "eyJpdiI6IklCV1VlRmlWL1owYkJiWTRpZkY4MkE9PSIsInZhbHVlIjoiRXJ2VTNjMm56Z3NNNWdwSE5RUHlSV2JhMHpoY21aeHJEb3JSUnVBRGIvVUpLZmVEbjBSM2ZQeFM0eDJEZDRYNHB0YVlpZUVqNVBYMFNXbXArYmFybVdrTnpBN3pJS0ZBcm9kSGlmQWs5dThVMXdyRUhOUjBRUFloWDJKWGl0L0UiLCJtYWMiOiIyYmZkMThmYjljNzIwMDgyOGE1N2U0NDgyMWVmNDg4YTU4MWFhNTk0MDA0NmM4MDA5YzU4ZGFlN2M5MGJmNjk3IiwidGFnIjoiIn0%3D",
"value": "eyJpdiI6IjJpaTBWTk10YU56aXBrUDdMOUtia2c9PSIsInZhbHVlIjoialhXU2N3cHRLM0dQN29teDR5MlFPRWhRMXlVRG1jOGFETUNKVnA3NW9HZSt1RjNOaXRUWGdXYmhDMEJLLzJXdTc5NUNOMUwrdk1wTE5wUk0vNDVjY3JTYjk5cmY5YmplT2NqRzBGckpIcDFiTjdvRWhEaXdidnNsbGcyTDhzK2MiLCJtYWMiOiJiOWVhNDhhOWQwMmI4NTRlMDkyMjFiMzQ4OWM3ZTc3YmEzYjhiNDFhOTBjMDhjMWE1MjI3YTQ1N2Y2ODAzODFmIiwidGFnIjoiIn0%3D",
"domain": "skinbase26.test",
"path": "/",
"expires": 1772951912.063846,
"expires": 1774549455.735725,
"httpOnly": true,
"secure": false,
"sameSite": "Lax"

View File

@@ -0,0 +1,111 @@
<?php
declare(strict_types=1);
use App\Models\Artwork;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;
uses(RefreshDatabase::class);
it('admin discovery feedback report includes negative feedback and undo metrics', function () {
$admin = User::factory()->create(['role' => 'admin']);
$user = User::factory()->create();
$artwork = Artwork::factory()->create();
$previousDate = now()->subDay()->toDateString();
$date = now()->toDateString();
$recordEvent = function (string $eventDate, string $eventType, array $meta = []) use ($user, $artwork) {
$algoVersion = (string) ($meta['algo_version'] ?? 'clip-cosine-v2-adaptive');
unset($meta['algo_version']);
DB::table('user_discovery_events')->insert([
'event_id' => (string) Str::uuid(),
'user_id' => $user->id,
'artwork_id' => $artwork->id,
'category_id' => null,
'event_type' => $eventType,
'event_version' => 'event-v1',
'algo_version' => $algoVersion,
'weight' => 1.0,
'event_date' => $eventDate,
'occurred_at' => now(),
'meta' => json_encode(array_merge([
'gallery_type' => 'for-you',
'surface' => 'for-you',
], $meta), JSON_THROW_ON_ERROR),
'created_at' => now(),
'updated_at' => now(),
]);
};
$recordEvent($previousDate, 'view');
$recordEvent($previousDate, 'click');
$recordEvent($previousDate, 'favorite');
$recordEvent($previousDate, 'hide_artwork', ['reason' => 'not_relevant']);
$recordEvent($previousDate, 'dislike_tag', ['tag_slug' => 'abstract']);
$recordEvent($previousDate, 'view', ['gallery_type' => 'homepage', 'surface' => 'homepage', 'algo_version' => 'clip-cosine-v1']);
$recordEvent($previousDate, 'click', ['gallery_type' => 'homepage', 'surface' => 'homepage', 'algo_version' => 'clip-cosine-v1']);
$recordEvent($previousDate, 'favorite', ['gallery_type' => 'homepage', 'surface' => 'homepage', 'algo_version' => 'clip-cosine-v1']);
$recordEvent($date, 'view');
$recordEvent($date, 'click');
$recordEvent($date, 'favorite');
$recordEvent($date, 'download');
$recordEvent($date, 'hide_artwork', ['reason' => 'not_relevant']);
$recordEvent($date, 'unhide_artwork', ['reason' => 'undo']);
$recordEvent($date, 'view', ['gallery_type' => 'homepage', 'surface' => 'homepage', 'algo_version' => 'clip-cosine-v1']);
$recordEvent($date, 'click', ['gallery_type' => 'homepage', 'surface' => 'homepage', 'algo_version' => 'clip-cosine-v1']);
$recordEvent($date, 'hide_artwork', ['gallery_type' => 'homepage', 'surface' => 'homepage', 'algo_version' => 'clip-cosine-v1', 'reason' => 'not_relevant']);
$recordEvent($date, 'dislike_tag', ['gallery_type' => 'homepage', 'surface' => 'homepage', 'algo_version' => 'clip-cosine-v1', 'tag_slug' => 'portrait']);
$this->artisan('analytics:aggregate-discovery-feedback', ['--date' => $previousDate])
->assertExitCode(0);
$this->artisan('analytics:aggregate-discovery-feedback', ['--date' => $date])
->assertExitCode(0);
$response = $this->actingAs($admin)
->getJson('/api/admin/reports/discovery-feedback?from=' . $previousDate . '&to=' . $date . '&limit=10');
$response->assertOk();
$response->assertJsonPath('overview.views', 4);
$response->assertJsonPath('overview.clicks', 4);
$response->assertJsonPath('overview.feedback_actions', 4);
$response->assertJsonPath('overview.hidden_artworks', 3);
$response->assertJsonPath('overview.disliked_tags', 2);
$response->assertJsonPath('overview.negative_feedback_actions', 5);
$response->assertJsonPath('overview.undo_hidden_artworks', 1);
$response->assertJsonPath('overview.undo_disliked_tags', 0);
$response->assertJsonPath('overview.undo_actions', 1);
$response->assertJsonPath('daily_feedback.0.negative_feedback_actions', 2);
$response->assertJsonPath('daily_feedback.1.negative_feedback_actions', 3);
$response->assertJsonPath('daily_feedback.1.undo_actions', 1);
$response->assertJsonPath('trend_summary.latest_day.date', $date);
$response->assertJsonPath('trend_summary.previous_day.date', $previousDate);
$response->assertJsonPath('trend_summary.rolling_7d_average.feedback_actions', 2);
$response->assertJsonPath('trend_summary.rolling_7d_average.negative_feedback_actions', 2.5);
$response->assertJsonPath('trend_summary.rolling_7d_average.undo_actions', 0.5);
$response->assertJsonPath('trend_summary.deltas.feedback_actions.label', 'Flat');
$response->assertJsonPath('trend_summary.deltas.negative_feedback_actions.label', 'Worse +1 vs prev day');
$response->assertJsonPath('trend_summary.overall_status.level', 'watch');
$response->assertJsonPath('by_surface.0.surface', 'homepage');
$response->assertJsonPath('by_surface.0.negative_feedback_actions', 2);
$response->assertJsonPath('by_surface.0.trend.overall_status.level', 'risk');
$response->assertJsonPath('by_surface.0.trend.deltas.feedback_actions.label', 'Worse -1 vs prev day');
$response->assertJsonPath('by_surface.0.trend.deltas.negative_feedback_actions.label', 'Worse +2 vs prev day');
$response->assertJsonPath('by_surface.1.surface', 'for-you');
$response->assertJsonPath('by_surface.1.trend.overall_status.level', 'healthy');
$response->assertJsonPath('by_algo_surface.0.algo_version', 'clip-cosine-v1');
$response->assertJsonPath('by_algo_surface.0.surface', 'homepage');
$response->assertJsonPath('by_algo_surface.0.negative_feedback_actions', 2);
$response->assertJsonPath('by_algo_surface.0.trend.overall_status.level', 'risk');
$response->assertJsonPath('by_algo_surface.0.trend.deltas.feedback_actions.label', 'Worse -1 vs prev day');
$response->assertJsonPath('by_algo_surface.0.trend.deltas.negative_feedback_actions.label', 'Worse +2 vs prev day');
$response->assertJsonPath('by_algo_surface.1.algo_version', 'clip-cosine-v2-adaptive');
$response->assertJsonPath('top_artworks.0.artwork_id', $artwork->id);
$response->assertJsonPath('top_artworks.0.negative_feedback_actions', 3);
$response->assertJsonPath('top_artworks.0.undo_actions', 1);
$response->assertJsonPath('latest_aggregated_date', $date);
});

View File

@@ -0,0 +1,55 @@
<?php
declare(strict_types=1);
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class);
it('admin can inspect feed engine decisions for a user bucket', function () {
config()->set('discovery.v2.enabled', true);
config()->set('discovery.v2.rollout_percentage', 35);
config()->set('discovery.v2.algo_version', 'clip-cosine-v2-adaptive');
$admin = User::factory()->create(['role' => 'admin']);
$subject = User::factory()->create();
$expectedBucket = abs((int) crc32((string) $subject->id)) % 100;
$response = $this->actingAs($admin)
->getJson('/api/admin/reports/feed-engine-decision?user_id=' . $subject->id);
$response->assertOk();
$response->assertJsonPath('decision.user_id', $subject->id);
$response->assertJsonPath('decision.bucket', $expectedBucket);
$response->assertJsonPath('decision.rollout_percentage', 35);
$response->assertJsonPath('decision.uses_v2', $expectedBucket < 35);
$response->assertJsonPath('decision.selected_engine', $expectedBucket < 35 ? 'v2' : 'v1');
});
it('admin can inspect explicit v2 algo overrides even when rollout is disabled', function () {
config()->set('discovery.v2.enabled', false);
config()->set('discovery.v2.rollout_percentage', 0);
config()->set('discovery.v2.algo_version', 'clip-cosine-v2-adaptive');
$admin = User::factory()->create(['role' => 'admin']);
$subject = User::factory()->create();
$response = $this->actingAs($admin)
->getJson('/api/admin/reports/feed-engine-decision?user_id=' . $subject->id . '&algo_version=clip-cosine-v2-adaptive');
$response->assertOk();
$response->assertJsonPath('decision.uses_v2', true);
$response->assertJsonPath('decision.selected_engine', 'v2');
$response->assertJsonPath('decision.reason', 'explicit_algo_override');
});
it('non-admin is denied feed engine decision endpoint', function () {
$user = User::factory()->create(['role' => 'user']);
$subject = User::factory()->create();
$response = $this->actingAs($user)
->getJson('/api/admin/reports/feed-engine-decision?user_id=' . $subject->id);
$response->assertStatus(403);
});

View File

@@ -0,0 +1,31 @@
<?php
declare(strict_types=1);
use App\Models\Artwork;
use App\Models\User;
use function Pest\Laravel\actingAs;
use function Pest\Laravel\postJson;
it('creates upload drafts as private artworks', function (): void {
$user = User::factory()->create();
actingAs($user);
$response = postJson('/api/artworks', [
'title' => 'Upload draft test',
'description' => '<p>Draft body</p>',
'is_mature' => false,
]);
$response->assertCreated()
->assertJsonPath('status', 'draft');
$artworkId = (int) $response->json('artwork_id');
$artwork = Artwork::query()->findOrFail($artworkId);
expect($artwork->visibility)->toBe(Artwork::VISIBILITY_PRIVATE)
->and($artwork->is_public)->toBeFalse()
->and($artwork->artwork_status)->toBe('draft')
->and($artwork->published_at)->toBeNull();
});

View File

@@ -39,5 +39,5 @@ test('approval filtering works via approved scope', function () {
});
test('admin routes are protected from unauthenticated users', function () {
$this->get('/admin/artworks')->assertRedirect('/login');
$this->get('/admin/artworks')->assertNotFound();
});

View File

@@ -63,7 +63,7 @@ test('profile username update writes history and redirect map', function () {
'email' => $user->email,
]);
$response->assertRedirect('/user');
$response->assertRedirect('/dashboard/profile');
$this->assertDatabaseHas('users', [
'id' => $user->id,

View File

@@ -77,3 +77,36 @@ it('does not throw on CLIP 4xx and never blocks publish', function () {
expect($artwork->tags()->count())->toBe(0);
});
it('persists clip tags blip caption and yolo objects from the unified gateway response', function () {
config()->set('vision.enabled', true);
config()->set('vision.gateway.base_url', 'https://vision.local');
config()->set('vision.yolo.enabled', false);
config()->set('cdn.files_url', 'https://files.local');
Http::fake([
'https://vision.local/analyze/all' => Http::response([
'clip' => [
['tag' => 'Neon City', 'confidence' => 0.71],
],
'blip' => ['A neon city street at night'],
'yolo' => [
['label' => 'car', 'confidence' => 0.88],
['label' => 'person', 'confidence' => 0.67],
],
], 200),
]);
$artwork = Artwork::factory()->create();
(new AutoTagArtworkJob($artwork->id, 'abcdef123456'))->handle(app(\App\Services\TagService::class), app(\App\Services\TagNormalizer::class));
$artwork->refresh();
expect($artwork->clip_tags_json)->toBeArray()
->and($artwork->clip_tags_json[0]['tag'] ?? null)->toBe('Neon City')
->and($artwork->blip_caption)->toBe('A neon city street at night')
->and($artwork->yolo_objects_json)->toBeArray()
->and($artwork->yolo_objects_json[0]['tag'] ?? null)->toBe('car')
->and($artwork->vision_metadata_updated_at)->not->toBeNull();
});

View File

@@ -42,45 +42,34 @@ class BrowseApiTest extends TestCase
$artwork->categories()->attach($category->id);
}
$response = $this->get('/browse?limit=12&grid=v2');
$response = $this->get('/explore?limit=12&grid=v2');
$response->assertOk();
$html = $response->getContent();
$this->assertNotFalse($html);
$this->assertStringContainsString('<meta name="robots" content="index,follow" />', $html);
$this->assertMatchesRegularExpression('/<link rel="canonical" href="[^"]*\/browse\?limit=12"\s*\/>/i', $html);
$this->assertStringContainsString('name="robots" content="index,follow"', $html);
$this->assertMatchesRegularExpression('/<link rel="canonical" href="[^"]*\/explore\?limit=12"\s*\/>/i', $html);
preg_match('/<link rel="canonical" href="([^"]+)"\s*\/>/i', $html, $canonicalMatches);
$this->assertArrayHasKey(1, $canonicalMatches);
$canonicalUrl = html_entity_decode((string) $canonicalMatches[1], ENT_QUOTES);
$this->assertStringNotContainsString('grid=v2', $canonicalUrl);
$this->assertMatchesRegularExpression('/<link rel="next" href="([^"]+)"\s*\/>/i', $html);
preg_match('/<link rel="next" href="([^"]+)"\s*\/>/i', $html, $nextMatches);
$this->assertArrayHasKey(1, $nextMatches);
$nextUrl = html_entity_decode((string) $nextMatches[1], ENT_QUOTES);
$this->assertStringContainsString('cursor=', $nextUrl);
$this->assertStringNotContainsString('grid=v2', $nextUrl);
$secondPage = $this->get($nextUrl);
$secondPage = $this->get('/explore?limit=12&page=2&grid=v2');
$secondPage->assertOk();
$secondHtml = $secondPage->getContent();
$this->assertNotFalse($secondHtml);
$this->assertMatchesRegularExpression('/<link rel="prev" href="([^"]+)"\s*\/>/i', $secondHtml);
preg_match('/<link rel="prev" href="([^"]+)"\s*\/>/i', $secondHtml, $prevMatches);
$this->assertArrayHasKey(1, $prevMatches);
$prevUrl = html_entity_decode((string) $prevMatches[1], ENT_QUOTES);
$this->assertStringNotContainsString('grid=v2', $prevUrl);
$this->assertMatchesRegularExpression('/<link rel="canonical" href="[^"]*\/browse\?[^\"]*cursor=/i', $secondHtml);
$this->assertMatchesRegularExpression('/<link rel="canonical" href="[^"]*\/explore\?[^"]*page=2/i', $secondHtml);
preg_match('/<link rel="canonical" href="([^"]+)"\s*\/>/i', $secondHtml, $secondCanonicalMatches);
$this->assertArrayHasKey(1, $secondCanonicalMatches);
$secondCanonicalUrl = html_entity_decode((string) $secondCanonicalMatches[1], ENT_QUOTES);
$this->assertStringNotContainsString('grid=v2', $secondCanonicalUrl);
$this->assertStringContainsString('page=2', $secondCanonicalUrl);
$pageOne = $this->get('/browse?limit=12&page=1&grid=v2');
$pageOne = $this->get('/explore?limit=12&page=1&grid=v2');
$pageOne->assertOk();
$pageOneHtml = $pageOne->getContent();
$this->assertNotFalse($pageOneHtml);
$this->assertMatchesRegularExpression('/<link rel="canonical" href="[^"]*\/browse\?limit=12"\s*\/>/i', $pageOneHtml);
$this->assertMatchesRegularExpression('/<link rel="canonical" href="[^"]*\/explore\?limit=12"\s*\/>/i', $pageOneHtml);
preg_match('/<link rel="canonical" href="([^"]+)"\s*\/>/i', $pageOneHtml, $pageOneCanonicalMatches);
$this->assertArrayHasKey(1, $pageOneCanonicalMatches);
$pageOneCanonicalUrl = html_entity_decode((string) $pageOneCanonicalMatches[1], ENT_QUOTES);
@@ -198,7 +187,7 @@ class BrowseApiTest extends TestCase
$artwork->categories()->attach($category->id);
$response = $this->get('/browse');
$response = $this->get('/explore');
$response->assertOk();
$response->assertSee('Forest Light');
@@ -206,11 +195,8 @@ class BrowseApiTest extends TestCase
$html = $response->getContent();
$this->assertNotFalse($html);
$this->assertStringContainsString('itemprop="thumbnailUrl"', $html);
// First card (index 0) is eager-loaded with fetchpriority=high — no blur-preview
$this->assertStringContainsString('loading="eager"', $html);
$this->assertStringContainsString('decoding="sync"', $html);
$this->assertStringContainsString('fetchpriority="high"', $html);
$this->assertMatchesRegularExpression('/<img[^>]*loading="eager"[^>]*width="\d+"[^>]*height="\d+"/i', $html);
$this->assertStringContainsString('data-react-masonry-gallery', $html);
$this->assertStringContainsString('data-gallery-type="browse"', $html);
$this->assertStringContainsString('Forest Light', $html);
}
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -46,3 +46,22 @@ it('validates discovery event payload', function () {
$response->assertStatus(422);
$response->assertJsonValidationErrors(['event_type']);
});
it('accepts session-oriented discovery events', function () {
Queue::fake();
$user = User::factory()->create();
$artwork = Artwork::factory()->create();
$response = $this->actingAs($user)->postJson('/api/discovery/events', [
'event_type' => 'dwell',
'artwork_id' => $artwork->id,
'meta' => ['duration_ms' => 4500],
]);
$response->assertStatus(202);
Queue::assertPushed(IngestUserDiscoveryEventJob::class, function (IngestUserDiscoveryEventJob $job): bool {
return $job->eventType === 'dwell';
});
});

View File

@@ -0,0 +1,95 @@
<?php
declare(strict_types=1);
use App\Models\Artwork;
use App\Models\Tag;
use App\Models\User;
use App\Models\UserNegativeSignal;
use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class);
it('stores hidden artwork signals', function () {
$user = User::factory()->create();
$artwork = Artwork::factory()->create();
$response = $this->actingAs($user)->postJson('/api/discovery/feedback/hide-artwork', [
'artwork_id' => $artwork->id,
'source' => 'feed-test',
]);
$response->assertStatus(202)->assertJsonPath('stored', true);
expect(UserNegativeSignal::query()
->where('user_id', $user->id)
->where('signal_type', 'hide_artwork')
->where('artwork_id', $artwork->id)
->exists())->toBeTrue();
});
it('revokes hidden artwork signals', function () {
$user = User::factory()->create();
$artwork = Artwork::factory()->create();
UserNegativeSignal::query()->create([
'user_id' => $user->id,
'signal_type' => 'hide_artwork',
'artwork_id' => $artwork->id,
'source' => 'feed-test',
]);
$response = $this->actingAs($user)->deleteJson('/api/discovery/feedback/hide-artwork', [
'artwork_id' => $artwork->id,
]);
$response->assertOk()->assertJsonPath('revoked', true);
expect(UserNegativeSignal::query()
->where('user_id', $user->id)
->where('signal_type', 'hide_artwork')
->where('artwork_id', $artwork->id)
->exists())->toBeFalse();
});
it('stores disliked tag signals by slug', function () {
$user = User::factory()->create();
$tag = Tag::query()->create(['name' => 'Sci-Fi', 'slug' => 'sci-fi']);
$response = $this->actingAs($user)->postJson('/api/discovery/feedback/dislike-tag', [
'tag_slug' => 'sci-fi',
'source' => 'feed-test',
]);
$response->assertStatus(202)->assertJsonPath('stored', true);
expect(UserNegativeSignal::query()
->where('user_id', $user->id)
->where('signal_type', 'dislike_tag')
->where('tag_id', $tag->id)
->exists())->toBeTrue();
});
it('revokes disliked tag signals by slug', function () {
$user = User::factory()->create();
$tag = Tag::query()->create(['name' => 'Sci-Fi', 'slug' => 'sci-fi']);
UserNegativeSignal::query()->create([
'user_id' => $user->id,
'signal_type' => 'dislike_tag',
'tag_id' => $tag->id,
'source' => 'feed-test',
]);
$response = $this->actingAs($user)->deleteJson('/api/discovery/feedback/dislike-tag', [
'tag_slug' => 'sci-fi',
]);
$response->assertOk()->assertJsonPath('revoked', true);
expect(UserNegativeSignal::query()
->where('user_id', $user->id)
->where('signal_type', 'dislike_tag')
->where('tag_id', $tag->id)
->exists())->toBeFalse();
});

View File

@@ -0,0 +1,262 @@
<?php
declare(strict_types=1);
use App\Models\Artwork;
use App\Models\ArtworkEmbedding;
use App\Models\Tag;
use App\Models\User;
use App\Models\UserRecommendationCache;
use App\Services\Recommendations\SessionRecoService;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Http;
use function Pest\Laravel\actingAs;
use function Pest\Laravel\getJson;
uses(RefreshDatabase::class);
it('can serve the feed through the v2 selector path', function () {
config()->set('discovery.v2.enabled', true);
config()->set('discovery.v2.rollout_percentage', 100);
config()->set('discovery.v2.algo_version', 'clip-cosine-v2-adaptive');
$user = User::factory()->create();
$creator = User::factory()->create();
$artwork = Artwork::factory()->create([
'user_id' => $creator->id,
'is_public' => true,
'is_approved' => true,
'published_at' => now()->subHour(),
'trending_score_1h' => 5,
'trending_score_24h' => 10,
'trending_score_7d' => 20,
]);
$tag = Tag::query()->create(['name' => 'Abstract', 'slug' => 'abstract']);
DB::table('artwork_tag')->insert([
'artwork_id' => $artwork->id,
'tag_id' => $tag->id,
'source' => 'user',
'created_at' => now(),
]);
DB::table('artwork_stats')->insert([
'artwork_id' => $artwork->id,
'views' => 150,
'downloads' => 20,
'favorites' => 15,
'comments_count' => 5,
'shares_count' => 2,
'views_24h' => 150,
'views_7d' => 150,
'downloads_24h' => 20,
'downloads_7d' => 20,
'shares_24h' => 2,
'comments_24h' => 5,
'favourites_24h' => 15,
'views_1h' => 40,
'downloads_1h' => 5,
'favourites_1h' => 4,
'comments_1h' => 2,
'shares_1h' => 1,
'ranking_score' => 50,
'engagement_velocity' => 12,
'heat_score' => 30,
'rating_avg' => 0,
'rating_count' => 0,
]);
UserRecommendationCache::query()->create([
'user_id' => $user->id,
'algo_version' => 'clip-cosine-v2-adaptive',
'cache_version' => 'cache-v2',
'recommendations_json' => [
'items' => [
['artwork_id' => $artwork->id, 'score' => 1.2, 'source' => 'trending', 'layer_sources' => ['trending']],
],
],
'generated_at' => now(),
'expires_at' => now()->addMinutes(10),
]);
actingAs($user);
$response = getJson('/api/v1/feed?algo_version=clip-cosine-v2-adaptive&limit=2');
$response->assertOk();
$response->assertJsonPath('meta.engine', 'v2');
$response->assertJsonPath('meta.algo_version', 'clip-cosine-v2-adaptive');
$response->assertJsonPath('meta.local_embedding_count', 0);
$response->assertJsonPath('meta.vector_indexed_count', 0);
$response->assertJsonPath('data.0.primary_tag.slug', 'abstract');
$response->assertJsonPath('data.0.has_local_embedding', false);
$response->assertJsonPath('data.0.vector_indexed_at', null);
$response->assertJsonPath('data.0.ranking_signals.local_embedding_present', false);
$response->assertJsonPath('data.0.ranking_signals.vector_indexed_at', null);
expect((array) $response->json('data'))->toHaveCount(1);
});
it('boosts vector-similar candidates in the v3 hybrid feed', function () {
config()->set('discovery.v2.enabled', true);
config()->set('discovery.v2.rollout_percentage', 100);
config()->set('discovery.v2.algo_version', 'clip-cosine-v2-adaptive');
config()->set('discovery.v3.enabled', true);
config()->set('discovery.v3.vector_similarity_weight', 2.0);
config()->set('vision.vector_gateway.enabled', true);
config()->set('vision.vector_gateway.base_url', 'https://vision.klevze.net');
config()->set('vision.vector_gateway.api_key', 'test-key');
config()->set('vision.vector_gateway.search_endpoint', '/vectors/search');
config()->set('cdn.files_url', 'https://files.skinbase.org');
$user = User::factory()->create();
$creator = User::factory()->create();
$seedArtwork = Artwork::factory()->create([
'user_id' => $creator->id,
'hash' => 'aabbcc112233',
'thumb_ext' => 'webp',
'is_public' => true,
'is_approved' => true,
'published_at' => now()->subDays(10),
]);
$vectorMatch = Artwork::factory()->create([
'user_id' => $creator->id,
'hash' => 'ddeeff445566',
'thumb_ext' => 'webp',
'title' => 'Vector winner',
'slug' => 'vector-winner',
'is_public' => true,
'is_approved' => true,
'published_at' => now()->subDays(10),
'trending_score_1h' => 5,
'trending_score_24h' => 5,
'trending_score_7d' => 5,
'last_vector_indexed_at' => now()->subMinutes(5),
]);
$trendingLeader = Artwork::factory()->create([
'user_id' => $creator->id,
'hash' => '778899001122',
'thumb_ext' => 'webp',
'title' => 'Trending leader',
'slug' => 'trending-leader',
'is_public' => true,
'is_approved' => true,
'published_at' => now()->subDays(10),
'trending_score_1h' => 30,
'trending_score_24h' => 30,
'trending_score_7d' => 30,
]);
$sectionArtwork = Artwork::factory()->create([
'user_id' => $creator->id,
'hash' => '334455667788',
'thumb_ext' => 'webp',
'title' => 'Section artwork',
'slug' => 'section-artwork',
'is_public' => true,
'is_approved' => true,
'published_at' => now()->subDays(10),
'trending_score_1h' => 6,
'trending_score_24h' => 6,
'trending_score_7d' => 6,
]);
ArtworkEmbedding::query()->create([
'artwork_id' => $vectorMatch->id,
'model' => 'clip',
'model_version' => 'v1',
'algo_version' => 'clip-cosine-v1',
'dim' => 2,
'embedding_json' => json_encode([0.6, 0.8], JSON_THROW_ON_ERROR),
'source_hash' => 'ddeeff445566',
'is_normalized' => true,
'generated_at' => now(),
'meta' => ['source' => 'clip'],
]);
foreach ([$seedArtwork, $vectorMatch, $trendingLeader, $sectionArtwork] as $artwork) {
DB::table('artwork_stats')->insert([
'artwork_id' => $artwork->id,
'views' => 100,
'downloads' => 10,
'favorites' => 8,
'comments_count' => 3,
'shares_count' => 1,
'views_24h' => 100,
'views_7d' => 100,
'downloads_24h' => 10,
'downloads_7d' => 10,
'shares_24h' => 1,
'comments_24h' => 3,
'favourites_24h' => 8,
'views_1h' => 20,
'downloads_1h' => 2,
'favourites_1h' => 2,
'comments_1h' => 1,
'shares_1h' => 1,
'ranking_score' => 25,
'engagement_velocity' => 8,
'heat_score' => 15,
'rating_avg' => 0,
'rating_count' => 0,
]);
}
Http::fake(function ($request) use ($seedArtwork, $vectorMatch, $sectionArtwork) {
$payload = json_decode($request->body(), true);
$url = (string) ($payload['url'] ?? '');
if (str_contains($url, 'aabbcc112233')) {
return Http::response([
'results' => [
['id' => $seedArtwork->id, 'score' => 1.0],
['id' => $vectorMatch->id, 'score' => 0.98],
],
], 200);
}
if (str_contains($url, 'ddeeff445566')) {
return Http::response([
'results' => [
['id' => $vectorMatch->id, 'score' => 1.0],
['id' => $sectionArtwork->id, 'score' => 0.91],
],
], 200);
}
return Http::response(['results' => []], 200);
});
app(SessionRecoService::class)->applyEvent(
userId: $user->id,
eventType: 'view',
artworkId: $seedArtwork->id,
categoryId: null,
occurredAt: now()->toIso8601String(),
meta: []
);
actingAs($user);
$response = getJson('/api/v1/feed?algo_version=clip-cosine-v2-adaptive&limit=2');
$response->assertOk();
$response->assertJsonPath('meta.engine', 'v2');
$response->assertJsonPath('meta.vector_influenced_count', 1);
$response->assertJsonPath('meta.local_embedding_count', 1);
$response->assertJsonPath('meta.vector_indexed_count', 1);
$response->assertJsonPath('data.0.id', $vectorMatch->id);
$response->assertJsonPath('data.0.source', 'vector');
$response->assertJsonPath('data.0.reason', 'Visually similar to art you engaged with');
$response->assertJsonPath('data.0.vector_influenced', true);
$response->assertJsonPath('data.0.has_local_embedding', true);
expect($response->json('data.0.vector_indexed_at'))->not->toBeNull();
$response->assertJsonPath('data.0.ranking_signals.vector_similarity_score', 0.98);
$response->assertJsonPath('data.0.ranking_signals.local_embedding_present', true);
expect($response->json('data.0.ranking_signals.vector_indexed_at'))->not->toBeNull();
$response->assertJsonPath('sections.0.key', 'similar_style');
$response->assertJsonPath('sections.1.key', 'you_may_also_like');
$response->assertJsonPath('sections.2.key', 'visually_related');
$response->assertJsonPath('sections.0.items.0.id', $sectionArtwork->id);
$response->assertJsonPath('sections.2.items.0.id', $sectionArtwork->id);
});

View File

@@ -31,7 +31,8 @@ it('renders the home page with grid=v2 without errors', function () {
it('home page contains the gallery section', function () {
$this->get('/')
->assertStatus(200)
->assertSee('data-nova-gallery', false);
->assertSee('id="homepage-root"', false)
->assertSee('id="homepage-props"', false);
});
it('home page includes a canonical link tag', function () {

View File

@@ -60,6 +60,5 @@ it('redirects an old username subdomain to the canonical profile URL for the ren
it('does not treat reserved subdomains as profile hosts', function () {
$this->call('GET', '/sections', [], [], [], ['HTTP_HOST' => 'www.skinbase26.test'])
->assertRedirect('/categories')
->assertStatus(301);
});
->assertRedirect('/categories');
});

View File

@@ -13,6 +13,7 @@ use App\Events\ConversationUpdated;
use App\Events\MessageCreated;
use App\Events\MessageRead;
use App\Services\Messaging\MessagingPresenceService;
use Illuminate\Database\QueryException;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\DB;
@@ -177,6 +178,80 @@ test('sending a message dispatches realtime events and preserves client temp id'
Event::assertDispatched(ConversationUpdated::class);
});
test('retrying a send with the same client temp id reuses the existing message', function () {
Event::fake([MessageCreated::class]);
$userA = makeMessagingUser();
$userB = makeMessagingUser();
$conv = makeDirectConversation($userA, $userB);
$first = $this->actingAs($userA)->postJson("/api/messages/{$conv->id}", [
'body' => 'Retry safe hello',
'client_temp_id' => 'tmp_retry_safe_001',
]);
$second = $this->actingAs($userA)->postJson("/api/messages/{$conv->id}", [
'body' => 'Retry safe hello',
'client_temp_id' => 'tmp_retry_safe_001',
]);
$first->assertStatus(201);
$second->assertStatus(201);
expect(Message::query()->count())->toBe(1)
->and($second->json('id'))->toBe($first->json('id'))
->and($second->json('uuid'))->toBe($first->json('uuid'))
->and($second->json('client_temp_id'))->toBe('tmp_retry_safe_001');
Event::assertDispatchedTimes(MessageCreated::class, 1);
});
test('client temp id dedupe is scoped to sender', function () {
Event::fake([MessageCreated::class]);
$userA = makeMessagingUser();
$userB = makeMessagingUser();
$conv = makeDirectConversation($userA, $userB);
$first = $this->actingAs($userA)->postJson("/api/messages/{$conv->id}", [
'body' => 'Sender A',
'client_temp_id' => 'tmp_sender_scope_001',
]);
$second = $this->actingAs($userB)->postJson("/api/messages/{$conv->id}", [
'body' => 'Sender B',
'client_temp_id' => 'tmp_sender_scope_001',
]);
$first->assertStatus(201);
$second->assertStatus(201);
expect(Message::query()->count())->toBe(2)
->and($second->json('id'))->not->toBe($first->json('id'));
Event::assertDispatchedTimes(MessageCreated::class, 2);
});
test('database enforces sender scoped client temp id uniqueness', function () {
$userA = makeMessagingUser();
$userB = makeMessagingUser();
$conv = makeDirectConversation($userA, $userB);
Message::create([
'conversation_id' => $conv->id,
'sender_id' => $userA->id,
'body' => 'Stored once',
'client_temp_id' => 'tmp_db_guard_001',
]);
expect(fn () => Message::create([
'conversation_id' => $conv->id,
'sender_id' => $userA->id,
'body' => 'Stored twice',
'client_temp_id' => 'tmp_db_guard_001',
]))->toThrow(QueryException::class);
});
test('non participant cannot send a message', function () {
$userA = makeMessagingUser();
$userB = makeMessagingUser();
@@ -338,6 +413,9 @@ test('delta endpoint returns only messages after requested id in ascending order
$response = $this->actingAs($userA)->getJson("/api/messages/{$conv->id}/delta?after_message_id={$first->id}");
$response->assertStatus(200)
->assertJsonPath('conversation.id', $conv->id)
->assertJsonPath('conversation.latest_message.id', $third->id)
->assertJsonPath('summary.unread_total', 2)
->assertJsonPath('data.0.id', $second->id)
->assertJsonPath('data.1.id', $third->id);
});

View File

@@ -0,0 +1,393 @@
<?php
declare(strict_types=1);
use App\Models\NovaCard;
use App\Models\NovaCardAssetPack;
use App\Models\NovaCardCategory;
use App\Models\NovaCardChallenge;
use App\Models\NovaCardCollection;
use App\Models\NovaCardCollectionItem;
use App\Models\NovaCardTemplate;
use App\Models\Report;
use App\Models\User;
use Illuminate\Support\Arr;
use Illuminate\Support\Str;
use Inertia\Testing\AssertableInertia;
function adminCardCategory(array $attributes = []): NovaCardCategory
{
return NovaCardCategory::query()->create(array_merge([
'slug' => 'admin-category-' . Str::lower(Str::random(6)),
'name' => 'Admin Category',
'description' => 'Admin category',
'active' => true,
'order_num' => 0,
], $attributes));
}
function adminCardTemplate(array $attributes = []): NovaCardTemplate
{
return NovaCardTemplate::query()->create(array_merge([
'slug' => 'admin-template-' . Str::lower(Str::random(6)),
'name' => 'Admin Template',
'description' => 'Admin template',
'config_json' => [
'font_preset' => 'modern-sans',
'gradient_preset' => 'midnight-nova',
'layout' => 'quote_heavy',
'text_align' => 'center',
'text_color' => '#ffffff',
'overlay_style' => 'dark-soft',
],
'supported_formats' => ['square'],
'active' => true,
'official' => true,
'order_num' => 0,
], $attributes));
}
function moderatedCard(User $user, array $attributes = []): NovaCard
{
$category = $attributes['category'] ?? adminCardCategory();
$template = $attributes['template'] ?? adminCardTemplate();
return NovaCard::query()->create(array_merge([
'user_id' => $user->id,
'category_id' => $category->id,
'template_id' => $template->id,
'title' => 'Moderation Card',
'slug' => 'moderation-card-' . Str::lower(Str::random(6)),
'quote_text' => 'A card waiting for moderation.',
'format' => NovaCard::FORMAT_SQUARE,
'project_json' => [
'content' => ['title' => 'Moderation Card', 'quote_text' => 'A card waiting for moderation.'],
'layout' => ['layout' => 'quote_heavy', 'position' => 'center', 'alignment' => 'center', 'padding' => 'comfortable', 'max_width' => 'balanced'],
'typography' => ['font_preset' => 'modern-sans', 'text_color' => '#ffffff', 'accent_color' => '#e0f2fe', 'quote_size' => 72, 'author_size' => 28, 'letter_spacing' => 0, 'line_height' => 1.2, 'shadow_preset' => 'soft'],
'background' => ['type' => 'gradient', 'gradient_preset' => 'midnight-nova', 'gradient_colors' => ['#0f172a', '#1d4ed8'], 'overlay_style' => 'dark-soft', 'focal_position' => 'center', 'blur_level' => 0, 'opacity' => 50],
'decorations' => [],
],
'render_version' => 1,
'background_type' => 'gradient',
'visibility' => NovaCard::VISIBILITY_PUBLIC,
'status' => NovaCard::STATUS_PUBLISHED,
'moderation_status' => NovaCard::MOD_PENDING,
'featured' => false,
'allow_download' => true,
'published_at' => now()->subMinutes(10),
], Arr::except($attributes, ['category', 'template'])));
}
it('blocks non staff users from the cards admin area', function (): void {
$user = User::factory()->create(['role' => 'user']);
$this->actingAs($user)
->get(route('cp.cards.index'))
->assertForbidden();
});
it('renders the cards admin index for admins', function (): void {
$admin = User::factory()->create(['role' => 'admin']);
$creator = User::factory()->create(['username' => 'spotlightmaker', 'nova_featured_creator' => true]);
$card = moderatedCard($creator, [
'title' => 'Needs Review',
'project_json' => [
'content' => ['title' => 'Needs Review', 'quote_text' => 'A card waiting for moderation.'],
'moderation' => [
'source' => 'publish_heuristics',
'flagged' => true,
'reasons' => ['duplicate_content'],
'override' => [
'moderation_status' => 'flagged',
'disposition' => 'escalated_for_review',
'disposition_label' => 'Escalated for review',
'source' => 'report_queue',
'actor_username' => 'modone',
'updated_at' => now()->subMinutes(5)->toISOString(),
],
'override_history' => [
[
'moderation_status' => 'flagged',
'disposition' => 'escalated_for_review',
'disposition_label' => 'Escalated for review',
'source' => 'report_queue',
'actor_username' => 'modone',
'updated_at' => now()->subMinutes(5)->toISOString(),
],
[
'moderation_status' => 'pending',
'disposition' => 'returned_to_pending',
'disposition_label' => 'Returned to pending',
'source' => 'admin_card_update',
'actor_username' => 'modtwo',
'updated_at' => now()->subMinutes(12)->toISOString(),
],
],
],
],
]);
Report::query()->create([
'reporter_id' => $creator->id,
'target_type' => 'nova_card',
'target_id' => $card->id,
'reason' => 'Spam remix bait',
'status' => 'open',
]);
$this->actingAs($admin)
->get(route('cp.cards.index'))
->assertOk()
->assertInertia(fn (AssertableInertia $page) => $page
->component('Collection/NovaCardsAdminIndex')
->where('cards.data.0.title', 'Needs Review')
->where('cards.data.0.moderation_reasons.0', 'duplicate_content')
->where('cards.data.0.moderation_reason_labels.0', 'Duplicate content')
->where('cards.data.0.moderation_override_history.1.disposition_label', 'Returned to pending')
->where('moderationDispositionOptions.approved.1.value', 'approved_with_watch')
->where('featuredCreators.0.username', 'spotlightmaker')
->where('featuredCreators.0.nova_featured_creator', true)
->where('reportingQueue.label', 'Nova Cards report queue')
->where('reportingQueue.pending', 1)
->where('endpoints.updateCreatorPattern', route('cp.cards.creators.update', ['user' => '__CREATOR__']))
->where('endpoints.moderateReportTargetPattern', route('api.admin.reports.moderate-target', ['report' => '__REPORT__']))
->where('endpoints.templates', route('cp.cards.templates.index')));
});
it('allows admins to update card moderation fields', function (): void {
$admin = User::factory()->create(['role' => 'admin']);
$creator = User::factory()->create();
$card = moderatedCard($creator);
$this->actingAs($admin)
->patchJson(route('cp.cards.update', ['card' => $card->id]), [
'status' => NovaCard::STATUS_PUBLISHED,
'moderation_status' => NovaCard::MOD_APPROVED,
'disposition' => 'approved_with_watch',
'featured' => true,
'allow_remix' => false,
])
->assertOk()
->assertJsonPath('card.featured', true)
->assertJsonPath('card.moderation_status', NovaCard::MOD_APPROVED)
->assertJsonPath('card.moderation_override.source', 'admin_card_update')
->assertJsonPath('card.moderation_override.disposition', 'approved_with_watch')
->assertJsonPath('card.moderation_override.disposition_label', 'Approved with watch')
->assertJsonPath('card.moderation_override_history.0.disposition_label', 'Approved with watch')
->assertJsonPath('card.moderation_override.actor_user_id', $admin->id)
->assertJsonPath('card.allow_remix', false);
$fresh = $card->fresh();
expect($fresh->featured)->toBeTrue();
expect($fresh->moderation_status)->toBe(NovaCard::MOD_APPROVED);
expect($fresh->allow_remix)->toBeFalse();
expect($fresh->project_json['moderation']['override']['source'] ?? null)->toBe('admin_card_update')
->and($fresh->project_json['moderation']['override']['disposition'] ?? null)->toBe('approved_with_watch');
});
it('allows admins to feature creators for editorial surfacing', function (): void {
$admin = User::factory()->create(['role' => 'admin']);
$creator = User::factory()->create(['username' => 'creatorflag']);
moderatedCard($creator, [
'title' => 'Eligible Creator Card',
'status' => NovaCard::STATUS_PUBLISHED,
'moderation_status' => NovaCard::MOD_APPROVED,
'visibility' => NovaCard::VISIBILITY_PUBLIC,
]);
$this->actingAs($admin)
->patchJson(route('cp.cards.creators.update', ['user' => $creator->id]), [
'nova_featured_creator' => true,
])
->assertOk()
->assertJsonPath('creator.username', 'creatorflag')
->assertJsonPath('creator.nova_featured_creator', true)
->assertJsonPath('creator.public_cards_count', 1);
expect($creator->fresh()->nova_featured_creator)->toBeTrue();
});
it('allows admins to create categories and templates', function (): void {
$admin = User::factory()->create(['role' => 'admin']);
$this->actingAs($admin)
->postJson(route('cp.cards.categories.store'), [
'slug' => 'serenity',
'name' => 'Serenity',
'description' => 'Peaceful cards',
'active' => true,
'order_num' => 3,
])
->assertOk()
->assertJsonPath('category.slug', 'serenity');
$this->actingAs($admin)
->postJson(route('cp.cards.templates.store'), [
'slug' => 'calm-quote',
'name' => 'Calm Quote',
'description' => 'Soft centered quote card',
'supported_formats' => ['square', 'story'],
'active' => true,
'official' => true,
'order_num' => 1,
'config_json' => [
'font_preset' => 'modern-sans',
'gradient_preset' => 'midnight-nova',
'layout' => 'quote_heavy',
'text_align' => 'center',
'text_color' => '#ffffff',
'overlay_style' => 'dark-soft',
],
])
->assertOk()
->assertJsonPath('template.slug', 'calm-quote')
->assertJsonPath('template.supported_formats.1', 'story');
expect(NovaCardCategory::query()->where('slug', 'serenity')->exists())->toBeTrue();
expect(NovaCardTemplate::query()->where('slug', 'calm-quote')->exists())->toBeTrue();
});
it('renders the template admin page for admins', function (): void {
$admin = User::factory()->create(['role' => 'admin']);
adminCardTemplate(['slug' => 'editorial', 'name' => 'Editorial']);
$this->actingAs($admin)
->get(route('cp.cards.templates.index'))
->assertOk()
->assertInertia(fn (AssertableInertia $page) => $page
->component('Collection/NovaCardsTemplateAdmin')
->where('templates.0.slug', 'editorial')
->where('endpoints.cards', route('cp.cards.index')));
});
it('renders and manages asset packs and challenges for admins', function (): void {
$admin = User::factory()->create(['role' => 'admin']);
$creator = User::factory()->create();
$winner = moderatedCard($creator, [
'slug' => 'winner-card',
'title' => 'Winner Card',
'moderation_status' => NovaCard::MOD_APPROVED,
]);
NovaCardAssetPack::query()->create([
'slug' => 'official-pack',
'name' => 'Official Pack',
'description' => 'Admin managed pack',
'type' => 'asset',
'manifest_json' => ['items' => [['key' => 'spark', 'label' => 'Spark', 'glyph' => '✦']]],
'official' => true,
'active' => true,
'order_num' => 0,
]);
NovaCardChallenge::query()->create([
'user_id' => $admin->id,
'slug' => 'editorial-week',
'title' => 'Editorial Week',
'description' => 'Admin challenge',
'prompt' => 'Create a sharp editorial quote card.',
'status' => NovaCardChallenge::STATUS_ACTIVE,
'official' => true,
'featured' => true,
'winner_card_id' => $winner->id,
'starts_at' => now()->subDay(),
'ends_at' => now()->addWeek(),
]);
$this->actingAs($admin)
->get(route('cp.cards.asset-packs.index'))
->assertOk()
->assertInertia(fn (AssertableInertia $page) => $page
->component('Collection/NovaCardsAssetPackAdmin')
->where('packs.0.slug', 'official-pack'));
$this->actingAs($admin)
->get(route('cp.cards.challenges.index'))
->assertOk()
->assertInertia(fn (AssertableInertia $page) => $page
->component('Collection/NovaCardsChallengeAdmin')
->where('challenges.0.slug', 'editorial-week'));
$this->actingAs($admin)
->postJson(route('cp.cards.asset-packs.store'), [
'slug' => 'story-pack',
'name' => 'Story Pack',
'description' => 'Pack for stories',
'type' => 'template',
'manifest_json' => ['templates' => ['story-vertical']],
'official' => true,
'active' => true,
'order_num' => 1,
])
->assertOk()
->assertJsonPath('pack.slug', 'story-pack');
$this->actingAs($admin)
->postJson(route('cp.cards.challenges.store'), [
'slug' => 'clarity-run',
'title' => 'Clarity Run',
'description' => 'A clean clarity challenge.',
'prompt' => 'Use space and contrast.',
'rules_json' => ['max_entries_per_user' => 1],
'status' => 'active',
'official' => true,
'featured' => false,
'winner_card_id' => $winner->id,
'starts_at' => now()->toISOString(),
'ends_at' => now()->addWeek()->toISOString(),
])
->assertOk()
->assertJsonPath('challenge.slug', 'clarity-run');
expect(NovaCardAssetPack::query()->where('slug', 'story-pack')->exists())->toBeTrue();
expect(NovaCardChallenge::query()->where('slug', 'clarity-run')->exists())->toBeTrue();
});
it('renders and manages official collections for admins', function (): void {
$admin = User::factory()->create(['role' => 'admin']);
$cardOwner = User::factory()->create();
$card = moderatedCard($cardOwner, ['slug' => 'collection-managed-card', 'title' => 'Collection Managed Card', 'moderation_status' => NovaCard::MOD_APPROVED]);
$collection = NovaCardCollection::query()->create([
'user_id' => $admin->id,
'slug' => 'official-launch',
'name' => 'Official Launch',
'description' => 'Initial official collection.',
'visibility' => NovaCardCollection::VISIBILITY_PUBLIC,
'official' => true,
'featured' => false,
'cards_count' => 0,
]);
$this->actingAs($admin)
->get(route('cp.cards.collections.index'))
->assertOk()
->assertInertia(fn (AssertableInertia $page) => $page
->component('Collection/NovaCardsCollectionAdmin')
->where('collections.0.slug', 'official-launch'));
$this->actingAs($admin)
->postJson(route('cp.cards.collections.store'), [
'user_id' => $admin->id,
'slug' => 'staff-picks',
'name' => 'Staff Picks',
'description' => 'Editorial picks.',
'visibility' => 'public',
'official' => true,
'featured' => true,
])
->assertOk()
->assertJsonPath('collection.slug', 'staff-picks')
->assertJsonPath('collection.featured', true);
$this->actingAs($admin)
->postJson(route('cp.cards.collections.cards.store', ['collection' => $collection->id]), [
'card_id' => $card->id,
'note' => 'Lead card',
])
->assertOk()
->assertJsonPath('collection.items.0.card.id', $card->id);
expect(NovaCardCollectionItem::query()->where('collection_id', $collection->id)->where('card_id', $card->id)->exists())->toBeTrue();
expect(NovaCardCollection::query()->where('slug', 'staff-picks')->value('featured'))->toBeTrue();
});

View File

@@ -0,0 +1,468 @@
<?php
declare(strict_types=1);
use App\Http\Requests\NovaCards\UploadNovaCardBackgroundRequest;
use App\Events\NovaCards\NovaCardAutosaved;
use App\Events\NovaCards\NovaCardBackgroundUploaded;
use App\Events\NovaCards\NovaCardCreated;
use App\Events\NovaCards\NovaCardPublished;
use App\Events\NovaCards\NovaCardTemplateSelected;
use App\Models\NovaCard;
use App\Models\NovaCardCategory;
use App\Models\NovaCardBackground;
use App\Models\NovaCardTemplate;
use App\Models\User;
use App\Services\NovaCards\NovaCardRenderService;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Arr;
use Illuminate\Support\Facades\Event;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;
function draftCategory(array $attributes = []): NovaCardCategory
{
return NovaCardCategory::query()->create(array_merge([
'slug' => 'draft-category-' . Str::lower(Str::random(6)),
'name' => 'Draft Category',
'description' => 'Draft category',
'active' => true,
'order_num' => 0,
], $attributes));
}
function draftTemplate(array $attributes = []): NovaCardTemplate
{
return NovaCardTemplate::query()->create(array_merge([
'slug' => 'draft-template-' . Str::lower(Str::random(6)),
'name' => 'Draft Template',
'description' => 'Draft template',
'config_json' => [
'font_preset' => 'modern-sans',
'gradient_preset' => 'midnight-nova',
'layout' => 'quote_heavy',
'text_align' => 'center',
'text_color' => '#ffffff',
'overlay_style' => 'dark-soft',
],
'supported_formats' => ['square', 'portrait'],
'active' => true,
'official' => true,
'order_num' => 0,
], $attributes));
}
function draftCard(User $user, array $attributes = []): NovaCard
{
$category = $attributes['category'] ?? draftCategory();
$template = $attributes['template'] ?? draftTemplate();
return NovaCard::query()->create(array_merge([
'user_id' => $user->id,
'category_id' => $category->id,
'template_id' => $template->id,
'title' => 'Draft Card',
'slug' => 'draft-card-' . Str::lower(Str::random(6)),
'quote_text' => 'A draft quote for editing.',
'quote_author' => 'Test Author',
'quote_source' => 'Notebook',
'description' => 'Draft description',
'format' => NovaCard::FORMAT_SQUARE,
'project_json' => [
'content' => [
'title' => 'Draft Card',
'quote_text' => 'A draft quote for editing.',
'quote_author' => 'Test Author',
'quote_source' => 'Notebook',
],
'layout' => [
'layout' => 'quote_heavy',
'position' => 'center',
'alignment' => 'center',
'padding' => 'comfortable',
'max_width' => 'balanced',
],
'typography' => [
'font_preset' => 'modern-sans',
'text_color' => '#ffffff',
'accent_color' => '#e0f2fe',
'quote_size' => 72,
'author_size' => 28,
'letter_spacing' => 0,
'line_height' => 1.2,
'shadow_preset' => 'soft',
],
'background' => [
'type' => 'gradient',
'gradient_preset' => 'midnight-nova',
'gradient_colors' => ['#0f172a', '#1d4ed8'],
'overlay_style' => 'dark-soft',
'focal_position' => 'center',
'blur_level' => 0,
'opacity' => 50,
],
'decorations' => [],
],
'render_version' => 1,
'background_type' => 'gradient',
'visibility' => NovaCard::VISIBILITY_PRIVATE,
'status' => NovaCard::STATUS_DRAFT,
'moderation_status' => NovaCard::MOD_PENDING,
'featured' => false,
'allow_download' => true,
], Arr::except($attributes, ['category', 'template'])));
}
afterEach(function (): void {
\Mockery::close();
});
it('creates and fetches a draft through the draft api', function (): void {
Event::fake([NovaCardCreated::class, NovaCardTemplateSelected::class]);
$user = User::factory()->create();
$category = draftCategory();
$template = draftTemplate();
$create = $this->actingAs($user)
->postJson(route('api.cards.drafts.store'), [
'title' => 'First Draft',
'quote_text' => 'The first saved quote.',
'format' => 'square',
'category_id' => $category->id,
'template_id' => $template->id,
'tags' => ['focus', 'clarity'],
]);
$create->assertCreated()
->assertJsonPath('data.title', 'First Draft')
->assertJsonPath('data.format', 'square')
->assertJsonCount(2, 'data.tags');
$cardId = (int) $create->json('data.id');
$this->actingAs($user)
->getJson(route('api.cards.drafts.show', ['id' => $cardId]))
->assertOk()
->assertJsonPath('data.id', $cardId)
->assertJsonPath('data.project_json.content.quote_text', 'The first saved quote.');
Event::assertDispatched(NovaCardCreated::class);
Event::assertDispatched(NovaCardTemplateSelected::class);
});
it('autosaves project json changes for the draft owner', function (): void {
Event::fake([NovaCardAutosaved::class]);
$user = User::factory()->create();
$card = draftCard($user);
$response = $this->actingAs($user)
->postJson(route('api.cards.drafts.autosave', ['id' => $card->id]), [
'title' => 'Updated Draft',
'quote_text' => 'Updated quote text for autosave.',
'editor_mode_last_used' => 'quick',
'project_json' => [
'text_blocks' => [
['key' => 'quote', 'type' => 'quote', 'text' => 'Updated quote text for autosave.', 'enabled' => true],
['key' => 'title', 'type' => 'title', 'text' => 'Updated Draft', 'enabled' => true],
['key' => 'body-1', 'type' => 'body', 'text' => 'Supporting body copy.', 'enabled' => true],
],
'layout' => [
'alignment' => 'left',
'position' => 'top',
],
'typography' => [
'quote_size' => 88,
'text_color' => '#ffffff',
],
],
]);
$response->assertOk()
->assertJsonPath('data.title', 'Updated Draft')
->assertJsonPath('data.project_json.layout.alignment', 'left')
->assertJsonPath('data.project_json.text_blocks.0.type', 'quote')
->assertJsonPath('data.editor_mode_last_used', 'quick')
->assertJsonPath('meta.saved_at', fn ($value): bool => is_string($value) && $value !== '');
expect($card->fresh()->project_json['layout']['alignment'])->toBe('left')
->and($card->fresh()->project_json['text_blocks'][0]['type'])->toBe('quote')
->and($card->fresh()->editor_mode_last_used)->toBe('quick');
Event::assertDispatched(NovaCardAutosaved::class);
});
it('returns snapshot payloads in the draft versions api for compare views', function (): void {
$user = User::factory()->create();
$card = draftCard($user);
$this->actingAs($user)
->postJson(route('api.cards.drafts.autosave', ['id' => $card->id]), [
'project_json' => [
'text_blocks' => [
['key' => 'title', 'type' => 'title', 'text' => 'Versioned title', 'enabled' => true],
['key' => 'quote', 'type' => 'quote', 'text' => 'Versioned quote', 'enabled' => true],
],
'layout' => [
'layout' => 'minimal',
],
],
])
->assertOk();
$this->actingAs($user)
->getJson(route('api.cards.drafts.versions', ['id' => $card->id]))
->assertOk()
->assertJsonPath('data.0.snapshot_json.text_blocks.0.type', 'title')
->assertJsonPath('data.0.snapshot_json.layout.layout', 'minimal');
});
it('prevents another user from editing a draft they do not own', function (): void {
$owner = User::factory()->create();
$other = User::factory()->create();
$card = draftCard($owner);
$this->actingAs($other)
->patchJson(route('api.cards.drafts.update', ['id' => $card->id]), [
'title' => 'Illegal update',
])
->assertNotFound();
});
it('publishes a draft when title and quote are present', function (): void {
Event::fake([NovaCardPublished::class]);
$user = User::factory()->create();
$card = draftCard($user, ['title' => 'Publishable Card', 'quote_text' => 'Ready for publishing.']);
$renderService = \Mockery::mock(NovaCardRenderService::class);
$renderService->shouldReceive('render')
->once()
->andReturn([
'preview_path' => 'nova-cards/previews/test.webp',
'preview_width' => 1080,
'preview_height' => 1080,
]);
app()->instance(NovaCardRenderService::class, $renderService);
$response = $this->actingAs($user)
->postJson(route('api.cards.drafts.publish', ['id' => $card->id]), []);
$response->assertOk()
->assertJsonPath('data.status', NovaCard::STATUS_PUBLISHED)
->assertJsonPath('data.moderation_status', NovaCard::MOD_APPROVED);
expect($card->fresh()->status)->toBe(NovaCard::STATUS_PUBLISHED);
Event::assertDispatched(NovaCardPublished::class);
});
it('flags risky duplicate publishes for moderation review', function (): void {
Event::fake([NovaCardPublished::class]);
$user = User::factory()->create();
NovaCard::query()->create([
'user_id' => $user->id,
'category_id' => draftCategory()->id,
'template_id' => draftTemplate()->id,
'title' => 'Repeated Card',
'slug' => 'repeated-card-existing',
'quote_text' => 'This quote already exists publicly.',
'quote_author' => 'Existing Author',
'quote_source' => 'Existing Source',
'description' => 'Existing public card',
'format' => NovaCard::FORMAT_SQUARE,
'project_json' => ['content' => ['title' => 'Repeated Card', 'quote_text' => 'This quote already exists publicly.']],
'render_version' => 1,
'background_type' => 'gradient',
'visibility' => NovaCard::VISIBILITY_PUBLIC,
'status' => NovaCard::STATUS_PUBLISHED,
'moderation_status' => NovaCard::MOD_APPROVED,
'allow_download' => true,
'published_at' => now()->subDay(),
]);
$card = draftCard($user, [
'title' => 'Repeated Card',
'quote_text' => 'This quote already exists publicly.',
]);
$renderService = \Mockery::mock(NovaCardRenderService::class);
$renderService->shouldReceive('render')
->once()
->andReturn([
'preview_path' => 'nova-cards/previews/risky.webp',
'preview_width' => 1080,
'preview_height' => 1080,
]);
app()->instance(NovaCardRenderService::class, $renderService);
$response = $this->actingAs($user)
->postJson(route('api.cards.drafts.publish', ['id' => $card->id]), []);
$response->assertOk()
->assertJsonPath('data.status', NovaCard::STATUS_PUBLISHED)
->assertJsonPath('data.moderation_status', NovaCard::MOD_FLAGGED)
->assertJsonPath('data.moderation_reasons.0', 'duplicate_content')
->assertJsonPath('data.moderation_reason_labels.0', 'Duplicate content');
expect($card->fresh()->moderation_status)->toBe(NovaCard::MOD_FLAGGED)
->and($card->fresh()->project_json['moderation']['reasons'] ?? [])->toContain('duplicate_content');
Event::assertDispatched(NovaCardPublished::class);
});
it('rejects publish when required fields are missing', function (): void {
$user = User::factory()->create();
$card = draftCard($user, ['title' => 'Ok title', 'quote_text' => 'Short quote']);
$response = $this->actingAs($user)
->postJson(route('api.cards.drafts.publish', ['id' => $card->id]), [
'title' => '',
]);
$response->assertStatus(422);
});
it('uploads a background image and attaches it to the draft', function (): void {
Event::fake([NovaCardBackgroundUploaded::class]);
Storage::fake('local');
Storage::fake('public');
config()->set('nova_cards.storage.private_disk', 'local');
config()->set('nova_cards.storage.public_disk', 'public');
$user = User::factory()->create();
$card = draftCard($user);
$response = $this->actingAs($user)
->postJson(route('api.cards.drafts.background', ['id' => $card->id]), [
'background' => UploadedFile::fake()->image('background.png', 1400, 1000),
]);
$response->assertCreated()
->assertJsonPath('data.background_type', 'upload')
->assertJsonPath('data.project_json.background.type', 'upload')
->assertJsonPath('background.width', 1400)
->assertJsonPath('background.height', 1000);
$backgroundId = (int) $response->json('background.id');
$background = NovaCardBackground::query()->findOrFail($backgroundId);
Storage::disk('local')->assertExists($background->original_path);
Storage::disk('public')->assertExists($background->processed_path);
expect($card->fresh()->background_image_id)->toBe($backgroundId);
Event::assertDispatched(NovaCardBackgroundUploaded::class);
});
it('rejects invalid background uploads', function (): void {
$user = User::factory()->create();
$card = draftCard($user);
$this->actingAs($user)
->postJson(route('api.cards.drafts.background', ['id' => $card->id]), [
'background' => UploadedFile::fake()->create('background.txt', 8, 'text/plain'),
])
->assertStatus(422)
->assertJsonValidationErrors(['background']);
$this->actingAs($user)
->postJson(route('api.cards.drafts.background', ['id' => $card->id]), [
'background' => UploadedFile::fake()->image('small.png', 320, 320),
])
->assertStatus(422)
->assertJsonValidationErrors(['background']);
});
it('rejects malformed background uploads without throwing', function (): void {
$request = new UploadNovaCardBackgroundRequest();
$tempPath = tempnam(sys_get_temp_dir(), 'nova-card-background-');
file_put_contents($tempPath, 'broken');
$invalidUpload = new class($tempPath) extends UploadedFile {
public function __construct(string $path)
{
parent::__construct($path, 'broken.png', 'image/png', \UPLOAD_ERR_NO_FILE, true);
}
public function isValid(): bool
{
return false;
}
public function getRealPath(): string|false
{
return '';
}
public function getPathname(): string
{
return '';
}
};
$validator = validator([
'background' => $invalidUpload,
], $request->rules());
expect(fn() => $validator->fails())->not->toThrow(ValueError::class);
expect($validator->fails())->toBeTrue();
expect($validator->errors()->keys())->toContain('background');
@unlink($tempPath);
});
it('renders a draft preview through the render endpoint', function (): void {
$user = User::factory()->create();
$card = draftCard($user);
$renderService = \Mockery::mock(NovaCardRenderService::class);
$renderService->shouldReceive('render')
->once()
->andReturn([
'preview_path' => 'cards/previews/' . $user->id . '/rendered.webp',
'og_path' => 'cards/previews/' . $user->id . '/rendered-og.jpg',
'width' => 1080,
'height' => 1080,
]);
app()->instance(NovaCardRenderService::class, $renderService);
$this->actingAs($user)
->postJson(route('api.cards.drafts.render', ['id' => $card->id]))
->assertOk()
->assertJsonPath('data.id', $card->id)
->assertJsonPath('render.preview_path', 'cards/previews/' . $user->id . '/rendered.webp')
->assertJsonPath('render.width', 1080)
->assertJsonPath('render.height', 1080);
});
it('deletes an unpublished draft through the draft api', function (): void {
$user = User::factory()->create();
$card = draftCard($user);
$this->actingAs($user)
->deleteJson(route('api.cards.drafts.destroy', ['id' => $card->id]))
->assertOk()
->assertJsonPath('ok', true);
expect(NovaCard::query()->whereKey($card->id)->exists())->toBeFalse();
expect(NovaCard::withTrashed()->whereKey($card->id)->exists())->toBeTrue();
});
it('does not allow deleting a published public card through the draft api', function (): void {
$user = User::factory()->create();
$card = draftCard($user, [
'status' => NovaCard::STATUS_PUBLISHED,
'visibility' => NovaCard::VISIBILITY_PUBLIC,
'moderation_status' => NovaCard::MOD_APPROVED,
'published_at' => now()->subMinute(),
]);
$this->actingAs($user)
->deleteJson(route('api.cards.drafts.destroy', ['id' => $card->id]))
->assertStatus(422)
->assertJsonPath('message', 'Published cards cannot be deleted from the draft API.');
expect(NovaCard::query()->whereKey($card->id)->exists())->toBeTrue();
});

View File

@@ -0,0 +1,621 @@
<?php
declare(strict_types=1);
use App\Events\NovaCards\NovaCardDownloaded;
use App\Events\NovaCards\NovaCardShared;
use App\Events\NovaCards\NovaCardViewed;
use App\Models\NovaCard;
use App\Models\NovaCardCategory;
use App\Models\NovaCardChallenge;
use App\Models\NovaCardChallengeEntry;
use App\Models\NovaCardComment;
use App\Models\NovaCardCollection;
use App\Models\NovaCardCollectionItem;
use App\Models\NovaCardCreatorPreset;
use App\Models\NovaCardTag;
use App\Models\NovaCardTemplate;
use App\Models\User;
use Illuminate\Support\Arr;
use Illuminate\Support\Facades\Event;
use Illuminate\Support\Str;
function novaCardCategory(array $attributes = []): NovaCardCategory
{
return NovaCardCategory::query()->create(array_merge([
'slug' => 'category-' . Str::lower(Str::random(8)),
'name' => 'Category ' . Str::upper(Str::random(4)),
'description' => 'Category description',
'active' => true,
'order_num' => 0,
], $attributes));
}
function novaCardTemplate(array $attributes = []): NovaCardTemplate
{
return NovaCardTemplate::query()->create(array_merge([
'slug' => 'template-' . Str::lower(Str::random(8)),
'name' => 'Template ' . Str::upper(Str::random(4)),
'description' => 'Template description',
'config_json' => [
'font_preset' => 'modern-sans',
'gradient_preset' => 'midnight-nova',
'layout' => 'quote_heavy',
'text_align' => 'center',
'text_color' => '#ffffff',
'overlay_style' => 'dark-soft',
],
'supported_formats' => ['square', 'portrait'],
'active' => true,
'official' => true,
'order_num' => 0,
], $attributes));
}
function novaCardTag(array $attributes = []): NovaCardTag
{
return NovaCardTag::query()->create(array_merge([
'slug' => 'tag-' . Str::lower(Str::random(8)),
'name' => 'Tag ' . Str::upper(Str::random(4)),
], $attributes));
}
function publishedNovaCard(User $user, array $attributes = []): NovaCard
{
$category = $attributes['category'] ?? novaCardCategory();
$template = $attributes['template'] ?? novaCardTemplate();
$card = NovaCard::query()->create(array_merge([
'user_id' => $user->id,
'category_id' => $category->id,
'template_id' => $template->id,
'title' => 'Skybound Thought',
'slug' => 'skybound-thought',
'quote_text' => 'A bright sentence for public display.',
'quote_author' => 'Nova Author',
'quote_source' => 'Test Source',
'description' => 'A public card used in tests.',
'format' => NovaCard::FORMAT_SQUARE,
'project_json' => [
'content' => [
'title' => 'Skybound Thought',
'quote_text' => 'A bright sentence for public display.',
'quote_author' => 'Nova Author',
'quote_source' => 'Test Source',
],
'layout' => [
'layout' => 'quote_heavy',
'position' => 'center',
'alignment' => 'center',
'padding' => 'comfortable',
'max_width' => 'balanced',
],
'typography' => [
'font_preset' => 'modern-sans',
'text_color' => '#ffffff',
'accent_color' => '#e0f2fe',
'quote_size' => 72,
'author_size' => 28,
'letter_spacing' => 0,
'line_height' => 1.2,
'shadow_preset' => 'soft',
],
'background' => [
'type' => 'gradient',
'gradient_preset' => 'midnight-nova',
'gradient_colors' => ['#0f172a', '#1d4ed8'],
'overlay_style' => 'dark-soft',
'focal_position' => 'center',
'blur_level' => 0,
'opacity' => 50,
],
'decorations' => [],
],
'render_version' => 1,
'background_type' => 'gradient',
'visibility' => NovaCard::VISIBILITY_PUBLIC,
'status' => NovaCard::STATUS_PUBLISHED,
'moderation_status' => NovaCard::MOD_APPROVED,
'featured' => false,
'allow_download' => true,
'views_count' => 12,
'shares_count' => 3,
'downloads_count' => 1,
'published_at' => now()->subHour(),
], Arr::except($attributes, ['category', 'template', 'tags'])));
foreach (($attributes['tags'] ?? []) as $tag) {
$card->tags()->attach($tag->id);
}
return $card->fresh(['user.profile', 'category', 'template', 'tags']);
}
it('renders the public cards index with featured content', function (): void {
$creator = User::factory()->create(['username' => 'novacreator']);
$featured = publishedNovaCard($creator, ['featured' => true, 'title' => 'Featured Nova']);
$latest = publishedNovaCard($creator, ['slug' => 'latest-nova', 'title' => 'Latest Nova']);
$response = $this->get(route('cards.index'));
$response->assertOk();
expect($response->getContent())
->toContain('Nova Cards')
->toContain('Featured Nova')
->toContain('Latest Nova')
->toContain(route('cards.show', ['slug' => $featured->slug, 'id' => $featured->id]))
->toContain('application/ld+json')
->toContain('CollectionPage')
->toContain('index,follow');
});
it('renders category, tag, style, palette, and creator pages with their matching card', function (): void {
$creator = User::factory()->create(['username' => 'tagcreator']);
$category = novaCardCategory(['slug' => 'mindset', 'name' => 'Mindset']);
$tag = novaCardTag(['slug' => 'clarity', 'name' => 'Clarity']);
$moodTag = novaCardTag(['slug' => 'calm', 'name' => 'Calm']);
$template = novaCardTemplate(['slug' => 'editorial-starter', 'name' => 'Editorial Starter']);
$card = publishedNovaCard($creator, [
'category' => $category,
'template' => $template,
'slug' => 'clarity-card',
'title' => 'Clarity Card',
'featured' => true,
'featured_score' => 95.0,
'style_family' => 'editorial',
'palette_family' => 'cool-tones',
'editor_mode_last_used' => 'full',
'views_count' => 120,
'likes_count' => 24,
'saves_count' => 18,
'remixes_count' => 5,
'tags' => [$tag, $moodTag],
]);
$second = publishedNovaCard($creator, [
'category' => $category,
'template' => $template,
'slug' => 'clarity-card-two',
'title' => 'Clarity Card Two',
'style_family' => 'editorial',
'palette_family' => 'cool-tones',
'editor_mode_last_used' => 'full',
'views_count' => 80,
'likes_count' => 14,
'saves_count' => 7,
'remixes_count' => 2,
'tags' => [$tag, $moodTag],
]);
$remix = publishedNovaCard($creator, [
'category' => $category,
'slug' => 'clarity-card-remix',
'title' => 'Clarity Card Remix',
'template' => $template,
'original_card_id' => $card->id,
'root_card_id' => $card->id,
'editor_mode_last_used' => 'quick',
'views_count' => 50,
'likes_count' => 9,
'saves_count' => 4,
'remixes_count' => 1,
'tags' => [$tag, $moodTag],
]);
NovaCardCreatorPreset::query()->create([
'user_id' => $creator->id,
'name' => 'Clarity Style',
'preset_type' => NovaCardCreatorPreset::TYPE_STYLE,
'config_json' => ['typography' => ['font_preset' => 'modern-sans']],
'is_default' => true,
]);
NovaCardCreatorPreset::query()->create([
'user_id' => $creator->id,
'name' => 'Editorial Starter',
'preset_type' => NovaCardCreatorPreset::TYPE_STARTER,
'config_json' => ['template' => ['slug' => 'editorial-starter']],
'is_default' => false,
]);
$collection = NovaCardCollection::query()->create([
'user_id' => $creator->id,
'slug' => 'clarity-picks',
'name' => 'Clarity Picks',
'description' => 'A featured public collection from this creator.',
'visibility' => NovaCardCollection::VISIBILITY_PUBLIC,
'official' => false,
'featured' => true,
'cards_count' => 2,
]);
NovaCardCollectionItem::query()->create([
'collection_id' => $collection->id,
'card_id' => $card->id,
'sort_order' => 1,
]);
NovaCardCollectionItem::query()->create([
'collection_id' => $collection->id,
'card_id' => $second->id,
'sort_order' => 2,
]);
$challenge = NovaCardChallenge::query()->create([
'slug' => 'clarity-sprint',
'title' => 'Clarity Sprint',
'description' => 'A creator challenge history entry.',
'status' => NovaCardChallenge::STATUS_COMPLETED,
'official' => true,
'featured' => true,
'entries_count' => 2,
]);
NovaCardChallengeEntry::query()->create([
'challenge_id' => $challenge->id,
'card_id' => $card->id,
'user_id' => $creator->id,
'status' => NovaCardChallengeEntry::STATUS_WINNER,
]);
$this->get(route('cards.category', ['categorySlug' => $category->slug]))
->assertOk();
expect($this->get(route('cards.category', ['categorySlug' => $category->slug]))->getContent())
->toContain('Mindset')
->toContain('Clarity Card');
expect($this->get(route('cards.tag', ['tagSlug' => $tag->slug]))->getContent())
->toContain('#Clarity')
->toContain('Clarity Card');
expect($this->get(route('cards.style', ['styleSlug' => 'editorial']))->getContent())
->toContain('Editorial')
->toContain('Clarity Card')
->toContain('Style families');
expect($this->get(route('cards.palette', ['paletteSlug' => 'cool-tones']))->getContent())
->toContain('Cool Tones')
->toContain('Clarity Card')
->toContain('Palette families');
expect($this->get(route('cards.creator', ['username' => $creator->username]))->getContent())
->toContain('@' . $creator->username)
->toContain('Clarity Card')
->toContain('Creator profile')
->toContain('Featured works')
->toContain('Featured collections')
->toContain('Clarity Picks')
->toContain('Signature themes')
->toContain('Cool Tones')
->toContain('Soft Morning')
->toContain('Most remixed works')
->toContain('Most liked works')
->toContain('Remix branches')
->toContain('Community branches')
->toContain('Published remixes')
->toContain('Published remix')
->toContain('Community branch')
->toContain('Clarity Card Remix')
->toContain('Source:')
->toContain('View lineage')
->toContain('Remix graph')
->toContain('Peak branch card')
->toContain('Creator identity')
->toContain('Preference signals')
->toContain('Editorial Starter')
->toContain('Preferred editor mode')
->toContain('Full')
->toContain('Saved presets')
->toContain('Style')
->toContain('Starter')
->toContain('Recent timeline')
->toContain('Featured release')
->toContain('Audience favorite')
->toContain('Remix traction')
->toContain('Challenge track record')
->toContain('Clarity Sprint')
->toContain('Winner entry')
->toContain('Creator highlights')
->toContain('All published works')
->toContain('Editorial')
->toContain('#Clarity')
->toContain('Mindset')
->toContain('200')
->toContain('Clarity Card Two');
expect($this->get(route('cards.creator.portfolio', ['username' => $creator->username]))->getContent())
->toContain('@' . $creator->username)
->toContain('Portfolio')
->toContain('Portfolio works')
->toContain('Profile overview')
->toContain('Portfolio page')
->toContain('Most liked works')
->toContain('Remix branches')
->toContain('Recent timeline')
->toContain('Clarity Card Remix');
});
it('renders the public card detail page and increments views', function (): void {
Event::fake([NovaCardViewed::class]);
$viewer = User::factory()->create(['username' => 'reportviewer']);
$creator = User::factory()->create(['username' => 'detailcreator']);
$category = novaCardCategory(['slug' => 'quotes', 'name' => 'Quotes']);
$tag = novaCardTag(['slug' => 'focus', 'name' => 'Focus']);
$card = publishedNovaCard($creator, [
'category' => $category,
'slug' => 'detail-card',
'title' => 'Detail Card',
'quote_text' => 'Precision matters when pages are crawlable.',
'views_count' => 7,
'tags' => [$tag],
]);
$response = $this->actingAs($viewer)->get(route('cards.show', ['slug' => $card->slug, 'id' => $card->id]));
$response->assertOk();
expect($response->getContent())
->toContain('Detail Card')
->toContain('Precision matters when pages are crawlable.')
->toContain('#Focus')
->toContain('CreativeWork')
->toContain('Copy link')
->toContain('data-card-report');
expect($card->fresh()->views_count)->toBe(8);
Event::assertDispatched(NovaCardViewed::class);
});
it('tracks share and download engagement for a public card', function (): void {
Event::fake([NovaCardShared::class, NovaCardDownloaded::class]);
$creator = User::factory()->create(['username' => 'engagementcreator']);
$card = publishedNovaCard($creator, [
'slug' => 'engagement-card',
'title' => 'Engagement Card',
'preview_path' => 'cards/previews/example.webp',
]);
$this->postJson(route('api.cards.share', ['id' => $card->id]))
->assertOk()
->assertJsonPath('shares_count', 4);
$this->postJson(route('api.cards.download', ['id' => $card->id]))
->assertOk()
->assertJsonPath('downloads_count', 2)
->assertJsonPath('download_url', $card->fresh()->previewUrl());
Event::assertDispatched(NovaCardShared::class);
Event::assertDispatched(NovaCardDownloaded::class);
});
it('redirects creator and show routes to canonical casing and slug', function (): void {
$creator = User::factory()->create(['username' => 'CanonicalUser']);
$card = publishedNovaCard($creator, ['slug' => 'canonical-card', 'title' => 'Canonical Card']);
$this->get('/cards/creator/CANONICALUSER')
->assertRedirect(route('cards.creator', ['username' => 'canonicaluser']));
$this->get('/cards/creator/CANONICALUSER/portfolio')
->assertRedirect(route('cards.creator.portfolio', ['username' => 'canonicaluser']));
$this->get(route('cards.show', ['slug' => 'wrong-slug', 'id' => $card->id]))
->assertRedirect(route('cards.show', ['slug' => $card->slug, 'id' => $card->id]));
});
it('renders a public collection detail page with curated cards', function (): void {
$owner = User::factory()->create(['username' => 'collectionowner']);
$first = publishedNovaCard($owner, ['slug' => 'collection-card-one', 'title' => 'Collection Card One']);
$second = publishedNovaCard($owner, ['slug' => 'collection-card-two', 'title' => 'Collection Card Two']);
$collection = NovaCardCollection::query()->create([
'user_id' => $owner->id,
'slug' => 'launch-picks',
'name' => 'Launch Picks',
'description' => 'Curated launch cards.',
'visibility' => NovaCardCollection::VISIBILITY_PUBLIC,
'official' => true,
'featured' => true,
'cards_count' => 2,
]);
NovaCardCollectionItem::query()->create(['collection_id' => $collection->id, 'card_id' => $first->id, 'sort_order' => 1, 'note' => 'Anchor card']);
NovaCardCollectionItem::query()->create(['collection_id' => $collection->id, 'card_id' => $second->id, 'sort_order' => 2]);
$response = $this->get(route('cards.collections.show', ['slug' => $collection->slug, 'id' => $collection->id]));
$response->assertOk();
expect($response->getContent())
->toContain('Launch Picks')
->toContain('Collection Card One')
->toContain('Collection Card Two')
->toContain('Anchor card')
->toContain('CollectionPage');
});
it('hides hidden challenge entries from the public card page', function (): void {
$creator = User::factory()->create(['username' => 'challengeowner']);
$card = publishedNovaCard($creator, ['slug' => 'challenge-visibility-card', 'title' => 'Challenge Visibility Card']);
$challenge = NovaCardChallenge::query()->create([
'slug' => 'hidden-entry-check',
'title' => 'Hidden Entry Check',
'status' => NovaCardChallenge::STATUS_ACTIVE,
'official' => true,
]);
NovaCardChallengeEntry::query()->create([
'challenge_id' => $challenge->id,
'card_id' => $card->id,
'user_id' => $creator->id,
'status' => NovaCardChallengeEntry::STATUS_HIDDEN,
]);
$response = $this->get(route('cards.show', ['slug' => $card->slug, 'id' => $card->id]));
$response->assertOk();
expect($response->getContent())->not->toContain('Hidden Entry Check');
});
it('renders a lineage page for remixed cards', function (): void {
$creator = User::factory()->create(['username' => 'lineagecreator']);
$root = publishedNovaCard($creator, ['slug' => 'lineage-root', 'title' => 'Lineage Root']);
$remix = publishedNovaCard($creator, [
'slug' => 'lineage-remix',
'title' => 'Lineage Remix',
'original_card_id' => $root->id,
'root_card_id' => $root->id,
]);
$response = $this->get(route('cards.lineage', ['slug' => $remix->slug, 'id' => $remix->id]));
$response->assertOk();
expect($response->getContent())
->toContain('Lineage Remix')
->toContain('Lineage Root')
->toContain('Cards in this remix branch');
});
it('renders a best remixes page ranked by remix traction', function (): void {
$creator = User::factory()->create(['username' => 'remixhighlightcreator']);
$root = publishedNovaCard($creator, ['slug' => 'highlight-root', 'title' => 'Highlight Root']);
$best = publishedNovaCard($creator, [
'slug' => 'best-remix-card',
'title' => 'Best Remix Card',
'original_card_id' => $root->id,
'root_card_id' => $root->id,
'remixes_count' => 12,
'saves_count' => 30,
'likes_count' => 20,
]);
$other = publishedNovaCard($creator, [
'slug' => 'other-remix-card',
'title' => 'Other Remix Card',
'original_card_id' => $root->id,
'root_card_id' => $root->id,
'remixes_count' => 3,
'saves_count' => 4,
'likes_count' => 2,
]);
$response = $this->get(route('cards.remix-highlights'));
$response->assertOk();
expect($response->getContent())
->toContain('Best remixes')
->toContain('Best Remix Card')
->toContain('Other Remix Card')
->toContain('Remix discovery')
->toContain(route('cards.show', ['slug' => $best->slug, 'id' => $best->id]))
->toContain('View lineage');
});
it('renders mood, editorial, and seasonal discovery pages', function (): void {
$creator = User::factory()->create(['username' => 'discoverycreator', 'nova_featured_creator' => true]);
$calm = novaCardTag(['slug' => 'calm', 'name' => 'Calm']);
$winter = novaCardTag(['slug' => 'winter', 'name' => 'Winter']);
$editorialCard = publishedNovaCard($creator, [
'slug' => 'editorial-spotlight-card',
'title' => 'Editorial Spotlight Card',
'featured' => true,
'featured_score' => 88.5,
]);
$moodCard = publishedNovaCard($creator, [
'slug' => 'calm-mood-card',
'title' => 'Calm Mood Card',
'tags' => [$calm],
]);
$seasonalCard = publishedNovaCard($creator, [
'slug' => 'winter-card',
'title' => 'Winter Card',
'tags' => [$winter],
]);
$collection = NovaCardCollection::query()->create([
'user_id' => $creator->id,
'slug' => 'editorial-picks-collection',
'name' => 'Editorial Picks Collection',
'description' => 'A featured collection for editorial discovery.',
'visibility' => NovaCardCollection::VISIBILITY_PUBLIC,
'official' => true,
'featured' => true,
'cards_count' => 1,
]);
NovaCardCollectionItem::query()->create([
'collection_id' => $collection->id,
'card_id' => $editorialCard->id,
'sort_order' => 1,
]);
$challenge = NovaCardChallenge::query()->create([
'slug' => 'editorial-highlight-challenge',
'title' => 'Editorial Highlight Challenge',
'description' => 'A featured challenge for discovery.',
'status' => NovaCardChallenge::STATUS_ACTIVE,
'official' => true,
'featured' => true,
'entries_count' => 3,
]);
expect($this->get(route('cards.mood', ['moodSlug' => 'soft-morning']))->getContent())
->toContain('Soft Morning')
->toContain('Calm Mood Card')
->toContain('Mood families');
$editorialResponse = $this->get(route('cards.editorial'));
$editorialResponse->assertOk()->assertViewHas('featuredCreators', function (array $creators): bool {
return count($creators) === 1
&& ($creators[0]['username'] ?? null) === 'discoverycreator'
&& ($creators[0]['featured_cards_count'] ?? null) === 1;
});
expect($editorialResponse->getContent())
->toContain('Editorial picks')
->toContain('Editorial Spotlight Card')
->toContain('Featured creators')
->toContain('Editorial Picks Collection')
->toContain('Editorial Highlight Challenge');
expect($this->get(route('cards.seasonal'))->getContent())
->toContain('Seasonal cards')
->toContain('Winter Card')
->toContain('Seasonal hubs');
});
it('allows authenticated viewers to comment on public cards and delete their own comment', function (): void {
$creator = User::factory()->create(['username' => 'commentcardcreator']);
$viewer = User::factory()->create(['username' => 'commentcardviewer']);
$card = publishedNovaCard($creator, ['slug' => 'commentable-card', 'title' => 'Commentable Card']);
$this->actingAs($viewer)
->post(route('cards.comments.store', ['card' => $card->id]), [
'body' => 'This layout has a strong editorial feel.',
])
->assertRedirect(route('cards.show', ['slug' => $card->slug, 'id' => $card->id]) . '#comments');
$comment = NovaCardComment::query()->latest('id')->first();
expect($comment)->not->toBeNull()
->and($comment->card_id)->toBe($card->id)
->and($comment->body)->toBe('This layout has a strong editorial feel.');
$show = $this->actingAs($viewer)
->get(route('cards.show', ['slug' => $card->slug, 'id' => $card->id]))
->assertOk();
expect($show->getContent())
->toContain('Comments')
->toContain('This layout has a strong editorial feel.');
$this->actingAs($viewer)
->delete(route('cards.comments.destroy', ['card' => $card->id, 'comment' => $comment->id]))
->assertRedirect(route('cards.show', ['slug' => $card->slug, 'id' => $card->id]) . '#comments');
expect($comment->fresh()->deleted_at)->not->toBeNull();
});

View File

@@ -0,0 +1,358 @@
<?php
declare(strict_types=1);
use App\Models\NovaCard;
use App\Models\NovaCardCategory;
use App\Models\NovaCardChallenge;
use App\Models\NovaCardChallengeEntry;
use App\Models\NovaCardComment;
use App\Models\NovaCardTemplate;
use App\Models\Report;
use App\Models\User;
use Illuminate\Support\Arr;
use Illuminate\Support\Str;
function reportCardCategory(array $attributes = []): NovaCardCategory
{
return NovaCardCategory::query()->create(array_merge([
'slug' => 'report-category-' . Str::lower(Str::random(6)),
'name' => 'Report Category',
'description' => 'Report category',
'active' => true,
'order_num' => 0,
], $attributes));
}
function reportCardTemplate(array $attributes = []): NovaCardTemplate
{
return NovaCardTemplate::query()->create(array_merge([
'slug' => 'report-template-' . Str::lower(Str::random(6)),
'name' => 'Report Template',
'description' => 'Report template',
'config_json' => [
'font_preset' => 'modern-sans',
'gradient_preset' => 'midnight-nova',
'layout' => 'quote_heavy',
'text_align' => 'center',
'text_color' => '#ffffff',
'overlay_style' => 'dark-soft',
],
'supported_formats' => ['square'],
'active' => true,
'official' => true,
'order_num' => 0,
], $attributes));
}
function reportableCard(User $user, array $attributes = []): NovaCard
{
$category = $attributes['category'] ?? reportCardCategory();
$template = $attributes['template'] ?? reportCardTemplate();
return NovaCard::query()->create(array_merge([
'user_id' => $user->id,
'category_id' => $category->id,
'template_id' => $template->id,
'title' => 'Reportable Card',
'slug' => 'reportable-card-' . Str::lower(Str::random(6)),
'quote_text' => 'Card that can be reported.',
'format' => NovaCard::FORMAT_SQUARE,
'project_json' => [
'content' => ['title' => 'Reportable Card', 'quote_text' => 'Card that can be reported.'],
'layout' => ['layout' => 'quote_heavy', 'position' => 'center', 'alignment' => 'center', 'padding' => 'comfortable', 'max_width' => 'balanced'],
'typography' => ['font_preset' => 'modern-sans', 'text_color' => '#ffffff', 'accent_color' => '#e0f2fe', 'quote_size' => 72, 'author_size' => 28, 'letter_spacing' => 0, 'line_height' => 1.2, 'shadow_preset' => 'soft'],
'background' => ['type' => 'gradient', 'gradient_preset' => 'midnight-nova', 'gradient_colors' => ['#0f172a', '#1d4ed8'], 'overlay_style' => 'dark-soft', 'focal_position' => 'center', 'blur_level' => 0, 'opacity' => 50],
'decorations' => [],
],
'render_version' => 2,
'schema_version' => 2,
'background_type' => 'gradient',
'visibility' => NovaCard::VISIBILITY_PUBLIC,
'status' => NovaCard::STATUS_PUBLISHED,
'moderation_status' => NovaCard::MOD_APPROVED,
'allow_download' => true,
'allow_remix' => true,
'published_at' => now()->subMinutes(5),
], Arr::except($attributes, ['category', 'template'])));
}
it('accepts nova card, challenge, and challenge entry reports through the shared intake endpoint', function (): void {
$reporter = User::factory()->create(['username' => 'reporter']);
$creator = User::factory()->create(['username' => 'creator']);
$card = reportableCard($creator);
$challenge = NovaCardChallenge::query()->create([
'user_id' => $creator->id,
'slug' => 'reporting-challenge',
'title' => 'Reporting Challenge',
'description' => 'Challenge description',
'prompt' => 'Challenge prompt',
'status' => NovaCardChallenge::STATUS_ACTIVE,
'official' => true,
'featured' => false,
'starts_at' => now()->subDay(),
'ends_at' => now()->addWeek(),
]);
$entry = NovaCardChallengeEntry::query()->create([
'challenge_id' => $challenge->id,
'card_id' => $card->id,
'user_id' => $creator->id,
'status' => NovaCardChallengeEntry::STATUS_SUBMITTED,
'note' => 'Challenge entry note',
]);
$this->actingAs($reporter)
->postJson(route('api.reports.store'), [
'target_type' => 'nova_card',
'target_id' => $card->id,
'reason' => 'Misleading card',
])
->assertCreated();
$this->actingAs($reporter)
->postJson(route('api.reports.store'), [
'target_type' => 'nova_card_challenge',
'target_id' => $challenge->id,
'reason' => 'Bad challenge brief',
])
->assertCreated();
$this->actingAs($reporter)
->postJson(route('api.reports.store'), [
'target_type' => 'nova_card_challenge_entry',
'target_id' => $entry->id,
'reason' => 'Spam challenge entry',
'details' => 'This entry is unrelated to the prompt.',
])
->assertCreated();
expect(Report::query()->where('reporter_id', $reporter->id)->count())->toBe(3);
});
it('accepts nova card comment reports through the shared intake endpoint', function (): void {
$reporter = User::factory()->create(['username' => 'commentreporter']);
$creator = User::factory()->create(['username' => 'commentreportcreator']);
$card = reportableCard($creator, ['title' => 'Comment Report Card']);
$comment = NovaCardComment::query()->create([
'card_id' => $card->id,
'user_id' => $creator->id,
'body' => 'Questionable comment body.',
'rendered_body' => 'Questionable comment body.',
'status' => 'visible',
]);
$this->actingAs($reporter)
->postJson(route('api.reports.store'), [
'target_type' => 'nova_card_comment',
'target_id' => $comment->id,
'reason' => 'Abusive comment',
])
->assertCreated();
expect(Report::query()->where('target_type', 'nova_card_comment')->where('target_id', $comment->id)->exists())->toBeTrue();
});
it('filters nova card reports in the moderation queue and allows status transitions', function (): void {
$admin = User::factory()->create(['role' => 'admin']);
$reporter = User::factory()->create(['username' => 'queuereporter']);
$creator = User::factory()->create(['username' => 'queuecreator']);
$card = reportableCard($creator, ['title' => 'Queue Card']);
$challenge = NovaCardChallenge::query()->create([
'user_id' => $creator->id,
'slug' => 'queue-challenge',
'title' => 'Queue Challenge',
'prompt' => 'Queue prompt',
'status' => NovaCardChallenge::STATUS_ACTIVE,
'official' => true,
'featured' => false,
'starts_at' => now()->subDay(),
'ends_at' => now()->addWeek(),
]);
$cardReport = Report::query()->create([
'reporter_id' => $reporter->id,
'target_type' => 'nova_card',
'target_id' => $card->id,
'reason' => 'Card spam',
'status' => 'open',
]);
Report::query()->create([
'reporter_id' => $reporter->id,
'target_type' => 'nova_card_challenge',
'target_id' => $challenge->id,
'reason' => 'Challenge abuse',
'status' => 'open',
]);
Report::query()->create([
'reporter_id' => $reporter->id,
'target_type' => 'user',
'target_id' => $creator->id,
'reason' => 'Unrelated user report',
'status' => 'open',
]);
$queue = $this->actingAs($admin)
->getJson(route('api.admin.reports.queue', ['group' => 'nova_cards', 'status' => 'open']))
->assertOk();
expect($queue->json('meta.total'))->toBe(2)
->and(collect($queue->json('data'))->pluck('target.label')->all())->toContain('Queue Card', 'Queue Challenge');
$this->actingAs($admin)
->patchJson(route('api.admin.reports.update', ['report' => $cardReport->id]), [
'status' => 'reviewing',
])
->assertOk()
->assertJsonPath('report.status', 'reviewing')
->assertJsonPath('report.target.label', 'Queue Card');
$reviewing = $this->actingAs($admin)
->getJson(route('api.admin.reports.queue', ['group' => 'nova_cards', 'status' => 'reviewing']))
->assertOk();
expect($reviewing->json('meta.total'))->toBe(1)
->and($cardReport->fresh()->status)->toBe('reviewing');
});
it('records moderator notes and report history entries from the moderation queue', function (): void {
$admin = User::factory()->create(['role' => 'admin', 'username' => 'auditadmin']);
$reporter = User::factory()->create(['username' => 'auditreporter']);
$creator = User::factory()->create(['username' => 'auditcreator']);
$card = reportableCard($creator, ['title' => 'Audit Queue Card']);
$report = Report::query()->create([
'reporter_id' => $reporter->id,
'target_type' => 'nova_card',
'target_id' => $card->id,
'reason' => 'Needs closer review',
'status' => 'open',
]);
$response = $this->actingAs($admin)
->patchJson(route('api.admin.reports.update', ['report' => $report->id]), [
'status' => 'reviewing',
'moderator_note' => 'Escalated to card moderation while we verify the prompt source.',
])
->assertOk();
$report->refresh();
expect($report->status)->toBe('reviewing')
->and($report->moderator_note)->toBe('Escalated to card moderation while we verify the prompt source.')
->and($report->last_moderated_by_id)->toBe($admin->id)
->and($report->historyEntries()->count())->toBe(1)
->and($response->json('report.history.0.summary'))->toContain('Status open -> reviewing')
->and($response->json('report.history.0.actor.username'))->toBe('auditadmin');
});
it('allows moderators to update the underlying nova card from a report row', function (): void {
$admin = User::factory()->create(['role' => 'admin']);
$reporter = User::factory()->create(['username' => 'targetreporter']);
$creator = User::factory()->create(['username' => 'targetcreator']);
$card = reportableCard($creator, [
'title' => 'Flaggable Queue Card',
'moderation_status' => NovaCard::MOD_PENDING,
'project_json' => [
'content' => ['title' => 'Flaggable Queue Card', 'quote_text' => 'Report queue card'],
'moderation' => [
'source' => 'publish_heuristics',
'flagged' => true,
'reasons' => ['self_remix_loop'],
],
],
]);
$report = Report::query()->create([
'reporter_id' => $reporter->id,
'target_type' => 'nova_card',
'target_id' => $card->id,
'reason' => 'Suspicious engagement bait',
'status' => 'reviewing',
]);
$this->actingAs($admin)
->postJson(route('api.admin.reports.moderate-target', ['report' => $report->id]), [
'action' => 'flag_card',
'disposition' => 'rights_review_required',
])
->assertOk()
->assertJsonPath('report.target.moderation_target.card_id', $card->id)
->assertJsonPath('report.target.moderation_target.moderation_status', NovaCard::MOD_FLAGGED)
->assertJsonPath('report.target.moderation_target.moderation_reasons.0', 'self_remix_loop')
->assertJsonPath('report.target.moderation_target.moderation_reason_labels.0', 'Self-remix loop')
->assertJsonPath('report.target.moderation_target.moderation_override.source', 'report_queue')
->assertJsonPath('report.target.moderation_target.moderation_override.disposition', 'rights_review_required')
->assertJsonPath('report.target.moderation_target.moderation_override.disposition_label', 'Rights review required')
->assertJsonPath('report.target.moderation_target.moderation_override_history.0.disposition_label', 'Rights review required')
->assertJsonPath('report.target.moderation_target.moderation_override.report_id', $report->id)
->assertJsonPath('report.history.0.action_type', 'target_moderated');
expect($card->fresh()->moderation_status)->toBe(NovaCard::MOD_FLAGGED)
->and($card->fresh()->project_json['moderation']['override']['source'] ?? null)->toBe('report_queue')
->and($card->fresh()->project_json['moderation']['override']['disposition'] ?? null)->toBe('rights_review_required')
->and($report->fresh()->historyEntries()->count())->toBe(1)
->and($report->fresh()->last_moderated_by_id)->toBe($admin->id);
});
it('includes nova card comment reports in the nova cards moderation queue', function (): void {
$admin = User::factory()->create(['role' => 'admin']);
$reporter = User::factory()->create(['username' => 'commentqueuereporter']);
$creator = User::factory()->create(['username' => 'commentqueuecreator']);
$card = reportableCard($creator, ['title' => 'Queue Comment Card']);
$comment = NovaCardComment::query()->create([
'card_id' => $card->id,
'user_id' => $creator->id,
'body' => 'Queue this comment too.',
'rendered_body' => 'Queue this comment too.',
'status' => 'visible',
]);
Report::query()->create([
'reporter_id' => $reporter->id,
'target_type' => 'nova_card_comment',
'target_id' => $comment->id,
'reason' => 'Comment harassment',
'status' => 'open',
]);
$queue = $this->actingAs($admin)
->getJson(route('api.admin.reports.queue', ['group' => 'nova_cards', 'status' => 'open']))
->assertOk();
expect(collect($queue->json('data'))->pluck('target.type')->all())->toContain('nova_card_comment');
});
it('renders challenge reporting controls for authenticated viewers', function (): void {
$viewer = User::factory()->create(['username' => 'challengeviewer']);
$creator = User::factory()->create(['username' => 'challengecreator']);
$card = reportableCard($creator, ['title' => 'Challenge Card']);
$challenge = NovaCardChallenge::query()->create([
'user_id' => $creator->id,
'slug' => 'challenge-report-page',
'title' => 'Challenge Report Page',
'description' => 'Challenge description',
'prompt' => 'Challenge prompt',
'status' => NovaCardChallenge::STATUS_ACTIVE,
'official' => true,
'featured' => true,
'starts_at' => now()->subDay(),
'ends_at' => now()->addWeek(),
]);
NovaCardChallengeEntry::query()->create([
'challenge_id' => $challenge->id,
'card_id' => $card->id,
'user_id' => $creator->id,
'status' => NovaCardChallengeEntry::STATUS_SUBMITTED,
]);
$response = $this->actingAs($viewer)
->get(route('cards.challenges.show', ['slug' => $challenge->slug]))
->assertOk();
expect($response->getContent())
->toContain('data-report-target-type="nova_card_challenge"')
->toContain('data-report-target-type="nova_card_challenge_entry"');
});

View File

@@ -0,0 +1,32 @@
<?php
declare(strict_types=1);
use App\Models\NovaCard;
use App\Models\NovaCardAsset;
use App\Models\NovaCardAssetPack;
use App\Models\NovaCardChallenge;
use App\Models\NovaCardChallengeEntry;
use App\Models\NovaCardCollection;
use App\Models\User;
use Database\Seeders\DatabaseSeeder;
it('seeds official demo cards when the hook is enabled', function (): void {
config()->set('nova_cards.seed_demo_cards.enabled', true);
config()->set('nova_cards.seed_demo_cards.user.username', 'nova.cards');
config()->set('nova_cards.seed_demo_cards.user.email', 'nova-cards-demo@skinbase.test');
$this->seed(DatabaseSeeder::class);
$demoUser = User::query()->where('email', 'nova-cards-demo@skinbase.test')->first();
expect($demoUser)->not->toBeNull();
expect(NovaCard::query()->where('user_id', $demoUser->id)->count())->toBe(6);
expect(NovaCard::query()->where('user_id', $demoUser->id)->where('featured', true)->exists())->toBeTrue();
expect(NovaCard::query()->where('user_id', $demoUser->id)->where('status', NovaCard::STATUS_PUBLISHED)->count())->toBe(6);
expect(NovaCardCollection::query()->where('user_id', $demoUser->id)->where('official', true)->count())->toBeGreaterThanOrEqual(2);
expect(NovaCardChallenge::query()->where('user_id', $demoUser->id)->where('official', true)->count())->toBeGreaterThanOrEqual(2);
expect(NovaCardChallengeEntry::query()->count())->toBeGreaterThanOrEqual(6);
expect(NovaCardAssetPack::query()->where('official', true)->count())->toBeGreaterThanOrEqual(4);
expect(NovaCardAsset::query()->where('official', true)->count())->toBeGreaterThan(0);
});

View File

@@ -0,0 +1,249 @@
<?php
declare(strict_types=1);
use App\Models\NovaCard;
use App\Models\NovaCardCategory;
use App\Models\NovaCardTemplate;
use App\Models\User;
use Inertia\Testing\AssertableInertia;
use Illuminate\Support\Arr;
use Illuminate\Support\Str;
function studioCardCategory(array $attributes = []): NovaCardCategory
{
return NovaCardCategory::query()->create(array_merge([
'slug' => 'studio-category-' . Str::lower(Str::random(6)),
'name' => 'Studio Category',
'description' => 'Studio category',
'active' => true,
'order_num' => 0,
], $attributes));
}
function studioCardTemplate(array $attributes = []): NovaCardTemplate
{
return NovaCardTemplate::query()->create(array_merge([
'slug' => 'studio-template-' . Str::lower(Str::random(6)),
'name' => 'Studio Template',
'description' => 'Studio template',
'config_json' => [
'font_preset' => 'modern-sans',
'gradient_preset' => 'midnight-nova',
'layout' => 'quote_heavy',
'text_align' => 'center',
'text_color' => '#ffffff',
'overlay_style' => 'dark-soft',
],
'supported_formats' => ['square', 'portrait', 'story'],
'active' => true,
'official' => true,
'order_num' => 0,
], $attributes));
}
function studioDraftCard(User $user, array $attributes = []): NovaCard
{
$category = $attributes['category'] ?? studioCardCategory();
$template = $attributes['template'] ?? studioCardTemplate();
return NovaCard::query()->create(array_merge([
'user_id' => $user->id,
'category_id' => $category->id,
'template_id' => $template->id,
'title' => 'Studio Draft',
'slug' => 'studio-draft-' . Str::lower(Str::random(6)),
'quote_text' => 'Studio draft quote text.',
'quote_author' => 'Studio Author',
'quote_source' => 'Studio Notes',
'description' => 'Studio draft description',
'format' => NovaCard::FORMAT_SQUARE,
'project_json' => [
'content' => [
'title' => 'Studio Draft',
'quote_text' => 'Studio draft quote text.',
'quote_author' => 'Studio Author',
'quote_source' => 'Studio Notes',
],
'layout' => [
'layout' => 'quote_heavy',
'position' => 'center',
'alignment' => 'center',
'padding' => 'comfortable',
'max_width' => 'balanced',
],
'typography' => [
'font_preset' => 'modern-sans',
'text_color' => '#ffffff',
'accent_color' => '#e0f2fe',
'quote_size' => 72,
'author_size' => 28,
'letter_spacing' => 0,
],
'background' => [
'type' => 'gradient',
'gradient_preset' => 'midnight-nova',
'gradient_colors' => ['#0f172a', '#1d4ed8'],
'overlay_style' => 'dark-soft',
'blur_level' => 0,
'opacity' => 50,
],
'decorations' => [],
],
'render_version' => 1,
'background_type' => 'gradient',
'visibility' => NovaCard::VISIBILITY_PRIVATE,
'status' => NovaCard::STATUS_DRAFT,
'moderation_status' => NovaCard::MOD_PENDING,
'featured' => false,
'allow_download' => true,
], Arr::except($attributes, ['category', 'template'])));
}
it('requires authentication for studio card pages', function (): void {
foreach ([
route('studio.cards.index'),
route('studio.cards.create'),
] as $url) {
$this->get($url)->assertRedirect('/login');
}
});
it('renders the studio cards index with user stats and edit endpoints', function (): void {
$user = User::factory()->create();
studioDraftCard($user, ['title' => 'My Studio Draft']);
$this->actingAs($user)
->get(route('studio.cards.index'))
->assertOk()
->assertInertia(fn (AssertableInertia $page) => $page
->component('Studio/StudioCardsIndex')
->where('stats.all', 1)
->where('stats.drafts', 1)
->where('cards.data.0.title', 'My Studio Draft')
->where('endpoints.create', route('studio.cards.create'))
->where('endpoints.draftStore', route('api.cards.drafts.store')));
});
it('renders the create editor with preview mode disabled', function (): void {
$user = User::factory()->create();
studioCardCategory(['slug' => 'affirmations', 'name' => 'Affirmations']);
studioCardTemplate(['slug' => 'bold-center', 'name' => 'Bold Center']);
$this->actingAs($user)
->get(route('studio.cards.create'))
->assertOk()
->assertInertia(fn (AssertableInertia $page) => $page
->component('Studio/StudioCardEditor')
->where('previewMode', false)
->where('card', null)
->where('mobileSteps.0.key', 'format')
->where('mobileSteps.5.key', 'publish')
->where('endpoints.studioCards', route('studio.cards.index'))
->where('endpoints.draftStore', route('api.cards.drafts.store'))
->where('editorOptions.categories.0.slug', 'affirmations')
->where('editorOptions.templates.0.slug', 'bold-center'));
});
it('renders edit and preview studio pages only for the owner', function (): void {
$owner = User::factory()->create();
$other = User::factory()->create();
$card = studioDraftCard($owner, [
'title' => 'Owner Draft',
'editor_mode_last_used' => 'quick',
'project_json' => [
'content' => [
'title' => 'Owner Draft',
'quote_text' => 'Studio draft quote text.',
'quote_author' => 'Studio Author',
'quote_source' => 'Studio Notes',
],
'text_blocks' => [
['key' => 'title', 'type' => 'title', 'text' => 'Owner Draft', 'enabled' => true],
['key' => 'quote', 'type' => 'quote', 'text' => 'Studio draft quote text.', 'enabled' => true],
],
'layout' => [
'layout' => 'quote_heavy',
'position' => 'center',
'alignment' => 'center',
'padding' => 'comfortable',
'max_width' => 'balanced',
],
'typography' => [
'font_preset' => 'modern-sans',
'text_color' => '#ffffff',
'accent_color' => '#e0f2fe',
'quote_size' => 72,
'author_size' => 28,
'letter_spacing' => 0,
],
'background' => [
'type' => 'gradient',
'gradient_preset' => 'midnight-nova',
'gradient_colors' => ['#0f172a', '#1d4ed8'],
'overlay_style' => 'dark-soft',
'blur_level' => 0,
'opacity' => 50,
],
'source_context' => [
'editor_mode' => 'quick',
],
'decorations' => [],
],
]);
$card->versions()->create([
'user_id' => $owner->id,
'version_number' => 1,
'label' => 'Initial snapshot',
'snapshot_hash' => hash('sha256', 'owner-draft-v1'),
'snapshot_json' => $card->project_json,
]);
$this->actingAs($owner)
->get(route('studio.cards.edit', ['id' => $card->id]))
->assertOk()
->assertInertia(fn (AssertableInertia $page) => $page
->component('Studio/StudioCardEditor')
->where('previewMode', false)
->where('card.id', $card->id)
->where('card.title', 'Owner Draft')
->where('card.editor_mode_last_used', 'quick')
->where('versions.0.snapshot_json.source_context.editor_mode', 'quick'));
$this->actingAs($owner)
->get(route('studio.cards.preview', ['id' => $card->id]))
->assertOk()
->assertInertia(fn (AssertableInertia $page) => $page
->component('Studio/StudioCardEditor')
->where('previewMode', true)
->where('card.id', $card->id));
$this->actingAs($other)
->get(route('studio.cards.edit', ['id' => $card->id]))
->assertNotFound();
});
it('renders the studio card analytics page for the owner', function (): void {
$owner = User::factory()->create();
$card = studioDraftCard($owner, [
'title' => 'Analytics Card',
'status' => NovaCard::STATUS_PUBLISHED,
'visibility' => NovaCard::VISIBILITY_PUBLIC,
'likes_count' => 4,
'saves_count' => 3,
'remixes_count' => 2,
'comments_count' => 5,
'views_count' => 12,
'trending_score' => 18.5,
'published_at' => now()->subHour(),
]);
$this->actingAs($owner)
->get(route('studio.cards.analytics', ['id' => $card->id]))
->assertOk()
->assertInertia(fn (AssertableInertia $page) => $page
->component('Studio/StudioCardAnalytics')
->where('card.id', $card->id)
->where('analytics.likes', 4)
->where('analytics.comments', 5));
});

View File

@@ -0,0 +1,368 @@
<?php
declare(strict_types=1);
use App\Models\NovaCard;
use App\Models\NovaCardCategory;
use App\Models\NovaCardChallenge;
use App\Models\NovaCardComment;
use App\Models\NovaCardCollection;
use App\Models\NovaCardTemplate;
use App\Models\User;
use Illuminate\Support\Arr;
use Illuminate\Support\Str;
function v2Category(array $attributes = []): NovaCardCategory
{
return NovaCardCategory::query()->create(array_merge([
'slug' => 'v2-category-' . Str::lower(Str::random(6)),
'name' => 'V2 Category',
'description' => 'Nova Cards v2 category',
'active' => true,
'order_num' => 0,
], $attributes));
}
function v2Template(array $attributes = []): NovaCardTemplate
{
return NovaCardTemplate::query()->create(array_merge([
'slug' => 'v2-template-' . Str::lower(Str::random(6)),
'name' => 'V2 Template',
'description' => 'Nova Cards v2 template',
'config_json' => [
'font_preset' => 'modern-sans',
'gradient_preset' => 'midnight-nova',
'layout' => 'quote_heavy',
'text_align' => 'center',
'text_color' => '#ffffff',
'overlay_style' => 'dark-soft',
],
'supported_formats' => ['square', 'portrait'],
'active' => true,
'official' => true,
'order_num' => 0,
], $attributes));
}
function v2PublishedCard(User $user, array $attributes = []): NovaCard
{
$category = $attributes['category'] ?? v2Category();
$template = $attributes['template'] ?? v2Template();
return NovaCard::query()->create(array_merge([
'user_id' => $user->id,
'category_id' => $category->id,
'template_id' => $template->id,
'title' => 'V2 Public Card',
'slug' => 'v2-public-card-' . Str::lower(Str::random(5)),
'quote_text' => 'A card built for Nova Cards v2 tests.',
'quote_author' => 'Nova V2',
'quote_source' => 'Feature suite',
'description' => 'Published Nova Cards v2 test card.',
'format' => NovaCard::FORMAT_SQUARE,
'project_json' => [
'schema_version' => 2,
'content' => [
'title' => 'V2 Public Card',
'quote_text' => 'A card built for Nova Cards v2 tests.',
'quote_author' => 'Nova V2',
'quote_source' => 'Feature suite',
],
'text_blocks' => [
['key' => 'title', 'type' => 'title', 'text' => 'V2 Public Card', 'enabled' => true],
['key' => 'quote', 'type' => 'quote', 'text' => 'A card built for Nova Cards v2 tests.', 'enabled' => true],
['key' => 'author', 'type' => 'author', 'text' => 'Nova V2', 'enabled' => true],
],
'layout' => ['layout' => 'quote_heavy', 'position' => 'center', 'alignment' => 'center', 'padding' => 'comfortable', 'max_width' => 'balanced'],
'typography' => ['font_preset' => 'modern-sans', 'text_color' => '#ffffff', 'accent_color' => '#e0f2fe', 'quote_size' => 72, 'author_size' => 28, 'letter_spacing' => 0, 'line_height' => 1.2, 'shadow_preset' => 'soft'],
'background' => ['type' => 'gradient', 'gradient_preset' => 'midnight-nova', 'gradient_colors' => ['#0f172a', '#1d4ed8'], 'overlay_style' => 'dark-soft', 'focal_position' => 'center', 'blur_level' => 0, 'opacity' => 50],
'decorations' => [],
'assets' => ['pack_ids' => [], 'template_pack_ids' => [], 'items' => []],
],
'schema_version' => 2,
'render_version' => 2,
'background_type' => 'gradient',
'visibility' => NovaCard::VISIBILITY_PUBLIC,
'status' => NovaCard::STATUS_PUBLISHED,
'moderation_status' => NovaCard::MOD_APPROVED,
'allow_download' => true,
'allow_remix' => true,
'published_at' => now()->subHour(),
], Arr::except($attributes, ['category', 'template'])));
}
it('supports likes favorites saves and remix lineage for published cards', function (): void {
$creator = User::factory()->create();
$viewer = User::factory()->create();
$card = v2PublishedCard($creator, ['slug' => 'remix-source-card']);
$this->actingAs($viewer)
->postJson(route('api.cards.like', ['id' => $card->id]))
->assertOk()
->assertJsonPath('liked', true);
$this->actingAs($viewer)
->postJson(route('api.cards.favorite', ['id' => $card->id]))
->assertOk()
->assertJsonPath('favorited', true);
$this->actingAs($viewer)
->postJson(route('api.cards.save', ['id' => $card->id]))
->assertOk()
->assertJsonPath('ok', true);
$remix = $this->actingAs($viewer)
->postJson(route('api.cards.remix', ['id' => $card->id]))
->assertCreated()
->assertJsonPath('data.lineage.original_card_id', $card->id);
$remixedCard = NovaCard::query()->findOrFail((int) $remix->json('data.id'));
expect($card->fresh()->likes_count)->toBe(1)
->and($card->fresh()->favorites_count)->toBe(1)
->and($card->fresh()->saves_count)->toBe(1)
->and($card->fresh()->remixes_count)->toBe(1)
->and($remixedCard->original_card_id)->toBe($card->id)
->and(NovaCardCollection::query()->where('user_id', $viewer->id)->exists())->toBeTrue();
});
it('returns lineage data for a published card via the api', function (): void {
$creator = User::factory()->create();
$root = v2PublishedCard($creator, ['slug' => 'api-lineage-root', 'title' => 'API Lineage Root']);
$remix = v2PublishedCard($creator, [
'slug' => 'api-lineage-remix',
'title' => 'API Lineage Remix',
'original_card_id' => $root->id,
'root_card_id' => $root->id,
]);
$this->getJson(route('api.cards.lineage', ['id' => $remix->id]))
->assertOk()
->assertJsonPath('data.root_card.id', $root->id)
->assertJsonPath('data.trail.0.id', $root->id)
->assertJsonPath('data.card.id', $remix->id);
});
it('supports the literal spec-style api aliases for v2 card flows', function (): void {
$creator = User::factory()->create();
$viewer = User::factory()->create();
$card = v2PublishedCard($creator, ['slug' => 'alias-source-card', 'title' => 'Alias Source']);
$challenge = NovaCardChallenge::query()->create([
'slug' => 'alias-challenge',
'title' => 'Alias Challenge',
'status' => NovaCardChallenge::STATUS_ACTIVE,
'official' => true,
]);
$this->actingAs($viewer)
->postJson('/api/cards/' . $card->id . '/like')
->assertOk()
->assertJsonPath('liked', true);
$this->actingAs($viewer)
->postJson('/api/cards/' . $card->id . '/save')
->assertOk()
->assertJsonPath('ok', true);
$this->actingAs($viewer)
->postJson('/api/cards/' . $card->id . '/remix')
->assertCreated();
$owned = v2PublishedCard($viewer, ['slug' => 'alias-owned-card', 'title' => 'Alias Owned']);
$this->actingAs($viewer)
->postJson('/api/cards/challenges/' . $challenge->id . '/submit', ['card_id' => $owned->id])
->assertOk()
->assertJsonPath('entry.challenge_id', $challenge->id);
$this->getJson('/api/cards/' . $card->id . '/lineage')
->assertOk();
});
it('supports collection metadata and item management via the v2 api collection endpoints', function (): void {
$user = User::factory()->create();
$card = v2PublishedCard(User::factory()->create(), ['slug' => 'collection-endpoint-card']);
$create = $this->actingAs($user)
->postJson(route('api.cards.collections.store'), [
'name' => 'My Picks',
'visibility' => 'public',
])
->assertCreated();
$collectionId = (int) $create->json('collection.id');
$this->actingAs($user)
->patchJson('/api/cards/collections/' . $collectionId, [
'name' => 'Updated Picks',
'slug' => 'updated-picks',
'visibility' => 'public',
])
->assertOk()
->assertJsonPath('collection.slug', 'updated-picks');
$this->actingAs($user)
->postJson('/api/cards/collections/' . $collectionId . '/items', [
'card_id' => $card->id,
'note' => 'Pinned',
])
->assertCreated()
->assertJsonPath('collection.items.0.card.id', $card->id);
$this->actingAs($user)
->deleteJson('/api/cards/collections/' . $collectionId . '/items/' . $card->id)
->assertOk();
});
it('lists challenge, asset, and template resources through the v2 api', function (): void {
$user = User::factory()->create();
NovaCardChallenge::query()->create([
'slug' => 'resource-check',
'title' => 'Resource Check',
'status' => NovaCardChallenge::STATUS_ACTIVE,
'official' => true,
]);
$this->actingAs($user)->getJson('/api/cards/challenges')->assertOk();
$this->actingAs($user)->getJson('/api/cards/assets')->assertOk();
$this->actingAs($user)->getJson('/api/cards/templates')->assertOk();
});
it('recomputes comments_count when card comments change', function (): void {
$creator = User::factory()->create();
$commenter = User::factory()->create();
$card = v2PublishedCard($creator, ['slug' => 'comment-counter-card']);
NovaCardComment::query()->create([
'card_id' => $card->id,
'user_id' => $commenter->id,
'body' => 'First comment',
'rendered_body' => 'First comment',
'status' => 'visible',
]);
app(\App\Services\NovaCards\NovaCardTrendingService::class)->refreshCard($card->fresh());
expect($card->fresh()->comments_count)->toBe(1);
});
it('upgrades legacy v1 project json into the v2 editor shape when editing', function (): void {
$user = User::factory()->create();
$category = v2Category();
$template = v2Template();
$card = NovaCard::query()->create([
'user_id' => $user->id,
'category_id' => $category->id,
'template_id' => $template->id,
'title' => 'Legacy Card',
'slug' => 'legacy-card',
'quote_text' => 'Legacy quote',
'format' => NovaCard::FORMAT_SQUARE,
'project_json' => [
'schema_version' => 1,
'content' => [
'title' => 'Legacy Card',
'quote_text' => 'Legacy quote',
],
'background' => [
'type' => 'gradient',
'gradient_preset' => 'midnight-nova',
],
],
'schema_version' => 1,
'background_type' => 'gradient',
'visibility' => NovaCard::VISIBILITY_PRIVATE,
'status' => NovaCard::STATUS_DRAFT,
'moderation_status' => NovaCard::MOD_PENDING,
'allow_download' => true,
]);
$response = $this->actingAs($user)
->patchJson(route('api.cards.drafts.update', ['id' => $card->id]), [
'quote_author' => 'Legacy Author',
])
->assertOk();
expect($response->json('data.schema_version'))->toBe(2)
->and($response->json('data.project_json.text_blocks.0.type'))->toBe('title')
->and(NovaCard::query()->findOrFail($card->id)->schema_version)->toBe(2);
});
it('duplicates an owned card into a fresh draft without remix lineage', function (): void {
$user = User::factory()->create();
$card = v2PublishedCard($user, ['slug' => 'duplicate-source-card', 'title' => 'Duplicate Source']);
$response = $this->actingAs($user)
->postJson(route('api.cards.duplicate', ['id' => $card->id]))
->assertCreated();
$duplicate = NovaCard::query()->findOrFail((int) $response->json('data.id'));
expect($duplicate->user_id)->toBe($user->id)
->and($duplicate->title)->toBe('Copy of Duplicate Source')
->and($duplicate->status)->toBe(NovaCard::STATUS_DRAFT)
->and($duplicate->original_card_id)->toBeNull()
->and($duplicate->root_card_id)->toBeNull();
});
it('restores an earlier draft version through the v2 versions api', function (): void {
$user = User::factory()->create();
$category = v2Category();
$template = v2Template();
$create = $this->actingAs($user)->postJson(route('api.cards.drafts.store'), [
'title' => 'Versioned Card',
'quote_text' => 'First version.',
'category_id' => $category->id,
'template_id' => $template->id,
])->assertCreated();
$cardId = (int) $create->json('data.id');
$this->actingAs($user)->postJson(route('api.cards.drafts.autosave', ['id' => $cardId]), [
'quote_text' => 'Second version.',
])->assertOk();
$versions = $this->actingAs($user)
->getJson(route('api.cards.drafts.versions', ['id' => $cardId]))
->assertOk();
$firstVersionId = (int) collect($versions->json('data'))->last()['id'];
$this->actingAs($user)
->postJson(route('api.cards.drafts.restore', ['id' => $cardId, 'versionId' => $firstVersionId]))
->assertOk()
->assertJsonPath('data.quote_text', 'First version.');
expect(NovaCard::query()->findOrFail($cardId)->quote_text)->toBe('First version.');
});
it('submits a published card to a challenge and renders the new public v2 routes', function (): void {
$user = User::factory()->create(['username' => 'v2creator']);
$card = v2PublishedCard($user, ['slug' => 'challenge-entry-card']);
$challenge = NovaCardChallenge::query()->create([
'slug' => 'weekly-clarity',
'title' => 'Weekly Clarity',
'description' => 'Make a clean editorial card about clarity.',
'prompt' => 'Design a card that turns one strong sentence into an editorial object.',
'status' => NovaCardChallenge::STATUS_ACTIVE,
'official' => true,
'featured' => true,
'starts_at' => now()->subDay(),
'ends_at' => now()->addWeek(),
]);
$this->actingAs($user)
->postJson(route('api.cards.challenges.submit', ['challengeId' => $challenge->id, 'id' => $card->id]))
->assertOk()
->assertJsonPath('entry.challenge_id', $challenge->id);
expect($card->fresh()->challenge_entries_count)->toBe(1);
$this->get(route('cards.popular'))->assertOk();
$this->get(route('cards.remixed'))->assertOk();
$this->get(route('cards.challenges'))->assertOk()->assertSee('Weekly Clarity');
$this->get(route('cards.challenges.show', ['slug' => $challenge->slug]))->assertOk()->assertSee($card->title);
$this->get(route('cards.templates'))->assertOk();
$this->get(route('cards.assets'))->assertOk();
});

View File

@@ -0,0 +1,357 @@
<?php
declare(strict_types=1);
use App\Models\NovaCard;
use App\Models\NovaCardCreatorPreset;
use App\Models\NovaCardExport;
use App\Models\User;
use App\Services\NovaCards\NovaCardCreatorPresetService;
use App\Services\NovaCards\NovaCardProjectNormalizer;
use App\Services\NovaCards\NovaCardRelatedCardsService;
use App\Services\NovaCards\NovaCardRisingService;
use Illuminate\Support\Str;
// ─── Helpers ──────────────────────────────────────────────────────────────────
function v3User(): User
{
return User::factory()->create();
}
function v3Card(User $user, array $attributes = []): NovaCard
{
return NovaCard::query()->create(array_merge([
'user_id' => $user->id,
'uuid' => Str::uuid()->toString(),
'slug' => 'v3-card-' . Str::lower(Str::random(8)),
'title' => 'Nova v3 Card',
'quote_text' => 'A test quote for version three.',
'format' => 'square',
'status' => NovaCard::STATUS_PUBLISHED,
'visibility' => NovaCard::VISIBILITY_PUBLIC,
'moderation_status' => NovaCard::MOD_APPROVED,
'published_at' => now(),
'schema_version' => 3,
'project_json' => [
'schema_version' => 3,
'meta' => ['editor' => 'nova-cards-v3'],
'content' => ['title' => 'Nova v3 Card', 'quote_text' => 'A test quote for version three.'],
'text_blocks' => [],
'layout' => ['layout' => 'quote_heavy', 'position' => 'center', 'alignment' => 'center', 'padding' => 'comfortable', 'max_width' => 'balanced'],
'typography' => ['font_preset' => 'modern-sans', 'text_color' => '#ffffff', 'accent_color' => '#e0f2fe', 'quote_size' => 72, 'author_size' => 28, 'line_height' => 1.2, 'shadow_preset' => 'soft', 'quote_mark_preset' => 'none', 'text_panel_style' => 'none'],
'background' => ['type' => 'gradient', 'gradient_preset' => 'midnight-nova', 'gradient_colors' => ['#0f172a', '#1d4ed8']],
'canvas' => ['density' => 'standard', 'safe_zone' => true],
'frame' => ['preset' => 'none'],
'effects' => ['color_grade' => 'none', 'effect_preset' => 'none', 'intensity' => 50],
'export_preferences' => ['allow_export' => true, 'default_format' => 'preview'],
'source_context' => ['style_family' => null, 'palette_family' => null],
'decorations' => [],
'assets' => ['pack_ids' => [], 'template_pack_ids' => [], 'items' => []],
],
'allow_export' => true,
'allow_background_reuse' => false,
], $attributes));
}
// ─── Schema normalizer v3 tests ───────────────────────────────────────────────
describe('NovaCardProjectNormalizer v3', function (): void {
it('produces schema_version 3 for new projects', function (): void {
$normalizer = app(NovaCardProjectNormalizer::class);
$result = $normalizer->normalize(null, null, []);
expect($result['schema_version'])->toBe(3);
expect($result['meta']['editor'])->toBe('nova-cards-v3');
});
it('detects legacy v1/v2 projects as needing upgrade', function (): void {
$normalizer = app(NovaCardProjectNormalizer::class);
$v1 = ['schema_version' => 1, 'quote_text' => 'old quote'];
expect($normalizer->isLegacyProject($v1))->toBeTrue();
$v2 = ['schema_version' => 2, 'meta' => ['editor' => 'nova-cards-v2']];
expect($normalizer->isLegacyProject($v2))->toBeTrue();
$v3 = ['schema_version' => 3, 'meta' => ['editor' => 'nova-cards-v3']];
expect($normalizer->isLegacyProject($v3))->toBeFalse();
});
it('upgradeToV3 includes all v3 sections', function (): void {
$normalizer = app(NovaCardProjectNormalizer::class);
$v2 = [
'schema_version' => 2,
'meta' => ['editor' => 'nova-cards-v2'],
'layout' => ['layout' => 'centered'],
'background' => ['type' => 'gradient', 'gradient_colors' => ['#000', '#fff']],
'typography' => ['font_preset' => 'elegant-serif'],
];
$result = $normalizer->upgradeToV3($v2);
expect($result['schema_version'])->toBe(3);
expect($result['meta']['editor'])->toBe('nova-cards-v3');
// v2 content preserved
expect($result['layout']['layout'])->toBe('centered');
expect($result['background']['gradient_colors'])->toBe(['#000', '#fff']);
expect($result['typography']['font_preset'])->toBe('elegant-serif');
// v3 sections added
expect($result)->toHaveKey('canvas');
expect($result)->toHaveKey('frame');
expect($result)->toHaveKey('effects');
expect($result)->toHaveKey('export_preferences');
expect($result)->toHaveKey('source_context');
// v3 typography additions
expect($result['typography'])->toHaveKey('quote_mark_preset');
expect($result['typography'])->toHaveKey('text_panel_style');
});
it('normalizeForCard upgrades a v1 card to v3 on the fly', function (): void {
$user = v3User();
$card = NovaCard::query()->create([
'user_id' => $user->id,
'uuid' => Str::uuid()->toString(),
'slug' => 'legacy-v1-' . Str::lower(Str::random(6)),
'title' => 'Legacy card',
'quote_text' => 'An old-school quote.',
'format' => 'square',
'status' => NovaCard::STATUS_DRAFT,
'visibility' => NovaCard::VISIBILITY_PRIVATE,
'moderation_status' => NovaCard::MOD_PENDING,
'schema_version' => 1,
'project_json' => ['schema_version' => 1, 'quote_text' => 'An old-school quote.'],
]);
$normalizer = app(NovaCardProjectNormalizer::class);
$result = $normalizer->normalizeForCard($card);
expect($result['schema_version'])->toBe(3);
expect($result)->toHaveKey('canvas');
expect($result)->toHaveKey('frame');
expect($result)->toHaveKey('effects');
});
});
// ─── Creator preset tests ─────────────────────────────────────────────────────
describe('NovaCardCreatorPresetService', function (): void {
it('creates a preset for a user', function (): void {
$user = v3User();
$service = app(NovaCardCreatorPresetService::class);
$preset = $service->create($user, [
'name' => 'My Midnight Style',
'preset_type' => NovaCardCreatorPreset::TYPE_STYLE,
'config_json' => ['typography' => ['font_preset' => 'bold-poster']],
]);
expect($preset)->toBeInstanceOf(NovaCardCreatorPreset::class)
->and($preset->name)->toBe('My Midnight Style')
->and($preset->preset_type)->toBe(NovaCardCreatorPreset::TYPE_STYLE)
->and($preset->user_id)->toBe($user->id);
});
it('enforces per-type limit', function (): void {
$user = v3User();
$service = app(NovaCardCreatorPresetService::class);
for ($i = 0; $i < NovaCardCreatorPresetService::MAX_PER_TYPE; $i++) {
$service->create($user, [
'name' => "Preset $i",
'preset_type' => NovaCardCreatorPreset::TYPE_LAYOUT,
'config_json' => [],
]);
}
expect(fn () => $service->create($user, [
'name' => 'One too many',
'preset_type' => NovaCardCreatorPreset::TYPE_LAYOUT,
'config_json' => [],
]))->toThrow(\Symfony\Component\HttpKernel\Exception\HttpException::class);
});
it('captures preset fields from a card project_json', function (): void {
$user = v3User();
$card = v3Card($user);
$service = app(NovaCardCreatorPresetService::class);
$preset = $service->captureFromCard($user, $card, 'My Style Capture', NovaCardCreatorPreset::TYPE_STYLE);
$config = $preset->config_json;
// Style presets capture typography
expect($config)->toHaveKey('typography');
});
it('applies a background preset to produce a project patch', function (): void {
$user = v3User();
$service = app(NovaCardCreatorPresetService::class);
$preset = NovaCardCreatorPreset::query()->create([
'user_id' => $user->id,
'name' => 'Cinematic BG',
'preset_type' => NovaCardCreatorPreset::TYPE_BACKGROUND,
'config_json' => [
'background' => ['type' => 'gradient', 'gradient_preset' => 'deep-cinema'],
],
'is_default' => false,
]);
$card = v3Card($user);
$patch = $service->applyToProjectPatch($preset, $card);
expect($patch)->toHaveKey('background')
->and($patch['background']['gradient_preset'])->toBe('deep-cinema');
});
it('sets a preset as the default for its type', function (): void {
$user = v3User();
$service = app(NovaCardCreatorPresetService::class);
$presetA = $service->create($user, ['name' => 'A', 'preset_type' => NovaCardCreatorPreset::TYPE_TYPOGRAPHY, 'config_json' => []]);
$presetB = $service->create($user, ['name' => 'B', 'preset_type' => NovaCardCreatorPreset::TYPE_TYPOGRAPHY, 'config_json' => []]);
$service->setDefault($user, $presetB->id);
expect(NovaCardCreatorPreset::query()->find($presetA->id)->is_default)->toBeFalse()
->and(NovaCardCreatorPreset::query()->find($presetB->id)->is_default)->toBeTrue();
});
});
// ─── Rising service tests ─────────────────────────────────────────────────────
describe('NovaCardRisingService', function (): void {
it('returns recently published cards with engagement', function (): void {
$user = v3User();
$rising = v3Card($user, [
'published_at' => now()->subHours(12),
'saves_count' => 30,
'likes_count' => 20,
]);
$old = v3Card($user, [
'slug' => 'old-card-' . Str::lower(Str::random(6)),
'published_at' => now()->subDays(10),
'saves_count' => 100,
]);
$service = app(NovaCardRisingService::class);
$results = $service->risingCards(20, false);
expect($results->pluck('id'))->toContain($rising->id)
->and($results->pluck('id'))->not->toContain($old->id);
});
it('invalidateCache does not throw', function (): void {
$service = app(NovaCardRisingService::class);
expect(fn () => $service->invalidateCache())->not->toThrow(\Throwable::class);
});
});
// ─── Related cards service tests ───────────────────────────────────────────────
describe('NovaCardRelatedCardsService', function (): void {
it('returns related cards for a given card', function (): void {
$user = v3User();
$source = v3Card($user, ['style_family' => 'minimal']);
$related = v3Card($user, [
'slug' => 'related-card-' . Str::lower(Str::random(6)),
'style_family' => 'minimal',
'published_at' => now()->subHours(2),
]);
$service = app(NovaCardRelatedCardsService::class);
$results = $service->related($source, 8, false);
expect($results->pluck('id'))->toContain($related->id)
->and($results->pluck('id'))->not->toContain($source->id);
});
});
// ─── Export model tests ────────────────────────────────────────────────────────
describe('NovaCardExport', function (): void {
it('isReady returns true only when status is ready', function (): void {
$export = new NovaCardExport(['status' => NovaCardExport::STATUS_READY]);
expect($export->isReady())->toBeTrue();
$pending = new NovaCardExport(['status' => NovaCardExport::STATUS_PENDING]);
expect($pending->isReady())->toBeFalse();
});
it('isExpired returns true when expires_at is in the past', function (): void {
$expired = new NovaCardExport(['expires_at' => now()->subHour()]);
expect($expired->isExpired())->toBeTrue();
$fresh = new NovaCardExport(['expires_at' => now()->addHour()]);
expect($fresh->isExpired())->toBeFalse();
});
});
// ─── API: preset routes ────────────────────────────────────────────────────────
describe('Nova Cards v3 API — presets', function (): void {
it('lists presets for authenticated user', function (): void {
$user = v3User();
NovaCardCreatorPreset::query()->create([
'user_id' => $user->id,
'name' => 'API preset',
'preset_type' => NovaCardCreatorPreset::TYPE_STYLE,
'config_json' => [],
'is_default' => false,
]);
$response = $this->actingAs($user)->getJson(route('api.cards.presets.index'));
$response->assertOk();
$response->assertJsonFragment(['name' => 'API preset']);
});
it('creates a preset via API', function (): void {
$user = v3User();
$response = $this->actingAs($user)->postJson(route('api.cards.presets.store'), [
'name' => 'API created preset',
'preset_type' => NovaCardCreatorPreset::TYPE_BACKGROUND,
'config_json' => ['background' => ['type' => 'gradient']],
]);
$response->assertCreated();
expect(NovaCardCreatorPreset::query()->where('user_id', $user->id)->count())->toBe(1);
});
it('deletes a preset via API', function (): void {
$user = v3User();
$preset = NovaCardCreatorPreset::query()->create([
'user_id' => $user->id,
'name' => 'To delete',
'preset_type' => NovaCardCreatorPreset::TYPE_STYLE,
'config_json' => [],
'is_default' => false,
]);
$response = $this->actingAs($user)->deleteJson(route('api.cards.presets.destroy', $preset->id));
$response->assertOk();
expect(NovaCardCreatorPreset::query()->find($preset->id))->toBeNull();
});
it('prevents deleting another user\'s preset', function (): void {
$owner = v3User();
$attacker = v3User();
$preset = NovaCardCreatorPreset::query()->create([
'user_id' => $owner->id,
'name' => 'Owned preset',
'preset_type' => NovaCardCreatorPreset::TYPE_STYLE,
'config_json' => [],
'is_default' => false,
]);
$response = $this->actingAs($attacker)->deleteJson(route('api.cards.presets.destroy', $preset->id));
$response->assertForbidden();
});
});
// ─── Web: rising page ────────────────────────────────────────────────────────
describe('Nova Cards v3 web — rising page', function (): void {
it('rising page returns 200', function (): void {
$this->get(route('cards.rising'))->assertOk();
});
});

View File

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

View File

@@ -20,40 +20,40 @@ it('GET /explore contains "Explore" heading', function () {
$this->get('/explore')->assertOk()->assertSee('Explore', false);
});
it('GET /explore/wallpapers returns 200', function () {
$this->get('/explore/wallpapers')->assertOk();
it('GET /explore/wallpapers redirects to the canonical /wallpapers route', function () {
$this->get('/explore/wallpapers')->assertRedirect('/wallpapers')->assertStatus(301);
});
it('GET /explore/skins returns 200', function () {
$this->get('/explore/skins')->assertOk();
it('GET /explore/skins redirects to the canonical /skins route', function () {
$this->get('/explore/skins')->assertRedirect('/skins')->assertStatus(301);
});
it('GET /explore/photography returns 200', function () {
$this->get('/explore/photography')->assertOk();
it('GET /explore/photography redirects to the canonical /photography route', function () {
$this->get('/explore/photography')->assertRedirect('/photography')->assertStatus(301);
});
it('GET /explore/artworks returns 200', function () {
$this->get('/explore/artworks')->assertOk();
});
it('GET /explore/other returns 200', function () {
$this->get('/explore/other')->assertOk();
it('GET /explore/other redirects to the canonical /other route', function () {
$this->get('/explore/other')->assertRedirect('/other')->assertStatus(301);
});
it('GET /explore/wallpapers/trending returns 200', function () {
$this->get('/explore/wallpapers/trending')->assertOk();
it('GET /explore/wallpapers/trending redirects to the canonical /wallpapers route', function () {
$this->get('/explore/wallpapers/trending')->assertRedirect('/wallpapers')->assertStatus(301);
});
it('GET /explore/wallpapers/latest returns 200', function () {
$this->get('/explore/wallpapers/latest')->assertOk();
it('GET /explore/wallpapers/latest redirects to the canonical sorted /wallpapers route', function () {
$this->get('/explore/wallpapers/latest')->assertRedirect('/wallpapers?sort=latest')->assertStatus(301);
});
it('GET /explore/wallpapers/best returns 200', function () {
$this->get('/explore/wallpapers/best')->assertOk();
it('GET /explore/wallpapers/best redirects to the canonical sorted /wallpapers route', function () {
$this->get('/explore/wallpapers/best')->assertRedirect('/wallpapers?sort=top-rated')->assertStatus(301);
});
it('GET /explore/wallpapers/new-hot returns 200', function () {
$this->get('/explore/wallpapers/new-hot')->assertOk();
it('GET /explore/wallpapers/new-hot redirects to the canonical sorted /wallpapers route', function () {
$this->get('/explore/wallpapers/new-hot')->assertRedirect('/wallpapers?sort=fresh')->assertStatus(301);
});
it('/explore pages include canonical link tag', function () {
@@ -66,8 +66,8 @@ it('/explore pages set robots index,follow', function () {
expect($html)->toContain('index,follow');
});
it('/explore pages include breadcrumb JSON-LD', function () {
$html = $this->get('/explore/wallpapers')->assertOk()->getContent();
it('/explore hub page includes breadcrumb JSON-LD', function () {
$html = $this->get('/explore')->assertOk()->getContent();
expect($html)->toContain('BreadcrumbList');
});
@@ -81,8 +81,8 @@ it('GET /discover redirects to /discover/trending with 301', function () {
$this->get('/discover')->assertRedirect('/discover/trending')->assertStatus(301);
});
it('GET /sections redirects to /categories with 301', function () {
$this->get('/sections')->assertRedirect('/categories')->assertStatus(301);
it('GET /sections returns the live sections page', function () {
$this->get('/sections')->assertOk()->assertSee('Browse Sections', false);
});
it('GET /browse-categories redirects to /categories with 301', function () {

View File

@@ -0,0 +1,528 @@
<?php
declare(strict_types=1);
use App\Jobs\AnalyzeArtworkAiAssistJob;
use App\Models\Artwork;
use App\Models\ArtworkAiAssist;
use App\Models\ArtworkAiAssistEvent;
use App\Models\Category;
use App\Models\ContentType;
use App\Models\User;
use App\Services\Studio\StudioAiAssistService;
use Carbon\Carbon;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Queue;
use function Pest\Laravel\actingAs;
use function Pest\Laravel\getJson;
use function Pest\Laravel\postJson;
use function Pest\Laravel\putJson;
it('returns a not-analyzed payload for artworks without ai assist data', function (): void {
$user = User::factory()->create();
$artwork = Artwork::factory()->create(['user_id' => $user->id]);
actingAs($user);
getJson('/api/studio/artworks/' . $artwork->id . '/ai')
->assertOk()
->assertJsonPath('data.status', 'not_analyzed')
->assertJsonPath('data.title_suggestions', [])
->assertJsonPath('data.current.sources.title', 'manual');
});
it('queues artwork ai analysis from the studio endpoint', function (): void {
Queue::fake();
$user = User::factory()->create();
$artwork = Artwork::factory()->create([
'user_id' => $user->id,
'hash' => 'aabbcc112233',
]);
actingAs($user);
postJson('/api/studio/artworks/' . $artwork->id . '/ai/analyze')
->assertStatus(202)
->assertJsonPath('status', ArtworkAiAssist::STATUS_QUEUED);
$this->assertDatabaseHas('artwork_ai_assists', [
'artwork_id' => $artwork->id,
'status' => ArtworkAiAssist::STATUS_QUEUED,
]);
Queue::assertPushed(AnalyzeArtworkAiAssistJob::class, function (AnalyzeArtworkAiAssistJob $job): bool {
return true;
});
});
it('can analyze artwork ai suggestions directly without queueing', function (): void {
Queue::fake();
config()->set('vision.enabled', true);
config()->set('vision.gateway.base_url', 'https://vision.local');
config()->set('cdn.files_url', 'https://files.local');
config()->set('vision.image_variant', 'md');
$photography = ContentType::query()->create([
'name' => 'Photography',
'slug' => 'photography',
]);
Category::query()->create([
'content_type_id' => $photography->id,
'name' => 'Flowers',
'slug' => 'flowers',
'is_active' => true,
]);
$user = User::factory()->create();
$artwork = Artwork::factory()->create([
'user_id' => $user->id,
'hash' => 'syncaa112233',
'file_name' => 'rose-closeup.jpg',
]);
Http::fake([
'https://vision.local/analyze/all' => Http::response([
'clip' => [
['tag' => 'rose', 'confidence' => 0.96],
['tag' => 'flower', 'confidence' => 0.91],
],
'yolo' => [
['label' => 'flower', 'confidence' => 0.79],
],
'blip' => 'a close up photograph of a rose bud with soft natural background',
], 200),
]);
actingAs($user);
postJson('/api/studio/artworks/' . $artwork->id . '/ai/analyze', [
'direct' => true,
'intent' => 'title',
])
->assertOk()
->assertJsonPath('direct', true)
->assertJsonPath('status', ArtworkAiAssist::STATUS_READY)
->assertJsonPath('data.status', ArtworkAiAssist::STATUS_READY)
->assertJsonPath('data.debug.request.hash', 'syncaa112233')
->assertJsonPath('data.debug.request.intent', 'title')
->assertJsonPath('data.debug.vision_debug.image_url', 'https://files.local/md/sy/nc/syncaa112233.webp')
->assertJsonPath('data.debug.vision_debug.calls.0.request.image_url', 'https://files.local/md/sy/nc/syncaa112233.webp')
->assertJsonPath('data.debug.vision_debug.calls.0.service', 'gateway_all');
Queue::assertNotPushed(AnalyzeArtworkAiAssistJob::class);
$this->assertDatabaseHas('artwork_ai_assist_events', [
'artwork_id' => $artwork->id,
'event_type' => 'analysis_requested',
]);
$completedEvent = ArtworkAiAssistEvent::query()
->where('artwork_id', $artwork->id)
->where('event_type', 'analysis_completed')
->first();
expect($completedEvent)->not->toBeNull();
expect($completedEvent?->meta['intent'] ?? null)->toBe('title');
});
it('persists upload-style visibility options from studio save', function (): void {
$user = User::factory()->create();
$artwork = Artwork::factory()->create([
'user_id' => $user->id,
'visibility' => Artwork::VISIBILITY_PUBLIC,
'is_public' => true,
'artwork_status' => 'published',
'published_at' => now()->subHour(),
]);
actingAs($user);
putJson('/api/studio/artworks/' . $artwork->id, [
'visibility' => Artwork::VISIBILITY_UNLISTED,
'mode' => 'now',
])
->assertOk()
->assertJsonPath('artwork.visibility', Artwork::VISIBILITY_UNLISTED)
->assertJsonPath('artwork.publish_mode', 'now');
$artwork->refresh();
expect($artwork->visibility)->toBe(Artwork::VISIBILITY_UNLISTED)
->and($artwork->is_public)->toBeTrue()
->and($artwork->artwork_status)->toBe('published');
});
it('can schedule publishing from studio save', function (): void {
Carbon::setTestNow('2026-03-28 12:00:00');
try {
$user = User::factory()->create();
$artwork = Artwork::factory()->private()->create([
'user_id' => $user->id,
'artwork_status' => 'draft',
'published_at' => null,
'publish_at' => null,
]);
actingAs($user);
putJson('/api/studio/artworks/' . $artwork->id, [
'visibility' => Artwork::VISIBILITY_PUBLIC,
'mode' => 'schedule',
'publish_at' => '2026-03-28T12:10:00Z',
'timezone' => 'Europe/Ljubljana',
])
->assertOk()
->assertJsonPath('artwork.publish_mode', 'schedule')
->assertJsonPath('artwork.visibility', Artwork::VISIBILITY_PUBLIC)
->assertJsonPath('artwork.artwork_status', 'scheduled');
$artwork->refresh();
expect($artwork->visibility)->toBe(Artwork::VISIBILITY_PUBLIC)
->and($artwork->is_public)->toBeFalse()
->and($artwork->artwork_status)->toBe('scheduled')
->and($artwork->artwork_timezone)->toBe('Europe/Ljubljana')
->and($artwork->publish_at?->toIso8601String())->toBe('2026-03-28T12:10:00+00:00')
->and($artwork->published_at)->toBeNull();
} finally {
Carbon::setTestNow();
}
});
it('can analyze artwork directly when exact and vector similar matches are both present', function (): void {
Queue::fake();
config()->set('vision.enabled', true);
config()->set('vision.gateway.base_url', 'https://vision.local');
config()->set('vision.vector_gateway.enabled', true);
config()->set('vision.vector_gateway.base_url', 'https://vector.local');
config()->set('vision.vector_gateway.api_key', 'test-key');
config()->set('cdn.files_url', 'https://files.local');
$photography = ContentType::query()->create([
'name' => 'Photography',
'slug' => 'photography',
]);
Category::query()->create([
'content_type_id' => $photography->id,
'name' => 'Flowers',
'slug' => 'flowers',
'is_active' => true,
]);
$user = User::factory()->create();
$artwork = Artwork::factory()->create([
'user_id' => $user->id,
'hash' => 'mixaa112233',
'file_name' => 'rose-closeup.jpg',
]);
$exactMatch = Artwork::factory()->create([
'user_id' => $user->id,
'hash' => 'mixaa112233',
'title' => 'Exact match artwork',
]);
$vectorMatch = Artwork::factory()->create([
'title' => 'Vector match artwork',
]);
Http::fake([
'https://vision.local/analyze/all' => Http::response([
'clip' => [
['tag' => 'rose', 'confidence' => 0.96],
['tag' => 'flower', 'confidence' => 0.91],
],
'yolo' => [
['label' => 'flower', 'confidence' => 0.79],
],
'blip' => 'a close up photograph of a rose bud with soft natural background',
], 200),
'https://vector.local/vectors/search' => Http::response([
'matches' => [
[
'id' => (string) $vectorMatch->id,
'score' => 0.88,
'metadata' => [],
],
],
], 200),
]);
actingAs($user);
postJson('/api/studio/artworks/' . $artwork->id . '/ai/analyze', [
'direct' => true,
])
->assertOk()
->assertJsonPath('direct', true)
->assertJsonPath('status', ArtworkAiAssist::STATUS_READY)
->assertJsonPath('data.status', ArtworkAiAssist::STATUS_READY)
->assertJsonCount(2, 'data.similar_candidates')
->assertJsonPath('data.similar_candidates.0.artwork_id', $exactMatch->id)
->assertJsonPath('data.similar_candidates.1.artwork_id', $vectorMatch->id);
Queue::assertNotPushed(AnalyzeArtworkAiAssistJob::class);
});
it('builds and exposes normalized studio ai suggestions', function (): void {
config()->set('vision.enabled', true);
config()->set('vision.gateway.base_url', 'https://vision.local');
config()->set('cdn.files_url', 'https://files.local');
$photography = ContentType::query()->create([
'name' => 'Photography',
'slug' => 'photography',
]);
Category::query()->create([
'content_type_id' => $photography->id,
'name' => 'Flowers',
'slug' => 'flowers',
'is_active' => true,
]);
$user = User::factory()->create();
$artwork = Artwork::factory()->create([
'user_id' => $user->id,
'hash' => 'ddeeff112233',
'file_name' => 'rose-closeup.jpg',
'title' => 'Untitled Rose',
]);
Http::fake([
'https://vision.local/analyze/all' => Http::response([
'clip' => [
['tag' => 'rose', 'confidence' => 0.96],
['tag' => 'flower', 'confidence' => 0.91],
['tag' => 'macro', 'confidence' => 0.72],
],
'yolo' => [
['label' => 'flower', 'confidence' => 0.79],
],
'blip' => 'a close up photograph of a rose bud with soft natural background',
], 200),
]);
app(StudioAiAssistService::class)->analyze($artwork->fresh(), false);
actingAs($user);
getJson('/api/studio/artworks/' . $artwork->id . '/ai')
->assertOk()
->assertJsonPath('data.status', ArtworkAiAssist::STATUS_READY)
->assertJsonPath('data.mode', 'artwork')
->assertJsonPath('data.content_type.value', 'photography')
->assertJsonPath('data.category.value', 'flowers')
->assertJson(fn ($json) => $json
->has('data.title_suggestions', 5)
->where('data.tag_suggestions.0.tag', 'rose')
->has('data.description_suggestions', 3));
});
it('applies ai suggestions to artwork fields and tracks ai sources', function (): void {
$photography = ContentType::query()->create([
'name' => 'Photography',
'slug' => 'photography',
]);
$flowers = Category::query()->create([
'content_type_id' => $photography->id,
'name' => 'Flowers',
'slug' => 'flowers',
'is_active' => true,
]);
$user = User::factory()->create();
$artwork = Artwork::factory()->create([
'user_id' => $user->id,
'title' => 'Original title',
'description' => 'Original description.',
]);
ArtworkAiAssist::query()->create([
'artwork_id' => $artwork->id,
'status' => ArtworkAiAssist::STATUS_READY,
'similar_candidates_json' => [
[
'artwork_id' => 998,
'title' => 'Possible duplicate',
'match_type' => 'exact_hash',
'score' => 1,
'review_state' => null,
],
],
]);
actingAs($user);
postJson('/api/studio/artworks/' . $artwork->id . '/ai/apply', [
'title' => 'Rose Bud in Soft Focus',
'description' => 'A close-up photograph of a rose bud with a soft floral backdrop.',
'tags' => ['rose', 'macro'],
'tag_mode' => 'replace',
'category_id' => $flowers->id,
'similar_actions' => [
['artwork_id' => 998, 'state' => 'reviewed'],
],
])
->assertOk()
->assertJsonPath('data.status', ArtworkAiAssist::STATUS_READY)
->assertJsonPath('data.current.sources.title', 'ai_applied')
->assertJsonPath('data.current.sources.tags', 'ai_applied');
$this->assertDatabaseHas('artworks', [
'id' => $artwork->id,
'title' => 'Rose Bud in Soft Focus',
'title_source' => 'ai_applied',
'description_source' => 'ai_applied',
'tags_source' => 'ai_applied',
'category_source' => 'ai_applied',
]);
$this->assertDatabaseHas('artwork_tag', [
'artwork_id' => $artwork->id,
'source' => 'ai',
]);
expect(ArtworkAiAssist::query()->where('artwork_id', $artwork->id)->first()?->similar_candidates_json[0]['review_state'])
->toBe('reviewed');
expect(ArtworkAiAssistEvent::query()
->where('artwork_id', $artwork->id)
->where('event_type', 'suggestions_applied')
->exists())
->toBeTrue();
expect(ArtworkAiAssistEvent::query()
->where('artwork_id', $artwork->id)
->where('event_type', 'title_suggestion_applied')
->exists())
->toBeTrue();
expect(ArtworkAiAssistEvent::query()
->where('artwork_id', $artwork->id)
->where('event_type', 'description_suggestion_applied')
->exists())
->toBeTrue();
expect(ArtworkAiAssistEvent::query()
->where('artwork_id', $artwork->id)
->where('event_type', 'tags_suggestion_applied')
->exists())
->toBeTrue();
expect(ArtworkAiAssistEvent::query()
->where('artwork_id', $artwork->id)
->where('event_type', 'category_suggestion_applied')
->exists())
->toBeTrue();
expect(ArtworkAiAssistEvent::query()
->where('artwork_id', $artwork->id)
->where('event_type', 'duplicate_candidate_reviewed')
->exists())
->toBeTrue();
});
it('applies ai content type suggestions by resolving a default category', function (): void {
$photography = ContentType::query()->create([
'name' => 'Photography',
'slug' => 'photography',
]);
$rootCategory = Category::query()->create([
'content_type_id' => $photography->id,
'name' => 'Photography',
'slug' => 'photography-root',
'is_active' => true,
'sort_order' => 1,
]);
$user = User::factory()->create();
$artwork = Artwork::factory()->create([
'user_id' => $user->id,
]);
ArtworkAiAssist::query()->create([
'artwork_id' => $artwork->id,
'status' => ArtworkAiAssist::STATUS_READY,
]);
actingAs($user);
postJson('/api/studio/artworks/' . $artwork->id . '/ai/apply', [
'content_type_id' => $photography->id,
])
->assertOk()
->assertJsonPath('data.current.content_type_id', $photography->id)
->assertJsonPath('data.current.category_id', $rootCategory->id)
->assertJsonPath('data.current.sources.category', 'ai_applied');
expect($artwork->fresh()->categories()->pluck('categories.id')->all())
->toBe([$rootCategory->id]);
expect(ArtworkAiAssistEvent::query()
->where('artwork_id', $artwork->id)
->where('event_type', 'content_type_suggestion_applied')
->exists())
->toBeTrue();
});
it('updates studio artworks with a content type when no category is provided', function (): void {
$photography = ContentType::query()->create([
'name' => 'Photography',
'slug' => 'photography',
]);
$rootCategory = Category::query()->create([
'content_type_id' => $photography->id,
'name' => 'Photography',
'slug' => 'photography-default',
'is_active' => true,
'sort_order' => 1,
]);
$user = User::factory()->create();
$artwork = Artwork::factory()->create([
'user_id' => $user->id,
'title' => 'Existing title',
]);
actingAs($user);
putJson('/api/studio/artworks/' . $artwork->id, [
'content_type_id' => $photography->id,
'category_source' => 'ai_applied',
])
->assertOk()
->assertJsonPath('artwork.content_type_id', $photography->id)
->assertJsonPath('artwork.category_id', $rootCategory->id)
->assertJsonPath('artwork.category_source', 'ai_applied');
expect($artwork->fresh()->categories()->pluck('categories.id')->all())
->toBe([$rootCategory->id]);
});
it('removes previously attached ai tags when studio save sends a smaller visible tag set', function (): void {
$user = User::factory()->create();
$artwork = Artwork::factory()->create([
'user_id' => $user->id,
'tags_source' => 'mixed',
]);
$tagService = app(\App\Services\TagService::class);
$tagService->attachAiTags($artwork, [
['tag' => 'rose'],
['tag' => 'macro'],
]);
actingAs($user);
putJson('/api/studio/artworks/' . $artwork->id, [
'tags' => ['rose'],
'tags_source' => 'mixed',
])
->assertOk()
->assertJsonPath('artwork.tags.0.slug', 'rose');
expect($artwork->fresh()->tags()->pluck('tags.slug')->sort()->values()->all())
->toBe(['rose']);
});

View File

@@ -0,0 +1,182 @@
<?php
declare(strict_types=1);
use App\Jobs\AutoTagArtworkJob;
use App\Jobs\GenerateArtworkEmbeddingJob;
use App\Models\Artwork;
use App\Models\User;
use App\Repositories\Uploads\UploadSessionRepository;
use App\Services\Uploads\UploadSessionStatus;
use App\Services\Uploads\UploadTokenService;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\File;
use Illuminate\Support\Facades\Queue;
use Illuminate\Support\Str;
uses(RefreshDatabase::class);
it('dispatches AI processing jobs after upload finish publishes successfully', function () {
config()->set('forum_bot_protection.enabled', false);
config()->set('uploads.queue_derivatives', false);
config()->set('uploads.storage_root', storage_path('framework/testing/uploads'));
Queue::fake();
File::deleteDirectory((string) config('uploads.storage_root'));
$user = User::factory()->create();
$artwork = Artwork::factory()->create([
'user_id' => $user->id,
'is_public' => false,
'is_approved' => false,
'published_at' => null,
]);
$sessionId = (string) Str::uuid();
$tmpPath = storage_path('framework/testing/uploads/tmp/' . $sessionId . '.png');
$sourceImage = base_path('public/favicon/favicon-96x96.png');
File::ensureDirectoryExists(dirname($tmpPath));
File::copy($sourceImage, $tmpPath);
app(UploadSessionRepository::class)->create(
$sessionId,
$user->id,
$tmpPath,
UploadSessionStatus::TMP,
'127.0.0.1'
);
$token = app(UploadTokenService::class)->generate($sessionId, $user->id);
$response = $this->actingAs($user)
->withHeader('X-Upload-Token', $token)
->postJson('/api/uploads/finish', [
'session_id' => $sessionId,
'artwork_id' => $artwork->id,
'file_name' => 'example.png',
]);
$response->assertOk();
$response->assertJsonPath('artwork_id', $artwork->id);
$response->assertJsonPath('status', UploadSessionStatus::PROCESSED);
Queue::assertPushed(AutoTagArtworkJob::class, 1);
Queue::assertPushed(GenerateArtworkEmbeddingJob::class, 1);
$artwork->refresh();
expect($artwork->hash)->not->toBe('')
->and($artwork->thumb_ext)->toBe('webp')
->and($artwork->width)->toBeGreaterThan(0)
->and($artwork->height)->toBeGreaterThan(0);
expect(File::exists((string) $tmpPath))->toBeTrue();
});
it('blocks upload finish only when the hash already belongs to a published artwork', function () {
config()->set('forum_bot_protection.enabled', false);
config()->set('uploads.queue_derivatives', false);
config()->set('uploads.storage_root', storage_path('framework/testing/uploads'));
File::deleteDirectory((string) config('uploads.storage_root'));
$user = User::factory()->create();
$publishedArtwork = Artwork::factory()->create([
'user_id' => $user->id,
'hash' => hash_file('sha256', base_path('public/favicon/favicon-96x96.png')),
'artwork_status' => 'published',
'published_at' => now()->subMinute(),
'is_public' => true,
]);
$draftArtwork = Artwork::factory()->create([
'user_id' => $user->id,
'is_public' => false,
'is_approved' => false,
'published_at' => null,
]);
$sessionId = (string) Str::uuid();
$tmpPath = storage_path('framework/testing/uploads/tmp/' . $sessionId . '.png');
$sourceImage = base_path('public/favicon/favicon-96x96.png');
File::ensureDirectoryExists(dirname($tmpPath));
File::copy($sourceImage, $tmpPath);
app(UploadSessionRepository::class)->create(
$sessionId,
$user->id,
$tmpPath,
UploadSessionStatus::TMP,
'127.0.0.1'
);
$token = app(UploadTokenService::class)->generate($sessionId, $user->id);
$response = $this->actingAs($user)
->withHeader('X-Upload-Token', $token)
->postJson('/api/uploads/finish', [
'session_id' => $sessionId,
'artwork_id' => $draftArtwork->id,
'file_name' => 'example.png',
]);
$response->assertStatus(409)
->assertJsonPath('reason', 'duplicate_hash');
expect($publishedArtwork->hash)->not->toBe('');
});
it('allows upload finish when the same hash exists only on an unpublished artwork', function () {
config()->set('forum_bot_protection.enabled', false);
config()->set('uploads.queue_derivatives', false);
config()->set('uploads.storage_root', storage_path('framework/testing/uploads'));
Queue::fake();
File::deleteDirectory((string) config('uploads.storage_root'));
$user = User::factory()->create();
$hash = hash_file('sha256', base_path('public/favicon/favicon-96x96.png'));
Artwork::factory()->create([
'user_id' => $user->id,
'hash' => $hash,
'artwork_status' => 'draft',
'published_at' => null,
'is_public' => false,
'is_approved' => false,
]);
$draftArtwork = Artwork::factory()->create([
'user_id' => $user->id,
'is_public' => false,
'is_approved' => false,
'published_at' => null,
]);
$sessionId = (string) Str::uuid();
$tmpPath = storage_path('framework/testing/uploads/tmp/' . $sessionId . '.png');
$sourceImage = base_path('public/favicon/favicon-96x96.png');
File::ensureDirectoryExists(dirname($tmpPath));
File::copy($sourceImage, $tmpPath);
app(UploadSessionRepository::class)->create(
$sessionId,
$user->id,
$tmpPath,
UploadSessionStatus::TMP,
'127.0.0.1'
);
$token = app(UploadTokenService::class)->generate($sessionId, $user->id);
$response = $this->actingAs($user)
->withHeader('X-Upload-Token', $token)
->postJson('/api/uploads/finish', [
'session_id' => $sessionId,
'artwork_id' => $draftArtwork->id,
'file_name' => 'example.png',
]);
$response->assertOk()
->assertJsonPath('artwork_id', $draftArtwork->id)
->assertJsonPath('status', UploadSessionStatus::PROCESSED);
});

View File

@@ -1,5 +1,6 @@
<?php
use App\Models\Artwork;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Http\UploadedFile;
@@ -107,13 +108,14 @@ it('blocks duplicate hash when policy is block', function () {
$main = UploadedFile::fake()->image('dupe.jpg', 400, 400);
$hash = hash_file('sha256', $main->getPathname());
$publishedUploadId = createUploadRowForQuota($owner->id, [
'status' => 'published',
Artwork::factory()->create([
'user_id' => $owner->id,
'hash' => $hash,
'artwork_status' => 'published',
'published_at' => now()->subMinute(),
'is_public' => true,
]);
attachMainUploadFileForQuota($publishedUploadId, (int) $main->getSize(), $hash);
$response = $this->actingAs($uploader)->postJson('/api/uploads/preload', [
'main' => $main,
]);
@@ -136,13 +138,14 @@ it('allows duplicate hash and returns warning when policy is warn', function ()
$main = UploadedFile::fake()->image('dupe-warn.jpg', 400, 400);
$hash = hash_file('sha256', $main->getPathname());
$publishedUploadId = createUploadRowForQuota($owner->id, [
'status' => 'published',
Artwork::factory()->create([
'user_id' => $owner->id,
'hash' => $hash,
'artwork_status' => 'published',
'published_at' => now()->subMinute(),
'is_public' => true,
]);
attachMainUploadFileForQuota($publishedUploadId, (int) $main->getSize(), $hash);
$response = $this->actingAs($uploader)->postJson('/api/uploads/preload', [
'main' => $main,
]);
@@ -175,6 +178,37 @@ it('does not count published uploads as drafts', function () {
]);
});
it('ignores duplicate hashes that exist only in temporary upload tables', function () {
Storage::fake('local');
config([
'uploads.draft_quota.max_drafts_per_user' => 20,
'uploads.draft_quota.duplicate_hash_policy' => 'block',
]);
$owner = User::factory()->create();
$uploader = User::factory()->create();
$main = UploadedFile::fake()->image('temp-only-dupe.jpg', 400, 400);
$hash = hash_file('sha256', $main->getPathname());
$publishedUploadId = createUploadRowForQuota($owner->id, [
'status' => 'published',
'published_at' => now()->subMinute(),
]);
attachMainUploadFileForQuota($publishedUploadId, (int) $main->getSize(), $hash);
$response = $this->actingAs($uploader)->postJson('/api/uploads/preload', [
'main' => $main,
]);
$response->assertOk()->assertJsonStructure([
'upload_id',
'status',
'expires_at',
]);
});
it('returns stable machine codes for quota errors', function () {
Storage::fake('local');
config(['uploads.draft_quota.max_drafts_per_user' => 1]);

View File

@@ -0,0 +1,110 @@
<?php
declare(strict_types=1);
use App\Models\Artwork;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Storage;
use function Pest\Laravel\actingAs;
use function Pest\Laravel\getJson;
use function Pest\Laravel\postJson;
uses(RefreshDatabase::class);
beforeEach(function (): void {
config()->set('vision.vector_gateway.enabled', true);
config()->set('vision.vector_gateway.base_url', 'https://vision.klevze.net');
config()->set('vision.vector_gateway.api_key', 'test-key');
config()->set('vision.vector_gateway.search_endpoint', '/vectors/search');
config()->set('cdn.files_url', 'https://files.skinbase.org');
config()->set('app.url', 'https://skinbase.test');
Storage::fake('public');
});
it('returns AI similar artworks for a public artwork', function (): void {
$source = Artwork::factory()->create([
'title' => 'Source artwork',
'hash' => 'aabbcc112233',
'thumb_ext' => 'webp',
'is_public' => true,
'is_approved' => true,
'published_at' => now()->subHour(),
]);
$match = Artwork::factory()->create([
'title' => 'AI match',
'hash' => 'ddeeff445566',
'thumb_ext' => 'webp',
'is_public' => true,
'is_approved' => true,
'published_at' => now()->subHour(),
]);
Http::fake([
'https://vision.klevze.net/vectors/search' => Http::response([
'results' => [
['id' => $source->id, 'score' => 1.0],
['id' => $match->id, 'score' => 0.91234],
],
], 200),
]);
$response = getJson('/api/art/' . $source->id . '/similar-ai');
$response->assertOk()
->assertJsonPath('data.0.id', $match->id)
->assertJsonPath('data.0.source', 'vector_gateway')
->assertJsonPath('meta.artwork_id', $source->id)
->assertJsonCount(1, 'data');
});
it('returns 404 for missing similar-ai source artwork', function (): void {
getJson('/api/art/999999/similar-ai')
->assertStatus(404)
->assertJsonPath('error', 'Artwork not found');
});
it('searches by uploaded image through the vector gateway', function (): void {
$user = User::factory()->create();
$match = Artwork::factory()->create([
'title' => 'Reverse image match',
'hash' => '112233445566',
'thumb_ext' => 'webp',
'is_public' => true,
'is_approved' => true,
'published_at' => now()->subHour(),
]);
Http::fake([
'https://vision.klevze.net/vectors/search' => Http::response([
'results' => [
['id' => $match->id, 'score' => 0.88765],
],
], 200),
]);
actingAs($user);
$response = postJson('/api/search/image', [
'image' => UploadedFile::fake()->image('query.png', 640, 480),
'limit' => 12,
]);
$response->assertOk()
->assertJsonPath('data.0.id', $match->id)
->assertJsonPath('data.0.source', 'vector_gateway')
->assertJsonPath('meta.limit', 12);
Http::assertSent(function ($request): bool {
$payload = json_decode($request->body(), true);
return $request->url() === 'https://vision.klevze.net/vectors/search'
&& $request->hasHeader('X-API-Key', 'test-key')
&& is_array($payload)
&& str_contains((string) ($payload['url'] ?? ''), '/storage/ai-search/tmp/')
&& ($payload['limit'] ?? null) === 12;
});
});

View File

@@ -3,8 +3,10 @@
declare(strict_types=1);
use App\Models\Artwork;
use App\Models\ArtworkEmbedding;
use App\Models\Category;
use App\Models\ContentType;
use App\Models\Tag;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Http;
use function Pest\Laravel\artisan;
@@ -43,6 +45,8 @@ it('indexes artworks into the vector gateway with artwork metadata', function ()
'thumb_ext' => 'webp',
]);
$artwork->categories()->attach($category->id);
$tag = Tag::query()->create(['name' => 'Skyline', 'slug' => 'skyline']);
$artwork->tags()->attach($tag->id, ['source' => 'ai', 'confidence' => 0.88]);
Http::fake([
'https://vision.klevze.net/vectors/upsert' => Http::response(['ok' => true], 200),
@@ -51,6 +55,9 @@ it('indexes artworks into the vector gateway with artwork metadata', function ()
artisan('artworks:vectors-index', ['--limit' => 1])
->assertSuccessful();
$artwork->refresh();
expect($artwork->last_vector_indexed_at)->not->toBeNull();
Http::assertSent(function ($request) use ($artwork): bool {
if ($request->url() !== 'https://vision.klevze.net/vectors/upsert') {
return false;
@@ -63,7 +70,8 @@ it('indexes artworks into the vector gateway with artwork metadata', function ()
&& ($payload['id'] ?? null) === (string) $artwork->id
&& ($payload['url'] ?? null) === 'https://files.skinbase.org/md/aa/bb/aabbcc112233.webp'
&& ($payload['metadata']['content_type'] ?? null) === 'Photography'
&& ($payload['metadata']['category'] ?? null) === 'Abstract';
&& ($payload['metadata']['category'] ?? null) === 'Abstract'
&& ($payload['metadata']['tags'] ?? null) === ['skyline'];
});
});
@@ -120,3 +128,78 @@ it('searches similar artworks through the vector gateway', function (): void {
]])
->assertSuccessful();
});
it('can re-upsert only artworks that already have local embeddings', function (): void {
$contentType = ContentType::query()->create([
'name' => 'Photography',
'slug' => 'photography',
'description' => '',
]);
$category = Category::query()->create([
'content_type_id' => $contentType->id,
'parent_id' => null,
'name' => 'Portraits',
'slug' => 'portraits',
'description' => '',
'is_active' => true,
'sort_order' => 0,
]);
$embeddedArtwork = Artwork::factory()->create([
'hash' => '112233445566',
'thumb_ext' => 'webp',
'is_public' => true,
'is_approved' => true,
'published_at' => now()->subDay(),
]);
$embeddedArtwork->categories()->attach($category->id);
ArtworkEmbedding::query()->create([
'artwork_id' => $embeddedArtwork->id,
'model' => 'clip',
'model_version' => 'v1',
'algo_version' => 'clip-cosine-v1',
'dim' => 2,
'embedding_json' => json_encode([0.6, 0.8], JSON_THROW_ON_ERROR),
'source_hash' => '112233445566',
'is_normalized' => true,
'generated_at' => now(),
'meta' => ['source' => 'clip'],
]);
$nonEmbeddedArtwork = Artwork::factory()->create([
'hash' => 'aabbccddeeff',
'thumb_ext' => 'webp',
'is_public' => true,
'is_approved' => true,
'published_at' => now()->subDay(),
]);
$nonEmbeddedArtwork->categories()->attach($category->id);
Http::fake([
'https://vision.klevze.net/vectors/upsert' => Http::response(['ok' => true], 200),
]);
artisan('artworks:vectors-index', ['--embedded-only' => true, '--limit' => 10])
->assertSuccessful();
$embeddedArtwork->refresh();
$nonEmbeddedArtwork->refresh();
expect($embeddedArtwork->last_vector_indexed_at)->not->toBeNull()
->and($nonEmbeddedArtwork->last_vector_indexed_at)->toBeNull();
Http::assertSentCount(1);
Http::assertSent(function ($request) use ($embeddedArtwork): bool {
if ($request->url() !== 'https://vision.klevze.net/vectors/upsert') {
return false;
}
$payload = json_decode($request->body(), true);
return is_array($payload)
&& ($payload['id'] ?? null) === (string) $embeddedArtwork->id
&& ($payload['url'] ?? null) === 'https://files.skinbase.org/md/11/22/112233445566.webp';
});
});

View File

@@ -0,0 +1,153 @@
<?php
declare(strict_types=1);
use App\Jobs\BackfillArtworkVectorIndexJob;
use App\Jobs\SyncArtworkVectorIndexJob;
use App\Models\Artwork;
use App\Models\ArtworkEmbedding;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Queue;
use function Pest\Laravel\artisan;
uses(RefreshDatabase::class);
function jobProperty(object $job, string $property): mixed
{
$reflection = new ReflectionProperty($job, $property);
$reflection->setAccessible(true);
return $reflection->getValue($job);
}
it('queues a resumable vector repair run from the command', function (): void {
Queue::fake();
artisan('artworks:vectors-repair', [
'--after-id' => 25,
'--batch' => 50,
'--public-only' => true,
'--stale-hours' => 24,
])->assertSuccessful();
Queue::assertPushed(BackfillArtworkVectorIndexJob::class, function (BackfillArtworkVectorIndexJob $job): bool {
return jobProperty($job, 'afterId') === 25
&& jobProperty($job, 'batchSize') === 50
&& jobProperty($job, 'publicOnly') === true
&& jobProperty($job, 'staleHours') === 24;
});
});
it('fans out queued vector repair only for artworks with local embeddings', function (): void {
Queue::fake();
$embeddedArtwork = Artwork::factory()->create([
'hash' => '1122334455667788',
'is_public' => true,
'is_approved' => true,
'published_at' => now()->subDay(),
]);
ArtworkEmbedding::query()->create([
'artwork_id' => $embeddedArtwork->id,
'model' => 'clip',
'model_version' => 'v1',
'algo_version' => 'clip-cosine-v1',
'dim' => 2,
'embedding_json' => json_encode([0.6, 0.8], JSON_THROW_ON_ERROR),
'source_hash' => '1122334455667788',
'is_normalized' => true,
'generated_at' => now(),
'meta' => ['source' => 'clip'],
]);
Artwork::factory()->create([
'hash' => 'aabbccddeeff0011',
'is_public' => true,
'is_approved' => true,
'published_at' => now()->subDay(),
]);
(new BackfillArtworkVectorIndexJob(0, 100, true))->handle();
Queue::assertPushed(SyncArtworkVectorIndexJob::class, function (SyncArtworkVectorIndexJob $job) use ($embeddedArtwork): bool {
return jobProperty($job, 'artworkId') === $embeddedArtwork->id;
});
Queue::assertNotPushed(BackfillArtworkVectorIndexJob::class);
});
it('can target only stale or never-indexed artworks during queued repair', function (): void {
Queue::fake();
$staleArtwork = Artwork::factory()->create([
'hash' => '1111222233334444',
'is_public' => true,
'is_approved' => true,
'published_at' => now()->subDay(),
'last_vector_indexed_at' => now()->subHours(48),
]);
ArtworkEmbedding::query()->create([
'artwork_id' => $staleArtwork->id,
'model' => 'clip',
'model_version' => 'v1',
'algo_version' => 'clip-cosine-v1',
'dim' => 2,
'embedding_json' => json_encode([0.6, 0.8], JSON_THROW_ON_ERROR),
'source_hash' => '1111222233334444',
'is_normalized' => true,
'generated_at' => now(),
'meta' => ['source' => 'clip'],
]);
$missingArtwork = Artwork::factory()->create([
'hash' => '5555666677778888',
'is_public' => true,
'is_approved' => true,
'published_at' => now()->subDay(),
'last_vector_indexed_at' => null,
]);
ArtworkEmbedding::query()->create([
'artwork_id' => $missingArtwork->id,
'model' => 'clip',
'model_version' => 'v1',
'algo_version' => 'clip-cosine-v1',
'dim' => 2,
'embedding_json' => json_encode([0.6, 0.8], JSON_THROW_ON_ERROR),
'source_hash' => '5555666677778888',
'is_normalized' => true,
'generated_at' => now(),
'meta' => ['source' => 'clip'],
]);
$freshArtwork = Artwork::factory()->create([
'hash' => '9999aaaabbbbcccc',
'is_public' => true,
'is_approved' => true,
'published_at' => now()->subDay(),
'last_vector_indexed_at' => now()->subHours(2),
]);
ArtworkEmbedding::query()->create([
'artwork_id' => $freshArtwork->id,
'model' => 'clip',
'model_version' => 'v1',
'algo_version' => 'clip-cosine-v1',
'dim' => 2,
'embedding_json' => json_encode([0.6, 0.8], JSON_THROW_ON_ERROR),
'source_hash' => '9999aaaabbbbcccc',
'is_normalized' => true,
'generated_at' => now(),
'meta' => ['source' => 'clip'],
]);
(new BackfillArtworkVectorIndexJob(0, 100, true, 24))->handle();
Queue::assertPushed(SyncArtworkVectorIndexJob::class, function (SyncArtworkVectorIndexJob $job) use ($staleArtwork): bool {
return jobProperty($job, 'artworkId') === $staleArtwork->id;
});
Queue::assertPushed(SyncArtworkVectorIndexJob::class, function (SyncArtworkVectorIndexJob $job) use ($missingArtwork): bool {
return jobProperty($job, 'artworkId') === $missingArtwork->id;
});
Queue::assertNotPushed(SyncArtworkVectorIndexJob::class, function (SyncArtworkVectorIndexJob $job) use ($freshArtwork): bool {
return jobProperty($job, 'artworkId') === $freshArtwork->id;
});
Queue::assertNotPushed(BackfillArtworkVectorIndexJob::class);
});

View File

@@ -0,0 +1,141 @@
<?php
declare(strict_types=1);
use App\Jobs\GenerateArtworkEmbeddingJob;
use App\Models\Artwork;
use App\Models\ArtworkEmbedding;
use App\Models\Category;
use App\Models\ContentType;
use App\Models\Tag;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Http;
uses(RefreshDatabase::class);
it('persists a normalized embedding and upserts the artwork to the vector gateway', function () {
config()->set('recommendations.embedding.enabled', true);
config()->set('recommendations.embedding.endpoint', '/embed');
config()->set('recommendations.embedding.min_dim', 2);
config()->set('vision.clip.base_url', 'https://clip.local');
config()->set('vision.vector_gateway.enabled', true);
config()->set('vision.vector_gateway.base_url', 'https://vision.local');
config()->set('vision.vector_gateway.api_key', 'test-key');
config()->set('vision.vector_gateway.upsert_endpoint', '/vectors/upsert');
config()->set('cdn.files_url', 'https://files.local');
Http::fake([
'https://clip.local/embed' => Http::response([
'embedding' => [3.0, 4.0],
], 200),
'https://vision.local/vectors/upsert' => Http::response([
'status' => 'ok',
], 200),
]);
$contentType = ContentType::query()->create([
'name' => 'Wallpapers',
'slug' => 'wallpapers',
'description' => '',
]);
$category = Category::query()->create([
'content_type_id' => $contentType->id,
'parent_id' => null,
'name' => 'Abstract',
'slug' => 'abstract',
'description' => '',
'image' => null,
'is_active' => true,
'sort_order' => 10,
]);
$artwork = Artwork::factory()->create([
'hash' => 'aabbccddeeff1122',
'thumb_ext' => 'webp',
'is_public' => true,
'is_approved' => true,
'published_at' => now()->subDay(),
]);
$artwork->categories()->attach($category->id);
$tag = Tag::query()->create(['name' => 'Neon', 'slug' => 'neon']);
$artwork->tags()->attach($tag->id, ['source' => 'ai', 'confidence' => 0.91]);
$job = new GenerateArtworkEmbeddingJob($artwork->id, 'aabbccddeeff1122');
$job->handle(
app(\App\Services\Vision\ArtworkEmbeddingClient::class),
app(\App\Services\Vision\ArtworkVisionImageUrl::class),
app(\App\Services\Vision\ArtworkVectorIndexService::class),
);
$embedding = ArtworkEmbedding::query()->where('artwork_id', $artwork->id)->first();
$artwork->refresh();
expect($embedding)->not->toBeNull()
->and($embedding?->dim)->toBe(2)
->and($embedding?->is_normalized)->toBeTrue()
->and($artwork->last_vector_indexed_at)->not->toBeNull();
$vector = json_decode((string) $embedding?->embedding_json, true, 512, JSON_THROW_ON_ERROR);
expect(round((float) $vector[0], 4))->toBe(0.6)
->and(round((float) $vector[1], 4))->toBe(0.8);
Http::assertSent(function (\Illuminate\Http\Client\Request $request) use ($artwork): bool {
if ($request->url() !== 'https://vision.local/vectors/upsert') {
return false;
}
$data = $request->data();
return ($data['id'] ?? null) === (string) $artwork->id
&& ($data['url'] ?? null) === 'https://files.local/md/aa/bb/aabbccddeeff1122.webp'
&& ($data['metadata']['content_type'] ?? null) === 'Wallpapers'
&& ($data['metadata']['category'] ?? null) === 'Abstract'
&& ($data['metadata']['tags'] ?? null) === ['neon']
&& ($data['metadata']['user_id'] ?? null) === (string) $artwork->user_id;
});
});
it('keeps the local embedding when vector upsert fails', function () {
config()->set('recommendations.embedding.enabled', true);
config()->set('recommendations.embedding.endpoint', '/embed');
config()->set('recommendations.embedding.min_dim', 2);
config()->set('vision.clip.base_url', 'https://clip.local');
config()->set('vision.vector_gateway.enabled', true);
config()->set('vision.vector_gateway.base_url', 'https://vision.local');
config()->set('vision.vector_gateway.api_key', 'test-key');
config()->set('vision.vector_gateway.upsert_endpoint', '/vectors/upsert');
config()->set('cdn.files_url', 'https://files.local');
Http::fake([
'https://clip.local/embed' => Http::response([
'embedding' => [1.0, 2.0, 2.0],
], 200),
'https://vision.local/vectors/upsert' => Http::response([
'message' => 'gateway error',
], 500),
]);
$artwork = Artwork::factory()->create([
'hash' => '1122334455667788',
'thumb_ext' => 'webp',
'is_public' => true,
'is_approved' => true,
'published_at' => now()->subDay(),
]);
$job = new GenerateArtworkEmbeddingJob($artwork->id, '1122334455667788');
$job->handle(
app(\App\Services\Vision\ArtworkEmbeddingClient::class),
app(\App\Services\Vision\ArtworkVisionImageUrl::class),
app(\App\Services\Vision\ArtworkVectorIndexService::class),
);
$artwork->refresh();
expect(ArtworkEmbedding::query()->where('artwork_id', $artwork->id)->exists())->toBeTrue()
->and($artwork->last_vector_indexed_at)->toBeNull();
Http::assertSentCount(2);
});

View File

@@ -0,0 +1,82 @@
<?php
declare(strict_types=1);
use App\Models\Artwork;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Http;
use function Pest\Laravel\actingAs;
use function Pest\Laravel\postJson;
uses(RefreshDatabase::class);
it('returns normalized synchronous vision tag suggestions for the artwork owner', function (): void {
config()->set('vision.enabled', true);
config()->set('vision.gateway.base_url', 'https://vision.local');
config()->set('cdn.files_url', 'https://files.local');
$user = User::factory()->create();
$artwork = Artwork::factory()->create([
'user_id' => $user->id,
'hash' => 'aabbcc112233',
]);
Http::fake([
'https://vision.local/analyze/all' => Http::response([
'clip' => [
['tag' => 'Neon City', 'confidence' => 0.91],
['tag' => 'Night Sky', 'confidence' => 0.77],
],
'yolo' => [
['label' => 'car', 'confidence' => 0.65],
],
], 200),
]);
actingAs($user);
$response = postJson('/api/uploads/' . $artwork->id . '/vision-suggest?limit=10');
$response->assertOk()
->assertJsonPath('vision_enabled', true)
->assertJsonPath('source', 'gateway_sync')
->assertJsonPath('tags.0.slug', 'neon-city')
->assertJsonPath('tags.0.source', 'clip')
->assertJsonPath('tags.1.slug', 'night-sky')
->assertJsonPath('tags.2.slug', 'car');
});
it('returns 404 when a non-owner requests upload vision suggestions', function (): void {
config()->set('vision.enabled', true);
config()->set('vision.gateway.base_url', 'https://vision.local');
config()->set('cdn.files_url', 'https://files.local');
$owner = User::factory()->create();
$viewer = User::factory()->create();
$artwork = Artwork::factory()->create([
'user_id' => $owner->id,
'hash' => 'aabbcc112233',
]);
actingAs($viewer);
postJson('/api/uploads/' . $artwork->id . '/vision-suggest')
->assertStatus(404);
});
it('returns disabled payload when vision suggestions are turned off', function (): void {
config()->set('vision.enabled', false);
$user = User::factory()->create();
$artwork = Artwork::factory()->create([
'user_id' => $user->id,
]);
actingAs($user);
postJson('/api/uploads/' . $artwork->id . '/vision-suggest')
->assertOk()
->assertJsonPath('vision_enabled', false)
->assertJsonPath('tags', []);
});

View File

@@ -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.');
});

View File

@@ -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');
});

View File

@@ -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']);
}

View File

@@ -0,0 +1,4 @@
{
"status": "passed",
"failedTests": []
}

View File

@@ -0,0 +1,788 @@
import { test, expect, type Page } from '@playwright/test'
import { execFileSync } from 'node:child_process'
type CollectionFixture = {
email: string
password: string
username: string
adminEmail: string
adminPassword: string
adminUsername: string
foreignUsername: string
artworkTitle: string
secondArtworkTitle: string
relatedArtworkTitle: string
foreignArtworkTitle: string
aiTagValue: string
styleValue: string
colorValue: string
secondColorValue: string
smartCollectionTitle: string
manualCollectionTitle: string
studioCollectionTitle: string
studioCollectionId: number
studioCollectionSlug: string
saveableCollectionTitle: string
saveableCollectionSlug: string
}
function seedCollectionFixture(): CollectionFixture {
const token = `${Date.now().toString().slice(-6)}${Math.floor(Math.random() * 1000).toString().padStart(3, '0')}`
const email = `e2e-collections-${token}@example.test`
const username = `e2ec${token}`.slice(0, 20)
const adminEmail = `e2e-collections-admin-${token}@example.test`
const adminUsername = `e2eadmin${token}`.slice(0, 20)
const artworkTitle = `Owned Neon City ${token}`
const secondArtworkTitle = `Owned Sunset Mirage ${token}`
const relatedArtworkTitle = `Owned Neon Echo ${token}`
const foreignArtworkTitle = `Foreign Neon City ${token}`
const aiTagValue = `neon-signal-${token}`
const styleValue = 'digital-painting'
const colorValue = 'blue-tones'
const secondColorValue = 'orange-tones'
const smartCollectionTitle = `Playwright Smart Collection ${token}`
const manualCollectionTitle = `Playwright Manual Collection ${token}`
const studioCollectionTitle = `Studio Workflow Collection ${token}`
const studioCollectionSlug = `studio-workflow-${token}`
const saveableCollectionTitle = `Saved Inspiration Collection ${token}`
const saveableCollectionSlug = `saved-inspiration-${token}`
const script = [
"use App\\Models\\Artwork;",
"use App\\Models\\Collection;",
"use App\\Models\\Tag;",
"use App\\Models\\User;",
"use Illuminate\\Support\\Facades\\DB;",
"use Illuminate\\Support\\Facades\\Hash;",
`$user = User::create([`,
" 'name' => 'E2E Collections User',",
` 'email' => '${email}',`,
` 'username' => '${username}',`,
" 'onboarding_step' => 'complete',",
" 'email_verified_at' => now(),",
" 'is_active' => 1,",
" 'password' => Hash::make('password'),",
"]);",
"$otherUser = User::create([",
" 'name' => 'E2E Foreign User',",
` 'email' => 'foreign-${email}',`,
` 'username' => 'f${username}'.substr(md5('${token}'), 0, 3),`,
" 'onboarding_step' => 'complete',",
" 'email_verified_at' => now(),",
" 'is_active' => 1,",
" 'password' => Hash::make('password'),",
"]);",
"$admin = User::create([",
" 'name' => 'E2E Collections Admin',",
` 'email' => '${adminEmail}',`,
` 'username' => '${adminUsername}',`,
" 'role' => 'admin',",
" 'onboarding_step' => 'complete',",
" 'email_verified_at' => now(),",
" 'is_active' => 1,",
" 'password' => Hash::make('password'),",
"]);",
"$tag = Tag::firstOrCreate(['slug' => 'cyberpunk'], ['name' => 'Cyberpunk', 'usage_count' => 0, 'is_active' => true]);",
"$warmTag = Tag::firstOrCreate(['slug' => 'sunset'], ['name' => 'Sunset', 'usage_count' => 0, 'is_active' => true]);",
`$styleTag = Tag::firstOrCreate(['slug' => '${styleValue}'], ['name' => 'Digital Painting', 'usage_count' => 0, 'is_active' => true]);`,
`$colorTag = Tag::firstOrCreate(['slug' => '${colorValue}'], ['name' => 'Blue Tones', 'usage_count' => 0, 'is_active' => true]);`,
`$secondColorTag = Tag::firstOrCreate(['slug' => '${secondColorValue}'], ['name' => 'Orange Tones', 'usage_count' => 0, 'is_active' => true]);`,
`$artwork = Artwork::create([`,
" 'user_id' => $user->id,",
` 'title' => '${artworkTitle}',`,
` 'slug' => 'owned-neon-city-${token}',`,
" 'description' => 'Owned smart collection preview artwork.',",
` 'blip_caption' => 'AI marker ${aiTagValue}',`,
" 'file_name' => 'image.jpg',",
" 'file_path' => 'uploads/artworks/image.jpg',",
` 'hash' => '${token}abcdef',`,
" 'thumb_ext' => 'webp',",
" 'file_ext' => 'jpg',",
" 'file_size' => 12345,",
" 'mime_type' => 'image/jpeg',",
" 'width' => 800,",
" 'height' => 600,",
" 'is_public' => true,",
" 'is_approved' => true,",
" 'published_at' => now()->subDay(),",
"]);",
"$secondArtwork = Artwork::create([",
" 'user_id' => $user->id,",
` 'title' => '${secondArtworkTitle}',`,
` 'slug' => 'owned-sunset-mirage-${token}',`,
" 'description' => 'Second owned artwork for tabbed studio and split suggestion coverage.',",
" 'blip_caption' => 'Warm sunset marker',",
" 'file_name' => 'image.jpg',",
" 'file_path' => 'uploads/artworks/image.jpg',",
` 'hash' => 'aa${token}cdef',`,
" 'thumb_ext' => 'webp',",
" 'file_ext' => 'jpg',",
" 'file_size' => 12345,",
" 'mime_type' => 'image/jpeg',",
" 'width' => 800,",
" 'height' => 600,",
" 'is_public' => true,",
" 'is_approved' => true,",
" 'published_at' => now()->subHours(18),",
"]);",
"$relatedArtwork = Artwork::create([",
" 'user_id' => $user->id,",
` 'title' => '${relatedArtworkTitle}',`,
` 'slug' => 'owned-neon-echo-${token}',`,
" 'description' => 'Unattached related artwork for merge idea coverage.',",
" 'blip_caption' => 'Echo marker for related artwork coverage',",
" 'file_name' => 'image.jpg',",
" 'file_path' => 'uploads/artworks/image.jpg',",
` 'hash' => 'bb${token}efgh',`,
" 'thumb_ext' => 'webp',",
" 'file_ext' => 'jpg',",
" 'file_size' => 12345,",
" 'mime_type' => 'image/jpeg',",
" 'width' => 800,",
" 'height' => 600,",
" 'is_public' => true,",
" 'is_approved' => true,",
" 'published_at' => now()->subHours(12),",
"]);",
"$foreignArtwork = Artwork::create([",
" 'user_id' => $otherUser->id,",
` 'title' => '${foreignArtworkTitle}',`,
` 'slug' => 'foreign-neon-city-${token}',`,
" 'description' => 'Foreign artwork should not appear in owner smart previews.',",
` 'blip_caption' => 'Foreign marker outsider-${token}',`,
" 'file_name' => 'image.jpg',",
" 'file_path' => 'uploads/artworks/image.jpg',",
` 'hash' => 'ff${token}abcd',`,
" 'thumb_ext' => 'webp',",
" 'file_ext' => 'jpg',",
" 'file_size' => 12345,",
" 'mime_type' => 'image/jpeg',",
" 'width' => 800,",
" 'height' => 600,",
" 'is_public' => true,",
" 'is_approved' => true,",
" 'published_at' => now()->subDay(),",
"]);",
"$artwork->tags()->syncWithoutDetaching([$tag->id => ['source' => 'user', 'confidence' => null]]);",
"$artwork->tags()->syncWithoutDetaching([$styleTag->id => ['source' => 'ai', 'confidence' => 0.98]]);",
"$artwork->tags()->syncWithoutDetaching([$colorTag->id => ['source' => 'ai', 'confidence' => 0.97]]);",
"$secondArtwork->tags()->syncWithoutDetaching([$warmTag->id => ['source' => 'user', 'confidence' => null]]);",
"$secondArtwork->tags()->syncWithoutDetaching([$secondColorTag->id => ['source' => 'ai', 'confidence' => 0.96]]);",
"$relatedArtwork->tags()->syncWithoutDetaching([$tag->id => ['source' => 'user', 'confidence' => null]]);",
"$foreignArtwork->tags()->syncWithoutDetaching([$tag->id => ['source' => 'user', 'confidence' => null]]);",
"$foreignArtwork->tags()->syncWithoutDetaching([$styleTag->id => ['source' => 'ai', 'confidence' => 0.99]]);",
"$foreignArtwork->tags()->syncWithoutDetaching([$colorTag->id => ['source' => 'ai', 'confidence' => 0.99]]);",
"$studioCollection = Collection::create([",
" 'user_id' => $user->id,",
` 'title' => '${studioCollectionTitle}',`,
` 'slug' => '${studioCollectionSlug}',`,
" 'type' => 'community',",
" 'description' => 'Seeded collection for tabbed studio and moderation coverage.',",
" 'subtitle' => 'Prepared for Playwright edit-mode coverage',",
" 'summary' => 'Includes two distinct theme clusters plus a related spare artwork.',",
" 'collaboration_mode' => 'invite_only',",
" 'allow_submissions' => true,",
" 'allow_comments' => true,",
" 'allow_saves' => true,",
" 'moderation_status' => 'active',",
" 'visibility' => 'public',",
" 'mode' => 'manual',",
" 'sort_mode' => 'manual',",
" 'artworks_count' => 2,",
" 'comments_count' => 0,",
" 'saves_count' => 0,",
" 'collaborators_count' => 1,",
" 'is_featured' => true,",
" 'featured_at' => now()->subHour(),",
" 'views_count' => 12,",
" 'likes_count' => 4,",
" 'followers_count' => 3,",
" 'shares_count' => 1,",
" 'published_at' => now()->subDay(),",
" 'last_activity_at' => now()->subHour(),",
"]);",
"DB::table('collection_members')->insert([",
" 'collection_id' => $studioCollection->id,",
" 'user_id' => $user->id,",
" 'invited_by_user_id' => $user->id,",
" 'role' => 'owner',",
" 'status' => 'active',",
" 'invited_at' => now()->subDay(),",
" 'accepted_at' => now()->subDay(),",
" 'created_at' => now()->subDay(),",
" 'updated_at' => now()->subDay(),",
"]);",
"DB::table('collection_artwork')->insert([",
" [",
" 'collection_id' => $studioCollection->id,",
" 'artwork_id' => $artwork->id,",
" 'order_num' => 0,",
" 'created_at' => now(),",
" 'updated_at' => now(),",
" ],",
" [",
" 'collection_id' => $studioCollection->id,",
" 'artwork_id' => $secondArtwork->id,",
" 'order_num' => 1,",
" 'created_at' => now(),",
" 'updated_at' => now(),",
" ],",
"]);",
"$studioCollection->forceFill(['cover_artwork_id' => $artwork->id])->save();",
"$foreignCollection = Collection::create([",
" 'user_id' => $otherUser->id,",
` 'title' => '${saveableCollectionTitle}',`,
` 'slug' => '${saveableCollectionSlug}',`,
" 'type' => 'community',",
" 'description' => 'A foreign collection used for saved collection browser coverage.',",
" 'collaboration_mode' => 'closed',",
" 'allow_submissions' => false,",
" 'allow_comments' => true,",
" 'allow_saves' => true,",
" 'moderation_status' => 'active',",
" 'visibility' => 'public',",
" 'mode' => 'manual',",
" 'sort_mode' => 'manual',",
" 'artworks_count' => 1,",
" 'comments_count' => 0,",
" 'saves_count' => 0,",
" 'collaborators_count' => 1,",
" 'is_featured' => false,",
" 'views_count' => 0,",
" 'likes_count' => 0,",
" 'followers_count' => 0,",
" 'shares_count' => 0,",
" 'published_at' => now()->subDay(),",
" 'last_activity_at' => now()->subDay(),",
"]);",
"DB::table('collection_artwork')->insert([",
" 'collection_id' => $foreignCollection->id,",
" 'artwork_id' => $foreignArtwork->id,",
" 'order_num' => 0,",
" 'created_at' => now(),",
" 'updated_at' => now(),",
"]);",
"$foreignCollection->forceFill(['cover_artwork_id' => $foreignArtwork->id])->save();",
"echo json_encode([",
" 'email' => $user->email,",
" 'password' => 'password',",
" 'username' => $user->username,",
" 'adminEmail' => $admin->email,",
" 'adminPassword' => 'password',",
" 'adminUsername' => $admin->username,",
" 'foreignUsername' => $otherUser->username,",
` 'artworkTitle' => '${artworkTitle}',`,
` 'secondArtworkTitle' => '${secondArtworkTitle}',`,
` 'relatedArtworkTitle' => '${relatedArtworkTitle}',`,
` 'foreignArtworkTitle' => '${foreignArtworkTitle}',`,
` 'aiTagValue' => '${aiTagValue}',`,
` 'styleValue' => '${styleValue}',`,
` 'colorValue' => '${colorValue}',`,
` 'secondColorValue' => '${secondColorValue}',`,
` 'smartCollectionTitle' => '${smartCollectionTitle}',`,
` 'manualCollectionTitle' => '${manualCollectionTitle}',`,
` 'studioCollectionTitle' => '${studioCollectionTitle}',`,
" 'studioCollectionId' => $studioCollection->id,",
` 'studioCollectionSlug' => '${studioCollectionSlug}',`,
` 'saveableCollectionTitle' => '${saveableCollectionTitle}',`,
` 'saveableCollectionSlug' => '${saveableCollectionSlug}',`,
"]);",
].join(' ')
const raw = execFileSync('php', ['artisan', 'tinker', `--execute=${script}`], {
cwd: process.cwd(),
encoding: 'utf8',
})
const jsonLine = raw
.split(/\r?\n/)
.map((line) => line.trim())
.filter(Boolean)
.reverse()
.find((line) => line.startsWith('{') && line.endsWith('}'))
if (!jsonLine) {
throw new Error(`Unable to parse collection fixture JSON from tinker output: ${raw}`)
}
return JSON.parse(jsonLine) as CollectionFixture
}
function resetBotProtectionState() {
const script = [
"use Illuminate\\Support\\Facades\\DB;",
"use Illuminate\\Support\\Facades\\Schema;",
"foreach (['forum_bot_logs', 'forum_bot_ip_blacklist', 'forum_bot_device_fingerprints', 'forum_bot_behavior_profiles'] as $table) {",
" if (Schema::hasTable($table)) {",
" DB::table($table)->delete();",
' }',
'}',
"echo 'ok';",
].join(' ')
execFileSync('php', ['artisan', 'tinker', `--execute=${script}`], {
cwd: process.cwd(),
encoding: 'utf8',
})
}
async function login(page: Page, credentials: { email: string; password: string }) {
for (let attempt = 0; attempt < 2; attempt += 1) {
await page.goto('/login')
const emailField = page.locator('input[name="email"]')
const suspiciousActivity = page.getByText('Suspicious activity detected.')
await emailField.waitFor({ state: 'visible', timeout: 8000 })
await emailField.fill(credentials.email)
await page.locator('input[name="password"]').fill(credentials.password)
await page.getByRole('button', { name: 'Sign In' }).click()
try {
await page.waitForURL((url) => url.pathname !== '/login', { timeout: 8000, waitUntil: 'domcontentloaded' })
return
} catch {
if (attempt === 0 && await suspiciousActivity.isVisible().catch(() => false)) {
resetBotProtectionState()
continue
}
throw new Error('Collection Playwright login failed before reaching an authenticated page.')
}
}
}
async function dismissCookieBanner(page: Page) {
const essentialOnlyButton = page.getByRole('button', { name: 'Essential only' })
const acceptAllButton = page.getByRole('button', { name: 'Accept all' })
if (await essentialOnlyButton.isVisible().catch(() => false)) {
await essentialOnlyButton.click()
return
}
if (await acceptAllButton.isVisible().catch(() => false)) {
await acceptAllButton.click()
}
}
async function waitForCollectionManagePage(page: Page) {
await expect.poll(() => new URL(page.url()).pathname, { timeout: 30000 }).toMatch(/\/settings\/collections\/\d+$/)
await expect(titleInput(page)).toBeVisible({ timeout: 10000 })
}
async function waitForPublicCollectionPage(page: Page) {
await expect.poll(() => new URL(page.url()).pathname, { timeout: 30000 }).toMatch(/^\/@.+\/collections\//)
}
async function createCollectionAndWaitForRedirect(page: Page) {
const createPath = new URL(page.url()).pathname
const storeResponsePromise = page.waitForResponse((response) => {
const request = response.request()
return request.method() === 'POST'
&& /\/settings\/collections$/.test(new URL(response.url()).pathname)
})
await page.getByRole('button', { name: 'Create Collection' }).click()
const storeResponse = await storeResponsePromise
expect(storeResponse.ok()).toBeTruthy()
await expect.poll(() => new URL(page.url()).pathname, { timeout: 30000 }).not.toBe(createPath)
await waitForCollectionManagePage(page)
await expect(titleInput(page)).toBeVisible({ timeout: 10000 })
}
function titleInput(page: Page) {
return page.locator('input[placeholder="Dark Fantasy Series"]')
}
function subtitleInput(page: Page) {
return page.locator('input[placeholder="A moody archive of midnight environments"]')
}
function summaryInput(page: Page) {
return page.locator('input[placeholder="Best performing sci-fi wallpapers from the last year"]')
}
function artworkPickerSection(page: Page) {
return page.locator('section').filter({ has: page.getByRole('heading', { name: /^add artworks$/i }) }).first()
}
function attachedArtworksSection(page: Page) {
return page.locator('section').filter({ has: page.getByRole('heading', { name: /arrange the showcase order/i }) }).first()
}
function smartBuilderSection(page: Page) {
return page.locator('section').filter({ has: page.getByRole('heading', { name: /define collection rules/i }) }).first()
}
function smartPreviewSection(page: Page) {
return page.locator('section').filter({ has: page.getByRole('heading', { name: /matching artworks/i }) }).first()
}
function studioTab(page: Page, label: string) {
return page.locator('button').filter({ hasText: new RegExp(label, 'i') }).first()
}
async function openStudioTab(page: Page, label: string) {
const tab = studioTab(page, label)
await tab.scrollIntoViewIfNeeded()
await tab.click()
}
async function attachSelectedArtworks(page: Page) {
const attachResponsePromise = page.waitForResponse((response) => {
const request = response.request()
return request.method() === 'POST'
&& /\/settings\/collections\/\d+\/artworks$/.test(new URL(response.url()).pathname)
})
await page.getByRole('button', { name: /add selected/i }).click()
const attachResponse = await attachResponsePromise
expect(attachResponse.ok()).toBeTruthy()
}
test.describe('Smart collections', () => {
test.describe.configure({ mode: 'serial' })
test.setTimeout(90000)
let fixture: CollectionFixture
test.beforeAll(() => {
fixture = seedCollectionFixture()
})
test.beforeEach(() => {
resetBotProtectionState()
})
test('owner can preview and create a smart collection', async ({ page }) => {
await login(page, fixture)
await page.goto('/settings/collections/create?mode=smart')
await dismissCookieBanner(page)
await expect(page.getByRole('heading', { name: /create a v2 collection/i })).toBeVisible()
await expect(page.getByRole('heading', { name: /define collection rules/i })).toBeVisible()
await titleInput(page).fill(fixture.smartCollectionTitle)
await subtitleInput(page).fill('A browser-tested smart showcase')
await summaryInput(page).fill('Built end to end through Playwright')
const smartBuilder = smartBuilderSection(page)
if (await smartBuilder.getByText(/add at least one rule/i).isVisible().catch(() => false)) {
await smartBuilder.getByRole('button', { name: /add rule/i }).click()
}
await smartBuilder.locator('select').nth(0).selectOption('ai_tag')
await smartBuilder.locator('input[placeholder="Enter a value"]').fill(fixture.aiTagValue)
const previewSection = smartPreviewSection(page)
const previewResponsePromise = page.waitForResponse(
(response) => response.url().includes('/settings/collections/smart-preview') && response.request().method() === 'POST'
)
await previewSection.getByRole('button', { name: /refresh preview/i }).click()
const previewResponse = await previewResponsePromise
expect(previewResponse.ok()).toBeTruthy()
const previewRequest = previewResponse.request().postDataJSON()
expect(previewRequest?.smart_rules_json?.rules?.[0]?.field).toBe('ai_tag')
expect(previewRequest?.smart_rules_json?.rules?.[0]?.value).toBe(fixture.aiTagValue)
const previewPayload = await previewResponse.json()
expect(previewPayload?.preview?.count).toBe(1)
expect(previewPayload?.preview?.artworks?.data?.some((artwork: { title?: string }) => artwork.title === fixture.artworkTitle)).toBeTruthy()
expect(previewPayload?.preview?.artworks?.data?.some((artwork: { title?: string }) => artwork.title === fixture.foreignArtworkTitle)).toBeFalsy()
await createCollectionAndWaitForRedirect(page)
await expect(titleInput(page)).toHaveValue(fixture.smartCollectionTitle)
await expect(page.getByText(/smart collection/i).first()).toBeVisible()
await page.getByRole('link', { name: /view public page/i }).click()
await waitForPublicCollectionPage(page)
await expect(page.getByRole('heading', { name: fixture.smartCollectionTitle })).toBeVisible()
const publicArtworkHeading = page.getByRole('heading', { name: fixture.artworkTitle })
await publicArtworkHeading.scrollIntoViewIfNeeded()
await expect(publicArtworkHeading).toBeVisible()
await expect(page.getByText(fixture.foreignArtworkTitle)).toHaveCount(0)
})
test('owner can preview style and color smart rules without leaking foreign artworks', async ({ page }) => {
await login(page, fixture)
await page.goto('/settings/collections/create?mode=smart')
await dismissCookieBanner(page)
await expect(page.getByRole('heading', { name: /define collection rules/i })).toBeVisible()
await titleInput(page).fill(`${fixture.smartCollectionTitle} Style Color`)
const smartBuilder = smartBuilderSection(page)
if (await smartBuilder.getByText(/add at least one rule/i).isVisible().catch(() => false)) {
await smartBuilder.getByRole('button', { name: /add rule/i }).click()
}
const selects = smartBuilder.locator('select')
await selects.nth(0).selectOption('style')
await selects.nth(2).selectOption(fixture.styleValue)
const previewSection = smartPreviewSection(page)
let previewResponsePromise = page.waitForResponse(
(response) => response.url().includes('/settings/collections/smart-preview') && response.request().method() === 'POST'
)
await previewSection.getByRole('button', { name: /refresh preview/i }).click()
let previewResponse = await previewResponsePromise
expect(previewResponse.ok()).toBeTruthy()
let previewRequest = previewResponse.request().postDataJSON()
expect(previewRequest?.smart_rules_json?.rules?.[0]?.field).toBe('style')
expect(previewRequest?.smart_rules_json?.rules?.[0]?.value).toBe(fixture.styleValue)
let previewPayload = await previewResponse.json()
expect(previewPayload?.preview?.count).toBe(1)
expect(previewPayload?.preview?.artworks?.data?.some((artwork: { title?: string }) => artwork.title === fixture.artworkTitle)).toBeTruthy()
expect(previewPayload?.preview?.artworks?.data?.some((artwork: { title?: string }) => artwork.title === fixture.foreignArtworkTitle)).toBeFalsy()
await selects.nth(0).selectOption('color')
await selects.nth(2).selectOption(fixture.colorValue)
previewResponsePromise = page.waitForResponse(
(response) => response.url().includes('/settings/collections/smart-preview') && response.request().method() === 'POST'
)
await previewSection.getByRole('button', { name: /refresh preview/i }).click()
previewResponse = await previewResponsePromise
expect(previewResponse.ok()).toBeTruthy()
previewRequest = previewResponse.request().postDataJSON()
expect(previewRequest?.smart_rules_json?.rules?.[0]?.field).toBe('color')
expect(previewRequest?.smart_rules_json?.rules?.[0]?.value).toBe(fixture.colorValue)
previewPayload = await previewResponse.json()
expect(previewPayload?.preview?.count).toBe(1)
expect(previewPayload?.preview?.artworks?.data?.some((artwork: { title?: string }) => artwork.title === fixture.artworkTitle)).toBeTruthy()
expect(previewPayload?.preview?.artworks?.data?.some((artwork: { title?: string }) => artwork.title === fixture.foreignArtworkTitle)).toBeFalsy()
})
test('owner can create a manual collection and attach artworks', async ({ page }) => {
await login(page, fixture)
await page.goto('/settings/collections/create')
await dismissCookieBanner(page)
await expect(page.getByRole('heading', { name: /create a v2 collection/i })).toBeVisible()
await titleInput(page).fill(fixture.manualCollectionTitle)
await subtitleInput(page).fill('A hand-picked browser-tested showcase')
await summaryInput(page).fill('Created manually and populated through the picker')
await createCollectionAndWaitForRedirect(page)
await expect(titleInput(page)).toHaveValue(fixture.manualCollectionTitle)
await expect(page.getByText(/^manual$/i).first()).toBeVisible()
await openStudioTab(page, 'Artworks')
await expect(page.getByRole('heading', { name: /arrange the showcase order/i })).toBeVisible()
const picker = artworkPickerSection(page)
await picker.getByRole('button').filter({ hasText: fixture.artworkTitle }).first().click()
await attachSelectedArtworks(page)
const attachedSection = attachedArtworksSection(page)
await expect(attachedSection.getByText(fixture.artworkTitle)).toBeVisible()
await openStudioTab(page, 'Details')
await expect(titleInput(page)).toBeVisible()
await expect(page.getByLabel('Cover Artwork')).toBeEnabled()
await page.getByLabel('Cover Artwork').selectOption({ label: fixture.artworkTitle })
await page.getByRole('button', { name: /save changes/i }).click()
await expect(page.getByText('Collection updated.')).toBeVisible()
await page.getByRole('link', { name: /view public page/i }).click()
await waitForPublicCollectionPage(page)
await expect(page.getByRole('heading', { name: fixture.manualCollectionTitle })).toBeVisible()
const manualPublicArtworkHeading = page.getByRole('heading', { name: fixture.artworkTitle })
await manualPublicArtworkHeading.scrollIntoViewIfNeeded()
await expect(manualPublicArtworkHeading).toBeVisible()
})
test('owner can request and apply AI assistant suggestions on the manage page', async ({ page }) => {
await login(page, fixture)
await page.goto('/settings/collections/create')
await dismissCookieBanner(page)
await titleInput(page).fill(`${fixture.manualCollectionTitle} AI`)
await subtitleInput(page).fill('Prepared for AI assistant coverage')
await summaryInput(page).fill('Initial summary before AI suggestions are applied')
await createCollectionAndWaitForRedirect(page)
await openStudioTab(page, 'Artworks')
await expect(page.getByRole('heading', { name: /arrange the showcase order/i })).toBeVisible()
const picker = artworkPickerSection(page)
await picker.getByRole('button').filter({ hasText: fixture.artworkTitle }).first().click()
await attachSelectedArtworks(page)
await expect(attachedArtworksSection(page).getByText(fixture.artworkTitle)).toBeVisible()
await openStudioTab(page, 'AI Suggestions')
await expect(page.getByRole('heading', { name: /review-only suggestions/i })).toBeVisible()
const aiSection = page.locator('section').filter({ hasText: /AI Assistant/ }).filter({ has: page.getByRole('button', { name: /suggest title/i }) }).first()
await aiSection.scrollIntoViewIfNeeded()
await expect(aiSection.getByRole('button', { name: /suggest title/i })).toBeVisible()
let aiResponsePromise = page.waitForResponse((response) => response.url().includes('/ai/suggest-title') && response.request().method() === 'POST')
await aiSection.getByRole('button', { name: /suggest title/i }).click()
let aiResponse = await aiResponsePromise
expect(aiResponse.ok()).toBeTruthy()
await expect(aiSection.getByRole('button', { name: /use title/i })).toBeVisible()
await aiSection.getByRole('button', { name: /use title/i }).click()
aiResponsePromise = page.waitForResponse((response) => response.url().includes('/ai/suggest-summary') && response.request().method() === 'POST')
await aiSection.getByRole('button', { name: /suggest summary/i }).click()
aiResponse = await aiResponsePromise
expect(aiResponse.ok()).toBeTruthy()
await expect(aiSection.getByRole('button', { name: /use summary/i })).toBeVisible()
await aiSection.getByRole('button', { name: /use summary/i }).click()
aiResponsePromise = page.waitForResponse((response) => response.url().includes('/ai/suggest-cover') && response.request().method() === 'POST')
await aiSection.getByRole('button', { name: /suggest cover/i }).click()
aiResponse = await aiResponsePromise
expect(aiResponse.ok()).toBeTruthy()
await expect(aiSection.getByRole('button', { name: /use cover/i })).toBeVisible()
await aiSection.getByRole('button', { name: /use cover/i }).click()
await openStudioTab(page, 'Details')
await expect(titleInput(page)).not.toHaveValue(`${fixture.manualCollectionTitle} AI`)
await expect(summaryInput(page)).not.toHaveValue('Initial summary before AI suggestions are applied')
await page.getByRole('button', { name: /save changes/i }).click()
await expect(page.getByText('Collection updated.')).toBeVisible()
})
test('owner can navigate the tabbed studio and request split and merge AI suggestions', async ({ page }) => {
await login(page, fixture)
await page.goto(`/settings/collections/${fixture.studioCollectionId}`)
await dismissCookieBanner(page)
await waitForCollectionManagePage(page)
await expect(studioTab(page, 'Details')).toBeVisible()
await expect(studioTab(page, 'Artworks')).toBeVisible()
await expect(studioTab(page, 'Members')).toBeVisible()
await expect(studioTab(page, 'Submissions')).toBeVisible()
await expect(studioTab(page, 'Settings')).toBeVisible()
await expect(studioTab(page, 'Discussion')).toBeVisible()
await expect(studioTab(page, 'AI Suggestions')).toBeVisible()
await openStudioTab(page, 'Artworks')
await expect(page.getByRole('heading', { name: /arrange the showcase order/i })).toBeVisible()
await expect(page.getByText(fixture.artworkTitle)).toBeVisible()
await expect(page.getByText(fixture.secondArtworkTitle)).toBeVisible()
await openStudioTab(page, 'Members')
const membersSection = page.locator('section').filter({ has: page.getByRole('heading', { name: /team access/i }) }).first()
await expect(membersSection.getByRole('heading', { name: /team access/i })).toBeVisible()
await expect(membersSection.getByText(/owner/i)).toBeVisible()
await expect(membersSection.getByText(/e2e collections user/i)).toBeVisible()
await openStudioTab(page, 'Submissions')
await expect(page.getByRole('heading', { name: /incoming artworks/i })).toBeVisible()
await expect(page.getByText(/no submissions yet\./i)).toBeVisible()
await openStudioTab(page, 'Discussion')
await expect(page.getByRole('heading', { name: /recent comments/i })).toBeVisible()
await expect(page.getByText(/no comments yet\./i)).toBeVisible()
await openStudioTab(page, 'Settings')
await expect(page.getByText(/use the details tab for metadata and publishing options/i)).toBeVisible()
await openStudioTab(page, 'AI Suggestions')
await expect(page.getByRole('heading', { name: /review-only suggestions/i })).toBeVisible()
let aiResponsePromise = page.waitForResponse((response) => response.url().includes('/ai/suggest-split-themes') && response.request().method() === 'POST')
await page.getByRole('button', { name: /suggest split/i }).click()
let aiResponse = await aiResponsePromise
expect(aiResponse.ok()).toBeTruthy()
const splitPayload = await aiResponse.json()
expect(Array.isArray(splitPayload?.suggestion?.splits)).toBeTruthy()
expect(splitPayload.suggestion.splits.some((split: { title?: string }) => /digital painting/i.test(split.title || ''))).toBeTruthy()
expect(splitPayload.suggestion.splits.some((split: { title?: string }) => /orange tones/i.test(split.title || ''))).toBeTruthy()
await expect(page.getByText(/split suggestion/i)).toBeVisible()
aiResponsePromise = page.waitForResponse((response) => response.url().includes('/ai/suggest-merge-idea') && response.request().method() === 'POST')
await page.getByRole('button', { name: /suggest merge idea/i }).click()
aiResponse = await aiResponsePromise
expect(aiResponse.ok()).toBeTruthy()
const mergePayload = await aiResponse.json()
expect(mergePayload?.suggestion?.idea?.title).toBeTruthy()
expect(mergePayload?.suggestion?.idea?.summary).toBeTruthy()
await expect(page.getByText(mergePayload.suggestion.idea.title)).toBeVisible()
})
test('admin can moderate the seeded collection from the moderation tab', async ({ page }) => {
await login(page, { email: fixture.adminEmail, password: fixture.adminPassword })
await page.goto(`/settings/collections/${fixture.studioCollectionId}`)
await dismissCookieBanner(page)
await waitForCollectionManagePage(page)
await expect(studioTab(page, 'Moderation')).toBeVisible()
await openStudioTab(page, 'Moderation')
await expect(page.getByRole('heading', { name: /admin controls/i })).toBeVisible()
let moderationResponsePromise = page.waitForResponse((response) => /\/api\/admin\/collections\/\d+\/moderation$/.test(new URL(response.url()).pathname) && response.request().method() === 'PATCH')
await page.locator('select').filter({ has: page.locator('option[value="restricted"]') }).first().selectOption('restricted')
let moderationResponse = await moderationResponsePromise
expect(moderationResponse.ok()).toBeTruthy()
await expect(page.getByText(/moderation state updated to restricted\./i)).toBeVisible()
await expect(page.getByText(/current state:\s*restricted/i)).toBeVisible()
const allowCommentsToggle = page.locator('label').filter({ hasText: 'Allow comments' }).locator('input[type="checkbox"]')
await expect(allowCommentsToggle).toBeChecked()
const interactionsResponsePromise = page.waitForResponse((response) => /\/api\/admin\/collections\/\d+\/interactions$/.test(new URL(response.url()).pathname) && response.request().method() === 'PATCH')
await allowCommentsToggle.uncheck()
const interactionsResponse = await interactionsResponsePromise
expect(interactionsResponse.ok()).toBeTruthy()
await expect(page.getByText(/collection interaction settings updated\./i)).toBeVisible()
await expect(allowCommentsToggle).not.toBeChecked()
const unfeatureResponsePromise = page.waitForResponse((response) => /\/api\/admin\/collections\/\d+\/unfeature$/.test(new URL(response.url()).pathname) && response.request().method() === 'POST')
await page.getByRole('button', { name: /remove featured placement/i }).click()
const unfeatureResponse = await unfeatureResponsePromise
expect(unfeatureResponse.ok()).toBeTruthy()
await expect(page.getByText(/collection removed from featured placement by moderation action\./i)).toBeVisible()
await openStudioTab(page, 'Details')
await expect(page.getByText(/^featured$/i)).toHaveCount(0)
})
test('viewer can save a public collection and see it in saved collections', async ({ page }) => {
await login(page, fixture)
await page.goto(`/@${fixture.foreignUsername}/collections/${fixture.saveableCollectionSlug}`)
await dismissCookieBanner(page)
await expect(page.getByRole('heading', { name: fixture.saveableCollectionTitle })).toBeVisible()
const saveResponsePromise = page.waitForResponse((response) => response.url().includes('/collections/') && /\/save$/.test(new URL(response.url()).pathname) && response.request().method() === 'POST')
await page.getByRole('button', { name: /^save collection$/i }).click()
const saveResponse = await saveResponsePromise
expect(saveResponse.ok()).toBeTruthy()
await expect(page.locator('button').filter({ hasText: /^saved$/i }).first()).toBeVisible()
await page.goto('/me/saved/collections')
await expect(page.getByRole('heading', { name: /saved collections/i })).toBeVisible()
await expect(page.getByText(fixture.saveableCollectionTitle)).toBeVisible()
})
})

35
tests/e2e/global-setup.ts Normal file
View File

@@ -0,0 +1,35 @@
import { execFileSync } from 'node:child_process'
import { existsSync } from 'node:fs'
import path from 'node:path'
const VITE_MANIFEST_PATH = path.join(process.cwd(), 'public', 'build', 'manifest.json')
function ensureCompiledAssets() {
if (existsSync(VITE_MANIFEST_PATH)) {
return
}
const npmCommand = process.platform === 'win32' ? 'npm.cmd' : 'npm'
execFileSync(npmCommand, ['run', 'build'], {
cwd: process.cwd(),
stdio: 'inherit',
})
}
function warmBladeViews() {
execFileSync('php', ['artisan', 'view:clear'], {
cwd: process.cwd(),
stdio: 'inherit',
})
execFileSync('php', ['artisan', 'view:cache'], {
cwd: process.cwd(),
stdio: 'inherit',
})
}
export default async function globalSetup() {
ensureCompiledAssets()
warmBladeViews()
}

View File

@@ -8,6 +8,16 @@ type Fixture = {
latest_message_id: number
}
type InsertedMessage = {
id: number
body: string
}
type ConversationSeed = {
conversation_id: number
latest_message_id: number
}
function seedMessagingFixture(): Fixture {
const token = `${Date.now().toString().slice(-6)}${Math.floor(Math.random() * 1000).toString().padStart(3, '0')}`
const ownerEmail = `e2e-messages-owner-${token}@example.test`
@@ -68,7 +78,95 @@ function seedMessagingFixture(): Fixture {
return JSON.parse(jsonLine) as Fixture
}
function insertReconnectRecoveryMessage(conversationId: number): InsertedMessage {
const token = `${Date.now()}-${Math.floor(Math.random() * 100000)}`
const body = `Reconnect recovery ${token}`
const script = [
"use App\\Models\\Conversation;",
"use App\\Models\\ConversationParticipant;",
"use App\\Models\\Message;",
`$conversation = Conversation::query()->findOrFail(${conversationId});`,
"$senderId = ConversationParticipant::query()->where('conversation_id', $conversation->id)->where('role', 'member')->value('user_id');",
`$message = Message::create(['conversation_id' => $conversation->id, 'sender_id' => $senderId, 'body' => '${body}']);`,
"$conversation->update(['last_message_id' => $message->id, 'last_message_at' => $message->created_at]);",
"echo json_encode(['id' => $message->id, 'body' => $message->body]);",
].join(' ')
const raw = execFileSync('php', ['artisan', 'tinker', `--execute=${script}`], {
cwd: process.cwd(),
encoding: 'utf8',
})
const lines = raw
.split(/\r?\n/)
.map((line) => line.trim())
.filter(Boolean)
const jsonLine = [...lines].reverse().find((line) => line.startsWith('{') && line.endsWith('}'))
if (!jsonLine) {
throw new Error(`Unable to parse inserted message JSON from tinker output: ${raw}`)
}
return JSON.parse(jsonLine) as InsertedMessage
}
function seedAdditionalConversation(conversationId: number): ConversationSeed {
const script = [
"use App\\Models\\Conversation;",
"use App\\Models\\ConversationParticipant;",
"use App\\Models\\Message;",
"use Illuminate\\Support\\Carbon;",
"use Illuminate\\Support\\Facades\\Cache;",
`$original = Conversation::query()->findOrFail(${conversationId});`,
"$ownerId = (int) $original->created_by;",
"$peerId = (int) ConversationParticipant::query()->where('conversation_id', $original->id)->where('user_id', '!=', $ownerId)->value('user_id');",
"$conversation = Conversation::create(['type' => 'direct', 'created_by' => $ownerId]);",
"ConversationParticipant::insert([",
" ['conversation_id' => $conversation->id, 'user_id' => $ownerId, 'role' => 'admin', 'joined_at' => now(), 'last_read_at' => null],",
" ['conversation_id' => $conversation->id, 'user_id' => $peerId, 'role' => 'member', 'joined_at' => now(), 'last_read_at' => now()],",
"]);",
"$first = Message::create(['conversation_id' => $conversation->id, 'sender_id' => $peerId, 'body' => 'Seed hello']);",
"$last = Message::create(['conversation_id' => $conversation->id, 'sender_id' => $ownerId, 'body' => 'Seed latest from owner']);",
"$conversation->update(['last_message_id' => $last->id, 'last_message_at' => $last->created_at]);",
"ConversationParticipant::where('conversation_id', $conversation->id)->where('user_id', $peerId)->update(['last_read_at' => Carbon::parse($last->created_at)->addSeconds(15)]);",
"foreach ([$ownerId, $peerId] as $uid) {",
" $versionKey = 'messages:conversations:version:' . $uid;",
" Cache::add($versionKey, 1, now()->addDay());",
" Cache::increment($versionKey);",
"}",
"echo json_encode(['conversation_id' => $conversation->id, 'latest_message_id' => $last->id]);",
].join(' ')
const raw = execFileSync('php', ['artisan', 'tinker', `--execute=${script}`], {
cwd: process.cwd(),
encoding: 'utf8',
})
const lines = raw
.split(/\r?\n/)
.map((line) => line.trim())
.filter(Boolean)
const jsonLine = [...lines].reverse().find((line) => line.startsWith('{') && line.endsWith('}'))
if (!jsonLine) {
throw new Error(`Unable to parse additional conversation JSON from tinker output: ${raw}`)
}
return JSON.parse(jsonLine) as ConversationSeed
}
async function login(page: Parameters<typeof test>[0]['page'], fixture: Fixture) {
const baseUrl = process.env.PLAYWRIGHT_BASE_URL || 'http://skinbase26.test'
await page.context().addCookies([
{
name: 'e2e_bot_bypass',
value: '1',
url: baseUrl,
},
])
await page.goto('/login')
await page.locator('input[name="email"]').fill(fixture.email)
await page.locator('input[name="password"]').fill(fixture.password)
@@ -76,6 +174,15 @@ async function login(page: Parameters<typeof test>[0]['page'], fixture: Fixture)
await page.waitForURL(/\/dashboard/)
}
async function waitForRealtimeConnection(page: Parameters<typeof test>[0]['page']) {
await page.waitForFunction(() => {
const echo = window.Echo
const connection = echo?.connector?.pusher?.connection
return Boolean(echo && connection && typeof connection.emit === 'function')
}, { timeout: 10000 })
}
test.describe('Messaging UI', () => {
test.describe.configure({ mode: 'serial' })
@@ -110,4 +217,59 @@ test.describe('Messaging UI', () => {
await expect(page.locator(`#message-${fixture.latest_message_id}`)).toContainText('Seed latest from owner')
await expect(page.locator('text=/^Seen\\s.+\\sago$/')).toBeVisible()
})
test('reconnect recovery fetches missed messages through delta without duplicates', async ({ page }) => {
await login(page, fixture)
await page.goto(`/messages/${fixture.conversation_id}`)
await waitForRealtimeConnection(page)
await expect(page.locator(`#message-${fixture.latest_message_id}`)).toContainText('Seed latest from owner')
const inserted = insertReconnectRecoveryMessage(fixture.conversation_id)
await page.evaluate(() => {
const connection = window.Echo?.connector?.pusher?.connection
connection?.emit?.('connected')
})
await expect(page.locator(`#message-${inserted.id}`)).toContainText(inserted.body)
await expect(page.locator(`#message-${inserted.id}`)).toHaveCount(1)
await page.evaluate(() => {
const connection = window.Echo?.connector?.pusher?.connection
connection?.emit?.('connected')
})
await expect(page.locator(`#message-${inserted.id}`)).toHaveCount(1)
})
test('reconnect recovery keeps sidebar unread summary consistent', async ({ page }) => {
await login(page, fixture)
await page.route(/\/api\/messages\/\d+\/read$/, (route) => route.abort())
const isolatedConversation = seedAdditionalConversation(fixture.conversation_id)
await page.goto(`/messages/${isolatedConversation.conversation_id}`)
const unreadStat = page.getByTestId('messages-stat-unread')
const unreadBefore = parseUnreadCount(await unreadStat.innerText())
await waitForRealtimeConnection(page)
const inserted = insertReconnectRecoveryMessage(isolatedConversation.conversation_id)
await page.evaluate(() => {
const connection = window.Echo?.connector?.pusher?.connection
connection?.emit?.('connected')
})
await expect(page.locator(`#message-${inserted.id}`)).toContainText(inserted.body)
const unreadAfter = parseUnreadCount(await unreadStat.innerText())
expect(unreadAfter).toBeGreaterThan(unreadBefore)
})
})
function parseUnreadCount(text: string): number {
const digits = (text.match(/\d+/g) ?? []).join('')
return Number.parseInt(digits || '0', 10)
}

View File

@@ -0,0 +1,230 @@
import { test, expect, type Page } from '@playwright/test'
import { execFileSync } from 'node:child_process'
import { existsSync, statSync } from 'node:fs'
import path from 'node:path'
type NovaCardFixture = {
email: string
password: string
username: string
}
const VITE_MANIFEST_PATH = path.join(process.cwd(), 'public', 'build', 'manifest.json')
const FRONTEND_SOURCES = [
path.join(process.cwd(), 'resources', 'js', 'Pages', 'Studio', 'StudioCardEditor.jsx'),
path.join(process.cwd(), 'resources', 'js', 'components', 'nova-cards', 'NovaCardCanvasPreview.jsx'),
]
function needsFrontendBuild() {
if (!existsSync(VITE_MANIFEST_PATH)) {
return true
}
const manifestUpdatedAt = statSync(VITE_MANIFEST_PATH).mtimeMs
return FRONTEND_SOURCES.some((filePath) => existsSync(filePath) && statSync(filePath).mtimeMs > manifestUpdatedAt)
}
function ensureCompiledAssets() {
if (!needsFrontendBuild()) {
return
}
if (process.platform === 'win32') {
execFileSync('cmd.exe', ['/c', 'npm', 'run', 'build'], {
cwd: process.cwd(),
stdio: 'inherit',
})
return
}
execFileSync('npm', ['run', 'build'], {
cwd: process.cwd(),
stdio: 'inherit',
})
}
function ensureNovaCardSeedData() {
execFileSync('php', ['artisan', 'db:seed', '--class=Database\\Seeders\\NovaCardCategorySeeder', '--no-interaction'], {
cwd: process.cwd(),
stdio: 'inherit',
})
execFileSync('php', ['artisan', 'db:seed', '--class=Database\\Seeders\\NovaCardTemplateSeeder', '--no-interaction'], {
cwd: process.cwd(),
stdio: 'inherit',
})
}
function seedNovaCardFixture(): NovaCardFixture {
const token = `${Date.now().toString().slice(-6)}${Math.floor(Math.random() * 1000).toString().padStart(3, '0')}`
const email = `e2e-nova-cards-${token}@example.test`
const username = `e2enc${token}`.slice(0, 20)
const script = [
'use App\\Models\\User;',
'use Illuminate\\Support\\Facades\\Hash;',
`$user = User::updateOrCreate(['email' => '${email}'], [`,
" 'name' => 'E2E Nova Cards User',",
` 'username' => '${username}',`,
" 'onboarding_step' => 'complete',",
" 'email_verified_at' => now(),",
" 'is_active' => 1,",
" 'password' => Hash::make('password'),",
']);',
"echo json_encode(['email' => $user->email, 'password' => 'password', 'username' => $user->username]);",
].join(' ')
const raw = execFileSync('php', ['artisan', 'tinker', `--execute=${script}`], {
cwd: process.cwd(),
encoding: 'utf8',
})
const lines = raw
.split(/\r?\n/)
.map((line) => line.trim())
.filter(Boolean)
const jsonLine = [...lines].reverse().find((line) => line.startsWith('{') && line.endsWith('}'))
if (!jsonLine) {
throw new Error(`Unable to parse fixture JSON from tinker output: ${raw}`)
}
return JSON.parse(jsonLine) as NovaCardFixture
}
function resetBotProtectionState() {
const script = [
'use Illuminate\\Support\\Facades\\DB;',
'use Illuminate\\Support\\Facades\\Schema;',
"foreach (['forum_bot_logs', 'forum_bot_ip_blacklist', 'forum_bot_device_fingerprints', 'forum_bot_behavior_profiles'] as $table) {",
' if (Schema::hasTable($table)) {',
' DB::table($table)->delete();',
' }',
'}',
"echo 'ok';",
].join(' ')
execFileSync('php', ['artisan', 'tinker', `--execute=${script}`], {
cwd: process.cwd(),
encoding: 'utf8',
})
}
async function login(page: Page, fixture: NovaCardFixture) {
for (let attempt = 0; attempt < 2; attempt += 1) {
await page.goto('/login')
const emailField = page.locator('input[name="email"]')
const internalServerError = page.getByText('Internal Server Error')
await Promise.race([
emailField.waitFor({ state: 'visible', timeout: 8000 }),
internalServerError.waitFor({ state: 'visible', timeout: 8000 }),
])
if ((await internalServerError.isVisible().catch(() => false)) && !(await emailField.isVisible().catch(() => false))) {
throw new Error('Nova Cards mobile editor login failed because the login page returned an internal server error.')
}
await emailField.fill(fixture.email)
await page.locator('input[name="password"]').fill(fixture.password)
await page.getByRole('button', { name: 'Sign In' }).click()
try {
await page.waitForURL((url) => url.pathname !== '/login', { timeout: 8000, waitUntil: 'domcontentloaded' })
await expect(page.getByRole('button', { name: /E2E Nova Cards User/i })).toBeVisible()
return
} catch {
const suspiciousActivity = page.getByText('Suspicious activity detected.')
if (attempt === 0 && (await suspiciousActivity.isVisible().catch(() => false))) {
resetBotProtectionState()
continue
}
throw new Error('Nova Cards mobile editor login failed before reaching an authenticated page.')
}
}
}
async function expectNoHorizontalOverflow(page: Page) {
const dimensions = await page.evaluate(() => ({
clientWidth: document.documentElement.clientWidth,
scrollWidth: document.documentElement.scrollWidth,
}))
expect(
dimensions.scrollWidth,
`Expected no horizontal overflow, but scrollWidth=${dimensions.scrollWidth} and clientWidth=${dimensions.clientWidth}`
).toBeLessThanOrEqual(dimensions.clientWidth + 1)
}
function fieldInsideLabel(page: Page, labelText: string, element: 'input' | 'textarea' | 'select' = 'input') {
return page.locator('label', { hasText: labelText }).locator(element).first()
}
test.describe('Nova Cards mobile editor', () => {
test.describe.configure({ mode: 'serial' })
test.use({ viewport: { width: 390, height: 844 } })
let fixture: NovaCardFixture
test.beforeAll(() => {
ensureCompiledAssets()
ensureNovaCardSeedData()
fixture = seedNovaCardFixture()
})
test.beforeEach(() => {
resetBotProtectionState()
})
test('step navigation exposes the mobile editor flow', async ({ page }) => {
await login(page, fixture)
await page.goto('/studio/cards/create', { waitUntil: 'domcontentloaded' })
await expect(page.getByRole('heading', { name: 'Structured card creation with live preview and autosave.' })).toBeVisible()
await expect(page.getByRole('button', { name: 'Save' })).toBeEnabled({ timeout: 15000 })
await expect(page.getByText('Step 1 / 6')).toBeVisible()
await expect(page.getByText('Choose the canvas shape and basic direction.')).toBeVisible()
await expect(page.getByRole('combobox', { name: 'Format' })).toBeVisible()
await expect(fieldInsideLabel(page, 'Quote text', 'textarea')).toBeHidden()
await expectNoHorizontalOverflow(page)
await page.getByRole('button', { name: 'Next', exact: true }).click()
await expect(page.getByText('Step 2 / 6')).toBeVisible()
await expect(page.getByText('Pick the visual foundation for the card.')).toBeVisible()
await expect(page.getByRole('combobox', { name: 'Overlay' })).toBeVisible()
await expect(page.getByRole('combobox', { name: 'Focal position' })).toBeVisible()
await page.getByRole('button', { name: 'Next', exact: true }).click()
await expect(page.getByText('Step 3 / 6')).toBeVisible()
await expect(fieldInsideLabel(page, 'Quote text', 'textarea')).toBeVisible()
await fieldInsideLabel(page, 'Title').fill('Mobile editor flow card')
await fieldInsideLabel(page, 'Quote text', 'textarea').fill('Mobile step preview quote')
await fieldInsideLabel(page, 'Author').fill('Playwright')
await page.getByRole('button', { name: 'Next', exact: true }).click()
await expect(page.getByText('Step 4 / 6')).toBeVisible()
await expect(page.getByRole('combobox', { name: 'Text width' })).toBeVisible()
await expect(page.getByRole('combobox', { name: 'Line height' })).toBeVisible()
await page.getByRole('button', { name: 'Next', exact: true }).click()
await expect(page.getByText('Step 5 / 6')).toBeVisible()
await expect(page.getByText('Check the live composition before publish.')).toBeVisible()
await expect(page.getByText('Mobile step preview quote').first()).toBeVisible()
await expect(page.getByText('Playwright').first()).toBeVisible()
await page.getByRole('button', { name: 'Next', exact: true }).click()
await expect(page.getByText('Step 6 / 6')).toBeVisible()
await expect(page.getByRole('combobox', { name: 'Visibility' })).toBeVisible()
await expect(page.getByText('Draft actions')).toBeVisible()
await expect(fieldInsideLabel(page, 'Quote text', 'textarea')).toBeHidden()
await page.getByRole('button', { name: 'Back', exact: true }).click()
await expect(page.getByText('Step 5 / 6')).toBeVisible()
await expect(page.getByText('Mobile step preview quote').first()).toBeVisible()
await expectNoHorizontalOverflow(page)
})
})

View File

@@ -0,0 +1,255 @@
import { test, expect, type Page } from '@playwright/test'
import { execFileSync } from 'node:child_process'
import { existsSync } from 'node:fs'
import path from 'node:path'
type ReportingFixture = {
viewerEmail: string
viewerPassword: string
cardId: number
cardPath: string
}
const VITE_MANIFEST_PATH = path.join(process.cwd(), 'public', 'build', 'manifest.json')
function ensureCompiledAssets() {
if (existsSync(VITE_MANIFEST_PATH)) {
return
}
if (process.platform === 'win32') {
execFileSync('cmd.exe', ['/c', 'npm', 'run', 'build'], {
cwd: process.cwd(),
stdio: 'inherit',
})
return
}
execFileSync('npm', ['run', 'build'], {
cwd: process.cwd(),
stdio: 'inherit',
})
}
function ensureNovaCardSeedData() {
execFileSync('php', ['artisan', 'db:seed', '--class=Database\\Seeders\\NovaCardCategorySeeder', '--no-interaction'], {
cwd: process.cwd(),
stdio: 'inherit',
})
execFileSync('php', ['artisan', 'db:seed', '--class=Database\\Seeders\\NovaCardTemplateSeeder', '--no-interaction'], {
cwd: process.cwd(),
stdio: 'inherit',
})
}
function resetBotProtectionState() {
const script = [
'use Illuminate\\Support\\Facades\\DB;',
'use Illuminate\\Support\\Facades\\Schema;',
"foreach (['forum_bot_logs', 'forum_bot_ip_blacklist', 'forum_bot_device_fingerprints', 'forum_bot_behavior_profiles'] as $table) {",
' if (Schema::hasTable($table)) {',
' DB::table($table)->delete();',
' }',
'}',
"echo 'ok';",
].join(' ')
execFileSync('php', ['artisan', 'tinker', `--execute=${script}`], {
cwd: process.cwd(),
encoding: 'utf8',
})
}
function seedReportingFixture(): ReportingFixture {
const token = `${Date.now().toString().slice(-6)}${Math.floor(Math.random() * 1000).toString().padStart(3, '0')}`
const viewerEmail = `e2e-nova-report-${token}@example.test`
const viewerUsername = `nrviewer${token}`.slice(0, 20)
const creatorEmail = `e2e-nova-report-creator-${token}@example.test`
const creatorUsername = `nrcreator${token}`.slice(0, 20)
const cardSlug = `nova-report-card-${token}`
const script = [
'use App\\Models\\NovaCard;',
'use App\\Models\\NovaCardCategory;',
'use App\\Models\\NovaCardTemplate;',
'use App\\Models\\User;',
'use Illuminate\\Support\\Facades\\Hash;',
`$viewer = User::updateOrCreate(['email' => '${viewerEmail}'], [`,
" 'name' => 'E2E Nova Report Viewer',",
` 'username' => '${viewerUsername}',`,
" 'onboarding_step' => 'complete',",
" 'email_verified_at' => now(),",
" 'is_active' => 1,",
" 'password' => Hash::make('password'),",
']);',
`$creator = User::updateOrCreate(['email' => '${creatorEmail}'], [`,
" 'name' => 'E2E Nova Report Creator',",
` 'username' => '${creatorUsername}',`,
" 'onboarding_step' => 'complete',",
" 'email_verified_at' => now(),",
" 'is_active' => 1,",
" 'password' => Hash::make('password'),",
']);',
'$category = NovaCardCategory::query()->orderBy("id")->first();',
'$template = NovaCardTemplate::query()->orderBy("id")->first();',
'$card = NovaCard::query()->create([',
' "user_id" => $creator->id,',
' "category_id" => $category->id,',
' "template_id" => $template->id,',
" 'title' => 'Playwright reportable card',",
` 'slug' => '${cardSlug}',`,
" 'quote_text' => 'Reported via Playwright.',",
" 'format' => NovaCard::FORMAT_SQUARE,",
' "project_json" => [',
' "content" => ["title" => "Playwright reportable card", "quote_text" => "Reported via Playwright."],',
' "layout" => ["layout" => "quote_heavy", "position" => "center", "alignment" => "center", "padding" => "comfortable", "max_width" => "balanced"],',
' "typography" => ["font_preset" => "modern-sans", "text_color" => "#ffffff", "accent_color" => "#e0f2fe", "quote_size" => 72, "author_size" => 28, "letter_spacing" => 0, "line_height" => 1.2, "shadow_preset" => "soft"],',
' "background" => ["type" => "gradient", "gradient_preset" => "midnight-nova", "gradient_colors" => ["#0f172a", "#1d4ed8"], "overlay_style" => "dark-soft", "focal_position" => "center", "blur_level" => 0, "opacity" => 50],',
' "decorations" => [],',
' ],',
" 'render_version' => 2,",
" 'schema_version' => 2,",
" 'background_type' => 'gradient',",
" 'visibility' => NovaCard::VISIBILITY_PUBLIC,",
" 'status' => NovaCard::STATUS_PUBLISHED,",
" 'moderation_status' => NovaCard::MOD_APPROVED,",
" 'allow_download' => true,",
" 'allow_remix' => true,",
" 'published_at' => now()->subMinute(),",
']);',
'echo json_encode([',
' "viewerEmail" => $viewer->email,',
' "viewerPassword" => "password",',
' "cardId" => $card->id,',
' "cardPath" => "/cards/{$card->slug}-{$card->id}",',
']);',
].join(' ')
const raw = execFileSync('php', ['artisan', 'tinker', `--execute=${script}`], {
cwd: process.cwd(),
encoding: 'utf8',
})
const jsonLine = raw
.split(/\r?\n/)
.map((line) => line.trim())
.filter(Boolean)
.reverse()
.find((line) => line.startsWith('{') && line.endsWith('}'))
if (!jsonLine) {
throw new Error(`Unable to parse Nova Card reporting fixture JSON: ${raw}`)
}
return JSON.parse(jsonLine) as ReportingFixture
}
function reportCount(cardId: number, viewerEmail: string): number {
const script = [
'use App\\Models\\Report;',
'use App\\Models\\User;',
`$viewer = User::query()->where('email', '${viewerEmail}')->first();`,
'if (! $viewer) { echo 0; return; }',
`echo Report::query()->where('reporter_id', $viewer->id)->where('target_type', 'nova_card')->where('target_id', ${cardId})->count();`,
].join(' ')
const raw = execFileSync('php', ['artisan', 'tinker', `--execute=${script}`], {
cwd: process.cwd(),
encoding: 'utf8',
})
const parsed = Number.parseInt(raw.trim().split(/\s+/).pop() || '0', 10)
return Number.isNaN(parsed) ? 0 : parsed
}
async function login(page: Page, fixture: ReportingFixture) {
for (let attempt = 0; attempt < 2; attempt += 1) {
await page.goto('/login')
const emailField = page.locator('input[name="email"]')
const internalServerError = page.getByText('Internal Server Error')
await Promise.race([
emailField.waitFor({ state: 'visible', timeout: 8000 }),
internalServerError.waitFor({ state: 'visible', timeout: 8000 }),
])
if ((await internalServerError.isVisible().catch(() => false)) && !(await emailField.isVisible().catch(() => false))) {
throw new Error('Nova Cards reporting login failed because the login page returned an internal server error.')
}
await emailField.fill(fixture.viewerEmail)
await page.locator('input[name="password"]').fill(fixture.viewerPassword)
await page.getByRole('button', { name: 'Sign In' }).click()
try {
await page.waitForURL((url) => url.pathname !== '/login', { timeout: 8000, waitUntil: 'domcontentloaded' })
await expect(page.getByRole('button', { name: /E2E Nova Report Viewer/i })).toBeVisible()
return
} catch {
const suspiciousActivity = page.getByText('Suspicious activity detected.')
if (attempt === 0 && (await suspiciousActivity.isVisible().catch(() => false))) {
resetBotProtectionState()
continue
}
throw new Error('Nova Cards reporting login failed before reaching an authenticated page.')
}
}
}
test.describe('Nova Cards reporting', () => {
test.describe.configure({ mode: 'serial' })
let fixture: ReportingFixture
test.beforeAll(() => {
ensureCompiledAssets()
ensureNovaCardSeedData()
fixture = seedReportingFixture()
})
test.beforeEach(() => {
resetBotProtectionState()
})
test('authenticated viewers can submit a report from the public card page', async ({ page }) => {
await login(page, fixture)
await page.goto(fixture.cardPath, { waitUntil: 'domcontentloaded' })
await expect(page.locator('[data-card-report]')).toBeVisible()
const dialogExpectations = [
{ type: 'prompt', message: 'Why are you reporting this card?', value: 'Playwright report reason' },
{ type: 'prompt', message: 'Add extra details for moderators (optional)', value: 'Playwright detail for moderation context.' },
{ type: 'alert', message: 'Report submitted. Thank you.' },
]
let handledDialogs = 0
const dialogHandler = async (dialog: Parameters<Page['on']>[1] extends (event: infer T) => any ? T : never) => {
const expected = dialogExpectations[handledDialogs]
expect(expected).toBeTruthy()
expect(dialog.type()).toBe(expected.type)
expect(dialog.message()).toBe(expected.message)
if (expected.type === 'prompt') {
await dialog.accept(expected.value)
} else {
await dialog.accept()
}
handledDialogs += 1
}
page.on('dialog', dialogHandler)
await page.locator('[data-card-report]').click()
await expect.poll(() => handledDialogs, { timeout: 10000 }).toBe(3)
await expect.poll(() => reportCount(fixture.cardId, fixture.viewerEmail), { timeout: 10000 }).toBe(1)
page.off('dialog', dialogHandler)
})
})