@nakedev/go-scaffold 0.1.3 → 0.3.0

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 (124) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +143 -50
  3. package/dist/commands/auth.js +116 -11
  4. package/dist/commands/create.js +13 -1
  5. package/dist/commands/generate.js +23 -12
  6. package/dist/commands/method.js +94 -17
  7. package/dist/commands/observability.js +114 -0
  8. package/dist/commands/rbac.js +19 -2
  9. package/dist/commands/undo.js +331 -0
  10. package/dist/commands/worker.js +92 -32
  11. package/dist/index.js +368 -64
  12. package/dist/prompts/auth-wizard.js +29 -0
  13. package/dist/prompts/create-wizard.js +5 -2
  14. package/dist/prompts/generate-wizard.js +57 -0
  15. package/dist/prompts/worker-wizard.js +25 -0
  16. package/dist/templates/auth-manifest.js +20 -3
  17. package/dist/templates/create-manifest.js +13 -20
  18. package/dist/templates/module-manifest.js +2 -0
  19. package/dist/templates/observability-manifest.js +24 -0
  20. package/dist/templates/rbac-manifest.js +1 -0
  21. package/dist/templates/worker-manifest.js +23 -6
  22. package/dist/utils/auth-patcher.js +96 -21
  23. package/dist/utils/config.js +58 -10
  24. package/dist/utils/gocheck.js +57 -5
  25. package/dist/utils/golangci-patcher.js +73 -0
  26. package/dist/utils/gomod-patcher.js +53 -0
  27. package/dist/utils/main-patcher.js +58 -4
  28. package/dist/utils/marker-patch.js +125 -3
  29. package/dist/utils/method-patcher.js +97 -18
  30. package/dist/utils/module-location.js +58 -0
  31. package/dist/utils/naming.js +125 -14
  32. package/dist/utils/observability-patcher.js +107 -0
  33. package/dist/utils/openapi-patcher.js +19 -1
  34. package/dist/utils/platform-patcher.js +98 -12
  35. package/dist/utils/rbac-patcher.js +60 -10
  36. package/dist/utils/smoke-run.js +31 -0
  37. package/package.json +11 -4
  38. package/templates/add/auth/internal/app/user/errors.go.hbs +7 -0
  39. package/templates/add/auth/internal/app/user/handler.go.hbs +64 -23
  40. package/templates/add/auth/internal/app/user/jwt.go.hbs +31 -7
  41. package/templates/add/auth/internal/app/user/model/authtoken.go.hbs +39 -0
  42. package/templates/add/auth/internal/app/user/model/identity.go.hbs +3 -0
  43. package/templates/add/auth/internal/app/user/model/loginthrottle.go.hbs +26 -0
  44. package/templates/add/auth/internal/app/user/model/user.go.hbs +9 -1
  45. package/templates/add/auth/internal/app/user/repository.go.hbs +64 -11
  46. package/templates/add/auth/internal/app/user/repository_test.go.hbs +192 -0
  47. package/templates/add/auth/internal/app/user/service.go.hbs +105 -21
  48. package/templates/add/auth/internal/app/user/service_test.go.hbs +81 -2
  49. package/templates/add/auth/internal/app/user/tokenstore.go.hbs +9 -138
  50. package/templates/add/auth/internal/app/user/tokenstore_pg.go.hbs +144 -0
  51. package/templates/add/auth/internal/app/user/tokenstore_redis.go.hbs +147 -0
  52. package/templates/add/auth/internal/shared/middleware/ratelimit.go.hbs +21 -19
  53. package/templates/add/auth/internal/shared/middleware/ratelimit_memory.go.hbs +63 -0
  54. package/templates/add/auth/internal/shared/middleware/ratelimit_redis.go.hbs +32 -0
  55. package/templates/add/auth/migrations/create_auth_tokens.down.sql.hbs +1 -0
  56. package/templates/add/auth/migrations/create_auth_tokens.up.sql.hbs +16 -0
  57. package/templates/add/auth/migrations/create_identities.down.sql.hbs +1 -1
  58. package/templates/add/auth/migrations/create_identities.up.sql.hbs +9 -5
  59. package/templates/add/auth/migrations/create_login_throttle.down.sql.hbs +1 -0
  60. package/templates/add/auth/migrations/create_login_throttle.up.sql.hbs +10 -0
  61. package/templates/add/auth/migrations/create_users.down.sql.hbs +1 -1
  62. package/templates/add/auth/migrations/create_users.up.sql.hbs +13 -2
  63. package/templates/add/rbac/internal/app/role/dto.go.hbs +8 -3
  64. package/templates/add/rbac/internal/app/role/handler.go.hbs +4 -1
  65. package/templates/add/rbac/internal/app/role/model/permission.go.hbs +3 -0
  66. package/templates/add/rbac/internal/app/role/model/role.go.hbs +4 -0
  67. package/templates/add/rbac/internal/app/role/model/role_permission.go.hbs +3 -0
  68. package/templates/add/rbac/internal/app/role/repository.go.hbs +12 -11
  69. package/templates/add/rbac/internal/app/role/repository_test.go.hbs +176 -0
  70. package/templates/add/rbac/internal/app/role/service.go.hbs +7 -0
  71. package/templates/add/rbac/internal/shared/middleware/authz.go.hbs +19 -0
  72. package/templates/add/rbac/internal/shared/middleware/authz_test.go.hbs +1 -1
  73. package/templates/add/rbac/migrations/add_roles.down.sql.hbs +5 -5
  74. package/templates/add/rbac/migrations/add_roles.up.sql.hbs +17 -11
  75. package/templates/add/worker/cmd/worker/main.go.hbs +30 -24
  76. package/templates/add/worker/internal/platform/mail/mail.go.hbs +21 -0
  77. package/templates/add/worker/internal/platform/mail/task.go.hbs +31 -34
  78. package/templates/add/worker/internal/platform/queue/asynq.go.hbs +140 -0
  79. package/templates/add/worker/internal/platform/queue/queue.go.hbs +87 -0
  80. package/templates/add/worker/internal/platform/queue/river.go.hbs +148 -0
  81. package/templates/create/base/.claude/skills/go-scaffold/SKILL.md.hbs +59 -12
  82. package/templates/create/base/.dockerignore.hbs +13 -0
  83. package/templates/create/base/.env.example.hbs +18 -9
  84. package/templates/create/base/.github/dependabot.yml.hbs +20 -0
  85. package/templates/create/base/.github/workflows/ci.yml.hbs +29 -6
  86. package/templates/create/base/.golangci.yml.hbs +27 -0
  87. package/templates/create/base/AGENTS.md.hbs +14 -12
  88. package/templates/create/base/Dockerfile.hbs +42 -0
  89. package/templates/create/base/Makefile.hbs +52 -16
  90. package/templates/create/base/README.md.hbs +61 -12
  91. package/templates/create/base/cmd/api/main.go.hbs +17 -130
  92. package/templates/create/base/cmd/api/wiring.go.hbs +161 -0
  93. package/templates/create/base/go.mod.hbs +4 -4
  94. package/templates/create/base/internal/platform/database/database.go.hbs +30 -11
  95. package/templates/create/base/internal/shared/config/config.go.hbs +13 -7
  96. package/templates/create/base/internal/shared/pagination/pagination.go.hbs +11 -0
  97. package/templates/create/base/internal/shared/tx/tx.go.hbs +47 -0
  98. package/templates/create/base/redocly.yaml.hbs +21 -0
  99. package/templates/create/features/docs/architecture.md.hbs +32 -11
  100. package/templates/create/features/docs/openapi.yaml.hbs +4 -9
  101. package/templates/create/features/docs/patterns.md.hbs +91 -14
  102. package/templates/create/features/docs/techstack.md.hbs +8 -3
  103. package/templates/generate/module/docs/item.yaml.hbs +3 -3
  104. package/templates/generate/module/dto.go.hbs +8 -1
  105. package/templates/generate/module/errors.go.hbs +5 -0
  106. package/templates/generate/module/field-column.down.sql.hbs +2 -0
  107. package/templates/generate/module/field-column.up.sql.hbs +15 -0
  108. package/templates/generate/module/handler.go.hbs +18 -4
  109. package/templates/generate/module/handler_test.go.hbs +88 -62
  110. package/templates/generate/module/migration.down.sql.hbs +3 -1
  111. package/templates/generate/module/migration.up.sql.hbs +7 -2
  112. package/templates/generate/module/minimal/dto.go.hbs +3 -1
  113. package/templates/generate/module/minimal/handler.go.hbs +8 -2
  114. package/templates/generate/module/minimal/handler_test.go.hbs +5 -112
  115. package/templates/generate/module/minimal/service_test.go.hbs +39 -16
  116. package/templates/generate/module/model/model.go.hbs +16 -0
  117. package/templates/generate/module/permission.up.sql.hbs +3 -1
  118. package/templates/generate/module/repository.go.hbs +60 -6
  119. package/templates/generate/module/repository_test.go.hbs +109 -0
  120. package/templates/generate/module/service.go.hbs +15 -4
  121. package/templates/generate/module/service_test.go.hbs +113 -17
  122. package/dist/commands/remove.js +0 -89
  123. package/templates/add/worker/internal/platform/queue/client.go.hbs +0 -31
  124. package/templates/add/worker/internal/platform/queue/server.go.hbs +0 -68
