feat(store): SQLite store with schema, CRUD, and feedback
This commit is contained in:
10
go.mod
10
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
|
||||
)
|
||||
|
||||
21
go.sum
21
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=
|
||||
|
||||
434
internal/store/store.go
Normal file
434
internal/store/store.go
Normal 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
|
||||
}
|
||||
117
internal/store/store_test.go
Normal file
117
internal/store/store_test.go
Normal 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 }
|
||||
Reference in New Issue
Block a user