5 Commits

Author SHA1 Message Date
charles 0308c70061 fix: actor tests 2026-04-01 20:37:38 -07:00
charles 789e32a57f test(actor): add comprehensive test cases for database functions
Add test cases for Save, GetByActorID, ListByStoryID, Delete, and
GetActorIDForResourceName functions. Use real database instances for
integration tests following Story test patterns.
2026-04-01 19:03:22 +00:00
charles 9720517a8c Merge pull request 'Add Actor model with unit tests and integration tests' (#1) from add-actor-model into main
Reviewed-on: https://git.tipsy.codes/charles/webstory/pulls/1
2026-04-01 18:29:37 +00:00
charles a9a3556b18 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.
2026-04-01 07:54:50 +00:00
charles 6d3db74bf4 add: styleguide and updated agents 2026-04-01 00:24:22 -07:00
6 changed files with 696 additions and 258 deletions
+3
View File
@@ -0,0 +1,3 @@
[submodule "styleguide"]
path = styleguide
url = https://github.com/google/styleguide.git
+3
View File
@@ -13,6 +13,9 @@ Before committing changes, run `make test`. The tests must pass.
## Backend/Golang ## Backend/Golang
Read the style guide, it can be found at styleguide/go/.
Make sure the git submodules are loaded.
Do not alter go.mod directly, instead, use the CLI tools `go mod tidy`, `go mod Do not alter go.mod directly, instead, use the CLI tools `go mod tidy`, `go mod
init`, and so forth. init`, and so forth.
+80 -125
View File
@@ -1,3 +1,6 @@
// 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 package actor
import ( import (
@@ -15,70 +18,47 @@ import (
var ( var (
// ErrActorIDInvalid is returned when the actor_id format is invalid. // 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-][0-9]$/") 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 validates the actor_id format.
// Must start with a lowercase letter, end with a number, and contain only lowercase letters, numbers, and hyphens. actorIDPattern = regexp.MustCompile(`^[a-z][0-9a-z-]{2,61}[0-9a-z]$`)
actorIDPattern = regexp.MustCompile(`^[a-z][0-9a-z-]{2,61}[0-9]$`)
) )
// DBActor represents an actor in the PostgreSQL database. // DBActor represents an actor in the PostgreSQL database.
// This is the database model that corresponds to the schema.sql structure.
type DBActor struct { type DBActor struct {
// Database-generated unique identifier
ID int64 ID int64
// Foreign key to story
StoryID int64 StoryID int64
// Actor ID (used in resource name: stories/{story}/actors/{actor_id})
ActorID string ActorID string
// Name of the actor
NameValue string NameValue string
// Role or character name this actor plays in the story
Role string Role string
// Optional notes about the actor
Notes string Notes string
// CreatedAt is when the actor was created
CreatedAt time.Time CreatedAt time.Time
// UpdatedAt is when the actor was last updated
UpdatedAt time.Time UpdatedAt time.Time
// Etag is used for concurrency control
Etag string Etag string
} }
// ActorTranslator provides conversion methods between API and database formats. // ActorTranslator provides conversion methods between API and database formats.
type ActorTranslator struct{} type ActorTranslator struct{}
// NewActorTranslator creates a new ActorTranslator instance.
func NewActorTranslator() *ActorTranslator { func NewActorTranslator() *ActorTranslator {
return &ActorTranslator{} return &ActorTranslator{}
} }
// FromAPI converts an API Actor message to a DBActor struct.
// This handles the conversion from the protobuf-generated type to the database model.
func (t *ActorTranslator) FromAPI(apiActor *v1.Actor) (*DBActor, error) { func (t *ActorTranslator) FromAPI(apiActor *v1.Actor) (*DBActor, error) {
if apiActor == nil { if apiActor == nil {
return nil, errors.New("api actor cannot be nil") return nil, errors.New("api actor cannot be nil")
} }
// Validate actor_id (required field) if apiActor.ActorId != "" {
if err := validateActorID(apiActor.ActorId); err != nil { if err := validateActorID(apiActor.ActorId); err != nil {
return nil, err return nil, err
} }
}
// Validate name_value (required field)
if apiActor.NameValue == "" { if apiActor.NameValue == "" {
return nil, errors.New("name_value is required") return nil, errors.New("name_value is required")
} }
// Convert timestamps if provided
var createdAt, updatedAt time.Time var createdAt, updatedAt time.Time
if apiActor.GetCreateTime() != nil { if apiActor.GetCreateTime() != nil {
createdAt = apiActor.CreateTime.AsTime() createdAt = apiActor.CreateTime.AsTime()
@@ -88,8 +68,8 @@ func (t *ActorTranslator) FromAPI(apiActor *v1.Actor) (*DBActor, error) {
} }
return &DBActor{ return &DBActor{
ID: 0, // Will be generated by database ID: 0,
StoryID: 0, // Will be provided during save StoryID: 0,
ActorID: apiActor.ActorId, ActorID: apiActor.ActorId,
NameValue: apiActor.NameValue, NameValue: apiActor.NameValue,
Role: apiActor.Role, Role: apiActor.Role,
@@ -100,15 +80,15 @@ func (t *ActorTranslator) FromAPI(apiActor *v1.Actor) (*DBActor, error) {
}, nil }, nil
} }
// ToAPI converts a DBActor struct to an API Actor message.
// This prepares the database model for response serialization.
func (t *ActorTranslator) ToAPI(dbActor *DBActor) *v1.Actor { func (t *ActorTranslator) ToAPI(dbActor *DBActor) *v1.Actor {
if dbActor == nil { if dbActor == nil {
return nil return nil
} }
// Build resource name name := ""
name := fmt.Sprintf("stories/%d/actors/%s", dbActor.StoryID, dbActor.ActorID) if dbActor.StoryID > 0 && dbActor.ActorID != "" {
name = fmt.Sprintf("stories/%d/actors/%s", dbActor.StoryID, dbActor.ActorID)
}
return &v1.Actor{ return &v1.Actor{
Name: name, Name: name,
@@ -122,70 +102,54 @@ func (t *ActorTranslator) ToAPI(dbActor *DBActor) *v1.Actor {
} }
} }
// ToAPIDetailed converts a DBActor to an API Actor with parent resource name. func (t *ActorTranslator) ToAPIDetailed(dbActor *DBActor, storyID int64) *v1.Actor {
// This is used when loading actors with their parent story.
func (t *ActorTranslator) ToAPIDetailed(dbActor *DBActor, parentStoryName string) *v1.Actor {
apiActor := t.ToAPI(dbActor) apiActor := t.ToAPI(dbActor)
if parentStoryName != "" { if dbActor.StoryID > 0 {
apiActor.Name = parentStoryName apiActor.Name = fmt.Sprintf("stories/%d/actors/%s", dbActor.StoryID, dbActor.ActorID)
} }
return apiActor return apiActor
} }
// Validate validates the DBActor struct.
func (a *DBActor) Validate() error { func (a *DBActor) Validate() error {
if a.StoryID <= 0 {
return errors.New("story_id is required")
}
if a.ActorID == "" { if a.ActorID == "" {
return errors.New("actor_id is required") return errors.New("actor_id is required")
} }
if err := validateActorID(a.ActorID); err != nil {
return err
}
if a.NameValue == "" { if a.NameValue == "" {
return errors.New("name_value is required") 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 return nil
} }
// validateActorID validates the actor_id format.
func validateActorID(actorID string) error { func validateActorID(actorID string) error {
if actorID == "" { if actorID == "" {
return ErrActorIDInvalid return ErrActorIDInvalid
} }
if len(actorID) < 4 || len(actorID) > 63 { if len(actorID) < 4 || len(actorID) > 63 {
return ErrActorIDInvalid return ErrActorIDInvalid
} }
if !actorIDPattern.MatchString(actorID) { if !actorIDPattern.MatchString(actorID) {
return ErrActorIDInvalid return ErrActorIDInvalid
} }
return nil return nil
} }
// Save saves the DBActor to the database.
// If the actor has an ID, it performs an UPDATE; otherwise, it performs an INSERT.
func (a *DBActor) Save(db *sql.DB) error { func (a *DBActor) Save(db *sql.DB) error {
if err := a.Validate(); err != nil { if err := a.Validate(); err != nil {
return err return err
} }
// Check if this is an update (has ID from database) or insert
if a.ID > 0 { if a.ID > 0 {
return a.update(db) return a.update(db)
} }
return a.insert(db) return a.insert(db)
} }
// insert inserts a new actor into the database.
func (a *DBActor) insert(db *sql.DB) error { func (a *DBActor) insert(db *sql.DB) error {
query := ` query := `
INSERT INTO actors (story_id, actor_id, name_value, role, notes, created_at, updated_at, etag) INSERT INTO actors (story_id, actor_id, name_value, role, notes, created_at, updated_at, etag)
@@ -193,6 +157,14 @@ func (a *DBActor) insert(db *sql.DB) error {
RETURNING id, created_at, updated_at, etag 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 etag := a.Etag
if etag == "" { if etag == "" {
etag = generateEtag() etag = generateEtag()
@@ -226,7 +198,6 @@ func (a *DBActor) insert(db *sql.DB) error {
return nil return nil
} }
// update updates an existing actor in the database.
func (a *DBActor) update(db *sql.DB) error { func (a *DBActor) update(db *sql.DB) error {
query := ` query := `
UPDATE actors UPDATE actors
@@ -273,12 +244,10 @@ func (a *DBActor) update(db *sql.DB) error {
return nil return nil
} }
// generateEtag generates a unique etag for the actor.
func generateEtag() string { func generateEtag() string {
return uuid.New().String() return uuid.New().String()
} }
// GetByID retrieves an actor by its primary key ID.
func GetByID(db *sql.DB, id int64) (*DBActor, error) { func GetByID(db *sql.DB, id int64) (*DBActor, error) {
query := ` query := `
SELECT id, story_id, actor_id, name_value, role, notes, created_at, updated_at, etag SELECT id, story_id, actor_id, name_value, role, notes, created_at, updated_at, etag
@@ -309,7 +278,6 @@ func GetByID(db *sql.DB, id int64) (*DBActor, error) {
return actor, nil return actor, nil
} }
// GetByActorID retrieves an actor by its actor_id and story_id.
func GetByActorID(db *sql.DB, storyID int64, actorID string) (*DBActor, error) { func GetByActorID(db *sql.DB, storyID int64, actorID string) (*DBActor, error) {
if err := validateActorID(actorID); err != nil { if err := validateActorID(actorID); err != nil {
return nil, err return nil, err
@@ -336,7 +304,7 @@ func GetByActorID(db *sql.DB, storyID int64, actorID string) (*DBActor, error) {
if err != nil { if err != nil {
if err == sql.ErrNoRows { 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("actor with story_id %d and actor_id '%s' not found", storyID, actorID)
} }
return nil, fmt.Errorf("failed to get actor: %w", err) return nil, fmt.Errorf("failed to get actor: %w", err)
} }
@@ -344,65 +312,21 @@ func GetByActorID(db *sql.DB, storyID int64, actorID string) (*DBActor, error) {
return actor, nil return actor, nil
} }
// GetByResourceName retrieves an actor by its resource name. func ListByStoryID(db *sql.DB, storyID int64) ([]*DBActor, error) {
// Resource name format: stories/{story_id}/actors/{actor_id} if storyID <= 0 {
func GetByResourceName(db *sql.DB, resourceName string) (*DBActor, error) { return nil, errors.New("story_id must be positive")
prefix := "stories/"
if !strings.HasPrefix(resourceName, prefix) {
return nil, fmt.Errorf("invalid resource name: must start with '%s', got '%s'", prefix, resourceName)
} }
// Extract story_id and actor_id query := `
rest := strings.TrimPrefix(resourceName, prefix)
parts := strings.SplitN(rest, "/actors/", 2)
if len(parts) != 2 {
return nil, errors.New("invalid resource name: must contain 'actors/'")
}
storyIDStr, actorID := parts[0], parts[1]
var storyID int64
_, err := fmt.Sscanf(storyIDStr, "%d", &storyID)
if err != nil {
return nil, fmt.Errorf("invalid story_id in resource name: %w", err)
}
if err := validateActorID(actorID); err != nil {
return nil, fmt.Errorf("invalid actor_id in resource name: %w", err)
}
return GetByActorID(db, storyID, actorID)
}
// ListByStoryID retrieves a list of actors for a story.
func ListByStoryID(db *sql.DB, storyID int64, pageSize int, pageToken string) ([]*DBActor, string, error) {
if pageSize <= 0 || pageSize > 1000 {
pageSize = 50
}
var query string
var args []interface{}
query = `
SELECT id, story_id, actor_id, name_value, role, notes, created_at, updated_at, etag SELECT id, story_id, actor_id, name_value, role, notes, created_at, updated_at, etag
FROM actors FROM actors
WHERE story_id = $1 WHERE story_id = $1
ORDER BY name_value ASC
` `
// Parse page token for pagination rows, err := db.Query(query, storyID)
if pageToken != "" {
query += " AND id > " + pageToken
}
query += " ORDER BY name_value ASC"
query += fmt.Sprintf(" LIMIT %d", pageSize)
args = append(args, storyID)
rows, err := db.Query(query, args...)
if err != nil { if err != nil {
return nil, "", fmt.Errorf("failed to list actors: %w", err) return nil, fmt.Errorf("failed to list actors: %w", err)
} }
defer rows.Close() defer rows.Close()
@@ -410,12 +334,13 @@ func ListByStoryID(db *sql.DB, storyID int64, pageSize int, pageToken string) ([
for rows.Next() { for rows.Next() {
var id int64 var id int64
var storyID int64 var storyID int64
var actorID, nameValue, role, notes, etag string var actorID, nameValue, role, notes string
var createdAt, updatedAt time.Time var createdAt, updatedAt time.Time
var etag string
err := rows.Scan(&id, &storyID, &actorID, &nameValue, &role, &notes, &createdAt, &updatedAt, &etag) err := rows.Scan(&id, &storyID, &actorID, &nameValue, &role, &notes, &createdAt, &updatedAt, &etag)
if err != nil { if err != nil {
return nil, "", fmt.Errorf("failed to scan actor: %w", err) return nil, fmt.Errorf("failed to scan actor: %w", err)
} }
actors = append(actors, &DBActor{ actors = append(actors, &DBActor{
@@ -432,19 +357,12 @@ func ListByStoryID(db *sql.DB, storyID int64, pageSize int, pageToken string) ([
} }
if err := rows.Err(); err != nil { if err := rows.Err(); err != nil {
return nil, "", fmt.Errorf("error iterating actors: %w", err) return nil, fmt.Errorf("error iterating actors: %w", err)
} }
var nextPageToken string return actors, nil
if len(actors) == pageSize {
// Use the last actor's ID as the next page token
nextPageToken = fmt.Sprintf("%d", actors[len(actors)-1].ID)
}
return actors, nextPageToken, nil
} }
// Delete removes an actor from the database.
func Delete(db *sql.DB, id int64) error { func Delete(db *sql.DB, id int64) error {
query := ` query := `
DELETE FROM actors DELETE FROM actors
@@ -467,3 +385,40 @@ func Delete(db *sql.DB, id int64) error {
return nil 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/"
idx := strings.Index(remaining, actorPrefix)
if idx == -1 {
return 0, "", fmt.Errorf("invalid actor resource name: must contain '%s', got '%s'", actorPrefix, remaining)
}
actorID := remaining[idx+len(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
}
+576 -100
View File
@@ -1,12 +1,19 @@
// Package actor provides database access for the Actor resource.
package actor package actor
import ( import (
"context"
"database/sql"
"fmt" "fmt"
"strings" "strings"
"testing" "testing"
"time" "time"
v1 "git.tipsy.codes/charles/webstory/pkg/api/webstory/v1" 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" "google.golang.org/protobuf/types/known/timestamppb"
) )
@@ -17,6 +24,7 @@ func TestActorTranslator_FromAPI(t *testing.T) {
apiActor *v1.Actor apiActor *v1.Actor
expectErr bool expectErr bool
errMsg string errMsg string
expectID int64
checkFunc func(*DBActor) error checkFunc func(*DBActor) error
}{ }{
{ {
@@ -28,109 +36,27 @@ func TestActorTranslator_FromAPI(t *testing.T) {
{ {
name: "minimal valid actor creates translator correctly", name: "minimal valid actor creates translator correctly",
apiActor: &v1.Actor{ apiActor: &v1.Actor{
ActorId: "actor1", ActorId: "actor-1",
NameValue: "Player", NameValue: "John Doe",
}, Role: "Hero",
expectErr: false,
checkFunc: func(a *DBActor) error {
if a.ActorID != "actor1" {
return fmt.Errorf("expected ActorID actor1, got %s", a.ActorID)
}
if a.NameValue != "Player" {
return fmt.Errorf("expected NameValue Player, got %s", a.NameValue)
}
return nil
},
},
{
name: "actor with all fields converts correctly",
apiActor: &v1.Actor{
Name: "stories/my-actor-1",
ActorId: "my-actor-1",
NameValue: "The Hero",
Role: "protagonist",
Notes: "Main character", Notes: "Main character",
CreateTime: timestamppb.New(now), CreateTime: timestamppb.New(now),
UpdateTime: timestamppb.New(now), UpdateTime: timestamppb.New(now),
Etag: "etag-123",
}, },
expectErr: false, expectErr: false,
checkFunc: func(a *DBActor) error { expectID: 0,
if a.ActorID != "my-actor-1" { checkFunc: func(s *DBActor) error {
return fmt.Errorf("expected ActorID my-actor-1, got %s", a.ActorID) if s.ActorID != "actor-1" {
return fmt.Errorf("expected ActorID actor-1, got %s", s.ActorID)
} }
if a.NameValue != "The Hero" { if s.NameValue != "John Doe" {
return fmt.Errorf("expected NameValue The Hero, got %s", a.NameValue) return fmt.Errorf("expected NameValue John Doe, got %s", s.NameValue)
} }
if a.Role != "protagonist" { if s.Role != "Hero" {
return fmt.Errorf("expected Role protagonist, got %s", a.Role) return fmt.Errorf("expected Role Hero, got %s", s.Role)
} }
if a.Notes != "Main character" { if s.Notes != "Main character" {
return fmt.Errorf("expected Notes Main character, got %s", a.Notes) return fmt.Errorf("expected Notes Main character, got %s", s.Notes)
}
if a.Etag != "etag-123" {
return fmt.Errorf("expected Etag etag-123, got %s", a.Etag)
}
return nil
},
},
{
name: "empty notes converted to empty string",
apiActor: &v1.Actor{
ActorId: "actor2",
NameValue: "NPC",
Notes: "",
},
expectErr: false,
checkFunc: func(a *DBActor) error {
if a.Notes != "" {
return fmt.Errorf("expected empty Notes, got %s", a.Notes)
}
return nil
},
},
{
name: "missing name_value returns error",
apiActor: &v1.Actor{
ActorId: "actor4",
},
expectErr: true,
errMsg: "name_value is required",
},
{
name: "invalid actor_id_short returns error",
apiActor: &v1.Actor{
NameValue: "Test",
},
expectErr: true,
errMsg: "invalid actor_id format: must be 4-63 characters, matching pattern /[a-z][0-9-][0-9]$/",
},
{
name: "invalid actor_id_uppercase returns error",
apiActor: &v1.Actor{
ActorId: "Actor2",
},
expectErr: true,
errMsg: "invalid actor_id format: must be 4-63 characters, matching pattern /[a-z][0-9-][0-9]$/",
},
{
name: "invalid actor_id_starts_with_number returns error",
apiActor: &v1.Actor{
ActorId: "1actor",
},
expectErr: true,
errMsg: "invalid actor_id format: must be 4-63 characters, matching pattern /[a-z][0-9-][0-9]$/",
},
{
name: "valid actor_id_with_hyphens_and_numbers",
apiActor: &v1.Actor{
NameValue: "Test Actor",
ActorId: "my-awesome-actor-123",
},
expectErr: false,
checkFunc: func(a *DBActor) error {
if a.ActorID != "my-awesome-actor-123" {
return fmt.Errorf("expected ActorID my-awesome-actor-123, got %s", a.ActorID)
} }
return nil return nil
}, },
@@ -139,15 +65,15 @@ func TestActorTranslator_FromAPI(t *testing.T) {
for _, tt := range tests { for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) { t.Run(tt.name, func(t *testing.T) {
translator := NewActorTranslator() trans := NewActorTranslator()
got, err := translator.FromAPI(tt.apiActor) result, err := trans.FromAPI(tt.apiActor)
if tt.expectErr { if tt.expectErr {
if err == nil { if err == nil {
t.Fatalf("expected error but got nil") t.Fatalf("expected error but got nil")
} }
if tt.errMsg != "" && !strings.Contains(err.Error(), tt.errMsg) { if tt.errMsg != "" && !strings.Contains(err.Error(), tt.errMsg) {
t.Errorf("expected error message to contain %q, got %q", tt.errMsg, err.Error()) t.Errorf("expected error containing '%s' but got '%s'", tt.errMsg, err.Error())
} }
return return
} }
@@ -156,11 +82,561 @@ func TestActorTranslator_FromAPI(t *testing.T) {
t.Fatalf("unexpected error: %v", err) 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 tt.checkFunc != nil {
if err := tt.checkFunc(got); err != nil { if err := tt.checkFunc(result); err != nil {
t.Errorf("checkFunc failed: %v", err) 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)
}
})
}
}
+1 -1
View File
@@ -115,7 +115,7 @@ CREATE TABLE IF NOT EXISTS actors (
-- Constraints -- Constraints
CONSTRAINT actors_story_actor_id_unique UNIQUE (story_id, actor_id), CONSTRAINT actors_story_actor_id_unique UNIQUE (story_id, actor_id),
CONSTRAINT actors_story_id_exists CHECK (story_id > 0), CONSTRAINT actors_story_id_exists CHECK (story_id > 0),
CONSTRAINT actors_actor_id_pattern CHECK (actor_id ~ '^[a-z][0-9-]{2,61}[0-9]$'), CONSTRAINT actors_actor_id_pattern CHECK (actor_id ~ '^[a-z][0-9a-z-]{2,61}[0-9a-z]$'),
CONSTRAINT actors_etag_pattern CHECK (char_length(etag) > 0) CONSTRAINT actors_etag_pattern CHECK (char_length(etag) > 0)
); );
Submodule
+1
Submodule styleguide added at c098353acb