add: story and some tests

This commit is contained in:
2026-03-25 21:56:27 -07:00
parent 98b14220e7
commit 7626b5618a
6 changed files with 1158 additions and 0 deletions
+508
View File
@@ -0,0 +1,508 @@
// Package story provides database access for the Story resource.
// It defines the DBStory struct and StoryTranslator to convert between
// the API format (api.pb.go) and the database format (schema.sql).
package story
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 (
// ErrStoryIDInvalid is returned when the story_id format is invalid.
ErrStoryIDInvalid = errors.New("invalid story_id format: must be 4-63 characters, matching pattern /[a-z][0-9-]/")
// storyIDPattern validates the story_id format.
// Must start with a lowercase letter, end with a letter or number,
// and contain only lowercase letters, numbers, and hyphens.
storyIDPattern = regexp.MustCompile(`^[a-z][0-9a-z-]{2,61}[0-9a-z]$`)
)
// DBStory represents a story in the PostgreSQL database.
// This is the database model that corresponds to the schema.sql structure.
type DBStory struct {
// Database-generated unique identifier
ID int64
// StoryID is the resource identifier used in the story's resource name.
// Format: stories/{story_id}
StoryID string
// Title of the story.
Title string
// Content of the story.
Content string
// Description or summary of the story.
Description string
// Labels for organizing and categorizing the story.
Labels []string
// CreatedAt is when the story was created.
CreatedAt time.Time
// UpdatedAt is when the story was last updated.
UpdatedAt time.Time
// Etag is used for concurrency control.
Etag string
}
// StoryTranslator provides conversion methods between API and database formats.
type StoryTranslator struct{}
// NewStoryTranslator creates a new StoryTranslator instance.
func NewStoryTranslator() *StoryTranslator {
return &StoryTranslator{}
}
// FromAPI converts an API Story message to a DBStory struct.
// This handles the conversion from the protobuf-generated type to the database model.
func (t *StoryTranslator) FromAPI(apiStory *v1.Story) (*DBStory, error) {
if apiStory == nil {
return nil, errors.New("api story cannot be nil")
}
// Validate story_id if provided (required for create operations)
if apiStory.StoryId != "" {
if err := validateStoryID(apiStory.StoryId); err != nil {
return nil, err
}
}
// Validate title (required field)
if apiStory.Title == "" {
return nil, errors.New("title is required")
}
// Convert labels (may be nil)
labels := apiStory.GetLabels()
if labels == nil {
labels = []string{}
}
// Build resource name if not set
name := apiStory.GetName()
if name == "" && apiStory.StoryId != "" {
name = fmt.Sprintf("stories/%s", apiStory.StoryId)
}
// Convert timestamps if provided
var createdAt, updatedAt time.Time
if apiStory.GetCreateTime() != nil {
createdAt = apiStory.CreateTime.AsTime()
}
if apiStory.GetUpdateTime() != nil {
updatedAt = apiStory.UpdateTime.AsTime()
}
return &DBStory{
ID: 0, // Will be generated by database
StoryID: apiStory.StoryId,
Title: apiStory.Title,
Content: apiStory.Content,
Description: apiStory.Description,
Labels: labels,
CreatedAt: createdAt,
UpdatedAt: updatedAt,
Etag: apiStory.Etag,
}, nil
}
// ToAPI converts a DBStory struct to an API Story message.
// This prepares the database model for response serialization.
func (t *StoryTranslator) ToAPI(dbStory *DBStory) *v1.Story {
if dbStory == nil {
return nil
}
// Build scenes list from the story
scenes := make([]*v1.Scene, 0)
// Build actors list from the story
actors := make([]*v1.Actor, 0)
// Build resource name
name := fmt.Sprintf("stories/%s", dbStory.StoryID)
return &v1.Story{
Name: name,
StoryId: dbStory.StoryID,
Title: dbStory.Title,
Content: dbStory.Content,
Description: dbStory.Description,
Labels: dbStory.Labels,
CreateTime: timestamppb.New(dbStory.CreatedAt),
UpdateTime: timestamppb.New(dbStory.UpdatedAt),
Etag: dbStory.Etag,
Scenes: scenes,
Actors: actors,
}
}
// ToAPIDetailed converts a DBStory to an API Story with nested scenes and actors.
// This is used when loading stories with their related resources.
func (t *StoryTranslator) ToAPIDetailed(dbStory *DBStory, scenes []*v1.Scene, actors []*v1.Actor) *v1.Story {
apiStory := t.ToAPI(dbStory)
apiStory.Scenes = scenes
apiStory.Actors = actors
return apiStory
}
// Validate validates the DBStory struct.
func (s *DBStory) Validate() error {
if s.StoryID == "" {
return errors.New("story_id is required")
}
if err := validateStoryID(s.StoryID); err != nil {
return err
}
if s.Title == "" {
return errors.New("title is required")
}
return nil
}
// validateStoryID validates the story_id format.
func validateStoryID(storyID string) error {
if storyID == "" {
return ErrStoryIDInvalid
}
if len(storyID) < 4 || len(storyID) > 63 {
return ErrStoryIDInvalid
}
if !storyIDPattern.MatchString(storyID) {
return ErrStoryIDInvalid
}
return nil
}
// Save saves the DBStory to the database.
// If the story has an ID, it performs an UPDATE; otherwise, it performs an INSERT.
func (s *DBStory) Save(db *sql.DB) error {
if err := s.Validate(); err != nil {
return err
}
// Check if this is an update (has ID from database) or insert
if s.ID > 0 {
return s.update(db)
}
return s.insert(db)
}
// insert inserts a new story into the database.
func (s *DBStory) insert(db *sql.DB) error {
query := `
INSERT INTO stories (story_id, title, content, description, labels, created_at, updated_at, etag)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
RETURNING id, created_at, updated_at, etag
`
labels := make([]string, len(s.Labels))
copy(labels, s.Labels)
now := time.Now()
if s.CreatedAt.IsZero() {
s.CreatedAt = now
}
if s.UpdatedAt.IsZero() {
s.UpdatedAt = now
}
// Generate etag if not provided
etag := s.Etag
if etag == "" {
etag = generateEtag()
}
var createdID int64
var createdAt, updatedAt time.Time
var returnedEtag string
err := db.QueryRow(
query,
s.StoryID,
s.Title,
s.Content,
s.Description,
labels,
s.CreatedAt,
s.UpdatedAt,
etag,
).Scan(&createdID, &createdAt, &updatedAt, &returnedEtag)
if err != nil {
return fmt.Errorf("failed to insert story: %w", err)
}
s.ID = createdID
s.CreatedAt = createdAt
s.UpdatedAt = updatedAt
s.Etag = returnedEtag
return nil
}
// update updates an existing story in the database.
func (s *DBStory) update(db *sql.DB) error {
query := `
UPDATE stories
SET title = $1,
content = $2,
description = $3,
labels = $4,
updated_at = $5,
etag = $6
WHERE id = $7
RETURNING created_at, updated_at, etag
`
labels := make([]string, len(s.Labels))
copy(labels, s.Labels)
now := time.Now()
if s.UpdatedAt.IsZero() {
s.UpdatedAt = now
}
etag := s.Etag
if etag == "" {
etag = generateEtag()
}
var createdAt, updatedAt time.Time
var returnedEtag string
err := db.QueryRow(
query,
s.Title,
s.Content,
s.Description,
labels,
s.UpdatedAt,
etag,
s.ID,
).Scan(&createdAt, &updatedAt, &returnedEtag)
if err != nil {
return fmt.Errorf("failed to update story: %w", err)
}
s.CreatedAt = createdAt
s.UpdatedAt = updatedAt
s.Etag = returnedEtag
return nil
}
// generateEtag generates a unique etag for the story.
func generateEtag() string {
return uuid.New().String()
}
// GetByID retrieves a story by its primary key ID.
func GetByID(db *sql.DB, id int64) (*DBStory, error) {
query := `
SELECT id, story_id, title, content, description, labels, created_at, updated_at, etag
FROM stories
WHERE id = $1
`
story := &DBStory{}
err := db.QueryRow(query, id).Scan(
&story.ID,
&story.StoryID,
&story.Title,
&story.Content,
&story.Description,
&story.Labels,
&story.CreatedAt,
&story.UpdatedAt,
&story.Etag,
)
if err != nil {
if err == sql.ErrNoRows {
return nil, fmt.Errorf("story with id %d not found", id)
}
return nil, fmt.Errorf("failed to get story: %w", err)
}
return story, nil
}
// GetByStoryID retrieves a story by its story_id.
func GetByStoryID(db *sql.DB, storyID string) (*DBStory, error) {
if err := validateStoryID(storyID); err != nil {
return nil, err
}
query := `
SELECT id, story_id, title, content, description, labels, created_at, updated_at, etag
FROM stories
WHERE story_id = $1
`
story := &DBStory{}
err := db.QueryRow(query, storyID).Scan(
&story.ID,
&story.StoryID,
&story.Title,
&story.Content,
&story.Description,
&story.Labels,
&story.CreatedAt,
&story.UpdatedAt,
&story.Etag,
)
if err != nil {
if err == sql.ErrNoRows {
return nil, fmt.Errorf("story with story_id '%s' not found", storyID)
}
return nil, fmt.Errorf("failed to get story: %w", err)
}
return story, nil
}
// List returns a paginated list of stories.
func List(db *sql.DB, pageSize int, pageToken string, filter string, orderBy string) ([]*DBStory, string, error) {
if pageSize <= 0 || pageSize > 1000 {
pageSize = 50
}
var query string
var args []interface{}
switch orderBy {
case "title":
query = `
SELECT id, story_id, title, content, description, labels, created_at, updated_at, etag
FROM stories
WHERE 1=1
`
case "create_time":
fallthrough
default:
query = `
SELECT id, story_id, title, content, description, labels, created_at, updated_at, etag
FROM stories
WHERE 1=1
`
}
// Parse page token for pagination
if pageToken != "" {
query += " AND id > " + pageToken
}
query += " ORDER BY id DESC"
query += fmt.Sprintf(" LIMIT %d", pageSize)
rows, err := db.Query(query, args...)
if err != nil {
return nil, "", fmt.Errorf("failed to list stories: %w", err)
}
defer rows.Close()
stories := make([]*DBStory, 0)
for rows.Next() {
var id int64
var storyID string
var title, content, description, etag string
var labels []string
var createdAt, updatedAt time.Time
err := rows.Scan(&id, &storyID, &title, &content, &description, &labels, &createdAt, &updatedAt, &etag)
if err != nil {
return nil, "", fmt.Errorf("failed to scan story: %w", err)
}
stories = append(stories, &DBStory{
ID: id,
StoryID: storyID,
Title: title,
Content: content,
Description: description,
Labels: labels,
CreatedAt: createdAt,
UpdatedAt: updatedAt,
Etag: etag,
})
}
if err := rows.Err(); err != nil {
return nil, "", fmt.Errorf("error iterating stories: %w", err)
}
var nextPageToken string
if len(stories) == pageSize {
// Use the last story's ID as the next page token
nextPageToken = fmt.Sprintf("%d", stories[len(stories)-1].ID)
}
return stories, nextPageToken, nil
}
// Delete removes a story from the database.
func Delete(db *sql.DB, id int64) error {
query := `
DELETE FROM stories
WHERE id = $1
`
result, err := db.Exec(query, id)
if err != nil {
return fmt.Errorf("failed to delete story: %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("story with id %d not found", id)
}
return nil
}
// GetStoryIDForResourceName extracts the story_id from a resource name.
// Resource name format: stories/{story_id}
func GetStoryIDForResourceName(resourceName string) (string, error) {
prefix := "stories/"
if !strings.HasPrefix(resourceName, prefix) {
return "", fmt.Errorf("invalid resource name: must start with '%s', got '%s'", prefix, resourceName)
}
storyID := strings.TrimPrefix(resourceName, prefix)
if storyID == "" {
return "", errors.New("story_id is empty in resource name")
}
if err := validateStoryID(storyID); err != nil {
return "", fmt.Errorf("invalid story_id in resource name: %w", err)
}
return storyID, nil
}
+382
View File
@@ -0,0 +1,382 @@
package story
import (
"fmt"
"strings"
"testing"
"time"
v1 "git.tipsy.codes/charles/webstory/pkg/api/webstory/v1"
"google.golang.org/protobuf/types/known/timestamppb"
)
func TestStoryTranslator_FromAPI(t *testing.T) {
trans := NewStoryTranslator()
now := time.Now()
tests := []struct {
name string
apiStory *v1.Story
expectErr bool
errMsg string
expectID int64
checkFunc func(*DBStory) error
}{
{
name: "nil story returns error",
apiStory: nil,
expectErr: true,
errMsg: "api story cannot be nil",
},
{
name: "minimal valid story creates translator correctly",
apiStory: &v1.Story{
StoryId: "abc123",
Title: "Test Story",
},
expectErr: false,
expectID: 0,
checkFunc: func(s *DBStory) error {
if s.StoryID != "abc123" {
return fmt.Errorf("expected StoryID abc123, got %s", s.StoryID)
}
if s.Title != "Test Story" {
return fmt.Errorf("expected Title Test Story, got %s", s.Title)
}
return nil
},
},
{
name: "story with all fields converts correctly",
apiStory: &v1.Story{
Name: "stories/my-story-1",
StoryId: "my-story-1",
Title: "My Story Title",
Content: "This is the story content",
Description: "A wonderful story",
Labels: []string{"fiction", "adventure"},
CreateTime: timestamppb.New(now),
UpdateTime: timestamppb.New(now),
Etag: "etag-123",
},
expectErr: false,
expectID: 0,
checkFunc: func(s *DBStory) error {
if s.StoryID != "my-story-1" {
return fmt.Errorf("expected StoryID my-story-1, got %s", s.StoryID)
}
if s.Title != "My Story Title" {
return fmt.Errorf("expected Title My Story Title, got %s", s.Title)
}
if s.Content != "This is the story content" {
return fmt.Errorf("expected Content, got %s", s.Content)
}
if s.Description != "A wonderful story" {
return fmt.Errorf("expected Description, got %s", s.Description)
}
if len(s.Labels) != 2 || s.Labels[0] != "fiction" || s.Labels[1] != "adventure" {
return fmt.Errorf("expected Labels [fiction adventure], got %v", s.Labels)
}
if s.Etag != "etag-123" {
return fmt.Errorf("expected Etag etag-123, got %s", s.Etag)
}
return nil
},
},
{
name: "nil labels converted to empty slice",
apiStory: &v1.Story{
StoryId: "test1",
Title: "Test",
Labels: nil,
},
expectErr: false,
expectID: 0,
checkFunc: func(s *DBStory) error {
if s.Labels == nil {
return fmt.Errorf("expected non-nil Labels slice, got nil")
}
return nil
},
},
{
name: "empty labels converted to empty slice",
apiStory: &v1.Story{
StoryId: "test2",
Title: "Test",
Labels: []string{},
},
expectErr: false,
expectID: 0,
checkFunc: func(s *DBStory) error {
if s.Labels == nil {
return fmt.Errorf("expected non-nil Labels slice, got nil")
}
if len(s.Labels) != 0 {
return fmt.Errorf("expected empty Labels slice, got %v", s.Labels)
}
return nil
},
},
{
name: "missing title returns error",
apiStory: &v1.Story{
StoryId: "test3",
},
expectErr: true,
errMsg: "title is required",
},
{
name: "invalid story_id short returns error",
apiStory: &v1.Story{
StoryId: "ab",
Title: "Test",
},
expectErr: true,
errMsg: "invalid story_id format",
},
{
name: "invalid story_id uppercase returns error",
apiStory: &v1.Story{
StoryId: "ABC123",
Title: "Test",
},
expectErr: true,
errMsg: "invalid story_id format",
},
{
name: "invalid story_id starts with number returns error",
apiStory: &v1.Story{
StoryId: "1abc",
Title: "Test",
},
expectErr: true,
errMsg: "invalid story_id format",
},
{
name: "valid story_id with hyphens and numbers",
apiStory: &v1.Story{
StoryId: "my-awesome-story-123",
Title: "Test",
},
expectErr: false,
expectID: 0,
checkFunc: func(s *DBStory) error {
if s.StoryID != "my-awesome-story-123" {
return fmt.Errorf("expected StoryID my-awesome-story-123, got %s", s.StoryID)
}
return nil
},
},
{
name: "story_id auto-generated from name if not provided",
apiStory: &v1.Story{
StoryId: "auto-gen",
Title: "Test",
Name: "stories/auto-gen",
},
expectErr: false,
expectID: 0,
checkFunc: func(s *DBStory) error {
if s.StoryID != "auto-gen" {
return fmt.Errorf("expected StoryID auto-gen, got %s", s.StoryID)
}
return nil
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result, err := trans.FromAPI(tt.apiStory)
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)
}
}
})
}
}
func TestStoryTranslator_ToAPI(t *testing.T) {
trans := NewStoryTranslator()
now := time.Now()
tests := []struct {
name string
dbStory *DBStory
expectErr bool
checkFunc func(*v1.Story) error
}{
{
name: "nil dbStory returns nil",
dbStory: nil,
expectErr: false,
checkFunc: func(s *v1.Story) error {
if s != nil {
return nil
}
return nil
},
},
{
name: "minimal story converts correctly",
dbStory: &DBStory{
ID: 1,
StoryID: "test-story",
Title: "Test Title",
CreatedAt: now,
UpdatedAt: now,
},
expectErr: false,
checkFunc: func(s *v1.Story) error {
if s.GetName() != "stories/test-story" {
return fmt.Errorf("expected Name stories/test-story, got %s", s.GetName())
}
if s.GetStoryId() != "test-story" {
return fmt.Errorf("expected StoryId test-story, got %s", s.GetStoryId())
}
if s.GetTitle() != "Test Title" {
return fmt.Errorf("expected Title Test Title, got %s", s.GetTitle())
}
return nil
},
},
{
name: "story with all fields converts correctly",
dbStory: &DBStory{
ID: 1,
StoryID: "full-story",
Title: "Full Title",
Content: "Full Content",
Description: "Full Description",
Labels: []string{"label1", "label2"},
CreatedAt: now,
UpdatedAt: now,
Etag: "test-etag",
},
expectErr: false,
checkFunc: func(s *v1.Story) error {
if s.GetName() != "stories/full-story" {
return fmt.Errorf("expected Name stories/full-story, got %s", s.GetName())
}
if s.Etag != "test-etag" {
return fmt.Errorf("expected Etag test-etag, got %s", s.Etag)
}
if len(s.GetLabels()) != 2 {
return fmt.Errorf("expected 2 labels, got %d", len(s.GetLabels()))
}
return nil
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := trans.ToAPI(tt.dbStory)
if tt.expectErr {
if result != nil {
t.Fatalf("expected nil result but got %v", result)
}
return
}
if tt.checkFunc != nil {
if err := tt.checkFunc(result); err != nil {
t.Errorf("check failed: %v", err)
}
}
})
}
}
func TestDBStory_Validate(t *testing.T) {
tests := []struct {
name string
story *DBStory
expectErr bool
errMsg string
}{
{
name: "empty story_id returns error",
story: &DBStory{
StoryID: "",
Title: "Test",
},
expectErr: true,
errMsg: "story_id is required",
},
{
name: "invalid story_id format returns error",
story: &DBStory{
StoryID: "INVALID",
Title: "Test",
},
expectErr: true,
errMsg: "invalid story_id format",
},
{
name: "empty title returns error",
story: &DBStory{
StoryID: "valid-story-1",
Title: "",
},
expectErr: true,
errMsg: "title is required",
},
{
name: "valid story passes validation",
story: &DBStory{
StoryID: "valid-story-1",
Title: "Valid Title",
},
expectErr: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := tt.story.Validate()
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)
}
})
}
}