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.

TingkatYang dibuktikanPerangkat inti telanjangPadanan Spring Boot
Unitsatu aturan, satu kelaspanggilan langsung dari mainJUnit 5, AssertJ
Integrasilapisan tersambung satu JVMHttpServer port 0 + HttpClient@SpringBootTest, MockMvc
End-to-endperilaku utuh dari luarsesi server kedua atas berkas samaTestRestTemplate

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:

text
== 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 gagal

Bedah kerangkanya:

  1. Konstanta HARI dan dua deleteIfExists: hari beku disuntik ke seluruh layanan uji dan berkas sisa eksekusi dibuang — dua sumber determinisme.
  2. Method menolak: padanan assertThrows yang ikut memeriksa status() — salah jenis atau salah angka status sama-sama gagal.
  3. Method mulai(Path): perakit server uji di satu tempat; server sesi kedua dibuat dengan pemanggilan yang sama.
  4. Method ujiCrud: urutan permintaan merangkum status Bagian 2-3: 400, 201, 200, 404, 405, 403, 204.
  5. 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:

xml Dependensi pengujian pada pom.xml — rancangan, tidak dieksekusi di lingkungan ini
<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:

java Padanan tingkat unit — JUnit murni, tidak dieksekusi di lingkungan ini
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:

java Padanan tingkat integrasi — MockMvc dan Mockito, tidak dieksekusi di lingkungan ini
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:

java Padanan end-to-end — @SpringBootTest dan TestRestTemplate, tidak dieksekusi di lingkungan ini
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:

text
$ 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.class

Manifest di dalamnya memuat Main-Class: AplikasiWeb — baris yang dibaca java -jar saat memilih titik masuk; lima tipe bersarang menjadi berkas sendiri bertanda $. Jalankan arsipnya:

text
$ 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.txt

Keluarannya persis demo Bagian 3 — bukti aplikasi utuh terkemas, exit code tetap 0. Harness juga ikut di dalam arsip:

text
$ 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 gagal

Bedah perintahnya:

  1. javac -d kelas *.java: compiler javac menghasilkan dua puluh tiga berkas .class dari delapan belas sumber.
  2. jar --create --file aplikasi-web.jar --main-class AplikasiWeb -C kelas .: alat jar membungkus isi kelas/ dan menuliskan Main-Class ke manifest.
  3. 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:

xml Plugin perakit pada pom.xml — rancangan, tidak dieksekusi di lingkungan ini
<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:

dockerfile Dockerfile multi-stage — rancangan, tidak dieksekusi di lingkungan ini
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 #

Berkas Sumber

Berkas pendukung halaman ini dapat diunduh dan dijalankan langsung sesuai penjelasan di atas.

ApiException.java

Unduh berkas
java
/**
 * 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 berkas
java
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.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
java
/**
 * 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 berkas
java
import 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 berkas
java
import 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 berkas
java
import 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 berkas
java
import 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
java
/**
 * 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 berkas
java
import 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 berkas
java
import 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 berkas
java
import 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 berkas
java
import 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
java
/**
 * 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
java
/**
 * 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 berkas
java
import 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 berkas
java
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;
    }
}

ValidasiGagalException.java

Unduh berkas
java
import 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 berkas
java
import 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;
    }
}