Image aplikasi sudah berdiri; sekarang kita menyusun rumahnya. Stack ini adalah tempat seluruh konsep Compose bertemu sekaligus: satu service untuk PostgreSQL yang harus dinyalakan sebelum aplikasi, satu healthcheck yang mendefinisikan arti “siap”, satu volume yang menampung seluruh memori sistem, dan satu interpolasi variabel yang menjaga password keluar dari berkas. Bagian ini menulis kedua service pertama compose.yaml — db lalu app — menyalakan stack untuk pertama kalinya, lalu membuktikan lewat log, curl, dan psql bahwa migrasi benar-benar terjadi sendiri dan API benar-benar melayani. Service ketiga, test, sengaja ditunda ke Bagian 5 agar tiap berkas compose.yaml lahir bersama bagian yang membutuhkannya.

Service db #

Inilah seluruh definisi database kita di compose.yaml:

yaml
  db:
    image: postgres:18-alpine
    environment:
      POSTGRES_USER: katalog
      POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?wajib diset dari .env}
      POSTGRES_DB: katalog
    volumes:
      - db-data:/var/lib/postgresql/data
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U katalog -d katalog"]
      interval: 2s
      timeout: 2s
      retries: 15
    restart: unless-stopped

Bersama blok volumes di level teratas berkas yang mendeklarasikan volume bernama itu:

yaml
volumes:
  db-data:

Mari kita telaah lima kunci terpentingnya:

  1. image: postgres:18-alpine — PostgreSQL 18, rilis mayor terbaru saat course ini ditulis, dalam varian Alpine yang ramping. Menariknya, service ini tidak punya kunci build: image resmi ditarik langsung dari Docker Hub, dan Compose memakai kebijakan pull default missing — tarik hanya bila belum ada di lokal — persis pembahasan Image, Build, dan Pull.
  2. Tiga variabel POSTGRES_* — POSTGRES_USER, POSTGRES_PASSWORD, dan POSTGRES_DB adalah kontrak entrypoint image postgres: mereka memberi tahu skrip inisialisasi user, password, dan database apa yang harus dibuat saat cluster pertama kali terbentuk.
  3. volumes: - db-data:/var/lib/postgresql/data — direktori data PostgreSQL ditumpangkan ke volume bernama db-data, yang dideklarasikan di blok top-level paling bawah berkas. Compose otomatis memberinya prefix nama proyek sehingga nama aslinya katalog_db-data — mesin-mesin ini dibahas Volume dan Bind Mount dan Persistensi Database.
  4. healthcheck dengan pg_isready — setiap 2 detik container menjalankan pg_isready -U katalog -d katalog, alat bawaan PostgreSQL yang memeriksa apakah server menerima koneksi. retries: 15 dengan interval: 2s memberi jendela 30 detik untuk startup — angka jauh lebih gesit daripada default 30 detiknya, karena yang kita tunggu adalah inisialisasi perdana yang biasanya selesai dalam hitungan detik; semantik lengkapnya ada di Healthcheck dan Service Readiness.
  5. restart: unless-stopped — bila container db mati karena crash atau host reboot, Docker membangunkannya kembali; hanya penghentian manual yang dihormati — perilaku tabel Restart Policy.

Service app #

Giliran aplikasi yang menyambung:

yaml
  app:
    build: .
    image: katalog-app:1.0
    ports:
      - "8080:8080"
    environment:
      PGHOST: db
      PGPORT: "5432"
      PGUSER: katalog
      PGPASSWORD: ${POSTGRES_PASSWORD:?wajib diset dari .env}
      PGDATABASE: katalog
    depends_on:
      db:
        condition: service_healthy
    restart: unless-stopped

