marky

Lightweight bookmarking + plaintext archive
Log | Files | Refs | README | LICENSE

e2e_test.go (5584B)


      1 package internal
      2 
      3 import (
      4 	"bytes"
      5 	"encoding/json"
      6 	"io"
      7 	"net/http"
      8 	"net/http/cookiejar"
      9 	"net/http/httptest"
     10 	"strings"
     11 	"testing"
     12 )
     13 
     14 func TestEndToEndAPI_JSON_RSS_CRUD(t *testing.T) {
     15 	t.Parallel()
     16 
     17 	// Content server that our archiver will fetch
     18 	content := `<!doctype html><html><head><title>E2E Title</title></head><body><p>Hello Durham token!</p></body></html>`
     19 	page := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
     20 		w.Header().Set("Content-Type", "text/html")
     21 		_, _ = w.Write([]byte(content))
     22 	}))
     23 	defer page.Close()
     24 
     25 	// DB + API server
     26 	dir := t.TempDir()
     27 	db, err := OpenAtPath(t.Context(), dir+"/e2e.db")
     28 	if err != nil {
     29 		t.Fatalf("open db: %v", err)
     30 	}
     31 	t.Cleanup(func() { _ = db.Close() })
     32 
     33 	h, err := NewHandler(db)
     34 	if err != nil {
     35 		t.Fatalf("new handler: %v", err)
     36 	}
     37 	srv := httptest.NewServer(h)
     38 	defer srv.Close()
     39 
     40 	// Auth: sign up and retain cookie
     41 	jar, _ := cookiejar.New(nil)
     42 	client := &http.Client{Jar: jar}
     43 	signupPayload := map[string]string{"username": "e2euser", "password": "pass"}
     44 	_ = postJSONWithClient[map[string]any](t, client, srv.URL+"/signup", signupPayload, 200)
     45 
     46 	// 1) Create bookmark without title -> title inferred; archive stored
     47 	body := map[string]any{"url": page.URL}
     48 	b := postJSONWithClient[map[string]any](t, client, srv.URL+"/bookmarks", body, 201)
     49 	idFloat, _ := b["id"].(float64)
     50 	if idFloat <= 0 {
     51 		t.Fatalf("expected id > 0, got %v", idFloat)
     52 	}
     53 	if title, _ := b["title"].(string); title != "E2E Title" {
     54 		t.Fatalf("expected inferred title 'E2E Title', got %q", title)
     55 	}
     56 
     57 	id := int(idFloat)
     58 
     59 	// 2) RSS feed
     60 	resp := getWithClient(t, client, srv.URL+"/bookmarks.xml", 200)
     61 	if ct := resp.Header.Get("Content-Type"); !strings.Contains(ct, "application/rss+xml") {
     62 		t.Fatalf("expected rss content-type, got %q", ct)
     63 	}
     64 	rssBytes, _ := io.ReadAll(resp.Body)
     65 	_ = resp.Body.Close()
     66 	if !bytes.Contains(rssBytes, []byte("<rss")) {
     67 		t.Fatalf("expected <rss> in feed")
     68 	}
     69 
     70 	// 3) Search JSON with excerpt
     71 	list := getJSONWithClient[[]map[string]any](t, client, srv.URL+"/bookmarks?q=durham", 200)
     72 	if len(list) != 1 {
     73 		t.Fatalf("expected 1 result, got %d", len(list))
     74 	}
     75 	if ex, _ := list[0]["excerpt"].(string); !strings.Contains(strings.ToLower(ex), "«durham»") {
     76 		t.Fatalf("expected excerpt highlighting token, got %q", ex)
     77 	}
     78 
     79 	// 4) GET archive
     80 	arch := getWithClient(t, client, srv.URL+"/bookmarks/"+itoa(id)+"/archive", 200)
     81 	archBody, _ := io.ReadAll(arch.Body)
     82 	_ = arch.Body.Close()
     83 	if !bytes.Contains(bytes.ToLower(archBody), []byte("hello")) {
     84 		t.Fatalf("expected archived text, got: %q", string(archBody))
     85 	}
     86 
     87 	// 5) Update title via PUT
     88 	upd := map[string]any{"url": page.URL, "title": "New Title"}
     89 	b2 := putJSONWithClient[map[string]any](t, client, srv.URL+"/bookmarks/"+itoa(id), upd, 200)
     90 	if b2["title"].(string) != "New Title" {
     91 		t.Fatalf("expected updated title, got %q", b2["title"])
     92 	}
     93 
     94 	// 6) Delete
     95 	delWithClient(t, client, srv.URL+"/bookmarks/"+itoa(id), 204)
     96 	list2 := getJSONWithClient[[]map[string]any](t, client, srv.URL+"/bookmarks", 200)
     97 	if len(list2) != 0 {
     98 		t.Fatalf("expected 0 bookmarks after delete, got %d", len(list2))
     99 	}
    100 }
    101 
    102 // Helpers
    103 func itoa(i int) string { return strconvItoa(i) }
    104 
    105 func postJSONWithClient[T any](t *testing.T, client *http.Client, url string, v any, want int) T {
    106 	t.Helper()
    107 	b, _ := json.Marshal(v)
    108 	resp, err := client.Post(url, "application/json", bytes.NewReader(b))
    109 	if err != nil {
    110 		t.Fatalf("post json: %v", err)
    111 	}
    112 	defer resp.Body.Close()
    113 	if resp.StatusCode != want {
    114 		t.Fatalf("POST %s: got %d want %d", url, resp.StatusCode, want)
    115 	}
    116 	var out T
    117 	if err := json.NewDecoder(resp.Body).Decode(&out); err != nil {
    118 		t.Fatalf("decode: %v", err)
    119 	}
    120 	return out
    121 }
    122 
    123 func putJSONWithClient[T any](t *testing.T, client *http.Client, url string, v any, want int) T {
    124 	t.Helper()
    125 	b, _ := json.Marshal(v)
    126 	req, _ := http.NewRequest(http.MethodPut, url, bytes.NewReader(b))
    127 	req.Header.Set("Content-Type", "application/json")
    128 	resp, err := client.Do(req)
    129 	if err != nil {
    130 		t.Fatalf("put json: %v", err)
    131 	}
    132 	defer resp.Body.Close()
    133 	if resp.StatusCode != want {
    134 		t.Fatalf("PUT %s: got %d want %d", url, resp.StatusCode, want)
    135 	}
    136 	var out T
    137 	if err := json.NewDecoder(resp.Body).Decode(&out); err != nil {
    138 		t.Fatalf("decode: %v", err)
    139 	}
    140 	return out
    141 }
    142 
    143 func getJSONWithClient[T any](t *testing.T, client *http.Client, url string, want int) T {
    144 	t.Helper()
    145 	resp := getWithClient(t, client, url, want)
    146 	defer resp.Body.Close()
    147 	var out T
    148 	if err := json.NewDecoder(resp.Body).Decode(&out); err != nil {
    149 		t.Fatalf("decode: %v", err)
    150 	}
    151 	return out
    152 }
    153 
    154 func getWithClient(t *testing.T, client *http.Client, url string, want int) *http.Response {
    155 	t.Helper()
    156 	resp, err := client.Get(url)
    157 	if err != nil {
    158 		t.Fatalf("get: %v", err)
    159 	}
    160 	if resp.StatusCode != want {
    161 		t.Fatalf("GET %s: got %d want %d", url, resp.StatusCode, want)
    162 	}
    163 	return resp
    164 }
    165 
    166 func delWithClient(t *testing.T, client *http.Client, url string, want int) {
    167 	t.Helper()
    168 	req, _ := http.NewRequest(http.MethodDelete, url, nil)
    169 	resp, err := client.Do(req)
    170 	if err != nil {
    171 		t.Fatalf("delete: %v", err)
    172 	}
    173 	defer resp.Body.Close()
    174 	if resp.StatusCode != want {
    175 		t.Fatalf("DELETE %s: got %d want %d", url, resp.StatusCode, want)
    176 	}
    177 }
    178 
    179 // strconv.Itoa substitute without extra imports
    180 func strconvItoa(i int) string {
    181 	if i == 0 {
    182 		return "0"
    183 	}
    184 	neg := i < 0
    185 	if neg {
    186 		i = -i
    187 	}
    188 	var buf [20]byte
    189 	bp := len(buf)
    190 	for i > 0 {
    191 		bp--
    192 		buf[bp] = byte('0' + i%10)
    193 		i /= 10
    194 	}
    195 	if neg {
    196 		bp--
    197 		buf[bp] = '-'
    198 	}
    199 	return string(buf[bp:])
    200 }