diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 380e5c97..26baa423 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -53,6 +53,11 @@ jobs: go test -coverpkg=./... ./database/... -coverprofile=coverage-database-${{ matrix.go-version }}.out go tool cover -func=coverage-database-${{ matrix.go-version }}.out | tail -n 1 + - name: Run CLI tests + run: | + go test -coverpkg=./... ./cli/... -coverprofile=coverage-cli-${{ matrix.go-version }}.out + go tool cover -func=coverage-cli-${{ matrix.go-version }}.out | tail -n 1 + - name: Merge coverage reports run: | echo "mode: set" > coverage-${{ matrix.go-version }}.out diff --git a/cli/accounts/factory_test.go b/cli/accounts/factory_test.go new file mode 100644 index 00000000..0afebce6 --- /dev/null +++ b/cli/accounts/factory_test.go @@ -0,0 +1,35 @@ +package accounts + +import ( + clitest "github.com/oullin/cli/clitest" + "testing" + + "github.com/oullin/database" +) + +func TestMakeHandler(t *testing.T) { + conn := clitest.MakeTestConnection(t, &database.APIKey{}) + h, err := MakeHandler(conn, clitest.MakeTestEnv()) + if err != nil { + t.Fatalf("make handler: %v", err) + } + if h.TokenHandler == nil || h.Tokens == nil { + t.Fatalf("handler not properly initialized") + } + if err := h.CreateAccount("sampleaccount"); err != nil { + t.Fatalf("create account: %v", err) + } + var key database.APIKey + if err := conn.Sql().First(&key, "account_name = ?", "sampleaccount").Error; err != nil { + t.Fatalf("key not saved: %v", err) + } +} + +func TestMakeHandlerInvalidKey(t *testing.T) { + conn := clitest.MakeTestConnection(t) + env := clitest.MakeTestEnv() + env.App.MasterKey = "short" + if _, err := MakeHandler(conn, env); err == nil { + t.Fatalf("expected error") + } +} diff --git a/cli/accounts/handler_test.go b/cli/accounts/handler_test.go new file mode 100644 index 00000000..d7a9da72 --- /dev/null +++ b/cli/accounts/handler_test.go @@ -0,0 +1,51 @@ +package accounts + +import ( + "testing" + + clitest "github.com/oullin/cli/clitest" + "github.com/oullin/database" +) + +func setupAccountHandler(t *testing.T) *Handler { + conn := clitest.MakeTestConnection(t, &database.APIKey{}) + h, err := MakeHandler(conn, clitest.MakeTestEnv()) + if err != nil { + t.Fatalf("make handler: %v", err) + } + return h +} + +func TestCreateReadSignature(t *testing.T) { + h := setupAccountHandler(t) + if err := h.CreateAccount("tester"); err != nil { + t.Fatalf("create: %v", err) + } + if err := h.ReadAccount("tester"); err != nil { + t.Fatalf("read: %v", err) + } + if err := h.CreateSignature("tester"); err != nil { + t.Fatalf("signature: %v", err) + } +} + +func TestCreateAccountInvalid(t *testing.T) { + h := setupAccountHandler(t) + if err := h.CreateAccount("ab"); err == nil { + t.Fatalf("expected error") + } +} + +func TestReadAccountNotFound(t *testing.T) { + h := setupAccountHandler(t) + if err := h.ReadAccount("missing"); err == nil { + t.Fatalf("expected error") + } +} + +func TestCreateSignatureNotFound(t *testing.T) { + h := setupAccountHandler(t) + if err := h.CreateSignature("missing"); err == nil { + t.Fatalf("expected error") + } +} diff --git a/cli/clitest/helpers.go b/cli/clitest/helpers.go new file mode 100644 index 00000000..9017248e --- /dev/null +++ b/cli/clitest/helpers.go @@ -0,0 +1,75 @@ +package clitest + +import ( + "context" + "os/exec" + "testing" + + "github.com/google/uuid" + "github.com/oullin/database" + "github.com/oullin/env" + "github.com/testcontainers/testcontainers-go" + "github.com/testcontainers/testcontainers-go/modules/postgres" +) + +func MakeTestConnection(t *testing.T, models ...interface{}) *database.Connection { + t.Helper() + + if _, err := exec.LookPath("docker"); err != nil { + t.Skip("docker not installed") + } + + ctx := context.Background() + + pg, err := postgres.RunContainer(ctx, + testcontainers.WithImage("postgres:16-alpine"), + postgres.WithDatabase("testdb"), + postgres.WithUsername("test"), + postgres.WithPassword("secret"), + postgres.BasicWaitStrategies(), + ) + if err != nil { + t.Fatalf("container run err: %v", err) + } + t.Cleanup(func() { pg.Terminate(ctx) }) + + host, err := pg.Host(ctx) + if err != nil { + t.Fatalf("host err: %v", err) + } + port, err := pg.MappedPort(ctx, "5432/tcp") + if err != nil { + t.Fatalf("port err: %v", err) + } + + e := &env.Environment{ + DB: env.DBEnvironment{ + UserName: "test", + UserPassword: "secret", + DatabaseName: "testdb", + Port: port.Int(), + Host: host, + DriverName: database.DriverName, + SSLMode: "disable", + TimeZone: "UTC", + }, + } + + conn, err := database.MakeConnection(e) + if err != nil { + t.Fatalf("make connection: %v", err) + } + t.Cleanup(func() { conn.Close() }) + + if len(models) > 0 { + if err := conn.Sql().AutoMigrate(models...); err != nil { + t.Fatalf("migrate: %v", err) + } + } + + return conn +} + +func MakeTestEnv() *env.Environment { + return &env.Environment{App: env.AppEnvironment{MasterKey: uuid.NewString()[:32]}} +} diff --git a/cli/clitest/helpers_test.go b/cli/clitest/helpers_test.go new file mode 100644 index 00000000..8b2f2aed --- /dev/null +++ b/cli/clitest/helpers_test.go @@ -0,0 +1,22 @@ +package clitest + +import ( + "os/exec" + "testing" +) + +func TestMakeTestEnv(t *testing.T) { + env := MakeTestEnv() + if len(env.App.MasterKey) != 32 { + t.Fatalf("expected master key length 32, got %d", len(env.App.MasterKey)) + } +} + +func TestMakeTestConnectionSkipsWithoutDocker(t *testing.T) { + if _, err := exec.LookPath("docker"); err != nil { + t.Skip("docker not available") + } + t.Run("skip", func(t *testing.T) { + MakeTestConnection(t) + }) +} diff --git a/cli/panel/menu_test.go b/cli/panel/menu_test.go new file mode 100644 index 00000000..308f86c9 --- /dev/null +++ b/cli/panel/menu_test.go @@ -0,0 +1,95 @@ +package panel + +import ( + "bufio" + "io" + "os" + "strings" + "testing" + + "github.com/oullin/pkg" +) + +func captureOutput(fn func()) string { + old := os.Stdout + r, w, _ := os.Pipe() + os.Stdout = w + fn() + w.Close() + out, _ := io.ReadAll(r) + os.Stdout = old + return string(out) +} + +func TestPrintLineAndGetChoiceNil(t *testing.T) { + m := Menu{Reader: bufio.NewReader(strings.NewReader("\n"))} + m.PrintLine() + if m.GetChoice() != 0 { + t.Fatalf("expected 0") + } +} + +func TestPrint(t *testing.T) { + m := Menu{Reader: bufio.NewReader(strings.NewReader(""))} + _ = captureOutput(func() { m.Print() }) +} + +func TestCenterText(t *testing.T) { + m := Menu{} + if got := m.CenterText("hi", 6); got != " hi " { + t.Fatalf("unexpected: %q", got) + } + if got := m.CenterText("toolong", 4); got != "tool" { + t.Fatalf("unexpected truncation: %q", got) + } +} + +func TestPrintOption(t *testing.T) { + m := Menu{} + out := captureOutput(func() { m.PrintOption("x", 5) }) + if !strings.Contains(out, "║ x ║") { + t.Fatalf("unexpected output: %q", out) + } +} + +func TestCaptureInput(t *testing.T) { + m := Menu{Reader: bufio.NewReader(strings.NewReader("2\n"))} + if err := m.CaptureInput(); err != nil { + t.Fatalf("capture: %v", err) + } + if m.GetChoice() != 2 { + t.Fatalf("choice: %d", m.GetChoice()) + } + + bad := Menu{Reader: bufio.NewReader(strings.NewReader("bad\n"))} + if err := bad.CaptureInput(); err == nil { + t.Fatalf("expected error") + } +} + +func TestCaptureAccountName(t *testing.T) { + m := Menu{Reader: bufio.NewReader(strings.NewReader("Alice\n"))} + name, err := m.CaptureAccountName() + if err != nil || name != "Alice" { + t.Fatalf("got %q err %v", name, err) + } + + bad := Menu{Reader: bufio.NewReader(strings.NewReader("\n"))} + if _, err := bad.CaptureAccountName(); err == nil { + t.Fatalf("expected error") + } +} + +func TestCapturePostURL(t *testing.T) { + goodURL := "https://raw.githubusercontent.com/user/repo/file.md" + m := Menu{Reader: bufio.NewReader(strings.NewReader(goodURL + "\n")), Validator: pkg.GetDefaultValidator()} + in, err := m.CapturePostURL() + if err != nil || in.Url != goodURL { + t.Fatalf("got %v err %v", in, err) + } + + m2 := Menu{Reader: bufio.NewReader(strings.NewReader("http://example.com\n")), Validator: pkg.GetDefaultValidator()} + if _, err := m2.CapturePostURL(); err == nil { + t.Fatalf("expected error") + } +} diff --git a/cli/posts/handler_test.go b/cli/posts/handler_test.go new file mode 100644 index 00000000..1d3baa19 --- /dev/null +++ b/cli/posts/handler_test.go @@ -0,0 +1,128 @@ +package posts + +import ( + "io" + "net/http" + "net/http/httptest" + "os" + "testing" + "time" + + "github.com/google/uuid" + clitest "github.com/oullin/cli/clitest" + "github.com/oullin/database" + "github.com/oullin/pkg" + "github.com/oullin/pkg/markdown" +) + +func captureOutput(fn func()) string { + old := os.Stdout + r, w, _ := os.Pipe() + os.Stdout = w + fn() + w.Close() + out, _ := io.ReadAll(r) + os.Stdout = old + return string(out) +} + +func setupPostsHandler(t *testing.T) (*Handler, *database.Connection) { + conn := clitest.MakeTestConnection(t, &database.User{}, &database.Post{}, &database.Category{}, &database.PostCategory{}, &database.Tag{}, &database.PostTag{}) + user := database.User{UUID: uuid.NewString(), Username: "jdoe", FirstName: "John", LastName: "Doe", Email: "jdoe@example.com", PasswordHash: "x"} + if err := conn.Sql().Create(&user).Error; err != nil { + t.Fatalf("user create: %v", err) + } + conn.Sql().Create(&database.Category{UUID: uuid.NewString(), Name: "Tech", Slug: "tech"}) + conn.Sql().Create(&database.Tag{UUID: uuid.NewString(), Name: "Go", Slug: "go"}) + input := &Input{Url: "http://example"} + h := MakeHandler(input, pkg.MakeDefaultClient(nil), conn) + return &h, conn +} + +func TestHandlePost(t *testing.T) { + h, conn := setupPostsHandler(t) + post := &markdown.Post{ + FrontMatter: markdown.FrontMatter{ + Title: "Hello", + Excerpt: "ex", + Slug: "hello", + Author: "jdoe", + Categories: "tech", + PublishedAt: time.Now().Format("2006-01-02"), + Tags: []string{"go"}, + }, + Content: "world", + } + if err := h.HandlePost(post); err != nil { + t.Fatalf("handle: %v", err) + } + var p database.Post + if err := conn.Sql().Preload("Categories").First(&p, "slug = ?", "hello").Error; err != nil { + t.Fatalf("post not created: %v", err) + } + if len(p.Categories) != 1 { + t.Fatalf("expected 1 category") + } + _ = captureOutput(func() { h.RenderArticle(post) }) +} + +func TestHandlePostMissingAuthor(t *testing.T) { + h, _ := setupPostsHandler(t) + post := &markdown.Post{FrontMatter: markdown.FrontMatter{Author: "none"}} + if err := h.HandlePost(post); err == nil { + t.Fatalf("expected error") + } +} + +func TestHandlePostEmptyCategories(t *testing.T) { + h, _ := setupPostsHandler(t) + post := &markdown.Post{FrontMatter: markdown.FrontMatter{Author: "jdoe"}} + if err := h.HandlePost(post); err == nil { + t.Fatalf("expected error") + } +} + +func TestHandlePostInvalidDate(t *testing.T) { + h, _ := setupPostsHandler(t) + post := &markdown.Post{FrontMatter: markdown.FrontMatter{Author: "jdoe", PublishedAt: "bad"}} + if err := h.HandlePost(post); err == nil { + t.Fatalf("expected error") + } +} + +func TestHandlePostDuplicateSlug(t *testing.T) { + h, _ := setupPostsHandler(t) + post := &markdown.Post{FrontMatter: markdown.FrontMatter{Author: "jdoe", Slug: "dup", Categories: "tech", PublishedAt: time.Now().Format("2006-01-02")}} + if err := h.HandlePost(post); err != nil { + t.Fatalf("first create: %v", err) + } + if err := h.HandlePost(post); err == nil { + t.Fatalf("expected duplicate error") + } +} + +func TestNotParsed(t *testing.T) { + h, conn := setupPostsHandler(t) + md := "---\nauthor: jdoe\nslug: parsed\ncategories: tech\npublished_at: 2024-01-01\ntags:\n - go\n---\ncontent" + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Write([]byte(md)) })) + defer srv.Close() + h.Input.Url = srv.URL + ok, err := h.NotParsed() + if err != nil || !ok { + t.Fatalf("not parsed: %v", err) + } + var p database.Post + if err := conn.Sql().First(&p, "slug = ?", "parsed").Error; err != nil { + t.Fatalf("post not saved") + } +} + +func TestNotParsedError(t *testing.T) { + h, _ := setupPostsHandler(t) + srv := httptest.NewServer(http.NotFoundHandler()) + defer srv.Close() + h.Input.Url = srv.URL + if ok, err := h.NotParsed(); err == nil || ok { + t.Fatalf("expected error") + } +} diff --git a/database/model_internal_test.go b/database/model_internal_test.go index 728cb714..a3f91e97 100644 --- a/database/model_internal_test.go +++ b/database/model_internal_test.go @@ -6,15 +6,23 @@ import ( ) func TestIsValidTable(t *testing.T) { - if !isValidTable("users") { - t.Fatalf("expected users table to be valid") - } - if isValidTable("unknown") { - t.Fatalf("unexpected valid table") + for _, name := range GetSchemaTables() { + name := name + t.Run(name, func(t *testing.T) { + if !isValidTable(name) { + t.Errorf("expected table %q to be valid", name) + } + }) } + + t.Run("nonexistent table", func(t *testing.T) { + if isValidTable("unknown") { + t.Error(`expected table "unknown" to be invalid`) + } + }) } -func TestIsValidTableEdgeCases(t *testing.T) { +func TestIsValidTableNonexistentTables(t *testing.T) { invalid := []string{ "", "user!@#", @@ -27,8 +35,11 @@ func TestIsValidTableEdgeCases(t *testing.T) { } for _, name := range invalid { - if isValidTable(name) { - t.Fatalf("%q should be invalid", name) - } + name := name + t.Run(name, func(t *testing.T) { + if isValidTable(name) { + t.Errorf("%q should be invalid", name) + } + }) } }