apps/feeds/feeds_test.go 18.7 K raw
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> &amp; 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
}