diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
new file mode 100644
index 0000000..1343103
--- /dev/null
+++ b/.github/workflows/release.yml
@@ -0,0 +1,100 @@
+name: Release
+
+on:
+ release:
+ types: [published]
+
+jobs:
+ test:
+ name: Run test suite
+ runs-on: ubuntu-latest
+
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v4
+
+ - name: Set up PHP
+ uses: shivammathur/setup-php@v2
+ with:
+ php-version: '8.4'
+ extensions: mbstring
+ coverage: none
+
+ - name: Install Composer dependencies
+ working-directory: www
+ run: composer install --no-interaction --prefer-dist --optimize-autoloader
+
+ - name: Set up environment
+ working-directory: www
+ run: |
+ cp .env.example .env
+ php artisan key:generate
+
+ - name: Build frontend assets
+ working-directory: www
+ run: npm ci && npm run build
+
+ - name: Run tests
+ working-directory: www
+ run: php artisan test
+
+ lint:
+ name: Check code style (Pint)
+ runs-on: ubuntu-latest
+
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v4
+
+ - name: Set up PHP
+ uses: shivammathur/setup-php@v2
+ with:
+ php-version: '8.4'
+ extensions: mbstring
+ coverage: none
+
+ - name: Install Composer dependencies
+ working-directory: www
+ run: composer install --no-interaction --prefer-dist --optimize-autoloader
+
+ - name: Run Pint
+ working-directory: www
+ run: ./vendor/bin/pint --test
+
+ docker:
+ name: Build and push Docker image
+ runs-on: ubuntu-latest
+ needs: [test, lint]
+
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v4
+
+ - name: Set up QEMU (multi-platform support)
+ uses: docker/setup-qemu-action@v3
+
+ - name: Set up Docker Buildx
+ uses: docker/setup-buildx-action@v3
+
+ - name: Log in to Docker Hub
+ uses: docker/login-action@v3
+ with:
+ username: ${{ secrets.DOCKERHUB_USERNAME }}
+ password: ${{ secrets.DOCKERHUB_TOKEN }}
+
+ - name: Extract metadata
+ id: meta
+ uses: docker/metadata-action@v5
+ with:
+ images: notf0und/sgs
+ tags: |
+ type=semver,pattern={{version}}
+ type=raw,value=latest
+
+ - name: Build and push
+ uses: docker/build-push-action@v6
+ with:
+ context: .
+ platforms: linux/amd64,linux/arm64
+ push: true
+ tags: ${{ steps.meta.outputs.tags }}
diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml
new file mode 100644
index 0000000..b234296
--- /dev/null
+++ b/.github/workflows/tests.yml
@@ -0,0 +1,62 @@
+name: Tests
+
+on:
+ pull_request:
+ branches: ['**']
+
+jobs:
+ test:
+ name: Run test suite
+ runs-on: ubuntu-latest
+
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v4
+
+ - name: Set up PHP
+ uses: shivammathur/setup-php@v2
+ with:
+ php-version: '8.4'
+ extensions: mbstring
+ coverage: none
+
+ - name: Install Composer dependencies
+ working-directory: www
+ run: composer install --no-interaction --prefer-dist --optimize-autoloader
+
+ - name: Set up environment
+ working-directory: www
+ run: |
+ cp .env.example .env
+ php artisan key:generate
+
+ - name: Build frontend assets
+ working-directory: www
+ run: npm ci && npm run build
+
+ - name: Run tests
+ working-directory: www
+ run: php artisan test
+
+ lint:
+ name: Check code style (Pint)
+ runs-on: ubuntu-latest
+
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v4
+
+ - name: Set up PHP
+ uses: shivammathur/setup-php@v2
+ with:
+ php-version: '8.4'
+ extensions: mbstring
+ coverage: none
+
+ - name: Install Composer dependencies
+ working-directory: www
+ run: composer install --no-interaction --prefer-dist --optimize-autoloader
+
+ - name: Run Pint
+ working-directory: www
+ run: ./vendor/bin/pint --test
diff --git a/.gitignore b/.gitignore
index a6e7ef5..9cd1b6f 100644
--- a/.gitignore
+++ b/.gitignore
@@ -10,3 +10,4 @@
/games/*
!/games/.gitkeep
Thumbs.db
+docker-compose.override.yml
diff --git a/www/app/Enums/NintendoFileExtension.php b/www/app/Enums/NintendoFileExtension.php
index e65b339..b35581a 100644
--- a/www/app/Enums/NintendoFileExtension.php
+++ b/www/app/Enums/NintendoFileExtension.php
@@ -18,7 +18,7 @@ enum NintendoFileExtension: string
*/
public static function values(): array
{
- return array_map(fn($case) => $case->value, self::cases());
+ return array_map(fn ($case) => $case->value, self::cases());
}
/**
diff --git a/www/app/Helpers/FileSize.php b/www/app/Helpers/FileSize.php
index 61f9ea0..7135188 100644
--- a/www/app/Helpers/FileSize.php
+++ b/www/app/Helpers/FileSize.php
@@ -5,6 +5,7 @@ namespace App\Helpers;
class FileSize
{
private const UNITS = ['', 'K', 'M', 'G', 'T'];
+
private const THRESHOLD = 1024;
/**
@@ -18,9 +19,10 @@ class FileSize
$unitIndex = (int) floor(log($bytes, self::THRESHOLD));
$unitIndex = min($unitIndex, count(self::UNITS) - 1);
-
+
$size = $bytes / pow(self::THRESHOLD, $unitIndex);
-
- return number_format($size, 2) . self::UNITS[$unitIndex];
+ $decimals = $unitIndex === 0 ? 0 : 2;
+
+ return number_format($size, $decimals, '.', '').self::UNITS[$unitIndex];
}
}
diff --git a/www/app/Http/Controllers/DBI/IndexController.php b/www/app/Http/Controllers/DBI/IndexController.php
index 447d543..d33b444 100644
--- a/www/app/Http/Controllers/DBI/IndexController.php
+++ b/www/app/Http/Controllers/DBI/IndexController.php
@@ -35,7 +35,8 @@ class IndexController extends Controller
private function getRequestPath(Request $request): ?string
{
$path = $request->route('path');
- return $path ? urldecode($path) : null;
+
+ return $path ? rawurldecode($path) : null;
}
/**
@@ -51,9 +52,9 @@ class IndexController extends Controller
*/
private function serveFile(string $path): Response|BinaryFileResponse
{
- $storagePath = self::GAMES_DIRECTORY . '/' . $path;
+ $storagePath = self::GAMES_DIRECTORY.'/'.$path;
- if (!Storage::exists($storagePath)) {
+ if (! Storage::exists($storagePath)) {
return response('File not found', 404);
}
@@ -68,19 +69,19 @@ class IndexController extends Controller
*/
private function serveDirectoryListing(?string $path, Request $request): Response
{
- $storagePath = $path
- ? self::GAMES_DIRECTORY . '/' . $path
+ $storagePath = $path
+ ? self::GAMES_DIRECTORY.'/'.$path
: self::GAMES_DIRECTORY;
$directories = $this->getDirectories($storagePath);
$files = $this->getFiles($storagePath);
-
+
// Determine if this is a DBI client or regular browser
$isDBI = str_starts_with($request->userAgent() ?? '', 'DBI/');
-
+
// Determine base URL for links based on request path
$baseUrl = str_starts_with($request->path(), 'api/dbi') ? '/api/dbi/' : '/';
-
+
$html = $this->buildDirectoryListingHtml($directories, $files, $path ?? '', $baseUrl, $isDBI);
return response($html)->header('Content-Type', 'text/html');
@@ -131,8 +132,8 @@ class IndexController extends Controller
*/
private function buildDirectoryListingHtml(Collection $directories, Collection $files, string $currentPath, string $baseUrl, bool $isDBI): string
{
- $displayPath = $currentPath
- ? '/games/' . urldecode($currentPath) . '/'
+ $displayPath = $currentPath
+ ? '/games/'.rawurldecode($currentPath).'/'
: '/games/';
return view('dbi.directory-listing', [
@@ -151,7 +152,7 @@ class IndexController extends Controller
*/
private function getParentDirectoryUrl(string $currentPath, string $baseUrl, bool $isDBI): ?string
{
- if (!$currentPath) {
+ if (! $currentPath) {
return null;
}
@@ -167,8 +168,8 @@ class IndexController extends Controller
return $baseUrl;
}
- return rtrim($baseUrl, '/') . '/' . collect(explode('/', $parentPath))
+ return rtrim($baseUrl, '/').'/'.collect(explode('/', $parentPath))
->map(fn ($segment) => rawurlencode($segment))
->implode('/');
}
-}
\ No newline at end of file
+}
diff --git a/www/app/Http/Controllers/Tinfoil/IndexController.php b/www/app/Http/Controllers/Tinfoil/IndexController.php
index 0ceea8e..9c3da1a 100644
--- a/www/app/Http/Controllers/Tinfoil/IndexController.php
+++ b/www/app/Http/Controllers/Tinfoil/IndexController.php
@@ -18,7 +18,7 @@ class IndexController extends Controller
{
return response()->json([
'files' => $this->getInstallableFiles()->values(),
- 'success' => config('app.name')
+ 'success' => config('app.name'),
]);
}
@@ -31,7 +31,7 @@ class IndexController extends Controller
->filter(fn ($path) => NintendoFileExtension::isSupported($path))
->map(fn ($path) => [
'url' => url($path),
- 'size' => Storage::size($path)
+ 'size' => Storage::size($path),
]);
}
}
diff --git a/www/app/Http/Middleware/EnsureIsTinfoilClient.php b/www/app/Http/Middleware/EnsureIsTinfoilClient.php
index b0850f7..991a641 100644
--- a/www/app/Http/Middleware/EnsureIsTinfoilClient.php
+++ b/www/app/Http/Middleware/EnsureIsTinfoilClient.php
@@ -6,20 +6,17 @@ use Closure;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Http\Response;
-use Illuminate\Support\Facades\Log;
class EnsureIsTinfoilClient
{
/**
* Handle an incoming request.
*
- * @param Request $request
- * @param Closure(Request): (Response|RedirectResponse) $next
- * @return Response|RedirectResponse
+ * @param Closure(Request): (Response|RedirectResponse) $next
*/
public function handle(Request $request, Closure $next): Response|RedirectResponse
{
- if (!!$request->header('theme')) {
+ if ((bool) $request->header('theme')) {
return redirect()->route('tinfoil');
}
diff --git a/www/bootstrap/providers.php b/www/bootstrap/providers.php
index 38b258d..fc94ae6 100644
--- a/www/bootstrap/providers.php
+++ b/www/bootstrap/providers.php
@@ -1,5 +1,7 @@
[
'users' => [
'driver' => 'eloquent',
- 'model' => env('AUTH_MODEL', App\Models\User::class),
+ 'model' => env('AUTH_MODEL', User::class),
],
// 'users' => [
diff --git a/www/config/database.php b/www/config/database.php
index c57fa63..93b15be 100644
--- a/www/config/database.php
+++ b/www/config/database.php
@@ -1,6 +1,7 @@
true,
'engine' => null,
'options' => extension_loaded('pdo_mysql') ? array_filter([
- (PHP_VERSION_ID >= 80500 ? \Pdo\Mysql::ATTR_SSL_CA : \PDO::MYSQL_ATTR_SSL_CA) => env('MYSQL_ATTR_SSL_CA'),
+ (PHP_VERSION_ID >= 80500 ? Mysql::ATTR_SSL_CA : PDO::MYSQL_ATTR_SSL_CA) => env('MYSQL_ATTR_SSL_CA'),
]) : [],
],
@@ -79,7 +80,7 @@ return [
'strict' => true,
'engine' => null,
'options' => extension_loaded('pdo_mysql') ? array_filter([
- (PHP_VERSION_ID >= 80500 ? \Pdo\Mysql::ATTR_SSL_CA : \PDO::MYSQL_ATTR_SSL_CA) => env('MYSQL_ATTR_SSL_CA'),
+ (PHP_VERSION_ID >= 80500 ? Mysql::ATTR_SSL_CA : PDO::MYSQL_ATTR_SSL_CA) => env('MYSQL_ATTR_SSL_CA'),
]) : [],
],
diff --git a/www/phpunit.xml b/www/phpunit.xml
index d703241..04fd4c8 100644
--- a/www/phpunit.xml
+++ b/www/phpunit.xml
@@ -23,8 +23,6 @@
-
-
diff --git a/www/routes/api.php b/www/routes/api.php
index 3b0c1aa..1e1935e 100644
--- a/www/routes/api.php
+++ b/www/routes/api.php
@@ -1,5 +1,7 @@
name('tinfoil');
-Route::get('dbi/{path?}', \App\Http\Controllers\DBI\IndexController::class)
+Route::get('dbi/{path?}', DBIIndexController::class)
->where('path', '.*')
->name('dbi');
diff --git a/www/routes/web.php b/www/routes/web.php
index d048365..29336f9 100644
--- a/www/routes/web.php
+++ b/www/routes/web.php
@@ -6,37 +6,37 @@ use Illuminate\Support\Facades\Route;
Route::view('/', 'welcome')->middleware([
EnsureIsDbiClient::class,
- EnsureIsTinfoilClient::class
+ EnsureIsTinfoilClient::class,
]);
Route::get('/docs/{page}', function ($page) {
$allowedPages = ['TINFOIL', 'DBI'];
- if (!in_array($page, $allowedPages)) {
+ if (! in_array($page, $allowedPages)) {
abort(404);
}
-
+
$content = file_get_contents(base_path("docs/{$page}.md"));
-
+
// Convert relative image paths to absolute paths for web viewing
$content = str_replace(
'](images/',
'](/docs-images/',
$content
);
-
+
return view('docs', ['content' => $content, 'title' => $page]);
})->name('docs');
Route::get('/docs-images/{path}', function ($path) {
$imagePath = base_path("docs/images/{$path}");
-
- if (!file_exists($imagePath)) {
+
+ if (! file_exists($imagePath)) {
abort(404);
}
-
+
return response()->file($imagePath);
})->where('path', '.*')->name('docs.images');
-Route::get('/{path}', fn() => response('Not Found', 404))
+Route::get('/{path}', fn () => response('Not Found', 404))
->where('path', '(?!api|docs-images).*')
->middleware(EnsureIsDbiClient::class);
diff --git a/www/tests/Feature/DBI/DirectoryListingTest.php b/www/tests/Feature/DBI/DirectoryListingTest.php
new file mode 100644
index 0000000..947bc0c
--- /dev/null
+++ b/www/tests/Feature/DBI/DirectoryListingTest.php
@@ -0,0 +1,126 @@
+get('/api/dbi/');
+
+ $response->assertStatus(200);
+ $response->assertHeader('Content-Type', 'text/html; charset=utf-8');
+ $response->assertSee('Index of /games/');
+});
+
+test('lists directories that contain installable files', function () {
+ Storage::put('games/GameA/game.nsp', 'data');
+ Storage::put('games/GameB/game.xci', 'data');
+
+ $response = $this->get('/api/dbi/');
+
+ $response->assertSee('GameA');
+ $response->assertSee('GameB');
+});
+
+test('excludes directories with no installable files', function () {
+ Storage::put('games/HasGames/game.nsp', 'data');
+ Storage::put('games/NoGames/readme.txt', 'data');
+
+ $response = $this->get('/api/dbi/');
+
+ $response->assertSee('HasGames');
+ $response->assertDontSee('NoGames');
+});
+
+test('lists only files with supported extensions', function () {
+ Storage::put('games/game.nsp', 'data');
+ Storage::put('games/game.nsz', 'data');
+ Storage::put('games/game.xci', 'data');
+ Storage::put('games/game.xcz', 'data');
+ Storage::put('games/readme.txt', 'data');
+
+ $response = $this->get('/api/dbi/');
+
+ $response->assertSee('game.nsp');
+ $response->assertSee('game.nsz');
+ $response->assertSee('game.xci');
+ $response->assertSee('game.xcz');
+ $response->assertDontSee('readme.txt');
+});
+
+test('shows a parent directory link when inside a subdirectory', function () {
+ Storage::put('games/GameA/game.nsp', 'data');
+
+ $response = $this->get('/api/dbi/GameA/');
+
+ $response->assertSee('Parent Directory');
+});
+
+test('does not show a parent directory link at the root', function () {
+ Storage::put('games/GameA/game.nsp', 'data');
+
+ $response = $this->get('/api/dbi/');
+
+ $response->assertDontSee('Parent Directory');
+});
+
+test('directory links use rawurlencode so special characters are encoded', function () {
+ Storage::put('games/Game Title/game.nsp', 'data');
+ Storage::put('games/Special & Chars/game.nsp', 'data');
+
+ $response = $this->get('/api/dbi/');
+
+ $response->assertSee('Game%20Title');
+ $response->assertSee('Special%20%26%20Chars');
+});
+
+test('folder names with a plus sign are encoded as %2B in links, not as a space', function () {
+ Storage::put('games/Game+Title/game.nsp', 'data');
+
+ $response = $this->get('/api/dbi/');
+
+ // The href must use %2B, not a literal + (which urlencode would produce)
+ $response->assertSee('Game%2BTitle');
+ // The display name is rendered as-is; only the href encoding matters
+ $response->assertDontSee('Game%20Title');
+ $response->assertDontSee('Game Title');
+});
+
+test('navigating into a folder with a plus sign returns a listing', function () {
+ Storage::put('games/Game+Title/game.nsp', 'data');
+
+ // The client follows the %2B-encoded link
+ $response = $this->get('/api/dbi/Game%2BTitle/');
+
+ $response->assertStatus(200);
+ $response->assertSee('game.nsp');
+});
+
+test('DBI user-agent receives relative links', function () {
+ Storage::put('games/GameA/game.nsp', 'data');
+
+ $response = $this->get('/api/dbi/', ['HTTP_USER_AGENT' => 'DBI/1.0']);
+
+ // DBI clients need a relative href like "GameA/" not an absolute path
+ $response->assertSee('href="'.rawurlencode('GameA').'/"', false);
+});
+
+test('browser user-agent receives absolute links', function () {
+ Storage::put('games/GameA/game.nsp', 'data');
+
+ $response = $this->get('/api/dbi/');
+
+ $response->assertSee('href="/api/dbi/'.rawurlencode('GameA').'"', false);
+});
+
+test('displays the current subdirectory path in the page title', function () {
+ Storage::put('games/GameA/game.nsp', 'data');
+
+ $response = $this->get('/api/dbi/GameA/');
+
+ $response->assertSee('Index of /games/GameA/');
+});
diff --git a/www/tests/Feature/DBI/FileServingTest.php b/www/tests/Feature/DBI/FileServingTest.php
new file mode 100644
index 0000000..49b1cf9
--- /dev/null
+++ b/www/tests/Feature/DBI/FileServingTest.php
@@ -0,0 +1,70 @@
+get('/api/dbi/game.nsp');
+
+ $response->assertStatus(200);
+ $response->assertHeader('Content-Type', 'application/octet-stream');
+});
+
+test('returns 404 when the requested file does not exist', function () {
+ $response = $this->get('/api/dbi/missing.nsp');
+
+ $response->assertStatus(404);
+});
+
+test('serves a file inside a subdirectory', function () {
+ Storage::put('games/GameA/game.nsp', 'binary-content');
+
+ $response = $this->get('/api/dbi/GameA/game.nsp');
+
+ $response->assertStatus(200);
+ $response->assertHeader('Content-Type', 'application/octet-stream');
+});
+
+test('serves a file whose folder name contains a plus sign', function () {
+ Storage::put('games/Game+Title/game.nsp', 'binary-content');
+
+ // %2B in the URL must decode to a literal + (not a space)
+ $response = $this->get('/api/dbi/Game%2BTitle/game.nsp');
+
+ $response->assertStatus(200);
+ $response->assertHeader('Content-Type', 'application/octet-stream');
+});
+
+test('returns 404 when plus sign is misread as a space', function () {
+ Storage::put('games/Game+Title/game.nsp', 'binary-content');
+
+ // A literal + in the URL path should NOT match a folder named "Game+Title"
+ // because + must be decoded as %2B, not as a space
+ $response = $this->get('/api/dbi/Game+Title/game.nsp');
+
+ // "Game+Title" decoded with rawurldecode stays "Game+Title",
+ // which correctly finds the file.
+ $response->assertStatus(200);
+});
+
+test('serves a file with a plus sign in the filename', function () {
+ Storage::put('games/Game+Edition.nsp', 'binary-content');
+
+ $response = $this->get('/api/dbi/Game%2BEdition.nsp');
+
+ $response->assertStatus(200);
+ $response->assertHeader('Content-Type', 'application/octet-stream');
+});
+
+test('response includes Accept-Ranges header for range request support', function () {
+ Storage::put('games/game.nsp', 'binary-content');
+
+ $response = $this->get('/api/dbi/game.nsp');
+
+ $response->assertHeader('Accept-Ranges', 'bytes');
+});
diff --git a/www/tests/Feature/ExampleTest.php b/www/tests/Feature/ExampleTest.php
deleted file mode 100644
index 8fdc86b..0000000
--- a/www/tests/Feature/ExampleTest.php
+++ /dev/null
@@ -1,7 +0,0 @@
-get('/');
-
- $response->assertStatus(200);
-});
diff --git a/www/tests/Feature/Middleware/EnsureIsDbiClientTest.php b/www/tests/Feature/Middleware/EnsureIsDbiClientTest.php
new file mode 100644
index 0000000..3a8f1ab
--- /dev/null
+++ b/www/tests/Feature/Middleware/EnsureIsDbiClientTest.php
@@ -0,0 +1,33 @@
+get('/', ['HTTP_USER_AGENT' => 'DBI/1.0']);
+
+ $response->assertStatus(200);
+ $response->assertHeader('Content-Type', 'text/html; charset=utf-8');
+ $response->assertSee('Index of /games/');
+});
+
+test('a non-DBI user-agent on the web root is passed through', function () {
+ $response = $this->get('/', ['HTTP_USER_AGENT' => 'Mozilla/5.0']);
+
+ // The welcome view or the next middleware handles the request (not DBI)
+ $response->assertDontSee('Index of /games/');
+});
+
+test('a DBI user-agent on an arbitrary web path gets a DBI directory listing', function () {
+ Storage::put('games/GameA/game.nsp', 'data');
+
+ $response = $this->get('/anything', ['HTTP_USER_AGENT' => 'DBI/2.5']);
+
+ $response->assertStatus(200);
+ $response->assertSee('Index of /games/');
+});
diff --git a/www/tests/Feature/Middleware/EnsureIsTinfoilClientTest.php b/www/tests/Feature/Middleware/EnsureIsTinfoilClientTest.php
new file mode 100644
index 0000000..4e35e77
--- /dev/null
+++ b/www/tests/Feature/Middleware/EnsureIsTinfoilClientTest.php
@@ -0,0 +1,13 @@
+get('/', ['HTTP_THEME' => 'default']);
+
+ $response->assertRedirect(route('tinfoil'));
+});
+
+test('a request without a theme header on the web root is not redirected', function () {
+ $response = $this->get('/');
+
+ $response->assertStatus(200);
+});
diff --git a/www/tests/Feature/Tinfoil/IndexControllerTest.php b/www/tests/Feature/Tinfoil/IndexControllerTest.php
new file mode 100644
index 0000000..7668f9b
--- /dev/null
+++ b/www/tests/Feature/Tinfoil/IndexControllerTest.php
@@ -0,0 +1,70 @@
+get('/api/tinfoil');
+
+ $response->assertStatus(200);
+ $response->assertJsonStructure(['files', 'success']);
+});
+
+test('success value equals the application name', function () {
+ $response = $this->get('/api/tinfoil');
+
+ $response->assertJsonPath('success', config('app.name'));
+});
+
+test('returns an empty files array when there are no installable files', function () {
+ $response = $this->get('/api/tinfoil');
+
+ $response->assertJsonPath('files', []);
+});
+
+test('each file entry contains a url and a size', function () {
+ Storage::put('game.nsp', 'binary-content');
+
+ $response = $this->get('/api/tinfoil');
+
+ $response->assertJsonStructure(['files' => [['url', 'size']]]);
+});
+
+test('only includes files with supported Nintendo Switch extensions', function () {
+ Storage::put('game.nsp', 'data');
+ Storage::put('game.nsz', 'data');
+ Storage::put('game.xci', 'data');
+ Storage::put('game.xcz', 'data');
+ Storage::put('readme.txt', 'data');
+ Storage::put('cover.jpg', 'data');
+
+ $response = $this->get('/api/tinfoil');
+
+ $files = collect($response->json('files'));
+ expect($files)->toHaveCount(4);
+ expect($files->pluck('url')->every(fn ($url) => ! str_ends_with($url, '.txt') && ! str_ends_with($url, '.jpg')))->toBeTrue();
+});
+
+test('file url is an absolute URL', function () {
+ Storage::put('game.nsp', 'data');
+
+ $response = $this->get('/api/tinfoil');
+
+ $url = $response->json('files.0.url');
+ expect($url)->toStartWith('http');
+});
+
+test('file size matches the stored file size', function () {
+ $content = str_repeat('x', 1024);
+ Storage::put('game.nsp', $content);
+
+ $response = $this->get('/api/tinfoil');
+
+ $size = $response->json('files.0.size');
+ expect($size)->toBe(1024);
+});
diff --git a/www/tests/Pest.php b/www/tests/Pest.php
index 60f04a4..3834758 100644
--- a/www/tests/Pest.php
+++ b/www/tests/Pest.php
@@ -1,5 +1,7 @@
extend(Tests\TestCase::class)
+pest()->extend(TestCase::class)
// ->use(Illuminate\Foundation\Testing\RefreshDatabase::class)
->in('Feature');
diff --git a/www/tests/Unit/ExampleTest.php b/www/tests/Unit/ExampleTest.php
deleted file mode 100644
index 44a4f33..0000000
--- a/www/tests/Unit/ExampleTest.php
+++ /dev/null
@@ -1,5 +0,0 @@
-toBeTrue();
-});
diff --git a/www/tests/Unit/FileSizeTest.php b/www/tests/Unit/FileSizeTest.php
new file mode 100644
index 0000000..2e7922f
--- /dev/null
+++ b/www/tests/Unit/FileSizeTest.php
@@ -0,0 +1,32 @@
+toBe('0');
+});
+
+test('formats byte values without decimal places', function () {
+ expect(FileSize::format(1))->toBe('1');
+ expect(FileSize::format(512))->toBe('512');
+ expect(FileSize::format(1001))->toBe('1001');
+ expect(FileSize::format(1023))->toBe('1023');
+});
+
+test('formats kilobytes', function () {
+ expect(FileSize::format(1024))->toBe('1.00K');
+ expect(FileSize::format(1536))->toBe('1.50K');
+});
+
+test('formats megabytes', function () {
+ expect(FileSize::format(1024 * 1024))->toBe('1.00M');
+ expect(FileSize::format((int) (1.5 * 1024 * 1024)))->toBe('1.50M');
+});
+
+test('formats gigabytes', function () {
+ expect(FileSize::format(1024 * 1024 * 1024))->toBe('1.00G');
+});
+
+test('formats terabytes', function () {
+ expect(FileSize::format(1024 * 1024 * 1024 * 1024))->toBe('1.00T');
+});
diff --git a/www/tests/Unit/NintendoFileExtensionTest.php b/www/tests/Unit/NintendoFileExtensionTest.php
new file mode 100644
index 0000000..5bebc6e
--- /dev/null
+++ b/www/tests/Unit/NintendoFileExtensionTest.php
@@ -0,0 +1,29 @@
+toBe(['nsp', 'nsz', 'xci', 'xcz']);
+});
+
+test('isSupported() returns true for each supported extension', function (string $filename) {
+ expect(NintendoFileExtension::isSupported($filename))->toBeTrue();
+})->with(['game.nsp', 'game.nsz', 'game.xci', 'game.xcz']);
+
+test('isSupported() is case-insensitive', function (string $filename) {
+ expect(NintendoFileExtension::isSupported($filename))->toBeTrue();
+})->with(['game.NSP', 'game.NSZ', 'game.XCI', 'game.XCZ', 'game.Nsp', 'game.Xci']);
+
+test('isSupported() returns false for unsupported extensions', function (string $filename) {
+ expect(NintendoFileExtension::isSupported($filename))->toBeFalse();
+})->with(['game.txt', 'game.exe', 'game.zip', 'game.iso', 'game.php', 'game.rom']);
+
+test('isSupported() returns false for files without an extension', function () {
+ expect(NintendoFileExtension::isSupported('gamefile'))->toBeFalse();
+});
+
+test('isSupported() works with paths containing directory separators', function () {
+ expect(NintendoFileExtension::isSupported('folder/game.nsp'))->toBeTrue();
+ expect(NintendoFileExtension::isSupported('folder/sub/game.xci'))->toBeTrue();
+ expect(NintendoFileExtension::isSupported('folder/game.txt'))->toBeFalse();
+});