@@ -2,14 +2,9 @@ package {{pkg}}
2
2
 
3
3
  import (
4
4
  "bytes"
5
- {{#if permission}}
6
5
  "context"
7
- {{/if}}
8
- "encoding/json"
9
6
  "net/http"
10
7
  "net/http/httptest"
11
- "os"
12
- "sync"
13
8
  "testing"
14
9
  {{#if auth}}
15
10
  "time"
@@ -23,67 +18,77 @@ import (
23
18
  "github.com/golang-jwt/jwt/v5"
24
19
  {{/if}}
25
20
  "github.com/google/uuid"
26
- "gorm.io/driver/postgres"
27
- "gorm.io/gorm"
28
21
  )
29
22
 
30
- // integration test backed by real Postgres (same engine as prod, no sqlite) — skips if the DB isn't reachable.
31
- // Runs against its own {{dbName}}_test database, never the one DB_DSN points at: the
32
- // harness does DropTable+AutoMigrate on every run, which would otherwise wipe the
33
- // schema `make migrate-up` built in your dev DB (FK constraints, seed data and all).
34
- // Create it once with `make db-create DB_NAME={{dbName}}_test`, or point TEST_DB_DSN
35
- // somewhere else.
36
- var (
37
- testDBOnce sync.Once
38
- testDB *gorm.DB
39
- testDBErr error
40
- )
23
+ // serviceStub keeps handler tests at the HTTP boundary. It exercises binding,
24
+ // routing, middleware, status codes, and serialization without a database.
25
+ type serviceStub struct {
26
+ // Embedding keeps this stub source-compatible when `generate method` adds a
27
+ // new operation to the handler's service interface. Base CRUD methods below
28
+ // still override the promoted concrete methods for focused unit tests.
29
+ *Service
30
+ createFn func(context.Context, createInput) (*model.{{pascalName}}, error)
31
+ listFn func(context.Context, int, int) ([]model.{{pascalName}}, error)
32
+ getFn func(context.Context, uuid.UUID) (*model.{{pascalName}}, error)
33
+ updateFn func(context.Context, uuid.UUID, updateInput) (*model.{{pascalName}}, error)
34
+ deleteFn func(context.Context, uuid.UUID) error
35
+ }
41
36
 
42
- func dbForTest(t *testing.T) *gorm.DB {
43
- t.Helper()
44
- testDBOnce.Do(func() {
45
- dsn := os.Getenv("TEST_DB_DSN")
46
- if dsn == "" {
47
- dsn = "postgres://postgres:postgres@localhost:5432/{{dbName}}_test?sslmode=disable"
48
- }
49
- if testDB, testDBErr = gorm.Open(postgres.Open(dsn), &gorm.Config{TranslateError: true}); testDBErr == nil {
50
- // drop first — AutoMigrate can't change an existing column's type, always start from a fresh schema
51
- _ = testDB.Migrator().DropTable(&model.{{pascalName}}{})
52
- testDBErr = testDB.AutoMigrate(&model.{{pascalName}}{})
53
- }
54
- })
55
- if testDBErr != nil {
56
- t.Skipf("postgres not ready (make db-create DB_NAME={{dbName}}_test, or set TEST_DB_DSN): %v", testDBErr)
37
+ func (s *serviceStub) Create(ctx context.Context, in createInput) (*model.{{pascalName}}, error) {
38
+ if s.createFn == nil {
39
+ panic("unexpected service.Create call")
40
+ }
41
+ return s.createFn(ctx, in)
42
+ }
43
+
44
+ func (s *serviceStub) List(ctx context.Context, limit, offset int) ([]model.{{pascalName}}, error) {
45
+ if s.listFn == nil {
46
+ panic("unexpected service.List call")
47
+ }
48
+ return s.listFn(ctx, limit, offset)
49
+ }
50
+
51
+ func (s *serviceStub) Get(ctx context.Context, id uuid.UUID) (*model.{{pascalName}}, error) {
52
+ if s.getFn == nil {
53
+ panic("unexpected service.Get call")
54
+ }
55
+ return s.getFn(ctx, id)
56
+ }
57
+
58
+ func (s *serviceStub) Update(ctx context.Context, id uuid.UUID, in updateInput) (*model.{{pascalName}}, error) {
59
+ if s.updateFn == nil {
60
+ panic("unexpected service.Update call")
61
+ }
62
+ return s.updateFn(ctx, id, in)
63
+ }
64
+
65
+ func (s *serviceStub) Delete(ctx context.Context, id uuid.UUID) error {
66
+ if s.deleteFn == nil {
67
+ panic("unexpected service.Delete call")
57
68
  }
58
- return testDB
69
+ return s.deleteFn(ctx, id)
59
70
  }
60
71
 
61
- // setup builds the full stack on a transaction that's rolled back at the end → each test is isolated, no leftover rows
62
- func setup(t *testing.T) *gin.Engine {
72
+ // go-scaffold:service-stub-methods
73
+
74
+ func setupHandlerTest(t *testing.T, svc service) *gin.Engine {
63
75
  t.Helper()
64
76
  gin.SetMode(gin.TestMode)
65
- tx := dbForTest(t).Begin()
66
- t.Cleanup(func() { tx.Rollback() })
67
77
  r := gin.New()
68
78
  r.Use(middleware.RequestID(), middleware.Error(true))
69
79
  {{#if permission}}
70
- // stub authz: always grants "{{permission}}" this test is about the
71
- // CRUD handler, not re-proving RBAC (that's covered by the role
72
- // package's own tests), so the resolver doesn't need a real DB lookup.
80
+ // The handler unit test verifies route composition, not the role repository.
73
81
  authz := middleware.NewAuthz(func(_ context.Context, _ string) (map[string]struct{}, error) {
74
82
  return map[string]struct{}{"{{permission}}": {}}, nil
75
83
  }, time.Minute)
76
84
  {{/if}}
77
- NewHandler(NewService(NewRepository(tx)){{#if auth}}, testJWTSecret{{/if}}{{#if permission}}, authz{{/if}}).Register(r)
85
+ NewHandler(svc{{#if auth}}, testJWTSecret{{/if}}{{#if permission}}, authz{{/if}}).Register(r)
78
86
  return r
79
87
  }
80
88
 
81
89
  {{#if auth}}
82
90
  const testJWTSecret = "test-secret"
83
91
 
84
- // authHeader mints a valid access token signed with testJWTSecret — the
85
- // route's own RequireAuth(testJWTSecret) (wired in via NewHandler above)
86
- // verifies against the same secret, so this round-trips for real.
87
92
  func authHeader() string {
88
93
  claims := jwt.MapClaims{
89
94
  "typ": "access",
@@ -97,8 +102,8 @@ func authHeader() string {
97
102
  }
98
103
 
99
104
  {{/if}}
100
- func do(r *gin.Engine, method, path, body string) *httptest.ResponseRecorder {
101
- req := httptest.NewRequest(method, path, bytes.NewBufferString(body))
105
+ func doHandlerRequest(r *gin.Engine, method, requestPath, body string) *httptest.ResponseRecorder {
106
+ req := httptest.NewRequest(method, requestPath, bytes.NewBufferString(body))
102
107
  req.Header.Set("Content-Type", "application/json")
103
108
  {{#if auth}}
104
109
  req.Header.Set("Authorization", authHeader())
@@ -108,40 +113,61 @@ func do(r *gin.Engine, method, path, body string) *httptest.ResponseRecorder {
108
113
  return w
109
114
  }
110
115
 
116
+ // createBody is the JSON these tests POST. It starts empty because createInput
117
+ // starts empty — add a value here for every field you add to createInput in
118
+ // dto.go. Miss that and this test fails with a 400 the moment one of those
119
+ // fields is `binding:"required"`: the handler is fine, the fixture just no
120
+ // longer satisfies it.
121
+ const createBody = `{}`
122
+
111
123
  func TestHandler_Create_OK(t *testing.T) {
112
- r := setup(t)
113
- w := do(r, http.MethodPost, "/{{plural}}", `{}`)
124
+ svc := &serviceStub{
125
+ createFn: func(context.Context, createInput) (*model.{{pascalName}}, error) {
126
+ return &model.{{pascalName}}{ID: uuid.New()}, nil
127
+ },
128
+ }
129
+ r := setupHandlerTest(t, svc)
130
+
131
+ w := doHandlerRequest(r, http.MethodPost, "/{{plural}}", createBody)
132
+
114
133
  if w.Code != http.StatusCreated {
115
134
  t.Fatalf("want 201, got %d body=%s", w.Code, w.Body)
116
135
  }
117
136
  }
118
137
 
119
138
  func TestHandler_Get_NotFound(t *testing.T) {
120
- r := setup(t)
121
- w := do(r, http.MethodGet, "/{{plural}}/"+uuid.NewString(), "")
139
+ svc := &serviceStub{
140
+ getFn: func(context.Context, uuid.UUID) (*model.{{pascalName}}, error) {
141
+ return nil, errNotFound()
142
+ },
143
+ }
144
+ r := setupHandlerTest(t, svc)
145
+
146
+ w := doHandlerRequest(r, http.MethodGet, "/{{plural}}/"+uuid.NewString(), "")
147
+
122
148
  if w.Code != http.StatusNotFound {
123
149
  t.Fatalf("want 404, got %d body=%s", w.Code, w.Body)
124
150
  }
125
151
  }
126
152
 
127
- func TestHandler_Get_InvalidID(t *testing.T) {
128
- r := setup(t)
129
- w := do(r, http.MethodGet, "/{{plural}}/not-a-uuid", "")
153
+ func TestHandler_Get_InvalidID_DoesNotCallService(t *testing.T) {
154
+ r := setupHandlerTest(t, &serviceStub{})
155
+
156
+ w := doHandlerRequest(r, http.MethodGet, "/{{plural}}/not-a-uuid", "")
157
+
130
158
  if w.Code != http.StatusBadRequest {
131
159
  t.Fatalf("want 400, got %d body=%s", w.Code, w.Body)
132
160
  }
133
161
  }
134
162
 
135
163
  func TestHandler_Delete_OK(t *testing.T) {
136
- r := setup(t)
137
- created := do(r, http.MethodPost, "/{{plural}}", `{}`)
138
- var body struct {
139
- ID string `json:"id"`
164
+ svc := &serviceStub{
165
+ deleteFn: func(context.Context, uuid.UUID) error { return nil },
140
166
  }
141
- if err := json.Unmarshal(created.Body.Bytes(), &body); err != nil {
142
- t.Fatalf("decode create response: %v", err)
143
- }
144
- w := do(r, http.MethodDelete, "/{{plural}}/"+body.ID, "")
167
+ r := setupHandlerTest(t, svc)
168
+
169
+ w := doHandlerRequest(r, http.MethodDelete, "/{{plural}}/"+uuid.NewString(), "")
170
+
145
171
  if w.Code != http.StatusNoContent {
146
172
  t.Fatalf("want 204, got %d body=%s", w.Code, w.Body)
147
173
  }
@@ -1 +1,3 @@
1
- DROP TABLE IF EXISTS {{plural}};
1
+ DROP TABLE IF EXISTS {{schemaName}}.{{tableName}};
2
+ -- the schema itself is left in place: cheap to keep, and dropping it here
3
+ -- would be wrong the moment a second table (added later, by hand) shares it
@@ -1,5 +1,10 @@
1
- CREATE TABLE {{plural}} (
1
+ -- one schema per domain — see model.go's TableName for why
2
+ CREATE SCHEMA IF NOT EXISTS {{schemaName}};
3
+
4
+ CREATE TABLE {{schemaName}}.{{tableName}} (
2
5
  id UUID PRIMARY KEY,
3
6
  created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
4
- updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
7
+ updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
8
+ -- bumped on every update; an update that carries an older value is refused
9
+ version INTEGER NOT NULL DEFAULT 1
5
10
  );
@@ -16,11 +16,13 @@ import (
16
16
  type response struct {
17
17
  ID uuid.UUID `json:"id"`
18
18
  CreatedAt time.Time `json:"created_at"`
19
+ // clients send this back on an update so a concurrent save can be detected
20
+ Version int `json:"version"`
19
21
  }
20
22
 
21
23
  //nolint:unused
22
24
  func toResponse(m *model.{{pascalName}}) response {
23
- return response{ID: m.ID, CreatedAt: m.CreatedAt}
25
+ return response{ID: m.ID, CreatedAt: m.CreatedAt, Version: m.Version}
24
26
  }
25
27
 
26
28
  // go-scaffold:dto
@@ -8,9 +8,15 @@ import (
8
8
  "github.com/gin-gonic/gin"
9
9
  )
10
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
+
11
17
  // Handler = delivery for {{pkg}} (parses HTTP, calls the service, attaches errors for the middleware to render)
12
18
  type Handler struct {
13
- svc *Service
19
+ svc service
14
20
  {{#if auth}}
15
21
  jwtSecret string
16
22
  {{/if}}
@@ -19,7 +25,7 @@ type Handler struct {
19
25
  {{/if}}
20
26
  }
21
27
 
22
- func NewHandler(svc *Service{{#if auth}}, jwtSecret string{{/if}}{{#if permission}}, authz *middleware.Authz{{/if}}) *Handler {
28
+ func NewHandler(svc service{{#if auth}}, jwtSecret string{{/if}}{{#if permission}}, authz *middleware.Authz{{/if}}) *Handler {
23
29
  return &Handler{
24
30
  svc: svc,
25
31
  {{#if auth}}
@@ -1,117 +1,10 @@
1
1
  package {{pkg}}
2
2
 
3
- import (
4
- "bytes"
5
- {{#if permission}}
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
- func setup(t *testing.T) *gin.Engine {
67
- t.Helper()
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
- // 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 {
@@ -17,4 +17,20 @@ type {{pascalName}} struct {
17
17
  ID uuid.UUID `json:"id" gorm:"type:uuid;primaryKey"`
18
18
  CreatedAt time.Time `json:"created_at"`
19
19
  UpdatedAt time.Time `json:"updated_at"`
20
+ // Version guards against two clients that both loaded this row overwriting
21
+ // each other: an update only applies if the version it carries still
22
+ // matches the stored one. Bumped by Repository.Update, never by hand.
23
+ Version int `json:"version" gorm:"not null;default:1"`
24
+ }
25
+
26
+ // TableName pins the persistence contract to the versioned SQL migrations.
27
+ // Do not rely on GORM's English inflector: irregular/plural module names must
28
+ // resolve to exactly the same table in development and production.
29
+ //
30
+ // Schema-qualified ("{{schemaName}}", not just "{{tableName}}") so this
31
+ // domain's table can never collide with another domain's, and a raw SQL JOIN
32
+ // reaching into it from outside this package fails loudly instead of quietly
33
+ // coupling two domains together.
34
+ func ({{pascalName}}) TableName() string {
35
+ return "{{schemaName}}.{{tableName}}"
20
36
  }
@@ -1,4 +1,6 @@
1
1
  -- ON CONFLICT DO NOTHING: safe to re-generate a module reusing a permission
2
2
  -- code another module already inserted (e.g. a shared "orders:manage").
3
- INSERT INTO permissions (code, description) VALUES ('{{permission}}', 'Manage {{plural}}')
3
+ -- role_svc, not this module's own schema: `add rbac` owns the permissions
4
+ -- table, wherever it ends up living — see `add rbac`'s migration.
5
+ INSERT INTO role_svc.permissions (code, description) VALUES ('{{permission}}', 'Manage {{plural}}')
4
6
  ON CONFLICT (code) DO NOTHING;
@@ -2,15 +2,23 @@ package {{pkg}}
2
2
 
3
3
  import (
4
4
  "context"
5
+ "errors"
5
6
 
6
7
  "{{goModule}}/internal/app/{{modulePath}}/model"
8
+ "{{goModule}}/internal/shared/tx"
7
9
 
8
10
  "github.com/google/uuid"
9
11
  "gorm.io/gorm"
10
12
  )
11
13
 
14
+ // ErrStaleVersion means the row moved on since the caller read it — someone
15
+ // else saved first. Returned instead of overwriting their work.
16
+ var ErrStaleVersion = errors.New("stale version")
17
+
12
18
  // Repository = data access for {{pkg}} (the only place that touches the DB for this domain)
13
- // every method takes ctx, so a cancelled request cancels the query too
19
+ // every method takes ctx, so a cancelled request cancels the query too — and
20
+ // so tx.From can pick up a transaction opened by the caller, letting two
21
+ // repositories commit together without changing any signature here.
14
22
  type Repository struct {
15
23
  db *gorm.DB
16
24
  }
@@ -20,30 +28,76 @@ func NewRepository(db *gorm.DB) *Repository {
20
28
  }
21
29
 
22
30
  func (r *Repository) Create(ctx context.Context, m *model.{{pascalName}}) error {
23
- return r.db.WithContext(ctx).Create(m).Error
31
+ return tx.From(ctx, r.db).WithContext(ctx).Create(m).Error
24
32
  }
25
33
 
26
34
  func (r *Repository) FindAll(ctx context.Context, limit, offset int) ([]model.{{pascalName}}, error) {
27
35
  var items []model.{{pascalName}}
28
- err := r.db.WithContext(ctx).Order("id desc").Limit(limit).Offset(offset).Find(&items).Error
36
+ err := tx.From(ctx, r.db).WithContext(ctx).Order("id desc").Limit(limit).Offset(offset).Find(&items).Error
29
37
  return items, err
30
38
  }
31
39
 
32
40
  func (r *Repository) FindByID(ctx context.Context, id uuid.UUID) (*model.{{pascalName}}, error) {
33
41
  var m model.{{pascalName}}
34
42
  // "id = ?" explicit — PK is a UUID, not an int, this avoids GORM misinterpreting the arg
35
- if err := r.db.WithContext(ctx).First(&m, "id = ?", id).Error; err != nil {
43
+ if err := tx.From(ctx, r.db).WithContext(ctx).First(&m, "id = ?", id).Error; err != nil {
36
44
  return nil, err
37
45
  }
38
46
  return &m, nil
39
47
  }
40
48
 
49
+ // Update applies m only if the version it carries still matches the stored
50
+ // row, then bumps it. Two clients that both loaded version 3 can't silently
51
+ // overwrite each other: the second one gets ErrStaleVersion instead of the
52
+ // first one's changes disappearing with no error anywhere.
41
53
  func (r *Repository) Update(ctx context.Context, m *model.{{pascalName}}) error {
42
- return r.db.WithContext(ctx).Save(m).Error
54
+ expected := m.Version
55
+ m.Version = expected + 1
56
+
57
+ res := tx.From(ctx, r.db).WithContext(ctx).
58
+ Model(&model.{{pascalName}}{}).
59
+ Where("id = ? AND version = ?", m.ID, expected).
60
+ // Select("*") because Updates on a struct skips zero values, which
61
+ // would quietly ignore a field the caller meant to clear.
62
+ Select("*").Omit("id", "created_at").
63
+ Updates(m)
64
+ if res.Error != nil {
65
+ m.Version = expected
66
+ return res.Error
67
+ }
68
+ if res.RowsAffected == 0 {
69
+ m.Version = expected
70
+ return ErrStaleVersion
71
+ }
72
+ return nil
43
73
  }
44
74
 
75
+ // Delete reports gorm.ErrRecordNotFound when the row wasn't there, so a
76
+ // DELETE on an id that never existed answers 404 like GET does — a plain
77
+ // GORM delete affects zero rows and returns no error, which had the same
78
+ // request answer 204 as a real deletion.
45
79
  func (r *Repository) Delete(ctx context.Context, id uuid.UUID) error {
46
- return r.db.WithContext(ctx).Delete(&model.{{pascalName}}{}, "id = ?", id).Error
80
+ res := tx.From(ctx, r.db).WithContext(ctx).Delete(&model.{{pascalName}}{}, "id = ?", id)
81
+ if res.Error != nil {
82
+ return res.Error
83
+ }
84
+ if res.RowsAffected == 0 {
85
+ return gorm.ErrRecordNotFound
86
+ }
87
+ return nil
88
+ }
89
+
90
+ // Count is the total the list endpoint needs to say "page 3 of 12" — see
91
+ // pagination.ResponseWithTotal. Separate from FindAll because it costs a second
92
+ // query: an endpoint that only ever scrolls shouldn't pay for a count nobody
93
+ // reads.
94
+ //
95
+ // Apply the same WHERE clause here that FindAll uses once you add filtering,
96
+ // or the total will describe a different set than the rows.
97
+ func (r *Repository) Count(ctx context.Context) (int64, error) {
98
+ var total int64
99
+ err := tx.From(ctx, r.db).WithContext(ctx).Model(&model.{{pascalName}}{}).Count(&total).Error
100
+ return total, err
47
101
  }
48
102
 
49
103
  // go-scaffold:repository-methods