Add unified post storage (#3)
Adds one post creation path for roots and replies, recursive thread loading, body-only updates, root listings, and root-only voting across PostgreSQL and the in-memory store. Co-authored-by: codegirl-007 <s.raide@gmail.com>
This commit was merged in pull request #3.
This commit is contained in:
@@ -0,0 +1,252 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgconn"
|
||||
)
|
||||
|
||||
func TestMapPostCreateError(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
for _, code := range []string{"23503", "23505", "23514"} {
|
||||
err := mapPostCreateError(&pgconn.PgError{Code: code})
|
||||
if !errors.Is(err, ErrInvalidPost) {
|
||||
t.Errorf("code %s error = %v, want ErrInvalidPost", code, err)
|
||||
}
|
||||
}
|
||||
original := &pgconn.PgError{Code: "08006"}
|
||||
if err := mapPostCreateError(original); !errors.Is(err, original) {
|
||||
t.Errorf("unexpected database error was replaced: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMemoryPostLifecycle(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
ctx := context.Background()
|
||||
mem := NewMemory()
|
||||
homeowner := &User{Username: "homeowner", PasswordHash: "hash", Role: RoleUser}
|
||||
plumber := &User{Username: "plumber", PasswordHash: "hash", Role: RoleAdmin}
|
||||
voter := &User{Username: "voter", PasswordHash: "hash", Role: RoleUser}
|
||||
for _, user := range []*User{homeowner, plumber, voter} {
|
||||
if err := mem.CreateUser(ctx, user); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
root := &Post{
|
||||
ID: "root",
|
||||
AuthorID: homeowner.ID,
|
||||
Title: "Leaky sink",
|
||||
Body: "It drips.",
|
||||
City: "Oakland",
|
||||
PostDate: "2026-08-26",
|
||||
CreatedAt: "2026-08-26T08:00:00Z",
|
||||
}
|
||||
if err := mem.CreatePost(ctx, root); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if root.PostState != PostStateVisible {
|
||||
t.Fatalf("default post state = %q, want visible", root.PostState)
|
||||
}
|
||||
|
||||
rootID := root.ID
|
||||
later := &Post{
|
||||
ID: "later",
|
||||
ParentID: &rootID,
|
||||
AuthorID: plumber.ID,
|
||||
Body: "Is it a single-handle faucet?",
|
||||
CreatedAt: "2026-08-26T09:00:00Z",
|
||||
}
|
||||
earlier := &Post{
|
||||
ID: "earlier",
|
||||
ParentID: &rootID,
|
||||
AuthorID: plumber.ID,
|
||||
Body: "Can you share the model number?",
|
||||
CreatedAt: "2026-08-26T08:30:00Z",
|
||||
}
|
||||
if err := mem.CreatePost(ctx, later); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := mem.CreatePost(ctx, earlier); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
laterID := later.ID
|
||||
nested := &Post{
|
||||
ID: "nested",
|
||||
ParentID: &laterID,
|
||||
AuthorID: homeowner.ID,
|
||||
Body: "Yes, it is.",
|
||||
CreatedAt: "2026-08-26T09:30:00Z",
|
||||
}
|
||||
if err := mem.CreatePost(ctx, nested); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
thread, err := mem.GetPostThread(ctx, root.ID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if thread.AuthorName != homeowner.Name || thread.AuthorRole != RoleUser {
|
||||
t.Fatalf("root author = %q %q", thread.AuthorName, thread.AuthorRole)
|
||||
}
|
||||
if len(thread.Replies) != 2 ||
|
||||
thread.Replies[0].ID != earlier.ID ||
|
||||
thread.Replies[1].ID != later.ID {
|
||||
t.Fatalf("root replies are not oldest-first: %+v", thread.Replies)
|
||||
}
|
||||
if len(thread.Replies[1].Replies) != 1 || thread.Replies[1].Replies[0].ID != nested.ID {
|
||||
t.Fatalf("nested reply missing: %+v", thread.Replies[1].Replies)
|
||||
}
|
||||
|
||||
otherParent := earlier.ID
|
||||
nested.ParentID = &otherParent
|
||||
nested.AuthorID = voter.ID
|
||||
nested.Body = "Yes—one handle."
|
||||
if err := mem.UpdatePost(ctx, nested); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
saved, err := mem.GetPost(ctx, nested.ID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if saved.ParentID == nil || *saved.ParentID != later.ID {
|
||||
t.Fatalf("update changed parent to %+v", saved.ParentID)
|
||||
}
|
||||
if saved.AuthorID != homeowner.ID {
|
||||
t.Fatalf("update changed author to %q", saved.AuthorID)
|
||||
}
|
||||
if saved.Body != "Yes—one handle." || saved.UpdatedAt == saved.CreatedAt {
|
||||
t.Fatalf("body update not applied: %+v", saved)
|
||||
}
|
||||
|
||||
if err := mem.VotePost(ctx, voter.ID, root.ID, 1); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
roots, err := mem.ListRootPosts(ctx, root.PostDate, voter.ID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(roots) != 1 || roots[0].ID != root.ID {
|
||||
t.Fatalf("root list = %+v", roots)
|
||||
}
|
||||
if roots[0].Score != 1 || roots[0].UserVote != 1 || !roots[0].Answered {
|
||||
t.Fatalf("root annotations = %+v", roots[0])
|
||||
}
|
||||
if err := mem.VotePost(ctx, voter.ID, later.ID, 1); !errors.Is(err, ErrPostNotVotable) {
|
||||
t.Fatalf("reply vote error = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMemoryPostValidation(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
ctx := context.Background()
|
||||
mem := NewMemory()
|
||||
homeowner := &User{Username: "homeowner", PasswordHash: "hash", Role: RoleUser}
|
||||
if err := mem.CreateUser(ctx, homeowner); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
post *Post
|
||||
}{
|
||||
{
|
||||
name: "root without title",
|
||||
post: &Post{AuthorID: homeowner.ID, Body: "Body"},
|
||||
},
|
||||
{
|
||||
name: "empty parent",
|
||||
post: &Post{ParentID: ptr(""), AuthorID: homeowner.ID, Body: "Body"},
|
||||
},
|
||||
{
|
||||
name: "missing parent",
|
||||
post: &Post{ParentID: ptr("missing"), AuthorID: homeowner.ID, Body: "Body"},
|
||||
},
|
||||
{
|
||||
name: "reply with root fields",
|
||||
post: &Post{
|
||||
ParentID: ptr("missing"),
|
||||
AuthorID: homeowner.ID,
|
||||
Title: "Not allowed",
|
||||
Body: "Body",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "invalid post state",
|
||||
post: &Post{
|
||||
AuthorID: homeowner.ID,
|
||||
Title: "Invalid state",
|
||||
Body: "Body",
|
||||
PostState: PostState("archived"),
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "reply with non-visible state",
|
||||
post: &Post{
|
||||
ParentID: ptr("missing"),
|
||||
AuthorID: homeowner.ID,
|
||||
Body: "Body",
|
||||
PostState: PostStateLocked,
|
||||
},
|
||||
},
|
||||
}
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
if err := mem.CreatePost(ctx, test.post); !errors.Is(err, ErrInvalidPost) {
|
||||
t.Fatalf("error = %v, want ErrInvalidPost", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
hidden := &Post{
|
||||
ID: "hidden",
|
||||
AuthorID: homeowner.ID,
|
||||
Title: "Hidden",
|
||||
Body: "Body",
|
||||
PostDate: "2026-08-26",
|
||||
PostState: PostStateHidden,
|
||||
}
|
||||
if err := mem.CreatePost(ctx, hidden); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := mem.VotePost(ctx, homeowner.ID, hidden.ID, 1); !errors.Is(err, ErrPostNotVotable) {
|
||||
t.Fatalf("hidden root vote error = %v", err)
|
||||
}
|
||||
|
||||
locked := &Post{
|
||||
ID: "locked",
|
||||
AuthorID: homeowner.ID,
|
||||
Title: "Locked",
|
||||
Body: "Body",
|
||||
PostDate: "2026-08-26",
|
||||
PostState: PostStateLocked,
|
||||
}
|
||||
if err := mem.CreatePost(ctx, locked); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := mem.VotePost(ctx, homeowner.ID, locked.ID, 1); err != nil {
|
||||
t.Fatalf("locked root should remain votable: %v", err)
|
||||
}
|
||||
roots, err := mem.ListRootPosts(ctx, locked.PostDate, homeowner.ID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(roots) != 1 || roots[0].ID != locked.ID || roots[0].PostState != PostStateLocked {
|
||||
t.Fatalf("locked root should remain visible: %+v", roots)
|
||||
}
|
||||
|
||||
if _, err := mem.GetPostThread(ctx, "missing"); !errors.Is(err, sql.ErrNoRows) {
|
||||
t.Fatalf("missing thread error = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func ptr(value string) *string {
|
||||
return &value
|
||||
}
|
||||
Reference in New Issue
Block a user