Files
projectsend/tests/Feature/Files/ShareNotificationsTest.php
denkfabrik-li 21cae2acb1 Stop a version link telling people about a file they already had
FileVersions::link() resolves its notification audience before the merge,
and says why:

    RESOLVED BEFORE THE MERGE, and the ordering is the whole dedupe:
    these are the people who could already see both files, so anyone the
    merge below is about to reach for the first time is excluded here and
    gets file_shared from FileSharing::assign() instead. Resolve it
    afterwards and every newly-added client receives two emails about one
    action.

The merge then undoes it. moveAssignmentsToRoot() hands every one of the
revision's targets to FileSharing::assign(), under a comment claiming
that firstOrCreate makes a target the root already has a no-op. It makes
the assignment row idempotent; the three side effects under it --
activity entry, in-app notification, digest -- run unconditionally.

Measured on main:

    client already holds the root and the revision, then both are linked
      file_shared      (Report)     <- wrong, they have had it all along
      file_new_version (Report v2)  <- right
      assignment rows on the root: 1

    client holds only the revision, then both are linked
      file_shared      (Report)     <- right, the merge does hand it over

Two notifications for one action, for exactly the people the early
resolve was meant to protect.

So a target the root already holds is skipped rather than handed to
assign(). Nobody is gaining access in that case, and the activity entry
would be as untrue as the notification. copyAssignmentsFrom() directly
below already states that rule for its own case, which is why it inserts
directly instead of going through FileSharing. Both stale comments are
corrected with it.

Not changed: FileSharing::assign() itself, and so the behaviour
ShareNotificationsTest pins -- re-posting an existing assignment through
the share endpoint still notifies again. That test names the condition
for ever changing it, "it should stop being sent for both at once", and
that is a decision about files and folders together. This is narrower: a
version merge is not somebody choosing to share again, and it already
had a stated intent to send exactly one notification.

Three cases in ShareNotificationsTest -- the target already on the root,
the target gaining it, and a group already on the root. Reverting
FileVersions alone leaves 2 failed / 8 passed in that file; the middle
case passes without the fix, because it guards against skipping too much
rather than against the duplicate notice.

Suite 2108 passed / 2 skipped, 11415 assertions, PHPStan level 8 clean.
Measured on base 06c364d2, where main itself is 2105 / 2.
2026-08-28 06:56:09 +02:00

180 lines
8.1 KiB
PHP

