Files
projectsend/tests/Feature/Groups/GroupsManagementTest.php
ignacionelson 6e47d76ba6 ProjectSend 2.0.0
Client file sharing, rebuilt from the ground up: a private area per
client, resumable uploads, folders, groups and categories, sharing with
expiry dates and download limits, comments, file versions, an activity
log, a REST API, and sixteen languages.

This repository begins here. ProjectSend 2 was developed privately, and
that development history is not published — the previous generation
remains available, with its own history, at projectsend/legacy.

Free software under the GNU General Public License v2, or (at your
option) any later version.
2026-08-14 01:38:12 -03:00

175 lines
7.7 KiB
PHP

<?php
declare(strict_types=1);
use App\Models\User;
use App\Modules\Audit\Action;
use App\Modules\Audit\ActivityLog;
use App\Modules\Groups\Models\Group;
use App\Modules\Identity\Permissions\SystemRole;
use App\Modules\Platform\Settings\Setting;
use App\Modules\Platform\Settings\Settings;
use Inertia\Testing\AssertableInertia;
beforeEach(function () {
$this->admin = User::factory()->create();
});
test('a group can be created, listed, and updated', function () {
// Settings are cached across tests (Cache::rememberForever survives
// the per-test DB rollback) — reset explicitly rather than assuming
// nothing else in the suite has touched this setting.
app(Settings::class)->set(Setting::PublicListingSlug, 'public');
$this->actingAs($this->admin);
$this->post('/groups', ['name' => 'Studio Clients', 'slug' => 'studio-clients', 'description' => 'The design studio', 'public' => false])
->assertRedirect();
$group = Group::query()->where('name', 'Studio Clients')->sole();
expect(ActivityLog::query()->where('action', Action::GroupCreated)->where('subject_name', 'Studio Clients')->exists())->toBeTrue();
$this->patch("/groups/{$group->id}", ['name' => 'Studio Clients', 'slug' => 'studio-clients', 'description' => 'Renamed desc', 'public' => true])
->assertRedirect();
expect($group->refresh()->public)->toBeTrue();
$this->get('/groups')->assertInertia(
fn (AssertableInertia $page) => $page
->component('groups/index')
->has('groups', 1)
->where('groups.0.public', true)
->where('groups.0.public_url', route('public.show', ['public', 'studio-clients'])),
);
});
test('a private group has no public URL in the groups list', function () {
$this->actingAs($this->admin);
Group::query()->create(['name' => 'Internal Only', 'public' => false]);
$this->get('/groups')->assertInertia(
fn (AssertableInertia $page) => $page->where('groups.0.public_url', null),
);
});
test('a group\'s slug is required only when public, but always unique', function () {
$this->actingAs($this->admin);
$this->post('/groups', ['name' => 'First', 'slug' => 'shared-slug', 'description' => null, 'public' => true])
->assertRedirect();
// Required once the group is public.
$this->post('/groups', ['name' => 'No Slug', 'description' => null, 'public' => true])
->assertSessionHasErrors('slug');
// Not required for a private group — one is derived from the name.
$this->post('/groups', ['name' => 'Private No Slug', 'description' => null, 'public' => false])
->assertRedirect();
expect(Group::query()->where('name', 'Private No Slug')->sole()->slug)->toBe('private-no-slug');
// Uniqueness is enforced regardless of public status.
$this->post('/groups', ['name' => 'Second', 'slug' => 'shared-slug', 'description' => null, 'public' => true])
->assertSessionHasErrors('slug');
// A group can keep its own slug on update without tripping the
// uniqueness rule against itself.
$group = Group::query()->where('name', 'First')->sole();
$this->patch("/groups/{$group->id}", ['name' => 'First', 'slug' => 'shared-slug', 'description' => null, 'public' => true])
->assertRedirect();
});
test('omitting the slug on update leaves the existing one alone, even if the name changes', function () {
$this->actingAs($this->admin);
// A private group doesn't require sending a slug — but it already
// has one (assigned explicitly here, standing in for one it kept
// from an earlier public stint or the auto-fallback).
$group = Group::query()->create(['name' => 'Original Name', 'slug' => 'kept-slug', 'public' => false]);
$this->patch("/groups/{$group->id}", ['name' => 'Renamed', 'description' => null, 'public' => false])
->assertRedirect();
expect($group->refresh()->slug)->toBe('kept-slug')->and($group->name)->toBe('Renamed');
});
test('creating a Group without a slug falls back to one derived from the name', function () {
$group = Group::query()->create(['name' => 'Marketing Team']);
expect($group->slug)->toBe('marketing-team');
$duplicate = Group::query()->create(['name' => 'Marketing Team']);
expect($duplicate->slug)->toBe('marketing-team-2');
});
test('clients can be added to and removed from a group; staff cannot join', function () {
$this->actingAs($this->admin);
$group = Group::query()->create(['name' => 'Members Test']);
$client = User::factory()->client()->create(['name' => 'Member Client']);
$staffer = User::factory()->role(SystemRole::Uploader)->create();
$this->post("/groups/{$group->id}/members", ['user_id' => $client->id])->assertRedirect();
expect($group->members()->pluck('users.id')->all())->toBe([$client->id]);
expect(ActivityLog::query()->where('action', Action::GroupMemberAdded)->sole()->context)
->toBe(['member' => 'Member Client']);
// Adding again is idempotent.
$this->post("/groups/{$group->id}/members", ['user_id' => $client->id])->assertRedirect();
expect($group->members()->count())->toBe(1);
// Staff are rejected.
$this->post("/groups/{$group->id}/members", ['user_id' => $staffer->id])->assertSessionHasErrors('user_id');
// The edit screen offers only clients not already in the group.
$other = User::factory()->client()->create();
$this->get("/groups/{$group->id}")->assertInertia(
fn (AssertableInertia $page) => $page
->has('members', 1)
->has('available_clients', 1)
->where('available_clients.0.id', $other->id),
);
$this->delete("/groups/{$group->id}/members/{$client->id}")->assertRedirect();
expect($group->members()->count())->toBe(0)
->and(ActivityLog::query()->where('action', Action::GroupMemberRemoved)->exists())->toBeTrue();
});
test('deleting a group is soft and leaves the clients alone', function () {
$this->actingAs($this->admin);
$group = Group::query()->create(['name' => 'Doomed']);
$client = User::factory()->client()->create();
$group->members()->attach($client->id);
$this->delete("/groups/{$group->id}")->assertRedirect(route('groups.index'));
expect(Group::query()->find($group->id))->toBeNull()
->and(Group::withTrashed()->find($group->id))->not->toBeNull()
->and(User::query()->find($client->id))->not->toBeNull();
});
test('group log entries link to the group for authorized viewers', function () {
$this->actingAs($this->admin);
$this->post('/groups', ['name' => 'Linky', 'slug' => 'linky', 'description' => null, 'public' => false]);
$group = Group::query()->where('name', 'Linky')->sole();
$this->get('/activity?action=group.created')->assertInertia(
fn (AssertableInertia $page) => $page->where('entries.0.subject_url', "/groups/{$group->id}"),
);
});
test('group permissions are granular and follow v1 defaults', function () {
// Account Manager: create/edit/delete but no manage_groups (no index).
$manager = User::factory()->role(SystemRole::AccountManager)->create();
$this->actingAs($manager);
$this->get('/groups')->assertForbidden();
$this->post('/groups', ['name' => 'AM Group', 'slug' => 'am-group', 'description' => null, 'public' => false])->assertRedirect();
// Uploader: nothing group-related.
$uploader = User::factory()->role(SystemRole::Uploader)->create();
$this->actingAs($uploader);
$this->get('/groups')->assertForbidden();
$this->post('/groups', ['name' => 'Nope', 'slug' => 'nope', 'description' => null, 'public' => false])->assertForbidden();
// Clients: sent home from staff URLs.
$this->actingAs(User::factory()->client()->create());
$this->get('/groups')->assertRedirect(route('dashboard'));
});