Mari kita telaah empat keputusan di dalamnya:

  1. PGHOST: db — nama host database adalah nama servicenya. Compose mendaftarkan setiap service ke DNS jaringan stack, sehingga db diselesaikan menjadi alamat IP container PostgreSQL — pembahasan penuhnya menjadi agenda Bagian 4. Aplikasi tidak pernah tahu IP berapa pun, dan IP yang berubah setelah down/up tidak menjadi urusannya.
  2. Kutip ganda di nilai YAML — PGPORT: "5432" dikutip agar tetap dibaca string, karena environment container memang harus berupa teks; entri ports: - "8080:8080" dikutip karena tanpa kutip parser YAML menafsirkan pola angka:angka sebagai angka sexagesimal basis 60 — jebakan yang dibedah Struktur compose.yaml. Mengutip nilai yang berbentuk angka atau mengandung tanda titik dua adalah kebiasaan murah hari ini yang mencegah kejutan mahal besok.
  3. depends_on beryarat service_healthy — Compose tidak sekadar menyalakan db lebih dulu; ia menunggu sampai healthcheck pg_isready benar-benar lulus baru melanjutkan ke app. Ini lapisan kesiapan pertama. Lapisan kedua ada di dalam aplikasi sendiri: Open() dari Bagian 1 tetap mencoba ulang sepuluh kali berjeda setengah detik. Dua lapisan itu saling menutup — healthcheck bisa lulus sesaat sebelum server penuh siap, dan retry aplikasi menampung sisa waktunya — persis anjuran berlapis di depends_on dan Startup Order dan Startup dan Dependency.
  4. Password lewat interpolasi ${POSTGRES_PASSWORD:?wajib diset dari .env} — Compose membaca variabel dari .env, dan pola :?pesan membuat stack menolak menyala dengan pesan itu bila variabel kosong, ketimbang gagal misterius di tengah jalan — fitur interpolasi dari Variable Interpolation. Perlakuan password ini punya tiga tingkat ketat yang menaik: nilai default di Dockerfile untuk eksperimen kilat, .env untuk mesin pengembangan, dan di produksi rahasia pindah ke secret manager atau Docker secrets — bukan environment variable — sebagaimana dibahas Secret Management dan Config dan Secret.

Dua kunci sisanya menyusun cerita: build: . memberi tahu Compose bahwa image dibangun dari Dockerfile Bagian 2 saat dibutuhkan, image: katalog-app:1.0 menamai hasilnya agar bisa dirujuk, dan ports: - "8080:8080" adalah satu-satunya pintu stack ini ke dunia luar.

Menyalakan Stack #

Ketiga berkas itu kini saling mengunci. Password ditulis sekali ke .env, lalu stack dinyalakan:

bash
$ echo 'POSTGRES_PASSWORD=rahasia123' > .env
$ docker compose up -d --build

Output yang Dihasilkan:

text
[+] Building 1.9s (21/21) FINISHED
 => [internal] load build definition from Dockerfile                       0.0s
...
 => => naming to docker.io/library/katalog-app:1.0                         0.0s
[+] Running 3/3
 ✔ Network katalog_default  Created                                       0.2s
 ✔ Container katalog-db-1   Healthy                                       9.4s
 ✔ Container katalog-app-1  Started                                       1.1s

Bagian build dipotong — seluruh langkahnya CACHED karena image katalog-app:1.0 sudah dibangun pada Bagian 2. Tiga baris ✔ adalah urutan kesiapan yang kita rancang: network dibuat, lalu Compose menunggu katalog-db-1 sampai berstatus Healthy — angka 9.4 detik itu termasuk tarik image postgres, inisialisasi cluster perdana, dan healthcheck yang lulus — baru katalog-app-1 dinyalakan. Migrasi yang dijanjikan Bagian 1 bisa disaksikan dari log:

bash
$ docker compose logs app

Output yang Dihasilkan:

text
katalog-app-1  | 2026/10/13 14:43:20 migrasi 001_create_items.sql diterapkan
katalog-app-1  | 2026/10/13 14:43:20 server mendengarkan di :8080

Dua baris ini menceritakan startup yang sehat: migrasi diterapkan, baru server mendengarkan — tidak ada baris error, tidak ada retry koneksi. Sekarang kita ketuk keempat pintu yang tersisa. Pertama kesehatan:

bash
$ curl -i http://localhost:8080/healthz

Output yang Dihasilkan:

text
HTTP/1.1 200 OK
Content-Type: application/json
Date: Tue, 13 Oct 2026 14:44:01 GMT
Content-Length: 16

