Merge pull request 'list-editing' (#1) from list-editing into main
Reviewed-on: tiradoe/movie-night-api-laravel#1
This commit is contained in:
commit
cd2c8adaa8
18 changed files with 425 additions and 274 deletions
|
|
@ -1,6 +0,0 @@
|
||||||
{
|
|
||||||
"enabledMcpjsonServers": [
|
|
||||||
"laravel-boost"
|
|
||||||
],
|
|
||||||
"enableAllProjectMcpServers": true
|
|
||||||
}
|
|
||||||
241
CLAUDE.md
241
CLAUDE.md
|
|
@ -1,241 +0,0 @@
|
||||||
<laravel-boost-guidelines>
|
|
||||||
=== 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.
|
|
||||||
|
|
||||||
<!-- Explicit Return Types and Method Params -->
|
|
||||||
```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.
|
|
||||||
|
|
||||||
</laravel-boost-guidelines>
|
|
||||||
44
app/Http/Controllers/AuthController.php
Normal file
44
app/Http/Controllers/AuthController.php
Normal file
|
|
@ -0,0 +1,44 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers;
|
||||||
|
|
||||||
|
use App\Http\Requests\LoginRequest;
|
||||||
|
use App\Http\Requests\RegisterRequest;
|
||||||
|
use App\Models\User;
|
||||||
|
use Illuminate\Http\JsonResponse;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Support\Facades\Auth;
|
||||||
|
|
||||||
|
class AuthController extends Controller
|
||||||
|
{
|
||||||
|
public function register(RegisterRequest $request): JsonResponse
|
||||||
|
{
|
||||||
|
$user = User::create($request->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.']);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -2,7 +2,9 @@
|
||||||
|
|
||||||
namespace App\Http\Controllers;
|
namespace App\Http\Controllers;
|
||||||
|
|
||||||
|
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
|
||||||
|
|
||||||
abstract class Controller
|
abstract class Controller
|
||||||
{
|
{
|
||||||
//
|
use AuthorizesRequests;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -56,11 +56,10 @@ class MovieController extends Controller
|
||||||
* @throws MovieNotFoundException
|
* @throws MovieNotFoundException
|
||||||
* @throws MovieDatabaseException
|
* @throws MovieDatabaseException
|
||||||
*/
|
*/
|
||||||
public function search(Request $request)
|
public function search(MovieDbInterface $movieDb, Request $request, string $query)
|
||||||
{
|
{
|
||||||
$searchTerm = $request->input('term');
|
$movies = $movieDb->search($query, $request->input('options', []));
|
||||||
$movie = $this->movieDb->search($searchTerm);
|
|
||||||
|
|
||||||
return response()->json(['results' => $movie]);
|
return response()->json(['results' => $movies]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,9 @@
|
||||||
namespace App\Http\Controllers;
|
namespace App\Http\Controllers;
|
||||||
|
|
||||||
use App\Http\Requests\CreateMovieListRequest;
|
use App\Http\Requests\CreateMovieListRequest;
|
||||||
|
use App\Http\Requests\UpdateMovieListRequest;
|
||||||
|
use App\Interfaces\MovieDbInterface;
|
||||||
|
use App\Models\Movie;
|
||||||
use App\Models\MovieList;
|
use App\Models\MovieList;
|
||||||
use Illuminate\Database\Eloquent\ModelNotFoundException;
|
use Illuminate\Database\Eloquent\ModelNotFoundException;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
|
|
@ -23,10 +26,12 @@ class MovieListController extends Controller
|
||||||
*/
|
*/
|
||||||
public function store(CreateMovieListRequest $request)
|
public function store(CreateMovieListRequest $request)
|
||||||
{
|
{
|
||||||
|
$this->authorize('create', MovieList::class);
|
||||||
|
|
||||||
$validated = $request->validated();
|
$validated = $request->validated();
|
||||||
$movieList = MovieList::create([
|
$movieList = MovieList::create([
|
||||||
...$validated,
|
...$validated,
|
||||||
'owner' => 1, // auth()->id(),
|
'owner' => auth()->id(),
|
||||||
'slug' => Str::slug($validated['name']),
|
'slug' => Str::slug($validated['name']),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
|
@ -38,6 +43,7 @@ class MovieListController extends Controller
|
||||||
*/
|
*/
|
||||||
public function show(MovieList $movieList)
|
public function show(MovieList $movieList)
|
||||||
{
|
{
|
||||||
|
$this->authorize('view', $movieList);
|
||||||
try {
|
try {
|
||||||
return $movieList->load('movies');
|
return $movieList->load('movies');
|
||||||
} catch (ModelNotFoundException $e) {
|
} catch (ModelNotFoundException $e) {
|
||||||
|
|
@ -48,7 +54,7 @@ class MovieListController extends Controller
|
||||||
/**
|
/**
|
||||||
* Update the specified resource in storage.
|
* Update the specified resource in storage.
|
||||||
*/
|
*/
|
||||||
public function update(Request $request, MovieList $movieList)
|
public function update(UpdateMovieListRequest $request, MovieList $movieList)
|
||||||
{
|
{
|
||||||
$validated = $request->validated();
|
$validated = $request->validated();
|
||||||
$movieList->update($validated);
|
$movieList->update($validated);
|
||||||
|
|
@ -61,6 +67,29 @@ class MovieListController extends Controller
|
||||||
*/
|
*/
|
||||||
public function destroy(MovieList $movieList)
|
public function destroy(MovieList $movieList)
|
||||||
{
|
{
|
||||||
|
$this->authorize('delete', $movieList);
|
||||||
$movieList->delete();
|
$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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
29
app/Http/Requests/LoginRequest.php
Normal file
29
app/Http/Requests/LoginRequest.php
Normal file
|
|
@ -0,0 +1,29 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Requests;
|
||||||
|
|
||||||
|
use Illuminate\Foundation\Http\FormRequest;
|
||||||
|
|
||||||
|
class LoginRequest extends FormRequest
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Determine if the user is authorized to make this request.
|
||||||
|
*/
|
||||||
|
public function authorize(): bool
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the validation rules that apply to the request.
|
||||||
|
*
|
||||||
|
* @return array<string, \Illuminate\Contracts\Validation\ValidationRule|array<mixed>|string>
|
||||||
|
*/
|
||||||
|
public function rules(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'email' => 'required|string|email',
|
||||||
|
'password' => 'required|string',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
30
app/Http/Requests/RegisterRequest.php
Normal file
30
app/Http/Requests/RegisterRequest.php
Normal file
|
|
@ -0,0 +1,30 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Requests;
|
||||||
|
|
||||||
|
use Illuminate\Foundation\Http\FormRequest;
|
||||||
|
|
||||||
|
class RegisterRequest extends FormRequest
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Determine if the user is authorized to make this request.
|
||||||
|
*/
|
||||||
|
public function authorize(): bool
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the validation rules that apply to the request.
|
||||||
|
*
|
||||||
|
* @return array<string, \Illuminate\Contracts\Validation\ValidationRule|array<mixed>|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',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
31
app/Http/Requests/UpdateMovieListRequest.php
Normal file
31
app/Http/Requests/UpdateMovieListRequest.php
Normal file
|
|
@ -0,0 +1,31 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Requests;
|
||||||
|
|
||||||
|
use Illuminate\Foundation\Http\FormRequest;
|
||||||
|
|
||||||
|
class UpdateMovieListRequest extends FormRequest
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Determine if the user is authorized to make this request.
|
||||||
|
*/
|
||||||
|
public function authorize(): bool
|
||||||
|
{
|
||||||
|
return $this->user()->can('update', $this->route('movieList'));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the validation rules that apply to the request.
|
||||||
|
*
|
||||||
|
* @return array<string, \Illuminate\Contracts\Validation\ValidationRule|array<mixed>|string>
|
||||||
|
*/
|
||||||
|
public function rules(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'name' => 'required|string|max:255',
|
||||||
|
'is_public' => 'boolean',
|
||||||
|
'movies' => 'array',
|
||||||
|
'slug' => 'string',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -19,7 +19,7 @@ interface MovieDbInterface
|
||||||
* @throws MovieNotFoundException If no movies match the query
|
* @throws MovieNotFoundException If no movies match the query
|
||||||
* @throws MovieDatabaseException If the external movie database is unreachable or returns an error
|
* @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.
|
* Find a specific movie by title or external ID.
|
||||||
|
|
|
||||||
|
|
@ -22,4 +22,11 @@ class Movie extends Model
|
||||||
'poster',
|
'poster',
|
||||||
'added_by',
|
'added_by',
|
||||||
];
|
];
|
||||||
|
|
||||||
|
protected function casts(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'critic_scores' => 'array',
|
||||||
|
];
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -19,7 +19,7 @@ class User extends Authenticatable
|
||||||
* @var list<string>
|
* @var list<string>
|
||||||
*/
|
*/
|
||||||
protected $fillable = [
|
protected $fillable = [
|
||||||
'name',
|
'username',
|
||||||
'email',
|
'email',
|
||||||
'password',
|
'password',
|
||||||
];
|
];
|
||||||
|
|
@ -47,7 +47,8 @@ class User extends Authenticatable
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
public function profile(): HasOne {
|
public function profile(): HasOne
|
||||||
|
{
|
||||||
return $this->hasOne(Profile::class);
|
return $this->hasOne(Profile::class);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
49
app/Policies/MovieListPolicy.php
Normal file
49
app/Policies/MovieListPolicy.php
Normal file
|
|
@ -0,0 +1,49 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Policies;
|
||||||
|
|
||||||
|
use App\Models\MovieList;
|
||||||
|
use App\Models\User;
|
||||||
|
|
||||||
|
class MovieListPolicy
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Create a new policy instance.
|
||||||
|
*/
|
||||||
|
public function __construct()
|
||||||
|
{
|
||||||
|
//
|
||||||
|
}
|
||||||
|
|
||||||
|
public function create(User $user): bool
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function view(User $user, MovieList $movieList): bool
|
||||||
|
{
|
||||||
|
if ($movieList->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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -82,7 +82,7 @@ class OmdbMovieService implements MovieDbInterface
|
||||||
'plot' => $movieDetails->plot,
|
'plot' => $movieDetails->plot,
|
||||||
'genre' => $movieDetails->genre,
|
'genre' => $movieDetails->genre,
|
||||||
'mpaa_rating' => $movieDetails->mpaaRating,
|
'mpaa_rating' => $movieDetails->mpaaRating,
|
||||||
'critic_scores' => $movieDetails->criticScores,
|
'critic_scores' => json_encode($movieDetails->criticScores),
|
||||||
'poster' => $movieDetails->poster,
|
'poster' => $movieDetails->poster,
|
||||||
'added_by' => auth()->id(),
|
'added_by' => auth()->id(),
|
||||||
]);
|
]);
|
||||||
|
|
@ -169,7 +169,7 @@ class OmdbMovieService implements MovieDbInterface
|
||||||
'plot' => $movieDetails->plot,
|
'plot' => $movieDetails->plot,
|
||||||
'genre' => $movieDetails->genre,
|
'genre' => $movieDetails->genre,
|
||||||
'mpaa_rating' => $movieDetails->mpaaRating,
|
'mpaa_rating' => $movieDetails->mpaaRating,
|
||||||
'critic_scores' => $movieDetails->criticScores,
|
'critic_scores' => json_encode($movieDetails->criticScores),
|
||||||
'poster' => $movieDetails->poster,
|
'poster' => $movieDetails->poster,
|
||||||
'added_by' => auth()->id(),
|
'added_by' => auth()->id(),
|
||||||
]);
|
]);
|
||||||
|
|
@ -182,9 +182,9 @@ class OmdbMovieService implements MovieDbInterface
|
||||||
*
|
*
|
||||||
* @throws ConnectionException If connection to OMDB fails
|
* @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 MovieDatabaseException If OMDB API returns an error
|
||||||
* @throws MovieNotFoundException If no movies are found
|
* @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'] ?? [])
|
return collect($searchResults['Search'] ?? [])
|
||||||
->map(fn ($movie) => new MovieSearchResult(
|
->map(fn ($movie) => new MovieSearchResult(
|
||||||
|
|
|
||||||
|
|
@ -15,7 +15,7 @@ return Application::configure(basePath: dirname(__DIR__))
|
||||||
health: '/up',
|
health: '/up',
|
||||||
)
|
)
|
||||||
->withMiddleware(function (Middleware $middleware): void {
|
->withMiddleware(function (Middleware $middleware): void {
|
||||||
//
|
$middleware->statefulApi();
|
||||||
})
|
})
|
||||||
->withExceptions(function (Exceptions $exceptions): void {
|
->withExceptions(function (Exceptions $exceptions): void {
|
||||||
$exceptions->renderable(function (MovieNotFoundException $e, Request $request) {
|
$exceptions->renderable(function (MovieNotFoundException $e, Request $request) {
|
||||||
|
|
|
||||||
34
config/cors.php
Normal file
34
config/cors.php
Normal file
|
|
@ -0,0 +1,34 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
return [
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Cross-Origin Resource Sharing (CORS) Configuration
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| Here you may configure your settings for cross-origin resource sharing
|
||||||
|
| or "CORS". This determines what cross-origin operations may execute
|
||||||
|
| in web browsers. You are free to adjust these settings as needed.
|
||||||
|
|
|
||||||
|
| To learn more: https://developer.mozilla.org/en-US/docs/Web/HTTP/CORS
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
'paths' => ['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,
|
||||||
|
|
||||||
|
];
|
||||||
|
|
@ -1,18 +1,29 @@
|
||||||
<?php
|
<?php
|
||||||
|
|
||||||
|
use App\Http\Controllers\AuthController;
|
||||||
use App\Http\Controllers\MovieController;
|
use App\Http\Controllers\MovieController;
|
||||||
use App\Http\Controllers\MovieListController;
|
use App\Http\Controllers\MovieListController;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
use Illuminate\Support\Facades\Route;
|
use Illuminate\Support\Facades\Route;
|
||||||
|
|
||||||
Route::get('/user', function (Request $request) {
|
// Public auth routes
|
||||||
return $request->user();
|
Route::post('/register', [AuthController::class, 'register']);
|
||||||
})->middleware('auth:sanctum');
|
Route::post('/login', [AuthController::class, 'login']);
|
||||||
|
|
||||||
// MOVIES
|
// Authenticated routes
|
||||||
Route::get('/movies/search', [MovieController::class, 'search'])->name('movies.search');
|
Route::middleware('auth:sanctum')->group(function () {
|
||||||
|
Route::post('/logout', [AuthController::class, 'logout']);
|
||||||
|
Route::get('/user', fn (Request $request) => $request->user());
|
||||||
|
|
||||||
// MOVIE LISTS
|
// 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::get('/movielists/{movieList}', [MovieListController::class, 'show'])->name('movielists.show');
|
||||||
Route::post('/movielists', [MovieListController::class, 'store'])->name('movielists.store');
|
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');
|
Route::delete('/movielists/{movieList}', [MovieListController::class, 'destroy'])->name('movielists.destroy');
|
||||||
|
});
|
||||||
|
|
|
||||||
128
tests/Feature/AuthTest.php
Normal file
128
tests/Feature/AuthTest.php
Normal file
|
|
@ -0,0 +1,128 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Tests\Feature;
|
||||||
|
|
||||||
|
use App\Models\User;
|
||||||
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
use Tests\TestCase;
|
||||||
|
|
||||||
|
class AuthTest extends TestCase
|
||||||
|
{
|
||||||
|
use RefreshDatabase;
|
||||||
|
|
||||||
|
public function test_user_can_register_with_valid_data(): void
|
||||||
|
{
|
||||||
|
$response = $this->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]);
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue