admin = User::factory()->create(); // Explicitly, never assumed: the Settings cache outlives a // RefreshDatabase rollback, so a test that wants the default has to say // so. See the settings-cache note in the test helpers. app(Settings::class)->set(Setting::ClientsHomeFolders, false); }); function enableHomeFolders(): void { app(Settings::class)->set(Setting::ClientsHomeFolders, true); } function shareFolderWithClient(Folder $folder, User $client): void { FolderAssignment::query()->create([ 'folder_id' => $folder->id, 'assignable_type' => $client->getMorphClass(), 'assignable_id' => $client->id, ]); } function clientWhoCanUpload(string $name = 'Acme Ltd'): User { $client = User::factory()->client()->create(['name' => $name]); foreach ([Permission::Upload, Permission::CreateOwnFolders] as $permission) { RolePermission::query()->firstOrCreate(['role_id' => $client->role_id, 'permission' => $permission->value]); } return $client->refresh(); } test('a new client gets a folder named after them, and only when the setting is on', function () { $before = User::factory()->client()->create(['name' => 'Before Ltd']); expect(app(ClientHomeFolders::class)->for($before))->toBeNull(); enableHomeFolders(); $after = User::factory()->client()->create(['name' => 'After Ltd']); $home = app(ClientHomeFolders::class)->for($after); expect($home)->not->toBeNull() ->and($home->name)->toBe('After Ltd') // At the root, which is the entire point: this is what an // administrator opening /files is meant to see. ->and($home->parent_id)->toBeNull() // Owned by the client, because that is how scopeVisibleToClient // grants them a folder -- no assignment row to keep in step. ->and($home->created_by)->toBe($after->id); // Staff who made the account did not accidentally become the owner. expect($home->created_by)->not->toBe($this->admin->id); }); test('the folder is created however the account was made, not just on one screen', function () { enableHomeFolders(); // Through the service the staff screens, the API and the control plane // all share -- a path that never calls a controller. $client = app(ClientAccounts::class)->create( name: 'Service Made', email: 'service@example.test', password: 'a-sufficiently-long-password', ); expect(app(ClientHomeFolders::class)->for($client))->not->toBeNull(); }); test('the home is a default location, never a boundary', function () { enableHomeFolders(); $client = clientWhoCanUpload(); // A folder staff shared with this client, nowhere near their home. $shared = app(FolderService::class)->create('Contracts', null); shareFolderWithClient($shared, $client); $this->actingAs($client)->get('/my-files')->assertOk()->assertInertia( fn (AssertableInertia $page) => $page // The shared folder is still right there. If this ever fails, // the feature has started revoking access rather than // organising it. ->where('folders', fn ($folders) => collect($folders)->pluck('name')->contains('Contracts')), ); }); test('the client sees inside their folder, not the folder itself', function () { enableHomeFolders(); $client = clientWhoCanUpload(); $home = app(ClientHomeFolders::class)->for($client); $inside = app(FolderService::class)->create('Invoices', $home); $inside->update(['created_by' => $client->id]); $this->actingAs($client)->get('/my-files')->assertOk()->assertInertia(function (AssertableInertia $page) { $names = collect($page->toArray()['props']['folders'])->pluck('name'); // Their own name is not information to them. expect($names)->toContain('Invoices') ->and($names)->not->toContain('Acme Ltd'); }); }); test('a folder the client creates without choosing a parent lands in their home', function () { enableHomeFolders(); $client = clientWhoCanUpload(); $home = app(ClientHomeFolders::class)->for($client); $this->actingAs($client)->post('/my-folders', ['name' => 'Receipts'])->assertRedirect(); expect(Folder::query()->where('name', 'Receipts')->sole()->parent_id)->toBe($home->id); }); test('the client cannot rename or delete their own home folder', function () { enableHomeFolders(); $client = clientWhoCanUpload(); $home = app(ClientHomeFolders::class)->for($client); // They own it -- created_by is them -- so without the guard both of // these would be allowed by ownership alone. expect($home->isOwnedBy($client))->toBeTrue(); $this->actingAs($client)->patch("/my-folders/{$home->id}", ['name' => 'Something else'])->assertForbidden(); $this->actingAs($client)->delete("/my-folders/{$home->id}")->assertForbidden(); expect($home->refresh()->name)->toBe('Acme Ltd'); }); test('renaming the client renames the folder, even over a hand-typed name', function () { enableHomeFolders(); $client = clientWhoCanUpload(); $home = app(ClientHomeFolders::class)->for($client); // Somebody renamed it by hand. The product decision is that the // account still wins: a folder called "Acme Ltd" under an account now // called something else misleads the administrator it exists for. $home->update(['name' => 'Hand typed']); $client->update(['name' => 'Acme Holdings']); expect($home->refresh()->name)->toBe('Acme Holdings'); }); test('the backfill is idempotent and reports what it did', function () { User::factory()->client()->count(3)->create(); enableHomeFolders(); $fresh = User::factory()->client()->create(); $first = app(ClientHomeFolders::class)->backfill(); // Four clients; the one created after the switch already had one. expect($first['total'])->toBe(4) ->and($first['created'])->toBe(3) ->and($first['existing'])->toBe(1); // Pressing the button twice must converge, not accumulate. $second = app(ClientHomeFolders::class)->backfill(); expect($second['created'])->toBe(0) ->and($second['existing'])->toBe(4) ->and(Folder::query()->whereNotNull('home_for_user_id')->count())->toBe(4); expect(app(ClientHomeFolders::class)->for($fresh))->not->toBeNull(); }); test('the backfill button is refused while the setting is off', function () { User::factory()->client()->create(); $this->actingAs($this->admin)->post('/system/settings/clients/home-folders')->assertForbidden(); expect(Folder::query()->whereNotNull('home_for_user_id')->count())->toBe(0); }); test('the settings screen says how many clients are still without a folder', function () { enableHomeFolders(); User::factory()->client()->count(2)->create(); // Created while the setting was on, so it already has one. expect(Folder::query()->whereNotNull('home_for_user_id')->count())->toBe(2); User::factory()->client()->count(3)->create(); Folder::query()->whereNotNull('home_for_user_id')->limit(3)->delete(); $this->actingAs($this->admin)->get('/system/settings/clients')->assertInertia( fn (AssertableInertia $page) => $page->where('clients_without_home', 3), ); }); test('a client upload with no folder chosen lands in their home', function () { enableHomeFolders(); $client = clientWhoCanUpload(); $home = app(ClientHomeFolders::class)->for($client); $this->actingAs($client); // The real intake path, with no folder_id in the body -- which is what // the portal sends when the client just picks a file and uploads. $session = $this->postJson('/uploads', [ 'filename' => 'note.txt', 'size' => 11, 'type' => 'text/plain', ])->assertOk()->json('uploadId'); // Parts go to a signed URL, not to the bare path -- see the portal's // own upload flow. $url = $this->getJson("/uploads/{$session}/parts/1/sign")->assertOk()->json('url'); $this->call('PUT', $url, [], [], [], ['CONTENT_TYPE' => 'application/octet-stream'], 'hello-world'); $this->postJson("/uploads/{$session}/complete")->assertOk(); expect(File::query()->where('uploaded_by', $client->id)->sole()->folder_id)->toBe($home->id); }); test('turning the setting off leaves an existing home working', function () { enableHomeFolders(); $client = clientWhoCanUpload(); $home = app(ClientHomeFolders::class)->for($client); app(Settings::class)->set(Setting::ClientsHomeFolders, false); // The folder is real and holds real files. Pretending it is gone would // strand them somewhere no listing looks. expect(app(ClientHomeFolders::class)->for($client->refresh())->id)->toBe($home->id); });