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

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