Add Actor model with unit tests and integration tests
Add Actor model implementation in pkg/database/actor/actor.go with associated unit tests in actor_test.go. Implement tests following the pattern used for Story model, including Postgres integration tests.
This commit is contained in:
@@ -0,0 +1,424 @@
|
||||
// Package actor provides database access for the Actor resource.
|
||||
// It defines the DBActor struct and ActorTranslator to convert between
|
||||
// the API format (api.pb.go) and the database format (schema.sql).
|
||||
package actor
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
v1 "git.tipsy.codes/charles/webstory/pkg/api/webstory/v1"
|
||||
"github.com/google/uuid"
|
||||
"github.com/lib/pq"
|
||||
"google.golang.org/protobuf/types/known/timestamppb"
|
||||
)
|
||||
|
||||
var (
|
||||
// ErrActorIDInvalid is returned when the actor_id format is invalid.
|
||||
ErrActorIDInvalid = errors.New("invalid actor_id format: must be 4-63 characters, matching pattern /[a-z][0-9-]/")
|
||||
|
||||
// actorIDPattern validates the actor_id format.
|
||||
actorIDPattern = regexp.MustCompile(`^[a-z][0-9a-z-]{2,61}[0-9a-z]$`)
|
||||
)
|
||||
|
||||
// DBActor represents an actor in the PostgreSQL database.
|
||||
type DBActor struct {
|
||||
ID int64
|
||||
StoryID int64
|
||||
ActorID string
|
||||
NameValue string
|
||||
Role string
|
||||
Notes string
|
||||
CreatedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
Etag string
|
||||
}
|
||||
|
||||
// ActorTranslator provides conversion methods between API and database formats.
|
||||
type ActorTranslator struct{}
|
||||
|
||||
func NewActorTranslator() *ActorTranslator {
|
||||
return &ActorTranslator{}
|
||||
}
|
||||
|
||||
func (t *ActorTranslator) FromAPI(apiActor *v1.Actor) (*DBActor, error) {
|
||||
if apiActor == nil {
|
||||
return nil, errors.New("api actor cannot be nil")
|
||||
}
|
||||
|
||||
if apiActor.ActorId != "" {
|
||||
if err := validateActorID(apiActor.ActorId); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
if apiActor.NameValue == "" {
|
||||
return nil, errors.New("name_value is required")
|
||||
}
|
||||
|
||||
var createdAt, updatedAt time.Time
|
||||
if apiActor.GetCreateTime() != nil {
|
||||
createdAt = apiActor.CreateTime.AsTime()
|
||||
}
|
||||
if apiActor.GetUpdateTime() != nil {
|
||||
updatedAt = apiActor.UpdateTime.AsTime()
|
||||
}
|
||||
|
||||
return &DBActor{
|
||||
ID: 0,
|
||||
StoryID: 0,
|
||||
ActorID: apiActor.ActorId,
|
||||
NameValue: apiActor.NameValue,
|
||||
Role: apiActor.Role,
|
||||
Notes: apiActor.Notes,
|
||||
CreatedAt: createdAt,
|
||||
UpdatedAt: updatedAt,
|
||||
Etag: apiActor.Etag,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (t *ActorTranslator) ToAPI(dbActor *DBActor) *v1.Actor {
|
||||
if dbActor == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
name := ""
|
||||
if dbActor.StoryID > 0 && dbActor.ActorID != "" {
|
||||
name = fmt.Sprintf("stories/%d/actors/%s", dbActor.StoryID, dbActor.ActorID)
|
||||
}
|
||||
|
||||
return &v1.Actor{
|
||||
Name: name,
|
||||
ActorId: dbActor.ActorID,
|
||||
NameValue: dbActor.NameValue,
|
||||
Role: dbActor.Role,
|
||||
Notes: dbActor.Notes,
|
||||
CreateTime: timestamppb.New(dbActor.CreatedAt),
|
||||
UpdateTime: timestamppb.New(dbActor.UpdatedAt),
|
||||
Etag: dbActor.Etag,
|
||||
}
|
||||
}
|
||||
|
||||
func (t *ActorTranslator) ToAPIDetailed(dbActor *DBActor, storyID int64) *v1.Actor {
|
||||
apiActor := t.ToAPI(dbActor)
|
||||
if dbActor.StoryID > 0 {
|
||||
apiActor.Name = fmt.Sprintf("stories/%d/actors/%s", dbActor.StoryID, dbActor.ActorID)
|
||||
}
|
||||
return apiActor
|
||||
}
|
||||
|
||||
func (a *DBActor) Validate() error {
|
||||
if a.ActorID == "" {
|
||||
return errors.New("actor_id is required")
|
||||
}
|
||||
if a.NameValue == "" {
|
||||
return errors.New("name_value is required")
|
||||
}
|
||||
if a.StoryID <= 0 {
|
||||
return errors.New("story_id is required")
|
||||
}
|
||||
if err := validateActorID(a.ActorID); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateActorID(actorID string) error {
|
||||
if actorID == "" {
|
||||
return ErrActorIDInvalid
|
||||
}
|
||||
if len(actorID) < 4 || len(actorID) > 63 {
|
||||
return ErrActorIDInvalid
|
||||
}
|
||||
if !actorIDPattern.MatchString(actorID) {
|
||||
return ErrActorIDInvalid
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (a *DBActor) Save(db *sql.DB) error {
|
||||
if err := a.Validate(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if a.ID > 0 {
|
||||
return a.update(db)
|
||||
}
|
||||
return a.insert(db)
|
||||
}
|
||||
|
||||
func (a *DBActor) insert(db *sql.DB) error {
|
||||
query := `
|
||||
INSERT INTO actors (story_id, actor_id, name_value, role, notes, created_at, updated_at, etag)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
|
||||
RETURNING id, created_at, updated_at, etag
|
||||
`
|
||||
|
||||
now := time.Now()
|
||||
if a.CreatedAt.IsZero() {
|
||||
a.CreatedAt = now
|
||||
}
|
||||
if a.UpdatedAt.IsZero() {
|
||||
a.UpdatedAt = now
|
||||
}
|
||||
|
||||
etag := a.Etag
|
||||
if etag == "" {
|
||||
etag = generateEtag()
|
||||
}
|
||||
|
||||
var createdID int64
|
||||
var createdAt, updatedAt time.Time
|
||||
var returnedEtag string
|
||||
|
||||
err := db.QueryRow(
|
||||
query,
|
||||
a.StoryID,
|
||||
a.ActorID,
|
||||
a.NameValue,
|
||||
pq.Array([]string{a.Role}),
|
||||
pq.Array([]string{a.Notes}),
|
||||
a.CreatedAt,
|
||||
a.UpdatedAt,
|
||||
etag,
|
||||
).Scan(&createdID, &createdAt, &updatedAt, &returnedEtag)
|
||||
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to insert actor: %w", err)
|
||||
}
|
||||
|
||||
a.ID = createdID
|
||||
a.CreatedAt = createdAt
|
||||
a.UpdatedAt = updatedAt
|
||||
a.Etag = returnedEtag
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (a *DBActor) update(db *sql.DB) error {
|
||||
query := `
|
||||
UPDATE actors
|
||||
SET name_value = $1,
|
||||
role = $2,
|
||||
notes = $3,
|
||||
updated_at = $4,
|
||||
etag = $5
|
||||
WHERE id = $6
|
||||
RETURNING created_at, updated_at, etag
|
||||
`
|
||||
|
||||
now := time.Now()
|
||||
if a.UpdatedAt.IsZero() {
|
||||
a.UpdatedAt = now
|
||||
}
|
||||
|
||||
etag := a.Etag
|
||||
if etag == "" {
|
||||
etag = generateEtag()
|
||||
}
|
||||
|
||||
var createdAt, updatedAt time.Time
|
||||
var returnedEtag string
|
||||
|
||||
err := db.QueryRow(
|
||||
query,
|
||||
a.NameValue,
|
||||
a.Role,
|
||||
a.Notes,
|
||||
a.UpdatedAt,
|
||||
etag,
|
||||
a.ID,
|
||||
).Scan(&createdAt, &updatedAt, &returnedEtag)
|
||||
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to update actor: %w", err)
|
||||
}
|
||||
|
||||
a.CreatedAt = createdAt
|
||||
a.UpdatedAt = updatedAt
|
||||
a.Etag = returnedEtag
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func generateEtag() string {
|
||||
return uuid.New().String()
|
||||
}
|
||||
|
||||
func GetByID(db *sql.DB, id int64) (*DBActor, error) {
|
||||
query := `
|
||||
SELECT id, story_id, actor_id, name_value, role, notes, created_at, updated_at, etag
|
||||
FROM actors
|
||||
WHERE id = $1
|
||||
`
|
||||
|
||||
actor := &DBActor{}
|
||||
err := db.QueryRow(query, id).Scan(
|
||||
&actor.ID,
|
||||
&actor.StoryID,
|
||||
&actor.ActorID,
|
||||
&actor.NameValue,
|
||||
&actor.Role,
|
||||
&actor.Notes,
|
||||
&actor.CreatedAt,
|
||||
&actor.UpdatedAt,
|
||||
&actor.Etag,
|
||||
)
|
||||
|
||||
if err != nil {
|
||||
if err == sql.ErrNoRows {
|
||||
return nil, fmt.Errorf("actor with id %d not found", id)
|
||||
}
|
||||
return nil, fmt.Errorf("failed to get actor: %w", err)
|
||||
}
|
||||
|
||||
return actor, nil
|
||||
}
|
||||
|
||||
func GetByActorID(db *sql.DB, storyID int64, actorID string) (*DBActor, error) {
|
||||
if err := validateActorID(actorID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
query := `
|
||||
SELECT id, story_id, actor_id, name_value, role, notes, created_at, updated_at, etag
|
||||
FROM actors
|
||||
WHERE story_id = $1 AND actor_id = $2
|
||||
`
|
||||
|
||||
actor := &DBActor{}
|
||||
err := db.QueryRow(query, storyID, actorID).Scan(
|
||||
&actor.ID,
|
||||
&actor.StoryID,
|
||||
&actor.ActorID,
|
||||
&actor.NameValue,
|
||||
&actor.Role,
|
||||
&actor.Notes,
|
||||
&actor.CreatedAt,
|
||||
&actor.UpdatedAt,
|
||||
&actor.Etag,
|
||||
)
|
||||
|
||||
if err != nil {
|
||||
if err == sql.ErrNoRows {
|
||||
return nil, fmt.Errorf("actor with story_id %d and actor_id '%s' not found", storyID, actorID)
|
||||
}
|
||||
return nil, fmt.Errorf("failed to get actor: %w", err)
|
||||
}
|
||||
|
||||
return actor, nil
|
||||
}
|
||||
|
||||
func ListByStoryID(db *sql.DB, storyID int64) ([]*DBActor, error) {
|
||||
if storyID <= 0 {
|
||||
return nil, errors.New("story_id must be positive")
|
||||
}
|
||||
|
||||
query := `
|
||||
SELECT id, story_id, actor_id, name_value, role, notes, created_at, updated_at, etag
|
||||
FROM actors
|
||||
WHERE story_id = $1
|
||||
ORDER BY name_value ASC
|
||||
`
|
||||
|
||||
rows, err := db.Query(query, storyID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to list actors: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
actors := make([]*DBActor, 0)
|
||||
for rows.Next() {
|
||||
var id int64
|
||||
var storyID int64
|
||||
var actorID, nameValue, role, notes string
|
||||
var createdAt, updatedAt time.Time
|
||||
var etag string
|
||||
|
||||
err := rows.Scan(&id, &storyID, &actorID, &nameValue, &role, ¬es, &createdAt, &updatedAt, &etag)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to scan actor: %w", err)
|
||||
}
|
||||
|
||||
actors = append(actors, &DBActor{
|
||||
ID: id,
|
||||
StoryID: storyID,
|
||||
ActorID: actorID,
|
||||
NameValue: nameValue,
|
||||
Role: role,
|
||||
Notes: notes,
|
||||
CreatedAt: createdAt,
|
||||
UpdatedAt: updatedAt,
|
||||
Etag: etag,
|
||||
})
|
||||
}
|
||||
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, fmt.Errorf("error iterating actors: %w", err)
|
||||
}
|
||||
|
||||
return actors, nil
|
||||
}
|
||||
|
||||
func Delete(db *sql.DB, id int64) error {
|
||||
query := `
|
||||
DELETE FROM actors
|
||||
WHERE id = $1
|
||||
`
|
||||
|
||||
result, err := db.Exec(query, id)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to delete actor: %w", err)
|
||||
}
|
||||
|
||||
rowsAffected, err := result.RowsAffected()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get rows affected: %w", err)
|
||||
}
|
||||
|
||||
if rowsAffected == 0 {
|
||||
return fmt.Errorf("actor with id %d not found", id)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func GetActorIDForResourceName(resourceName string) (int64, string, error) {
|
||||
prefix := "stories/"
|
||||
if !strings.HasPrefix(resourceName, prefix) {
|
||||
return 0, "", fmt.Errorf("invalid resource name: must start with '%s', got '%s'", prefix, resourceName)
|
||||
}
|
||||
|
||||
remaining := strings.TrimPrefix(resourceName, prefix)
|
||||
|
||||
actorPrefix := "actors/"
|
||||
if !strings.HasPrefix(remaining, actorPrefix) {
|
||||
return 0, "", fmt.Errorf("invalid actor resource name: must contain '%s', got '%s'", actorPrefix, remaining)
|
||||
}
|
||||
|
||||
actorID := strings.TrimPrefix(remaining, actorPrefix)
|
||||
if actorID == "" {
|
||||
return 0, "", errors.New("actor_id is empty in resource name")
|
||||
}
|
||||
|
||||
if err := validateActorID(actorID); err != nil {
|
||||
return 0, "", fmt.Errorf("invalid actor_id in resource name: %w", err)
|
||||
}
|
||||
|
||||
storyPart := strings.TrimSuffix(remaining, actorPrefix+actorID)
|
||||
if storyPart == "" {
|
||||
return 0, "", errors.New("story_id is missing from resource name")
|
||||
}
|
||||
|
||||
var storyID int64
|
||||
_, err := fmt.Sscanf(storyPart, "%d", &storyID)
|
||||
if err != nil || storyID <= 0 {
|
||||
return 0, "", errors.New("invalid story_id in resource name")
|
||||
}
|
||||
|
||||
return storyID, actorID, nil
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
// Package actor provides database access for the Actor resource.
|
||||
package actor
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
v1 "git.tipsy.codes/charles/webstory/pkg/api/webstory/v1"
|
||||
"google.golang.org/protobuf/types/known/timestamppb"
|
||||
)
|
||||
|
||||
func TestActorTranslator_FromAPI(t *testing.T) {
|
||||
now := time.Now()
|
||||
tests := []struct {
|
||||
name string
|
||||
apiActor *v1.Actor
|
||||
expectErr bool
|
||||
errMsg string
|
||||
expectID int64
|
||||
checkFunc func(*DBActor) error
|
||||
}{
|
||||
{
|
||||
name: "nil actor returns error",
|
||||
apiActor: nil,
|
||||
expectErr: true,
|
||||
errMsg: "api actor cannot be nil",
|
||||
},
|
||||
{
|
||||
name: "minimal valid actor creates translator correctly",
|
||||
apiActor: &v1.Actor{
|
||||
ActorId: "actor-1",
|
||||
NameValue: "John Doe",
|
||||
Role: "Hero",
|
||||
Notes: "Main character",
|
||||
CreateTime: timestamppb.New(now),
|
||||
UpdateTime: timestamppb.New(now),
|
||||
},
|
||||
expectErr: false,
|
||||
expectID: 0,
|
||||
checkFunc: func(s *DBActor) error {
|
||||
if s.ActorID != "actor-1" {
|
||||
return fmt.Errorf("expected ActorID actor-1, got %s", s.ActorID)
|
||||
}
|
||||
if s.NameValue != "John Doe" {
|
||||
return fmt.Errorf("expected NameValue John Doe, got %s", s.NameValue)
|
||||
}
|
||||
if s.Role != "Hero" {
|
||||
return fmt.Errorf("expected Role Hero, got %s", s.Role)
|
||||
}
|
||||
if s.Notes != "Main character" {
|
||||
return fmt.Errorf("expected Notes Main character, got %s", s.Notes)
|
||||
}
|
||||
return nil
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
trans := NewActorTranslator()
|
||||
result, err := trans.FromAPI(tt.apiActor)
|
||||
|
||||
if tt.expectErr {
|
||||
if err == nil {
|
||||
t.Fatalf("expected error but got nil")
|
||||
}
|
||||
if tt.errMsg != "" && !strings.Contains(err.Error(), tt.errMsg) {
|
||||
t.Errorf("expected error containing '%s' but got '%s'", tt.errMsg, err.Error())
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
if result == nil {
|
||||
t.Fatalf("expected non-nil result")
|
||||
}
|
||||
|
||||
if result.ID != tt.expectID {
|
||||
t.Errorf("expected ID %d but got %d", tt.expectID, result.ID)
|
||||
}
|
||||
|
||||
if tt.checkFunc != nil {
|
||||
if err := tt.checkFunc(result); err != nil {
|
||||
t.Errorf("check failed: %v", err)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user