2198 lines
83 KiB
PHP
2198 lines
83 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('lets owners manage challenge outcomes and exposes them on public challenge pages', function () {
|
|
$owner = User::factory()->create();
|
|
$group = Group::factory()->for($owner, 'owner')->create([
|
|
'visibility' => Group::VISIBILITY_PUBLIC,
|
|
]);
|
|
|
|
app(GroupMembershipService::class)->ensureOwnerMembership($group);
|
|
|
|
$winner = 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(),
|
|
]);
|
|
$finalist = 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.challenges.store', ['group' => $group]), [
|
|
'title' => 'Pixel Week Results',
|
|
'summary' => 'Challenge summary',
|
|
'description' => 'Challenge description',
|
|
'visibility' => GroupChallenge::VISIBILITY_PUBLIC,
|
|
'participation_scope' => GroupChallenge::PARTICIPATION_PUBLIC,
|
|
'status' => GroupChallenge::STATUS_ACTIVE,
|
|
'start_at' => now()->subDay()->toDateTimeString(),
|
|
'end_at' => now()->addDay()->toDateTimeString(),
|
|
'judging_mode' => 'curated',
|
|
])
|
|
->assertRedirect();
|
|
|
|
$challenge = GroupChallenge::query()->where('group_id', $group->id)->latest('id')->firstOrFail();
|
|
|
|
$this->actingAs($owner)
|
|
->post(route('studio.groups.challenges.attach-artwork', ['group' => $group, 'challenge' => $challenge]), [
|
|
'artwork_id' => $winner->id,
|
|
])
|
|
->assertRedirect();
|
|
|
|
$this->actingAs($owner)
|
|
->post(route('studio.groups.challenges.attach-artwork', ['group' => $group, 'challenge' => $challenge]), [
|
|
'artwork_id' => $finalist->id,
|
|
])
|
|
->assertRedirect();
|
|
|
|
$this->actingAs($owner)
|
|
->patch(route('studio.groups.challenges.update', ['group' => $group, 'challenge' => $challenge]), [
|
|
'title' => 'Pixel Week Results',
|
|
'summary' => 'Challenge summary',
|
|
'description' => 'Challenge description',
|
|
'visibility' => GroupChallenge::VISIBILITY_PUBLIC,
|
|
'participation_scope' => GroupChallenge::PARTICIPATION_PUBLIC,
|
|
'status' => GroupChallenge::STATUS_ACTIVE,
|
|
'start_at' => now()->subDay()->toDateTimeString(),
|
|
'end_at' => now()->addDay()->toDateTimeString(),
|
|
'judging_mode' => 'curated',
|
|
'outcomes' => [
|
|
[
|
|
'artwork_id' => $winner->id,
|
|
'outcome_type' => 'winner',
|
|
'position' => 1,
|
|
'sort_order' => 0,
|
|
'title_override' => 'Grand Winner',
|
|
'note' => 'Top overall result.',
|
|
],
|
|
[
|
|
'artwork_id' => $finalist->id,
|
|
'outcome_type' => 'finalist',
|
|
'sort_order' => 1,
|
|
'note' => 'Strong finalist showing.',
|
|
],
|
|
],
|
|
])
|
|
->assertRedirect();
|
|
|
|
$challenge->refresh();
|
|
|
|
expect($challenge->featured_artwork_id)->toBe($winner->id);
|
|
|
|
$this->assertDatabaseHas('group_challenge_outcomes', [
|
|
'group_challenge_id' => $challenge->id,
|
|
'artwork_id' => $winner->id,
|
|
'outcome_type' => 'winner',
|
|
]);
|
|
$this->assertDatabaseHas('group_challenge_outcomes', [
|
|
'group_challenge_id' => $challenge->id,
|
|
'artwork_id' => $finalist->id,
|
|
'outcome_type' => 'finalist',
|
|
]);
|
|
|
|
$this->get(route('groups.challenges.show', ['group' => $group, 'challenge' => $challenge]))
|
|
->assertOk()
|
|
->assertInertia(fn (AssertableInertia $page) => $page
|
|
->component('Group/GroupChallengeShow')
|
|
->where('challenge.outcome_sections.winner.items.0.title', $winner->title)
|
|
->where('challenge.outcome_sections.finalist.items.0.title', $finalist->title));
|
|
});
|
|
|
|
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);
|
|
}); |