Make web tests database-free and finish review hardening.

Introduce a Store interface with Postgres and in-memory backends, cover mutations/CSRF/session rotation without Postgres, bound avatar decode dimensions, add truncate/prepareAvatar unit tests, and run go test -race in CI.
This commit is contained in:
2026-08-22 07:36:13 -07:00
parent afd2476f3c
commit f4cec32afb
12 changed files with 945 additions and 223 deletions
+327
View File
@@ -0,0 +1,327 @@
package store
import (
"context"
"database/sql"
"fmt"
"sort"
"strings"
"sync"
"time"
"github.com/google/uuid"
"plumber/internal/pacific"
)
// Memory is an in-process Store for tests.
type Memory struct {
mu sync.Mutex
users map[string]*User // id -> user
byName map[string]string // username -> id
questions map[string]*RankedQuestion // id -> question
answers map[string]*Answer // questionID -> answer
votes map[string]map[string]int // questionID -> userID -> value
}
// NewMemory returns an empty Memory store.
func NewMemory() *Memory {
return &Memory{
users: map[string]*User{},
byName: map[string]string{},
questions: map[string]*RankedQuestion{},
answers: map[string]*Answer{},
votes: map[string]map[string]int{},
}
}
func (m *Memory) CreateUser(_ context.Context, u *User) error {
m.mu.Lock()
defer m.mu.Unlock()
if u.Role != RoleUser && u.Role != RoleAdmin {
return fmt.Errorf("invalid role")
}
u.Username = NormalizeUsername(u.Username)
if _, ok := m.byName[u.Username]; ok {
return fmt.Errorf("username taken")
}
if u.ID == "" {
u.ID = uuid.NewString()
}
if u.Name == "" {
u.Name = u.Username
}
if u.CreatedAt == "" {
u.CreatedAt = time.Now().UTC().Format(time.RFC3339)
}
cp := *u
cp.db = nil
m.users[cp.ID] = &cp
m.byName[cp.Username] = cp.ID
*u = cp
return nil
}
func (m *Memory) UserByID(_ context.Context, id string) (*User, error) {
m.mu.Lock()
defer m.mu.Unlock()
u, ok := m.users[id]
if !ok {
return nil, sql.ErrNoRows
}
cp := *u
return &cp, nil
}
func (m *Memory) UserByUsername(_ context.Context, username string) (*User, error) {
m.mu.Lock()
defer m.mu.Unlock()
id, ok := m.byName[NormalizeUsername(username)]
if !ok {
return nil, sql.ErrNoRows
}
cp := *m.users[id]
return &cp, nil
}
func (m *Memory) ListUsers(_ context.Context) ([]User, error) {
m.mu.Lock()
defer m.mu.Unlock()
out := make([]User, 0, len(m.users))
for _, u := range m.users {
out = append(out, *u)
}
sort.Slice(out, func(i, j int) bool { return out[i].CreatedAt < out[j].CreatedAt })
return out, nil
}
func (m *Memory) CountAdmins(_ context.Context) (int, error) {
m.mu.Lock()
defer m.mu.Unlock()
n := 0
for _, u := range m.users {
if u.Role == RoleAdmin {
n++
}
}
return n, nil
}
// SetUserRole serializes demotions under m.mu (same critical section as count).
func (m *Memory) SetUserRole(_ context.Context, id string, role Role) error {
m.mu.Lock()
defer m.mu.Unlock()
if role != RoleUser && role != RoleAdmin {
return fmt.Errorf("invalid role")
}
u, ok := m.users[id]
if !ok {
return sql.ErrNoRows
}
if u.Role == RoleAdmin && role == RoleUser {
n := 0
for _, x := range m.users {
if x.Role == RoleAdmin {
n++
}
}
if n <= 1 {
return ErrLastAdmin
}
}
u.Role = role
return nil
}
func (m *Memory) SaveUserProfile(_ context.Context, u *User) error {
m.mu.Lock()
defer m.mu.Unlock()
cur, ok := m.users[u.ID]
if !ok {
return sql.ErrNoRows
}
cur.State = strings.TrimSpace(u.State)
if u.AvatarURL != "" {
cur.AvatarURL = u.AvatarURL
}
u.State = cur.State
u.AvatarURL = cur.AvatarURL
return nil
}
func (m *Memory) CreateQuestion(_ context.Context, q *RankedQuestion) error {
m.mu.Lock()
defer m.mu.Unlock()
q.Title = strings.TrimSpace(q.Title)
q.Body = strings.TrimSpace(q.Body)
q.City = strings.TrimSpace(q.City)
if q.ID == "" {
q.ID = uuid.NewString()
}
if q.HuntDate == "" {
q.HuntDate = pacific.Today()
}
if q.CreatedAt == "" {
q.CreatedAt = time.Now().UTC().Format(time.RFC3339)
}
author, ok := m.users[q.AuthorID]
if !ok {
return fmt.Errorf("unknown author")
}
cp := *q
cp.AuthorName = author.Name
cp.db = nil
m.questions[cp.ID] = &cp
*q = cp
return nil
}
func (m *Memory) annotate(q *RankedQuestion, viewerID string) RankedQuestion {
out := *q
score := 0
userVote := 0
if votes, ok := m.votes[q.ID]; ok {
for uid, v := range votes {
score += v
if uid == viewerID {
userVote = v
}
}
}
_, answered := m.answers[q.ID]
out.Score = score
out.Answered = answered
out.UserVote = userVote
out.db = nil
return out
}
func (m *Memory) GetQuestion(_ context.Context, id, viewerID string) (*RankedQuestion, error) {
m.mu.Lock()
defer m.mu.Unlock()
q, ok := m.questions[id]
if !ok {
return nil, sql.ErrNoRows
}
out := m.annotate(q, viewerID)
return &out, nil
}
func (m *Memory) ListHunt(_ context.Context, huntDate, viewerID string) ([]RankedQuestion, error) {
m.mu.Lock()
defer m.mu.Unlock()
out := make([]RankedQuestion, 0)
for _, q := range m.questions {
if q.HuntDate != huntDate || q.Hidden {
continue
}
out = append(out, m.annotate(q, viewerID))
}
sort.Slice(out, func(i, j int) bool {
if out[i].Score != out[j].Score {
return out[i].Score > out[j].Score
}
return out[i].CreatedAt < out[j].CreatedAt
})
return out, nil
}
func (m *Memory) ListQuestionsByAuthor(_ context.Context, authorID string) ([]RankedQuestion, error) {
m.mu.Lock()
defer m.mu.Unlock()
out := make([]RankedQuestion, 0)
for _, q := range m.questions {
if q.AuthorID != authorID || q.Hidden {
continue
}
out = append(out, m.annotate(q, ""))
}
sort.Slice(out, func(i, j int) bool { return out[i].CreatedAt > out[j].CreatedAt })
return out, nil
}
func (m *Memory) ListQuestionsAnsweredBy(_ context.Context, adminID string) ([]RankedQuestion, error) {
m.mu.Lock()
defer m.mu.Unlock()
out := make([]RankedQuestion, 0)
for qid, a := range m.answers {
if a.AuthorID != adminID {
continue
}
q, ok := m.questions[qid]
if !ok || q.Hidden {
continue
}
rq := m.annotate(q, "")
rq.Answered = true
out = append(out, rq)
}
sort.Slice(out, func(i, j int) bool { return out[i].CreatedAt > out[j].CreatedAt })
return out, nil
}
func (m *Memory) HideQuestion(_ context.Context, id string) error {
m.mu.Lock()
defer m.mu.Unlock()
q, ok := m.questions[id]
if !ok {
return sql.ErrNoRows
}
q.Hidden = true
return nil
}
func (m *Memory) GetAnswer(_ context.Context, questionID string) (*Answer, error) {
m.mu.Lock()
defer m.mu.Unlock()
a, ok := m.answers[questionID]
if !ok {
return nil, sql.ErrNoRows
}
cp := *a
if u, ok := m.users[a.AuthorID]; ok {
cp.AuthorName = u.Name
}
return &cp, nil
}
func (m *Memory) UpsertAnswer(_ context.Context, a *Answer) error {
m.mu.Lock()
defer m.mu.Unlock()
if _, ok := m.questions[a.QuestionID]; !ok {
return fmt.Errorf("unknown question")
}
a.Body = strings.TrimSpace(a.Body)
now := time.Now().UTC().Format(time.RFC3339)
if existing, ok := m.answers[a.QuestionID]; ok {
a.CreatedAt = existing.CreatedAt
} else if a.CreatedAt == "" {
a.CreatedAt = now
}
a.UpdatedAt = now
cp := *a
cp.db = nil
m.answers[a.QuestionID] = &cp
*a = cp
return nil
}
func (m *Memory) Vote(_ context.Context, userID, questionID string, value int) error {
m.mu.Lock()
defer m.mu.Unlock()
if value != 1 && value != -1 {
return fmt.Errorf("invalid vote")
}
if _, ok := m.questions[questionID]; !ok {
return sql.ErrNoRows
}
if m.votes[questionID] == nil {
m.votes[questionID] = map[string]int{}
}
if cur, ok := m.votes[questionID][userID]; ok && cur == value {
delete(m.votes[questionID], userID)
return nil
}
m.votes[questionID][userID] = value
return nil
}