{"status":"ok"}

Kedua, tabel pencatat migrasi di dalam database — dibaca lewat psql yang sudah ada di dalam container, tanpa perlu klien terpasang di host:

bash
$ docker compose exec db psql -U katalog -d katalog -c 'SELECT version, applied_at FROM schema_migrations;'

Output yang Dihasilkan:

text
       version        |          applied_at           
----------------------+-------------------------------
 001_create_items.sql | 2026-10-13 14:43:20.164951+00
(1 row)

Baris tunggal itu bukti migrate() bekerja: nama berkas tercatat di schema_migrations dengan waktu terap yang berselisih detik dari log aplikasi di atas. Ketiga, item pertama lewat pintu yang sama seperti di halaman overview:

bash
$ curl -s -X POST http://localhost:8080/api/items \
  -H "Content-Type: application/json" \
  -d '{"name":"Kopi Torabika","note":"favorit pelanggan"}'

Output yang Dihasilkan:

text
{"id":1,"name":"Kopi Torabika","note":"favorit pelanggan","created_at":"2026-10-13T14:44:01.035749Z"}

Dan keempat, item itu dibaca kembali dari jalur detail:

bash
$ curl -s http://localhost:8080/api/items/1

Output yang Dihasilkan:

text
{"id":1,"name":"Kopi Torabika","note":"favorit pelanggan","created_at":"2026-10-13T14:44:01.035749Z"}

id yang sama, created_at yang sama — baris yang sama, ditulis sekali lewat INSERT dan dibaca berkali-kali lewat SELECT tanpa container app menyimpan apa pun. Sebelum menutup bagian, ada satu pemeriksaan yang sebaiknya kita lakukan justru untuk menyadari batasnya:

bash
$ docker inspect katalog-app-1 --format '{{range .Config.Env}}{{println .}}{{end}}'

Output yang Dihasilkan:

text
PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
PGHOST=db
PGPORT=5432
PGUSER=katalog
PGPASSWORD=rahasia123
PGDATABASE=katalog

Stack hidup dengan urutan yang kita tulis, migrasi terjadi tanpa disuruh dua kali, dan keempat endpoint menjawab. Tetapi dua pertanyaan besar belum terjawab: bagaimana tepatnya nama db berubah menjadi alamat yang bisa dihubungi app, dan apa yang terjadi pada baris Kopi Torabika itu bila stack kita bongkar total? Bagian berikutnya menjawab keduanya sekaligus — membuka network katalog_default untuk melihat DNS internalnya, lalu menjalankan ritual down dan up untuk membuktikan bahwa volume benar-benar menjaga data kita.

Referensi #

Berkas Sumber

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

compose.yaml

Unduh berkas
yaml
name: katalog

services:
  app:
    build: .
    image: katalog-app:1.0
    ports:
      - "8080:8080"
    environment:
      PGHOST: db
      PGPORT: "5432"
      PGUSER: katalog
      PGPASSWORD: ${POSTGRES_PASSWORD:?wajib diset dari .env}
      PGDATABASE: katalog
    depends_on:
      db:
        condition: service_healthy
    restart: unless-stopped

  db:
    image: postgres:18-alpine
    environment:
      POSTGRES_USER: katalog
      POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?wajib diset dari .env}
      POSTGRES_DB: katalog
    volumes:
      - db-data:/var/lib/postgresql/data
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U katalog -d katalog"]
      interval: 2s
      timeout: 2s
      retries: 15
    restart: unless-stopped

  # Service test hanya berjalan dengan profile khusus:
  #   docker compose --profile test run --rm test
  test:
    profiles: ["test"]
    image: golang:1.24-alpine
    working_dir: /src
    volumes:
      - ./:/src
    environment:
      APP_URL: http://app:8080
      PGHOST: db
      PGPORT: "5432"
      PGUSER: katalog
      PGPASSWORD: ${POSTGRES_PASSWORD:?wajib diset dari .env}
      PGDATABASE: katalog
    depends_on:
      app:
        condition: service_started
      db:
        condition: service_healthy
    command: go test -v ./tests/

