From a9a3556b181a13526df3f4640ea471c43fdecea4 Mon Sep 17 00:00:00 2001 From: charles Date: Wed, 1 Apr 2026 07:54:50 +0000 Subject: [PATCH] Add Actor model with unit tests and integration tests Add Actor model implementation in pkg/database/actor/actor.go with associated unit tests in actor_test.go. Implement tests following the pattern used for Story model, including Postgres integration tests. --- pkg/database/actor/actor.go | 424 +++++++++++++++++++++++++++++++ pkg/database/actor/actor_test.go | 94 +++++++ 2 files changed, 518 insertions(+) create mode 100644 pkg/database/actor/actor.go create mode 100644 pkg/database/actor/actor_test.go diff --git a/pkg/database/actor/actor.go b/pkg/database/actor/actor.go new file mode 100644 index 0000000..f838b0e --- /dev/null +++ b/pkg/database/actor/actor.go @@ -0,0 +1,424 @@ +// Package actor provides database access for the Actor resource. +// It defines the DBActor struct and ActorTranslator to convert between +// the API format (api.pb.go) and the database format (schema.sql). +package actor + +import ( + "database/sql" + "errors" + "fmt" + "regexp" + "strings" + "time" + + v1 "git.tipsy.codes/charles/webstory/pkg/api/webstory/v1" + "github.com/google/uuid" + "github.com/lib/pq" + "google.golang.org/protobuf/types/known/timestamppb" +) + +var ( + // ErrActorIDInvalid is returned when the actor_id format is invalid. + ErrActorIDInvalid = errors.New("invalid actor_id format: must be 4-63 characters, matching pattern /[a-z][0-9-]/") + + // actorIDPattern validates the actor_id format. + actorIDPattern = regexp.MustCompile(`^[a-z][0-9a-z-]{2,61}[0-9a-z]$`) +) + +// DBActor represents an actor in the PostgreSQL database. +type DBActor struct { + ID int64 + StoryID int64 + ActorID string + NameValue string + Role string + Notes string + CreatedAt time.Time + UpdatedAt time.Time + Etag string +} + +// ActorTranslator provides conversion methods between API and database formats. +type ActorTranslator struct{} + +func NewActorTranslator() *ActorTranslator { + return &ActorTranslator{} +} + +func (t *ActorTranslator) FromAPI(apiActor *v1.Actor) (*DBActor, error) { + if apiActor == nil { + return nil, errors.New("api actor cannot be nil") + } + + if apiActor.ActorId != "" { + if err := validateActorID(apiActor.ActorId); err != nil { + return nil, err + } + } + + if apiActor.NameValue == "" { + return nil, errors.New("name_value is required") + } + + var createdAt, updatedAt time.Time + if apiActor.GetCreateTime() != nil { + createdAt = apiActor.CreateTime.AsTime() + } + if apiActor.GetUpdateTime() != nil { + updatedAt = apiActor.UpdateTime.AsTime() + } + + return &DBActor{ + ID: 0, + StoryID: 0, + ActorID: apiActor.ActorId, + NameValue: apiActor.NameValue, + Role: apiActor.Role, + Notes: apiActor.Notes, + CreatedAt: createdAt, + UpdatedAt: updatedAt, + Etag: apiActor.Etag, + }, nil +} + +func (t *ActorTranslator) ToAPI(dbActor *DBActor) *v1.Actor { + if dbActor == nil { + return nil + } + + name := "" + if dbActor.StoryID > 0 && dbActor.ActorID != "" { + name = fmt.Sprintf("stories/%d/actors/%s", dbActor.StoryID, dbActor.ActorID) + } + + return &v1.Actor{ + Name: name, + ActorId: dbActor.ActorID, + NameValue: dbActor.NameValue, + Role: dbActor.Role, + Notes: dbActor.Notes, + CreateTime: timestamppb.New(dbActor.CreatedAt), + UpdateTime: timestamppb.New(dbActor.UpdatedAt), + Etag: dbActor.Etag, + } +} + +func (t *ActorTranslator) ToAPIDetailed(dbActor *DBActor, storyID int64) *v1.Actor { + apiActor := t.ToAPI(dbActor) + if dbActor.StoryID > 0 { + apiActor.Name = fmt.Sprintf("stories/%d/actors/%s", dbActor.StoryID, dbActor.ActorID) + } + return apiActor +} + +func (a *DBActor) Validate() error { + if a.ActorID == "" { + return errors.New("actor_id is required") + } + if a.NameValue == "" { + return errors.New("name_value is required") + } + if a.StoryID <= 0 { + return errors.New("story_id is required") + } + if err := validateActorID(a.ActorID); err != nil { + return err + } + return nil +} + +func validateActorID(actorID string) error { + if actorID == "" { + return ErrActorIDInvalid + } + if len(actorID) < 4 || len(actorID) > 63 { + return ErrActorIDInvalid + } + if !actorIDPattern.MatchString(actorID) { + return ErrActorIDInvalid + } + return nil +} + +func (a *DBActor) Save(db *sql.DB) error { + if err := a.Validate(); err != nil { + return err + } + + if a.ID > 0 { + return a.update(db) + } + return a.insert(db) +} + +func (a *DBActor) insert(db *sql.DB) error { + query := ` + INSERT INTO actors (story_id, actor_id, name_value, role, notes, created_at, updated_at, etag) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8) + RETURNING id, created_at, updated_at, etag + ` + + now := time.Now() + if a.CreatedAt.IsZero() { + a.CreatedAt = now + } + if a.UpdatedAt.IsZero() { + a.UpdatedAt = now + } + + etag := a.Etag + if etag == "" { + etag = generateEtag() + } + + var createdID int64 + var createdAt, updatedAt time.Time + var returnedEtag string + + err := db.QueryRow( + query, + a.StoryID, + a.ActorID, + a.NameValue, + pq.Array([]string{a.Role}), + pq.Array([]string{a.Notes}), + a.CreatedAt, + a.UpdatedAt, + etag, + ).Scan(&createdID, &createdAt, &updatedAt, &returnedEtag) + + if err != nil { + return fmt.Errorf("failed to insert actor: %w", err) + } + + a.ID = createdID + a.CreatedAt = createdAt + a.UpdatedAt = updatedAt + a.Etag = returnedEtag + + return nil +} + +func (a *DBActor) update(db *sql.DB) error { + query := ` + UPDATE actors + SET name_value = $1, + role = $2, + notes = $3, + updated_at = $4, + etag = $5 + WHERE id = $6 + RETURNING created_at, updated_at, etag + ` + + now := time.Now() + if a.UpdatedAt.IsZero() { + a.UpdatedAt = now + } + + etag := a.Etag + if etag == "" { + etag = generateEtag() + } + + var createdAt, updatedAt time.Time + var returnedEtag string + + err := db.QueryRow( + query, + a.NameValue, + a.Role, + a.Notes, + a.UpdatedAt, + etag, + a.ID, + ).Scan(&createdAt, &updatedAt, &returnedEtag) + + if err != nil { + return fmt.Errorf("failed to update actor: %w", err) + } + + a.CreatedAt = createdAt + a.UpdatedAt = updatedAt + a.Etag = returnedEtag + + return nil +} + +func generateEtag() string { + return uuid.New().String() +} + +func GetByID(db *sql.DB, id int64) (*DBActor, error) { + query := ` + SELECT id, story_id, actor_id, name_value, role, notes, created_at, updated_at, etag + FROM actors + WHERE id = $1 + ` + + actor := &DBActor{} + err := db.QueryRow(query, id).Scan( + &actor.ID, + &actor.StoryID, + &actor.ActorID, + &actor.NameValue, + &actor.Role, + &actor.Notes, + &actor.CreatedAt, + &actor.UpdatedAt, + &actor.Etag, + ) + + if err != nil { + if err == sql.ErrNoRows { + return nil, fmt.Errorf("actor with id %d not found", id) + } + return nil, fmt.Errorf("failed to get actor: %w", err) + } + + return actor, nil +} + +func GetByActorID(db *sql.DB, storyID int64, actorID string) (*DBActor, error) { + if err := validateActorID(actorID); err != nil { + return nil, err + } + + query := ` + SELECT id, story_id, actor_id, name_value, role, notes, created_at, updated_at, etag + FROM actors + WHERE story_id = $1 AND actor_id = $2 + ` + + actor := &DBActor{} + err := db.QueryRow(query, storyID, actorID).Scan( + &actor.ID, + &actor.StoryID, + &actor.ActorID, + &actor.NameValue, + &actor.Role, + &actor.Notes, + &actor.CreatedAt, + &actor.UpdatedAt, + &actor.Etag, + ) + + if err != nil { + if err == sql.ErrNoRows { + return nil, fmt.Errorf("actor with story_id %d and actor_id '%s' not found", storyID, actorID) + } + return nil, fmt.Errorf("failed to get actor: %w", err) + } + + return actor, nil +} + +func ListByStoryID(db *sql.DB, storyID int64) ([]*DBActor, error) { + if storyID <= 0 { + return nil, errors.New("story_id must be positive") + } + + query := ` + SELECT id, story_id, actor_id, name_value, role, notes, created_at, updated_at, etag + FROM actors + WHERE story_id = $1 + ORDER BY name_value ASC + ` + + rows, err := db.Query(query, storyID) + if err != nil { + return nil, fmt.Errorf("failed to list actors: %w", err) + } + defer rows.Close() + + actors := make([]*DBActor, 0) + for rows.Next() { + var id int64 + var storyID int64 + var actorID, nameValue, role, notes string + var createdAt, updatedAt time.Time + var etag string + + err := rows.Scan(&id, &storyID, &actorID, &nameValue, &role, ¬es, &createdAt, &updatedAt, &etag) + if err != nil { + return nil, fmt.Errorf("failed to scan actor: %w", err) + } + + actors = append(actors, &DBActor{ + ID: id, + StoryID: storyID, + ActorID: actorID, + NameValue: nameValue, + Role: role, + Notes: notes, + CreatedAt: createdAt, + UpdatedAt: updatedAt, + Etag: etag, + }) + } + + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("error iterating actors: %w", err) + } + + return actors, nil +} + +func Delete(db *sql.DB, id int64) error { + query := ` + DELETE FROM actors + WHERE id = $1 + ` + + result, err := db.Exec(query, id) + if err != nil { + return fmt.Errorf("failed to delete actor: %w", err) + } + + rowsAffected, err := result.RowsAffected() + if err != nil { + return fmt.Errorf("failed to get rows affected: %w", err) + } + + if rowsAffected == 0 { + return fmt.Errorf("actor with id %d not found", id) + } + + return nil +} + +func GetActorIDForResourceName(resourceName string) (int64, string, error) { + prefix := "stories/" + if !strings.HasPrefix(resourceName, prefix) { + return 0, "", fmt.Errorf("invalid resource name: must start with '%s', got '%s'", prefix, resourceName) + } + + remaining := strings.TrimPrefix(resourceName, prefix) + + actorPrefix := "actors/" + if !strings.HasPrefix(remaining, actorPrefix) { + return 0, "", fmt.Errorf("invalid actor resource name: must contain '%s', got '%s'", actorPrefix, remaining) + } + + actorID := strings.TrimPrefix(remaining, actorPrefix) + if actorID == "" { + return 0, "", errors.New("actor_id is empty in resource name") + } + + if err := validateActorID(actorID); err != nil { + return 0, "", fmt.Errorf("invalid actor_id in resource name: %w", err) + } + + storyPart := strings.TrimSuffix(remaining, actorPrefix+actorID) + if storyPart == "" { + return 0, "", errors.New("story_id is missing from resource name") + } + + var storyID int64 + _, err := fmt.Sscanf(storyPart, "%d", &storyID) + if err != nil || storyID <= 0 { + return 0, "", errors.New("invalid story_id in resource name") + } + + return storyID, actorID, nil +} diff --git a/pkg/database/actor/actor_test.go b/pkg/database/actor/actor_test.go new file mode 100644 index 0000000..e61e834 --- /dev/null +++ b/pkg/database/actor/actor_test.go @@ -0,0 +1,94 @@ +// Package actor provides database access for the Actor resource. +package actor + +import ( + "fmt" + "strings" + "testing" + "time" + + v1 "git.tipsy.codes/charles/webstory/pkg/api/webstory/v1" + "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) + } + } + }) + } +}