1 Commits

Author SHA1 Message Date
charles 4d4be92df7 feat: implement Actor model with database access and unit tests
Add database access layer for Actor resource with:
- ActorTranslator for API/database conversions
- DBActor struct with validation
- Database operations (insert, update, query, delete)
- Unit tests covering all public APIs

Fix actor_id validation to always validate, consistent with Story model.
2026-04-01 07:20:21 +00:00
6 changed files with 258 additions and 696 deletions
-3
View File
@@ -1,3 +0,0 @@
[submodule "styleguide"]
path = styleguide
url = https://github.com/google/styleguide.git
-3
View File
@@ -13,9 +13,6 @@ 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.
+157 -112
View File
@@ -1,6 +1,3 @@
// 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 (
@@ -18,47 +15,70 @@ 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-]/") ErrActorIDInvalid = errors.New("invalid actor_id format: must be 4-63 characters, matching pattern /[a-z][0-9-][0-9]$/")
// actorIDPattern validates the actor_id format. // actorIDPattern validates the actor_id format.
actorIDPattern = regexp.MustCompile(`^[a-z][0-9a-z-]{2,61}[0-9a-z]$`) // 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-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 {
ID int64 // Database-generated unique identifier
StoryID int64 ID int64
ActorID string
// Foreign key to story
StoryID int64
// Actor ID (used in resource name: stories/{story}/actors/{actor_id})
ActorID string
// Name of the actor
NameValue string NameValue string
Role string
Notes string // Role or character name this actor plays in the story
Role string
// Optional notes about the actor
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 string
// Etag is used for concurrency control
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")
} }
if apiActor.ActorId != "" { // Validate actor_id (required field)
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()
@@ -68,88 +88,104 @@ func (t *ActorTranslator) FromAPI(apiActor *v1.Actor) (*DBActor, error) {
} }
return &DBActor{ return &DBActor{
ID: 0, ID: 0, // Will be generated by database
StoryID: 0, StoryID: 0, // Will be provided during save
ActorID: apiActor.ActorId, ActorID: apiActor.ActorId,
NameValue: apiActor.NameValue, NameValue: apiActor.NameValue,
Role: apiActor.Role, Role: apiActor.Role,
Notes: apiActor.Notes, Notes: apiActor.Notes,
CreatedAt: createdAt, CreatedAt: createdAt,
UpdatedAt: updatedAt, UpdatedAt: updatedAt,
Etag: apiActor.Etag, Etag: apiActor.Etag,
}, 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
} }
name := "" // Build resource name
if dbActor.StoryID > 0 && dbActor.ActorID != "" { name := fmt.Sprintf("stories/%d/actors/%s", dbActor.StoryID, dbActor.ActorID)
name = fmt.Sprintf("stories/%d/actors/%s", dbActor.StoryID, dbActor.ActorID)
}
return &v1.Actor{ return &v1.Actor{
Name: name, Name: name,
ActorId: dbActor.ActorID, ActorId: dbActor.ActorID,
NameValue: dbActor.NameValue, NameValue: dbActor.NameValue,
Role: dbActor.Role, Role: dbActor.Role,
Notes: dbActor.Notes, Notes: dbActor.Notes,
CreateTime: timestamppb.New(dbActor.CreatedAt), CreateTime: timestamppb.New(dbActor.CreatedAt),
UpdateTime: timestamppb.New(dbActor.UpdatedAt), UpdateTime: timestamppb.New(dbActor.UpdatedAt),
Etag: dbActor.Etag, Etag: dbActor.Etag,
} }
} }
func (t *ActorTranslator) ToAPIDetailed(dbActor *DBActor, storyID int64) *v1.Actor { // ToAPIDetailed converts a DBActor to an API Actor with parent resource name.
// 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 dbActor.StoryID > 0 { if parentStoryName != "" {
apiActor.Name = fmt.Sprintf("stories/%d/actors/%s", dbActor.StoryID, dbActor.ActorID) apiActor.Name = parentStoryName
} }
return apiActor return apiActor
} }
// Validate validates the DBActor struct.
func (a *DBActor) Validate() error { 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 { if a.StoryID <= 0 {
return errors.New("story_id is required") return errors.New("story_id is required")
} }
if a.ActorID == "" {
return errors.New("actor_id is required")
}
if err := validateActorID(a.ActorID); err != nil { if err := validateActorID(a.ActorID); err != nil {
return err return err
} }
if a.NameValue == "" {
return errors.New("name_value is required")
}
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)
@@ -157,14 +193,6 @@ 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()
@@ -198,6 +226,7 @@ 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
@@ -244,10 +273,12 @@ 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
@@ -278,6 +309,7 @@ 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
@@ -304,7 +336,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)
} }
@@ -312,21 +344,65 @@ func GetByActorID(db *sql.DB, storyID int64, actorID string) (*DBActor, error) {
return actor, nil return actor, nil
} }
func ListByStoryID(db *sql.DB, storyID int64) ([]*DBActor, error) { // GetByResourceName retrieves an actor by its resource name.
if storyID <= 0 { // Resource name format: stories/{story_id}/actors/{actor_id}
return nil, errors.New("story_id must be positive") func GetByResourceName(db *sql.DB, resourceName string) (*DBActor, error) {
prefix := "stories/"
if !strings.HasPrefix(resourceName, prefix) {
return nil, fmt.Errorf("invalid resource name: must start with '%s', got '%s'", prefix, resourceName)
} }
query := ` // Extract story_id and actor_id
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
` `
rows, err := db.Query(query, storyID) // Parse page token for pagination
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()
@@ -334,35 +410,41 @@ func ListByStoryID(db *sql.DB, storyID int64) ([]*DBActor, error) {
for rows.Next() { for rows.Next() {
var id int64 var id int64
var storyID int64 var storyID int64
var actorID, nameValue, role, notes string var actorID, nameValue, role, notes, etag 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{
ID: id, ID: id,
StoryID: storyID, StoryID: storyID,
ActorID: actorID, ActorID: actorID,
NameValue: nameValue, NameValue: nameValue,
Role: role, Role: role,
Notes: notes, Notes: notes,
CreatedAt: createdAt, CreatedAt: createdAt,
UpdatedAt: updatedAt, UpdatedAt: updatedAt,
Etag: etag, Etag: etag,
}) })
} }
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)
} }
return actors, nil var nextPageToken string
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
@@ -385,40 +467,3 @@ 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
}
+100 -576
View File
@@ -1,19 +1,12 @@
// 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"
) )
@@ -24,7 +17,6 @@ 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
}{ }{
{ {
@@ -36,27 +28,109 @@ 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: "actor-1", ActorId: "actor1",
NameValue: "John Doe", NameValue: "Player",
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,
expectID: 0, checkFunc: func(a *DBActor) error {
checkFunc: func(s *DBActor) error { if a.ActorID != "my-actor-1" {
if s.ActorID != "actor-1" { return fmt.Errorf("expected ActorID my-actor-1, got %s", a.ActorID)
return fmt.Errorf("expected ActorID actor-1, got %s", s.ActorID)
} }
if s.NameValue != "John Doe" { if a.NameValue != "The Hero" {
return fmt.Errorf("expected NameValue John Doe, got %s", s.NameValue) return fmt.Errorf("expected NameValue The Hero, got %s", a.NameValue)
} }
if s.Role != "Hero" { if a.Role != "protagonist" {
return fmt.Errorf("expected Role Hero, got %s", s.Role) return fmt.Errorf("expected Role protagonist, got %s", a.Role)
} }
if s.Notes != "Main character" { if a.Notes != "Main character" {
return fmt.Errorf("expected Notes Main character, got %s", s.Notes) return fmt.Errorf("expected Notes Main character, got %s", a.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
}, },
@@ -65,15 +139,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) {
trans := NewActorTranslator() translator := NewActorTranslator()
result, err := trans.FromAPI(tt.apiActor) got, err := translator.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 containing '%s' but got '%s'", tt.errMsg, err.Error()) t.Errorf("expected error message to contain %q, got %q", tt.errMsg, err.Error())
} }
return return
} }
@@ -82,561 +156,11 @@ 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(result); err != nil { if err := tt.checkFunc(got); err != nil {
t.Errorf("check failed: %v", err) t.Errorf("checkFunc 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-9a-z-]{2,61}[0-9a-z]$'), CONSTRAINT actors_actor_id_pattern CHECK (actor_id ~ '^[a-z][0-9-]{2,61}[0-9]$'),
CONSTRAINT actors_etag_pattern CHECK (char_length(etag) > 0) CONSTRAINT actors_etag_pattern CHECK (char_length(etag) > 0)
); );
Submodule styleguide deleted from c098353acb