Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 0308c70061 | |||
| 789e32a57f | |||
| 9720517a8c | |||
| a9a3556b18 | |||
| 6d3db74bf4 |
@@ -0,0 +1,3 @@
|
||||
[submodule "styleguide"]
|
||||
path = styleguide
|
||||
url = https://github.com/google/styleguide.git
|
||||
@@ -13,6 +13,9 @@ Before committing changes, run `make test`. The tests must pass.
|
||||
|
||||
## 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
|
||||
init`, and so forth.
|
||||
|
||||
|
||||
+80
-125
@@ -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
|
||||
|
||||
import (
|
||||
@@ -15,70 +18,47 @@ import (
|
||||
|
||||
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]$/")
|
||||
ErrActorIDInvalid = errors.New("invalid actor_id format: must be 4-63 characters, matching pattern /[a-z][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]$`)
|
||||
actorIDPattern = regexp.MustCompile(`^[a-z][0-9a-z-]{2,61}[0-9a-z]$`)
|
||||
)
|
||||
|
||||
// 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 apiActor.ActorId != "" {
|
||||
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()
|
||||
@@ -88,8 +68,8 @@ func (t *ActorTranslator) FromAPI(apiActor *v1.Actor) (*DBActor, error) {
|
||||
}
|
||||
|
||||
return &DBActor{
|
||||
ID: 0, // Will be generated by database
|
||||
StoryID: 0, // Will be provided during save
|
||||
ID: 0,
|
||||
StoryID: 0,
|
||||
ActorID: apiActor.ActorId,
|
||||
NameValue: apiActor.NameValue,
|
||||
Role: apiActor.Role,
|
||||
@@ -100,15 +80,15 @@ func (t *ActorTranslator) FromAPI(apiActor *v1.Actor) (*DBActor, error) {
|
||||
}, 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)
|
||||
name := ""
|
||||
if dbActor.StoryID > 0 && dbActor.ActorID != "" {
|
||||
name = fmt.Sprintf("stories/%d/actors/%s", dbActor.StoryID, dbActor.ActorID)
|
||||
}
|
||||
|
||||
return &v1.Actor{
|
||||
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.
|
||||
// This is used when loading actors with their parent story.
|
||||
func (t *ActorTranslator) ToAPIDetailed(dbActor *DBActor, parentStoryName string) *v1.Actor {
|
||||
func (t *ActorTranslator) ToAPIDetailed(dbActor *DBActor, storyID int64) *v1.Actor {
|
||||
apiActor := t.ToAPI(dbActor)
|
||||
if parentStoryName != "" {
|
||||
apiActor.Name = parentStoryName
|
||||
if dbActor.StoryID > 0 {
|
||||
apiActor.Name = fmt.Sprintf("stories/%d/actors/%s", dbActor.StoryID, dbActor.ActorID)
|
||||
}
|
||||
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")
|
||||
}
|
||||
|
||||
if a.StoryID <= 0 {
|
||||
return errors.New("story_id is required")
|
||||
}
|
||||
if err := validateActorID(a.ActorID); err != nil {
|
||||
return err
|
||||
}
|
||||
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)
|
||||
@@ -193,6 +157,14 @@ func (a *DBActor) insert(db *sql.DB) error {
|
||||
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()
|
||||
@@ -226,7 +198,6 @@ func (a *DBActor) insert(db *sql.DB) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// update updates an existing actor in the database.
|
||||
func (a *DBActor) update(db *sql.DB) error {
|
||||
query := `
|
||||
UPDATE actors
|
||||
@@ -273,12 +244,10 @@ func (a *DBActor) update(db *sql.DB) error {
|
||||
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
|
||||
@@ -309,7 +278,6 @@ func GetByID(db *sql.DB, id int64) (*DBActor, error) {
|
||||
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
|
||||
@@ -336,7 +304,7 @@ func GetByActorID(db *sql.DB, storyID int64, actorID string) (*DBActor, error) {
|
||||
|
||||
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("actor with story_id %d and actor_id '%s' not found", storyID, actorID)
|
||||
}
|
||||
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
|
||||
}
|
||||
|
||||
// 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)
|
||||
func ListByStoryID(db *sql.DB, storyID int64) ([]*DBActor, error) {
|
||||
if storyID <= 0 {
|
||||
return nil, errors.New("story_id must be positive")
|
||||
}
|
||||
|
||||
// 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 = `
|
||||
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
|
||||
`
|
||||
|
||||
// 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...)
|
||||
rows, err := db.Query(query, storyID)
|
||||
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()
|
||||
|
||||
@@ -410,12 +334,13 @@ func ListByStoryID(db *sql.DB, storyID int64, pageSize int, pageToken string) ([
|
||||
for rows.Next() {
|
||||
var id int64
|
||||
var storyID int64
|
||||
var actorID, nameValue, role, notes, etag string
|
||||
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)
|
||||
return nil, fmt.Errorf("failed to scan actor: %w", err)
|
||||
}
|
||||
|
||||
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 {
|
||||
return nil, "", fmt.Errorf("error iterating actors: %w", err)
|
||||
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, nil
|
||||
}
|
||||
|
||||
return actors, nextPageToken, nil
|
||||
}
|
||||
|
||||
// Delete removes an actor from the database.
|
||||
func Delete(db *sql.DB, id int64) error {
|
||||
query := `
|
||||
DELETE FROM actors
|
||||
@@ -467,3 +385,40 @@ func Delete(db *sql.DB, id int64) error {
|
||||
|
||||
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
@@ -1,12 +1,19 @@
|
||||
// Package actor provides database access for the Actor resource.
|
||||
package actor
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
v1 "git.tipsy.codes/charles/webstory/pkg/api/webstory/v1"
|
||||
"git.tipsy.codes/charles/webstory/pkg/database/schema"
|
||||
|
||||
story "git.tipsy.codes/charles/webstory/pkg/database/story"
|
||||
embeddedpostgres "github.com/fergusstrange/embedded-postgres"
|
||||
"google.golang.org/protobuf/types/known/timestamppb"
|
||||
)
|
||||
|
||||
@@ -17,6 +24,7 @@ func TestActorTranslator_FromAPI(t *testing.T) {
|
||||
apiActor *v1.Actor
|
||||
expectErr bool
|
||||
errMsg string
|
||||
expectID int64
|
||||
checkFunc func(*DBActor) error
|
||||
}{
|
||||
{
|
||||
@@ -28,109 +36,27 @@ func TestActorTranslator_FromAPI(t *testing.T) {
|
||||
{
|
||||
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",
|
||||
ActorId: "actor-1",
|
||||
NameValue: "John Doe",
|
||||
Role: "Hero",
|
||||
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)
|
||||
expectID: 0,
|
||||
checkFunc: func(s *DBActor) error {
|
||||
if s.ActorID != "actor-1" {
|
||||
return fmt.Errorf("expected ActorID actor-1, got %s", s.ActorID)
|
||||
}
|
||||
if a.NameValue != "The Hero" {
|
||||
return fmt.Errorf("expected NameValue The Hero, got %s", a.NameValue)
|
||||
if s.NameValue != "John Doe" {
|
||||
return fmt.Errorf("expected NameValue John Doe, got %s", s.NameValue)
|
||||
}
|
||||
if a.Role != "protagonist" {
|
||||
return fmt.Errorf("expected Role protagonist, got %s", a.Role)
|
||||
if s.Role != "Hero" {
|
||||
return fmt.Errorf("expected Role Hero, got %s", s.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)
|
||||
if s.Notes != "Main character" {
|
||||
return fmt.Errorf("expected Notes Main character, got %s", s.Notes)
|
||||
}
|
||||
return nil
|
||||
},
|
||||
@@ -139,15 +65,15 @@ func TestActorTranslator_FromAPI(t *testing.T) {
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
translator := NewActorTranslator()
|
||||
got, err := translator.FromAPI(tt.apiActor)
|
||||
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 message to contain %q, got %q", tt.errMsg, err.Error())
|
||||
t.Errorf("expected error containing '%s' but got '%s'", tt.errMsg, err.Error())
|
||||
}
|
||||
return
|
||||
}
|
||||
@@ -156,11 +82,561 @@ func TestActorTranslator_FromAPI(t *testing.T) {
|
||||
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(got); err != nil {
|
||||
t.Errorf("checkFunc failed: %v", err)
|
||||
if err := tt.checkFunc(result); err != nil {
|
||||
t.Errorf("check failed: %v", err)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestActorTranslator_ToAPI(t *testing.T) {
|
||||
now := time.Now()
|
||||
tests := []struct {
|
||||
name string
|
||||
dbActor *DBActor
|
||||
checkFunc func(*v1.Actor) error
|
||||
}{
|
||||
{
|
||||
name: "nil dbActor returns nil",
|
||||
dbActor: nil,
|
||||
checkFunc: func(a *v1.Actor) error {
|
||||
if a != nil {
|
||||
return fmt.Errorf("expected nil, got %v", a)
|
||||
}
|
||||
return nil
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "minimal dbActor converts correctly",
|
||||
dbActor: &DBActor{
|
||||
ID: 1,
|
||||
StoryID: 100,
|
||||
ActorID: "actor-1",
|
||||
NameValue: "John Doe",
|
||||
Role: "Hero",
|
||||
Notes: "Main character",
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
Etag: "etag-123",
|
||||
},
|
||||
checkFunc: func(a *v1.Actor) error {
|
||||
if a.ActorId != "actor-1" {
|
||||
return fmt.Errorf("expected ActorId actor-1, got %s", a.ActorId)
|
||||
}
|
||||
if a.NameValue != "John Doe" {
|
||||
return fmt.Errorf("expected NameValue John Doe, got %s", a.NameValue)
|
||||
}
|
||||
if a.Role != "Hero" {
|
||||
return fmt.Errorf("expected Role Hero, got %s", a.Role)
|
||||
}
|
||||
if a.Notes != "Main character" {
|
||||
return fmt.Errorf("expected Notes Main character, got %s", a.Notes)
|
||||
}
|
||||
return nil
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "dbActor with story_id and actor_id creates correct name",
|
||||
dbActor: &DBActor{
|
||||
ID: 2,
|
||||
StoryID: 200,
|
||||
ActorID: "actor-2",
|
||||
NameValue: "Jane Smith",
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
},
|
||||
checkFunc: func(a *v1.Actor) error {
|
||||
if a.Name != "stories/200/actors/actor-2" {
|
||||
return fmt.Errorf("expected name stories/200/actors/actor-2, got %s", a.Name)
|
||||
}
|
||||
if a.ActorId != "actor-2" {
|
||||
return fmt.Errorf("expected ActorId actor-2, got %s", a.ActorId)
|
||||
}
|
||||
return nil
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "dbActor with zero story_id does not create name",
|
||||
dbActor: &DBActor{
|
||||
ID: 3,
|
||||
StoryID: 0,
|
||||
ActorID: "actor-3",
|
||||
NameValue: "Test Actor",
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
},
|
||||
checkFunc: func(a *v1.Actor) error {
|
||||
if a.Name != "" {
|
||||
return fmt.Errorf("expected empty name, got %s", a.Name)
|
||||
}
|
||||
return nil
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
trans := NewActorTranslator()
|
||||
result := trans.ToAPI(tt.dbActor)
|
||||
|
||||
if tt.checkFunc != nil {
|
||||
if err := tt.checkFunc(result); err != nil {
|
||||
t.Errorf("check failed: %v", err)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestDBActor_Save(t *testing.T) {
|
||||
server := embeddedpostgres.NewDatabase(embeddedpostgres.DefaultConfig().Port(5432))
|
||||
err := server.Start()
|
||||
if err != nil {
|
||||
t.Fatalf("failed to start embedded postgres: %v", err)
|
||||
}
|
||||
defer func() {
|
||||
if err := server.Stop(); err != nil {
|
||||
t.Errorf("failed to stop embedded postgres: %v", err)
|
||||
}
|
||||
}()
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
db, err := sql.Open("postgres", "postgres://postgres:postgres@127.0.0.1:5432/postgres?sslmode=disable")
|
||||
if err != nil {
|
||||
t.Fatalf("failed to connect to database: %v", err)
|
||||
}
|
||||
defer db.Close()
|
||||
|
||||
_, err = db.ExecContext(ctx, string(schema.Bytes))
|
||||
if err != nil {
|
||||
t.Fatalf("failed to run schema: %v", err)
|
||||
}
|
||||
|
||||
// Create a story first
|
||||
story := &story.DBStory{
|
||||
StoryID: "save-test-story-1",
|
||||
Title: "Save Test Story",
|
||||
}
|
||||
err = story.Save(db)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to save story: %v", err)
|
||||
}
|
||||
|
||||
actor := &DBActor{
|
||||
StoryID: story.ID,
|
||||
ActorID: "save-test-1",
|
||||
NameValue: "Save Test",
|
||||
Role: "Test Role",
|
||||
Notes: "Test notes",
|
||||
}
|
||||
|
||||
err = actor.Save(db)
|
||||
if err != nil {
|
||||
t.Fatalf("Save failed: %v", err)
|
||||
}
|
||||
|
||||
if actor.ID <= 0 {
|
||||
t.Errorf("expected positive ID, got %d", actor.ID)
|
||||
}
|
||||
|
||||
var dbActor DBActor
|
||||
err = db.QueryRow(
|
||||
`SELECT id, story_id, actor_id, name_value, role, notes, created_at, updated_at, etag FROM actors WHERE id = $1`,
|
||||
actor.ID,
|
||||
).Scan(
|
||||
&dbActor.ID, &dbActor.StoryID, &dbActor.ActorID, &dbActor.NameValue,
|
||||
&dbActor.Role, &dbActor.Notes, &dbActor.CreatedAt, &dbActor.UpdatedAt, &dbActor.Etag,
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to query actor: %v", err)
|
||||
}
|
||||
|
||||
if dbActor.ActorID != actor.ActorID {
|
||||
t.Errorf("actor_id mismatch: expected %s, got %s", actor.ActorID, dbActor.ActorID)
|
||||
}
|
||||
if dbActor.NameValue != actor.NameValue {
|
||||
t.Errorf("name_value mismatch: expected %s, got %s", actor.NameValue, dbActor.NameValue)
|
||||
}
|
||||
if dbActor.Role != actor.Role {
|
||||
t.Errorf("role mismatch: expected %s, got %s", actor.Role, dbActor.Role)
|
||||
}
|
||||
if dbActor.Notes != actor.Notes {
|
||||
t.Errorf("notes mismatch: expected %s, got %s", actor.Notes, dbActor.Notes)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDBActor_GetByID(t *testing.T) {
|
||||
server := embeddedpostgres.NewDatabase(embeddedpostgres.DefaultConfig().Port(5432))
|
||||
err := server.Start()
|
||||
if err != nil {
|
||||
t.Fatalf("failed to start embedded postgres: %v", err)
|
||||
}
|
||||
defer func() {
|
||||
if err := server.Stop(); err != nil {
|
||||
t.Errorf("failed to stop embedded postgres: %v", err)
|
||||
}
|
||||
}()
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
db, err := sql.Open("postgres", "postgres://postgres:postgres@127.0.0.1:5432/postgres?sslmode=disable")
|
||||
if err != nil {
|
||||
t.Fatalf("failed to connect to database: %v", err)
|
||||
}
|
||||
defer db.Close()
|
||||
|
||||
_, err = db.ExecContext(ctx, string(schema.Bytes))
|
||||
if err != nil {
|
||||
t.Fatalf("failed to run schema: %v", err)
|
||||
}
|
||||
|
||||
// Create a story first
|
||||
story := &story.DBStory{
|
||||
StoryID: "get-by-id-story-1",
|
||||
Title: "Get By ID Story",
|
||||
}
|
||||
err = story.Save(db)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to save story: %v", err)
|
||||
}
|
||||
|
||||
actor := &DBActor{
|
||||
StoryID: story.ID,
|
||||
ActorID: "get-by-id-test-1",
|
||||
NameValue: "Get Test",
|
||||
}
|
||||
err = actor.Save(db)
|
||||
if err != nil {
|
||||
t.Fatalf("Save failed: %v", err)
|
||||
}
|
||||
|
||||
result, err := GetByID(db, actor.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("GetByID failed: %v", err)
|
||||
}
|
||||
|
||||
if result.ID != actor.ID {
|
||||
t.Errorf("ID mismatch: expected %d, got %d", actor.ID, result.ID)
|
||||
}
|
||||
if result.NameValue != actor.NameValue {
|
||||
t.Errorf("name_value mismatch: expected %s, got %s", actor.NameValue, result.NameValue)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDBActor_GetByActorID(t *testing.T) {
|
||||
server := embeddedpostgres.NewDatabase(embeddedpostgres.DefaultConfig().Port(5432))
|
||||
err := server.Start()
|
||||
if err != nil {
|
||||
t.Fatalf("failed to start embedded postgres: %v", err)
|
||||
}
|
||||
defer func() {
|
||||
if err := server.Stop(); err != nil {
|
||||
t.Errorf("failed to stop embedded postgres: %v", err)
|
||||
}
|
||||
}()
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
db, err := sql.Open("postgres", "postgres://postgres:postgres@127.0.0.1:5432/postgres?sslmode=disable")
|
||||
if err != nil {
|
||||
t.Fatalf("failed to connect to database: %v", err)
|
||||
}
|
||||
defer db.Close()
|
||||
|
||||
_, err = db.ExecContext(ctx, string(schema.Bytes))
|
||||
if err != nil {
|
||||
t.Fatalf("failed to run schema: %v", err)
|
||||
}
|
||||
|
||||
// Create a story first
|
||||
story := &story.DBStory{
|
||||
StoryID: "get-by-actor-id-story-1",
|
||||
Title: "Get By Actor ID Story",
|
||||
}
|
||||
err = story.Save(db)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to save story: %v", err)
|
||||
}
|
||||
|
||||
actor := &DBActor{
|
||||
StoryID: story.ID,
|
||||
ActorID: "get-by-actor-id-test-1",
|
||||
NameValue: "Get By Actor ID Test",
|
||||
}
|
||||
err = actor.Save(db)
|
||||
if err != nil {
|
||||
t.Fatalf("Save failed: %v", err)
|
||||
}
|
||||
|
||||
result, err := GetByActorID(db, actor.StoryID, actor.ActorID)
|
||||
if err != nil {
|
||||
t.Fatalf("GetByActorID failed: %v", err)
|
||||
}
|
||||
|
||||
if result.NameValue != actor.NameValue {
|
||||
t.Errorf("name_value mismatch: expected %s, got %s", actor.NameValue, result.NameValue)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDBActor_ListByStoryID(t *testing.T) {
|
||||
server := embeddedpostgres.NewDatabase(embeddedpostgres.DefaultConfig().Port(5432))
|
||||
err := server.Start()
|
||||
if err != nil {
|
||||
t.Fatalf("failed to start embedded postgres: %v", err)
|
||||
}
|
||||
defer func() {
|
||||
if err := server.Stop(); err != nil {
|
||||
t.Errorf("failed to stop embedded postgres: %v", err)
|
||||
}
|
||||
}()
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
db, err := sql.Open("postgres", "postgres://postgres:postgres@127.0.0.1:5432/postgres?sslmode=disable")
|
||||
if err != nil {
|
||||
t.Fatalf("failed to connect to database: %v", err)
|
||||
}
|
||||
defer db.Close()
|
||||
|
||||
_, err = db.ExecContext(ctx, string(schema.Bytes))
|
||||
if err != nil {
|
||||
t.Fatalf("failed to run schema: %v", err)
|
||||
}
|
||||
|
||||
// Create a story first
|
||||
story := &story.DBStory{
|
||||
StoryID: "list-test-story-1",
|
||||
Title: "List Test Story",
|
||||
}
|
||||
err = story.Save(db)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to save story: %v", err)
|
||||
}
|
||||
|
||||
actor1 := &DBActor{
|
||||
StoryID: story.ID,
|
||||
ActorID: "list-test-1",
|
||||
NameValue: "Actor One",
|
||||
Role: "Role One",
|
||||
}
|
||||
actor2 := &DBActor{
|
||||
StoryID: story.ID,
|
||||
ActorID: "list-test-2",
|
||||
NameValue: "Actor Two",
|
||||
Role: "Role Two",
|
||||
}
|
||||
err = actor1.Save(db)
|
||||
if err != nil {
|
||||
t.Fatalf("Save failed: %v", err)
|
||||
}
|
||||
err = actor2.Save(db)
|
||||
if err != nil {
|
||||
t.Fatalf("Save failed: %v", err)
|
||||
}
|
||||
|
||||
actors, err := ListByStoryID(db, 1)
|
||||
if err != nil {
|
||||
t.Fatalf("ListByStoryID failed: %v", err)
|
||||
}
|
||||
|
||||
if len(actors) != 2 {
|
||||
t.Errorf("expected 2 actors, got %d", len(actors))
|
||||
}
|
||||
|
||||
// Verify they're sorted alphabetically
|
||||
if actors[0].NameValue != "Actor One" || actors[1].NameValue != "Actor Two" {
|
||||
t.Errorf("actors are not in alphabetical order")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDBActor_Delete(t *testing.T) {
|
||||
server := embeddedpostgres.NewDatabase(embeddedpostgres.DefaultConfig().Port(5432))
|
||||
err := server.Start()
|
||||
if err != nil {
|
||||
t.Fatalf("failed to start embedded postgres: %v", err)
|
||||
}
|
||||
defer func() {
|
||||
if err := server.Stop(); err != nil {
|
||||
t.Errorf("failed to stop embedded postgres: %v", err)
|
||||
}
|
||||
}()
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
db, err := sql.Open("postgres", "postgres://postgres:postgres@127.0.0.1:5432/postgres?sslmode=disable")
|
||||
if err != nil {
|
||||
t.Fatalf("failed to connect to database: %v", err)
|
||||
}
|
||||
defer db.Close()
|
||||
|
||||
_, err = db.ExecContext(ctx, string(schema.Bytes))
|
||||
if err != nil {
|
||||
t.Fatalf("failed to run schema: %v", err)
|
||||
}
|
||||
|
||||
// Create a story first
|
||||
story := &story.DBStory{
|
||||
StoryID: "delete-test-story-1",
|
||||
Title: "Delete Test Story",
|
||||
}
|
||||
err = story.Save(db)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to save story: %v", err)
|
||||
}
|
||||
|
||||
actor := &DBActor{
|
||||
StoryID: story.ID,
|
||||
ActorID: "delete-test-actor-1",
|
||||
NameValue: "Delete Test",
|
||||
}
|
||||
err = actor.Save(db)
|
||||
if err != nil {
|
||||
t.Fatalf("Save failed: %v", err)
|
||||
}
|
||||
|
||||
// Verify actor exists
|
||||
var count int
|
||||
err = db.QueryRow("SELECT COUNT(*) FROM actors WHERE id = $1", actor.ID).Scan(&count)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to count actors: %v", err)
|
||||
}
|
||||
if count != 1 {
|
||||
t.Errorf("expected 1 actor, got %d", count)
|
||||
}
|
||||
|
||||
err = Delete(db, actor.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("Delete failed: %v", err)
|
||||
}
|
||||
|
||||
// Verify actor is deleted
|
||||
err = db.QueryRow("SELECT COUNT(*) FROM actors WHERE id = $1", actor.ID).Scan(&count)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to count actors after deletion: %v", err)
|
||||
}
|
||||
if count != 0 {
|
||||
t.Errorf("expected 0 actors after deletion, got %d", count)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDBActor_GetActorIDForResourceName(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
input string
|
||||
storyID int64
|
||||
actorID string
|
||||
expectErr bool
|
||||
}{
|
||||
{
|
||||
name: "valid story/actor resource name",
|
||||
input: "stories/1/actors/my-actor",
|
||||
storyID: 1,
|
||||
actorID: "my-actor",
|
||||
expectErr: false,
|
||||
},
|
||||
{
|
||||
name: "valid story/actor resource name with numbers",
|
||||
input: "stories/100/actors/actor-123",
|
||||
storyID: 100,
|
||||
actorID: "actor-123",
|
||||
expectErr: false,
|
||||
},
|
||||
{
|
||||
name: "missing actors/ prefix",
|
||||
input: "stories/1/actor/my-actor",
|
||||
storyID: 1,
|
||||
actorID: "",
|
||||
expectErr: true,
|
||||
},
|
||||
{
|
||||
name: "actor_id with underscore not allowed",
|
||||
input: "stories/1/actors/my_actor",
|
||||
storyID: 1,
|
||||
actorID: "",
|
||||
expectErr: true,
|
||||
},
|
||||
{
|
||||
name: "actor_id with spaces not allowed",
|
||||
input: "stories/1/actors/my actor",
|
||||
storyID: 1,
|
||||
actorID: "",
|
||||
expectErr: true,
|
||||
},
|
||||
{
|
||||
name: "actor_id with uppercase not allowed",
|
||||
input: "stories/1/actors/MyActor",
|
||||
storyID: 1,
|
||||
actorID: "",
|
||||
expectErr: true,
|
||||
},
|
||||
{
|
||||
name: "actor_id with dots not allowed",
|
||||
input: "stories/1/actors/my.actor",
|
||||
storyID: 1,
|
||||
actorID: "",
|
||||
expectErr: true,
|
||||
},
|
||||
{
|
||||
name: "missing stories/ prefix",
|
||||
input: "actors/my-actor",
|
||||
storyID: 1,
|
||||
actorID: "",
|
||||
expectErr: true,
|
||||
},
|
||||
{
|
||||
name: "empty actor_id",
|
||||
input: "stories/1/actors/",
|
||||
storyID: 1,
|
||||
actorID: "",
|
||||
expectErr: true,
|
||||
},
|
||||
{
|
||||
name: "wrong stories/ prefix",
|
||||
input: "story/1/actors/my-actor",
|
||||
storyID: 1,
|
||||
actorID: "",
|
||||
expectErr: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
storyID, actorID, err := GetActorIDForResourceName(tt.input)
|
||||
|
||||
if tt.expectErr {
|
||||
if err == nil {
|
||||
t.Fatalf("expected error but got nil")
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
if storyID != tt.storyID {
|
||||
t.Errorf("expected story_id %d, got %d", tt.storyID, storyID)
|
||||
}
|
||||
|
||||
if actorID != tt.actorID {
|
||||
t.Errorf("expected actor_id %s, got %s", tt.actorID, actorID)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -115,7 +115,7 @@ CREATE TABLE IF NOT EXISTS actors (
|
||||
-- Constraints
|
||||
CONSTRAINT actors_story_actor_id_unique UNIQUE (story_id, actor_id),
|
||||
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)
|
||||
);
|
||||
|
||||
|
||||
Submodule
+1
Submodule styleguide added at c098353acb
Reference in New Issue
Block a user