From 6824328fa1d4a3a7980d11c1341a11b8af891a04 Mon Sep 17 00:00:00 2001 From: Gus Date: Thu, 31 Jul 2025 11:22:17 +0800 Subject: [PATCH 01/30] Add unit tests and CI workflow --- .github/workflows/tests.yml | 22 ++++++ pkg/auth/encryption_test.go | 48 +++++++++++ pkg/auth/handler_test.go | 39 +++++++++ pkg/auth/render_test.go | 11 +++ pkg/cli/helpers_test.go | 8 ++ pkg/cli/message_test.go | 41 ++++++++++ pkg/client_test.go | 31 ++++++++ pkg/gorm/support_test.go | 37 +++++++++ pkg/http/handler_test.go | 28 +++++++ pkg/http/middleware/pipeline_test.go | 39 +++++++++ pkg/http/middleware/token_middleware_test.go | 38 +++++++++ pkg/http/request_test.go | 35 +++++++++ pkg/http/response_test.go | 64 +++++++++++++++ pkg/llogs/files_logs_test.go | 34 ++++++++ pkg/markdown/handler_test.go | 20 +++++ pkg/markdown/schema_test.go | 42 ++++++++++ pkg/media/media_test.go | 83 ++++++++++++++++++++ pkg/parser_test.go | 35 +++++++++ pkg/password_test.go | 19 +++++ pkg/stringable_test.go | 43 ++++++++++ pkg/support_test.go | 30 +++++++ pkg/validator_test.go | 39 +++++++++ 22 files changed, 786 insertions(+) create mode 100644 .github/workflows/tests.yml create mode 100644 pkg/auth/encryption_test.go create mode 100644 pkg/auth/handler_test.go create mode 100644 pkg/auth/render_test.go create mode 100644 pkg/cli/helpers_test.go create mode 100644 pkg/cli/message_test.go create mode 100644 pkg/client_test.go create mode 100644 pkg/gorm/support_test.go create mode 100644 pkg/http/handler_test.go create mode 100644 pkg/http/middleware/pipeline_test.go create mode 100644 pkg/http/middleware/token_middleware_test.go create mode 100644 pkg/http/request_test.go create mode 100644 pkg/http/response_test.go create mode 100644 pkg/llogs/files_logs_test.go create mode 100644 pkg/markdown/handler_test.go create mode 100644 pkg/markdown/schema_test.go create mode 100644 pkg/media/media_test.go create mode 100644 pkg/parser_test.go create mode 100644 pkg/password_test.go create mode 100644 pkg/stringable_test.go create mode 100644 pkg/support_test.go create mode 100644 pkg/validator_test.go diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml new file mode 100644 index 00000000..af813b97 --- /dev/null +++ b/.github/workflows/tests.yml @@ -0,0 +1,22 @@ +name: Tests + +on: + push: + branches: [main] + pull_request: + +jobs: + test: + runs-on: ubuntu-latest + strategy: + matrix: + go-version: [1.24.x] + steps: + - uses: actions/setup-go@v5 + with: + go-version: ${{ matrix.go-version }} + - uses: actions/checkout@v4 + - name: Run unit tests + run: go test ./pkg ./pkg/auth ./pkg/cli ./pkg/gorm ./pkg/http ./pkg/llogs ./pkg/media -coverprofile=coverage.out + - name: Coverage Report + run: go tool cover -func=coverage.out diff --git a/pkg/auth/encryption_test.go b/pkg/auth/encryption_test.go new file mode 100644 index 00000000..fed0ea58 --- /dev/null +++ b/pkg/auth/encryption_test.go @@ -0,0 +1,48 @@ +package auth + +import "testing" + +func TestEncryptDecrypt(t *testing.T) { + key, err := GenerateAESKey() + if err != nil || len(key) != EncryptionKeyLength { + t.Fatalf("key err") + } + plain := []byte("hello") + enc, err := Encrypt(plain, key) + if err != nil { + t.Fatalf("encrypt err: %v", err) + } + dec, err := Decrypt(enc, key) + if err != nil { + t.Fatalf("decrypt err: %v", err) + } + if string(dec) != "hello" { + t.Fatalf("expected hello got %s", dec) + } +} + +func TestDecryptWrongKey(t *testing.T) { + key, _ := GenerateAESKey() + other, _ := GenerateAESKey() + enc, _ := Encrypt([]byte("hello"), key) + if _, err := Decrypt(enc, other); err == nil { + t.Fatalf("expected error") + } +} + +func TestCreateSignatureFrom(t *testing.T) { + sig1 := CreateSignatureFrom("msg", "secret") + sig2 := CreateSignatureFrom("msg", "secret") + if sig1 != sig2 { + t.Fatalf("signature mismatch") + } +} + +func TestValidateTokenFormat(t *testing.T) { + if ValidateTokenFormat("pk_1234567890123") != nil { + t.Fatalf("valid token should pass") + } + if ValidateTokenFormat("bad") == nil { + t.Fatalf("invalid token should fail") + } +} diff --git a/pkg/auth/handler_test.go b/pkg/auth/handler_test.go new file mode 100644 index 00000000..924a3679 --- /dev/null +++ b/pkg/auth/handler_test.go @@ -0,0 +1,39 @@ +package auth + +import "testing" + +func TestTokenHandlerLifecycle(t *testing.T) { + key, _ := GenerateAESKey() + h, err := MakeTokensHandler(key) + if err != nil { + t.Fatalf("make handler: %v", err) + } + + token, err := h.SetupNewAccount("tester") + if err != nil { + t.Fatalf("setup: %v", err) + } + + decoded, err := h.DecodeTokensFor(token.AccountName, token.EncryptedSecretKey, token.EncryptedPublicKey) + if err != nil { + t.Fatalf("decode: %v", err) + } + if decoded.PublicKey != token.PublicKey || decoded.SecretKey != token.SecretKey { + t.Fatalf("decode mismatch") + } +} + +func TestMakeTokensHandlerError(t *testing.T) { + _, err := MakeTokensHandler([]byte("short")) + if err == nil { + t.Fatalf("expected error for short key") + } +} + +func TestSetupNewAccountErrors(t *testing.T) { + key, _ := GenerateAESKey() + h, _ := MakeTokensHandler(key) + if _, err := h.SetupNewAccount("ab"); err == nil { + t.Fatalf("expected error for short name") + } +} diff --git a/pkg/auth/render_test.go b/pkg/auth/render_test.go new file mode 100644 index 00000000..7875b898 --- /dev/null +++ b/pkg/auth/render_test.go @@ -0,0 +1,11 @@ +package auth + +import "testing" + +func TestSafeDisplay(t *testing.T) { + tok := "sk_1234567890123456abcd" + d := SafeDisplay(tok) + if d == tok || len(d) >= len(tok) { + t.Fatalf("masking failed") + } +} diff --git a/pkg/cli/helpers_test.go b/pkg/cli/helpers_test.go new file mode 100644 index 00000000..20bd4516 --- /dev/null +++ b/pkg/cli/helpers_test.go @@ -0,0 +1,8 @@ +package cli + +import "testing" + +func TestClearScreen(t *testing.T) { + // just ensure it does not panic + ClearScreen() +} diff --git a/pkg/cli/message_test.go b/pkg/cli/message_test.go new file mode 100644 index 00000000..f1df448d --- /dev/null +++ b/pkg/cli/message_test.go @@ -0,0 +1,41 @@ +package cli + +import ( + "io" + "os" + "testing" +) + +func captureOutput(f func()) string { + r, w, _ := os.Pipe() + old := os.Stdout + os.Stdout = w + f() + w.Close() + os.Stdout = old + out, _ := io.ReadAll(r) + return string(out) +} + +func TestMessageFunctions(t *testing.T) { + if captureOutput(func() { Error("err") }) == "" { + t.Fatalf("no output") + } + if captureOutput(func() { Success("ok") }) == "" { + t.Fatalf("no output") + } + if captureOutput(func() { Warning("warn") }) == "" { + t.Fatalf("no output") + } + captureOutput(func() { Errorln("err") }) + captureOutput(func() { Successln("ok") }) + captureOutput(func() { Warningln("warn") }) + captureOutput(func() { Magenta("m") }) + captureOutput(func() { Magentaln("m") }) + captureOutput(func() { Blue("b") }) + captureOutput(func() { Blueln("b") }) + captureOutput(func() { Cyan("c") }) + captureOutput(func() { Cyanln("c") }) + captureOutput(func() { Gray("g") }) + captureOutput(func() { Grayln("g") }) +} diff --git a/pkg/client_test.go b/pkg/client_test.go new file mode 100644 index 00000000..0286d77c --- /dev/null +++ b/pkg/client_test.go @@ -0,0 +1,31 @@ +package pkg + +import ( + "context" + "net/http" + "net/http/httptest" + "testing" +) + +func TestClientTransportAndGet(t *testing.T) { + tr := GetDefaultTransport() + c := MakeDefaultClient(tr) + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Write([]byte("hello")) + })) + defer srv.Close() + + out, err := c.Get(context.Background(), srv.URL) + if err != nil || out != "hello" { + t.Fatalf("get failed: %v %s", err, out) + } +} + +func TestClientGetNil(t *testing.T) { + var c *Client + _, err := c.Get(context.Background(), "http://example.com") + if err == nil { + t.Fatalf("expected error") + } +} diff --git a/pkg/gorm/support_test.go b/pkg/gorm/support_test.go new file mode 100644 index 00000000..7a46eb6e --- /dev/null +++ b/pkg/gorm/support_test.go @@ -0,0 +1,37 @@ +package gorm + +import ( + "errors" + stdgorm "gorm.io/gorm" + "testing" +) + +func TestIsNotFound(t *testing.T) { + if !IsNotFound(stdgorm.ErrRecordNotFound) { + t.Fatalf("expected true") + } + if IsNotFound(nil) { + t.Fatalf("nil should be false") + } +} + +func TestIsFoundButHasErrors(t *testing.T) { + if !IsFoundButHasErrors(errors.New("other")) { + t.Fatalf("expected true") + } + if IsFoundButHasErrors(stdgorm.ErrRecordNotFound) { + t.Fatalf("should be false") + } +} + +func TestHasDbIssues(t *testing.T) { + if !HasDbIssues(stdgorm.ErrRecordNotFound) { + t.Fatalf("expected true") + } + if !HasDbIssues(errors.New("foo")) { + t.Fatalf("expected true") + } + if HasDbIssues(nil) { + t.Fatalf("nil should be false") + } +} diff --git a/pkg/http/handler_test.go b/pkg/http/handler_test.go new file mode 100644 index 00000000..c78f63e6 --- /dev/null +++ b/pkg/http/handler_test.go @@ -0,0 +1,28 @@ +package http + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "testing" +) + +func TestMakeApiHandler(t *testing.T) { + h := MakeApiHandler(func(w http.ResponseWriter, r *http.Request) *ApiError { + return &ApiError{Message: "bad", Status: http.StatusBadRequest} + }) + + rec := httptest.NewRecorder() + h(rec, httptest.NewRequest("GET", "/", nil)) + + if rec.Code != http.StatusBadRequest { + t.Fatalf("status %d", rec.Code) + } + var resp ErrorResponse + if err := json.NewDecoder(rec.Body).Decode(&resp); err != nil { + t.Fatalf("decode: %v", err) + } + if resp.Error == "" || resp.Status != http.StatusBadRequest { + t.Fatalf("invalid response") + } +} diff --git a/pkg/http/middleware/pipeline_test.go b/pkg/http/middleware/pipeline_test.go new file mode 100644 index 00000000..a7e830ac --- /dev/null +++ b/pkg/http/middleware/pipeline_test.go @@ -0,0 +1,39 @@ +package middleware + +import ( + "net/http" + "net/http/httptest" + "strings" + "testing" + + pkgHttp "github.com/oullin/pkg/http" +) + +func TestPipelineChainOrder(t *testing.T) { + p := Pipeline{} + order := []string{} + m1 := func(next pkgHttp.ApiHandler) pkgHttp.ApiHandler { + return func(w http.ResponseWriter, r *http.Request) *pkgHttp.ApiError { + order = append(order, "m1") + return next(w, r) + } + } + m2 := func(next pkgHttp.ApiHandler) pkgHttp.ApiHandler { + return func(w http.ResponseWriter, r *http.Request) *pkgHttp.ApiError { + order = append(order, "m2") + return next(w, r) + } + } + final := func(w http.ResponseWriter, r *http.Request) *pkgHttp.ApiError { + order = append(order, "final") + return nil + } + + chained := p.Chain(final, m1, m2) + chained(httptest.NewRecorder(), httptest.NewRequest("GET", "/", nil)) + + joined := strings.Join(order, ",") + if joined != "m1,m2,final" { + t.Fatalf("order wrong: %s", joined) + } +} diff --git a/pkg/http/middleware/token_middleware_test.go b/pkg/http/middleware/token_middleware_test.go new file mode 100644 index 00000000..7b028b34 --- /dev/null +++ b/pkg/http/middleware/token_middleware_test.go @@ -0,0 +1,38 @@ +package middleware + +import ( + "net/http" + "net/http/httptest" + "testing" + + pkgAuth "github.com/oullin/pkg/auth" + pkgHttp "github.com/oullin/pkg/http" +) + +func TestTokenMiddlewareErrors(t *testing.T) { + tm := TokenCheckMiddleware{} + e := tm.getInvalidRequestError("a", "b", "c") + if e.Status != 403 || e.Message == "" { + t.Fatalf("invalid request error") + } + e = tm.getInvalidTokenFormatError("pk_x", pkgAuth.ValidateTokenFormat("bad")) + if e.Status != 403 { + t.Fatalf("invalid token error") + } + e = tm.getUnauthenticatedError("a", "b", "c") + if e.Status != 403 { + t.Fatalf("unauthenticated error") + } +} + +func TestTokenMiddlewareHandleInvalid(t *testing.T) { + tm := MakeTokenMiddleware(nil, nil) + handler := tm.Handle(func(w http.ResponseWriter, r *http.Request) *pkgHttp.ApiError { + return nil + }) + rec := httptest.NewRecorder() + err := handler(rec, httptest.NewRequest("GET", "/", nil)) + if err == nil || err.Status != 403 { + t.Fatalf("expected forbidden") + } +} diff --git a/pkg/http/request_test.go b/pkg/http/request_test.go new file mode 100644 index 00000000..ed2bb830 --- /dev/null +++ b/pkg/http/request_test.go @@ -0,0 +1,35 @@ +package http + +import ( + "net/http/httptest" + "strings" + "testing" +) + +type sampleReq struct { + Name string `json:"name"` +} + +func TestParseRequestBody(t *testing.T) { + r := httptest.NewRequest("POST", "/", strings.NewReader("{\"name\":\"bob\"}")) + v, err := ParseRequestBody[sampleReq](r) + if err != nil || v.Name != "bob" { + t.Fatalf("parse failed: %v %#v", err, v) + } +} + +func TestParseRequestBodyEmpty(t *testing.T) { + r := httptest.NewRequest("POST", "/", nil) + v, err := ParseRequestBody[sampleReq](r) + if err != nil || v.Name != "" { + t.Fatalf("expected zero value") + } +} + +func TestParseRequestBodyInvalid(t *testing.T) { + r := httptest.NewRequest("POST", "/", strings.NewReader("{")) + _, err := ParseRequestBody[sampleReq](r) + if err == nil { + t.Fatalf("expected error") + } +} diff --git a/pkg/http/response_test.go b/pkg/http/response_test.go new file mode 100644 index 00000000..032b92e6 --- /dev/null +++ b/pkg/http/response_test.go @@ -0,0 +1,64 @@ +package http + +import ( + "net/http" + "net/http/httptest" + "testing" +) + +func TestResponse_RespondOkAndHasCache(t *testing.T) { + req := httptest.NewRequest("GET", "/", nil) + rec := httptest.NewRecorder() + + r := MakeResponseFrom("salt", rec, req) + if err := r.RespondOk(map[string]string{"a": "b"}); err != nil { + t.Fatalf("respond: %v", err) + } + + if rec.Code != http.StatusOK { + t.Fatalf("status %d", rec.Code) + } + if rec.Header().Get("ETag") == "" || rec.Header().Get("Cache-Control") == "" { + t.Fatalf("headers missing") + } + + req.Header.Set("If-None-Match", r.etag) + if !r.HasCache() { + t.Fatalf("expected cache") + } +} + +func TestResponse_WithHeaders(t *testing.T) { + req := httptest.NewRequest("GET", "/", nil) + rec := httptest.NewRecorder() + r := MakeResponseFrom("salt", rec, req) + called := false + r.WithHeaders(func(w http.ResponseWriter) { called = true }) + if !called { + t.Fatalf("callback not called") + } +} + +func TestResponse_NotModified(t *testing.T) { + req := httptest.NewRequest("GET", "/", nil) + rec := httptest.NewRecorder() + + r := MakeResponseFrom("salt", rec, req) + r.RespondWithNotModified() + + if rec.Code != http.StatusNotModified { + t.Fatalf("status %d", rec.Code) + } +} + +func TestApiErrorHelpers(t *testing.T) { + if InternalError("x").Status != http.StatusInternalServerError { + t.Fatalf("internal status") + } + if BadRequestError("x").Status != http.StatusBadRequest { + t.Fatalf("bad req status") + } + if NotFound("x").Status != http.StatusNotFound { + t.Fatalf("not found") + } +} diff --git a/pkg/llogs/files_logs_test.go b/pkg/llogs/files_logs_test.go new file mode 100644 index 00000000..840368e8 --- /dev/null +++ b/pkg/llogs/files_logs_test.go @@ -0,0 +1,34 @@ +package llogs + +import ( + "strings" + "testing" + + "github.com/oullin/env" +) + +func TestFilesLogs(t *testing.T) { + dir := t.TempDir() + e := &env.Environment{Logs: env.LogsEnvironment{Dir: dir + "/log-%s.txt", DateFormat: "2006"}} + + d, err := MakeFilesLogs(e) + if err != nil { + t.Fatalf("make logs: %v", err) + } + fl := d.(FilesLogs) + if !strings.HasPrefix(fl.path, dir) { + t.Fatalf("path not in dir") + } + if !fl.Close() { + t.Fatalf("close") + } +} + +func TestDefaultPath(t *testing.T) { + e := &env.Environment{Logs: env.LogsEnvironment{Dir: "foo-%s", DateFormat: "2006"}} + fl := FilesLogs{env: e} + p := fl.DefaultPath() + if !strings.HasPrefix(p, "foo-") { + t.Fatalf("path prefix") + } +} diff --git a/pkg/markdown/handler_test.go b/pkg/markdown/handler_test.go new file mode 100644 index 00000000..1ea61328 --- /dev/null +++ b/pkg/markdown/handler_test.go @@ -0,0 +1,20 @@ +package markdown + +import ( + "net/http" + "net/http/httptest" + "testing" +) + +func TestParserFetch(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Write([]byte("data")) + })) + defer server.Close() + + p := Parser{Url: server.URL} + content, err := p.Fetch() + if err != nil || content != "data" { + t.Fatalf("fetch failed") + } +} diff --git a/pkg/markdown/schema_test.go b/pkg/markdown/schema_test.go new file mode 100644 index 00000000..a15f8097 --- /dev/null +++ b/pkg/markdown/schema_test.go @@ -0,0 +1,42 @@ +package markdown + +import ( + "testing" +) + +func TestParseWithHeaderImage(t *testing.T) { + md := `--- +slug: test +published_at: 2024-06-09 +--- +![alt](url) +content` + post, err := Parse(md) + if err != nil { + t.Fatalf("err: %v", err) + } + if post.ImageAlt != "alt" || post.ImageURL != "url" || post.Content != "content" { + t.Fatalf("parse failed") + } + if post.Slug != "test" { + t.Fatalf("front matter parse failed") + } + if _, err := post.GetPublishedAt(); err != nil { + t.Fatalf("get date: %v", err) + } +} + +func TestParseWithoutHeaderImage(t *testing.T) { + md := `--- +slug: another +published_at: 2024-06-09 +--- +content` + post, err := Parse(md) + if err != nil { + t.Fatalf("err: %v", err) + } + if post.ImageAlt != "" || post.ImageURL != "" || post.Content != "content" { + t.Fatalf("parse failed") + } +} diff --git a/pkg/media/media_test.go b/pkg/media/media_test.go new file mode 100644 index 00000000..162c57bd --- /dev/null +++ b/pkg/media/media_test.go @@ -0,0 +1,83 @@ +package media + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +func setupTempDir(t *testing.T) string { + t.Helper() + dir := t.TempDir() + old, _ := os.Getwd() + os.Chdir(dir) + t.Cleanup(func() { os.Chdir(old) }) + os.MkdirAll(GetUsersImagesDir(), 0755) + return dir +} + +func TestMakeMediaAndUpload(t *testing.T) { + setupTempDir(t) + data := []byte{1, 2, 3} + m, err := MakeMedia("uid", data, "pic.jpg") + if err != nil { + t.Fatalf("make: %v", err) + } + if !strings.HasPrefix(m.GetFileName(), "uid-") { + t.Fatalf("name prefix") + } + if m.GetExtension() != ".jpg" { + t.Fatalf("ext") + } + if m.GetHeaderName() != "pic.jpg" { + t.Fatalf("header") + } + if err := m.Upload(GetUsersImagesDir()); err != nil { + t.Fatalf("upload: %v", err) + } + if _, err := os.Stat(m.path); err != nil { + t.Fatalf("file not created") + } + if err := m.RemovePrefixedFiles(GetUsersImagesDir(), "uid"); err != nil { + t.Fatalf("remove: %v", err) + } +} + +func TestMakeMediaErrors(t *testing.T) { + setupTempDir(t) + if _, err := MakeMedia("u", []byte{}, "a.jpg"); err == nil { + t.Fatalf("expected empty file error") + } + big := make([]byte, maxFileSize+1) + if _, err := MakeMedia("u", big, "a.jpg"); err == nil { + t.Fatalf("expected size error") + } + if _, err := MakeMedia("u", []byte{1}, "a.txt"); err == nil { + t.Fatalf("expected ext error") + } +} + +func TestGetFilePath(t *testing.T) { + setupTempDir(t) + m, _ := MakeMedia("u", []byte{1}, "a.jpg") + p := m.GetFilePath("thumb") + if !strings.Contains(filepath.Base(p), "thumb-") { + t.Fatalf("file path wrong: %s", p) + } +} + +func TestGetPostsImagesDir(t *testing.T) { + setupTempDir(t) + if !strings.Contains(GetPostsImagesDir(), "posts") { + t.Fatalf("dir invalid") + } +} + +func TestGetStorageDir(t *testing.T) { + dir := setupTempDir(t) + p := GetStorageDir() + if !strings.HasPrefix(p, dir) { + t.Fatalf("unexpected storage dir") + } +} diff --git a/pkg/parser_test.go b/pkg/parser_test.go new file mode 100644 index 00000000..fcaf1245 --- /dev/null +++ b/pkg/parser_test.go @@ -0,0 +1,35 @@ +package pkg + +import ( + "os" + "testing" +) + +type jsonSample struct { + Name string `json:"name"` + Age int `json:"age"` +} + +func TestParseJsonFile(t *testing.T) { + dir := t.TempDir() + file := dir + "/sample.json" + content := `{"name":"john","age":30}` + if err := os.WriteFile(file, []byte(content), 0644); err != nil { + t.Fatalf("write: %v", err) + } + + v, err := ParseJsonFile[jsonSample](file) + if err != nil { + t.Fatalf("parse: %v", err) + } + if v.Name != "john" || v.Age != 30 { + t.Fatalf("unexpected result: %#v", v) + } +} + +func TestParseJsonFileError(t *testing.T) { + _, err := ParseJsonFile[jsonSample]("nonexistent.json") + if err == nil { + t.Fatalf("expected error") + } +} diff --git a/pkg/password_test.go b/pkg/password_test.go new file mode 100644 index 00000000..fb16036c --- /dev/null +++ b/pkg/password_test.go @@ -0,0 +1,19 @@ +package pkg + +import "testing" + +func TestPassword_MakeAndValidate(t *testing.T) { + pw, err := MakePassword("secret") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !pw.Is("secret") { + t.Fatalf("password validation failed") + } + if pw.Is("other") { + t.Fatalf("password should not match") + } + if pw.GetHash() == "" { + t.Fatalf("hash is empty") + } +} diff --git a/pkg/stringable_test.go b/pkg/stringable_test.go new file mode 100644 index 00000000..6fefb20b --- /dev/null +++ b/pkg/stringable_test.go @@ -0,0 +1,43 @@ +package pkg + +import ( + "testing" + "time" +) + +func TestStringable_ToLower(t *testing.T) { + s := MakeStringable(" FooBar ") + if got := s.ToLower(); got != "foobar" { + t.Fatalf("expected foobar got %s", got) + } +} + +func TestStringable_ToSnakeCase(t *testing.T) { + s := MakeStringable("HelloWorldTest") + if got := s.ToSnakeCase(); got != "hello_world_test" { + t.Fatalf("expected hello_world_test got %s", got) + } +} + +func TestStringable_ToDatetime(t *testing.T) { + s := MakeStringable("2024-06-09") + dt, err := s.ToDatetime() + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if dt.Year() != 2024 || dt.Month() != time.June || dt.Day() != 9 { + t.Fatalf("unexpected datetime: %v", dt) + } +} + +func TestStringable_ToDatetimeError(t *testing.T) { + s := MakeStringable("bad-date") + if _, err := s.ToDatetime(); err == nil { + t.Fatalf("expected error") + } +} + +func TestStringable_Dd(t *testing.T) { + // just ensure it does not panic and prints + MakeStringable("test").Dd(struct{ X int }{1}) +} diff --git a/pkg/support_test.go b/pkg/support_test.go new file mode 100644 index 00000000..f711b8e9 --- /dev/null +++ b/pkg/support_test.go @@ -0,0 +1,30 @@ +package pkg + +import ( + "errors" + "testing" +) + +type fakeCloser struct { + closed bool + err error +} + +func (f *fakeCloser) Close() error { + f.closed = true + return f.err +} + +func TestCloseWithLog(t *testing.T) { + c := &fakeCloser{} + CloseWithLog(c) + if !c.closed { + t.Fatalf("close not called") + } + + c2 := &fakeCloser{err: errors.New("fail")} + CloseWithLog(c2) + if !c2.closed { + t.Fatalf("close not called with error") + } +} diff --git a/pkg/validator_test.go b/pkg/validator_test.go new file mode 100644 index 00000000..3118e4bf --- /dev/null +++ b/pkg/validator_test.go @@ -0,0 +1,39 @@ +package pkg + +import "testing" + +type user struct { + Email string `validate:"required,email"` + Name string `validate:"required"` + Code string `validate:"len=3"` +} + +func TestValidator_PassesAndRejects(t *testing.T) { + v := GetDefaultValidator() + + ok, err := v.Passes(&user{Email: "a@b.com", Name: "John", Code: "123"}) + if err != nil || !ok { + t.Fatalf("expected pass got %v %v", ok, err) + } + + invalid := &user{Email: "bad", Name: "", Code: "1"} + if ok, err := v.Passes(invalid); ok || err == nil { + t.Fatalf("expected fail") + } + if len(v.GetErrors()) == 0 { + t.Fatalf("errors not recorded") + } + json := v.GetErrorsAsJason() + if json == "" { + t.Fatalf("json empty") + } +} + +func TestValidator_Rejects(t *testing.T) { + v := GetDefaultValidator() + u := &user{Email: "", Name: "", Code: "1"} + reject, _ := v.Rejects(u) + if !reject { + t.Fatalf("expected reject") + } +} From 4c949efc999133da0a01537cf694a9c6e0d2865f Mon Sep 17 00:00:00 2001 From: Gus Date: Thu, 31 Jul 2025 12:00:51 +0800 Subject: [PATCH 02/30] style: adjust tests and workflow --- .github/workflows/tests.yml | 3 +++ pkg/auth/encryption_test.go | 3 +++ pkg/auth/handler_test.go | 5 +++++ pkg/auth/render_test.go | 1 + pkg/cli/message_test.go | 2 ++ pkg/client_test.go | 1 + pkg/http/handler_test.go | 1 + pkg/http/middleware/pipeline_test.go | 1 + pkg/http/middleware/token_middleware_test.go | 3 +++ pkg/http/request_test.go | 3 +++ pkg/http/response_test.go | 2 ++ pkg/llogs/files_logs_test.go | 2 ++ pkg/markdown/handler_test.go | 1 + pkg/markdown/schema_test.go | 2 ++ pkg/media/media_test.go | 4 ++++ pkg/parser_test.go | 1 + pkg/password_test.go | 1 + pkg/stringable_test.go | 4 ++++ pkg/support_test.go | 2 ++ pkg/validator_test.go | 3 +++ 20 files changed, 45 insertions(+) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index af813b97..a12ec9d7 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -15,8 +15,11 @@ jobs: - uses: actions/setup-go@v5 with: go-version: ${{ matrix.go-version }} + - uses: actions/checkout@v4 + - name: Run unit tests run: go test ./pkg ./pkg/auth ./pkg/cli ./pkg/gorm ./pkg/http ./pkg/llogs ./pkg/media -coverprofile=coverage.out + - name: Coverage Report run: go tool cover -func=coverage.out diff --git a/pkg/auth/encryption_test.go b/pkg/auth/encryption_test.go index fed0ea58..3f3ffb18 100644 --- a/pkg/auth/encryption_test.go +++ b/pkg/auth/encryption_test.go @@ -7,6 +7,7 @@ func TestEncryptDecrypt(t *testing.T) { if err != nil || len(key) != EncryptionKeyLength { t.Fatalf("key err") } + plain := []byte("hello") enc, err := Encrypt(plain, key) if err != nil { @@ -25,6 +26,7 @@ func TestDecryptWrongKey(t *testing.T) { key, _ := GenerateAESKey() other, _ := GenerateAESKey() enc, _ := Encrypt([]byte("hello"), key) + if _, err := Decrypt(enc, other); err == nil { t.Fatalf("expected error") } @@ -33,6 +35,7 @@ func TestDecryptWrongKey(t *testing.T) { func TestCreateSignatureFrom(t *testing.T) { sig1 := CreateSignatureFrom("msg", "secret") sig2 := CreateSignatureFrom("msg", "secret") + if sig1 != sig2 { t.Fatalf("signature mismatch") } diff --git a/pkg/auth/handler_test.go b/pkg/auth/handler_test.go index 924a3679..6fd8a9c6 100644 --- a/pkg/auth/handler_test.go +++ b/pkg/auth/handler_test.go @@ -5,16 +5,19 @@ import "testing" func TestTokenHandlerLifecycle(t *testing.T) { key, _ := GenerateAESKey() h, err := MakeTokensHandler(key) + if err != nil { t.Fatalf("make handler: %v", err) } token, err := h.SetupNewAccount("tester") + if err != nil { t.Fatalf("setup: %v", err) } decoded, err := h.DecodeTokensFor(token.AccountName, token.EncryptedSecretKey, token.EncryptedPublicKey) + if err != nil { t.Fatalf("decode: %v", err) } @@ -25,6 +28,7 @@ func TestTokenHandlerLifecycle(t *testing.T) { func TestMakeTokensHandlerError(t *testing.T) { _, err := MakeTokensHandler([]byte("short")) + if err == nil { t.Fatalf("expected error for short key") } @@ -33,6 +37,7 @@ func TestMakeTokensHandlerError(t *testing.T) { func TestSetupNewAccountErrors(t *testing.T) { key, _ := GenerateAESKey() h, _ := MakeTokensHandler(key) + if _, err := h.SetupNewAccount("ab"); err == nil { t.Fatalf("expected error for short name") } diff --git a/pkg/auth/render_test.go b/pkg/auth/render_test.go index 7875b898..23b6dc09 100644 --- a/pkg/auth/render_test.go +++ b/pkg/auth/render_test.go @@ -5,6 +5,7 @@ import "testing" func TestSafeDisplay(t *testing.T) { tok := "sk_1234567890123456abcd" d := SafeDisplay(tok) + if d == tok || len(d) >= len(tok) { t.Fatalf("masking failed") } diff --git a/pkg/cli/message_test.go b/pkg/cli/message_test.go index f1df448d..9d5d4ce1 100644 --- a/pkg/cli/message_test.go +++ b/pkg/cli/message_test.go @@ -9,11 +9,13 @@ import ( func captureOutput(f func()) string { r, w, _ := os.Pipe() old := os.Stdout + os.Stdout = w f() w.Close() os.Stdout = old out, _ := io.ReadAll(r) + return string(out) } diff --git a/pkg/client_test.go b/pkg/client_test.go index 0286d77c..3dbd7cfd 100644 --- a/pkg/client_test.go +++ b/pkg/client_test.go @@ -24,6 +24,7 @@ func TestClientTransportAndGet(t *testing.T) { func TestClientGetNil(t *testing.T) { var c *Client + _, err := c.Get(context.Background(), "http://example.com") if err == nil { t.Fatalf("expected error") diff --git a/pkg/http/handler_test.go b/pkg/http/handler_test.go index c78f63e6..a5810de1 100644 --- a/pkg/http/handler_test.go +++ b/pkg/http/handler_test.go @@ -19,6 +19,7 @@ func TestMakeApiHandler(t *testing.T) { t.Fatalf("status %d", rec.Code) } var resp ErrorResponse + if err := json.NewDecoder(rec.Body).Decode(&resp); err != nil { t.Fatalf("decode: %v", err) } diff --git a/pkg/http/middleware/pipeline_test.go b/pkg/http/middleware/pipeline_test.go index a7e830ac..f13b0654 100644 --- a/pkg/http/middleware/pipeline_test.go +++ b/pkg/http/middleware/pipeline_test.go @@ -12,6 +12,7 @@ import ( func TestPipelineChainOrder(t *testing.T) { p := Pipeline{} order := []string{} + m1 := func(next pkgHttp.ApiHandler) pkgHttp.ApiHandler { return func(w http.ResponseWriter, r *http.Request) *pkgHttp.ApiError { order = append(order, "m1") diff --git a/pkg/http/middleware/token_middleware_test.go b/pkg/http/middleware/token_middleware_test.go index 7b028b34..52871ade 100644 --- a/pkg/http/middleware/token_middleware_test.go +++ b/pkg/http/middleware/token_middleware_test.go @@ -11,6 +11,7 @@ import ( func TestTokenMiddlewareErrors(t *testing.T) { tm := TokenCheckMiddleware{} + e := tm.getInvalidRequestError("a", "b", "c") if e.Status != 403 || e.Message == "" { t.Fatalf("invalid request error") @@ -27,9 +28,11 @@ func TestTokenMiddlewareErrors(t *testing.T) { func TestTokenMiddlewareHandleInvalid(t *testing.T) { tm := MakeTokenMiddleware(nil, nil) + handler := tm.Handle(func(w http.ResponseWriter, r *http.Request) *pkgHttp.ApiError { return nil }) + rec := httptest.NewRecorder() err := handler(rec, httptest.NewRequest("GET", "/", nil)) if err == nil || err.Status != 403 { diff --git a/pkg/http/request_test.go b/pkg/http/request_test.go index ed2bb830..afb105ac 100644 --- a/pkg/http/request_test.go +++ b/pkg/http/request_test.go @@ -13,6 +13,7 @@ type sampleReq struct { func TestParseRequestBody(t *testing.T) { r := httptest.NewRequest("POST", "/", strings.NewReader("{\"name\":\"bob\"}")) v, err := ParseRequestBody[sampleReq](r) + if err != nil || v.Name != "bob" { t.Fatalf("parse failed: %v %#v", err, v) } @@ -21,6 +22,7 @@ func TestParseRequestBody(t *testing.T) { func TestParseRequestBodyEmpty(t *testing.T) { r := httptest.NewRequest("POST", "/", nil) v, err := ParseRequestBody[sampleReq](r) + if err != nil || v.Name != "" { t.Fatalf("expected zero value") } @@ -29,6 +31,7 @@ func TestParseRequestBodyEmpty(t *testing.T) { func TestParseRequestBodyInvalid(t *testing.T) { r := httptest.NewRequest("POST", "/", strings.NewReader("{")) _, err := ParseRequestBody[sampleReq](r) + if err == nil { t.Fatalf("expected error") } diff --git a/pkg/http/response_test.go b/pkg/http/response_test.go index 032b92e6..b40c38cb 100644 --- a/pkg/http/response_test.go +++ b/pkg/http/response_test.go @@ -11,6 +11,7 @@ func TestResponse_RespondOkAndHasCache(t *testing.T) { rec := httptest.NewRecorder() r := MakeResponseFrom("salt", rec, req) + if err := r.RespondOk(map[string]string{"a": "b"}); err != nil { t.Fatalf("respond: %v", err) } @@ -33,6 +34,7 @@ func TestResponse_WithHeaders(t *testing.T) { rec := httptest.NewRecorder() r := MakeResponseFrom("salt", rec, req) called := false + r.WithHeaders(func(w http.ResponseWriter) { called = true }) if !called { t.Fatalf("callback not called") diff --git a/pkg/llogs/files_logs_test.go b/pkg/llogs/files_logs_test.go index 840368e8..0e09bcad 100644 --- a/pkg/llogs/files_logs_test.go +++ b/pkg/llogs/files_logs_test.go @@ -16,6 +16,7 @@ func TestFilesLogs(t *testing.T) { t.Fatalf("make logs: %v", err) } fl := d.(FilesLogs) + if !strings.HasPrefix(fl.path, dir) { t.Fatalf("path not in dir") } @@ -27,6 +28,7 @@ func TestFilesLogs(t *testing.T) { func TestDefaultPath(t *testing.T) { e := &env.Environment{Logs: env.LogsEnvironment{Dir: "foo-%s", DateFormat: "2006"}} fl := FilesLogs{env: e} + p := fl.DefaultPath() if !strings.HasPrefix(p, "foo-") { t.Fatalf("path prefix") diff --git a/pkg/markdown/handler_test.go b/pkg/markdown/handler_test.go index 1ea61328..ba0ba763 100644 --- a/pkg/markdown/handler_test.go +++ b/pkg/markdown/handler_test.go @@ -13,6 +13,7 @@ func TestParserFetch(t *testing.T) { defer server.Close() p := Parser{Url: server.URL} + content, err := p.Fetch() if err != nil || content != "data" { t.Fatalf("fetch failed") diff --git a/pkg/markdown/schema_test.go b/pkg/markdown/schema_test.go index a15f8097..e64f08df 100644 --- a/pkg/markdown/schema_test.go +++ b/pkg/markdown/schema_test.go @@ -11,6 +11,7 @@ published_at: 2024-06-09 --- ![alt](url) content` + post, err := Parse(md) if err != nil { t.Fatalf("err: %v", err) @@ -32,6 +33,7 @@ slug: another published_at: 2024-06-09 --- content` + post, err := Parse(md) if err != nil { t.Fatalf("err: %v", err) diff --git a/pkg/media/media_test.go b/pkg/media/media_test.go index 162c57bd..00e58a05 100644 --- a/pkg/media/media_test.go +++ b/pkg/media/media_test.go @@ -20,6 +20,7 @@ func setupTempDir(t *testing.T) string { func TestMakeMediaAndUpload(t *testing.T) { setupTempDir(t) data := []byte{1, 2, 3} + m, err := MakeMedia("uid", data, "pic.jpg") if err != nil { t.Fatalf("make: %v", err) @@ -50,6 +51,7 @@ func TestMakeMediaErrors(t *testing.T) { t.Fatalf("expected empty file error") } big := make([]byte, maxFileSize+1) + if _, err := MakeMedia("u", big, "a.jpg"); err == nil { t.Fatalf("expected size error") } @@ -61,6 +63,7 @@ func TestMakeMediaErrors(t *testing.T) { func TestGetFilePath(t *testing.T) { setupTempDir(t) m, _ := MakeMedia("u", []byte{1}, "a.jpg") + p := m.GetFilePath("thumb") if !strings.Contains(filepath.Base(p), "thumb-") { t.Fatalf("file path wrong: %s", p) @@ -76,6 +79,7 @@ func TestGetPostsImagesDir(t *testing.T) { func TestGetStorageDir(t *testing.T) { dir := setupTempDir(t) + p := GetStorageDir() if !strings.HasPrefix(p, dir) { t.Fatalf("unexpected storage dir") diff --git a/pkg/parser_test.go b/pkg/parser_test.go index fcaf1245..0a141440 100644 --- a/pkg/parser_test.go +++ b/pkg/parser_test.go @@ -29,6 +29,7 @@ func TestParseJsonFile(t *testing.T) { func TestParseJsonFileError(t *testing.T) { _, err := ParseJsonFile[jsonSample]("nonexistent.json") + if err == nil { t.Fatalf("expected error") } diff --git a/pkg/password_test.go b/pkg/password_test.go index fb16036c..588bbfa6 100644 --- a/pkg/password_test.go +++ b/pkg/password_test.go @@ -4,6 +4,7 @@ import "testing" func TestPassword_MakeAndValidate(t *testing.T) { pw, err := MakePassword("secret") + if err != nil { t.Fatalf("unexpected error: %v", err) } diff --git a/pkg/stringable_test.go b/pkg/stringable_test.go index 6fefb20b..d5dc15dd 100644 --- a/pkg/stringable_test.go +++ b/pkg/stringable_test.go @@ -7,6 +7,7 @@ import ( func TestStringable_ToLower(t *testing.T) { s := MakeStringable(" FooBar ") + if got := s.ToLower(); got != "foobar" { t.Fatalf("expected foobar got %s", got) } @@ -14,6 +15,7 @@ func TestStringable_ToLower(t *testing.T) { func TestStringable_ToSnakeCase(t *testing.T) { s := MakeStringable("HelloWorldTest") + if got := s.ToSnakeCase(); got != "hello_world_test" { t.Fatalf("expected hello_world_test got %s", got) } @@ -22,6 +24,7 @@ func TestStringable_ToSnakeCase(t *testing.T) { func TestStringable_ToDatetime(t *testing.T) { s := MakeStringable("2024-06-09") dt, err := s.ToDatetime() + if err != nil { t.Fatalf("unexpected error: %v", err) } @@ -32,6 +35,7 @@ func TestStringable_ToDatetime(t *testing.T) { func TestStringable_ToDatetimeError(t *testing.T) { s := MakeStringable("bad-date") + if _, err := s.ToDatetime(); err == nil { t.Fatalf("expected error") } diff --git a/pkg/support_test.go b/pkg/support_test.go index f711b8e9..891dfa4b 100644 --- a/pkg/support_test.go +++ b/pkg/support_test.go @@ -17,12 +17,14 @@ func (f *fakeCloser) Close() error { func TestCloseWithLog(t *testing.T) { c := &fakeCloser{} + CloseWithLog(c) if !c.closed { t.Fatalf("close not called") } c2 := &fakeCloser{err: errors.New("fail")} + CloseWithLog(c2) if !c2.closed { t.Fatalf("close not called with error") diff --git a/pkg/validator_test.go b/pkg/validator_test.go index 3118e4bf..b0442403 100644 --- a/pkg/validator_test.go +++ b/pkg/validator_test.go @@ -17,6 +17,7 @@ func TestValidator_PassesAndRejects(t *testing.T) { } invalid := &user{Email: "bad", Name: "", Code: "1"} + if ok, err := v.Passes(invalid); ok || err == nil { t.Fatalf("expected fail") } @@ -24,6 +25,7 @@ func TestValidator_PassesAndRejects(t *testing.T) { t.Fatalf("errors not recorded") } json := v.GetErrorsAsJason() + if json == "" { t.Fatalf("json empty") } @@ -32,6 +34,7 @@ func TestValidator_PassesAndRejects(t *testing.T) { func TestValidator_Rejects(t *testing.T) { v := GetDefaultValidator() u := &user{Email: "", Name: "", Code: "1"} + reject, _ := v.Rejects(u) if !reject { t.Fatalf("expected reject") From 879e9c82cfedd7850e9d31dbe89f2d56e1dda873 Mon Sep 17 00:00:00 2001 From: Gus Date: Thu, 31 Jul 2025 12:13:36 +0800 Subject: [PATCH 03/30] Fix tests and rename validation method --- boost/factory.go | 12 ++++----- cli/panel/menu.go | 2 +- pkg/auth/encryption_test.go | 7 +++-- pkg/auth/handler_test.go | 15 ++++++++--- pkg/auth/render_test.go | 5 ++-- pkg/cli/message_test.go | 52 ++++++++++++++++++++++++++----------- pkg/media/media_test.go | 5 +++- pkg/validator.go | 2 +- pkg/validator_test.go | 2 +- 9 files changed, 70 insertions(+), 32 deletions(-) diff --git a/boost/factory.go b/boost/factory.go index 7d5dab3f..e173042a 100644 --- a/boost/factory.go +++ b/boost/factory.go @@ -93,23 +93,23 @@ func MakeEnv(validate *pkg.Validator) *env.Environment { } if _, err := validate.Rejects(app); err != nil { - panic(errorSuffix + "invalid [APP] model: " + validate.GetErrorsAsJason()) + panic(errorSuffix + "invalid [APP] model: " + validate.GetErrorsAsJson()) } if _, err := validate.Rejects(db); err != nil { - panic(errorSuffix + "invalid [Sql] model: " + validate.GetErrorsAsJason()) + panic(errorSuffix + "invalid [Sql] model: " + validate.GetErrorsAsJson()) } if _, err := validate.Rejects(logsCreds); err != nil { - panic(errorSuffix + "invalid [logs Creds] model: " + validate.GetErrorsAsJason()) + panic(errorSuffix + "invalid [logs Creds] model: " + validate.GetErrorsAsJson()) } if _, err := validate.Rejects(net); err != nil { - panic(errorSuffix + "invalid [NETWORK] model: " + validate.GetErrorsAsJason()) + panic(errorSuffix + "invalid [NETWORK] model: " + validate.GetErrorsAsJson()) } if _, err := validate.Rejects(sentryEnvironment); err != nil { - panic(errorSuffix + "invalid [SENTRY] model: " + validate.GetErrorsAsJason()) + panic(errorSuffix + "invalid [SENTRY] model: " + validate.GetErrorsAsJson()) } blog := &env.Environment{ @@ -121,7 +121,7 @@ func MakeEnv(validate *pkg.Validator) *env.Environment { } if _, err := validate.Rejects(blog); err != nil { - panic(errorSuffix + "invalid blog [ENVIRONMENT] model: " + validate.GetErrorsAsJason()) + panic(errorSuffix + "invalid blog [ENVIRONMENT] model: " + validate.GetErrorsAsJson()) } return blog diff --git a/cli/panel/menu.go b/cli/panel/menu.go index b0090237..78d7e42c 100644 --- a/cli/panel/menu.go +++ b/cli/panel/menu.go @@ -175,7 +175,7 @@ func (p *Menu) CapturePostURL() (*posts.Input, error) { cli.Reset, cli.BlueColour, cli.Reset, - validate.GetErrorsAsJason(), + validate.GetErrorsAsJson(), ) } diff --git a/pkg/auth/encryption_test.go b/pkg/auth/encryption_test.go index 3f3ffb18..bc2a14a9 100644 --- a/pkg/auth/encryption_test.go +++ b/pkg/auth/encryption_test.go @@ -4,8 +4,11 @@ import "testing" func TestEncryptDecrypt(t *testing.T) { key, err := GenerateAESKey() - if err != nil || len(key) != EncryptionKeyLength { - t.Fatalf("key err") + if err != nil { + t.Fatalf("key err: %v", err) + } + if len(key) != EncryptionKeyLength { + t.Fatalf("invalid key length %d", len(key)) } plain := []byte("hello") diff --git a/pkg/auth/handler_test.go b/pkg/auth/handler_test.go index 6fd8a9c6..3c7fb7cb 100644 --- a/pkg/auth/handler_test.go +++ b/pkg/auth/handler_test.go @@ -3,7 +3,10 @@ package auth import "testing" func TestTokenHandlerLifecycle(t *testing.T) { - key, _ := GenerateAESKey() + key, err := GenerateAESKey() + if err != nil { + t.Fatalf("generate key: %v", err) + } h, err := MakeTokensHandler(key) if err != nil { @@ -35,8 +38,14 @@ func TestMakeTokensHandlerError(t *testing.T) { } func TestSetupNewAccountErrors(t *testing.T) { - key, _ := GenerateAESKey() - h, _ := MakeTokensHandler(key) + key, err := GenerateAESKey() + if err != nil { + t.Fatalf("generate key: %v", err) + } + h, err := MakeTokensHandler(key) + if err != nil { + t.Fatalf("make handler: %v", err) + } if _, err := h.SetupNewAccount("ab"); err == nil { t.Fatalf("expected error for short name") diff --git a/pkg/auth/render_test.go b/pkg/auth/render_test.go index 23b6dc09..82f5d248 100644 --- a/pkg/auth/render_test.go +++ b/pkg/auth/render_test.go @@ -5,8 +5,9 @@ import "testing" func TestSafeDisplay(t *testing.T) { tok := "sk_1234567890123456abcd" d := SafeDisplay(tok) + expected := "sk_1234567890..." - if d == tok || len(d) >= len(tok) { - t.Fatalf("masking failed") + if d != expected { + t.Fatalf("expected %s got %s", expected, d) } } diff --git a/pkg/cli/message_test.go b/pkg/cli/message_test.go index 9d5d4ce1..6c67f49a 100644 --- a/pkg/cli/message_test.go +++ b/pkg/cli/message_test.go @@ -21,23 +21,45 @@ func captureOutput(f func()) string { func TestMessageFunctions(t *testing.T) { if captureOutput(func() { Error("err") }) == "" { - t.Fatalf("no output") + t.Fatalf("no output for Error") } if captureOutput(func() { Success("ok") }) == "" { - t.Fatalf("no output") + t.Fatalf("no output for Success") } if captureOutput(func() { Warning("warn") }) == "" { - t.Fatalf("no output") - } - captureOutput(func() { Errorln("err") }) - captureOutput(func() { Successln("ok") }) - captureOutput(func() { Warningln("warn") }) - captureOutput(func() { Magenta("m") }) - captureOutput(func() { Magentaln("m") }) - captureOutput(func() { Blue("b") }) - captureOutput(func() { Blueln("b") }) - captureOutput(func() { Cyan("c") }) - captureOutput(func() { Cyanln("c") }) - captureOutput(func() { Gray("g") }) - captureOutput(func() { Grayln("g") }) + t.Fatalf("no output for Warning") + } + if captureOutput(func() { Errorln("err") }) == "" { + t.Fatalf("no output for Errorln") + } + if captureOutput(func() { Successln("ok") }) == "" { + t.Fatalf("no output for Successln") + } + if captureOutput(func() { Warningln("warn") }) == "" { + t.Fatalf("no output for Warningln") + } + if captureOutput(func() { Magenta("m") }) == "" { + t.Fatalf("no output for Magenta") + } + if captureOutput(func() { Magentaln("m") }) == "" { + t.Fatalf("no output for Magentaln") + } + if captureOutput(func() { Blue("b") }) == "" { + t.Fatalf("no output for Blue") + } + if captureOutput(func() { Blueln("b") }) == "" { + t.Fatalf("no output for Blueln") + } + if captureOutput(func() { Cyan("c") }) == "" { + t.Fatalf("no output for Cyan") + } + if captureOutput(func() { Cyanln("c") }) == "" { + t.Fatalf("no output for Cyanln") + } + if captureOutput(func() { Gray("g") }) == "" { + t.Fatalf("no output for Gray") + } + if captureOutput(func() { Grayln("g") }) == "" { + t.Fatalf("no output for Grayln") + } } diff --git a/pkg/media/media_test.go b/pkg/media/media_test.go index 00e58a05..be7b7ab7 100644 --- a/pkg/media/media_test.go +++ b/pkg/media/media_test.go @@ -62,7 +62,10 @@ func TestMakeMediaErrors(t *testing.T) { func TestGetFilePath(t *testing.T) { setupTempDir(t) - m, _ := MakeMedia("u", []byte{1}, "a.jpg") + m, err := MakeMedia("u", []byte{1}, "a.jpg") + if err != nil { + t.Fatalf("make: %v", err) + } p := m.GetFilePath("thumb") if !strings.Contains(filepath.Base(p), "thumb-") { diff --git a/pkg/validator.go b/pkg/validator.go index e5ba8a6f..7662c505 100644 --- a/pkg/validator.go +++ b/pkg/validator.go @@ -97,7 +97,7 @@ func (v *Validator) parseError(validateErrs validator.ValidationErrors) { } } -func (v *Validator) GetErrorsAsJason() string { +func (v *Validator) GetErrorsAsJson() string { value, err := json.Marshal(v.GetErrors()) if err != nil { diff --git a/pkg/validator_test.go b/pkg/validator_test.go index b0442403..148a1b73 100644 --- a/pkg/validator_test.go +++ b/pkg/validator_test.go @@ -24,7 +24,7 @@ func TestValidator_PassesAndRejects(t *testing.T) { if len(v.GetErrors()) == 0 { t.Fatalf("errors not recorded") } - json := v.GetErrorsAsJason() + json := v.GetErrorsAsJson() if json == "" { t.Fatalf("json empty") From 6bc8660c47b4a92a986391b3acca5b82c049260b Mon Sep 17 00:00:00 2001 From: Gus Date: Thu, 31 Jul 2025 12:27:15 +0800 Subject: [PATCH 04/30] Fix tests workflow and encryption test --- .github/workflows/tests.yml | 2 +- pkg/auth/encryption_test.go | 18 +++++++++++++++--- 2 files changed, 16 insertions(+), 4 deletions(-) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index a12ec9d7..415608b2 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -19,7 +19,7 @@ jobs: - uses: actions/checkout@v4 - name: Run unit tests - run: go test ./pkg ./pkg/auth ./pkg/cli ./pkg/gorm ./pkg/http ./pkg/llogs ./pkg/media -coverprofile=coverage.out + run: go test ./pkg/... -coverprofile=coverage.out - name: Coverage Report run: go tool cover -func=coverage.out diff --git a/pkg/auth/encryption_test.go b/pkg/auth/encryption_test.go index bc2a14a9..e43fe49f 100644 --- a/pkg/auth/encryption_test.go +++ b/pkg/auth/encryption_test.go @@ -26,9 +26,21 @@ func TestEncryptDecrypt(t *testing.T) { } func TestDecryptWrongKey(t *testing.T) { - key, _ := GenerateAESKey() - other, _ := GenerateAESKey() - enc, _ := Encrypt([]byte("hello"), key) + key, err := GenerateAESKey() + if err != nil { + t.Fatalf("key err: %v", err) + } + + other, err := GenerateAESKey() + if err != nil { + t.Fatalf("other key err: %v", err) + } + + enc, err := Encrypt([]byte("hello"), key) + + if err != nil { + t.Fatalf("encrypt err: %v", err) + } if _, err := Decrypt(enc, other); err == nil { t.Fatalf("expected error") From 9b30f932b0a5251b2d5c023e21561d5cd8208ffe Mon Sep 17 00:00:00 2001 From: Gus Date: Thu, 31 Jul 2025 12:41:37 +0800 Subject: [PATCH 05/30] Update tests workflow conditions and coverage --- .github/workflows/tests.yml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 415608b2..3bb15e14 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -1,12 +1,12 @@ name: Tests on: - push: - branches: [main] pull_request: + types: [ready_for_review, synchronize, labeled] jobs: test: + if: github.event.pull_request.draft == false || (github.event.action == 'labeled' && github.event.label.name == 'test') runs-on: ubuntu-latest strategy: matrix: @@ -22,4 +22,4 @@ jobs: run: go test ./pkg/... -coverprofile=coverage.out - name: Coverage Report - run: go tool cover -func=coverage.out + run: go tool cover -func=coverage.out | tail -n 1 From 01fd15450850197641661a733bf35c97b0637c07 Mon Sep 17 00:00:00 2001 From: Gus Date: Thu, 31 Jul 2025 12:57:47 +0800 Subject: [PATCH 06/30] Increase test coverage --- pkg/auth/encryption_test.go | 16 ++++++++++++++++ pkg/auth/handler_test.go | 19 +++++++++++++++++++ pkg/auth/render_test.go | 7 +++++++ pkg/client_test.go | 25 +++++++++++++++++++++++++ pkg/http/schema_test.go | 15 +++++++++++++++ pkg/llogs/files_logs_test.go | 4 ++++ pkg/markdown/handler_test.go | 13 +++++++++++++ pkg/markdown/schema_test.go | 23 +++++++++++++++++++++++ 8 files changed, 122 insertions(+) create mode 100644 pkg/http/schema_test.go diff --git a/pkg/auth/encryption_test.go b/pkg/auth/encryption_test.go index e43fe49f..aa465eac 100644 --- a/pkg/auth/encryption_test.go +++ b/pkg/auth/encryption_test.go @@ -47,6 +47,22 @@ func TestDecryptWrongKey(t *testing.T) { } } +func TestDecryptShortCipher(t *testing.T) { + key, err := GenerateAESKey() + if err != nil { + t.Fatalf("key err: %v", err) + } + if _, err := Decrypt([]byte("short"), key); err == nil { + t.Fatalf("expected error for short cipher") + } +} + +func TestValidateTokenFormatEmpty(t *testing.T) { + if ValidateTokenFormat(" ") == nil { + t.Fatalf("empty token should fail") + } +} + func TestCreateSignatureFrom(t *testing.T) { sig1 := CreateSignatureFrom("msg", "secret") sig2 := CreateSignatureFrom("msg", "secret") diff --git a/pkg/auth/handler_test.go b/pkg/auth/handler_test.go index 3c7fb7cb..87725c6b 100644 --- a/pkg/auth/handler_test.go +++ b/pkg/auth/handler_test.go @@ -50,4 +50,23 @@ func TestSetupNewAccountErrors(t *testing.T) { if _, err := h.SetupNewAccount("ab"); err == nil { t.Fatalf("expected error for short name") } + + badHandler := &TokenHandler{EncryptionKey: []byte("short")} + if _, err := badHandler.SetupNewAccount("tester"); err == nil { + t.Fatalf("expected encrypt error") + } +} + +func TestDecodeTokensForError(t *testing.T) { + key, err := GenerateAESKey() + if err != nil { + t.Fatalf("key err: %v", err) + } + h, err := MakeTokensHandler(key) + if err != nil { + t.Fatalf("make handler: %v", err) + } + if _, err := h.DecodeTokensFor("acc", []byte("bad"), []byte("bad")); err == nil { + t.Fatalf("expected error") + } } diff --git a/pkg/auth/render_test.go b/pkg/auth/render_test.go index 82f5d248..dbd3dcb1 100644 --- a/pkg/auth/render_test.go +++ b/pkg/auth/render_test.go @@ -11,3 +11,10 @@ func TestSafeDisplay(t *testing.T) { t.Fatalf("expected %s got %s", expected, d) } } + +func TestSafeDisplayShort(t *testing.T) { + tok := "pk_short" + if SafeDisplay(tok) != tok { + t.Fatalf("expected same") + } +} diff --git a/pkg/client_test.go b/pkg/client_test.go index 3dbd7cfd..53b58f6f 100644 --- a/pkg/client_test.go +++ b/pkg/client_test.go @@ -30,3 +30,28 @@ func TestClientGetNil(t *testing.T) { t.Fatalf("expected error") } } + +func TestClientOnHeadersAndAbort(t *testing.T) { + c := MakeDefaultClient(nil) + called := false + c.OnHeaders = func(req *http.Request) { + req.Header.Set("X-Test", "ok") + called = true + } + c.AbortOnNone2xx = true + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Header.Get("X-Test") != "ok" { + t.Fatalf("missing header") + } + w.WriteHeader(500) + })) + defer srv.Close() + + if _, err := c.Get(context.Background(), srv.URL); err == nil { + t.Fatalf("expected error") + } + if !called { + t.Fatalf("OnHeaders not called") + } +} diff --git a/pkg/http/schema_test.go b/pkg/http/schema_test.go new file mode 100644 index 00000000..426e1286 --- /dev/null +++ b/pkg/http/schema_test.go @@ -0,0 +1,15 @@ +package http + +import "testing" + +func TestApiErrorError(t *testing.T) { + e := &ApiError{Message: "boom", Status: 500} + if e.Error() != "boom" { + t.Fatalf("got %s", e.Error()) + } + + var nilErr *ApiError + if nilErr.Error() != "Internal Server Error" { + t.Fatalf("nil error wrong") + } +} diff --git a/pkg/llogs/files_logs_test.go b/pkg/llogs/files_logs_test.go index 0e09bcad..a54f0135 100644 --- a/pkg/llogs/files_logs_test.go +++ b/pkg/llogs/files_logs_test.go @@ -23,6 +23,10 @@ func TestFilesLogs(t *testing.T) { if !fl.Close() { t.Fatalf("close") } + + if fl.Close() { + t.Fatalf("expected false on second close") + } } func TestDefaultPath(t *testing.T) { diff --git a/pkg/markdown/handler_test.go b/pkg/markdown/handler_test.go index ba0ba763..165c5151 100644 --- a/pkg/markdown/handler_test.go +++ b/pkg/markdown/handler_test.go @@ -19,3 +19,16 @@ func TestParserFetch(t *testing.T) { t.Fatalf("fetch failed") } } + +func TestParserFetchError(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(500) + })) + defer server.Close() + + p := Parser{Url: server.URL} + + if _, err := p.Fetch(); err == nil { + t.Fatalf("expected error") + } +} diff --git a/pkg/markdown/schema_test.go b/pkg/markdown/schema_test.go index e64f08df..118ff75a 100644 --- a/pkg/markdown/schema_test.go +++ b/pkg/markdown/schema_test.go @@ -42,3 +42,26 @@ content` t.Fatalf("parse failed") } } + +func TestParseErrors(t *testing.T) { + if _, err := Parse("invalid"); err == nil { + t.Fatalf("expected error") + } + + if _, err := Parse(`---\nbad`); err == nil { + t.Fatalf("expected bad yaml error") + } + + md := `--- +slug: a +published_at: "bad" +--- +content` + post, err := Parse(md) + if err != nil { + t.Fatalf("parse: %v", err) + } + if _, err := post.GetPublishedAt(); err == nil { + t.Fatalf("expected date error") + } +} From d2279170ecd232b3a127a59b672c0bede0fd4d3e Mon Sep 17 00:00:00 2001 From: Gus Date: Thu, 31 Jul 2025 13:44:55 +0800 Subject: [PATCH 07/30] Add tests for boost and env packages --- boost/boost_test.go | 105 ++++++++++++++++++++++++++++++++++++++++++++ env/env_test.go | 90 +++++++++++++++++++++++++++++++++++++ 2 files changed, 195 insertions(+) create mode 100644 boost/boost_test.go create mode 100644 env/env_test.go diff --git a/boost/boost_test.go b/boost/boost_test.go new file mode 100644 index 00000000..4de3cde8 --- /dev/null +++ b/boost/boost_test.go @@ -0,0 +1,105 @@ +package boost + +import ( + "net/http" + "os" + "testing" + + "github.com/oullin/pkg" +) + +func validEnvVars(t *testing.T) { + t.Setenv("ENV_APP_NAME", "guss") + t.Setenv("ENV_APP_ENV_TYPE", "local") + t.Setenv("ENV_APP_MASTER_KEY", "12345678901234567890123456789012") + t.Setenv("ENV_DB_USER_NAME", "usernamefoo") + t.Setenv("ENV_DB_USER_PASSWORD", "passwordfoo") + t.Setenv("ENV_DB_DATABASE_NAME", "dbnamefoo") + t.Setenv("ENV_DB_PORT", "5432") + t.Setenv("ENV_DB_HOST", "localhost") + t.Setenv("ENV_DB_SSL_MODE", "require") + t.Setenv("ENV_DB_TIMEZONE", "UTC") + t.Setenv("ENV_APP_LOG_LEVEL", "debug") + t.Setenv("ENV_APP_LOGS_DIR", "logs_%s.log") + t.Setenv("ENV_APP_LOGS_DATE_FORMAT", "2006_01_02") + t.Setenv("ENV_HTTP_HOST", "localhost") + t.Setenv("ENV_HTTP_PORT", "8080") + t.Setenv("ENV_SENTRY_DSN", "dsn") + t.Setenv("ENV_SENTRY_CSP", "csp") +} + +func TestMakeEnv(t *testing.T) { + validEnvVars(t) + + env := MakeEnv(pkg.GetDefaultValidator()) + + if env.App.Name != "guss" { + t.Fatalf("env not loaded") + } +} + +func TestIgnite(t *testing.T) { + content := "ENV_APP_NAME=guss\n" + + "ENV_APP_ENV_TYPE=local\n" + + "ENV_APP_MASTER_KEY=12345678901234567890123456789012\n" + + "ENV_DB_USER_NAME=usernamefoo\n" + + "ENV_DB_USER_PASSWORD=passwordfoo\n" + + "ENV_DB_DATABASE_NAME=dbnamefoo\n" + + "ENV_DB_PORT=5432\n" + + "ENV_DB_HOST=localhost\n" + + "ENV_DB_SSL_MODE=require\n" + + "ENV_DB_TIMEZONE=UTC\n" + + "ENV_APP_LOG_LEVEL=debug\n" + + "ENV_APP_LOGS_DIR=logs_%s.log\n" + + "ENV_APP_LOGS_DATE_FORMAT=2006_01_02\n" + + "ENV_HTTP_HOST=localhost\n" + + "ENV_HTTP_PORT=8080\n" + + "ENV_SENTRY_DSN=dsn\n" + + "ENV_SENTRY_CSP=csp\n" + + f, err := os.CreateTemp("", "envfile") + if err != nil { + t.Fatalf("temp file err: %v", err) + } + defer os.Remove(f.Name()) + f.WriteString(content) + f.Close() + + env := Ignite(f.Name(), pkg.GetDefaultValidator()) + + if env.Network.HttpPort != "8080" { + t.Fatalf("env not loaded") + } +} + +func TestAppBootNil(t *testing.T) { + defer func() { + if r := recover(); r == nil { + t.Fatalf("expected panic") + } + }() + + var a *App + a.Boot() +} + +func TestAppHelpers(t *testing.T) { + app := &App{} + mux := http.NewServeMux() + r := Router{Mux: mux} + app.SetRouter(r) + + if app.GetMux() != mux { + t.Fatalf("mux not set") + } + + app.CloseLogs() + app.CloseDB() + + if app.GetEnv() != nil { + t.Fatalf("expected nil env") + } + if app.GetDB() != nil { + t.Fatalf("expected nil db") + } +} diff --git a/env/env_test.go b/env/env_test.go new file mode 100644 index 00000000..9426f9db --- /dev/null +++ b/env/env_test.go @@ -0,0 +1,90 @@ +package env + +import ( + "os" + "testing" +) + +func TestGetEnvVar(t *testing.T) { + t.Setenv("FOO", " bar ") + + if val := GetEnvVar("FOO"); val != "bar" { + t.Fatalf("expected bar got %q", val) + } +} + +func TestGetSecretOrEnv_File(t *testing.T) { + path := "/run/secrets/testsecret" + os.MkdirAll("/run/secrets", 0755) + os.WriteFile(path, []byte("secret"), 0644) + t.Cleanup(func() { os.Remove(path) }) + + t.Setenv("ENV", "env") + + got := GetSecretOrEnv("testsecret", "ENV") + if got != "secret" { + t.Fatalf("expected secret got %q", got) + } +} + +func TestGetSecretOrEnv_Env(t *testing.T) { + t.Setenv("ENV", "envvalue") + + got := GetSecretOrEnv("missing", "ENV") + if got != "envvalue" { + t.Fatalf("expected envvalue got %q", got) + } +} + +func TestAppEnvironmentChecks(t *testing.T) { + env := AppEnvironment{Type: "production"} + + if !env.IsProduction() { + t.Fatalf("expected production") + } + if env.IsStaging() || env.IsLocal() { + t.Fatalf("unexpected type flags") + } + + env.Type = "staging" + if !env.IsStaging() { + t.Fatalf("expected staging") + } + + env.Type = "local" + if !env.IsLocal() { + t.Fatalf("expected local") + } +} + +func TestDBEnvironment_GetDSN(t *testing.T) { + db := DBEnvironment{ + UserName: "usernamefoo", + UserPassword: "passwordfoo", + DatabaseName: "dbnamefoo", + Port: 5432, + Host: "localhost", + DriverName: "postgres", + SSLMode: "require", + TimeZone: "UTC", + } + + expect := "host=localhost user='usernamefoo' password='passwordfoo' dbname='dbnamefoo' port=5432 sslmode=require TimeZone=UTC" + if dsn := db.GetDSN(); dsn != expect { + t.Fatalf("unexpected dsn %q", dsn) + } +} + +func TestNetEnvironment(t *testing.T) { + net := NetEnvironment{HttpHost: "localhost", HttpPort: "8080"} + + if net.GetHttpHost() != "localhost" { + t.Fatalf("wrong host") + } + if net.GetHttpPort() != "8080" { + t.Fatalf("wrong port") + } + if net.GetHostURL() != "localhost:8080" { + t.Fatalf("wrong host url") + } +} From 5a33e8b822ba1998dd82c6e043556afb4285234c Mon Sep 17 00:00:00 2001 From: Gus Date: Thu, 31 Jul 2025 14:13:30 +0800 Subject: [PATCH 08/30] Run all tests in workflow --- .github/workflows/tests.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 3bb15e14..9084f884 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -19,7 +19,7 @@ jobs: - uses: actions/checkout@v4 - name: Run unit tests - run: go test ./pkg/... -coverprofile=coverage.out + run: go test ./pkg/... ./boost ./env -coverprofile=coverage.out - name: Coverage Report run: go tool cover -func=coverage.out | tail -n 1 From f310bcef94fc397ac61418a88373ad0674f9dc62 Mon Sep 17 00:00:00 2001 From: Gus Date: Thu, 31 Jul 2025 14:26:38 +0800 Subject: [PATCH 09/30] Update tests workflow and make secrets path configurable --- .github/workflows/tests.yml | 2 +- env/env.go | 7 ++++++- env/env_test.go | 6 ++++-- 3 files changed, 11 insertions(+), 4 deletions(-) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 9084f884..3bb15e14 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -19,7 +19,7 @@ jobs: - uses: actions/checkout@v4 - name: Run unit tests - run: go test ./pkg/... ./boost ./env -coverprofile=coverage.out + run: go test ./pkg/... -coverprofile=coverage.out - name: Coverage Report run: go tool cover -func=coverage.out | tail -n 1 diff --git a/env/env.go b/env/env.go index f3772c28..b2099d2d 100644 --- a/env/env.go +++ b/env/env.go @@ -2,6 +2,7 @@ package env import ( "os" + "path/filepath" "strings" ) @@ -13,12 +14,16 @@ type Environment struct { Sentry SentryEnvironment } +// SecretsDir defines where secret files are read from. It can be overridden in +// tests. +var SecretsDir = "/run/secrets" + func GetEnvVar(key string) string { return strings.TrimSpace(os.Getenv(key)) } func GetSecretOrEnv(secretName string, envVarName string) string { - secretPath := "/run/secrets/" + secretName + secretPath := filepath.Join(SecretsDir, secretName) // Try to read the secret file first. content, err := os.ReadFile(secretPath) diff --git a/env/env_test.go b/env/env_test.go index 9426f9db..e2fa609f 100644 --- a/env/env_test.go +++ b/env/env_test.go @@ -2,6 +2,7 @@ package env import ( "os" + "path/filepath" "testing" ) @@ -14,8 +15,9 @@ func TestGetEnvVar(t *testing.T) { } func TestGetSecretOrEnv_File(t *testing.T) { - path := "/run/secrets/testsecret" - os.MkdirAll("/run/secrets", 0755) + dir := t.TempDir() + SecretsDir = dir + path := filepath.Join(dir, "testsecret") os.WriteFile(path, []byte("secret"), 0644) t.Cleanup(func() { os.Remove(path) }) From b4d0e3ad4b755fe014aded4c2f06ced3b4b41fde Mon Sep 17 00:00:00 2001 From: Gus Date: Thu, 31 Jul 2025 14:34:04 +0800 Subject: [PATCH 10/30] Include boost and env packages in tests --- .github/workflows/tests.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 3bb15e14..9084f884 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -19,7 +19,7 @@ jobs: - uses: actions/checkout@v4 - name: Run unit tests - run: go test ./pkg/... -coverprofile=coverage.out + run: go test ./pkg/... ./boost ./env -coverprofile=coverage.out - name: Coverage Report run: go tool cover -func=coverage.out | tail -n 1 From 7e0264169c2b70f0e700dc1b5bab26459ca109ed Mon Sep 17 00:00:00 2001 From: Gus Date: Thu, 31 Jul 2025 14:47:06 +0800 Subject: [PATCH 11/30] chore(ci): cache deps and test pkg coverage --- .github/workflows/tests.yml | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 9084f884..a91cedf4 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -18,8 +18,20 @@ jobs: - uses: actions/checkout@v4 - - name: Run unit tests - run: go test ./pkg/... ./boost ./env -coverprofile=coverage.out + - uses: actions/cache@v4 + with: + path: | + ~/go/pkg/mod + ~/.cache/go-build + key: ${{ runner.os }}-go-${{ hashFiles('**/go.sum') }} + restore-keys: | + ${{ runner.os }}-go- + + - name: Run pkg tests + run: go test ./pkg/... -coverprofile=coverage.out + + - name: Run other tests + run: go test ./boost ./env - name: Coverage Report run: go tool cover -func=coverage.out | tail -n 1 From 0019e0e8f353f8918df4d32673df7000293b5737 Mon Sep 17 00:00:00 2001 From: Gus Date: Thu, 31 Jul 2025 14:56:32 +0800 Subject: [PATCH 12/30] Run boost and env tests with coverage --- .github/workflows/tests.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index a91cedf4..d8cf9961 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -31,7 +31,7 @@ jobs: run: go test ./pkg/... -coverprofile=coverage.out - name: Run other tests - run: go test ./boost ./env + run: go test ./boost ./env -coverprofile=coverage.out - name: Coverage Report run: go tool cover -func=coverage.out | tail -n 1 From 9f6e9262eac3cc45d24e7cd3ad0f5762605a0bae Mon Sep 17 00:00:00 2001 From: Gus Date: Thu, 31 Jul 2025 15:21:30 +0800 Subject: [PATCH 13/30] Run coverage in each test step --- .github/workflows/tests.yml | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index d8cf9961..ec340c76 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -28,10 +28,11 @@ jobs: ${{ runner.os }}-go- - name: Run pkg tests - run: go test ./pkg/... -coverprofile=coverage.out + run: | + go test ./pkg/... -coverprofile=coverage.out + go tool cover -func=coverage.out | tail -n 1 - name: Run other tests - run: go test ./boost ./env -coverprofile=coverage.out - - - name: Coverage Report - run: go tool cover -func=coverage.out | tail -n 1 + run: | + go test ./boost ./env -coverprofile=coverage.out + go tool cover -func=coverage.out | tail -n 1 From 972570323107dcd907e2d4c16f07456274960235 Mon Sep 17 00:00:00 2001 From: Gus Date: Thu, 31 Jul 2025 15:21:36 +0800 Subject: [PATCH 14/30] Update PR label for tests --- .github/workflows/tests.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index ec340c76..6654776f 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -6,7 +6,7 @@ on: jobs: test: - if: github.event.pull_request.draft == false || (github.event.action == 'labeled' && github.event.label.name == 'test') + if: github.event.pull_request.draft == false || (github.event.action == 'labeled' && github.event.label.name == 'testing') runs-on: ubuntu-latest strategy: matrix: From 9f078b9e047daecfe08bdb28db4429a05b3c881e Mon Sep 17 00:00:00 2001 From: Gus Date: Thu, 31 Jul 2025 15:50:33 +0800 Subject: [PATCH 15/30] Increase boost test coverage --- boost/boost_test.go | 157 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 157 insertions(+) diff --git a/boost/boost_test.go b/boost/boost_test.go index 4de3cde8..dd77699f 100644 --- a/boost/boost_test.go +++ b/boost/boost_test.go @@ -2,10 +2,18 @@ package boost import ( "net/http" + "net/http/httptest" "os" + "path/filepath" + "strings" "testing" + "github.com/oullin/database" + "github.com/oullin/database/repository" "github.com/oullin/pkg" + "github.com/oullin/pkg/auth" + "github.com/oullin/pkg/http/middleware" + "github.com/oullin/pkg/llogs" ) func validEnvVars(t *testing.T) { @@ -103,3 +111,152 @@ func TestAppHelpers(t *testing.T) { t.Fatalf("expected nil db") } } +func TestAppBootRoutes(t *testing.T) { + validEnvVars(t) + + env := MakeEnv(pkg.GetDefaultValidator()) + + key, err := auth.GenerateAESKey() + if err != nil { + t.Fatalf("key err: %v", err) + } + + handler, err := auth.MakeTokensHandler(key) + if err != nil { + t.Fatalf("handler err: %v", err) + } + + router := Router{ + Env: env, + Mux: http.NewServeMux(), + Pipeline: middleware.Pipeline{ + Env: env, + ApiKeys: &repository.ApiKeys{DB: &database.Connection{}}, + TokenHandler: handler, + }, + Db: &database.Connection{}, + } + + app := &App{} + app.SetRouter(router) + + app.Boot() + + routes := []struct { + method string + path string + }{ + {"GET", "/profile"}, + {"GET", "/experience"}, + {"GET", "/projects"}, + {"GET", "/social"}, + {"GET", "/talks"}, + {"GET", "/education"}, + {"GET", "/recommendations"}, + {"POST", "/posts"}, + {"GET", "/posts/slug"}, + {"GET", "/categories"}, + } + + for _, rt := range routes { + req := httptest.NewRequest(rt.method, rt.path, nil) + h, pattern := app.GetMux().Handler(req) + if pattern == "" || h == nil { + t.Fatalf("route missing %s %s", rt.method, rt.path) + } + } +} + +func TestMakeLogs(t *testing.T) { + dir, err := os.MkdirTemp("", "logdir") + if err != nil { + t.Fatalf("tmpdir err: %v", err) + } + + validEnvVars(t) + t.Setenv("ENV_APP_LOGS_DIR", filepath.Join(dir, "log-%s.txt")) + + env := MakeEnv(pkg.GetDefaultValidator()) + + d := MakeLogs(env) + driver := *d + fl := driver.(llogs.FilesLogs) + + if !strings.HasPrefix(fl.DefaultPath(), dir) { + t.Fatalf("wrong log dir") + } + + if !fl.Close() { + t.Fatalf("close failed") + } +} + +func TestMakeDbConnectionPanic(t *testing.T) { + validEnvVars(t) + t.Setenv("ENV_DB_PORT", "1") + t.Setenv("ENV_SENTRY_DSN", "https://public@o0.ingest.sentry.io/0") + + env := MakeEnv(pkg.GetDefaultValidator()) + + defer func() { + if r := recover(); r == nil { + t.Fatalf("expected panic") + } + }() + + MakeDbConnection(env) +} + +func TestMakeAppPanic(t *testing.T) { + validEnvVars(t) + t.Setenv("ENV_DB_PORT", "1") + t.Setenv("ENV_APP_LOGS_DIR", "/tmp/log-%s.txt") + t.Setenv("ENV_SENTRY_DSN", "https://public@o0.ingest.sentry.io/0") + + env := MakeEnv(pkg.GetDefaultValidator()) + + defer func() { + if r := recover(); r == nil { + t.Fatalf("expected panic") + } + }() + + MakeApp(env, pkg.GetDefaultValidator()) +} + +func TestMakeSentry(t *testing.T) { + validEnvVars(t) + t.Setenv("ENV_SENTRY_DSN", "https://public@o0.ingest.sentry.io/0") + + env := MakeEnv(pkg.GetDefaultValidator()) + + s := MakeSentry(env) + if s == nil || s.Handler == nil || s.Options == nil { + t.Fatalf("sentry setup failed") + } +} + +func TestCloseLogs(t *testing.T) { + dir, err := os.MkdirTemp("", "logdir") + if err != nil { + t.Fatalf("tmpdir err: %v", err) + } + + validEnvVars(t) + t.Setenv("ENV_APP_LOGS_DIR", filepath.Join(dir, "log-%s.txt")) + t.Setenv("ENV_SENTRY_DSN", "https://public@o0.ingest.sentry.io/0") + + env := MakeEnv(pkg.GetDefaultValidator()) + + l := MakeLogs(env) + app := &App{logs: l} + + app.CloseLogs() +} + +func TestGetMuxNil(t *testing.T) { + app := &App{} + if app.GetMux() != nil { + t.Fatalf("expected nil mux") + } +} From 16a3b3669e22f11219d07655aebaa9063aab44c5 Mon Sep 17 00:00:00 2001 From: Gus Date: Thu, 31 Jul 2025 17:14:56 +0800 Subject: [PATCH 16/30] Add handler tests and workflow step --- .github/workflows/tests.yml | 5 ++ handler/file_handlers_test.go | 82 ++++++++++++++++++++++++++++++ handler/paginate/paginate_test.go | 25 +++++++++ handler/payload/categories_test.go | 23 +++++++++ handler/payload/posts_test.go | 41 +++++++++++++++ 5 files changed, 176 insertions(+) create mode 100644 handler/file_handlers_test.go create mode 100644 handler/paginate/paginate_test.go create mode 100644 handler/payload/categories_test.go create mode 100644 handler/payload/posts_test.go diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 6654776f..8c5cf1a5 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -36,3 +36,8 @@ jobs: run: | go test ./boost ./env -coverprofile=coverage.out go tool cover -func=coverage.out | tail -n 1 + + - name: Run handler tests + run: | + go test ./handler/... -coverprofile=coverage.out + go tool cover -func=coverage.out | tail -n 1 diff --git a/handler/file_handlers_test.go b/handler/file_handlers_test.go new file mode 100644 index 00000000..bbcd6414 --- /dev/null +++ b/handler/file_handlers_test.go @@ -0,0 +1,82 @@ +package handler + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "os" + "testing" +) + +type profileData struct { + Version string `json:"version"` + Data any `json:"data"` +} + +func writeTempJSON(t *testing.T, v any) string { + f, err := os.CreateTemp("", "data.json") + if err != nil { + t.Fatalf("tmp: %v", err) + } + enc := json.NewEncoder(f) + if err := enc.Encode(v); err != nil { + t.Fatalf("encode: %v", err) + } + f.Close() + return f.Name() +} + +func TestProfileHandlerHandle(t *testing.T) { + file := writeTempJSON(t, profileData{Version: "v1", Data: map[string]string{"nickname": "nick"}}) + defer os.Remove(file) + h := MakeProfileHandler(file) + + // ok response + req := httptest.NewRequest("GET", "/profile", nil) + rec := httptest.NewRecorder() + if err := h.Handle(rec, req); err != nil { + t.Fatalf("err: %v", err) + } + if rec.Code != http.StatusOK { + t.Fatalf("status %d", rec.Code) + } + + // cached request + req2 := httptest.NewRequest("GET", "/profile", nil) + req2.Header.Set("If-None-Match", "\"v1\"") + rec2 := httptest.NewRecorder() + if err := h.Handle(rec2, req2); err != nil { + t.Fatalf("err: %v", err) + } + if rec2.Code != http.StatusNotModified { + t.Fatalf("status %d", rec2.Code) + } + + // error on parse + badF, _ := os.CreateTemp("", "bad.json") + badF.WriteString("{invalid") + badF.Close() + badFile := badF.Name() + defer os.Remove(badFile) + bad := MakeProfileHandler(badFile) + req3 := httptest.NewRequest("GET", "/profile", nil) + rec3 := httptest.NewRecorder() + if bad.Handle(rec3, req3) == nil { + t.Fatalf("expected error") + } +} + +func TestSocialHandlerHandle(t *testing.T) { + file := writeTempJSON(t, profileData{Version: "v1", Data: []map[string]string{{"uuid": "1"}}}) + defer os.Remove(file) + h := MakeSocialHandler(file) + + req := httptest.NewRequest("GET", "/social", nil) + rec := httptest.NewRecorder() + if err := h.Handle(rec, req); err != nil { + t.Fatalf("err: %v", err) + } + if rec.Code != http.StatusOK { + t.Fatalf("status %d", rec.Code) + } +} diff --git a/handler/paginate/paginate_test.go b/handler/paginate/paginate_test.go new file mode 100644 index 00000000..7b6212fd --- /dev/null +++ b/handler/paginate/paginate_test.go @@ -0,0 +1,25 @@ +package paginate + +import ( + "net/url" + "testing" + + "github.com/oullin/database/repository/pagination" +) + +func TestMakeFrom(t *testing.T) { + u, _ := url.Parse("https://example.com/posts?page=2&limit=50") + p := MakeFrom(u, 5) + if p.Page != 2 { + t.Fatalf("page %d", p.Page) + } + if p.Limit != pagination.PostsMaxLimit { + t.Fatalf("limit %d", p.Limit) + } + + u2, _ := url.Parse("/categories?page=-1&limit=50") + p2 := MakeFrom(u2, 5) + if p2.Page != pagination.MinPage || p2.Limit != pagination.CategoriesMaxLimit { + t.Fatalf("unexpected %+v", p2) + } +} diff --git a/handler/payload/categories_test.go b/handler/payload/categories_test.go new file mode 100644 index 00000000..8883969d --- /dev/null +++ b/handler/payload/categories_test.go @@ -0,0 +1,23 @@ +package payload + +import ( + "testing" + + "github.com/oullin/database" +) + +func TestGetCategoriesResponse(t *testing.T) { + cats := []database.Category{{UUID: "1", Name: "n", Slug: "s", Description: "d"}} + r := GetCategoriesResponse(cats) + if len(r) != 1 || r[0].Slug != "s" { + t.Fatalf("unexpected %#v", r) + } +} + +func TestGetTagsResponse(t *testing.T) { + tags := []database.Tag{{UUID: "1", Name: "n", Slug: "s", Description: "d"}} + r := GetTagsResponse(tags) + if len(r) != 1 || r[0].Slug != "s" { + t.Fatalf("unexpected %#v", r) + } +} diff --git a/handler/payload/posts_test.go b/handler/payload/posts_test.go new file mode 100644 index 00000000..d3b08111 --- /dev/null +++ b/handler/payload/posts_test.go @@ -0,0 +1,41 @@ +package payload + +import ( + "net/http/httptest" + "testing" + "time" + + "github.com/oullin/database" +) + +func TestGetPostsFiltersFrom(t *testing.T) { + req := IndexRequestBody{Title: "t", Author: "a", Category: "c", Tag: "g", Text: "x"} + f := GetPostsFiltersFrom(req) + if f.Title != "t" || f.Author != "a" || f.Category != "c" || f.Tag != "g" || f.Text != "x" { + t.Fatalf("unexpected filters: %+v", f) + } +} + +func TestGetSlugFrom(t *testing.T) { + r := httptest.NewRequest("GET", "/posts/s", nil) + r.SetPathValue("slug", " SLUG ") + if s := GetSlugFrom(r); s != "slug" { + t.Fatalf("slug %s", s) + } +} + +func TestGetPostsResponse(t *testing.T) { + now := time.Now() + p := database.Post{ + UUID: "1", Slug: "slug", Title: "title", Excerpt: "ex", Content: "c", + CoverImageURL: "url", PublishedAt: &now, CreatedAt: now, UpdatedAt: now, + Categories: []database.Category{{UUID: "c1", Name: "cn", Slug: "cs", Description: "cd"}}, + Tags: []database.Tag{{UUID: "t1", Name: "tn", Slug: "ts", Description: "td"}}, + Author: database.User{UUID: "u1", FirstName: "fn", LastName: "ln", Username: "un", DisplayName: "dn", Bio: "b", PictureFileName: "pf", ProfilePictureURL: "pu", IsAdmin: true}, + } + + r := GetPostsResponse(p) + if r.UUID != "1" || r.Author.UUID != "u1" || len(r.Categories) != 1 || len(r.Tags) != 1 { + t.Fatalf("unexpected response: %+v", r) + } +} From 85e81b54a10c445311760d0f7b16d88e3291c60e Mon Sep 17 00:00:00 2001 From: Gus Date: Thu, 31 Jul 2025 17:26:38 +0800 Subject: [PATCH 17/30] test: cover all handlers --- handler/categories.go | 13 +-- handler/categories_posts_test.go | 133 +++++++++++++++++++++++++++++ handler/file_handlers_more_test.go | 104 ++++++++++++++++++++++ handler/posts.go | 16 ++-- 4 files changed, 254 insertions(+), 12 deletions(-) create mode 100644 handler/categories_posts_test.go create mode 100644 handler/file_handlers_more_test.go diff --git a/handler/categories.go b/handler/categories.go index 1ef2c451..38212c6d 100644 --- a/handler/categories.go +++ b/handler/categories.go @@ -3,7 +3,6 @@ package handler import ( "encoding/json" "github.com/oullin/database" - "github.com/oullin/database/repository" "github.com/oullin/database/repository/pagination" "github.com/oullin/handler/paginate" "github.com/oullin/handler/payload" @@ -12,14 +11,16 @@ import ( baseHttp "net/http" ) +type categoriesRepo interface { + GetAll(pagination.Paginate) (*pagination.Pagination[database.Category], error) +} + type CategoriesHandler struct { - Categories *repository.Categories + Categories categoriesRepo } -func MakeCategoriesHandler(categories *repository.Categories) CategoriesHandler { - return CategoriesHandler{ - Categories: categories, - } +func MakeCategoriesHandler(categories categoriesRepo) CategoriesHandler { + return CategoriesHandler{Categories: categories} } func (h *CategoriesHandler) Index(w baseHttp.ResponseWriter, r *baseHttp.Request) *http.ApiError { diff --git a/handler/categories_posts_test.go b/handler/categories_posts_test.go new file mode 100644 index 00000000..b4fa20a7 --- /dev/null +++ b/handler/categories_posts_test.go @@ -0,0 +1,133 @@ +package handler + +import ( + "bytes" + "encoding/json" + "errors" + "net/http" + "net/http/httptest" + "testing" + + "github.com/oullin/database" + "github.com/oullin/database/repository/pagination" + "github.com/oullin/database/repository/queries" + "github.com/oullin/handler/payload" +) + +// stubCategories simulates repository.Categories +type stubCategories struct { + result *pagination.Pagination[database.Category] + err error +} + +func (s stubCategories) GetAll(p pagination.Paginate) (*pagination.Pagination[database.Category], error) { + return s.result, s.err +} + +// stubPosts simulates repository.Posts +type stubPosts struct { + list *pagination.Pagination[database.Post] + err error + item *database.Post +} + +func (s stubPosts) GetAll(filters queries.PostFilters, p pagination.Paginate) (*pagination.Pagination[database.Post], error) { + return s.list, s.err +} + +func (s stubPosts) FindBy(slug string) *database.Post { + return s.item +} + +func TestCategoriesHandlerIndex(t *testing.T) { + pag := pagination.Paginate{Page: 1, Limit: 5} + pag.SetNumItems(1) + cats := []database.Category{{UUID: "1", Name: "Cat", Slug: "cat", Description: "desc"}} + repo := stubCategories{result: pagination.MakePagination(cats, pag)} + h := MakeCategoriesHandler(&repo) + + req := httptest.NewRequest("GET", "/categories", nil) + rec := httptest.NewRecorder() + if err := h.Index(rec, req); err != nil { + t.Fatalf("err: %v", err) + } + if rec.Code != http.StatusOK { + t.Fatalf("status %d", rec.Code) + } + + var resp struct { + Data []struct{ Slug string } `json:"data"` + } + if err := json.NewDecoder(rec.Body).Decode(&resp); err != nil { + t.Fatalf("decode: %v", err) + } + if len(resp.Data) != 1 || resp.Data[0].Slug != "cat" { + t.Fatalf("unexpected resp %#v", resp) + } + + repo.err = errors.New("fail") + rec2 := httptest.NewRecorder() + if h.Index(rec2, req) == nil { + t.Fatalf("expected error") + } +} + +func TestPostsHandlerIndex(t *testing.T) { + post := database.Post{UUID: "p1", Slug: "slug", Title: "title"} + pag := pagination.Paginate{Page: 1, Limit: 10} + pag.SetNumItems(1) + repo := stubPosts{list: pagination.MakePagination([]database.Post{post}, pag)} + h := MakePostsHandler(&repo) + + body, _ := json.Marshal(payload.IndexRequestBody{Title: "title"}) + req := httptest.NewRequest("POST", "/posts", bytes.NewReader(body)) + rec := httptest.NewRecorder() + if err := h.Index(rec, req); err != nil { + t.Fatalf("err: %v", err) + } + if rec.Code != http.StatusOK { + t.Fatalf("status %d", rec.Code) + } + + repo.err = errors.New("fail") + rec2 := httptest.NewRecorder() + if h.Index(rec2, req) == nil { + t.Fatalf("expected error") + } + + badReq := httptest.NewRequest("POST", "/posts", bytes.NewReader([]byte("{"))) + rec3 := httptest.NewRecorder() + if h.Index(rec3, badReq) == nil { + t.Fatalf("expected parse error") + } +} + +func TestPostsHandlerShow(t *testing.T) { + post := database.Post{UUID: "p1", Slug: "slug", Title: "title"} + repo := stubPosts{item: &post} + h := MakePostsHandler(&repo) + + req := httptest.NewRequest("GET", "/posts/slug", nil) + req.SetPathValue("slug", "slug") + rec := httptest.NewRecorder() + if err := h.Show(rec, req); err != nil { + t.Fatalf("err: %v", err) + } + if rec.Code != http.StatusOK { + t.Fatalf("status %d", rec.Code) + } + + req2 := httptest.NewRequest("GET", "/posts/", nil) + rec2 := httptest.NewRecorder() + if h.Show(rec2, req2) == nil { + t.Fatalf("expected bad request") + } + + repo.item = nil + req3 := httptest.NewRequest("GET", "/posts/slug", nil) + req3.SetPathValue("slug", "slug") + rec3 := httptest.NewRecorder() + if h.Show(rec3, req3) == nil { + t.Fatalf("expected not found") + } +} diff --git a/handler/file_handlers_more_test.go b/handler/file_handlers_more_test.go new file mode 100644 index 00000000..a2677560 --- /dev/null +++ b/handler/file_handlers_more_test.go @@ -0,0 +1,104 @@ +package handler + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "os" + "testing" + + pkghttp "github.com/oullin/pkg/http" +) + +type testData struct { + Version string `json:"version"` + Data any `json:"data"` +} + +func writeJSON(t *testing.T, v any) string { + f, err := os.CreateTemp("", "data.json") + if err != nil { + t.Fatalf("tmp: %v", err) + } + enc := json.NewEncoder(f) + if err := enc.Encode(v); err != nil { + t.Fatalf("encode: %v", err) + } + f.Close() + return f.Name() +} + +func runFileHandlerTest(t *testing.T, makeFn func(string) interface { + Handle(http.ResponseWriter, *http.Request) *pkghttp.ApiError +}, path string) { + file := writeJSON(t, testData{Version: "v1", Data: []map[string]string{{"id": "1"}}}) + defer os.Remove(file) + h := makeFn(file) + + req := httptest.NewRequest("GET", path, nil) + rec := httptest.NewRecorder() + if err := h.Handle(rec, req); err != nil { + t.Fatalf("err: %v", err) + } + if rec.Code != http.StatusOK { + t.Fatalf("status %d", rec.Code) + } + + req2 := httptest.NewRequest("GET", path, nil) + req2.Header.Set("If-None-Match", "\"v1\"") + rec2 := httptest.NewRecorder() + if err := h.Handle(rec2, req2); err != nil { + t.Fatalf("err: %v", err) + } + if rec2.Code != http.StatusNotModified { + t.Fatalf("status %d", rec2.Code) + } + + badF, _ := os.CreateTemp("", "bad.json") + badF.WriteString("{") + badF.Close() + defer os.Remove(badF.Name()) + bad := makeFn(badF.Name()) + rec3 := httptest.NewRecorder() + req3 := httptest.NewRequest("GET", path, nil) + if bad.Handle(rec3, req3) == nil { + t.Fatalf("expected error") + } +} + +func TestAdditionalFileHandlers(t *testing.T) { + runFileHandlerTest(t, func(p string) interface { + Handle(http.ResponseWriter, *http.Request) *pkghttp.ApiError + } { + h := MakeEducationHandler(p) + return h + }, "/education") + + runFileHandlerTest(t, func(p string) interface { + Handle(http.ResponseWriter, *http.Request) *pkghttp.ApiError + } { + h := MakeExperienceHandler(p) + return h + }, "/experience") + + runFileHandlerTest(t, func(p string) interface { + Handle(http.ResponseWriter, *http.Request) *pkghttp.ApiError + } { + h := MakeProjectsHandler(p) + return h + }, "/projects") + + runFileHandlerTest(t, func(p string) interface { + Handle(http.ResponseWriter, *http.Request) *pkghttp.ApiError + } { + h := MakeRecommendationsHandler(p) + return h + }, "/recommendations") + + runFileHandlerTest(t, func(p string) interface { + Handle(http.ResponseWriter, *http.Request) *pkghttp.ApiError + } { + h := MakeTalksHandler(p) + return h + }, "/talks") +} diff --git a/handler/posts.go b/handler/posts.go index efd75816..c1fbeb60 100644 --- a/handler/posts.go +++ b/handler/posts.go @@ -3,8 +3,9 @@ package handler import ( "encoding/json" "fmt" - "github.com/oullin/database/repository" + "github.com/oullin/database" "github.com/oullin/database/repository/pagination" + "github.com/oullin/database/repository/queries" "github.com/oullin/handler/paginate" "github.com/oullin/handler/payload" "github.com/oullin/pkg" @@ -13,14 +14,17 @@ import ( baseHttp "net/http" ) +type postsRepo interface { + GetAll(queries.PostFilters, pagination.Paginate) (*pagination.Pagination[database.Post], error) + FindBy(slug string) *database.Post +} + type PostsHandler struct { - Posts *repository.Posts + Posts postsRepo } -func MakePostsHandler(posts *repository.Posts) PostsHandler { - return PostsHandler{ - Posts: posts, - } +func MakePostsHandler(posts postsRepo) PostsHandler { + return PostsHandler{Posts: posts} } func (h *PostsHandler) Index(w baseHttp.ResponseWriter, r *baseHttp.Request) *http.ApiError { From f839d938d368f1055e096d9c6f901f9974e93b31 Mon Sep 17 00:00:00 2001 From: Gus Date: Fri, 1 Aug 2025 10:03:41 +0800 Subject: [PATCH 18/30] Use testcontainers for DB tests and update workflow --- .github/workflows/tests.yml | 5 + database/connection_test.go | 80 ++++++++++++++++ go.mod | 54 ++++++++++- go.sum | 176 ++++++++++++++++++++++++++++++++++-- 4 files changed, 305 insertions(+), 10 deletions(-) create mode 100644 database/connection_test.go diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 8c5cf1a5..f2e9fc97 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -41,3 +41,8 @@ jobs: run: | go test ./handler/... -coverprofile=coverage.out go tool cover -func=coverage.out | tail -n 1 + + - name: Run database tests + run: | + go test ./database -coverprofile=coverage.out + go tool cover -func=coverage.out | tail -n 1 diff --git a/database/connection_test.go b/database/connection_test.go new file mode 100644 index 00000000..aa39c33f --- /dev/null +++ b/database/connection_test.go @@ -0,0 +1,80 @@ +package database_test + +import ( + "context" + "os/exec" + "testing" + + "github.com/testcontainers/testcontainers-go" + "github.com/testcontainers/testcontainers-go/modules/postgres" + + "github.com/oullin/database" + "github.com/oullin/database/repository" + "github.com/oullin/env" +) + +func TestApiKeysWithTestContainer(t *testing.T) { + 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"), + ) + 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 err := conn.Sql().AutoMigrate(&database.APIKey{}); err != nil { + t.Fatalf("migrate err: %v", err) + } + + repo := repository.ApiKeys{DB: conn} + + created, err := repo.Create(database.APIKeyAttr{ + AccountName: "demo", + PublicKey: []byte("pub"), + SecretKey: []byte("sec"), + }) + if err != nil { + t.Fatalf("create: %v", err) + } + + if found := repo.FindBy("demo"); found == nil || found.ID != created.ID { + t.Fatalf("find mismatch") + } +} diff --git a/go.mod b/go.mod index 2c2ec5bd..3da7da20 100644 --- a/go.mod +++ b/go.mod @@ -8,6 +8,8 @@ require ( github.com/google/uuid v1.6.0 github.com/joho/godotenv v1.5.1 github.com/lib/pq v1.10.9 + github.com/testcontainers/testcontainers-go v0.38.0 + github.com/testcontainers/testcontainers-go/modules/postgres v0.38.0 golang.org/x/crypto v0.40.0 golang.org/x/term v0.33.0 golang.org/x/text v0.27.0 @@ -17,20 +19,70 @@ require ( ) require ( + dario.cat/mergo v1.0.1 // indirect + github.com/Azure/go-ansiterm v0.0.0-20210617225240-d185dfc1b5a1 // indirect + github.com/Microsoft/go-winio v0.6.2 // indirect + github.com/cenkalti/backoff/v4 v4.2.1 // indirect + github.com/containerd/errdefs v1.0.0 // indirect + github.com/containerd/errdefs/pkg v0.3.0 // indirect + github.com/containerd/log v0.1.0 // indirect + github.com/containerd/platforms v0.2.1 // indirect + github.com/cpuguy83/dockercfg v0.3.2 // indirect + github.com/davecgh/go-spew v1.1.1 // indirect + github.com/distribution/reference v0.6.0 // indirect + github.com/docker/docker v28.2.2+incompatible // indirect + github.com/docker/go-connections v0.5.0 // indirect + github.com/docker/go-units v0.5.0 // indirect + github.com/ebitengine/purego v0.8.4 // indirect + github.com/felixge/httpsnoop v1.0.4 // indirect github.com/gabriel-vasile/mimetype v1.4.9 // indirect + github.com/go-logr/logr v1.4.3 // indirect + github.com/go-logr/stdr v1.2.2 // indirect + github.com/go-ole/go-ole v1.2.6 // indirect github.com/go-playground/locales v0.14.1 // indirect github.com/go-playground/universal-translator v0.18.1 // indirect + github.com/gogo/protobuf v1.3.2 // indirect github.com/jackc/pgpassfile v1.0.0 // indirect github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect github.com/jackc/pgx/v5 v5.7.5 // indirect github.com/jackc/puddle/v2 v2.2.2 // indirect github.com/jinzhu/inflection v1.0.0 // indirect github.com/jinzhu/now v1.1.5 // indirect - github.com/kr/text v0.2.0 // indirect + github.com/klauspost/compress v1.18.0 // indirect github.com/leodido/go-urn v1.4.0 // indirect + github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 // indirect + github.com/magiconair/properties v1.8.10 // indirect + github.com/moby/docker-image-spec v1.3.1 // indirect + github.com/moby/go-archive v0.1.0 // indirect + github.com/moby/patternmatcher v0.6.0 // indirect + github.com/moby/sys/sequential v0.6.0 // indirect + github.com/moby/sys/user v0.4.0 // indirect + github.com/moby/sys/userns v0.1.0 // indirect + github.com/moby/term v0.5.0 // indirect + github.com/morikuni/aec v1.0.0 // indirect + github.com/opencontainers/go-digest v1.0.0 // indirect + github.com/opencontainers/image-spec v1.1.1 // indirect + github.com/pkg/errors v0.9.1 // indirect + github.com/pmezard/go-difflib v1.0.0 // indirect + github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c // indirect + github.com/shirou/gopsutil/v4 v4.25.5 // indirect + github.com/sirupsen/logrus v1.9.3 // indirect + github.com/stretchr/testify v1.10.0 // indirect + github.com/tklauser/go-sysconf v0.3.12 // indirect + github.com/tklauser/numcpus v0.6.1 // indirect + github.com/yusufpapurcu/wmi v1.2.4 // indirect + go.opentelemetry.io/auto/sdk v1.1.0 // indirect + go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.49.0 // indirect + go.opentelemetry.io/otel v1.37.0 // indirect + go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.37.0 // indirect + go.opentelemetry.io/otel/metric v1.37.0 // indirect + go.opentelemetry.io/otel/sdk v1.37.0 // indirect + go.opentelemetry.io/otel/trace v1.37.0 // indirect + go.opentelemetry.io/proto/otlp v1.7.1 // indirect golang.org/x/net v0.42.0 // indirect golang.org/x/sync v0.16.0 // indirect golang.org/x/sys v0.34.0 // indirect + google.golang.org/protobuf v1.36.6 // indirect ) replace github.com/oullin/boost => ./boost diff --git a/go.sum b/go.sum index 5b0d89a1..9579e9f2 100644 --- a/go.sum +++ b/go.sum @@ -1,13 +1,53 @@ -github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= +dario.cat/mergo v1.0.1 h1:Ra4+bf83h2ztPIQYNP99R6m+Y7KfnARDfID+a+vLl4s= +dario.cat/mergo v1.0.1/go.mod h1:uNxQE+84aUszobStD9th8a29P2fMDhsBdgRYvZOxGmk= +github.com/AdaLogics/go-fuzz-headers v0.0.0-20240806141605-e8a1dd7889d6 h1:He8afgbRMd7mFxO99hRNu+6tazq8nFF9lIwo9JFroBk= +github.com/AdaLogics/go-fuzz-headers v0.0.0-20240806141605-e8a1dd7889d6/go.mod h1:8o94RPi1/7XTJvwPpRSzSUedZrtlirdB3r9Z20bi2f8= +github.com/Azure/go-ansiterm v0.0.0-20210617225240-d185dfc1b5a1 h1:UQHMgLO+TxOElx5B5HZ4hJQsoJ/PvUvKRhJHDQXO8P8= +github.com/Azure/go-ansiterm v0.0.0-20210617225240-d185dfc1b5a1/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E= +github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY= +github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU= +github.com/cenkalti/backoff/v4 v4.2.1 h1:y4OZtCnogmCPw98Zjyt5a6+QwPLGkiQsYW5oUqylYbM= +github.com/cenkalti/backoff/v4 v4.2.1/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE= +github.com/containerd/errdefs v1.0.0 h1:tg5yIfIlQIrxYtu9ajqY42W3lpS19XqdxRQeEwYG8PI= +github.com/containerd/errdefs v1.0.0/go.mod h1:+YBYIdtsnF4Iw6nWZhJcqGSg/dwvV7tyJ/kCkyJ2k+M= +github.com/containerd/errdefs/pkg v0.3.0 h1:9IKJ06FvyNlexW690DXuQNx2KA2cUJXx151Xdx3ZPPE= +github.com/containerd/errdefs/pkg v0.3.0/go.mod h1:NJw6s9HwNuRhnjJhM7pylWwMyAkmCQvQ4GpJHEqRLVk= +github.com/containerd/log v0.1.0 h1:TCJt7ioM2cr/tfR8GPbGf9/VRAX8D2B4PjzCpfX540I= +github.com/containerd/log v0.1.0/go.mod h1:VRRf09a7mHDIRezVKTRCrOq78v577GXq3bSa3EhrzVo= +github.com/containerd/platforms v0.2.1 h1:zvwtM3rz2YHPQsF2CHYM8+KtB5dvhISiXh5ZpSBQv6A= +github.com/containerd/platforms v0.2.1/go.mod h1:XHCb+2/hzowdiut9rkudds9bE5yJ7npe7dG/wG+uFPw= +github.com/cpuguy83/dockercfg v0.3.2 h1:DlJTyZGBDlXqUZ2Dk2Q3xHs/FtnooJJVaad2S9GKorA= +github.com/cpuguy83/dockercfg v0.3.2/go.mod h1:sugsbF4//dDlL/i+S+rtpIWp+5h0BHJHfjj5/jFyUJc= +github.com/creack/pty v1.1.18 h1:n56/Zwd5o6whRC5PMGretI4IdRLlmBXYNjScPaBgsbY= +github.com/creack/pty v1.1.18/go.mod h1:MOBLtS5ELjhRRrroQr9kyvTxUAFNvYEK993ew/Vr4O4= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk= +github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E= +github.com/docker/docker v28.2.2+incompatible h1:CjwRSksz8Yo4+RmQ339Dp/D2tGO5JxwYeqtMOEe0LDw= +github.com/docker/docker v28.2.2+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk= +github.com/docker/go-connections v0.5.0 h1:USnMq7hx7gwdVZq1L49hLXaFtUdTADjXGp+uj1Br63c= +github.com/docker/go-connections v0.5.0/go.mod h1:ov60Kzw0kKElRwhNs9UlUHAE/F9Fe6GLaXnqyDdmEXc= +github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4= +github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk= +github.com/ebitengine/purego v0.8.4 h1:CF7LEKg5FFOsASUj0+QwaXf8Ht6TlFxg09+S9wz0omw= +github.com/ebitengine/purego v0.8.4/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ= +github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= +github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= github.com/gabriel-vasile/mimetype v1.4.9 h1:5k+WDwEsD9eTLL8Tz3L0VnmVh9QxGjRmjBvAG7U/oYY= github.com/gabriel-vasile/mimetype v1.4.9/go.mod h1:WnSQhFKJuBlRyLiKohA/2DtIlPFAbguNaG7QCHcyGok= github.com/getsentry/sentry-go v0.34.1 h1:HSjc1C/OsnZttohEPrrqKH42Iud0HuLCXpv8cU1pWcw= github.com/getsentry/sentry-go v0.34.1/go.mod h1:C55omcY9ChRQIUcVcGcs+Zdy4ZpQGvNJ7JYHIoSWOtE= github.com/go-errors/errors v1.4.2 h1:J6MZopCL4uSllY1OfXM374weqZFFItUbrImctkmUxIA= github.com/go-errors/errors v1.4.2/go.mod h1:sIVyrIiJhuEF+Pj9Ebtd6P/rEYROXFi3BopGUQ5a5Og= +github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= +github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= +github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= +github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= +github.com/go-ole/go-ole v1.2.6 h1:/Fpf6oFPoeFik9ty7siob0G6Ke8QvQEuVcuChpwXzpY= +github.com/go-ole/go-ole v1.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0= github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s= github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4= github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA= @@ -16,10 +56,15 @@ github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJn github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY= github.com/go-playground/validator/v10 v10.27.0 h1:w8+XrWVMhGkxOaaowyKH35gFydVHOvC0/uWoy2Fzwn4= github.com/go-playground/validator/v10 v10.27.0/go.mod h1:I5QpIEbmr8On7W0TktmJAumgzX4CA1XNl4ZmDuVHKKo= -github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38= -github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= +github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= +github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +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/grpc-ecosystem/grpc-gateway/v2 v2.27.1 h1:X5VWvz21y3gzm9Nw/kaUeku/1+uBhcekkmy4IkffJww= +github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.1/go.mod h1:Zanoh4+gvIgluNqcfMVTJueD4wSS5hT7zTt4Mrutd90= github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo= @@ -34,41 +79,152 @@ github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ= github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8= github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= -github.com/kr/pretty v0.3.0 h1:WgNl7dwNpEZ6jJ9k1snq4pZsg7DOEN8hP9Xw0Tsjwk0= -github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk= +github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8= +github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= +github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo= +github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ= github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI= github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw= github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o= +github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 h1:6E+4a0GO5zZEnZ81pIr0yLvtUWk2if982qA3F3QD6H4= +github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0/go.mod h1:zJYVVT2jmtg6P3p1VtQj7WsuWi/y4VnjVBn7F8KPB3I= +github.com/magiconair/properties v1.8.10 h1:s31yESBquKXCV9a/ScB3ESkOjUYYv+X0rg8SYxI99mE= +github.com/magiconair/properties v1.8.10/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0= +github.com/mdelapenya/tlscert v0.2.0 h1:7H81W6Z/4weDvZBNOfQte5GpIMo0lGYEeWbkGp5LJHI= +github.com/mdelapenya/tlscert v0.2.0/go.mod h1:O4njj3ELLnJjGdkN7M/vIVCpZ+Cf0L6muqOG4tLSl8o= +github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0= +github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo= +github.com/moby/go-archive v0.1.0 h1:Kk/5rdW/g+H8NHdJW2gsXyZ7UnzvJNOy6VKJqueWdcQ= +github.com/moby/go-archive v0.1.0/go.mod h1:G9B+YoujNohJmrIYFBpSd54GTUB4lt9S+xVQvsJyFuo= +github.com/moby/patternmatcher v0.6.0 h1:GmP9lR19aU5GqSSFko+5pRqHi+Ohk1O69aFiKkVGiPk= +github.com/moby/patternmatcher v0.6.0/go.mod h1:hDPoyOpDY7OrrMDLaYoY3hf52gNCR/YOUYxkhApJIxc= +github.com/moby/sys/atomicwriter v0.1.0 h1:kw5D/EqkBwsBFi0ss9v1VG3wIkVhzGvLklJ+w3A14Sw= +github.com/moby/sys/atomicwriter v0.1.0/go.mod h1:Ul8oqv2ZMNHOceF643P6FKPXeCmYtlQMvpizfsSoaWs= +github.com/moby/sys/sequential v0.6.0 h1:qrx7XFUd/5DxtqcoH1h438hF5TmOvzC/lspjy7zgvCU= +github.com/moby/sys/sequential v0.6.0/go.mod h1:uyv8EUTrca5PnDsdMGXhZe6CCe8U/UiTWd+lL+7b/Ko= +github.com/moby/sys/user v0.4.0 h1:jhcMKit7SA80hivmFJcbB1vqmw//wU61Zdui2eQXuMs= +github.com/moby/sys/user v0.4.0/go.mod h1:bG+tYYYJgaMtRKgEmuueC0hJEAZWwtIbZTB+85uoHjs= +github.com/moby/sys/userns v0.1.0 h1:tVLXkFOxVu9A64/yh59slHVv9ahO9UIev4JZusOLG/g= +github.com/moby/sys/userns v0.1.0/go.mod h1:IHUYgu/kao6N8YZlp9Cf444ySSvCmDlmzUcYfDHOl28= +github.com/moby/term v0.5.0 h1:xt8Q1nalod/v7BqbG21f8mQPqH+xAaC9C3N3wfWbVP0= +github.com/moby/term v0.5.0/go.mod h1:8FzsFHVUBGZdbDsJw/ot+X+d5HLUbvklYLJ9uGfcI3Y= +github.com/morikuni/aec v1.0.0 h1:nP9CBfwrvYnBRgY6qfDQkygYDmYwOilePFkwzv4dU8A= +github.com/morikuni/aec v1.0.0/go.mod h1:BbKIizmSmc5MMPqRYbxO4ZU0S0+P200+tUnFx7PXmsc= +github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U= +github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM= +github.com/opencontainers/image-spec v1.1.1 h1:y0fUlFfIZhPF1W537XOLg0/fcx6zcHCJwooC2xJA040= +github.com/opencontainers/image-spec v1.1.1/go.mod h1:qpqAh3Dmcf36wStyyWU+kCeDgrGnAve2nCC8+7h8Q0M= github.com/pingcap/errors v0.11.4 h1:lFuQV/oaUMGcD2tqt+01ROSmJs75VG1ToEOkZIZ4nE4= github.com/pingcap/errors v0.11.4/go.mod h1:Oi8TUi2kEtXXLMJk9l1cGmz20kV3TaQ0usTwv5KuLY8= github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= -github.com/rogpeppe/go-internal v1.8.0 h1:FCbCCtXNOY3UtUuHUYaghJg4y7Fd14rXifAYUAtL9R8= -github.com/rogpeppe/go-internal v1.8.0/go.mod h1:WmiCO8CzOY8rg0OYDC4/i/2WRWAB6poM+XZ2dLUbcbE= +github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c h1:ncq/mPwQF4JjgDlrVEn3C11VoGHZN7m8qihwgMEtzYw= +github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE= +github.com/rogpeppe/go-internal v1.13.1 h1:KvO1DLK/DRN07sQ1LQKScxyZJuNnedQ5/wKSR38lUII= +github.com/rogpeppe/go-internal v1.13.1/go.mod h1:uMEvuHeurkdAXX61udpOXGD/AzZDWNMNyH2VO9fmH0o= +github.com/shirou/gopsutil/v4 v4.25.5 h1:rtd9piuSMGeU8g1RMXjZs9y9luK5BwtnG7dZaQUJAsc= +github.com/shirou/gopsutil/v4 v4.25.5/go.mod h1:PfybzyydfZcN+JMMjkF6Zb8Mq1A/VcogFFg7hj50W9c= +github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ= +github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY= +github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= -github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk= -github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= +github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= +github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/testcontainers/testcontainers-go v0.38.0 h1:d7uEapLcv2P8AvH8ahLqDMMxda2W9gQN1nRbHS28HBw= +github.com/testcontainers/testcontainers-go v0.38.0/go.mod h1:C52c9MoHpWO+C4aqmgSU+hxlR5jlEayWtgYrb8Pzz1w= +github.com/testcontainers/testcontainers-go/modules/postgres v0.38.0 h1:KFdx9A0yF94K70T6ibSuvgkQQeX1xKlZVF3hEagXEtY= +github.com/testcontainers/testcontainers-go/modules/postgres v0.38.0/go.mod h1:T/QRECND6N6tAKMxF1Za+G2tpwnGEHcODzHRsgIpw9M= +github.com/tklauser/go-sysconf v0.3.12 h1:0QaGUFOdQaIVdPgfITYzaTegZvdCjmYO52cSFAEVmqU= +github.com/tklauser/go-sysconf v0.3.12/go.mod h1:Ho14jnntGE1fpdOqQEEaiKRpvIavV0hSfmBq8nJbHYI= +github.com/tklauser/numcpus v0.6.1 h1:ng9scYS7az0Bk4OZLvrNXNSAO2Pxr1XXRAPyjhIx+Fk= +github.com/tklauser/numcpus v0.6.1/go.mod h1:1XfjsgE2zo8GVw7POkMbHENHzVg3GzmoZ9fESEdAacY= +github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= +github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= +github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0= +github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0= +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/contrib/instrumentation/net/http/otelhttp v0.49.0 h1:jq9TW8u3so/bN+JPT166wjOI6/vQPF6Xe7nMNIltagk= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.49.0/go.mod h1:p8pYQP+m5XfbZm9fxtSKAbM6oIllS7s2AfxrChvc7iw= +go.opentelemetry.io/otel v1.37.0 h1:9zhNfelUvx0KBfu/gb+ZgeAfAgtWrfHJZcAqFC228wQ= +go.opentelemetry.io/otel v1.37.0/go.mod h1:ehE/umFRLnuLa/vSccNq9oS1ErUlkkK71gMcN34UG8I= +go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.37.0 h1:Ahq7pZmv87yiyn3jeFz/LekZmPLLdKejuO3NcK9MssM= +go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.37.0/go.mod h1:MJTqhM0im3mRLw1i8uGHnCvUEeS7VwRyxlLC78PA18M= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.19.0 h1:IeMeyr1aBvBiPVYihXIaeIZba6b8E1bYp7lbdxK8CQg= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.19.0/go.mod h1:oVdCUtjq9MK9BlS7TtucsQwUcXcymNiEDjgDD2jMtZU= +go.opentelemetry.io/otel/metric v1.37.0 h1:mvwbQS5m0tbmqML4NqK+e3aDiO02vsf/WgbsdpcPoZE= +go.opentelemetry.io/otel/metric v1.37.0/go.mod h1:04wGrZurHYKOc+RKeye86GwKiTb9FKm1WHtO+4EVr2E= +go.opentelemetry.io/otel/sdk v1.37.0 h1:ItB0QUqnjesGRvNcmAcU0LyvkVyGJ2xftD29bWdDvKI= +go.opentelemetry.io/otel/sdk v1.37.0/go.mod h1:VredYzxUvuo2q3WRcDnKDjbdvmO0sCzOvVAiY+yUkAg= +go.opentelemetry.io/otel/trace v1.37.0 h1:HLdcFNbRQBE2imdSEgm/kwqmQj1Or1l/7bW6mxVK7z4= +go.opentelemetry.io/otel/trace v1.37.0/go.mod h1:TlgrlQ+PtQO5XFerSPUYG0JSgGyryXewPGyayAWSBS0= +go.opentelemetry.io/proto/otlp v1.7.1 h1:gTOMpGDb0WTBOP8JaO72iL3auEZhVmAQg4ipjOVAtj4= +go.opentelemetry.io/proto/otlp v1.7.1/go.mod h1:b2rVh6rfI/s2pHWNlB7ILJcRALpcNDzKhACevjI+ZnE= go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= +golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= golang.org/x/crypto v0.40.0 h1:r4x+VvoG5Fm+eJcxMaY8CQM7Lb0l1lsmjGBQ6s8BfKM= golang.org/x/crypto v0.40.0/go.mod h1:Qr1vMER5WyS2dfPHAlsOj01wgLbsyWtFn/aY+5+ZdxY= +golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= +golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= +golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= golang.org/x/net v0.42.0 h1:jzkYrhi3YQWD6MLBJcsklgQsoAcw89EcZbJw8Z614hs= golang.org/x/net v0.42.0/go.mod h1:FF1RA5d3u7nAYA4z2TkclSCKh68eSXtiFwcWQpPXdt8= +golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.16.0 h1:ycBJEhp9p4vXvUZNszeOq0kGTPghopOL8q0fq3vstxw= golang.org/x/sync v0.16.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20201204225414-ed752295db88/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210616094352-59db8d763f22/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.11.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.34.0 h1:H5Y5sJ2L2JRdyv7ROF1he/lPdvFsd0mJHFw2ThKHxLA= golang.org/x/sys v0.34.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= golang.org/x/term v0.33.0 h1:NuFncQrRcaRvVmgRkvM3j/F00gWIAlcmlB8ACEKmGIg= golang.org/x/term v0.33.0/go.mod h1:s18+ql9tYWp1IfpV9DmCtQDDSRBUjKaw9M1eAv5UeF0= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.27.0 h1:4fGWRpyh641NLlecmyl4LOe6yDdfaYNrGb2zdfo4JV4= golang.org/x/text v0.27.0/go.mod h1:1D28KMCvyooCX9hBiosv5Tz/+YLxj0j7XhWjpSUF7CU= +golang.org/x/time v0.0.0-20220210224613-90d013bbcef8 h1:vVKdlvoWBphwdxWKrFZEuM0kGgGLxUOYcY4U/2Vjg44= +golang.org/x/time v0.0.0-20220210224613-90d013bbcef8/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= +golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= +golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +google.golang.org/genproto/googleapis/api v0.0.0-20250728155136-f173205681a0 h1:0UOBWO4dC+e51ui0NFKSPbkHHiQ4TmrEfEZMLDyRmY8= +google.golang.org/genproto/googleapis/api v0.0.0-20250728155136-f173205681a0/go.mod h1:8ytArBbtOy2xfht+y2fqKd5DRDJRUQhqbyEnQ4bDChs= +google.golang.org/genproto/googleapis/rpc v0.0.0-20250728155136-f173205681a0 h1:MAKi5q709QWfnkkpNQ0M12hYJ1+e8qYVDyowc4U1XZM= +google.golang.org/genproto/googleapis/rpc v0.0.0-20250728155136-f173205681a0/go.mod h1:qQ0YXyHHx3XkvlzUtpXDkS29lDSafHMZBAZDc03LQ3A= +google.golang.org/grpc v1.74.2 h1:WoosgB65DlWVC9FqI82dGsZhWFNBSLjQ84bjROOpMu4= +google.golang.org/grpc v1.74.2/go.mod h1:CtQ+BGjaAIXHs/5YS3i473GqwBBa1zGQNevxdeBEXrM= +google.golang.org/protobuf v1.36.6 h1:z1NpPI8ku2WgiWnf+t9wTPsn6eP1L7ksHUlkfLvd9xY= +google.golang.org/protobuf v1.36.6/go.mod h1:jduwjTPXsFjZGTmRluh+L6NjiWu7pchiJ2/5YcXBHnY= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= @@ -79,3 +235,5 @@ gorm.io/driver/postgres v1.6.0 h1:2dxzU8xJ+ivvqTRph34QX+WrRaJlmfyPqXmoGVjMBa4= gorm.io/driver/postgres v1.6.0/go.mod h1:vUw0mrGgrTK+uPHEhAdV4sfFELrByKVGnaVRkXDhtWo= gorm.io/gorm v1.30.0 h1:qbT5aPv1UH8gI99OsRlvDToLxW5zR7FzS9acZDOZcgs= gorm.io/gorm v1.30.0/go.mod h1:8Z33v652h4//uMA76KjeDH8mJXPm1QNCYrMeatR0DOE= +gotest.tools/v3 v3.5.2 h1:7koQfIKdy+I8UTetycgUqXWSDwpgv193Ka+qRsmBY8Q= +gotest.tools/v3 v3.5.2/go.mod h1:LtdLGcnqToBH83WByAAi/wiwSFCArdFIUV/xxN4pcjA= From b342a30376caeb87eb8babe30eeb167ca4cc65cd Mon Sep 17 00:00:00 2001 From: Gus Date: Fri, 1 Aug 2025 10:16:57 +0800 Subject: [PATCH 19/30] Add pkg test workflow and update label --- .github/workflows/pkg-tests.yml | 30 ++++++++++++++++++++++++++++++ .github/workflows/tests.yml | 2 +- 2 files changed, 31 insertions(+), 1 deletion(-) create mode 100644 .github/workflows/pkg-tests.yml diff --git a/.github/workflows/pkg-tests.yml b/.github/workflows/pkg-tests.yml new file mode 100644 index 00000000..7cf3c760 --- /dev/null +++ b/.github/workflows/pkg-tests.yml @@ -0,0 +1,30 @@ +name: Pkg Tests + +on: + pull_request: + types: [ready_for_review, synchronize, labeled] + +jobs: + test: + if: github.event.pull_request.draft == false || (github.event.action == 'labeled' && github.event.label.name == 'test') + runs-on: ubuntu-latest + steps: + - uses: actions/setup-go@v5 + with: + go-version: 1.24.x + + - uses: actions/checkout@v4 + + - uses: actions/cache@v4 + with: + path: | + ~/go/pkg/mod + ~/.cache/go-build + key: ${{ runner.os }}-go-${{ hashFiles('**/go.sum') }} + restore-keys: | + ${{ runner.os }}-go- + + - name: Run pkg tests + run: | + go test ./pkg/... -coverprofile=coverage.out + go tool cover -func=coverage.out | tail -n 1 diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index f2e9fc97..7e12f0ff 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -6,7 +6,7 @@ on: jobs: test: - if: github.event.pull_request.draft == false || (github.event.action == 'labeled' && github.event.label.name == 'testing') + if: github.event.pull_request.draft == false || (github.event.action == 'labeled' && github.event.label.name == 'test') runs-on: ubuntu-latest strategy: matrix: From b57a67eb85336e5540b373bd058bc5e8f52a27c8 Mon Sep 17 00:00:00 2001 From: Gus Date: Fri, 1 Aug 2025 10:37:42 +0800 Subject: [PATCH 20/30] Remove pkg-tests workflow and adjust test trigger --- .github/workflows/pkg-tests.yml | 30 ------------------------------ .github/workflows/tests.yml | 2 +- 2 files changed, 1 insertion(+), 31 deletions(-) delete mode 100644 .github/workflows/pkg-tests.yml diff --git a/.github/workflows/pkg-tests.yml b/.github/workflows/pkg-tests.yml deleted file mode 100644 index 7cf3c760..00000000 --- a/.github/workflows/pkg-tests.yml +++ /dev/null @@ -1,30 +0,0 @@ -name: Pkg Tests - -on: - pull_request: - types: [ready_for_review, synchronize, labeled] - -jobs: - test: - if: github.event.pull_request.draft == false || (github.event.action == 'labeled' && github.event.label.name == 'test') - runs-on: ubuntu-latest - steps: - - uses: actions/setup-go@v5 - with: - go-version: 1.24.x - - - uses: actions/checkout@v4 - - - uses: actions/cache@v4 - with: - path: | - ~/go/pkg/mod - ~/.cache/go-build - key: ${{ runner.os }}-go-${{ hashFiles('**/go.sum') }} - restore-keys: | - ${{ runner.os }}-go- - - - name: Run pkg tests - run: | - go test ./pkg/... -coverprofile=coverage.out - go tool cover -func=coverage.out | tail -n 1 diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 7e12f0ff..f2e9fc97 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -6,7 +6,7 @@ on: jobs: test: - if: github.event.pull_request.draft == false || (github.event.action == 'labeled' && github.event.label.name == 'test') + if: github.event.pull_request.draft == false || (github.event.action == 'labeled' && github.event.label.name == 'testing') runs-on: ubuntu-latest strategy: matrix: From 3a558ea3baae51243792cb0ecb367e2c13f14317 Mon Sep 17 00:00:00 2001 From: Gus Date: Fri, 1 Aug 2025 11:10:47 +0800 Subject: [PATCH 21/30] Fix database tests and cache modules --- .github/workflows/tests.yml | 11 ++++++++++- database/connection_test.go | 1 + 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index f2e9fc97..15e22438 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -6,7 +6,7 @@ on: jobs: test: - if: github.event.pull_request.draft == false || (github.event.action == 'labeled' && github.event.label.name == 'testing') + if: github.event.pull_request.draft == false || (github.event.action == 'labeled' && github.event.label.name == 'test') runs-on: ubuntu-latest strategy: matrix: @@ -42,6 +42,15 @@ jobs: go test ./handler/... -coverprofile=coverage.out go tool cover -func=coverage.out | tail -n 1 + - uses: actions/cache@v4 + with: + path: | + ~/go/pkg/mod + ~/.cache/go-build + key: ${{ runner.os }}-go-${{ hashFiles('**/go.sum') }} + restore-keys: | + ${{ runner.os }}-go- + - name: Run database tests run: | go test ./database -coverprofile=coverage.out diff --git a/database/connection_test.go b/database/connection_test.go index aa39c33f..2a7136ed 100644 --- a/database/connection_test.go +++ b/database/connection_test.go @@ -25,6 +25,7 @@ func TestApiKeysWithTestContainer(t *testing.T) { postgres.WithDatabase("testdb"), postgres.WithUsername("test"), postgres.WithPassword("secret"), + postgres.BasicWaitStrategies(), ) if err != nil { t.Fatalf("container run err: %v", err) From 0329b5e79373db10620d211789c4ae76cad10660 Mon Sep 17 00:00:00 2001 From: Gus Date: Fri, 1 Aug 2025 11:21:23 +0800 Subject: [PATCH 22/30] Fix workflow label --- .github/workflows/tests.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 15e22438..e860f133 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -6,7 +6,7 @@ on: jobs: test: - if: github.event.pull_request.draft == false || (github.event.action == 'labeled' && github.event.label.name == 'test') + if: github.event.pull_request.draft == false || (github.event.action == 'labeled' && github.event.label.name == 'testing') runs-on: ubuntu-latest strategy: matrix: From a7f7fefab41e586b2edc2afc9358663052fd6322 Mon Sep 17 00:00:00 2001 From: Gus Date: Fri, 1 Aug 2025 11:42:44 +0800 Subject: [PATCH 23/30] Remove redundant cache step --- .github/workflows/tests.yml | 9 --------- 1 file changed, 9 deletions(-) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index e860f133..f2e9fc97 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -42,15 +42,6 @@ jobs: go test ./handler/... -coverprofile=coverage.out go tool cover -func=coverage.out | tail -n 1 - - uses: actions/cache@v4 - with: - path: | - ~/go/pkg/mod - ~/.cache/go-build - key: ${{ runner.os }}-go-${{ hashFiles('**/go.sum') }} - restore-keys: | - ${{ runner.os }}-go- - - name: Run database tests run: | go test ./database -coverprofile=coverage.out From e34c0f45aa6891f2d260de2e046b27dfc5f5f5b4 Mon Sep 17 00:00:00 2001 From: Gus Date: Fri, 1 Aug 2025 12:08:02 +0800 Subject: [PATCH 24/30] Add seeder tests with sqlite and update CI --- .github/workflows/tests.yml | 2 +- database/seeder/seeds/seeder_test.go | 121 +++++++++++++++++++++++++++ go.mod | 2 + go.sum | 4 + 4 files changed, 128 insertions(+), 1 deletion(-) create mode 100644 database/seeder/seeds/seeder_test.go diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index f2e9fc97..3a57c0fd 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -44,5 +44,5 @@ jobs: - name: Run database tests run: | - go test ./database -coverprofile=coverage.out + go test ./database/... -coverprofile=coverage.out go tool cover -func=coverage.out | tail -n 1 diff --git a/database/seeder/seeds/seeder_test.go b/database/seeder/seeds/seeder_test.go new file mode 100644 index 00000000..5a002977 --- /dev/null +++ b/database/seeder/seeds/seeder_test.go @@ -0,0 +1,121 @@ +package seeds + +import ( + "reflect" + "testing" + "unsafe" + + "github.com/google/uuid" + "gorm.io/driver/sqlite" + "gorm.io/gorm" + + "github.com/oullin/database" + "github.com/oullin/env" +) + +func testConnection(t *testing.T, e *env.Environment) *database.Connection { + dsn := "file:" + uuid.NewString() + "?mode=memory&cache=shared" + db, err := gorm.Open(sqlite.Open(dsn), &gorm.Config{}) + if err != nil { + t.Fatalf("open sqlite: %v", err) + } + + conn := &database.Connection{} + rv := reflect.ValueOf(conn).Elem() + + driverField := rv.FieldByName("driver") + reflect.NewAt(driverField.Type(), unsafe.Pointer(driverField.UnsafeAddr())).Elem().Set(reflect.ValueOf(db)) + + nameField := rv.FieldByName("driverName") + reflect.NewAt(nameField.Type(), unsafe.Pointer(nameField.UnsafeAddr())).Elem().SetString("sqlite") + + envField := rv.FieldByName("env") + reflect.NewAt(envField.Type(), unsafe.Pointer(envField.UnsafeAddr())).Elem().Set(reflect.ValueOf(e)) + + err = db.AutoMigrate( + &database.User{}, + &database.Post{}, + &database.Category{}, + &database.PostCategory{}, + &database.Tag{}, + &database.PostTag{}, + &database.PostView{}, + &database.Comment{}, + &database.Like{}, + &database.Newsletter{}, + ) + if err != nil { + t.Fatalf("migrate: %v", err) + } + + return conn +} + +func setupSeeder(t *testing.T) *Seeder { + e := &env.Environment{App: env.AppEnvironment{Type: "local"}} + conn := testConnection(t, e) + return MakeSeeder(conn, e) +} + +func TestSeederWorkflow(t *testing.T) { + seeder := setupSeeder(t) + + if err := seeder.TruncateDB(); err != nil { + t.Fatalf("truncate err: %v", err) + } + + userA, userB := seeder.SeedUsers() + posts := seeder.SeedPosts(userA, userB) + categories := seeder.SeedCategories() + tags := seeder.SeedTags() + seeder.SeedComments(posts...) + seeder.SeedLikes(posts...) + seeder.SeedPostsCategories(categories, posts) + seeder.SeedPostTags(tags, posts) + seeder.SeedPostViews(posts, userA, userB) + if err := seeder.SeedNewsLetters(); err != nil { + t.Fatalf("newsletter err: %v", err) + } + + var count int64 + + seeder.dbConn.Sql().Model(&database.User{}).Count(&count) + if count != 2 { + t.Fatalf("expected 2 users got %d", count) + } + + seeder.dbConn.Sql().Model(&database.Post{}).Count(&count) + if count != 2 { + t.Fatalf("expected 2 posts got %d", count) + } + + seeder.dbConn.Sql().Model(&database.Category{}).Count(&count) + if count == 0 { + t.Fatalf("categories not seeded") + } +} + +func TestSeederEmptyMethods(t *testing.T) { + seeder := setupSeeder(t) + + seeder.SeedPostsCategories(nil, nil) + seeder.SeedPostTags(nil, nil) + seeder.SeedPostViews(nil) + + var count int64 + + seeder.dbConn.Sql().Model(&database.PostCategory{}).Count(&count) + if count != 0 { + t.Fatalf("expected 0 post_categories") + } + + seeder.dbConn.Sql().Model(&database.PostTag{}).Count(&count) + if count != 0 { + t.Fatalf("expected 0 post_tags") + } + + seeder.dbConn.Sql().Model(&database.PostView{}).Count(&count) + if count != 0 { + t.Fatalf("expected 0 post_views") + } +} diff --git a/go.mod b/go.mod index 3da7da20..f947a7ee 100644 --- a/go.mod +++ b/go.mod @@ -15,6 +15,7 @@ require ( golang.org/x/text v0.27.0 gopkg.in/yaml.v3 v3.0.1 gorm.io/driver/postgres v1.6.0 + gorm.io/driver/sqlite v1.6.0 gorm.io/gorm v1.30.0 ) @@ -52,6 +53,7 @@ require ( github.com/leodido/go-urn v1.4.0 // indirect github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 // indirect github.com/magiconair/properties v1.8.10 // indirect + github.com/mattn/go-sqlite3 v1.14.22 // indirect github.com/moby/docker-image-spec v1.3.1 // indirect github.com/moby/go-archive v0.1.0 // indirect github.com/moby/patternmatcher v0.6.0 // indirect diff --git a/go.sum b/go.sum index 9579e9f2..48b7e45b 100644 --- a/go.sum +++ b/go.sum @@ -95,6 +95,8 @@ github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 h1:6E+4a0GO5zZEnZ github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0/go.mod h1:zJYVVT2jmtg6P3p1VtQj7WsuWi/y4VnjVBn7F8KPB3I= github.com/magiconair/properties v1.8.10 h1:s31yESBquKXCV9a/ScB3ESkOjUYYv+X0rg8SYxI99mE= github.com/magiconair/properties v1.8.10/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0= +github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU= +github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= github.com/mdelapenya/tlscert v0.2.0 h1:7H81W6Z/4weDvZBNOfQte5GpIMo0lGYEeWbkGp5LJHI= github.com/mdelapenya/tlscert v0.2.0/go.mod h1:O4njj3ELLnJjGdkN7M/vIVCpZ+Cf0L6muqOG4tLSl8o= github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0= @@ -233,6 +235,8 @@ gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gorm.io/driver/postgres v1.6.0 h1:2dxzU8xJ+ivvqTRph34QX+WrRaJlmfyPqXmoGVjMBa4= gorm.io/driver/postgres v1.6.0/go.mod h1:vUw0mrGgrTK+uPHEhAdV4sfFELrByKVGnaVRkXDhtWo= +gorm.io/driver/sqlite v1.6.0 h1:WHRRrIiulaPiPFmDcod6prc4l2VGVWHz80KspNsxSfQ= +gorm.io/driver/sqlite v1.6.0/go.mod h1:AO9V1qIQddBESngQUKWL9yoH93HIeA1X6V633rBwyT8= gorm.io/gorm v1.30.0 h1:qbT5aPv1UH8gI99OsRlvDToLxW5zR7FzS9acZDOZcgs= gorm.io/gorm v1.30.0/go.mod h1:8Z33v652h4//uMA76KjeDH8mJXPm1QNCYrMeatR0DOE= gotest.tools/v3 v3.5.2 h1:7koQfIKdy+I8UTetycgUqXWSDwpgv193Ka+qRsmBY8Q= From 21ea1de0b52689e9d67ff011d8c1284c19fef607 Mon Sep 17 00:00:00 2001 From: Gus Date: Fri, 1 Aug 2025 12:25:20 +0800 Subject: [PATCH 25/30] Use Postgres container for seeder tests --- database/seeder/seeds/seeder_test.go | 64 +++++++++++++++++++--------- go.mod | 2 - go.sum | 4 -- 3 files changed, 44 insertions(+), 26 deletions(-) diff --git a/database/seeder/seeds/seeder_test.go b/database/seeder/seeds/seeder_test.go index 5a002977..34ad3522 100644 --- a/database/seeder/seeds/seeder_test.go +++ b/database/seeder/seeds/seeder_test.go @@ -1,38 +1,63 @@ package seeds import ( - "reflect" + "context" + "os/exec" "testing" - "unsafe" - "github.com/google/uuid" - "gorm.io/driver/sqlite" - "gorm.io/gorm" + "github.com/testcontainers/testcontainers-go" + "github.com/testcontainers/testcontainers-go/modules/postgres" "github.com/oullin/database" "github.com/oullin/env" ) func testConnection(t *testing.T, e *env.Environment) *database.Connection { - dsn := "file:" + uuid.NewString() + "?mode=memory&cache=shared" - db, err := gorm.Open(sqlite.Open(dsn), &gorm.Config{}) - if err != nil { - t.Fatalf("open sqlite: %v", err) + if _, err := exec.LookPath("docker"); err != nil { + t.Skip("docker not installed") } - conn := &database.Connection{} - rv := reflect.ValueOf(conn).Elem() + ctx := context.Background() - driverField := rv.FieldByName("driver") - reflect.NewAt(driverField.Type(), unsafe.Pointer(driverField.UnsafeAddr())).Elem().Set(reflect.ValueOf(db)) + 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) + } - nameField := rv.FieldByName("driverName") - reflect.NewAt(nameField.Type(), unsafe.Pointer(nameField.UnsafeAddr())).Elem().SetString("sqlite") + e.DB = env.DBEnvironment{ + UserName: "test", + UserPassword: "secret", + DatabaseName: "testdb", + Port: port.Int(), + Host: host, + DriverName: database.DriverName, + SSLMode: "disable", + TimeZone: "UTC", + } - envField := rv.FieldByName("env") - reflect.NewAt(envField.Type(), unsafe.Pointer(envField.UnsafeAddr())).Elem().Set(reflect.ValueOf(e)) + conn, err := database.MakeConnection(e) + if err != nil { + t.Fatalf("make connection: %v", err) + } + t.Cleanup(func() { conn.Close() }) - err = db.AutoMigrate( + if err := conn.Sql().AutoMigrate( &database.User{}, &database.Post{}, &database.Category{}, @@ -43,8 +68,7 @@ func testConnection(t *testing.T, e *env.Environment) *database.Connection { &database.Comment{}, &database.Like{}, &database.Newsletter{}, - ) - if err != nil { + ); err != nil { t.Fatalf("migrate: %v", err) } diff --git a/go.mod b/go.mod index f947a7ee..3da7da20 100644 --- a/go.mod +++ b/go.mod @@ -15,7 +15,6 @@ require ( golang.org/x/text v0.27.0 gopkg.in/yaml.v3 v3.0.1 gorm.io/driver/postgres v1.6.0 - gorm.io/driver/sqlite v1.6.0 gorm.io/gorm v1.30.0 ) @@ -53,7 +52,6 @@ require ( github.com/leodido/go-urn v1.4.0 // indirect github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 // indirect github.com/magiconair/properties v1.8.10 // indirect - github.com/mattn/go-sqlite3 v1.14.22 // indirect github.com/moby/docker-image-spec v1.3.1 // indirect github.com/moby/go-archive v0.1.0 // indirect github.com/moby/patternmatcher v0.6.0 // indirect diff --git a/go.sum b/go.sum index 48b7e45b..9579e9f2 100644 --- a/go.sum +++ b/go.sum @@ -95,8 +95,6 @@ github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 h1:6E+4a0GO5zZEnZ github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0/go.mod h1:zJYVVT2jmtg6P3p1VtQj7WsuWi/y4VnjVBn7F8KPB3I= github.com/magiconair/properties v1.8.10 h1:s31yESBquKXCV9a/ScB3ESkOjUYYv+X0rg8SYxI99mE= github.com/magiconair/properties v1.8.10/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0= -github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU= -github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= github.com/mdelapenya/tlscert v0.2.0 h1:7H81W6Z/4weDvZBNOfQte5GpIMo0lGYEeWbkGp5LJHI= github.com/mdelapenya/tlscert v0.2.0/go.mod h1:O4njj3ELLnJjGdkN7M/vIVCpZ+Cf0L6muqOG4tLSl8o= github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0= @@ -235,8 +233,6 @@ gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gorm.io/driver/postgres v1.6.0 h1:2dxzU8xJ+ivvqTRph34QX+WrRaJlmfyPqXmoGVjMBa4= gorm.io/driver/postgres v1.6.0/go.mod h1:vUw0mrGgrTK+uPHEhAdV4sfFELrByKVGnaVRkXDhtWo= -gorm.io/driver/sqlite v1.6.0 h1:WHRRrIiulaPiPFmDcod6prc4l2VGVWHz80KspNsxSfQ= -gorm.io/driver/sqlite v1.6.0/go.mod h1:AO9V1qIQddBESngQUKWL9yoH93HIeA1X6V633rBwyT8= gorm.io/gorm v1.30.0 h1:qbT5aPv1UH8gI99OsRlvDToLxW5zR7FzS9acZDOZcgs= gorm.io/gorm v1.30.0/go.mod h1:8Z33v652h4//uMA76KjeDH8mJXPm1QNCYrMeatR0DOE= gotest.tools/v3 v3.5.2 h1:7koQfIKdy+I8UTetycgUqXWSDwpgv193Ka+qRsmBY8Q= From eb0145ea4c504b70c8537829e3fd8b8457903701 Mon Sep 17 00:00:00 2001 From: Gus Date: Fri, 1 Aug 2025 12:33:48 +0800 Subject: [PATCH 26/30] Fix seeder datetime type and add pkg tests workflow --- .github/workflows/pkg-tests.yml | 31 +++++++++++++++++++++++++++++++ database/model.go | 4 ++-- 2 files changed, 33 insertions(+), 2 deletions(-) create mode 100644 .github/workflows/pkg-tests.yml diff --git a/.github/workflows/pkg-tests.yml b/.github/workflows/pkg-tests.yml new file mode 100644 index 00000000..0c27a523 --- /dev/null +++ b/.github/workflows/pkg-tests.yml @@ -0,0 +1,31 @@ +name: Pkg Tests + +on: + workflow_dispatch: + pull_request: + types: [ready_for_review, synchronize, labeled] + +jobs: + pkg-tests: + if: github.event.pull_request.draft == false || (github.event.action == 'labeled' && github.event.label.name == 'testing') + runs-on: ubuntu-latest + steps: + - uses: actions/setup-go@v5 + with: + go-version: 1.24.x + + - uses: actions/checkout@v4 + + - uses: actions/cache@v4 + with: + path: | + ~/go/pkg/mod + ~/.cache/go-build + key: ${{ runner.os }}-go-${{ hashFiles('**/go.sum') }} + restore-keys: | + ${{ runner.os }}-go- + + - name: Run pkg tests + run: | + go test ./pkg/... -coverprofile=coverage.out + go tool cover -func=coverage.out | tail -n 1 diff --git a/database/model.go b/database/model.go index 6e2f2b69..69c560c2 100644 --- a/database/model.go +++ b/database/model.go @@ -170,8 +170,8 @@ type Newsletter struct { FirstName string `gorm:"type:varchar(250);not null"` LastName string `gorm:"type:varchar(250);not null"` Email string `gorm:"type:varchar(250);unique;not null"` - SubscribedAt *time.Time `gorm:"index:idx_newsletters_subscribed_at;type:datetime"` - UnsubscribedAt *time.Time `gorm:"index:idx_newsletters_unsubscribed_at;type:datetime"` + SubscribedAt *time.Time `gorm:"index:idx_newsletters_subscribed_at;type:timestamptz"` + UnsubscribedAt *time.Time `gorm:"index:idx_newsletters_unsubscribed_at;type:timestamptz"` CreatedAt time.Time `gorm:"default:CURRENT_TIMESTAMP;index:idx_newsletters_created_at"` UpdatedAt time.Time `gorm:"default:CURRENT_TIMESTAMP"` } From 3a608ebbc1667330792638e544f00306c3cc93ec Mon Sep 17 00:00:00 2001 From: Gus Date: Fri, 1 Aug 2025 13:24:32 +0800 Subject: [PATCH 27/30] Format new tests --- database/model_internal_test.go | 12 ++ .../pagination/pagination_func_test.go | 39 ++++ .../repository/queries/posts_filters_test.go | 29 +++ database/repository/repository_test.go | 192 ++++++++++++++++++ 4 files changed, 272 insertions(+) create mode 100644 database/model_internal_test.go create mode 100644 database/repository/pagination/pagination_func_test.go create mode 100644 database/repository/queries/posts_filters_test.go create mode 100644 database/repository/repository_test.go diff --git a/database/model_internal_test.go b/database/model_internal_test.go new file mode 100644 index 00000000..2fabf46b --- /dev/null +++ b/database/model_internal_test.go @@ -0,0 +1,12 @@ +package database + +import "testing" + +func TestIsValidTable(t *testing.T) { + if !isValidTable("users") { + t.Fatalf("expected users table to be valid") + } + if isValidTable("unknown") { + t.Fatalf("unexpected valid table") + } +} diff --git a/database/repository/pagination/pagination_func_test.go b/database/repository/pagination/pagination_func_test.go new file mode 100644 index 00000000..97e0ff11 --- /dev/null +++ b/database/repository/pagination/pagination_func_test.go @@ -0,0 +1,39 @@ +package pagination + +import "testing" + +func TestMakePagination(t *testing.T) { + p := Paginate{Page: 2, Limit: 2} + p.SetNumItems(5) + + result := MakePagination([]int{1, 2}, p) + + if result.TotalPages != 3 { + t.Fatalf("expected 3 pages got %d", result.TotalPages) + } + if result.NextPage == nil || *result.NextPage != 3 { + t.Fatalf("next page mismatch") + } + if result.PreviousPage == nil || *result.PreviousPage != 1 { + t.Fatalf("prev page mismatch") + } +} + +func TestHydratePagination(t *testing.T) { + src := &Pagination[string]{ + Data: []string{"a", "bb"}, + Page: 1, + Total: 2, + PageSize: 2, + TotalPages: 1, + } + + dst := HydratePagination(src, func(s string) int { return len(s) }) + + if len(dst.Data) != 2 || dst.Data[1] != 2 { + t.Fatalf("unexpected hydration") + } + if dst.Total != src.Total || dst.Page != src.Page { + t.Fatalf("metadata mismatch") + } +} diff --git a/database/repository/queries/posts_filters_test.go b/database/repository/queries/posts_filters_test.go new file mode 100644 index 00000000..6eed1d24 --- /dev/null +++ b/database/repository/queries/posts_filters_test.go @@ -0,0 +1,29 @@ +package queries + +import "testing" + +func TestPostFiltersSanitise(t *testing.T) { + f := PostFilters{ + Text: " Hello ", + Title: " MyTitle ", + Author: " ME ", + Category: " Tech ", + Tag: "Tag ", + } + + if f.GetText() != "hello" { + t.Fatalf("got %s", f.GetText()) + } + if f.GetTitle() != "mytitle" { + t.Fatalf("got %s", f.GetTitle()) + } + if f.GetAuthor() != "me" { + t.Fatalf("got %s", f.GetAuthor()) + } + if f.GetCategory() != "tech" { + t.Fatalf("got %s", f.GetCategory()) + } + if f.GetTag() != "tag" { + t.Fatalf("got %s", f.GetTag()) + } +} diff --git a/database/repository/repository_test.go b/database/repository/repository_test.go new file mode 100644 index 00000000..cc01acfa --- /dev/null +++ b/database/repository/repository_test.go @@ -0,0 +1,192 @@ +package repository_test + +import ( + "context" + "os/exec" + "testing" + + "github.com/google/uuid" + "github.com/testcontainers/testcontainers-go" + "github.com/testcontainers/testcontainers-go/modules/postgres" + + "github.com/oullin/database" + "github.com/oullin/database/repository" + "github.com/oullin/env" +) + +func setupDB(t *testing.T) *database.Connection { + 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() }) + + return conn +} + +func TestUsersFindBy(t *testing.T) { + conn := setupDB(t) + + if err := conn.Sql().AutoMigrate(&database.User{}); err != nil { + t.Fatalf("migrate: %v", err) + } + + u := database.User{ + UUID: uuid.NewString(), + FirstName: "John", + LastName: "Doe", + Username: "jdoe", + DisplayName: "John Doe", + Email: "jdoe@test.com", + PasswordHash: "x", + PublicToken: uuid.NewString(), + } + if err := conn.Sql().Create(&u).Error; err != nil { + t.Fatalf("create user: %v", err) + } + + repo := repository.Users{DB: conn, Env: &env.Environment{}} + found := repo.FindBy("jdoe") + if found == nil || found.ID != u.ID { + t.Fatalf("user not found") + } +} + +func TestTagsFindOrCreate(t *testing.T) { + conn := setupDB(t) + + if err := conn.Sql().AutoMigrate(&database.Tag{}); err != nil { + t.Fatalf("migrate: %v", err) + } + + repo := repository.Tags{DB: conn} + + first, err := repo.FindOrCreate("golang") + if err != nil { + t.Fatalf("create tag: %v", err) + } + + second, err := repo.FindOrCreate("golang") + if err != nil { + t.Fatalf("find tag: %v", err) + } + + if first.ID != second.ID { + t.Fatalf("expected same tag") + } +} + +func TestCategoriesFindBy(t *testing.T) { + conn := setupDB(t) + + if err := conn.Sql().AutoMigrate(&database.Category{}); err != nil { + t.Fatalf("migrate: %v", err) + } + + c := database.Category{UUID: uuid.NewString(), Name: "News", Slug: "news"} + if err := conn.Sql().Create(&c).Error; err != nil { + t.Fatalf("create cat: %v", err) + } + + repo := repository.Categories{DB: conn} + found := repo.FindBy("news") + if found == nil || found.ID != c.ID { + t.Fatalf("category not found") + } +} + +func TestPostsCreateAndFind(t *testing.T) { + conn := setupDB(t) + + if err := conn.Sql().AutoMigrate(&database.User{}, &database.Post{}, &database.Category{}, &database.PostCategory{}, &database.Tag{}, &database.PostTag{}); err != nil { + t.Fatalf("migrate: %v", err) + } + + user := database.User{ + UUID: uuid.NewString(), + FirstName: "Jane", + LastName: "Doe", + Username: "jane", + DisplayName: "Jane Doe", + Email: "jane@test.com", + PasswordHash: "x", + PublicToken: uuid.NewString(), + } + if err := conn.Sql().Create(&user).Error; err != nil { + t.Fatalf("create user: %v", err) + } + + cat := database.Category{UUID: uuid.NewString(), Name: "Tech", Slug: "tech"} + if err := conn.Sql().Create(&cat).Error; err != nil { + t.Fatalf("create cat: %v", err) + } + + tag := database.Tag{UUID: uuid.NewString(), Name: "Go", Slug: "go"} + if err := conn.Sql().Create(&tag).Error; err != nil { + t.Fatalf("create tag: %v", err) + } + + postsRepo := repository.Posts{ + DB: conn, + Categories: &repository.Categories{DB: conn}, + Tags: &repository.Tags{DB: conn}, + } + + p, err := postsRepo.Create(database.PostsAttrs{ + AuthorID: user.ID, + Slug: "first-post", + Title: "First Post", + Excerpt: "excerpt", + Content: "content", + Categories: []database.CategoriesAttrs{{Id: cat.ID, Name: cat.Name}}, + Tags: []database.TagAttrs{{Id: tag.ID, Name: tag.Name}}, + }) + if err != nil { + t.Fatalf("create post: %v", err) + } + + found := postsRepo.FindBy("first-post") + if found == nil || found.ID != p.ID { + t.Fatalf("post not found") + } +} From 0e12cda39e4d2d48a6600e7c3da8ca3d9fb4fbd9 Mon Sep 17 00:00:00 2001 From: Gus Date: Fri, 1 Aug 2025 13:33:00 +0800 Subject: [PATCH 28/30] Remove pkg tests workflow --- .github/workflows/pkg-tests.yml | 31 ------------------------------- .github/workflows/tests.yml | 2 +- 2 files changed, 1 insertion(+), 32 deletions(-) delete mode 100644 .github/workflows/pkg-tests.yml diff --git a/.github/workflows/pkg-tests.yml b/.github/workflows/pkg-tests.yml deleted file mode 100644 index 0c27a523..00000000 --- a/.github/workflows/pkg-tests.yml +++ /dev/null @@ -1,31 +0,0 @@ -name: Pkg Tests - -on: - workflow_dispatch: - pull_request: - types: [ready_for_review, synchronize, labeled] - -jobs: - pkg-tests: - if: github.event.pull_request.draft == false || (github.event.action == 'labeled' && github.event.label.name == 'testing') - runs-on: ubuntu-latest - steps: - - uses: actions/setup-go@v5 - with: - go-version: 1.24.x - - - uses: actions/checkout@v4 - - - uses: actions/cache@v4 - with: - path: | - ~/go/pkg/mod - ~/.cache/go-build - key: ${{ runner.os }}-go-${{ hashFiles('**/go.sum') }} - restore-keys: | - ${{ runner.os }}-go- - - - name: Run pkg tests - run: | - go test ./pkg/... -coverprofile=coverage.out - go tool cover -func=coverage.out | tail -n 1 diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 3a57c0fd..b9aff258 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -6,7 +6,7 @@ on: jobs: test: - if: github.event.pull_request.draft == false || (github.event.action == 'labeled' && github.event.label.name == 'testing') + if: github.event.pull_request.draft == false || (github.event.action == 'labeled' && github.event.label.name == 'test') runs-on: ubuntu-latest strategy: matrix: From 59baa8065855a44c3405dafd40f22392a98a3103 Mon Sep 17 00:00:00 2001 From: Gus Date: Fri, 1 Aug 2025 13:56:01 +0800 Subject: [PATCH 29/30] Add pkg tests workflow --- .github/workflows/pkg.yml | 33 +++++++++++++++++++++++++++++++++ 1 file changed, 33 insertions(+) create mode 100644 .github/workflows/pkg.yml diff --git a/.github/workflows/pkg.yml b/.github/workflows/pkg.yml new file mode 100644 index 00000000..440b1de9 --- /dev/null +++ b/.github/workflows/pkg.yml @@ -0,0 +1,33 @@ +name: Pkg Tests + +on: + pull_request: + types: [ready_for_review, synchronize, labeled] + +jobs: + pkg: + if: github.event.pull_request.draft == false || (github.event.action == 'labeled' && github.event.label.name == 'testing') + runs-on: ubuntu-latest + strategy: + matrix: + go-version: [1.24.x] + steps: + - uses: actions/setup-go@v5 + with: + go-version: ${{ matrix.go-version }} + + - uses: actions/checkout@v4 + + - uses: actions/cache@v4 + with: + path: | + ~/go/pkg/mod + ~/.cache/go-build + key: ${{ runner.os }}-go-${{ hashFiles('**/go.sum') }} + restore-keys: | + ${{ runner.os }}-go- + + - name: Run pkg tests + run: | + go test ./pkg/... -coverprofile=coverage.out + go tool cover -func=coverage.out | tail -n 1 From 51fd169b88343c159ea7d315952c64ff1c414c5e Mon Sep 17 00:00:00 2001 From: Gustavo Ocanto Date: Fri, 1 Aug 2025 13:59:06 +0800 Subject: [PATCH 30/30] format --- .github/workflows/pkg.yml | 33 --------------------------------- .github/workflows/tests.yml | 6 +++--- 2 files changed, 3 insertions(+), 36 deletions(-) delete mode 100644 .github/workflows/pkg.yml diff --git a/.github/workflows/pkg.yml b/.github/workflows/pkg.yml deleted file mode 100644 index 440b1de9..00000000 --- a/.github/workflows/pkg.yml +++ /dev/null @@ -1,33 +0,0 @@ -name: Pkg Tests - -on: - pull_request: - types: [ready_for_review, synchronize, labeled] - -jobs: - pkg: - if: github.event.pull_request.draft == false || (github.event.action == 'labeled' && github.event.label.name == 'testing') - runs-on: ubuntu-latest - strategy: - matrix: - go-version: [1.24.x] - steps: - - uses: actions/setup-go@v5 - with: - go-version: ${{ matrix.go-version }} - - - uses: actions/checkout@v4 - - - uses: actions/cache@v4 - with: - path: | - ~/go/pkg/mod - ~/.cache/go-build - key: ${{ runner.os }}-go-${{ hashFiles('**/go.sum') }} - restore-keys: | - ${{ runner.os }}-go- - - - name: Run pkg tests - run: | - go test ./pkg/... -coverprofile=coverage.out - go tool cover -func=coverage.out | tail -n 1 diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index b9aff258..3d7988ca 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -6,7 +6,7 @@ on: jobs: test: - if: github.event.pull_request.draft == false || (github.event.action == 'labeled' && github.event.label.name == 'test') + if: github.event.pull_request.draft == false || (github.event.action == 'labeled' && github.event.label.name == 'testing') runs-on: ubuntu-latest strategy: matrix: @@ -32,12 +32,12 @@ jobs: go test ./pkg/... -coverprofile=coverage.out go tool cover -func=coverage.out | tail -n 1 - - name: Run other tests + - name: Run boost & env tests run: | go test ./boost ./env -coverprofile=coverage.out go tool cover -func=coverage.out | tail -n 1 - - name: Run handler tests + - name: Run handlers tests run: | go test ./handler/... -coverprofile=coverage.out go tool cover -func=coverage.out | tail -n 1