<?php
declare(strict_types=1);
use App\Models\User;
use App\Modules\Files\Models\File;
use App\Modules\Files\Models\FileAssignment;
use App\Modules\Files\Models\Folder;
use App\Modules\Files\Versions\FileVersions;
use App\Modules\Groups\Models\Group;
use App\Modules\Notifications\InAppNotification;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Support\Facades\Storage;
beforeEach(function () {
Storage::fake('files');
$this->admin = User::factory()->create();
});
function sharedNotificationsFor(User $client): Collection
{
return InAppNotification::query()
->where('user_id', $client->id)
->where('type', 'file_shared')
->get();
}
test('sharing a file with a client notifies them', function () {
$file = File::factory()->create(['name' => 'Report']);
$client = User::factory()->client()->create();
$this->actingAs($this->admin)->post("/files/{$file->id}/assignments", ['type' => 'client', 'id' => $client->id]);
$notifications = sharedNotificationsFor($client);
expect($notifications)->toHaveCount(1)
->and($notifications->first()->data['itemName'])->toBe('Report')
->and($notifications->first()->subject_id)->toBe($file->id);
});
// Folder shares used to send nothing at all: FolderAssignmentsController had
// no Notifier, so a client was granted live access to a whole subtree without
// being told. The registered type's own label — "A file or folder was shared
// with you" — shows folders were always meant to be covered.
test('sharing a folder with a client notifies them too', function () {
$folder = Folder::query()->create(['name' => 'Brand Kit', 'path' => '/']);
$client = User::factory()->client()->create();
$this->actingAs($this->admin)->post("/folders/{$folder->id}/assignments", ['type' => 'client', 'id' => $client->id]);
$notifications = sharedNotificationsFor($client);
expect($notifications)->toHaveCount(1)
->and($notifications->first()->data['itemName'])->toBe('Brand Kit')
->and($notifications->first()->subject_id)->toBe($folder->id)
->and($notifications->first()->subject_type)->toBe($folder->getMorphClass());
});
test('sharing with a group notifies every member, for both subjects', function (string $type) {
$group = Group::query()->create(['name' => 'Design Team']);
$first = User::factory()->client()->create();
$second = User::factory()->client()->create();
$group->members()->sync([$first->id, $second->id]);
$subject = $type === 'files'
? File::factory()->create(['name' => 'Report'])
: Folder::query()->create(['name' => 'Brand Kit', 'path' => '/']);
$this->actingAs($this->admin)->post("/{$type}/{$subject->id}/assignments", ['type' => 'group', 'id' => $group->id]);
expect(sharedNotificationsFor($first))->toHaveCount(1)
->and(sharedNotificationsFor($second))->toHaveCount(1);
})->with(['files', 'folders']);
// firstOrCreate makes re-posting an existing assignment a no-op on the row,
// but the notification is sent outside that check, so a repeat share notifies
// again. That is long-standing behaviour on the file side; what this pins is
// that folders now do exactly the same thing. If the repeat notification is
// ever judged wrong, it should stop being sent for both at once — which is
// the point of them sharing one implementation.
test('a repeat share notifies again, identically for files and folders', function () {
$file = File::factory()->create(['name' => 'Report']);
$folder = Folder::query()->create(['name' => 'Brand Kit', 'path' => '/']);
$fileClient = User::factory()->client()->create();
$folderClient = User::factory()->client()->create();
foreach ([1, 2] as $ignored) {
$this->actingAs($this->admin)->post("/files/{$file->id}/assignments", ['type' => 'client', 'id' => $fileClient->id]);
$this->actingAs($this->admin)->post("/folders/{$folder->id}/assignments", ['type' => 'client', 'id' => $folderClient->id]);
}
expect(sharedNotificationsFor($fileClient)->count())
->toBe(sharedNotificationsFor($folderClient)->count())
->and(sharedNotificationsFor($fileClient))->toHaveCount(2);
});
test('unsharing does not notify', function (string $type) {
$subject = $type === 'files'
? File::factory()->create(['name' => 'Report'])
: Folder::query()->create(['name' => 'Brand Kit', 'path' => '/']);
$client = User::factory()->client()->create();
$this->actingAs($this->admin)->post("/{$type}/{$subject->id}/assignments", ['type' => 'client', 'id' => $client->id]);
$this->actingAs($this->admin)->delete("/{$type}/{$subject->id}/assignments", ['type' => 'client', 'id' => $client->id]);
expect(sharedNotificationsFor($client))->toHaveCount(1);
})->with(['files', 'folders']);
/*
|--------------------------------------------------------------------------
| Linking a revision moves recipients; it must not re-announce the root
|--------------------------------------------------------------------------
*/
function notificationTypesFor(User $client): array
{
return InAppNotification::query()
->where('user_id', $client->id)
->orderBy('id')
->pluck('type')
->all();
}
test('linking a revision does not re-announce the root to somebody who already had it', function () {
$client = User::factory()->client()->create();
$root = File::factory()->create(['name' => 'Report']);
$revision = File::factory()->create(['name' => 'Report v2']);
$this->actingAs($this->admin)->post("/files/{$root->id}/assignments", ['type' => 'client', 'id' => $client->id]);
$this->actingAs($this->admin)->post("/files/{$revision->id}/assignments", ['type' => 'client', 'id' => $client->id]);
InAppNotification::query()->where('user_id', $client->id)->delete();
app(FileVersions::class)->link($revision->refresh(), $root->refresh(), $this->admin);
// One action, one notification. file_new_version is the right one:
// link() resolves that audience before the merge precisely so the
// people who could already see both are told once, and here they hold
// the root itself, so nothing was shared with them.
expect(notificationTypesFor($client))->toBe(['file_new_version'])
->and(FileAssignment::query()->where('file_id', $root->id)->count())->toBe(1);
});
test('linking a revision still announces the root to somebody who is gaining it', function () {
$client = User::factory()->client()->create();
$root = File::factory()->create(['name' => 'Report']);
$revision = File::factory()->create(['name' => 'Report v2']);
// Only the revision, so the merge really does hand them the root.
$this->actingAs($this->admin)->post("/files/{$revision->id}/assignments", ['type' => 'client', 'id' => $client->id]);
InAppNotification::query()->where('user_id', $client->id)->delete();
app(FileVersions::class)->link($revision->refresh(), $root->refresh(), $this->admin);
// Not file_new_version: they could not see the root before, so they are
// not part of sharedAudience() — see its INTERSECTION docblock.
expect(notificationTypesFor($client))->toBe(['file_shared'])
->and(FileAssignment::query()->where('file_id', $root->id)->count())->toBe(1);
});
test('a group already on the root is skipped the same way a client is', function () {
$member = User::factory()->client()->create();
$group = Group::query()->create(['name' => 'Design Team']);
$group->members()->sync([$member->id]);
$root = File::factory()->create(['name' => 'Report']);
$revision = File::factory()->create(['name' => 'Report v2']);
$this->actingAs($this->admin)->post("/files/{$root->id}/assignments", ['type' => 'group', 'id' => $group->id]);
$this->actingAs($this->admin)->post("/files/{$revision->id}/assignments", ['type' => 'group', 'id' => $group->id]);
InAppNotification::query()->where('user_id', $member->id)->delete();
app(FileVersions::class)->link($revision->refresh(), $root->refresh(), $this->admin);
expect(notificationTypesFor($member))->toBe(['file_new_version'])
->and(FileAssignment::query()->where('file_id', $root->id)->count())->toBe(1);
});