From ba91c6389c0df92a321bc7d9f0b77da6f07cd7f9 Mon Sep 17 00:00:00 2001 From: Owen Voke Date: Mon, 7 Sep 2026 17:40:00 +0100 Subject: [PATCH] test: add full test coverage --- tests/Feature/ExampleTest.php | 9 -- .../Controllers/DashboardControllerTest.php | 55 +++++++ .../Controllers/DetailsControllerTest.php | 47 ++++++ .../Controllers/DownloadControllerTest.php | 88 ++++++++++ .../Http/Controllers/HomeControllerTest.php | 9 -- .../Http/Controllers/UploadControllerTest.php | 152 +++++++++++------- .../Feature/Livewire/UploadedTorrentsTest.php | 50 ++++++ tests/Feature/Rules/IsTorrentFileTest.php | 56 +++++++ tests/Feature/TorrentFlowTest.php | 90 +++++++++++ tests/Pest.php | 112 +++++++++++++ tests/Unit/Models/UserTest.php | 53 ++++++ 11 files changed, 647 insertions(+), 74 deletions(-) delete mode 100644 tests/Feature/ExampleTest.php create mode 100644 tests/Feature/Http/Controllers/DashboardControllerTest.php create mode 100644 tests/Feature/Http/Controllers/DetailsControllerTest.php create mode 100644 tests/Feature/Http/Controllers/DownloadControllerTest.php delete mode 100644 tests/Feature/Http/Controllers/HomeControllerTest.php create mode 100644 tests/Feature/Rules/IsTorrentFileTest.php create mode 100644 tests/Feature/TorrentFlowTest.php create mode 100644 tests/Unit/Models/UserTest.php diff --git a/tests/Feature/ExampleTest.php b/tests/Feature/ExampleTest.php deleted file mode 100644 index 646ab61..0000000 --- a/tests/Feature/ExampleTest.php +++ /dev/null @@ -1,9 +0,0 @@ -get('/'); - - $response->assertStatus(200); -}); diff --git a/tests/Feature/Http/Controllers/DashboardControllerTest.php b/tests/Feature/Http/Controllers/DashboardControllerTest.php new file mode 100644 index 0000000..3478f15 --- /dev/null +++ b/tests/Feature/Http/Controllers/DashboardControllerTest.php @@ -0,0 +1,55 @@ +get(route('dashboard')) + ->assertOk() + ->assertSee('Click to upload') + ->assertSee(route('upload')); +}); + +it('does not show the torrent list to a guest', function () { + $this->get(route('dashboard')) + ->assertOk() + ->assertDontSee('You have not uploaded any torrents.'); +}); + +it('shows a signed in user their uploaded torrents', function () { + $user = UserFactory::new()->create(); + $torrent = TorrentFactory::new()->create(['filename' => 'example.bin']); + $user->torrents()->attach($torrent); + + $this->actingAs($user)->get(route('dashboard')) + ->assertOk() + ->assertSee('example.bin') + ->assertSee(route('details', ['torrent' => $torrent->hash])); +}); + +it('tells a signed in user when they have uploaded nothing', function () { + $this->actingAs(UserFactory::new()->create()) + ->get(route('dashboard')) + ->assertOk() + ->assertSee('You have not uploaded any torrents.'); +}); + +it('reports why an upload was rejected', function () { + $this->from(route('dashboard')) + ->post(route('upload'), [ + 'torrent' => UploadedFile::fake()->createWithContent('bad.torrent', 'this is not bencoded'), + ]) + ->assertRedirect(route('dashboard')); + + $this->followingRedirects() + ->from(route('dashboard')) + ->post(route('upload'), [ + 'torrent' => UploadedFile::fake()->createWithContent('bad.torrent', 'this is not bencoded'), + ]) + ->assertOk() + ->assertSee('That torrent could not be uploaded') + ->assertSee('Invalid torrent file provided.'); +}); diff --git a/tests/Feature/Http/Controllers/DetailsControllerTest.php b/tests/Feature/Http/Controllers/DetailsControllerTest.php new file mode 100644 index 0000000..ca80aa4 --- /dev/null +++ b/tests/Feature/Http/Controllers/DetailsControllerTest.php @@ -0,0 +1,47 @@ +create([ + 'filename' => 'example.bin', + 'size' => 1048576, + 'downloads' => 1234, + ]); + + $this->get(route('details', ['torrent' => $torrent->hash])) + ->assertOk() + ->assertSee($torrent->hash) + ->assertSee('example.bin') + ->assertSee('1.00 MB') + ->assertSee('1,234') + ->assertSee(route('download', ['torrent' => $torrent->hash])); +}); + +it('falls back to the hash when the original filename is unknown', function () { + $torrent = TorrentFactory::new()->create(['filename' => null]); + + $this->get(route('details', ['torrent' => $torrent->hash])) + ->assertOk() + ->assertSee('Unknown') + ->assertSee($torrent->hash); +}); + +it('is reachable by a guest', function () { + $torrent = TorrentFactory::new()->create(); + + $this->get(route('details', ['torrent' => $torrent->hash]))->assertOk(); +}); + +it('looks a torrent up by its hash rather than its id', function () { + $torrent = TorrentFactory::new()->create(); + + $this->get("/torrents/{$torrent->id}")->assertNotFound(); + $this->get("/torrents/{$torrent->hash}")->assertOk(); +}); + +it('returns a 404 for a hash that was never uploaded', function () { + $this->get(route('details', ['torrent' => str_repeat('a', 40)]))->assertNotFound(); +}); diff --git a/tests/Feature/Http/Controllers/DownloadControllerTest.php b/tests/Feature/Http/Controllers/DownloadControllerTest.php new file mode 100644 index 0000000..ff71b8f --- /dev/null +++ b/tests/Feature/Http/Controllers/DownloadControllerTest.php @@ -0,0 +1,88 @@ + Storage::fake('torrents')); + +/** Put a torrent on the disk the same way the upload pipeline would. */ +function storedTorrent(string $contents = 'd8:announce0:e'): Torrent +{ + $torrent = TorrentFactory::new()->create(['downloads' => 0]); + + Storage::disk('torrents')->put("{$torrent->hash}.torrent", $contents); + + return $torrent; +} + +it('serves the stored file under the torrent hash', function () { + $torrent = storedTorrent('the-original-bytes'); + + $response = $this->get(route('download', ['torrent' => $torrent->hash])) + ->assertOk() + ->assertDownload("{$torrent->hash}.torrent"); + + expect($response->streamedContent())->toBe('the-original-bytes'); +}); + +it('counts every download', function () { + $torrent = storedTorrent(); + + foreach (range(1, 3) as $expected) { + $this->get(route('download', ['torrent' => $torrent->hash]))->assertOk(); + + expect($torrent->refresh()->downloads)->toBe($expected); + } +}); + +it('is reachable by a guest', function () { + $torrent = storedTorrent(); + + $this->get(route('download', ['torrent' => $torrent->hash]))->assertOk(); +}); + +it('returns a 404 for a hash that was never uploaded', function () { + $this->get(route('download', ['torrent' => str_repeat('a', 40)]))->assertNotFound(); + + Storage::disk('torrents')->assertDirectoryEmpty(''); +}); + +it('returns a 404 when the row outlived its cached file', function () { + $torrent = TorrentFactory::new()->create(['downloads' => 0]); + + $this->get(route('download', ['torrent' => $torrent->hash]))->assertNotFound(); +}); + +it('does not count a download it could not serve', function () { + $torrent = TorrentFactory::new()->create(['downloads' => 7]); + + $this->get(route('download', ['torrent' => $torrent->hash]))->assertNotFound(); + + expect($torrent->refresh()->downloads)->toBe(7); +}); + +it('logs the hash whose cached file has gone missing', function () { + Log::spy(); + + $torrent = TorrentFactory::new()->create(); + + $this->get(route('download', ['torrent' => $torrent->hash]))->assertNotFound(); + + Log::shouldHaveReceived('error') + ->once() + ->with('Cached torrent file is missing.', ['hash' => $torrent->hash]); +}); + +it('does not log when the torrent is served normally', function () { + Log::spy(); + + $torrent = storedTorrent(); + + $this->get(route('download', ['torrent' => $torrent->hash]))->assertOk(); + + Log::shouldNotHaveReceived('error'); +}); diff --git a/tests/Feature/Http/Controllers/HomeControllerTest.php b/tests/Feature/Http/Controllers/HomeControllerTest.php deleted file mode 100644 index f706ea4..0000000 --- a/tests/Feature/Http/Controllers/HomeControllerTest.php +++ /dev/null @@ -1,9 +0,0 @@ -get('/'); - - $response->assertStatus(200); -}); diff --git a/tests/Feature/Http/Controllers/UploadControllerTest.php b/tests/Feature/Http/Controllers/UploadControllerTest.php index 5e98ea7..be3f3f4 100644 --- a/tests/Feature/Http/Controllers/UploadControllerTest.php +++ b/tests/Feature/Http/Controllers/UploadControllerTest.php @@ -7,54 +7,6 @@ use Illuminate\Http\UploadedFile; use Illuminate\Support\Facades\Storage; -/** - * Build a bencoded torrent so the tests exercise the real parser rather than a stub. - * - * @param array $data - */ -function bencode(array|int|string $data): string -{ - if (is_int($data)) { - return "i{$data}e"; - } - - if (is_string($data)) { - return strlen($data).':'.$data; - } - - if (array_is_list($data)) { - return 'l'.implode('', array_map('bencode', $data)).'e'; - } - - ksort($data); - - $encoded = 'd'; - - foreach ($data as $key => $value) { - $encoded .= bencode((string) $key).bencode($value); - } - - return $encoded.'e'; -} - -function torrentFile(string $name = 'example.bin', int $length = 1048576): UploadedFile -{ - $contents = bencode([ - 'announce' => 'http://tracker.example.test/announce', - 'info' => [ - 'length' => $length, - 'name' => $name, - 'piece length' => 262144, - 'pieces' => str_repeat("\x01", 20), - ], - ]); - - $path = tempnam(sys_get_temp_dir(), 'torrent').'.torrent'; - file_put_contents($path, $contents); - - return new UploadedFile($path, $name.'.torrent', 'application/x-bittorrent', test: true); -} - beforeEach(fn () => Storage::fake('torrents')); it('stores an uploaded torrent and redirects to its details page', function () { @@ -64,12 +16,26 @@ function torrentFile(string $name = 'example.bin', int $length = 1048576): Uploa expect($torrent->hash)->toHaveLength(40) ->and($torrent->filename)->toBe('example.bin') - ->and($torrent->size)->toBe(1048576); + ->and($torrent->size)->toBe(1048576) + ->and($torrent->downloads)->toBe(0); $response->assertRedirect(route('details', ['torrent' => $torrent->hash])); Storage::disk('torrents')->assertExists("{$torrent->hash}.torrent"); }); +it('stores the torrent bytes verbatim so the download is byte for byte identical', function () { + $contents = bencode([ + 'announce' => 'http://tracker.example.test/announce', + 'info' => singleFileInfo(), + ]); + + $this->post(route('upload'), ['torrent' => uploadedTorrent($contents)]); + + $torrent = Torrent::sole(); + + expect(Storage::disk('torrents')->get("{$torrent->hash}.torrent"))->toBe($contents); +}); + it('computes the same info hash the details route is keyed by', function () { $this->post(route('upload'), ['torrent' => torrentFile()]); @@ -77,16 +43,28 @@ function torrentFile(string $name = 'example.bin', int $length = 1048576): Uploa // The hash is the standard SHA-1 of the bencoded info dictionary, so it must // stay stable: existing rows and /torrents/{hash} URLs depend on it. - $info = bencode([ - 'length' => 1048576, - 'name' => 'example.bin', - 'piece length' => 262144, - 'pieces' => str_repeat("\x01", 20), + expect($torrent->hash)->toBe(sha1(bencode(singleFileInfo()))); + + $this->get(route('details', ['torrent' => $torrent->hash]))->assertOk(); +}); + +it('sums the declared length of every file in a multi file torrent', function () { + $this->post(route('upload'), [ + 'torrent' => multiFileTorrentFile(['one.bin' => 1000, 'nested/two.bin' => 2500]), ]); - expect($torrent->hash)->toBe(sha1($info)); + $torrent = Torrent::sole(); - $this->get(route('details', ['torrent' => $torrent->hash]))->assertOk(); + expect($torrent->size)->toBe(3500) + ->and($torrent->filename)->toBe('example-dir'); +}); + +it('treats torrents with different contents as separate uploads', function () { + $this->post(route('upload'), ['torrent' => torrentFile('first.bin')]); + $this->post(route('upload'), ['torrent' => torrentFile('second.bin')]); + + expect(Torrent::count())->toBe(2) + ->and(Torrent::pluck('filename')->all())->toBe(['first.bin', 'second.bin']); }); it('deduplicates a torrent that was already uploaded', function () { @@ -96,6 +74,18 @@ function torrentFile(string $name = 'example.bin', int $length = 1048576): Uploa expect(Torrent::count())->toBe(1); }); +it('keeps the download counter of a torrent that is uploaded again', function () { + $this->post(route('upload'), ['torrent' => torrentFile()]); + + $torrent = Torrent::sole(); + $this->get(route('download', ['torrent' => $torrent->hash])); + + $this->post(route('upload'), ['torrent' => torrentFile()]) + ->assertRedirect(route('details', ['torrent' => $torrent->hash])); + + expect($torrent->refresh()->downloads)->toBe(1); +}); + it('attaches the torrent to the uploader when signed in', function () { $user = UserFactory::new()->create(); @@ -105,6 +95,30 @@ function torrentFile(string $name = 'example.bin', int $length = 1048576): Uploa expect($user->torrents()->count())->toBe(1); }); +it('attaches an existing torrent to a second uploader', function () { + $first = UserFactory::new()->create(); + $second = UserFactory::new()->create(); + + $this->actingAs($first)->post(route('upload'), ['torrent' => torrentFile()]); + $this->actingAs($second)->post(route('upload'), ['torrent' => torrentFile()]); + + expect(Torrent::count())->toBe(1) + ->and($first->torrents()->count())->toBe(1) + ->and($second->torrents()->count())->toBe(1); +}); + +it('accepts an upload from a guest without attaching it to anybody', function () { + $this->post(route('upload'), ['torrent' => torrentFile()])->assertRedirect(); + + expect(Torrent::sole()->users()->count())->toBe(0); +}); + +it('requires a torrent to be provided', function () { + $this->post(route('upload'))->assertSessionHasErrors('torrent'); + + expect(Torrent::count())->toBe(0); +}); + it('rejects a file that is not a torrent', function () { $response = $this->post(route('upload'), [ 'torrent' => UploadedFile::fake()->createWithContent('bad.torrent', 'this is not bencoded'), @@ -114,6 +128,32 @@ function torrentFile(string $name = 'example.bin', int $length = 1048576): Uploa expect(Torrent::count())->toBe(0); }); +it('rejects a torrent without a v1 info dictionary', function () { + $this->post(route('upload'), ['torrent' => v2OnlyTorrentFile()]) + ->assertSessionHasErrors('torrent'); + + expect(Torrent::count())->toBe(0); + Storage::disk('torrents')->assertDirectoryEmpty(''); +}); + +it('accepts a torrent whatever filename it was uploaded under', function () { + $contents = bencode([ + 'announce' => 'http://tracker.example.test/announce', + 'info' => singleFileInfo(), + ]); + + // `mimes:torrent` reads the contents rather than trusting the client, so a + // mislabelled but otherwise valid torrent still gets through. + $path = tempnam(sys_get_temp_dir(), 'torrent').'.txt'; + file_put_contents($path, $contents); + + $this->post(route('upload'), [ + 'torrent' => new UploadedFile($path, 'example.txt', 'text/plain', test: true), + ])->assertSessionHasNoErrors(); + + expect(Torrent::sole()->hash)->toBe(sha1(bencode(singleFileInfo()))); +}); + it('serves the stored torrent for download and counts it', function () { $this->post(route('upload'), ['torrent' => torrentFile()]); diff --git a/tests/Feature/Livewire/UploadedTorrentsTest.php b/tests/Feature/Livewire/UploadedTorrentsTest.php index 7934a0a..18e36b1 100644 --- a/tests/Feature/Livewire/UploadedTorrentsTest.php +++ b/tests/Feature/Livewire/UploadedTorrentsTest.php @@ -3,6 +3,7 @@ declare(strict_types=1); use App\Livewire\UploadedTorrents; +use Database\Factories\TorrentFactory; use Database\Factories\UserFactory; use Livewire\Livewire; @@ -13,3 +14,52 @@ $component->assertStatus(200); }); + +it('lists the torrents the user has uploaded', function () { + $user = UserFactory::new()->create(); + $torrent = TorrentFactory::new()->create(['filename' => 'example.bin']); + $user->torrents()->attach($torrent); + + Livewire::actingAs($user)->test(UploadedTorrents::class) + ->assertSee('example.bin') + ->assertSee(route('details', ['torrent' => $torrent->hash])); +}); + +it('falls back to the hash for a torrent with no filename', function () { + $user = UserFactory::new()->create(); + $torrent = TorrentFactory::new()->create(['filename' => null]); + $user->torrents()->attach($torrent); + + Livewire::actingAs($user)->test(UploadedTorrents::class) + ->assertSee($torrent->hash); +}); + +it('does not leak torrents uploaded by somebody else', function () { + $user = UserFactory::new()->create(); + $other = UserFactory::new()->create(); + + $other->torrents()->attach(TorrentFactory::new()->create(['filename' => 'theirs.bin'])); + + Livewire::actingAs($user)->test(UploadedTorrents::class) + ->assertDontSee('theirs.bin') + ->assertSee('You have not uploaded any torrents.'); +}); + +it('paginates at twenty five torrents a page', function () { + $user = UserFactory::new()->create(); + + $torrents = TorrentFactory::new() + ->count(26) + ->sequence(fn ($sequence) => ['filename' => "torrent-{$sequence->index}.bin"]) + ->create(); + + $user->torrents()->attach($torrents); + + $component = Livewire::actingAs($user)->test(UploadedTorrents::class) + ->assertSee('torrent-0.bin') + ->assertDontSee('torrent-25.bin'); + + $component->call('gotoPage', 2) + ->assertSee('torrent-25.bin') + ->assertDontSee('torrent-0.bin'); +}); diff --git a/tests/Feature/Rules/IsTorrentFileTest.php b/tests/Feature/Rules/IsTorrentFileTest.php new file mode 100644 index 0000000..831f638 --- /dev/null +++ b/tests/Feature/Rules/IsTorrentFileTest.php @@ -0,0 +1,56 @@ + $value], ['torrent' => new IsTorrentFile]); +} + +it('passes a single file torrent', function () { + expect(validateTorrent(torrentFile())->passes())->toBeTrue(); +}); + +it('passes a multi file torrent', function () { + expect(validateTorrent(multiFileTorrentFile(['one.bin' => 1000]))->passes())->toBeTrue(); +}); + +it('fails a value that is not an uploaded file', function (mixed $value) { + $validator = validateTorrent($value); + + expect($validator->fails())->toBeTrue() + ->and($validator->errors()->first('torrent'))->toBe('Invalid torrent file provided.'); +})->with([ + 'a string' => 'example.torrent', + 'null' => null, + 'an array' => [['torrent']], +]); + +it('fails a file that is not bencoded', function () { + $file = UploadedFile::fake()->createWithContent('bad.torrent', 'this is not bencoded'); + + expect(validateTorrent($file)->fails())->toBeTrue(); +}); + +it('fails a bencoded file that is not a torrent', function () { + $file = uploadedTorrent(bencode(['not' => 'a torrent'])); + + expect(validateTorrent($file)->fails())->toBeTrue(); +}); + +it('fails an empty file', function () { + $file = UploadedFile::fake()->createWithContent('empty.torrent', ''); + + expect(validateTorrent($file)->fails())->toBeTrue(); +}); + +it('fails a torrent with no v1 info dictionary', function () { + // The upload pipeline reads the v1 info dictionary, so a v2-only torrent + // would parse but leave it without a hash or file list. + expect(validateTorrent(v2OnlyTorrentFile())->fails())->toBeTrue(); +}); diff --git a/tests/Feature/TorrentFlowTest.php b/tests/Feature/TorrentFlowTest.php new file mode 100644 index 0000000..c22a360 --- /dev/null +++ b/tests/Feature/TorrentFlowTest.php @@ -0,0 +1,90 @@ + Storage::fake('torrents')); + +it('carries a guest from the dashboard through upload to download', function () { + $this->get(route('dashboard')) + ->assertOk() + ->assertSee('Click to upload'); + + $this->post(route('upload'), ['torrent' => torrentFile()]) + ->assertRedirect(route('details', ['torrent' => sha1(bencode(singleFileInfo()))])); + + $torrent = Torrent::sole(); + + $this->get(route('details', ['torrent' => $torrent->hash])) + ->assertOk() + ->assertSee('example.bin') + ->assertSee('1.00 MB') + ->assertSee(route('download', ['torrent' => $torrent->hash])); + + $this->get(route('download', ['torrent' => $torrent->hash])) + ->assertOk() + ->assertDownload("{$torrent->hash}.torrent"); + + expect($torrent->refresh()->downloads)->toBe(1); +}); + +it('lists a signed in user their upload straight after making it', function () { + $user = UserFactory::new()->create(); + + $this->actingAs($user)->get(route('dashboard')) + ->assertOk() + ->assertSee('You have not uploaded any torrents.'); + + $this->actingAs($user) + ->post(route('upload'), ['torrent' => torrentFile('holiday-photos.bin')]) + ->assertRedirect(); + + $torrent = Torrent::sole(); + + $this->actingAs($user)->get(route('dashboard')) + ->assertOk() + ->assertSee('holiday-photos.bin') + ->assertSee(route('details', ['torrent' => $torrent->hash])) + ->assertDontSee('You have not uploaded any torrents.'); +}); + +it('lets a second user re-upload a torrent and download the cached copy', function () { + $first = UserFactory::new()->create(); + $second = UserFactory::new()->create(); + + $this->actingAs($first)->post(route('upload'), ['torrent' => torrentFile()]); + + $torrent = Torrent::sole(); + $stored = Storage::disk('torrents')->get("{$torrent->hash}.torrent"); + + $this->actingAs($second)->post(route('upload'), ['torrent' => torrentFile()]) + ->assertRedirect(route('details', ['torrent' => $torrent->hash])); + + // The second upload must not have replaced the cached copy, or the file the + // hash was computed from could drift away from what is served. + expect(Torrent::count())->toBe(1) + ->and(Storage::disk('torrents')->get("{$torrent->hash}.torrent"))->toBe($stored); + + $this->actingAs($second)->get(route('dashboard')) + ->assertOk() + ->assertSee(route('details', ['torrent' => $torrent->hash])); + + $response = $this->actingAs($second) + ->get(route('download', ['torrent' => $torrent->hash])) + ->assertOk(); + + expect($response->streamedContent())->toBe($stored); +}); + +it('sends a rejected upload back to the dashboard with the torrent uncached', function () { + $this->from(route('dashboard')) + ->post(route('upload'), ['torrent' => v2OnlyTorrentFile()]) + ->assertRedirect(route('dashboard')) + ->assertSessionHasErrors('torrent'); + + expect(Torrent::count())->toBe(0); + Storage::disk('torrents')->assertDirectoryEmpty(''); +}); diff --git a/tests/Pest.php b/tests/Pest.php index 413c42d..231b5bf 100644 --- a/tests/Pest.php +++ b/tests/Pest.php @@ -3,6 +3,7 @@ declare(strict_types=1); use Illuminate\Foundation\Testing\RefreshDatabase; +use Illuminate\Http\UploadedFile; use Tests\TestCase; /* @@ -41,3 +42,114 @@ | global functions to help you to reduce the number of lines of code in your test files. | */ + +/** + * Bencode a value so the tests exercise the real torrent parser rather than a stub. + * + * @param array|int|string $data + */ +function bencode(array|int|string $data): string +{ + if (is_int($data)) { + return "i{$data}e"; + } + + if (is_string($data)) { + return strlen($data).':'.$data; + } + + if (array_is_list($data)) { + return 'l'.implode('', array_map('bencode', $data)).'e'; + } + + ksort($data); + + $encoded = 'd'; + + foreach ($data as $key => $value) { + $encoded .= bencode((string) $key).bencode($value); + } + + return $encoded.'e'; +} + +/** + * Wrap already bencoded contents in an uploaded `.torrent` file. + */ +function uploadedTorrent(string $contents, string $filename = 'example.torrent'): UploadedFile +{ + $path = tempnam(sys_get_temp_dir(), 'torrent').'.torrent'; + file_put_contents($path, $contents); + + return new UploadedFile($path, $filename, 'application/x-bittorrent', test: true); +} + +/** + * A single file v1 torrent, the shape the upload pipeline is built around. + */ +function torrentFile(string $name = 'example.bin', int $length = 1048576): UploadedFile +{ + return uploadedTorrent(bencode([ + 'announce' => 'http://tracker.example.test/announce', + 'info' => singleFileInfo($name, $length), + ]), $name.'.torrent'); +} + +/** + * The info dictionary `torrentFile()` builds, so tests can hash it themselves. + * + * @return array + */ +function singleFileInfo(string $name = 'example.bin', int $length = 1048576): array +{ + return [ + 'length' => $length, + 'name' => $name, + 'piece length' => 262144, + 'pieces' => str_repeat("\x01", 20), + ]; +} + +/** + * A v1 torrent describing a directory, where the size is the sum of its files. + * + * @param array $files Relative path to declared length. + */ +function multiFileTorrentFile(array $files, string $name = 'example-dir'): UploadedFile +{ + $entries = []; + + foreach ($files as $path => $length) { + $entries[] = ['length' => $length, 'path' => explode('/', $path)]; + } + + return uploadedTorrent(bencode([ + 'announce' => 'http://tracker.example.test/announce', + 'info' => [ + 'files' => $entries, + 'name' => $name, + 'piece length' => 262144, + 'pieces' => str_repeat("\x01", 20), + ], + ]), $name.'.torrent'); +} + +/** + * A torrent that parses but carries no v1 info dictionary, leaving the upload + * pipeline without the hash and file list it reads. + */ +function v2OnlyTorrentFile(string $name = 'example.bin', int $length = 1048576): UploadedFile +{ + return uploadedTorrent(bencode([ + 'announce' => 'http://tracker.example.test/announce', + 'info' => [ + 'file tree' => [ + $name => ['' => ['length' => $length, 'pieces root' => str_repeat("\x02", 32)]], + ], + 'meta version' => 2, + 'name' => $name, + 'piece length' => 262144, + ], + 'piece layers' => [], + ]), $name.'.torrent'); +} diff --git a/tests/Unit/Models/UserTest.php b/tests/Unit/Models/UserTest.php new file mode 100644 index 0000000..1dd202a --- /dev/null +++ b/tests/Unit/Models/UserTest.php @@ -0,0 +1,53 @@ +name = $name; + + return $user->initials(); +} + +it('builds initials from a name', function (string $name, string $expected) { + expect(initialsFor($name))->toBe($expected); +})->with([ + 'a first and last name' => ['Owen Voke', 'OV'], + 'a single name' => ['Owen', 'O'], + // More than two words still yields two letters, so the avatar never + // overflows the space it is given. + 'a middle name' => ['Owen Michael Voke', 'OV'], + 'an initialised middle name' => ['Ada B. Lovelace', 'AL'], + 'a hyphenated first name' => ['Mary-Jane Watson', 'MW'], + 'an apostrophe' => ["O'Brien Smith", 'OS'], +]); + +it('capitalises initials taken from a lowercase name', function () { + expect(initialsFor('owen voke'))->toBe('OV'); +}); + +it('ignores surrounding and repeated whitespace', function () { + expect(initialsFor(' Owen Voke '))->toBe('OV'); +}); + +it('returns nothing for a name with no letters', function (string $name) { + expect(initialsFor($name))->toBe(''); +})->with([ + 'an empty name' => [''], + 'only whitespace' => [' '], +]); + +it('handles a single character name', function () { + expect(initialsFor('A'))->toBe('A'); +}); + +it('handles multibyte names', function (string $name, string $expected) { + expect(initialsFor($name))->toBe($expected); +})->with([ + 'accented latin' => ['Renée Descartes', 'RD'], + 'cyrillic' => ['Ада Лавлейс', 'АЛ'], +]);