volumes:
  db-data:

database.go

Unduh berkas
go
// koneksi database dan runner migrasi sederhana.
//
// Open() mencoba terhubung hingga 10 kali dengan jeda 500 ms — pola ini
// menutup celah startup saat container database belum sepenuhnya siap.
// migrate() membaca migrations/*.sql secara berurutan dan mencatat versi
// yang sudah diterapkan ke tabel schema_migrations.
package main

import (
	"context"
	"database/sql"
	"fmt"
	"log"
	"os"
	"path/filepath"
	"sort"
	"strings"
	"time"

	_ "github.com/jackc/pgx/v5/stdlib" // mendaftarkan driver "pgx" untuk database/sql
)

// jumlahPercobaan dan jedaPercobaan mengatur retry koneksi database.
const (
	jumlahPercobaan = 10
	jedaPercobaan   = 500 * time.Millisecond
)

// Open membuka koneksi PostgreSQL dan memastikan benar-benar hidup
// dengan PingContext sebelum dikembalikan.
func Open(ctx context.Context, dsn string) (*sql.DB, error) {
	var errTerakhir error
	for percobaan := 1; percobaan <= jumlahPercobaan; percobaan++ {
		db, err := sql.Open("pgx", dsn)
		if err == nil {
			if err = db.PingContext(ctx); err == nil {
				return db, nil
			}
			db.Close()
		}
		errTerakhir = err
		log.Printf("koneksi database gagal (percobaan %d/%d): %v", percobaan, jumlahPercobaan, err)
		select {
		case <-ctx.Done():
			return nil, ctx.Err()
		case <-time.After(jedaPercobaan):
		}
	}
	return nil, fmt.Errorf("database tidak dapat dijangkau setelah %d percobaan: %w", jumlahPercobaan, errTerakhir)
}

// migrate menerapkan setiap berkas migrations/*.sql yang belum tercatat
// di schema_migrations. Satu berkas berjalan dalam satu transaksi,
// bersama pencatatan versinya, supaya keduanya sukses atau gagal bersama.
func migrate(ctx context.Context, db *sql.DB) error {
	if _, err := db.ExecContext(ctx, `
		CREATE TABLE IF NOT EXISTS schema_migrations (
			version    TEXT PRIMARY KEY,
			applied_at TIMESTAMPTZ NOT NULL DEFAULT now()
		)`); err != nil {
		return fmt.Errorf("membuat tabel schema_migrations: %w", err)
	}

	entri, err := os.ReadDir("migrations")
	if err != nil {
		return fmt.Errorf("membaca direktori migrations: %w", err)
	}

	var berkas []string
	for _, e := range entri {
		if !e.IsDir() && strings.HasSuffix(e.Name(), ".sql") {
			berkas = append(berkas, e.Name())
		}
	}
	sort.Strings(berkas)

	for _, nama := range berkas {
		var sudah bool
		if err := db.QueryRowContext(ctx,
			`SELECT EXISTS (SELECT 1 FROM schema_migrations WHERE version = $1)`, nama,
		).Scan(&sudah); err != nil {
			return fmt.Errorf("memeriksa versi %s: %w", nama, err)
		}
		if sudah {
			log.Printf("migrasi %s sudah diterapkan, dilewati", nama)
			continue
		}

		isi, err := os.ReadFile(filepath.Join("migrations", nama))
		if err != nil {
			return fmt.Errorf("membaca %s: %w", nama, err)
		}

		tx, err := db.BeginTx(ctx, nil)
		if err != nil {
			return fmt.Errorf("memulai transaksi %s: %w", nama, err)
		}
		if _, err := tx.ExecContext(ctx, string(isi)); err != nil {
			tx.Rollback()
			return fmt.Errorf("menjalankan %s: %w", nama, err)
		}
		if _, err := tx.ExecContext(ctx,
			`INSERT INTO schema_migrations (version) VALUES ($1)`, nama,
		); err != nil {
			tx.Rollback()
			return fmt.Errorf("mencatat %s: %w", nama, err)
		}
		if err := tx.Commit(); err != nil {
			return fmt.Errorf("komit %s: %w", nama, err)
		}
		log.Printf("migrasi %s diterapkan", nama)
	}
	return nil
}

