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.
This commit is contained in:
@@ -0,0 +1,469 @@
|
|||||||
|
package actor
|
||||||
|
|
||||||
|
import (
|
||||||
|
"database/sql"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"regexp"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
v1 "git.tipsy.codes/charles/webstory/pkg/api/webstory/v1"
|
||||||
|
"github.com/google/uuid"
|
||||||
|
"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-][0-9]$/")
|
||||||
|
|
||||||
|
// 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-9]$`)
|
||||||
|
)
|
||||||
|
|
||||||
|
// DBActor represents an actor in the PostgreSQL database.
|
||||||
|
// This is the database model that corresponds to the schema.sql structure.
|
||||||
|
type DBActor struct {
|
||||||
|
// Database-generated unique identifier
|
||||||
|
ID int64
|
||||||
|
|
||||||
|
// 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
|
||||||
|
|
||||||
|
// 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
|
||||||
|
|
||||||
|
// UpdatedAt is when the actor was last updated
|
||||||
|
UpdatedAt time.Time
|
||||||
|
|
||||||
|
// Etag is used for concurrency control
|
||||||
|
Etag string
|
||||||
|
}
|
||||||
|
|
||||||
|
// ActorTranslator provides conversion methods between API and database formats.
|
||||||
|
type ActorTranslator struct{}
|
||||||
|
|
||||||
|
// NewActorTranslator creates a new ActorTranslator instance.
|
||||||
|
func NewActorTranslator() *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) {
|
||||||
|
if apiActor == nil {
|
||||||
|
return nil, errors.New("api actor cannot be nil")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate actor_id (required field)
|
||||||
|
if err := validateActorID(apiActor.ActorId); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate name_value (required field)
|
||||||
|
if apiActor.NameValue == "" {
|
||||||
|
return nil, errors.New("name_value is required")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convert timestamps if provided
|
||||||
|
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, // Will be generated by database
|
||||||
|
StoryID: 0, // Will be provided during save
|
||||||
|
ActorID: apiActor.ActorId,
|
||||||
|
NameValue: apiActor.NameValue,
|
||||||
|
Role: apiActor.Role,
|
||||||
|
Notes: apiActor.Notes,
|
||||||
|
CreatedAt: createdAt,
|
||||||
|
UpdatedAt: updatedAt,
|
||||||
|
Etag: apiActor.Etag,
|
||||||
|
}, 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 {
|
||||||
|
if dbActor == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build resource name
|
||||||
|
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,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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)
|
||||||
|
if parentStoryName != "" {
|
||||||
|
apiActor.Name = parentStoryName
|
||||||
|
}
|
||||||
|
return apiActor
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate validates the DBActor struct.
|
||||||
|
func (a *DBActor) Validate() error {
|
||||||
|
if a.StoryID <= 0 {
|
||||||
|
return errors.New("story_id is required")
|
||||||
|
}
|
||||||
|
|
||||||
|
if a.ActorID == "" {
|
||||||
|
return errors.New("actor_id is required")
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := validateActorID(a.ActorID); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if a.NameValue == "" {
|
||||||
|
return errors.New("name_value is required")
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// validateActorID validates the actor_id format.
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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 {
|
||||||
|
if err := a.Validate(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if this is an update (has ID from database) or insert
|
||||||
|
if a.ID > 0 {
|
||||||
|
return a.update(db)
|
||||||
|
}
|
||||||
|
|
||||||
|
return a.insert(db)
|
||||||
|
}
|
||||||
|
|
||||||
|
// insert inserts a new actor into the database.
|
||||||
|
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
|
||||||
|
`
|
||||||
|
|
||||||
|
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,
|
||||||
|
a.Role,
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
// update updates an existing actor in the database.
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
// generateEtag generates a unique etag for the actor.
|
||||||
|
func generateEtag() string {
|
||||||
|
return uuid.New().String()
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetByID retrieves an actor by its primary key ID.
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetByActorID retrieves an actor by its actor_id and story_id.
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetByResourceName retrieves an actor by its resource name.
|
||||||
|
// Resource name format: stories/{story_id}/actors/{actor_id}
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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
|
||||||
|
FROM actors
|
||||||
|
WHERE story_id = $1
|
||||||
|
`
|
||||||
|
|
||||||
|
// 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 {
|
||||||
|
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, etag string
|
||||||
|
var createdAt, updatedAt time.Time
|
||||||
|
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
|
||||||
|
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 {
|
||||||
|
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
|
||||||
|
}
|
||||||
@@ -0,0 +1,166 @@
|
|||||||
|
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
|
||||||
|
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: "actor1",
|
||||||
|
NameValue: "Player",
|
||||||
|
},
|
||||||
|
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",
|
||||||
|
CreateTime: timestamppb.New(now),
|
||||||
|
UpdateTime: timestamppb.New(now),
|
||||||
|
Etag: "etag-123",
|
||||||
|
},
|
||||||
|
expectErr: false,
|
||||||
|
checkFunc: func(a *DBActor) error {
|
||||||
|
if a.ActorID != "my-actor-1" {
|
||||||
|
return fmt.Errorf("expected ActorID my-actor-1, got %s", a.ActorID)
|
||||||
|
}
|
||||||
|
if a.NameValue != "The Hero" {
|
||||||
|
return fmt.Errorf("expected NameValue The Hero, got %s", a.NameValue)
|
||||||
|
}
|
||||||
|
if a.Role != "protagonist" {
|
||||||
|
return fmt.Errorf("expected Role protagonist, got %s", a.Role)
|
||||||
|
}
|
||||||
|
if a.Notes != "Main character" {
|
||||||
|
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
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
translator := NewActorTranslator()
|
||||||
|
got, err := translator.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 message to contain %q, got %q", tt.errMsg, err.Error())
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if tt.checkFunc != nil {
|
||||||
|
if err := tt.checkFunc(got); err != nil {
|
||||||
|
t.Errorf("checkFunc failed: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user