chore: added go tests
ecfa5791
18 file(s) · +1323 −29
| 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 |
| 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 | + | } |
| 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 | + | } |
| 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 | + | } |
| 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> & 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 | + | } |
|
| 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) |
| 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 | + | } |
| 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 | } |
| 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 |
| 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). |
| 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, ¬e); 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 | + | } |
| 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 | + | } |
| 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 | + | } |
| 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 | + | } |
| 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 | + | } |
| 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 | ) |
| 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 |
| 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 | + | } |