handlers.go

Unduh berkas
go
// handler HTTP untuk API katalog.
//
// Semua respons JSON dikirim lewat writeJSON agar Content-Type konsisten.
// Validasi input dilakukan sebelum menyentuh database: name wajib terisi,
// note opsional dengan batas 200 karakter.
package main

import (
	"database/sql"
	"encoding/json"
	"errors"
	"io"
	"net/http"
	"strconv"
	"strings"
	"time"
	"unicode/utf8"
)

// item adalah satu baris tabel items; tag json menentukan bentuk respons.
type item struct {
	ID        int64     `json:"id"`
	Name      string    `json:"name"`
	Note      string    `json:"note"`
	CreatedAt time.Time `json:"created_at"`
}

// itemBaru adalah bentuk body request POST /api/items.
type itemBaru struct {
	Name string `json:"name"`
	Note string `json:"note"`
}

// batasCatatan membatasi panjang field note agar data tetap wajar.
const batasCatatan = 200

// writeJSON menulis respons JSON dengan status HTTP yang diberikan.
func writeJSON(w http.ResponseWriter, status int, data any) {
	w.Header().Set("Content-Type", "application/json")
	w.WriteHeader(status)
	_ = json.NewEncoder(w).Encode(data)
}

// tulisError mengirim pesan error standar berbentuk {"error": "..."}.
func tulisError(w http.ResponseWriter, status int, pesan string) {
	writeJSON(w, status, map[string]string{"error": pesan})
}

// handleHealthz memastikan aplikasi DAN database sama-sama hidup.
func handleHealthz(db *sql.DB) http.HandlerFunc {
	return func(w http.ResponseWriter, r *http.Request) {
		var satu int
		if err := db.QueryRowContext(r.Context(), `SELECT 1`).Scan(&satu); err != nil {
			tulisError(w, http.StatusServiceUnavailable, "database tidak merespons")
			return
		}
		writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
	}
}

// handleListItems mengembalikan seluruh item sebagai array JSON.
func handleListItems(db *sql.DB) http.HandlerFunc {
	return func(w http.ResponseWriter, r *http.Request) {
		rows, err := db.QueryContext(r.Context(),
			`SELECT id, name, note, created_at FROM items ORDER BY id`)
		if err != nil {
			tulisError(w, http.StatusInternalServerError, "gagal membaca items")
			return
		}
		defer rows.Close()

		items := []item{}
		for rows.Next() {
			var it item
			if err := rows.Scan(&it.ID, &it.Name, &it.Note, &it.CreatedAt); err != nil {
				tulisError(w, http.StatusInternalServerError, "gagal membaca baris item")
				return
			}
			items = append(items, it)
		}
		writeJSON(w, http.StatusOK, items)
	}
}

// handleCreateItem memvalidasi body lalu menyimpan item baru.
func handleCreateItem(db *sql.DB) http.HandlerFunc {
	return func(w http.ResponseWriter, r *http.Request) {
		// Body dibatasi 1 MB agar request besar tidak menghabiskan memori.
		var body itemBaru
		if err := json.NewDecoder(io.LimitReader(r.Body, 1<<20)).Decode(&body); err != nil {
			tulisError(w, http.StatusBadRequest, "body JSON tidak valid")
			return
		}

		name := strings.TrimSpace(body.Name)
		if name == "" {
			tulisError(w, http.StatusBadRequest, "name wajib diisi")
			return
		}
		if utf8.RuneCountInString(body.Note) > batasCatatan {
			tulisError(w, http.StatusBadRequest, "note maksimal 200 karakter")
			return
		}

		var it item
		err := db.QueryRowContext(r.Context(),
			`INSERT INTO items (name, note) VALUES ($1, $2)
             RETURNING id, name, note, created_at`,
			name, body.Note,
		).Scan(&it.ID, &it.Name, &it.Note, &it.CreatedAt)
		if err != nil {
			tulisError(w, http.StatusInternalServerError, "gagal menyimpan item")
			return
		}
		writeJSON(w, http.StatusCreated, it)
	}
}

