Bagian penutup menghubungkan semua yang telah dibangun dengan dunia nyata: konfigurasi yang dibaca dari environment dan divalidasi sejak awal, piramida pengujian dari unit hingga integrasi, dan container image dua tahap yang menghasilkan binary tunggal berukuran belasan megabyte. Setelah bagian ini, URL shortener Anda bukan lagi latihan — ia layanan yang dapat dijalankan dengan satu perintah compose.

Konfigurasi Fail-Fast dari Environment #

config.go merangkum seluruh pengaturan dalam satu struct dan menolak konfigurasi setengah jadi:

go
func Load() (Config, error) {
	cfg := Config{
		Port:        envOr("APP_PORT", defaultPort),
		BaseURL:     strings.TrimSuffix(envOr("APP_BASE_URL", defaultBaseURL), "/"),
		DatabaseURL: firstNonEmpty(os.Getenv("TEST_DATABASE_URL"), os.Getenv("DATABASE_URL")),
	}

	if cfg.DatabaseURL == "" {
		return cfg, errors.New("DATABASE_URL kosong: setel misalnya DATABASE_URL=postgres://user:pass@localhost:5432/urls?sslmode=disable")
	}
	...
}

Load() menerapkan pola Configuration Management : nilai bawaan untuk yang boleh kosong, galat eksplisit untuk yang wajib. BaseURL dipangkas garis miring ekornya agar pembentukan short URL dengan fmt.Sprintf selalu benar, dan nomor port divalidasi numerik sebelum server dibangun. Karena main.go memanggil Load() paling awal, kesalahan konfigurasi mati dalam < 1 detik dengan pesan yang menyebut solusinya — bukan tengah malam saat pool koneksi kehabisan napas.

Piramida Pengujian Tiga Lapis #

Proyek ini menguji di tiga ketinggian. Lapisan unit (service_test.go, handler_test.go, config_test.go) berjalan di atas MemoryURLRepository, FixedClock, dan generator stub: cepat, deterministik, dan mencakup jalur tabrakan kode, validasi, serta 404. Lapisan handler memakai httptest.NewRecorder persis pola chapter httptest . Lapisan integrasi menyentuh PostgreSQL sungguhan:

go
func TestIntegrationShortenRedirectStats(t *testing.T) {
	dsn := os.Getenv("TEST_DATABASE_URL")
	if dsn == "" {
		t.Skip("TEST_DATABASE_URL kosong: integration test membutuhkan PostgreSQL aktif")
	}
	...
}

Tanpa environment variable tersebut, test ini melompat dengan t.Skip sehingga go test ./... tetap hijau di lingkungan offline — pola yang sama dipakai proyek REST API dan dibahas pada chapter Testing Database . Dengan database aktif, seluruh alur shorten → redirect → stats dijalankan end-to-end termasuk migration. Mari kita jalankan keduanya dan lihat perilakunya:

bash
go test ./... && go test -race ./...

Output yang Dihasilkan:

text
ok  	example.com/url-shortener	0.012s
ok  	example.com/url-shortener	1.019s

Baris kedua adalah bukti race detector tidak menemukan akses memori liar — penting karena handler statistik, klik, dan generator kode semuanya menyentuh state bersama.

Image Dua Tahap dengan distroless #

Dockerfile proyek memakai multi-stage build yang dibahas pada chapter Docker Multi-Stage Build :

dockerfile
FROM golang:1.27 AS build
WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 go build -o /bin/shortener .

FROM gcr.io/distroless/static-debian12:nonroot
COPY --from=build /bin/shortener /shortener
EXPOSE 8080
ENTRYPOINT ["/shortener"]

Dua keputusan pentingnya: go.mod dan go.sum disalin sebelum sumber sehingga layer go mod download ter-cache antar build; dan CGO_ENABLED=0 menghasilkan binary statis yang bisa tinggal di image distroless tanpa shell maupun glibc. Image akhir berukuran belasan megabyte — lebih kecil daripada satu foto ponsel — dan berjalan sebagai user nonroot sesuai prinsip Environment Production .

Menjalankan Stack Lengkap #

compose.yaml merangkai database dan API dalam satu perintah:

yaml
services:
  db:
    image: postgres:17
    environment:
      POSTGRES_USER: user
      POSTGRES_PASSWORD: pass
      POSTGRES_DB: urls
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U user -d urls"]
      interval: 5s
      timeout: 3s
      retries: 10
    volumes:
      - pgdata:/var/lib/postgresql/data

  api:
    build: .
    environment:
      APP_PORT: "8080"
      APP_BASE_URL: "http://localhost:8080"
      DATABASE_URL: postgres://user:pass@db:5432/urls?sslmode=disable
    ports:
      - "8080:8080"
    depends_on:
      db:
        condition: service_healthy

Perhatikan condition: service_healthy: API hanya dimulai setelah pg_isready berhasil, menghilangkan masalah start yang berpacu. Setelah docker compose up --build, layanan siap di port 8080: kirim POST /shorten dengan body {"url": "https://example.com"}, lalu buka short URL yang dikembalikan di browser dan periksa statistiknya di GET /stats/{code} — migration berjalan otomatis saat start.

Penutup Proyek #

URL shortener menuntut hampir seluruh peta Course ini: domain modeling dan interface kecil, pembuatan kode dengan sumber acak yang tepat, repository dengan terjemahan galat, handler HTTP dengan status code yang cermat, observabilitas lewat health check, hingga containerization. Pola go run . demo memungkinkan seluruh alur dipelajari tanpa infrastruktur; compose up mengangkatnya menjadi layanan lengkap. Dengan lima proyek lain di section ini — kalkulator, pengelola tugas, web server, REST API, dan worker pool — Anda kini memiliki enam contoh kerangka kerja yang dapat ditiru untuk proyek Anda sendiri.

Referensi #

Berkas Sumber

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

compose.yaml

Unduh berkas
yaml
services:
  db:
    image: postgres:17
    environment:
      POSTGRES_USER: user
      POSTGRES_PASSWORD: pass
      POSTGRES_DB: urls
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U user -d urls"]
      interval: 5s
      timeout: 3s
      retries: 10
    volumes:
      - pgdata:/var/lib/postgresql/data

  api:
    build: .
    environment:
      APP_PORT: "8080"
      APP_BASE_URL: "http://localhost:8080"
      DATABASE_URL: postgres://user:pass@db:5432/urls?sslmode=disable
    ports:
      - "8080:8080"
    depends_on:
      db:
        condition: service_healthy

volumes:
  pgdata:

config_test.go

Unduh berkas
go
package main

import (
	"os"
	"strings"
	"testing"
)

const dsnUji = "postgres://user:pass@localhost:5432/urls?sslmode=disable"

// kosongkanEnv menghapus variabel lingkungan name selama test berjalan
// lalu memulihkan nilainya setelah test selesai. t.Setenv mendaftarkan
// pemulihannya; os.Unsetenv melakukan penghapusan sesungguhnya.
func kosongkanEnv(t *testing.T, name string) {
	t.Helper()
	t.Setenv(name, "")
	os.Unsetenv(name)
}

func TestLoadNilaiDefault(t *testing.T) {
	t.Setenv("DATABASE_URL", dsnUji)

	cfg, err := Load()
	if err != nil {
		t.Fatalf("Load() galat tak terduga: %v", err)
	}
	if cfg.Port != "8080" {
		t.Errorf("Port = %q, ingin default \"8080\"", cfg.Port)
	}
	if cfg.BaseURL != "http://localhost:8080" {
		t.Errorf("BaseURL = %q, ingin default http://localhost:8080", cfg.BaseURL)
	}
}

