@nakedev/go-scaffold 0.1.2 → 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.
Files changed (131) hide show
  1. package/README.md +93 -14
  2. package/dist/commands/auth.js +129 -0
  3. package/dist/commands/create.js +3 -2
  4. package/dist/commands/generate.js +61 -2
  5. package/dist/commands/method.js +66 -15
  6. package/dist/commands/migration.js +34 -0
  7. package/dist/commands/rbac.js +103 -0
  8. package/dist/commands/remove.js +36 -20
  9. package/dist/commands/worker.js +75 -0
  10. package/dist/index.js +71 -7
  11. package/dist/prompts/create-wizard.js +6 -1
  12. package/dist/prompts/generate-wizard.js +8 -0
  13. package/dist/templates/auth-manifest.js +19 -0
  14. package/dist/templates/create-manifest.js +25 -0
  15. package/dist/templates/module-manifest.js +2 -0
  16. package/dist/templates/rbac-manifest.js +17 -0
  17. package/dist/templates/worker-manifest.js +12 -0
  18. package/dist/utils/auth-patcher.js +96 -0
  19. package/dist/utils/gocheck.js +65 -0
  20. package/dist/utils/main-patcher.js +8 -1
  21. package/dist/utils/method-patcher.js +80 -16
  22. package/dist/utils/migrations.js +30 -8
  23. package/dist/utils/module-location.js +22 -0
  24. package/dist/utils/naming.js +75 -12
  25. package/dist/utils/openapi-patcher.js +35 -1
  26. package/dist/utils/platform-patcher.js +59 -0
  27. package/dist/utils/rbac-patcher.js +277 -0
  28. package/dist/utils/smoke-run.js +31 -0
  29. package/dist/utils/version.js +24 -0
  30. package/package.json +15 -6
  31. package/scripts/smoke-test.mjs +2058 -0
  32. package/templates/add/auth/cmd/seed/main.go.hbs +76 -0
  33. package/templates/add/auth/docs/forgot-password.yaml.hbs +19 -0
  34. package/templates/add/auth/docs/google-callback.yaml.hbs +22 -0
  35. package/templates/add/auth/docs/google-login.yaml.hbs +7 -0
  36. package/templates/add/auth/docs/login.yaml.hbs +19 -0
  37. package/templates/add/auth/docs/logout.yaml.hbs +8 -0
  38. package/templates/add/auth/docs/refresh.yaml.hbs +15 -0
  39. package/templates/add/auth/docs/register.yaml.hbs +19 -0
  40. package/templates/add/auth/docs/reset-password.yaml.hbs +16 -0
  41. package/templates/add/auth/docs/schemas.yaml.hbs +58 -0
  42. package/templates/add/auth/docs/users-me-logout-all.yaml.hbs +9 -0
  43. package/templates/add/auth/docs/users-me-resend-verification.yaml.hbs +10 -0
  44. package/templates/add/auth/docs/users-me.yaml.hbs +12 -0
  45. package/templates/add/auth/docs/verify-email.yaml.hbs +16 -0
  46. package/templates/add/auth/internal/app/user/dto.go.hbs +77 -0
  47. package/templates/add/auth/internal/app/user/errors.go.hbs +36 -0
  48. package/templates/add/auth/internal/app/user/handler.go.hbs +235 -0
  49. package/templates/add/auth/internal/app/user/jwt.go.hbs +84 -0
  50. package/templates/add/auth/internal/app/user/model/identity.go.hbs +28 -0
  51. package/templates/add/auth/internal/app/user/model/user.go.hbs +22 -0
  52. package/templates/add/auth/internal/app/user/repository.go.hbs +84 -0
  53. package/templates/add/auth/internal/app/user/service.go.hbs +447 -0
  54. package/templates/add/auth/internal/app/user/service_test.go.hbs +237 -0
  55. package/templates/add/auth/internal/app/user/tokenstore.go.hbs +159 -0
  56. package/templates/add/auth/internal/shared/middleware/auth.go.hbs +64 -0
  57. package/templates/add/auth/internal/shared/middleware/ratelimit.go.hbs +44 -0
  58. package/templates/add/auth/migrations/create_identities.down.sql.hbs +1 -0
  59. package/templates/add/auth/migrations/create_identities.up.sql.hbs +11 -0
  60. package/templates/add/auth/migrations/create_users.down.sql.hbs +1 -0
  61. package/templates/add/auth/migrations/create_users.up.sql.hbs +9 -0
  62. package/templates/add/rbac/docs/permissions.yaml.hbs +24 -0
  63. package/templates/add/rbac/docs/role-permissions.yaml.hbs +31 -0
  64. package/templates/add/rbac/docs/role.yaml.hbs +17 -0
  65. package/templates/add/rbac/docs/roles.yaml.hbs +43 -0
  66. package/templates/add/rbac/docs/schemas.yaml.hbs +37 -0
  67. package/templates/add/rbac/docs/user-set-role.yaml.hbs +23 -0
  68. package/templates/add/rbac/docs/user.yaml.hbs +16 -0
  69. package/templates/add/rbac/docs/users.yaml.hbs +23 -0
  70. package/templates/add/rbac/internal/app/role/dto.go.hbs +40 -0
  71. package/templates/add/rbac/internal/app/role/errors.go.hbs +39 -0
  72. package/templates/add/rbac/internal/app/role/handler.go.hbs +101 -0
  73. package/templates/add/rbac/internal/app/role/model/permission.go.hbs +9 -0
  74. package/templates/add/rbac/internal/app/role/model/role.go.hbs +18 -0
  75. package/templates/add/rbac/internal/app/role/model/role_permission.go.hbs +8 -0
  76. package/templates/add/rbac/internal/app/role/repository.go.hbs +96 -0
  77. package/templates/add/rbac/internal/app/role/service.go.hbs +210 -0
  78. package/templates/add/rbac/internal/app/role/service_test.go.hbs +119 -0
  79. package/templates/add/rbac/internal/shared/middleware/authz.go.hbs +88 -0
  80. package/templates/add/rbac/internal/shared/middleware/authz_test.go.hbs +88 -0
  81. package/templates/add/rbac/migrations/add_roles.down.sql.hbs +15 -0
  82. package/templates/add/rbac/migrations/add_roles.up.sql.hbs +35 -0
  83. package/templates/add/worker/cmd/worker/main.go.hbs +77 -0
  84. package/templates/add/worker/internal/platform/cache/redis.go.hbs +18 -0
  85. package/templates/add/worker/internal/platform/mail/mail.go.hbs +48 -0
  86. package/templates/add/worker/internal/platform/mail/task.go.hbs +52 -0
  87. package/templates/add/worker/internal/platform/queue/client.go.hbs +31 -0
  88. package/templates/add/worker/internal/platform/queue/server.go.hbs +68 -0
  89. package/templates/create/base/.env.example.hbs +20 -0
  90. package/templates/create/base/.github/workflows/ci.yml.hbs +19 -4
  91. package/templates/create/base/.gitignore.hbs +2 -0
  92. package/templates/create/base/AGENTS.md.hbs +10 -12
  93. package/templates/create/base/Makefile.hbs +39 -8
  94. package/templates/create/base/README.md.hbs +52 -12
  95. package/templates/create/base/cmd/api/main.go.hbs +26 -1
  96. package/templates/create/base/internal/platform/database/database.go.hbs +53 -0
  97. package/templates/create/base/internal/shared/config/config.go.hbs +43 -1
  98. package/templates/create/base/internal/shared/middleware/cors.go.hbs +29 -0
  99. package/templates/create/base/internal/shared/middleware/error.go.hbs +13 -1
  100. package/templates/create/base/migrations/embed.go.hbs +15 -0
  101. package/templates/create/features/docs/architecture.md.hbs +22 -0
  102. package/templates/create/features/docs/common/responses.yaml.hbs +15 -0
  103. package/templates/create/features/docs/observability/metrics.yaml.hbs +12 -0
  104. package/templates/create/features/docs/openapi.yaml.hbs +17 -5
  105. package/templates/create/features/docs/patterns.md.hbs +9 -6
  106. package/templates/create/features/docs/techstack.md.hbs +3 -0
  107. package/templates/create/features/observability/middleware/metrics.go.hbs +41 -0
  108. package/templates/create/features/observability/middleware/tracing.go.hbs +46 -0
  109. package/templates/create/features/observability/platform/telemetry/tracing.go.hbs +130 -0
  110. package/templates/generate/module/docs/item.yaml.hbs +3 -3
  111. package/templates/generate/module/handler.go.hbs +37 -6
  112. package/templates/generate/module/handler_test.go.hbs +113 -51
  113. package/templates/generate/module/migration.down.sql.hbs +1 -1
  114. package/templates/generate/module/migration.up.sql.hbs +1 -1
  115. package/templates/generate/module/minimal/handler.go.hbs +28 -4
  116. package/templates/generate/module/minimal/handler_test.go.hbs +5 -65
  117. package/templates/generate/module/minimal/service_test.go.hbs +39 -16
  118. package/templates/generate/module/model/model.go.hbs +7 -0
  119. package/templates/generate/module/permission.down.sql.hbs +5 -0
  120. package/templates/generate/module/permission.up.sql.hbs +4 -0
  121. package/templates/generate/module/repository_test.go.hbs +79 -0
  122. package/templates/generate/module/service.go.hbs +6 -4
  123. package/templates/generate/module/service_test.go.hbs +68 -17
  124. package/tests/integration/default-module.test.mjs +46 -0
  125. package/tests/integration/generator-naming.test.mjs +81 -0
  126. package/tests/integration/generator-unit-test-seams.test.mjs +91 -0
  127. package/tests/integration/legacy-method-compat.test.mjs +222 -0
  128. package/tests/integration/remove-module.test.mjs +58 -0
  129. package/tests/unit/naming.test.mjs +94 -0
  130. package/tests/unit/smoke-isolation.test.mjs +35 -0
  131. package/dist/utils/module-paths.js +0 -33
