| 1 | package main |
| 2 | |
| 3 | import ( |
| 4 | "database/sql" |
| 5 | "io" |
| 6 | "log/slog" |
| 7 | "net/http" |
| 8 | "net/http/httptest" |
| 9 | "strings" |
| 10 | "testing" |
| 11 | |
| 12 | sharedsqlite "github.com/stevedylandev/andromeda/pkg/sqlite" |
| 13 | ) |
| 14 | |
| 15 | func newTestDB(t *testing.T) *sql.DB { |
| 16 | t.Helper() |
| 17 | db, err := sharedsqlite.Open("file::memory:?cache=shared", feedsSchema) |
| 18 | if err != nil { |
| 19 | t.Fatal(err) |
| 20 | } |
| 21 | t.Cleanup(func() { _ = db.Close() }) |
| 22 | return db |
| 23 | } |
| 24 | |
| 25 | func newTestApp(t *testing.T) *App { |
| 26 | t.Helper() |
| 27 | return &App{ |
| 28 | DB: newTestDB(t), |
| 29 | Log: slog.New(slog.NewTextHandler(io.Discard, nil)), |
| 30 | DefaultPollMinutes: 30, |
| 31 | ItemCap: 2, |
| 32 | } |
| 33 | } |
| 34 | |
| 35 | func seedSubscriptionForTest(t *testing.T, db *sql.DB, feedURL, title string, categoryID *int64) *Subscription { |
| 36 | t.Helper() |
| 37 | sub, err := insertSubscription(db, feedURL, title, nil, categoryID) |
| 38 | if err != nil { |
| 39 | t.Fatal(err) |
| 40 | } |
| 41 | return sub |
| 42 | } |
| 43 | |
| 44 | func parsedEntry(guid, link string, publishedAt int64) ParsedEntry { |
| 45 | return ParsedEntry{ |
| 46 | GUID: guid, |
| 47 | Title: "post " + guid, |
| 48 | Link: link, |
| 49 | PublishedAt: publishedAt, |
| 50 | } |
| 51 | } |
| 52 | |
| 53 | func int64Ptr(v int64) *int64 { |
| 54 | return &v |
| 55 | } |
| 56 | |
| 57 | func TestParseOPMLHandlesNestedCategories(t *testing.T) { |
| 58 | content := `<?xml version="1.0" encoding="UTF-8"?> |
| 59 | <opml version="2.0"> |
| 60 | <body> |
| 61 | <outline text="Tech"> |
| 62 | <outline text="Go Blog" xmlUrl="https://go.dev/feed.xml" htmlUrl="https://go.dev/blog/" /> |
| 63 | <outline text="News"> |
| 64 | <outline title="Hacker News" xmlUrl="https://hnrss.org/frontpage" htmlUrl="https://news.ycombinator.com/" /> |
| 65 | </outline> |
| 66 | </outline> |
| 67 | <outline text="Standalone" xmlUrl="https://example.com/rss.xml" /> |
| 68 | </body> |
| 69 | </opml>` |
| 70 | |
| 71 | got := parseOPML(content) |
| 72 | if len(got) != 3 { |
| 73 | t.Fatalf("expected 3 entries, got %d", len(got)) |
| 74 | } |
| 75 | if got[0].Category != "Tech" || got[0].Title != "Go Blog" { |
| 76 | t.Fatalf("unexpected first entry: %+v", got[0]) |
| 77 | } |
| 78 | if got[1].Category != "News" || got[1].Title != "Hacker News" { |
| 79 | t.Fatalf("unexpected nested entry: %+v", got[1]) |
| 80 | } |
| 81 | if got[2].Category != "" || got[2].Title != "Standalone" { |
| 82 | t.Fatalf("unexpected standalone entry: %+v", got[2]) |
| 83 | } |
| 84 | } |
| 85 | |
| 86 | func TestParseOPMLInvalidReturnsNil(t *testing.T) { |
| 87 | if got := parseOPML("<opml><body>"); got != nil { |
| 88 | t.Fatalf("expected nil for invalid OPML, got %+v", got) |
| 89 | } |
| 90 | } |
| 91 | |
| 92 | func TestParseOPMLFlatOutlines(t *testing.T) { |
| 93 | content := `<?xml version="1.0" encoding="UTF-8"?> |
| 94 | <opml version="2.0"><body> |
| 95 | <outline type="rss" text="Blog A" xmlUrl="https://a.com/feed" /> |
| 96 | <outline type="rss" text="Blog B" xmlUrl="https://b.com/rss" /> |
| 97 | </body></opml>` |
| 98 | |
| 99 | entries := parseOPML(content) |
| 100 | if len(entries) != 2 { |
| 101 | t.Fatalf("expected 2 entries, got %d", len(entries)) |
| 102 | } |
| 103 | if entries[0].XMLURL != "https://a.com/feed" || entries[0].Title != "Blog A" || entries[0].Category != "" { |
| 104 | t.Fatalf("unexpected first entry: %+v", entries[0]) |
| 105 | } |
| 106 | } |
| 107 | |
| 108 | func TestParseOPMLEmptyAndMissingURLs(t *testing.T) { |
| 109 | if got := parseOPML(`<?xml version="1.0"?><opml><body></body></opml>`); len(got) != 0 { |
| 110 | t.Fatalf("expected empty OPML to produce no entries, got %+v", got) |
| 111 | } |
| 112 | if got := parseOPML(`<?xml version="1.0"?><opml><body><outline type="rss" text="No URL" htmlUrl="https://example.com" /></body></opml>`); len(got) != 0 { |
| 113 | t.Fatalf("expected outline without xmlUrl to be skipped, got %+v", got) |
| 114 | } |
| 115 | } |
| 116 | |
| 117 | func TestParseOPMLDeeplyNestedUsesNearestCategory(t *testing.T) { |
| 118 | content := `<?xml version="1.0"?> |
| 119 | <opml><body> |
| 120 | <outline text="Root"> |
| 121 | <outline text="Tech"> |
| 122 | <outline type="rss" text="A" xmlUrl="https://a.com/feed" /> |
| 123 | </outline> |
| 124 | <outline type="rss" text="B" xmlUrl="https://b.com/feed" /> |
| 125 | </outline> |
| 126 | </body></opml>` |
| 127 | |
| 128 | entries := parseOPML(content) |
| 129 | if len(entries) != 2 { |
| 130 | t.Fatalf("expected 2 entries, got %d", len(entries)) |
| 131 | } |
| 132 | if entries[0].XMLURL != "https://a.com/feed" || entries[0].Category != "Tech" { |
| 133 | t.Fatalf("unexpected deeply nested entry: %+v", entries[0]) |
| 134 | } |
| 135 | if entries[1].XMLURL != "https://b.com/feed" || entries[1].Category != "Root" { |
| 136 | t.Fatalf("unexpected root nested entry: %+v", entries[1]) |
| 137 | } |
| 138 | } |
| 139 | |
| 140 | func TestParseOPMLSkipsEmptyURL(t *testing.T) { |
| 141 | content := `<?xml version="1.0"?> |
| 142 | <opml><body> |
| 143 | <outline type="rss" text="Empty" xmlUrl="" /> |
| 144 | <outline type="rss" text="Valid" xmlUrl="https://valid.com/feed" /> |
| 145 | </body></opml>` |
| 146 | |
| 147 | entries := parseOPML(content) |
| 148 | if len(entries) != 1 || entries[0].XMLURL != "https://valid.com/feed" { |
| 149 | t.Fatalf("expected only valid URL entry, got %+v", entries) |
| 150 | } |
| 151 | } |
| 152 | |
| 153 | func TestDeriveTitleFromHTMLStripsMarkupAndTruncates(t *testing.T) { |
| 154 | src := `<p>Hello <strong>world</strong> & friends.</p>` |
| 155 | if got := deriveTitleFromHTML(src); got != "Hello world & friends." { |
| 156 | t.Fatalf("unexpected title: %q", got) |
| 157 | } |
| 158 | |
| 159 | long := strings.Repeat("word ", 30) |
| 160 | got := deriveTitleFromHTML("<div>" + long + "</div>") |
| 161 | if !strings.HasSuffix(got, "…") { |
| 162 | t.Fatalf("expected ellipsis, got %q", got) |
| 163 | } |
| 164 | if len([]rune(got)) > 81 { |
| 165 | t.Fatalf("expected title to be capped at 81 runes including ellipsis, got %d", len([]rune(got))) |
| 166 | } |
| 167 | } |
| 168 | |
| 169 | func TestDeriveTitleFromHTMLEmptyYieldsEmpty(t *testing.T) { |
| 170 | for _, src := range []string{"", "<p> </p>"} { |
| 171 | if got := deriveTitleFromHTML(src); got != "" { |
| 172 | t.Fatalf("expected empty derived title for %q, got %q", src, got) |
| 173 | } |
| 174 | } |
| 175 | } |
| 176 | |
| 177 | func TestFindAlternateFeedLinksAndFavicon(t *testing.T) { |
| 178 | doc := ` |
| 179 | <html><head> |
| 180 | <link rel="alternate" type="application/rss+xml" href="/rss.xml"> |
| 181 | <link rel="icon" type="image/png" href="/favicon.png"> |
| 182 | <link rel="alternate stylesheet" type="application/atom+xml" href="https://example.com/atom.xml"> |
| 183 | </head></html>` |
| 184 | |
| 185 | links := findAlternateFeedLinks(doc) |
| 186 | if len(links) != 2 { |
| 187 | t.Fatalf("expected 2 feed links, got %d (%v)", len(links), links) |
| 188 | } |
| 189 | if links[0] != "/rss.xml" || links[1] != "https://example.com/atom.xml" { |
| 190 | t.Fatalf("unexpected links: %v", links) |
| 191 | } |
| 192 | if href := findLinkHref(doc, func(rel, typ string) bool { return strings.Contains(strings.ToLower(rel), "icon") }); href != "/favicon.png" { |
| 193 | t.Fatalf("unexpected favicon href: %q", href) |
| 194 | } |
| 195 | } |
| 196 | |
| 197 | func TestItemFilterFromRequestParsesValues(t *testing.T) { |
| 198 | req := httptest.NewRequest(http.MethodGet, "/?limit=25&unread=true&category_id=5&subscription_id=8", nil) |
| 199 | filter := itemFilterFromRequest(req) |
| 200 | if filter.Limit != 25 || !filter.UnreadOnly { |
| 201 | t.Fatalf("unexpected base filter: %+v", filter) |
| 202 | } |
| 203 | if filter.CategoryID == nil || *filter.CategoryID != 5 { |
| 204 | t.Fatalf("unexpected category id: %+v", filter.CategoryID) |
| 205 | } |
| 206 | if filter.SubscriptionID == nil || *filter.SubscriptionID != 8 { |
| 207 | t.Fatalf("unexpected subscription id: %+v", filter.SubscriptionID) |
| 208 | } |
| 209 | } |
| 210 | |
| 211 | func TestFormPollMinutesValidation(t *testing.T) { |
| 212 | good := httptest.NewRequest(http.MethodPost, "/", strings.NewReader("poll_interval_minutes=60")) |
| 213 | good.Header.Set("Content-Type", "application/x-www-form-urlencoded") |
| 214 | if mins, ok := formPollMinutes(good); !ok || mins != 60 { |
| 215 | t.Fatalf("expected valid poll minutes, got %d %v", mins, ok) |
| 216 | } |
| 217 | |
| 218 | bad := httptest.NewRequest(http.MethodPost, "/", strings.NewReader("poll_interval_minutes=0")) |
| 219 | bad.Header.Set("Content-Type", "application/x-www-form-urlencoded") |
| 220 | if _, ok := formPollMinutes(bad); ok { |
| 221 | t.Fatal("expected invalid poll minutes") |
| 222 | } |
| 223 | } |
| 224 | |
| 225 | func TestWithCORSHandlesOptions(t *testing.T) { |
| 226 | app := &App{} |
| 227 | called := false |
| 228 | h := app.withCORS(func(w http.ResponseWriter, r *http.Request) { |
| 229 | called = true |
| 230 | w.WriteHeader(http.StatusCreated) |
| 231 | }) |
| 232 | |
| 233 | rec := httptest.NewRecorder() |
| 234 | h(rec, httptest.NewRequest(http.MethodOptions, "/api/items", nil)) |
| 235 | if called { |
| 236 | t.Fatal("handler should not be called for OPTIONS") |
| 237 | } |
| 238 | if rec.Code != http.StatusNoContent { |
| 239 | t.Fatalf("expected 204, got %d", rec.Code) |
| 240 | } |
| 241 | if rec.Header().Get("Access-Control-Allow-Origin") != "*" { |
| 242 | t.Fatalf("missing CORS header: %v", rec.Header()) |
| 243 | } |
| 244 | } |
| 245 | |
| 246 | func TestCategoryCRUD(t *testing.T) { |
| 247 | db := newTestDB(t) |
| 248 | first, err := getOrCreateCategory(db, " Tech ") |
| 249 | if err != nil { |
| 250 | t.Fatal(err) |
| 251 | } |
| 252 | if first == nil || first.Name != "Tech" { |
| 253 | t.Fatalf("unexpected category: %+v", first) |
| 254 | } |
| 255 | second, err := getOrCreateCategory(db, "Tech") |
| 256 | if err != nil { |
| 257 | t.Fatal(err) |
| 258 | } |
| 259 | if second == nil || first.ID != second.ID { |
| 260 | t.Fatalf("expected same category, got first=%+v second=%+v", first, second) |
| 261 | } |
| 262 | other, err := getOrCreateCategory(db, "News") |
| 263 | if err != nil { |
| 264 | t.Fatal(err) |
| 265 | } |
| 266 | if other == nil || other.ID == first.ID { |
| 267 | t.Fatalf("expected distinct second category, got %+v", other) |
| 268 | } |
| 269 | if ok, err := deleteCategory(db, first.ID); err != nil || !ok { |
| 270 | t.Fatalf("delete category failed: ok=%v err=%v", ok, err) |
| 271 | } |
| 272 | cats, err := listCategories(db) |
| 273 | if err != nil { |
| 274 | t.Fatal(err) |
| 275 | } |
| 276 | if len(cats) != 1 || cats[0].Name != "News" { |
| 277 | t.Fatalf("unexpected categories after delete: %+v", cats) |
| 278 | } |
| 279 | } |
| 280 | |
| 281 | func TestListItemsAndPruneSubscription(t *testing.T) { |
| 282 | app := newTestApp(t) |
| 283 | cat, err := getOrCreateCategory(app.DB, "Tech") |
| 284 | if err != nil { |
| 285 | t.Fatal(err) |
| 286 | } |
| 287 | sub := seedSubscriptionForTest(t, app.DB, "https://example.com/feed.xml", "Example Feed", &cat.ID) |
| 288 | |
| 289 | items := []NewItem{ |
| 290 | {SubscriptionID: sub.ID, GUID: "1", Title: "Old", Link: "https://example.com/1", Author: "Ron", PublishedAt: 10}, |
| 291 | {SubscriptionID: sub.ID, GUID: "2", Title: "Mid", Link: "https://example.com/2", Author: "", PublishedAt: 20}, |
| 292 | {SubscriptionID: sub.ID, GUID: "3", Title: "New", Link: "https://example.com/3", Author: "Leslie", PublishedAt: 30}, |
| 293 | } |
| 294 | for _, item := range items { |
| 295 | ok, err := insertItemIgnoreDup(app.DB, item) |
| 296 | if err != nil || !ok { |
| 297 | t.Fatalf("insert failed for %+v: ok=%v err=%v", item, ok, err) |
| 298 | } |
| 299 | } |
| 300 | if ok, err := insertItemIgnoreDup(app.DB, items[0]); err != nil || ok { |
| 301 | t.Fatalf("expected duplicate insert to be ignored, ok=%v err=%v", ok, err) |
| 302 | } |
| 303 | if _, err := markItemRead(app.DB, 1, true); err != nil { |
| 304 | t.Fatal(err) |
| 305 | } |
| 306 | if err := pruneSubscription(app.DB, sub.ID, 2); err != nil { |
| 307 | t.Fatal(err) |
| 308 | } |
| 309 | |
| 310 | listed, err := listItems(app.DB, ListItemsFilter{Limit: 10}) |
| 311 | if err != nil { |
| 312 | t.Fatal(err) |
| 313 | } |
| 314 | if len(listed) != 2 { |
| 315 | t.Fatalf("expected 2 items after prune, got %d", len(listed)) |
| 316 | } |
| 317 | if listed[0].Title != "New" || listed[1].Title != "Mid" { |
| 318 | t.Fatalf("unexpected order after prune: %+v", listed) |
| 319 | } |
| 320 | if listed[0].Author == nil || *listed[0].Author != "Leslie" { |
| 321 | t.Fatalf("expected author pointer on newest item, got %+v", listed[0].Author) |
| 322 | } |
| 323 | if listed[1].Author != nil { |
| 324 | t.Fatalf("expected nil author on blank author item, got %+v", listed[1].Author) |
| 325 | } |
| 326 | if listed[0].CategoryName == nil || *listed[0].CategoryName != "Tech" { |
| 327 | t.Fatalf("expected category name, got %+v", listed[0].CategoryName) |
| 328 | } |
| 329 | |
| 330 | filtered, err := listItems(app.DB, ListItemsFilter{Limit: 10, UnreadOnly: true}) |
| 331 | if err != nil { |
| 332 | t.Fatal(err) |
| 333 | } |
| 334 | if len(filtered) != 2 { |
| 335 | t.Fatalf("expected both remaining items to be unread, got %d", len(filtered)) |
| 336 | } |
| 337 | } |
| 338 | |
| 339 | func TestPollIntervalMinutesUsesFallbackForMissingOrInvalidSetting(t *testing.T) { |
| 340 | app := newTestApp(t) |
| 341 | if got := app.pollIntervalMinutes(); got != 30 { |
| 342 | t.Fatalf("expected default poll interval, got %d", got) |
| 343 | } |
| 344 | if err := setSetting(app.DB, "poll_interval_minutes", "45"); err != nil { |
| 345 | t.Fatal(err) |
| 346 | } |
| 347 | if got := app.pollIntervalMinutes(); got != 45 { |
| 348 | t.Fatalf("expected stored poll interval, got %d", got) |
| 349 | } |
| 350 | if err := setSetting(app.DB, "poll_interval_minutes", "nonsense"); err != nil { |
| 351 | t.Fatal(err) |
| 352 | } |
| 353 | if got := app.pollIntervalMinutes(); got != 30 { |
| 354 | t.Fatalf("expected fallback for invalid setting, got %d", got) |
| 355 | } |
| 356 | } |
| 357 | |
| 358 | func TestSettingsUpsert(t *testing.T) { |
| 359 | db := newTestDB(t) |
| 360 | if value, ok, err := getSetting(db, "poll"); err != nil || ok || value != "" { |
| 361 | t.Fatalf("expected missing setting, value=%q ok=%v err=%v", value, ok, err) |
| 362 | } |
| 363 | if err := setSetting(db, "poll", "30"); err != nil { |
| 364 | t.Fatal(err) |
| 365 | } |
| 366 | if value, ok, err := getSetting(db, "poll"); err != nil || !ok || value != "30" { |
| 367 | t.Fatalf("expected setting=30, value=%q ok=%v err=%v", value, ok, err) |
| 368 | } |
| 369 | if err := setSetting(db, "poll", "60"); err != nil { |
| 370 | t.Fatal(err) |
| 371 | } |
| 372 | if value, ok, err := getSetting(db, "poll"); err != nil || !ok || value != "60" { |
| 373 | t.Fatalf("expected setting=60, value=%q ok=%v err=%v", value, ok, err) |
| 374 | } |
| 375 | } |
| 376 | |
| 377 | func TestSubscriptionCRUDAndMeta(t *testing.T) { |
| 378 | db := newTestDB(t) |
| 379 | siteURL := "https://example.com" |
| 380 | sub, err := insertSubscription(db, "https://example.com/feed", "Example", &siteURL, nil) |
| 381 | if err != nil { |
| 382 | t.Fatal(err) |
| 383 | } |
| 384 | if sub.Title != "Example" || !sub.SiteURL.Valid || sub.SiteURL.String != siteURL { |
| 385 | t.Fatalf("unexpected subscription: %+v", sub) |
| 386 | } |
| 387 | |
| 388 | byURL, err := getSubscriptionByURL(db, "https://example.com/feed") |
| 389 | if err != nil { |
| 390 | t.Fatal(err) |
| 391 | } |
| 392 | if byURL == nil || byURL.ID != sub.ID { |
| 393 | t.Fatalf("expected subscription by URL, got %+v", byURL) |
| 394 | } |
| 395 | |
| 396 | etag := "etag-1" |
| 397 | lastModified := "Sun, 01 Jan 2024 00:00:00 GMT" |
| 398 | if err := updateSubscriptionMeta(db, sub.ID, &etag, &lastModified, nil); err != nil { |
| 399 | t.Fatal(err) |
| 400 | } |
| 401 | after, err := getSubscription(db, sub.ID) |
| 402 | if err != nil { |
| 403 | t.Fatal(err) |
| 404 | } |
| 405 | if after == nil || !after.ETag.Valid || after.ETag.String != etag || !after.LastModified.Valid || after.LastModified.String != lastModified || !after.LastFetchedAt.Valid || after.LastError.Valid { |
| 406 | t.Fatalf("unexpected subscription meta: %+v", after) |
| 407 | } |
| 408 | |
| 409 | if ok, err := deleteSubscription(db, sub.ID); err != nil || !ok { |
| 410 | t.Fatalf("delete subscription failed: ok=%v err=%v", ok, err) |
| 411 | } |
| 412 | gone, err := getSubscription(db, sub.ID) |
| 413 | if err != nil { |
| 414 | t.Fatal(err) |
| 415 | } |
| 416 | if gone != nil { |
| 417 | t.Fatalf("expected subscription to be deleted, got %+v", gone) |
| 418 | } |
| 419 | } |
| 420 | |
| 421 | func TestMarkReadUnread(t *testing.T) { |
| 422 | db := newTestDB(t) |
| 423 | sub := seedSubscriptionForTest(t, db, "https://a.com/feed", "A", nil) |
| 424 | if ok, err := insertItemIgnoreDup(db, NewItem{SubscriptionID: sub.ID, GUID: "g", Title: "t", Link: "l", PublishedAt: 1}); err != nil || !ok { |
| 425 | t.Fatalf("insert item failed: ok=%v err=%v", ok, err) |
| 426 | } |
| 427 | items, err := listItems(db, ListItemsFilter{}) |
| 428 | if err != nil { |
| 429 | t.Fatal(err) |
| 430 | } |
| 431 | if len(items) != 1 { |
| 432 | t.Fatalf("expected 1 item, got %+v", items) |
| 433 | } |
| 434 | |
| 435 | if ok, err := markItemRead(db, items[0].ID, true); err != nil || !ok { |
| 436 | t.Fatalf("mark read failed: ok=%v err=%v", ok, err) |
| 437 | } |
| 438 | unread, err := listItems(db, ListItemsFilter{UnreadOnly: true}) |
| 439 | if err != nil { |
| 440 | t.Fatal(err) |
| 441 | } |
| 442 | if len(unread) != 0 { |
| 443 | t.Fatalf("expected no unread items, got %+v", unread) |
| 444 | } |
| 445 | |
| 446 | if ok, err := markItemRead(db, items[0].ID, false); err != nil || !ok { |
| 447 | t.Fatalf("mark unread failed: ok=%v err=%v", ok, err) |
| 448 | } |
| 449 | unread, err = listItems(db, ListItemsFilter{UnreadOnly: true}) |
| 450 | if err != nil { |
| 451 | t.Fatal(err) |
| 452 | } |
| 453 | if len(unread) != 1 { |
| 454 | t.Fatalf("expected one unread item, got %+v", unread) |
| 455 | } |
| 456 | } |
| 457 | |
| 458 | func TestCategoryFilterOnItems(t *testing.T) { |
| 459 | db := newTestDB(t) |
| 460 | tech, err := getOrCreateCategory(db, "Tech") |
| 461 | if err != nil { |
| 462 | t.Fatal(err) |
| 463 | } |
| 464 | subTech := seedSubscriptionForTest(t, db, "https://a.com/feed", "A", &tech.ID) |
| 465 | subOther := seedSubscriptionForTest(t, db, "https://b.com/feed", "B", nil) |
| 466 | _, _ = insertItemIgnoreDup(db, NewItem{SubscriptionID: subTech.ID, GUID: "g1", Title: "tech post", Link: "https://a.com/1", PublishedAt: 1}) |
| 467 | _, _ = insertItemIgnoreDup(db, NewItem{SubscriptionID: subOther.ID, GUID: "g2", Title: "other post", Link: "https://b.com/1", PublishedAt: 2}) |
| 468 | |
| 469 | items, err := listItems(db, ListItemsFilter{CategoryID: &tech.ID}) |
| 470 | if err != nil { |
| 471 | t.Fatal(err) |
| 472 | } |
| 473 | if len(items) != 1 || items[0].Title != "tech post" || items[0].CategoryName == nil || *items[0].CategoryName != "Tech" { |
| 474 | t.Fatalf("unexpected category-filtered items: %+v", items) |
| 475 | } |
| 476 | } |
| 477 | |
| 478 | func TestSeedSubscriptionPersistsEntriesAndMeta(t *testing.T) { |
| 479 | app := newTestApp(t) |
| 480 | sub := seedSubscriptionForTest(t, app.DB, "https://x.com/feed", "X", nil) |
| 481 | res := &FetchResult{ |
| 482 | ETag: "etag-1", |
| 483 | LastModified: "Sun, 01 Jan", |
| 484 | Entries: []ParsedEntry{parsedEntry("g1", "https://x.com/1", 100), parsedEntry("g2", "https://x.com/2", 200)}, |
| 485 | } |
| 486 | |
| 487 | if err := app.seedSubscription(sub.ID, res); err != nil { |
| 488 | t.Fatal(err) |
| 489 | } |
| 490 | items, err := listItems(app.DB, ListItemsFilter{}) |
| 491 | if err != nil { |
| 492 | t.Fatal(err) |
| 493 | } |
| 494 | if len(items) != 2 { |
| 495 | t.Fatalf("expected 2 seeded items, got %+v", items) |
| 496 | } |
| 497 | after, err := getSubscription(app.DB, sub.ID) |
| 498 | if err != nil { |
| 499 | t.Fatal(err) |
| 500 | } |
| 501 | if after == nil || !after.ETag.Valid || after.ETag.String != "etag-1" || !after.LastModified.Valid || after.LastModified.String != "Sun, 01 Jan" || !after.LastFetchedAt.Valid || after.LastError.Valid { |
| 502 | t.Fatalf("unexpected seeded subscription meta: %+v", after) |
| 503 | } |
| 504 | } |
| 505 | |
| 506 | func TestSeedSubscriptionSkipsEmptyLinksAndDedups(t *testing.T) { |
| 507 | app := newTestApp(t) |
| 508 | sub := seedSubscriptionForTest(t, app.DB, "https://x.com/feed", "X", nil) |
| 509 | res := &FetchResult{Entries: []ParsedEntry{parsedEntry("g1", "", 100), parsedEntry("g2", "https://x.com/2", 200)}} |
| 510 | if err := app.seedSubscription(sub.ID, res); err != nil { |
| 511 | t.Fatal(err) |
| 512 | } |
| 513 | if err := app.seedSubscription(sub.ID, res); err != nil { |
| 514 | t.Fatal(err) |
| 515 | } |
| 516 | items, err := listItems(app.DB, ListItemsFilter{}) |
| 517 | if err != nil { |
| 518 | t.Fatal(err) |
| 519 | } |
| 520 | if len(items) != 1 || items[0].GUID != "g2" { |
| 521 | t.Fatalf("expected one deduped non-empty-link item, got %+v", items) |
| 522 | } |
| 523 | } |
| 524 | |
| 525 | func TestSeedSubscriptionPrunesToItemCap(t *testing.T) { |
| 526 | app := newTestApp(t) |
| 527 | app.ItemCap = 3 |
| 528 | sub := seedSubscriptionForTest(t, app.DB, "https://x.com/feed", "X", nil) |
| 529 | entries := make([]ParsedEntry, 0, 10) |
| 530 | for i := int64(0); i < 10; i++ { |
| 531 | entries = append(entries, parsedEntry(string(rune('a'+i)), "https://x.com/"+string(rune('a'+i)), i)) |
| 532 | } |
| 533 | |
| 534 | if err := app.seedSubscription(sub.ID, &FetchResult{Entries: entries}); err != nil { |
| 535 | t.Fatal(err) |
| 536 | } |
| 537 | items, err := listItems(app.DB, ListItemsFilter{}) |
| 538 | if err != nil { |
| 539 | t.Fatal(err) |
| 540 | } |
| 541 | if len(items) != 3 || items[0].PublishedAt != 9 || items[2].PublishedAt != 7 { |
| 542 | t.Fatalf("expected newest three items after prune, got %+v", items) |
| 543 | } |
| 544 | } |
| 545 | |
| 546 | func TestSeedSubscriptionWithNoEntriesStillPersistsMeta(t *testing.T) { |
| 547 | app := newTestApp(t) |
| 548 | sub := seedSubscriptionForTest(t, app.DB, "https://x.com/feed", "X", nil) |
| 549 | if err := app.seedSubscription(sub.ID, &FetchResult{ETag: "etag-empty"}); err != nil { |
| 550 | t.Fatal(err) |
| 551 | } |
| 552 | after, err := getSubscription(app.DB, sub.ID) |
| 553 | if err != nil { |
| 554 | t.Fatal(err) |
| 555 | } |
| 556 | if after == nil || !after.ETag.Valid || after.ETag.String != "etag-empty" { |
| 557 | t.Fatalf("expected empty seed to persist etag, got %+v", after) |
| 558 | } |
| 559 | } |
| 560 | |
| 561 | func TestResolveCategoryPrefersIDAndCreatesByName(t *testing.T) { |
| 562 | app := newTestApp(t) |
| 563 | existing, err := getOrCreateCategory(app.DB, "Existing") |
| 564 | if err != nil { |
| 565 | t.Fatal(err) |
| 566 | } |
| 567 | if got, err := app.resolveCategory(&existing.ID, "Ignored"); err != nil || got == nil || *got != existing.ID { |
| 568 | t.Fatalf("expected existing id, got id=%v err=%v", got, err) |
| 569 | } |
| 570 | if got, err := app.resolveCategory(nil, "Created"); err != nil || got == nil { |
| 571 | t.Fatalf("expected created category id, got id=%v err=%v", got, err) |
| 572 | } |
| 573 | if got, err := app.resolveCategory(nil, " "); err != nil || got != nil { |
| 574 | t.Fatalf("expected nil category for blank name, got id=%v err=%v", got, err) |
| 575 | } |
| 576 | if got, err := app.resolveSubscriptionCategory(updateSubscriptionBody{CategoryID: int64Ptr(existing.ID), ClearCategory: true}); err != nil || got != nil { |
| 577 | t.Fatalf("expected clear category to return nil, got id=%v err=%v", got, err) |
| 578 | } |
| 579 | } |