diff options
| author | Adam Mathes <adam@adammathes.com> | 2026-02-12 19:55:05 -0800 |
|---|---|---|
| committer | Adam Mathes <adam@adammathes.com> | 2026-02-12 19:55:05 -0800 |
| commit | 16186a344a7b61633cb7342aac37ac56ad83d261 (patch) | |
| tree | 739556a9dc80457d072a6f3ab1db4226fa25a9f5 /web/web_test.go | |
| parent | 39ed5fcfe9327ab4eb81c4863d9e6353f08f6c07 (diff) | |
| download | neko-16186a344a7b61633cb7342aac37ac56ad83d261.tar.gz neko-16186a344a7b61633cb7342aac37ac56ad83d261.tar.bz2 neko-16186a344a7b61633cb7342aac37ac56ad83d261.zip | |
Add comprehensive test suite — 81% cross-package coverage
Bug fixes:
- config: remove unused log import
- item: fix Printf format %d->%t for boolean ReadState
- util: update stale config.Read -> config.Init, remove config.Config.DBServer
Test files added:
- config/config_test.go: Init, readConfig, addDefaults (100%)
- vlog/vlog_test.go: Printf, Println verbose/silent (100%)
- models/db_test.go: InitDB tests
- models/feed/feed_test.go: CRUD, filter, Categories, NewFeed, ResolveFeedURL (87%)
- models/item/item_test.go: CRUD, Filter with category/search/starred, rewriteImages (71%)
- exporter/exporter_test.go: all export formats (91%)
- importer/importer_test.go: InsertIItem, ImportJSON (90%)
- crawler/crawler_test.go: GetFeedContent, CrawlFeed, CrawlWorker, Crawl (89%)
- web/web_test.go: auth, login/logout, stream, item, feed, category,
export, crawl, imageProxy handlers (77%)
Remaining 0% functions require HTTP/rice.MustFindBox/main entry and
can't be unit tested without refactoring (see tickets NK-gqkh96, NK-6q9nyg).
Diffstat (limited to 'web/web_test.go')
| -rw-r--r-- | web/web_test.go | 647 |
1 files changed, 647 insertions, 0 deletions
diff --git a/web/web_test.go b/web/web_test.go new file mode 100644 index 0000000..1a83798 --- /dev/null +++ b/web/web_test.go @@ -0,0 +1,647 @@ +package web + +import ( + "encoding/base64" + "encoding/json" + "net/http" + "net/http/httptest" + "net/url" + "strings" + "testing" + + "adammathes.com/neko/config" + "adammathes.com/neko/models" + "golang.org/x/crypto/bcrypt" +) + +func setupTestDB(t *testing.T) { + t.Helper() + config.Config.DBFile = ":memory:" + models.InitDB() + t.Cleanup(func() { + if models.DB != nil { + models.DB.Close() + } + }) +} + +func seedData(t *testing.T) { + t.Helper() + _, err := models.DB.Exec("INSERT INTO feed(url, web_url, title, category) VALUES(?, ?, ?, ?)", + "https://example.com/feed", "https://example.com", "Example Feed", "tech") + if err != nil { + t.Fatal(err) + } + _, err = models.DB.Exec(`INSERT INTO item(title, url, description, publish_date, feed_id, read_state, starred) + VALUES(?, ?, ?, ?, ?, ?, ?)`, + "Test Item", "https://example.com/item1", "Description", "2024-01-01 00:00:00", 1, 0, 0) + if err != nil { + t.Fatal(err) + } +} + +func authCookie() *http.Cookie { + hash, _ := bcrypt.GenerateFromPassword([]byte("secret"), 0) + return &http.Cookie{Name: AuthCookie, Value: string(hash)} +} + +// --- Authentication tests --- + +func TestAuthenticatedNoCookie(t *testing.T) { + req := httptest.NewRequest("GET", "/", nil) + if Authenticated(req) { + t.Error("Should not be authenticated without cookie") + } +} + +func TestAuthenticatedBadCookie(t *testing.T) { + config.Config.DigestPassword = "secret" + req := httptest.NewRequest("GET", "/", nil) + req.AddCookie(&http.Cookie{Name: AuthCookie, Value: "badvalue"}) + if Authenticated(req) { + t.Error("Should not be authenticated with bad cookie") + } +} + +func TestAuthenticatedValidCookie(t *testing.T) { + config.Config.DigestPassword = "secret" + hash, _ := bcrypt.GenerateFromPassword([]byte("secret"), 0) + req := httptest.NewRequest("GET", "/", nil) + req.AddCookie(&http.Cookie{Name: AuthCookie, Value: string(hash)}) + if !Authenticated(req) { + t.Error("Should be authenticated with valid cookie") + } +} + +func TestAuthenticatedWrongPassword(t *testing.T) { + config.Config.DigestPassword = "secret" + hash, _ := bcrypt.GenerateFromPassword([]byte("wrongpassword"), 0) + req := httptest.NewRequest("GET", "/", nil) + req.AddCookie(&http.Cookie{Name: AuthCookie, Value: string(hash)}) + if Authenticated(req) { + t.Error("Should not be authenticated with wrong password hash") + } +} + +// --- AuthWrap tests --- + +func TestAuthWrapUnauthenticated(t *testing.T) { + config.Config.DigestPassword = "secret" + handler := AuthWrap(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + }) + req := httptest.NewRequest("GET", "/", nil) + rr := httptest.NewRecorder() + handler.ServeHTTP(rr, req) + + if rr.Code != http.StatusTemporaryRedirect { + t.Errorf("Expected 307, got %d", rr.Code) + } +} + +func TestAuthWrapAuthenticated(t *testing.T) { + config.Config.DigestPassword = "secret" + called := false + handler := AuthWrap(func(w http.ResponseWriter, r *http.Request) { + called = true + w.WriteHeader(http.StatusOK) + }) + req := httptest.NewRequest("GET", "/", nil) + req.AddCookie(authCookie()) + rr := httptest.NewRecorder() + handler.ServeHTTP(rr, req) + + if !called { + t.Error("Wrapped handler should be called") + } +} + +// --- Login/Logout tests --- + +func TestLoginHandlerPostSuccess(t *testing.T) { + config.Config.DigestPassword = "testpass" + req := httptest.NewRequest("POST", "/login/", nil) + req.Form = map[string][]string{"password": {"testpass"}} + rr := httptest.NewRecorder() + loginHandler(rr, req) + if rr.Code != http.StatusTemporaryRedirect { + t.Errorf("Expected 307, got %d", rr.Code) + } +} + +func TestLoginHandlerPostFail(t *testing.T) { + config.Config.DigestPassword = "testpass" + req := httptest.NewRequest("POST", "/login/", nil) + req.Form = map[string][]string{"password": {"wrongpass"}} + rr := httptest.NewRecorder() + loginHandler(rr, req) + if rr.Code != http.StatusUnauthorized { + t.Errorf("Expected 401, got %d", rr.Code) + } +} + +func TestLoginHandlerBadMethod(t *testing.T) { + req := httptest.NewRequest("DELETE", "/login/", nil) + rr := httptest.NewRecorder() + loginHandler(rr, req) + if rr.Code != http.StatusInternalServerError { + t.Errorf("Expected 500, got %d", rr.Code) + } +} + +func TestLogoutHandler(t *testing.T) { + req := httptest.NewRequest("GET", "/logout/", nil) + rr := httptest.NewRecorder() + logoutHandler(rr, req) + + if rr.Code != http.StatusOK { + t.Errorf("Expected 200, got %d", rr.Code) + } + cookies := rr.Result().Cookies() + found := false + for _, c := range cookies { + if c.Name == AuthCookie { + found = true + if c.MaxAge != 0 { + t.Errorf("Logout should set MaxAge=0, got %d", c.MaxAge) + } + } + } + if !found { + t.Error("Logout should set auth cookie") + } + if rr.Body.String() != "you are logged out" { + t.Errorf("Expected logout message, got %q", rr.Body.String()) + } +} + +// --- Stream handler tests --- + +func TestStreamHandler(t *testing.T) { + setupTestDB(t) + seedData(t) + config.Config.DigestPassword = "secret" + + req := httptest.NewRequest("GET", "/stream/", nil) + rr := httptest.NewRecorder() + streamHandler(rr, req) + + if rr.Code != http.StatusOK { + t.Errorf("Expected 200, got %d", rr.Code) + } + + contentType := rr.Header().Get("Content-Type") + if contentType != "application/json" { + t.Errorf("Expected application/json, got %q", contentType) + } + + var items []interface{} + err := json.Unmarshal(rr.Body.Bytes(), &items) + if err != nil { + t.Fatalf("Response should be valid JSON: %v", err) + } + if len(items) != 1 { + t.Errorf("Expected 1 item, got %d", len(items)) + } +} + +func TestStreamHandlerWithMaxId(t *testing.T) { + setupTestDB(t) + seedData(t) + + req := httptest.NewRequest("GET", "/stream/?max_id=999", nil) + rr := httptest.NewRecorder() + streamHandler(rr, req) + + if rr.Code != http.StatusOK { + t.Errorf("Expected 200, got %d", rr.Code) + } +} + +func TestStreamHandlerWithFeedUrl(t *testing.T) { + setupTestDB(t) + seedData(t) + + req := httptest.NewRequest("GET", "/stream/?feed_url=https://example.com/feed", nil) + rr := httptest.NewRecorder() + streamHandler(rr, req) + + if rr.Code != http.StatusOK { + t.Errorf("Expected 200, got %d", rr.Code) + } +} + +func TestStreamHandlerWithTag(t *testing.T) { + setupTestDB(t) + seedData(t) + + req := httptest.NewRequest("GET", "/stream/?tag=tech", nil) + rr := httptest.NewRecorder() + streamHandler(rr, req) + if rr.Code != http.StatusOK { + t.Errorf("Expected 200, got %d", rr.Code) + } +} + +func TestStreamHandlerWithReadFilter(t *testing.T) { + setupTestDB(t) + seedData(t) + + req := httptest.NewRequest("GET", "/stream/?read_filter=all", nil) + rr := httptest.NewRecorder() + streamHandler(rr, req) + if rr.Code != http.StatusOK { + t.Errorf("Expected 200, got %d", rr.Code) + } +} + +func TestStreamHandlerWithStarred(t *testing.T) { + setupTestDB(t) + seedData(t) + + req := httptest.NewRequest("GET", "/stream/?starred=1", nil) + rr := httptest.NewRecorder() + streamHandler(rr, req) + if rr.Code != http.StatusOK { + t.Errorf("Expected 200, got %d", rr.Code) + } +} + +func TestStreamHandlerWithSearch(t *testing.T) { + setupTestDB(t) + seedData(t) + + req := httptest.NewRequest("GET", "/stream/?q=test", nil) + rr := httptest.NewRecorder() + streamHandler(rr, req) + // Search may or may not find results, but should not error + if rr.Code != http.StatusOK { + t.Errorf("Expected 200, got %d", rr.Code) + } +} + +// --- Item handler tests --- + +func TestItemHandlerPut(t *testing.T) { + setupTestDB(t) + seedData(t) + + body := `{"_id":"1","read":true,"starred":true}` + req := httptest.NewRequest("PUT", "/item/1", strings.NewReader(body)) + rr := httptest.NewRecorder() + itemHandler(rr, req) + if rr.Code != http.StatusOK { + t.Errorf("Expected 200, got %d", rr.Code) + } +} + +func TestItemHandlerPutBadJSON(t *testing.T) { + setupTestDB(t) + + req := httptest.NewRequest("PUT", "/item/1", strings.NewReader("not json")) + rr := httptest.NewRecorder() + itemHandler(rr, req) + // Should not crash + if rr.Code != http.StatusOK { + t.Errorf("Expected 200 (error handled internally), got %d", rr.Code) + } +} + +// --- Feed handler tests --- + +func TestFeedHandlerGet(t *testing.T) { + setupTestDB(t) + seedData(t) + + req := httptest.NewRequest("GET", "/feed/", nil) + rr := httptest.NewRecorder() + feedHandler(rr, req) + + if rr.Code != http.StatusOK { + t.Errorf("Expected 200, got %d", rr.Code) + } + + var feeds []interface{} + err := json.Unmarshal(rr.Body.Bytes(), &feeds) + if err != nil { + t.Fatalf("Response should be valid JSON: %v", err) + } + if len(feeds) != 1 { + t.Errorf("Expected 1 feed, got %d", len(feeds)) + } +} + +func TestFeedHandlerPut(t *testing.T) { + setupTestDB(t) + seedData(t) + + body := `{"_id":1,"url":"https://example.com/feed","title":"Updated Feed","category":"updated"}` + req := httptest.NewRequest("PUT", "/feed/", strings.NewReader(body)) + rr := httptest.NewRecorder() + feedHandler(rr, req) + if rr.Code != http.StatusOK { + t.Errorf("Expected 200, got %d", rr.Code) + } +} + +func TestFeedHandlerDelete(t *testing.T) { + setupTestDB(t) + seedData(t) + + req := httptest.NewRequest("DELETE", "/feed/1", nil) + rr := httptest.NewRecorder() + feedHandler(rr, req) + if rr.Code != http.StatusOK { + t.Errorf("Expected 200, got %d", rr.Code) + } +} + +func TestFeedHandlerDeleteBadId(t *testing.T) { + setupTestDB(t) + + req := httptest.NewRequest("DELETE", "/feed/notanumber", nil) + rr := httptest.NewRecorder() + feedHandler(rr, req) + // Should handle gracefully (returns early after logging error) +} + +// --- Category handler tests --- + +func TestCategoryHandlerGet(t *testing.T) { + setupTestDB(t) + seedData(t) + + req := httptest.NewRequest("GET", "/tag/", nil) + rr := httptest.NewRecorder() + categoryHandler(rr, req) + + if rr.Code != http.StatusOK { + t.Errorf("Expected 200, got %d", rr.Code) + } + + var categories []interface{} + err := json.Unmarshal(rr.Body.Bytes(), &categories) + if err != nil { + t.Fatalf("Response should be valid JSON: %v", err) + } +} + +func TestCategoryHandlerNonGet(t *testing.T) { + req := httptest.NewRequest("POST", "/tag/", nil) + rr := httptest.NewRecorder() + categoryHandler(rr, req) + // Non-GET is a no-op, just verify no crash + if rr.Code != http.StatusOK { + t.Errorf("Expected 200, got %d", rr.Code) + } +} + +// --- Export handler tests --- + +func TestExportHandler(t *testing.T) { + setupTestDB(t) + seedData(t) + + req := httptest.NewRequest("GET", "/text", nil) + rr := httptest.NewRecorder() + exportHandler(rr, req) + + if rr.Code != http.StatusOK { + t.Errorf("Expected 200, got %d", rr.Code) + } + if rr.Header().Get("content-type") != "text/plain" { + t.Errorf("Expected text/plain content type") + } +} + +// --- Crawl handler tests --- + +func TestCrawlHandler(t *testing.T) { + setupTestDB(t) + + req := httptest.NewRequest("GET", "/crawl/", nil) + rr := httptest.NewRecorder() + crawlHandler(rr, req) + + body := rr.Body.String() + if !strings.Contains(body, "crawling...") { + t.Error("Expected 'crawling...' in response") + } + if !strings.Contains(body, "done...") { + t.Error("Expected 'done...' in response") + } +} + +// --- FullText handler tests --- + +func TestFullTextHandlerNoId(t *testing.T) { + setupTestDB(t) + + req := httptest.NewRequest("GET", "/0", nil) + rr := httptest.NewRecorder() + fullTextHandler(rr, req) + // Should return early with no content + if rr.Code != http.StatusOK { + t.Errorf("Expected 200, got %d", rr.Code) + } +} + +// --- Image proxy handler tests --- + +func TestImageProxyHandlerIfNoneMatch(t *testing.T) { + req := httptest.NewRequest("GET", "/aHR0cHM6Ly9leGFtcGxlLmNvbS9pbWFnZS5qcGc=", nil) + req.Header.Set("If-None-Match", "https://example.com/image.jpg") + rr := httptest.NewRecorder() + imageProxyHandler(rr, req) + if rr.Code != http.StatusNotModified { + t.Errorf("Expected 304, got %d", rr.Code) + } +} + +func TestSecondsInAYear(t *testing.T) { + expected := 60 * 60 * 24 * 365 + if SecondsInAYear != expected { + t.Errorf("SecondsInAYear = %d, want %d", SecondsInAYear, expected) + } +} + +// --- More image proxy handler tests --- + +func TestImageProxyHandlerEtag(t *testing.T) { + req := httptest.NewRequest("GET", "/aHR0cHM6Ly9leGFtcGxlLmNvbS9pbWFnZS5qcGc=", nil) + req.Header.Set("Etag", "https://example.com/image.jpg") + rr := httptest.NewRecorder() + imageProxyHandler(rr, req) + if rr.Code != http.StatusNotModified { + t.Errorf("Expected 304, got %d", rr.Code) + } +} + +func TestImageProxyHandlerSuccess(t *testing.T) { + // Create test image server + imgServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "image/jpeg") + w.WriteHeader(200) + w.Write([]byte("fake-image-data")) + })) + defer imgServer.Close() + + // Encode the URL as base64 + encodedURL := base64.URLEncoding.EncodeToString([]byte(imgServer.URL + "/test.jpg")) + + // Build request with URL set to just the encoded string + // (simulating http.StripPrefix behavior) + req := httptest.NewRequest("GET", "/"+encodedURL, nil) + req.URL = &url.URL{Path: encodedURL} + rr := httptest.NewRecorder() + imageProxyHandler(rr, req) + + if rr.Code != http.StatusOK { + t.Errorf("Expected 200, got %d", rr.Code) + } + if rr.Body.String() != "fake-image-data" { + t.Errorf("Expected image data, got %q", rr.Body.String()) + } + if rr.Header().Get("ETag") == "" { + t.Error("Expected ETag header") + } + if rr.Header().Get("Cache-Control") != "public" { + t.Error("Expected Cache-Control: public") + } +} + +func TestImageProxyHandlerBadRemote(t *testing.T) { + // Encode a URL that will fail to connect + encodedURL := base64.URLEncoding.EncodeToString([]byte("http://127.0.0.1:1/bad")) + req := httptest.NewRequest("GET", "/"+encodedURL, nil) + req.URL = &url.URL{Path: encodedURL} + rr := httptest.NewRecorder() + imageProxyHandler(rr, req) + // Should return 404 + if rr.Code != http.StatusNotFound { + t.Errorf("Expected 404, got %d", rr.Code) + } +} + +// --- More fulltext handler tests --- + +func TestFullTextHandlerNonNumericId(t *testing.T) { + setupTestDB(t) + + req := httptest.NewRequest("GET", "/abc", nil) + rr := httptest.NewRecorder() + fullTextHandler(rr, req) + // Should return early since Atoi("abc") = 0 + if rr.Code != http.StatusOK { + t.Errorf("Expected 200, got %d", rr.Code) + } +} + +// --- Item handler GET test --- + +func TestItemHandlerGetNoId(t *testing.T) { + setupTestDB(t) + + req := httptest.NewRequest("GET", "/0", nil) + rr := httptest.NewRecorder() + itemHandler(rr, req) + if rr.Code != http.StatusOK { + t.Errorf("Expected 200, got %d", rr.Code) + } +} + +// --- Additional export handler tests --- + +func TestExportHandlerOPML(t *testing.T) { + setupTestDB(t) + seedData(t) + + req := httptest.NewRequest("GET", "/opml", nil) + rr := httptest.NewRecorder() + exportHandler(rr, req) + if rr.Code != http.StatusOK { + t.Errorf("Expected 200, got %d", rr.Code) + } +} + +func TestExportHandlerJSON(t *testing.T) { + setupTestDB(t) + seedData(t) + + req := httptest.NewRequest("GET", "/json", nil) + rr := httptest.NewRecorder() + exportHandler(rr, req) + if rr.Code != http.StatusOK { + t.Errorf("Expected 200, got %d", rr.Code) + } +} + +func TestExportHandlerHTML(t *testing.T) { + setupTestDB(t) + seedData(t) + + req := httptest.NewRequest("GET", "/html", nil) + rr := httptest.NewRecorder() + exportHandler(rr, req) + if rr.Code != http.StatusOK { + t.Errorf("Expected 200, got %d", rr.Code) + } +} + +// --- Feed handler PUT with bad JSON --- + +func TestFeedHandlerPutBadJSON(t *testing.T) { + setupTestDB(t) + + req := httptest.NewRequest("PUT", "/feed/", strings.NewReader("not json")) + rr := httptest.NewRecorder() + feedHandler(rr, req) + // Should handle gracefully (logs error, attempts f.Update with zero values) + if rr.Code != http.StatusOK { + t.Errorf("Expected 200, got %d", rr.Code) + } +} + +// --- Item handler PUT with read/unread states --- + +func TestItemHandlerPutUnread(t *testing.T) { + setupTestDB(t) + seedData(t) + + body := `{"_id":"1","read":false,"starred":false}` + req := httptest.NewRequest("PUT", "/item/1", strings.NewReader(body)) + rr := httptest.NewRecorder() + itemHandler(rr, req) + if rr.Code != http.StatusOK { + t.Errorf("Expected 200, got %d", rr.Code) + } +} + +// --- Stream handler with multiple combined params --- + +func TestStreamHandlerCombinedParams(t *testing.T) { + setupTestDB(t) + seedData(t) + + req := httptest.NewRequest("GET", "/stream/?max_id=999&read_filter=all&starred=0", nil) + rr := httptest.NewRecorder() + streamHandler(rr, req) + if rr.Code != http.StatusOK { + t.Errorf("Expected 200, got %d", rr.Code) + } +} + +// --- Category handler PUT test --- + +func TestCategoryHandlerPut(t *testing.T) { + setupTestDB(t) + seedData(t) + + req := httptest.NewRequest("PUT", "/tag/", nil) + rr := httptest.NewRecorder() + categoryHandler(rr, req) + // PUT is handled by the default case (no-op) + if rr.Code != http.StatusOK { + t.Errorf("Expected 200, got %d", rr.Code) + } +} |
