chore: added go tests ecfa5791
Steve Simkins · 2026-05-16 21:45 18 file(s) · +1323 −29
Makefile (added) +51 −0
1 +
GO_APPS := $(shell find apps -maxdepth 1 -type d -name '*-go' | sort)
2 +
3 +
.PHONY: help go-test go-vet go-fmt go-check go-app-test go-app-vet go-app-fmt
4 +
5 +
help:
6 +
	@echo "Available targets:"
7 +
	@echo "  make go-test              Run go test ./... in every apps/*-go module"
8 +
	@echo "  make go-vet               Run go vet ./... in every apps/*-go module"
9 +
	@echo "  make go-fmt               Run gofmt -w . in every apps/*-go module"
10 +
	@echo "  make go-check             Run go-fmt, go-test, and go-vet for all Go apps"
11 +
	@echo "  make go-app-test APP=...  Run go test ./... in one Go app, e.g. APP=feeds-go"
12 +
	@echo "  make go-app-vet APP=...   Run go vet ./... in one Go app"
13 +
	@echo "  make go-app-fmt APP=...   Run gofmt -w . in one Go app"
14 +
15 +
ifndef APP
16 +
go-app-test go-app-vet go-app-fmt:
17 +
	@echo "APP is required, e.g. make $@ APP=feeds-go" >&2
18 +
	@exit 1
19 +
else
20 +
go-app-test:
21 +
	@echo "==> apps/$(APP)"
22 +
	@cd apps/$(APP) && go test ./...
23 +
24 +
go-app-vet:
25 +
	@echo "==> apps/$(APP)"
26 +
	@cd apps/$(APP) && go vet ./...
27 +
28 +
go-app-fmt:
29 +
	@echo "==> apps/$(APP)"
30 +
	@cd apps/$(APP) && gofmt -w .
31 +
endif
32 +
33 +
go-test:
34 +
	@for app in $(GO_APPS); do \
35 +
		echo "==> $$app"; \
36 +
		(cd "$$app" && go test ./...) || exit $$?; \
37 +
	done
38 +
39 +
go-vet:
40 +
	@for app in $(GO_APPS); do \
41 +
		echo "==> $$app"; \
42 +
		(cd "$$app" && go vet ./...) || exit $$?; \
43 +
	done
44 +
45 +
go-fmt:
46 +
	@for app in $(GO_APPS); do \
47 +
		echo "==> $$app"; \
48 +
		(cd "$$app" && gofmt -w .) || exit $$?; \
49 +
	done
