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: "sam@example.com", 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"] != "sam@example.com" { 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) } }