feat(store): SQLite store with schema, CRUD, and feedback

This commit is contained in:
2026-04-03 11:25:37 +02:00
parent ca06804248
commit a8c6557d57
4 changed files with 582 additions and 0 deletions

10
go.mod
View File

@@ -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
)

21
go.sum
View File

@@ -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=

434
internal/store/store.go Normal file
View File

@@ -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
}

View File

@@ -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 }