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
+30
View File
@@ -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 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. 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 ## Frontend/Typescript
Do not alter package.json directly, instead, use the CLI tool `npm`. Do not alter package.json directly, instead, use the CLI tool `npm`.
+1
View File
@@ -3,6 +3,7 @@ module git.tipsy.codes/charles/webstory
go 1.26.1 go 1.26.1
require ( require (
github.com/google/uuid v1.6.0
google.golang.org/genproto/googleapis/api v0.0.0-20241202173237-19429a94021a google.golang.org/genproto/googleapis/api v0.0.0-20241202173237-19429a94021a
google.golang.org/grpc v1.70.0 google.golang.org/grpc v1.70.0
google.golang.org/protobuf v1.36.11 google.golang.org/protobuf v1.36.11
+8
View File
@@ -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.
+229
View File
@@ -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;
+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)
}
})
}
}