commit 42f1ecfc4ab7fe7a3c0644091b8cf4c4b6876d8b
parent d1c9e11a7457eb2d7822cfb223a0080461f71714
Author: David Eisinger <[email protected]>
Date: Mon, 6 Oct 2025 23:47:21 -0400
Add more tests
Diffstat:
3 files changed, 273 insertions(+), 0 deletions(-)
diff --git a/internal/api_auth_test.go b/internal/api_auth_test.go
@@ -0,0 +1,52 @@
+package internal
+
+import (
+ "bytes"
+ "encoding/json"
+ "net/http"
+ "net/http/cookiejar"
+ "net/http/httptest"
+ "testing"
+)
+
+func TestSignupLoginLogoutMe(t *testing.T) {
+ t.Parallel()
+ db, err := OpenAtPath(t.Context(), t.TempDir()+"/authz.db")
+ if err != nil { t.Fatalf("open: %v", err) }
+ t.Cleanup(func(){ _ = db.Close() })
+ h, _ := NewHandler(db)
+ srv := httptest.NewServer(h)
+ defer srv.Close()
+
+ jar, _ := cookiejar.New(nil)
+ client := &http.Client{Jar: jar}
+
+ // /me unauth
+ resp, _ := http.Get(srv.URL+"/me")
+ if resp.StatusCode != http.StatusUnauthorized { t.Fatalf("/me unauth got %d", resp.StatusCode) }
+
+ // signup
+ b, _ := json.Marshal(map[string]string{"username":"bob","password":"pw"})
+ r, _ := http.NewRequest(http.MethodPost, srv.URL+"/signup", bytes.NewReader(b))
+ r.Header.Set("Content-Type","application/json")
+ rs, _ := client.Do(r)
+ if rs.StatusCode != http.StatusOK { t.Fatalf("signup status %d", rs.StatusCode) }
+ rs.Body.Close()
+
+ // /me auth
+ r2, _ := client.Get(srv.URL+"/me")
+ if r2.StatusCode != http.StatusOK { t.Fatalf("/me auth %d", r2.StatusCode) }
+ r2.Body.Close()
+
+ // logout
+ r3, _ := http.NewRequest(http.MethodPost, srv.URL+"/logout", nil)
+ rs3, _ := client.Do(r3)
+ if rs3.StatusCode != http.StatusNoContent { t.Fatalf("logout %d", rs3.StatusCode) }
+ rs3.Body.Close()
+
+ // /me again
+ r4, _ := client.Get(srv.URL+"/me")
+ if r4.StatusCode != http.StatusUnauthorized { t.Fatalf("/me after logout %d", r4.StatusCode) }
+ r4.Body.Close()
+}
+
diff --git a/internal/bookmarks_api_test.go b/internal/bookmarks_api_test.go
@@ -0,0 +1,97 @@
+package internal
+
+import (
+ "bytes"
+ "encoding/json"
+ "io"
+ "net/http"
+ "net/http/cookiejar"
+ "net/http/httptest"
+ "strings"
+ "testing"
+)
+
+func TestBookmarksCRUDAndUnauthorized(t *testing.T) {
+ t.Parallel()
+ page := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ w.Header().Set("Content-Type","text/html")
+ io.WriteString(w, "<!doctype html><html><head><title>CRUD Title</title></head><body>body</body></html>")
+ }))
+ defer page.Close()
+
+ db, err := OpenAtPath(t.Context(), t.TempDir()+"/crud.db")
+ if err != nil { t.Fatalf("open: %v", err) }
+ t.Cleanup(func(){ _ = db.Close() })
+ h, _ := NewHandler(db)
+ srv := httptest.NewServer(h)
+ defer srv.Close()
+
+ // Unauth GET /bookmarks → 401
+ if resp, _ := http.Get(srv.URL+"/bookmarks"); resp.StatusCode != http.StatusUnauthorized {
+ t.Fatalf("/bookmarks unauth %d", resp.StatusCode)
+ }
+ // Unauth POST /bookmarks → 401
+ rp, _ := http.NewRequest(http.MethodPost, srv.URL+"/bookmarks", strings.NewReader("{}"))
+ rp.Header.Set("Content-Type","application/json")
+ if rs, _ := http.DefaultClient.Do(rp); rs.StatusCode != http.StatusUnauthorized {
+ t.Fatalf("post unauth %d", rs.StatusCode)
+ }
+
+ // Sign up client
+ jar, _ := cookiejar.New(nil)
+ client := &http.Client{Jar: jar}
+ body, _ := json.Marshal(map[string]string{"username":"crud","password":"pw"})
+ rq, _ := http.NewRequest(http.MethodPost, srv.URL+"/signup", bytes.NewReader(body))
+ rq.Header.Set("Content-Type","application/json")
+ rsu, _ := client.Do(rq)
+ if rsu.StatusCode != http.StatusOK { t.Fatalf("signup %d", rsu.StatusCode) }
+ rsu.Body.Close()
+
+ // Wrong content type → 415
+ bad, _ := http.NewRequest(http.MethodPost, srv.URL+"/bookmarks", strings.NewReader("not json"))
+ bad.Header.Set("Content-Type","text/plain")
+ rbad, _ := client.Do(bad)
+ if rbad.StatusCode != http.StatusBadRequest { t.Fatalf("ct status %d", rbad.StatusCode) }
+ rbad.Body.Close()
+
+ // Create
+ create := map[string]any{"url": page.URL}
+ cbody, _ := json.Marshal(create)
+ rc, _ := http.NewRequest(http.MethodPost, srv.URL+"/bookmarks", bytes.NewReader(cbody))
+ rc.Header.Set("Content-Type","application/json")
+ rcr, _ := client.Do(rc)
+ if rcr.StatusCode != http.StatusCreated { t.Fatalf("create %d", rcr.StatusCode) }
+ var created map[string]any
+ _ = json.NewDecoder(rcr.Body).Decode(&created)
+ rcr.Body.Close()
+ id := int(created["id"].(float64))
+ if created["title"].(string) != "CRUD Title" { t.Fatalf("title inferred") }
+
+ // Duplicate POST should 200 and touch updated_at
+ rc2, _ := http.NewRequest(http.MethodPost, srv.URL+"/bookmarks", bytes.NewReader(cbody))
+ rc2.Header.Set("Content-Type","application/json")
+ rcr2, _ := client.Do(rc2)
+ if rcr2.StatusCode != http.StatusOK { t.Fatalf("dup %d", rcr2.StatusCode) }
+ rcr2.Body.Close()
+
+ // GET list and RSS
+ if _, err := client.Get(srv.URL+"/bookmarks"); err != nil { t.Fatalf("list err: %v", err) }
+ if resp, _ := client.Get(srv.URL+"/bookmarks.xml"); resp.StatusCode != 200 { t.Fatalf("rss %d", resp.StatusCode) }
+
+ // PUT update (preserve description when omitted)
+ put := map[string]any{"url": page.URL, "title":"Changed"}
+ pb, _ := json.Marshal(put)
+ pr, _ := http.NewRequest(http.MethodPut, srv.URL+"/bookmarks/"+strconvItoa(id), bytes.NewReader(pb))
+ pr.Header.Set("Content-Type","application/json")
+ prr, _ := client.Do(pr)
+ if prr.StatusCode != 200 { t.Fatalf("put %d", prr.StatusCode) }
+ var upd map[string]any
+ _ = json.NewDecoder(prr.Body).Decode(&upd)
+ prr.Body.Close()
+ if upd["title"].(string) != "Changed" { t.Fatalf("put title") }
+
+ // DELETE
+ dr, _ := http.NewRequest(http.MethodDelete, srv.URL+"/bookmarks/"+strconvItoa(id), nil)
+ drr, _ := client.Do(dr)
+ if drr.StatusCode != 204 { t.Fatalf("delete %d", drr.StatusCode) }
+}
diff --git a/internal/more_api_tests.go b/internal/more_api_tests.go
@@ -0,0 +1,124 @@
+package internal
+
+import (
+ "bytes"
+ "encoding/json"
+ "io"
+ "net/http"
+ "net/http/cookiejar"
+ "net/http/httptest"
+ "testing"
+ "strconv"
+)
+
+func TestPublicUserListingJSON(t *testing.T) {
+ t.Parallel()
+ db, _ := OpenAtPath(t.Context(), t.TempDir()+"/u.db")
+ t.Cleanup(func(){ _ = db.Close() })
+ h, _ := NewHandler(db)
+ srv := httptest.NewServer(h)
+ defer srv.Close()
+
+ // Unknown user
+ if resp, _ := http.Get(srv.URL+"/u/nouser" ); resp.StatusCode != http.StatusNotFound {
+ t.Fatalf("/u/nouser expected 404, got %d", resp.StatusCode)
+ }
+
+ // Create user and ensure empty list
+ _, _ = db.CreateUser(t.Context(), "eve", "pw")
+ if resp, _ := http.Get(srv.URL+"/u/eve" ); resp.StatusCode != 200 {
+ t.Fatalf("/u/eve %d", resp.StatusCode)
+ }
+}
+
+func TestBookmarksParamValidationAndRSSAccept(t *testing.T) {
+ t.Parallel()
+ page := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ w.Header().Set("Content-Type","text/html")
+ io.WriteString(w, "<!doctype html><html><head><title>P</title></head><body/></html>")
+ }))
+ defer page.Close()
+
+ db, _ := OpenAtPath(t.Context(), t.TempDir()+"/p.db")
+ t.Cleanup(func(){ _ = db.Close() })
+ h, _ := NewHandler(db)
+ srv := httptest.NewServer(h)
+ defer srv.Close()
+
+ jar, _ := cookiejar.New(nil)
+ client := &http.Client{Jar: jar}
+ // signup
+ body, _ := json.Marshal(map[string]string{"username":"param","password":"pw"})
+ req, _ := http.NewRequest(http.MethodPost, srv.URL+"/signup", bytes.NewReader(body))
+ req.Header.Set("Content-Type","application/json")
+ rs, _ := client.Do(req)
+ if rs.StatusCode != 200 { t.Fatalf("signup %d", rs.StatusCode) }
+ rs.Body.Close()
+ // One bookmark
+ b2, _ := json.Marshal(map[string]any{"url": page.URL})
+ rq, _ := http.NewRequest(http.MethodPost, srv.URL+"/bookmarks", bytes.NewReader(b2))
+ rq.Header.Set("Content-Type","application/json")
+ cr, _ := client.Do(rq)
+ if cr.StatusCode != 201 { t.Fatalf("create %d", cr.StatusCode) }
+ cr.Body.Close()
+
+ // invalid limit
+ if resp, _ := client.Get(srv.URL+"/bookmarks?limit=abc"); resp.StatusCode != http.StatusBadRequest {
+ t.Fatalf("limit status %d", resp.StatusCode)
+ }
+ // invalid offset
+ if resp, _ := client.Get(srv.URL+"/bookmarks?offset=-1"); resp.StatusCode != http.StatusBadRequest {
+ t.Fatalf("offset status %d", resp.StatusCode)
+ }
+ // Accept RSS
+ req2, _ := http.NewRequest(http.MethodGet, srv.URL+"/bookmarks", nil)
+ req2.Header.Set("Accept","application/rss+xml")
+ rs2, _ := client.Do(req2)
+ if rs2.StatusCode != 200 { t.Fatalf("accept rss %d", rs2.StatusCode) }
+ rs2.Body.Close()
+}
+
+func TestArchiveFetchOwnerOnly(t *testing.T) {
+ t.Parallel()
+ // Page to fetch
+ page := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ w.Header().Set("Content-Type","text/html")
+ io.WriteString(w, "<!doctype html><html><head><title>A</title></head><body/></html>")
+ }))
+ defer page.Close()
+
+ db, _ := OpenAtPath(t.Context(), t.TempDir()+"/arc.db")
+ t.Cleanup(func(){ _ = db.Close() })
+ h, _ := NewHandler(db)
+ srv := httptest.NewServer(h)
+ defer srv.Close()
+
+ // signup owner
+ j1, _ := cookiejar.New(nil)
+ c1 := &http.Client{Jar: j1}
+ s1, _ := json.Marshal(map[string]string{"username":"own","password":"pw"})
+ rq1, _ := http.NewRequest(http.MethodPost, srv.URL+"/signup", bytes.NewReader(s1))
+ rq1.Header.Set("Content-Type","application/json")
+ _ , _ = c1.Do(rq1)
+ // create via DB without archive
+ u, _ := db.GetUserByUsername(t.Context(), "own")
+ bmk, err := db.CreateBookmark(t.Context(), u.ID, page.URL+"/priv", "PVT", nil)
+ if err != nil { t.Fatalf("create raw: %v", err) }
+ // owner can fetch with ?fetch=1
+ resp, _ := c1.Get(srv.URL+"/bookmarks/"+strconv.Itoa(int(bmk.ID))+"/archive?fetch=1")
+ if resp.StatusCode != 200 { t.Fatalf("owner fetch %d", resp.StatusCode) }
+ resp.Body.Close()
+
+ // signup other user
+ j2, _ := cookiejar.New(nil)
+ c2 := &http.Client{Jar: j2}
+ s2, _ := json.Marshal(map[string]string{"username":"other","password":"pw"})
+ rq2, _ := http.NewRequest(http.MethodPost, srv.URL+"/signup", bytes.NewReader(s2))
+ rq2.Header.Set("Content-Type","application/json")
+ _ , _ = c2.Do(rq2)
+ // other user cannot see private archive
+ resp2, _ := c2.Get(srv.URL+"/bookmarks/"+strconv.Itoa(int(bmk.ID))+"/archive")
+ if resp2.StatusCode != 404 { t.Fatalf("other archive %d", resp2.StatusCode) }
+ resp2.Body.Close()
+}
+// no helper needed; use strconv.Itoa inline