Files
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

147 lines
6.6 KiB
PHP

<?php
declare(strict_types=1);
use App\Models\User;
use App\Modules\Comments\Models\FileComment;
use App\Modules\Files\Models\File;
use App\Modules\Identity\Models\Role;
use App\Modules\Identity\Models\RolePermission;
use App\Modules\Platform\Settings\Setting;
use App\Modules\Platform\Settings\Settings;
use Inertia\Testing\AssertableInertia;
/**
* The management screen at /comments: searching and filtering everything,
* and — the part that matters — not showing more than the person could
* already read one file at a time.
*/
beforeEach(function () {
$this->admin = User::factory()->create();
$this->settings = app(Settings::class);
$this->settings->set(Setting::CommentsScope, 'all');
$this->settings->set(Setting::CommentsAuthors, 'everyone');
$this->settings->set(Setting::PublicCommentsEnabled, true);
$this->settings->set(Setting::CommentsGuestModeration, true);
$this->file = File::factory()->public()->create(['uploaded_by' => $this->admin->id, 'name' => 'Quarterly report']);
});
/**
* The bodies the screen returned, in order.
*
* @param array<string, string> $query
* @return list<string>
*/
function bodiesOn(array $query = []): array
{
$bodies = [];
test()->actingAs(test()->admin)
->get('/comments'.($query === [] ? '' : '?'.http_build_query($query)))
->assertOk()
->assertInertia(function (AssertableInertia $page) use (&$bodies): void {
$bodies = array_column($page->toArray()['props']['entries'], 'body');
});
return $bodies;
}
test('a moderation screen is not a way around the visibility model', function () {
$other = User::factory()->create();
// Somebody else's private note. Invisible to staff on the file's own
// thread, and listing every comment must not be the loophole that
// finally shows it — "only me" is the strongest promise this feature
// makes.
FileComment::factory()->for($this->file)->onlyMe()->create(['author_id' => $other->id, 'body' => 'My private note']);
FileComment::factory()->for($this->file)->everyone()->create(['author_id' => $other->id, 'body' => 'A public remark']);
expect(bodiesOn())->toBe(['A public remark']);
});
test('a client-scoped moderator sees only their own clients\' conversations', function () {
$mine = User::factory()->client()->create();
$theirs = User::factory()->client()->create();
shareFileWith($this->file, $mine);
shareFileWith($this->file, $theirs);
FileComment::factory()->for($this->file)->inThreadOf($mine)->create(['author_id' => $mine->id, 'body' => 'From my client']);
FileComment::factory()->for($this->file)->inThreadOf($theirs)->create(['author_id' => $theirs->id, 'body' => 'From another client']);
$role = Role::query()->create(['name' => 'Scoped moderator', 'client_scoped' => true]);
RolePermission::query()->insert([
['role_id' => $role->id, 'permission' => 'moderate_comments'],
['role_id' => $role->id, 'permission' => 'upload'],
]);
$manager = User::factory()->create(['role_id' => $role->id]);
$manager->assignedClients()->attach($mine->id);
$bodies = [];
$this->actingAs($manager)->get('/comments')->assertOk()
->assertInertia(function (AssertableInertia $page) use (&$bodies): void {
$bodies = array_column($page->toArray()['props']['entries'], 'body');
});
expect($bodies)->toContain('From my client')
->not->toContain('From another client');
});
test('the status filter separates what is held from what is published', function () {
FileComment::factory()->for($this->file)->everyone()->create(['author_id' => $this->admin->id, 'body' => 'Live']);
FileComment::factory()->for($this->file)->fromGuest()->pending()->create(['body' => 'Held']);
expect(bodiesOn(['status' => 'pending']))->toBe(['Held'])
->and(bodiesOn(['status' => 'approved']))->toBe(['Live'])
->and(bodiesOn())->toHaveCount(2);
});
test('search covers what was said and who said it, since either is how you remember it', function () {
$client = User::factory()->client()->create(['name' => 'Wanda Fields']);
shareFileWith($this->file, $client);
FileComment::factory()->for($this->file)->inThreadOf($client)->create(['author_id' => $client->id, 'body' => 'Where is the invoice?']);
FileComment::factory()->for($this->file)->fromGuest('Passing visitor')->create(['body' => 'Nice work']);
expect(bodiesOn(['search' => 'invoice']))->toBe(['Where is the invoice?'])
// By the account behind it…
->and(bodiesOn(['search' => 'Wanda']))->toBe(['Where is the invoice?'])
// …and by the name a visitor typed, which lives on the comment
// rather than on any account.
->and(bodiesOn(['search' => 'Passing']))->toBe(['Nice work']);
});
test('the other filters narrow by file, audience and author', function () {
$elsewhere = File::factory()->public()->create(['uploaded_by' => $this->admin->id, 'name' => 'Brand guidelines']);
FileComment::factory()->for($this->file)->staffOnly()->create(['author_id' => $this->admin->id, 'body' => 'Internal']);
FileComment::factory()->for($elsewhere)->everyone()->create(['author_id' => $this->admin->id, 'body' => 'On the other file']);
FileComment::factory()->for($this->file)->fromGuest()->create(['body' => 'From a stranger']);
expect(bodiesOn(['file' => 'Brand']))->toBe(['On the other file'])
->and(bodiesOn(['visibility' => 'staff_only']))->toBe(['Internal'])
->and(bodiesOn(['author_type' => 'guest']))->toBe(['From a stranger'])
->and(bodiesOn(['author_type' => 'staff']))->toHaveCount(2);
});
test('a filter value outside the vocabulary is rejected rather than ignored', function () {
$this->actingAs($this->admin)->get('/comments?status=maybe')->assertSessionHasErrors('status');
$this->actingAs($this->admin)->get('/comments?visibility=whoever')->assertSessionHasErrors('visibility');
});
test('the sidebar badge counts what is waiting, for whoever may act on it', function () {
FileComment::factory()->for($this->file)->fromGuest()->pending()->create();
FileComment::factory()->for($this->file)->fromGuest()->pending()->create();
FileComment::factory()->for($this->file)->everyone()->create(['author_id' => $this->admin->id]);
$this->actingAs($this->admin)->get('/dashboard')
->assertInertia(fn (AssertableInertia $page) => $page->where('pending.comments', 2));
// No permission, no number — a badge asking somebody to approve
// something they cannot approve is worse than no badge.
$this->actingAs(staffWithPermissions(['upload']))->get('/dashboard')
->assertInertia(fn (AssertableInertia $page) => $page->missing('pending.comments'));
});