feature(Todo API):

Added:
- API Support for Todos
- Todo API tests

Modified:
- API Authentication tests
- Minor Updates to Policies
- Minor Updates to Views
This commit is contained in:
devoalda 2023-08-12 11:15:31 +08:00
parent 1cf52e32ed
commit 1d9397e738
14 changed files with 638 additions and 58 deletions

View File

@ -38,7 +38,8 @@ class ApiAuthController extends Controller
return response()->json([
'access_token' => $token,
'token_type' => 'Bearer',
]);
], 201
);
}
}
@ -52,11 +53,13 @@ class ApiAuthController extends Controller
$user = User::where('email', $request['email'])->firstOrFail();
$token = $user->createToken('auth_token')->plainTextToken;
$token = $user->createToken('auth_token')
->plainTextToken;
return response()->json([
'access_token' => $token,
'token_type' => 'Bearer',
'message' => 'Login successful, please remember to logout!'
]);
}
@ -64,4 +67,14 @@ class ApiAuthController extends Controller
{
return $request->user();
}
public function logout(Request $request)
{
$request->user()->currentAccessToken()->delete();
return response()->json([
'message' => 'Logged out'
], 200
);
}
}

View File

@ -22,16 +22,20 @@ use Illuminate\Support\Facades\Response;
class ProjectController extends Controller
{
protected mixed $project_api_route_pattern = 'api/*';
/**
* Display Listing of all Projects.
* @throws AuthorizationException
*/
public function index(Request $request): View|Factory|Application|JsonResponse|ProjectResource
{
// Check if API Call, get userID from request
if ($request->is('api/*')) {
if ($request->expectsJson()) {
$user = Auth::user();
$this->authorize('viewAny', Project::class);
$projects = $user->projects()->paginate(4);
return new ProjectResource($projects);
}
@ -77,13 +81,15 @@ class ProjectController extends Controller
$data = $request->validated();
// Check if API Call, get userID from request
if ($request->is('api/*')) {
$user = User::find($request->user_id);
if (!$user) {
return response()->json(['error' => 'User not found'], 404);
}
if ($request->expectsJson()) {
$user = Auth::user();
$this->authorize('create', Project::class);
$user->projects()->create($data);
$data = $user->projects()->latest()->first();
return response()->json([
'message' => 'Project created successfully',
'data' => $data,
@ -103,11 +109,8 @@ class ProjectController extends Controller
public function show(Request $request, $project_id): RedirectResponse|JsonResponse
{
// Check if API Call, get userID from request
if ($request->is('api/*')) {
$user = User::find($request->user_id);
if (!$user) {
return response()->json(['error' => 'User not found'], 404);
}
if ($request->expectsJson()) {
$user = Auth::user();
$project = $user->projects()->find($project_id);
@ -117,6 +120,8 @@ class ProjectController extends Controller
], 404);
}
$this->authorize('view', $project);
return response()->json([
'message' => 'Project retrieved successfully',
'data' => $project,
@ -152,11 +157,8 @@ class ProjectController extends Controller
$data = $request->validatedWithCompletedAt();
// API Call
if ($request->is('api/*')) {
$user = User::find($request->user_id);
if (!$user) {
return response()->json(['error' => 'User not found'], 404);
}
if ($request->expectsJson()) {
$user = Auth::user();
$project = $user->projects()->find($project_id);
@ -166,6 +168,8 @@ class ProjectController extends Controller
], 404);
}
$this->authorize('update', $project);
$project->update($data);
return response()->json([
@ -201,12 +205,8 @@ class ProjectController extends Controller
public function destroy($project_id, Request $request): RedirectResponse|JsonResponse
{
// Check if API Call and $project_id is provided
if ($request->is('api/*')) {
$user_id = $request->user_id;
$user = User::find($user_id);
if (!$user) {
return response()->json(['error' => 'User not found'], 404);
}
if ($request->expectsJson() && $project_id) {
$user = Auth::user();
$project = $user->projects()->find($project_id);
@ -217,6 +217,8 @@ class ProjectController extends Controller
], 404);
}
$this->authorize('delete', $project);
$project->delete();
return response()->json([

View File

@ -4,13 +4,17 @@ namespace App\Http\Controllers;
use App\Http\Requests\Project\StoreTodoRequest;
use App\Http\Requests\Project\UpdateTodoRequest;
use App\Http\Resources\TodoResource;
use App\Models\Todo;
use App\Models\User;
use Illuminate\Contracts\View\Factory;
use Illuminate\Contracts\View\View;
use Illuminate\Foundation\Application;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\RedirectResponse;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\Auth;
use Illuminate\Http\Request;
class ProjectTodoController extends Controller
{
@ -19,13 +23,23 @@ class ProjectTodoController extends Controller
/**
* Display a listing of all Todos for a Project.
*/
public function index($project_id): Factory|Application|View|\Illuminate\Contracts\Foundation\Application|RedirectResponse
public function index(Request $request, $project_id):
Factory|Application|View|\Illuminate\Contracts\Foundation\Application|RedirectResponse|TodoResource
{
$user = User::find(auth()->user()->id);
$projects = $user->projects;
$user = Auth::user();
$projects = $user->projects();
$project = $projects->find($project_id);
if (!$project || $project->user->id !== auth()->user()->id)
if ($request->expectsJson()) {
$this->authorize('viewAny', [Todo::class, $project]);
$todos = $project->todos()->paginate(4);
return new TodoResource($todos);
}
if (!$project || $project->user->id !== $user->id)
return back()
->with('error', 'Project not found');
@ -43,7 +57,7 @@ class ProjectTodoController extends Controller
*/
public function create($project_id): Factory|View|Application
{
$project = auth()->user()->projects->find($project_id);
$project = Auth::user()->projects->find($project_id);
return view('todo.create', [
'project' => $project,
]);
@ -52,33 +66,55 @@ class ProjectTodoController extends Controller
/**
* Store a newly created Todo in storage.
*/
public function store($project_id, StoreTodoRequest $request): RedirectResponse
public function store($project_id, StoreTodoRequest $request): RedirectResponse|JsonResponse
{
$user = User::find(auth()->user()->id);
$validatedData = $request->validated();
$user = Auth::user();
$project = $user->projects->find($project_id);
$this->authorize('create', [Todo::class, $user]);
$validatedData = $request->validated();
$project = $user->projects->find($project_id);
// Add the Todo to the Project
$project->todos()->save(new Todo($validatedData));
if ($request->expectsJson()) {
return response()->json([
'message' => 'Todo created successfully.',
'data' => $project->todos()->latest()->first(),
], 201);
}
return redirect()->route('project.todo.index', $project_id)
->with('success', 'Todo created successfully.');
}
/**
* Display the specified resource.
*/
public function show($project_id, Todo $todo)
public function show(Request $request, $project_id)
{
$user = User::find(auth()->user()->id);
$projects = $user->projects;
$project = $projects->find($project_id);
if ($request->expectsJson()) {
$user = Auth::user();
$project = $user->projects->find($project_id);
$todo = $project->todos->find($request->todo_id);
$this->authorize('view', [Todo::class, $project, $todo]);
return view('todo.show', compact('project', 'todo'));
return response()->json([
'message' => 'Todo retrieved successfully.',
'data' => $todo,
], 200);
}
return redirect()->route('project.todo.index', $project_id);
// $user = Auth::user();
// $projects = $user->projects;
// $project = $projects->find($project_id);
//
// $this->authorize('view', [Todo::class, $project, $project->todos->find($request->todo_id)]);
//
// return view('todo.show', compact('project', 'todo'));
}
/**
@ -99,37 +135,49 @@ class ProjectTodoController extends Controller
public function update($project_id, UpdateTodoRequest $request, Todo $todo)
{
$project = auth()->user()->projects->find($project_id);
$this->authorize('update', [Todo::class, $project, $todo]);
// Update other fields
$todo->fill($request->validated());
$todo->due_start = $request->due_start ?
strtotime(Carbon::parse($request->due_start)) :
($todo->due_start ?: null);
$dueStart = $request->due_start ? strtotime(Carbon::parse($request->due_start)) : null;
$dueEnd = $request->due_end ? strtotime(Carbon::parse($request->due_end)) : null;
$todo->due_end = $request->due_end ?
strtotime(Carbon::parse($request->due_end)) :
($todo->due_end ?:
($todo->due_start ? strtotime(Carbon::parse($todo->due_start)) : null));
if ($dueEnd === null && $dueStart !== null) {
$dueEnd = strtotime(Carbon::parse($todo->due_start));
}
$todo->due_start = $dueStart;
$todo->due_end = $dueEnd;
$todo->save();
return back()
->with('success', 'Todo updated successfully');
if ($request->expectsJson()) {
return response()->json([
'message' => 'Todo updated successfully',
'data' => $todo,
], 200);
}
return back()->with('success', 'Todo updated successfully');
}
/**
* Remove the specified resource from storage.
*/
public function destroy($project_id, Todo $todo): RedirectResponse
public function destroy($project_id, Request $request, Todo $todo): RedirectResponse|JsonResponse
{
$this->authorize('delete', [Todo::class, $todo]);
$todo->delete();
if (request()->expectsJson()) {
return response()->json([
'message' => 'Todo deleted successfully',
], 200);
}
return redirect()->route('project.todo.index', $project_id)
->with('success', 'Todo deleted successfully');
}

View File

@ -16,6 +16,9 @@ class ProjectResource extends ResourceCollection
{
return [
'data' => $this->collection,
'links' => [
'self' => 'link-value',
],
];
}
}

View File

@ -0,0 +1,24 @@
<?php
namespace App\Http\Resources;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\ResourceCollection;
class TodoResource extends ResourceCollection
{
/**
* Transform the resource collection into an array.
*
* @return array<int|string, mixed>
*/
public function toArray(Request $request): array
{
return [
'data' => $this->collection,
'links' => [
'self' => 'link-value',
],
];
}
}

View File

@ -13,7 +13,7 @@ class ProjectPolicy
*/
public function viewAny(User $user): bool
{
//
return true;
}
/**

View File

@ -14,7 +14,7 @@ class TodoPolicy
*/
public function viewAny(User $user): bool
{
return false;
return true;
}
/**

View File

@ -46,7 +46,7 @@ return [
|
*/
'expiration' => null,
'expiration' => 525600,
/*
|--------------------------------------------------------------------------

View File

@ -1,6 +1,7 @@
@foreach($projects as $project)
<div class="relative">
<a href="{{ route('project.todo.index', $project) }}" class="card-link">
<a href="{{ route('project.todo.index', $project->id) }}"
class="card-link">
<div
class="bg-white dark:bg-gray-800 shadow-sm rounded-lg p-6 hover:shadow-md transition duration-300 ease-in-out transform hover:-translate-y-1">
<div class="text-gray-800 dark:text-gray-100">

View File

@ -77,7 +77,7 @@
<!-- Completed Todos Section -->
<div class="bg-white dark:bg-gray-800 shadow-sm rounded-lg">
<h2 class="text-2xl font-semibold mb-4 text-green-500 dark:text-green-400 px-6 pt-6">
Completed Today ({{ $completed->count() }})
Completed ({{ $completed->count() }})
</h2>
<div class="space-y-4">
@foreach ($completed as $todo)
@ -140,8 +140,16 @@
<p class="text-sm text-green-600">{{ $timeRemaining }} ago</p>
@endif
@endif
<!-- Completed at time -->
@if ($todo->completed_at)
<!-- Print at the right side of the div -->
<p class="text-sm text-gray-600 dark:text-gray-400 absolute top-0 right-0 mr-6">
Completed {{ \Carbon\Carbon::parse($todo->completed_at)->diffForHumans() }}
</p>
</div>
@endif
</a>
@endforeach
</div>

View File

@ -2,6 +2,7 @@
use App\Http\Controllers\ProjectController;
use App\Http\Controllers\Auth\ApiAuthController;
use App\Http\Controllers\ProjectTodoController;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Route;
@ -20,13 +21,15 @@ Route::middleware('auth:sanctum')->get('/user', function (Request $request) {
return $request->user();
});
// Auth Group to ApiAuthController
Route::post('/register', [ApiAuthController::class, 'register']);
Route::post('/login', [ApiAuthController::class, 'login']);
// Resources route to /api/projects
Route::middleware('auth:sanctum')->group( function () {
Route::post('/logout', [ApiAuthController::class, 'logout']);
Route::get('/me', [ApiAuthController::class, 'me']);
Route::apiResource('projects', ProjectController::class);
Route::apiResource('project', ProjectController::class);
Route::apiResource('project.todo', ProjectTodoController::class);
});

View File

@ -0,0 +1,85 @@
<?php
namespace Tests\Feature\API;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Foundation\Testing\WithFaker;
use Tests\TestCase;
class AuthTest extends TestCase
{
use RefreshDatabase;
protected array $user = [
'name' => 'Test User',
'email' => 'test@mail.com',
'password' => 'password1234',
];
protected array $auth_struct = [
'access_token',
'token_type',
];
protected string $registerRoute = '/api/register';
protected string $loginRoute = '/api/login';
protected string $meRoute = '/api/me';
protected function setUp(): void
{
parent::setUp();
$this->withHeaders([
'Accept' => 'application/json',
'Content-Type' => 'application/json',
]);
}
protected function registerAndLoginUser(): string
{
$this->postJson($this->registerRoute, $this->user);
$response = $this->postJson($this->loginRoute, [
'email' => $this->user['email'],
'password' => $this->user['password'],
]);
return $response['access_token'];
}
public function test_user_can_register_with_api(): void
{
$response = $this->postJson($this->registerRoute, $this->user);
$response->assertCreated()->assertJsonStructure($this->auth_struct);
}
public function test_user_can_authenticate_with_api(): void
{
$this->test_user_can_register_with_api();
$response = $this->postJson($this->loginRoute, [
'email' => $this->user['email'],
'password' => $this->user['password'],
]);
$response->assertOk()->assertJsonStructure($this->auth_struct);
}
public function test_user_can_view_me(): void
{
$token = $this->registerAndLoginUser();
$response = $this->withHeaders([
'Authorization' => 'Bearer ' . $token,
])->getJson($this->meRoute);
$response->assertOk()->assertJsonStructure([
'id',
'name',
'email',
'email_verified_at',
'created_at',
'updated_at',
]);
}
}

View File

@ -0,0 +1,133 @@
<?php
namespace Tests\Feature\API;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Foundation\Testing\WithFaker;
use Tests\TestCase;
class ProjectTest extends TestCase
{
use RefreshDatabase;
protected array $user = [
'name' => 'Test User',
'email' => 'test@test.com',
'password' => 'password1234'
];
private string $project_route = '/api/project';
private $access_token;
private $user_id;
private $project_id;
protected function setUp(): void
{
parent::setUp();
// Register user
$this->registerUser();
// Authenticate user
$this->authenticateUser();
}
protected function registerUser(): void
{
$response = $this->postJson('/api/register', $this->user);
$response->assertCreated();
}
protected function authenticateUser(): void
{
$response = $this->postJson('/api/login', [
'email' => $this->user['email'],
'password' => $this->user['password'],
]);
$response->assertOk();
$this->access_token = $response['access_token'];
$response = $this->withHeaders([
'Authorization' => 'Bearer ' . $this->access_token,
])->getJson('/api/me');
$response->assertOk();
$this->user_id = $response['id'];
}
public function test_user_can_view_all_projects(): void
{
$response = $this->getJson($this->project_route);
$response->assertOk()
->assertJsonStructure([
'data' => [
'*' => $this->getProjectJsonStructure(),
],
'links' => [
'first',
'last',
'prev',
'next',
],
]);
}
public function test_user_can_create_project(): void
{
$response = $this->postJson($this->project_route, [
'name' => 'Test Project',
'description' => 'Test Description',
]);
$response->assertCreated()
->assertJsonStructure([
'data' => $this->getProjectJsonStructure(),
]);
$this->assertDatabaseHas('projects', [
'name' => 'Test Project',
'description' => 'Test Description',
]);
$this->assertDatabaseHas('project_user', [
'user_id' => $this->user_id,
'project_id' => $response['data']['id'],
]);
$this->project_id = $response['data']['id'];
}
public function test_user_can_get_created_project_from_api(): void
{
$this->test_user_can_create_project();
$response = $this->getJson($this->project_route . '/' . $this->project_id);
$response->assertOk();
}
public function test_user_can_delete_created_project_from_api(): void
{
$this->test_user_can_create_project();
$response = $this->deleteJson($this->project_route . '/' . $this->project_id);
$response->assertOk();
}
protected function getProjectJsonStructure()
{
return [
'id',
'name',
'description',
'created_at',
'updated_at',
];
}
}

View File

@ -0,0 +1,260 @@
<?php
namespace Tests\Feature\API;
use App\Models\Project;
use App\Models\Todo;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Foundation\Testing\WithFaker;
use Tests\TestCase;
class TodoTest extends TestCase
{
use RefreshDatabase;
protected array $user = [
'name' => 'Test User',
'email' => 'test@test.com',
'password' => 'password1234'
];
private string $project_route = '/api/project/';
private $access_token;
private $user_id;
private $project_id;
private $todo_id;
protected function setUp(): void
{
parent::setUp();
// Register user
$this->registerUser();
// Authenticate user
$this->authenticateUser();
}
protected function registerUser(): void
{
$response = $this->postJson('/api/register', $this->user);
$response->assertCreated();
}
protected function authenticateUser(): void
{
$response = $this->postJson('/api/login', [
'email' => $this->user['email'],
'password' => $this->user['password'],
]);
$response->assertOk();
$this->access_token = $response['access_token'];
$response = $this->withHeaders([
'Authorization' => 'Bearer ' . $this->access_token,
])->getJson('/api/me');
$response->assertOk();
$this->user_id = $response['id'];
}
public function create_project_and_todo(): void
{
$this->actingAs(User::find($this->user_id));
// Create a project through POST and store it in the project property
$response = $this->post(route('project.store'), [
'name' => 'Test Project',
'description' => 'Test Description',
]);
$response->assertRedirect(route('project.index'));
$this->assertDatabaseHas('projects', [
'name' => 'Test Project',
'description' => 'Test Description',
]);
$this->assertDatabaseHas('project_user', [
'project_id' => Project::where('name', 'Test Project')->first()->id,
'user_id' => $this->user_id,
]);
$this->project_id = Project::where('name', 'Test Project')->first()->id;
$response = $this->post(route('project.todo.store', $this->project_id), [
'title' => 'Test Todo',
'description' => 'Test Description',
]);
$response->assertRedirect(route('project.todo.index', $this->project_id));
$this->todo_id = Todo::where('title', 'Test Todo')->first()->id;
$this->assertDatabaseHas('todos', [
'title' => 'Test Todo',
'description' => 'Test Description',
]);
$this->assertDatabaseHas('project_todo', [
'project_id' => $this->project_id,
'todo_id' => $this->todo_id,
]);
}
public function test_user_can_see_todo_of_project(): void
{
$this->create_project_and_todo();
$response = $this->getJson($this->project_route . $this->project_id . '/todo');
$response->assertOk()
->assertJsonStructure([
'data' => [
'*' => [
'id',
'title',
'description',
'created_at',
'updated_at',
],
],
'links' => [
'first',
'last',
'prev',
'next',
],
]);
}
public function test_user_can_create_todo(): void
{
$this->create_project_and_todo();
$response = $this->postJson($this->project_route . $this->project_id . '/todo', [
'title' => 'Test Todo',
'description' => 'Test Description',
]);
$response->assertCreated()
->assertJsonStructure([
'data' => [
'id',
'title',
'description',
'created_at',
'updated_at',
],
]);
$this->assertDatabaseHas('todos', [
'title' => 'Test Todo',
'description' => 'Test Description',
]);
$this->assertDatabaseHas('project_todo', [
'project_id' => $this->project_id,
'todo_id' => $response['data']['id'],
]);
}
public function test_user_can_edit_todo(): void
{
$this->create_project_and_todo();
$response = $this->putJson($this->project_route . $this->project_id . '/todo/' . $this->todo_id, [
'title' => 'Updated Todo',
'description' => 'Updated Description',
]);
$response->assertOk()
->assertJsonStructure([
'data' => [
'id',
'title',
'description',
'created_at',
'updated_at',
],
]);
}
public function test_user_complete_todo()
{
$this->create_project_and_todo();
$response = $this->putJson($this->project_route . $this->project_id . '/todo/' . $this->todo_id,
[
'completed_at' => 'on',
]
);
$response->assertOk()
->assertJsonStructure([
'data' => [
'id',
'title',
'description',
'completed_at',
'created_at',
'updated_at',
],
]);
}
public function test_user_can_uncomplete_todo(): void
{
$this->create_project_and_todo();
$response = $this->putJson($this->project_route . $this->project_id . '/todo/' . $this->todo_id,
[
'completed_at' => 'on',
]
);
$response->assertOk()
->assertJsonStructure([
'data' => [
'id',
'title',
'description',
'completed_at',
'created_at',
'updated_at',
],
]);
$response = $this->putJson($this->project_route . $this->project_id . '/todo/' . $this->todo_id,
[]
);
$response->assertOk()
->assertJsonStructure([
'data' => [
'id',
'title',
'description',
'completed_at',
'created_at',
'updated_at',
],
]);
// Check database for "completed_at" is null
$this->assertDatabaseHas('todos', [
'id' => $this->todo_id,
'completed_at' => null,
]);
}
public function test_user_can_delete_todo(): void
{
$this->create_project_and_todo();
$response = $this->deleteJson($this->project_route . $this->project_id . '/todo/' . $this->todo_id);
$response->assertOk();
}
}