@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.
Files changed (38) hide show
  1. package/README.md +18 -14
  2. package/dist/commands/generate.js +2 -1
  3. package/dist/commands/method.js +63 -15
  4. package/dist/commands/remove.js +23 -24
  5. package/dist/index.js +5 -4
  6. package/dist/templates/module-manifest.js +2 -0
  7. package/dist/utils/method-patcher.js +80 -16
  8. package/dist/utils/module-location.js +22 -0
  9. package/dist/utils/naming.js +75 -12
  10. package/dist/utils/openapi-patcher.js +19 -1
  11. package/dist/utils/smoke-run.js +31 -0
  12. package/package.json +14 -5
  13. package/scripts/smoke-test.mjs +2058 -0
  14. package/templates/create/base/.github/workflows/ci.yml.hbs +18 -5
  15. package/templates/create/base/AGENTS.md.hbs +10 -12
  16. package/templates/create/base/Makefile.hbs +9 -3
  17. package/templates/create/base/README.md.hbs +18 -6
  18. package/templates/create/features/docs/openapi.yaml.hbs +4 -5
  19. package/templates/create/features/docs/patterns.md.hbs +9 -6
  20. package/templates/generate/module/docs/item.yaml.hbs +3 -3
  21. package/templates/generate/module/handler.go.hbs +18 -4
  22. package/templates/generate/module/handler_test.go.hbs +81 -62
  23. package/templates/generate/module/migration.down.sql.hbs +1 -1
  24. package/templates/generate/module/migration.up.sql.hbs +1 -1
  25. package/templates/generate/module/minimal/handler.go.hbs +8 -2
  26. package/templates/generate/module/minimal/handler_test.go.hbs +5 -112
  27. package/templates/generate/module/minimal/service_test.go.hbs +39 -16
  28. package/templates/generate/module/model/model.go.hbs +7 -0
  29. package/templates/generate/module/repository_test.go.hbs +79 -0
  30. package/templates/generate/module/service.go.hbs +6 -4
  31. package/templates/generate/module/service_test.go.hbs +68 -17
  32. package/tests/integration/default-module.test.mjs +46 -0
  33. package/tests/integration/generator-naming.test.mjs +81 -0
  34. package/tests/integration/generator-unit-test-seams.test.mjs +91 -0
  35. package/tests/integration/legacy-method-compat.test.mjs +222 -0
  36. package/tests/integration/remove-module.test.mjs +58 -0
  37. package/tests/unit/naming.test.mjs +94 -0
  38. package/tests/unit/smoke-isolation.test.mjs +35 -0
@@ -14,8 +14,8 @@ jobs:
14
14
  env:
15
15
  POSTGRES_USER: postgres
16
16
  POSTGRES_PASSWORD: postgres
17
- # the test database, not the app's CI only runs tests, and the
18
- # integration harness drops/recreates its tables on every run
17
+ # repository integration tests run against this isolated database after
18
+ # applying the same versioned SQL migrations used in production
19
19
  POSTGRES_DB: {{dbName}}_test
20
20
  ports:
21
21
  - 5432:5432
@@ -43,6 +43,19 @@ jobs:
43
43
  with:
44
44
  version: latest
45
45
 
46
- # TEST_DB_DSN not set: the default in handler_test.go already points at
47
- # localhost:5432/{{dbName}}_test with postgres/postgres, matching the service above
48
- - run: go test ./...
46
+ - name: Install migration runner
47
+ run: go install -tags 'postgres' github.com/golang-migrate/migrate/v4/cmd/migrate@v4.18.3
48
+
49
+ - name: Apply production migrations to the test database
50
+ env:
51
+ DB_DSN: postgres://postgres:postgres@localhost:5432/{{dbName}}_test?sslmode=disable
52
+ run: |
53
+ if compgen -G "migrations/*.up.sql" > /dev/null; then
54
+ migrate -path migrations -database "$DB_DSN" up
55
+ fi
56
+
57
+ - name: Test (required PostgreSQL integration tests cannot skip)
58
+ env:
59
+ TEST_DB_DSN: postgres://postgres:postgres@localhost:5432/{{dbName}}_test?sslmode=disable
60
+ REQUIRE_TEST_DB: "true"
61
+ run: go test ./...
@@ -45,21 +45,19 @@ hand-rolling anything that looks like scaffolding.
45
45
 
