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 }