func TestLoadDatabaseURLKosong(t *testing.T) {
	kosongkanEnv(t, "DATABASE_URL")
	kosongkanEnv(t, "TEST_DATABASE_URL")

	cfg, err := Load()
	if err == nil {
		t.Fatal("Load() ingin galat saat DATABASE_URL kosong")
	}
	if cfg.DatabaseURL != "" {
		t.Errorf("DatabaseURL = %q, ingin kosong", cfg.DatabaseURL)
	}
	if !strings.Contains(err.Error(), "DATABASE_URL") {
		t.Errorf("pesan galat %q tidak menyebut DATABASE_URL", err.Error())
	}
}

func TestLoadBaseURLTidakValid(t *testing.T) {
	t.Setenv("DATABASE_URL", dsnUji)

	t.Setenv("APP_BASE_URL", "ftp://short.example.com")
	if _, err := Load(); err == nil {
		t.Error("APP_BASE_URL dengan skema ftp ingin galat, tetapi Load() sukses")
	}

	t.Setenv("APP_BASE_URL", "short.example.com")
	if _, err := Load(); err == nil {
		t.Error("APP_BASE_URL tanpa skema ingin galat, tetapi Load() sukses")
	}
}

func TestLoadUtamakanTestDatabaseURL(t *testing.T) {
	t.Setenv("DATABASE_URL", "postgres://user:pass@db-produksi/urls")
	t.Setenv("TEST_DATABASE_URL", "postgres://user:pass@db-uji/urls_test")

	cfg, err := Load()
	if err != nil {
		t.Fatalf("Load() galat tak terduga: %v", err)
	}
	if cfg.DatabaseURL != "postgres://user:pass@db-uji/urls_test" {
		t.Errorf("DatabaseURL = %q, ingin TEST_DATABASE_URL diutamakan", cfg.DatabaseURL)
	}
}

config.go

Unduh berkas
go
package main

import (
	"errors"
	"fmt"
	"net/url"
	"os"
	"strconv"
	"strings"
)

// Config menyimpan seluruh pengaturan layanan pemendek URL.
type Config struct {
	// Port adalah nomor port TCP tempat server HTTP mendengarkan.
	Port string
	// BaseURL adalah alamat publik layanan, misalnya https://short.example.com.
	// Short URL dibangun dari nilai ini.
	BaseURL string
	// DatabaseURL adalah connection string PostgreSQL.
	DatabaseURL string
}

// Nilai default ketika environment variable tidak disetel.
const (
	defaultPort    = "8080"
	defaultBaseURL = "http://localhost:8080"
)

// Load membaca environment variable, menerapkan default, lalu
// memvalidasi hasilnya secara fail-fast sebelum server dibangun.
func Load() (Config, error) {
	cfg := Config{
		Port:        envOr("APP_PORT", defaultPort),
		BaseURL:     strings.TrimSuffix(envOr("APP_BASE_URL", defaultBaseURL), "/"),
		DatabaseURL: firstNonEmpty(os.Getenv("TEST_DATABASE_URL"), os.Getenv("DATABASE_URL")),
	}

	if cfg.DatabaseURL == "" {
		return cfg, errors.New("DATABASE_URL kosong: setel misalnya DATABASE_URL=postgres://user:pass@localhost:5432/urls?sslmode=disable")
	}
	if err := cekPort(cfg.Port); err != nil {
		return cfg, err
	}
	if err := cekBaseURL(cfg.BaseURL); err != nil {
		return cfg, err
	}
	return cfg, nil
}

// envOr mengembalikan nilai environment variable name, atau fallback
// ketika variabel tersebut tidak disetel atau kosong.
func envOr(name, fallback string) string {
	if v := os.Getenv(name); v != "" {
		return v
	}
	return fallback
}

// firstNonEmpty mengembalikan string pertama yang tidak kosong.
// TEST_DATABASE_URL diutamakan agar integration test bisa mengarah
// ke database khusus tanpa mengubah DATABASE_URL produksi.
func firstNonEmpty(values ...string) string {
	for _, v := range values {
		if v != "" {
			return v
		}
	}
	return ""
}

// cekPort memastikan port berupa bilangan bulat antara 1 dan 65535.
func cekPort(port string) error {
	n, err := strconv.Atoi(port)
	if err != nil {
		return fmt.Errorf("APP_PORT tidak valid: %q bukan bilangan bulat", port)
	}
	if n < 1 || n > 65535 {
		return fmt.Errorf("APP_PORT tidak valid: %d di luar rentang 1-65535", n)
	}
	return nil
}

// cekBaseURL memastikan BaseURL bisa diparse dan memakai skema http
// atau https; short URL yang dibangun dari nilai lain akan rusak.
func cekBaseURL(base string) error {
	u, err := url.Parse(base)
	if err != nil {
		return fmt.Errorf("APP_BASE_URL tidak valid: %v", err)
	}
	if u.Scheme != "http" && u.Scheme != "https" {
		return fmt.Errorf("APP_BASE_URL tidak valid: skema harus http atau https, didapat %q", u.Scheme)
	}
	if u.Host == "" {
		return errors.New("APP_BASE_URL tidak valid: host kosong")
	}
	return nil
}

database.go

Unduh berkas
go
package main

import (
	"context"
	"database/sql"
	"embed"
	"fmt"
	"io/fs"
	"path/filepath"
	"strings"
	"time"

	// Blank import mendaftarkan driver "pgx" ke database/sql.
	_ "github.com/jackc/pgx/v5/stdlib"
)

// migrationFS menyimpan seluruh berkas SQL di folder migrations ke
// dalam binary, sehingga image Docker tidak perlu menyalin folder ini.
//
//go:embed migrations/*.sql
var migrationFS embed.FS

// OpenDatabase membuka pool koneksi PostgreSQL lalu memastikan
// database benar-benar merespons lewat PingContext. sql.Open bersifat
// malas: tanpa ping, galat koneksi baru muncul saat query pertama.
func OpenDatabase(ctx context.Context, dsn string) (*sql.DB, error) {
	db, err := sql.Open("pgx", dsn)
	if err != nil {
		return nil, fmt.Errorf("membuka koneksi: %w", err)
	}

	pingCtx, cancel := context.WithTimeout(ctx, 5*time.Second)
	defer cancel()
	if err := db.PingContext(pingCtx); err != nil {
		db.Close()
		return nil, fmt.Errorf("ping database: %w", err)
	}
	return db, nil
}

