diff --git a/pkg/database/actor/actor_test.go b/pkg/database/actor/actor_test.go index e61e834..7a7bde9 100644 --- a/pkg/database/actor/actor_test.go +++ b/pkg/database/actor/actor_test.go @@ -2,93 +2,589 @@ package actor import ( - "fmt" - "strings" - "testing" - "time" + "context" + "database/sql" + "fmt" + "strings" + "testing" + "time" - v1 "git.tipsy.codes/charles/webstory/pkg/api/webstory/v1" - "google.golang.org/protobuf/types/known/timestamppb" + v1 "git.tipsy.codes/charles/webstory/pkg/api/webstory/v1" + "git.tipsy.codes/charles/webstory/pkg/database/schema" + embeddedpostgres "github.com/fergusstrange/embedded-postgres" + "google.golang.org/protobuf/types/known/timestamppb" ) func TestActorTranslator_FromAPI(t *testing.T) { - now := time.Now() - tests := []struct { - name string - apiActor *v1.Actor - expectErr bool - errMsg string - expectID int64 - checkFunc func(*DBActor) error - }{ - { - name: "nil actor returns error", - apiActor: nil, - expectErr: true, - errMsg: "api actor cannot be nil", - }, - { - name: "minimal valid actor creates translator correctly", - apiActor: &v1.Actor{ - ActorId: "actor-1", - NameValue: "John Doe", - Role: "Hero", - Notes: "Main character", - CreateTime: timestamppb.New(now), - UpdateTime: timestamppb.New(now), - }, - expectErr: false, - expectID: 0, - checkFunc: func(s *DBActor) error { - if s.ActorID != "actor-1" { - return fmt.Errorf("expected ActorID actor-1, got %s", s.ActorID) - } - if s.NameValue != "John Doe" { - return fmt.Errorf("expected NameValue John Doe, got %s", s.NameValue) - } - if s.Role != "Hero" { - return fmt.Errorf("expected Role Hero, got %s", s.Role) - } - if s.Notes != "Main character" { - return fmt.Errorf("expected Notes Main character, got %s", s.Notes) - } - return nil - }, - }, - } + now := time.Now() + tests := []struct { + name string + apiActor *v1.Actor + expectErr bool + errMsg string + expectID int64 + checkFunc func(*DBActor) error + }{ + { + name: "nil actor returns error", + apiActor: nil, + expectErr: true, + errMsg: "api actor cannot be nil", + }, + { + name: "minimal valid actor creates translator correctly", + apiActor: &v1.Actor{ + ActorId: "actor-1", + NameValue: "John Doe", + Role: "Hero", + Notes: "Main character", + CreateTime: timestamppb.New(now), + UpdateTime: timestamppb.New(now), + }, + expectErr: false, + expectID: 0, + checkFunc: func(s *DBActor) error { + if s.ActorID != "actor-1" { + return fmt.Errorf("expected ActorID actor-1, got %s", s.ActorID) + } + if s.NameValue != "John Doe" { + return fmt.Errorf("expected NameValue John Doe, got %s", s.NameValue) + } + if s.Role != "Hero" { + return fmt.Errorf("expected Role Hero, got %s", s.Role) + } + if s.Notes != "Main character" { + return fmt.Errorf("expected Notes Main character, got %s", s.Notes) + } + return nil + }, + }, + } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - trans := NewActorTranslator() - result, err := trans.FromAPI(tt.apiActor) + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + trans := NewActorTranslator() + result, err := trans.FromAPI(tt.apiActor) - 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 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 err != nil { + t.Fatalf("unexpected error: %v", err) + } - if result == nil { - t.Fatalf("expected non-nil result") - } + 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 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) - } - } - }) - } + if tt.checkFunc != nil { + if err := tt.checkFunc(result); err != nil { + t.Errorf("check failed: %v", err) + } + } + }) + } +} + +func TestActorTranslator_ToAPI(t *testing.T) { + now := time.Now() + tests := []struct { + name string + dbActor *DBActor + checkFunc func(*v1.Actor) error + }{ + { + name: "nil dbActor returns nil", + dbActor: nil, + checkFunc: func(a *v1.Actor) error { + if a != nil { + return fmt.Errorf("expected nil, got %v", a) + } + return nil + }, + }, + { + name: "minimal dbActor converts correctly", + dbActor: &DBActor{ + ID: 1, + StoryID: 100, + ActorID: "actor-1", + NameValue: "John Doe", + Role: "Hero", + Notes: "Main character", + CreatedAt: now, + UpdatedAt: now, + Etag: "etag-123", + }, + checkFunc: func(a *v1.Actor) error { + if a.ActorId != "actor-1" { + return fmt.Errorf("expected ActorId actor-1, got %s", a.ActorId) + } + if a.NameValue != "John Doe" { + return fmt.Errorf("expected NameValue John Doe, got %s", a.NameValue) + } + if a.Role != "Hero" { + return fmt.Errorf("expected Role Hero, got %s", a.Role) + } + if a.Notes != "Main character" { + return fmt.Errorf("expected Notes Main character, got %s", a.Notes) + } + return nil + }, + }, + { + name: "dbActor with story_id and actor_id creates correct name", + dbActor: &DBActor{ + ID: 2, + StoryID: 200, + ActorID: "actor-2", + NameValue: "Jane Smith", + CreatedAt: now, + UpdatedAt: now, + }, + checkFunc: func(a *v1.Actor) error { + if a.Name != "stories/200/actors/actor-2" { + return fmt.Errorf("expected name stories/200/actors/actor-2, got %s", a.Name) + } + if a.ActorId != "actor-2" { + return fmt.Errorf("expected ActorId actor-2, got %s", a.ActorId) + } + return nil + }, + }, + { + name: "dbActor with zero story_id does not create name", + dbActor: &DBActor{ + ID: 3, + StoryID: 0, + ActorID: "actor-3", + NameValue: "Test Actor", + CreatedAt: now, + UpdatedAt: now, + }, + checkFunc: func(a *v1.Actor) error { + if a.Name != "" { + return fmt.Errorf("expected empty name, got %s", a.Name) + } + return nil + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + trans := NewActorTranslator() + result := trans.ToAPI(tt.dbActor) + + if tt.checkFunc != nil { + if err := tt.checkFunc(result); err != nil { + t.Errorf("check failed: %v", err) + } + } + }) + } +} + +func TestDBActor_Save(t *testing.T) { + 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() + + _, err = db.ExecContext(ctx, string(schema.Bytes)) + if err != nil { + t.Fatalf("failed to run schema: %v", err) + } + + actor := &DBActor{ + StoryID: 1, + ActorID: "save-test1", + NameValue: "Save Test", + Role: "Test Role", + Notes: "Test notes", + } + + err = actor.Save(db) + if err != nil { + t.Fatalf("Save failed: %v", err) + } + + if actor.ID <= 0 { + t.Errorf("expected positive ID, got %d", actor.ID) + } + + var dbActor DBActor + err = db.QueryRow( + `SELECT id, story_id, actor_id, name_value, role, notes, created_at, updated_at, etag FROM actors WHERE id = $1`, + actor.ID, + ).Scan( + &dbActor.ID, &dbActor.StoryID, &dbActor.ActorID, &dbActor.NameValue, + &dbActor.Role, &dbActor.Notes, &dbActor.CreatedAt, &dbActor.UpdatedAt, &dbActor.Etag, + ) + if err != nil { + t.Fatalf("failed to query actor: %v", err) + } + + if dbActor.ActorID != actor.ActorID { + t.Errorf("actor_id mismatch: expected %s, got %s", actor.ActorID, dbActor.ActorID) + } + if dbActor.NameValue != actor.NameValue { + t.Errorf("name_value mismatch: expected %s, got %s", actor.NameValue, dbActor.NameValue) + } + if dbActor.Role != actor.Role { + t.Errorf("role mismatch: expected %s, got %s", actor.Role, dbActor.Role) + } + if dbActor.Notes != actor.Notes { + t.Errorf("notes mismatch: expected %s, got %s", actor.Notes, dbActor.Notes) + } +} + +func TestDBActor_GetByID(t *testing.T) { + 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() + + _, err = db.ExecContext(ctx, string(schema.Bytes)) + if err != nil { + t.Fatalf("failed to run schema: %v", err) + } + + actor := &DBActor{ + StoryID: 1, + ActorID: "get-by-id-test", + NameValue: "Get Test", + } + err = actor.Save(db) + if err != nil { + t.Fatalf("Save failed: %v", err) + } + + result, err := GetByID(db, actor.ID) + if err != nil { + t.Fatalf("GetByID failed: %v", err) + } + + if result.ID != actor.ID { + t.Errorf("ID mismatch: expected %d, got %d", actor.ID, result.ID) + } + if result.NameValue != actor.NameValue { + t.Errorf("name_value mismatch: expected %s, got %s", actor.NameValue, result.NameValue) + } +} + +func TestDBActor_GetByActorID(t *testing.T) { + 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() + + _, err = db.ExecContext(ctx, string(schema.Bytes)) + if err != nil { + t.Fatalf("failed to run schema: %v", err) + } + + actor := &DBActor{ + StoryID: 1, + ActorID: "get-by-actor-id-test", + NameValue: "Get By Actor ID Test", + } + err = actor.Save(db) + if err != nil { + t.Fatalf("Save failed: %v", err) + } + + result, err := GetByActorID(db, actor.StoryID, actor.ActorID) + if err != nil { + t.Fatalf("GetByActorID failed: %v", err) + } + + if result.NameValue != actor.NameValue { + t.Errorf("name_value mismatch: expected %s, got %s", actor.NameValue, result.NameValue) + } +} + +func TestDBActor_ListByStoryID(t *testing.T) { + 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() + + _, err = db.ExecContext(ctx, string(schema.Bytes)) + if err != nil { + t.Fatalf("failed to run schema: %v", err) + } + + actor1 := &DBActor{ + StoryID: 1, + ActorID: "list-test-1", + NameValue: "Actor One", + Role: "Role One", + } + actor2 := &DBActor{ + StoryID: 1, + ActorID: "list-test-2", + NameValue: "Actor Two", + Role: "Role Two", + } + err = actor1.Save(db) + if err != nil { + t.Fatalf("Save failed: %v", err) + } + err = actor2.Save(db) + if err != nil { + t.Fatalf("Save failed: %v", err) + } + + actors, err := ListByStoryID(db, 1) + if err != nil { + t.Fatalf("ListByStoryID failed: %v", err) + } + + if len(actors) != 2 { + t.Errorf("expected 2 actors, got %d", len(actors)) + } + + // Verify they're sorted alphabetically + if actors[0].NameValue != "Actor One" || actors[1].NameValue != "Actor Two" { + t.Errorf("actors are not in alphabetical order") + } +} + +func TestDBActor_Delete(t *testing.T) { + 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() + + _, err = db.ExecContext(ctx, string(schema.Bytes)) + if err != nil { + t.Fatalf("failed to run schema: %v", err) + } + + actor := &DBActor{ + StoryID: 1, + ActorID: "delete-test-1", + NameValue: "Delete Test", + } + err = actor.Save(db) + if err != nil { + t.Fatalf("Save failed: %v", err) + } + + // Verify actor exists + var count int + err = db.QueryRow("SELECT COUNT(*) FROM actors WHERE id = $1", actor.ID).Scan(&count) + if err != nil { + t.Fatalf("failed to count actors: %v", err) + } + if count != 1 { + t.Errorf("expected 1 actor, got %d", count) + } + + err = Delete(db, actor.ID) + if err != nil { + t.Fatalf("Delete failed: %v", err) + } + + // Verify actor is deleted + err = db.QueryRow("SELECT COUNT(*) FROM actors WHERE id = $1", actor.ID).Scan(&count) + if err != nil { + t.Fatalf("failed to count actors after deletion: %v", err) + } + if count != 0 { + t.Errorf("expected 0 actors after deletion, got %d", count) + } +} + +func TestDBActor_GetActorIDForResourceName(t *testing.T) { + tests := []struct { + name string + input string + storyID int64 + actorID string + expectErr bool + }{ + { + name: "valid story/actor resource name", + input: "stories/1/actors/my-actor", + storyID: 1, + actorID: "my-actor", + expectErr: false, + }, + { + name: "valid story/actor resource name with numbers", + input: "stories/100/actors/actor-123", + storyID: 100, + actorID: "actor-123", + expectErr: false, + }, + { + name: "missing actors/ prefix", + input: "stories/1/actor/my-actor", + storyID: 1, + actorID: "", + expectErr: true, + }, + { + name: "actor_id with underscore not allowed", + input: "stories/1/actors/my_actor", + storyID: 1, + actorID: "", + expectErr: true, + }, + { + name: "actor_id with spaces not allowed", + input: "stories/1/actors/my actor", + storyID: 1, + actorID: "", + expectErr: true, + }, + { + name: "actor_id with uppercase not allowed", + input: "stories/1/actors/MyActor", + storyID: 1, + actorID: "", + expectErr: true, + }, + { + name: "actor_id with dots not allowed", + input: "stories/1/actors/my.actor", + storyID: 1, + actorID: "", + expectErr: true, + }, + { + name: "missing stories/ prefix", + input: "actors/my-actor", + storyID: 1, + actorID: "", + expectErr: true, + }, + { + name: "empty actor_id", + input: "stories/1/actors/", + storyID: 1, + actorID: "", + expectErr: true, + }, + { + name: "wrong stories/ prefix", + input: "story/1/actors/my-actor", + storyID: 1, + actorID: "", + expectErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + storyID, actorID, err := GetActorIDForResourceName(tt.input) + + if tt.expectErr { + if err == nil { + t.Fatalf("expected error but got nil") + } + return + } + + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if storyID != tt.storyID { + t.Errorf("expected story_id %d, got %d", tt.storyID, storyID) + } + + if actorID != tt.actorID { + t.Errorf("expected actor_id %s, got %s", tt.actorID, actorID) + } + }) + } }