From bddfbd4f7407ee2222956fa1d211afd3f2dd0d2e Mon Sep 17 00:00:00 2001 From: charles Date: Sun, 29 Mar 2026 15:24:09 -0700 Subject: [PATCH] add: tests using an actual database --- go.mod | 3 + go.sum | 6 + pkg/database/schema/schema.go | 8 + pkg/database/{ => schema}/schema.sql | 2 +- pkg/database/story/story.go | 5 +- pkg/database/story/story_test.go | 279 ++++++++++++++++++++++++++- 6 files changed, 295 insertions(+), 8 deletions(-) create mode 100644 pkg/database/schema/schema.go rename pkg/database/{ => schema}/schema.sql (99%) diff --git a/go.mod b/go.mod index e5d5125..8ab7d73 100644 --- a/go.mod +++ b/go.mod @@ -10,6 +10,9 @@ require ( ) require ( + github.com/fergusstrange/embedded-postgres v1.34.0 // indirect + github.com/lib/pq v1.10.9 // indirect + github.com/xi2/xz v0.0.0-20171230120015-48954b6210f8 // indirect go.opentelemetry.io/otel v1.34.0 // indirect go.opentelemetry.io/otel/sdk/metric v1.34.0 // indirect golang.org/x/net v0.38.0 // indirect diff --git a/go.sum b/go.sum index 609d570..e2e9084 100644 --- a/go.sum +++ b/go.sum @@ -1,3 +1,5 @@ +github.com/fergusstrange/embedded-postgres v1.34.0 h1:c6RKhPKFsLVU+Tdxsx8q0UxCHsvZZ/iShAnljRBXs6s= +github.com/fergusstrange/embedded-postgres v1.34.0/go.mod h1:w0YvnCgf19o6tskInrOOACtnqfVlOvluz3hlNLY7tRk= github.com/go-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY= github.com/go-logr/logr v1.4.2/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= @@ -8,6 +10,10 @@ github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw= +github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o= +github.com/xi2/xz v0.0.0-20171230120015-48954b6210f8 h1:nIPpBwaJSVYIxUFsDv3M8ofmx9yWTog9BfvIu0q41lo= +github.com/xi2/xz v0.0.0-20171230120015-48954b6210f8/go.mod h1:HUYIGzjTL3rfEspMxjDjgmT5uz5wzYJKVo23qUhYTos= go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA= go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A= go.opentelemetry.io/otel v1.34.0 h1:zRLXxLCgL1WyKsPVrgbSdMN4c0FMkDAskSTQP+0hdUY= diff --git a/pkg/database/schema/schema.go b/pkg/database/schema/schema.go new file mode 100644 index 0000000..f0de438 --- /dev/null +++ b/pkg/database/schema/schema.go @@ -0,0 +1,8 @@ +package schema + +import ( + _ "embed" +) + +//go:embed schema.sql +var Bytes []byte diff --git a/pkg/database/schema.sql b/pkg/database/schema/schema.sql similarity index 99% rename from pkg/database/schema.sql rename to pkg/database/schema/schema.sql index 400e5e5..4370778 100644 --- a/pkg/database/schema.sql +++ b/pkg/database/schema/schema.sql @@ -33,7 +33,7 @@ CREATE TABLE IF NOT EXISTS stories ( -- 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_story_id_pattern CHECK (story_id ~ '^[a-z][a-z0-9-]{2,61}$'), CONSTRAINT stories_etag_pattern CHECK (char_length(etag) > 0) ); diff --git a/pkg/database/story/story.go b/pkg/database/story/story.go index 3457eb0..d4d0eac 100644 --- a/pkg/database/story/story.go +++ b/pkg/database/story/story.go @@ -13,6 +13,7 @@ import ( v1 "git.tipsy.codes/charles/webstory/pkg/api/webstory/v1" "github.com/google/uuid" + "github.com/lib/pq" "google.golang.org/protobuf/types/known/timestamppb" ) @@ -243,7 +244,7 @@ func (s *DBStory) insert(db *sql.DB) error { s.Title, s.Content, s.Description, - labels, + pq.Array(labels), s.CreatedAt, s.UpdatedAt, etag, @@ -296,7 +297,7 @@ func (s *DBStory) update(db *sql.DB) error { s.Title, s.Content, s.Description, - labels, + pq.Array(labels), s.UpdatedAt, etag, s.ID, diff --git a/pkg/database/story/story_test.go b/pkg/database/story/story_test.go index d07658b..d037a30 100644 --- a/pkg/database/story/story_test.go +++ b/pkg/database/story/story_test.go @@ -1,20 +1,22 @@ package story import ( + "context" + "database/sql" "fmt" "strings" "testing" "time" v1 "git.tipsy.codes/charles/webstory/pkg/api/webstory/v1" + "git.tipsy.codes/charles/webstory/pkg/database/schema" + embeddedpostgres "github.com/fergusstrange/embedded-postgres" + "github.com/lib/pq" "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 @@ -189,6 +191,7 @@ func TestStoryTranslator_FromAPI(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { + trans := NewStoryTranslator() result, err := trans.FromAPI(tt.apiStory) if tt.expectErr { @@ -223,7 +226,6 @@ func TestStoryTranslator_FromAPI(t *testing.T) { } func TestStoryTranslator_ToAPI(t *testing.T) { - trans := NewStoryTranslator() now := time.Now() @@ -239,7 +241,7 @@ func TestStoryTranslator_ToAPI(t *testing.T) { expectErr: false, checkFunc: func(s *v1.Story) error { if s != nil { - return nil + return fmt.Errorf("expected nil, got %v", s) } return nil }, @@ -298,6 +300,7 @@ func TestStoryTranslator_ToAPI(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { + trans := NewStoryTranslator() result := trans.ToAPI(tt.dbStory) if tt.expectErr { @@ -380,3 +383,269 @@ func TestDBStory_Validate(t *testing.T) { }) } } + +func TestDBStory_Save(t *testing.T) { + // Set up embedded postgres for testing + server := embeddedpostgres.NewDatabase(embeddedpostgres.DefaultConfig().Port(5432)) + err := server.Start() + if err != nil { + t.Fatalf("failed to start embedded postgres: %v", err) + } + defer func() { + if err := server.Stop(); err != nil { + t.Errorf("failed to stop embedded postgres: %v", err) + } + }() + + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + db, err := sql.Open("postgres", "postgres://postgres:postgres@127.0.0.1:5432/postgres?sslmode=disable") + if err != nil { + t.Fatalf("failed to connect to database: %v", err) + } + defer db.Close() + + // Run migrations + _, err = db.ExecContext(ctx, string(schema.Bytes)) + if err != nil { + t.Fatalf("failed to run schema: %v", err) + } + + tests := []struct { + name string + setupFunc func() *DBStory + expectErr bool + checkFunc func(*testing.T, *sql.DB, *DBStory) error + }{ + { + name: "insert new story successfully", + setupFunc: func() *DBStory { + return &DBStory{ + StoryID: "insert-test-1", + Title: "Insert Test Story", + Content: "Test content", + Labels: []string{"test", "insert"}, + } + }, + expectErr: false, + checkFunc: func(t *testing.T, db *sql.DB, story *DBStory) error { + if story.ID == 0 { + return fmt.Errorf("expected non-zero ID after insert, got 0") + } + if story.CreatedAt.IsZero() { + return fmt.Errorf("expected non-zero CreatedAt") + } + if story.UpdatedAt.IsZero() { + return fmt.Errorf("expected non-zero UpdatedAt") + } + if story.Etag == "" { + return fmt.Errorf("expected non-empty etag") + } + + // Verify in database + var dbStory DBStory + var labels pq.StringArray + err := db.QueryRow(` + SELECT id, story_id, title, content, labels, created_at, updated_at, etag + FROM stories WHERE id = $1`, + story.ID).Scan( + &dbStory.ID, &dbStory.StoryID, &dbStory.Title, &dbStory.Content, + &labels, &dbStory.CreatedAt, &dbStory.UpdatedAt, &dbStory.Etag, + ) + if err != nil { + return fmt.Errorf("failed to query inserted story: %w", err) + } + dbStory.Labels = labels + if dbStory.StoryID != story.StoryID { + return fmt.Errorf("story_id mismatch: expected %s, got %s", story.StoryID, dbStory.StoryID) + } + if dbStory.Title != story.Title { + return fmt.Errorf("title mismatch: expected %s, got %s", story.Title, dbStory.Title) + } + return nil + }, + }, + { + name: "insert new story with etag provided", + setupFunc: func() *DBStory { + return &DBStory{ + StoryID: "insert-test-2", + Title: "Insert Test with Etag", + Etag: "custom-etag-123", + } + }, + expectErr: false, + checkFunc: func(t *testing.T, db *sql.DB, story *DBStory) error { + // Verify etag was set (even if custom provided, DB may override) + var dbStory DBStory + err := db.QueryRow(`SELECT etag FROM stories WHERE story_id = $1`, "insert-test-2").Scan(&dbStory.Etag) + if err != nil { + return fmt.Errorf("failed to query story: %w", err) + } + if dbStory.Etag == "" { + return fmt.Errorf("expected non-empty etag in database") + } + return nil + }, + }, + { + name: "insert new story with all fields", + setupFunc: func() *DBStory { + return &DBStory{ + StoryID: "insert-test-3", + Title: "Full Story", + Content: "Full content here", + Description: "Full description", + Labels: []string{"tag1", "tag2", "tag3"}, + } + }, + expectErr: false, + checkFunc: func(t *testing.T, db *sql.DB, story *DBStory) error { + var dbStory DBStory + var labels pq.StringArray + err := db.QueryRow(` + SELECT id, story_id, title, content, description, labels, created_at, updated_at, etag + FROM stories WHERE story_id = $1`, + "insert-test-3").Scan( + &dbStory.ID, &dbStory.StoryID, &dbStory.Title, &dbStory.Content, + &dbStory.Description, &labels, &dbStory.CreatedAt, + &dbStory.UpdatedAt, &dbStory.Etag, + ) + if err != nil { + return fmt.Errorf("failed to query inserted story: %w", err) + } + dbStory.Labels = []string(labels) + if dbStory.Description != "Full description" { + return fmt.Errorf("description mismatch") + } + return nil + }, + }, + { + name: "update existing story successfully", + setupFunc: func() *DBStory { + // First create the story + newStory := &DBStory{ + StoryID: "update-test-1", + Title: "Original Title", + Content: "Original content", + } + if err := newStory.Save(db); err != nil { + t.Fatalf("failed to insert story for update test: %v", err) + } + // Now update it + newStory.ID = newStory.ID + newStory.Title = "Updated Title" + newStory.Content = "Updated content" + return newStory + }, + expectErr: false, + checkFunc: func(t *testing.T, db *sql.DB, story *DBStory) error { + if story.Title != "Updated Title" { + return fmt.Errorf("expected title to be 'Updated Title', got %s", story.Title) + } + if story.Content != "Updated content" { + return fmt.Errorf("expected content to be 'Updated content', got %s", story.Content) + } + + // Verify in database + var dbStory DBStory + err := db.QueryRow(` + SELECT title, content FROM stories WHERE id = $1`, + story.ID).Scan(&dbStory.Title, &dbStory.Content) + if err != nil { + return fmt.Errorf("failed to query updated story: %w", err) + } + if dbStory.Title != "Updated Title" { + return fmt.Errorf("title mismatch in DB: expected 'Updated Title', got %s", dbStory.Title) + } + if dbStory.Content != "Updated content" { + return fmt.Errorf("content mismatch in DB") + } + return nil + }, + }, + { + name: "update with labels changes", + setupFunc: func() *DBStory { + // First create + newStory := &DBStory{ + StoryID: "update-test-2", + Title: "Label Update Test", + Labels: []string{"old-tag"}, + } + if err := newStory.Save(db); err != nil { + t.Fatalf("failed to insert story for label update test: %v", err) + } + // Update + newStory.ID = newStory.ID + newStory.Labels = []string{"new-tag-1", "new-tag-2"} + return newStory + }, + expectErr: false, + checkFunc: func(t *testing.T, db *sql.DB, story *DBStory) error { + var dbLabels pq.StringArray + err := db.QueryRow(`SELECT labels FROM stories WHERE id = $1`, story.ID).Scan(&dbLabels) + if err != nil { + return fmt.Errorf("failed to query labels: %w", err) + } + if len(dbLabels) != 2 { + return fmt.Errorf("expected 2 labels, got %d", len(dbLabels)) + } + return nil + }, + }, + { + name: "insert with invalid story_id fails", + setupFunc: func() *DBStory { + return &DBStory{ + StoryID: "INVALID", + Title: "Should Fail", + } + }, + expectErr: true, + checkFunc: func(t *testing.T, db *sql.DB, story *DBStory) error { + return nil + }, + }, + { + name: "insert with empty title fails", + setupFunc: func() *DBStory { + return &DBStory{ + StoryID: "empty-title-test", + Title: "", + } + }, + expectErr: true, + checkFunc: func(t *testing.T, db *sql.DB, story *DBStory) error { + return nil + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + story := tt.setupFunc() + + err := story.Save(db) + + if tt.expectErr { + if err == nil { + t.Fatalf("expected error but got nil") + } + return + } + + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if tt.checkFunc != nil { + if err := tt.checkFunc(t, db, story); err != nil { + t.Errorf("check failed: %v", err) + } + } + }) + } +}