// RunMigrations menjalankan seluruh berkas migrations/*.sql yang belum
// diterapkan. Versi tercatat di tabel schema_migrations sehingga
// menjalankan migration dua kali aman (idempoten).
func RunMigrations(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)
	}

	files, err := fs.Glob(migrationFS, "migrations/*.sql")
	if err != nil {
		return fmt.Errorf("membaca daftar migration: %w", err)
	}

	for _, name := range files {
		version := strings.TrimSuffix(filepath.Base(name), ".sql")

		var sudah bool
		err := db.QueryRowContext(ctx,
			`SELECT EXISTS (SELECT 1 FROM schema_migrations WHERE version = $1)`,
			version,
		).Scan(&sudah)
		if err != nil {
			return fmt.Errorf("memeriksa migration %s: %w", version, err)
		}
		if sudah {
			continue
		}

		body, err := migrationFS.ReadFile(name)
		if err != nil {
			return fmt.Errorf("membaca berkas %s: %w", name, err)
		}

		// Satu migration dijalankan dalam satu transaksi bersama
		// pencatatannya, sehingga skema dan daftar versi selalu
		// sejalan walau proses berhenti di tengah jalan.
		tx, err := db.BeginTx(ctx, nil)
		if err != nil {
			return fmt.Errorf("memulai transaksi migration %s: %w", version, err)
		}
		if _, err := tx.ExecContext(ctx, string(body)); err != nil {
			tx.Rollback()
			return fmt.Errorf("menjalankan migration %s: %w", version, err)
		}
		if _, err := tx.ExecContext(ctx,
			`INSERT INTO schema_migrations (version) VALUES ($1)`, version,
		); err != nil {
			tx.Rollback()
			return fmt.Errorf("mencatat migration %s: %w", version, err)
		}
		if err := tx.Commit(); err != nil {
			return fmt.Errorf("mengcommit migration %s: %w", version, err)
		}
	}
	return nil
}

demo.go

Unduh berkas
go
package main

import (
	"fmt"
	"io"
	"net/http"
	"net/http/httptest"
	"strings"
	"time"
)

// waktuDemo adalah waktu beku supaya output demo selalu identik.
var waktuDemo = time.Date(2026, 1, 15, 8, 0, 0, 0, time.UTC)

// genDemo mengeluarkan kode dari daftar tetap secara bergilir —
// deterministik untuk demo (produksi memakai Base62Generator acak).
type genDemo struct {
	daftar []string
	i      int
}

// NewCode memenuhi interface CodeGenerator.
func (g *genDemo) NewCode() (string, error) {
	code := g.daftar[g.i%len(g.daftar)]
	g.i++
	return code, nil
}

// demo menjalankan alur lengkap layanan di atas MemoryURLRepository
// tanpa PostgreSQL dan tanpa membuka port: shorten → redirect (dua
// kali) → stats → galat 404. Gunakan `go run . demo` untuk mencoba.
func demo() {
	gen := &genDemo{daftar: []string{"b1Gk0D3", "zP9xQ2w"}}
	svc := NewShortenService(NewMemoryURLRepository(), gen, FixedClock{Waktu: waktuDemo})
	router := NewRouter(svc, "http://short.test")

	fmt.Println("=== Demo URL shortener (mode memori, tanpa PostgreSQL) ===")
	kirimDemo(router, "POST", "/shorten", `{"url":"https://example.com/tutorial-go-yang-sangat-panjang"}`)
	kirimDemo(router, "POST", "/shorten", `{"url":"bukan-url-lengkap"}`)
	kirimDemo(router, "GET", "/b1Gk0D3", "")
	kirimDemo(router, "GET", "/b1Gk0D3", "")
	kirimDemo(router, "GET", "/stats/b1Gk0D3", "")
	kirimDemo(router, "GET", "/stats/takDikn", "")
}

// kirimDemo mengirim satu permintaan ke router lewat recorder, lalu
// mencetak status, header Location, dan body respons.
func kirimDemo(router http.Handler, metode, target, body string) {
	var rdr io.Reader
	if body != "" {
		rdr = strings.NewReader(body)
	}
	req := httptest.NewRequest(metode, target, rdr)
	if body != "" {
		req.Header.Set("Content-Type", "application/json")
	}
	rec := httptest.NewRecorder()
	router.ServeHTTP(rec, req)

	fmt.Printf("%s %s → %d %s\n", metode, target, rec.Code, http.StatusText(rec.Code))
	if loc := rec.Header().Get("Location"); loc != "" {
		fmt.Printf("  Location: %s\n", loc)
	}
	if b := strings.TrimSpace(rec.Body.String()); b != "" {
		fmt.Printf("  body: %s\n", b)
	}
}

handler_test.go

Unduh berkas
go
package main

import (
	"encoding/json"
	"net/http"
	"net/http/httptest"
	"strings"
	"testing"
	"time"
)

// klienTanpaIkut redirect membiarkan respons 302 sampai ke test
// alih-alih diikuti otomatis oleh http.Client.
var klienTanpaIkut = &http.Client{
	CheckRedirect: func(req *http.Request, via []*http.Request) error {
		return http.ErrUseLastResponse
	},
}

// serverUji membangun layanan lengkap di atas MemoryURLRepository
// dengan generator stub dan waktu beku, lalu menjalankannya di
// httptest.Server.
func serverUji() *httptest.Server {
	gen := &genStub{daftar: []string{"b1Gk0D3"}}
	svc := NewShortenService(NewMemoryURLRepository(), gen, FixedClock{Waktu: waktuUji})
	return httptest.NewServer(NewRouter(svc, "http://short.test/"))
}

func TestAlurShortenRedirectStats(t *testing.T) {
	ts := serverUji()
	defer ts.Close()

	resp, err := http.Post(ts.URL+"/shorten", "application/json",
		strings.NewReader(`{"url":"https://example.com/tutorial-go"}`))
	if err != nil {
		t.Fatalf("POST /shorten: %v", err)
	}
	defer resp.Body.Close()
	if resp.StatusCode != http.StatusCreated {
		t.Fatalf("POST /shorten status = %d, ingin 201", resp.StatusCode)
	}
	if loc := resp.Header.Get("Location"); loc != "http://short.test/b1Gk0D3" {
		t.Errorf("Location = %q, ingin short URL", loc)
	}
	var balas struct {
		Code     string `json:"code"`
		URL      string `json:"url"`
		ShortURL string `json:"short_url"`
	}
	if err := json.NewDecoder(resp.Body).Decode(&balas); err != nil {
		t.Fatalf("mendekode body: %v", err)
	}
	if balas.Code != "b1Gk0D3" || balas.URL != "https://example.com/tutorial-go" {
		t.Errorf("balasan = %+v, tidak sesuai masukan", balas)
	}
	if balas.ShortURL != "http://short.test/b1Gk0D3" {
		t.Errorf("short_url = %q, ingin http://short.test/b1Gk0D3", balas.ShortURL)
	}

	// Dua redirect: masing-masing 302 + Location URL asli.
	for i := 1; i <= 2; i++ {
		resp2, err := klienTanpaIkut.Get(ts.URL + "/b1Gk0D3")
		if err != nil {
			t.Fatalf("GET /b1Gk0D3 ke-%d: %v", i, err)
		}
		resp2.Body.Close()
		if resp2.StatusCode != http.StatusFound {
			t.Fatalf("redirect ke-%d status = %d, ingin 302", i, resp2.StatusCode)
		}
		if loc := resp2.Header.Get("Location"); loc != "https://example.com/tutorial-go" {
			t.Errorf("redirect ke-%d Location = %q, ingin URL asli", i, loc)
		}
	}

	resp3, err := http.Get(ts.URL + "/stats/b1Gk0D3")
	if err != nil {
		t.Fatalf("GET /stats/b1Gk0D3: %v", err)
	}
	defer resp3.Body.Close()
	if resp3.StatusCode != http.StatusOK {
		t.Fatalf("GET /stats status = %d, ingin 200", resp3.StatusCode)
	}
	var stat struct {
		Code      string    `json:"code"`
		URL       string    `json:"url"`
		Clicks    int64     `json:"clicks"`
		CreatedAt time.Time `json:"created_at"`
	}
	if err := json.NewDecoder(resp3.Body).Decode(&stat); err != nil {
		t.Fatalf("mendekode body stats: %v", err)
	}
	if stat.Clicks != 2 {
		t.Errorf("klik = %d, ingin 2 setelah dua redirect", stat.Clicks)
	}
	if !stat.CreatedAt.Equal(waktuUji) {
		t.Errorf("created_at = %v, ingin %v (waktu beku)", stat.CreatedAt, waktuUji)
	}
}

