Files
SkinbaseNova/tests/Feature/Groups/GroupsFeatureTest.php

2088 lines
78 KiB
PHP

<?php
use App\Models\Artwork;
use App\Models\Group;
use App\Models\GroupInvitation;
use App\Models\GroupJoinRequest;
use App\Models\GroupMember;
use App\Models\GroupPost;
use App\Models\GroupRecruitmentProfile;
use App\Models\GroupActivityItem;
use App\Models\GroupAsset;
use App\Models\GroupChallenge;
use App\Models\GroupEvent;
use App\Models\GroupContributorStat;
use App\Models\GroupProject;
use App\Models\GroupProjectMember;
use App\Models\GroupRelease;
use App\Models\GroupReleaseContributor;
use App\Models\GroupReleaseMilestone;
use App\Models\Notification;
use App\Models\Report;
use App\Models\User;
use App\Models\Collection;
use App\Services\GroupMembershipService;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;
use Inertia\Testing\AssertableInertia;
uses(RefreshDatabase::class);
function createCategoryForGroupsFeatureTests(): int
{
$contentTypeId = DB::table('content_types')->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);
});