Proyek sebelumnya membangun aplikasi web dengan Blade; kali ini kita melepas lapisan view sepenuhnya dan menyajikan data sebagai REST API murni berbasis JSON. Kita membangun backend manajemen tugas: klien mendaftar dan login untuk mendapat personal access token dari Sanctum , lalu memakai token tersebut untuk membuat, membaca, mengubah, dan menghapus tugas miliknya. Setiap input divalidasi sebagai JSON, daftar tugas bisa disaring dan dipaginasi, dan seluruh perilaku ini dibuktikan lewat feature test berbasis JSON. Proyek ini adalah konsolidasi materi section API Development, Laravel Sanctum, Validasi dan Form, dan Testing Laravel.
Fitur API #
REST API yang kita bangun menyediakan kemampuan berikut:
- registrasi akun baru yang langsung mengembalikan token pertama,
- login yang menukar email dan kata sandi dengan personal access token,
- logout yang mencabut token yang sedang dipakai tanpa mengganggu token lain,
- CRUD tugas penuh lewat endpoint
/api/tasksyang dilindungi middlewareauth:sanctum, - tugas selalu terikat pada pemiliknya — pengguna lain tidak pernah melihat tugas yang bukan miliknya,
- validasi input JSON dengan respons error 422 berformat konsisten,
- filter daftar tugas berdasarkan
statusdanpriority, - pagination dengan metadata halaman dan
per_pageyang bisa diatur klien, - seluruh alur utama terlindungi 21 feature test yang selalu hijau.
Tujuan Pembelajaran #
Setelah menyelesaikan proyek ini, kita mampu:
- menerjemahkan kebutuhan klien menjadi tabel endpoint, bentuk resource JSON, dan kontrak error sebelum menulis kode,
- mengimplementasikan autentikasi token stateless dengan Sanctum pada route API,
- memakai
apiResourceroute, API resource controller, dan Eloquent resource class secara bersamaan, - menegakkan validasi Form Request yang mengembalikan JSON 422, bukan redirect,
- membangun filter dan pagination yang aman dengan query scope dan
appends(), - menulis feature test
postJson()/assertJsonPath()untuk REST API.
Prasyarat #
Proyek ini memakai materi yang sudah dibahas pada section berikut:
- Routing dan Request
— route group dan prefix
api, - Controller — resource controller dan separation of concerns,
- Database dan Migrations — foreign key, factory, migrasi,
- Eloquent ORM — mass assignment, query scope, pagination,
- Laravel Sanctum
— personal access token dan middleware
auth:sanctum, - Validasi dan Form — Form Request dan rule validasi,
- Testing Laravel — feature test dan database testing.
Skema Database #
API ini hanya menambahkan satu tabel baru di sekitar tabel users bawaan. Sanctum menyimpan token pada tabelnya sendiri:
Satu pengguna (users) memiliki banyak tasks; relasi ini yang mendasari aturan bahwa query tugas selalu dibatasi milik pemiliknya. Tabel personal_access_tokens dibuat Sanctum untuk menyimpan hash token setiap kali createToken() dipanggil.
Tahapan Pengerjaan #
Proyek dikerjakan dalam enam bagian berurutan. Setiap bagian berdiri di atas hasil bagian sebelumnya:
- Bagian 1: Perancangan API — menetapkan resource, daftar endpoint, bentuk respons JSON, dan alur autentikasi token.
- Bagian 2: Autentikasi Token — membangun endpoint register, login, dan logout dengan Sanctum.
- Bagian 3: CRUD Tugas — membuat migration, model, resource class, dan API resource controller untuk tugas.
- Bagian 4: Validasi dan Error Handling — memvalidasi input JSON dan menstandarkan respons error.
- Bagian 5: Filtering dan Pagination — menyaring tugas berdasarkan status dan prioritas dengan pagination meta.
- Bagian 6: Pengujian API — membuktikan seluruh kontrak API lewat feature test JSON.
Menjalankan API #
Seluruh kode sumber proyek tersedia pada direktori app/, database/, routes/, dan tests/ di folder proyek ini — struktur foldernya mengikuti aplikasi Laravel sungguhan sehingga mudah dibandingkan dengan hasil kerja Anda. API memakai SQLite dan konfigurasi bawaan .env hasil instalasi Laravel; tidak ada layanan eksternal yang dibutuhkan.
Setelah struktur proyek siap, jalankan migration lalu nyalakan development server:
php artisan migrate
php artisan serveKarena registrasi dilakukan lewat API, tidak ada seeder akun. Daftarkan pengguna pertama dengan curl (ganti rahasia-kuat sesuai kebutuhan):
curl -X POST http://localhost:8000/api/register \
-H "Content-Type: application/json" \
-H "Accept: application/json" \
-d '{"name":"Budi","email":"[email protected]","password":"rahasia-kuat"}'Responsnya berisi token yang dipakai pada header Authorization untuk seluruh endpoint privat — contoh lengkapnya kita bahas di Bagian 2
:
curl http://localhost:8000/api/tasks \
-H "Authorization: Bearer YOUR_API_TOKEN" \
-H "Accept: application/json"Terakhir, pastikan seluruh kontrak API sehat dengan rangkaian test dari Bagian 6:
php artisan testMulailah dari Bagian 1 — di bagian itulah seluruh keputusan desain API diambil sebelum satu baris kode pun ditulis.
Rancang resource, endpoint, dan struktur respons API tugas.
Buka → # Bagian 2: Autentikasi TokenTerapkan personal access token Sanctum pada seluruh endpoint.
Buka → # Bagian 3: CRUD TugasBangun endpoint CRUD tugas dengan API resource controller dan resource class.
Buka → # Bagian 4: Validasi dan Error HandlingValidasi input JSON dan standarkan respons error.
Buka → # Bagian 5: Filtering dan PaginationSediakan filter status, prioritas, dan pagination meta.
Buka → # Bagian 6: Pengujian APIUji endpoint API dengan feature test berbasis JSON.
Buka →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
{
//
}