50 +
51 +
go-check: go-fmt go-test go-vet
apps/bookmarks-go/db_test.go (added) +86 −0
1 +
package main
2 +
3 +
import (
4 +
	"database/sql"
5 +
	"testing"
6 +
7 +
	sharedsqlite "github.com/stevedylandev/andromeda/crates-go/sqlite"
8 +
)
9 +
10 +
func openBookmarksTestDB(t *testing.T) *sql.DB {
11 +
	t.Helper()
12 +
	db, err := sharedsqlite.Open("file:bookmarks-test?mode=memory&cache=shared", schema)
13 +
	if err != nil {
14 +
		t.Fatal(err)
15 +
	}
16 +
	return db
17 +
}
18 +
19 +
func TestCategoriesAndBookmarkCRUD(t *testing.T) {
20 +
	db := openBookmarksTestDB(t)
21 +
	defer db.Close()
22 +
	cat, err := createCategory(db, "Reading")
23 +
	if err != nil {
24 +
		t.Fatal(err)
25 +
	}
26 +
	if cat.ShortID == "" || cat.Position != 1 {
27 +
		t.Fatalf("category %#v", cat)
28 +
	}
29 +
	if got, err := getCategoryByName(db, " Reading "); err != nil || got == nil || got.ID != cat.ID {
30 +
		t.Fatalf("category by name %#v err %v", got, err)
31 +
	}
32 +
	cats, err := listCategories(db)
33 +
	if err != nil || len(cats) != 1 {
34 +
		t.Fatalf("cats %#v err %v", cats, err)
35 +
	}
36 +
37 +
	fav := "https://example.com/favicon.ico"
38 +
	link, err := createLink(db, "Example", "https://example.com", &fav, cat.ID)
39 +
	if err != nil {
40 +
		t.Fatal(err)
41 +
	}
42 +
	if link.ShortID == "" || link.FaviconURL == nil || *link.FaviconURL != fav {
43 +
		t.Fatalf("link %#v", link)
44 +
	}
45 +
	dup, err := createLink(db, "Example 2", "https://example.com", nil, cat.ID)
46 +
	if err != nil {
47 +
		t.Fatal(err)
48 +
	}
49 +
	if dup.ID == link.ID {
50 +
		t.Fatal("duplicate URL should create a distinct bookmark")
51 +
	}
52 +
	links, err := listLinks(db)
53 +
	if err != nil {
54 +
		t.Fatal(err)
55 +
	}
56 +
	if len(links) != 2 {
57 +
		t.Fatalf("links %#v", links)
58 +
	}
59 +
	missingFav, err := listLinksMissingFavicon(db)
60 +
	if err != nil {
61 +
		t.Fatal(err)
62 +
	}
63 +
	if len(missingFav) != 1 || missingFav[0].ID != dup.ID {
64 +
		t.Fatalf("missing fav %#v", missingFav)
65 +
	}
66 +
	if err := updateLinkFavicon(db, dup.ID, &fav); err != nil {
67 +
		t.Fatal(err)
68 +
	}
69 +
	missingFav, err = listLinksMissingFavicon(db)
70 +
	if err != nil || len(missingFav) != 0 {
71 +
		t.Fatalf("missing after update %#v err %v", missingFav, err)
72 +
	}
73 +
74 +
	deleted, err := deleteLinkByShortID(db, link.ShortID)
75 +
	if err != nil || !deleted {
76 +
		t.Fatalf("delete link %v err %v", deleted, err)
77 +
	}
78 +
	deleted, err = deleteLinkByShortID(db, "missing")
79 +
	if err != nil || deleted {
80 +
		t.Fatalf("delete missing link %v err %v", deleted, err)
81 +
	}
82 +
	deleted, err = deleteCategoryByShortID(db, cat.ShortID)
83 +
	if err != nil || !deleted {
84 +
		t.Fatalf("delete category %v err %v", deleted, err)
85 +
	}
86 +
}
apps/cellar-go/db_test.go (added) +124 −0
1 +
package main
2 +
3 +
import (
4 +
	"bytes"
5 +
	"database/sql"
6 +
	"testing"
7 +
8 +
	sharedsqlite "github.com/stevedylandev/andromeda/crates-go/sqlite"
9 +
)
10 +
11 +
func openCellarTestDB(t *testing.T) *sql.DB {
12 +
	t.Helper()
13 +
	db, err := sharedsqlite.Open("file:cellar-test?mode=memory&cache=shared", cellarSchema)
14 +
	if err != nil {
15 +
		t.Fatal(err)
16 +
	}
17 +
	return db
18 +
}
19 +
20 +
func validWine(name string) WineInput {
21 +
	return WineInput{Name: name, Origin: "France", Grape: "Gamay", Notes: "notes", Background: "bg", Sweetness: 3, Acidity: 3, Tannin: 3, Alcohol: 3, Body: 3, Clarity: 3, ColorIntensity: 3, AromaIntensity: 3, NoseComplexity: 3}
22 +
}
23 +
24 +
func TestWineCRUDValidationListsAndImages(t *testing.T) {
25 +
	db := openCellarTestDB(t)
26 +
	defer db.Close()
27 +
28 +
	cellar, err := createWine(db, validWine("Cellar"), false)
29 +
	if err != nil {
30 +
		t.Fatal(err)
31 +
	}
32 +
	if cellar.ShortID == "" || cellar.Wishlist {
33 +
		t.Fatalf("unexpected cellar wine: %#v", cellar)
34 +
	}
35 +
	wish, err := createWine(db, validWine("Wish"), true)
36 +
	if err != nil {
37 +
		t.Fatal(err)
38 +
	}
39 +
	if !wish.Wishlist {
40 +
		t.Fatalf("expected wishlist wine: %#v", wish)
41 +
	}
42 +
43 +
	bad := validWine("Bad")
44 +
	bad.Sweetness = 6
45 +
	if _, err := createWine(db, bad, false); err == nil {
46 +
		t.Fatal("expected invalid sweetness to fail")
47 +
	}
48 +
	bad = validWine("Bad")
49 +
	bad.Body = 0
50 +
	if _, err := createWine(db, bad, false); err == nil {
51 +
		t.Fatal("expected zero rating to fail")
52 +
	}
53 +
54 +
	cellarList, err := getCellarWines(db)
55 +
	if err != nil {
56 +
		t.Fatal(err)
57 +
	}
58 +
	if len(cellarList) != 1 || cellarList[0].ShortID != cellar.ShortID {
59 +
		t.Fatalf("unexpected cellar list: %#v", cellarList)
60 +
	}
61 +
	wishlist, err := getWishlistWines(db)
62 +
	if err != nil {
63 +
		t.Fatal(err)
64 +
	}
65 +
	if len(wishlist) != 1 || wishlist[0].ShortID != wish.ShortID {
66 +
		t.Fatalf("unexpected wishlist: %#v", wishlist)
67 +
	}
68 +
69 +
	promoted, err := promoteWine(db, wish.ShortID)
70 +
	if err != nil {
71 +
		t.Fatal(err)
72 +
	}
73 +
	if !promoted {
74 +
		t.Fatal("expected promotion")
75 +
	}
76 +
	promoted, err = promoteWine(db, cellar.ShortID)
77 +
	if err != nil {
78 +
		t.Fatal(err)
79 +
	}
80 +
	if promoted {
81 +
		t.Fatal("cellar promotion should be no-op")
82 +
	}
83 +
84 +
	updatedInput := validWine("Updated")
85 +
	updated, err := updateWine(db, cellar.ShortID, updatedInput)
86 +
	if err != nil {
87 +
		t.Fatal(err)
88 +
	}
89 +
	if updated == nil || updated.Name != "Updated" {
90 +
		t.Fatalf("unexpected update: %#v", updated)
91 +
	}
92 +
	if got, err := updateWishlistWine(db, cellar.ShortID, "x", "y", "z", "n", "b"); err != nil || got != nil {
93 +
		t.Fatalf("wishlist update on cellar got %#v err %v", got, err)
94 +
	}
95 +
96 +
	img, mime, err := getWineImage(db, cellar.ShortID)
97 +
	if err != nil {
98 +
		t.Fatal(err)
99 +
	}
100 +
	if img != nil || mime != "" {
101 +
		t.Fatalf("expected no image, got %q %q", img, mime)
102 +
	}
103 +
	wantImg := []byte{1, 2, 3}
104 +
	if err := updateWineImage(db, cellar.ShortID, wantImg, "image/png"); err != nil {
105 +
		t.Fatal(err)
106 +
	}
107 +
	img, mime, err = getWineImage(db, cellar.ShortID)
108 +
	if err != nil {
109 +
		t.Fatal(err)
110 +
	}
111 +
	if !bytes.Equal(img, wantImg) || mime != "image/png" {
112 +
		t.Fatalf("bad image %v %q", img, mime)
113 +
	}
114 +
115 +
	if err := deleteWine(db, cellar.ShortID); err != nil {
116 +
		t.Fatal(err)
117 +
	}
118 +
	if got, err := getWineByShortID(db, cellar.ShortID); err != nil || got != nil {
119 +
		t.Fatalf("deleted wine got %#v err %v", got, err)
120 +
	}
121 +
	if err := deleteWine(db, "missing"); err != nil {
122 +
		t.Fatal(err)
123 +
	}
124 +
}
apps/easel-go/db_test.go (added) +98 −0
1 +
package main
2 +
3 +
import (
4 +
	"database/sql"
5 +
	"strings"
6 +
	"testing"
7 +
	"time"
8 +
9 +
	sharedsqlite "github.com/stevedylandev/andromeda/crates-go/sqlite"
10 +
)
11 +
12 +
func openEaselTestDB(t *testing.T) *sql.DB {
13 +
	t.Helper()
14 +
	db, err := sharedsqlite.Open("file:easel-test?mode=memory&cache=shared", easelSchema)
15 +
	if err != nil {
16 +
		t.Fatal(err)
17 +
	}
18 +
	return db
19 +
}
20 +
21 +
func daily(date string, id int64) *DailyArtwork {
22 +
	return &DailyArtwork{Date: date, ArtworkID: id, Title: "Title", ImageID: "img", FetchedAt: "now"}
23 +
}
24 +
25 +
func TestDailyArtworkDBHelpers(t *testing.T) {
26 +
	db := openEaselTestDB(t)
27 +
	defer db.Close()
28 +
	ok, err := insertDaily(db, daily("2024-01-01", 1))
29 +
	if err != nil || !ok {
30 +
		t.Fatalf("insert %v err %v", ok, err)
31 +
	}
32 +
	ok, err = insertDaily(db, daily("2024-01-01", 2))
33 +
	if err != nil || ok {
34 +
		t.Fatalf("duplicate %v err %v", ok, err)
35 +
	}
36 +
	if got, err := getDaily(db, "2024-01-01"); err != nil || got == nil || got.ArtworkID != 1 {
37 +
		t.Fatalf("get %#v err %v", got, err)
38 +
	}
39 +
	if exists, err := artworkIDExists(db, 1); err != nil || !exists {
40 +
		t.Fatalf("exists %v err %v", exists, err)
41 +
	}
42 +
	if exists, err := artworkIDExists(db, 99); err != nil || exists {
43 +
		t.Fatalf("missing exists %v err %v", exists, err)
44 +
	}
45 +
	if _, err := insertDaily(db, daily("2024-01-03", 3)); err != nil {
46 +
		t.Fatal(err)
47 +
	}
48 +
	list, err := listDaily(db, 10)
49 +
	if err != nil {
50 +
		t.Fatal(err)
51 +
	}
52 +
	if len(list) != 2 || list[0].Date != "2024-01-03" {
53 +
		t.Fatalf("list order %#v", list)
54 +
	}
55 +
	missing, err := missingDates(db, []string{"2024-01-01", "2024-01-02", "2024-01-03"})
56 +
	if err != nil {
57 +
		t.Fatal(err)
58 +
	}
59 +
	if len(missing) != 1 || missing[0] != "2024-01-02" {
60 +
		t.Fatalf("missing %#v", missing)
61 +
	}
62 +
}
63 +
64 +
func TestAICAndSchedulerHelpers(t *testing.T) {
65 +
	params := buildAICParams([]string{"Painting", "Print"}, []string{"war"})
66 +
	if !strings.Contains(params, "painting") || strings.Contains(params, "Painting") {
67 +
		t.Fatalf("classifications not lowercased: %s", params)
68 +
	}
69 +
	if lower("AbC!") != "abc!" {
70 +
		t.Fatal("lower failed")
71 +
	}
72 +
	title, img, artist := "Mona", "image-id", "Artist"
73 +
	d := rawToDaily(&rawArtwork{ID: 42, Title: &title, ImageID: &img, ArtistTitle: &artist}, "2024-01-01", "fetched")
74 +
	if d == nil || d.Title != title || d.ImageID != img || !d.ArtistTitle.Valid {
75 +
		t.Fatalf("daily %#v", d)
76 +
	}
77 +
	if rawToDaily(&rawArtwork{ID: 1}, "2024-01-01", "now") != nil {
78 +
		t.Fatal("missing image should return nil")
79 +
	}
80 +
	if rawToDaily(&rawArtwork{ID: 1, ImageID: &img}, "2024-01-01", "now").Title != "Untitled" {
81 +
		t.Fatal("expected untitled fallback")
82 +
	}
83 +
84 +
	app := &App{TZ: time.UTC}
85 +
	dates := app.pastNDates(3)
86 +
	if len(dates) != 3 {
87 +
		t.Fatalf("dates %#v", dates)
88 +
	}
89 +
	if today := time.Now().UTC().Format("2006-01-02"); dates[0] == today {
90 +
		t.Fatal("past dates included today")
91 +
	}
92 +
	if _, ok := parseDate("2024-02-29"); !ok {
93 +
		t.Fatal("valid date rejected")
94 +
	}
95 +
	if _, ok := parseDate("2024-02-30"); ok {
96 +
		t.Fatal("invalid date accepted")
97 +
	}
98 +
}
apps/feeds-go/feeds_test.go +332 −4
41 41
	return sub
