Autentikasi sudah berdiri; saatnya resource yang dilindunginya. Bagian ini adalah jantung proyek: satu migration mendefinisikan tabel tasks, satu model menyediakan API Eloquent-nya, satu route apiResource memetakan lima aksi CRUD, dan satu controller mengeksekusinya semua. Setiap respons sukses dilewatkan lewat TaskResource agar bentuk JSON-nya konsisten dengan rancangan Bagian 1.
Migration dan Model Tugas #
Kolom-kolom rancangan Bagian 1 diterjemahkan hampir satu banding satu menjadi migration:
Schema::create('tasks', function (Blueprint $table) {
$table->id();
$table->foreignId('user_id')->constrained()->cascadeOnDelete();
$table->string('title');
$table->text('description')->nullable();
$table->string('status')->default('pending');
$table->string('priority')->default('medium');
$table->date('due_date')->nullable();
$table->timestamps();
});foreignId('user_id')->constrained()->cascadeOnDelete() mengikat setiap tugas pada pemiliknya dan ikut menghapus tugas bila user dihapus. Nilai default pending dan medium membuat klien cukup mengirim judul. Kolom status dan priority sengaja tidak memakai enum database — daftar nilai sahnya dijaga validasi di Bagian 4, sehingga menambah nilai baru tidak menuntut perubahan skema. Hasil eksekusinya di sandbox:
php artisan migrate:freshOutput yang Dihasilkan:
Dropping all tables ............................................ 3.36ms DONE
INFO Preparing database.
Creating migration table ....................................... 2.29ms DONE
INFO Running migrations.
0001_01_01_000000_create_users_table ........................... 1.70ms DONE
0001_01_01_000001_create_cache_table ........................... 0.90ms DONE
0001_01_01_000002_create_jobs_table ............................ 1.51ms DONE
2026_10_13_153049_create_personal_access_tokens_table .......... 0.81ms DONE
2026_10_14_000001_create_tasks_table ........................... 1.12ms DONEModel App\Models\Task lalu menyimpan daftar nilai sah sebagai konstanta, mass assignment di $fillable, dan relasi miliknya:
class Task extends Model
{
/** @use HasFactory<TaskFactory> */
use HasFactory;
public const STATUSES = ['pending', 'in_progress', 'done'];
public const PRIORITIES = ['low', 'medium', 'high'];
protected $fillable = [
'title', 'description', 'status', 'priority', 'due_date',
];
protected function casts(): array
{
return ['due_date' => 'date'];
}
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
}Konstanta STATUSES dan PRIORITIES dipakai ulang oleh validasi di Bagian 4 dan filter di Bagian 5 — sumber kebenaran nilai sah hanya satu. Perhatikan juga bahwa user_id tidak ada di $fillable: kolom ini tidak boleh diisi klien lewat mass assignment, karena pemilik tugas ditetapkan dari token, bukan dari input JSON. Cast due_date menjadi date membuat Eloquent mengembalikan instance Carbon untuk kolom tersebut, yang kemudian dimanfaatkan TaskResource saat memformat keluaran.
Route apiResource #
Satu baris apiResource mendaftarkan lima aksi CRUD tanpa create dan edit — keduanya hanya relevan untuk form HTML, sedangkan klien kita menyusun sendiri antarmukanya dari data JSON. Nama route yang dihasilkan (tasks.index, tasks.store, dst.) tetap mengikuti pola resource controller web, jadi kebiasaan dari aplikasi blog sebelumnya tetap berlaku:
php artisan route:list --path=apiOutput yang Dihasilkan:
POST api/login .......................... AuthApiController@login
POST api/logout ........................ AuthApiController@logout
POST api/register .................... AuthApiController@register
GET|HEAD api/tasks ............... tasks.index › TaskController@index
POST api/tasks ............... tasks.store › TaskController@store
GET|HEAD api/tasks/{task} .......... tasks.show › TaskController@show
PUT|PATCH api/tasks/{task} ...... tasks.update › TaskController@update
DELETE api/tasks/{task} .... tasks.destroy › TaskController@destroy
Showing [8] routesController dan Resource Class #
store() mengambil data tervalidasi, menempelkan user dari token, lalu membungkus hasilnya dengan TaskResource berstatus 201:
public function store(StoreTaskRequest $request): JsonResponse
{
$data = array_merge([
'status' => 'pending',
'priority' => 'medium',
], $request->validated());
$task = $request->user()->tasks()->create($data);
return (new TaskResource($task))
->response()
->setStatusCode(201);
}Relasi $request->user()->tasks() mengisi user_id otomatis dari token — klien tidak pernah (dan tidak boleh) mengirim pemilik tugas. Respons nyatanya:
POST /api/tasks {"title":"Belajar Sanctum","description":"Membaca dokumentasi API authentication.","due_date":"2026-01-20"}
HTTP 201
{
"data": {
"id": 1,
"title": "Belajar Sanctum",
"description": "Membaca dokumentasi API authentication.",
"status": "pending",
"priority": "medium",
"due_date": "2026-01-20",
"created_at": "2026-01-10T09:00:00+00:00",
"updated_at": "2026-01-10T09:00:00+00:00"
}
}Aksi show(), update(), dan destroy() memakai route model binding {task}, lalu menjaga kepemilikan dengan satu baris:
public function show(Request $request, Task $task): TaskResource
{
// 404, bukan 403, agar keberadaan tugas milik orang lain tidak bocor.
abort_unless($task->user_id === $request->user()->id, 404);
return new TaskResource($task);
}update() memanggil $task->update($request->validated()) lalu mengembalikan resource segar; destroy() menghapus lalu response()->noContent() untuk status 204 tanpa body. Kombinasi aksinya terlihat pada respons berikut — perhatikan Siti ditolak saat meminta tugas Budi:
GET /api/tasks/1 (token Siti — tugas milik Budi)
HTTP 404
{
"message": "Resource tidak ditemukan."
}
PUT /api/tasks/1 {"status":"done"} (token Budi)
HTTP 200
{
"data": {
"id": 1,
"title": "Belajar Sanctum",
"description": "Membaca dokumentasi API authentication.",
"status": "done",
"priority": "medium",
"due_date": "2026-01-20",
"created_at": "2026-01-10T09:00:00+00:00",
"updated_at": "2026-01-12T14:30:00+00:00"
}
}
DELETE /api/tasks/7 (token Budi)
HTTP 204
(tanpa body)Terakhir, TaskResource adalah alasan semua bentuk JSON di atas seragam. Kelas ini memetakan model ke array dengan tanggal terformat:
/** @mixin \App\Models\Task */
class TaskResource extends JsonResource
{
public function toArray(Request $request): array
{
return [
'id' => $this->id,
'title' => $this->title,
'description' => $this->description,
'status' => $this->status,
'priority' => $this->priority,
'due_date' => $this->due_date?->format('Y-m-d'),
'created_at' => $this->created_at->toIso8601String(),
'updated_at' => $this->updated_at->toIso8601String(),
];
}
}Operator ?-> pada due_date menjaga kolom nullable tetap menjadi null, bukan error. Seluruh resource otomatis dibungkus kunci data saat dirender — itulah asal pembungkus pada respons show dan store di atas. Bila suatu endpoint ingin respons tanpa pembungkus, JsonResource::withoutWrapping() dipanggil di AppServiceProvider; kita biarkan bawaannya karena konsistensi bentuk data memudahkan klien mem-parse semua endpoint dengan cara yang sama. Pola ini persis konvensi Eloquent resources
bawaan Laravel.
Route model binding pada {task} juga bekerja sama seperti di aplikasi web: ID yang tidak ditemukan memicu ModelNotFoundException yang Laravel ubah menjadi 404. Pemeriksaan abort_unless() tinggal menutup celah kedua — tugas yang ada tetapi bukan milik pemohon. CRUD sudah hidup, tetapi input di atas belum divalidasi — Bagian 4
memperkuat pintu masuknya.
Berkas Sumber
Berkas pendukung halaman ini dapat diunduh dan dijalankan langsung sesuai penjelasan di atas.
app/Http/Controllers/AuthApiController.php
Unduh berkas<?php
namespace App\Http\Controllers;
use App\Models\User;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Hash;
use Illuminate\Validation\ValidationException;
class AuthApiController extends Controller
{
/** Daftar akun baru lalu langsung terbitkan token pertama. */
public function register(Request $request): JsonResponse
{
$credentials = $request->validate([
'name' => ['required', 'string', 'max:255'],
'email' => ['required', 'string', 'email', 'max:255', 'unique:users,email'],
'password' => ['required', 'string', 'min:8'],
]);
$user = User::create([
'name' => $credentials['name'],
'email' => $credentials['email'],
'password' => Hash::make($credentials['password']),
]);
$token = $user->createToken('api')->plainTextToken;
return response()->json([
'user' => [
'id' => $user->id,
'name' => $user->name,
'email' => $user->email,
],
'token' => $token,
], 201);
}
/** Tukar email dan kata sandi dengan personal access token. */
public function login(Request $request): JsonResponse
{
$credentials = $request->validate([
'email' => ['required', 'string', 'email'],
'password' => ['required', 'string'],
]);
$user = User::where('email', $credentials['email'])->first();
if ($user === null || ! Hash::check($credentials['password'], $user->password)) {
throw ValidationException::withMessages([
'email' => 'Email atau kata sandi salah.',
]);
}
$token = $user->createToken('api')->plainTextToken;
return response()->json([
'user' => [
'id' => $user->id,
'name' => $user->name,
'email' => $user->email,
],
'token' => $token,
]);
}
/** Cabut token yang dipakai request ini; token lain milik pengguna tetap hidup. */
public function logout(Request $request): JsonResponse
{
$request->user()->currentAccessToken()->delete();
return response()->json([
'message' => 'Logout berhasil.',
]);
}
}
app/Http/Controllers/Controller.php
Unduh berkas<?php
namespace App\Http\Controllers;
abstract class Controller
{
//
}
app/Http/Controllers/TaskController.php
Unduh berkas<?php
namespace App\Http\Controllers;
use App\Http\Requests\StoreTaskRequest;
use App\Http\Requests\UpdateTaskRequest;
use App\Http\Resources\TaskCollection;
use App\Http\Resources\TaskResource;
use App\Models\Task;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Http\Response;
use Illuminate\Validation\Rule;
class TaskController extends Controller
{
/** Daftar tugas milik pengguna, dengan filter status/priority dan pagination. */
public function index(Request $request): TaskCollection
{
$filters = $request->validate([
'status' => ['nullable', Rule::in(Task::STATUSES)],
'priority' => ['nullable', Rule::in(Task::PRIORITIES)],
'per_page' => ['nullable', 'integer', 'min:1', 'max:100'],
]);
$tasks = $request->user()->tasks()
->when($filters['status'] ?? null, fn ($query, $status) => $query->status($status))
->when($filters['priority'] ?? null, fn ($query, $priority) => $query->priority($priority))
->latest('id')
->paginate($filters['per_page'] ?? 15)
->appends(array_filter($filters));
return new TaskCollection($tasks);
}
/** Simpan tugas baru milik pengguna yang terautentikasi. */
public function store(StoreTaskRequest $request): JsonResponse
{
$data = array_merge([
'status' => 'pending',
'priority' => 'medium',
], $request->validated());
$task = $request->user()->tasks()->create($data);
return (new TaskResource($task))
->response()
->setStatusCode(201);
}
/** Tampilkan satu tugas milik pengguna. */
public function show(Request $request, Task $task): TaskResource
{
// 404, bukan 403, agar keberadaan tugas milik orang lain tidak bocor.
abort_unless($task->user_id === $request->user()->id, 404);
return new TaskResource($task);
}
/** Ubah sebagian atau seluruh field tugas. */
public function update(UpdateTaskRequest $request, Task $task): TaskResource
{
abort_unless($task->user_id === $request->user()->id, 404);
$task->update($request->validated());
return new TaskResource($task->refresh());
}
/** Hapus tugas; respons 204 tanpa body. */
public function destroy(Request $request, Task $task): Response
{
abort_unless($task->user_id === $request->user()->id, 404);
$task->delete();
return response()->noContent();
}
}
app/Http/Requests/StoreTaskRequest.php
Unduh berkas<?php
namespace App\Http\Requests;
use App\Models\Task;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
class StoreTaskRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
public function rules(): array
{
return [
'title' => ['required', 'string', 'max:255'],
'description' => ['nullable', 'string'],
'status' => ['sometimes', Rule::in(Task::STATUSES)],
'priority' => ['sometimes', Rule::in(Task::PRIORITIES)],
'due_date' => ['nullable', 'date'],
];
}
public function messages(): array
{
return [
'title.required' => 'Judul tugas wajib diisi.',
'status.in' => 'Status harus salah satu dari: pending, in_progress, done.',
'priority.in' => 'Prioritas harus salah satu dari: low, medium, high.',
];
}
}
app/Http/Requests/UpdateTaskRequest.php
Unduh berkas<?php
namespace App\Http\Requests;
use App\Models\Task;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
class UpdateTaskRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
/** Semua field bersifat partial: hanya field yang dikirim yang divalidasi. */
public function rules(): array
{
return [
'title' => ['sometimes', 'required', 'string', 'max:255'],
'description' => ['sometimes', 'nullable', 'string'],
'status' => ['sometimes', Rule::in(Task::STATUSES)],
'priority' => ['sometimes', Rule::in(Task::PRIORITIES)],
'due_date' => ['sometimes', 'nullable', 'date'],
];
}
public function messages(): array
{
return [
'title.required' => 'Judul tugas wajib diisi.',
'status.in' => 'Status harus salah satu dari: pending, in_progress, done.',
'priority.in' => 'Prioritas harus salah satu dari: low, medium, high.',
];
}
}
app/Http/Resources/TaskCollection.php
Unduh berkas<?php
namespace App\Http\Resources;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\ResourceCollection;
/** @extends ResourceCollection<\App\Http\Resources\TaskResource> */
class TaskCollection extends ResourceCollection
{
/** Setiap item dikonversi lewat TaskResource. */
public $collects = TaskResource::class;
public function toArray(Request $request): array
{
return [
'data' => $this->collection,
];
}
/** Meta pagination ringkas: hanya field yang dipakai klien. */
public function paginationInformation(Request $request, array $paginated, array $default): array
{
return [
'links' => [
'first' => $paginated['first_page_url'],
'last' => $paginated['last_page_url'],
'next' => $paginated['next_page_url'],
'prev' => $paginated['prev_page_url'],
],
'meta' => [
'current_page' => $paginated['current_page'],
'from' => $paginated['from'],
'last_page' => $paginated['last_page'],
'per_page' => $paginated['per_page'],
'to' => $paginated['to'],
'total' => $paginated['total'],
],
];
}
}
app/Http/Resources/TaskResource.php
Unduh berkas<?php
namespace App\Http\Resources;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
/** @mixin \App\Models\Task */
class TaskResource extends JsonResource
{
public function toArray(Request $request): array
{
return [
'id' => $this->id,
'title' => $this->title,
'description' => $this->description,
'status' => $this->status,
'priority' => $this->priority,
'due_date' => $this->due_date?->format('Y-m-d'),
'created_at' => $this->created_at->toIso8601String(),
'updated_at' => $this->updated_at->toIso8601String(),
];
}
}
app/Models/Task.php
Unduh berkas<?php
namespace App\Models;
use Database\Factories\TaskFactory;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class Task extends Model
{
/** @use HasFactory<TaskFactory> */
use HasFactory;
/** Nilai status yang diizinkan kolom tasks.status. */
public const STATUSES = ['pending', 'in_progress', 'done'];
/** Nilai priority yang diizinkan kolom tasks.priority. */
public const PRIORITIES = ['low', 'medium', 'high'];
protected $fillable = [
'title',
'description',
'status',
'priority',
'due_date',
];
protected function casts(): array
{
return [
'due_date' => 'date',
];
}
/** Tugas selalu dimiliki satu pengguna. */
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
/** Batasi query pada tugas ber-status tertentu, mis. ->status('done'). */
public function scopeStatus(Builder $query, string $status): Builder
{
return $query->where('status', $status);
}
/** Batasi query pada tugas berprioritas tertentu, mis. ->priority('high'). */
public function scopePriority(Builder $query, string $priority): Builder
{
return $query->where('priority', $priority);
}
}
app/Models/User.php
Unduh berkas<?php
namespace App\Models;
// use Illuminate\Contracts\Auth\MustVerifyEmail;
use Database\Factories\UserFactory;
use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\Attributes\Hidden;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Foundation\Auth\User as Authenticatable;
use Illuminate\Notifications\Notifiable;
use Laravel\Sanctum\HasApiTokens;
#[Fillable(['name', 'email', 'password'])]
#[Hidden(['password', 'remember_token'])]
class User extends Authenticatable
{
/** @use HasFactory<UserFactory> */
use HasApiTokens, HasFactory, Notifiable;
/**
* Get the attributes that should be cast.
*
* @return array<string, string>
*/
protected function casts(): array
{
return [
'email_verified_at' => 'datetime',
'password' => 'hashed',
];
}
/** Semua tugas milik pengguna. */
public function tasks(): HasMany
{
return $this->hasMany(Task::class);
}
}
database/factories/TaskFactory.php
Unduh berkas<?php
namespace Database\Factories;
use App\Models\Task;
use App\Models\User;
use Illuminate\Database\Eloquent\Factories\Factory;
/** @extends Factory<Task> */
class TaskFactory extends Factory
{
public function definition(): array
{
return [
'user_id' => User::factory(),
'title' => 'Belajar Laravel',
'description' => 'Membaca dokumentasi dan berlatih.',
'status' => 'pending',
'priority' => 'medium',
'due_date' => null,
];
}
}
database/factories/UserFactory.php
Unduh berkas<?php
namespace Database\Factories;
use App\Models\User;
use Illuminate\Database\Eloquent\Factories\Factory;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Str;
/**
* @extends Factory<User>
*/
class UserFactory extends Factory
{
/**
* The current password being used by the factory.
*/
protected static ?string $password;
/**
* Define the model's default state.
*
* @return array<string, mixed>
*/
public function definition(): array
{
return [
'name' => fake()->name(),
'email' => fake()->unique()->safeEmail(),
'email_verified_at' => now(),
'password' => static::$password ??= Hash::make('password'),
'remember_token' => Str::random(10),
];
}
/**
* Indicate that the model's email address should be unverified.
*/
public function unverified(): static
{
return $this->state(fn (array $attributes) => [
'email_verified_at' => null,
]);
}
}
database/migrations/0001_01_01_000000_create_users_table.php
Unduh berkas<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('users', function (Blueprint $table) {
$table->id();
$table->string('name');
$table->string('email')->unique();
$table->timestamp('email_verified_at')->nullable();
$table->string('password');
$table->rememberToken();
$table->timestamps();
});
Schema::create('password_reset_tokens', function (Blueprint $table) {
$table->string('email')->primary();
$table->string('token');
$table->timestamp('created_at')->nullable();
});
Schema::create('sessions', function (Blueprint $table) {
$table->string('id')->primary();
$table->foreignId('user_id')->nullable()->index();
$table->string('ip_address', 45)->nullable();
$table->text('user_agent')->nullable();
$table->longText('payload');
$table->integer('last_activity')->index();
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('users');
Schema::dropIfExists('password_reset_tokens');
Schema::dropIfExists('sessions');
}
};
database/migrations/0001_01_01_000001_create_cache_table.php
Unduh berkas<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('cache', function (Blueprint $table) {
$table->string('key')->primary();
$table->mediumText('value');
$table->bigInteger('expiration')->index();
});
Schema::create('cache_locks', function (Blueprint $table) {
$table->string('key')->primary();
$table->string('owner');
$table->bigInteger('expiration')->index();
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('cache');
Schema::dropIfExists('cache_locks');
}
};
database/migrations/0001_01_01_000002_create_jobs_table.php
Unduh berkas<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('jobs', function (Blueprint $table) {
$table->id();
$table->string('queue')->index();
$table->longText('payload');
$table->unsignedSmallInteger('attempts');
$table->unsignedInteger('reserved_at')->nullable();
$table->unsignedInteger('available_at');
$table->unsignedInteger('created_at');
});
Schema::create('job_batches', function (Blueprint $table) {
$table->string('id')->primary();
$table->string('name');
$table->integer('total_jobs');
$table->integer('pending_jobs');
$table->integer('failed_jobs');
$table->longText('failed_job_ids');
$table->mediumText('options')->nullable();
$table->integer('cancelled_at')->nullable();
$table->integer('created_at');
$table->integer('finished_at')->nullable();
});
Schema::create('failed_jobs', function (Blueprint $table) {
$table->id();
$table->string('uuid')->unique();
$table->string('connection');
$table->string('queue');
$table->longText('payload');
$table->longText('exception');
$table->timestamp('failed_at')->useCurrent();
$table->index(['connection', 'queue', 'failed_at']);
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('jobs');
Schema::dropIfExists('job_batches');
Schema::dropIfExists('failed_jobs');
}
};
database/migrations/2026_10_13_153049_create_personal_access_tokens_table.php
Unduh berkas<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('personal_access_tokens', function (Blueprint $table) {
$table->id();
$table->morphs('tokenable');
$table->text('name');
$table->string('token', 64)->unique();
$table->text('abilities')->nullable();
$table->timestamp('last_used_at')->nullable();
$table->timestamp('expires_at')->nullable()->index();
$table->timestamps();
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('personal_access_tokens');
}
};
database/migrations/2026_10_14_000001_create_tasks_table.php
Unduh berkas<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('tasks', function (Blueprint $table) {
$table->id();
$table->foreignId('user_id')->constrained()->cascadeOnDelete();
$table->string('title');
$table->text('description')->nullable();
$table->string('status')->default('pending');
$table->string('priority')->default('medium');
$table->date('due_date')->nullable();
$table->timestamps();
});
}
public function down(): void
{
Schema::dropIfExists('tasks');
}
};
routes/api.php
Unduh berkas<?php
use App\Http\Controllers\AuthApiController;
use App\Http\Controllers\TaskController;
use Illuminate\Support\Facades\Route;
// Endpoint publik: mendaftar akun baru dan menukar kredensial dengan token.
Route::post('/register', [AuthApiController::class, 'register']);
Route::post('/login', [AuthApiController::class, 'login']);
// Endpoint privat: butuh header Authorization: Bearer <token> Sanctum.
Route::middleware('auth:sanctum')->group(function () {
Route::post('/logout', [AuthApiController::class, 'logout']);
Route::apiResource('tasks', TaskController::class);
});
tests/Feature/AuthApiTest.php
Unduh berkas<?php
namespace Tests\Feature;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
class AuthApiTest extends TestCase
{
use RefreshDatabase;
public function test_register_mengembalikan_token_dan_user_201(): void
{
$response = $this->postJson('/api/register', [
'name' => 'Budi',
'email' => '[email protected]',
'password' => 'rahasia-kuat',
]);
$response->assertStatus(201)
->assertJsonStructure([
'user' => ['id', 'name', 'email'],
'token',
]);
$this->assertDatabaseHas('users', [
'email' => '[email protected]',
]);
// Token yang diterbitkan harus bisa dipakai mengakses endpoint privat.
$token = $response->json('token');
$this->withToken($token)
->getJson('/api/tasks')
->assertStatus(200)
->assertJsonPath('meta.total', 0);
}
public function test_register_dengan_email_duplikat_mengembalikan_422(): void
{
User::factory()->create(['email' => '[email protected]']);
$this->postJson('/api/register', [
'name' => 'Budi Dua',
'email' => '[email protected]',
'password' => 'rahasia-kuat',
])->assertStatus(422)->assertJsonValidationErrors('email');
}
public function test_register_dengan_input_kosong_mengembalikan_422(): void
{
$this->postJson('/api/register', [])
->assertStatus(422)
->assertJsonValidationErrors(['name', 'email', 'password']);
}
public function test_login_mengembalikan_token_untuk_kredensial_benar(): void
{
$user = User::factory()->create([
'name' => 'Siti',
'email' => '[email protected]',
'password' => 'rahasia-kuat',
]);
$response = $this->postJson('/api/login', [
'email' => '[email protected]',
'password' => 'rahasia-kuat',
]);
$response->assertStatus(200)
->assertJsonPath('user.email', '[email protected]')
->assertJsonStructure(['user', 'token']);
$this->withToken($response->json('token'))
->getJson('/api/tasks')
->assertStatus(200);
}
public function test_login_dengan_kata_sandi_salah_mengembalikan_422(): void
{
User::factory()->create([
'email' => '[email protected]',
'password' => 'rahasia-kuat',
]);
$this->postJson('/api/login', [
'email' => '[email protected]',
'password' => 'kata-sandi-salah',
])->assertStatus(422)->assertJsonValidationErrors('email');
}
public function test_logout_mencabut_token_aktif(): void
{
$user = User::factory()->create();
$token = $user->createToken('api')->plainTextToken;
$this->withToken($token)
->postJson('/api/logout')
->assertStatus(200)
->assertJsonPath('message', 'Logout berhasil.');
$this->assertDatabaseCount('personal_access_tokens', 0);
// Token lama sudah dicabut: request berikutnya ditolak 401.
// Guard auth di-cache per request dalam satu proses; kita reset
// cache tersebut agar request kedua benar-benar memeriksa token.
$this->app->make('auth')->forgetGuards();
$this->withToken($token)
->getJson('/api/tasks')
->assertStatus(401);
}
public function test_akses_endpoint_privat_tanpa_token_mengembalikan_401(): void
{
$this->getJson('/api/tasks')->assertStatus(401);
}
}
tests/Feature/TaskApiTest.php
Unduh berkas<?php
namespace Tests\Feature;
use App\Models\Task;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
class TaskApiTest extends TestCase
{
use RefreshDatabase;
private User $user;
private string $token;
protected function setUp(): void
{
parent::setUp();
$this->user = User::factory()->create([
'name' => 'Budi',
'email' => '[email protected]',
]);
$this->token = $this->user->createToken('api')->plainTextToken;
}
private function actingAsApi(): self
{
return $this->withToken($this->token);
}
public function test_index_hanya_menampilkan_tugas_milik_sendiri(): void
{
Task::factory()->count(2)->create(['user_id' => $this->user->id]);
Task::factory()->create(); // tugas milik pengguna lain
$this->actingAsApi()
->getJson('/api/tasks')
->assertStatus(200)
->assertJsonCount(2, 'data')
->assertJsonPath('meta.total', 2);
}
public function test_semua_endpoint_tugas_menolak_tamu_dengan_401(): void
{
$task = Task::factory()->create(['user_id' => $this->user->id]);
$this->getJson('/api/tasks')->assertStatus(401);
$this->postJson('/api/tasks', [])->assertStatus(401);
$this->getJson("/api/tasks/{$task->id}")->assertStatus(401);
$this->putJson("/api/tasks/{$task->id}", [])->assertStatus(401);
$this->deleteJson("/api/tasks/{$task->id}")->assertStatus(401);
}
public function test_store_menyimpan_tugas_dengan_default_dan_mengembalikan_201(): void
{
$response = $this->actingAsApi()
->postJson('/api/tasks', [
'title' => 'Belajar Sanctum',
'description' => 'Membaca dokumentasi API authentication.',
'due_date' => '2026-01-20',
]);
$response->assertStatus(201)
->assertJsonPath('data.title', 'Belajar Sanctum')
->assertJsonPath('data.status', 'pending')
->assertJsonPath('data.priority', 'medium')
->assertJsonPath('data.due_date', '2026-01-20');
$this->assertDatabaseHas('tasks', [
'user_id' => $this->user->id,
'title' => 'Belajar Sanctum',
]);
}
public function test_store_dengan_status_dan_prioritas_eksplisit(): void
{
$this->actingAsApi()
->postJson('/api/tasks', [
'title' => 'Deploy ke production',
'status' => 'in_progress',
'priority' => 'high',
])
->assertStatus(201)
->assertJsonPath('data.status', 'in_progress')
->assertJsonPath('data.priority', 'high');
}
public function test_store_dengan_input_tidak_valid_mengembalikan_422(): void
{
$this->actingAsApi()
->postJson('/api/tasks', [
'title' => '',
'status' => 'selesai',
'priority' => 'tinggi',
'due_date' => 'bukan-tanggal',
])
->assertStatus(422)
->assertJsonValidationErrors(['title', 'status', 'priority', 'due_date']);
$this->assertDatabaseCount('tasks', 0);
}
public function test_show_mengembalikan_detail_tugas(): void
{
$task = Task::factory()->create([
'user_id' => $this->user->id,
'title' => 'Menulis laporan',
]);
$this->actingAsApi()
->getJson("/api/tasks/{$task->id}")
->assertStatus(200)
->assertJsonPath('data.title', 'Menulis laporan')
->assertJsonStructure([
'data' => [
'id', 'title', 'description', 'status',
'priority', 'due_date', 'created_at', 'updated_at',
],
]);
}
public function test_tugas_milik_pengguna_lain_mengembalikan_404(): void
{
$task = Task::factory()->create(); // milik pengguna lain
$this->actingAsApi()->getJson("/api/tasks/{$task->id}")->assertStatus(404);
$this->actingAsApi()
->putJson("/api/tasks/{$task->id}", ['title' => 'Perubahan'])
->assertStatus(404);
$this->actingAsApi()->deleteJson("/api/tasks/{$task->id}")->assertStatus(404);
}
public function test_tugas_yang_tidak_ada_mengembalikan_404(): void
{
$this->actingAsApi()->getJson('/api/tasks/999')->assertStatus(404);
}
public function test_update_mengubah_field_yang_dikirim_saja(): void
{
$task = Task::factory()->create([
'user_id' => $this->user->id,
'title' => 'Belajar Sanctum',
'priority' => 'medium',
]);
$this->actingAsApi()
->putJson("/api/tasks/{$task->id}", [
'status' => 'done',
'priority' => 'high',
])
->assertStatus(200)
->assertJsonPath('data.status', 'done')
->assertJsonPath('data.priority', 'high')
->assertJsonPath('data.title', 'Belajar Sanctum');
}
public function test_update_dengan_status_tidak_valid_mengembalikan_422(): void
{
$task = Task::factory()->create(['user_id' => $this->user->id]);
$this->actingAsApi()
->putJson("/api/tasks/{$task->id}", ['status' => 'batal'])
->assertStatus(422)
->assertJsonValidationErrors('status');
$this->assertDatabaseHas('tasks', ['id' => $task->id, 'status' => 'pending']);
}
public function test_destroy_menghapus_tugas_dan_mengembalikan_204(): void
{
$task = Task::factory()->create(['user_id' => $this->user->id]);
$this->actingAsApi()
->deleteJson("/api/tasks/{$task->id}")
->assertStatus(204)
->assertNoContent();
$this->assertDatabaseMissing('tasks', ['id' => $task->id]);
}
public function test_filter_status_dan_priority(): void
{
Task::factory()->create(['user_id' => $this->user->id, 'status' => 'pending', 'priority' => 'low']);
Task::factory()->create(['user_id' => $this->user->id, 'status' => 'in_progress', 'priority' => 'high']);
Task::factory()->create(['user_id' => $this->user->id, 'status' => 'done', 'priority' => 'high']);
$this->actingAsApi()
->getJson('/api/tasks?status=in_progress')
->assertJsonPath('meta.total', 1)
->assertJsonPath('data.0.status', 'in_progress');
$this->actingAsApi()
->getJson('/api/tasks?priority=high')
->assertJsonPath('meta.total', 2);
$this->actingAsApi()
->getJson('/api/tasks?status=done&priority=high')
->assertJsonPath('meta.total', 1)
->assertJsonPath('data.0.status', 'done')
->assertJsonPath('data.0.priority', 'high');
}
public function test_filter_dengan_nilai_tidak_valid_mengembalikan_422(): void
{
$this->actingAsApi()
->getJson('/api/tasks?status=unknown')
->assertStatus(422)
->assertJsonValidationErrors('status');
}
public function test_pagination_membagi_tugas_per_halaman(): void
{
Task::factory()->count(20)->create(['user_id' => $this->user->id]);
$response = $this->actingAsApi()
->getJson('/api/tasks?per_page=8&page=2')
->assertStatus(200)
->assertJsonCount(8, 'data')
->assertJsonPath('meta.current_page', 2)
->assertJsonPath('meta.last_page', 3)
->assertJsonPath('meta.per_page', 8)
->assertJsonPath('meta.total', 20)
->assertJsonPath('meta.from', 9)
->assertJsonPath('meta.to', 16);
$base = rtrim(config('app.url'), '/');
$this->assertSame(
"{$base}/api/tasks?per_page=8&page=3",
$response->json('links.next'),
);
$this->assertSame(
"{$base}/api/tasks?per_page=8&page=1",
$response->json('links.prev'),
);
}
}
tests/TestCase.php
Unduh berkas<?php
namespace Tests;
use Illuminate\Foundation\Testing\TestCase as BaseTestCase;
abstract class TestCase extends BaseTestCase
{
//
}