func TestShortenBodyTidakValid(t *testing.T) {
	ts := serverUji()
	defer ts.Close()

	resp, err := http.Post(ts.URL+"/shorten", "text/plain", strings.NewReader(`{}`))
	if err != nil {
		t.Fatalf("POST text/plain: %v", err)
	}
	resp.Body.Close()
	if resp.StatusCode != http.StatusUnsupportedMediaType {
		t.Errorf("Content-Type salah status = %d, ingin 415", resp.StatusCode)
	}

	resp2, err := http.Post(ts.URL+"/shorten", "application/json", strings.NewReader(`bukan json`))
	if err != nil {
		t.Fatalf("POST JSON rusak: %v", err)
	}
	resp2.Body.Close()
	if resp2.StatusCode != http.StatusBadRequest {
		t.Errorf("JSON rusak status = %d, ingin 400", resp2.StatusCode)
	}

	resp3, err := http.Post(ts.URL+"/shorten", "application/json",
		strings.NewReader(`{"url":"example.com/tanpa-skema"}`))
	if err != nil {
		t.Fatalf("POST URL tanpa skema: %v", err)
	}
	defer resp3.Body.Close()
	if resp3.StatusCode != http.StatusBadRequest {
		t.Fatalf("URL tanpa skema status = %d, ingin 400", resp3.StatusCode)
	}
	var env struct {
		Error struct {
			Code   string            `json:"code"`
			Fields map[string]string `json:"fields"`
		} `json:"error"`
	}
	if err := json.NewDecoder(resp3.Body).Decode(&env); err != nil {
		t.Fatalf("mendekode body galat: %v", err)
	}
	if env.Error.Code != "VALIDATION_ERROR" {
		t.Errorf("kode galat = %q, ingin VALIDATION_ERROR", env.Error.Code)
	}
	if env.Error.Fields["url"] == "" {
		t.Error("fields[url] kosong, ingin pesan galat skema")
	}
}

func TestKodeTakDikenal(t *testing.T) {
	ts := serverUji()
	defer ts.Close()

	resp, err := klienTanpaIkut.Get(ts.URL + "/zzzzzzz")
	if err != nil {
		t.Fatalf("GET /zzzzzzz: %v", err)
	}
	resp.Body.Close()
	if resp.StatusCode != http.StatusNotFound {
		t.Errorf("GET kode tak dikenal status = %d, ingin 404", resp.StatusCode)
	}

	resp2, err := http.Get(ts.URL + "/stats/zzzzzzz")
	if err != nil {
		t.Fatalf("GET /stats/zzzzzzz: %v", err)
	}
	defer resp2.Body.Close()
	if resp2.StatusCode != http.StatusNotFound {
		t.Errorf("GET stats kode tak dikenal status = %d, ingin 404", resp2.StatusCode)
	}
}

handler.go

Unduh berkas
go
package main

import (
	"encoding/json"
	"errors"
	"fmt"
	"io"
	"mime"
	"net/http"
	"strings"
	"time"
)

// maxBodyBytes membatasi ukuran body JSON permintaan.
const maxBodyBytes = 1 << 20 // 1 MiB

// Kode galat yang stabil untuk konsumen API.
const (
	codeNotFound      = "NOT_FOUND"
	codeValidasi      = "VALIDATION_ERROR"
	codeJSONInvalid   = "INVALID_JSON"
	codeMediaType     = "UNSUPPORTED_MEDIA_TYPE"
	codeBodyKebesaran = "REQUEST_TOO_LARGE"
	codeInternal      = "INTERNAL_ERROR"
)

// errorEnvelope adalah bentuk body galat yang konsisten di seluruh
// endpoint: {"error":{"code":"...","message":"..."}}.
type errorEnvelope struct {
	Error errorBody `json:"error"`
}

type errorBody struct {
	Code    string            `json:"code"`
	Message string            `json:"message"`
	Fields  map[string]string `json:"fields,omitempty"`
}

// galatBody adalah galat transport HTTP yang sudah membawa status dan
// kode galatnya sendiri, misalnya body JSON yang rusak.
type galatBody struct {
	status  int
	code    string
	message string
}

func (e *galatBody) Error() string { return e.message }

// writeJSON menuliskan v sebagai respons JSON dengan status code yang
// diberikan; Content-Type diatur sebelum WriteHeader.
func writeJSON(w http.ResponseWriter, status int, v any) {
	w.Header().Set("Content-Type", "application/json")
	w.WriteHeader(status)
	_ = json.NewEncoder(w).Encode(v)
}

// writeError menerjemahkan error domain menjadi respons JSON
// konsisten: ValidationError menjadi 400 dengan peta fields,
// ErrNotFound menjadi 404, galat lain menjadi 500 tanpa detail.
func writeError(w http.ResponseWriter, err error) {
	var ve ValidationError
	if errors.As(err, &ve) {
		writeJSON(w, http.StatusBadRequest, errorEnvelope{Error: errorBody{
			Code:    codeValidasi,
			Message: "masukan tidak lolos validasi",
			Fields:  map[string]string{ve.Field: ve.Message},
		}})
		return
	}
	status, code, message := terjemahkanGalat(err)
	writeJSON(w, status, errorEnvelope{Error: errorBody{Code: code, Message: message}})
}

// terjemahkanGalat memetakan error domain ke status HTTP dan kode
// galat yang aman untuk klien.
func terjemahkanGalat(err error) (status int, code, message string) {
	if errors.Is(err, ErrNotFound) {
		return http.StatusNotFound, codeNotFound, err.Error()
	}
	return http.StatusInternalServerError, codeInternal, "galat internal server"
}

// tulisGalatBody menuliskan galat transport (galatBody) atau galat
// domain sebagai respons JSON.
func tulisGalatBody(w http.ResponseWriter, err error) {
	var gb *galatBody
	if errors.As(err, &gb) {
		writeJSON(w, gb.status, errorEnvelope{Error: errorBody{
			Code:    gb.code,
			Message: gb.message,
		}})
		return
	}
	writeError(w, err)
}

// ShortenHandler menerjemahkan permintaan HTTP menjadi pemanggilan
// ShortenService, lalu hasilnya kembali menjadi respons.
type ShortenHandler struct {
	svc     *ShortenService
	baseURL string
}

// NewRouter membangun ServeMux dengan seluruh pattern layanan.
// Pattern "GET /stats/{code}" lebih spesifik daripada "GET /{code}"
// sehingga ServeMux memilihnya untuk path dua segmen; "POST /shorten"
// tidak bertabrakan dengan "GET /{code}" karena method-nya berbeda.
func NewRouter(svc *ShortenService, baseURL string) http.Handler {
	h := &ShortenHandler{svc: svc, baseURL: strings.TrimSuffix(baseURL, "/")}
	mux := http.NewServeMux()
	mux.HandleFunc("GET /healthz", h.Healthz)
	mux.HandleFunc("POST /shorten", h.Shorten)
	mux.HandleFunc("GET /{code}", h.Redirect)
	mux.HandleFunc("GET /stats/{code}", h.Stats)
	return mux
}

