Files
Snip/backend/internal/httpx/api/api_test.go
T
2026-06-15 21:25:57 +07:00

199 lines
6.3 KiB
Go

package api_test
import (
"bytes"
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"time"
"github.com/snip/backend/internal/adapter/memory"
"github.com/snip/backend/internal/adapter/security"
"github.com/snip/backend/internal/config"
"github.com/snip/backend/internal/domain"
"github.com/snip/backend/internal/httpx/api"
"github.com/snip/backend/internal/service"
)
type harness struct {
h http.Handler
token string
}
func newHarness(t *testing.T) *harness {
t.Helper()
repo := memory.NewLinkRepo()
cache := memory.NewCache()
users := memory.NewUserRepo()
hasher := security.NewBcryptHasher()
sessions := security.NewHMACSessions("test-secret")
links := service.NewLinkService(repo, cache, hasher)
auth := service.NewAuthService(nil, users, sessions)
cfg := config.Config{PostLoginRedirect: "/dashboard"}
user, err := users.Upsert(context.Background(), &domain.User{
Email: "[email protected]", Name: "Sam", Provider: "test", Subject: "s1",
})
if err != nil {
t.Fatal(err)
}
token, _ := sessions.Issue(user.ID, time.Hour)
return &harness{h: api.New(links, auth, sessions, cfg).Handler(), token: token}
}
func (h *harness) do(t *testing.T, method, path, body, token string) *httptest.ResponseRecorder {
t.Helper()
var r *http.Request
if body != "" {
r = httptest.NewRequest(method, path, bytes.NewBufferString(body))
} else {
r = httptest.NewRequest(method, path, nil)
}
if token != "" {
r.AddCookie(&http.Cookie{Name: "snip_session", Value: token})
}
w := httptest.NewRecorder()
h.h.ServeHTTP(w, r)
return w
}
func decodeLink(t *testing.T, w *httptest.ResponseRecorder) map[string]any {
t.Helper()
var m map[string]any
if err := json.Unmarshal(w.Body.Bytes(), &m); err != nil {
t.Fatalf("bad json (%d): %s", w.Code, w.Body.String())
}
return m
}
func TestAnonCanCreateRandomButNotList(t *testing.T) {
h := newHarness(t)
w := h.do(t, http.MethodPost, "/api/v1/links", `{"longUrl":"acme.com/x","mode":"random"}`, "")
if w.Code != http.StatusCreated {
t.Fatalf("create: want 201, got %d: %s", w.Code, w.Body.String())
}
if code, _ := decodeLink(t, w)["code"].(string); code == "" {
t.Fatal("expected a code")
}
if w := h.do(t, http.MethodGet, "/api/v1/links", "", ""); w.Code != http.StatusUnauthorized {
t.Fatalf("anon list: want 401, got %d", w.Code)
}
}
func TestCustomAndReservedAndConflict(t *testing.T) {
h := newHarness(t)
// reserved alias → 409
if w := h.do(t, http.MethodPost, "/api/v1/links", `{"longUrl":"acme.com","mode":"custom","customAlias":"dashboard"}`, h.token); w.Code != http.StatusConflict {
t.Fatalf("reserved: want 409, got %d", w.Code)
}
// valid custom → 201
if w := h.do(t, http.MethodPost, "/api/v1/links", `{"longUrl":"acme.com","mode":"custom","customAlias":"promo"}`, h.token); w.Code != http.StatusCreated {
t.Fatalf("custom: want 201, got %d: %s", w.Code, w.Body.String())
}
// duplicate → 409
if w := h.do(t, http.MethodPost, "/api/v1/links", `{"longUrl":"acme.com","mode":"custom","customAlias":"promo"}`, h.token); w.Code != http.StatusConflict {
t.Fatalf("dupe: want 409, got %d", w.Code)
}
// anon custom → 401
if w := h.do(t, http.MethodPost, "/api/v1/links", `{"longUrl":"acme.com","mode":"custom","customAlias":"x9z"}`, ""); w.Code != http.StatusUnauthorized {
t.Fatalf("anon custom: want 401, got %d", w.Code)
}
}
func TestFullLifecycleWithPin(t *testing.T) {
h := newHarness(t)
// create
w := h.do(t, http.MethodPost, "/api/v1/links", `{"longUrl":"acme.com/a","mode":"random"}`, h.token)
id := decodeLink(t, w)["id"].(string)
// list shows it
w = h.do(t, http.MethodGet, "/api/v1/links", "", h.token)
if w.Code != http.StatusOK {
t.Fatalf("list: %d", w.Code)
}
if total := listTotal(t, w); total != 1 {
t.Fatalf("want total 1, got %d", total)
}
// patch destination
w = h.do(t, http.MethodPatch, "/api/v1/links/"+id, `{"longUrl":"acme.com/b"}`, h.token)
if w.Code != http.StatusOK || decodeLink(t, w)["longUrl"] != "https://acme.com/b" {
t.Fatalf("patch failed: %d %s", w.Code, w.Body.String())
}
// set pin
w = h.do(t, http.MethodPut, "/api/v1/links/"+id+"/pin", `{"pin":"123456"}`, h.token)
if w.Code != http.StatusOK || decodeLink(t, w)["hasPin"] != true {
t.Fatalf("set pin failed: %d %s", w.Code, w.Body.String())
}
// bad pin → 400
if w := h.do(t, http.MethodPut, "/api/v1/links/"+id+"/pin", `{"pin":"12"}`, h.token); w.Code != http.StatusBadRequest {
t.Fatalf("bad pin: want 400, got %d", w.Code)
}
// remove pin
w = h.do(t, http.MethodDelete, "/api/v1/links/"+id+"/pin", "", h.token)
if w.Code != http.StatusOK || decodeLink(t, w)["hasPin"] != false {
t.Fatalf("remove pin failed: %d", w.Code)
}
// delete
if w := h.do(t, http.MethodDelete, "/api/v1/links/"+id, "", h.token); w.Code != http.StatusNoContent {
t.Fatalf("delete: want 204, got %d", w.Code)
}
// gone from list
w = h.do(t, http.MethodGet, "/api/v1/links", "", h.token)
if total := listTotal(t, w); total != 0 {
t.Fatalf("want 0 links after delete, got %d", total)
}
}
func listTotal(t *testing.T, w *httptest.ResponseRecorder) int {
t.Helper()
var resp struct {
Items []map[string]any `json:"items"`
Total int `json:"total"`
}
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
t.Fatalf("bad list json: %s", w.Body.String())
}
return resp.Total
}
func TestPatchForbiddenForOtherUser(t *testing.T) {
h := newHarness(t)
w := h.do(t, http.MethodPost, "/api/v1/links", `{"longUrl":"acme.com","mode":"random"}`, h.token)
id := decodeLink(t, w)["id"].(string)
other := security.NewHMACSessions("test-secret")
otherToken, _ := other.Issue("someone-else", time.Hour)
if w := h.do(t, http.MethodPatch, "/api/v1/links/"+id, `{"longUrl":"https://evil.com"}`, otherToken); w.Code != http.StatusForbidden {
t.Fatalf("want 403 for non-owner, got %d", w.Code)
}
}
func TestMeRequiresSession(t *testing.T) {
h := newHarness(t)
if w := h.do(t, http.MethodGet, "/api/v1/auth/me", "", ""); w.Code != http.StatusUnauthorized {
t.Fatalf("me anon: want 401, got %d", w.Code)
}
w := h.do(t, http.MethodGet, "/api/v1/auth/me", "", h.token)
if w.Code != http.StatusOK || decodeLink(t, w)["email"] != "[email protected]" {
t.Fatalf("me: %d %s", w.Code, w.Body.String())
}
}
func TestHealthz(t *testing.T) {
h := newHarness(t)
if w := h.do(t, http.MethodGet, "/api/v1/healthz", "", ""); w.Code != http.StatusOK {
t.Fatalf("healthz: %d", w.Code)
}
}