// handleGetItem mengembalikan satu item berdasarkan {id} di path.
func handleGetItem(db *sql.DB) http.HandlerFunc {
	return func(w http.ResponseWriter, r *http.Request) {
		id, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
		if err != nil {
			tulisError(w, http.StatusBadRequest, "id tidak valid")
			return
		}

		var it item
		err = db.QueryRowContext(r.Context(),
			`SELECT id, name, note, created_at FROM items WHERE id = $1`, id,
		).Scan(&it.ID, &it.Name, &it.Note, &it.CreatedAt)
		if errors.Is(err, sql.ErrNoRows) {
			tulisError(w, http.StatusNotFound, "item tidak ditemukan")
			return
		}
		if err != nil {
			tulisError(w, http.StatusInternalServerError, "gagal membaca item")
			return
		}
		writeJSON(w, http.StatusOK, it)
	}
}

main.go

Unduh berkas
go
// Package katalog adalah REST API sederhana untuk daftar item.
//
// Konfigurasi dibaca dari environment variable (pola twelve-factor):
// PORT menentukan port HTTP, sedangkan PG* menentukan koneksi PostgreSQL.
package main

import (
	"context"
	"database/sql"
	"errors"
	"fmt"
	"log"
	"net/http"
	"os"
	"os/signal"
	"syscall"
	"time"
)

// konfigurasi menyimpan seluruh nilai yang dibaca dari environment.
type konfigurasi struct {
	Port string
	DSN  string
}

// bacaKonfigurasi mengambil PORT dan membangun DSN dari variabel PG*.
// Nilai default dipilih agar aplikasi juga bisa jalan di luar container.
func bacaKonfigurasi() konfigurasi {
	ambil := func(kunci, standar string) string {
		if v := os.Getenv(kunci); v != "" {
			return v
		}
		return standar
	}
	dsn := fmt.Sprintf("host=%s port=%s user=%s password=%s dbname=%s sslmode=disable",
		ambil("PGHOST", "localhost"),
		ambil("PGPORT", "5432"),
		ambil("PGUSER", "katalog"),
		os.Getenv("PGPASSWORD"),
		ambil("PGDATABASE", "katalog"),
	)
	return konfigurasi{
		Port: ambil("PORT", "8080"),
		DSN:  dsn,
	}
}

// routes memetakan endpoint ke handler dengan pola routing Go 1.22+.
func routes(db *sql.DB) http.Handler {
	mux := http.NewServeMux()
	mux.HandleFunc("GET /healthz", handleHealthz(db))
	mux.HandleFunc("GET /api/items", handleListItems(db))
	mux.HandleFunc("POST /api/items", handleCreateItem(db))
	mux.HandleFunc("GET /api/items/{id}", handleGetItem(db))
	return mux
}

func main() {
	cfg := bacaKonfigurasi()

	// Context dibatalkan saat proses menerima SIGINT atau SIGTERM.
	ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
	defer stop()

	db, err := Open(ctx, cfg.DSN)
	if err != nil {
		log.Fatalf("membuka database: %v", err)
	}
	defer db.Close()

	if err := migrate(ctx, db); err != nil {
		log.Fatalf("menjalankan migrasi: %v", err)
	}

	srv := &http.Server{
		Addr:              ":" + cfg.Port,
		Handler:           routes(db),
		ReadHeaderTimeout: 5 * time.Second,
	}

	// Saat sinyal berakhir diterima, beri waktu 10 detik untuk
	// menyelesaikan request yang sedang diproses (graceful shutdown).
	go func() {
		<-ctx.Done()
		log.Println("sinyal berakhir diterima, mematikan server...")
		shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
		defer cancel()
		if err := srv.Shutdown(shutdownCtx); err != nil {
			log.Printf("shutdown: %v", err)
		}
	}()

	log.Printf("server mendengarkan di :%s", cfg.Port)
	if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
		log.Fatalf("server: %v", err)
	}
}

migrations/001_create_items.sql