42 42
}
43 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 +
44 57
func TestParseOPMLHandlesNestedCategories(t *testing.T) {
45 58
	content := `<?xml version="1.0" encoding="UTF-8"?>
46 59
<opml version="2.0">
76 89
	}
77 90
}
78 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 +
79 153
func TestDeriveTitleFromHTMLStripsMarkupAndTruncates(t *testing.T) {
80 154
	src := `<p>Hello <strong>world</strong> &amp; friends.</p>`
81 155
	if got := deriveTitleFromHTML(src); got != "Hello world & friends." {
87 161
	if !strings.HasSuffix(got, "…") {
88 162
		t.Fatalf("expected ellipsis, got %q", got)
89 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 +
	}
90 175
}
91 176
92 177
func TestFindAlternateFeedLinksAndFavicon(t *testing.T) {
158 243
	}
159 244
}
160 245
161 -
func TestGetOrCreateCategoryTrimsAndReuses(t *testing.T) {
246 +
func TestCategoryCRUD(t *testing.T) {
162 247
	db := newTestDB(t)
163 -
	first, err := getOrCreateCategory(db, "  News  ")
248 +
	first, err := getOrCreateCategory(db, "  Tech  ")
164 249
	if err != nil {
165 250
		t.Fatal(err)
166 251
	}
167 -
	second, err := getOrCreateCategory(db, "News")
252 +
	if first == nil || first.Name != "Tech" {
253 +
		t.Fatalf("unexpected category: %+v", first)
254 +
	}
255 +
	second, err := getOrCreateCategory(db, "Tech")
168 256
	if err != nil {
169 257
		t.Fatal(err)
170 258
	}
171 -
	if first == nil || second == nil || first.ID != second.ID {
259 +
	if second == nil || first.ID != second.ID {
172 260
		t.Fatalf("expected same category, got first=%+v second=%+v", first, second)
173 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 +
	}
174 279
}
175 280
176 281
func TestListItemsAndPruneSubscription(t *testing.T) {
249 354
		t.Fatalf("expected fallback for invalid setting, got %d", got)
250 355
	}
251 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 +
}
apps/feeds-go/subscriptions.go +3 −0
87 87
88 88
func (a *App) seedSubscription(subID int64, res *FetchResult) error {
89 89
	for _, entry := range res.Entries {
90 +
		if strings.TrimSpace(entry.Link) == "" {
91 +
			continue
92 +
		}
90 93
		_, err := insertItemIgnoreDup(a.DB, NewItem{SubscriptionID: subID, GUID: entry.GUID, Title: entry.Title, Link: entry.Link, Author: entry.Author, PublishedAt: entry.PublishedAt})
91 94
		if err != nil {
92 95
			a.Log.Warn("seed insert failed", "sub_id", subID, "err", err)
apps/jotts-go/db_test.go (added) +77 −0
1 +
package main
2 +
3 +
import "testing"
4 +
5 +
func TestNoteCRUDAndOrdering(t *testing.T) {
6 +
	db, err := openDB("file:jotts-test?mode=memory&cache=shared")
7 +
	if err != nil {
8 +
		t.Fatal(err)
9 +
	}
10 +
	defer db.Close()
11 +
12 +
	first, err := createNote(db, "first", "one")
13 +
	if err != nil {
14 +
		t.Fatal(err)
15 +
	}
16 +
	if first.ShortID == "" {
17 +
		t.Fatal("expected short id")
18 +
	}
19 +
	second, err := createNote(db, "second", "two")
20 +
	if err != nil {
21 +
		t.Fatal(err)
22 +
	}
23 +
24 +
	got, err := getNoteByShortID(db, first.ShortID)
25 +
	if err != nil {
26 +
		t.Fatal(err)
27 +
	}
28 +
	if got == nil || got.Title != "first" || got.Content != "one" {
29 +
		t.Fatalf("unexpected note: %#v", got)
30 +
	}
31 +
32 +
	missing, err := getNoteByShortID(db, "missing")
33 +
	if err != nil {
34 +
		t.Fatal(err)
35 +
	}
36 +
	if missing != nil {
37 +
		t.Fatalf("expected nil missing note, got %#v", missing)
38 +
	}
39 +
40 +
	all, err := listNotes(db)
41 +
	if err != nil {
42 +
		t.Fatal(err)
43 +
	}
44 +
	if len(all) != 2 || all[0].ShortID != second.ShortID || all[1].ShortID != first.ShortID {
45 +
		t.Fatalf("not newest first: %#v", all)
46 +
	}
47 +
48 +
	updated, err := updateNoteByShortID(db, first.ShortID, "updated", "changed")
49 +
	if err != nil {
50 +
		t.Fatal(err)
51 +
	}
52 +
	if updated == nil || updated.Title != "updated" || updated.Content != "changed" {
53 +
		t.Fatalf("unexpected update: %#v", updated)
54 +
	}
55 +
	updated, err = updateNoteByShortID(db, "missing", "x", "y")
56 +
	if err != nil {
57 +
		t.Fatal(err)
58 +
	}
59 +
	if updated != nil {
60 +
		t.Fatalf("expected nil updating missing, got %#v", updated)
61 +
	}
62 +
63 +
	deleted, err := deleteNoteByShortID(db, first.ShortID)
64 +
	if err != nil {
65 +
		t.Fatal(err)
66 +
	}
67 +
	if !deleted {
68 +
		t.Fatal("expected delete to report true")
69 +
	}
70 +
	deleted, err = deleteNoteByShortID(db, "missing")
71 +
	if err != nil {
72 +
		t.Fatal(err)
73 +
	}
74 +
	if deleted {
75 +
		t.Fatal("expected missing delete false")
76 +
	}
77 +
}
apps/jotts-go/tui/backend.go +3 −3
31 31
	DB *sql.DB
32 32
}
33 33
34 -
func (b *LocalBackend) List() ([]Note, error)               { return store.List(b.DB) }
35 -
func (b *LocalBackend) Get(s string) (*Note, error)         { return store.GetByShortID(b.DB, s) }
36 -
func (b *LocalBackend) Create(t, c string) (*Note, error)   { return store.Create(b.DB, t, c) }
34 +
func (b *LocalBackend) List() ([]Note, error)             { return store.List(b.DB) }
35 +
func (b *LocalBackend) Get(s string) (*Note, error)       { return store.GetByShortID(b.DB, s) }
36 +
func (b *LocalBackend) Create(t, c string) (*Note, error) { return store.Create(b.DB, t, c) }
37 37
func (b *LocalBackend) Update(s, t, c string) (*Note, error) {
38 38
	return store.UpdateByShortID(b.DB, s, t, c)
39 39
}
apps/jotts-go/tui/model.go +2 −2
24 24
)
25 25
26 26
type Model struct {
27 -
	backend     Backend
28 -
	isRemote    bool
27 +
	backend  Backend
28 +
	isRemote bool
29 29
30 30
	notes    []Note
31 31
	filtered []int
apps/jotts-go/tui/view.go +1 −1
18 18
			Bold(true).
19 19
			Foreground(lipgloss.Color("214")).
20 20
			Padding(0, 1)
21 -
	itemStyle = lipgloss.NewStyle().Padding(0, 1)
21 +
	itemStyle    = lipgloss.NewStyle().Padding(0, 1)
22 22
	itemSelected = lipgloss.NewStyle().
23 23
			Padding(0, 1).
24 24
			Bold(true).
apps/library-go/db_test.go (added) +78 −0
1 +
package main
2 +
3 +
import (
4 +
	"database/sql"
5 +
	"testing"
6 +
7 +
	sharedsqlite "github.com/stevedylandev/andromeda/crates-go/sqlite"
8 +
)
9 +
10 +
func openLibraryTestDB(t *testing.T) *sql.DB {
11 +
	t.Helper()
12 +
	db, err := sharedsqlite.Open("file:library-test?mode=memory&cache=shared", booksSchema)
13 +
	if err != nil {
14 +
		t.Fatal(err)
15 +
	}
16 +
	return db
17 +
}
18 +
19 +
func sp(s string) *string { return &s }
20 +
21 +
func TestBookCRUDSearchSettingsAndGoogleHelpers(t *testing.T) {
22 +
	db := openLibraryTestDB(t)
23 +
	defer db.Close()
24 +
	id, err := insertBook(db, NewBook{GoogleID: sp("gid"), Title: "Dune", Authors: "Frank Herbert", ISBN: sp("123"), CoverURL: sp("https://cover"), Notes: sp("note"), Status: "want"})
25 +
	if err != nil {
26 +
		t.Fatal(err)
27 +
	}
28 +
	book, err := getBook(db, id)
29 +
	if err != nil || book == nil || book.Title != "Dune" || book.GoogleID == nil || *book.GoogleID != "gid" {
30 +
		t.Fatalf("book %#v err %v", book, err)
31 +
	}
32 +
	if err := updateBookStatus(db, id, "reading"); err != nil {
33 +
		t.Fatal(err)
34 +
	}
35 +
	note := "updated"
36 +
	if err := updateBookNotes(db, id, &note); err != nil {
37 +
		t.Fatal(err)
38 +
	}
39 +
	book, _ = getBook(db, id)
40 +
	if book.Status != "reading" || book.Notes == nil || *book.Notes != note {
41 +
		t.Fatalf("updated book %#v", book)
42 +
	}
43 +
	books, err := listBooks(db, "reading")
44 +
	if err != nil || len(books) != 1 || books[0].ID != id {
45 +
		t.Fatalf("filtered %#v err %v", books, err)
46 +
	}
47 +
	books, err = searchBooks(db, "herbert")
48 +
	if err != nil || len(books) != 1 {
49 +
		t.Fatalf("search %#v err %v", books, err)
50 +
	}
51 +
	if err := deleteBook(db, id); err != nil {
52 +
		t.Fatal(err)
53 +
	}
54 +
	if got, err := getBook(db, id); err != nil || got != nil {
55 +
		t.Fatalf("deleted %#v err %v", got, err)
56 +
	}
57 +
58 +
	if _, ok, err := getSetting(db, "missing"); err != nil || ok {
59 +
		t.Fatalf("missing setting ok=%v err=%v", ok, err)
60 +
	}
61 +
	if err := setSetting(db, "category_label.want", "Wishlist"); err != nil {
62 +
		t.Fatal(err)
63 +
	}
64 +
	labels, err := getCategoryLabels(db)
65 +
	if err != nil {
66 +
		t.Fatal(err)
67 +
	}
68 +
	if labels.Want != "Wishlist" || labels.Read == "" || labels.Reading == "" {
69 +
		t.Fatalf("labels %#v", labels)
70 +
	}
71 +
72 +
	if got := pickISBN([]identifier{{Kind: "ISBN_10", Identifier: "ten"}, {Kind: "ISBN_13", Identifier: "thirteen"}}); got != "thirteen" {
73 +
		t.Fatalf("isbn %q", got)
74 +
	}
75 +
	if !isISBNChars("123456789X") || isISBNChars("123ABC") {
76 +
		t.Fatal("isbn char validation failed")
77 +
	}
78 +
}
apps/og-go/og_test.go (added) +54 −0
1 +
package main
2 +
3 +
import (
4 +
	"net/url"
5 +
	"strings"
6 +
	"testing"
7 +
8 +
	"golang.org/x/net/html"
9 +
)
10 +
11 +
func parseDoc(t *testing.T, s string) *html.Node {
12 +
	t.Helper()
13 +
	doc, err := html.Parse(strings.NewReader(s))
14 +
	if err != nil {
15 +
		t.Fatal(err)
16 +
	}
17 +
	return doc
18 +
}
19 +
20 +
func TestFaviconExtractionPriorityAndFallback(t *testing.T) {
21 +
	base, _ := url.Parse("https://example.com/path/page")
22 +
	cases := []struct{ name, html, want string }{
23 +
		{"icon", `<head><link rel="icon" href="/icon.png"></head>`, "https://example.com/icon.png"},
24 +
		{"shortcut", `<head><link rel="shortcut icon" href="shortcut.ico"></head>`, "https://example.com/path/shortcut.ico"},
25 +
		{"apple", `<head><link rel="apple-touch-icon" href="https://cdn.test/apple.png"></head>`, "https://cdn.test/apple.png"},
26 +
		{"priority", `<head><link rel="apple-touch-icon" href="/apple.png"><link rel="shortcut icon" href="/shortcut.ico"><link rel="icon" href="/icon.png"></head>`, "https://example.com/icon.png"},
27 +
		{"fallback", `<head></head>`, "https://example.com/favicon.ico"},
28 +
	}
29 +
	for _, tc := range cases {
30 +
		t.Run(tc.name, func(t *testing.T) {
31 +
			if got := extractFavicon(parseDoc(t, tc.html), base); got != tc.want {
32 +
				t.Fatalf("got %q want %q", got, tc.want)
33 +
			}
34 +
		})
35 +
	}
36 +
}
37 +
38 +
func TestExtractLinkTags(t *testing.T) {
39 +
	base, _ := url.Parse("https://example.com/dir/page")
40 +
	doc := parseDoc(t, `<html><head><link rel="preload" href="/style.css" as="style"><link rel="canonical" href="canonical"></head><body><link rel="ignored" href="/body"></body></html>`)
41 +
	links := extractLinkTags(doc, base)
42 +
	if len(links) != 2 {
43 +
		t.Fatalf("links %#v", links)
44 +
	}
45 +
	if links[0].Rel != "preload" || links[0].Href != "https://example.com/style.css" || links[0].Extra != `as="style"` {
46 +
		t.Fatalf("first link %#v", links[0])
47 +
	}
48 +
	if links[1].Href != "https://example.com/dir/canonical" {
49 +
		t.Fatalf("relative href %q", links[1].Href)
50 +
	}
51 +
	if got := extractLinkTags(parseDoc(t, `<html><body></body></html>`), base); got != nil {
52 +
		t.Fatalf("expected nil without head, got %#v", got)
53 +
	}
54 +
}
apps/posts-go/db_test.go (added) +165 −0
1 +
package main
2 +
3 +
import (
4 +
	"database/sql"
5 +
	"testing"
6 +
7 +
	sharedsqlite "github.com/stevedylandev/andromeda/crates-go/sqlite"
8 +
)
9 +
10 +
func openPostsTestDB(t *testing.T) *sql.DB {
11 +
	t.Helper()
12 +
	db, err := sharedsqlite.Open("file:posts-test?mode=memory&cache=shared", postsSchema)
13 +
	if err != nil {
14 +
		t.Fatal(err)
15 +
	}
16 +
	seedDefaultSettings(db)
17 +
	return db
18 +
}
19 +
20 +
func strp(s string) *string { return &s }
21 +
22 +
func postInput(slug, status string) PostInput {
23 +
	return PostInput{Title: strp("Title " + slug), Slug: slug, Content: "content " + slug, Status: status, Lang: "en"}
24 +
}
25 +
26 +
func TestPostCRUDPublishedAliasesAndDisplayTitle(t *testing.T) {
27 +
	db := openPostsTestDB(t)
28 +
	defer db.Close()
29 +
	draft, err := createPost(db, postInput("draft", "draft"))
30 +
	if err != nil {
31 +
		t.Fatal(err)
32 +
	}
33 +
	pubIn := postInput("pub", "published")
34 +
	pubIn.PublishedDate = strp("2024-01-02")
35 +
	pubIn.Alias = strp("old")
36 +
	pub, err := createPost(db, pubIn)
37 +
	if err != nil {
38 +
		t.Fatal(err)
39 +
	}
40 +
	noTitle, err := createPost(db, PostInput{Slug: "no-title", Content: "body fallback content", Status: "draft", Lang: "en"})
41 +
	if err != nil {
42 +
		t.Fatal(err)
43 +
	}
44 +
	if noTitle.DisplayTitle() != "body fallback content" {
45 +
		t.Fatalf("fallback title %q", noTitle.DisplayTitle())
46 +
	}
47 +
	if _, err := createPost(db, postInput("pub", "draft")); err == nil {
48 +
		t.Fatal("expected duplicate slug failure")
49 +
	}
50 +
	got, err := getPostByShortID(db, draft.ShortID)
51 +
	if err != nil || got == nil || got.Slug != "draft" {
52 +
		t.Fatalf("get by id %#v err %v", got, err)
53 +
	}
54 +
	got, err = getPostBySlug(db, "pub")
55 +
	if err != nil || got == nil || got.ShortID != pub.ShortID {
56 +
		t.Fatalf("get by slug %#v err %v", got, err)
57 +
	}
58 +
	all, err := getAllPosts(db)
59 +
	if err != nil {
60 +
		t.Fatal(err)
61 +
	}
62 +
	if len(all) != 3 || all[0].ShortID != noTitle.ShortID {
63 +
		t.Fatalf("all posts not newest first: %#v", all)
64 +
	}
65 +
	published, err := getPublishedPosts(db, 10)
66 +
	if err != nil {
67 +
		t.Fatal(err)
68 +
	}
69 +
	if len(published) != 1 || published[0].ShortID != pub.ShortID {
70 +
		t.Fatalf("published filter: %#v", published)
71 +
	}
72 +
	published, err = getPublishedPosts(db, 1)
73 +
	if err != nil || len(published) != 1 {
74 +
		t.Fatalf("published limit: %#v err %v", published, err)
75 +
	}
76 +
	redir, err := findAliasRedirect(db, "old")
77 +
	if err != nil {
78 +
		t.Fatal(err)
79 +
	}
80 +
	if redir != "/posts/pub" {
81 +
		t.Fatalf("redirect %q", redir)
82 +
	}
83 +
	redir, err = findAliasRedirect(db, "missing")
84 +
	if err != nil || redir != "" {
85 +
		t.Fatalf("missing redirect %q err %v", redir, err)
86 +
	}
87 +
88 +
	newStatus, err := togglePostStatus(db, draft.ShortID)
89 +
	if err != nil || newStatus != "published" {
90 +
		t.Fatalf("toggle draft %#v %v", newStatus, err)
91 +
	}
92 +
	newStatus, err = togglePostStatus(db, draft.ShortID)
93 +
	if err != nil || newStatus != "draft" {
94 +
		t.Fatalf("toggle published %#v %v", newStatus, err)
95 +
	}
96 +
	deleted, err := deletePost(db, pub.ShortID)
97 +
	if err != nil || !deleted {
98 +
		t.Fatalf("delete %v %v", deleted, err)
99 +
	}
100 +
	deleted, err = deletePost(db, "missing")
101 +
	if err != nil || deleted {
102 +
		t.Fatalf("delete missing %v %v", deleted, err)
103 +
	}
104 +
}
105 +
106 +
func TestPagesSettingsAndFiles(t *testing.T) {
107 +
	db := openPostsTestDB(t)
108 +
	defer db.Close()
109 +
	page, err := createPage(db, "About", "about", "hello", true, 2)
110 +
	if err != nil {
111 +
		t.Fatal(err)
112 +
	}
113 +
	if got, err := getPageByShortID(db, page.ShortID); err != nil || got == nil || got.Title != "About" {
114 +
		t.Fatalf("page by id %#v err %v", got, err)
115 +
	}
116 +
	if got, err := getPageBySlug(db, "about"); err != nil || got == nil || got.ShortID != page.ShortID {
117 +
		t.Fatalf("page by slug %#v err %v", got, err)
118 +
	}
119 +
	updated, err := updatePage(db, page.ShortID, "About us", "about-us", "hi", false, 1)
120 +
	if err != nil || updated == nil || updated.IsPublished || updated.NavOrder != 1 {
121 +
		t.Fatalf("updated page %#v err %v", updated, err)
122 +
	}
123 +
	if err := deletePage(db, page.ShortID); err != nil {
124 +
		t.Fatal(err)
125 +
	}
126 +
	if got, err := getPageByShortID(db, page.ShortID); err != nil || got != nil {
127 +
		t.Fatalf("deleted page %#v err %v", got, err)
128 +
	}
129 +
130 +
	if v, err := getSetting(db, "missing"); err != nil || v != "" {
131 +
		t.Fatalf("missing setting %q err %v", v, err)
132 +
	}
133 +
	if err := setSetting(db, "blog_title", "Andromeda"); err != nil {
134 +
		t.Fatal(err)
135 +
	}
136 +
	if err := setSetting(db, "blog_title", "Updated"); err != nil {
137 +
		t.Fatal(err)
138 +
	}
139 +
	if v, err := getSetting(db, "blog_title"); err != nil || v != "Updated" {
140 +
		t.Fatalf("setting %q err %v", v, err)
141 +
	}
142 +
143 +
	file, err := createFile(db, "stored.jpg", "photo.jpg", "image/jpeg", 123, "")
144 +
	if err != nil {
145 +
		t.Fatal(err)
146 +
	}
147 +
	if file.StorageBackend != "local" {
148 +
		t.Fatalf("backend %q", file.StorageBackend)
149 +
	}
150 +
	if got, err := getFileByFilename(db, "stored.jpg"); err != nil || got == nil || got.OriginalName != "photo.jpg" {
151 +
		t.Fatalf("file %#v err %v", got, err)
152 +
	}
153 +
	files, err := getAllFiles(db)
154 +
	if err != nil || len(files) != 1 {
155 +
		t.Fatalf("files %#v err %v", files, err)
156 +
	}
157 +
	deleted, err := deleteFile(db, file.ShortID)
158 +
	if err != nil || deleted == nil || deleted.Filename != "stored.jpg" {
159 +
		t.Fatalf("deleted file %#v err %v", deleted, err)
160 +
	}
161 +
	deleted, err = deleteFile(db, "missing")
162 +
	if err != nil || deleted != nil {
163 +
		t.Fatalf("missing file %#v err %v", deleted, err)
164 +
	}
165 +
}
apps/shrink-go/image_test.go (added) +97 −0
1 +
package main
2 +
3 +
import (
4 +
	"bytes"
5 +
	"encoding/binary"
6 +
	"image"
7 +
	"image/color"
8 +
	"image/jpeg"
9 +
	"testing"
10 +
)
11 +
12 +
func jpegBytes(t *testing.T, w, h int) []byte {
13 +
	t.Helper()
14 +
	img := image.NewRGBA(image.Rect(0, 0, w, h))
15 +
	for y := 0; y < h; y++ {
16 +
		for x := 0; x < w; x++ {
17 +
			img.Set(x, y, color.RGBA{R: 200, G: 10, B: 10, A: 255})
18 +
		}
19 +
	}
20 +
	var b bytes.Buffer
21 +
	if err := jpeg.Encode(&b, img, nil); err != nil {
22 +
		t.Fatal(err)
23 +
	}
24 +
	return b.Bytes()
25 +
}
26 +
27 +
func TestBuildDownloadFilename(t *testing.T) {
28 +
	cases := map[string]string{"photo.jpg": "photo_compressed.webp", "photo": "photo_compressed.webp", "": "compressed_compressed.webp", "a.b.c.png": "a.b.c_compressed.webp"}
29 +
	for in, want := range cases {
30 +
		if got := buildDownloadFilename(in, "webp"); got != want {
31 +
			t.Fatalf("%q got %q want %q", in, got, want)
32 +
		}
33 +
	}
34 +
}
35 +
36 +
func TestCompressImage(t *testing.T) {
37 +
	if _, err := compressImage([]byte("not an image"), 80, 0); err == nil {
38 +
		t.Fatal("expected invalid image error")
39 +
	}
40 +
	out, err := compressImage(jpegBytes(t, 4, 2), 80, 0)
41 +
	if err != nil {
42 +
		t.Fatal(err)
43 +
	}
44 +
	if len(out) == 0 {
45 +
		t.Fatal("empty output")
46 +
	}
47 +
	resized, err := compressImage(jpegBytes(t, 4, 2), 80, 2)
48 +
	if err != nil {
49 +
		t.Fatal(err)
50 +
	}
51 +
	img, _, err := image.Decode(bytes.NewReader(resized))
52 +
	if err != nil {
53 +
		t.Fatal(err)
54 +
	}
55 +
	if img.Bounds().Dx() != 2 || img.Bounds().Dy() != 1 {
56 +
		t.Fatalf("resized bounds %v", img.Bounds())
57 +
	}
58 +
}
59 +
60 +
func makeExif(order binary.ByteOrder, magic string, gps bool) []byte {
61 +
	b := make([]byte, 40)
62 +
	copy(b[:2], magic)
63 +
	order.PutUint16(b[2:4], 42)
64 +
	order.PutUint32(b[4:8], 8)
65 +
	order.PutUint16(b[8:10], 1)
66 +
	if gps {
67 +
		order.PutUint16(b[10:12], 0x8825)
68 +
		order.PutUint32(b[18:22], 30)
69 +
		order.PutUint16(b[30:32], 7)
70 +
	} else {
71 +
		order.PutUint16(b[10:12], 0x010f)
72 +
	}
73 +
	return b
74 +
}
75 +
76 +
func TestStripGPS(t *testing.T) {
77 +
	short := []byte{1, 2, 3}
78 +
	if !bytes.Equal(stripGPS(short), short) {
79 +
		t.Fatal("short exif changed")
80 +
	}
81 +
	bad := []byte("not-tiff-data")
82 +
	if !bytes.Equal(stripGPS(bad), bad) {
83 +
		t.Fatal("bad header changed")
84 +
	}
85 +
	le := stripGPS(makeExif(binary.LittleEndian, "II", true))
86 +
	if binary.LittleEndian.Uint16(le[30:32]) != 0 {
87 +
		t.Fatal("little endian gps not zeroed")
88 +
	}
89 +
	be := stripGPS(makeExif(binary.BigEndian, "MM", true))
90 +
	if binary.BigEndian.Uint16(be[30:32]) != 0 {
91 +
		t.Fatal("big endian gps not zeroed")
92 +
	}
93 +
	noGPS := makeExif(binary.LittleEndian, "II", false)
94 +
	if !bytes.Equal(stripGPS(noGPS), noGPS) {
95 +
		t.Fatal("no gps changed")
96 +
	}
97 +
}
apps/sipp-go/internal/store/store_test.go (added) +76 −0
1 +
package store
2 +
3 +
import "testing"
4 +
5 +
func TestSnippetCRUDAndOrdering(t *testing.T) {
6 +
	db, err := Open("file:sipp-store-test?mode=memory&cache=shared")
7 +
	if err != nil {
8 +
		t.Fatal(err)
9 +
	}
10 +
	defer db.Close()
11 +
12 +
	first, err := Create(db, "first", "one")
13 +
	if err != nil {
14 +
		t.Fatal(err)
15 +
	}
16 +
	if first.ShortID == "" {
17 +
		t.Fatal("expected short id")
18 +
	}
19 +
	second, err := Create(db, "second", "two")
20 +
	if err != nil {
21 +
		t.Fatal(err)
22 +
	}
23 +
24 +
	got, err := GetByShortID(db, first.ShortID)
25 +
	if err != nil {
26 +
		t.Fatal(err)
27 +
	}
28 +
	if got == nil || got.Name != "first" || got.Content != "one" {
29 +
		t.Fatalf("unexpected snippet: %#v", got)
30 +
	}
31 +
	missing, err := GetByShortID(db, "missing")
32 +
	if err != nil {
33 +
		t.Fatal(err)
34 +
	}
35 +
	if missing != nil {
36 +
		t.Fatalf("expected nil missing snippet, got %#v", missing)
37 +
	}
38 +
39 +
	all, err := List(db)
40 +
	if err != nil {
41 +
		t.Fatal(err)
42 +
	}
43 +
	if len(all) != 2 || all[0].ShortID != second.ShortID || all[1].ShortID != first.ShortID {
44 +
		t.Fatalf("not newest first: %#v", all)
45 +
	}
46 +
47 +
	updated, err := UpdateByShortID(db, first.ShortID, "updated", "changed")
48 +
	if err != nil {
49 +
		t.Fatal(err)
50 +
	}
51 +
	if updated == nil || updated.Name != "updated" || updated.Content != "changed" {
52 +
		t.Fatalf("unexpected update: %#v", updated)
53 +
	}
54 +
	updated, err = UpdateByShortID(db, "missing", "x", "y")
55 +
	if err != nil {
56 +
		t.Fatal(err)
57 +
	}
58 +
	if updated != nil {
59 +
		t.Fatalf("expected nil updating missing, got %#v", updated)
60 +
	}
61 +
62 +
	deleted, err := DeleteByShortID(db, first.ShortID)
63 +
	if err != nil {
64 +
		t.Fatal(err)
65 +
	}
66 +
	if !deleted {
67 +
		t.Fatal("expected delete true")
68 +
	}
69 +
	deleted, err = DeleteByShortID(db, "missing")
70 +
	if err != nil {
71 +
		t.Fatal(err)
72 +
	}
73 +
	if deleted {
74 +
		t.Fatal("expected missing delete false")
75 +
	}
76 +
}
apps/sipp-go/server/server.go +12 −12
33 33
type Snippet = store.Snippet
34 34
35 35
type App struct {
36 -
	DB              *sql.DB
37 -
	Log             *slog.Logger
38 -
	Templates       *template.Template
39 -
	Sessions        *auth.Store
40 -
	APIKey          string
41 -
	BaseURL         string
42 -
	CookieSecure    bool
43 -
	AuthEndpoints   map[string]bool
44 -
	MaxContentSize  int
36 +
	DB             *sql.DB
37 +
	Log            *slog.Logger
38 +
	Templates      *template.Template
39 +
	Sessions       *auth.Store
40 +
	APIKey         string
41 +
	BaseURL        string
42 +
	CookieSecure   bool
43 +
	AuthEndpoints  map[string]bool
44 +
	MaxContentSize int
45 45
}
46 46
47 47
var (
48 -
	createSnippet         = store.Create
49 -
	getSnippetByShortID   = store.GetByShortID
50 -
	getAllSnippets        = store.List
48 +
	createSnippet          = store.Create
49 +
	getSnippetByShortID    = store.GetByShortID
50 +
	getAllSnippets         = store.List
51 51
	deleteSnippetByShortID = store.DeleteByShortID
52 52
	updateSnippetByShortID = store.UpdateByShortID
53 53
)
apps/sipp-go/tui/backend.go +9 −7
31 31
	DB *sql.DB
32 32
}
33 33
34 -
func (b *LocalBackend) List() ([]Snippet, error)                      { return store.List(b.DB) }
35 -
func (b *LocalBackend) Get(s string) (*Snippet, error)                { return store.GetByShortID(b.DB, s) }
36 -
func (b *LocalBackend) Create(n, c string) (*Snippet, error)          { return store.Create(b.DB, n, c) }
37 -
func (b *LocalBackend) Update(s, n, c string) (*Snippet, error)       { return store.UpdateByShortID(b.DB, s, n, c) }
38 -
func (b *LocalBackend) Delete(s string) (bool, error)                 { return store.DeleteByShortID(b.DB, s) }
39 -
func (b *LocalBackend) RemoteURL() string                             { return "" }
40 -
func (b *LocalBackend) Close() error                                  { return b.DB.Close() }
34 +
func (b *LocalBackend) List() ([]Snippet, error)             { return store.List(b.DB) }
35 +
func (b *LocalBackend) Get(s string) (*Snippet, error)       { return store.GetByShortID(b.DB, s) }
36 +
func (b *LocalBackend) Create(n, c string) (*Snippet, error) { return store.Create(b.DB, n, c) }
37 +
func (b *LocalBackend) Update(s, n, c string) (*Snippet, error) {
38 +
	return store.UpdateByShortID(b.DB, s, n, c)
39 +
}
40 +
func (b *LocalBackend) Delete(s string) (bool, error) { return store.DeleteByShortID(b.DB, s) }
41 +
func (b *LocalBackend) RemoteURL() string             { return "" }
42 +
func (b *LocalBackend) Close() error                  { return b.DB.Close() }
41 43
42 44
type RemoteBackend struct {
43 45
	BaseURL string
apps/sipp-go/tui/config_test.go (added) +55 −0
1 +
package tui
2 +
3 +
import (
4 +
	"os"
5 +
	"path/filepath"
6 +
	"testing"
7 +
8 +
	"github.com/BurntSushi/toml"
9 +
)
10 +
11 +
func TestConfigTOMLRoundTrip(t *testing.T) {
12 +
	cfg := Config{RemoteURL: "https://example.test", APIKey: "secret"}
13 +
	var path = filepath.Join(t.TempDir(), "config.toml")
14 +
	f, err := os.Create(path)
15 +
	if err != nil {
16 +
		t.Fatal(err)
17 +
	}
18 +
	if err := toml.NewEncoder(f).Encode(cfg); err != nil {
19 +
		t.Fatal(err)
20 +
	}
21 +
	if err := f.Close(); err != nil {
22 +
		t.Fatal(err)
23 +
	}
24 +
	var got Config
25 +
	if _, err := toml.DecodeFile(path, &got); err != nil {
26 +
		t.Fatal(err)
27 +
	}
28 +
	if got != cfg {
29 +
		t.Fatalf("got %#v want %#v", got, cfg)
30 +
	}
31 +
}
32 +
33 +
func TestLoadConfigMissingAndSaveRoundTrip(t *testing.T) {
34 +
	dir := t.TempDir()
35 +
	t.Setenv("XDG_CONFIG_HOME", dir)
36 +
	cfg, err := LoadConfig()
37 +
	if err != nil {
38 +
		t.Fatal(err)
39 +
	}
40 +
	if cfg != (Config{}) {
41 +
		t.Fatalf("missing config got %#v", cfg)
42 +
	}
43 +
44 +
	want := Config{RemoteURL: "http://localhost:3000", APIKey: "key"}
45 +
	if err := SaveConfig(want); err != nil {
46 +
		t.Fatal(err)
47 +
	}
48 +
	got, err := LoadConfig()
49 +
	if err != nil {
50 +
		t.Fatal(err)
51 +
	}
52 +
	if got != want {
53 +
		t.Fatalf("got %#v want %#v", got, want)
54 +
	}
55 +
}