diff --git a/.claude/settings.local.json b/.claude/settings.local.json
deleted file mode 100644
index 78c8763..0000000
--- a/.claude/settings.local.json
+++ /dev/null
@@ -1,6 +0,0 @@
-{
- "enabledMcpjsonServers": [
- "laravel-boost"
- ],
- "enableAllProjectMcpServers": true
-}
diff --git a/CLAUDE.md b/CLAUDE.md
deleted file mode 100644
index 3e09433..0000000
--- a/CLAUDE.md
+++ /dev/null
@@ -1,241 +0,0 @@
-
-=== foundation rules ===
-
-# Laravel Boost Guidelines
-
-The Laravel Boost guidelines are specifically curated by Laravel maintainers for this application. These guidelines should be followed closely to ensure the best experience when building Laravel applications.
-
-## Foundational Context
-
-This application is a Laravel application and its main Laravel ecosystems package & versions are below. You are an expert with them all. Ensure you abide by these specific packages & versions.
-
-- php - 8.4.17
-- laravel/framework (LARAVEL) - v12
-- laravel/prompts (PROMPTS) - v0
-- laravel/sanctum (SANCTUM) - v4
-- laravel/boost (BOOST) - v2
-- laravel/mcp (MCP) - v0
-- laravel/pail (PAIL) - v1
-- laravel/pint (PINT) - v1
-- laravel/sail (SAIL) - v1
-- phpunit/phpunit (PHPUNIT) - v11
-- tailwindcss (TAILWINDCSS) - v4
-
-## Conventions
-
-- You must follow all existing code conventions used in this application. When creating or editing a file, check sibling files for the correct structure, approach, and naming.
-- Use descriptive names for variables and methods. For example, `isRegisteredForDiscounts`, not `discount()`.
-- Check for existing components to reuse before writing a new one.
-
-## Verification Scripts
-
-- Do not create verification scripts or tinker when tests cover that functionality and prove they work. Unit and feature tests are more important.
-
-## Application Structure & Architecture
-
-- Stick to existing directory structure; don't create new base folders without approval.
-- Do not change the application's dependencies without approval.
-
-## Frontend Bundling
-
-- If the user doesn't see a frontend change reflected in the UI, it could mean they need to run `npm run build`, `npm run dev`, or `composer run dev`. Ask them.
-
-## Documentation Files
-
-- You must only create documentation files if explicitly requested by the user.
-
-## Replies
-
-- Be concise in your explanations - focus on what's important rather than explaining obvious details.
-
-=== boost rules ===
-
-# Laravel Boost
-
-- Laravel Boost is an MCP server that comes with powerful tools designed specifically for this application. Use them.
-
-## Artisan
-
-- Use the `list-artisan-commands` tool when you need to call an Artisan command to double-check the available parameters.
-
-## URLs
-
-- Whenever you share a project URL with the user, you should use the `get-absolute-url` tool to ensure you're using the correct scheme, domain/IP, and port.
-
-## Tinker / Debugging
-
-- You should use the `tinker` tool when you need to execute PHP to debug code or query Eloquent models directly.
-- Use the `database-query` tool when you only need to read from the database.
-- Use the `database-schema` tool to inspect table structure before writing migrations or models.
-
-## Reading Browser Logs With the `browser-logs` Tool
-
-- You can read browser logs, errors, and exceptions using the `browser-logs` tool from Boost.
-- Only recent browser logs will be useful - ignore old logs.
-
-## Searching Documentation (Critically Important)
-
-- Boost comes with a powerful `search-docs` tool you should use before trying other approaches when working with Laravel or Laravel ecosystem packages. This tool automatically passes a list of installed packages and their versions to the remote Boost API, so it returns only version-specific documentation for the user's circumstance. You should pass an array of packages to filter on if you know you need docs for particular packages.
-- Search the documentation before making code changes to ensure we are taking the correct approach.
-- Use multiple, broad, simple, topic-based queries at once. For example: `['rate limiting', 'routing rate limiting', 'routing']`. The most relevant results will be returned first.
-- Do not add package names to queries; package information is already shared. For example, use `test resource table`, not `filament 4 test resource table`.
-
-### Available Search Syntax
-
-1. Simple Word Searches with auto-stemming - query=authentication - finds 'authenticate' and 'auth'.
-2. Multiple Words (AND Logic) - query=rate limit - finds knowledge containing both "rate" AND "limit".
-3. Quoted Phrases (Exact Position) - query="infinite scroll" - words must be adjacent and in that order.
-4. Mixed Queries - query=middleware "rate limit" - "middleware" AND exact phrase "rate limit".
-5. Multiple Queries - queries=["authentication", "middleware"] - ANY of these terms.
-
-=== php rules ===
-
-# PHP
-
-- Always use curly braces for control structures, even for single-line bodies.
-
-## Constructors
-
-- Use PHP 8 constructor property promotion in `__construct()`.
- - `public function __construct(public GitHub $github) { }`
-- Do not allow empty `__construct()` methods with zero parameters unless the constructor is private.
-
-## Type Declarations
-
-- Always use explicit return type declarations for methods and functions.
-- Use appropriate PHP type hints for method parameters.
-
-
-```php
-protected function isAccessible(User $user, ?string $path = null): bool
-{
- ...
-}
-```
-
-## Enums
-
-- Typically, keys in an Enum should be TitleCase. For example: `FavoritePerson`, `BestLake`, `Monthly`.
-
-## Comments
-
-- Prefer PHPDoc blocks over inline comments. Never use comments within the code itself unless the logic is exceptionally complex.
-
-## PHPDoc Blocks
-
-- Add useful array shape type definitions when appropriate.
-
-=== laravel/core rules ===
-
-# Do Things the Laravel Way
-
-- Use `php artisan make:` commands to create new files (i.e. migrations, controllers, models, etc.). You can list available Artisan commands using the `list-artisan-commands` tool.
-- If you're creating a generic PHP class, use `php artisan make:class`.
-- Pass `--no-interaction` to all Artisan commands to ensure they work without user input. You should also pass the correct `--options` to ensure correct behavior.
-
-## Database
-
-- Always use proper Eloquent relationship methods with return type hints. Prefer relationship methods over raw queries or manual joins.
-- Use Eloquent models and relationships before suggesting raw database queries.
-- Avoid `DB::`; prefer `Model::query()`. Generate code that leverages Laravel's ORM capabilities rather than bypassing them.
-- Generate code that prevents N+1 query problems by using eager loading.
-- Use Laravel's query builder for very complex database operations.
-
-### Model Creation
-
-- When creating new models, create useful factories and seeders for them too. Ask the user if they need any other things, using `list-artisan-commands` to check the available options to `php artisan make:model`.
-
-### APIs & Eloquent Resources
-
-- For APIs, default to using Eloquent API Resources and API versioning unless existing API routes do not, then you should follow existing application convention.
-
-## Controllers & Validation
-
-- Always create Form Request classes for validation rather than inline validation in controllers. Include both validation rules and custom error messages.
-- Check sibling Form Requests to see if the application uses array or string based validation rules.
-
-## Authentication & Authorization
-
-- Use Laravel's built-in authentication and authorization features (gates, policies, Sanctum, etc.).
-
-## URL Generation
-
-- When generating links to other pages, prefer named routes and the `route()` function.
-
-## Queues
-
-- Use queued jobs for time-consuming operations with the `ShouldQueue` interface.
-
-## Configuration
-
-- Use environment variables only in configuration files - never use the `env()` function directly outside of config files. Always use `config('app.name')`, not `env('APP_NAME')`.
-
-## Testing
-
-- When creating models for tests, use the factories for the models. Check if the factory has custom states that can be used before manually setting up the model.
-- Faker: Use methods such as `$this->faker->word()` or `fake()->randomDigit()`. Follow existing conventions whether to use `$this->faker` or `fake()`.
-- When creating tests, make use of `php artisan make:test [options] {name}` to create a feature test, and pass `--unit` to create a unit test. Most tests should be feature tests.
-
-## Vite Error
-
-- If you receive an "Illuminate\Foundation\ViteException: Unable to locate file in Vite manifest" error, you can run `npm run build` or ask the user to run `npm run dev` or `composer run dev`.
-
-=== laravel/v12 rules ===
-
-# Laravel 12
-
-- CRITICAL: ALWAYS use `search-docs` tool for version-specific Laravel documentation and updated code examples.
-- Since Laravel 11, Laravel has a new streamlined file structure which this project uses.
-
-## Laravel 12 Structure
-
-- In Laravel 12, middleware are no longer registered in `app/Http/Kernel.php`.
-- Middleware are configured declaratively in `bootstrap/app.php` using `Application::configure()->withMiddleware()`.
-- `bootstrap/app.php` is the file to register middleware, exceptions, and routing files.
-- `bootstrap/providers.php` contains application specific service providers.
-- The `app\Console\Kernel.php` file no longer exists; use `bootstrap/app.php` or `routes/console.php` for console configuration.
-- Console commands in `app/Console/Commands/` are automatically available and do not require manual registration.
-
-## Database
-
-- When modifying a column, the migration must include all of the attributes that were previously defined on the column. Otherwise, they will be dropped and lost.
-- Laravel 12 allows limiting eagerly loaded records natively, without external packages: `$query->latest()->limit(10);`.
-
-### Models
-
-- Casts can and likely should be set in a `casts()` method on a model rather than the `$casts` property. Follow existing conventions from other models.
-
-=== pint/core rules ===
-
-# Laravel Pint Code Formatter
-
-- You must run `vendor/bin/pint --dirty --format agent` before finalizing changes to ensure your code matches the project's expected style.
-- Do not run `vendor/bin/pint --test --format agent`, simply run `vendor/bin/pint --format agent` to fix any formatting issues.
-
-=== phpunit/core rules ===
-
-# PHPUnit
-
-- This application uses PHPUnit for testing. All tests must be written as PHPUnit classes. Use `php artisan make:test --phpunit {name}` to create a new test.
-- If you see a test using "Pest", convert it to PHPUnit.
-- Every time a test has been updated, run that singular test.
-- When the tests relating to your feature are passing, ask the user if they would like to also run the entire test suite to make sure everything is still passing.
-- Tests should cover all happy paths, failure paths, and edge cases.
-- You must not remove any tests or test files from the tests directory without approval. These are not temporary or helper files; these are core to the application.
-
-## Running Tests
-
-- Run the minimal number of tests, using an appropriate filter, before finalizing.
-- To run all tests: `php artisan test --compact`.
-- To run all tests in a file: `php artisan test --compact tests/Feature/ExampleTest.php`.
-- To filter on a particular test name: `php artisan test --compact --filter=testName` (recommended after making a change to a related file).
-
-=== tailwindcss/core rules ===
-
-# Tailwind CSS
-
-- Always use existing Tailwind conventions; check project patterns before adding new ones.
-- IMPORTANT: Always use `search-docs` tool for version-specific Tailwind CSS documentation and updated code examples. Never rely on training data.
-- IMPORTANT: Activate `tailwindcss-development` every time you're working with a Tailwind CSS or styling-related task.
-
-
diff --git a/app/Http/Controllers/AuthController.php b/app/Http/Controllers/AuthController.php
new file mode 100644
index 0000000..7756775
--- /dev/null
+++ b/app/Http/Controllers/AuthController.php
@@ -0,0 +1,44 @@
+validated());
+
+ Auth::login($user);
+
+ $request->session()->regenerate();
+
+ return response()->json($user, 201);
+ }
+
+ public function login(LoginRequest $request): JsonResponse
+ {
+ if (! Auth::attempt($request->validated())) {
+ return response()->json(['message' => 'Invalid credentials.'], 401);
+ }
+
+ $request->session()->regenerate();
+
+ return response()->json(Auth::user());
+ }
+
+ public function logout(Request $request): JsonResponse
+ {
+ Auth::guard('web')->logout();
+
+ $request->session()->invalidate();
+
+ return response()->json(['message' => 'Logged out.']);
+ }
+}
diff --git a/app/Http/Controllers/Controller.php b/app/Http/Controllers/Controller.php
index 8677cd5..e7f7c94 100644
--- a/app/Http/Controllers/Controller.php
+++ b/app/Http/Controllers/Controller.php
@@ -2,7 +2,9 @@
namespace App\Http\Controllers;
+use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
+
abstract class Controller
{
- //
+ use AuthorizesRequests;
}
diff --git a/app/Http/Controllers/MovieController.php b/app/Http/Controllers/MovieController.php
index 16b1278..ad94aa9 100644
--- a/app/Http/Controllers/MovieController.php
+++ b/app/Http/Controllers/MovieController.php
@@ -56,11 +56,10 @@ class MovieController extends Controller
* @throws MovieNotFoundException
* @throws MovieDatabaseException
*/
- public function search(Request $request)
+ public function search(MovieDbInterface $movieDb, Request $request, string $query)
{
- $searchTerm = $request->input('term');
- $movie = $this->movieDb->search($searchTerm);
+ $movies = $movieDb->search($query, $request->input('options', []));
- return response()->json(['results' => $movie]);
+ return response()->json(['results' => $movies]);
}
}
diff --git a/app/Http/Controllers/MovieListController.php b/app/Http/Controllers/MovieListController.php
index 58e992c..f10c83f 100644
--- a/app/Http/Controllers/MovieListController.php
+++ b/app/Http/Controllers/MovieListController.php
@@ -3,6 +3,9 @@
namespace App\Http\Controllers;
use App\Http\Requests\CreateMovieListRequest;
+use App\Http\Requests\UpdateMovieListRequest;
+use App\Interfaces\MovieDbInterface;
+use App\Models\Movie;
use App\Models\MovieList;
use Illuminate\Database\Eloquent\ModelNotFoundException;
use Illuminate\Http\Request;
@@ -23,10 +26,12 @@ class MovieListController extends Controller
*/
public function store(CreateMovieListRequest $request)
{
+ $this->authorize('create', MovieList::class);
+
$validated = $request->validated();
$movieList = MovieList::create([
...$validated,
- 'owner' => 1, // auth()->id(),
+ 'owner' => auth()->id(),
'slug' => Str::slug($validated['name']),
]);
@@ -38,6 +43,7 @@ class MovieListController extends Controller
*/
public function show(MovieList $movieList)
{
+ $this->authorize('view', $movieList);
try {
return $movieList->load('movies');
} catch (ModelNotFoundException $e) {
@@ -48,7 +54,7 @@ class MovieListController extends Controller
/**
* Update the specified resource in storage.
*/
- public function update(Request $request, MovieList $movieList)
+ public function update(UpdateMovieListRequest $request, MovieList $movieList)
{
$validated = $request->validated();
$movieList->update($validated);
@@ -61,6 +67,29 @@ class MovieListController extends Controller
*/
public function destroy(MovieList $movieList)
{
+ $this->authorize('delete', $movieList);
$movieList->delete();
}
+
+ public function addMovie(MovieDbInterface $movieDb, Request $request, MovieList $movieList)
+ {
+ $this->authorize('update', $movieList);
+ $movieResult = $movieDb->find($request->input('movie')['imdbId'], ['type' => 'imdb']);
+ $movie = Movie::where('imdb_id', $movieResult->imdbId)->first();
+
+ $movieList->movies()->attach($movie);
+ $movieList->load('movies');
+
+ return response()->json($movieList);
+ }
+
+ public function removeMovie(MovieDbInterface $movieDb, Request $request, MovieList $movieList, Movie $movie)
+ {
+ $this->authorize('update', $movieList);
+
+ $movieList->movies()->detach($movie);
+ $movieList->load('movies');
+
+ return response()->json($movieList);
+ }
}
diff --git a/app/Http/Requests/LoginRequest.php b/app/Http/Requests/LoginRequest.php
new file mode 100644
index 0000000..d614336
--- /dev/null
+++ b/app/Http/Requests/LoginRequest.php
@@ -0,0 +1,29 @@
+|string>
+ */
+ public function rules(): array
+ {
+ return [
+ 'email' => 'required|string|email',
+ 'password' => 'required|string',
+ ];
+ }
+}
diff --git a/app/Http/Requests/RegisterRequest.php b/app/Http/Requests/RegisterRequest.php
new file mode 100644
index 0000000..8768ffe
--- /dev/null
+++ b/app/Http/Requests/RegisterRequest.php
@@ -0,0 +1,30 @@
+|string>
+ */
+ public function rules(): array
+ {
+ return [
+ 'username' => 'required|string|max:255|unique:users',
+ 'email' => 'required|string|email|max:255|unique:users',
+ 'password' => 'required|string|min:8|confirmed',
+ ];
+ }
+}
diff --git a/app/Http/Requests/UpdateMovieListRequest.php b/app/Http/Requests/UpdateMovieListRequest.php
new file mode 100644
index 0000000..f9b8bef
--- /dev/null
+++ b/app/Http/Requests/UpdateMovieListRequest.php
@@ -0,0 +1,31 @@
+user()->can('update', $this->route('movieList'));
+ }
+
+ /**
+ * Get the validation rules that apply to the request.
+ *
+ * @return array|string>
+ */
+ public function rules(): array
+ {
+ return [
+ 'name' => 'required|string|max:255',
+ 'is_public' => 'boolean',
+ 'movies' => 'array',
+ 'slug' => 'string',
+ ];
+ }
+}
diff --git a/app/Interfaces/MovieDbInterface.php b/app/Interfaces/MovieDbInterface.php
index c5f4287..b73bd7a 100644
--- a/app/Interfaces/MovieDbInterface.php
+++ b/app/Interfaces/MovieDbInterface.php
@@ -19,7 +19,7 @@ interface MovieDbInterface
* @throws MovieNotFoundException If no movies match the query
* @throws MovieDatabaseException If the external movie database is unreachable or returns an error
*/
- public function search(string $query): Collection;
+ public function search(string $query, array $options): Collection;
/**
* Find a specific movie by title or external ID.
diff --git a/app/Models/Movie.php b/app/Models/Movie.php
index 086509c..caddde3 100644
--- a/app/Models/Movie.php
+++ b/app/Models/Movie.php
@@ -22,4 +22,11 @@ class Movie extends Model
'poster',
'added_by',
];
+
+ protected function casts(): array
+ {
+ return [
+ 'critic_scores' => 'array',
+ ];
+ }
}
diff --git a/app/Models/User.php b/app/Models/User.php
index 54f7de4..f070dd1 100644
--- a/app/Models/User.php
+++ b/app/Models/User.php
@@ -19,7 +19,7 @@ class User extends Authenticatable
* @var list
*/
protected $fillable = [
- 'name',
+ 'username',
'email',
'password',
];
@@ -47,7 +47,8 @@ class User extends Authenticatable
];
}
- public function profile(): HasOne {
- return $this->hasOne(Profile::class);
+ public function profile(): HasOne
+ {
+ return $this->hasOne(Profile::class);
}
}
diff --git a/app/Policies/MovieListPolicy.php b/app/Policies/MovieListPolicy.php
new file mode 100644
index 0000000..444899a
--- /dev/null
+++ b/app/Policies/MovieListPolicy.php
@@ -0,0 +1,49 @@
+owner === $user->getKey() || $movieList->isPublic) {
+ return true;
+ }
+
+ return false;
+ }
+
+ public function update(User $user, MovieList $movieList): bool
+ {
+ if ($movieList->owner === $user->getKey()) {
+ return true;
+ }
+
+ return false;
+ }
+
+ public function delete(User $user, MovieList $movieList): bool
+ {
+ if ($movieList->owner === $user->getKey()) {
+ return true;
+ }
+
+ return false;
+ }
+}
diff --git a/app/Services/OmdbMovieService.php b/app/Services/OmdbMovieService.php
index 77565f3..72e381a 100644
--- a/app/Services/OmdbMovieService.php
+++ b/app/Services/OmdbMovieService.php
@@ -82,7 +82,7 @@ class OmdbMovieService implements MovieDbInterface
'plot' => $movieDetails->plot,
'genre' => $movieDetails->genre,
'mpaa_rating' => $movieDetails->mpaaRating,
- 'critic_scores' => $movieDetails->criticScores,
+ 'critic_scores' => json_encode($movieDetails->criticScores),
'poster' => $movieDetails->poster,
'added_by' => auth()->id(),
]);
@@ -169,7 +169,7 @@ class OmdbMovieService implements MovieDbInterface
'plot' => $movieDetails->plot,
'genre' => $movieDetails->genre,
'mpaa_rating' => $movieDetails->mpaaRating,
- 'critic_scores' => $movieDetails->criticScores,
+ 'critic_scores' => json_encode($movieDetails->criticScores),
'poster' => $movieDetails->poster,
'added_by' => auth()->id(),
]);
@@ -182,9 +182,9 @@ class OmdbMovieService implements MovieDbInterface
*
* @throws ConnectionException If connection to OMDB fails
*/
- public function search(string $query): Collection
+ public function search(string $query, array $options = []): Collection
{
- return $this->searchByTitle($query);
+ return $this->searchByTitle($query, $options);
}
/**
@@ -197,9 +197,13 @@ class OmdbMovieService implements MovieDbInterface
* @throws MovieDatabaseException If OMDB API returns an error
* @throws MovieNotFoundException If no movies are found
*/
- private function searchByTitle(string $title): Collection
+ private function searchByTitle(string $title, array $options): Collection
{
- $searchResults = $this->makeOmdbRequest(['apikey' => $this->apiKey, 's' => $title, 'type' => 'movie']);
+ $searchResults = $this->makeOmdbRequest([
+ 'apikey' => $this->apiKey, 's' => $title,
+ 'type' => 'movie',
+ ...$options,
+ ]);
return collect($searchResults['Search'] ?? [])
->map(fn ($movie) => new MovieSearchResult(
diff --git a/bootstrap/app.php b/bootstrap/app.php
index ab17935..9676c94 100644
--- a/bootstrap/app.php
+++ b/bootstrap/app.php
@@ -15,7 +15,7 @@ return Application::configure(basePath: dirname(__DIR__))
health: '/up',
)
->withMiddleware(function (Middleware $middleware): void {
- //
+ $middleware->statefulApi();
})
->withExceptions(function (Exceptions $exceptions): void {
$exceptions->renderable(function (MovieNotFoundException $e, Request $request) {
diff --git a/config/cors.php b/config/cors.php
new file mode 100644
index 0000000..08aa7f1
--- /dev/null
+++ b/config/cors.php
@@ -0,0 +1,34 @@
+ ['api/*', 'sanctum/csrf-cookie'],
+
+ 'allowed_methods' => ['*'],
+
+ 'allowed_origins' => explode(',', env('ALLOWED_ORIGINS', '')),
+
+ 'allowed_origins_patterns' => [],
+
+ 'allowed_headers' => ['*'],
+
+ 'exposed_headers' => [],
+
+ 'max_age' => 0,
+
+ 'supports_credentials' => true,
+
+];
diff --git a/routes/api.php b/routes/api.php
index 5701fba..be8b0d9 100644
--- a/routes/api.php
+++ b/routes/api.php
@@ -1,18 +1,29 @@
user();
-})->middleware('auth:sanctum');
+// Public auth routes
+Route::post('/register', [AuthController::class, 'register']);
+Route::post('/login', [AuthController::class, 'login']);
-// MOVIES
-Route::get('/movies/search', [MovieController::class, 'search'])->name('movies.search');
+// Authenticated routes
+Route::middleware('auth:sanctum')->group(function () {
+ Route::post('/logout', [AuthController::class, 'logout']);
+ Route::get('/user', fn (Request $request) => $request->user());
-// MOVIE LISTS
-Route::get('/movielists/{movieList}', [MovieListController::class, 'show'])->name('movielists.show');
-Route::post('/movielists', [MovieListController::class, 'store'])->name('movielists.store');
-Route::delete('/movielists/{movieList}', [MovieListController::class, 'destroy'])->name('movielists.destroy');
+ // Movies
+ Route::get('/movies/search/{query}', [MovieController::class, 'search'])->name('movies.search');
+
+ // Movie Lists
+ Route::get('/movielists', [MovieListController::class, 'index'])->name('movielists.index');
+ Route::put('/movielists/', [MovieListController::class, 'index'])->name('movielists.index');
+ Route::get('/movielists/{movieList}', [MovieListController::class, 'show'])->name('movielists.show');
+ Route::post('/movielists', [MovieListController::class, 'store'])->name('movielists.store');
+ Route::post('/movielists/{movieList}/movies', [MovieListController::class, 'addMovie'])->name('movielists.addMovie');
+ Route::delete('/movielists/{movieList}/movies/{movie}', [MovieListController::class, 'removeMovie'])->name('movielists.removeMovie');
+ Route::delete('/movielists/{movieList}', [MovieListController::class, 'destroy'])->name('movielists.destroy');
+});
diff --git a/tests/Feature/AuthTest.php b/tests/Feature/AuthTest.php
new file mode 100644
index 0000000..34b95ea
--- /dev/null
+++ b/tests/Feature/AuthTest.php
@@ -0,0 +1,128 @@
+withHeader('Origin', 'http://localhost')
+ ->postJson('/api/register', [
+ 'username' => 'johndoe',
+ 'email' => 'john@example.com',
+ 'password' => 'password123',
+ 'password_confirmation' => 'password123',
+ ]);
+
+ $response->assertStatus(201)
+ ->assertJsonFragment(['username' => 'johndoe', 'email' => 'john@example.com']);
+
+ $this->assertDatabaseHas('users', ['email' => 'john@example.com', 'username' => 'johndoe']);
+ }
+
+ public function test_registration_fails_with_invalid_data(): void
+ {
+ $response = $this->postJson('/api/register', [
+ 'username' => '',
+ 'email' => 'not-an-email',
+ 'password' => 'short',
+ 'password_confirmation' => 'mismatch',
+ ]);
+
+ $response->assertStatus(422)
+ ->assertJsonValidationErrors(['username', 'email', 'password']);
+ }
+
+ public function test_registration_fails_with_duplicate_email(): void
+ {
+ User::factory()->create(['email' => 'john@example.com']);
+
+ $response = $this->postJson('/api/register', [
+ 'username' => 'johndoe',
+ 'email' => 'john@example.com',
+ 'password' => 'password123',
+ 'password_confirmation' => 'password123',
+ ]);
+
+ $response->assertStatus(422)
+ ->assertJsonValidationErrors(['email']);
+ }
+
+ public function test_registration_fails_with_duplicate_username(): void
+ {
+ User::factory()->create(['username' => 'johndoe']);
+
+ $response = $this->postJson('/api/register', [
+ 'username' => 'johndoe',
+ 'email' => 'john@example.com',
+ 'password' => 'password123',
+ 'password_confirmation' => 'password123',
+ ]);
+
+ $response->assertStatus(422)
+ ->assertJsonValidationErrors(['username']);
+ }
+
+ public function test_user_can_login_with_correct_credentials(): void
+ {
+ User::factory()->create(['email' => 'john@example.com']);
+
+ $response = $this->withHeader('Origin', 'http://localhost')
+ ->postJson('/api/login', [
+ 'email' => 'john@example.com',
+ 'password' => 'password',
+ ]);
+
+ $response->assertOk()
+ ->assertJsonFragment(['email' => 'john@example.com']);
+ }
+
+ public function test_login_fails_with_wrong_credentials(): void
+ {
+ User::factory()->create(['email' => 'john@example.com']);
+
+ $response = $this->withHeader('Origin', 'http://localhost')
+ ->postJson('/api/login', [
+ 'email' => 'john@example.com',
+ 'password' => 'wrong-password',
+ ]);
+
+ $response->assertStatus(401)
+ ->assertJsonFragment(['message' => 'Invalid credentials.']);
+ }
+
+ public function test_authenticated_user_can_logout(): void
+ {
+ $user = User::factory()->create();
+
+ $response = $this->actingAs($user)
+ ->withHeader('Origin', 'http://localhost')
+ ->postJson('/api/logout');
+
+ $response->assertOk()
+ ->assertJsonFragment(['message' => 'Logged out.']);
+ }
+
+ public function test_unauthenticated_user_cannot_access_protected_routes(): void
+ {
+ $response = $this->getJson('/api/user');
+
+ $response->assertStatus(401);
+ }
+
+ public function test_authenticated_user_can_access_user_endpoint(): void
+ {
+ $user = User::factory()->create();
+
+ $response = $this->actingAs($user)->getJson('/api/user');
+
+ $response->assertOk()
+ ->assertJsonFragment(['email' => $user->email]);
+ }
+}