Unduh berkas
sql
-- Tabel utama katalog: satu baris mewakili satu item yang bisa dibuat
-- lewat POST /api/items. id dibangkitkan otomatis oleh BIGSERIAL.
CREATE TABLE IF NOT EXISTS items (
    id         BIGSERIAL PRIMARY KEY,
    name       TEXT NOT NULL,
    note       TEXT DEFAULT '',
    created_at TIMESTAMPTZ DEFAULT now()
);

tests/integration_test.go

Unduh berkas
go
// Test black-box untuk API katalog.
//
// Test ini tidak memanggil fungsi internal aplikasi; yang diperiksa adalah
// perilaku HTTP persis seperti yang dirasakan klien sungguhan. Aplikasi
// harus sudah berjalan di APP_URL (default http://localhost:8080) dengan
// database yang dapat dijangkau lewat TEST_DATABASE_URL atau variabel PG*.
package tests

import (
	"bytes"
	"context"
	"encoding/json"
	"fmt"
	"net/http"
	"os"
	"strings"
	"testing"
	"time"

	"github.com/jackc/pgx/v5"
)

// appURL adalah alamat aplikasi yang sedang diuji.
var appURL string

// TestMain memastikan aplikasi siap sebelum seluruh test dijalankan.
func TestMain(m *testing.M) {
	appURL = os.Getenv("APP_URL")
	if appURL == "" {
		appURL = "http://localhost:8080"
	}
	if err := tungguSiap(30 * time.Second); err != nil {
		fmt.Println("aplikasi tidak siap:", err)
		os.Exit(1)
	}
	os.Exit(m.Run())
}

// tungguSiap melakukan polling /healthz sampai aplikasi merespons
// atau batas waktu terlampaui — container sering butuh beberapa detik
// sebelum benar-benar siap menerima request.
func tungguSiap(batas time.Duration) error {
	mulai := time.Now()
	for {
		resp, err := http.Get(appURL + "/healthz")
		if err == nil {
			resp.Body.Close()
			if resp.StatusCode == http.StatusOK {
				return nil
			}
		}
		if time.Since(mulai) > batas {
			return fmt.Errorf("timeout menunggu %s/healthz setelah %s", appURL, batas)
		}
		time.Sleep(500 * time.Millisecond)
	}
}

// dsnTest menyusun DSN untuk reset database: TEST_DATABASE_URL diutamakan,
// jika kosong variabel PG* dirangkai dengan nilai default lokal.
func dsnTest() string {
	if dsn := os.Getenv("TEST_DATABASE_URL"); dsn != "" {
		return dsn
	}
	ambil := func(kunci, standar string) string {
		if v := os.Getenv(kunci); v != "" {
			return v
		}
		return standar
	}
	return fmt.Sprintf("host=%s port=%s user=%s dbname=%s sslmode=disable",
		ambil("PGHOST", "localhost"),
		ambil("PGPORT", "5432"),
		ambil("PGUSER", "katalog"),
		ambil("PGDATABASE", "katalog"),
	)
}

// resetItems mengosongkan tabel items dan mengembalikan counter id ke 1
// sehingga setiap test mulai dari kondisi yang sama.
func resetItems(t *testing.T) {
	t.Helper()
	ctx := context.Background()
	conn, err := pgx.Connect(ctx, dsnTest())
	if err != nil {
		t.Fatalf("koneksi database untuk reset: %v", err)
	}
	defer conn.Close(ctx)
	if _, err := conn.Exec(ctx, `TRUNCATE items RESTART IDENTITY`); err != nil {
		t.Fatalf("reset tabel items: %v", err)
	}
}

// postJSON mengirim body JSON dan mengembalikan respons mentahnya.
func postJSON(t *testing.T, path string, body any) *http.Response {
	t.Helper()
	isi, err := json.Marshal(body)
	if err != nil {
		t.Fatalf("marshal body: %v", err)
	}
	resp, err := http.Post(appURL+path, "application/json", bytes.NewReader(isi))
	if err != nil {
		t.Fatalf("POST %s: %v", path, err)
	}
	t.Cleanup(func() { resp.Body.Close() })
	return resp
}

