package story import ( "context" "database/sql" "fmt" "strings" "testing" "time" v1 "git.tipsy.codes/charles/webstory/pkg/api/webstory/v1" "git.tipsy.codes/charles/webstory/pkg/database/schema" embeddedpostgres "github.com/fergusstrange/embedded-postgres" "github.com/lib/pq" "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) { trans := NewStoryTranslator() 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 { return fmt.Errorf("expected nil, got %v", s) } 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) { trans := NewStoryTranslator() 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) } }) } } 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) } } }) } }