From a8c6557d57c1fc61c988772ce9ba2b3934e11f3d Mon Sep 17 00:00:00 2001 From: vikingowl Date: Fri, 3 Apr 2026 11:25:37 +0200 Subject: [PATCH] feat(store): SQLite store with schema, CRUD, and feedback --- go.mod | 10 + go.sum | 21 ++ internal/store/store.go | 434 +++++++++++++++++++++++++++++++++++ internal/store/store_test.go | 117 ++++++++++ 4 files changed, 582 insertions(+) create mode 100644 internal/store/store.go create mode 100644 internal/store/store_test.go diff --git a/go.mod b/go.mod index db02ee1..6c1fc5c 100644 --- a/go.mod +++ b/go.mod @@ -3,8 +3,18 @@ module somegit.dev/vikingowl/reddit-reader go 1.26 require ( + github.com/dustin/go-humanize v1.0.1 // indirect + github.com/google/uuid v1.6.0 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/ncruces/go-strftime v1.0.0 // indirect github.com/pelletier/go-toml/v2 v2.3.0 // indirect + github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect github.com/spf13/cobra v1.10.2 // indirect github.com/spf13/pflag v1.0.9 // indirect + golang.org/x/sys v0.42.0 // indirect + modernc.org/libc v1.70.0 // indirect + modernc.org/mathutil v1.7.1 // indirect + modernc.org/memory v1.11.0 // indirect + modernc.org/sqlite v1.48.0 // indirect ) diff --git a/go.sum b/go.sum index 4af9525..f0eae39 100644 --- a/go.sum +++ b/go.sum @@ -1,12 +1,33 @@ github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= +github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= +github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w= +github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= github.com/pelletier/go-toml/v2 v2.3.0 h1:k59bC/lIZREW0/iVaQR8nDHxVq8OVlIzYCOJf421CaM= github.com/pelletier/go-toml/v2 v2.3.0/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU= github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4= github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY= github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo= +golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +modernc.org/libc v1.70.0 h1:U58NawXqXbgpZ/dcdS9kMshu08aiA6b7gusEusqzNkw= +modernc.org/libc v1.70.0/go.mod h1:OVmxFGP1CI/Z4L3E0Q3Mf1PDE0BucwMkcXjjLntvHJo= +modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU= +modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg= +modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI= +modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw= +modernc.org/sqlite v1.48.0 h1:ElZyLop3Q2mHYk5IFPPXADejZrlHu7APbpB0sF78bq4= +modernc.org/sqlite v1.48.0/go.mod h1:hWjRO6Tj/5Ik8ieqxQybiEOUXy0NJFNp2tpvVpKlvig= diff --git a/internal/store/store.go b/internal/store/store.go new file mode 100644 index 0000000..4bd26f1 --- /dev/null +++ b/internal/store/store.go @@ -0,0 +1,434 @@ +package store + +import ( + "database/sql" + "fmt" + "strings" + "time" + + "somegit.dev/vikingowl/reddit-reader/internal/domain" + _ "modernc.org/sqlite" +) + +const schema = ` +CREATE TABLE IF NOT EXISTS subreddits ( + name TEXT PRIMARY KEY, + enabled INTEGER DEFAULT 1, + poll_sort TEXT DEFAULT 'new', + added_at TEXT DEFAULT (datetime('now')) +); + +CREATE TABLE IF NOT EXISTS filters ( + id INTEGER PRIMARY KEY, + subreddit TEXT REFERENCES subreddits(name) ON DELETE CASCADE, + pattern TEXT NOT NULL, + is_regex INTEGER DEFAULT 0 +); + +CREATE TABLE IF NOT EXISTS posts ( + id TEXT PRIMARY KEY, + subreddit TEXT NOT NULL, + title TEXT NOT NULL, + author TEXT, + url TEXT, + selftext TEXT, + score INTEGER, + created_utc TEXT, + fetched_at TEXT DEFAULT (datetime('now')), + relevance REAL, + summary TEXT, + read INTEGER DEFAULT 0, + starred INTEGER DEFAULT 0, + dismissed INTEGER DEFAULT 0 +); + +CREATE TABLE IF NOT EXISTS feedback ( + id INTEGER PRIMARY KEY, + post_id TEXT REFERENCES posts(id), + vote INTEGER NOT NULL, + created_at TEXT DEFAULT (datetime('now')) +); +` + +// Store is the SQLite-backed persistence layer. +type Store struct { + db *sql.DB +} + +// ListFilter controls which posts ListPosts returns. +type ListFilter struct { + Subreddit string + Unread *bool + Starred *bool + Dismissed *bool + Limit int +} + +// PostUpdate carries the fields to update on a post; nil fields are skipped. +type PostUpdate struct { + Read *bool + Starred *bool + Dismissed *bool + Relevance *float64 + Summary *string +} + +// Open opens (or creates) the SQLite database at dsn and runs migrations. +func Open(dsn string) (*Store, error) { + db, err := sql.Open("sqlite", dsn) + if err != nil { + return nil, fmt.Errorf("store.Open: %w", err) + } + db.SetMaxOpenConns(1) + + if _, err := db.Exec("PRAGMA journal_mode=WAL"); err != nil { + db.Close() + return nil, fmt.Errorf("store.Open WAL: %w", err) + } + if _, err := db.Exec("PRAGMA foreign_keys=ON"); err != nil { + db.Close() + return nil, fmt.Errorf("store.Open foreign_keys: %w", err) + } + if _, err := db.Exec(schema); err != nil { + db.Close() + return nil, fmt.Errorf("store.Open schema: %w", err) + } + + return &Store{db: db}, nil +} + +// Close closes the underlying database connection. +func (s *Store) Close() error { + return s.db.Close() +} + +// InsertPost inserts a post; silently ignores duplicates (INSERT OR IGNORE). +func (s *Store) InsertPost(p domain.Post) error { + const q = ` +INSERT OR IGNORE INTO posts + (id, subreddit, title, author, url, selftext, score, created_utc, relevance, summary) +VALUES + (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)` + + _, err := s.db.Exec(q, + p.ID, p.Subreddit, p.Title, p.Author, p.URL, p.SelfText, + p.Score, p.CreatedUTC.UTC().Format(time.RFC3339), + p.Relevance, p.Summary, + ) + if err != nil { + return fmt.Errorf("store.InsertPost: %w", err) + } + return nil +} + +// GetPost retrieves a single post by ID. +func (s *Store) GetPost(id string) (domain.Post, error) { + const q = ` +SELECT id, subreddit, title, author, url, selftext, score, + created_utc, fetched_at, relevance, summary, read, starred, dismissed +FROM posts WHERE id = ?` + + row := s.db.QueryRow(q, id) + return scanPost(row) +} + +// PostExists reports whether a post with the given ID exists. +func (s *Store) PostExists(id string) (bool, error) { + var n int + err := s.db.QueryRow(`SELECT COUNT(1) FROM posts WHERE id = ?`, id).Scan(&n) + if err != nil { + return false, fmt.Errorf("store.PostExists: %w", err) + } + return n > 0, nil +} + +// ListPosts returns posts ordered by relevance DESC, fetched_at DESC. +func (s *Store) ListPosts(f ListFilter) ([]domain.Post, error) { + where, args := buildPostWhere(f) + limit := "" + if f.Limit > 0 { + limit = fmt.Sprintf(" LIMIT %d", f.Limit) + } + q := `SELECT id, subreddit, title, author, url, selftext, score, + created_utc, fetched_at, relevance, summary, read, starred, dismissed +FROM posts` + where + ` ORDER BY COALESCE(relevance, 0) DESC, fetched_at DESC` + limit + + rows, err := s.db.Query(q, args...) + if err != nil { + return nil, fmt.Errorf("store.ListPosts: %w", err) + } + defer rows.Close() + return collectPosts(rows) +} + +// UpdatePost updates the non-nil fields in u for the post with the given ID. +func (s *Store) UpdatePost(id string, u PostUpdate) error { + setClauses, args := buildPostSetClauses(u) + if len(setClauses) == 0 { + return nil + } + args = append(args, id) + q := `UPDATE posts SET ` + strings.Join(setClauses, ", ") + ` WHERE id = ?` + if _, err := s.db.Exec(q, args...); err != nil { + return fmt.Errorf("store.UpdatePost: %w", err) + } + return nil +} + +// UnsummarizedPosts returns posts where summary IS NULL. +func (s *Store) UnsummarizedPosts() ([]domain.Post, error) { + const q = `SELECT id, subreddit, title, author, url, selftext, score, + created_utc, fetched_at, relevance, summary, read, starred, dismissed +FROM posts WHERE summary IS NULL` + + rows, err := s.db.Query(q) + if err != nil { + return nil, fmt.Errorf("store.UnsummarizedPosts: %w", err) + } + defer rows.Close() + return collectPosts(rows) +} + +// AddSubreddit inserts a subreddit (INSERT OR IGNORE). +func (s *Store) AddSubreddit(sub domain.Subreddit) error { + _, err := s.db.Exec( + `INSERT OR IGNORE INTO subreddits (name, poll_sort) VALUES (?, ?)`, + sub.Name, sub.PollSort, + ) + if err != nil { + return fmt.Errorf("store.AddSubreddit: %w", err) + } + return nil +} + +// RemoveSubreddit deletes a subreddit by name. +func (s *Store) RemoveSubreddit(name string) error { + if _, err := s.db.Exec(`DELETE FROM subreddits WHERE name = ?`, name); err != nil { + return fmt.Errorf("store.RemoveSubreddit: %w", err) + } + return nil +} + +// ListSubreddits returns all subreddits. +func (s *Store) ListSubreddits() ([]domain.Subreddit, error) { + rows, err := s.db.Query(`SELECT name, enabled, poll_sort, added_at FROM subreddits ORDER BY name`) + if err != nil { + return nil, fmt.Errorf("store.ListSubreddits: %w", err) + } + defer rows.Close() + + var subs []domain.Subreddit + for rows.Next() { + var sub domain.Subreddit + var enabled int + var addedAt string + if err := rows.Scan(&sub.Name, &enabled, &sub.PollSort, &addedAt); err != nil { + return nil, fmt.Errorf("store.ListSubreddits scan: %w", err) + } + sub.Enabled = enabled != 0 + sub.AddedAt, _ = time.Parse(time.RFC3339, addedAt) + subs = append(subs, sub) + } + return subs, rows.Err() +} + +// AddFilter inserts a filter and returns its ID. +func (s *Store) AddFilter(f domain.Filter) (int64, error) { + isRegex := 0 + if f.IsRegex { + isRegex = 1 + } + res, err := s.db.Exec( + `INSERT INTO filters (subreddit, pattern, is_regex) VALUES (?, ?, ?)`, + f.Subreddit, f.Pattern, isRegex, + ) + if err != nil { + return 0, fmt.Errorf("store.AddFilter: %w", err) + } + id, err := res.LastInsertId() + if err != nil { + return 0, fmt.Errorf("store.AddFilter LastInsertId: %w", err) + } + return id, nil +} + +// ListFilters returns all filters for a subreddit. +func (s *Store) ListFilters(subreddit string) ([]domain.Filter, error) { + rows, err := s.db.Query( + `SELECT id, subreddit, pattern, is_regex FROM filters WHERE subreddit = ?`, + subreddit, + ) + if err != nil { + return nil, fmt.Errorf("store.ListFilters: %w", err) + } + defer rows.Close() + + var filters []domain.Filter + for rows.Next() { + var f domain.Filter + var isRegex int + if err := rows.Scan(&f.ID, &f.Subreddit, &f.Pattern, &isRegex); err != nil { + return nil, fmt.Errorf("store.ListFilters scan: %w", err) + } + f.IsRegex = isRegex != 0 + filters = append(filters, f) + } + return filters, rows.Err() +} + +// RemoveFilter deletes a filter by ID. +func (s *Store) RemoveFilter(id int64) error { + if _, err := s.db.Exec(`DELETE FROM filters WHERE id = ?`, id); err != nil { + return fmt.Errorf("store.RemoveFilter: %w", err) + } + return nil +} + +// AddFeedback records a vote for a post. +func (s *Store) AddFeedback(postID string, vote int) error { + if _, err := s.db.Exec( + `INSERT INTO feedback (post_id, vote) VALUES (?, ?)`, postID, vote, + ); err != nil { + return fmt.Errorf("store.AddFeedback: %w", err) + } + return nil +} + +// RecentFeedback returns up to limit feedback rows ordered by created_at DESC. +func (s *Store) RecentFeedback(limit int) ([]domain.Feedback, error) { + rows, err := s.db.Query( + `SELECT id, post_id, vote, created_at FROM feedback ORDER BY created_at DESC LIMIT ?`, + limit, + ) + if err != nil { + return nil, fmt.Errorf("store.RecentFeedback: %w", err) + } + defer rows.Close() + + var out []domain.Feedback + for rows.Next() { + var fb domain.Feedback + var createdAt string + if err := rows.Scan(&fb.ID, &fb.PostID, &fb.Vote, &createdAt); err != nil { + return nil, fmt.Errorf("store.RecentFeedback scan: %w", err) + } + fb.CreatedAt, _ = time.Parse(time.RFC3339, createdAt) + out = append(out, fb) + } + return out, rows.Err() +} + +// --- helpers --- + +type rowScanner interface { + Scan(dest ...any) error +} + +func scanPost(row rowScanner) (domain.Post, error) { + var p domain.Post + var createdUTC, fetchedAt string + var readInt, starredInt, dismissedInt int + err := row.Scan( + &p.ID, &p.Subreddit, &p.Title, &p.Author, &p.URL, &p.SelfText, + &p.Score, &createdUTC, &fetchedAt, + &p.Relevance, &p.Summary, + &readInt, &starredInt, &dismissedInt, + ) + if err != nil { + return domain.Post{}, fmt.Errorf("store scanPost: %w", err) + } + p.CreatedUTC, _ = time.Parse(time.RFC3339, createdUTC) + p.FetchedAt, _ = time.Parse(time.RFC3339, fetchedAt) + p.Read = readInt != 0 + p.Starred = starredInt != 0 + p.Dismissed = dismissedInt != 0 + return p, nil +} + +func collectPosts(rows *sql.Rows) ([]domain.Post, error) { + var posts []domain.Post + for rows.Next() { + p, err := scanPost(rows) + if err != nil { + return nil, err + } + posts = append(posts, p) + } + return posts, rows.Err() +} + +func buildPostWhere(f ListFilter) (string, []any) { + var clauses []string + var args []any + + if f.Subreddit != "" { + clauses = append(clauses, "subreddit = ?") + args = append(args, f.Subreddit) + } + if f.Unread != nil { + if *f.Unread { + clauses = append(clauses, "read = 0") + } else { + clauses = append(clauses, "read = 1") + } + } + if f.Starred != nil { + if *f.Starred { + clauses = append(clauses, "starred = 1") + } else { + clauses = append(clauses, "starred = 0") + } + } + if f.Dismissed != nil { + if *f.Dismissed { + clauses = append(clauses, "dismissed = 1") + } else { + clauses = append(clauses, "dismissed = 0") + } + } + + if len(clauses) == 0 { + return "", args + } + return " WHERE " + strings.Join(clauses, " AND "), args +} + +func buildPostSetClauses(u PostUpdate) ([]string, []any) { + var clauses []string + var args []any + + if u.Read != nil { + clauses = append(clauses, "read = ?") + v := 0 + if *u.Read { + v = 1 + } + args = append(args, v) + } + if u.Starred != nil { + clauses = append(clauses, "starred = ?") + v := 0 + if *u.Starred { + v = 1 + } + args = append(args, v) + } + if u.Dismissed != nil { + clauses = append(clauses, "dismissed = ?") + v := 0 + if *u.Dismissed { + v = 1 + } + args = append(args, v) + } + if u.Relevance != nil { + clauses = append(clauses, "relevance = ?") + args = append(args, *u.Relevance) + } + if u.Summary != nil { + clauses = append(clauses, "summary = ?") + args = append(args, *u.Summary) + } + + return clauses, args +} diff --git a/internal/store/store_test.go b/internal/store/store_test.go new file mode 100644 index 0000000..951f49b --- /dev/null +++ b/internal/store/store_test.go @@ -0,0 +1,117 @@ +package store_test + +import ( + "testing" + "time" + + "somegit.dev/vikingowl/reddit-reader/internal/domain" + "somegit.dev/vikingowl/reddit-reader/internal/store" +) + +func newTestStore(t *testing.T) *store.Store { + t.Helper() + s, err := store.Open(":memory:") + if err != nil { + t.Fatalf("Open: %v", err) + } + t.Cleanup(func() { s.Close() }) + return s +} + +func TestInsertAndGetPost(t *testing.T) { + s := newTestStore(t) + rel := 0.85 + sum := "- point 1\n- point 2" + post := domain.Post{ + ID: "t3_abc123", Subreddit: "golang", Title: "Test Post", + Author: "testuser", URL: "https://reddit.com/r/golang/test", + SelfText: "Some text", Score: 42, + CreatedUTC: time.Date(2026, 4, 1, 12, 0, 0, 0, time.UTC), + Relevance: &rel, Summary: &sum, + } + if err := s.InsertPost(post); err != nil { + t.Fatalf("InsertPost: %v", err) + } + got, err := s.GetPost("t3_abc123") + if err != nil { + t.Fatalf("GetPost: %v", err) + } + if got.Title != "Test Post" { t.Errorf("Title = %q, want %q", got.Title, "Test Post") } + if got.Relevance == nil || *got.Relevance != 0.85 { t.Errorf("Relevance = %v, want 0.85", got.Relevance) } +} + +func TestPostExists(t *testing.T) { + s := newTestStore(t) + exists, _ := s.PostExists("t3_nonexistent") + if exists { t.Error("PostExists returned true for nonexistent post") } + s.InsertPost(domain.Post{ID: "t3_exists", Subreddit: "test", Title: "Exists", CreatedUTC: time.Now()}) + exists, _ = s.PostExists("t3_exists") + if !exists { t.Error("PostExists returned false for existing post") } +} + +func TestListPosts(t *testing.T) { + s := newTestStore(t) + rel1, rel2 := 0.9, 0.5 + s.InsertPost(domain.Post{ID: "t3_a", Subreddit: "golang", Title: "High", CreatedUTC: time.Now(), Relevance: &rel1}) + s.InsertPost(domain.Post{ID: "t3_b", Subreddit: "golang", Title: "Low", CreatedUTC: time.Now(), Relevance: &rel2}) + posts, err := s.ListPosts(store.ListFilter{}) + if err != nil { t.Fatalf("ListPosts: %v", err) } + if len(posts) != 2 { t.Fatalf("len = %d, want 2", len(posts)) } + if posts[0].Title != "High" { t.Errorf("first post = %q, want %q (ordered by relevance desc)", posts[0].Title, "High") } +} + +func TestListPostsFilterBySubreddit(t *testing.T) { + s := newTestStore(t) + s.InsertPost(domain.Post{ID: "t3_a", Subreddit: "golang", Title: "Go", CreatedUTC: time.Now()}) + s.InsertPost(domain.Post{ID: "t3_b", Subreddit: "linux", Title: "Linux", CreatedUTC: time.Now()}) + posts, _ := s.ListPosts(store.ListFilter{Subreddit: "golang"}) + if len(posts) != 1 { t.Fatalf("len = %d, want 1", len(posts)) } + if posts[0].Subreddit != "golang" { t.Errorf("Subreddit = %q, want golang", posts[0].Subreddit) } +} + +func TestUpdatePostFlags(t *testing.T) { + s := newTestStore(t) + s.InsertPost(domain.Post{ID: "t3_a", Subreddit: "test", Title: "Test", CreatedUTC: time.Now()}) + s.UpdatePost("t3_a", store.PostUpdate{Read: boolPtr(true), Starred: boolPtr(true)}) + got, _ := s.GetPost("t3_a") + if !got.Read { t.Error("Read should be true") } + if !got.Starred { t.Error("Starred should be true") } +} + +func TestSubredditCRUD(t *testing.T) { + s := newTestStore(t) + s.AddSubreddit(domain.Subreddit{Name: "golang", PollSort: "new"}) + subs, _ := s.ListSubreddits() + if len(subs) != 1 || subs[0].Name != "golang" { t.Errorf("ListSubreddits = %v", subs) } + s.RemoveSubreddit("golang") + subs, _ = s.ListSubreddits() + if len(subs) != 0 { t.Errorf("after remove = %v", subs) } +} + +func TestFilterCRUD(t *testing.T) { + s := newTestStore(t) + s.AddSubreddit(domain.Subreddit{Name: "golang", PollSort: "new"}) + id, _ := s.AddFilter(domain.Filter{Subreddit: "golang", Pattern: "generics"}) + if id == 0 { t.Error("AddFilter returned 0") } + filters, _ := s.ListFilters("golang") + if len(filters) != 1 || filters[0].Pattern != "generics" { t.Errorf("ListFilters = %v", filters) } +} + +func TestFeedback(t *testing.T) { + s := newTestStore(t) + s.InsertPost(domain.Post{ID: "t3_a", Subreddit: "test", Title: "Test", CreatedUTC: time.Now()}) + s.AddFeedback("t3_a", 1) + fb, _ := s.RecentFeedback(10) + if len(fb) != 1 || fb[0].Vote != 1 { t.Errorf("RecentFeedback = %v", fb) } +} + +func TestUnsummarizedPosts(t *testing.T) { + s := newTestStore(t) + s.InsertPost(domain.Post{ID: "t3_a", Subreddit: "test", Title: "No summary", CreatedUTC: time.Now()}) + rel := 0.8; sum := "has summary" + s.InsertPost(domain.Post{ID: "t3_b", Subreddit: "test", Title: "With summary", CreatedUTC: time.Now(), Relevance: &rel, Summary: &sum}) + posts, _ := s.UnsummarizedPosts() + if len(posts) != 1 || posts[0].ID != "t3_a" { t.Errorf("UnsummarizedPosts = %v", posts) } +} + +func boolPtr(b bool) *bool { return &b }