// getJSON mengirim GET dan mengurai hasilnya ke target.
func getJSON(t *testing.T, path string, target any) *http.Response {
	t.Helper()
	resp, err := http.Get(appURL + path)
	if err != nil {
		t.Fatalf("GET %s: %v", path, err)
	}
	t.Cleanup(func() { resp.Body.Close() })
	if err := json.NewDecoder(resp.Body).Decode(target); err != nil {
		t.Fatalf("decode respons GET %s: %v", path, err)
	}
	return resp
}

func TestHealthz(t *testing.T) {
	var body struct {
		Status string `json:"status"`
	}
	resp := getJSON(t, "/healthz", &body)
	if resp.StatusCode != http.StatusOK {
		t.Fatalf("status = %d, ingin 200", resp.StatusCode)
	}
	if body.Status != "ok" {
		t.Fatalf("status body = %q, ingin \"ok\"", body.Status)
	}
}

func TestCreateListGetItem(t *testing.T) {
	resetItems(t)

	t.Run("create", func(t *testing.T) {
		resp := postJSON(t, "/api/items", map[string]string{
			"name": "Kopi Torabika",
			"note": "untuk pengujian",
		})
		if resp.StatusCode != http.StatusCreated {
			t.Fatalf("status = %d, ingin 201", resp.StatusCode)
		}
		var it struct {
			ID   int64  `json:"id"`
			Name string `json:"name"`
			Note string `json:"note"`
		}
		if err := json.NewDecoder(resp.Body).Decode(&it); err != nil {
			t.Fatalf("decode item: %v", err)
		}
		if it.ID != 1 {
			t.Fatalf("id = %d, ingin 1 (tabel baru di-reset)", it.ID)
		}
		if it.Name != "Kopi Torabika" {
			t.Fatalf("name = %q, ingin \"Kopi Torabika\"", it.Name)
		}
	})

	t.Run("list", func(t *testing.T) {
		var items []struct {
			ID   int64  `json:"id"`
			Name string `json:"name"`
		}
		resp := getJSON(t, "/api/items", &items)
		if resp.StatusCode != http.StatusOK {
			t.Fatalf("status = %d, ingin 200", resp.StatusCode)
		}
		if len(items) != 1 || items[0].Name != "Kopi Torabika" {
			t.Fatalf("list = %+v, ingin satu item Kopi Torabika", items)
		}
	})

	t.Run("get", func(t *testing.T) {
		var it struct {
			ID   int64  `json:"id"`
			Name string `json:"name"`
			Note string `json:"note"`
		}
		resp := getJSON(t, "/api/items/1", &it)
		if resp.StatusCode != http.StatusOK {
			t.Fatalf("status = %d, ingin 200", resp.StatusCode)
		}
		if it.Name != "Kopi Torabika" || it.Note != "untuk pengujian" {
			t.Fatalf("item = %+v, name/note tidak sesuai", it)
		}
	})
}

func TestGetItemNotFound(t *testing.T) {
	resetItems(t)
	var body map[string]string
	resp := getJSON(t, "/api/items/999", &body)
	if resp.StatusCode != http.StatusNotFound {
		t.Fatalf("status = %d, ingin 404", resp.StatusCode)
	}
	if body["error"] == "" {
		t.Fatal("respons 404 seharusnya memuat pesan error")
	}
}

func TestCreateItemValidasi(t *testing.T) {
	resetItems(t)

	t.Run("nama kosong", func(t *testing.T) {
		resp := postJSON(t, "/api/items", map[string]string{
			"name": "",
			"note": "",
		})
		if resp.StatusCode != http.StatusBadRequest {
			t.Fatalf("status = %d, ingin 400", resp.StatusCode)
		}
	})

	t.Run("catatan terlalu panjang", func(t *testing.T) {
		resp := postJSON(t, "/api/items", map[string]string{
			"name": "Kopi Gayo",
			"note": strings.Repeat("x", 201),
		})
		if resp.StatusCode != http.StatusBadRequest {
			t.Fatalf("status = %d, ingin 400", resp.StatusCode)
		}
	})
}