@nakedev/go-scaffold 0.1.3 → 0.1.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +18 -14
- package/dist/commands/generate.js +2 -1
- package/dist/commands/method.js +63 -15
- package/dist/commands/remove.js +23 -24
- package/dist/index.js +5 -4
- package/dist/templates/module-manifest.js +2 -0
- package/dist/utils/method-patcher.js +80 -16
- package/dist/utils/module-location.js +22 -0
- package/dist/utils/naming.js +75 -12
- package/dist/utils/openapi-patcher.js +19 -1
- package/dist/utils/smoke-run.js +31 -0
- package/package.json +14 -5
- package/scripts/smoke-test.mjs +2058 -0
- package/templates/create/base/.github/workflows/ci.yml.hbs +18 -5
- package/templates/create/base/AGENTS.md.hbs +10 -12
- package/templates/create/base/Makefile.hbs +9 -3
- package/templates/create/base/README.md.hbs +18 -6
- package/templates/create/features/docs/openapi.yaml.hbs +4 -5
- package/templates/create/features/docs/patterns.md.hbs +9 -6
- package/templates/generate/module/docs/item.yaml.hbs +3 -3
- package/templates/generate/module/handler.go.hbs +18 -4
- package/templates/generate/module/handler_test.go.hbs +81 -62
- package/templates/generate/module/migration.down.sql.hbs +1 -1
- package/templates/generate/module/migration.up.sql.hbs +1 -1
- package/templates/generate/module/minimal/handler.go.hbs +8 -2
- package/templates/generate/module/minimal/handler_test.go.hbs +5 -112
- package/templates/generate/module/minimal/service_test.go.hbs +39 -16
- package/templates/generate/module/model/model.go.hbs +7 -0
- package/templates/generate/module/repository_test.go.hbs +79 -0
- package/templates/generate/module/service.go.hbs +6 -4
- package/templates/generate/module/service_test.go.hbs +68 -17
- package/tests/integration/default-module.test.mjs +46 -0
- package/tests/integration/generator-naming.test.mjs +81 -0
- package/tests/integration/generator-unit-test-seams.test.mjs +91 -0
- package/tests/integration/legacy-method-compat.test.mjs +222 -0
- package/tests/integration/remove-module.test.mjs +58 -0
- package/tests/unit/naming.test.mjs +94 -0
- package/tests/unit/smoke-isolation.test.mjs +35 -0
|
@@ -1,117 +1,10 @@
|
|
|
1
1
|
package {{pkg}}
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
"context"
|
|
7
|
-
{{/if}}
|
|
8
|
-
"net/http/httptest"
|
|
9
|
-
"os"
|
|
10
|
-
"sync"
|
|
11
|
-
"testing"
|
|
12
|
-
{{#if auth}}
|
|
13
|
-
"time"
|
|
14
|
-
{{/if}}
|
|
15
|
-
|
|
16
|
-
"{{goModule}}/internal/app/{{modulePath}}/model"
|
|
17
|
-
"{{goModule}}/internal/shared/middleware"
|
|
18
|
-
|
|
19
|
-
"gorm.io/gorm"
|
|
20
|
-
|
|
21
|
-
"github.com/gin-gonic/gin"
|
|
22
|
-
{{#if auth}}
|
|
23
|
-
"github.com/golang-jwt/jwt/v5"
|
|
24
|
-
"github.com/google/uuid"
|
|
25
|
-
{{/if}}
|
|
26
|
-
"gorm.io/driver/postgres"
|
|
27
|
-
)
|
|
28
|
-
|
|
29
|
-
// integration test harness, backed by real Postgres (same engine as prod, no sqlite) — skips if
|
|
30
|
-
// the DB isn't reachable. minimal module: no routes yet, so no tests reference this yet — kept
|
|
31
|
-
// ready for once `generate method` adds endpoints.
|
|
32
|
-
// Runs against its own {{dbName}}_test database, never the one DB_DSN points at: the
|
|
33
|
-
// harness does DropTable+AutoMigrate on every run, which would otherwise wipe the
|
|
34
|
-
// schema `make migrate-up` built in your dev DB (FK constraints, seed data and all).
|
|
35
|
-
// Create it once with `make db-create DB_NAME={{dbName}}_test`, or point TEST_DB_DSN
|
|
36
|
-
// somewhere else.
|
|
37
|
-
//nolint:unused
|
|
38
|
-
var (
|
|
39
|
-
testDBOnce sync.Once
|
|
40
|
-
testDB *gorm.DB
|
|
41
|
-
testDBErr error
|
|
42
|
-
)
|
|
43
|
-
|
|
44
|
-
//nolint:unused
|
|
45
|
-
func dbForTest(t *testing.T) *gorm.DB {
|
|
46
|
-
t.Helper()
|
|
47
|
-
testDBOnce.Do(func() {
|
|
48
|
-
dsn := os.Getenv("TEST_DB_DSN")
|
|
49
|
-
if dsn == "" {
|
|
50
|
-
dsn = "postgres://postgres:postgres@localhost:5432/{{dbName}}_test?sslmode=disable"
|
|
51
|
-
}
|
|
52
|
-
if testDB, testDBErr = gorm.Open(postgres.Open(dsn), &gorm.Config{TranslateError: true}); testDBErr == nil {
|
|
53
|
-
_ = testDB.Migrator().DropTable(&model.{{pascalName}}{})
|
|
54
|
-
testDBErr = testDB.AutoMigrate(&model.{{pascalName}}{})
|
|
55
|
-
}
|
|
56
|
-
})
|
|
57
|
-
if testDBErr != nil {
|
|
58
|
-
t.Skipf("postgres not ready (make db-create DB_NAME={{dbName}}_test, or set TEST_DB_DSN): %v", testDBErr)
|
|
59
|
-
}
|
|
60
|
-
return testDB
|
|
61
|
-
}
|
|
62
|
-
|
|
63
|
-
// setup builds the full stack on a transaction that's rolled back at the end → each test is isolated, no leftover rows
|
|
3
|
+
// serviceStub embeds the concrete service so a generated method automatically
|
|
4
|
+
// satisfies the handler's narrow service interface. Add explicit function-backed
|
|
5
|
+
// overrides here when writing focused handler tests for that method.
|
|
64
6
|
//
|
|
65
7
|
//nolint:unused
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
gin.SetMode(gin.TestMode)
|
|
69
|
-
tx := dbForTest(t).Begin()
|
|
70
|
-
t.Cleanup(func() { tx.Rollback() })
|
|
71
|
-
r := gin.New()
|
|
72
|
-
r.Use(middleware.RequestID(), middleware.Error(true))
|
|
73
|
-
{{#if permission}}
|
|
74
|
-
// stub authz: always grants "{{permission}}" — this test is about the
|
|
75
|
-
// CRUD handler, not re-proving RBAC (that's covered by the role
|
|
76
|
-
// package's own tests), so the resolver doesn't need a real DB lookup.
|
|
77
|
-
authz := middleware.NewAuthz(func(_ context.Context, _ string) (map[string]struct{}, error) {
|
|
78
|
-
return map[string]struct{}{"{{permission}}": {}}, nil
|
|
79
|
-
}, time.Minute)
|
|
80
|
-
{{/if}}
|
|
81
|
-
NewHandler(NewService(NewRepository(tx)){{#if auth}}, testJWTSecret{{/if}}{{#if permission}}, authz{{/if}}).Register(r)
|
|
82
|
-
return r
|
|
83
|
-
}
|
|
84
|
-
|
|
85
|
-
{{#if auth}}
|
|
86
|
-
//nolint:unused
|
|
87
|
-
const testJWTSecret = "test-secret"
|
|
88
|
-
|
|
89
|
-
// authHeader mints a valid access token signed with testJWTSecret — the
|
|
90
|
-
// route's own RequireAuth(testJWTSecret) (wired in via NewHandler above)
|
|
91
|
-
// verifies against the same secret, so this round-trips for real.
|
|
92
|
-
//
|
|
93
|
-
//nolint:unused
|
|
94
|
-
func authHeader() string {
|
|
95
|
-
claims := jwt.MapClaims{
|
|
96
|
-
"typ": "access",
|
|
97
|
-
"sub": uuid.NewString(),
|
|
98
|
-
"role": "staff",
|
|
99
|
-
"exp": time.Now().Add(time.Hour).Unix(),
|
|
100
|
-
"iat": time.Now().Unix(),
|
|
101
|
-
}
|
|
102
|
-
tok, _ := jwt.NewWithClaims(jwt.SigningMethodHS256, claims).SignedString([]byte(testJWTSecret))
|
|
103
|
-
return "Bearer " + tok
|
|
104
|
-
}
|
|
105
|
-
|
|
106
|
-
{{/if}}
|
|
107
|
-
//nolint:unused
|
|
108
|
-
func do(r *gin.Engine, method, path, body string) *httptest.ResponseRecorder {
|
|
109
|
-
req := httptest.NewRequest(method, path, bytes.NewBufferString(body))
|
|
110
|
-
req.Header.Set("Content-Type", "application/json")
|
|
111
|
-
{{#if auth}}
|
|
112
|
-
req.Header.Set("Authorization", authHeader())
|
|
113
|
-
{{/if}}
|
|
114
|
-
w := httptest.NewRecorder()
|
|
115
|
-
r.ServeHTTP(w, req)
|
|
116
|
-
return w
|
|
8
|
+
type serviceStub struct {
|
|
9
|
+
*Service
|
|
117
10
|
}
|
|
@@ -11,37 +11,60 @@ import (
|
|
|
11
11
|
"github.com/google/uuid"
|
|
12
12
|
)
|
|
13
13
|
|
|
14
|
-
//
|
|
15
|
-
//
|
|
16
|
-
//
|
|
17
|
-
// for a `get --get-mode one --field` lookup)
|
|
14
|
+
// repositoryStub is ready for focused use-case tests added alongside generated
|
|
15
|
+
// methods. Every unset dependency panics so unexpected orchestration is visible.
|
|
16
|
+
//
|
|
18
17
|
//nolint:unused
|
|
19
|
-
type
|
|
20
|
-
|
|
21
|
-
|
|
18
|
+
type repositoryStub struct {
|
|
19
|
+
createFn func(context.Context, *model.{{pascalName}}) error
|
|
20
|
+
findAllFn func(context.Context, int, int) ([]model.{{pascalName}}, error)
|
|
21
|
+
findByIDFn func(context.Context, uuid.UUID) (*model.{{pascalName}}, error)
|
|
22
|
+
updateFn func(context.Context, *model.{{pascalName}}) error
|
|
23
|
+
deleteFn func(context.Context, uuid.UUID) error
|
|
24
|
+
// go-scaffold:repository-stub-fields
|
|
22
25
|
}
|
|
23
26
|
|
|
24
27
|
//nolint:unused
|
|
25
|
-
func (
|
|
28
|
+
func (s *repositoryStub) Create(ctx context.Context, m *model.{{pascalName}}) error {
|
|
29
|
+
if s.createFn == nil {
|
|
30
|
+
panic("unexpected repository.Create call")
|
|
31
|
+
}
|
|
32
|
+
return s.createFn(ctx, m)
|
|
33
|
+
}
|
|
26
34
|
|
|
27
35
|
//nolint:unused
|
|
28
|
-
func (
|
|
36
|
+
func (s *repositoryStub) FindAll(ctx context.Context, limit, offset int) ([]model.{{pascalName}}, error) {
|
|
37
|
+
if s.findAllFn == nil {
|
|
38
|
+
panic("unexpected repository.FindAll call")
|
|
39
|
+
}
|
|
40
|
+
return s.findAllFn(ctx, limit, offset)
|
|
41
|
+
}
|
|
29
42
|
|
|
30
43
|
//nolint:unused
|
|
31
|
-
func (
|
|
32
|
-
if
|
|
33
|
-
|
|
44
|
+
func (s *repositoryStub) FindByID(ctx context.Context, id uuid.UUID) (*model.{{pascalName}}, error) {
|
|
45
|
+
if s.findByIDFn == nil {
|
|
46
|
+
panic("unexpected repository.FindByID call")
|
|
34
47
|
}
|
|
35
|
-
return
|
|
48
|
+
return s.findByIDFn(ctx, id)
|
|
36
49
|
}
|
|
37
50
|
|
|
38
51
|
//nolint:unused
|
|
39
|
-
func (
|
|
52
|
+
func (s *repositoryStub) Update(ctx context.Context, m *model.{{pascalName}}) error {
|
|
53
|
+
if s.updateFn == nil {
|
|
54
|
+
panic("unexpected repository.Update call")
|
|
55
|
+
}
|
|
56
|
+
return s.updateFn(ctx, m)
|
|
57
|
+
}
|
|
40
58
|
|
|
41
59
|
//nolint:unused
|
|
42
|
-
func (
|
|
60
|
+
func (s *repositoryStub) Delete(ctx context.Context, id uuid.UUID) error {
|
|
61
|
+
if s.deleteFn == nil {
|
|
62
|
+
panic("unexpected repository.Delete call")
|
|
63
|
+
}
|
|
64
|
+
return s.deleteFn(ctx, id)
|
|
65
|
+
}
|
|
43
66
|
|
|
44
|
-
// go-scaffold:
|
|
67
|
+
// go-scaffold:repository-stub-methods
|
|
45
68
|
|
|
46
69
|
//nolint:unused
|
|
47
70
|
func status(t *testing.T, err error) int {
|
|
@@ -18,3 +18,10 @@ type {{pascalName}} struct {
|
|
|
18
18
|
CreatedAt time.Time `json:"created_at"`
|
|
19
19
|
UpdatedAt time.Time `json:"updated_at"`
|
|
20
20
|
}
|
|
21
|
+
|
|
22
|
+
// TableName pins the persistence contract to the versioned SQL migrations.
|
|
23
|
+
// Do not rely on GORM's English inflector: irregular/plural module names must
|
|
24
|
+
// resolve to exactly the same table in development and production.
|
|
25
|
+
func ({{pascalName}}) TableName() string {
|
|
26
|
+
return "{{tableName}}"
|
|
27
|
+
}
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
package {{pkg}}
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"context"
|
|
5
|
+
"os"
|
|
6
|
+
"testing"
|
|
7
|
+
|
|
8
|
+
"{{goModule}}/internal/app/{{modulePath}}/model"
|
|
9
|
+
|
|
10
|
+
"github.com/google/uuid"
|
|
11
|
+
"gorm.io/driver/postgres"
|
|
12
|
+
"gorm.io/gorm"
|
|
13
|
+
)
|
|
14
|
+
|
|
15
|
+
// repositoryDBForTest expects the test database schema to come from the same
|
|
16
|
+
// versioned SQL migrations used in production. Unit tests stay database-free;
|
|
17
|
+
// CI sets REQUIRE_TEST_DB=true so an unavailable/unmigrated database fails
|
|
18
|
+
// instead of becoming a false-green skip.
|
|
19
|
+
func repositoryDBForTest(t *testing.T) *gorm.DB {
|
|
20
|
+
t.Helper()
|
|
21
|
+
dsn := os.Getenv("TEST_DB_DSN")
|
|
22
|
+
if dsn == "" {
|
|
23
|
+
if os.Getenv("REQUIRE_TEST_DB") == "true" {
|
|
24
|
+
t.Fatal("TEST_DB_DSN is required when REQUIRE_TEST_DB=true")
|
|
25
|
+
}
|
|
26
|
+
t.Skip("repository integration test skipped: set TEST_DB_DSN to a migrated PostgreSQL database")
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
db, err := gorm.Open(postgres.Open(dsn), &gorm.Config{TranslateError: true})
|
|
30
|
+
if err != nil {
|
|
31
|
+
if os.Getenv("REQUIRE_TEST_DB") == "true" {
|
|
32
|
+
t.Fatalf("open required test database: %v", err)
|
|
33
|
+
}
|
|
34
|
+
t.Skipf("repository integration test skipped: %v", err)
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
sqlDB, err := db.DB()
|
|
38
|
+
if err != nil {
|
|
39
|
+
t.Fatalf("get SQL database handle: %v", err)
|
|
40
|
+
}
|
|
41
|
+
if err := sqlDB.Ping(); err != nil {
|
|
42
|
+
if os.Getenv("REQUIRE_TEST_DB") == "true" {
|
|
43
|
+
t.Fatalf("ping required test database: %v", err)
|
|
44
|
+
}
|
|
45
|
+
t.Skipf("repository integration test skipped: %v", err)
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
tx := db.Begin()
|
|
49
|
+
if tx.Error != nil {
|
|
50
|
+
t.Fatalf("begin test transaction: %v", tx.Error)
|
|
51
|
+
}
|
|
52
|
+
t.Cleanup(func() {
|
|
53
|
+
if err := tx.Rollback().Error; err != nil {
|
|
54
|
+
t.Errorf("rollback test transaction: %v", err)
|
|
55
|
+
}
|
|
56
|
+
})
|
|
57
|
+
return tx
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
func TestRepository_CreateFindDelete(t *testing.T) {
|
|
61
|
+
repo := NewRepository(repositoryDBForTest(t))
|
|
62
|
+
ctx := context.Background()
|
|
63
|
+
id := uuid.New()
|
|
64
|
+
item := &model.{{pascalName}}{ID: id}
|
|
65
|
+
|
|
66
|
+
if err := repo.Create(ctx, item); err != nil {
|
|
67
|
+
t.Fatalf("create: %v", err)
|
|
68
|
+
}
|
|
69
|
+
found, err := repo.FindByID(ctx, id)
|
|
70
|
+
if err != nil {
|
|
71
|
+
t.Fatalf("find by id: %v", err)
|
|
72
|
+
}
|
|
73
|
+
if found.ID != id {
|
|
74
|
+
t.Fatalf("want id %s, got %s", id, found.ID)
|
|
75
|
+
}
|
|
76
|
+
if err := repo.Delete(ctx, id); err != nil {
|
|
77
|
+
t.Fatalf("delete: %v", err)
|
|
78
|
+
}
|
|
79
|
+
}
|
|
@@ -33,8 +33,9 @@ func NewService(repo repository) *Service {
|
|
|
33
33
|
return &Service{repo: repo}
|
|
34
34
|
}
|
|
35
35
|
|
|
36
|
-
func (s *Service) Create(ctx context.Context) (*model.{{pascalName}}, error) {
|
|
37
|
-
// TODO:
|
|
36
|
+
func (s *Service) Create(ctx context.Context, in createInput) (*model.{{pascalName}}, error) {
|
|
37
|
+
// TODO: set real fields from in
|
|
38
|
+
_ = in
|
|
38
39
|
m := &model.{{pascalName}}{ID: id.New()}
|
|
39
40
|
if err := s.repo.Create(ctx, m); err != nil {
|
|
40
41
|
if dberr.IsDuplicate(err) {
|
|
@@ -61,12 +62,13 @@ func (s *Service) Get(ctx context.Context, id uuid.UUID) (*model.{{pascalName}},
|
|
|
61
62
|
return m, nil
|
|
62
63
|
}
|
|
63
64
|
|
|
64
|
-
func (s *Service) Update(ctx context.Context, id uuid.UUID) (*model.{{pascalName}}, error) {
|
|
65
|
+
func (s *Service) Update(ctx context.Context, id uuid.UUID, in updateInput) (*model.{{pascalName}}, error) {
|
|
65
66
|
m, err := s.repo.FindByID(ctx, id)
|
|
66
67
|
if err != nil {
|
|
67
68
|
return nil, wrapFindErr(err)
|
|
68
69
|
}
|
|
69
|
-
// TODO: apply real fields from
|
|
70
|
+
// TODO: apply real fields from in before saving
|
|
71
|
+
_ = in
|
|
70
72
|
if err := s.repo.Update(ctx, m); err != nil {
|
|
71
73
|
if dberr.IsDuplicate(err) {
|
|
72
74
|
return nil, errConflict()
|
|
@@ -12,24 +12,54 @@ import (
|
|
|
12
12
|
"gorm.io/gorm"
|
|
13
13
|
)
|
|
14
14
|
|
|
15
|
-
//
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
15
|
+
// repositoryStub exposes one function per dependency operation. Tests configure
|
|
16
|
+
// only the calls they expect; an unexpected call panics instead of silently
|
|
17
|
+
// returning a shared zero value/error that can hide broken orchestration.
|
|
18
|
+
type repositoryStub struct {
|
|
19
|
+
createFn func(context.Context, *model.{{pascalName}}) error
|
|
20
|
+
findAllFn func(context.Context, int, int) ([]model.{{pascalName}}, error)
|
|
21
|
+
findByIDFn func(context.Context, uuid.UUID) (*model.{{pascalName}}, error)
|
|
22
|
+
updateFn func(context.Context, *model.{{pascalName}}) error
|
|
23
|
+
deleteFn func(context.Context, uuid.UUID) error
|
|
24
|
+
// go-scaffold:repository-stub-fields
|
|
19
25
|
}
|
|
20
26
|
|
|
21
|
-
func (
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
if f.err != nil {
|
|
25
|
-
return nil, f.err
|
|
27
|
+
func (s *repositoryStub) Create(ctx context.Context, m *model.{{pascalName}}) error {
|
|
28
|
+
if s.createFn == nil {
|
|
29
|
+
panic("unexpected repository.Create call")
|
|
26
30
|
}
|
|
27
|
-
return
|
|
31
|
+
return s.createFn(ctx, m)
|
|
28
32
|
}
|
|
29
|
-
func (f *fakeRepo) Update(context.Context, *model.{{pascalName}}) error { return f.err }
|
|
30
|
-
func (f *fakeRepo) Delete(context.Context, uuid.UUID) error { return f.err }
|
|
31
33
|
|
|
32
|
-
|
|
34
|
+
func (s *repositoryStub) FindAll(ctx context.Context, limit, offset int) ([]model.{{pascalName}}, error) {
|
|
35
|
+
if s.findAllFn == nil {
|
|
36
|
+
panic("unexpected repository.FindAll call")
|
|
37
|
+
}
|
|
38
|
+
return s.findAllFn(ctx, limit, offset)
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
func (s *repositoryStub) FindByID(ctx context.Context, id uuid.UUID) (*model.{{pascalName}}, error) {
|
|
42
|
+
if s.findByIDFn == nil {
|
|
43
|
+
panic("unexpected repository.FindByID call")
|
|
44
|
+
}
|
|
45
|
+
return s.findByIDFn(ctx, id)
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
func (s *repositoryStub) Update(ctx context.Context, m *model.{{pascalName}}) error {
|
|
49
|
+
if s.updateFn == nil {
|
|
50
|
+
panic("unexpected repository.Update call")
|
|
51
|
+
}
|
|
52
|
+
return s.updateFn(ctx, m)
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
func (s *repositoryStub) Delete(ctx context.Context, id uuid.UUID) error {
|
|
56
|
+
if s.deleteFn == nil {
|
|
57
|
+
panic("unexpected repository.Delete call")
|
|
58
|
+
}
|
|
59
|
+
return s.deleteFn(ctx, id)
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// go-scaffold:repository-stub-methods
|
|
33
63
|
|
|
34
64
|
func status(t *testing.T, err error) int {
|
|
35
65
|
t.Helper()
|
|
@@ -41,24 +71,45 @@ func status(t *testing.T, err error) int {
|
|
|
41
71
|
}
|
|
42
72
|
|
|
43
73
|
func TestService_Create_Duplicate(t *testing.T) {
|
|
44
|
-
|
|
45
|
-
|
|
74
|
+
repo := &repositoryStub{
|
|
75
|
+
createFn: func(context.Context, *model.{{pascalName}}) error {
|
|
76
|
+
return gorm.ErrDuplicatedKey
|
|
77
|
+
},
|
|
78
|
+
}
|
|
79
|
+
svc := NewService(repo)
|
|
80
|
+
|
|
81
|
+
_, err := svc.Create(context.Background(), createInput{})
|
|
82
|
+
|
|
46
83
|
if got := status(t, err); got != 409 {
|
|
47
84
|
t.Fatalf("want 409 conflict, got %d", got)
|
|
48
85
|
}
|
|
49
86
|
}
|
|
50
87
|
|
|
51
88
|
func TestService_Get_NotFound(t *testing.T) {
|
|
52
|
-
|
|
89
|
+
repo := &repositoryStub{
|
|
90
|
+
findByIDFn: func(context.Context, uuid.UUID) (*model.{{pascalName}}, error) {
|
|
91
|
+
return nil, gorm.ErrRecordNotFound
|
|
92
|
+
},
|
|
93
|
+
}
|
|
94
|
+
svc := NewService(repo)
|
|
95
|
+
|
|
53
96
|
_, err := svc.Get(context.Background(), uuid.New())
|
|
97
|
+
|
|
54
98
|
if got := status(t, err); got != 404 {
|
|
55
99
|
t.Fatalf("want 404, got %d", got)
|
|
56
100
|
}
|
|
57
101
|
}
|
|
58
102
|
|
|
59
103
|
func TestService_List_DBError_Becomes500(t *testing.T) {
|
|
60
|
-
|
|
104
|
+
repo := &repositoryStub{
|
|
105
|
+
findAllFn: func(context.Context, int, int) ([]model.{{pascalName}}, error) {
|
|
106
|
+
return nil, errors.New("connection refused")
|
|
107
|
+
},
|
|
108
|
+
}
|
|
109
|
+
svc := NewService(repo)
|
|
110
|
+
|
|
61
111
|
_, err := svc.List(context.Background(), 20, 0)
|
|
112
|
+
|
|
62
113
|
if got := status(t, err); got != 500 {
|
|
63
114
|
t.Fatalf("want 500, got %d", got)
|
|
64
115
|
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { execFileSync } from "node:child_process";
|
|
3
|
+
import { existsSync, mkdtempSync, readFileSync, rmSync } from "node:fs";
|
|
4
|
+
import { tmpdir } from "node:os";
|
|
5
|
+
import path from "node:path";
|
|
6
|
+
import test from "node:test";
|
|
7
|
+
import { fileURLToPath } from "node:url";
|
|
8
|
+
|
|
9
|
+
const ROOT = path.dirname(path.dirname(path.dirname(fileURLToPath(import.meta.url))));
|
|
10
|
+
const CLI = path.join(ROOT, "bin", "go-scaffold.js");
|
|
11
|
+
|
|
12
|
+
function runCLI(cwd, ...args) {
|
|
13
|
+
return execFileSync("node", [CLI, ...args], {
|
|
14
|
+
cwd,
|
|
15
|
+
encoding: "utf8",
|
|
16
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
17
|
+
});
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
test("generate module defaults to a safe minimal module", () => {
|
|
21
|
+
const scratch = mkdtempSync(path.join(tmpdir(), "go-scaffold-default-module-"));
|
|
22
|
+
try {
|
|
23
|
+
runCLI(scratch, "create", "sample", "--defaults", "--no-docker");
|
|
24
|
+
const project = path.join(scratch, "sample");
|
|
25
|
+
|
|
26
|
+
const output = runCLI(project, "generate", "module", "orders");
|
|
27
|
+
const handler = readFileSync(
|
|
28
|
+
path.join(project, "internal", "app", "order", "handler.go"),
|
|
29
|
+
"utf8"
|
|
30
|
+
);
|
|
31
|
+
|
|
32
|
+
assert.match(output, /registered empty route group/);
|
|
33
|
+
assert.doesNotMatch(handler, /g\.POST\(/);
|
|
34
|
+
assert.equal(existsSync(path.join(project, "docs", "orders")), false);
|
|
35
|
+
|
|
36
|
+
const legacyOutput = runCLI(project, "generate", "module", "widgets", "--no-full");
|
|
37
|
+
const legacyHandler = readFileSync(
|
|
38
|
+
path.join(project, "internal", "app", "widget", "handler.go"),
|
|
39
|
+
"utf8"
|
|
40
|
+
);
|
|
41
|
+
assert.match(legacyOutput, /registered empty route group/);
|
|
42
|
+
assert.doesNotMatch(legacyHandler, /g\.POST\(/);
|
|
43
|
+
} finally {
|
|
44
|
+
rmSync(scratch, { recursive: true, force: true });
|
|
45
|
+
}
|
|
46
|
+
});
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { execFileSync } from "node:child_process";
|
|
3
|
+
import {
|
|
4
|
+
mkdtempSync,
|
|
5
|
+
readFileSync,
|
|
6
|
+
readdirSync,
|
|
7
|
+
rmSync,
|
|
8
|
+
} from "node:fs";
|
|
9
|
+
import { tmpdir } from "node:os";
|
|
10
|
+
import path from "node:path";
|
|
11
|
+
import test from "node:test";
|
|
12
|
+
import { fileURLToPath } from "node:url";
|
|
13
|
+
|
|
14
|
+
const ROOT = path.dirname(path.dirname(path.dirname(fileURLToPath(import.meta.url))));
|
|
15
|
+
const CLI = path.join(ROOT, "bin", "go-scaffold.js");
|
|
16
|
+
|
|
17
|
+
function runCLI(cwd, ...args) {
|
|
18
|
+
return execFileSync("node", [CLI, ...args], {
|
|
19
|
+
cwd,
|
|
20
|
+
encoding: "utf8",
|
|
21
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
22
|
+
});
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function read(root, relativePath) {
|
|
26
|
+
return readFileSync(path.join(root, relativePath), "utf8");
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function migration(root, suffix) {
|
|
30
|
+
const filename = readdirSync(path.join(root, "migrations")).find((entry) =>
|
|
31
|
+
entry.endsWith(suffix)
|
|
32
|
+
);
|
|
33
|
+
assert.ok(filename, `missing migration ending with ${suffix}`);
|
|
34
|
+
return read(root, path.join("migrations", filename));
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
test("plural module input produces a singular entity with one explicit table name", () => {
|
|
38
|
+
const scratch = mkdtempSync(path.join(tmpdir(), "go-scaffold-naming-"));
|
|
39
|
+
try {
|
|
40
|
+
runCLI(scratch, "create", "sample", "--defaults", "--no-docker");
|
|
41
|
+
const project = path.join(scratch, "sample");
|
|
42
|
+
runCLI(project, "generate", "module", "orders");
|
|
43
|
+
|
|
44
|
+
const model = read(project, "internal/app/order/model/model.go");
|
|
45
|
+
assert.match(model, /type Order struct/);
|
|
46
|
+
assert.match(
|
|
47
|
+
model,
|
|
48
|
+
/func \(Order\) TableName\(\) string \{\s*return "orders"\s*\}/
|
|
49
|
+
);
|
|
50
|
+
assert.match(migration(project, "_create_orders.up.sql"), /CREATE TABLE orders/);
|
|
51
|
+
assert.match(read(project, "internal/app/order/handler.go"), /Group\("\/orders"/);
|
|
52
|
+
} finally {
|
|
53
|
+
rmSync(scratch, { recursive: true, force: true });
|
|
54
|
+
}
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
test("multi-word module keeps URL words and uses snake_case SQL identifiers", () => {
|
|
58
|
+
const scratch = mkdtempSync(path.join(tmpdir(), "go-scaffold-multiword-"));
|
|
59
|
+
try {
|
|
60
|
+
runCLI(scratch, "create", "sample", "--defaults", "--no-docker");
|
|
61
|
+
const project = path.join(scratch, "sample");
|
|
62
|
+
runCLI(project, "generate", "module", "order-items");
|
|
63
|
+
|
|
64
|
+
const model = read(project, "internal/app/orderitem/model/model.go");
|
|
65
|
+
assert.match(model, /type OrderItem struct/);
|
|
66
|
+
assert.match(
|
|
67
|
+
model,
|
|
68
|
+
/func \(OrderItem\) TableName\(\) string \{\s*return "order_items"\s*\}/
|
|
69
|
+
);
|
|
70
|
+
assert.match(
|
|
71
|
+
migration(project, "_create_order-items.up.sql"),
|
|
72
|
+
/CREATE TABLE order_items/
|
|
73
|
+
);
|
|
74
|
+
assert.match(
|
|
75
|
+
read(project, "internal/app/orderitem/handler.go"),
|
|
76
|
+
/Group\("\/order-items"/
|
|
77
|
+
);
|
|
78
|
+
} finally {
|
|
79
|
+
rmSync(scratch, { recursive: true, force: true });
|
|
80
|
+
}
|
|
81
|
+
});
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { execFileSync } from "node:child_process";
|
|
3
|
+
import { mkdtempSync, readFileSync, rmSync } from "node:fs";
|
|
4
|
+
import { tmpdir } from "node:os";
|
|
5
|
+
import path from "node:path";
|
|
6
|
+
import test from "node:test";
|
|
7
|
+
import { fileURLToPath } from "node:url";
|
|
8
|
+
|
|
9
|
+
const ROOT = path.dirname(path.dirname(path.dirname(fileURLToPath(import.meta.url))));
|
|
10
|
+
const CLI = path.join(ROOT, "bin", "go-scaffold.js");
|
|
11
|
+
|
|
12
|
+
function run(command, args, cwd) {
|
|
13
|
+
return execFileSync(command, args, {
|
|
14
|
+
cwd,
|
|
15
|
+
encoding: "utf8",
|
|
16
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
17
|
+
});
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function read(project, relativePath) {
|
|
21
|
+
return readFileSync(path.join(project, relativePath), "utf8");
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
test("generated modules have scalable service and handler unit-test seams", () => {
|
|
25
|
+
const scratch = mkdtempSync(path.join(tmpdir(), "go-scaffold-unit-seams-"));
|
|
26
|
+
try {
|
|
27
|
+
run("node", [CLI, "create", "sample", "--defaults", "--no-docker"], scratch);
|
|
28
|
+
const project = path.join(scratch, "sample");
|
|
29
|
+
run("node", [CLI, "generate", "module", "orders", "--full"], project);
|
|
30
|
+
|
|
31
|
+
const handler = read(project, "internal/app/order/handler.go");
|
|
32
|
+
assert.match(handler, /type service interface \{/);
|
|
33
|
+
assert.match(handler, /svc service/);
|
|
34
|
+
assert.doesNotMatch(handler, /svc \*Service/);
|
|
35
|
+
assert.match(handler, /Create\(context\.Context, createInput\)/);
|
|
36
|
+
assert.match(handler, /Update\(context\.Context, uuid\.UUID, updateInput\)/);
|
|
37
|
+
|
|
38
|
+
const service = read(project, "internal/app/order/service.go");
|
|
39
|
+
assert.match(service, /Create\(ctx context\.Context, in createInput\)/);
|
|
40
|
+
assert.match(service, /Update\(ctx context\.Context, id uuid\.UUID, in updateInput\)/);
|
|
41
|
+
|
|
42
|
+
const serviceTest = read(project, "internal/app/order/service_test.go");
|
|
43
|
+
assert.match(serviceTest, /type repositoryStub struct/);
|
|
44
|
+
assert.match(serviceTest, /createFn\s+func/);
|
|
45
|
+
assert.match(serviceTest, /findByIDFn\s+func/);
|
|
46
|
+
assert.match(serviceTest, /updateFn\s+func/);
|
|
47
|
+
assert.doesNotMatch(serviceTest, /type fakeRepo struct/);
|
|
48
|
+
|
|
49
|
+
const handlerTest = read(project, "internal/app/order/handler_test.go");
|
|
50
|
+
assert.match(handlerTest, /type serviceStub struct/);
|
|
51
|
+
assert.match(handlerTest, /createFn\s+func/);
|
|
52
|
+
assert.doesNotMatch(handlerTest, /gorm\.io\/driver\/postgres/);
|
|
53
|
+
assert.doesNotMatch(handlerTest, /TEST_DB_DSN/);
|
|
54
|
+
|
|
55
|
+
const repositoryTest = read(project, "internal/app/order/repository_test.go");
|
|
56
|
+
assert.match(repositoryTest, /REQUIRE_TEST_DB/);
|
|
57
|
+
assert.match(repositoryTest, /TEST_DB_DSN/);
|
|
58
|
+
assert.doesNotMatch(repositoryTest, /AutoMigrate/);
|
|
59
|
+
assert.doesNotMatch(repositoryTest, /DropTable/);
|
|
60
|
+
|
|
61
|
+
const ci = read(project, ".github/workflows/ci.yml");
|
|
62
|
+
assert.match(ci, /migrate -path migrations/);
|
|
63
|
+
assert.match(ci, /REQUIRE_TEST_DB: "true"/);
|
|
64
|
+
assert.match(ci, /TEST_DB_DSN:/);
|
|
65
|
+
|
|
66
|
+
const itemDocs = read(project, "docs/orders/item.yaml");
|
|
67
|
+
const deleteContract = itemDocs.slice(itemDocs.indexOf("delete:"));
|
|
68
|
+
assert.doesNotMatch(deleteContract, /"404"/);
|
|
69
|
+
|
|
70
|
+
run("node", [CLI, "generate", "method", "orders", "approve", "--type", "patch"], project);
|
|
71
|
+
const openapi = read(project, "docs/openapi.yaml");
|
|
72
|
+
assert.match(openapi, /\/v1\/orders\/\{id\}\/approve:/);
|
|
73
|
+
const approveDocs = read(project, "docs/orders/methods/approve.yaml");
|
|
74
|
+
assert.match(approveDocs, /^parameters:/);
|
|
75
|
+
assert.match(approveDocs, /^patch:/m);
|
|
76
|
+
assert.match(approveDocs, /operationId: approveOrder/);
|
|
77
|
+
assert.doesNotMatch(approveDocs, /requestBody:/);
|
|
78
|
+
|
|
79
|
+
run("node", [CLI, "generate", "method", "orders", "submit", "--type", "post"], project);
|
|
80
|
+
const submitDocs = read(project, "docs/orders/methods/submit.yaml");
|
|
81
|
+
assert.doesNotMatch(submitDocs, /^parameters:/);
|
|
82
|
+
assert.doesNotMatch(submitDocs, /in: path/);
|
|
83
|
+
assert.match(submitDocs, /^post:/m);
|
|
84
|
+
assert.match(submitDocs, /requestBody:/);
|
|
85
|
+
|
|
86
|
+
run("go", ["mod", "tidy"], project);
|
|
87
|
+
run("go", ["test", "./..."], project);
|
|
88
|
+
} finally {
|
|
89
|
+
rmSync(scratch, { recursive: true, force: true });
|
|
90
|
+
}
|
|
91
|
+
});
|