mirror of
https://github.com/ThisTine/Snip.git
synced 2026-08-18 23:18:47 +07:00
feat: first commit
This commit is contained in:
@@ -0,0 +1,198 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user