Files
plumber/internal/store/post_test.go
T
codegirl007 1840a662d9
CI / test (pull_request) Successful in 6m17s
Add post image storage
2026-08-27 23:46:06 -07:00

360 lines
9.9 KiB
Go

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)
}
byAuthor, err := mem.ListRootPostsByAuthor(ctx, homeowner.ID)
if err != nil || len(byAuthor) != 1 || byAuthor[0].ID != root.ID {
t.Fatalf("roots by author = %+v, %v", byAuthor, err)
}
answeredBy, err := mem.ListRootPostsAnsweredBy(ctx, plumber.ID)
if err != nil || len(answeredBy) != 1 || answeredBy[0].ID != root.ID {
t.Fatalf("roots answered by admin = %+v, %v", answeredBy, err)
}
if err := mem.SetRootPostState(ctx, later.ID, PostStateHidden); !errors.Is(err, sql.ErrNoRows) {
t.Fatalf("reply state error = %v, want sql.ErrNoRows", err)
}
if err := mem.SetRootPostState(ctx, root.ID, PostStateHidden); err != nil {
t.Fatal(err)
}
if roots, err := mem.ListRootPostsByAuthor(ctx, homeowner.ID); err != nil || len(roots) != 0 {
t.Fatalf("hidden author roots = %+v, %v", roots, err)
}
if roots, err := mem.ListRootPostsAnsweredBy(ctx, plumber.ID); err != nil || len(roots) != 0 {
t.Fatalf("hidden answered roots = %+v, %v", roots, 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 TestMemoryPostImages(t *testing.T) {
t.Parallel()
ctx := context.Background()
mem := NewMemory()
homeowner := &User{Username: "images", PasswordHash: "hash", Role: RoleUser}
if err := mem.CreateUser(ctx, homeowner); err != nil {
t.Fatal(err)
}
root := &Post{
ID: "image-root",
AuthorID: homeowner.ID,
Title: "What is leaking?",
Body: "Here are two photos.",
Images: []PostImage{
{ID: "image-a", ObjectKey: "posts/image-root/image-a.jpg", PublicURL: "https://cdn.example/image-a.jpg", Description: " Supply valve ", Position: 3, Width: 1200, Height: 900},
{ID: "image-b", ObjectKey: "posts/image-root/image-b.png", PublicURL: "https://cdn.example/image-b.png", Width: 900, Height: 1200},
},
}
if err := mem.CreatePost(ctx, root); err != nil {
t.Fatal(err)
}
if root.Images[0].Position != 0 ||
root.Images[1].Position != 1 ||
root.Images[0].PostID != root.ID ||
root.Images[0].Description != "Supply valve" {
t.Fatalf("created images were not normalized: %+v", root.Images)
}
loaded, err := mem.GetPost(ctx, root.ID)
if err != nil {
t.Fatal(err)
}
loaded.Images[0].Description = "mutated outside store"
reloaded, err := mem.GetPost(ctx, root.ID)
if err != nil {
t.Fatal(err)
}
if reloaded.Images[0].Description != "Supply valve" {
t.Fatalf("stored image mutated through clone: %+v", reloaded.Images[0])
}
reloaded.Body = "Updated photos."
reloaded.Images = []PostImage{
reloaded.Images[1],
{ID: "image-c", ObjectKey: "posts/image-root/image-c.jpg", PublicURL: "https://cdn.example/image-c.jpg", Description: "Trap connection", Width: 1600, Height: 1000},
}
if err := mem.UpdatePost(ctx, reloaded); err != nil {
t.Fatal(err)
}
updated, err := mem.GetPost(ctx, root.ID)
if err != nil {
t.Fatal(err)
}
if len(updated.Images) != 2 ||
updated.Images[0].ID != "image-b" ||
updated.Images[0].Position != 0 ||
updated.Images[1].ID != "image-c" ||
updated.Images[1].Position != 1 {
t.Fatalf("updated images = %+v", updated.Images)
}
tooMany := &Post{
AuthorID: homeowner.ID,
Title: "Too many",
Body: "Five photos.",
Images: validPostImages(5),
}
if err := mem.CreatePost(ctx, tooMany); !errors.Is(err, ErrInvalidPost) {
t.Fatalf("five-image create error = %v, want ErrInvalidPost", err)
}
}
func validPostImages(count int) []PostImage {
images := make([]PostImage, count)
for i := range images {
images[i] = PostImage{
ID: "image-" + string(rune('a'+i)),
ObjectKey: "posts/key-" + string(rune('a'+i)) + ".jpg",
PublicURL: "https://cdn.example/" + string(rune('a'+i)) + ".jpg",
Width: 100,
Height: 100,
}
}
return images
}
func ptr(value string) *string {
return &value
}