diff --git a/Dockerfile b/Dockerfile index def3ed14..cb1a0795 100644 --- a/Dockerfile +++ b/Dockerfile @@ -6,7 +6,7 @@ FROM golang:1.25-alpine AS build WORKDIR /src # Cache module downloads before copying source (layer caching). -COPY go.mod ./ +COPY go.mod go.sum ./ RUN go mod download COPY . ./ diff --git a/cmd/hatch/main.go b/cmd/hatch/main.go index 493c035b..626756af 100644 --- a/cmd/hatch/main.go +++ b/cmd/hatch/main.go @@ -1,8 +1,3 @@ -// Command hatch is the Hatch HTTP request inspector + mocker server. -// -// Hatch captures, inspects, and mocks HTTP requests. It ships as a single -// static binary — one command on a VPS and your payloads never leave your -// network. package main import ( @@ -10,25 +5,27 @@ import ( "log" "net/http" "os" + + "github.com/elfoundation/hatch/internal/handler" + "github.com/elfoundation/hatch/internal/store" ) func main() { port := os.Getenv("PORT") - if port == "" { - port = "8080" - } - + if port == "" { port = "8080" } + dbPath := os.Getenv("HATCH_DB_PATH") + repo, err := store.Open(dbPath) + if err != nil { log.Fatalf("hatch: open store: %v", err) } + defer repo.Close() + h := handler.New(repo) mux := http.NewServeMux() mux.HandleFunc("GET /healthz", healthz) - + h.RegisterRoutes(mux) addr := fmt.Sprintf(":%s", port) log.Printf("hatch starting on %s", addr) - if err := http.ListenAndServe(addr, mux); err != nil { - log.Fatalf("hatch server error: %v", err) - } + if err := http.ListenAndServe(addr, mux); err != nil { log.Fatalf("hatch server error: %v", err) } } -// healthz returns 200 OK with body "ok" for liveness probes. func healthz(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "text/plain") w.WriteHeader(http.StatusOK) diff --git a/go.mod b/go.mod index 02f741ea..5083bce5 100644 --- a/go.mod +++ b/go.mod @@ -1,3 +1,19 @@ module github.com/elfoundation/hatch -go 1.25 +go 1.25.0 + +require ( + github.com/google/uuid v1.6.0 + modernc.org/sqlite v1.53.0 +) + +require ( + github.com/dustin/go-humanize v1.0.1 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/ncruces/go-strftime v1.0.0 // indirect + github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect + golang.org/x/sys v0.44.0 // indirect + modernc.org/libc v1.73.4 // indirect + modernc.org/mathutil v1.7.1 // indirect + modernc.org/memory v1.11.0 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 00000000..b0540320 --- /dev/null +++ b/go.sum @@ -0,0 +1,51 @@ +github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= +github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= +github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs= +github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k= +github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w= +github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= +golang.org/x/mod v0.36.0 h1:JJjpVx6myfUsUdAzZuOSTTmRE0PfZeNWzzvKrP7amb4= +golang.org/x/mod v0.36.0/go.mod h1:moc6ELqsWcOw5Ef3xVprK5ul/MvtVvkIXLziUOICjUQ= +golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= +golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.44.0 h1:ildZl3J4uzeKP07r2F++Op7E9B29JRUy+a27EibtBTQ= +golang.org/x/sys v0.44.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/tools v0.45.0 h1:18qN3FAooORvApf5XjCXgsuayZOEtXf6JK18I3+ONa8= +golang.org/x/tools v0.45.0/go.mod h1:LuUGqqaXcXMEFEruIVJVm5mgDD8vww/z/SR1gQ4uE/0= +modernc.org/cc/v4 v4.28.4 h1:Hd/4Es+MBj+/7hSdZaisNyu6bv3V0Dp2MdllyfqaH+c= +modernc.org/cc/v4 v4.28.4/go.mod h1:OnovgIhbbMXMu1aISnJ0wvVD1KnW+cAUJkIrAWh+kVI= +modernc.org/ccgo/v4 v4.34.4 h1:OVnSOWQjVKOYkFxoHYB+qQmSHK5gqMqARM+K9DpR/Ws= +modernc.org/ccgo/v4 v4.34.4/go.mod h1:qdKqE8FNIYyysougB1RX9MxCzp5oJOcQXSobANJ4TuE= +modernc.org/fileutil v1.4.0 h1:j6ZzNTftVS054gi281TyLjHPp6CPHr2KCxEXjEbD6SM= +modernc.org/fileutil v1.4.0/go.mod h1:EqdKFDxiByqxLk8ozOxObDSfcVOv/54xDs/DUHdvCUU= +modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI= +modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito= +modernc.org/gc/v3 v3.1.3 h1:6QAplYyVO+KdPW3pGnqmJDUxtkec8ooEWvks/hhU3lc= +modernc.org/gc/v3 v3.1.3/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY= +modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks= +modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI= +modernc.org/libc v1.73.4 h1:+ra4Ui8ngyt8HDcO1FTDPWlkAh6yOdaO2yAoh8MddQA= +modernc.org/libc v1.73.4/go.mod h1:DXZ3eO8qMCNn2SnmTNCiC71nJ9Rcq3PsnpU6Vc4rWK8= +modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU= +modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg= +modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI= +modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw= +modernc.org/opt v0.2.0 h1:tGyef5ApycA7FSEOMraay9SaTk5zmbx7Tu+cJs4QKZg= +modernc.org/opt v0.2.0/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns= +modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w= +modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE= +modernc.org/sqlite v1.53.0 h1:20WG8N9q4ji/dEqGk4uiI0c6OPjSeLTNYGFCc3+7c1M= +modernc.org/sqlite v1.53.0/go.mod h1:xoEpOIpGrgT48H5iiyt/YXPCZPEzlfmfFwtk8Lklw8s= +modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0= +modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A= +modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y= +modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM= diff --git a/internal/handler/handler.go b/internal/handler/handler.go new file mode 100644 index 00000000..0887dd68 --- /dev/null +++ b/internal/handler/handler.go @@ -0,0 +1,44 @@ +package handler + +import ( + "context" + "encoding/json" + "io" + "log" + "net/http" + "strings" + + "github.com/elfoundation/hatch/internal/store" +) + +type Handler struct{ Repo store.Repository } + +func New(repo store.Repository) *Handler { return &Handler{Repo: repo} } + +func (h *Handler) RegisterRoutes(mux *http.ServeMux) { mux.Handle("/{endpoint}", h) } + +func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { + eid := extractID(r.URL.Path) + ctx := context.Background() + if _, err := h.Repo.GetEndpoint(ctx, eid); err != nil { + h.Repo.CreateEndpoint(ctx, eid) + } + hdr := map[string]string{} + for k, v := range r.Header { hdr[k] = strings.Join(v, ", ") } + hdrJSON, _ := json.Marshal(hdr) + var body []byte + if r.Body != nil { body, _ = io.ReadAll(r.Body); r.Body.Close() } + h.Repo.AppendRequest(ctx, eid, &store.Request{ + Method: r.Method, Path: r.URL.Path, Headers: string(hdrJSON), + Query: r.URL.RawQuery, Body: body, + }) + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + json.NewEncoder(w).Encode(map[string]interface{}{}) +} + +func extractID(p string) string { + t := strings.TrimLeft(p, "/") + if i := strings.Index(t, "/"); i >= 0 { return t[:i] } + return t +} diff --git a/internal/handler/handler_test.go b/internal/handler/handler_test.go new file mode 100644 index 00000000..c11f14da --- /dev/null +++ b/internal/handler/handler_test.go @@ -0,0 +1,74 @@ +package handler + +import ( + "context" + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" + "github.com/elfoundation/hatch/internal/store" +) + +type fakeRepo struct { + endpoints map[string]*store.Endpoint + requests []*store.Request +} +func newFakeRepo() *fakeRepo { return &fakeRepo{endpoints: map[string]*store.Endpoint{}} } +func (f *fakeRepo) CreateEndpoint(_ context.Context, u string) (*store.Endpoint, error) { + e := &store.Endpoint{ID: u, URL: u, CreatedAt: "t", UpdatedAt: "t"} + f.endpoints[u] = e; return e, nil +} +func (f *fakeRepo) GetEndpoint(_ context.Context, id string) (*store.Endpoint, error) { + e, ok := f.endpoints[id]; if !ok { return nil, errNotFound }; return e, nil +} +func (f *fakeRepo) AppendRequest(_ context.Context, eid string, r *store.Request) error { + r.ID = "x"; r.EndpointID = eid; f.requests = append(f.requests, r); return nil +} +func (f *fakeRepo) ListRequests(_ context.Context, _ string, _ int) ([]*store.Request, error) { return f.requests, nil } +func (f *fakeRepo) Close() error { return nil } +var errNotFound = &se{"nf"} +type se struct{ m string } +func (e *se) Error() string { return e.m } + +func TestRecordsAllVerbs(t *testing.T) { + methods := []string{"GET", "POST", "PUT", "PATCH", "DELETE"} + for _, m := range methods { + repo := newFakeRepo() + h := New(repo) + body := "" + if m == "POST" || m == "PUT" || m == "PATCH" { body = `{"k":"v"}` } + req := httptest.NewRequest(m, "/ep", strings.NewReader(body)) + if m == "GET" { req.Header.Set("Accept", "text/html") } + w := httptest.NewRecorder() + h.ServeHTTP(w, req) + if w.Result().StatusCode != 200 { t.Errorf("%s: expected 200", m) } + if len(repo.requests) != 1 { t.Errorf("%s: expected 1 request", m); continue } + r := repo.requests[0] + if r.Method != m { t.Errorf("%s: wrong method %s", m, r.Method) } + if r.Path != "/ep" { t.Errorf("%s: wrong path", m) } + } +} + +func TestHandlerReturnsJSON200(t *testing.T) { + h := New(newFakeRepo()) + w := httptest.NewRecorder() + h.ServeHTTP(w, httptest.NewRequest("GET", "/t", nil)) + resp := w.Result() + if resp.StatusCode != 200 { t.Fatalf("expected 200") } + if !strings.Contains(resp.Header.Get("Content-Type"), "application/json") { t.Error("not json") } + data, _ := io.ReadAll(resp.Body); resp.Body.Close() + var v map[string]interface{} + if json.Unmarshal(data, &v) != nil { t.Fatal("invalid json") } +} + +func TestExtractID(t *testing.T) { + for _, tc := range []struct{ p, w string }{ + {"/a","a"},{"/a/b","a"},{"/",""},{"/x?y=z","x"}, + } { + if g := extractID(tc.p); g != tc.w { + t.Errorf("extractID(%q)=%q want %q", tc.p, g, tc.w) + } + } +} diff --git a/internal/store/db.go b/internal/store/db.go new file mode 100644 index 00000000..9d08e6ba --- /dev/null +++ b/internal/store/db.go @@ -0,0 +1,35 @@ +package store + +import ( + "database/sql" + _ "embed" + "fmt" + "os" + "path/filepath" + _ "modernc.org/sqlite" +) + +//go:embed schema.sql +var schemaSQL string + +func Open(dbPath string) (Repository, error) { + if dbPath == "" { dbPath = filepath.Join("data", "hatch.db") } + dir := filepath.Dir(dbPath) + if err := os.MkdirAll(dir, 0o755); err != nil { + return nil, fmt.Errorf("store: create db dir %s: %w", dir, err) + } + conn, err := sql.Open("sqlite", dbPath+"?_journal_mode=WAL&_busy_timeout=5000") + if err != nil { return nil, fmt.Errorf("store: open %s: %w", dbPath, err) } + conn.SetMaxOpenConns(1) + if err := migrate(conn); err != nil { + conn.Close() + return nil, fmt.Errorf("store: migrate: %w", err) + } + return NewSQLiteRepo(conn) +} + +func migrate(db *sql.DB) error { + _, err := db.Exec(schemaSQL) + if err != nil { return fmt.Errorf("store/migrate: %w", err) } + return nil +} diff --git a/internal/store/doc.go b/internal/store/doc.go new file mode 100644 index 00000000..33ccf6b8 --- /dev/null +++ b/internal/store/doc.go @@ -0,0 +1,2 @@ +// Package store provides the SQLite-backed persistence layer for Hatch. +package store diff --git a/internal/store/models.go b/internal/store/models.go new file mode 100644 index 00000000..1b8371c5 --- /dev/null +++ b/internal/store/models.go @@ -0,0 +1,31 @@ +package store + +import "context" + +type Endpoint struct { + ID string `json:"id"` + URL string `json:"url"` + CreatedAt string `json:"created_at"` + UpdatedAt string `json:"updated_at"` +} + +type Request struct { + ID string `json:"id"` + EndpointID string `json:"endpoint_id"` + Method string `json:"method"` + Path string `json:"path"` + Headers string `json:"headers"` + Query string `json:"query"` + Body []byte `json:"body"` + CreatedAt string `json:"created_at"` +} + +type Repository interface { + CreateEndpoint(ctx context.Context, url string) (*Endpoint, error) + GetEndpoint(ctx context.Context, id string) (*Endpoint, error) + AppendRequest(ctx context.Context, endpointID string, req *Request) error + ListRequests(ctx context.Context, endpointID string, limit int) ([]*Request, error) + Close() error +} + +var _ Repository = (*sqliteRepo)(nil) diff --git a/internal/store/schema.sql b/internal/store/schema.sql new file mode 100644 index 00000000..c551283d --- /dev/null +++ b/internal/store/schema.sql @@ -0,0 +1,18 @@ +CREATE TABLE IF NOT EXISTS endpoints ( + id TEXT NOT NULL PRIMARY KEY, + url TEXT NOT NULL UNIQUE, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL +); +CREATE TABLE IF NOT EXISTS requests ( + id TEXT NOT NULL PRIMARY KEY, + endpoint_id TEXT NOT NULL REFERENCES endpoints(id), + method TEXT NOT NULL, + path TEXT NOT NULL, + headers TEXT NOT NULL DEFAULT '{}', + query TEXT NOT NULL DEFAULT '', + body BLOB, + created_at TEXT NOT NULL +); +CREATE INDEX IF NOT EXISTS idx_requests_endpoint_id ON requests(endpoint_id); +CREATE INDEX IF NOT EXISTS idx_requests_created_at ON requests(created_at); diff --git a/internal/store/sqlite_repo.go b/internal/store/sqlite_repo.go new file mode 100644 index 00000000..6b9ba152 --- /dev/null +++ b/internal/store/sqlite_repo.go @@ -0,0 +1,64 @@ +package store + +import ( + "context" + "database/sql" + "fmt" + "time" + "github.com/google/uuid" +) + +type sqliteRepo struct{ db *sql.DB } + +func NewSQLiteRepo(db *sql.DB) (Repository, error) { + if db == nil { return nil, fmt.Errorf("store: nil db") } + return &sqliteRepo{db: db}, nil +} + +func (r *sqliteRepo) CreateEndpoint(ctx context.Context, url string) (*Endpoint, error) { + id := uuid.NewString() + now := utcNow() + e := &Endpoint{ID: id, URL: url, CreatedAt: now, UpdatedAt: now} + _, err := r.db.ExecContext(ctx, `INSERT INTO endpoints (id, url, created_at, updated_at) VALUES (?, ?, ?, ?)`, e.ID, e.URL, e.CreatedAt, e.UpdatedAt) + if err != nil { return nil, fmt.Errorf("store: create endpoint: %w", err) } + return e, nil +} + +func (r *sqliteRepo) GetEndpoint(ctx context.Context, id string) (*Endpoint, error) { + row := r.db.QueryRowContext(ctx, `SELECT id, url, created_at, updated_at FROM endpoints WHERE id = ?`, id) + e := &Endpoint{} + if err := row.Scan(&e.ID, &e.URL, &e.CreatedAt, &e.UpdatedAt); err != nil { + return nil, fmt.Errorf("store: get endpoint: %w", err) + } + return e, nil +} + +func (r *sqliteRepo) AppendRequest(ctx context.Context, endpointID string, req *Request) error { + req.ID = uuid.NewString() + req.EndpointID = endpointID + req.CreatedAt = utcNow() + _, err := r.db.ExecContext(ctx, `INSERT INTO requests (id, endpoint_id, method, path, headers, query, body, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?)`, + req.ID, req.EndpointID, req.Method, req.Path, req.Headers, req.Query, req.Body, req.CreatedAt) + if err != nil { return fmt.Errorf("store: append request: %w", err) } + return nil +} + +func (r *sqliteRepo) ListRequests(ctx context.Context, endpointID string, limit int) ([]*Request, error) { + if limit <= 0 { limit = 50 } + rows, err := r.db.QueryContext(ctx, `SELECT id, endpoint_id, method, path, headers, query, body, created_at FROM requests WHERE endpoint_id = ? ORDER BY created_at DESC LIMIT ?`, endpointID, limit) + if err != nil { return nil, fmt.Errorf("store: list requests: %w", err) } + defer rows.Close() + var out []*Request + for rows.Next() { + var req Request + if err := rows.Scan(&req.ID, &req.EndpointID, &req.Method, &req.Path, &req.Headers, &req.Query, &req.Body, &req.CreatedAt); err != nil { + return nil, fmt.Errorf("store: scan request: %w", err) + } + out = append(out, &req) + } + return out, rows.Err() +} + +func (r *sqliteRepo) Close() error { return r.db.Close() } + +func utcNow() string { return time.Now().UTC().Format("2006-01-02T15:04:05.000Z07:00") } diff --git a/internal/store/sqlite_repo_test.go b/internal/store/sqlite_repo_test.go new file mode 100644 index 00000000..a1bbc702 --- /dev/null +++ b/internal/store/sqlite_repo_test.go @@ -0,0 +1,107 @@ +package store + +import ( + "context" + "database/sql" + "testing" + "time" +) + +func openTestRepo(t *testing.T) Repository { + t.Helper() + db, err := sql.Open("sqlite", ":memory:?_journal_mode=WAL&_foreign_keys=on") + if err != nil { t.Fatalf("open in-memory sqlite: %v", err) } + db.SetMaxOpenConns(1) + if err := migrate(db); err != nil { db.Close(); t.Fatalf("migrate: %v", err) } + repo, err := NewSQLiteRepo(db) + if err != nil { db.Close(); t.Fatalf("new sqlite repo: %v", err) } + t.Cleanup(func() { repo.Close() }) + return repo +} + +func TestCreateAndGetEndpoint(t *testing.T) { + repo := openTestRepo(t) + e, err := repo.CreateEndpoint(context.Background(), "test-one") + if err != nil { t.Fatalf("CreateEndpoint: %v", err) } + if e.ID == "" { t.Error("expected non-empty ID") } + if e.URL != "test-one" { t.Errorf("url: got %q", e.URL) } + if e.CreatedAt == "" || e.UpdatedAt == "" { t.Error("expected timestamps") } + got, err := repo.GetEndpoint(context.Background(), e.ID) + if err != nil { t.Fatalf("GetEndpoint: %v", err) } + if got.ID != e.ID || got.URL != e.URL { t.Errorf("got %+v", got) } +} + +func TestGetEndpointNotFound(t *testing.T) { + repo := openTestRepo(t) + _, err := repo.GetEndpoint(context.Background(), "nonexistent") + if err == nil { t.Fatal("expected error") } +} + +func TestCreateEndpointDuplicateURL(t *testing.T) { + repo := openTestRepo(t) + ctx := context.Background() + if _, err := repo.CreateEndpoint(ctx, "dup"); err != nil { t.Fatal(err) } + if _, err := repo.CreateEndpoint(ctx, "dup"); err == nil { t.Fatal("expected UNIQUE error") } +} + +func TestAppendAndListRequests(t *testing.T) { + repo := openTestRepo(t) + ctx := context.Background() + e, err := repo.CreateEndpoint(ctx, "reqs-test") + if err != nil { t.Fatal(err) } + r1 := &Request{Method: "POST", Path: "/webhook", Headers: `{"Content-Type":"application/json"}`, Query: "foo=bar", Body: []byte(`{"hello":"world"}`)} + if err := repo.AppendRequest(ctx, e.ID, r1); err != nil { t.Fatal(err) } + time.Sleep(time.Millisecond) + r2 := &Request{Method: "GET", Path: "/webhook", Headers: `{}`} + if err := repo.AppendRequest(ctx, e.ID, r2); err != nil { t.Fatal(err) } + reqs, err := repo.ListRequests(ctx, e.ID, 10) + if err != nil { t.Fatal(err) } + if len(reqs) != 2 { t.Fatalf("got %d requests", len(reqs)) } + if reqs[0].Method != "GET" { t.Errorf("first: got %s", reqs[0].Method) } + if reqs[1].Method != "POST" { t.Errorf("second: got %s", reqs[1].Method) } +} + +func TestListRequestsLimit(t *testing.T) { + repo := openTestRepo(t) + ctx := context.Background() + e, _ := repo.CreateEndpoint(ctx, "limit-test") + for i := 0; i < 5; i++ { repo.AppendRequest(ctx, e.ID, &Request{Method: "GET", Path: "/"}) } + reqs, err := repo.ListRequests(ctx, e.ID, 3) + if err != nil { t.Fatal(err) } + if len(reqs) != 3 { t.Fatalf("got %d", len(reqs)) } +} + +func TestListRequestsEmpty(t *testing.T) { + repo := openTestRepo(t) + ctx := context.Background() + e, _ := repo.CreateEndpoint(ctx, "empty-test") + reqs, _ := repo.ListRequests(ctx, e.ID, 10) + if len(reqs) != 0 { t.Errorf("got %d", len(reqs)) } +} + +func TestAppendRequestBodyBinary(t *testing.T) { + repo := openTestRepo(t) + ctx := context.Background() + e, _ := repo.CreateEndpoint(ctx, "binary-test") + body := []byte{0x00, 0x01, 0x02, 0xFF} + if err := repo.AppendRequest(ctx, e.ID, &Request{Method: "POST", Path: "/binary", Body: body}); err != nil { t.Fatal(err) } + reqs, _ := repo.ListRequests(ctx, e.ID, 1) + if len(reqs) != 1 { t.Fatal("expected 1 request") } + if len(reqs[0].Body) != 4 { t.Errorf("body len: %d", len(reqs[0].Body)) } +} + +func TestMigrateIdempotent(t *testing.T) { + db, err := sql.Open("sqlite", ":memory:?_journal_mode=WAL&_foreign_keys=on") + if err != nil { t.Fatal(err) } + defer db.Close() + db.SetMaxOpenConns(1) + if err := migrate(db); err != nil { t.Fatal(err) } + if err := migrate(db); err != nil { t.Fatal(err) } + var name string + if err := db.QueryRow("SELECT name FROM sqlite_master WHERE type='table' AND name='endpoints'").Scan(&name); err != nil { + t.Fatalf("endpoints table: %v", err) + } + if err := db.QueryRow("SELECT name FROM sqlite_master WHERE type='table' AND name='requests'").Scan(&name); err != nil { + t.Fatalf("requests table: %v", err) + } +}