All checks were successful
Release / Scraper / Test (push) Successful in 10s
Release / UI / Build (push) Successful in 26s
Release / v2 / Build ui-v2 (push) Successful in 17s
Release / Scraper / Docker (push) Successful in 47s
Release / UI / Docker (push) Successful in 56s
CI / Scraper / Lint (pull_request) Successful in 7s
CI / Scraper / Test (pull_request) Successful in 8s
CI / UI / Build (pull_request) Successful in 16s
CI / Scraper / Docker Push (pull_request) Has been skipped
CI / UI / Docker Push (pull_request) Has been skipped
Release / v2 / Docker / ui-v2 (push) Successful in 56s
Release / v2 / Test backend (push) Successful in 4m35s
iOS CI / Build (pull_request) Successful in 4m28s
Release / v2 / Docker / backend (push) Successful in 1m29s
Release / v2 / Docker / runner (push) Successful in 1m39s
iOS CI / Test (pull_request) Successful in 9m51s
- backend/: Go API server and runner binaries with PocketBase + MinIO storage - ui-v2/: SvelteKit frontend rewrite - docker-compose-new.yml: compose file for the v2 stack - .gitea/workflows/release-v2.yaml: CI/CD for backend, runner, and ui-v2 Docker Hub images - scripts/pb-init.sh: migrate from wget to curl, add superuser bootstrap for fresh installs - .env.example: document DOCKER_BUILDKIT=1 for Colima users
182 lines
5.5 KiB
Go
182 lines
5.5 KiB
Go
package httputil_test
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/libnovel/backend/internal/httputil"
|
|
)
|
|
|
|
// ── RetryGet ──────────────────────────────────────────────────────────────────
|
|
|
|
func TestRetryGet_ImmediateSuccess(t *testing.T) {
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
w.Write([]byte("hello"))
|
|
}))
|
|
defer srv.Close()
|
|
|
|
body, err := httputil.RetryGet(context.Background(), srv.Client(), srv.URL, 3, time.Millisecond)
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
if body != "hello" {
|
|
t.Errorf("want hello, got %q", body)
|
|
}
|
|
}
|
|
|
|
func TestRetryGet_RetriesOn5xx(t *testing.T) {
|
|
calls := 0
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
calls++
|
|
if calls < 3 {
|
|
w.WriteHeader(http.StatusServiceUnavailable)
|
|
return
|
|
}
|
|
w.Write([]byte("ok"))
|
|
}))
|
|
defer srv.Close()
|
|
|
|
body, err := httputil.RetryGet(context.Background(), srv.Client(), srv.URL, 5, time.Millisecond)
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
if body != "ok" {
|
|
t.Errorf("want ok, got %q", body)
|
|
}
|
|
if calls != 3 {
|
|
t.Errorf("want 3 calls, got %d", calls)
|
|
}
|
|
}
|
|
|
|
func TestRetryGet_MaxAttemptsExceeded(t *testing.T) {
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
w.WriteHeader(http.StatusInternalServerError)
|
|
}))
|
|
defer srv.Close()
|
|
|
|
_, err := httputil.RetryGet(context.Background(), srv.Client(), srv.URL, 3, time.Millisecond)
|
|
if err == nil {
|
|
t.Fatal("expected error, got nil")
|
|
}
|
|
}
|
|
|
|
func TestRetryGet_ContextCancelDuringBackoff(t *testing.T) {
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
w.WriteHeader(http.StatusServiceUnavailable)
|
|
}))
|
|
defer srv.Close()
|
|
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
|
|
// Cancel after first failed attempt hits the backoff wait.
|
|
go func() { time.Sleep(5 * time.Millisecond); cancel() }()
|
|
|
|
_, err := httputil.RetryGet(ctx, srv.Client(), srv.URL, 10, 500*time.Millisecond)
|
|
if err == nil {
|
|
t.Fatal("expected context cancellation error")
|
|
}
|
|
}
|
|
|
|
func TestRetryGet_NoRetryOn4xx(t *testing.T) {
|
|
calls := 0
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
calls++
|
|
w.WriteHeader(http.StatusNotFound)
|
|
}))
|
|
defer srv.Close()
|
|
|
|
_, err := httputil.RetryGet(context.Background(), srv.Client(), srv.URL, 5, time.Millisecond)
|
|
if err == nil {
|
|
t.Fatal("expected error for 404")
|
|
}
|
|
// 4xx is NOT retried — should be exactly 1 call.
|
|
if calls != 1 {
|
|
t.Errorf("want 1 call for 4xx, got %d", calls)
|
|
}
|
|
}
|
|
|
|
// ── WriteJSON ─────────────────────────────────────────────────────────────────
|
|
|
|
func TestWriteJSON_SetsHeadersAndStatus(t *testing.T) {
|
|
rr := httptest.NewRecorder()
|
|
httputil.WriteJSON(rr, http.StatusCreated, map[string]string{"key": "val"})
|
|
|
|
if rr.Code != http.StatusCreated {
|
|
t.Errorf("status: want 201, got %d", rr.Code)
|
|
}
|
|
if ct := rr.Header().Get("Content-Type"); ct != "application/json" {
|
|
t.Errorf("Content-Type: want application/json, got %q", ct)
|
|
}
|
|
var got map[string]string
|
|
if err := json.NewDecoder(rr.Body).Decode(&got); err != nil {
|
|
t.Fatalf("decode body: %v", err)
|
|
}
|
|
if got["key"] != "val" {
|
|
t.Errorf("body key: want val, got %q", got["key"])
|
|
}
|
|
}
|
|
|
|
// ── WriteError ────────────────────────────────────────────────────────────────
|
|
|
|
func TestWriteError_Format(t *testing.T) {
|
|
rr := httptest.NewRecorder()
|
|
httputil.WriteError(rr, http.StatusBadRequest, "bad input")
|
|
|
|
if rr.Code != http.StatusBadRequest {
|
|
t.Errorf("status: want 400, got %d", rr.Code)
|
|
}
|
|
var got map[string]string
|
|
json.NewDecoder(rr.Body).Decode(&got)
|
|
if got["error"] != "bad input" {
|
|
t.Errorf("error field: want bad input, got %q", got["error"])
|
|
}
|
|
}
|
|
|
|
// ── DecodeJSON ────────────────────────────────────────────────────────────────
|
|
|
|
func TestDecodeJSON_HappyPath(t *testing.T) {
|
|
body := `{"name":"test","value":42}`
|
|
req := httptest.NewRequest(http.MethodPost, "/", strings.NewReader(body))
|
|
req.Header.Set("Content-Type", "application/json")
|
|
|
|
var payload struct {
|
|
Name string `json:"name"`
|
|
Value int `json:"value"`
|
|
}
|
|
if err := httputil.DecodeJSON(req, &payload); err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
if payload.Name != "test" || payload.Value != 42 {
|
|
t.Errorf("unexpected payload: %+v", payload)
|
|
}
|
|
}
|
|
|
|
func TestDecodeJSON_UnknownFieldReturnsError(t *testing.T) {
|
|
body := `{"name":"test","unknown_field":"boom"}`
|
|
req := httptest.NewRequest(http.MethodPost, "/", strings.NewReader(body))
|
|
|
|
var payload struct {
|
|
Name string `json:"name"`
|
|
}
|
|
if err := httputil.DecodeJSON(req, &payload); err == nil {
|
|
t.Fatal("expected error for unknown field, got nil")
|
|
}
|
|
}
|
|
|
|
func TestDecodeJSON_BodyTooLarge(t *testing.T) {
|
|
// Build a body > 1 MiB.
|
|
big := bytes.Repeat([]byte("a"), 2<<20)
|
|
req := httptest.NewRequest(http.MethodPost, "/", bytes.NewReader(big))
|
|
|
|
var payload map[string]any
|
|
if err := httputil.DecodeJSON(req, &payload); err == nil {
|
|
t.Fatal("expected error for oversized body, got nil")
|
|
}
|
|
}
|