Files
projectsend/tests/Feature/Files/BulkEditFilesTest.php
T
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

248 lines
9.6 KiB
PHP

<?php
declare(strict_types=1);
use App\Models\User;
use App\Modules\Audit\Action;
use App\Modules\Audit\ActivityLog;
use App\Modules\Files\Models\Category;
use App\Modules\Files\Models\Folder;
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 Illuminate\Support\Facades\Storage;
beforeEach(function () {
Storage::fake('files');
$this->admin = User::factory()->create();
app(Settings::class)->set(Setting::Theme, 'default');
});
/**
* A staff role limited to editing only files it owns — no
* set_file_expiration_date/set_file_categories either, mirroring the
* "File Editor Only" fixture FilesTest.php already uses for the equivalent
* single-edit permission tests.
*/
function ownFilesOnlyStaff(string $roleName = 'Own Files Only'): User
{
$role = Role::query()->create(['name' => $roleName, 'is_administrator' => false, 'is_system' => false]);
RolePermission::query()->insert([
['role_id' => $role->id, 'permission' => 'upload'],
['role_id' => $role->id, 'permission' => 'edit_files'],
]);
return User::factory()->create(['role_id' => $role->id]);
}
function noEditPayload(array $overrides = []): array
{
return array_merge([
'folder_action' => 'no_change',
'description_action' => 'no_change',
'expiration_action' => 'no_change',
'add_category_ids' => [],
'remove_category_ids' => [],
], $overrides);
}
test('a bulk edit only applies the fields that were actually touched', function () {
$file = uploadDocumentFile($this->admin);
$folder = Folder::query()->create(['name' => 'Reports']);
$category = Category::query()->create(['name' => 'Invoices']);
$file->update(['folder_id' => $folder->id, 'expires_at' => '2030-01-01']);
$file->categories()->attach($category->id);
$this->actingAs($this->admin)->patch('/files/bulk-edit', noEditPayload([
'file_ids' => [$file->id],
'description_action' => 'set',
'description' => 'Updated description',
]))->assertRedirect();
$file->refresh();
expect($file->description)->toBe('Updated description')
->and($file->folder_id)->toBe($folder->id)
->and($file->expires_at?->toDateString())->toBe('2030-01-01')
->and($file->categories->pluck('id')->all())->toBe([$category->id]);
});
test('adding categories in bulk does not remove categories not selected for removal', function () {
$file = uploadDocumentFile($this->admin);
$existing = Category::query()->create(['name' => 'Existing']);
$new = Category::query()->create(['name' => 'New']);
$file->categories()->attach($existing->id);
$this->actingAs($this->admin)->patch('/files/bulk-edit', noEditPayload([
'file_ids' => [$file->id],
'add_category_ids' => [$new->id],
]))->assertRedirect();
expect($file->refresh()->categories->pluck('id')->sort()->values()->all())
->toBe(collect([$existing->id, $new->id])->sort()->values()->all());
});
test('removing categories in bulk does not touch categories not selected for removal', function () {
$file = uploadDocumentFile($this->admin);
$keep = Category::query()->create(['name' => 'Keep']);
$drop = Category::query()->create(['name' => 'Drop']);
$file->categories()->attach([$keep->id, $drop->id]);
$this->actingAs($this->admin)->patch('/files/bulk-edit', noEditPayload([
'file_ids' => [$file->id],
'remove_category_ids' => [$drop->id],
]))->assertRedirect();
expect($file->refresh()->categories->pluck('id')->all())->toBe([$keep->id]);
});
test('bulk expiration set/clear/no-change behave independently per action', function () {
$withExpiry = uploadDocumentFile($this->admin, 'a.pdf');
$withExpiry->update(['expires_at' => '2030-06-01']);
$noExpiry = uploadDocumentFile($this->admin, 'b.pdf');
// "no_change" leaves differing existing values exactly as they were.
$this->actingAs($this->admin)->patch('/files/bulk-edit', noEditPayload([
'file_ids' => [$withExpiry->id, $noExpiry->id],
'description_action' => 'set',
'description' => 'touch something unrelated',
]))->assertRedirect();
expect($withExpiry->refresh()->expires_at?->toDateString())->toBe('2030-06-01')
->and($noExpiry->refresh()->expires_at)->toBeNull();
// "set" applies the same date to both, regardless of prior state.
$this->actingAs($this->admin)->patch('/files/bulk-edit', noEditPayload([
'file_ids' => [$withExpiry->id, $noExpiry->id],
'expiration_action' => 'set',
'expires_at' => '2031-01-01',
]))->assertRedirect();
expect($withExpiry->refresh()->expires_at?->toDateString())->toBe('2031-01-01')
->and($noExpiry->refresh()->expires_at?->toDateString())->toBe('2031-01-01');
// "clear" nulls both.
$this->actingAs($this->admin)->patch('/files/bulk-edit', noEditPayload([
'file_ids' => [$withExpiry->id, $noExpiry->id],
'expiration_action' => 'clear',
]))->assertRedirect();
expect($withExpiry->refresh()->expires_at)->toBeNull()
->and($noExpiry->refresh()->expires_at)->toBeNull();
});
test('bulk folder move reparents every selected file, including to root', function () {
$folder = Folder::query()->create(['name' => 'Reports']);
$fileA = uploadDocumentFile($this->admin, 'a.pdf');
$fileB = uploadDocumentFile($this->admin, 'b.pdf');
$this->actingAs($this->admin)->patch('/files/bulk-edit', noEditPayload([
'file_ids' => [$fileA->id, $fileB->id],
'folder_action' => 'move',
'folder_id' => $folder->id,
]))->assertRedirect();
expect($fileA->refresh()->folder_id)->toBe($folder->id)
->and($fileB->refresh()->folder_id)->toBe($folder->id);
$this->actingAs($this->admin)->patch('/files/bulk-edit', noEditPayload([
'file_ids' => [$fileA->id, $fileB->id],
'folder_action' => 'move',
'folder_id' => null,
]))->assertRedirect();
expect($fileA->refresh()->folder_id)->toBeNull()
->and($fileB->refresh()->folder_id)->toBeNull();
});
test('a file the acting staff member cannot edit is silently skipped, not 403d', function () {
$staff = ownFilesOnlyStaff();
expect($staff->can('edit_others_files'))->toBeFalse();
$ownFile = uploadDocumentFile($staff, 'own.pdf');
$othersFile = uploadDocumentFile($this->admin, 'others.pdf');
$response = $this->actingAs($staff)->patch('/files/bulk-edit', noEditPayload([
'file_ids' => [$ownFile->id, $othersFile->id],
'description_action' => 'set',
'description' => 'edited',
]));
$response->assertRedirect();
expect(session('success'))->toContain('1 of 2 selected files were updated');
expect($ownFile->refresh()->description)->toBe('edited')
->and($othersFile->refresh()->description)->not->toBe('edited');
});
test('a bulk edit where nothing is authorized returns 422, not a misleading success', function () {
$role = Role::query()->create(['name' => 'No Edit', 'is_administrator' => false, 'is_system' => false]);
RolePermission::query()->insert([
['role_id' => $role->id, 'permission' => 'upload'],
]);
$staff = User::factory()->create(['role_id' => $role->id]);
$file = uploadDocumentFile($this->admin);
$this->actingAs($staff)->patch('/files/bulk-edit', noEditPayload([
'file_ids' => [$file->id],
'description_action' => 'set',
'description' => 'nope',
]))->assertStatus(422);
});
test('expiration and category changes in a bulk edit are silently ignored without the matching permission', function () {
$staff = ownFilesOnlyStaff('File Editor Only, No Expiry Or Categories');
expect($staff->can('set_file_expiration_date'))->toBeFalse()
->and($staff->can('set_file_categories'))->toBeFalse();
$file = uploadDocumentFile($staff);
$category = Category::query()->create(['name' => 'Should Not Apply']);
$this->actingAs($staff)->patch('/files/bulk-edit', noEditPayload([
'file_ids' => [$file->id],
'expiration_action' => 'set',
'expires_at' => '2030-01-01',
'add_category_ids' => [$category->id],
]))->assertRedirect();
expect($file->refresh()->expires_at)->toBeNull()
->and($file->categories()->count())->toBe(0);
});
test('a bulk edit that changes nothing is rejected', function () {
$file = uploadDocumentFile($this->admin);
$this->actingAs($this->admin)->patch('/files/bulk-edit', noEditPayload([
'file_ids' => [$file->id],
]))->assertStatus(422);
});
test('each successfully updated file gets exactly one FileUpdated activity log entry', function () {
$staff = ownFilesOnlyStaff('Own Files Only 2');
$ownFile = uploadDocumentFile($staff, 'own.pdf');
$othersFile = uploadDocumentFile($this->admin, 'others.pdf');
$before = ActivityLog::query()->where('action', Action::FileUpdated)->count();
$this->actingAs($staff)->patch('/files/bulk-edit', noEditPayload([
'file_ids' => [$ownFile->id, $othersFile->id],
'description_action' => 'set',
'description' => 'edited',
]))->assertRedirect();
expect(ActivityLog::query()->where('action', Action::FileUpdated)->count())->toBe($before + 1);
});
test('the bulk-edit route is not swallowed by the single-file {file} route', function () {
$file = uploadDocumentFile($this->admin);
$this->actingAs($this->admin)->patch('/files/bulk-edit', noEditPayload([
'file_ids' => [$file->id],
'description_action' => 'set',
'description' => 'route sanity',
]))->assertRedirect();
expect($file->refresh()->description)->toBe('route sanity');
});