add: story and some tests
This commit is contained in:
@@ -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`.
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -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;
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user