Files
webstory/pkg/database/story/story_test.go
T

652 lines
16 KiB
Go
Raw Normal View History

2026-03-25 21:56:27 -07:00
package story
import (
2026-03-29 15:24:09 -07:00
"context"
"database/sql"
2026-03-25 21:56:27 -07:00
"fmt"
"strings"
"testing"
"time"
v1 "git.tipsy.codes/charles/webstory/pkg/api/webstory/v1"
2026-03-29 15:24:09 -07:00
"git.tipsy.codes/charles/webstory/pkg/database/schema"
embeddedpostgres "github.com/fergusstrange/embedded-postgres"
"github.com/lib/pq"
2026-03-25 21:56:27 -07:00
"google.golang.org/protobuf/types/known/timestamppb"
)
func TestStoryTranslator_FromAPI(t *testing.T) {
now := time.Now()
tests := []struct {
name string
apiStory *v1.Story
expectErr bool
errMsg string
expectID int64
checkFunc func(*DBStory) error
}{
{
name: "nil story returns error",
apiStory: nil,
expectErr: true,
errMsg: "api story cannot be nil",
},
{
name: "minimal valid story creates translator correctly",
apiStory: &v1.Story{
StoryId: "abc123",
Title: "Test Story",
},
expectErr: false,
expectID: 0,
checkFunc: func(s *DBStory) error {
if s.StoryID != "abc123" {
return fmt.Errorf("expected StoryID abc123, got %s", s.StoryID)
}
if s.Title != "Test Story" {
return fmt.Errorf("expected Title Test Story, got %s", s.Title)
}
return nil
},
},
{
name: "story with all fields converts correctly",
apiStory: &v1.Story{
Name: "stories/my-story-1",
StoryId: "my-story-1",
Title: "My Story Title",
Content: "This is the story content",
Description: "A wonderful story",
Labels: []string{"fiction", "adventure"},
CreateTime: timestamppb.New(now),
UpdateTime: timestamppb.New(now),
Etag: "etag-123",
},
expectErr: false,
expectID: 0,
checkFunc: func(s *DBStory) error {
if s.StoryID != "my-story-1" {
return fmt.Errorf("expected StoryID my-story-1, got %s", s.StoryID)
}
if s.Title != "My Story Title" {
return fmt.Errorf("expected Title My Story Title, got %s", s.Title)
}
if s.Content != "This is the story content" {
return fmt.Errorf("expected Content, got %s", s.Content)
}
if s.Description != "A wonderful story" {
return fmt.Errorf("expected Description, got %s", s.Description)
}
if len(s.Labels) != 2 || s.Labels[0] != "fiction" || s.Labels[1] != "adventure" {
return fmt.Errorf("expected Labels [fiction adventure], got %v", s.Labels)
}
if s.Etag != "etag-123" {
return fmt.Errorf("expected Etag etag-123, got %s", s.Etag)
}
return nil
},
},
{
name: "nil labels converted to empty slice",
apiStory: &v1.Story{
StoryId: "test1",
Title: "Test",
Labels: nil,
},
expectErr: false,
expectID: 0,
checkFunc: func(s *DBStory) error {
if s.Labels == nil {
return fmt.Errorf("expected non-nil Labels slice, got nil")
}
return nil
},
},
{
name: "empty labels converted to empty slice",
apiStory: &v1.Story{
StoryId: "test2",
Title: "Test",
Labels: []string{},
},
expectErr: false,
expectID: 0,
checkFunc: func(s *DBStory) error {
if s.Labels == nil {
return fmt.Errorf("expected non-nil Labels slice, got nil")
}
if len(s.Labels) != 0 {
return fmt.Errorf("expected empty Labels slice, got %v", s.Labels)
}
return nil
},
},
{
name: "missing title returns error",
apiStory: &v1.Story{
StoryId: "test3",
},
expectErr: true,
errMsg: "title is required",
},
{
name: "invalid story_id short returns error",
apiStory: &v1.Story{
StoryId: "ab",
Title: "Test",
},
expectErr: true,
errMsg: "invalid story_id format",
},
{
name: "invalid story_id uppercase returns error",
apiStory: &v1.Story{
StoryId: "ABC123",
Title: "Test",
},
expectErr: true,
errMsg: "invalid story_id format",
},
{
name: "invalid story_id starts with number returns error",
apiStory: &v1.Story{
StoryId: "1abc",
Title: "Test",
},
expectErr: true,
errMsg: "invalid story_id format",
},
{
name: "valid story_id with hyphens and numbers",
apiStory: &v1.Story{
StoryId: "my-awesome-story-123",
Title: "Test",
},
expectErr: false,
expectID: 0,
checkFunc: func(s *DBStory) error {
if s.StoryID != "my-awesome-story-123" {
return fmt.Errorf("expected StoryID my-awesome-story-123, got %s", s.StoryID)
}
return nil
},
},
{
name: "story_id auto-generated from name if not provided",
apiStory: &v1.Story{
StoryId: "auto-gen",
Title: "Test",
Name: "stories/auto-gen",
},
expectErr: false,
expectID: 0,
checkFunc: func(s *DBStory) error {
if s.StoryID != "auto-gen" {
return fmt.Errorf("expected StoryID auto-gen, got %s", s.StoryID)
}
return nil
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
2026-03-29 15:24:09 -07:00
trans := NewStoryTranslator()
2026-03-25 21:56:27 -07:00
result, err := trans.FromAPI(tt.apiStory)
if tt.expectErr {
if err == nil {
t.Fatalf("expected error but got nil")
}
if tt.errMsg != "" && !strings.Contains(err.Error(), tt.errMsg) {
t.Errorf("expected error containing '%s' but got '%s'", tt.errMsg, err.Error())
}
return
}
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if result == nil {
t.Fatalf("expected non-nil result")
}
if result.ID != tt.expectID {
t.Errorf("expected ID %d but got %d", tt.expectID, result.ID)
}
if tt.checkFunc != nil {
if err := tt.checkFunc(result); err != nil {
t.Errorf("check failed: %v", err)
}
}
})
}
}
func TestStoryTranslator_ToAPI(t *testing.T) {
now := time.Now()
tests := []struct {
name string
dbStory *DBStory
expectErr bool
checkFunc func(*v1.Story) error
}{
{
name: "nil dbStory returns nil",
dbStory: nil,
expectErr: false,
checkFunc: func(s *v1.Story) error {
if s != nil {
2026-03-29 15:24:09 -07:00
return fmt.Errorf("expected nil, got %v", s)
2026-03-25 21:56:27 -07:00
}
return nil
},
},
{
name: "minimal story converts correctly",
dbStory: &DBStory{
ID: 1,
StoryID: "test-story",
Title: "Test Title",
CreatedAt: now,
UpdatedAt: now,
},
expectErr: false,
checkFunc: func(s *v1.Story) error {
if s.GetName() != "stories/test-story" {
return fmt.Errorf("expected Name stories/test-story, got %s", s.GetName())
}
if s.GetStoryId() != "test-story" {
return fmt.Errorf("expected StoryId test-story, got %s", s.GetStoryId())
}
if s.GetTitle() != "Test Title" {
return fmt.Errorf("expected Title Test Title, got %s", s.GetTitle())
}
return nil
},
},
{
name: "story with all fields converts correctly",
dbStory: &DBStory{
ID: 1,
StoryID: "full-story",
Title: "Full Title",
Content: "Full Content",
Description: "Full Description",
Labels: []string{"label1", "label2"},
CreatedAt: now,
UpdatedAt: now,
Etag: "test-etag",
},
expectErr: false,
checkFunc: func(s *v1.Story) error {
if s.GetName() != "stories/full-story" {
return fmt.Errorf("expected Name stories/full-story, got %s", s.GetName())
}
if s.Etag != "test-etag" {
return fmt.Errorf("expected Etag test-etag, got %s", s.Etag)
}
if len(s.GetLabels()) != 2 {
return fmt.Errorf("expected 2 labels, got %d", len(s.GetLabels()))
}
return nil
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
2026-03-29 15:24:09 -07:00
trans := NewStoryTranslator()
2026-03-25 21:56:27 -07:00
result := trans.ToAPI(tt.dbStory)
if tt.expectErr {
if result != nil {
t.Fatalf("expected nil result but got %v", result)
}
return
}
if tt.checkFunc != nil {
if err := tt.checkFunc(result); err != nil {
t.Errorf("check failed: %v", err)
}
}
})
}
}
func TestDBStory_Validate(t *testing.T) {
tests := []struct {
name string
story *DBStory
expectErr bool
errMsg string
}{
{
name: "empty story_id returns error",
story: &DBStory{
StoryID: "",
Title: "Test",
},
expectErr: true,
errMsg: "story_id is required",
},
{
name: "invalid story_id format returns error",
story: &DBStory{
StoryID: "INVALID",
Title: "Test",
},
expectErr: true,
errMsg: "invalid story_id format",
},
{
name: "empty title returns error",
story: &DBStory{
StoryID: "valid-story-1",
Title: "",
},
expectErr: true,
errMsg: "title is required",
},
{
name: "valid story passes validation",
story: &DBStory{
StoryID: "valid-story-1",
Title: "Valid Title",
},
expectErr: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := tt.story.Validate()
if tt.expectErr {
if err == nil {
t.Fatalf("expected error but got nil")
}
if tt.errMsg != "" && !strings.Contains(err.Error(), tt.errMsg) {
t.Errorf("expected error containing '%s' but got '%s'", tt.errMsg, err.Error())
}
return
}
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
})
}
}
2026-03-29 15:24:09 -07:00
func TestDBStory_Save(t *testing.T) {
// Set up embedded postgres for testing
server := embeddedpostgres.NewDatabase(embeddedpostgres.DefaultConfig().Port(5432))
err := server.Start()
if err != nil {
t.Fatalf("failed to start embedded postgres: %v", err)
}
defer func() {
if err := server.Stop(); err != nil {
t.Errorf("failed to stop embedded postgres: %v", err)
}
}()
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
db, err := sql.Open("postgres", "postgres://postgres:postgres@127.0.0.1:5432/postgres?sslmode=disable")
if err != nil {
t.Fatalf("failed to connect to database: %v", err)
}
defer db.Close()
// Run migrations
_, err = db.ExecContext(ctx, string(schema.Bytes))
if err != nil {
t.Fatalf("failed to run schema: %v", err)
}
tests := []struct {
name string
setupFunc func() *DBStory
expectErr bool
checkFunc func(*testing.T, *sql.DB, *DBStory) error
}{
{
name: "insert new story successfully",
setupFunc: func() *DBStory {
return &DBStory{
StoryID: "insert-test-1",
Title: "Insert Test Story",
Content: "Test content",
Labels: []string{"test", "insert"},
}
},
expectErr: false,
checkFunc: func(t *testing.T, db *sql.DB, story *DBStory) error {
if story.ID == 0 {
return fmt.Errorf("expected non-zero ID after insert, got 0")
}
if story.CreatedAt.IsZero() {
return fmt.Errorf("expected non-zero CreatedAt")
}
if story.UpdatedAt.IsZero() {
return fmt.Errorf("expected non-zero UpdatedAt")
}
if story.Etag == "" {
return fmt.Errorf("expected non-empty etag")
}
// Verify in database
var dbStory DBStory
var labels pq.StringArray
err := db.QueryRow(`
SELECT id, story_id, title, content, labels, created_at, updated_at, etag
FROM stories WHERE id = $1`,
story.ID).Scan(
&dbStory.ID, &dbStory.StoryID, &dbStory.Title, &dbStory.Content,
&labels, &dbStory.CreatedAt, &dbStory.UpdatedAt, &dbStory.Etag,
)
if err != nil {
return fmt.Errorf("failed to query inserted story: %w", err)
}
dbStory.Labels = labels
if dbStory.StoryID != story.StoryID {
return fmt.Errorf("story_id mismatch: expected %s, got %s", story.StoryID, dbStory.StoryID)
}
if dbStory.Title != story.Title {
return fmt.Errorf("title mismatch: expected %s, got %s", story.Title, dbStory.Title)
}
return nil
},
},
{
name: "insert new story with etag provided",
setupFunc: func() *DBStory {
return &DBStory{
StoryID: "insert-test-2",
Title: "Insert Test with Etag",
Etag: "custom-etag-123",
}
},
expectErr: false,
checkFunc: func(t *testing.T, db *sql.DB, story *DBStory) error {
// Verify etag was set (even if custom provided, DB may override)
var dbStory DBStory
err := db.QueryRow(`SELECT etag FROM stories WHERE story_id = $1`, "insert-test-2").Scan(&dbStory.Etag)
if err != nil {
return fmt.Errorf("failed to query story: %w", err)
}
if dbStory.Etag == "" {
return fmt.Errorf("expected non-empty etag in database")
}
return nil
},
},
{
name: "insert new story with all fields",
setupFunc: func() *DBStory {
return &DBStory{
StoryID: "insert-test-3",
Title: "Full Story",
Content: "Full content here",
Description: "Full description",
Labels: []string{"tag1", "tag2", "tag3"},
}
},
expectErr: false,
checkFunc: func(t *testing.T, db *sql.DB, story *DBStory) error {
var dbStory DBStory
var labels pq.StringArray
err := db.QueryRow(`
SELECT id, story_id, title, content, description, labels, created_at, updated_at, etag
FROM stories WHERE story_id = $1`,
"insert-test-3").Scan(
&dbStory.ID, &dbStory.StoryID, &dbStory.Title, &dbStory.Content,
&dbStory.Description, &labels, &dbStory.CreatedAt,
&dbStory.UpdatedAt, &dbStory.Etag,
)
if err != nil {
return fmt.Errorf("failed to query inserted story: %w", err)
}
dbStory.Labels = []string(labels)
if dbStory.Description != "Full description" {
return fmt.Errorf("description mismatch")
}
return nil
},
},
{
name: "update existing story successfully",
setupFunc: func() *DBStory {
// First create the story
newStory := &DBStory{
StoryID: "update-test-1",
Title: "Original Title",
Content: "Original content",
}
if err := newStory.Save(db); err != nil {
t.Fatalf("failed to insert story for update test: %v", err)
}
// Now update it
newStory.ID = newStory.ID
newStory.Title = "Updated Title"
newStory.Content = "Updated content"
return newStory
},
expectErr: false,
checkFunc: func(t *testing.T, db *sql.DB, story *DBStory) error {
if story.Title != "Updated Title" {
return fmt.Errorf("expected title to be 'Updated Title', got %s", story.Title)
}
if story.Content != "Updated content" {
return fmt.Errorf("expected content to be 'Updated content', got %s", story.Content)
}
// Verify in database
var dbStory DBStory
err := db.QueryRow(`
SELECT title, content FROM stories WHERE id = $1`,
story.ID).Scan(&dbStory.Title, &dbStory.Content)
if err != nil {
return fmt.Errorf("failed to query updated story: %w", err)
}
if dbStory.Title != "Updated Title" {
return fmt.Errorf("title mismatch in DB: expected 'Updated Title', got %s", dbStory.Title)
}
if dbStory.Content != "Updated content" {
return fmt.Errorf("content mismatch in DB")
}
return nil
},
},
{
name: "update with labels changes",
setupFunc: func() *DBStory {
// First create
newStory := &DBStory{
StoryID: "update-test-2",
Title: "Label Update Test",
Labels: []string{"old-tag"},
}
if err := newStory.Save(db); err != nil {
t.Fatalf("failed to insert story for label update test: %v", err)
}
// Update
newStory.ID = newStory.ID
newStory.Labels = []string{"new-tag-1", "new-tag-2"}
return newStory
},
expectErr: false,
checkFunc: func(t *testing.T, db *sql.DB, story *DBStory) error {
var dbLabels pq.StringArray
err := db.QueryRow(`SELECT labels FROM stories WHERE id = $1`, story.ID).Scan(&dbLabels)
if err != nil {
return fmt.Errorf("failed to query labels: %w", err)
}
if len(dbLabels) != 2 {
return fmt.Errorf("expected 2 labels, got %d", len(dbLabels))
}
return nil
},
},
{
name: "insert with invalid story_id fails",
setupFunc: func() *DBStory {
return &DBStory{
StoryID: "INVALID",
Title: "Should Fail",
}
},
expectErr: true,
checkFunc: func(t *testing.T, db *sql.DB, story *DBStory) error {
return nil
},
},
{
name: "insert with empty title fails",
setupFunc: func() *DBStory {
return &DBStory{
StoryID: "empty-title-test",
Title: "",
}
},
expectErr: true,
checkFunc: func(t *testing.T, db *sql.DB, story *DBStory) error {
return nil
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
story := tt.setupFunc()
err := story.Save(db)
if tt.expectErr {
if err == nil {
t.Fatalf("expected error but got nil")
}
return
}
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if tt.checkFunc != nil {
if err := tt.checkFunc(t, db, story); err != nil {
t.Errorf("check failed: %v", err)
}
}
})
}
}