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:
@@ -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
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sync"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestMemoryConcurrentLastAdminDemotion(t *testing.T) {
|
||||
m := NewMemory()
|
||||
ctx := context.Background()
|
||||
a := &User{Username: "admin_a", PasswordHash: "x", Role: RoleAdmin}
|
||||
b := &User{Username: "admin_b", PasswordHash: "x", Role: RoleAdmin}
|
||||
if err := m.CreateUser(ctx, a); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := m.CreateUser(ctx, b); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
var wg sync.WaitGroup
|
||||
errs := make(chan error, 2)
|
||||
wg.Add(2)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
errs <- m.SetUserRole(ctx, a.ID, RoleUser)
|
||||
}()
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
errs <- m.SetUserRole(ctx, b.ID, RoleUser)
|
||||
}()
|
||||
wg.Wait()
|
||||
close(errs)
|
||||
|
||||
var ok, lastAdmin int
|
||||
for err := range errs {
|
||||
switch err {
|
||||
case nil:
|
||||
ok++
|
||||
case ErrLastAdmin:
|
||||
lastAdmin++
|
||||
default:
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
if ok != 1 || lastAdmin != 1 {
|
||||
t.Fatalf("want 1 success and 1 ErrLastAdmin, got ok=%d lastAdmin=%d", ok, lastAdmin)
|
||||
}
|
||||
n, err := m.CountAdmins(ctx)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if n != 1 {
|
||||
t.Fatalf("admins remaining = %d, want 1", n)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
)
|
||||
|
||||
// Postgres implements Store against a sqlc-backed database.
|
||||
type Postgres struct {
|
||||
db *sql.DB
|
||||
}
|
||||
|
||||
// NewPostgres wraps db as a Store.
|
||||
func NewPostgres(db *sql.DB) *Postgres {
|
||||
return &Postgres{db: db}
|
||||
}
|
||||
|
||||
func (p *Postgres) CreateUser(ctx context.Context, u *User) error {
|
||||
u.db = p.db
|
||||
return u.Create(ctx)
|
||||
}
|
||||
|
||||
func (p *Postgres) UserByID(ctx context.Context, id string) (*User, error) {
|
||||
return UserByID(ctx, p.db, id)
|
||||
}
|
||||
|
||||
func (p *Postgres) UserByUsername(ctx context.Context, username string) (*User, error) {
|
||||
return UserByUsername(ctx, p.db, username)
|
||||
}
|
||||
|
||||
func (p *Postgres) ListUsers(ctx context.Context) ([]User, error) {
|
||||
return ListUsers(ctx, p.db)
|
||||
}
|
||||
|
||||
func (p *Postgres) CountAdmins(ctx context.Context) (int, error) {
|
||||
return CountAdmins(ctx, p.db)
|
||||
}
|
||||
|
||||
func (p *Postgres) SetUserRole(ctx context.Context, id string, role Role) error {
|
||||
u := &User{ID: id, db: p.db}
|
||||
return u.SetRole(ctx, role)
|
||||
}
|
||||
|
||||
func (p *Postgres) SaveUserProfile(ctx context.Context, u *User) error {
|
||||
u.db = p.db
|
||||
return u.SaveProfile(ctx)
|
||||
}
|
||||
|
||||
func (p *Postgres) CreateQuestion(ctx context.Context, q *RankedQuestion) error {
|
||||
q.db = p.db
|
||||
return q.Create(ctx)
|
||||
}
|
||||
|
||||
func (p *Postgres) GetQuestion(ctx context.Context, id, viewerID string) (*RankedQuestion, error) {
|
||||
return GetQuestion(ctx, p.db, id, viewerID)
|
||||
}
|
||||
|
||||
func (p *Postgres) ListHunt(ctx context.Context, huntDate, viewerID string) ([]RankedQuestion, error) {
|
||||
return ListHunt(ctx, p.db, huntDate, viewerID)
|
||||
}
|
||||
|
||||
func (p *Postgres) ListQuestionsByAuthor(ctx context.Context, authorID string) ([]RankedQuestion, error) {
|
||||
return ListQuestionsByAuthor(ctx, p.db, authorID)
|
||||
}
|
||||
|
||||
func (p *Postgres) ListQuestionsAnsweredBy(ctx context.Context, adminID string) ([]RankedQuestion, error) {
|
||||
return ListQuestionsAnsweredBy(ctx, p.db, adminID)
|
||||
}
|
||||
|
||||
func (p *Postgres) HideQuestion(ctx context.Context, id string) error {
|
||||
q := &RankedQuestion{ID: id, db: p.db}
|
||||
return q.Hide(ctx)
|
||||
}
|
||||
|
||||
func (p *Postgres) GetAnswer(ctx context.Context, questionID string) (*Answer, error) {
|
||||
return GetAnswer(ctx, p.db, questionID)
|
||||
}
|
||||
|
||||
func (p *Postgres) UpsertAnswer(ctx context.Context, a *Answer) error {
|
||||
a.db = p.db
|
||||
return a.Upsert(ctx)
|
||||
}
|
||||
|
||||
func (p *Postgres) Vote(ctx context.Context, userID, questionID string, value int) error {
|
||||
return Vote(ctx, p.db, userID, questionID, value)
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
package store
|
||||
|
||||
import "context"
|
||||
|
||||
// Store is the application persistence API used by the web layer.
|
||||
type Store interface {
|
||||
CreateUser(ctx context.Context, u *User) error
|
||||
UserByID(ctx context.Context, id string) (*User, error)
|
||||
UserByUsername(ctx context.Context, username string) (*User, error)
|
||||
ListUsers(ctx context.Context) ([]User, error)
|
||||
CountAdmins(ctx context.Context) (int, error)
|
||||
SetUserRole(ctx context.Context, id string, role Role) error
|
||||
SaveUserProfile(ctx context.Context, u *User) error
|
||||
|
||||
CreateQuestion(ctx context.Context, q *RankedQuestion) error
|
||||
GetQuestion(ctx context.Context, id, viewerID string) (*RankedQuestion, error)
|
||||
ListHunt(ctx context.Context, huntDate, viewerID string) ([]RankedQuestion, error)
|
||||
ListQuestionsByAuthor(ctx context.Context, authorID string) ([]RankedQuestion, error)
|
||||
ListQuestionsAnsweredBy(ctx context.Context, adminID string) ([]RankedQuestion, error)
|
||||
HideQuestion(ctx context.Context, id string) error
|
||||
|
||||
GetAnswer(ctx context.Context, questionID string) (*Answer, error)
|
||||
UpsertAnswer(ctx context.Context, a *Answer) error
|
||||
|
||||
Vote(ctx context.Context, userID, questionID string, value int) error
|
||||
}
|
||||
Reference in New Issue
Block a user