// Healthz melaporkan proses hidup dan siap menerima permintaan.
func (h *ShortenHandler) Healthz(w http.ResponseWriter, r *http.Request) {
	writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
}

// shortenRequest adalah body permintaan POST /shorten.
type shortenRequest struct {
	URL string `json:"url"`
}

// shortenResponse adalah body respons POST /shorten.
type shortenResponse struct {
	Code     string `json:"code"`
	URL      string `json:"url"`
	ShortURL string `json:"short_url"`
}

// Shorten menangani POST /shorten: validasi URL, buat kode, lalu balas
// 201 Created dengan Location menuju short URL.
func (h *ShortenHandler) Shorten(w http.ResponseWriter, r *http.Request) {
	var req shortenRequest
	if err := bacaJSON(w, r, &req); err != nil {
		tulisGalatBody(w, err)
		return
	}
	rec, err := h.svc.Shorten(r.Context(), req.URL)
	if err != nil {
		writeError(w, err)
		return
	}
	shortURL := fmt.Sprintf("%s/%s", h.baseURL, rec.Code)
	w.Header().Set("Location", shortURL)
	writeJSON(w, http.StatusCreated, shortenResponse{
		Code:     rec.Code,
		URL:      rec.OriginalURL,
		ShortURL: shortURL,
	})
}

// Redirect menangani GET /{code}: klik dihitung, lalu klien dialihkan
// ke URL asli dengan 302 Found dan header Location.
func (h *ShortenHandler) Redirect(w http.ResponseWriter, r *http.Request) {
	asli, err := h.svc.Redirect(r.Context(), r.PathValue("code"))
	if err != nil {
		writeError(w, err)
		return
	}
	http.Redirect(w, r, asli, http.StatusFound)
}

// statsBody adalah body respons GET /stats/{code}.
type statsBody struct {
	Code      string    `json:"code"`
	URL       string    `json:"url"`
	Clicks    int64     `json:"clicks"`
	CreatedAt time.Time `json:"created_at"`
}

// Stats menangani GET /stats/{code} dan mengembalikan statistik klik
// tanpa menaikkannya.
func (h *ShortenHandler) Stats(w http.ResponseWriter, r *http.Request) {
	rec, err := h.svc.Stats(r.Context(), r.PathValue("code"))
	if err != nil {
		writeError(w, err)
		return
	}
	writeJSON(w, http.StatusOK, statsBody{
		Code:      rec.Code,
		URL:       rec.OriginalURL,
		Clicks:    rec.Clicks,
		CreatedAt: rec.CreatedAt,
	})
}

// bacaJSON memeriksa Content-Type, membatasi ukuran body, lalu
// mendekode JSON ke dst sambil menolak field yang tidak dikenal.
func bacaJSON(w http.ResponseWriter, r *http.Request, dst any) error {
	ct, _, err := mime.ParseMediaType(r.Header.Get("Content-Type"))
	if err != nil || ct != "application/json" {
		return &galatBody{
			status:  http.StatusUnsupportedMediaType,
			code:    codeMediaType,
			message: "Content-Type harus application/json",
		}
	}
	r.Body = http.MaxBytesReader(w, r.Body, maxBodyBytes)
	dec := json.NewDecoder(r.Body)
	dec.DisallowUnknownFields()
	if err := dec.Decode(dst); err != nil {
		var maxErr *http.MaxBytesError
		switch {
		case errors.As(err, &maxErr):
			return &galatBody{
				status:  http.StatusRequestEntityTooLarge,
				code:    codeBodyKebesaran,
				message: fmt.Sprintf("body melebihi batas %d byte", maxErr.Limit),
			}
		case errors.Is(err, io.EOF):
			return &galatBody{
				status:  http.StatusBadRequest,
				code:    codeJSONInvalid,
				message: "body kosong; kirim JSON seperti {\"url\":\"https://example.com\"}",
			}
		default:
			return &galatBody{
				status:  http.StatusBadRequest,
				code:    codeJSONInvalid,
				message: "body bukan JSON valid: " + err.Error(),
			}
		}
	}
	return nil
}

integration_test.go

Unduh berkas
go
package main

import (
	"context"
	"encoding/json"
	"net/http"
	"net/http/httptest"
	"os"
	"strings"
	"testing"
)

// TestIntegrationShortenRedirectStats menjalankan alur shorten →
// redirect → stats melawan PostgreSQL sungguhan. Tanpa
// TEST_DATABASE_URL, test ini dilewati (skip) supaya `go test ./...`
// tetap lulus di lingkungan offline:
//
//	TEST_DATABASE_URL=postgres://user:pass@localhost:5432/urls?sslmode=disable \
//	  go test -v -run Integration ./...
func TestIntegrationShortenRedirectStats(t *testing.T) {
	dsn := os.Getenv("TEST_DATABASE_URL")
	if dsn == "" {
		t.Skip("TEST_DATABASE_URL kosong: integration test membutuhkan PostgreSQL aktif")
	}

	ctx := context.Background()
	db, err := OpenDatabase(ctx, dsn)
	if err != nil {
		t.Fatalf("membuka database: %v", err)
	}
	defer db.Close()

	if err := RunMigrations(ctx, db); err != nil {
		t.Fatalf("menjalankan migration: %v", err)
	}

	// Bersihkan data test sebelumnya supaya test dapat diulang.
	if _, err := db.ExecContext(ctx, `TRUNCATE urls`); err != nil {
		t.Fatalf("membersihkan tabel urls: %v", err)
	}

	gen := &genStub{daftar: []string{"b1Gk0D3"}}
	svc := NewShortenService(NewPostgresURLRepository(db), gen, RealClock{})
	ts := httptest.NewServer(NewRouter(svc, "http://short.test/"))
	defer ts.Close()

	klien := &http.Client{CheckRedirect: func(req *http.Request, via []*http.Request) error {
		return http.ErrUseLastResponse
	}}

	resp, err := http.Post(ts.URL+"/shorten", "application/json",
		strings.NewReader(`{"url":"https://example.com/integrasi"}`))
	if err != nil {
		t.Fatalf("POST /shorten: %v", err)
	}
	resp.Body.Close()
	if resp.StatusCode != http.StatusCreated {
		t.Fatalf("POST /shorten status = %d, ingin 201", resp.StatusCode)
	}

	for i := 1; i <= 2; i++ {
		resp2, err := klien.Get(ts.URL + "/b1Gk0D3")
		if err != nil {
			t.Fatalf("GET /b1Gk0D3 ke-%d: %v", i, err)
		}
		resp2.Body.Close()
		if resp2.StatusCode != http.StatusFound {
			t.Fatalf("redirect ke-%d status = %d, ingin 302", i, resp2.StatusCode)
		}
		if loc := resp2.Header.Get("Location"); loc != "https://example.com/integrasi" {
			t.Fatalf("redirect ke-%d Location = %q, ingin URL asli", i, loc)
		}
	}

	resp3, err := http.Get(ts.URL + "/stats/b1Gk0D3")
	if err != nil {
		t.Fatalf("GET /stats/b1Gk0D3: %v", err)
	}
	defer resp3.Body.Close()
	if resp3.StatusCode != http.StatusOK {
		t.Fatalf("GET /stats status = %d, ingin 200", resp3.StatusCode)
	}
	var stat struct {
		Clicks int64 `json:"clicks"`
	}
	if err := json.NewDecoder(resp3.Body).Decode(&stat); err != nil {
		t.Fatalf("mendekode body stats: %v", err)
	}
	if stat.Clicks != 2 {
		t.Errorf("klik = %d, ingin 2 setelah dua redirect (UPDATE atomik)", stat.Clicks)
	}
}

