From 7626b5618a740cec2177de16247d0ae7ad90c5a7 Mon Sep 17 00:00:00 2001 From: charles Date: Wed, 25 Mar 2026 21:56:27 -0700 Subject: [PATCH] add: story and some tests --- AGENT.md | 30 ++ go.mod | 1 + pkg/database/README.md | 8 + pkg/database/schema.sql | 229 ++++++++++++++ pkg/database/story/story.go | 508 +++++++++++++++++++++++++++++++ pkg/database/story/story_test.go | 382 +++++++++++++++++++++++ 6 files changed, 1158 insertions(+) create mode 100644 pkg/database/README.md create mode 100644 pkg/database/schema.sql create mode 100644 pkg/database/story/story.go create mode 100644 pkg/database/story/story_test.go diff --git a/AGENT.md b/AGENT.md index ce02ec9..f39a160 100644 --- a/AGENT.md +++ b/AGENT.md @@ -33,6 +33,36 @@ Generally conform to resource-oriented design describe in https://google.aip.dev/general. Read aip.md for a summary. All proto service methods should be annotated with google.api.http hints and path variables. +When writing tests, always split the error cases into a different test. For example: + +```go +func TestThing(t *testing.T) { + for _, tc := range []struct{ + name string + }{ + {"test1"}, + } { + t.Run(tc.name, func() { + got, err := Thing() + if err != nil { t.Fatalf("Got err=%v; want nil", err)} + }) + } +} +func TestThingErrors(t *testing.T) { + for _, tc := range []struct{ + name string + }{ + {"test1"}, + } { + t.Run(tc.name, func() { + _, err := Thing() + if err == nil { t.Fatalf("Got nil err; want err")} + // Test thing + }) + } +} +``` + ## Frontend/Typescript Do not alter package.json directly, instead, use the CLI tool `npm`. diff --git a/go.mod b/go.mod index b0ad8e2..e5d5125 100644 --- a/go.mod +++ b/go.mod @@ -3,6 +3,7 @@ module git.tipsy.codes/charles/webstory go 1.26.1 require ( + github.com/google/uuid v1.6.0 google.golang.org/genproto/googleapis/api v0.0.0-20241202173237-19429a94021a google.golang.org/grpc v1.70.0 google.golang.org/protobuf v1.36.11 diff --git a/pkg/database/README.md b/pkg/database/README.md new file mode 100644 index 0000000..861c091 --- /dev/null +++ b/pkg/database/README.md @@ -0,0 +1,8 @@ +# Database + +Database provides access to the webstory database. + +## Layout + +Each resource gets a folder (story/ for stories, for example). +schema.sql contains the postgresql database. diff --git a/pkg/database/schema.sql b/pkg/database/schema.sql new file mode 100644 index 0000000..400e5e5 --- /dev/null +++ b/pkg/database/schema.sql @@ -0,0 +1,229 @@ +-- Database schema for Webstory +-- This schema supports the resources defined in proto/webstory/v1/api.proto + +-- Enable UUID generation +CREATE EXTENSION IF NOT EXISTS "uuid-ossp"; + +-- Stories table +CREATE TABLE IF NOT EXISTS stories ( + -- Primary key + id SERIAL PRIMARY KEY, + + -- Story ID (used in resource name: stories/{story_id}) + story_id VARCHAR(63) NOT NULL, + + -- Title of the story + title VARCHAR(500) NOT NULL, + + -- Content of the story + content TEXT, + + -- Description or summary of the story + description TEXT, + + -- Labels for organizing and categorizing the story + labels TEXT[], + + -- Timestamps + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + + -- Etag for concurrency control + etag VARCHAR(128) NOT NULL, + + -- Constraints + CONSTRAINT stories_story_id_unique UNIQUE (story_id), + CONSTRAINT stories_story_id_pattern CHECK (story_id ~ '^[a-z][0-9-]{2,61}[0-9]$'), + CONSTRAINT stories_etag_pattern CHECK (char_length(etag) > 0) +); + +-- Create index for efficient filtering and sorting +CREATE INDEX IF NOT EXISTS idx_stories_title ON stories (title); +CREATE INDEX IF NOT EXISTS idx_stories_labels ON stories USING GIN (labels); +CREATE INDEX IF NOT EXISTS idx_stories_created_at ON stories (created_at DESC); + +-- Scenes table (child of stories) +CREATE TABLE IF NOT EXISTS scenes ( + -- Primary key + id SERIAL PRIMARY KEY, + + -- Foreign key to stories + story_id INTEGER NOT NULL REFERENCES stories(id) ON DELETE CASCADE, + + -- Scene ID (used in resource name: stories/{story}/scenes/{scene_id}) + scene_id VARCHAR(63) NOT NULL, + + -- Scene number within the story + scene_number INTEGER NOT NULL, + + -- Title of the scene + title VARCHAR(500) NOT NULL, + + -- Content of the scene + content TEXT, + + -- Description of the scene + description TEXT, + + -- Timestamps + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + + -- Etag for concurrency control + etag VARCHAR(128) NOT NULL, + + -- Constraints + CONSTRAINT scenes_story_scene_id_unique UNIQUE (story_id, scene_id), + CONSTRAINT scenes_story_id_exists CHECK (story_id > 0), + CONSTRAINT scenes_scene_id_pattern CHECK (scene_id ~ '^[a-z][0-9-]{2,61}[0-9]$'), + CONSTRAINT scenes_scene_number_positive CHECK (scene_number > 0), + CONSTRAINT scenes_etag_pattern CHECK (char_length(etag) > 0) +); + +-- Create index for efficient queries +CREATE INDEX IF NOT EXISTS idx_scenes_story_id ON scenes (story_id); +CREATE INDEX IF NOT EXISTS idx_scenes_scene_number ON scenes (story_id, scene_number); +CREATE INDEX IF NOT EXISTS idx_scenes_title ON scenes (title); + +-- Actors table (child of stories) +CREATE TABLE IF NOT EXISTS actors ( + -- Primary key + id SERIAL PRIMARY KEY, + + -- Foreign key to stories + story_id INTEGER NOT NULL REFERENCES stories(id) ON DELETE CASCADE, + + -- Actor ID (used in resource name: stories/{story}/actors/{actor_id}) + actor_id VARCHAR(63) NOT NULL, + + -- Name of the actor + name_value VARCHAR(255) NOT NULL, + + -- Role or character name this actor plays in the story + role VARCHAR(255), + + -- Optional notes about the actor + notes TEXT, + + -- Timestamps + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + + -- Etag for concurrency control + etag VARCHAR(128) NOT NULL, + + -- 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_etag_pattern CHECK (char_length(etag) > 0) +); + +-- Create index for efficient queries +CREATE INDEX IF NOT EXISTS idx_actors_story_id ON actors (story_id); +CREATE INDEX IF NOT EXISTS idx_actors_name_value ON actors (story_id, name_value); +CREATE INDEX IF NOT EXISTS idx_actors_role ON actors (story_id, role); + +-- Function to update updated_at timestamp +CREATE OR REPLACE FUNCTION update_updated_at_column() +RETURNS TRIGGER AS $$ +BEGIN + NEW.updated_at = NOW(); + -- Update etag when row is modified (simple version using timestamp) + NEW.etag := md5(NEW.created_at::text || NEW.updated_at::text); + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +-- Trigger for stories table +CREATE TRIGGER update_stories_updated_at + BEFORE UPDATE ON stories + FOR EACH ROW + EXECUTE FUNCTION update_updated_at_column(); + +-- Trigger for scenes table +CREATE TRIGGER update_scenes_updated_at + BEFORE UPDATE ON scenes + FOR EACH ROW + EXECUTE FUNCTION update_updated_at_column(); + +-- Trigger for actors table +CREATE TRIGGER update_actors_updated_at + BEFORE UPDATE ON actors + FOR EACH ROW + EXECUTE FUNCTION update_updated_at_column(); + +-- Function to generate new etag (called during inserts) +CREATE OR REPLACE FUNCTION generate_etag() +RETURNS TRIGGER AS $$ +BEGIN + NEW.etag := md5(NEW.id::text || NEW.created_at::text); + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +-- Trigger for etag generation on inserts +CREATE TRIGGER generate_stories_etag + BEFORE INSERT ON stories + FOR EACH ROW + EXECUTE FUNCTION generate_etag(); + +CREATE TRIGGER generate_scenes_etag + BEFORE INSERT ON scenes + FOR EACH ROW + EXECUTE FUNCTION generate_etag(); + +CREATE TRIGGER generate_actors_etag + BEFORE INSERT ON actors + FOR EACH ROW + EXECUTE FUNCTION generate_etag(); + +-- Helper function to get stories with label filtering +CREATE OR REPLACE FUNCTION get_stories( + p_page_size INTEGER DEFAULT 50, + p_page_token TEXT DEFAULT NULL, + p_filter TEXT DEFAULT NULL +) +RETURNS TABLE ( + id INTEGER, + story_id VARCHAR(63), + title VARCHAR(500), + content TEXT, + description TEXT, + labels TEXT[], + created_at TIMESTAMPTZ, + updated_at TIMESTAMPTZ, + etag VARCHAR(128) +) AS $$ +DECLARE + v_page_size INTEGER; + v_last_id INTEGER; +BEGIN + -- Validate and limit page size + v_page_size := LEAST(GREATEST(p_page_size, 1), 1000); + + -- Parse page token to get last ID for pagination + IF p_page_token IS NOT NULL AND p_page_token != '' THEN + v_last_id := p_page_token::INTEGER; + END IF; + + -- Build dynamic query based on filter + -- Note: This is a simplified version. Production code should handle + -- filter parsing more robustly + RETURN QUERY + SELECT + s.id, + s.story_id, + s.title, + s.content, + s.description, + s.labels, + s.created_at, + s.updated_at, + s.etag + FROM stories s + WHERE (v_last_id IS NULL OR s.id > v_last_id) + ORDER BY s.id + LIMIT v_page_size; +END; +$$ LANGUAGE plpgsql; diff --git a/pkg/database/story/story.go b/pkg/database/story/story.go new file mode 100644 index 0000000..3457eb0 --- /dev/null +++ b/pkg/database/story/story.go @@ -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 +} diff --git a/pkg/database/story/story_test.go b/pkg/database/story/story_test.go new file mode 100644 index 0000000..d07658b --- /dev/null +++ b/pkg/database/story/story_test.go @@ -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) + } + }) + } +}