insertGetId([ 'name' => 'Digital Art', 'slug' => 'digital-art-' . Str::lower(Str::random(6)), 'description' => null, 'created_at' => now(), 'updated_at' => now(), ]); return DB::table('categories')->insertGetId([ 'content_type_id' => $contentTypeId, 'parent_id' => null, 'name' => 'Concept', 'slug' => 'concept-' . Str::lower(Str::random(6)), 'description' => null, 'image' => null, 'is_active' => true, 'sort_order' => 0, 'created_at' => now(), 'updated_at' => now(), ]); } it('publishes an artwork under a group with credited authors', function () { $owner = User::factory()->create(); $editor = User::factory()->create(); $group = Group::factory()->for($owner, 'owner')->create(); app(GroupMembershipService::class)->ensureOwnerMembership($group); GroupMember::query()->create([ 'group_id' => $group->id, 'user_id' => $editor->id, 'invited_by_user_id' => $owner->id, 'role' => Group::ROLE_EDITOR, 'status' => Group::STATUS_ACTIVE, 'invited_at' => now(), 'accepted_at' => now(), ]); $artwork = Artwork::factory()->unpublished()->for($editor, 'user')->create([ 'is_public' => false, 'visibility' => Artwork::VISIBILITY_PRIVATE, 'is_approved' => false, 'artwork_status' => 'draft', ]); $response = $this->actingAs($editor)->postJson("/api/uploads/{$artwork->id}/publish", [ 'group' => $group->slug, 'primary_author_user_id' => $owner->id, 'contributor_user_ids' => [$editor->id], 'contributor_credits' => [ [ 'user_id' => $editor->id, 'credit_role' => 'Color assist', 'is_primary' => true, ], ], 'visibility' => 'public', ]); $response->assertOk()->assertJson([ 'success' => true, 'artwork_id' => $artwork->id, 'status' => 'published', ]); $artwork->refresh()->load(['contributors', 'group', 'primaryAuthor', 'uploadedBy']); $group->refresh(); expect($artwork->group_id)->toBe($group->id) ->and($artwork->published_as_type)->toBe(Artwork::PUBLISHED_AS_GROUP) ->and($artwork->published_as_id)->toBe($group->id) ->and($artwork->uploaded_by_user_id)->toBe($editor->id) ->and($artwork->primary_author_user_id)->toBe($owner->id) ->and($artwork->contributors)->toHaveCount(1) ->and($artwork->contributors->first()->user_id)->toBe($editor->id) ->and($artwork->contributors->first()->credit_role)->toBe('Color assist') ->and($artwork->contributors->first()->is_primary)->toBeTrue() ->and($artwork->artwork_status)->toBe('published') ->and($group->artworks_count)->toBe(1); }); it('renders the enriched studio group dashboard payload', function () { $owner = User::factory()->create(); $editor = User::factory()->create(); $group = Group::factory()->for($owner, 'owner')->create(); app(GroupMembershipService::class)->ensureOwnerMembership($group); GroupMember::query()->create([ 'group_id' => $group->id, 'user_id' => $editor->id, 'invited_by_user_id' => $owner->id, 'role' => Group::ROLE_EDITOR, 'status' => Group::STATUS_ACTIVE, 'invited_at' => now(), 'accepted_at' => now(), ]); Artwork::factory()->unpublished()->for($owner, 'user')->create([ 'group_id' => $group->id, 'uploaded_by_user_id' => $owner->id, 'primary_author_user_id' => $owner->id, 'artwork_status' => 'draft', 'visibility' => Artwork::VISIBILITY_PRIVATE, 'is_public' => false, 'is_approved' => false, ]); Artwork::factory()->for($owner, 'user')->create([ 'group_id' => $group->id, 'uploaded_by_user_id' => $owner->id, 'primary_author_user_id' => $owner->id, 'artwork_status' => 'published', ]); Collection::factory()->for($owner, 'user')->create([ 'group_id' => $group->id, 'visibility' => Collection::VISIBILITY_PUBLIC, ]); $this->actingAs($owner) ->get(route('studio.groups.show', ['group' => $group])) ->assertOk() ->assertInertia(fn (AssertableInertia $page) => $page ->component('Studio/StudioGroupDashboard') ->where('studioGroup.slug', $group->slug) ->where('dashboard.draft_artworks_count', 1) ->where('dashboard.active_members_count', 2) ->where('draftsPendingAction.0.status', 'draft') ->where('recentArtworks.0.status', 'published') ->has('recentCollections', 1) ); }); it('renders public group pages and accepts group reports', function () { $viewer = User::factory()->create(); $owner = User::factory()->create(); $group = Group::factory()->for($owner, 'owner')->create([ 'visibility' => Group::VISIBILITY_PUBLIC, ]); $admin = User::factory()->create(); $featuredArtwork = Artwork::factory()->for($owner, 'user')->create([ 'group_id' => $group->id, 'primary_author_user_id' => $owner->id, 'uploaded_by_user_id' => $owner->id, ]); $featuredCollection = Collection::factory()->for($owner, 'user')->create([ 'group_id' => $group->id, 'is_featured' => true, 'visibility' => Collection::VISIBILITY_PUBLIC, ]); app(GroupMembershipService::class)->ensureOwnerMembership($group); $group->forceFill(['featured_artwork_id' => $featuredArtwork->id])->save(); GroupMember::query()->create([ 'group_id' => $group->id, 'user_id' => $admin->id, 'invited_by_user_id' => $owner->id, 'role' => Group::ROLE_ADMIN, 'status' => Group::STATUS_ACTIVE, 'invited_at' => now(), 'accepted_at' => now(), ]); $this->get(route('groups.index')) ->assertOk() ->assertInertia(fn (AssertableInertia $page) => $page ->component('Group/GroupIndex') ->where('title', 'Groups') ); $this->actingAs($viewer) ->get(route('groups.show', ['group' => $group])) ->assertOk() ->assertInertia(fn (AssertableInertia $page) => $page ->component('Group/GroupShow') ->where('group.slug', $group->slug) ->where('section', 'overview') ->where('featuredArtworks.0.id', $featuredArtwork->id) ->where('featuredCollections.0.id', $featuredCollection->id) ->where('leadership.0.role', Group::ROLE_OWNER) ); $this->actingAs($viewer) ->postJson(route('api.reports.store'), [ 'target_type' => 'group', 'target_id' => $group->id, 'reason' => 'Impersonation risk', ]) ->assertCreated(); expect(Report::query()->where('target_type', 'group')->where('target_id', $group->id)->count())->toBe(1); }); it('lets owners manage releases, contributors, milestones, and publishing through studio endpoints', function () { $owner = User::factory()->create(); $contributor = User::factory()->create(); $group = Group::factory()->for($owner, 'owner')->create([ 'visibility' => Group::VISIBILITY_PUBLIC, ]); app(GroupMembershipService::class)->ensureOwnerMembership($group); GroupMember::query()->create([ 'group_id' => $group->id, 'user_id' => $contributor->id, 'invited_by_user_id' => $owner->id, 'role' => Group::ROLE_MEMBER, 'status' => Group::STATUS_ACTIVE, 'invited_at' => now(), 'accepted_at' => now(), ]); $artwork = Artwork::factory()->for($owner, 'user')->create([ 'group_id' => $group->id, 'uploaded_by_user_id' => $owner->id, 'primary_author_user_id' => $owner->id, 'artwork_status' => 'published', 'visibility' => Artwork::VISIBILITY_PUBLIC, 'is_public' => true, 'is_approved' => true, 'published_at' => now(), ]); $this->actingAs($owner) ->post(route('studio.groups.releases.store', ['group' => $group]), [ 'title' => 'Spring Systems Drop', 'summary' => 'A shared release pipeline for the group.', 'description' => 'Coordinated release covering artworks and public milestones.', 'visibility' => GroupRelease::VISIBILITY_PUBLIC, 'status' => GroupRelease::STATUS_PLANNED, 'current_stage' => GroupRelease::STAGE_CONCEPT, 'lead_user_id' => $owner->id, 'featured_artwork_id' => $artwork->id, 'is_featured' => true, ]) ->assertRedirect(); $release = GroupRelease::query()->where('group_id', $group->id)->firstOrFail(); $this->actingAs($owner) ->post(route('studio.groups.releases.attach-artwork', ['group' => $group, 'release' => $release]), [ 'artwork_id' => $artwork->id, ]) ->assertRedirect(); $this->actingAs($owner) ->post(route('studio.groups.releases.attach-contributor', ['group' => $group, 'release' => $release]), [ 'user_id' => $contributor->id, 'role_label' => 'Texture artist', ]) ->assertRedirect(); $this->actingAs($owner) ->post(route('studio.groups.releases.milestones.store', ['group' => $group, 'release' => $release]), [ 'title' => 'Final approval', 'summary' => 'Wrap quality checks before launch.', 'status' => 'active', 'owner_user_id' => $owner->id, ]) ->assertRedirect(); $this->actingAs($owner) ->post(route('studio.groups.releases.stage', ['group' => $group, 'release' => $release]), [ 'current_stage' => GroupRelease::STAGE_APPROVAL, ]) ->assertRedirect(); $this->actingAs($owner) ->post(route('studio.groups.releases.publish', ['group' => $group, 'release' => $release])) ->assertRedirect(); $release->refresh(); expect($release->status)->toBe(GroupRelease::STATUS_RELEASED) ->and($release->current_stage)->toBe(GroupRelease::STAGE_RELEASED) ->and($release->released_at)->not->toBeNull() ->and($release->published_at)->not->toBeNull() ->and($release->artworks()->count())->toBe(1) ->and(GroupReleaseContributor::query()->where('group_release_id', $release->id)->where('user_id', $contributor->id)->exists())->toBeTrue() ->and(GroupReleaseMilestone::query()->where('group_release_id', $release->id)->count())->toBe(1); }); it('renders public release pages and the studio reputation dashboard with v4 payloads', function () { $viewer = User::factory()->create(); $owner = User::factory()->create(); $contributor = User::factory()->create(); $group = Group::factory()->for($owner, 'owner')->create([ 'visibility' => Group::VISIBILITY_PUBLIC, 'is_verified' => true, 'last_activity_at' => now(), ]); app(GroupMembershipService::class)->ensureOwnerMembership($group); GroupMember::query()->create([ 'group_id' => $group->id, 'user_id' => $contributor->id, 'invited_by_user_id' => $owner->id, 'role' => Group::ROLE_EDITOR, 'status' => Group::STATUS_ACTIVE, 'invited_at' => now(), 'accepted_at' => now(), ]); $artwork = Artwork::factory()->for($owner, 'user')->create([ 'group_id' => $group->id, 'uploaded_by_user_id' => $owner->id, 'primary_author_user_id' => $owner->id, 'artwork_status' => 'published', 'visibility' => Artwork::VISIBILITY_PUBLIC, 'is_public' => true, 'is_approved' => true, 'group_review_status' => 'approved', 'published_at' => now(), ]); $release = GroupRelease::query()->create([ 'group_id' => $group->id, 'title' => 'Public Drop', 'slug' => 'public-drop', 'summary' => 'Release summary', 'description' => 'Release detail body', 'status' => GroupRelease::STATUS_RELEASED, 'current_stage' => GroupRelease::STAGE_RELEASED, 'visibility' => GroupRelease::VISIBILITY_PUBLIC, 'lead_user_id' => $owner->id, 'featured_artwork_id' => $artwork->id, 'created_by_user_id' => $owner->id, 'released_at' => now(), 'published_at' => now(), 'is_featured' => true, ]); $release->artworks()->attach($artwork->id, ['sort_order' => 1]); GroupReleaseContributor::query()->create([ 'group_release_id' => $release->id, 'user_id' => $contributor->id, 'role_label' => 'Editor', 'sort_order' => 1, ]); GroupReleaseMilestone::query()->create([ 'group_release_id' => $release->id, 'title' => 'Published', 'summary' => 'Release has shipped.', 'status' => 'completed', 'owner_user_id' => $owner->id, 'sort_order' => 1, ]); $this->actingAs($viewer) ->get(route('groups.releases.show', ['group' => $group, 'release' => $release])) ->assertOk() ->assertInertia(fn (AssertableInertia $page) => $page ->component('Group/GroupReleaseShow') ->where('group.slug', $group->slug) ->where('release.title', 'Public Drop') ->where('seo.canonical', route('groups.releases.show', ['group' => $group, 'release' => $release])) ->where('seo.og_type', 'article') ->has('release.artworks', 1) ->has('release.contributors', 1) ->has('release.milestones', 1) ); $this->actingAs($owner) ->get(route('studio.groups.reputation', ['group' => $group])) ->assertOk() ->assertInertia(fn (AssertableInertia $page) => $page ->component('Studio/StudioGroupReputation') ->where('studioGroup.slug', $group->slug) ->has('trustSignals') ->has('reputation.top_contributors') ); }); it('expands group search across public releases projects challenges and events', function () { $owner = User::factory()->create(); $group = Group::factory()->for($owner, 'owner')->create([ 'name' => 'Signal Crew', 'slug' => 'signal-crew', 'visibility' => Group::VISIBILITY_PUBLIC, ]); app(GroupMembershipService::class)->ensureOwnerMembership($group); GroupRelease::query()->create([ 'group_id' => $group->id, 'title' => 'Neon Release Marker', 'slug' => 'neon-release-marker', 'summary' => 'Searchable release summary', 'status' => GroupRelease::STATUS_RELEASED, 'current_stage' => GroupRelease::STAGE_RELEASED, 'visibility' => GroupRelease::VISIBILITY_PUBLIC, 'created_by_user_id' => $owner->id, 'released_at' => now(), 'published_at' => now(), ]); GroupProject::query()->create([ 'group_id' => $group->id, 'title' => 'Orbit Project Marker', 'slug' => 'orbit-project-marker', 'summary' => 'Searchable project summary', 'visibility' => GroupProject::VISIBILITY_PUBLIC, 'status' => GroupProject::STATUS_ACTIVE, 'created_by_user_id' => $owner->id, ]); GroupChallenge::query()->create([ 'group_id' => $group->id, 'title' => 'Pulse Challenge Marker', 'slug' => 'pulse-challenge-marker', 'summary' => 'Searchable challenge summary', 'visibility' => GroupChallenge::VISIBILITY_PUBLIC, 'participation_scope' => GroupChallenge::PARTICIPATION_GROUP_ONLY, 'status' => GroupChallenge::STATUS_ACTIVE, 'created_by_user_id' => $owner->id, ]); GroupEvent::query()->create([ 'group_id' => $group->id, 'title' => 'Echo Event Marker', 'slug' => 'echo-event-marker', 'summary' => 'Searchable event summary', 'event_type' => GroupEvent::TYPE_SHOWCASE, 'visibility' => GroupEvent::VISIBILITY_PUBLIC, 'status' => GroupEvent::STATUS_PUBLISHED, 'created_by_user_id' => $owner->id, 'published_at' => now(), ]); foreach (['neon release marker', 'orbit project marker', 'pulse challenge marker', 'echo event marker'] as $term) { $this->getJson('/api/search/groups?q=' . urlencode($term)) ->assertOk() ->assertJsonPath('data.0.slug', 'signal-crew'); } }); it('matches group search against badge labels and active member names', function () { $owner = User::factory()->create(['name' => 'Owner Search']); $member = User::factory()->create(['name' => 'Nova Curator']); $group = Group::factory()->for($owner, 'owner')->create([ 'name' => 'Signal Atlas', 'slug' => 'signal-atlas', 'visibility' => Group::VISIBILITY_PUBLIC, ]); app(GroupMembershipService::class)->ensureOwnerMembership($group); GroupMember::query()->create([ 'group_id' => $group->id, 'user_id' => $member->id, 'invited_by_user_id' => $owner->id, 'role' => Group::ROLE_EDITOR, 'status' => Group::STATUS_ACTIVE, 'invited_at' => now(), 'accepted_at' => now(), ]); $artwork = Artwork::factory()->for($owner, 'user')->create([ 'group_id' => $group->id, 'uploaded_by_user_id' => $owner->id, 'primary_author_user_id' => $owner->id, 'artwork_status' => 'published', 'visibility' => Artwork::VISIBILITY_PUBLIC, 'is_public' => true, 'is_approved' => true, 'published_at' => now(), ]); $release = GroupRelease::query()->create([ 'group_id' => $group->id, 'title' => 'Atlas Release', 'slug' => 'atlas-release', 'summary' => 'Badge search support release.', 'status' => GroupRelease::STATUS_RELEASED, 'current_stage' => GroupRelease::STAGE_RELEASED, 'visibility' => GroupRelease::VISIBILITY_PUBLIC, 'created_by_user_id' => $owner->id, 'featured_artwork_id' => $artwork->id, 'released_at' => now(), 'published_at' => now(), ]); GroupReleaseContributor::query()->create([ 'group_release_id' => $release->id, 'user_id' => $member->id, 'role_label' => 'Curator', 'sort_order' => 1, ]); app(\App\Services\GroupReputationService::class)->refreshGroup($group); $badgeSearchResponse = $this->getJson('/api/search/groups?q=' . urlencode('first release')) ->assertOk() ->assertJsonPath('data.0.slug', 'signal-atlas'); expect($badgeSearchResponse->json('data.0.badge_keys'))->toContain('first_release'); $this->getJson('/api/search/groups?q=' . urlencode('nova curator')) ->assertOk() ->assertJsonPath('data.0.slug', 'signal-atlas'); }); it('renders public profile group contribution history for real group activity', function () { $owner = User::factory()->create(); $contributor = User::factory()->create(); $group = Group::factory()->for($owner, 'owner')->create([ 'visibility' => Group::VISIBILITY_PUBLIC, ]); app(GroupMembershipService::class)->ensureOwnerMembership($group); GroupMember::query()->create([ 'group_id' => $group->id, 'user_id' => $contributor->id, 'invited_by_user_id' => $owner->id, 'role' => Group::ROLE_EDITOR, 'status' => Group::STATUS_ACTIVE, 'invited_at' => now(), 'accepted_at' => now(), ]); $release = GroupRelease::query()->create([ 'group_id' => $group->id, 'title' => 'Profile Release Marker', 'slug' => 'profile-release-marker', 'summary' => 'A release for profile history.', 'status' => GroupRelease::STATUS_RELEASED, 'current_stage' => GroupRelease::STAGE_RELEASED, 'visibility' => GroupRelease::VISIBILITY_PUBLIC, 'created_by_user_id' => $owner->id, 'released_at' => now(), 'published_at' => now(), ]); GroupReleaseContributor::query()->create([ 'group_release_id' => $release->id, 'user_id' => $contributor->id, 'role_label' => 'Packaging Lead', 'sort_order' => 1, ]); GroupProject::query()->create([ 'group_id' => $group->id, 'title' => 'Profile Project Marker', 'slug' => 'profile-project-marker', 'summary' => 'Project tied to profile history.', 'visibility' => GroupProject::VISIBILITY_PUBLIC, 'status' => GroupProject::STATUS_ACTIVE, 'created_by_user_id' => $owner->id, ]); $project = GroupProject::query()->where('group_id', $group->id)->firstOrFail(); GroupProjectMember::query()->create([ 'group_project_id' => $project->id, 'user_id' => $contributor->id, 'role_label' => 'Art Director', 'is_lead' => false, ]); Artwork::factory()->for($contributor, 'user')->create([ 'group_id' => $group->id, 'uploaded_by_user_id' => $contributor->id, 'primary_author_user_id' => $contributor->id, 'artwork_status' => 'published', 'visibility' => Artwork::VISIBILITY_PUBLIC, 'is_public' => true, 'is_approved' => true, 'group_review_status' => 'approved', 'published_at' => now(), ]); app(\App\Services\GroupReputationService::class)->refreshGroup($group); expect(GroupContributorStat::query()->where('group_id', $group->id)->where('user_id', $contributor->id)->exists())->toBeTrue(); $this->get(route('profile.show', ['username' => strtolower((string) $contributor->username)])) ->assertOk() ->assertInertia(fn (AssertableInertia $page) => $page ->component('Profile/ProfileShow') ->where('groupContributionHistory.0.group.slug', $group->slug) ->where('groupContributionHistory.0.counts.releases', 1) ->where('groupContributionHistory.0.counts.credited_artworks', 1) ->has('groupContributionHistory.0.role_labels', 2) ); }); it('notifies contributors about release assignment milestones and earned badges', function () { $owner = User::factory()->create(); $contributor = User::factory()->create(); $group = Group::factory()->for($owner, 'owner')->create([ 'visibility' => Group::VISIBILITY_PUBLIC, ]); app(GroupMembershipService::class)->ensureOwnerMembership($group); GroupMember::query()->create([ 'group_id' => $group->id, 'user_id' => $contributor->id, 'invited_by_user_id' => $owner->id, 'role' => Group::ROLE_MEMBER, 'status' => Group::STATUS_ACTIVE, 'invited_at' => now(), 'accepted_at' => now(), ]); $artwork = Artwork::factory()->for($owner, 'user')->create([ 'group_id' => $group->id, 'uploaded_by_user_id' => $owner->id, 'primary_author_user_id' => $owner->id, 'artwork_status' => 'published', 'visibility' => Artwork::VISIBILITY_PUBLIC, 'is_public' => true, 'is_approved' => true, 'published_at' => now(), ]); $this->actingAs($owner) ->post(route('studio.groups.releases.store', ['group' => $group]), [ 'title' => 'Notification Release Marker', 'summary' => 'A release used for notification coverage.', 'description' => 'Notification coverage body.', 'visibility' => GroupRelease::VISIBILITY_PUBLIC, 'status' => GroupRelease::STATUS_PLANNED, 'current_stage' => GroupRelease::STAGE_CONCEPT, 'featured_artwork_id' => $artwork->id, ]) ->assertRedirect(); $release = GroupRelease::query()->where('group_id', $group->id)->firstOrFail(); $this->actingAs($owner) ->post(route('studio.groups.releases.attach-contributor', ['group' => $group, 'release' => $release]), [ 'user_id' => $contributor->id, 'role_label' => 'Packaging Lead', ]) ->assertRedirect(); $this->actingAs($owner) ->post(route('studio.groups.releases.milestones.store', ['group' => $group, 'release' => $release]), [ 'title' => 'Final assets', 'summary' => 'Prepare the last package.', 'status' => 'active', 'owner_user_id' => $contributor->id, ]) ->assertRedirect(); $this->actingAs($owner) ->post(route('studio.groups.releases.publish', ['group' => $group, 'release' => $release])) ->assertRedirect(); expect(Notification::query()->where('user_id', $contributor->id)->where('type', 'group_release_contributor_added')->exists())->toBeTrue() ->and(Notification::query()->where('user_id', $contributor->id)->where('type', 'group_milestone_assigned')->exists())->toBeTrue() ->and(Notification::query()->where('user_id', $contributor->id)->where('type', 'group_member_badge_earned')->exists())->toBeTrue() ->and(Notification::query()->where('user_id', $owner->id)->where('type', 'group_badge_earned')->exists())->toBeTrue(); }); it('notifies followers when a release is scheduled and assignees when milestones are due soon', function () { $owner = User::factory()->create(); $follower = User::factory()->create(); $assignee = User::factory()->create(); $group = Group::factory()->for($owner, 'owner')->create([ 'visibility' => Group::VISIBILITY_PUBLIC, ]); app(GroupMembershipService::class)->ensureOwnerMembership($group); GroupMember::query()->create([ 'group_id' => $group->id, 'user_id' => $assignee->id, 'invited_by_user_id' => $owner->id, 'role' => Group::ROLE_EDITOR, 'status' => Group::STATUS_ACTIVE, 'invited_at' => now(), 'accepted_at' => now(), ]); $this->actingAs($follower)->post(route('groups.follow', ['group' => $group]))->assertOk(); $release = GroupRelease::query()->create([ 'group_id' => $group->id, 'title' => 'Scheduled Marker Release', 'slug' => 'scheduled-marker-release', 'summary' => 'A release that gets scheduled.', 'status' => GroupRelease::STATUS_PLANNED, 'current_stage' => GroupRelease::STAGE_CONCEPT, 'visibility' => GroupRelease::VISIBILITY_PUBLIC, 'created_by_user_id' => $owner->id, ]); $this->actingAs($owner) ->patch(route('studio.groups.releases.update', ['group' => $group, 'release' => $release]), [ 'title' => $release->title, 'summary' => $release->summary, 'description' => $release->description, 'status' => GroupRelease::STATUS_SCHEDULED, 'current_stage' => GroupRelease::STAGE_PUBLISHING, 'visibility' => GroupRelease::VISIBILITY_PUBLIC, 'planned_release_at' => now()->addDays(7)->toISOString(), 'is_featured' => false, ]) ->assertRedirect(); $this->actingAs($owner) ->post(route('studio.groups.releases.milestones.store', ['group' => $group, 'release' => $release]), [ 'title' => 'Ship assets', 'summary' => 'Packaging delivery', 'status' => 'active', 'owner_user_id' => $assignee->id, 'due_date' => now()->addDays(2)->toDateString(), ]) ->assertRedirect(); expect(Notification::query()->where('user_id', $follower->id)->where('type', 'group_release_scheduled')->exists())->toBeTrue() ->and(Notification::query()->where('user_id', $assignee->id)->where('type', 'group_milestone_due_soon')->exists())->toBeTrue(); }); it('allows creating a group with richer profile metadata', function () { $owner = User::factory()->create(); $this->actingAs($owner) ->post(route('studio.groups.store'), [ 'name' => 'Warp Collective', 'slug' => 'warp-collective', 'headline' => 'Retro visual lab', 'bio' => 'Full about text for the collective.', 'type' => 'Studio', 'founded_at' => '2021-06-15', 'avatar_path' => 'https://cdn.example.test/groups/warp-avatar.webp', 'banner_path' => 'https://cdn.example.test/groups/warp-cover.webp', 'website_url' => 'https://warp.example.test', 'links_json' => [ ['label' => 'Behance', 'url' => 'https://behance.example.test/warp'], ], 'visibility' => Group::VISIBILITY_PUBLIC, 'membership_policy' => Group::MEMBERSHIP_INVITE_ONLY, ]) ->assertRedirect(); $group = Group::query()->where('slug', 'warp-collective')->firstOrFail(); expect($group->type)->toBe('Studio') ->and($group->headline)->toBe('Retro visual lab') ->and($group->avatar_path)->toBe('https://cdn.example.test/groups/warp-avatar.webp') ->and($group->banner_path)->toBe('https://cdn.example.test/groups/warp-cover.webp'); }); it('stores uploaded group media during group creation', function () { $disk = (string) config('uploads.object_storage.disk', 's3'); Storage::fake($disk); $owner = User::factory()->create(); $this->actingAs($owner) ->post(route('studio.groups.store'), [ 'name' => 'Pixel Forge', 'slug' => 'pixel-forge', 'headline' => 'Shared pixel craft', 'bio' => 'A tiny studio with a big release calendar.', 'visibility' => Group::VISIBILITY_PUBLIC, 'membership_policy' => Group::MEMBERSHIP_INVITE_ONLY, 'avatar_file' => UploadedFile::fake()->image('group-avatar.png', 512, 512), 'banner_file' => UploadedFile::fake()->image('group-banner.png', 1600, 600), ]) ->assertRedirect(); $group = Group::query()->where('slug', 'pixel-forge')->firstOrFail(); expect($group->avatar_path)->toStartWith('groups/' . $group->id . '/avatar/') ->and($group->banner_path)->toStartWith('groups/' . $group->id . '/banner/'); Storage::disk($disk)->assertExists((string) $group->avatar_path); Storage::disk($disk)->assertExists((string) $group->banner_path); }); it('lets owners update uploaded group media and featured artwork selection', function () { $disk = (string) config('uploads.object_storage.disk', 's3'); Storage::fake($disk); $owner = User::factory()->create(); $group = Group::factory()->for($owner, 'owner')->create(); app(GroupMembershipService::class)->ensureOwnerMembership($group); $featuredArtwork = Artwork::factory()->for($owner, 'user')->create([ 'group_id' => $group->id, 'uploaded_by_user_id' => $owner->id, 'primary_author_user_id' => $owner->id, 'artwork_status' => 'published', 'visibility' => Artwork::VISIBILITY_PUBLIC, 'is_public' => true, 'is_approved' => true, 'published_at' => now(), ]); $this->actingAs($owner) ->post(route('studio.groups.update', ['group' => $group]), [ '_method' => 'PATCH', 'name' => 'Warp Collective Updated', 'slug' => 'warp-collective-updated', 'headline' => 'Refined release identity', 'bio' => 'Now with sharper curation.', 'visibility' => Group::VISIBILITY_PUBLIC, 'membership_policy' => Group::MEMBERSHIP_INVITE_ONLY, 'featured_artwork_id' => $featuredArtwork->id, 'avatar_file' => UploadedFile::fake()->image('updated-avatar.png', 512, 512), 'banner_file' => UploadedFile::fake()->image('updated-banner.png', 1600, 600), ]) ->assertRedirect(route('studio.groups.settings', ['group' => 'warp-collective-updated'])); $group->refresh(); expect($group->slug)->toBe('warp-collective-updated') ->and($group->featured_artwork_id)->toBe($featuredArtwork->id) ->and($group->avatar_path)->toStartWith('groups/' . $group->id . '/avatar/') ->and($group->banner_path)->toStartWith('groups/' . $group->id . '/banner/'); Storage::disk($disk)->assertExists((string) $group->avatar_path); Storage::disk($disk)->assertExists((string) $group->banner_path); }); it('renders the studio invitations page with pending invites for authorized members', function () { $owner = User::factory()->create(); $invitee = User::factory()->create(); $group = Group::factory()->for($owner, 'owner')->create(); app(GroupMembershipService::class)->ensureOwnerMembership($group); GroupInvitation::query()->create([ 'group_id' => $group->id, 'invited_user_id' => $invitee->id, 'invited_by_user_id' => $owner->id, 'role' => Group::ROLE_EDITOR, 'status' => GroupInvitation::STATUS_PENDING, 'token' => Str::random(64), 'invited_at' => now(), 'expires_at' => now()->addDays(7), ]); $this->actingAs($owner) ->get(route('studio.groups.invitations', ['group' => $group])) ->assertOk() ->assertInertia(fn (AssertableInertia $page) => $page ->component('Studio/StudioGroupInvitations') ->where('studioGroup.slug', $group->slug) ->has('invitations', 1) ->where('invitations.0.user.username', $invitee->username) ->where('invitations.0.status', Group::STATUS_PENDING) ); }); it('allows owners to invite and revoke a pending group invitation', function () { $owner = User::factory()->create(); $invitee = User::factory()->create(); $group = Group::factory()->for($owner, 'owner')->create(); app(GroupMembershipService::class)->ensureOwnerMembership($group); $this->actingAs($owner) ->postJson(route('studio.groups.members.store', ['group' => $group]), [ 'username' => $invitee->username, 'role' => 'contributor', 'note' => 'Join the next release pack.', 'expires_in_days' => 5, ]) ->assertOk() ->assertJsonPath('member.status', GroupInvitation::STATUS_PENDING); $invitation = GroupInvitation::query() ->where('group_id', $group->id) ->where('invited_user_id', $invitee->id) ->firstOrFail(); expect($invitation->role)->toBe(Group::ROLE_MEMBER) ->and($invitation->status)->toBe(GroupInvitation::STATUS_PENDING) ->and(GroupMember::query()->where('group_id', $group->id)->where('user_id', $invitee->id)->exists())->toBeFalse(); $this->actingAs($owner) ->deleteJson(route('studio.groups.invitations.destroy', ['group' => $group, 'invitation' => $invitation])) ->assertOk(); expect($invitation->fresh()->status)->toBe(GroupInvitation::STATUS_REVOKED); }); it('allows non-managing members to view the members page but not the invitations manager', function () { $owner = User::factory()->create(); $editor = User::factory()->create(); $group = Group::factory()->for($owner, 'owner')->create(); app(GroupMembershipService::class)->ensureOwnerMembership($group); GroupMember::query()->create([ 'group_id' => $group->id, 'user_id' => $editor->id, 'invited_by_user_id' => $owner->id, 'role' => Group::ROLE_EDITOR, 'status' => Group::STATUS_ACTIVE, 'invited_at' => now(), 'accepted_at' => now(), ]); $this->actingAs($editor) ->get(route('studio.groups.members', ['group' => $group])) ->assertOk() ->assertInertia(fn (AssertableInertia $page) => $page ->component('Studio/StudioGroupMembers') ->where('canManageMembers', false) ->where('studioGroup.permissions.can_manage_members', false) ->missing('endpoints.invite') ); $this->actingAs($editor) ->get(route('studio.groups.invitations', ['group' => $group])) ->assertForbidden(); }); it('manages v3 projects challenges and events across studio and public group pages', function () { $owner = User::factory()->create(); $viewer = User::factory()->create(); $group = Group::factory()->for($owner, 'owner')->create([ 'visibility' => Group::VISIBILITY_PUBLIC, ]); app(GroupMembershipService::class)->ensureOwnerMembership($group); $this->actingAs($owner) ->post(route('studio.groups.projects.store', ['group' => $group]), [ 'title' => 'Launch Capsule', 'summary' => 'Primary release planning hub.', 'description' => 'Tracks the flagship group release.', 'status' => GroupProject::STATUS_ACTIVE, 'visibility' => GroupProject::VISIBILITY_PUBLIC, 'start_date' => now()->toDateString(), 'target_date' => now()->addWeeks(2)->toDateString(), ]) ->assertRedirect(); $project = GroupProject::query()->where('group_id', $group->id)->firstOrFail(); $this->actingAs($owner) ->post(route('studio.groups.projects.status', ['group' => $group, 'project' => $project]), [ 'status' => GroupProject::STATUS_RELEASED, ]) ->assertRedirect(); $this->actingAs($owner) ->post(route('studio.groups.challenges.store', ['group' => $group]), [ 'title' => 'Spring Prompt', 'summary' => 'A short challenge for the next drop.', 'description' => 'Members submit concept work for the release window.', 'visibility' => GroupChallenge::VISIBILITY_PUBLIC, 'participation_scope' => GroupChallenge::PARTICIPATION_GROUP_ONLY, 'status' => GroupChallenge::STATUS_DRAFT, 'start_at' => now()->toDateTimeString(), 'end_at' => now()->addDays(7)->toDateTimeString(), ]) ->assertRedirect(); $challenge = GroupChallenge::query()->where('group_id', $group->id)->firstOrFail(); $this->actingAs($owner) ->post(route('studio.groups.challenges.publish', ['group' => $group, 'challenge' => $challenge])) ->assertRedirect(); $this->actingAs($owner) ->post(route('studio.groups.events.store', ['group' => $group]), [ 'title' => 'Release Stream', 'summary' => 'Live reveal and Q&A.', 'description' => 'A public livestream for the group release.', 'event_type' => GroupEvent::TYPE_LIVESTREAM, 'visibility' => GroupEvent::VISIBILITY_PUBLIC, 'status' => GroupEvent::STATUS_DRAFT, 'start_at' => now()->addDays(3)->toDateTimeString(), 'end_at' => now()->addDays(3)->addHour()->toDateTimeString(), 'timezone' => 'UTC', ]) ->assertRedirect(); $event = GroupEvent::query()->where('group_id', $group->id)->firstOrFail(); $this->actingAs($owner) ->post(route('studio.groups.events.publish', ['group' => $group, 'event' => $event])) ->assertRedirect(); expect(GroupActivityItem::query()->where('group_id', $group->id)->count())->toBeGreaterThanOrEqual(4); $this->actingAs($owner) ->get(route('studio.groups.show', ['group' => $group])) ->assertOk() ->assertInertia(fn (AssertableInertia $page) => $page ->component('Studio/StudioGroupDashboard') ->where('dashboard.projects_count', 1) ->where('dashboard.active_challenges_count', 1) ->where('dashboard.events_count', 1) ->has('recentProjects', 1) ->has('recentChallenges', 1) ->has('recentEvents', 1) ->has('recentActivity') ); $this->actingAs($viewer) ->get(route('groups.show', ['group' => $group])) ->assertOk() ->assertInertia(fn (AssertableInertia $page) => $page ->component('Group/GroupShow') ->where('group.featured_project.title', 'Launch Capsule') ->where('group.active_challenge.title', 'Spring Prompt') ->where('group.upcoming_event.title', 'Release Stream') ); $this->actingAs($viewer) ->get(route('groups.section', ['group' => $group, 'section' => 'projects'])) ->assertOk() ->assertInertia(fn (AssertableInertia $page) => $page ->where('section', 'projects') ->where('projects.0.title', 'Launch Capsule') ); $this->actingAs($viewer) ->get(route('groups.section', ['group' => $group, 'section' => 'challenges'])) ->assertOk() ->assertInertia(fn (AssertableInertia $page) => $page ->where('section', 'challenges') ->where('challenges.0.title', 'Spring Prompt') ); $this->actingAs($viewer) ->get(route('groups.section', ['group' => $group, 'section' => 'events'])) ->assertOk() ->assertInertia(fn (AssertableInertia $page) => $page ->where('section', 'events') ->where('events.0.title', 'Release Stream') ); $this->get('/') ->assertOk(); }); it('handles group assets and keeps public activity limited to public items', function () { Storage::fake('local'); $owner = User::factory()->create(); $viewer = User::factory()->create(); $group = Group::factory()->for($owner, 'owner')->create([ 'visibility' => Group::VISIBILITY_PUBLIC, ]); app(GroupMembershipService::class)->ensureOwnerMembership($group); $this->actingAs($owner) ->post(route('studio.groups.assets.store', ['group' => $group]), [ 'title' => 'Launch Brief', 'description' => 'Public release brief.', 'category' => GroupAsset::CATEGORY_REFERENCE, 'visibility' => GroupAsset::VISIBILITY_PUBLIC_DOWNLOAD, 'status' => GroupAsset::STATUS_ACTIVE, 'file' => UploadedFile::fake()->create('launch-brief.pdf', 12, 'application/pdf'), ]) ->assertRedirect(); $publicAsset = GroupAsset::query()->where('group_id', $group->id)->where('visibility', GroupAsset::VISIBILITY_PUBLIC_DOWNLOAD)->firstOrFail(); $this->actingAs($owner) ->post(route('studio.groups.assets.store', ['group' => $group]), [ 'title' => 'Internal Source Pack', 'description' => 'Members-only working files.', 'category' => GroupAsset::CATEGORY_SOURCE_PACK, 'visibility' => GroupAsset::VISIBILITY_MEMBERS_ONLY, 'status' => GroupAsset::STATUS_ACTIVE, 'file' => UploadedFile::fake()->create('internal-pack.zip', 24, 'application/zip'), ]) ->assertRedirect(); $internalAsset = GroupAsset::query()->where('group_id', $group->id)->where('visibility', GroupAsset::VISIBILITY_MEMBERS_ONLY)->firstOrFail(); Storage::disk('local')->assertExists((string) $publicAsset->file_path); Storage::disk('local')->assertExists((string) $internalAsset->file_path); $this->actingAs($viewer) ->get(route('groups.assets.download', ['group' => $group, 'asset' => $publicAsset])) ->assertOk(); $this->actingAs($viewer) ->get(route('groups.assets.download', ['group' => $group, 'asset' => $internalAsset])) ->assertForbidden(); $publicActivity = GroupActivityItem::query()->where('group_id', $group->id)->where('visibility', 'public')->firstOrFail(); $this->actingAs($owner) ->post(route('studio.groups.activity.pin', ['group' => $group, 'item' => $publicActivity]), [ 'is_pinned' => true, ]) ->assertRedirect(); expect($publicActivity->fresh()->is_pinned)->toBeTrue(); $this->actingAs($viewer) ->get(route('groups.activity.index', ['group' => $group])) ->assertOk() ->assertInertia(fn (AssertableInertia $page) => $page ->component('Group/GroupShow') ->where('section', 'activity') ->has('activity', 1) ->where('activity.0.subject.type', 'group_asset') ->where('activity.0.headline', fn (string $headline) => str_contains($headline, 'Launch Brief')) ); $this->actingAs($owner) ->get(route('studio.groups.activity', ['group' => $group])) ->assertOk() ->assertInertia(fn (AssertableInertia $page) => $page ->component('Studio/StudioGroupActivity') ->has('activity', 2) ->where('activity.0.is_pinned', true) ); }); it('allows invited users to accept a pending group invitation', function () { $owner = User::factory()->create(); $invitee = User::factory()->create(); $group = Group::factory()->for($owner, 'owner')->create(); app(GroupMembershipService::class)->ensureOwnerMembership($group); $invitation = GroupInvitation::query()->create([ 'group_id' => $group->id, 'invited_user_id' => $invitee->id, 'invited_by_user_id' => $owner->id, 'role' => Group::ROLE_EDITOR, 'status' => GroupInvitation::STATUS_PENDING, 'token' => Str::random(64), 'invited_at' => now(), 'expires_at' => now()->addDays(7), ]); $this->actingAs($invitee) ->post(route('studio.groups.invitations.accept', ['invitation' => $invitation])) ->assertRedirect(route('studio.groups.members', ['group' => $group])); $member = GroupMember::query() ->where('group_id', $group->id) ->where('user_id', $invitee->id) ->firstOrFail(); expect($member->status)->toBe(Group::STATUS_ACTIVE) ->and($member->accepted_at)->not->toBeNull() ->and($invitation->fresh()->status)->toBe(GroupInvitation::STATUS_ACCEPTED); }); it('allows owners to change a member role', function () { $owner = User::factory()->create(); $memberUser = User::factory()->create(); $group = Group::factory()->for($owner, 'owner')->create(); app(GroupMembershipService::class)->ensureOwnerMembership($group); $member = GroupMember::query()->create([ 'group_id' => $group->id, 'user_id' => $memberUser->id, 'invited_by_user_id' => $owner->id, 'role' => Group::ROLE_MEMBER, 'status' => Group::STATUS_ACTIVE, 'invited_at' => now(), 'accepted_at' => now(), ]); $this->actingAs($owner) ->patchJson(route('studio.groups.members.update', ['group' => $group, 'member' => $member]), [ 'role' => Group::ROLE_EDITOR, ]) ->assertOk() ->assertJsonPath('member.role', Group::ROLE_EDITOR); expect($member->fresh()->role)->toBe(Group::ROLE_EDITOR); }); it('allows owners to remove active members from the group', function () { $owner = User::factory()->create(); $memberUser = User::factory()->create(); $group = Group::factory()->for($owner, 'owner')->create(); app(GroupMembershipService::class)->ensureOwnerMembership($group); $member = GroupMember::query()->create([ 'group_id' => $group->id, 'user_id' => $memberUser->id, 'invited_by_user_id' => $owner->id, 'role' => Group::ROLE_EDITOR, 'status' => Group::STATUS_ACTIVE, 'invited_at' => now(), 'accepted_at' => now(), ]); $this->actingAs($owner) ->deleteJson(route('studio.groups.members.destroy', ['group' => $group, 'member' => $member])) ->assertOk(); expect($member->fresh()->status)->toBe(Group::STATUS_REVOKED); }); it('prevents removing the group owner without ownership transfer', function () { $owner = User::factory()->create(); $admin = User::factory()->create(); $group = Group::factory()->for($owner, 'owner')->create(); app(GroupMembershipService::class)->ensureOwnerMembership($group); GroupMember::query()->create([ 'group_id' => $group->id, 'user_id' => $admin->id, 'invited_by_user_id' => $owner->id, 'role' => Group::ROLE_ADMIN, 'status' => Group::STATUS_ACTIVE, 'invited_at' => now(), 'accepted_at' => now(), ]); $ownerMember = GroupMember::query() ->where('group_id', $group->id) ->where('user_id', $owner->id) ->firstOrFail(); $this->actingAs($admin) ->deleteJson(route('studio.groups.members.destroy', ['group' => $group, 'member' => $ownerMember])) ->assertStatus(422) ->assertJsonValidationErrors(['member']); }); it('allows owners to transfer ownership to another active member', function () { $owner = User::factory()->create(); $admin = User::factory()->create(); $group = Group::factory()->for($owner, 'owner')->create(); app(GroupMembershipService::class)->ensureOwnerMembership($group); $member = GroupMember::query()->create([ 'group_id' => $group->id, 'user_id' => $admin->id, 'invited_by_user_id' => $owner->id, 'role' => Group::ROLE_ADMIN, 'status' => Group::STATUS_ACTIVE, 'invited_at' => now(), 'accepted_at' => now(), ]); $this->actingAs($owner) ->postJson(route('studio.groups.members.transfer', ['group' => $group, 'member' => $member])) ->assertOk(); $group->refresh(); expect($group->owner_user_id)->toBe($admin->id) ->and($member->fresh()->role)->toBe(Group::ROLE_OWNER) ->and(GroupMember::query()->where('group_id', $group->id)->where('user_id', $owner->id)->firstOrFail()->role)->toBe(Group::ROLE_ADMIN); }); it('filters the group asset library by category and search query in studio', function () { $owner = User::factory()->create(); $group = Group::factory()->for($owner, 'owner')->create(); app(GroupMembershipService::class)->ensureOwnerMembership($group); GroupAsset::query()->create([ 'group_id' => $group->id, 'title' => 'Brand Mark Kit', 'description' => 'Official SVG and PNG logos.', 'category' => GroupAsset::CATEGORY_LOGO, 'file_path' => 'group-assets/' . $group->id . '/brand-mark.zip', 'visibility' => GroupAsset::VISIBILITY_MEMBERS_ONLY, 'status' => GroupAsset::STATUS_ACTIVE, 'uploaded_by_user_id' => $owner->id, 'approved_by_user_id' => $owner->id, 'is_featured' => false, 'file_meta_json' => ['original_name' => 'brand-mark.zip'], ]); GroupAsset::query()->create([ 'group_id' => $group->id, 'title' => 'Palette Reference', 'description' => 'Color ramps for the spring pack.', 'category' => GroupAsset::CATEGORY_PALETTE, 'file_path' => 'group-assets/' . $group->id . '/palette-reference.pdf', 'visibility' => GroupAsset::VISIBILITY_MEMBERS_ONLY, 'status' => GroupAsset::STATUS_ACTIVE, 'uploaded_by_user_id' => $owner->id, 'approved_by_user_id' => $owner->id, 'is_featured' => false, 'file_meta_json' => ['original_name' => 'palette-reference.pdf'], ]); $this->actingAs($owner) ->get(route('studio.groups.assets.index', ['group' => $group, 'category' => GroupAsset::CATEGORY_LOGO, 'q' => 'brand'])) ->assertOk() ->assertInertia(fn (AssertableInertia $page) => $page ->component('Studio/StudioGroupAssets') ->where('listing.filters.category', GroupAsset::CATEGORY_LOGO) ->where('listing.filters.q', 'brand') ->has('listing.items', 1) ->where('listing.items.0.title', 'Brand Mark Kit') ); }); it('allows public challenge entries and sends richer v3 notifications', function () { $owner = User::factory()->create(); $follower = User::factory()->create(); $participant = User::factory()->create(); $group = Group::factory()->for($owner, 'owner')->create([ 'visibility' => Group::VISIBILITY_PUBLIC, ]); app(GroupMembershipService::class)->ensureOwnerMembership($group); DB::table('group_follows')->insert([ 'group_id' => $group->id, 'user_id' => $follower->id, 'created_at' => now(), 'updated_at' => now(), ]); $challenge = GroupChallenge::query()->create([ 'group_id' => $group->id, 'title' => 'Open Prompt', 'slug' => 'open-prompt', 'summary' => 'Public challenge', 'description' => 'Open to outside participants.', 'visibility' => GroupChallenge::VISIBILITY_PUBLIC, 'participation_scope' => GroupChallenge::PARTICIPATION_PUBLIC, 'status' => GroupChallenge::STATUS_ACTIVE, 'start_at' => now()->subDay(), 'end_at' => now()->addDay(), 'created_by_user_id' => $owner->id, ]); $participantArtwork = Artwork::factory()->for($participant, 'user')->create([ 'is_public' => true, 'visibility' => Artwork::VISIBILITY_PUBLIC, 'is_approved' => true, 'artwork_status' => 'published', 'published_at' => now(), ]); $this->actingAs($participant) ->post(route('groups.challenges.entries.store', ['group' => $group, 'challenge' => $challenge]), [ 'artwork_id' => $participantArtwork->id, ]) ->assertRedirect(); expect(DB::table('group_challenge_artworks') ->where('group_challenge_id', $challenge->id) ->where('artwork_id', $participantArtwork->id) ->exists())->toBeTrue(); $project = GroupProject::query()->create([ 'group_id' => $group->id, 'title' => 'Status Capsule', 'slug' => 'status-capsule', 'summary' => 'Project status notifications', 'visibility' => GroupProject::VISIBILITY_PUBLIC, 'status' => GroupProject::STATUS_ACTIVE, 'created_by_user_id' => $owner->id, ]); app(\App\Services\GroupProjectService::class)->updateStatus($project, $owner, GroupProject::STATUS_REVIEW); $event = GroupEvent::query()->create([ 'group_id' => $group->id, 'title' => 'Updated Event', 'slug' => 'updated-event', 'summary' => 'Followers should see updates', 'description' => 'Event update notifications.', 'event_type' => GroupEvent::TYPE_SHOWCASE, 'visibility' => GroupEvent::VISIBILITY_PUBLIC, 'start_at' => now()->addDays(3), 'end_at' => now()->addDays(3)->addHour(), 'timezone' => 'UTC', 'status' => GroupEvent::STATUS_PUBLISHED, 'published_at' => now()->subHour(), 'created_by_user_id' => $owner->id, ]); app(\App\Services\GroupEventService::class)->update($event, $owner, [ 'title' => 'Updated Event', 'summary' => 'Followers should see updates', 'description' => 'Event update notifications.', 'event_type' => GroupEvent::TYPE_SHOWCASE, 'visibility' => GroupEvent::VISIBILITY_PUBLIC, 'start_at' => now()->addDays(4)->toDateTimeString(), 'end_at' => now()->addDays(4)->addHour()->toDateTimeString(), 'timezone' => 'UTC', ]); $asset = GroupAsset::query()->create([ 'group_id' => $group->id, 'title' => 'Pending Approval Pack', 'description' => 'Awaiting approval', 'category' => GroupAsset::CATEGORY_PROMO, 'file_path' => 'group-assets/' . $group->id . '/pending-approval.zip', 'visibility' => GroupAsset::VISIBILITY_MEMBERS_ONLY, 'status' => GroupAsset::STATUS_ARCHIVED, 'uploaded_by_user_id' => $participant->id, 'approved_by_user_id' => null, 'is_featured' => false, 'file_meta_json' => ['original_name' => 'pending-approval.zip'], ]); app(\App\Services\GroupAssetService::class)->update($asset, $owner, [ 'title' => 'Pending Approval Pack', 'description' => 'Approved for members', 'category' => GroupAsset::CATEGORY_PROMO, 'visibility' => GroupAsset::VISIBILITY_MEMBERS_ONLY, 'status' => GroupAsset::STATUS_ACTIVE, 'linked_project_id' => null, 'is_featured' => false, ]); expect(Notification::query()->where('user_id', $follower->id)->where('type', 'group_project_status_changed')->exists())->toBeTrue() ->and(Notification::query()->where('user_id', $follower->id)->where('type', 'group_event_updated')->exists())->toBeTrue() ->and(Notification::query()->where('user_id', $participant->id)->where('type', 'group_asset_approved')->exists())->toBeTrue(); }); it('creates group drafts in group context for contributors', function () { $owner = User::factory()->create(); $contributor = User::factory()->create(); $group = Group::factory()->for($owner, 'owner')->create(); $categoryId = createCategoryForGroupsFeatureTests(); app(GroupMembershipService::class)->ensureOwnerMembership($group); GroupMember::query()->create([ 'group_id' => $group->id, 'user_id' => $contributor->id, 'invited_by_user_id' => $owner->id, 'role' => Group::ROLE_MEMBER, 'status' => Group::STATUS_ACTIVE, 'invited_at' => now(), 'accepted_at' => now(), ]); $response = $this->actingAs($contributor)->postJson('/api/artworks', [ 'title' => 'Crew Draft', 'description' => 'Draft created in group context.', 'category' => $categoryId, 'group' => $group->slug, 'is_mature' => false, ]); $response->assertCreated(); $artwork = Artwork::query()->findOrFail((int) $response->json('artwork_id')); expect($artwork->group_id)->toBe($group->id) ->and($artwork->published_as_type)->toBe(Artwork::PUBLISHED_AS_GROUP) ->and($artwork->published_as_id)->toBe($group->id) ->and($artwork->uploaded_by_user_id)->toBe($contributor->id) ->and($artwork->primary_author_user_id)->toBe($contributor->id) ->and($artwork->artwork_status)->toBe('draft'); }); it('prevents contributors from publishing group drafts directly', function () { $owner = User::factory()->create(); $contributor = User::factory()->create(); $group = Group::factory()->for($owner, 'owner')->create(); app(GroupMembershipService::class)->ensureOwnerMembership($group); GroupMember::query()->create([ 'group_id' => $group->id, 'user_id' => $contributor->id, 'invited_by_user_id' => $owner->id, 'role' => Group::ROLE_MEMBER, 'status' => Group::STATUS_ACTIVE, 'invited_at' => now(), 'accepted_at' => now(), ]); $artwork = Artwork::factory()->unpublished()->for($contributor, 'user')->create([ 'group_id' => $group->id, 'uploaded_by_user_id' => $contributor->id, 'primary_author_user_id' => $contributor->id, 'is_public' => false, 'visibility' => Artwork::VISIBILITY_PRIVATE, 'is_approved' => false, 'artwork_status' => 'draft', ]); $this->actingAs($contributor) ->postJson("/api/uploads/{$artwork->id}/publish", [ 'group' => $group->slug, 'visibility' => 'public', ]) ->assertStatus(422) ->assertJsonValidationErrors(['group']); }); it('prevents non-members from publishing as a group', function () { $owner = User::factory()->create(); $outsider = User::factory()->create(); $group = Group::factory()->for($owner, 'owner')->create(); app(GroupMembershipService::class)->ensureOwnerMembership($group); $artwork = Artwork::factory()->unpublished()->for($outsider, 'user')->create([ 'is_public' => false, 'visibility' => Artwork::VISIBILITY_PRIVATE, 'is_approved' => false, 'artwork_status' => 'draft', ]); $this->actingAs($outsider) ->postJson("/api/uploads/{$artwork->id}/publish", [ 'group' => $group->slug, 'visibility' => 'public', ]) ->assertStatus(422) ->assertJsonValidationErrors(['group']); }); it('returns public groups from search', function () { $owner = User::factory()->create(); $group = Group::factory()->for($owner, 'owner')->create([ 'name' => 'Searchable Crew', 'slug' => 'searchable-crew', 'visibility' => Group::VISIBILITY_PUBLIC, 'status' => Group::LIFECYCLE_ACTIVE, ]); app(GroupMembershipService::class)->ensureOwnerMembership($group); $this->getJson(route('api.search.groups', ['q' => 'searchable'])) ->assertOk() ->assertJsonPath('data.0.slug', 'searchable-crew') ->assertJsonPath('data.0.type', 'group'); }); it('keeps unlisted groups off the directory while still allowing direct access', function () { $owner = User::factory()->create(); $group = Group::factory()->for($owner, 'owner')->create([ 'visibility' => Group::VISIBILITY_UNLISTED, 'status' => Group::LIFECYCLE_ACTIVE, ]); app(GroupMembershipService::class)->ensureOwnerMembership($group); $this->get(route('groups.index')) ->assertOk() ->assertInertia(fn (AssertableInertia $page) => $page ->component('Group/GroupIndex') ->missing('groups.data.0.slug') ); $this->get(route('groups.show', ['group' => $group])) ->assertOk() ->assertInertia(fn (AssertableInertia $page) => $page ->component('Group/GroupShow') ->where('group.slug', $group->slug) ->where('group.visibility', Group::VISIBILITY_UNLISTED) ); }); it('prevents publishing as an archived group', function () { $owner = User::factory()->create(); $editor = User::factory()->create(); $group = Group::factory()->for($owner, 'owner')->create([ 'status' => Group::LIFECYCLE_ARCHIVED, ]); app(GroupMembershipService::class)->ensureOwnerMembership($group); GroupMember::query()->create([ 'group_id' => $group->id, 'user_id' => $editor->id, 'invited_by_user_id' => $owner->id, 'role' => Group::ROLE_EDITOR, 'status' => Group::STATUS_ACTIVE, 'invited_at' => now(), 'accepted_at' => now(), ]); $artwork = Artwork::factory()->unpublished()->for($editor, 'user')->create([ 'is_public' => false, 'visibility' => Artwork::VISIBILITY_PRIVATE, 'is_approved' => false, 'artwork_status' => 'draft', ]); $this->actingAs($editor) ->postJson("/api/uploads/{$artwork->id}/publish", [ 'group' => $group->slug, 'visibility' => 'public', ]) ->assertStatus(422) ->assertJsonValidationErrors(['group']); }); it('handles group join requests and approvals', function () { $owner = User::factory()->create(); $applicant = User::factory()->create(); $group = Group::factory()->for($owner, 'owner')->create([ 'visibility' => Group::VISIBILITY_PUBLIC, 'membership_policy' => Group::MEMBERSHIP_REQUEST_TO_JOIN, ]); app(GroupMembershipService::class)->ensureOwnerMembership($group); $this->actingAs($applicant) ->post(route('groups.join-requests.store', ['group' => $group]), [ 'message' => 'I can help with release artwork.', 'desired_role' => 'contributor', ]) ->assertRedirect(); $joinRequest = GroupJoinRequest::query()->firstOrFail(); expect($joinRequest->status)->toBe(GroupJoinRequest::STATUS_PENDING) ->and($joinRequest->desired_role)->toBe(Group::ROLE_MEMBER); $this->actingAs($owner) ->post(route('studio.groups.join-requests.approve', ['group' => $group, 'joinRequest' => $joinRequest]), [ 'role' => 'editor', 'review_notes' => 'Approved for release support.', ]) ->assertRedirect(); expect($joinRequest->fresh()->status)->toBe(GroupJoinRequest::STATUS_APPROVED) ->and(GroupMember::query() ->where('group_id', $group->id) ->where('user_id', $applicant->id) ->where('status', Group::STATUS_ACTIVE) ->where('role', Group::ROLE_EDITOR) ->exists())->toBeTrue(); }); it('submits contributor artwork for group review instead of direct publish', function () { $owner = User::factory()->create(); $contributor = User::factory()->create(); $group = Group::factory()->for($owner, 'owner')->create(); app(GroupMembershipService::class)->ensureOwnerMembership($group); GroupMember::query()->create([ 'group_id' => $group->id, 'user_id' => $contributor->id, 'invited_by_user_id' => $owner->id, 'role' => Group::ROLE_MEMBER, 'status' => Group::STATUS_ACTIVE, 'invited_at' => now(), 'accepted_at' => now(), ]); $artwork = Artwork::factory()->unpublished()->for($contributor, 'user')->create([ 'is_public' => false, 'visibility' => Artwork::VISIBILITY_PRIVATE, 'is_approved' => false, 'artwork_status' => 'draft', 'slug' => 'review-me-artwork', ]); $this->actingAs($contributor) ->postJson("/api/uploads/{$artwork->id}/submit-review", [ 'group' => $group->slug, 'title' => 'Co-op release teaser', 'description' => 'Ready for group review.', 'visibility' => 'public', 'tags' => ['teaser', 'group'], ]) ->assertOk() ->assertJson([ 'success' => true, 'artwork_id' => $artwork->id, 'status' => 'submitted_for_review', 'group_review_status' => 'submitted', ]); $artwork->refresh(); expect($artwork->group_id)->toBe($group->id) ->and($artwork->group_review_status)->toBe('submitted') ->and($artwork->published_as_type)->toBe(Artwork::PUBLISHED_AS_GROUP) ->and($artwork->is_public)->toBeFalse() ->and($artwork->artwork_status)->toBe('draft'); }); it('renders public group posts and recruitment payloads', function () { $owner = User::factory()->create(); $group = Group::factory()->for($owner, 'owner')->create([ 'visibility' => Group::VISIBILITY_PUBLIC, ]); app(GroupMembershipService::class)->ensureOwnerMembership($group); GroupRecruitmentProfile::query()->create([ 'group_id' => $group->id, 'is_recruiting' => true, 'headline' => 'Looking for animation support', 'description' => 'Need collaborators for short-form loops.', 'roles_json' => ['Animator', 'Sound designer'], 'skills_json' => ['After Effects', 'Pixel art'], 'contact_mode' => 'join_request', 'visibility' => 'public', ]); GroupPost::query()->create([ 'group_id' => $group->id, 'author_user_id' => $owner->id, 'type' => GroupPost::TYPE_ANNOUNCEMENT, 'title' => 'Spring update', 'slug' => 'spring-update', 'excerpt' => 'A new release calendar is live.', 'content' => 'Detailed update body.', 'status' => GroupPost::STATUS_PUBLISHED, 'is_pinned' => true, 'published_at' => now(), ]); $this->get(route('groups.section', ['group' => $group, 'section' => 'posts'])) ->assertOk() ->assertInertia(fn (AssertableInertia $page) => $page ->component('Group/GroupShow') ->where('section', 'posts') ->where('recruitment.is_recruiting', true) ->where('posts.0.title', 'Spring update') ->where('group.pinned_post.title', 'Spring update') ); }); it('blocks duplicate join requests and allows managers to reject them', function () { $owner = User::factory()->create(); $applicant = User::factory()->create(); $group = Group::factory()->for($owner, 'owner')->create([ 'visibility' => Group::VISIBILITY_PUBLIC, 'membership_policy' => Group::MEMBERSHIP_REQUEST_TO_JOIN, ]); app(GroupMembershipService::class)->ensureOwnerMembership($group); $this->actingAs($applicant) ->post(route('groups.join-requests.store', ['group' => $group]), [ 'message' => 'First request', 'desired_role' => 'contributor', ]) ->assertRedirect(); $this->actingAs($applicant) ->post(route('groups.join-requests.store', ['group' => $group]), [ 'message' => 'Duplicate request', 'desired_role' => 'contributor', ]) ->assertSessionHasErrors('group'); $joinRequest = GroupJoinRequest::query()->firstOrFail(); $this->actingAs($owner) ->post(route('studio.groups.join-requests.reject', ['group' => $group, 'joinRequest' => $joinRequest]), [ 'review_notes' => 'Not a fit right now.', ]) ->assertRedirect(); expect($joinRequest->fresh()->status)->toBe(GroupJoinRequest::STATUS_REJECTED); }); it('supports explicit allow permission overrides for contributors', function () { $owner = User::factory()->create(); $contributor = User::factory()->create(); $group = Group::factory()->for($owner, 'owner')->create(); app(GroupMembershipService::class)->ensureOwnerMembership($group); $member = GroupMember::query()->create([ 'group_id' => $group->id, 'user_id' => $contributor->id, 'invited_by_user_id' => $owner->id, 'role' => Group::ROLE_MEMBER, 'status' => Group::STATUS_ACTIVE, 'invited_at' => now(), 'accepted_at' => now(), ]); expect($group->fresh()->canManagePosts($contributor))->toBeFalse(); $this->actingAs($owner) ->patchJson(route('studio.groups.members.permissions.update', ['group' => $group, 'member' => $member]), [ 'permission_overrides' => [ ['key' => Group::PERMISSION_MANAGE_POSTS, 'is_allowed' => true], ], ]) ->assertOk() ->assertJsonPath('member.permission_overrides.0.key', Group::PERMISSION_MANAGE_POSTS) ->assertJsonPath('member.permission_overrides.0.is_allowed', true); expect($group->fresh()->canManagePosts($contributor))->toBeTrue(); }); it('supports explicit deny permission overrides for editor capabilities', function () { $owner = User::factory()->create(); $editor = User::factory()->create(); $group = Group::factory()->for($owner, 'owner')->create(); app(GroupMembershipService::class)->ensureOwnerMembership($group); $member = GroupMember::query()->create([ 'group_id' => $group->id, 'user_id' => $editor->id, 'invited_by_user_id' => $owner->id, 'role' => Group::ROLE_EDITOR, 'status' => Group::STATUS_ACTIVE, 'invited_at' => now(), 'accepted_at' => now(), ]); expect($group->fresh()->canManagePosts($editor))->toBeTrue() ->and($group->fresh()->canReviewSubmissions($editor))->toBeTrue(); $this->actingAs($owner) ->patchJson(route('studio.groups.members.permissions.update', ['group' => $group, 'member' => $member]), [ 'permission_overrides' => [ ['key' => Group::PERMISSION_MANAGE_POSTS, 'is_allowed' => false], ['key' => Group::PERMISSION_REVIEW_SUBMISSIONS, 'is_allowed' => false], ], ]) ->assertOk(); $reloaded = $group->fresh(); expect($reloaded->canManagePosts($editor))->toBeFalse() ->and($reloaded->canReviewSubmissions($editor))->toBeFalse(); }); it('allows authorized reviewers to request changes and reject submissions', function () { $owner = User::factory()->create(); $contributor = User::factory()->create(); $group = Group::factory()->for($owner, 'owner')->create(); app(GroupMembershipService::class)->ensureOwnerMembership($group); GroupMember::query()->create([ 'group_id' => $group->id, 'user_id' => $contributor->id, 'invited_by_user_id' => $owner->id, 'role' => Group::ROLE_MEMBER, 'status' => Group::STATUS_ACTIVE, 'invited_at' => now(), 'accepted_at' => now(), ]); $artwork = Artwork::factory()->unpublished()->for($contributor, 'user')->create([ 'is_public' => false, 'visibility' => Artwork::VISIBILITY_PRIVATE, 'is_approved' => false, 'artwork_status' => 'draft', ]); $this->actingAs($contributor) ->postJson("/api/uploads/{$artwork->id}/submit-review", [ 'group' => $group->slug, 'title' => 'Review me', 'description' => 'Needs review.', 'visibility' => 'public', ]) ->assertOk(); $artwork->refresh(); $this->actingAs($owner) ->post(route('studio.groups.artworks.needs-changes', ['group' => $group, 'artwork' => $artwork]), [ 'review_notes' => 'Tighten the crop.', ]) ->assertRedirect(); expect($artwork->fresh()->group_review_status)->toBe('needs_changes'); $this->actingAs($contributor) ->postJson("/api/uploads/{$artwork->id}/submit-review", [ 'group' => $group->slug, 'title' => 'Review me again', 'description' => 'Updated version.', 'visibility' => 'public', ]) ->assertOk(); $artwork->refresh(); $this->actingAs($owner) ->post(route('studio.groups.artworks.reject', ['group' => $group, 'artwork' => $artwork]), [ 'review_notes' => 'Still not ready.', ]) ->assertRedirect(); expect($artwork->fresh()->group_review_status)->toBe('rejected'); }); it('blocks unauthorized members from reviewing group submissions', function () { $owner = User::factory()->create(); $contributor = User::factory()->create(); $reviewer = User::factory()->create(); $group = Group::factory()->for($owner, 'owner')->create(); app(GroupMembershipService::class)->ensureOwnerMembership($group); foreach ([$contributor, $reviewer] as $user) { GroupMember::query()->create([ 'group_id' => $group->id, 'user_id' => $user->id, 'invited_by_user_id' => $owner->id, 'role' => Group::ROLE_MEMBER, 'status' => Group::STATUS_ACTIVE, 'invited_at' => now(), 'accepted_at' => now(), ]); } $artwork = Artwork::factory()->unpublished()->for($contributor, 'user')->create([ 'is_public' => false, 'visibility' => Artwork::VISIBILITY_PRIVATE, 'is_approved' => false, 'artwork_status' => 'draft', 'slug' => 'review-target-artwork', ]); $this->actingAs($contributor) ->postJson("/api/uploads/{$artwork->id}/submit-review", [ 'group' => $group->slug, 'title' => 'Review target', 'description' => 'Queued for review.', 'visibility' => 'public', ]) ->assertOk(); $artwork->refresh(); $this->actingAs($reviewer) ->post(route('studio.groups.artworks.approve', ['group' => $group, 'artwork' => $artwork]), [ 'review_notes' => 'I should not be able to do this.', ]) ->assertForbidden(); }); it('updates recruitment with controlled values, notifies followers, and enriches group search', function () { $owner = User::factory()->create(); $follower = User::factory()->create(); $group = Group::factory()->for($owner, 'owner')->create([ 'visibility' => Group::VISIBILITY_PUBLIC, ]); app(GroupMembershipService::class)->ensureOwnerMembership($group); DB::table('group_follows')->insert([ 'group_id' => $group->id, 'user_id' => $follower->id, 'created_at' => now(), 'updated_at' => now(), ]); $this->actingAs($owner) ->patch(route('studio.groups.recruitment.update', ['group' => $group]), [ 'is_recruiting' => true, 'headline' => 'Looking for loop artists', 'description' => 'We need animation support for short-form loops.', 'roles_json' => ['Animator', 'Sound Designer'], 'skills_json' => ['After Effects', 'Pixel Art'], 'contact_mode' => 'join_request', 'visibility' => 'public', ]) ->assertRedirect(); expect(Notification::query() ->where('user_id', $follower->id) ->where('type', 'group_recruitment_updated') ->exists())->toBeTrue(); $this->getJson(route('api.search.groups', ['q' => 'loop'])) ->assertOk() ->assertJsonPath('data.0.is_recruiting', true) ->assertJsonPath('data.0.recruitment_headline', 'Looking for loop artists'); }); it('publishes and pins posts, hides archived posts from public listings, and supports group post reporting with seo payloads', function () { $owner = User::factory()->create(); $viewer = User::factory()->create(); $group = Group::factory()->for($owner, 'owner')->create([ 'visibility' => Group::VISIBILITY_PUBLIC, ]); app(GroupMembershipService::class)->ensureOwnerMembership($group); $draft = GroupPost::query()->create([ 'group_id' => $group->id, 'author_user_id' => $owner->id, 'type' => GroupPost::TYPE_ANNOUNCEMENT, 'title' => 'Roadmap drop', 'slug' => 'roadmap-drop', 'excerpt' => 'The next release roadmap is ready.', 'content' => 'Full roadmap copy.', 'status' => GroupPost::STATUS_DRAFT, 'is_pinned' => false, ]); $archived = GroupPost::query()->create([ 'group_id' => $group->id, 'author_user_id' => $owner->id, 'type' => GroupPost::TYPE_UPDATE, 'title' => 'Old note', 'slug' => 'old-note', 'excerpt' => 'Old note excerpt.', 'content' => 'Old content.', 'status' => GroupPost::STATUS_DRAFT, 'is_pinned' => false, ]); $this->actingAs($owner) ->post(route('studio.groups.posts.publish', ['group' => $group, 'post' => $draft])) ->assertRedirect(); $this->actingAs($owner) ->post(route('studio.groups.posts.pin', ['group' => $group, 'post' => $draft])) ->assertRedirect(); $this->actingAs($owner) ->post(route('studio.groups.posts.archive', ['group' => $group, 'post' => $archived])) ->assertRedirect(); $this->get(route('groups.section', ['group' => $group, 'section' => 'posts'])) ->assertOk() ->assertInertia(fn (AssertableInertia $page) => $page ->component('Group/GroupShow') ->where('posts.0.title', 'Roadmap drop') ->where('group.pinned_post.title', 'Roadmap drop') ); $this->get(route('groups.posts.show', ['group' => $group, 'post' => $draft])) ->assertOk() ->assertInertia(fn (AssertableInertia $page) => $page ->component('Group/GroupPostShow') ->where('seo.canonical', route('groups.posts.show', ['group' => $group, 'post' => $draft])) ->where('seo.og_type', 'article') ); $this->actingAs($viewer) ->postJson(route('api.reports.store'), [ 'target_type' => 'group_post', 'target_id' => $draft->id, 'reason' => 'Spam-like announcement', ]) ->assertCreated(); expect(Report::query()->where('target_type', 'group_post')->where('target_id', $draft->id)->count())->toBe(1); });