optimizations
This commit is contained in:
111
tests/Feature/Admin/DiscoveryFeedbackReportTest.php
Normal file
111
tests/Feature/Admin/DiscoveryFeedbackReportTest.php
Normal 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);
|
||||
});
|
||||
55
tests/Feature/Admin/FeedEngineDecisionReportTest.php
Normal file
55
tests/Feature/Admin/FeedEngineDecisionReportTest.php
Normal 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);
|
||||
});
|
||||
31
tests/Feature/Api/ArtworkDraftApiTest.php
Normal file
31
tests/Feature/Api/ArtworkDraftApiTest.php
Normal 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();
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
3846
tests/Feature/Collections/CollectionsFeatureTest.php
Normal file
3846
tests/Feature/Collections/CollectionsFeatureTest.php
Normal file
File diff suppressed because it is too large
Load Diff
1277
tests/Feature/Collections/CollectionsV5FeatureTest.php
Normal file
1277
tests/Feature/Collections/CollectionsV5FeatureTest.php
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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';
|
||||
});
|
||||
});
|
||||
|
||||
95
tests/Feature/Discovery/DiscoveryNegativeSignalTest.php
Normal file
95
tests/Feature/Discovery/DiscoveryNegativeSignalTest.php
Normal 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();
|
||||
});
|
||||
262
tests/Feature/Discovery/FeedEndpointV2Test.php
Normal file
262
tests/Feature/Discovery/FeedEndpointV2Test.php
Normal 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);
|
||||
});
|
||||
@@ -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 () {
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
393
tests/Feature/NovaCards/NovaCardAdminTest.php
Normal file
393
tests/Feature/NovaCards/NovaCardAdminTest.php
Normal 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();
|
||||
});
|
||||
468
tests/Feature/NovaCards/NovaCardDraftApiTest.php
Normal file
468
tests/Feature/NovaCards/NovaCardDraftApiTest.php
Normal 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();
|
||||
});
|
||||
621
tests/Feature/NovaCards/NovaCardPublicPagesTest.php
Normal file
621
tests/Feature/NovaCards/NovaCardPublicPagesTest.php
Normal 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();
|
||||
});
|
||||
358
tests/Feature/NovaCards/NovaCardReportingTest.php
Normal file
358
tests/Feature/NovaCards/NovaCardReportingTest.php
Normal 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"');
|
||||
});
|
||||
32
tests/Feature/NovaCards/NovaCardSeederHooksTest.php
Normal file
32
tests/Feature/NovaCards/NovaCardSeederHooksTest.php
Normal 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);
|
||||
});
|
||||
249
tests/Feature/NovaCards/NovaCardStudioPagesTest.php
Normal file
249
tests/Feature/NovaCards/NovaCardStudioPagesTest.php
Normal 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));
|
||||
});
|
||||
368
tests/Feature/NovaCards/NovaCardV2ApiTest.php
Normal file
368
tests/Feature/NovaCards/NovaCardV2ApiTest.php
Normal 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();
|
||||
});
|
||||
357
tests/Feature/NovaCards/NovaCardV3Test.php
Normal file
357
tests/Feature/NovaCards/NovaCardV3Test.php
Normal 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();
|
||||
});
|
||||
});
|
||||
@@ -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());
|
||||
});
|
||||
|
||||
@@ -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 () {
|
||||
|
||||
528
tests/Feature/Studio/StudioArtworkAiAssistApiTest.php
Normal file
528
tests/Feature/Studio/StudioArtworkAiAssistApiTest.php
Normal 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']);
|
||||
});
|
||||
182
tests/Feature/Uploads/UploadFinishAiDispatchTest.php
Normal file
182
tests/Feature/Uploads/UploadFinishAiDispatchTest.php
Normal 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);
|
||||
});
|
||||
@@ -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]);
|
||||
|
||||
110
tests/Feature/Vision/AiArtworkSearchApiTest.php
Normal file
110
tests/Feature/Vision/AiArtworkSearchApiTest.php
Normal 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;
|
||||
});
|
||||
});
|
||||
@@ -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';
|
||||
});
|
||||
});
|
||||
|
||||
153
tests/Feature/Vision/ArtworkVectorRepairQueueTest.php
Normal file
153
tests/Feature/Vision/ArtworkVectorRepairQueueTest.php
Normal 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);
|
||||
});
|
||||
141
tests/Feature/Vision/GenerateArtworkEmbeddingJobTest.php
Normal file
141
tests/Feature/Vision/GenerateArtworkEmbeddingJobTest.php
Normal 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);
|
||||
});
|
||||
82
tests/Feature/Vision/UploadVisionSuggestApiTest.php
Normal file
82
tests/Feature/Vision/UploadVisionSuggestApiTest.php
Normal 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', []);
|
||||
});
|
||||
Reference in New Issue
Block a user