main.go

Unduh berkas
go
package main

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

func main() {
	if len(os.Args) > 1 {
		if os.Args[1] == "demo" {
			demo()
			return
		}
		fmt.Fprintln(os.Stderr, "perintah tidak dikenal:", os.Args[1])
		fmt.Fprintln(os.Stderr, "pemakaian: shortener [demo]")
		os.Exit(2)
	}
	if err := run(); err != nil {
		// Pesan ditulis tanpa stempel waktu supaya output tetap
		// deterministik dan mudah dibandingkan.
		fmt.Fprintln(os.Stderr, "galat:", err)
		os.Exit(1)
	}
}

// run merangkai seluruh dependensi: konfigurasi dibaca dan
// divalidasi, pool koneksi dibuka, migration dijalankan, lalu
// repository, generator, service, handler, dan server disusun.
func run() error {
	cfg, err := Load()
	if err != nil {
		return fmt.Errorf("memuat konfigurasi: %w", err)
	}

	ctx := context.Background()
	db, err := OpenDatabase(ctx, cfg.DatabaseURL)
	if err != nil {
		return fmt.Errorf("membuka database: %w", err)
	}
	defer db.Close()

	if err := RunMigrations(ctx, db); err != nil {
		return fmt.Errorf("menjalankan migration: %w", err)
	}

	gen, err := NewBase62Generator()
	if err != nil {
		return fmt.Errorf("menyiapkan generator kode: %w", err)
	}

	svc := NewShortenService(NewPostgresURLRepository(db), gen, RealClock{})
	srv := NewServer(cfg, svc)

	log.Printf("server mendengarkan di :%s (base URL %s)", cfg.Port, cfg.BaseURL)
	return ListenAndServe(srv)
}

// NewServer membangun http.Server tanpa memulai mendengarkan port.
// Pemisahan ini membuat server dapat diuji dengan httptest dan
// diganti-ganti pemanggilnya tanpa menyentuh logika shutdown.
func NewServer(cfg Config, svc *ShortenService) *http.Server {
	return &http.Server{
		Addr:              ":" + cfg.Port,
		Handler:           NewRouter(svc, cfg.BaseURL),
		ReadHeaderTimeout: 5 * time.Second,
	}
}

// ListenAndServe menjalankan server di goroutine, menunggu sinyal
// SIGINT atau SIGTERM, lalu mematikan server secara graceful dengan
// tenggat 10 detik agar permintaan yang sedang diproses selesai.
func ListenAndServe(srv *http.Server) error {
	errCh := make(chan error, 1)
	go func() {
		if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
			errCh <- err
		}
	}()

	ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
	defer stop()

	select {
	case err := <-errCh:
		return fmt.Errorf("server gagal berjalan: %w", err)
	case <-ctx.Done():
		log.Println("sinyal berhenti diterima; mematikan server")
	}

	shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
	defer cancel()
	if err := srv.Shutdown(shutdownCtx); err != nil {
		return fmt.Errorf("graceful shutdown: %w", err)
	}
	return nil
}

migrations/001_create_urls.sql

Unduh berkas
sql
-- 001_create_urls.sql membuat tabel urls: pemetaan code → original_url.
CREATE TABLE IF NOT EXISTS urls (
    code         TEXT PRIMARY KEY,
    original_url TEXT NOT NULL,
    clicks       BIGINT NOT NULL DEFAULT 0 CHECK (clicks >= 0),
    created_at   TIMESTAMPTZ NOT NULL
);

repository.go

Unduh berkas
go
package main

