With a download limit of 1, downloading one file of a share with several files deleted the share and the files not yet downloaded. ShareService::recordDownload() ran at the end of every download request, one file or the ZIP alike, and deleted the share as soon as download_count reached max_downloads. It has worked that way since the first commit. - One recipient's visit is one download. The first file or ZIP a session downloads is counted when it starts, in one conditional UPDATE that also checks the limit, so two recipients starting at once can't both take the last download. The session remembers the time, and for ShareService::DOWNLOAD_WINDOW_MINUTES (60) it may start more downloads of the share without counting them, even once the limit is reached. The claim happens in the controller before streaming, because the session is saved before the body is sent, and after the share key is resolved, so a request without the key uses nothing. - A share at its limit is closed to everyone else at once. The hourly cleanup deletes it 24 hours after shares.last_downloaded_at (new column), since a ZIP opens each file only when it reaches it and a large download can outlast the hour. - The download page of a limited share says how many downloads are left, switches to "You have 1 hour" on the first press (Alpine, as a download link does not render the page again), and shows the time left on the next visit. - The admin dashboard shows "2 of 3 downloads", marks shares at their limit "Download limit reached" and leaves them out of Active Shares. - Tests: the regression (3 files, limit 1: every file and the ZIP download, counted once), another recipient, the end of the hour, the last download going to one of two recipients, requests refused before streaming, unlimited shares, the page notes in PHP and in Chromium, the dashboard, and the cleanup at 23 and 25 hours. The tests of recordDownload() and of the instant deletion are gone. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
146 lines
5.6 KiB
PHP
146 lines
5.6 KiB
PHP
<?php
|
|
|
|
use App\Livewire\Admin\AdminDashboard;
|
|
use App\Models\Share;
|
|
use App\Models\ShareFile;
|
|
use App\Models\User;
|
|
use Illuminate\Support\Facades\Storage;
|
|
use Livewire\Livewire;
|
|
|
|
test('admin dashboard requires authentication', function () {
|
|
$response = $this->get(route('admin.dashboard'));
|
|
|
|
$response->assertRedirect(route('login'));
|
|
});
|
|
|
|
test('non-admin user cannot access admin dashboard', function () {
|
|
$user = User::factory()->create(['is_admin' => false]);
|
|
|
|
$response = $this->actingAs($user)->get(route('admin.dashboard'));
|
|
|
|
$response->assertForbidden();
|
|
});
|
|
|
|
test('admin can access dashboard', function () {
|
|
$admin = User::query()->where('is_admin', true)->first();
|
|
|
|
$response = $this->actingAs($admin)->get(route('admin.dashboard'));
|
|
|
|
$response->assertOk();
|
|
});
|
|
|
|
test('admin dashboard shows stats', function () {
|
|
$admin = User::query()->where('is_admin', true)->first();
|
|
|
|
$share = Share::factory()->create(['total_size' => 1024]);
|
|
ShareFile::factory()->create(['share_id' => $share->id]);
|
|
|
|
$response = $this->actingAs($admin)->get(route('admin.dashboard'));
|
|
|
|
$response->assertOk();
|
|
$response->assertSee('Total Shares');
|
|
$response->assertSee('Active Shares');
|
|
$response->assertSee('Total Files');
|
|
$response->assertSee('Disk Usage');
|
|
});
|
|
|
|
test('admin can delete share', function () {
|
|
Storage::fake('shares');
|
|
|
|
$admin = User::query()->where('is_admin', true)->first();
|
|
|
|
$share = Share::factory()->create();
|
|
$shareId = $share->id;
|
|
|
|
Livewire::actingAs($admin)
|
|
->test(AdminDashboard::class)
|
|
->set('deletingShareId', $shareId)
|
|
->call('deleteShare', $shareId)
|
|
->assertSet('deletingShareId', null);
|
|
|
|
expect(Share::query()->find($shareId))->toBeNull();
|
|
});
|
|
|
|
test('admin dashboard lists the shares', function () {
|
|
$admin = User::query()->where('is_admin', true)->first();
|
|
|
|
$share = Share::factory()->create(['token' => 'testtoken12345678']);
|
|
|
|
$response = $this->actingAs($admin)->get(route('admin.dashboard'));
|
|
|
|
$response->assertOk();
|
|
$response->assertSee('testtoken12345678');
|
|
$response->assertSee('href="'.route('share.download', $share).'"', false);
|
|
});
|
|
|
|
test('the shares list shows downloads against the limit, and a share at its limit as closed and not active', function () {
|
|
$admin = User::query()->where('is_admin', true)->first();
|
|
Share::factory()->withMaxDownloads(3)->create(['token' => 'limitedshare0000', 'download_count' => 2, 'expires_at' => null]);
|
|
Share::factory()->withMaxDownloads(1)->create(['token' => 'limitreached0000', 'download_count' => 1, 'expires_at' => null]);
|
|
Share::factory()->create(['token' => 'unlimitedshare00', 'download_count' => 1, 'expires_at' => null]);
|
|
|
|
$dashboard = Livewire::actingAs($admin)->test(AdminDashboard::class);
|
|
|
|
$dashboard->assertSeeInOrder(['limitedshare0000', '2 of 3 downloads', 'Never expires'])
|
|
->assertSeeInOrder(['limitreached0000', '1 of 1 download', 'Download limit reached'])
|
|
->assertSeeInOrder(['unlimitedshare00', '1 download', 'Never expires'])
|
|
->assertViewHas('activeShares', 2);
|
|
});
|
|
|
|
test('the shares list sorts only by its own orders', function () {
|
|
$admin = User::query()->where('is_admin', true)->first();
|
|
|
|
Share::factory()->create(['token' => 'aaaaaaaaaaaaaaaa', 'download_count' => 1, 'created_at' => now()->subDay()]);
|
|
Share::factory()->create(['token' => 'zzzzzzzzzzzzzzzz', 'download_count' => 9, 'created_at' => now()->subDays(2)]);
|
|
|
|
Livewire::actingAs($admin)
|
|
->test(AdminDashboard::class)
|
|
->assertSeeInOrder(['aaaaaaaaaaaaaaaa', 'zzzzzzzzzzzzzzzz'])
|
|
->set('sort', 'most-downloaded')
|
|
->assertSeeInOrder(['zzzzzzzzzzzzzzzz', 'aaaaaaaaaaaaaaaa'])
|
|
->set('sort', 'token; drop table shares')
|
|
->assertOk()
|
|
->assertSeeInOrder(['aaaaaaaaaaaaaaaa', 'zzzzzzzzzzzzzzzz']);
|
|
|
|
expect(Share::query()->count())->toBe(2);
|
|
});
|
|
|
|
test('sorting by expiry puts shares that never expire last', function () {
|
|
$admin = User::query()->where('is_admin', true)->first();
|
|
|
|
Share::factory()->create(['token' => 'neverexpires0000', 'expires_at' => null]);
|
|
Share::factory()->create(['token' => 'expireslater0000', 'expires_at' => now()->addWeek()]);
|
|
Share::factory()->create(['token' => 'expiressoon00000', 'expires_at' => now()->addHour()]);
|
|
|
|
Livewire::actingAs($admin)
|
|
->test(AdminDashboard::class)
|
|
->set('sort', 'expiring')
|
|
->assertSeeInOrder(['expiressoon00000', 'expireslater0000', 'neverexpires0000']);
|
|
});
|
|
|
|
test('without shares the dashboard shows an empty state instead of the list', function () {
|
|
$admin = User::query()->where('is_admin', true)->first();
|
|
|
|
$this->actingAs($admin)->get(route('admin.dashboard'))
|
|
->assertOk()
|
|
->assertSee('No shares yet')
|
|
->assertDontSee('data-test="share-row"', false);
|
|
});
|
|
|
|
test('shares whose files are still uploading are neither listed nor counted, but their bytes count as used space', function () {
|
|
$admin = User::query()->where('is_admin', true)->first();
|
|
$completed = Share::factory()->create(['token' => 'completedshare01', 'total_size' => 1000]);
|
|
ShareFile::factory()->for($completed)->create();
|
|
$pending = Share::factory()->pending()->create(['token' => 'pendingshare0001', 'total_size' => 500]);
|
|
ShareFile::factory()->for($pending)->uploading()->create();
|
|
|
|
Livewire::actingAs($admin)
|
|
->test(AdminDashboard::class)
|
|
->assertSee('completedshare01')
|
|
->assertDontSee('pendingshare0001')
|
|
->assertViewHas('totalShares', 1)
|
|
->assertViewHas('activeShares', 1)
|
|
->assertViewHas('totalFiles', 1)
|
|
->assertViewHas('usedSpace', 1500);
|
|
});
|