// Package actor provides database access for the Actor resource. package actor 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" story "git.tipsy.codes/charles/webstory/pkg/database/story" 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 }, }, } 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 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 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) } // Create a story first story := &story.DBStory{ StoryID: "save-test-story-1", Title: "Save Test Story", } err = story.Save(db) if err != nil { t.Fatalf("failed to save story: %v", err) } actor := &DBActor{ StoryID: story.ID, ActorID: "save-test-1", 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) } // Create a story first story := &story.DBStory{ StoryID: "get-by-id-story-1", Title: "Get By ID Story", } err = story.Save(db) if err != nil { t.Fatalf("failed to save story: %v", err) } actor := &DBActor{ StoryID: story.ID, ActorID: "get-by-id-test-1", 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) } // Create a story first story := &story.DBStory{ StoryID: "get-by-actor-id-story-1", Title: "Get By Actor ID Story", } err = story.Save(db) if err != nil { t.Fatalf("failed to save story: %v", err) } actor := &DBActor{ StoryID: story.ID, ActorID: "get-by-actor-id-test-1", 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) } // Create a story first story := &story.DBStory{ StoryID: "list-test-story-1", Title: "List Test Story", } err = story.Save(db) if err != nil { t.Fatalf("failed to save story: %v", err) } actor1 := &DBActor{ StoryID: story.ID, ActorID: "list-test-1", NameValue: "Actor One", Role: "Role One", } actor2 := &DBActor{ StoryID: story.ID, 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) } // Create a story first story := &story.DBStory{ StoryID: "delete-test-story-1", Title: "Delete Test Story", } err = story.Save(db) if err != nil { t.Fatalf("failed to save story: %v", err) } actor := &DBActor{ StoryID: story.ID, ActorID: "delete-test-actor-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) } }) } }