Bagian penutup ini membuktikan aplikasi tiga bagian sebelumnya memang bekerja, lalu membekukannya menjadi artefak siap bagikan. Pengujian disusun bertingkat — unit, integrasi, end-to-end — dan ditampung harness UjiAplikasiWeb berbasis method main dengan tiga puluh pemeriksaan PASS/FAIL serta exit code, karena lingkungan kursus tidak menyertakan JUnit maupun runtime Spring Boot. Setelah lulus, delapan belas berkas sumber dirakit menjadi aplikasi-web.jar.
Hierarki Pengujian Aplikasi Web #
Piramida pengujian menukar biaya dengan keyakinan. Pemeriksaan unit berjalan milidetik tanpa jaringan dan galatnya mudah dilokalkan karena ValidasiWeb, LayananTugasWeb, dan RepositoryTugasWebBerkas hanyalah kelas biasa; jumlahnya karenanya paling banyak. Integrasi menaikkan taruhan: HTTP sungguhan melintasi HttpClient dalam proses, FilterTokenWeb aktif, dan berkas benar-benar ditulis lalu dibaca ulang.
| Tingkat | Yang dibuktikan | Perangkat inti telanjang | Padanan Spring Boot |
|---|---|---|---|
| Unit | satu aturan, satu kelas | panggilan langsung dari main | JUnit 5, AssertJ |
| Integrasi | lapisan tersambung satu JVM | HttpServer port 0 + HttpClient | @SpringBootTest, MockMvc |
| End-to-end | perilaku utuh dari luar | sesi server kedua atas berkas sama | TestRestTemplate |
Harness Pengujian Bertingkat #
UjiAplikasiWeb menaikkan kedua tingkat dalam satu eksekusi. Tingkat 1 memanggil kelas tanpa soket: ValidasiWeb.periksa diperiksa daftar kembaliannya, LayananTugasWeb diuji dengan repository memori dan hari beku, RepositoryTugasWebBerkas diuji atas berkas sementara yang dihapus dulu. Tingkat 2 merakit server persis AplikasiWeb Bagian 3 lalu mengirim permintaan sekuensial dari satu HttpClient. Sesi server kedua di akhir menguji persistensi dari luar.
import com.sun.net.httpserver.HttpServer;
import java.io.IOException;
import java.net.InetSocketAddress;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.time.Duration;
import java.time.LocalDate;
import java.util.Base64;
import java.util.List;
/**
* Harness pengujian bertingkat aplikasi web manajemen tugas. Tingkat 1
* menguji unit tanpa jaringan: ValidasiWeb, LayananTugasWeb dengan repository
* memori, dan RepositoryTugasWebBerkas. Tingkat 2 menguji integrasi HTTP
* in-process: FilterTokenWeb membungkus RouterWeb di atas HttpServer yang
* mendengarkan 127.0.0.1 pada port pilihan sistem operasi (nomor port tidak
* dicetak agar keluaran deterministik), lalu satu HttpClient mengirim seluruh
* permintaan secara sekuensial. Exit code 1 bila ada kegagalan.
*/
public class UjiAplikasiWeb {
/** Hari operasional beku: aturan "tenggat lampau" diuji tanpa LocalDate.now(). */
private static final LocalDate HARI = LocalDate.of(2026, 1, 15);
private static int lulus = 0;
private static int gagal = 0;
public static void main(String[] args) throws Exception {
Path berkasRepo = Path.of("uji-repo-web.txt");
Path berkasIntegrasi = Path.of("uji-integrasi-web.txt");
Files.deleteIfExists(berkasRepo); // mulai bersih agar uji dapat diulang
Files.deleteIfExists(berkasIntegrasi);
System.out.println("== Tingkat 1: unit tanpa jaringan ==");
ujiValidasi();
ujiLayanan();
ujiRepositoryBerkas(berkasRepo);
System.out.println();
System.out.println("== Tingkat 2: integrasi HTTP dengan filter aktif ==");
HttpClient klien = HttpClient.newHttpClient();
HttpServer server = mulai(berkasIntegrasi);
try {
String dasar = dasar(server);
ujiAutentikasi(klien, dasar);
ujiCrud(klien, dasar);
} finally {
server.stop(0);
}
System.out.println();
System.out.println("== Tingkat 2 (lanjutan): server baru memuat berkas ==");
HttpServer serverBaru = mulai(berkasIntegrasi);
try {
ujiPersistensi(klien, dasar(serverBaru));
} finally {
serverBaru.stop(0);
}
System.out.println();
System.out.println("Ringkasan: " + lulus + " lulus, " + gagal + " gagal");
if (gagal > 0) {
System.exit(1);
}
}
/** Tingkat 1a: validasi bentuk field mengumpulkan galat, bukan berhenti di awal. */
private static void ujiValidasi() {
List<String> sah = ValidasiWeb.periksa(new PermintaanTugas(
"Belajar arsitektur berlapis", "Budi", "2026-02-01"));
periksa("validasi: muatan sah tidak memuat galat", sah.isEmpty());
List<String> judul = ValidasiWeb.periksa(new PermintaanTugas(
"ab", "Budi", "2026-02-01"));
periksa("validasi: judul terlalu pendek ditolak",
judul.size() == 1 && judul.get(0).startsWith("judul"));
List<String> pemilik = ValidasiWeb.periksa(new PermintaanTugas(
"Menulis dokumentasi API", "", "2026-02-01"));
periksa("validasi: pemilik kosong ditolak",
pemilik.size() == 1 && pemilik.get(0).startsWith("pemilik"));
List<String> tenggat = ValidasiWeb.periksa(new PermintaanTugas(
"Menulis dokumentasi API", "Budi", "besok"));
periksa("validasi: tenggat bukan format ISO ditolak",
tenggat.size() == 1 && tenggat.get(0).startsWith("tenggat"));
List<String> pipa = ValidasiWeb.periksa(new PermintaanTugas(
"Riset|keamanan", "Budi", "2026-02-01"));
periksa("validasi: karakter pipa ditolak",
pipa.size() == 1 && pipa.get(0).contains("|"));
List<String> semuaSalah = ValidasiWeb.periksa(new PermintaanTugas(
"ab", "", "besok"));
periksa("validasi: tiga galat terkumpul sekaligus", semuaSalah.size() == 3);
}
/** Tingkat 1b: aturan bisnis layanan dengan repository memori dan hari beku. */
private static void ujiLayanan() {
LayananTugasWeb layanan = new LayananTugasWeb(
new RepositoryTugasWebMemori(), HARI);
TugasWeb tugas = layanan.buat(new PermintaanTugas(
"Belajar arsitektur berlapis", "Budi", "2026-02-01"));
periksa("layanan: buat menetapkan status TODO",
tugas.status() == StatusTugasWeb.TODO);
periksa("layanan: buat menetapkan id mulai 1", tugas.id() == 1);
periksa("layanan: tenggat lampau ditolak status 400",
menolak(() -> layanan.buat(new PermintaanTugas(
"Riset keamanan", "Dewi", "2026-01-01")),
ValidasiGagalException.class, 400));
TugasWeb selesai = layanan.tandaiSelesai(1);
periksa("layanan: tandaiSelesai memindahkan status ke SELESAI",
selesai.status() == StatusTugasWeb.SELESAI);
periksa("layanan: tandaiSelesai kedua kali ditolak status 409",
menolak(() -> layanan.tandaiSelesai(1),
AturanBisnisException.class, 409));
TugasWeb revisi = layanan.ubah(1, new PermintaanTugas(
"Belajar arsitektur berlapis (revisi)", "Budi", "2026-02-08"));
periksa("layanan: ubah mempertahankan status dan id",
revisi.status() == StatusTugasWeb.SELESAI && revisi.id() == 1);
periksa("layanan: cari id tidak ada melempar status 404",
menolak(() -> layanan.cari(99),
TugasTidakDitemukanException.class, 404));
LayananTugasWeb kuota = new LayananTugasWeb(
new RepositoryTugasWebMemori(), HARI);
for (int i = 1; i <= LayananTugasWeb.BATAS_TUGAS_AKTIF; i++) {
kuota.buat(new PermintaanTugas("Tugas kantor " + i, "Dewi", "2026-03-02"));
}
periksa("layanan: tugas keenam pemilik sama ditolak status 409",
menolak(() -> kuota.buat(new PermintaanTugas(
"Tugas kantor 6", "Dewi", "2026-03-02")),
AturanBisnisException.class, 409));
}
/** Tingkat 1c: repository berkas menulis, memuat kembali, dan meneruskan id. */
private static void ujiRepositoryBerkas(Path berkas) throws IOException {
RepositoryTugasWebBerkas repository = new RepositoryTugasWebBerkas(berkas);
repository.simpanBaru(new TugasWeb(0, "Belajar arsitektur berlapis", "Budi",
StatusTugasWeb.TODO, LocalDate.of(2026, 2, 1)));
periksa("repository: mutasi menulis berkas", Files.exists(berkas));
periksa("repository: baris berkas berformat pipa",
Files.readAllLines(berkas, StandardCharsets.UTF_8).get(0)
.equals("1|Belajar arsitektur berlapis|Budi|TODO|2026-02-01"));
RepositoryTugasWebBerkas termuat = new RepositoryTugasWebBerkas(berkas);
periksa("repository: berkas dimuat kembali",
termuat.jumlahTermuat() == 1 && termuat.cari(1).isPresent());
periksa("repository: id berlanjut dari tertinggi",
termuat.simpanBaru(new TugasWeb(0, "Riset keamanan aplikasi web",
"Dewi", StatusTugasWeb.TODO, LocalDate.of(2026, 1, 25))).id() == 2);
}
/** Tingkat 2a: gerbang keamanan menolak permintaan tanpa identitas sah. */
private static void ujiAutentikasi(HttpClient klien, String dasar)
throws IOException, InterruptedException {
HttpResponse<String> tanpa = minta(klien, dasar, null, "GET", "/api/tugas", null);
periksa("integrasi: tanpa kredensial dijawab 401", tanpa.statusCode() == 401);
periksa("integrasi: penolakan memuat WWW-Authenticate",
tanpa.headers().firstValue("WWW-Authenticate").isPresent());
HttpResponse<String> salah = minta(klien, dasar,
basic("budi", "sandi-salah"), "GET", "/api/tugas", null);
periksa("integrasi: sandi salah dijawab 401 dengan body JSON",
salah.statusCode() == 401 && salah.body().contains("\"status\":401"));
}
/** Tingkat 2b: CRUD, validasi, dan otorisasi melalui HTTP sungguhan. */
private static void ujiCrud(HttpClient klien, String dasar)
throws IOException, InterruptedException {
String budi = basic("budi", "sandi-budi");
String siti = basic("siti", "sandi-siti");
HttpResponse<String> galat = minta(klien, dasar, budi, "POST", "/api/tugas",
"{\"judul\":\"ab\",\"pemilik\":\"\",\"tenggat\":\"besok\"}");
periksa("integrasi: muatan salah dijawab 400 dengan rincian",
galat.statusCode() == 400 && galat.body().contains("\"galat\":["));
HttpResponse<String> dibuat = minta(klien, dasar, budi, "POST", "/api/tugas",
"{\"judul\":\"Belajar arsitektur berlapis\",\"pemilik\":\"Budi\","
+ "\"tenggat\":\"2026-02-01\"}");
periksa("integrasi: POST sah dijawab 201 dengan Location",
dibuat.statusCode() == 201
&& dibuat.headers().firstValue("Location").orElse("")
.equals("/api/tugas/1"));
minta(klien, dasar, budi, "POST", "/api/tugas",
"{\"judul\":\"Riset keamanan aplikasi web\",\"pemilik\":\"Dewi\","
+ "\"tenggat\":\"2026-01-25\"}");
HttpResponse<String> daftar = minta(klien, dasar, budi, "GET", "/api/tugas", null);
periksa("integrasi: GET daftar menampilkan dua tugas",
daftar.statusCode() == 200 && daftar.body().contains("\"id\":2"));
HttpResponse<String> hilang = minta(klien, dasar, budi, "GET", "/api/tugas/99", null);
periksa("integrasi: id tidak ada dijawab 404", hilang.statusCode() == 404);
HttpResponse<String> asing = minta(klien, dasar, budi, "PATCH", "/api/tugas/1", null);
periksa("integrasi: metode asing dijawab 405", asing.statusCode() == 405);
HttpResponse<String> terlarang = minta(klien, dasar, budi,
"DELETE", "/api/tugas/1", null);
periksa("integrasi: DELETE oleh ANGGOTA dijawab 403",
terlarang.statusCode() == 403);
HttpResponse<String> terhapus = minta(klien, dasar, siti,
"DELETE", "/api/tugas/1", null);
periksa("integrasi: DELETE oleh ADMIN dijawab 204",
terhapus.statusCode() == 204);
}
/** Tingkat 2c: server baru atas berkas yang sama membuktikan persistensi. */
private static void ujiPersistensi(HttpClient klien, String dasar)
throws IOException, InterruptedException {
String siti = basic("siti", "sandi-siti");
HttpResponse<String> daftar = minta(klien, dasar, siti, "GET", "/api/tugas", null);
periksa("persistensi: tugas tersisa termuat setelah restart",
daftar.statusCode() == 200 && daftar.body().contains("\"id\":2")
&& !daftar.body().contains("\"id\":1"));
HttpResponse<String> lanjut = minta(klien, dasar, siti, "POST", "/api/tugas",
"{\"judul\":\"Menulis dokumentasi API\",\"pemilik\":\"Siti\","
+ "\"tenggat\":\"2026-01-20\"}");
periksa("persistensi: id baru berlanjut setelah restart",
lanjut.statusCode() == 201
&& lanjut.headers().firstValue("Location").orElse("")
.equals("/api/tugas/3"));
}
/** Server uji: filter keamanan membungkus router di atas repository berkas. */
private static HttpServer mulai(Path berkas) throws IOException {
LayananTugasWeb layanan = new LayananTugasWeb(
new RepositoryTugasWebBerkas(berkas), HARI);
HttpServer server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0);
server.createContext("/", new FilterTokenWeb(new RouterWeb(layanan),
new PenggunaWeb("budi", "sandi-budi", PenggunaWeb.Peran.ANGGOTA),
new PenggunaWeb("siti", "sandi-siti", PenggunaWeb.Peran.ADMIN)));
server.start();
return server;
}
/** Alamat dasar server; nomor port tidak dicetak agar keluaran deterministik. */
private static String dasar(HttpServer server) {
return "http://127.0.0.1:" + server.getAddress().getPort();
}
/** Header Authorization Basic dari pasangan nama dan sandi. */
private static String basic(String nama, String sandi) {
byte[] oktet = (nama + ":" + sandi).getBytes(StandardCharsets.UTF_8);
return "Basic " + Base64.getEncoder().encodeToString(oktet);
}
/**
* Mengirim satu permintaan; authorization null berarti tanpa header
* Authorization sama sekali.
*/
private static HttpResponse<String> minta(HttpClient klien, String dasar,
String authorization, String metode, String path, String body)
throws IOException, InterruptedException {
HttpRequest.Builder b = HttpRequest.newBuilder()
.uri(URI.create(dasar + path))
.timeout(Duration.ofSeconds(5));
if (authorization != null) {
b.header("Authorization", authorization);
}
if (body == null) {
b.method(metode, HttpRequest.BodyPublishers.noBody());
} else {
b.header("Content-Type", "application/json")
.method(metode, HttpRequest.BodyPublishers.ofString(
body, StandardCharsets.UTF_8));
}
return klien.send(b.build(), HttpResponse.BodyHandlers.ofString());
}
/** Satu pernyataan uji: catat PASS bila kondisi benar, FAIL bila salah. */
private static void periksa(String nama, boolean kondisi) {
if (kondisi) {
lulus++;
System.out.println("PASS: " + nama);
} else {
gagal++;
System.out.println("FAIL: " + nama);
}
}
/**
* Menjalankan perintah; true bila melempar exception bertepatan jenis dan
* status. Perbandingan getClass() menolak exception salah jenis, dan status
* dibaca lewat ApiException sehingga angka HTTP ikut diperiksa.
*/
private static boolean menolak(Perintah perintah,
Class<? extends ApiException> jenis, int status) {
try {
perintah.jalankan();
return false;
} catch (Exception e) {
return e.getClass() == jenis && ((ApiException) e).status() == status;
}
}
/** Antarmuka satu method agar blok uji bisa melempar exception terperiksa. */
interface Perintah {
void jalankan() throws Exception;
}
}Output yang Dihasilkan:
== Tingkat 1: unit tanpa jaringan ==
PASS: validasi: muatan sah tidak memuat galat
PASS: validasi: judul terlalu pendek ditolak
PASS: validasi: pemilik kosong ditolak
PASS: validasi: tenggat bukan format ISO ditolak
PASS: validasi: karakter pipa ditolak
PASS: validasi: tiga galat terkumpul sekaligus
PASS: layanan: buat menetapkan status TODO
PASS: layanan: buat menetapkan id mulai 1
PASS: layanan: tenggat lampau ditolak status 400
PASS: layanan: tandaiSelesai memindahkan status ke SELESAI
PASS: layanan: tandaiSelesai kedua kali ditolak status 409
PASS: layanan: ubah mempertahankan status dan id
PASS: layanan: cari id tidak ada melempar status 404
PASS: layanan: tugas keenam pemilik sama ditolak status 409
PASS: repository: mutasi menulis berkas
PASS: repository: baris berkas berformat pipa
PASS: repository: berkas dimuat kembali
PASS: repository: id berlanjut dari tertinggi
== Tingkat 2: integrasi HTTP dengan filter aktif ==
PASS: integrasi: tanpa kredensial dijawab 401
PASS: integrasi: penolakan memuat WWW-Authenticate
PASS: integrasi: sandi salah dijawab 401 dengan body JSON
PASS: integrasi: muatan salah dijawab 400 dengan rincian
PASS: integrasi: POST sah dijawab 201 dengan Location
PASS: integrasi: GET daftar menampilkan dua tugas
PASS: integrasi: id tidak ada dijawab 404
PASS: integrasi: metode asing dijawab 405
PASS: integrasi: DELETE oleh ANGGOTA dijawab 403
PASS: integrasi: DELETE oleh ADMIN dijawab 204
== Tingkat 2 (lanjutan): server baru memuat berkas ==
PASS: persistensi: tugas tersisa termuat setelah restart
PASS: persistensi: id baru berlanjut setelah restart
Ringkasan: 30 lulus, 0 gagalBedah kerangkanya:
- Konstanta
HARIdan duadeleteIfExists: hari beku disuntik ke seluruh layanan uji dan berkas sisa eksekusi dibuang — dua sumber determinisme. - Method
menolak: padananassertThrowsyang ikut memeriksastatus()— salah jenis atau salah angka status sama-sama gagal. - Method
mulai(Path): perakit server uji di satu tempat; server sesi kedua dibuat dengan pemanggilan yang sama. - Method
ujiCrud: urutan permintaan merangkum status Bagian 2-3: 400, 201, 200, 404, 405, 403, 204. - Penutup
main:System.exit(1)bila ada kegagalan — sinyal bagi pipeline otomatis.
Padanan Pengujian Spring #
Pada Spring Boot, harness ini menjadi kelas uji beranotasi; satu dependensi membawa JUnit Jupiter, AssertJ, dan Mockito sekaligus:
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>Tingkat unit menjadi kelas uji JUnit murni tanpa Spring:
import java.time.LocalDate;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.*;
class UjiLayananTugas {
@Test
void tenggatLampauDitolak() {
LayananTugas layanan = new LayananTugas(
new RepositoryTugasMemori(), LocalDate.of(2026, 1, 15));
ValidasiGagalException galat = assertThrows(ValidasiGagalException.class,
() -> layanan.buat(new PermintaanTugasDto("Riset keamanan", "Dewi", "2026-01-01")));
assertEquals(400, galat.status());
}
}Irisan web diuji MockMvc tanpa server sungguhan; layanan diganti tiruan lewat @MockitoBean:
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest;
import org.springframework.test.context.bean.override.mockito.MockitoBean;
import org.springframework.test.web.servlet.MockMvc;
import static org.mockito.Mockito.when;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
@WebMvcTest(TugasController.class)
class UjiWebTugas {
@Autowired
private MockMvc mockMvc;
@MockitoBean
private LayananTugas layanan;
@Test
void idTidakAdaDijawab404() throws Exception {
when(layanan.cari(99)).thenThrow(new TugasTidakDitemukanException(99));
mockMvc.perform(get("/api/tugas/99"))
.andExpect(status().isNotFound());
}
}Konteks penuh diuji @SpringBootTest dengan server nyata di port acak lewat TestRestTemplate — termasuk keamanannya:
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.web.client.TestRestTemplate;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import static org.junit.jupiter.api.Assertions.assertEquals;
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
class UjiAplikasiPenuh {
@Autowired
private TestRestTemplate klien;
@Test
void tanpaKredensialDijawab401() {
ResponseEntity<String> respons = klien.getForEntity("/api/tugas", String.class);
assertEquals(HttpStatus.UNAUTHORIZED, respons.getStatusCode());
}
@Test
void muatanSalahDijawab400() {
ResponseEntity<String> respons = klien.withBasicAuth("budi", "sandi-budi")
.postForEntity("/api/tugas",
new PermintaanTugasDto("ab", "", "besok"), String.class);
assertEquals(HttpStatus.BAD_REQUEST, respons.getStatusCode());
}
}Padanannya presisi: periksa menjadi assertEquals dan assertTrue, menolak menjadi assertThrows, minta menjadi mockMvc.perform atau TestRestTemplate; baris Ringkasan menjadi laporan test runner.
Executable JAR dan Deployment #
Pengujian yang lulus memberi keyakinan membekukan kode menjadi artefak; rantainya:
Sesi berikut dikutip dari eksekusi sungguhan JDK 25 atas salinan berkas sumber Bagian 4:
$ javac -d kelas *.java
$ jar --create --file aplikasi-web.jar --main-class AplikasiWeb -C kelas .
$ jar --list --file aplikasi-web.jar
META-INF/
META-INF/MANIFEST.MF
ApiException.class
AplikasiWeb.class
AturanBisnisException.class
FilterTokenWeb.class
JsonWeb$Pembaca.class
JsonWeb.class
LayananTugasWeb.class
PenggunaWeb$Peran.class
PenggunaWeb.class
PermintaanTugas.class
RepositoryTugasWeb.class
RepositoryTugasWebBerkas.class
RepositoryTugasWebMemori.class
RouterWeb$Endpoint.class
RouterWeb$ResponsWeb.class
RouterWeb.class
StatusTugasWeb.class
TugasTidakDitemukanException.class
TugasWeb.class
UjiAplikasiWeb$Perintah.class
UjiAplikasiWeb.class
ValidasiGagalException.class
ValidasiWeb.classManifest di dalamnya memuat Main-Class: AplikasiWeb — baris yang dibaca java -jar saat memilih titik masuk; lima tipe bersarang menjadi berkas sendiri bertanda $. Jalankan arsipnya:
$ java -jar aplikasi-web.jar
=== AplikasiWeb Bagian 3: persistensi + keamanan ===
[Bootstrap] tugas-web.txt disiapkan bersih
[Bootstrap] pengguna terdaftar: budi (ANGGOTA), siti (ADMIN)
== Sesi 1: server pertama ==
[Bootstrap] repository berkas siap: 0 tugas termuat
GET /api/tugas -> 401 WWW-Authenticate=Basic realm="tugas-web", charset="UTF-8" {"status":401,"pesan":"header Authorization wajib berformat 'Basic <kredensial>'"}
GET /api/tugas -> 401 WWW-Authenticate=Basic realm="tugas-web", charset="UTF-8" {"status":401,"pesan":"header Authorization wajib berformat 'Basic <kredensial>'"}
GET /api/tugas -> 401 {"status":401,"pesan":"nama atau sandi salah"}
POST /api/tugas -> 201 {"id":1,"judul":"Belajar arsitektur berlapis","pemilik":"Budi","status":"TODO","tenggat":"2026-02-01"}
POST /api/tugas -> 201 {"id":2,"judul":"Riset keamanan aplikasi web","pemilik":"Dewi","status":"TODO","tenggat":"2026-01-25"}
DELETE /api/tugas/1 -> 403 {"status":403,"pesan":"menghapus tugas memerlukan peran ADMIN (terautentikasi sebagai budi)"}
DELETE /api/tugas/1 -> 204 (tanpa body)
GET /api/tugas -> 200 [{"id":2,"judul":"Riset keamanan aplikasi web","pemilik":"Dewi","status":"TODO","tenggat":"2026-01-25"}]
== Sesi 2: server baru memuat berkas yang sama ==
[Bootstrap] repository berkas siap: 1 tugas termuat
GET /api/tugas -> 200 [{"id":2,"judul":"Riset keamanan aplikasi web","pemilik":"Dewi","status":"TODO","tenggat":"2026-01-25"}]
POST /api/tugas -> 201 {"id":3,"judul":"Menulis dokumentasi API","pemilik":"Siti","status":"TODO","tenggat":"2026-01-20"}
[Bootstrap] kedua server berhenti; data bertahan di tugas-web.txtKeluarannya persis demo Bagian 3 — bukti aplikasi utuh terkemas, exit code tetap 0. Harness juga ikut di dalam arsip:
$ java -cp aplikasi-web.jar UjiAplikasiWeb
== Tingkat 1: unit tanpa jaringan ==
PASS: validasi: muatan sah tidak memuat galat
PASS: validasi: judul terlalu pendek ditolak
PASS: validasi: pemilik kosong ditolak
PASS: validasi: tenggat bukan format ISO ditolak
PASS: validasi: karakter pipa ditolak
PASS: validasi: tiga galat terkumpul sekaligus
PASS: layanan: buat menetapkan status TODO
PASS: layanan: buat menetapkan id mulai 1
PASS: layanan: tenggat lampau ditolak status 400
PASS: layanan: tandaiSelesai memindahkan status ke SELESAI
PASS: layanan: tandaiSelesai kedua kali ditolak status 409
PASS: layanan: ubah mempertahankan status dan id
PASS: layanan: cari id tidak ada melempar status 404
PASS: layanan: tugas keenam pemilik sama ditolak status 409
PASS: repository: mutasi menulis berkas
PASS: repository: baris berkas berformat pipa
PASS: repository: berkas dimuat kembali
PASS: repository: id berlanjut dari tertinggi
== Tingkat 2: integrasi HTTP dengan filter aktif ==
PASS: integrasi: tanpa kredensial dijawab 401
PASS: integrasi: penolakan memuat WWW-Authenticate
PASS: integrasi: sandi salah dijawab 401 dengan body JSON
PASS: integrasi: muatan salah dijawab 400 dengan rincian
PASS: integrasi: POST sah dijawab 201 dengan Location
PASS: integrasi: GET daftar menampilkan dua tugas
PASS: integrasi: id tidak ada dijawab 404
PASS: integrasi: metode asing dijawab 405
PASS: integrasi: DELETE oleh ANGGOTA dijawab 403
PASS: integrasi: DELETE oleh ADMIN dijawab 204
== Tingkat 2 (lanjutan): server baru memuat berkas ==
PASS: persistensi: tugas tersisa termuat setelah restart
PASS: persistensi: id baru berlanjut setelah restart
Ringkasan: 30 lulus, 0 gagalBedah perintahnya:
javac -d kelas *.java: compiler javac menghasilkan dua puluh tiga berkas.classdari delapan belas sumber.jar --create --file aplikasi-web.jar --main-class AplikasiWeb -C kelas .: alat jar membungkus isikelas/dan menuliskanMain-Classke manifest.java -cp aplikasi-web.jar UjiAplikasiWeb: mode classpath menyebut class eksplisit sehingga manifest diabaikan; exit code 0 menandakan seluruh pemeriksaan lulus.
Pada Maven, pekerjaan javac dan jar diambil alih siklus package; spring-boot-maven-plugin menambahkan satu langkah repackage:
<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
</plugin>
</plugins>
</build>Hasil mvn package berbeda dari aplikasi-web.jar: kelas aplikasi ditempatkan di BOOT-INF/classes, ratusan dependensi di BOOT-INF/lib, loader Spring Boot di akar arsip — bentuk fat jar. Ukurannya puluhan megabyte, tetapi java -jar target/aplikasi-web-spring-boot-1.0.0.jar berjalan di komputer mana pun yang memasang JRE — kemandirian yang menjadikannya unit deployment baku.
Citra container membungkus fat jar beserta runtime menjadi lingkungan identik di pengembangan dan produksi. Dockerfile multi-stage memisahkan tahap bangun dari tahap jalankan agar citra akhir kecil:
FROM maven:3.9-eclipse-temurin-25 AS bangun
WORKDIR /app
COPY pom.xml .
RUN mvn --no-transfer-progress dependency:go-offline
COPY src ./src
RUN mvn --no-transfer-progress package
FROM eclipse-temurin:25-jre
WORKDIR /app
COPY --from=bangun /app/target/aplikasi-web-spring-boot-1.0.0.jar app.jar
EXPOSE 8080
ENTRYPOINT ["java", "-jar", "app.jar"]Tahap bangun hanya hidup selama build; dependency:go-offline mengunduh dependensi sekali dan lapisan citra menyimpannya. Tahap kedua menyalin fat jar ke citra JRE; EXPOSE 8080 mendokumentasikan port bawaan Spring Boot.
Proyek aplikasi web tuntas: struktur, API, data, keamanan, kini pengujian dan kemasan — dengan padanan Spring Boot menyertai tiap langkah. Perakitan dependensi, pemetaan rute, dan filter keamanan yang ditulis tangan kini punya wajah anotasi dan plugin. Proyek penutup course, aplikasi-pemrosesan-data-asinkron, berganti medan dari melayani permintaan menjadi memproses data: pipeline asinkron dengan pembacaan berkas, pemrosesan paralel, retry, dan pengukuran performa.
Referensi #
- Spring Boot Reference: Testing
- Spring Boot Reference: Packaging
- jar — Panduan Alat JDK 25
- Docker Docs: Multi-stage Builds
Berkas Sumber
Berkas pendukung halaman ini dapat diunduh dan dijalankan langsung sesuai penjelasan di atas.
ApiException.java
Unduh berkas/**
* Exception unchecked pembawa status HTTP. Ditangkap di satu titik pada batas
* router, lalu diterjemahkan menjadi respons JSON berstatus sesuai. Peran ini
* dipegang @ExceptionHandler pada aplikasi Spring Boot sungguhan.
*/
public class ApiException extends RuntimeException {
private final int status;
public ApiException(int status, String pesan) {
super(pesan);
this.status = status;
}
public int status() {
return status;
}
}
AplikasiWeb.java
Unduh berkasimport com.sun.net.httpserver.HttpServer;
import java.io.IOException;
import java.net.InetSocketAddress;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.time.Duration;
import java.time.LocalDate;
import java.util.Base64;
import java.util.Locale;
/**
* Titik masuk inti telanjang Bagian 3: repository berkas + gerbang keamanan.
* Dua sesi server menunjukkan persistensi lintas restart: sesi pertama
* membuat data pada berkas tugas-web.txt, sesi kedua memuatnya kembali.
* Berkas dihapus lebih dulu bila ada agar demo selalu mulai bersih dan
* keluaran identik di setiap eksekusi.
*/
public class AplikasiWeb {
private static final Path BERKAS = Path.of("tugas-web.txt");
public static void main(String[] args) throws Exception {
Files.deleteIfExists(BERKAS);
System.out.println("=== AplikasiWeb Bagian 3: persistensi + keamanan ===");
System.out.println("[Bootstrap] tugas-web.txt disiapkan bersih");
PenggunaWeb budi = new PenggunaWeb("budi", "sandi-budi", PenggunaWeb.Peran.ANGGOTA);
PenggunaWeb siti = new PenggunaWeb("siti", "sandi-siti", PenggunaWeb.Peran.ADMIN);
System.out.println("[Bootstrap] pengguna terdaftar: budi (ANGGOTA), siti (ADMIN)");
HttpClient klien = HttpClient.newHttpClient();
System.out.println();
System.out.println("== Sesi 1: server pertama ==");
HttpServer server = mulai();
try {
String dasar = dasar(server);
kirim(klien, dasar, null, "GET", "/api/tugas", null);
kirim(klien, dasar, "Bearer bukan-basic", "GET", "/api/tugas", null);
kirim(klien, dasar, otorisasi("budi", "salah-total"), "GET", "/api/tugas", null);
kirim(klien, dasar, otorisasi(budi), "POST", "/api/tugas",
"{\"judul\":\"Belajar arsitektur berlapis\",\"pemilik\":\"Budi\","
+ "\"tenggat\":\"2026-02-01\"}");
kirim(klien, dasar, otorisasi(budi), "POST", "/api/tugas",
"{\"judul\":\"Riset keamanan aplikasi web\",\"pemilik\":\"Dewi\","
+ "\"tenggat\":\"2026-01-25\"}");
kirim(klien, dasar, otorisasi(budi), "DELETE", "/api/tugas/1", null);
kirim(klien, dasar, otorisasi(siti), "DELETE", "/api/tugas/1", null);
kirim(klien, dasar, otorisasi(budi), "GET", "/api/tugas", null);
} finally {
server.stop(0);
}
System.out.println();
System.out.println("== Sesi 2: server baru memuat berkas yang sama ==");
HttpServer serverBaru = mulai();
try {
String dasar = dasar(serverBaru);
kirim(klien, dasar, otorisasi(siti), "GET", "/api/tugas", null);
kirim(klien, dasar, otorisasi(budi), "POST", "/api/tugas",
"{\"judul\":\"Menulis dokumentasi API\",\"pemilik\":\"Siti\","
+ "\"tenggat\":\"2026-01-20\"}");
} finally {
serverBaru.stop(0);
}
System.out.println();
System.out.println("[Bootstrap] kedua server berhenti; data bertahan di tugas-web.txt");
}
/** Server uji: filter keamanan membungkus router di atas repository berkas. */
private static HttpServer mulai() throws IOException {
RepositoryTugasWebBerkas repository = new RepositoryTugasWebBerkas(BERKAS);
System.out.println("[Bootstrap] repository berkas siap: "
+ repository.jumlahTermuat() + " tugas termuat");
LayananTugasWeb layanan = new LayananTugasWeb(repository, LocalDate.of(2026, 1, 15));
HttpServer server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0);
server.createContext("/", new FilterTokenWeb(new RouterWeb(layanan),
new PenggunaWeb("budi", "sandi-budi", PenggunaWeb.Peran.ANGGOTA),
new PenggunaWeb("siti", "sandi-siti", PenggunaWeb.Peran.ADMIN)));
server.start();
return server;
}
/** Alamat dasar server; nomor port tidak dicetak agar keluaran deterministik. */
private static String dasar(HttpServer server) {
return "http://127.0.0.1:" + server.getAddress().getPort();
}
/** Header Authorization Basic dari pasangan nama dan sandi. */
private static String otorisasi(String nama, String sandi) {
byte[] oktet = (nama + ":" + sandi).getBytes(StandardCharsets.UTF_8);
return "Basic " + Base64.getEncoder().encodeToString(oktet);
}
private static String otorisasi(PenggunaWeb pengguna) {
return otorisasi(pengguna.nama(), pengguna.sandi());
}
/** Mengirim satu permintaan lalu mencetak status dan body respons. */
private static void kirim(HttpClient klien, String dasar, String otorisasi,
String metode, String path, String body)
throws IOException, InterruptedException {
HttpRequest.Builder b = HttpRequest.newBuilder()
.uri(URI.create(dasar + path))
.timeout(Duration.ofSeconds(5));
if (otorisasi != null) {
b.header("Authorization", otorisasi);
}
if (body == null) {
b.method(metode, HttpRequest.BodyPublishers.noBody());
} else {
b.header("Content-Type", "application/json")
.method(metode, HttpRequest.BodyPublishers.ofString(
body, StandardCharsets.UTF_8));
}
HttpResponse<String> r = klien.send(b.build(),
HttpResponse.BodyHandlers.ofString());
String www = r.headers().firstValue("WWW-Authenticate")
.map(w -> " WWW-Authenticate=" + w).orElse("");
String isi = r.body().isEmpty() ? "(tanpa body)" : r.body();
System.out.printf(Locale.US, "%-7s %-15s -> %d%s %s%n",
metode, path, r.statusCode(), www, isi);
}
}
AturanBisnisException.java
Unduh berkas/**
* Operasi menabrak aturan bisnis yang bergantung pada kondisi terkini:
* transisi status yang sudah lampau atau batas tugas aktif per pemilik.
* Diterjemahkan router menjadi respons 409 Conflict.
*/
public class AturanBisnisException extends ApiException {
public AturanBisnisException(String pesan) {
super(409, pesan);
}
}
FilterTokenWeb.java
Unduh berkasimport com.sun.net.httpserver.HttpExchange;
import com.sun.net.httpserver.HttpHandler;
import java.io.IOException;
import java.io.OutputStream;
import java.nio.charset.StandardCharsets;
import java.util.Base64;
import java.util.LinkedHashMap;
import java.util.Map;
/**
* Gerbang keamanan di depan router: dekorator HttpHandler yang memeriksa
* header Authorization skema Basic sebelum permintaan diteruskan. Struktur
* ini adalah miniatur filter chain Spring Security: satu pembungkus di luar
* seluruh aplikasi, kode aplikasi tidak menyentuh satu baris pun.
*
* Autentikasi: kredensial "nama:sandi" dienkode base64 pada header; nama
* dicari di daftar pengguna terdaftar dan sandi dibandingkan konstan-waktu.
* Otorisasi: DELETE hanya untuk peran ADMIN, ditolak 403 bila peran kurang.
* Seluruh penolakan autentikasi dijawab 401 plus header WWW-Authenticate
* yang diwajibkan RFC 7235 (dan RFC 7617 untuk parameter charset).
*/
public class FilterTokenWeb implements HttpHandler {
private final HttpHandler target;
private final Map<String, PenggunaWeb> pengguna = new LinkedHashMap<>();
public FilterTokenWeb(HttpHandler target, PenggunaWeb... penggunaTerdaftar) {
this.target = target;
for (PenggunaWeb p : penggunaTerdaftar) {
pengguna.put(p.nama(), p);
}
}
@Override
public void handle(HttpExchange exchange) throws IOException {
try {
PenggunaWeb terverifikasi = autentikasi(exchange);
otorisasi(exchange, terverifikasi);
} catch (ApiException e) {
tolak(exchange, e.status(), e.getMessage());
return; // permintaan ditolak: router tidak pernah dipanggil
}
target.handle(exchange); // router punya penanganan galat dan penutupan exchange sendiri
}
/**
* Memeriksa kredensial Basic pada header Authorization.
* Melempar ApiException 401 bila kredensial tidak sah.
*/
private PenggunaWeb autentikasi(HttpExchange ex) {
String header = ex.getRequestHeaders().getFirst("Authorization");
if (header == null || !header.startsWith("Basic ")) {
throw new ApiException(401,
"header Authorization wajib berformat 'Basic <kredensial>'");
}
byte[] oktet;
try {
oktet = Base64.getDecoder().decode(header.substring("Basic ".length()).strip());
} catch (IllegalArgumentException e) {
throw new ApiException(401, "kredensial Basic bukan base64 yang sah");
}
String teks = new String(oktet, StandardCharsets.UTF_8);
// Pemisah pertama: sandi boleh memuat karakter ':' sedangkan nama tidak.
int posisi = teks.indexOf(':');
if (posisi < 1) {
throw new ApiException(401, "kredensial harus berformat 'nama:sandi'");
}
String nama = teks.substring(0, posisi);
String sandi = teks.substring(posisi + 1);
PenggunaWeb terdaftar = pengguna.get(nama);
if (terdaftar == null || !terdaftar.cocokSandi(sandi)) {
// Pesan seragam untuk nama tak dikenal dan sandi salah,
// supaya tidak membocorkan nama mana yang terdaftar.
throw new ApiException(401, "nama atau sandi salah");
}
return terdaftar;
}
/**
* Memeriksa izin per permintaan: menghapus tugas hanya untuk ADMIN.
* Melempar ApiException 403 bila peran tidak mencukupi.
*/
private void otorisasi(HttpExchange ex, PenggunaWeb terverifikasi) {
if ("DELETE".equals(ex.getRequestMethod())
&& terverifikasi.peran() != PenggunaWeb.Peran.ADMIN) {
throw new ApiException(403, "menghapus tugas memerlukan peran ADMIN"
+ " (terautentikasi sebagai " + terverifikasi.nama() + ")");
}
}
/** Menulis respons penolakan: body JSON, header WWW-Authenticate untuk 401. */
private static void tolak(HttpExchange ex, int status, String pesan) throws IOException {
ex.getRequestBody().readAllBytes(); // buang body belum terbaca agar koneksi sehat
byte[] isi = JsonWeb.galat(status, pesan).getBytes(StandardCharsets.UTF_8);
ex.getResponseHeaders().set("Content-Type", "application/json; charset=utf-8");
if (status == 401) {
ex.getResponseHeaders().set("WWW-Authenticate",
"Basic realm=\"tugas-web\", charset=\"UTF-8\"");
}
ex.sendResponseHeaders(status, isi.length);
try (OutputStream os = ex.getResponseBody()) {
os.write(isi);
} finally {
ex.close();
}
}
}
JsonWeb.java
Unduh berkasimport java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
/**
* Serializer dan parser JSON mini untuk inti telanjang aplikasi web:
* objek datar dengan nilai teks, angka, boolean, atau null. Objek dan array
* bersarang sengaja tidak didukung agar kelas ini tetap ringkas; pustaka
* seperti Jackson menangani seluruh kasus pada aplikasi Spring Boot asli.
*/
public final class JsonWeb {
private JsonWeb() {
// Kelas utilitas: seluruh anggota static.
}
/** Meloloskan karakter yang wajib di-escape di dalam string JSON. */
public static String escape(String teks) {
StringBuilder hasil = new StringBuilder();
for (int i = 0; i < teks.length(); i++) {
char c = teks.charAt(i);
switch (c) {
case '"' -> hasil.append("\\\"");
case '\\' -> hasil.append("\\\\");
case '\n' -> hasil.append("\\n");
case '\t' -> hasil.append("\\t");
case '\r' -> hasil.append("\\r");
default -> {
if (c < 0x20) {
hasil.append(String.format("\\u%04x", (int) c));
} else {
hasil.append(c);
}
}
}
}
return hasil.toString();
}
/** Membungkus teks menjadi string JSON sah (kutip plus escape). */
public static String teks(String nilai) {
return "\"" + escape(nilai) + "\"";
}
/** Satu tugas menjadi objek JSON dengan urutan field tetap. */
public static String tugas(TugasWeb t) {
return "{\"id\":" + t.id()
+ ",\"judul\":" + teks(t.judul())
+ ",\"pemilik\":" + teks(t.pemilik())
+ ",\"status\":\"" + t.status() + "\""
+ ",\"tenggat\":\"" + t.tenggat() + "\"}";
}
/** Daftar tugas menjadi array JSON. */
public static String daftar(List<TugasWeb> semua) {
StringBuilder hasil = new StringBuilder("[");
for (TugasWeb t : semua) {
if (hasil.length() > 1) {
hasil.append(",");
}
hasil.append(tugas(t));
}
return hasil.append("]").toString();
}
/** Amplop galat tunggal. */
public static String galat(int status, String pesan) {
return "{\"status\":" + status + ",\"pesan\":" + teks(pesan) + "}";
}
/** Amplop galat validasi dengan rincian per field. */
public static String galat(int status, String pesan, List<String> rincian) {
if (rincian == null || rincian.isEmpty()) {
return galat(status, pesan);
}
StringBuilder hasil = new StringBuilder("{\"status\":").append(status)
.append(",\"pesan\":").append(teks(pesan)).append(",\"galat\":[");
boolean pertama = true;
for (String g : rincian) {
if (!pertama) {
hasil.append(",");
}
pertama = false;
hasil.append(teks(g));
}
return hasil.append("]}").toString();
}
/**
* Mem-parsing objek JSON datar menjadi Map dengan urutan field terjaga.
* Nilai berupa String, Long, Double, Boolean, atau null; nilai bersarang
* ditolak. Melempar IllegalArgumentException bila teks tidak sah.
*/
public static Map<String, Object> parseObjek(String json) {
Pembaca baca = new Pembaca(json);
Map<String, Object> hasil = baca.objek();
baca.spasi();
if (!baca.habis()) {
throw baca.galat("konten tambahan setelah objek");
}
return hasil;
}
/** Pembaca JSON karakter demi karakter dengan posisi terlacak. */
private static final class Pembaca {
private final String teks;
private int pos = 0;
Pembaca(String teks) {
this.teks = teks;
}
/** Membaca '{' sampai '}' menjadi LinkedHashMap. */
Map<String, Object> objek() {
spasi();
harap('{');
Map<String, Object> hasil = new LinkedHashMap<>();
spasi();
if (lihat() == '}') {
pos++;
return hasil;
}
while (true) {
spasi();
String kunci = string();
spasi();
harap(':');
spasi();
hasil.put(kunci, nilai());
spasi();
if (lihat() == ',') {
pos++;
continue;
}
harap('}');
return hasil;
}
}
/** Satu nilai: string, true, false, null, atau angka datar. */
Object nilai() {
char c = lihat();
if (c == '"') {
return string();
}
if (c == '{' || c == '[') {
throw galat("objek/array bersarang tidak didukung parser mini");
}
if (c == 't') {
harapKata("true");
return Boolean.TRUE;
}
if (c == 'f') {
harapKata("false");
return Boolean.FALSE;
}
if (c == 'n') {
harapKata("null");
return null;
}
return angka();
}
/** Membaca string berikut kutip pembuka dan penutupnya. */
String string() {
harap('"');
StringBuilder hasil = new StringBuilder();
while (pos < teks.length()) {
char c = teks.charAt(pos++);
if (c == '"') {
return hasil.toString();
}
if (c == '\\') {
hasil.append(escapeBerikutnya());
} else {
hasil.append(c);
}
}
throw galat("string tidak ditutup kutip");
}
/** Membaca satu karakter hasil escape, misalnya \n atau \". */
char escapeBerikutnya() {
if (pos >= teks.length()) {
throw galat("escape berada di ujung string");
}
char c = teks.charAt(pos++);
return switch (c) {
case '"' -> '"';
case '\\' -> '\\';
case '/' -> '/';
case 'n' -> '\n';
case 't' -> '\t';
case 'r' -> '\r';
case 'b' -> '\b';
case 'f' -> '\f';
default -> throw galat("escape tidak dikenal: \\" + c);
};
}
/** Membaca angka bulat atau pecahan tanpa notasi ilmiah. */
Object angka() {
int awal = pos;
if (lihat() == '-') {
pos++;
}
while (pos < teks.length() && "0123456789.".indexOf(teks.charAt(pos)) >= 0) {
pos++;
}
String teksAngka = teks.substring(awal, pos);
if (teksAngka.isEmpty() || teksAngka.equals("-")) {
throw galat("nilai tidak dikenal mulai di posisi " + awal);
}
try {
if (teksAngka.indexOf('.') >= 0) {
return Double.valueOf(teksAngka);
}
return Long.valueOf(teksAngka);
} catch (NumberFormatException e) {
throw galat("angka tidak sah: " + teksAngka);
}
}
/** Memastikan kata kunci (true/false/null) ada di posisi sekarang. */
void harapKata(String kata) {
if (!teks.startsWith(kata, pos)) {
throw galat("kata kunci '" + kata + "' tidak ditemukan");
}
pos += kata.length();
}
/** Melewati spasi dan whitespace lain. */
void spasi() {
while (pos < teks.length() && Character.isWhitespace(teks.charAt(pos))) {
pos++;
}
}
/** Melihat karakter pada posisi sekarang tanpa memajukannya. */
char lihat() {
if (pos >= teks.length()) {
throw galat("teks berakhir sebelum waktunya");
}
return teks.charAt(pos);
}
/** Memastikan karakter yang diharapkan ada, lalu memajukan posisi. */
void harap(char diharapkan) {
char c = lihat();
if (c != diharapkan) {
throw galat("diharapkan '" + diharapkan + "', ditemukan '" + c + "'");
}
pos++;
}
boolean habis() {
return pos >= teks.length();
}
IllegalArgumentException galat(String pesan) {
return new IllegalArgumentException(pesan + " (posisi " + pos + ")");
}
}
}
LayananTugasWeb.java
Unduh berkasimport java.time.LocalDate;
import java.util.List;
/**
* Lapisan layanan: pemilik aturan bisnis aplikasi web manajemen tugas.
* Seluruh operasi lewat sini; router tidak pernah menyentuh repository
* secara langsung, dan repository tidak pernah tahu bahwa HTTP ada.
* Hari operasional disuntik lewat konstruktor agar aturan "tenggat lampau"
* bisa diuji dengan tanggal beku, bukan LocalDate.now().
*/
public class LayananTugasWeb {
/** Batas tugas berstatus TODO per pemilik: aturan bisnis lapisan layanan. */
public static final int BATAS_TUGAS_AKTIF = 5;
private final RepositoryTugasWeb repository;
private final LocalDate hariIni;
public LayananTugasWeb(RepositoryTugasWeb repository, LocalDate hariIni) {
this.repository = repository;
this.hariIni = hariIni;
}
/** Hari operasional aplikasi, disuntik lewat konstruktor. */
public LocalDate hariIni() {
return hariIni;
}
/**
* Menyusun tugas baru setelah validasi bentuk field dan aturan kontekstual:
* status selalu TODO, tenggat wajib belum lampau, dan pemilik belum
* menyentuh batas tugas aktif.
*/
public TugasWeb buat(PermintaanTugas permintaan) {
LocalDate tenggat = validasi(permintaan);
TugasWeb baru = new TugasWeb(0, permintaan.judul(), permintaan.pemilik(),
StatusTugasWeb.TODO, tenggat);
return repository.simpanBaru(baru);
}
/** Seluruh tugas terurut id. */
public List<TugasWeb> daftar() {
return repository.semua();
}
/** Satu tugas berdasarkan id; 404 bila tidak ada. */
public TugasWeb cari(int id) {
return repository.cari(id)
.orElseThrow(() -> new TugasTidakDitemukanException(id));
}
/**
* Memperbarui judul, pemilik, dan tenggat; status dipertahankan apa adanya
* karena transisi status hanya boleh lewat tandaiSelesai.
*/
public TugasWeb ubah(int id, PermintaanTugas permintaan) {
TugasWeb tugas = cari(id);
LocalDate tenggat = validasi(permintaan);
TugasWeb baru = new TugasWeb(id, permintaan.judul(), permintaan.pemilik(),
tugas.status(), tenggat);
return repository.perbarui(baru)
.orElseThrow(() -> new TugasTidakDitemukanException(id));
}
/** Menandai tugas selesai; 409 bila tugas memang sudah SELESAI. */
public TugasWeb tandaiSelesai(int id) {
TugasWeb tugas = cari(id);
if (tugas.status() == StatusTugasWeb.SELESAI) {
throw new AturanBisnisException("tugas #" + id
+ " sudah SELESAI, tidak bisa ditandai selesai ulang");
}
TugasWeb selesai = tugas.denganStatus(StatusTugasWeb.SELESAI);
return repository.perbarui(selesai)
.orElseThrow(() -> new TugasTidakDitemukanException(id));
}
/** Menghapus tugas; 404 bila id tidak ada. */
public void hapus(int id) {
cari(id);
repository.hapus(id);
}
/**
* Validasi menyeluruh satu muatan: bentuk field lewat ValidasiWeb
* (galat dikumpulkan), lalu dua aturan kontekstual — tenggat wajib
* belum lampau dan pemilik belum menyentuh batas tugas aktif.
* Mengembalikan tenggat terurai untuk dipakai pemanggil.
*/
private LocalDate validasi(PermintaanTugas permintaan) {
List<String> galat = ValidasiWeb.periksa(permintaan);
if (!galat.isEmpty()) {
throw new ValidasiGagalException(galat);
}
LocalDate tenggat = LocalDate.parse(permintaan.tenggat().strip());
if (tenggat.isBefore(hariIni)) {
throw new ValidasiGagalException("tenggat " + tenggat
+ " sudah lampau, hari operasional " + hariIni);
}
long aktif = repository.semua().stream()
.filter(t -> t.pemilik().equals(permintaan.pemilik().strip()))
.filter(t -> t.status() == StatusTugasWeb.TODO)
.count();
if (aktif >= BATAS_TUGAS_AKTIF) {
throw new AturanBisnisException("pemilik " + permintaan.pemilik()
+ " sudah memiliki " + BATAS_TUGAS_AKTIF + " tugas TODO");
}
return tenggat;
}
}
PenggunaWeb.java
Unduh berkasimport java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
/**
* Satu pengguna aplikasi: nama, sandi, dan peran. Pembandingan sandi
* memakai MessageDigest.isEqual yang membandingkan seluruh byte tanpa
* berhenti di byte pertama yang berbeda, sehingga lama pembandingan tidak
* membocorkan isi sandi (kelanjutan pola proyek REST API manajemen tugas).
* Pada Spring Security, entitas ini berpadanan dengan UserDetails dan
* sandi seharusnya tersimpan sebagai hash.
*/
public record PenggunaWeb(String nama, String sandi, Peran peran) {
/** Peran pengguna: ANGGOTA boleh mengelola tugas, ADMIN boleh menghapus. */
public enum Peran {
ANGGOTA,
ADMIN
}
public PenggunaWeb {
if (nama == null || nama.isBlank()) {
throw new IllegalArgumentException("nama pengguna wajib diisi");
}
if (sandi == null || sandi.isEmpty()) {
throw new IllegalArgumentException("sandi pengguna wajib diisi");
}
if (peran == null) {
throw new IllegalArgumentException("peran pengguna wajib diisi");
}
nama = nama.strip();
}
/** Membandingkan sandi kandidat dengan sandi tersimpan secara konstan. */
public boolean cocokSandi(String kandidat) {
String pembanding = kandidat == null ? "" : kandidat;
return MessageDigest.isEqual(
sandi.getBytes(StandardCharsets.UTF_8),
pembanding.getBytes(StandardCharsets.UTF_8));
}
}
PermintaanTugas.java
Unduh berkas/**
* Muatan permintaan pembuatan dan pembaruan tugas: teks mentah dari klien
* sebelum divalidasi. Record ini berperan seperti DTO pada controller
* Spring; pemeriksaan isinya dilakukan lapisan layanan, bukan record ini.
*/
public record PermintaanTugas(String judul, String pemilik, String tenggat) {
}
RepositoryTugasWeb.java
Unduh berkasimport java.util.List;
import java.util.Optional;
/**
* Kontrak lapisan penyimpanan untuk resource tugas. Layanan hanya mengenal
* interface ini sehingga implementasi bebas ditukar: memori untuk pengujian,
* berkas untuk persistensi Bagian 3, atau JDBC/JPA pada aplikasi nyata.
*/
public interface RepositoryTugasWeb {
/** Menyimpan tugas baru; id ditetapkan otomatis oleh penyimpanan. */
TugasWeb simpanBaru(TugasWeb tugas);
/** Mencari satu tugas berdasarkan id. */
Optional<TugasWeb> cari(int id);
/** Seluruh tugas terurut menaik berdasarkan id. */
List<TugasWeb> semua();
/** Menyimpan perubahan tugas yang sudah ada; kosong bila id tidak ada. */
Optional<TugasWeb> perbarui(TugasWeb tugas);
/** Menghapus tugas; false bila id tidak ada. */
boolean hapus(int id);
}
RepositoryTugasWebBerkas.java
Unduh berkasimport java.io.IOException;
import java.io.UncheckedIOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.time.LocalDate;
import java.time.format.DateTimeParseException;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Optional;
/**
* Penyimpanan berkas untuk lapisan repository: satu tugas satu baris dengan
* format "id|judul|pemilik|status|tenggat" (karakter | karenanya dilarang
* ValidasiWeb). Berkas dibaca saat konstruksi bila sudah ada; setiap mutasi
* menulis ulang seluruh isi agar isi berkas selalu konsisten. Id berlanjut
* dari id tertinggi yang termuat, sehingga restart tidak mengulang id.
* Ke gagalan I/O dibungkus UncheckedIOException agar kontrak tetap rapi.
*/
public class RepositoryTugasWebBerkas implements RepositoryTugasWeb {
private final Path berkas;
private final Map<Integer, TugasWeb> data = new LinkedHashMap<>();
private int urutan = 0;
public RepositoryTugasWebBerkas(Path berkas) {
this.berkas = berkas;
if (Files.exists(berkas)) {
muat();
}
}
/** Jumlah tugas yang termuat saat konstruksi; nol bila berkas baru. */
public int jumlahTermuat() {
return data.size();
}
@Override
public TugasWeb simpanBaru(TugasWeb tugas) {
urutan++;
TugasWeb tersimpan = tugas.denganId(urutan);
data.put(urutan, tersimpan);
tulisUlang();
return tersimpan;
}
@Override
public Optional<TugasWeb> cari(int id) {
return Optional.ofNullable(data.get(id));
}
@Override
public List<TugasWeb> semua() {
return new ArrayList<>(data.values());
}
@Override
public Optional<TugasWeb> perbarui(TugasWeb tugas) {
if (!data.containsKey(tugas.id())) {
return Optional.empty();
}
data.put(tugas.id(), tugas);
tulisUlang();
return Optional.of(tugas);
}
@Override
public boolean hapus(int id) {
boolean ada = data.remove(id) != null;
if (ada) {
tulisUlang();
}
return ada;
}
/** Membaca seluruh baris berkas; baris kosong diabaikan, baris rusak
* menghentikan proses dengan nomor baris yang jelas. */
private void muat() {
List<String> baris;
try {
baris = Files.readAllLines(berkas, StandardCharsets.UTF_8);
} catch (IOException e) {
throw new UncheckedIOException("gagal membaca " + berkas.getFileName(), e);
}
int nomor = 0;
for (String isi : baris) {
nomor++;
if (isi.isBlank()) {
continue;
}
TugasWeb tugas = parseBaris(nomor, isi);
data.put(tugas.id(), tugas);
}
urutan = data.keySet().stream()
.mapToInt(Integer::intValue)
.max().orElse(0);
}
/** Mengurai satu baris "id|judul|pemilik|status|tenggat". */
private static TugasWeb parseBaris(int nomor, String isi) {
String[] bagian = isi.split("\\|", 5);
if (bagian.length != 5) {
throw new IllegalStateException("baris " + nomor + " rusak: \"" + isi + "\"");
}
try {
int id = Integer.parseInt(bagian[0].strip());
return new TugasWeb(id, bagian[1], bagian[2],
StatusTugasWeb.valueOf(bagian[3].strip()),
LocalDate.parse(bagian[4].strip()));
} catch (RuntimeException e) {
throw new IllegalStateException("baris " + nomor + " rusak: \"" + isi + "\"", e);
}
}
/** Menulis ulang seluruh isi ke berkas, satu tugas satu baris. */
private void tulisUlang() {
List<String> baris = new ArrayList<>();
for (TugasWeb t : data.values()) {
baris.add(t.id() + "|" + t.judul() + "|" + t.pemilik()
+ "|" + t.status() + "|" + t.tenggat());
}
try {
Files.write(berkas, baris, StandardCharsets.UTF_8);
} catch (IOException e) {
throw new UncheckedIOException("gagal menulis " + berkas.getFileName(), e);
}
}
}
RepositoryTugasWebMemori.java
Unduh berkasimport java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Optional;
/**
* Penyimpanan dalam memori: LinkedHashMap menjaga urutan sisip sama dengan
* urutan id, penghitung sederhana menetapkan id berurutan mulai dari 1.
*/
public class RepositoryTugasWebMemori implements RepositoryTugasWeb {
private final Map<Integer, TugasWeb> data = new LinkedHashMap<>();
private int urutan = 0;
@Override
public TugasWeb simpanBaru(TugasWeb tugas) {
urutan++;
TugasWeb tersimpan = tugas.denganId(urutan);
data.put(urutan, tersimpan);
return tersimpan;
}
@Override
public Optional<TugasWeb> cari(int id) {
return Optional.ofNullable(data.get(id));
}
@Override
public List<TugasWeb> semua() {
return new ArrayList<>(data.values());
}
@Override
public Optional<TugasWeb> perbarui(TugasWeb tugas) {
if (!data.containsKey(tugas.id())) {
return Optional.empty();
}
data.put(tugas.id(), tugas);
return Optional.of(tugas);
}
@Override
public boolean hapus(int id) {
return data.remove(id) != null;
}
}
RouterWeb.java
Unduh berkasimport com.sun.net.httpserver.HttpExchange;
import com.sun.net.httpserver.HttpHandler;
import java.io.IOException;
import java.io.OutputStream;
import java.nio.charset.StandardCharsets;
import java.util.LinkedHashMap;
import java.util.Map;
/**
* Lapisan web inti telanjang: satu HttpHandler yang menyimpan peta endpoint
* "METODE /pola" -> kode, lengkap dengan pencocokan pola {id}. Struktur ini
* adalah miniatur DispatcherServlet Spring MVC: peta handler menggantikan
* anotasi @GetMapping/@PostMapping, dan satu blok catch di batas menggantikan
* @ExceptionHandler.
*/
public class RouterWeb implements HttpHandler {
/** Satu endpoint terdaftar: pola "METODE /api/tugas/{id}". */
@FunctionalInterface
public interface Endpoint {
/** Memproses satu permintaan; parameter berisi nilai {id} hasil cocok pola. */
ResponsWeb tangani(Map<String, String> parameter, String body);
}
/** Respons siap kirim: status HTTP, body JSON, dan Location untuk 201. */
public record ResponsWeb(int status, String json, String lokasi) {
static ResponsWeb ok(String json) {
return new ResponsWeb(200, json, null);
}
static ResponsWeb dibuat(TugasWeb tugas) {
return new ResponsWeb(201, JsonWeb.tugas(tugas), "/api/tugas/" + tugas.id());
}
static ResponsWeb tanpaIsi() {
return new ResponsWeb(204, null, null);
}
}
private final LayananTugasWeb layanan;
private final Map<String, Endpoint> rute = new LinkedHashMap<>();
public RouterWeb(LayananTugasWeb layanan) {
this.layanan = layanan;
rute.put("GET /api/tugas", (p, b) -> ResponsWeb.ok(JsonWeb.daftar(layanan.daftar())));
rute.put("POST /api/tugas", (p, b) -> ResponsWeb.dibuat(layanan.buat(bacaPermintaan(b))));
rute.put("GET /api/tugas/{id}", (p, b) ->
ResponsWeb.ok(JsonWeb.tugas(layanan.cari(id(p)))));
rute.put("PUT /api/tugas/{id}", (p, b) ->
ResponsWeb.ok(JsonWeb.tugas(layanan.ubah(id(p), bacaPermintaan(b)))));
rute.put("POST /api/tugas/{id}/selesai", (p, b) ->
ResponsWeb.ok(JsonWeb.tugas(layanan.tandaiSelesai(id(p)))));
rute.put("DELETE /api/tugas/{id}", (p, b) -> {
layanan.hapus(id(p));
return ResponsWeb.tanpaIsi();
});
}
@Override
public void handle(HttpExchange exchange) throws IOException {
// Body dibaca selalu agar koneksi sehat walau permintaan ditolak.
String body = new String(exchange.getRequestBody().readAllBytes(),
StandardCharsets.UTF_8);
String metode = exchange.getRequestMethod();
String path = exchange.getRequestURI().getPath();
try {
kirim(exchange, cocokkan(metode, path, body));
} catch (ValidasiGagalException e) {
kirim(exchange, new ResponsWeb(400,
JsonWeb.galat(400, "validasi gagal", e.galat()), null));
} catch (ApiException e) {
kirim(exchange, new ResponsWeb(e.status(),
JsonWeb.galat(e.status(), e.getMessage()), null));
} catch (IllegalArgumentException e) {
kirim(exchange, new ResponsWeb(400,
JsonWeb.galat(400, e.getMessage()), null));
} catch (RuntimeException e) {
kirim(exchange, new ResponsWeb(500,
JsonWeb.galat(500, "kegagalan tak terduga: " + e), null));
}
}
/** Mencocokkan metode dan path dengan peta rute; 405 bila path dikenal
* dengan metode lain, 404 bila path memang tidak terdaftar. */
private ResponsWeb cocokkan(String metode, String path, String body) {
for (Map.Entry<String, Endpoint> rute : this.rute.entrySet()) {
String[] kunci = rute.getKey().split(" ", 2);
Map<String, String> parameter = cocokkanPola(kunci[1], path);
if (parameter != null && kunci[0].equals(metode)) {
return rute.getValue().tangani(parameter, body);
}
}
if (pathTerdaftar(path)) {
throw new ApiException(405, "metode " + metode + " tidak didukung untuk " + path);
}
throw new ApiException(404, "endpoint tidak dikenal: " + metode + " " + path);
}
/** Mencocokkan pola bersegmen dengan path; {id} menyerap satu segmen angka. */
private static Map<String, String> cocokkanPola(String pola, String path) {
String[] segPola = pola.split("/");
String[] segPath = path.split("/");
if (segPola.length != segPath.length) {
return null;
}
Map<String, String> parameter = new LinkedHashMap<>();
for (int i = 0; i < segPola.length; i++) {
String p = segPola[i];
if (p.startsWith("{") && p.endsWith("}")) {
if (!segPath[i].matches("\\d+")) {
return null;
}
parameter.put(p.substring(1, p.length() - 1), segPath[i]);
} else if (!p.equals(segPath[i])) {
return null;
}
}
return parameter;
}
/** Apakah path cocok dengan pola rute mana pun (metode apa pun). */
private boolean pathTerdaftar(String path) {
for (String kunci : rute.keySet()) {
if (cocokkanPola(kunci.split(" ", 2)[1], path) != null) {
return true;
}
}
return false;
}
/** Id numerik dari parameter hasil cocok pola. */
private static int id(Map<String, String> parameter) {
return Integer.parseInt(parameter.get("id"));
}
/** Membaca body JSON menjadi PermintaanTugas. */
private static PermintaanTugas bacaPermintaan(String body) {
if (body == null || body.isBlank()) {
throw new ValidasiGagalException("body permintaan wajib berisi JSON objek");
}
Map<String, Object> data = JsonWeb.parseObjek(body);
return new PermintaanTugas(teks(data, "judul"), teks(data, "pemilik"),
teks(data, "tenggat"));
}
/** Nilai field teks; null bila field hilang, 400 bila bukan teks. */
private static String teks(Map<String, Object> data, String field) {
Object nilai = data.get(field);
if (nilai == null) {
return null;
}
if (nilai instanceof String s) {
return s;
}
throw new ApiException(400, "field " + field + " harus berupa teks");
}
/** Menulis respons: body null berarti 204 tanpa isi. */
private static void kirim(HttpExchange exchange, ResponsWeb respons) throws IOException {
if (respons.json() == null) {
exchange.sendResponseHeaders(respons.status(), -1);
exchange.close();
return;
}
byte[] isi = respons.json().getBytes(StandardCharsets.UTF_8);
exchange.getResponseHeaders().set("Content-Type", "application/json; charset=utf-8");
if (respons.lokasi() != null) {
exchange.getResponseHeaders().set("Location", respons.lokasi());
}
exchange.sendResponseHeaders(respons.status(), isi.length);
try (OutputStream os = exchange.getResponseBody()) {
os.write(isi);
} finally {
exchange.close();
}
}
}
StatusTugasWeb.java
Unduh berkas/**
* Status daur hidup tugas pada aplikasi web manajemen tugas.
* TODO berarti tugas masih terbuka, SELESAI berarti tugas tuntas.
*/
public enum StatusTugasWeb {
TODO,
SELESAI
}
TugasTidakDitemukanException.java
Unduh berkas/**
* Id tugas yang diminta tidak ada di penyimpanan.
* Diterjemahkan router menjadi respons 404 Not Found.
*/
public class TugasTidakDitemukanException extends ApiException {
public TugasTidakDitemukanException(int id) {
super(404, "tugas dengan id " + id + " tidak ditemukan");
}
}
TugasWeb.java
Unduh berkasimport java.time.LocalDate;
/**
* Satu tugas sebagai entitas domain: judul, pemilik, status, dan tenggat.
* Record dipilih karena tugas adalah nilai yang tidak berubah di tempat;
* perubahan dilakukan lewat method salinan di bawah, persis pola wither.
*/
public record TugasWeb(int id, String judul, String pemilik, StatusTugasWeb status,
LocalDate tenggat) {
public TugasWeb {
if (judul == null || judul.isBlank()) {
throw new IllegalArgumentException("judul tugas wajib diisi");
}
if (pemilik == null || pemilik.isBlank()) {
throw new IllegalArgumentException("pemilik tugas wajib diisi");
}
if (status == null) {
throw new IllegalArgumentException("status tugas wajib diisi");
}
if (tenggat == null) {
throw new IllegalArgumentException("tenggat tugas wajib diisi");
}
judul = judul.strip();
pemilik = pemilik.strip();
}
/** Salinan tugas dengan id berbeda; dipakai repository saat menetapkan id. */
public TugasWeb denganId(int idBaru) {
return new TugasWeb(idBaru, judul, pemilik, status, tenggat);
}
/** Salinan tugas dengan status berbeda; dipakai layanan saat transisi status. */
public TugasWeb denganStatus(StatusTugasWeb statusBaru) {
return new TugasWeb(id, judul, pemilik, statusBaru, tenggat);
}
/** Ringkasan satu baris untuk pencatatan di terminal. */
public String formatRingkas() {
return "#" + id + " [" + status + "] " + judul
+ " (pemilik " + pemilik + ", tenggat " + tenggat + ")";
}
}
UjiAplikasiWeb.java
Unduh berkasimport com.sun.net.httpserver.HttpServer;
import java.io.IOException;
import java.net.InetSocketAddress;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.time.Duration;
import java.time.LocalDate;
import java.util.Base64;
import java.util.List;
/**
* Harness pengujian bertingkat aplikasi web manajemen tugas. Tingkat 1
* menguji unit tanpa jaringan: ValidasiWeb, LayananTugasWeb dengan repository
* memori, dan RepositoryTugasWebBerkas. Tingkat 2 menguji integrasi HTTP
* in-process: FilterTokenWeb membungkus RouterWeb di atas HttpServer yang
* mendengarkan 127.0.0.1 pada port pilihan sistem operasi (nomor port tidak
* dicetak agar keluaran deterministik), lalu satu HttpClient mengirim seluruh
* permintaan secara sekuensial. Exit code 1 bila ada kegagalan.
*/
public class UjiAplikasiWeb {
/** Hari operasional beku: aturan "tenggat lampau" diuji tanpa LocalDate.now(). */
private static final LocalDate HARI = LocalDate.of(2026, 1, 15);
private static int lulus = 0;
private static int gagal = 0;
public static void main(String[] args) throws Exception {
Path berkasRepo = Path.of("uji-repo-web.txt");
Path berkasIntegrasi = Path.of("uji-integrasi-web.txt");
Files.deleteIfExists(berkasRepo); // mulai bersih agar uji dapat diulang
Files.deleteIfExists(berkasIntegrasi);
System.out.println("== Tingkat 1: unit tanpa jaringan ==");
ujiValidasi();
ujiLayanan();
ujiRepositoryBerkas(berkasRepo);
System.out.println();
System.out.println("== Tingkat 2: integrasi HTTP dengan filter aktif ==");
HttpClient klien = HttpClient.newHttpClient();
HttpServer server = mulai(berkasIntegrasi);
try {
String dasar = dasar(server);
ujiAutentikasi(klien, dasar);
ujiCrud(klien, dasar);
} finally {
server.stop(0);
}
System.out.println();
System.out.println("== Tingkat 2 (lanjutan): server baru memuat berkas ==");
HttpServer serverBaru = mulai(berkasIntegrasi);
try {
ujiPersistensi(klien, dasar(serverBaru));
} finally {
serverBaru.stop(0);
}
System.out.println();
System.out.println("Ringkasan: " + lulus + " lulus, " + gagal + " gagal");
if (gagal > 0) {
System.exit(1);
}
}
/** Tingkat 1a: validasi bentuk field mengumpulkan galat, bukan berhenti di awal. */
private static void ujiValidasi() {
List<String> sah = ValidasiWeb.periksa(new PermintaanTugas(
"Belajar arsitektur berlapis", "Budi", "2026-02-01"));
periksa("validasi: muatan sah tidak memuat galat", sah.isEmpty());
List<String> judul = ValidasiWeb.periksa(new PermintaanTugas(
"ab", "Budi", "2026-02-01"));
periksa("validasi: judul terlalu pendek ditolak",
judul.size() == 1 && judul.get(0).startsWith("judul"));
List<String> pemilik = ValidasiWeb.periksa(new PermintaanTugas(
"Menulis dokumentasi API", "", "2026-02-01"));
periksa("validasi: pemilik kosong ditolak",
pemilik.size() == 1 && pemilik.get(0).startsWith("pemilik"));
List<String> tenggat = ValidasiWeb.periksa(new PermintaanTugas(
"Menulis dokumentasi API", "Budi", "besok"));
periksa("validasi: tenggat bukan format ISO ditolak",
tenggat.size() == 1 && tenggat.get(0).startsWith("tenggat"));
List<String> pipa = ValidasiWeb.periksa(new PermintaanTugas(
"Riset|keamanan", "Budi", "2026-02-01"));
periksa("validasi: karakter pipa ditolak",
pipa.size() == 1 && pipa.get(0).contains("|"));
List<String> semuaSalah = ValidasiWeb.periksa(new PermintaanTugas(
"ab", "", "besok"));
periksa("validasi: tiga galat terkumpul sekaligus", semuaSalah.size() == 3);
}
/** Tingkat 1b: aturan bisnis layanan dengan repository memori dan hari beku. */
private static void ujiLayanan() {
LayananTugasWeb layanan = new LayananTugasWeb(
new RepositoryTugasWebMemori(), HARI);
TugasWeb tugas = layanan.buat(new PermintaanTugas(
"Belajar arsitektur berlapis", "Budi", "2026-02-01"));
periksa("layanan: buat menetapkan status TODO",
tugas.status() == StatusTugasWeb.TODO);
periksa("layanan: buat menetapkan id mulai 1", tugas.id() == 1);
periksa("layanan: tenggat lampau ditolak status 400",
menolak(() -> layanan.buat(new PermintaanTugas(
"Riset keamanan", "Dewi", "2026-01-01")),
ValidasiGagalException.class, 400));
TugasWeb selesai = layanan.tandaiSelesai(1);
periksa("layanan: tandaiSelesai memindahkan status ke SELESAI",
selesai.status() == StatusTugasWeb.SELESAI);
periksa("layanan: tandaiSelesai kedua kali ditolak status 409",
menolak(() -> layanan.tandaiSelesai(1),
AturanBisnisException.class, 409));
TugasWeb revisi = layanan.ubah(1, new PermintaanTugas(
"Belajar arsitektur berlapis (revisi)", "Budi", "2026-02-08"));
periksa("layanan: ubah mempertahankan status dan id",
revisi.status() == StatusTugasWeb.SELESAI && revisi.id() == 1);
periksa("layanan: cari id tidak ada melempar status 404",
menolak(() -> layanan.cari(99),
TugasTidakDitemukanException.class, 404));
LayananTugasWeb kuota = new LayananTugasWeb(
new RepositoryTugasWebMemori(), HARI);
for (int i = 1; i <= LayananTugasWeb.BATAS_TUGAS_AKTIF; i++) {
kuota.buat(new PermintaanTugas("Tugas kantor " + i, "Dewi", "2026-03-02"));
}
periksa("layanan: tugas keenam pemilik sama ditolak status 409",
menolak(() -> kuota.buat(new PermintaanTugas(
"Tugas kantor 6", "Dewi", "2026-03-02")),
AturanBisnisException.class, 409));
}
/** Tingkat 1c: repository berkas menulis, memuat kembali, dan meneruskan id. */
private static void ujiRepositoryBerkas(Path berkas) throws IOException {
RepositoryTugasWebBerkas repository = new RepositoryTugasWebBerkas(berkas);
repository.simpanBaru(new TugasWeb(0, "Belajar arsitektur berlapis", "Budi",
StatusTugasWeb.TODO, LocalDate.of(2026, 2, 1)));
periksa("repository: mutasi menulis berkas", Files.exists(berkas));
periksa("repository: baris berkas berformat pipa",
Files.readAllLines(berkas, StandardCharsets.UTF_8).get(0)
.equals("1|Belajar arsitektur berlapis|Budi|TODO|2026-02-01"));
RepositoryTugasWebBerkas termuat = new RepositoryTugasWebBerkas(berkas);
periksa("repository: berkas dimuat kembali",
termuat.jumlahTermuat() == 1 && termuat.cari(1).isPresent());
periksa("repository: id berlanjut dari tertinggi",
termuat.simpanBaru(new TugasWeb(0, "Riset keamanan aplikasi web",
"Dewi", StatusTugasWeb.TODO, LocalDate.of(2026, 1, 25))).id() == 2);
}
/** Tingkat 2a: gerbang keamanan menolak permintaan tanpa identitas sah. */
private static void ujiAutentikasi(HttpClient klien, String dasar)
throws IOException, InterruptedException {
HttpResponse<String> tanpa = minta(klien, dasar, null, "GET", "/api/tugas", null);
periksa("integrasi: tanpa kredensial dijawab 401", tanpa.statusCode() == 401);
periksa("integrasi: penolakan memuat WWW-Authenticate",
tanpa.headers().firstValue("WWW-Authenticate").isPresent());
HttpResponse<String> salah = minta(klien, dasar,
basic("budi", "sandi-salah"), "GET", "/api/tugas", null);
periksa("integrasi: sandi salah dijawab 401 dengan body JSON",
salah.statusCode() == 401 && salah.body().contains("\"status\":401"));
}
/** Tingkat 2b: CRUD, validasi, dan otorisasi melalui HTTP sungguhan. */
private static void ujiCrud(HttpClient klien, String dasar)
throws IOException, InterruptedException {
String budi = basic("budi", "sandi-budi");
String siti = basic("siti", "sandi-siti");
HttpResponse<String> galat = minta(klien, dasar, budi, "POST", "/api/tugas",
"{\"judul\":\"ab\",\"pemilik\":\"\",\"tenggat\":\"besok\"}");
periksa("integrasi: muatan salah dijawab 400 dengan rincian",
galat.statusCode() == 400 && galat.body().contains("\"galat\":["));
HttpResponse<String> dibuat = minta(klien, dasar, budi, "POST", "/api/tugas",
"{\"judul\":\"Belajar arsitektur berlapis\",\"pemilik\":\"Budi\","
+ "\"tenggat\":\"2026-02-01\"}");
periksa("integrasi: POST sah dijawab 201 dengan Location",
dibuat.statusCode() == 201
&& dibuat.headers().firstValue("Location").orElse("")
.equals("/api/tugas/1"));
minta(klien, dasar, budi, "POST", "/api/tugas",
"{\"judul\":\"Riset keamanan aplikasi web\",\"pemilik\":\"Dewi\","
+ "\"tenggat\":\"2026-01-25\"}");
HttpResponse<String> daftar = minta(klien, dasar, budi, "GET", "/api/tugas", null);
periksa("integrasi: GET daftar menampilkan dua tugas",
daftar.statusCode() == 200 && daftar.body().contains("\"id\":2"));
HttpResponse<String> hilang = minta(klien, dasar, budi, "GET", "/api/tugas/99", null);
periksa("integrasi: id tidak ada dijawab 404", hilang.statusCode() == 404);
HttpResponse<String> asing = minta(klien, dasar, budi, "PATCH", "/api/tugas/1", null);
periksa("integrasi: metode asing dijawab 405", asing.statusCode() == 405);
HttpResponse<String> terlarang = minta(klien, dasar, budi,
"DELETE", "/api/tugas/1", null);
periksa("integrasi: DELETE oleh ANGGOTA dijawab 403",
terlarang.statusCode() == 403);
HttpResponse<String> terhapus = minta(klien, dasar, siti,
"DELETE", "/api/tugas/1", null);
periksa("integrasi: DELETE oleh ADMIN dijawab 204",
terhapus.statusCode() == 204);
}
/** Tingkat 2c: server baru atas berkas yang sama membuktikan persistensi. */
private static void ujiPersistensi(HttpClient klien, String dasar)
throws IOException, InterruptedException {
String siti = basic("siti", "sandi-siti");
HttpResponse<String> daftar = minta(klien, dasar, siti, "GET", "/api/tugas", null);
periksa("persistensi: tugas tersisa termuat setelah restart",
daftar.statusCode() == 200 && daftar.body().contains("\"id\":2")
&& !daftar.body().contains("\"id\":1"));
HttpResponse<String> lanjut = minta(klien, dasar, siti, "POST", "/api/tugas",
"{\"judul\":\"Menulis dokumentasi API\",\"pemilik\":\"Siti\","
+ "\"tenggat\":\"2026-01-20\"}");
periksa("persistensi: id baru berlanjut setelah restart",
lanjut.statusCode() == 201
&& lanjut.headers().firstValue("Location").orElse("")
.equals("/api/tugas/3"));
}
/** Server uji: filter keamanan membungkus router di atas repository berkas. */
private static HttpServer mulai(Path berkas) throws IOException {
LayananTugasWeb layanan = new LayananTugasWeb(
new RepositoryTugasWebBerkas(berkas), HARI);
HttpServer server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0);
server.createContext("/", new FilterTokenWeb(new RouterWeb(layanan),
new PenggunaWeb("budi", "sandi-budi", PenggunaWeb.Peran.ANGGOTA),
new PenggunaWeb("siti", "sandi-siti", PenggunaWeb.Peran.ADMIN)));
server.start();
return server;
}
/** Alamat dasar server; nomor port tidak dicetak agar keluaran deterministik. */
private static String dasar(HttpServer server) {
return "http://127.0.0.1:" + server.getAddress().getPort();
}
/** Header Authorization Basic dari pasangan nama dan sandi. */
private static String basic(String nama, String sandi) {
byte[] oktet = (nama + ":" + sandi).getBytes(StandardCharsets.UTF_8);
return "Basic " + Base64.getEncoder().encodeToString(oktet);
}
/**
* Mengirim satu permintaan; authorization null berarti tanpa header
* Authorization sama sekali.
*/
private static HttpResponse<String> minta(HttpClient klien, String dasar,
String authorization, String metode, String path, String body)
throws IOException, InterruptedException {
HttpRequest.Builder b = HttpRequest.newBuilder()
.uri(URI.create(dasar + path))
.timeout(Duration.ofSeconds(5));
if (authorization != null) {
b.header("Authorization", authorization);
}
if (body == null) {
b.method(metode, HttpRequest.BodyPublishers.noBody());
} else {
b.header("Content-Type", "application/json")
.method(metode, HttpRequest.BodyPublishers.ofString(
body, StandardCharsets.UTF_8));
}
return klien.send(b.build(), HttpResponse.BodyHandlers.ofString());
}
/** Satu pernyataan uji: catat PASS bila kondisi benar, FAIL bila salah. */
private static void periksa(String nama, boolean kondisi) {
if (kondisi) {
lulus++;
System.out.println("PASS: " + nama);
} else {
gagal++;
System.out.println("FAIL: " + nama);
}
}
/**
* Menjalankan perintah; true bila melempar exception bertepatan jenis dan
* status. Perbandingan getClass() menolak exception salah jenis, dan status
* dibaca lewat ApiException sehingga angka HTTP ikut diperiksa.
*/
private static boolean menolak(Perintah perintah,
Class<? extends ApiException> jenis, int status) {
try {
perintah.jalankan();
return false;
} catch (Exception e) {
return e.getClass() == jenis && ((ApiException) e).status() == status;
}
}
/** Antarmuka satu method agar blok uji bisa melempar exception terperiksa. */
interface Perintah {
void jalankan() throws Exception;
}
}
ValidasiGagalException.java
Unduh berkasimport java.util.List;
/**
* Muatan permintaan tidak lolos pemeriksaan. Membawa daftar galat per field
* agar router bisa menyusun respons 400 dengan rincian terstruktur — padanan
* MethodArgumentNotValidException pada Spring MVC.
*/
public class ValidasiGagalException extends ApiException {
private final List<String> galat;
public ValidasiGagalException(String pesan) {
this(List.of(pesan));
}
public ValidasiGagalException(List<String> galat) {
super(400, "validasi gagal: " + String.join("; ", galat));
this.galat = List.copyOf(galat);
}
/** Rincian galat per field, siap ditampilkan sebagai array JSON. */
public List<String> galat() {
return galat;
}
}
ValidasiWeb.java
Unduh berkasimport java.time.LocalDate;
import java.time.format.DateTimeParseException;
import java.util.ArrayList;
import java.util.List;
/**
* Validasi bentuk field muatan tugas: panjang judul, isi pemilik, format
* tanggal, dan karakter yang dilarang format penyimpanan Bagian 3. Seluruh
* galat dikumpulkan sekaligus agar klien tahu semua kesalahan dalam satu
* respons, bukan satu per satu. Aturan yang butuh konteks (tenggat lampau,
* batas tugas aktif) tetap di LayananTugasWeb.
*/
public final class ValidasiWeb {
private ValidasiWeb() {
// Kelas utilitas: seluruh anggota static.
}
/**
* Memeriksa muatan permintaan dan mengembalikan daftar galat.
* Daftar kosong berarti seluruh field lolos pemeriksaan bentuk.
*/
public static List<String> periksa(PermintaanTugas permintaan) {
List<String> galat = new ArrayList<>();
String judul = permintaan.judul() == null ? "" : permintaan.judul().strip();
if (judul.length() < 3 || judul.length() > 100) {
galat.add("judul wajib berisi 3-100 karakter");
}
if (judul.contains("|")) {
galat.add("judul tidak boleh memuat karakter '|' (pemisah format penyimpanan)");
}
String pemilik = permintaan.pemilik() == null ? "" : permintaan.pemilik().strip();
if (pemilik.isEmpty() || pemilik.length() > 60) {
galat.add("pemilik wajib diisi, maksimum 60 karakter");
}
if (pemilik.contains("|")) {
galat.add("pemilik tidak boleh memuat karakter '|'");
}
if (permintaan.tenggat() == null) {
galat.add("tenggat wajib berformat ISO yyyy-MM-dd");
} else {
try {
LocalDate.parse(permintaan.tenggat().strip());
} catch (DateTimeParseException e) {
galat.add("tenggat wajib berformat ISO yyyy-MM-dd");
}
}
return galat;
}
}