Membangun API tanpa rancangan ibarat menulis kontrak setelah rumah dibangun: klien dan server sama-sama menebak bentuk data. Bagian ini kita kerjakan tanpa menyentuh kode sama sekali — kita menetapkan resource apa yang disajikan, endpoint apa saja yang tersedia, bagaimana bentuk JSON-nya, dan bagaimana klien mengautentikasi dirinya. Keputusan-keputusan ini menjadi kontrak yang dipegang klien, jadi setiap perubahan di bagian selanjutnya harus tetap patuh pada rancangan di sini.
Menentukan Resource #
Satu-satunya resource inti proyek ini adalah task — tugas milik satu pengguna. Kita tetapkan kolom-kolomnya beserta aturannya sebelum membuat migration:
| Kolom | Tipe | Aturan |
|---|---|---|
id | bigint | primary key, diisi otomatis |
user_id | bigint | foreign key ke users, ikut terhapus bila pemiliknya dihapus |
title | string(255) | wajib diisi |
description | text | boleh kosong |
status | string | salah satu dari pending, in_progress, done; default pending |
priority | string | salah satu dari low, medium, high; default medium |
due_date | date | boleh kosong |
created_at / updated_at | timestamp | dikelola Eloquent |
Kolom status dan priority berisi kumpulan nilai tetap, tetapi kita simpan sebagai string biasa alih-alih enum database — nilai sahnya kita jaga lewat validasi di Bagian 4, bukan lewat skema. Pola ini menjaga migration tetap lentur saat daftar nilai berubah.
Daftar Endpoint #
Kebutuhan klien manajemen tugas kita terjemahkan menjadi delapan endpoint yang terbagi dua kelompok: menukar kredensial dengan token (register, login, logout), dan mengelola resource tasks (lima aksi CRUD):
| Method | Path | Autentikasi | Deskripsi | Sukses |
|---|---|---|---|---|
| POST | /api/register | publik | daftar akun baru, kirim token pertama | 201 |
| POST | /api/login | publik | tukar kredensial dengan token | 200 |
| POST | /api/logout | token | cabut token yang sedang dipakai | 200 |
| GET | /api/tasks | token | daftar tugas milik pengguna, bisa difilter | 200 |
| POST | /api/tasks | token | simpan tugas baru | 201 |
| GET | /api/tasks/{id} | token | detail satu tugas | 200 |
| PUT/PATCH | /api/tasks/{id} | token | ubah sebagian atau seluruh field | 200 |
| DELETE | /api/tasks/{id} | token | hapus tugas | 204 |
Pembagian ini mengikuti konvensi REST: URL menunjuk resource (/tasks), HTTP method menunjuk aksi, dan status code menunjuk hasil. Prefix /api otomatis diberikan Laravel pada route di routes/api.php. Method PUT dan PATCH kita perlakukan sama — keduanya masuk ke satu aksi update — karena keduanya idempoten: memanggilnya berkali-kali dengan input yang sama menghasilkan keadaan yang sama pula. Method DELETE juga idempoten; menghapus tugas yang sudah terhapus tetap dianggap selesai, bukan error.
Pemilihan status code sukses juga ikut konvensi: 201 Created untuk pembuatan (register dan store), 200 OK untuk operasi baca dan ubah, serta 204 No Content untuk penghapusan yang tidak punya apa pun untuk dikembalikan. Klien cukup memeriksa angka pertama status code untuk mengambil keputusan besar: 2xx sukses, 4xx kesalahan di sisi klien, 5xx kesalahan di sisi server.
Bentuk Respons JSON #
Detail satu tugas selalu punya bentuk yang sama di endpoint show, store, dan update — kita sebut bentuk ini sebagai bentuk resource:
{
"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"
}Tanggal diformat konsisten: due_date sebagai tanggal Y-m-d dan timestamp sebagai ISO-8601. Daftar tugas membungkus bentuk ini dalam envelope data, links, dan meta untuk pagination — bentuk lengkapnya kita lihat nyata di Bagian 5. Ketika terjadi kesalahan, klien menerima envelope error: kunci message berisi ringkasan, dan bila input tidak valid ditambah kunci errors berisi pesan per field. Tiga status error yang mungkin kita bagi per penyebab: 401 bila token hilang atau sudah dicabut, 404 bila resource tidak ditemukan atau bukan milik pemohon, dan 422 bila input validasinya gagal. Dengan pembagian ini, klien tahu persis kapan harus mengarahkan pengguna login ulang, kapan menampilkan pesan “tidak ada”, dan kapan menyorot field form yang salah.
Alur Autentikasi Token #
Endpoint privat tidak mengenal session cookie karena klien API bisa jadi aplikasi mobile atau layanan lain. Kita memakai personal access token
Sanctum: string acak yang dikirim klien pada header Authorization: Bearer <token>. Alur lengkapnya:
Server hanya menyimpan hash token, bukan token mentahnya, sehingga kebocoran database tidak serta-merta membocorkan kredensial klien. Kontrak kini lengkap — Bagian 2 mulai mewujudkannya dengan endpoint register, login, dan logout.
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
{
//
}