46
46
  ## Command quick reference
47
47
 
48
- - `go-scaffold generate module <name>` — model + dto + errors + repository +
49
- service + handler + tests, wired into `cmd/api/main.go` (AutoMigrate +
50
- route registration) and appended to `migrations/`. Use `--no-full` for a
51
- bare skeleton (no default CRUD/routes) when you'd rather add endpoints one
52
- at a time with `generate method`
48
+ - `go-scaffold generate module <name>` — safe minimal model + errors +
49
+ repository + service/handler plumbing, wired into `cmd/api/main.go` and
50
+ appended to `migrations/`. Add endpoints one at a time with `generate
51
+ method`, or pass `--full` to opt into a CRUD skeleton with TODO DTO fields
53
52
  - `go-scaffold generate method <module> <name> --type <get|post|put|patch|delete> [--get-mode all|one] [--field <name>]` —
54
53
  patches an *existing* module's handler + service (and repository, for a
55
54
  `get --get-mode one --field` lookup) in place; never overwrites a method
56
- with the same name — pick a different one if it collides. Endpoint docs in
57
- `docs/openapi.yaml` are **not** auto-updated for methods the command
58
- prints the route so you can add the spec entry by hand
59
- - `go-scaffold remove module <name>` — the inverse of `generate module`:
60
- deletes the package and un-wires main.go / openapi.yaml / migrations. Use
61
- this to drop a domain instead of hand-deleting the folder (a partial
62
- hand-delete leaves duplicate wiring behind)
55
+ with the same name — pick a different one if it collides. With OpenAPI
56
+ enabled it also writes a valid TODO path document and wires the index;
57
+ replace placeholder schemas while implementing the method
58
+ - `go-scaffold remove module <name>` — deletes the package and un-wires
59
+ main.go/OpenAPI while preserving immutable migrations and table data. Use
60
+ `generate migration drop_<table>` for an explicit reviewed data removal
63
61
  - Every route in this project is grouped under{{#if apiPrefix}} `/{{apiPrefix}}`{{else}} no prefix{{/if}}
64
62
  (set once at `create` time via `--api-prefix`) — there is no per-domain
65
63
  versioning; a breaking API change gets a new domain package or a new DTO
@@ -3,6 +3,7 @@ DB_PORT ?= 5432
3
3
  DB_USER ?= postgres
4
4
  DB_NAME ?= {{dbName}}
5
5
  PGPASSWORD ?= postgres
6
+ POSTGRES_CONTAINER ?=
6
7
 
7
8
  # which env file to load — defaults to .env (your local override, gitignored).
8
9
  # override to run against another file, e.g. copy .env.example to .env.production,
@@ -47,14 +48,17 @@ tidy:
47
48
  # connects to the always-present "postgres" maintenance DB to run CREATE DATABASE,
48
49
  # since the target DB may not exist yet. Safe to re-run — skips if it already exists.
49
50
  # \gexec only works read from stdin, not through -c, hence the pipe.
50
- # no local psql? falls back to `docker exec` into whatever container is publishing
51
- # DB_PORT the project's own `docker compose up`'d postgres, or an unrelated
52
- # shared Postgres container you already have running, either works the same way.
51
+ # `POSTGRES_CONTAINER` selects an exact Docker container when no host psql is
52
+ # installed; otherwise the fallback discovers a container publishing DB_PORT.
53
53
  # override DB_HOST/DB_PORT/DB_USER/DB_NAME/PGPASSWORD to point at a different server.
54
54
  db-create:
55
55
  @if command -v psql >/dev/null 2>&1; then \
56
56
  echo "SELECT 'CREATE DATABASE $(DB_NAME)' WHERE NOT EXISTS (SELECT FROM pg_database WHERE datname = '$(DB_NAME)')\gexec" | \
57
57
  PGPASSWORD=$(PGPASSWORD) psql -h $(DB_HOST) -p $(DB_PORT) -U $(DB_USER) -d postgres && echo "SUCCESS! database $(DB_NAME) is ready"; \
58
+ elif [ -n "$(POSTGRES_CONTAINER)" ]; then \
59
+ echo "no local psql — using docker exec into $(POSTGRES_CONTAINER)"; \
60
+ echo "SELECT 'CREATE DATABASE $(DB_NAME)' WHERE NOT EXISTS (SELECT FROM pg_database WHERE datname = '$(DB_NAME)')\gexec" | \
61
+ docker exec -i -e PGPASSWORD=$(PGPASSWORD) $(POSTGRES_CONTAINER) psql -U $(DB_USER) -d postgres && echo "SUCCESS! database $(DB_NAME) is ready"; \
58
62
  elif [ "$(DB_HOST)" = "localhost" ] || [ "$(DB_HOST)" = "127.0.0.1" ]; then \
59
63
  container=$$(docker ps -q --filter "publish=$(DB_PORT)" | head -1); \
60
64
  if [ -z "$$container" ]; then \
@@ -72,6 +76,8 @@ db-create:
72
76
  db-drop:
73
77
  @if command -v psql >/dev/null 2>&1; then \
74
78
  PGPASSWORD=$(PGPASSWORD) psql -h $(DB_HOST) -p $(DB_PORT) -U $(DB_USER) -d postgres -c "DROP DATABASE IF EXISTS $(DB_NAME);" && echo "SUCCESS! database $(DB_NAME) dropped"; \
79
+ elif [ -n "$(POSTGRES_CONTAINER)" ]; then \
80
+ docker exec -i -e PGPASSWORD=$(PGPASSWORD) $(POSTGRES_CONTAINER) psql -U $(DB_USER) -d postgres -c "DROP DATABASE IF EXISTS $(DB_NAME);" && echo "SUCCESS! database $(DB_NAME) dropped"; \
75
81
  elif [ "$(DB_HOST)" = "localhost" ] || [ "$(DB_HOST)" = "127.0.0.1" ]; then \
76
82
  container=$$(docker ps -q --filter "publish=$(DB_PORT)" | head -1); \
77
83
  if [ -z "$$container" ]; then \
@@ -2,12 +2,15 @@
2
2
 
3
3
  Gin + GORM backend, scaffolded by [go-scaffold](https://github.com/nakedev/go-scaffold). Organized by feature (domain), Postgres-backed, schema managed with [golang-migrate](https://github.com/golang-migrate/migrate).
4
4
 
5
- This is a bare skeleton no domain modules yet. Add one with:
5
+ This project starts as a bare skeleton. Add a safe minimal domain with:
6
6
 
7
7
  ```bash
8
8
  go-scaffold generate module orders
9
9
  ```
10
10
 
11
+ Add endpoints one at a time with `generate method`, or pass `--full` to opt
12
+ into a CRUD skeleton whose DTO fields and business rules remain explicit TODOs.
13
+
11
14
  See `docs/architect/` for the conventions every generated module follows, and `AGENTS.md`/`CLAUDE.md` if you're working with an AI coding agent in this repo.
12
15
 
13
16
  ## Layout
@@ -129,17 +132,23 @@ make migrate-verify # up -> down-to-zero -> up, catches a broken down.sql befo
129
132
 
130
133
  ## Tests
131
134
 
132
- Integration tests (handler-level) run against a **real Postgres** instance (same engine as prod), each test in a transaction that's rolled back — no leftover rows. If the DB isn't reachable those tests **skip** (unit tests using a fake repo always run).
135
+ Handler and service tests are fast unit tests: handlers depend on a narrow
136
+ service interface, and services use function-backed repository stubs. Neither
137
+ suite requires a database.
133
138
 
134
- They use a **separate `{{dbName}}_test` database**, not the one `DB_DSN` points at. That's deliberate: the harness runs `DropTable` + `AutoMigrate` on every real run, so pointing it at your dev database would wipe whatever `make migrate-up` built there — FK constraints and seed data included. Create it once:
139
+ Repository integration tests run against a **real Postgres** instance using
140
+ the same versioned SQL migrations as production, each test in a transaction
141
+ that's rolled back. Locally they skip when `TEST_DB_DSN` is unset; CI sets
142
+ `REQUIRE_TEST_DB=true`, so a missing or unmigrated database fails instead of
143
+ becoming a false-green skip. Use a separate `{{dbName}}_test` database:
135
144
 
136
145
  ```bash
137
146
  {{#if docker}}
138
147
  make docker-up # local postgres first
139
148
  {{/if}}
140
149
  make db-create DB_NAME={{dbName}}_test
141
- make test
142
- # point somewhere else entirely: TEST_DB_DSN=postgres://... go test ./...
150
+ DB_DSN=postgres://postgres:postgres@localhost:5432/{{dbName}}_test?sslmode=disable make migrate-up
151
+ TEST_DB_DSN=postgres://postgres:postgres@localhost:5432/{{dbName}}_test?sslmode=disable REQUIRE_TEST_DB=true go test ./...
143
152
  ```
144
153
 
145
154
  ## Error payload
@@ -170,4 +179,7 @@ Import `docs/openapi.bundled.yaml` instead. Tools that resolve `$ref` over HTTP
170
179
  go-scaffold generate module orders
171
180
  ```
172
181
 
173
- Scaffolds `internal/app/orders/` (model/dto/errors/repository/service/handler + tests), wires it into `cmd/api/main.go` (AutoMigrate, route registration), and appends a migration file. See `docs/architect/patterns.md` for the module shape and the rules for domains with foreign keys.
182
+ Scaffolds the safe minimal `internal/app/order/` module, wires its empty route
183
+ group/model into `cmd/api/main.go`, and appends a migration file. Add endpoints
184
+ with `generate method`; use `--full` only when a CRUD skeleton is intentional.
185
+ See `docs/architect/patterns.md` for the module shape and foreign-key rules.
@@ -1,11 +1,10 @@
1
1
  # ponytail: hand-written, not generated from annotations — cheap while the
2
- # endpoint count is low. Ceiling: must be updated by hand whenever an
3
- # endpoint/DTO changes beyond what `generate module` scaffolds.
2
+ # endpoint count is low. Generated module/method stubs keep the index wired,
3
+ # but request/response schemas still need to be completed with the code.
4
4
  # Switch to swaggo (comment-generated) if drift becomes a recurring problem.
5
5
  # multi-file: split by domain module (health/<domain>) + common/ for shared
6
- # pieces — this file is just the index. `generate module <name>`
7
- # adds its paths/schemas here automatically; `generate method` does not
8
- # (endpoint-specific docs stay hand-written).
6
+ # pieces — this file is just the index. `generate module` and `generate method`
7
+ # add path entries automatically.
9
8
  openapi: 3.0.3
10
9
  info:
11
10
  title: {{projectName}} API
@@ -17,8 +17,9 @@ internal/app/<domain>/
17
17
  ├── repository.go # the only place that touches the DB for this domain, every method takes ctx
18
18
  ├── service.go # business logic; declares the repository interface it needs (mockable in tests)
19
19
  ├── handler.go # HTTP: routing, bind, delegate, respond
20
- ├── service_test.go # unit test, fake repo, no DB
21
- └── handler_test.go # integration test, real Postgres, transaction rolled back per test
20
+ ├── service_test.go # unit test, function-backed repository stub, no DB
21
+ ├── handler_test.go # HTTP unit test, service stub, no DB
22
+ └── repository_test.go # Postgres integration test against migrated schema
22
23
  ```
23
24
 
24
25
  ### Model
@@ -137,10 +138,12 @@ placeholder fields.
137
138
  - Unit and integration tests live in the same directory as the code under
138
139
  test (Go convention) — never a separate `test/` folder. `test/` is only
139
140
  for e2e black-box suites or fixtures.
140
- - `service_test.go` uses a fake repository no DB required, always runs.
141
- - `handler_test.go` runs against a real Postgres instance inside a
142
- transaction that's rolled back after each test skips automatically if
143
- the DB isn't reachable.
141
+ - `service_test.go` uses a function-backed repository stub so each dependency
142
+ method has independent behavior and argument assertions.
143
+ - `handler_test.go` uses a service stubHTTP tests never need Postgres.
144
+ - `repository_test.go` runs against a migrated Postgres database inside a
145
+ transaction that's rolled back after each test. Local runs may skip when
146
+ `TEST_DB_DSN` is unset; CI sets `REQUIRE_TEST_DB=true` so it must run.
144
147
 
145
148
  ## Docs Maintenance
146
149
 
@@ -29,9 +29,9 @@ put:
29
29
  "400": { $ref: '../common/responses.yaml#/ValidationError' }
30
30
  "404": { $ref: '../common/responses.yaml#/NotFoundError' }
31
31
  delete:
32
- summary: Delete {{pkg}}
32
+ summary: Delete {{name}}
33
+ description: Idempotent — deleting an already-missing resource also returns 204.
33
34
  operationId: delete{{pascalName}}
34
35
  tags: [{{plural}}]
35
36
  responses:
36
- "204": { description: deleted }
37
- "404": { $ref: '../common/responses.yaml#/NotFoundError' }
37
+ "204": { description: deleted or already absent }
@@ -1,8 +1,10 @@
1
1
  package {{pkg}}
2
2
 
3
3
  import (
4
+ "context"
4
5
  "net/http"
5
6
 
7
+ "{{goModule}}/internal/app/{{modulePath}}/model"
6
8
  "{{goModule}}/internal/shared/httpx"
7
9
  {{#if auth}}
8
10
  "{{goModule}}/internal/shared/middleware"
@@ -10,11 +12,23 @@ import (
10
12
  "{{goModule}}/internal/shared/pagination"
11
13
 
12
14
  "github.com/gin-gonic/gin"
15
+ "github.com/google/uuid"
13
16
  )
14
17
 
18
+ // service is the narrow application API required by this HTTP adapter. Keeping
19
+ // the dependency as an interface makes handler tests fast and database-free.
20
+ type service interface {
21
+ Create(context.Context, createInput) (*model.{{pascalName}}, error)
22
+ List(context.Context, int, int) ([]model.{{pascalName}}, error)
23
+ Get(context.Context, uuid.UUID) (*model.{{pascalName}}, error)
24
+ Update(context.Context, uuid.UUID, updateInput) (*model.{{pascalName}}, error)
25
+ Delete(context.Context, uuid.UUID) error
26
+ // go-scaffold:service-interface
27
+ }
28
+
15
29
  // Handler = delivery for {{pkg}} (parses HTTP, calls the service, attaches errors for the middleware to render)
16
30
  type Handler struct {
17
- svc *Service
31
+ svc service
18
32
  {{#if auth}}
19
33
  jwtSecret string
20
34
  {{/if}}
@@ -23,7 +37,7 @@ type Handler struct {
23
37
  {{/if}}
24
38
  }
25
39
 
26
- func NewHandler(svc *Service{{#if auth}}, jwtSecret string{{/if}}{{#if permission}}, authz *middleware.Authz{{/if}}) *Handler {
40
+ func NewHandler(svc service{{#if auth}}, jwtSecret string{{/if}}{{#if permission}}, authz *middleware.Authz{{/if}}) *Handler {
27
41
  return &Handler{
28
42
  svc: svc,
29
43
  {{#if auth}}
@@ -52,7 +66,7 @@ func (h *Handler) create(c *gin.Context) {
52
66
  c.Error(httpx.BindErr(err))
53
67
  return
54
68
  }
55
- m, err := h.svc.Create(c.Request.Context())
69
+ m, err := h.svc.Create(c.Request.Context(), in)
56
70
  if err != nil {
57
71
  c.Error(err)
58
72
  return
@@ -97,7 +111,7 @@ func (h *Handler) update(c *gin.Context) {
97
111
  c.Error(httpx.BindErr(err))
98
112
  return
99
113
  }
100
- m, err := h.svc.Update(c.Request.Context(), id)
114
+ m, err := h.svc.Update(c.Request.Context(), id, in)
101
115
  if err != nil {
102
116
  c.Error(err)
103
117
  return
@@ -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")
57
47
  }
58
- return testDB
48
+ return s.listFn(ctx, limit, offset)
59
49
  }
60
50
 
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 {
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")
68
+ }
69
+ return s.deleteFn(ctx, id)
70
+ }
71
+
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())
@@ -109,39 +114,53 @@ func do(r *gin.Engine, method, path, body string) *httptest.ResponseRecorder {
109
114
  }
110
115
 
111
116
  func TestHandler_Create_OK(t *testing.T) {
112
- r := setup(t)
113
- w := do(r, http.MethodPost, "/{{plural}}", `{}`)
117
+ svc := &serviceStub{
118
+ createFn: func(context.Context, createInput) (*model.{{pascalName}}, error) {
119
+ return &model.{{pascalName}}{ID: uuid.New()}, nil
120
+ },
121
+ }
122
+ r := setupHandlerTest(t, svc)
123
+
124
+ w := doHandlerRequest(r, http.MethodPost, "/{{plural}}", `{}`)
125
+
114
126
  if w.Code != http.StatusCreated {
115
127
  t.Fatalf("want 201, got %d body=%s", w.Code, w.Body)
116
128
  }
117
129
  }
118
130
 
119
131
  func TestHandler_Get_NotFound(t *testing.T) {
120
- r := setup(t)
121
- w := do(r, http.MethodGet, "/{{plural}}/"+uuid.NewString(), "")
132
+ svc := &serviceStub{
133
+ getFn: func(context.Context, uuid.UUID) (*model.{{pascalName}}, error) {
134
+ return nil, errNotFound()
135
+ },
136
+ }
137
+ r := setupHandlerTest(t, svc)
138
+
139
+ w := doHandlerRequest(r, http.MethodGet, "/{{plural}}/"+uuid.NewString(), "")
140
+
122
141
  if w.Code != http.StatusNotFound {
123
142
  t.Fatalf("want 404, got %d body=%s", w.Code, w.Body)
124
143
  }
125
144
  }
126
145
 
127
- func TestHandler_Get_InvalidID(t *testing.T) {
128
- r := setup(t)
129
- w := do(r, http.MethodGet, "/{{plural}}/not-a-uuid", "")
146
+ func TestHandler_Get_InvalidID_DoesNotCallService(t *testing.T) {
147
+ r := setupHandlerTest(t, &serviceStub{})
148
+
149
+ w := doHandlerRequest(r, http.MethodGet, "/{{plural}}/not-a-uuid", "")
150
+
130
151
  if w.Code != http.StatusBadRequest {
131
152
  t.Fatalf("want 400, got %d body=%s", w.Code, w.Body)
132
153
  }
133
154
  }
134
155
 
135
156
  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"`
157
+ svc := &serviceStub{
158
+ deleteFn: func(context.Context, uuid.UUID) error { return nil },
140
159
  }
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, "")
160
+ r := setupHandlerTest(t, svc)
161
+
162
+ w := doHandlerRequest(r, http.MethodDelete, "/{{plural}}/"+uuid.NewString(), "")
163
+
145
164
  if w.Code != http.StatusNoContent {
146
165
  t.Fatalf("want 204, got %d body=%s", w.Code, w.Body)
147
166
  }
@@ -1 +1 @@
1
- DROP TABLE IF EXISTS {{plural}};
1
+ DROP TABLE IF EXISTS {{tableName}};
@@ -1,4 +1,4 @@
1
- CREATE TABLE {{plural}} (
1
+ CREATE TABLE {{tableName}} (
2
2
  id UUID PRIMARY KEY,
3
3
  created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
4
4
  updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
@@ -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}}