Di aplikasi web, validasi gagal berarti redirect kembali ke form beserta old input. Klien API tidak punya form maupun cookie — ia hanya bisa membaca status code dan body JSON. Bagian ini memindahkan seluruh validasi ke Form Request, lalu menstandarkan bentuk respons error agar klien cukup menulis satu penanganan galat untuk seluruh API.
Form Request untuk API #
Validasi store dan login di Bagian 2–3 memakai $request->validate() langsung di controller. Untuk tugas, kita naikkan kelasnya ke StoreTaskRequest agar aturan bisa dibaca dan diuji terpisah:
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.',
];
}
}Rule::in(Task::STATUSES) memakai konstanta dari model Bagian 3, jadi daftar nilai sah tidak pernah terpisah dari tempatnya. Rule sometimes pada status dan priority membuat kedua field opsional — bila dikirim harus sah, bila tidak maka controller mengisi default pending/medium. Method messages() mengganti pesan bawaan dengan bahasa Indonesia untuk field yang paling sering salah ketik. Method authorize() mengembalikan true karena otorisasi sudah ditangani middleware auth:sanctum — Form Request tidak perlu memeriksa ulang siapa pemanggilnya.
Bedakan dua rule yang mirip: nullable berarti “boleh kosong bila field dikirim”, sedangkan sometimes berarti “boleh tidak dikirim sama sekali”. Kolom description memakai nullable karena klien boleh mengirim "description": null; kolom status memakai sometimes karena klien boleh tidak menyebut kunci status sama sekali. Kombinasi keduanya di due_date — sometimes pada update dan nullable di kedua tempat — memberi ruang paling longgar bagi klien tanpa membuka celah nilai kotor.
UpdateTaskRequest bentuknya hampir sama dengan satu perbedahan penting: seluruh field memakai sometimes, termasuk judul:
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'],
];
}Semantiknya: request PUT yang hanya mengirim {"status": "done"} diterima sebagai partial update — field lain tidak divalidasi dan tidak diubah. Bila title ikut dikirim, ia tetap wajib tidak kosong karena required berlaku setelah sometimes terpenuhi.
Envelope Respons 422 #
Mari kita kirim input yang salah pada POST /api/tasks dan membaca respons nyatanya:
POST /api/tasks {"title":"","status":"selesai","priority":"urgent","due_date":"besok"}
HTTP 422
{
"message": "Judul tugas wajib diisi. (and 3 more errors)",
"errors": {
"title": [
"Judul tugas wajib diisi."
],
"status": [
"Status harus salah satu dari: pending, in_progress, done."
],
"priority": [
"Prioritas harus salah satu dari: low, medium, high."
],
"due_date": [
"The due date field must be a valid date."
]
}
}Inilah envelope error kontrak kita: kunci message merangkum kesalahan pertama beserta jumlah totalnya, dan kunci errors memetakan setiap field bermasalah ke daftar pesannya. Klien cukup memeriksa errors.status[0] untuk menampilkan pesan di sisi antarmuka. Perhatikan pula bahwa Laravel memvalidasi semua field sekaligus, bukan berhenti di kesalahan pertama. Pesan due_date masih berbahasa Inggris karena method messages() sengaja hanya menimpa tiga pesan teratas — sisanya tetap memakai bawaan framework.
Kenapa hasilnya JSON, bukan redirect ke form? Laravel menentukan format gagal validasi dari request itu sendiri: request dengan header Accept: application/json — seperti semua klien API yang benar — membuat exception handler memilih respons JSON 422. Helper postJson() pada test di Bagian 6 memasang header tersebut secara otomatis, dan closure shouldRenderJsonWhen() di bawah memperkuat keputusan yang sama untuk seluruh path api/*.
Menstandarkan Error di bootstrap #
ValidationException otomatis menghasilkan JSON 422 pada route API, tetapi dua error lain perlu diarahkan. Laravel 11+ mengatur ini di bootstrap/app.php:
->withExceptions(function (Exceptions $exceptions): void {
$exceptions->shouldRenderJsonWhen(
fn (Request $request) => $request->is('api/*') || $request->expectsJson(),
);
// Envelope 404 konsisten untuk seluruh endpoint API.
$exceptions->render(function (NotFoundHttpException $e, Request $request) {
if ($request->is('api/*')) {
return response()->json([
'message' => 'Resource tidak ditemukan.',
], 404);
}
return null;
});
})shouldRenderJsonWhen() memastikan semua exception pada path api/* dirender sebagai JSON — termasuk halaman 404 yang biasanya berupa HTML. Closure render() lalu menimpa body 404 bawaan dengan envelope pesan kita sendiri, sedangkan return null membuat request non-API tetap memakai penanganan bawaan. Hasilnya, tiga status error utama kini selalu berbentuk sama:
GET /api/tasks/999 (token Budi — tugas tidak ada)
HTTP 404
{
"message": "Resource tidak ditemukan."
}
GET /api/tasks (tanpa token)
HTTP 401
{
"message": "Unauthenticated."
}Kontrak error tuntas — input bersih masuk, input kotor ditolak dengan bentuk yang bisa diprediksi. Bagian 5 melengkapi sisi sukses: filter dan pagination pada daftar tugas.
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
{
//
}