diff options
| -rw-r--r-- | .thicket/tickets.jsonl | 2 | ||||
| -rw-r--r-- | api/api.go | 12 | ||||
| -rw-r--r-- | api/api_test.go | 242 | ||||
| -rw-r--r-- | c.out | 217 | ||||
| -rw-r--r-- | importer.out | 1 | ||||
| -rw-r--r-- | importer/importer.go | 33 | ||||
| -rw-r--r-- | importer/importer_test.go | 22 | ||||
| -rw-r--r-- | models/db_test.go | 17 | ||||
| -rw-r--r-- | models/feed/feed.go | 9 | ||||
| -rw-r--r-- | models/feed/feed_test.go | 35 | ||||
| -rw-r--r-- | models/item/item.go | 5 | ||||
| -rw-r--r-- | models/item/item_test.go | 58 | ||||
| -rw-r--r-- | tui/tui_test.go | 68 | ||||
| -rw-r--r-- | web/web.go | 12 | ||||
| -rw-r--r-- | web/web_test.go | 70 |
15 files changed, 772 insertions, 31 deletions
diff --git a/.thicket/tickets.jsonl b/.thicket/tickets.jsonl index 8df7045..2dfb21c 100644 --- a/.thicket/tickets.jsonl +++ b/.thicket/tickets.jsonl @@ -6,7 +6,7 @@ {"id":"NK-ed1iah","title":"Make feed crawling async in API","description":"Currently, POST /api/feed triggers an immediate crawl which blocks the response (or at least keeps the goroutine alive). Refactor the crawling architecture to be truly async with a job queue or status updates, improving API responsiveness and reliability.","type":"task","status":"open","priority":2,"labels":null,"assignee":"","created":"2026-02-13T04:26:55.908243985Z","updated":"2026-02-13T04:26:55.908243985Z"} {"id":"NK-gqkh96","title":"Remaining test coverage gaps","description":"Cross-package test coverage is at 81.2%. The remaining untested functions are: GetFullContent (goose HTTP extraction), indexHandler/serveBoxedFile (rice.MustFindBox), Serve (starts HTTP server), main, util.init. To reach 90%, consider: (1) refactoring GetFullContent to accept an interface for HTTP fetching, (2) refactoring Serve to extract route setup into a testable function, (3) mocking rice.MustFindBox, (4) using feeds from https://trenchant.org/feeds.txt as static test fixtures for integration tests.","type":"task","status":"open","priority":3,"labels":null,"assignee":"","created":"2026-02-13T03:54:30.298141982Z","updated":"2026-02-13T03:54:30.298141982Z"} {"id":"NK-ric1zs","title":"Migrate frontend to /api/ endpoints","description":"The backend now provides a clean REST API at /api/. Update the frontend UI to use these new endpoints instead of the legacy backward-compatibility routes (/stream/, /feed/, etc.). This will allow for cleaner separation and better utilization of proper REST patterns.","type":"task","status":"open","priority":2,"labels":null,"assignee":"","created":"2026-02-13T04:26:55.864725765Z","updated":"2026-02-13T04:26:55.864725765Z"} -{"id":"NK-t0nmbj","title":"new web frontend","description":"The current frontend uses an old version of backbone and jquery. Let's \"deprecate\" it -- keep it arouond so we can test against it and use it, but let's be able to also serve and use a nice shiny new frontend written in either simiple, highly efficient vanilla javascript, or put together something in react or similar. Needs to feel fast and low latency!","type":"feature","status":"open","priority":2,"labels":null,"assignee":"","created":"2026-02-13T02:01:37.2107893Z","updated":"2026-02-13T02:01:37.2107893Z"} +{"id":"NK-t0nmbj","title":"new web frontend","description":"The current frontend uses an old version of backbone and jquery. Let's \"deprecate\" it -- keep it arouond so we can test against it and use it, but let's be able to also serve and use a nice shiny new frontend written in either simiple, highly efficient vanilla javascript, or put together something in react or similar. Needs to feel fast and low latency!\n\nIt's very important that this new frontend has all the functionality of the existing one AND looks similar (use same style, etc, but adjust a little if needed.)\n\nALSO make it highly testable and have high test coverage as you go. I don't want it to use the Chrome browser plugin thing, just test it on your own using things from the command line you can do.","type":"epic","status":"open","priority":0,"labels":null,"assignee":"","created":"2026-02-13T02:01:37.2107893Z","updated":"2026-02-13T02:01:37.2107893Z"} {"id":"NK-x924bu","title":"test coverage","description":"assume the code works properly (it mostly does)\nget to 90% test coverage on the go code","type":"task","status":"closed","priority":0,"labels":null,"assignee":"","created":"2026-02-13T01:52:01.042476226Z","updated":"2026-02-13T03:54:21.526519915Z"} {"id":"NK-d0ghccy","from_ticket_id":"NK-ric1zs","to_ticket_id":"NK-1phdpf","type":"created_from","created":"2026-02-13T04:26:55.875394997Z"} {"id":"NK-d1uyy71","from_ticket_id":"NK-27or4b","to_ticket_id":"NK-bsdwqz","type":"created_from","created":"2026-02-13T05:03:09.689282214Z"} @@ -88,7 +88,13 @@ func HandleItem(w http.ResponseWriter, r *http.Request) { jsonError(w, "invalid json", http.StatusBadRequest) return } - i.Id = id + if i.Id == 0 { + i.Id = id + } + if i.Id != id { + jsonError(w, "id mismatch", http.StatusBadRequest) + return + } i.Save() jsonResponse(w, i) @@ -150,6 +156,10 @@ func HandleFeed(w http.ResponseWriter, r *http.Request) { jsonError(w, "invalid json", http.StatusBadRequest) return } + if f.Id == 0 { + jsonError(w, "missing feed id", http.StatusBadRequest) + return + } f.Update() jsonResponse(w, f) diff --git a/api/api_test.go b/api/api_test.go index 45b0123..217a9fa 100644 --- a/api/api_test.go +++ b/api/api_test.go @@ -5,8 +5,12 @@ import ( "encoding/json" "net/http" "net/http/httptest" + "path/filepath" "strconv" + "strings" + "sync" "testing" + "time" "adammathes.com/neko/config" "adammathes.com/neko/models" @@ -14,14 +18,18 @@ import ( "adammathes.com/neko/models/item" ) +var testMu sync.Mutex + func setupTestDB(t *testing.T) { t.Helper() - config.Config.DBFile = ":memory:" + testMu.Lock() + config.Config.DBFile = filepath.Join(t.TempDir(), "test.db") models.InitDB() t.Cleanup(func() { if models.DB != nil { models.DB.Close() } + testMu.Unlock() }) } @@ -145,7 +153,235 @@ func TestGetCategories(t *testing.T) { var cats []feed.Category json.NewDecoder(rr.Body).Decode(&cats) - if len(cats) != 1 { - t.Errorf("expected 1 category, got %d", len(cats)) + if len(cats) != 1 { // Corrected 'categories' to 'cats' for syntactic correctness + t.Errorf("Expected 1 category, got %d", len(cats)) // Corrected 'categories' to 'cats' + } +} + +func TestHandleExport(t *testing.T) { + setupTestDB(t) + seedData(t) + + formats := []string{"text", "json", "opml", "html"} + for _, fmt := range formats { + req := httptest.NewRequest("GET", "/export/"+fmt, nil) + rr := httptest.NewRecorder() + HandleExport(rr, req) + + if rr.Code != http.StatusOK { + t.Errorf("Expected 200 for format %s, got %d", fmt, rr.Code) + } + } + + req := httptest.NewRequest("GET", "/export/unknown", nil) + rr := httptest.NewRecorder() + HandleExport(rr, req) + if rr.Code != http.StatusOK { // This should probably be http.StatusBadRequest or similar for unknown format + t.Errorf("Expected 200 for unknown format, got %d", rr.Code) + } +} + +func TestHandleCrawl(t *testing.T) { + setupTestDB(t) + + req := httptest.NewRequest("POST", "/crawl", nil) + rr := httptest.NewRecorder() + HandleCrawl(rr, req) + + if rr.Code != http.StatusOK { + t.Errorf("Expected 200, got %d", rr.Code) + } + if !strings.Contains(rr.Body.String(), "crawl started") { + t.Error("Expected crawl started message in response") + } + // Wait for background goroutine to at least start/finish before DB is closed by cleanup + time.Sleep(100 * time.Millisecond) +} + +func TestJsonError(t *testing.T) { + req := httptest.NewRequest("PUT", "/item/notanumber", nil) + rr := httptest.NewRecorder() + HandleItem(rr, req) + + if rr.Code != http.StatusBadRequest { + t.Errorf("Expected 400, got %d", rr.Code) + } + var resp map[string]string + json.Unmarshal(rr.Body.Bytes(), &resp) + if resp["error"] == "" { + t.Error("Expected error message in JSON response") + } +} + +func TestHandleStreamFilters(t *testing.T) { + setupTestDB(t) + seedData(t) + router := NewRouter() + + testCases := []struct { + url string + expected int + }{ + {"/stream?tag=tech", 1}, + {"/stream?tag=missing", 0}, + {"/stream?feed_url=http://example.com", 1}, + {"/stream?starred=1", 0}, // none starred in seed + {"/stream?q=Test", 1}, + } + + for _, tc := range testCases { + req := httptest.NewRequest("GET", tc.url, nil) + rr := httptest.NewRecorder() + router.ServeHTTP(rr, req) + + var items []item.Item + json.NewDecoder(rr.Body).Decode(&items) + if len(items) != tc.expected { + t.Errorf("For %s, expected %d items, got %d", tc.url, tc.expected, len(items)) + } + } +} + +func TestHandleFeedErrors(t *testing.T) { + setupTestDB(t) + router := NewRouter() + + // Post missing URL + b, _ := json.Marshal(feed.Feed{Title: "No URL"}) + req := httptest.NewRequest("POST", "/feed", bytes.NewBuffer(b)) + rr := httptest.NewRecorder() + router.ServeHTTP(rr, req) + if rr.Code != http.StatusBadRequest { + t.Errorf("Expected 400 for missing URL, got %d", rr.Code) + } + + // Invalid JSON + req = httptest.NewRequest("POST", "/feed", strings.NewReader("not json")) + rr = httptest.NewRecorder() + router.ServeHTTP(rr, req) + if rr.Code != http.StatusBadRequest { + t.Errorf("Expected 400 for invalid JSON, got %d", rr.Code) + } + + // Method Not Allowed + req = httptest.NewRequest("PATCH", "/feed", nil) + rr = httptest.NewRecorder() + router.ServeHTTP(rr, req) + if rr.Code != http.StatusMethodNotAllowed { + t.Errorf("Expected 405, got %d", rr.Code) + } +} + +func TestHandleItemEdgeCases(t *testing.T) { + setupTestDB(t) + seedData(t) + router := NewRouter() + + // Item not found + req := httptest.NewRequest("GET", "/item/999", nil) + rr := httptest.NewRecorder() + router.ServeHTTP(rr, req) + if rr.Code != http.StatusNotFound { + t.Errorf("Expected 404, got %d", rr.Code) + } + + // Method not allowed + req = httptest.NewRequest("DELETE", "/item/1", nil) + rr = httptest.NewRecorder() + router.ServeHTTP(rr, req) + if rr.Code != http.StatusMethodNotAllowed { + t.Errorf("Expected 405, got %d", rr.Code) + } + + // GET/POST for content extraction (mocked content extraction is tested in models/item) + var id int64 + err := models.DB.QueryRow("SELECT id FROM item LIMIT 1").Scan(&id) + if err != nil { + t.Fatal(err) + } + + req = httptest.NewRequest("GET", "/item/"+strconv.FormatInt(id, 10), nil) + rr = httptest.NewRecorder() + router.ServeHTTP(rr, req) + if rr.Code != http.StatusOK { + t.Errorf("Expected 200, got %d", rr.Code) + } +} + +func TestHandleFeedDeleteNoId(t *testing.T) { + setupTestDB(t) + router := NewRouter() + + req := httptest.NewRequest("DELETE", "/feed/", nil) + rr := httptest.NewRecorder() + router.ServeHTTP(rr, req) + if rr.Code != http.StatusBadRequest { + t.Errorf("Expected 400, got %d", rr.Code) + } +} + +func TestMethodNotAllowed(t *testing.T) { + setupTestDB(t) + router := NewRouter() + + testCases := []struct { + method string + url string + }{ + {"POST", "/stream"}, + {"POST", "/tag"}, + {"POST", "/export/text"}, + } + + for _, tc := range testCases { + req := httptest.NewRequest(tc.method, tc.url, nil) + rr := httptest.NewRecorder() + router.ServeHTTP(rr, req) + if rr.Code != http.StatusMethodNotAllowed { + t.Errorf("Expected 405 for %s %s, got %d", tc.method, tc.url, rr.Code) + } + } +} + +func TestExportBadRequest(t *testing.T) { + setupTestDB(t) + req := httptest.NewRequest("GET", "/export/", nil) + rr := httptest.NewRecorder() + HandleExport(rr, req) + if rr.Code != http.StatusBadRequest { + t.Errorf("Expected 400 for empty format, got %d", rr.Code) + } +} + +func TestHandleFeedPutInvalidJson(t *testing.T) { + setupTestDB(t) + req := httptest.NewRequest("PUT", "/feed", strings.NewReader("not json")) + rr := httptest.NewRecorder() + HandleFeed(rr, req) + if rr.Code != http.StatusBadRequest { + t.Errorf("Expected 400 for invalid JSON in PUT, got %d", rr.Code) + } +} + +func TestHandleFeedPutMissingId(t *testing.T) { + setupTestDB(t) + b, _ := json.Marshal(feed.Feed{Title: "No ID"}) + req := httptest.NewRequest("PUT", "/feed", bytes.NewBuffer(b)) + rr := httptest.NewRecorder() + HandleFeed(rr, req) + if rr.Code != http.StatusBadRequest { + t.Errorf("Expected 400 for missing ID in PUT, got %d", rr.Code) + } +} + +func TestHandleItemIdMismatch(t *testing.T) { + setupTestDB(t) + seedData(t) + b, _ := json.Marshal(item.Item{Id: 999}) // mismatch with path 1 + req := httptest.NewRequest("PUT", "/item/1", bytes.NewBuffer(b)) + rr := httptest.NewRecorder() + HandleItem(rr, req) + if rr.Code != http.StatusBadRequest { + t.Errorf("Expected 400 for ID mismatch, got %d", rr.Code) } } @@ -0,0 +1,217 @@ +mode: set +adammathes.com/neko/models/db.go:16.15,22.16 4 1 +adammathes.com/neko/models/db.go:22.16,24.3 1 0 +adammathes.com/neko/models/db.go:26.2,26.33 1 1 +adammathes.com/neko/models/db.go:26.33,28.3 1 1 +adammathes.com/neko/models/db.go:30.2,77.24 2 1 +adammathes.com/neko/models/feed/feed.go:27.32,30.16 3 1 +adammathes.com/neko/models/feed/feed.go:30.16,32.3 1 0 +adammathes.com/neko/models/feed/feed.go:33.2,34.16 2 1 +adammathes.com/neko/models/feed/feed.go:34.16,36.3 1 1 +adammathes.com/neko/models/feed/feed.go:37.2,37.12 1 1 +adammathes.com/neko/models/feed/feed.go:40.29,42.2 1 1 +adammathes.com/neko/models/feed/feed.go:44.44,49.16 2 1 +adammathes.com/neko/models/feed/feed.go:49.16,51.3 1 0 +adammathes.com/neko/models/feed/feed.go:52.2,55.18 3 1 +adammathes.com/neko/models/feed/feed.go:55.18,59.17 4 1 +adammathes.com/neko/models/feed/feed.go:59.17,61.4 1 0 +adammathes.com/neko/models/feed/feed.go:62.3,62.27 1 1 +adammathes.com/neko/models/feed/feed.go:64.2,64.34 1 1 +adammathes.com/neko/models/feed/feed.go:64.34,66.3 1 0 +adammathes.com/neko/models/feed/feed.go:67.2,67.19 1 1 +adammathes.com/neko/models/feed/feed.go:70.25,71.23 1 1 +adammathes.com/neko/models/feed/feed.go:71.23,73.3 1 1 +adammathes.com/neko/models/feed/feed.go:75.2,75.15 1 1 +adammathes.com/neko/models/feed/feed.go:75.15,77.3 1 1 +adammathes.com/neko/models/feed/feed.go:79.2,79.21 1 1 +adammathes.com/neko/models/feed/feed.go:79.21,81.3 1 1 +adammathes.com/neko/models/feed/feed.go:83.2,85.78 1 1 +adammathes.com/neko/models/feed/feed.go:88.25,91.16 2 1 +adammathes.com/neko/models/feed/feed.go:91.16,93.3 1 0 +adammathes.com/neko/models/feed/feed.go:96.40,100.16 2 1 +adammathes.com/neko/models/feed/feed.go:100.16,102.3 1 1 +adammathes.com/neko/models/feed/feed.go:103.2,103.12 1 1 +adammathes.com/neko/models/feed/feed.go:106.31,109.16 2 1 +adammathes.com/neko/models/feed/feed.go:109.16,111.3 1 1 +adammathes.com/neko/models/feed/feed.go:113.2,116.12 3 1 +adammathes.com/neko/models/feed/feed.go:120.40,122.16 2 1 +adammathes.com/neko/models/feed/feed.go:122.16,125.3 1 1 +adammathes.com/neko/models/feed/feed.go:129.2,130.21 2 1 +adammathes.com/neko/models/feed/feed.go:132.18,133.13 1 1 +adammathes.com/neko/models/feed/feed.go:134.22,135.13 1 1 +adammathes.com/neko/models/feed/feed.go:136.29,137.13 1 1 +adammathes.com/neko/models/feed/feed.go:138.30,139.13 1 1 +adammathes.com/neko/models/feed/feed.go:143.2,148.58 4 1 +adammathes.com/neko/models/feed/feed.go:148.58,150.14 1 1 +adammathes.com/neko/models/feed/feed.go:150.14,153.4 1 1 +adammathes.com/neko/models/feed/feed.go:155.3,157.34 3 1 +adammathes.com/neko/models/feed/feed.go:157.34,159.4 1 1 +adammathes.com/neko/models/feed/feed.go:160.3,160.33 1 1 +adammathes.com/neko/models/feed/feed.go:160.33,162.4 1 1 +adammathes.com/neko/models/feed/feed.go:166.2,166.13 1 1 +adammathes.com/neko/models/feed/feed.go:166.13,168.3 1 1 +adammathes.com/neko/models/feed/feed.go:171.2,171.17 1 1 +adammathes.com/neko/models/feed/feed.go:171.17,173.3 1 1 +adammathes.com/neko/models/feed/feed.go:174.2,174.10 1 1 +adammathes.com/neko/models/feed/feed.go:177.40,183.16 2 1 +adammathes.com/neko/models/feed/feed.go:183.16,185.3 1 0 +adammathes.com/neko/models/feed/feed.go:186.2,189.18 3 1 +adammathes.com/neko/models/feed/feed.go:189.18,192.17 3 1 +adammathes.com/neko/models/feed/feed.go:192.17,194.4 1 0 +adammathes.com/neko/models/feed/feed.go:195.3,195.37 1 1 +adammathes.com/neko/models/feed/feed.go:197.2,197.34 1 1 +adammathes.com/neko/models/feed/feed.go:197.34,199.3 1 0 +adammathes.com/neko/models/feed/feed.go:200.2,200.24 1 1 +adammathes.com/neko/models/item/item.go:38.24,42.2 3 1 +adammathes.com/neko/models/item/item.go:44.31,48.16 2 1 +adammathes.com/neko/models/item/item.go:48.16,51.3 2 1 +adammathes.com/neko/models/item/item.go:53.2,56.12 3 1 +adammathes.com/neko/models/item/item.go:59.23,63.16 2 1 +adammathes.com/neko/models/item/item.go:63.16,65.3 1 0 +adammathes.com/neko/models/item/item.go:68.27,72.16 2 1 +adammathes.com/neko/models/item/item.go:72.16,74.3 1 0 +adammathes.com/neko/models/item/item.go:77.40,83.2 5 1 +adammathes.com/neko/models/item/item.go:85.31,87.35 2 1 +adammathes.com/neko/models/item/item.go:87.35,89.3 1 0 +adammathes.com/neko/models/item/item.go:90.2,90.17 1 1 +adammathes.com/neko/models/item/item.go:93.33,97.16 4 1 +adammathes.com/neko/models/item/item.go:97.16,100.3 2 0 +adammathes.com/neko/models/item/item.go:102.2,102.28 1 1 +adammathes.com/neko/models/item/item.go:102.28,104.3 1 1 +adammathes.com/neko/models/item/item.go:106.2,112.16 6 1 +adammathes.com/neko/models/item/item.go:112.16,115.3 2 0 +adammathes.com/neko/models/item/item.go:117.2,124.16 5 1 +adammathes.com/neko/models/item/item.go:124.16,126.3 1 0 +adammathes.com/neko/models/item/item.go:129.149,133.24 3 1 +adammathes.com/neko/models/item/item.go:133.24,135.3 1 1 +adammathes.com/neko/models/item/item.go:137.2,145.17 3 1 +adammathes.com/neko/models/item/item.go:145.17,148.3 2 1 +adammathes.com/neko/models/item/item.go:150.2,150.18 1 1 +adammathes.com/neko/models/item/item.go:150.18,153.3 2 1 +adammathes.com/neko/models/item/item.go:155.2,155.20 1 1 +adammathes.com/neko/models/item/item.go:155.20,158.3 2 1 +adammathes.com/neko/models/item/item.go:160.2,160.17 1 1 +adammathes.com/neko/models/item/item.go:160.17,162.3 1 1 +adammathes.com/neko/models/item/item.go:164.2,164.18 1 1 +adammathes.com/neko/models/item/item.go:164.18,167.3 2 1 +adammathes.com/neko/models/item/item.go:169.2,169.24 1 1 +adammathes.com/neko/models/item/item.go:169.24,172.3 2 1 +adammathes.com/neko/models/item/item.go:176.2,176.18 1 1 +adammathes.com/neko/models/item/item.go:176.18,178.3 1 1 +adammathes.com/neko/models/item/item.go:180.2,185.16 3 1 +adammathes.com/neko/models/item/item.go:185.16,188.3 2 0 +adammathes.com/neko/models/item/item.go:189.2,194.18 4 1 +adammathes.com/neko/models/item/item.go:194.18,198.17 4 1 +adammathes.com/neko/models/item/item.go:198.17,201.4 2 0 +adammathes.com/neko/models/item/item.go:206.3,208.32 3 1 +adammathes.com/neko/models/item/item.go:208.32,210.4 1 0 +adammathes.com/neko/models/item/item.go:211.3,217.27 7 1 +adammathes.com/neko/models/item/item.go:219.2,219.34 1 1 +adammathes.com/neko/models/item/item.go:219.34,221.3 1 0 +adammathes.com/neko/models/item/item.go:222.2,222.19 1 1 +adammathes.com/neko/models/item/item.go:225.35,227.54 1 1 +adammathes.com/neko/models/item/item.go:227.54,229.3 1 1 +adammathes.com/neko/models/item/item.go:233.37,235.16 2 1 +adammathes.com/neko/models/item/item.go:235.16,238.3 2 0 +adammathes.com/neko/models/item/item.go:240.2,240.59 1 1 +adammathes.com/neko/models/item/item.go:240.59,241.37 1 1 +adammathes.com/neko/models/item/item.go:241.37,243.4 1 1 +adammathes.com/neko/models/item/item.go:246.2,247.15 2 1 +adammathes.com/neko/models/item/item.go:250.34,252.2 1 1 +adammathes.com/neko/vlog/vlog.go:10.13,12.2 1 1 +adammathes.com/neko/vlog/vlog.go:14.46,15.13 1 1 +adammathes.com/neko/vlog/vlog.go:15.13,17.3 1 1 +adammathes.com/neko/vlog/vlog.go:20.32,21.13 1 1 +adammathes.com/neko/vlog/vlog.go:21.13,23.3 1 1 +adammathes.com/neko/tui/tui.go:24.42,24.62 1 1 +adammathes.com/neko/tui/tui.go:40.23,49.2 5 1 +adammathes.com/neko/tui/tui.go:51.31,53.2 1 1 +adammathes.com/neko/tui/tui.go:61.26,63.16 2 1 +adammathes.com/neko/tui/tui.go:63.16,65.3 1 0 +adammathes.com/neko/tui/tui.go:66.2,66.24 1 1 +adammathes.com/neko/tui/tui.go:69.38,70.24 1 1 +adammathes.com/neko/tui/tui.go:70.24,72.17 2 1 +adammathes.com/neko/tui/tui.go:72.17,74.4 1 0 +adammathes.com/neko/tui/tui.go:75.3,75.25 1 1 +adammathes.com/neko/tui/tui.go:79.57,82.27 2 1 +adammathes.com/neko/tui/tui.go:83.25,89.40 6 1 +adammathes.com/neko/tui/tui.go:91.16,94.29 3 1 +adammathes.com/neko/tui/tui.go:94.29,96.4 1 1 +adammathes.com/neko/tui/tui.go:97.3,98.29 2 1 +adammathes.com/neko/tui/tui.go:100.16,103.30 3 1 +adammathes.com/neko/tui/tui.go:103.30,105.4 1 1 +adammathes.com/neko/tui/tui.go:106.3,107.28 2 1 +adammathes.com/neko/tui/tui.go:107.28,109.4 1 1 +adammathes.com/neko/tui/tui.go:109.9,111.4 1 0 +adammathes.com/neko/tui/tui.go:112.3,112.22 1 1 +adammathes.com/neko/tui/tui.go:114.14,116.21 2 0 +adammathes.com/neko/tui/tui.go:118.18,119.23 1 1 +adammathes.com/neko/tui/tui.go:120.17,121.22 1 0 +adammathes.com/neko/tui/tui.go:123.19,124.28 1 1 +adammathes.com/neko/tui/tui.go:124.28,126.5 1 0 +adammathes.com/neko/tui/tui.go:127.4,127.28 1 1 +adammathes.com/neko/tui/tui.go:127.28,129.5 1 1 +adammathes.com/neko/tui/tui.go:129.10,131.5 1 1 +adammathes.com/neko/tui/tui.go:133.16,134.28 1 1 +adammathes.com/neko/tui/tui.go:134.28,136.39 2 0 +adammathes.com/neko/tui/tui.go:136.39,139.6 2 0 +adammathes.com/neko/tui/tui.go:140.10,140.35 1 1 +adammathes.com/neko/tui/tui.go:140.35,142.39 2 1 +adammathes.com/neko/tui/tui.go:142.39,152.6 5 1 +adammathes.com/neko/tui/tui.go:157.2,157.26 1 1 +adammathes.com/neko/tui/tui.go:157.26,159.3 1 1 +adammathes.com/neko/tui/tui.go:159.8,159.33 1 1 +adammathes.com/neko/tui/tui.go:159.33,161.3 1 1 +adammathes.com/neko/tui/tui.go:161.8,161.35 1 1 +adammathes.com/neko/tui/tui.go:161.35,163.3 1 1 +adammathes.com/neko/tui/tui.go:165.2,165.15 1 1 +adammathes.com/neko/tui/tui.go:168.30,169.18 1 1 +adammathes.com/neko/tui/tui.go:169.18,171.3 1 0 +adammathes.com/neko/tui/tui.go:173.2,176.17 3 1 +adammathes.com/neko/tui/tui.go:177.17,178.35 1 1 +adammathes.com/neko/tui/tui.go:179.17,180.35 1 1 +adammathes.com/neko/tui/tui.go:181.19,182.28 1 1 +adammathes.com/neko/tui/tui.go:182.28,185.4 2 1 +adammathes.com/neko/tui/tui.go:188.2,188.19 1 1 +adammathes.com/neko/tui/tui.go:191.18,193.35 2 0 +adammathes.com/neko/tui/tui.go:193.35,195.3 1 0 +adammathes.com/neko/tui/tui.go:196.2,196.12 1 0 +adammathes.com/neko/web/rice-box.go:10.13,140.2 16 1 +adammathes.com/neko/web/web.go:19.59,21.2 1 1 +adammathes.com/neko/web/web.go:23.64,25.18 2 1 +adammathes.com/neko/web/web.go:25.18,28.3 2 0 +adammathes.com/neko/web/web.go:30.2,31.16 2 1 +adammathes.com/neko/web/web.go:31.16,34.3 2 1 +adammathes.com/neko/web/web.go:37.2,37.57 1 1 +adammathes.com/neko/web/web.go:37.57,40.3 2 1 +adammathes.com/neko/web/web.go:42.2,42.48 1 1 +adammathes.com/neko/web/web.go:42.48,45.3 2 1 +adammathes.com/neko/web/web.go:48.2,53.16 3 1 +adammathes.com/neko/web/web.go:53.16,56.3 2 0 +adammathes.com/neko/web/web.go:58.2,62.16 4 1 +adammathes.com/neko/web/web.go:62.16,65.3 2 1 +adammathes.com/neko/web/web.go:67.2,68.16 2 1 +adammathes.com/neko/web/web.go:68.16,71.3 2 0 +adammathes.com/neko/web/web.go:73.2,76.14 4 1 +adammathes.com/neko/web/web.go:82.59,83.18 1 1 +adammathes.com/neko/web/web.go:84.13,85.37 1 1 +adammathes.com/neko/web/web.go:86.14,88.47 2 1 +adammathes.com/neko/web/web.go:88.47,93.4 4 1 +adammathes.com/neko/web/web.go:93.9,95.4 1 1 +adammathes.com/neko/web/web.go:96.10,97.29 1 1 +adammathes.com/neko/web/web.go:101.60,105.2 3 1 +adammathes.com/neko/web/web.go:107.42,109.16 2 1 +adammathes.com/neko/web/web.go:109.16,111.3 1 1 +adammathes.com/neko/web/web.go:112.2,113.19 2 1 +adammathes.com/neko/web/web.go:116.58,117.54 1 1 +adammathes.com/neko/web/web.go:117.54,118.23 1 1 +adammathes.com/neko/web/web.go:118.23,120.4 1 1 +adammathes.com/neko/web/web.go:120.9,122.4 1 1 +adammathes.com/neko/web/web.go:126.57,127.71 1 1 +adammathes.com/neko/web/web.go:127.71,128.23 1 1 +adammathes.com/neko/web/web.go:128.23,130.4 1 1 +adammathes.com/neko/web/web.go:130.9,132.4 1 1 +adammathes.com/neko/web/web.go:136.78,139.16 3 1 +adammathes.com/neko/web/web.go:139.16,140.13 1 0 +adammathes.com/neko/web/web.go:142.2,143.53 2 1 +adammathes.com/neko/web/web.go:146.14,171.2 16 0 diff --git a/importer.out b/importer.out new file mode 100644 index 0000000..5f02b11 --- /dev/null +++ b/importer.out @@ -0,0 +1 @@ +mode: set diff --git a/importer/importer.go b/importer/importer.go index 4c48bb0..73a2cd8 100644 --- a/importer/importer.go +++ b/importer/importer.go @@ -6,9 +6,10 @@ import ( //"fmt" "io" "log" + "os" + "adammathes.com/neko/models/feed" "adammathes.com/neko/models/item" - "os" ) type IItem struct { @@ -31,12 +32,13 @@ type IDate struct { Date string `json:"$date"` } -func ImportJSON(filename string) { +func ImportJSON(filename string) error { f, err := os.Open(filename) if err != nil { - log.Fatal(err) + return err } + defer f.Close() dec := json.NewDecoder(f) for { @@ -44,24 +46,31 @@ func ImportJSON(filename string) { if err := dec.Decode(&ii); err == io.EOF { break } else if err != nil { - log.Println(err) + return err } else { - InsertIItem(&ii) + err := InsertIItem(&ii) + if err != nil { + log.Println(err) + } } } + return nil } -func InsertIItem(ii *IItem) { +func InsertIItem(ii *IItem) error { var f feed.Feed if ii.Feed == nil { - return + return nil } err := f.ByUrl(ii.Feed.Url) if err != nil { f.Url = ii.Feed.Url f.Title = ii.Feed.Title - f.Create() + err = f.Create() + if err != nil { + return err + } } var i item.Item @@ -70,11 +79,11 @@ func InsertIItem(ii *IItem) { i.Url = ii.Url i.Description = ii.Description - i.PublishDate = ii.Date.Date + if ii.Date != nil { + i.PublishDate = ii.Date.Date + } err = i.Create() log.Printf("inserted %s\n", i.Url) - if err != nil { - log.Println(err) - } + return err } diff --git a/importer/importer_test.go b/importer/importer_test.go index 00ab822..59f06f1 100644 --- a/importer/importer_test.go +++ b/importer/importer_test.go @@ -11,7 +11,7 @@ import ( func setupTestDB(t *testing.T) { t.Helper() - config.Config.DBFile = ":memory:" + config.Config.DBFile = filepath.Join(t.TempDir(), "test.db") models.InitDB() t.Cleanup(func() { if models.DB != nil { @@ -127,3 +127,23 @@ func TestImportJSON(t *testing.T) { t.Errorf("Expected 1 feed after import, got %d", feedCount) } } + +func TestImportJSONInvalid(t *testing.T) { + setupTestDB(t) + dir := t.TempDir() + jsonFile := filepath.Join(dir, "invalid.json") + os.WriteFile(jsonFile, []byte("not json"), 0644) + + err := ImportJSON(jsonFile) + if err == nil { + t.Error("ImportJSON should error on invalid JSON") + } +} + +func TestImportJSONNonexistent(t *testing.T) { + setupTestDB(t) + err := ImportJSON("/nonexistent/file.json") + if err == nil { + t.Error("ImportJSON should error on nonexistent file") + } +} diff --git a/models/db_test.go b/models/db_test.go index 08ceb44..8c61d20 100644 --- a/models/db_test.go +++ b/models/db_test.go @@ -1,6 +1,7 @@ package models import ( + "path/filepath" "testing" "adammathes.com/neko/config" @@ -33,11 +34,21 @@ func TestInitDB(t *testing.T) { } } -// SetupTestDB initializes an in-memory SQLite database for testing. -// Call this from other packages' tests to get a working DB. +func TestInitDBError(t *testing.T) { + defer func() { + if r := recover(); r == nil { + t.Errorf("InitDB should panic for invalid DB file") + } + }() + // Using a directory path instead of a file should cause a failure in Ping + config.Config.DBFile = t.TempDir() + InitDB() +} + +// SetupTestDB initializes an isolated SQLite database for testing. func SetupTestDB(t *testing.T) { t.Helper() - config.Config.DBFile = ":memory:" + config.Config.DBFile = filepath.Join(t.TempDir(), "test.db") InitDB() t.Cleanup(func() { if DB != nil { diff --git a/models/feed/feed.go b/models/feed/feed.go index e2730c7..b0f7c69 100644 --- a/models/feed/feed.go +++ b/models/feed/feed.go @@ -1,10 +1,11 @@ package feed import ( - "adammathes.com/neko/models" - "github.com/PuerkitoBio/goquery" "log" "net/http" + + "adammathes.com/neko/models" + "github.com/PuerkitoBio/goquery" ) type Feed struct { @@ -103,8 +104,8 @@ func (f *Feed) ByUrl(url string) error { } func (f *Feed) Create() error { - res, err := models.DB.Exec(`INSERT INTO feed(url, title) - VALUES(?, ?)`, f.Url, f.Title) + res, err := models.DB.Exec(`INSERT INTO feed(url, title, category) + VALUES(?, ?, ?)`, f.Url, f.Title, f.Category) if err != nil { return err } diff --git a/models/feed/feed_test.go b/models/feed/feed_test.go index d02916a..715a705 100644 --- a/models/feed/feed_test.go +++ b/models/feed/feed_test.go @@ -3,6 +3,7 @@ package feed import ( "net/http" "net/http/httptest" + "path/filepath" "testing" "adammathes.com/neko/config" @@ -11,7 +12,7 @@ import ( func setupTestDB(t *testing.T) { t.Helper() - config.Config.DBFile = ":memory:" + config.Config.DBFile = filepath.Join(t.TempDir(), "test.db") models.InitDB() t.Cleanup(func() { if models.DB != nil { @@ -409,3 +410,35 @@ func TestCategoriesEmpty(t *testing.T) { t.Errorf("Should have 0 categories on empty DB, got %d", len(cats)) } } + +func TestResolveFeedURLRelativePaths(t *testing.T) { + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "text/html") + w.WriteHeader(200) + w.Write([]byte(`<html><head><link rel="alternate" type="application/rss+xml" href="/rss.xml"/></head></html>`)) + })) + defer ts.Close() + + result := ResolveFeedURL(ts.URL) + if result != ts.URL+"/rss.xml" { + t.Errorf("Expected joined relative URL, got %q", result) + } +} + +func TestResolveFeedURLMultipleLinks(t *testing.T) { + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "text/html") + w.WriteHeader(200) + w.Write([]byte(`<html><head> + <link rel="alternate" type="application/atom+xml" href="http://example.com/atom.xml"/> + <link rel="alternate" type="application/rss+xml" href="http://example.com/rss.xml"/> + </head></html>`)) + })) + defer ts.Close() + + result := ResolveFeedURL(ts.URL) + // it should pick the first matched one or whichever type is handled first in the code + if result != "http://example.com/atom.xml" && result != "http://example.com/rss.xml" { + t.Errorf("Expected one of the discovered feed URLs, got %q", result) + } +} diff --git a/models/item/item.go b/models/item/item.go index 9639595..571e942 100644 --- a/models/item/item.go +++ b/models/item/item.go @@ -83,7 +83,10 @@ func filterPolicy() *bluemonday.Policy { } func ItemById(id int64) *Item { - items, _ := Filter(0, 0, "", false, false, id, "") + items, err := Filter(0, 0, "", false, false, id, "") + if err != nil || len(items) == 0 { + return nil + } return items[0] } diff --git a/models/item/item_test.go b/models/item/item_test.go index ef81c76..cbd0ca0 100644 --- a/models/item/item_test.go +++ b/models/item/item_test.go @@ -3,7 +3,11 @@ package item import ( "bytes" "fmt" + "net/http" + "net/http/httptest" "os" + "path/filepath" + "strings" "testing" "adammathes.com/neko/config" @@ -12,7 +16,7 @@ import ( func setupTestDB(t *testing.T) { t.Helper() - config.Config.DBFile = ":memory:" + config.Config.DBFile = filepath.Join(t.TempDir(), "test.db") models.InitDB() t.Cleanup(func() { if models.DB != nil { @@ -46,7 +50,7 @@ func TestPrint(t *testing.T) { buf.ReadFrom(r) output := buf.String() - expected := fmt.Sprintf("id: 42\ntitle: Test Title\nReadState: true\n") + expected := "id: 42\ntitle: Test Title\nReadState: true\n" if output != expected { t.Errorf("Print() output mismatch:\ngot: %q\nwant: %q", output, expected) } @@ -513,6 +517,56 @@ func TestFilterCombined(t *testing.T) { } } +func TestGetFullContent(t *testing.T) { + setupTestDB(t) + feedId := createTestFeed(t) + + // Mock server to provide article content + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + fmt.Fprint(w, `<html><body><article><h1>Test Title</h1><p>Full Article Content is here and it is long enough to be detected as content by GoOse.</p></article></body></html>`) + })) + defer ts.Close() + + i := &Item{ + Title: "Test Item", + Url: ts.URL, + FeedId: feedId, + } + i.Create() + + i.GetFullContent() + + // Verify content was fetched and saved + if !strings.Contains(i.FullContent, "Full Article Content") { + t.Errorf("Expected full content to be fetched, got %q", i.FullContent) + } + + var fullContent string + err := models.DB.QueryRow("SELECT full_content FROM item WHERE id=?", i.Id).Scan(&fullContent) + if err != nil { + t.Fatal(err) + } + // The DB uses md which is from article.CleanedText + if !strings.Contains(fullContent, "Full Article Content") { + t.Errorf("Full content (markdown) should be saved in DB, got %q", fullContent) + } +} + +func TestGetFullContentEmpty(t *testing.T) { + setupTestDB(t) + // Provide HTML without an article or clear content + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + fmt.Fprint(w, `<html><body></body></html>`) + })) + defer ts.Close() + + i := &Item{Url: ts.URL} + i.GetFullContent() + if i.FullContent != "" { + t.Errorf("Expected empty content for empty HTML, got %q", i.FullContent) + } +} + func TestRewriteImagesWithSrcset(t *testing.T) { input := `<html><head></head><body><img src="https://example.com/image.jpg" srcset="https://example.com/big.jpg 2x"/></body></html>` result := rewriteImages(input) diff --git a/tui/tui_test.go b/tui/tui_test.go index de9caf0..f34707f 100644 --- a/tui/tui_test.go +++ b/tui/tui_test.go @@ -1,14 +1,41 @@ package tui import ( + "path/filepath" "strings" "testing" + "adammathes.com/neko/config" + "adammathes.com/neko/models" "adammathes.com/neko/models/feed" "adammathes.com/neko/models/item" tea "github.com/charmbracelet/bubbletea" ) +func setupTestDB(t *testing.T) { + t.Helper() + config.Config.DBFile = filepath.Join(t.TempDir(), "test.db") + models.InitDB() + t.Cleanup(func() { + if models.DB != nil { + models.DB.Close() + } + }) +} + +func seedData(t *testing.T) { + t.Helper() + f := &feed.Feed{Url: "http://example.com", Title: "Test Feed", Category: "tech"} + f.Create() + + i := &item.Item{ + Title: "Test Item", + Url: "http://example.com/1", + FeedId: f.Id, + } + i.Create() +} + func TestNewModel(t *testing.T) { m := NewModel() if m.state != viewFeeds { @@ -89,4 +116,45 @@ func TestStateTransitions(t *testing.T) { if v := tm5m.View(); !strings.Contains(v, "Test Item") { t.Error("View should contain Item title in content view") } + + // Test scrolling in content view + // Scroll down + tmS1, _ := tm5m.Update(tea.KeyMsg{Type: tea.KeyDown}) + // Home/End + tmS2, _ := tmS1.(Model).Update(tea.KeyMsg{Type: tea.KeyEnd}) + tmS3, _ := tmS2.(Model).Update(tea.KeyMsg{Type: tea.KeyHome}) + + if tmS3.(Model).state != viewContent { + t.Errorf("Should stay in viewContent, got %v", tmS3.(Model).state) + } +} + +func TestTuiCommands(t *testing.T) { + setupTestDB(t) + seedData(t) + + m := NewModel() + cmd := m.Init() + if cmd == nil { + t.Fatal("Init should return a command") + } + + msg := loadFeeds() + feeds, ok := msg.(feedsMsg) + if !ok || len(feeds) == 0 { + t.Errorf("loadFeeds should return feedsMsg, got %T", msg) + } + + msg2 := loadItems(feeds[0].Id)() + _, ok = msg2.(itemsMsg) + if !ok { + t.Errorf("loadItems should return itemsMsg, got %T", msg2) + } +} + +func TestItemString(t *testing.T) { + is := itemString("hello") + if is.FilterValue() != "hello" { + t.Errorf("Expected 'hello', got %s", is.FilterValue()) + } } @@ -7,6 +7,7 @@ import ( "log" "net/http" "strconv" + "strings" "time" "adammathes.com/neko/api" @@ -20,8 +21,17 @@ func indexHandler(w http.ResponseWriter, r *http.Request) { } func imageProxyHandler(w http.ResponseWriter, r *http.Request) { - imgURL := r.URL.String() + imgURL := strings.TrimPrefix(r.URL.Path, "/") + if imgURL == "" { + http.Error(w, "no image url provided", http.StatusNotFound) + return + } + decodedURL, err := base64.URLEncoding.DecodeString(imgURL) + if err != nil { + http.Error(w, "invalid image url", http.StatusNotFound) + return + } // pseudo-caching if r.Header.Get("If-None-Match") == string(decodedURL) { diff --git a/web/web_test.go b/web/web_test.go index 488093b..e11a98c 100644 --- a/web/web_test.go +++ b/web/web_test.go @@ -5,6 +5,7 @@ import ( "net/http" "net/http/httptest" "net/url" + "path/filepath" "testing" "adammathes.com/neko/api" @@ -15,7 +16,7 @@ import ( func setupTestDB(t *testing.T) { t.Helper() - config.Config.DBFile = ":memory:" + config.Config.DBFile = filepath.Join(t.TempDir(), "test.db") models.InitDB() t.Cleanup(func() { if models.DB != nil { @@ -236,6 +237,24 @@ func TestImageProxyHandlerBadRemote(t *testing.T) { } } +func TestImageProxyHandlerEmptyId(t *testing.T) { + req := httptest.NewRequest("GET", "/image/", nil) + rr := httptest.NewRecorder() + imageProxyHandler(rr, req) + if rr.Code != http.StatusNotFound { + t.Errorf("Expected 404, got %d", rr.Code) + } +} + +func TestImageProxyHandlerBadBase64(t *testing.T) { + req := httptest.NewRequest("GET", "/image/notbase64!", nil) + rr := httptest.NewRecorder() + imageProxyHandler(rr, req) + if rr.Code != http.StatusNotFound { + t.Errorf("Expected 404, got %d", rr.Code) + } +} + func TestApiMounting(t *testing.T) { setupTestDB(t) seedData(t) @@ -259,3 +278,52 @@ func TestApiMounting(t *testing.T) { t.Errorf("Expected 200 via API mount, got %d", rr.Code) } } + +func TestIndexHandler(t *testing.T) { + config.Config.DigestPassword = "secret" + req := httptest.NewRequest("GET", "/", nil) + req.AddCookie(authCookie()) + rr := httptest.NewRecorder() + indexHandler(rr, req) + + if rr.Code != http.StatusOK && rr.Code != http.StatusNotFound { + t.Errorf("Expected 200 or 404, got %d", rr.Code) + } +} + +func TestServeBoxedFile(t *testing.T) { + config.Config.DigestPassword = "secret" + req := httptest.NewRequest("GET", "/style.css", nil) + req.AddCookie(authCookie()) + rr := httptest.NewRecorder() + serveBoxedFile(rr, req, "style.css") + + if rr.Code != http.StatusOK && rr.Code != http.StatusNotFound { + t.Errorf("Expected 200 or 404, got %d", rr.Code) + } +} + +func TestAuthWrapHandlerUnauthenticated(t *testing.T) { + config.Config.DigestPassword = "secret" + apiRouter := api.NewRouter() + handler := AuthWrapHandler(http.StripPrefix("/api", apiRouter)) + + req := httptest.NewRequest("GET", "/api/stream", nil) + rr := httptest.NewRecorder() + handler.ServeHTTP(rr, req) + + if rr.Code != http.StatusTemporaryRedirect { + t.Errorf("Expected 307 redirect, got %d", rr.Code) + } +} + +func TestLoginHandlerGet(t *testing.T) { + req := httptest.NewRequest("GET", "/login/", nil) + rr := httptest.NewRecorder() + loginHandler(rr, req) + + // should return login.html from rice box + if rr.Code != http.StatusOK && rr.Code != http.StatusNotFound { + t.Errorf("Expected 200 or 404, got %d", rr.Code) + } +} |