@@ -1,22 +1,46 @@
1
1
  package {{pkg}}
2
2
 
3
3
  import (
4
+ {{#if auth}}
5
+ "{{goModule}}/internal/shared/middleware"
6
+
7
+ {{/if}}
4
8
  "github.com/gin-gonic/gin"
5
9
  )
6
10
 
11
+ // service is intentionally empty until `generate method` adds the exact
12
+ // application operations required by this HTTP adapter.
13
+ type service interface {
14
+ // go-scaffold:service-interface
15
+ }
16
+
7
17
  // Handler = delivery for {{pkg}} (parses HTTP, calls the service, attaches errors for the middleware to render)
8
18
  type Handler struct {
9
- svc *Service
19
+ svc service
20
+ {{#if auth}}
21
+ jwtSecret string
22
+ {{/if}}
23
+ {{#if permission}}
24
+ authz *middleware.Authz
25
+ {{/if}}
10
26
  }
11
27
 
12
- func NewHandler(svc *Service) *Handler {
13
- return &Handler{svc: svc}
28
+ func NewHandler(svc service{{#if auth}}, jwtSecret string{{/if}}{{#if permission}}, authz *middleware.Authz{{/if}}) *Handler {
29
+ return &Handler{
30
+ svc: svc,
31
+ {{#if auth}}
32
+ jwtSecret: jwtSecret,
33
+ {{/if}}
34
+ {{#if permission}}
35
+ authz: authz,
36
+ {{/if}}
37
+ }
14
38
  }
15
39
 
16
40
  // Register wires {{pkg}}'s routes onto the router group (takes an IRouter so it can be nested under /v1)
17
41
  // minimal module: no routes yet — add them with `go-scaffold generate method {{pkg}} <name> --type ...`
18
42
  func (h *Handler) Register(rg gin.IRouter) {
19
- g := rg.Group("/{{plural}}")
43
+ g := rg.Group("/{{plural}}"{{#if auth}}, middleware.RequireAuth(h.jwtSecret){{/if}}{{#if permission}}, h.authz.Require("{{permission}}"){{/if}})
20
44
  _ = g
21
45
  // go-scaffold:handler-routes
22
46
  }
@@ -1,70 +1,10 @@
1
1
  package {{pkg}}
2
2
 
3
- import (
4
- "bytes"
5
- "net/http/httptest"
6
- "os"
7
- "sync"
8
- "testing"
9
-
10
- "{{goModule}}/internal/app/{{modulePath}}/model"
11
- "{{goModule}}/internal/shared/middleware"
12
-
13
- "gorm.io/gorm"
14
-
15
- "github.com/gin-gonic/gin"
16
- "gorm.io/driver/postgres"
17
- )
18
-
19
- // integration test harness, backed by real Postgres (same engine as prod, no sqlite) — skips if
20
- // the DB isn't reachable. minimal module: no routes yet, so no tests reference this yet — kept
21
- // ready for once `generate method` adds endpoints (start the DB: docker compose up -d, override
22
- // with TEST_DB_DSN)
23
- //nolint:unused
24
- var (
25
- testDBOnce sync.Once
26
- testDB *gorm.DB
27
- testDBErr error
28
- )
29
-
30
- //nolint:unused
31
- func dbForTest(t *testing.T) *gorm.DB {
32
- t.Helper()
33
- testDBOnce.Do(func() {
34
- dsn := os.Getenv("TEST_DB_DSN")
35
- if dsn == "" {
36
- dsn = "postgres://postgres:postgres@localhost:5432/{{dbName}}?sslmode=disable"
37
- }
38
- if testDB, testDBErr = gorm.Open(postgres.Open(dsn), &gorm.Config{TranslateError: true}); testDBErr == nil {
39
- _ = testDB.Migrator().DropTable(&model.{{pascalName}}{})
40
- testDBErr = testDB.AutoMigrate(&model.{{pascalName}}{})
41
- }
42
- })
43
- if testDBErr != nil {
44
- t.Skipf("postgres not ready (docker compose up -d, or set TEST_DB_DSN): %v", testDBErr)
45
- }
46
- return testDB
47
- }
48
-
49
- // 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.
50
6
  //
51
7
  //nolint:unused
52
- func setup(t *testing.T) *gin.Engine {
53
- t.Helper()
54
- gin.SetMode(gin.TestMode)
55
- tx := dbForTest(t).Begin()
56
- t.Cleanup(func() { tx.Rollback() })
57
- r := gin.New()
58
- r.Use(middleware.RequestID(), middleware.Error())
59
- NewHandler(NewService(NewRepository(tx))).Register(r)
60
- return r
61
- }
62
-
63
- //nolint:unused
64
- func do(r *gin.Engine, method, path, body string) *httptest.ResponseRecorder {
65
- req := httptest.NewRequest(method, path, bytes.NewBufferString(body))
66
- req.Header.Set("Content-Type", "application/json")
67
- w := httptest.NewRecorder()
68
- r.ServeHTTP(w, req)
69
- return w
8
+ type serviceStub struct {
9
+ *Service
70
10
  }
@@ -11,37 +11,60 @@ import (
11
11
  "github.com/google/uuid"
12
12
  )
13
13
 
14
- // fakeRepo = mock of the repository interface, so the service can be tested without a DB
15
- // minimal module: no service methods yet, so no tests reference this — kept ready for
16
- // when `generate method` adds one (its patches append matching stubs here automatically
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 fakeRepo struct {
20
- err error
21
- m *model.{{pascalName}}
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 (f *fakeRepo) Create(context.Context, *model.{{pascalName}}) error { return f.err }
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 (f *fakeRepo) FindAll(context.Context, int, int) ([]model.{{pascalName}}, error) { return nil, f.err }
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 (f *fakeRepo) FindByID(context.Context, uuid.UUID) (*model.{{pascalName}}, error) {
32
- if f.err != nil {
33
- return nil, f.err
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 f.m, nil
48
+ return s.findByIDFn(ctx, id)
36
49
  }
37
50
 
38
51
  //nolint:unused
39
- func (f *fakeRepo) Update(context.Context, *model.{{pascalName}}) error { return f.err }
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 (f *fakeRepo) Delete(context.Context, uuid.UUID) error { return f.err }
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:fake-repo-methods
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,5 @@
1
+ -- intentionally not deleting the permission row: another module generated
2
+ -- later may have reused this same code (the up migration's ON CONFLICT
3
+ -- assumes exactly that), and this file can't know whether that happened.
4
+ -- An unused permission row with no route checking it is harmless — delete
5
+ -- it by hand if you're sure nothing else references '{{permission}}'.
@@ -0,0 +1,4 @@
1
+ -- ON CONFLICT DO NOTHING: safe to re-generate a module reusing a permission
2
+ -- code another module already inserted (e.g. a shared "orders:manage").
3
+ INSERT INTO permissions (code, description) VALUES ('{{permission}}', 'Manage {{plural}}')
4
+ ON CONFLICT (code) DO NOTHING;
@@ -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: accept and set real fields from createInput
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 updateInput before saving
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
- // fakeRepo = mock of the repository interface, so the service can be tested without a DB
16
- type fakeRepo struct {
17
- err error
18
- m *model.{{pascalName}}
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 (f *fakeRepo) Create(context.Context, *model.{{pascalName}}) error { return f.err }
22
- func (f *fakeRepo) FindAll(context.Context, int, int) ([]model.{{pascalName}}, error) { return nil, f.err }
23
- func (f *fakeRepo) FindByID(context.Context, uuid.UUID) (*model.{{pascalName}}, error) {
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 f.m, nil
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
- // go-scaffold:fake-repo-methods
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
- svc := NewService(&fakeRepo{err: gorm.ErrDuplicatedKey})
45
- _, err := svc.Create(context.Background())
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
- svc := NewService(&fakeRepo{err: gorm.ErrRecordNotFound})
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
- svc := NewService(&fakeRepo{err: errors.New("connection refused")})
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
+ });