import (
	"context"
	"database/sql"
	"errors"
	"fmt"
	"sync"
	"time"

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

// ErrNotFound menandai short code yang tidak ada di penyimpanan.
var ErrNotFound = errors.New("pemetaan tidak ditemukan")

// ErrCodeExists menandai short code yang sudah dipakai pemetaan lain.
var ErrCodeExists = errors.New("short code sudah dipakai")

// URLRecord adalah pemetaan satu short code ke URL asli beserta
// statistik kliknya — inti domain layanan pemendek URL.
type URLRecord struct {
	Code        string    `json:"code"`
	OriginalURL string    `json:"original_url"`
	Clicks      int64     `json:"clicks"`
	CreatedAt   time.Time `json:"created_at"`
}

// URLRepository adalah kontrak penyimpanan pemetaan URL. Implementasi
// PostgreSQL dipakai di produksi; implementasi memori dipakai untuk
// unit test dan mode demo.
type URLRepository interface {
	// Save menyimpan pemetaan baru; mengembalikan ErrCodeExists bila
	// kode sudah dipakai.
	Save(ctx context.Context, rec URLRecord) error
	// FindByCode mengambil satu pemetaan beserta statistiknya.
	FindByCode(ctx context.Context, code string) (URLRecord, error)
	// AddClick menaikkan klik satu kali dan mengembalikan pemetaan
	// terbaru dalam satu operasi penyimpanan.
	AddClick(ctx context.Context, code string) (URLRecord, error)
}

// PostgresURLRepository menyimpan pemetaan URL di PostgreSQL.
type PostgresURLRepository struct {
	db *sql.DB
}

// NewPostgresURLRepository membangun repository di atas pool *sql.DB.
func NewPostgresURLRepository(db *sql.DB) *PostgresURLRepository {
	return &PostgresURLRepository{db: db}
}

// Save memasukkan pemetaan baru. Pelanggaran primary key
// (unique_violation, kode SQL 23505) diterjemahkan menjadi
// ErrCodeExists sehingga service cukup memeriksa sentinel error.
func (r *PostgresURLRepository) Save(ctx context.Context, rec URLRecord) error {
	_, err := r.db.ExecContext(ctx,
		`INSERT INTO urls (code, original_url, created_at) VALUES ($1, $2, $3)`,
		rec.Code, rec.OriginalURL, rec.CreatedAt)
	if err != nil {
		var pgErr *pgconn.PgError
		if errors.As(err, &pgErr) && pgErr.Code == "23505" {
			return fmt.Errorf("kode %s: %w", rec.Code, ErrCodeExists)
		}
		return fmt.Errorf("menyimpan urls: %w", err)
	}
	return nil
}

// FindByCode membaca satu pemetaan; baris kosong menjadi ErrNotFound.
func (r *PostgresURLRepository) FindByCode(ctx context.Context, code string) (URLRecord, error) {
	var rec URLRecord
	err := r.db.QueryRowContext(ctx,
		`SELECT code, original_url, clicks, created_at FROM urls WHERE code = $1`, code,
	).Scan(&rec.Code, &rec.OriginalURL, &rec.Clicks, &rec.CreatedAt)
	if err != nil {
		if errors.Is(err, sql.ErrNoRows) {
			return URLRecord{}, fmt.Errorf("kode %s: %w", code, ErrNotFound)
		}
		return URLRecord{}, fmt.Errorf("membaca urls: %w", err)
	}
	return rec, nil
}

// AddClick menaikkan klik dan mengembalikan baris terbaru dalam satu
// round trip lewat RETURNING. UPDATE berjalan atomik di database,
// sehingga dua klik bersamaan tidak bisa saling menimpa.
func (r *PostgresURLRepository) AddClick(ctx context.Context, code string) (URLRecord, error) {
	var rec URLRecord
	err := r.db.QueryRowContext(ctx,
		`UPDATE urls SET clicks = clicks + 1 WHERE code = $1
		RETURNING code, original_url, clicks, created_at`, code,
	).Scan(&rec.Code, &rec.OriginalURL, &rec.Clicks, &rec.CreatedAt)
	if err != nil {
		if errors.Is(err, sql.ErrNoRows) {
			return URLRecord{}, fmt.Errorf("kode %s: %w", code, ErrNotFound)
		}
		return URLRecord{}, fmt.Errorf("menaikkan klik: %w", err)
	}
	return rec, nil
}

// MemoryURLRepository menyimpan pemetaan di map dengan mutex untuk
// unit test dan mode demo. Perilakunya meniru implementasi
// PostgreSQL, termasuk ErrCodeExists dan ErrNotFound.
type MemoryURLRepository struct {
	mu   sync.Mutex
	urls map[string]URLRecord
}

// NewMemoryURLRepository membuat penyimpanan kosong.
func NewMemoryURLRepository() *MemoryURLRepository {
	return &MemoryURLRepository{urls: make(map[string]URLRecord)}
}

func (r *MemoryURLRepository) Save(ctx context.Context, rec URLRecord) error {
	r.mu.Lock()
	defer r.mu.Unlock()
	if _, ada := r.urls[rec.Code]; ada {
		return fmt.Errorf("kode %s: %w", rec.Code, ErrCodeExists)
	}
	r.urls[rec.Code] = rec
	return nil
}

func (r *MemoryURLRepository) FindByCode(ctx context.Context, code string) (URLRecord, error) {
	r.mu.Lock()
	defer r.mu.Unlock()
	rec, ada := r.urls[code]
	if !ada {
		return URLRecord{}, fmt.Errorf("kode %s: %w", code, ErrNotFound)
	}
	return rec, nil
}

// AddClick mengunci map, menaikkan klik, lalu mengembalikan salinan —
// padanan atomik dari UPDATE ... RETURNING versi SQL.
func (r *MemoryURLRepository) AddClick(ctx context.Context, code string) (URLRecord, error) {
	r.mu.Lock()
	defer r.mu.Unlock()
	rec, ada := r.urls[code]
	if !ada {
		return URLRecord{}, fmt.Errorf("kode %s: %w", code, ErrNotFound)
	}
	rec.Clicks++
	r.urls[code] = rec
	return rec, nil
}

service_test.go

Unduh berkas
go
package main

import (
	"context"
	"errors"
	"testing"
	"time"
)

// waktuUji adalah waktu beku untuk seluruh unit test.
var waktuUji = time.Date(2026, 1, 15, 8, 0, 0, 0, time.UTC)

// genStub mengeluarkan kode dari daftar tetap secara bergilir.
type genStub struct {
	daftar []string
	i      int
}

// NewCode memenuhi interface CodeGenerator.
func (g *genStub) NewCode() (string, error) {
	code := g.daftar[g.i%len(g.daftar)]
	g.i++
	return code, nil
}

// genGagal selalu mengembalikan galat dari generator.
type genGagal struct{}

// NewCode selalu gagal — untuk menguji jalur galat generator.
func (genGagal) NewCode() (string, error) {
	return "", errors.New("entropi habis")
}

func TestValidasiURL(t *testing.T) {
	uji := []struct {
		nama string
		url  string
		ok   bool
	}{
		{"url kosong", "", false},
		{"tanpa skema", "example.com/halaman", false},
		{"skema ftp", "ftp://example.com/berkas", false},
		{"host kosong", "http:///path", false},
		{"http valid", "http://example.com/halaman?q=1", true},
		{"https valid", "https://go.dev/doc/", true},
	}
	for _, tt := range uji {
		t.Run(tt.nama, func(t *testing.T) {
			err := ValidasiURL(tt.url)
			if tt.ok && err != nil {
				t.Fatalf("ValidasiURL(%q) galat tak terduga: %v", tt.url, err)
			}
			if !tt.ok {
				var ve ValidationError
				if !errors.As(err, &ve) {
					t.Fatalf("ValidasiURL(%q) ingin ValidationError, dapat %v", tt.url, err)
				}
				if ve.Field != "url" {
					t.Errorf("field galat = %q, ingin \"url\"", ve.Field)
				}
			}
		})
	}
}

func TestShortenKonflikRetry(t *testing.T) {
	repo := NewMemoryURLRepository()
	ctx := context.Background()

	// Kode pertama dari generator sudah dipakai pemetaan lain.
	err := repo.Save(ctx, URLRecord{Code: "aaaaaaa", OriginalURL: "https://lama.example.com", CreatedAt: waktuUji})
	if err != nil {
		t.Fatalf("menyiapkan data: %v", err)
	}

	gen := &genStub{daftar: []string{"aaaaaaa", "aaaaaaa", "bbbbbbb"}}
	svc := NewShortenService(repo, gen, FixedClock{Waktu: waktuUji})

	rec, err := svc.Shorten(ctx, "https://baru.example.com/halaman")
	if err != nil {
		t.Fatalf("Shorten() galat tak terduga: %v", err)
	}
	if rec.Code != "bbbbbbb" {
		t.Errorf("kode = %q, ingin \"bbbbbbb\" setelah dua kali bentrok", rec.Code)
	}
	if !rec.CreatedAt.Equal(waktuUji) {
		t.Errorf("created_at = %v, ingin %v (waktu beku)", rec.CreatedAt, waktuUji)
	}
}

func TestShortenKodeHabis(t *testing.T) {
	repo := NewMemoryURLRepository()
	ctx := context.Background()

	_ = repo.Save(ctx, URLRecord{Code: "aaaaaaa", OriginalURL: "https://lama.example.com", CreatedAt: waktuUji})
	gen := &genStub{daftar: []string{"aaaaaaa"}} // selalu bentrok
	svc := NewShortenService(repo, gen, FixedClock{Waktu: waktuUji})

	_, err := svc.Shorten(ctx, "https://baru.example.com")
	if !errors.Is(err, ErrCodeExists) {
		t.Fatalf("Shorten() ingin ErrCodeExists setelah percobaan habis, dapat %v", err)
	}
}

func TestShortenGeneratorGagal(t *testing.T) {
	svc := NewShortenService(NewMemoryURLRepository(), genGagal{}, FixedClock{Waktu: waktuUji})
	_, err := svc.Shorten(context.Background(), "https://example.com")
	if err == nil {
		t.Fatal("Shorten() ingin galat ketika generator gagal")
	}
}

func TestRedirectMenaikkanKlik(t *testing.T) {
	repo := NewMemoryURLRepository()
	ctx := context.Background()

	gen := &genStub{daftar: []string{"b1Gk0D3"}}
	svc := NewShortenService(repo, gen, FixedClock{Waktu: waktuUji})

	rec, err := svc.Shorten(ctx, "https://example.com/tutorial")
	if err != nil {
		t.Fatalf("Shorten(): %v", err)
	}

	for i := 1; i <= 2; i++ {
		asli, err := svc.Redirect(ctx, rec.Code)
		if err != nil {
			t.Fatalf("Redirect() ke-%d: %v", i, err)
		}
		if asli != "https://example.com/tutorial" {
			t.Errorf("Redirect() ke-%d = %q, ingin URL asli", i, asli)
		}
	}

	stat, err := svc.Stats(ctx, rec.Code)
	if err != nil {
		t.Fatalf("Stats(): %v", err)
	}
	if stat.Clicks != 2 {
		t.Errorf("klik = %d, ingin 2 setelah dua redirect", stat.Clicks)
	}
}

func TestRedirectKodeTakDikenal(t *testing.T) {
	svc := NewShortenService(NewMemoryURLRepository(), &genStub{daftar: []string{"b1Gk0D3"}}, FixedClock{Waktu: waktuUji})
	_, err := svc.Redirect(context.Background(), "takDikn")
	if !errors.Is(err, ErrNotFound) {
		t.Fatalf("Redirect() ingin ErrNotFound, dapat %v", err)
	}
}

service.go

Unduh berkas
go
package main

import (
	"context"
	cryptorand "crypto/rand"
	"encoding/binary"
	"errors"
	"fmt"
	"math/rand/v2"
	"net/url"
	"sync"
	"time"
)

// Aturan domain layanan pemendek URL.
const (
	// panjangKode adalah jumlah karakter short code produksi.
	panjangKode = 7
	// maxPercobaanKode membatasi retry pembuatan kode saat bentrok.
	maxPercobaanKode = 5
	// maxPanjangURL membatasi URL asli yang diterima.
	maxPanjangURL = 2048
)

// ValidationError menjelaskan satu field masukan yang melanggar
// aturan domain.
type ValidationError struct {
	Field   string
	Message string
}

// Error memenuhi kontrak interface error.
func (e ValidationError) Error() string {
	return fmt.Sprintf("field %s: %s", e.Field, e.Message)
}

// Clock memisahkan sumber waktu dari logika bisnis. Produksi memakai
// RealClock; demo dan unit test memakai FixedClock sehingga nilai
// created_at selalu deterministik.
type Clock interface {
	Now() time.Time
}

// RealClock mengembalikan waktu sistem dalam zona UTC.
type RealClock struct{}

// Now memenuhi interface Clock.
func (RealClock) Now() time.Time { return time.Now().UTC() }

// FixedClock selalu mengembalikan Waktu yang sama.
type FixedClock struct {
	Waktu time.Time
}

// Now memenuhi interface Clock.
func (c FixedClock) Now() time.Time { return c.Waktu }

// CodeGenerator memproduksi kandidat short code baru. Produksi
// memakai Base62Generator; test dan demo memakai generator stub
// yang mengembalikan urutan tetap.
type CodeGenerator interface {
	NewCode() (string, error)
}

// ShortenService menampung aturan pemendekan URL: validasi masukan,
// pembuatan kode unik dengan retry, penghitungan klik, dan
// statistik — seluruh akses penyimpanan didelegasikan ke
// URLRepository yang diinjeksi.
type ShortenService struct {
	repo  URLRepository
	gen   CodeGenerator
	clock Clock
}

// NewShortenService membangun service dengan repository, generator
// kode, dan clock yang diinjeksi pemanggil.
func NewShortenService(repo URLRepository, gen CodeGenerator, clock Clock) *ShortenService {
	return &ShortenService{repo: repo, gen: gen, clock: clock}
}

// Shorten memvalidasi URL, lalu mencoba menyimpan pemetaan dengan
// kode dari generator. Bila kode bentrok (ErrCodeExists), kode baru
// dibuat lagi sampai maxPercobaanKode kali.
func (s *ShortenService) Shorten(ctx context.Context, rawURL string) (URLRecord, error) {
	if err := ValidasiURL(rawURL); err != nil {
		return URLRecord{}, err
	}

	for percobaan := 1; percobaan <= maxPercobaanKode; percobaan++ {
		code, err := s.gen.NewCode()
		if err != nil {
			return URLRecord{}, fmt.Errorf("membuat short code: %w", err)
		}
		rec := URLRecord{
			Code:        code,
			OriginalURL: rawURL,
			CreatedAt:   s.clock.Now(),
		}

		err = s.repo.Save(ctx, rec)
		switch {
		case err == nil:
			return rec, nil
		case errors.Is(err, ErrCodeExists):
			continue // kode sudah dipakai: coba kode berikutnya
		default:
			return URLRecord{}, err
		}
	}
	return URLRecord{}, fmt.Errorf("kode unik tidak didapat setelah %d percobaan: %w", maxPercobaanKode, ErrCodeExists)
}

// Redirect menaikkan jumlah klik satu kali lalu mengembalikan URL
// asli yang menjadi tujuan pengalihan.
func (s *ShortenService) Redirect(ctx context.Context, code string) (string, error) {
	rec, err := s.repo.AddClick(ctx, code)
	if err != nil {
		return "", err
	}
	return rec.OriginalURL, nil
}

// Stats mengambil statistik satu short code tanpa menaikkan klik.
func (s *ShortenService) Stats(ctx context.Context, code string) (URLRecord, error) {
	return s.repo.FindByCode(ctx, code)
}

// ValidasiURL memeriksa URL asli dengan net/url.Parse: wajib berisi
// skema http/https dan host yang tidak kosong.
func ValidasiURL(raw string) error {
	if raw == "" {
		return ValidationError{Field: "url", Message: "wajib diisi"}
	}
	if len(raw) > maxPanjangURL {
		return ValidationError{Field: "url", Message: "melebihi 2048 karakter"}
	}
	u, err := url.Parse(raw)
	if err != nil {
		return ValidationError{Field: "url", Message: "format tidak valid"}
	}
	if u.Scheme != "http" && u.Scheme != "https" {
		return ValidationError{Field: "url", Message: "skema harus http atau https"}
	}
	if u.Host == "" {
		return ValidationError{Field: "url", Message: "host kosong"}
	}
	return nil
}

// alfabetBase62 berisi 62 karakter (a-z, A-Z, 0-9) yang aman
// dimasukkan ke URL tanpa perlu di-escape.
const alfabetBase62 = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"

// Base62Generator membuat short code acak sepanjang panjangKode dari
// alfabet base62. Sumber acaknya *rand.Rand (math/rand/v2) yang
// di-seed 16 byte crypto/rand, sehingga kode tidak bisa diprediksi
// dari kode-kode sebelumnya. Mutex diperlukan karena *rand.Rand
// tidak aman dipakai konkuren.
type Base62Generator struct {
	mu  sync.Mutex
	rng *rand.Rand
}

// NewBase62Generator membaca seed acak dari crypto/rand lalu membangun
// generator PCG.
func NewBase62Generator() (*Base62Generator, error) {
	seed := make([]byte, 16)
	if _, err := cryptorand.Read(seed); err != nil {
		return nil, fmt.Errorf("membaca seed crypto/rand: %w", err)
	}
	rng := rand.New(rand.NewPCG(
		binary.LittleEndian.Uint64(seed[:8]),
		binary.LittleEndian.Uint64(seed[8:]),
	))
	return &Base62Generator{rng: rng}, nil
}

// NewCode menghasilkan string acak dari alfabetBase62.
func (g *Base62Generator) NewCode() (string, error) {
	g.mu.Lock()
	defer g.mu.Unlock()
	buf := make([]byte, panjangKode)
	for i := range buf {
		buf[i] = alfabetBase62[g.rng.IntN(len(alfabetBase62))]
	}
	return string(buf), nil
}