527 lines
18 KiB
PHP
527 lines
18 KiB
PHP
<?php
|
|
|
|
use App\Models\User;
|
|
use App\Models\Artwork;
|
|
use App\Models\ArtworkStats;
|
|
use Illuminate\Support\Facades\DB;
|
|
use Inertia\Testing\AssertableInertia;
|
|
|
|
/**
|
|
* Helper: create an artwork without triggering observers (avoids GREATEST() SQLite issue).
|
|
*/
|
|
function studioArtwork(array $attrs = []): Artwork
|
|
{
|
|
return Artwork::withoutEvents(fn () => Artwork::factory()->create($attrs));
|
|
}
|
|
|
|
beforeEach(function () {
|
|
// Register GREATEST() polyfill for SQLite (used by observers on user_statistics)
|
|
if (DB::connection()->getDriverName() === 'sqlite') {
|
|
DB::connection()->getPdo()->sqliteCreateFunction('GREATEST', function (...$args) {
|
|
return max($args);
|
|
}, -1);
|
|
}
|
|
|
|
$this->user = User::factory()->create();
|
|
$this->actingAs($this->user);
|
|
});
|
|
|
|
// ── Route Auth Tests ──────────────────────────────────────────────────────────
|
|
|
|
test('studio routes require authentication', function () {
|
|
auth()->logout();
|
|
|
|
$routes = [
|
|
'/studio',
|
|
'/studio/content',
|
|
'/studio/artworks',
|
|
'/studio/cards',
|
|
'/studio/collections',
|
|
'/studio/stories',
|
|
'/studio/drafts',
|
|
'/studio/scheduled',
|
|
'/studio/calendar',
|
|
'/studio/archived',
|
|
'/studio/assets',
|
|
'/studio/activity',
|
|
'/studio/inbox',
|
|
'/studio/challenges',
|
|
'/studio/analytics',
|
|
'/studio/growth',
|
|
'/studio/search',
|
|
'/studio/comments',
|
|
'/studio/followers',
|
|
'/studio/profile',
|
|
'/studio/featured',
|
|
'/studio/preferences',
|
|
'/studio/settings',
|
|
];
|
|
|
|
foreach ($routes as $route) {
|
|
$this->get($route)->assertRedirect('/login');
|
|
}
|
|
});
|
|
|
|
test('studio dashboard loads for authenticated user', function () {
|
|
$this->get('/studio')
|
|
->assertStatus(200)
|
|
->assertInertia(fn (AssertableInertia $page) => $page
|
|
->component('Studio/StudioDashboard')
|
|
->has('overview.active_challenges')
|
|
->has('overview.creator_health')
|
|
->has('overview.featured_status'));
|
|
});
|
|
|
|
test('studio dashboard honors saved landing page preference', function () {
|
|
DB::table('dashboard_preferences')->insert([
|
|
'user_id' => $this->user->id,
|
|
'studio_preferences' => json_encode([
|
|
'default_landing_page' => 'scheduled',
|
|
], JSON_THROW_ON_ERROR),
|
|
'created_at' => now(),
|
|
'updated_at' => now(),
|
|
]);
|
|
|
|
$this->get('/studio')
|
|
->assertRedirect('/studio/scheduled');
|
|
});
|
|
|
|
test('studio artworks page loads', function () {
|
|
$this->get('/studio/artworks')
|
|
->assertStatus(200)
|
|
->assertInertia(fn (AssertableInertia $page) => $page->component('Studio/StudioArtworks')->where('title', 'Artworks'));
|
|
});
|
|
|
|
test('studio drafts page loads', function () {
|
|
$this->get('/studio/artworks/drafts')
|
|
->assertRedirect('/studio/drafts');
|
|
|
|
$this->get('/studio/drafts')
|
|
->assertStatus(200)
|
|
->assertInertia(fn (AssertableInertia $page) => $page->component('Studio/StudioDrafts')->where('title', 'Drafts'));
|
|
});
|
|
|
|
test('studio archived page loads', function () {
|
|
$this->get('/studio/artworks/archived')
|
|
->assertRedirect('/studio/archived');
|
|
|
|
$this->get('/studio/archived')
|
|
->assertStatus(200)
|
|
->assertInertia(fn (AssertableInertia $page) => $page->component('Studio/StudioArchived')->where('title', 'Archived'));
|
|
});
|
|
|
|
test('studio scheduled page loads', function () {
|
|
$this->get('/studio/scheduled')
|
|
->assertStatus(200)
|
|
->assertInertia(fn (AssertableInertia $page) => $page
|
|
->component('Studio/StudioScheduled')
|
|
->where('title', 'Scheduled')
|
|
->has('listing.items')
|
|
->has('listing.summary')
|
|
->has('listing.agenda')
|
|
->has('listing.range_options')
|
|
->where('endpoints.publishNowPattern', route('api.studio.schedule.publishNow', ['module' => '__MODULE__', 'id' => '__ID__']))
|
|
->where('endpoints.unschedulePattern', route('api.studio.schedule.unschedule', ['module' => '__MODULE__', 'id' => '__ID__'])));
|
|
});
|
|
|
|
test('studio calendar page loads', function () {
|
|
$this->get('/studio/calendar')
|
|
->assertStatus(200)
|
|
->assertInertia(fn (AssertableInertia $page) => $page
|
|
->component('Studio/StudioCalendar')
|
|
->where('title', 'Calendar')
|
|
->has('calendar.month.days')
|
|
->has('calendar.week.days')
|
|
->has('calendar.agenda')
|
|
->has('calendar.unscheduled_items')
|
|
->where('endpoints.publishNowPattern', route('api.studio.schedule.publishNow', ['module' => '__MODULE__', 'id' => '__ID__'])));
|
|
});
|
|
|
|
test('studio activity page loads', function () {
|
|
$this->get('/studio/activity')
|
|
->assertStatus(200)
|
|
->assertInertia(fn (AssertableInertia $page) => $page
|
|
->component('Studio/StudioActivity')
|
|
->where('title', 'Activity')
|
|
->has('listing.items')
|
|
->has('listing.summary')
|
|
->has('listing.module_options')
|
|
->where('endpoints.markAllRead', route('api.studio.activity.readAll')));
|
|
});
|
|
|
|
test('studio inbox page loads', function () {
|
|
$this->get('/studio/inbox')
|
|
->assertStatus(200)
|
|
->assertInertia(fn (AssertableInertia $page) => $page
|
|
->component('Studio/StudioInbox')
|
|
->where('title', 'Inbox')
|
|
->has('inbox.items')
|
|
->has('inbox.panels.attention_now')
|
|
->has('inbox.read_state_options')
|
|
->has('inbox.priority_options')
|
|
->where('endpoints.markAllRead', route('api.studio.activity.readAll')));
|
|
});
|
|
|
|
test('studio challenges page exposes challenge workflow payload', function () {
|
|
$this->get('/studio/challenges')
|
|
->assertStatus(200)
|
|
->assertInertia(fn (AssertableInertia $page) => $page
|
|
->component('Studio/StudioChallenges')
|
|
->where('title', 'Challenges')
|
|
->has('summary')
|
|
->has('activeChallenges')
|
|
->has('recentEntries')
|
|
->has('cardLeaders')
|
|
->has('reminders'));
|
|
});
|
|
|
|
test('studio search page loads', function () {
|
|
$this->get('/studio/search')
|
|
->assertStatus(200)
|
|
->assertInertia(fn (AssertableInertia $page) => $page
|
|
->component('Studio/StudioSearch')
|
|
->where('title', 'Search')
|
|
->has('search.filters')
|
|
->has('search.empty_state.continue_working')
|
|
->has('quickCreate'));
|
|
});
|
|
|
|
test('studio followers page loads', function () {
|
|
$this->get('/studio/followers')
|
|
->assertStatus(200)
|
|
->assertInertia(fn (AssertableInertia $page) => $page->component('Studio/StudioFollowers')->where('title', 'Followers'));
|
|
});
|
|
|
|
test('studio assets page exposes asset library payload', function () {
|
|
studioArtwork([
|
|
'user_id' => $this->user->id,
|
|
]);
|
|
|
|
$this->get('/studio/assets')
|
|
->assertStatus(200)
|
|
->assertInertia(fn (AssertableInertia $page) => $page
|
|
->component('Studio/StudioAssets')
|
|
->where('title', 'Assets')
|
|
->has('assets.items')
|
|
->has('assets.meta')
|
|
->has('assets.filters')
|
|
->has('assets.type_options')
|
|
->has('assets.source_options')
|
|
->has('assets.sort_options')
|
|
->has('assets.summary')
|
|
->has('assets.items.0.usage_references'));
|
|
});
|
|
|
|
test('studio comments page exposes unified comments contract', function () {
|
|
$this->get('/studio/comments')
|
|
->assertStatus(200)
|
|
->assertInertia(fn (AssertableInertia $page) => $page
|
|
->component('Studio/StudioComments')
|
|
->where('title', 'Comments')
|
|
->has('listing.items')
|
|
->has('listing.meta')
|
|
->has('listing.filters')
|
|
->has('listing.module_options')
|
|
->where('endpoints.replyPattern', route('api.studio.comments.reply', ['module' => '__MODULE__', 'commentId' => '__COMMENT__']))
|
|
->where('endpoints.moderatePattern', route('api.studio.comments.moderate', ['module' => '__MODULE__', 'commentId' => '__COMMENT__']))
|
|
->where('endpoints.reportPattern', route('api.studio.comments.report', ['module' => '__MODULE__', 'commentId' => '__COMMENT__'])));
|
|
});
|
|
|
|
test('studio profile page exposes editable profile contract', function () {
|
|
$this->get('/studio/profile')
|
|
->assertStatus(200)
|
|
->assertInertia(fn (AssertableInertia $page) => $page
|
|
->component('Studio/StudioProfile')
|
|
->where('title', 'Profile')
|
|
->where('profile.name', $this->user->name)
|
|
->where('profile.username', $this->user->username)
|
|
->has('profile.social_links')
|
|
->has('moduleSummaries')
|
|
->has('featuredModules')
|
|
->has('featuredContent')
|
|
->where('endpoints.profile', route('api.studio.preferences.profile'))
|
|
->where('endpoints.avatarUpload', route('avatar.upload'))
|
|
->where('endpoints.coverUpload', route('api.profile.cover.upload'))
|
|
->where('endpoints.coverPosition', route('api.profile.cover.position'))
|
|
->where('endpoints.coverDelete', route('api.profile.cover.destroy')));
|
|
});
|
|
|
|
test('studio featured page exposes selection contract', function () {
|
|
$this->get('/studio/featured')
|
|
->assertStatus(200)
|
|
->assertInertia(fn (AssertableInertia $page) => $page
|
|
->component('Studio/StudioFeatured')
|
|
->where('title', 'Featured')
|
|
->has('items')
|
|
->has('selected')
|
|
->has('featuredModules')
|
|
->where('endpoints.save', route('api.studio.preferences.featured')));
|
|
});
|
|
|
|
test('studio preferences page exposes preference contract', function () {
|
|
$this->get('/studio/preferences')
|
|
->assertStatus(200)
|
|
->assertInertia(fn (AssertableInertia $page) => $page
|
|
->component('Studio/StudioPreferences')
|
|
->where('title', 'Preferences')
|
|
->has('preferences')
|
|
->where('preferences.default_landing_page', 'overview')
|
|
->has('preferences.widget_visibility')
|
|
->has('preferences.widget_order')
|
|
->has('links')
|
|
->where('endpoints.save', route('api.studio.preferences.settings')));
|
|
});
|
|
|
|
test('studio settings page exposes settings handoff payload', function () {
|
|
$this->get('/studio/settings')
|
|
->assertStatus(200)
|
|
->assertInertia(fn (AssertableInertia $page) => $page
|
|
->component('Studio/StudioSettings')
|
|
->where('title', 'Settings')
|
|
->has('links')
|
|
->has('sections'));
|
|
});
|
|
|
|
test('studio analytics page exposes trend and comparison payloads', function () {
|
|
$this->get('/studio/analytics')
|
|
->assertStatus(200)
|
|
->assertInertia(fn (AssertableInertia $page) => $page
|
|
->component('Studio/StudioAnalytics')
|
|
->has('totals')
|
|
->has('topContent')
|
|
->has('moduleBreakdown')
|
|
->has('viewsTrend')
|
|
->has('engagementTrend')
|
|
->has('publishingTimeline')
|
|
->has('comparison')
|
|
->has('insightBlocks')
|
|
->where('rangeDays', 30)
|
|
->has('recentComments'));
|
|
});
|
|
|
|
test('studio analytics page respects requested date range', function () {
|
|
$this->get('/studio/analytics?range_days=14')
|
|
->assertStatus(200)
|
|
->assertInertia(fn (AssertableInertia $page) => $page
|
|
->component('Studio/StudioAnalytics')
|
|
->where('rangeDays', 14));
|
|
});
|
|
|
|
test('studio growth page exposes growth workspace payloads', function () {
|
|
$this->get('/studio/growth')
|
|
->assertStatus(200)
|
|
->assertInertia(fn (AssertableInertia $page) => $page
|
|
->component('Studio/StudioGrowth')
|
|
->where('title', 'Growth')
|
|
->has('summary')
|
|
->has('moduleFocus')
|
|
->has('checkpoints')
|
|
->has('opportunities')
|
|
->has('milestones')
|
|
->has('momentum.views_trend')
|
|
->has('momentum.engagement_trend')
|
|
->has('momentum.publishing_timeline')
|
|
->where('rangeDays', 30));
|
|
});
|
|
|
|
test('studio growth page respects requested date range', function () {
|
|
$this->get('/studio/growth?range_days=14')
|
|
->assertStatus(200)
|
|
->assertInertia(fn (AssertableInertia $page) => $page
|
|
->component('Studio/StudioGrowth')
|
|
->where('rangeDays', 14));
|
|
});
|
|
|
|
// ── API Tests ─────────────────────────────────────────────────────────────────
|
|
|
|
test('studio api requires authentication', function () {
|
|
auth()->logout();
|
|
|
|
$this->getJson('/api/studio/artworks')
|
|
->assertStatus(401);
|
|
|
|
$this->postJson('/api/studio/events', [
|
|
'event_type' => 'studio_opened',
|
|
])->assertStatus(401);
|
|
});
|
|
|
|
test('studio events api accepts known creator studio hooks', function () {
|
|
$this->postJson('/api/studio/events', [
|
|
'event_type' => 'studio_filter_used',
|
|
'module' => 'artworks',
|
|
'surface' => '/studio/artworks',
|
|
'item_module' => 'artworks',
|
|
'item_id' => 42,
|
|
'meta' => [
|
|
'filter' => 'bucket',
|
|
'value' => 'drafts',
|
|
],
|
|
])
|
|
->assertStatus(202)
|
|
->assertJsonPath('ok', true);
|
|
});
|
|
|
|
test('studio api returns artworks for authenticated user', function () {
|
|
// Create artworks for this user
|
|
$artwork = studioArtwork([
|
|
'user_id' => $this->user->id,
|
|
'is_public' => true,
|
|
'is_approved' => true,
|
|
]);
|
|
|
|
ArtworkStats::create([
|
|
'artwork_id' => $artwork->id,
|
|
'views' => 100,
|
|
'downloads' => 10,
|
|
'favorites' => 5,
|
|
]);
|
|
|
|
$this->getJson('/api/studio/artworks')
|
|
->assertStatus(200)
|
|
->assertJsonStructure([
|
|
'data' => [['id', 'title', 'slug', 'views', 'favourites']],
|
|
'meta' => ['current_page', 'last_page', 'per_page', 'total'],
|
|
]);
|
|
});
|
|
|
|
test('studio api does not return other users artworks', function () {
|
|
$otherUser = User::factory()->create();
|
|
studioArtwork([
|
|
'user_id' => $otherUser->id,
|
|
'is_public' => true,
|
|
'is_approved' => true,
|
|
]);
|
|
|
|
$this->getJson('/api/studio/artworks')
|
|
->assertStatus(200)
|
|
->assertJsonCount(0, 'data');
|
|
});
|
|
|
|
// ── Bulk Action Tests ─────────────────────────────────────────────────────────
|
|
|
|
test('bulk archive works on owned artworks', function () {
|
|
$artwork = studioArtwork([
|
|
'user_id' => $this->user->id,
|
|
'is_public' => true,
|
|
]);
|
|
|
|
$this->postJson('/api/studio/artworks/bulk', [
|
|
'action' => 'archive',
|
|
'artwork_ids' => [$artwork->id],
|
|
])
|
|
->assertStatus(200)
|
|
->assertJsonPath('success', 1);
|
|
|
|
expect($artwork->fresh()->trashed())->toBeTrue();
|
|
});
|
|
|
|
test('bulk delete requires confirmation', function () {
|
|
$artwork = studioArtwork(['user_id' => $this->user->id]);
|
|
|
|
$this->postJson('/api/studio/artworks/bulk', [
|
|
'action' => 'delete',
|
|
'artwork_ids' => [$artwork->id],
|
|
])
|
|
->assertStatus(422);
|
|
});
|
|
|
|
test('bulk delete with confirmation works', function () {
|
|
$artwork = studioArtwork(['user_id' => $this->user->id]);
|
|
|
|
$this->postJson('/api/studio/artworks/bulk', [
|
|
'action' => 'delete',
|
|
'artwork_ids' => [$artwork->id],
|
|
'confirm' => 'DELETE',
|
|
])
|
|
->assertStatus(200)
|
|
->assertJsonPath('success', 1);
|
|
});
|
|
|
|
test('bulk publish on owned artworks', function () {
|
|
$artwork = studioArtwork([
|
|
'user_id' => $this->user->id,
|
|
'is_public' => false,
|
|
]);
|
|
|
|
$this->postJson('/api/studio/artworks/bulk', [
|
|
'action' => 'publish',
|
|
'artwork_ids' => [$artwork->id],
|
|
])
|
|
->assertStatus(200)
|
|
->assertJsonPath('success', 1);
|
|
|
|
expect($artwork->fresh()->is_public)->toBeTrue();
|
|
});
|
|
|
|
test('bulk action cannot modify other users artworks', function () {
|
|
$otherUser = User::factory()->create();
|
|
$artwork = studioArtwork(['user_id' => $otherUser->id]);
|
|
|
|
$this->postJson('/api/studio/artworks/bulk', [
|
|
'action' => 'archive',
|
|
'artwork_ids' => [$artwork->id],
|
|
])
|
|
->assertStatus(422)
|
|
->assertJsonPath('success', 0)
|
|
->assertJsonPath('failed', 1);
|
|
});
|
|
|
|
// ── Toggle Tests ──────────────────────────────────────────────────────────────
|
|
|
|
test('toggle publish on single artwork', function () {
|
|
$artwork = studioArtwork([
|
|
'user_id' => $this->user->id,
|
|
'is_public' => false,
|
|
]);
|
|
|
|
$this->postJson("/api/studio/artworks/{$artwork->id}/toggle", [
|
|
'action' => 'publish',
|
|
])
|
|
->assertStatus(200)
|
|
->assertJsonPath('success', true);
|
|
|
|
expect($artwork->fresh()->is_public)->toBeTrue();
|
|
});
|
|
|
|
test('toggle on non-owned artwork returns 404', function () {
|
|
$otherUser = User::factory()->create();
|
|
$artwork = studioArtwork(['user_id' => $otherUser->id]);
|
|
|
|
$this->postJson("/api/studio/artworks/{$artwork->id}/toggle", [
|
|
'action' => 'archive',
|
|
])
|
|
->assertStatus(404);
|
|
});
|
|
|
|
// ── Analytics API Tests ───────────────────────────────────────────────────────
|
|
|
|
test('analytics api returns artwork stats', function () {
|
|
$artwork = studioArtwork(['user_id' => $this->user->id]);
|
|
ArtworkStats::create([
|
|
'artwork_id' => $artwork->id,
|
|
'views' => 500,
|
|
'downloads' => 20,
|
|
'favorites' => 30,
|
|
'shares_count' => 10,
|
|
'comments_count' => 5,
|
|
'ranking_score' => 42.5,
|
|
'heat_score' => 8.3,
|
|
]);
|
|
|
|
$this->getJson("/api/studio/artworks/{$artwork->id}/analytics")
|
|
->assertStatus(200)
|
|
->assertJsonStructure([
|
|
'artwork' => ['id', 'title', 'slug'],
|
|
'analytics' => ['views', 'favourites', 'shares', 'comments', 'downloads', 'ranking_score', 'heat_score'],
|
|
]);
|
|
});
|
|
|
|
test('analytics api denies access to other users artwork', function () {
|
|
$otherUser = User::factory()->create();
|
|
$artwork = studioArtwork(['user_id' => $otherUser->id]);
|
|
|
|
$this->getJson("/api/studio/artworks/{$artwork->id}/analytics")
|
|
->assertStatus(404);
|
|
});
|
|
|