@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.
- package/README.md +93 -14
- package/dist/commands/auth.js +129 -0
- package/dist/commands/create.js +3 -2
- package/dist/commands/generate.js +61 -2
- package/dist/commands/method.js +66 -15
- package/dist/commands/migration.js +34 -0
- package/dist/commands/rbac.js +103 -0
- package/dist/commands/remove.js +36 -20
- package/dist/commands/worker.js +75 -0
- package/dist/index.js +71 -7
- package/dist/prompts/create-wizard.js +6 -1
- package/dist/prompts/generate-wizard.js +8 -0
- package/dist/templates/auth-manifest.js +19 -0
- package/dist/templates/create-manifest.js +25 -0
- package/dist/templates/module-manifest.js +2 -0
- package/dist/templates/rbac-manifest.js +17 -0
- package/dist/templates/worker-manifest.js +12 -0
- package/dist/utils/auth-patcher.js +96 -0
- package/dist/utils/gocheck.js +65 -0
- package/dist/utils/main-patcher.js +8 -1
- package/dist/utils/method-patcher.js +80 -16
- package/dist/utils/migrations.js +30 -8
- package/dist/utils/module-location.js +22 -0
- package/dist/utils/naming.js +75 -12
- package/dist/utils/openapi-patcher.js +35 -1
- package/dist/utils/platform-patcher.js +59 -0
- package/dist/utils/rbac-patcher.js +277 -0
- package/dist/utils/smoke-run.js +31 -0
- package/dist/utils/version.js +24 -0
- package/package.json +15 -6
- package/scripts/smoke-test.mjs +2058 -0
- package/templates/add/auth/cmd/seed/main.go.hbs +76 -0
- package/templates/add/auth/docs/forgot-password.yaml.hbs +19 -0
- package/templates/add/auth/docs/google-callback.yaml.hbs +22 -0
- package/templates/add/auth/docs/google-login.yaml.hbs +7 -0
- package/templates/add/auth/docs/login.yaml.hbs +19 -0
- package/templates/add/auth/docs/logout.yaml.hbs +8 -0
- package/templates/add/auth/docs/refresh.yaml.hbs +15 -0
- package/templates/add/auth/docs/register.yaml.hbs +19 -0
- package/templates/add/auth/docs/reset-password.yaml.hbs +16 -0
- package/templates/add/auth/docs/schemas.yaml.hbs +58 -0
- package/templates/add/auth/docs/users-me-logout-all.yaml.hbs +9 -0
- package/templates/add/auth/docs/users-me-resend-verification.yaml.hbs +10 -0
- package/templates/add/auth/docs/users-me.yaml.hbs +12 -0
- package/templates/add/auth/docs/verify-email.yaml.hbs +16 -0
- package/templates/add/auth/internal/app/user/dto.go.hbs +77 -0
- package/templates/add/auth/internal/app/user/errors.go.hbs +36 -0
- package/templates/add/auth/internal/app/user/handler.go.hbs +235 -0
- package/templates/add/auth/internal/app/user/jwt.go.hbs +84 -0
- package/templates/add/auth/internal/app/user/model/identity.go.hbs +28 -0
- package/templates/add/auth/internal/app/user/model/user.go.hbs +22 -0
- package/templates/add/auth/internal/app/user/repository.go.hbs +84 -0
- package/templates/add/auth/internal/app/user/service.go.hbs +447 -0
- package/templates/add/auth/internal/app/user/service_test.go.hbs +237 -0
- package/templates/add/auth/internal/app/user/tokenstore.go.hbs +159 -0
- package/templates/add/auth/internal/shared/middleware/auth.go.hbs +64 -0
- package/templates/add/auth/internal/shared/middleware/ratelimit.go.hbs +44 -0
- package/templates/add/auth/migrations/create_identities.down.sql.hbs +1 -0
- package/templates/add/auth/migrations/create_identities.up.sql.hbs +11 -0
- package/templates/add/auth/migrations/create_users.down.sql.hbs +1 -0
- package/templates/add/auth/migrations/create_users.up.sql.hbs +9 -0
- package/templates/add/rbac/docs/permissions.yaml.hbs +24 -0
- package/templates/add/rbac/docs/role-permissions.yaml.hbs +31 -0
- package/templates/add/rbac/docs/role.yaml.hbs +17 -0
- package/templates/add/rbac/docs/roles.yaml.hbs +43 -0
- package/templates/add/rbac/docs/schemas.yaml.hbs +37 -0
- package/templates/add/rbac/docs/user-set-role.yaml.hbs +23 -0
- package/templates/add/rbac/docs/user.yaml.hbs +16 -0
- package/templates/add/rbac/docs/users.yaml.hbs +23 -0
- package/templates/add/rbac/internal/app/role/dto.go.hbs +40 -0
- package/templates/add/rbac/internal/app/role/errors.go.hbs +39 -0
- package/templates/add/rbac/internal/app/role/handler.go.hbs +101 -0
- package/templates/add/rbac/internal/app/role/model/permission.go.hbs +9 -0
- package/templates/add/rbac/internal/app/role/model/role.go.hbs +18 -0
- package/templates/add/rbac/internal/app/role/model/role_permission.go.hbs +8 -0
- package/templates/add/rbac/internal/app/role/repository.go.hbs +96 -0
- package/templates/add/rbac/internal/app/role/service.go.hbs +210 -0
- package/templates/add/rbac/internal/app/role/service_test.go.hbs +119 -0
- package/templates/add/rbac/internal/shared/middleware/authz.go.hbs +88 -0
- package/templates/add/rbac/internal/shared/middleware/authz_test.go.hbs +88 -0
- package/templates/add/rbac/migrations/add_roles.down.sql.hbs +15 -0
- package/templates/add/rbac/migrations/add_roles.up.sql.hbs +35 -0
- package/templates/add/worker/cmd/worker/main.go.hbs +77 -0
- package/templates/add/worker/internal/platform/cache/redis.go.hbs +18 -0
- package/templates/add/worker/internal/platform/mail/mail.go.hbs +48 -0
- package/templates/add/worker/internal/platform/mail/task.go.hbs +52 -0
- package/templates/add/worker/internal/platform/queue/client.go.hbs +31 -0
- package/templates/add/worker/internal/platform/queue/server.go.hbs +68 -0
- package/templates/create/base/.env.example.hbs +20 -0
- package/templates/create/base/.github/workflows/ci.yml.hbs +19 -4
- package/templates/create/base/.gitignore.hbs +2 -0
- package/templates/create/base/AGENTS.md.hbs +10 -12
- package/templates/create/base/Makefile.hbs +39 -8
- package/templates/create/base/README.md.hbs +52 -12
- package/templates/create/base/cmd/api/main.go.hbs +26 -1
- package/templates/create/base/internal/platform/database/database.go.hbs +53 -0
- package/templates/create/base/internal/shared/config/config.go.hbs +43 -1
- package/templates/create/base/internal/shared/middleware/cors.go.hbs +29 -0
- package/templates/create/base/internal/shared/middleware/error.go.hbs +13 -1
- package/templates/create/base/migrations/embed.go.hbs +15 -0
- package/templates/create/features/docs/architecture.md.hbs +22 -0
- package/templates/create/features/docs/common/responses.yaml.hbs +15 -0
- package/templates/create/features/docs/observability/metrics.yaml.hbs +12 -0
- package/templates/create/features/docs/openapi.yaml.hbs +17 -5
- package/templates/create/features/docs/patterns.md.hbs +9 -6
- package/templates/create/features/docs/techstack.md.hbs +3 -0
- package/templates/create/features/observability/middleware/metrics.go.hbs +41 -0
- package/templates/create/features/observability/middleware/tracing.go.hbs +46 -0
- package/templates/create/features/observability/platform/telemetry/tracing.go.hbs +130 -0
- package/templates/generate/module/docs/item.yaml.hbs +3 -3
- package/templates/generate/module/handler.go.hbs +37 -6
- package/templates/generate/module/handler_test.go.hbs +113 -51
- package/templates/generate/module/migration.down.sql.hbs +1 -1
- package/templates/generate/module/migration.up.sql.hbs +1 -1
- package/templates/generate/module/minimal/handler.go.hbs +28 -4
- package/templates/generate/module/minimal/handler_test.go.hbs +5 -65
- package/templates/generate/module/minimal/service_test.go.hbs +39 -16
- package/templates/generate/module/model/model.go.hbs +7 -0
- package/templates/generate/module/permission.down.sql.hbs +5 -0
- package/templates/generate/module/permission.up.sql.hbs +4 -0
- package/templates/generate/module/repository_test.go.hbs +79 -0
- package/templates/generate/module/service.go.hbs +6 -4
- package/templates/generate/module/service_test.go.hbs +68 -17
- package/tests/integration/default-module.test.mjs +46 -0
- package/tests/integration/generator-naming.test.mjs +81 -0
- package/tests/integration/generator-unit-test-seams.test.mjs +91 -0
- package/tests/integration/legacy-method-compat.test.mjs +222 -0
- package/tests/integration/remove-module.test.mjs +58 -0
- package/tests/unit/naming.test.mjs +94 -0
- package/tests/unit/smoke-isolation.test.mjs +35 -0
- 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
|
|
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 *
|
|
13
|
-
return &Handler{
|
|
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
|
-
|
|
4
|
-
|
|
5
|
-
|
|
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
|
-
|
|
53
|
-
|
|
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
|
-
//
|
|
15
|
-
//
|
|
16
|
-
//
|
|
17
|
-
// for a `get --get-mode one --field` lookup)
|
|
14
|
+
// repositoryStub is ready for focused use-case tests added alongside generated
|
|
15
|
+
// methods. Every unset dependency panics so unexpected orchestration is visible.
|
|
16
|
+
//
|
|
18
17
|
//nolint:unused
|
|
19
|
-
type
|
|
20
|
-
|
|
21
|
-
|
|
18
|
+
type repositoryStub struct {
|
|
19
|
+
createFn func(context.Context, *model.{{pascalName}}) error
|
|
20
|
+
findAllFn func(context.Context, int, int) ([]model.{{pascalName}}, error)
|
|
21
|
+
findByIDFn func(context.Context, uuid.UUID) (*model.{{pascalName}}, error)
|
|
22
|
+
updateFn func(context.Context, *model.{{pascalName}}) error
|
|
23
|
+
deleteFn func(context.Context, uuid.UUID) error
|
|
24
|
+
// go-scaffold:repository-stub-fields
|
|
22
25
|
}
|
|
23
26
|
|
|
24
27
|
//nolint:unused
|
|
25
|
-
func (
|
|
28
|
+
func (s *repositoryStub) Create(ctx context.Context, m *model.{{pascalName}}) error {
|
|
29
|
+
if s.createFn == nil {
|
|
30
|
+
panic("unexpected repository.Create call")
|
|
31
|
+
}
|
|
32
|
+
return s.createFn(ctx, m)
|
|
33
|
+
}
|
|
26
34
|
|
|
27
35
|
//nolint:unused
|
|
28
|
-
func (
|
|
36
|
+
func (s *repositoryStub) FindAll(ctx context.Context, limit, offset int) ([]model.{{pascalName}}, error) {
|
|
37
|
+
if s.findAllFn == nil {
|
|
38
|
+
panic("unexpected repository.FindAll call")
|
|
39
|
+
}
|
|
40
|
+
return s.findAllFn(ctx, limit, offset)
|
|
41
|
+
}
|
|
29
42
|
|
|
30
43
|
//nolint:unused
|
|
31
|
-
func (
|
|
32
|
-
if
|
|
33
|
-
|
|
44
|
+
func (s *repositoryStub) FindByID(ctx context.Context, id uuid.UUID) (*model.{{pascalName}}, error) {
|
|
45
|
+
if s.findByIDFn == nil {
|
|
46
|
+
panic("unexpected repository.FindByID call")
|
|
34
47
|
}
|
|
35
|
-
return
|
|
48
|
+
return s.findByIDFn(ctx, id)
|
|
36
49
|
}
|
|
37
50
|
|
|
38
51
|
//nolint:unused
|
|
39
|
-
func (
|
|
52
|
+
func (s *repositoryStub) Update(ctx context.Context, m *model.{{pascalName}}) error {
|
|
53
|
+
if s.updateFn == nil {
|
|
54
|
+
panic("unexpected repository.Update call")
|
|
55
|
+
}
|
|
56
|
+
return s.updateFn(ctx, m)
|
|
57
|
+
}
|
|
40
58
|
|
|
41
59
|
//nolint:unused
|
|
42
|
-
func (
|
|
60
|
+
func (s *repositoryStub) Delete(ctx context.Context, id uuid.UUID) error {
|
|
61
|
+
if s.deleteFn == nil {
|
|
62
|
+
panic("unexpected repository.Delete call")
|
|
63
|
+
}
|
|
64
|
+
return s.deleteFn(ctx, id)
|
|
65
|
+
}
|
|
43
66
|
|
|
44
|
-
// go-scaffold:
|
|
67
|
+
// go-scaffold:repository-stub-methods
|
|
45
68
|
|
|
46
69
|
//nolint:unused
|
|
47
70
|
func status(t *testing.T, err error) int {
|
|
@@ -18,3 +18,10 @@ type {{pascalName}} struct {
|
|
|
18
18
|
CreatedAt time.Time `json:"created_at"`
|
|
19
19
|
UpdatedAt time.Time `json:"updated_at"`
|
|
20
20
|
}
|
|
21
|
+
|
|
22
|
+
// TableName pins the persistence contract to the versioned SQL migrations.
|
|
23
|
+
// Do not rely on GORM's English inflector: irregular/plural module names must
|
|
24
|
+
// resolve to exactly the same table in development and production.
|
|
25
|
+
func ({{pascalName}}) TableName() string {
|
|
26
|
+
return "{{tableName}}"
|
|
27
|
+
}
|
|
@@ -0,0 +1,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:
|
|
36
|
+
func (s *Service) Create(ctx context.Context, in createInput) (*model.{{pascalName}}, error) {
|
|
37
|
+
// TODO: set real fields from in
|
|
38
|
+
_ = in
|
|
38
39
|
m := &model.{{pascalName}}{ID: id.New()}
|
|
39
40
|
if err := s.repo.Create(ctx, m); err != nil {
|
|
40
41
|
if dberr.IsDuplicate(err) {
|
|
@@ -61,12 +62,13 @@ func (s *Service) Get(ctx context.Context, id uuid.UUID) (*model.{{pascalName}},
|
|
|
61
62
|
return m, nil
|
|
62
63
|
}
|
|
63
64
|
|
|
64
|
-
func (s *Service) Update(ctx context.Context, id uuid.UUID) (*model.{{pascalName}}, error) {
|
|
65
|
+
func (s *Service) Update(ctx context.Context, id uuid.UUID, in updateInput) (*model.{{pascalName}}, error) {
|
|
65
66
|
m, err := s.repo.FindByID(ctx, id)
|
|
66
67
|
if err != nil {
|
|
67
68
|
return nil, wrapFindErr(err)
|
|
68
69
|
}
|
|
69
|
-
// TODO: apply real fields from
|
|
70
|
+
// TODO: apply real fields from in before saving
|
|
71
|
+
_ = in
|
|
70
72
|
if err := s.repo.Update(ctx, m); err != nil {
|
|
71
73
|
if dberr.IsDuplicate(err) {
|
|
72
74
|
return nil, errConflict()
|
|
@@ -12,24 +12,54 @@ import (
|
|
|
12
12
|
"gorm.io/gorm"
|
|
13
13
|
)
|
|
14
14
|
|
|
15
|
-
//
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
15
|
+
// repositoryStub exposes one function per dependency operation. Tests configure
|
|
16
|
+
// only the calls they expect; an unexpected call panics instead of silently
|
|
17
|
+
// returning a shared zero value/error that can hide broken orchestration.
|
|
18
|
+
type repositoryStub struct {
|
|
19
|
+
createFn func(context.Context, *model.{{pascalName}}) error
|
|
20
|
+
findAllFn func(context.Context, int, int) ([]model.{{pascalName}}, error)
|
|
21
|
+
findByIDFn func(context.Context, uuid.UUID) (*model.{{pascalName}}, error)
|
|
22
|
+
updateFn func(context.Context, *model.{{pascalName}}) error
|
|
23
|
+
deleteFn func(context.Context, uuid.UUID) error
|
|
24
|
+
// go-scaffold:repository-stub-fields
|
|
19
25
|
}
|
|
20
26
|
|
|
21
|
-
func (
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
if f.err != nil {
|
|
25
|
-
return nil, f.err
|
|
27
|
+
func (s *repositoryStub) Create(ctx context.Context, m *model.{{pascalName}}) error {
|
|
28
|
+
if s.createFn == nil {
|
|
29
|
+
panic("unexpected repository.Create call")
|
|
26
30
|
}
|
|
27
|
-
return
|
|
31
|
+
return s.createFn(ctx, m)
|
|
28
32
|
}
|
|
29
|
-
func (f *fakeRepo) Update(context.Context, *model.{{pascalName}}) error { return f.err }
|
|
30
|
-
func (f *fakeRepo) Delete(context.Context, uuid.UUID) error { return f.err }
|
|
31
33
|
|
|
32
|
-
|
|
34
|
+
func (s *repositoryStub) FindAll(ctx context.Context, limit, offset int) ([]model.{{pascalName}}, error) {
|
|
35
|
+
if s.findAllFn == nil {
|
|
36
|
+
panic("unexpected repository.FindAll call")
|
|
37
|
+
}
|
|
38
|
+
return s.findAllFn(ctx, limit, offset)
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
func (s *repositoryStub) FindByID(ctx context.Context, id uuid.UUID) (*model.{{pascalName}}, error) {
|
|
42
|
+
if s.findByIDFn == nil {
|
|
43
|
+
panic("unexpected repository.FindByID call")
|
|
44
|
+
}
|
|
45
|
+
return s.findByIDFn(ctx, id)
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
func (s *repositoryStub) Update(ctx context.Context, m *model.{{pascalName}}) error {
|
|
49
|
+
if s.updateFn == nil {
|
|
50
|
+
panic("unexpected repository.Update call")
|
|
51
|
+
}
|
|
52
|
+
return s.updateFn(ctx, m)
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
func (s *repositoryStub) Delete(ctx context.Context, id uuid.UUID) error {
|
|
56
|
+
if s.deleteFn == nil {
|
|
57
|
+
panic("unexpected repository.Delete call")
|
|
58
|
+
}
|
|
59
|
+
return s.deleteFn(ctx, id)
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// go-scaffold:repository-stub-methods
|
|
33
63
|
|
|
34
64
|
func status(t *testing.T, err error) int {
|
|
35
65
|
t.Helper()
|
|
@@ -41,24 +71,45 @@ func status(t *testing.T, err error) int {
|
|
|
41
71
|
}
|
|
42
72
|
|
|
43
73
|
func TestService_Create_Duplicate(t *testing.T) {
|
|
44
|
-
|
|
45
|
-
|
|
74
|
+
repo := &repositoryStub{
|
|
75
|
+
createFn: func(context.Context, *model.{{pascalName}}) error {
|
|
76
|
+
return gorm.ErrDuplicatedKey
|
|
77
|
+
},
|
|
78
|
+
}
|
|
79
|
+
svc := NewService(repo)
|
|
80
|
+
|
|
81
|
+
_, err := svc.Create(context.Background(), createInput{})
|
|
82
|
+
|
|
46
83
|
if got := status(t, err); got != 409 {
|
|
47
84
|
t.Fatalf("want 409 conflict, got %d", got)
|
|
48
85
|
}
|
|
49
86
|
}
|
|
50
87
|
|
|
51
88
|
func TestService_Get_NotFound(t *testing.T) {
|
|
52
|
-
|
|
89
|
+
repo := &repositoryStub{
|
|
90
|
+
findByIDFn: func(context.Context, uuid.UUID) (*model.{{pascalName}}, error) {
|
|
91
|
+
return nil, gorm.ErrRecordNotFound
|
|
92
|
+
},
|
|
93
|
+
}
|
|
94
|
+
svc := NewService(repo)
|
|
95
|
+
|
|
53
96
|
_, err := svc.Get(context.Background(), uuid.New())
|
|
97
|
+
|
|
54
98
|
if got := status(t, err); got != 404 {
|
|
55
99
|
t.Fatalf("want 404, got %d", got)
|
|
56
100
|
}
|
|
57
101
|
}
|
|
58
102
|
|
|
59
103
|
func TestService_List_DBError_Becomes500(t *testing.T) {
|
|
60
|
-
|
|
104
|
+
repo := &repositoryStub{
|
|
105
|
+
findAllFn: func(context.Context, int, int) ([]model.{{pascalName}}, error) {
|
|
106
|
+
return nil, errors.New("connection refused")
|
|
107
|
+
},
|
|
108
|
+
}
|
|
109
|
+
svc := NewService(repo)
|
|
110
|
+
|
|
61
111
|
_, err := svc.List(context.Background(), 20, 0)
|
|
112
|
+
|
|
62
113
|
if got := status(t, err); got != 500 {
|
|
63
114
|
t.Fatalf("want 500, got %d", got)
|
|
64
115
|
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { execFileSync } from "node:child_process";
|
|
3
|
+
import { existsSync, mkdtempSync, readFileSync, rmSync } from "node:fs";
|
|
4
|
+
import { tmpdir } from "node:os";
|
|
5
|
+
import path from "node:path";
|
|
6
|
+
import test from "node:test";
|
|
7
|
+
import { fileURLToPath } from "node:url";
|
|
8
|
+
|
|
9
|
+
const ROOT = path.dirname(path.dirname(path.dirname(fileURLToPath(import.meta.url))));
|
|
10
|
+
const CLI = path.join(ROOT, "bin", "go-scaffold.js");
|
|
11
|
+
|
|
12
|
+
function runCLI(cwd, ...args) {
|
|
13
|
+
return execFileSync("node", [CLI, ...args], {
|
|
14
|
+
cwd,
|
|
15
|
+
encoding: "utf8",
|
|
16
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
17
|
+
});
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
test("generate module defaults to a safe minimal module", () => {
|
|
21
|
+
const scratch = mkdtempSync(path.join(tmpdir(), "go-scaffold-default-module-"));
|
|
22
|
+
try {
|
|
23
|
+
runCLI(scratch, "create", "sample", "--defaults", "--no-docker");
|
|
24
|
+
const project = path.join(scratch, "sample");
|
|
25
|
+
|
|
26
|
+
const output = runCLI(project, "generate", "module", "orders");
|
|
27
|
+
const handler = readFileSync(
|
|
28
|
+
path.join(project, "internal", "app", "order", "handler.go"),
|
|
29
|
+
"utf8"
|
|
30
|
+
);
|
|
31
|
+
|
|
32
|
+
assert.match(output, /registered empty route group/);
|
|
33
|
+
assert.doesNotMatch(handler, /g\.POST\(/);
|
|
34
|
+
assert.equal(existsSync(path.join(project, "docs", "orders")), false);
|
|
35
|
+
|
|
36
|
+
const legacyOutput = runCLI(project, "generate", "module", "widgets", "--no-full");
|
|
37
|
+
const legacyHandler = readFileSync(
|
|
38
|
+
path.join(project, "internal", "app", "widget", "handler.go"),
|
|
39
|
+
"utf8"
|
|
40
|
+
);
|
|
41
|
+
assert.match(legacyOutput, /registered empty route group/);
|
|
42
|
+
assert.doesNotMatch(legacyHandler, /g\.POST\(/);
|
|
43
|
+
} finally {
|
|
44
|
+
rmSync(scratch, { recursive: true, force: true });
|
|
45
|
+
}
|
|
46
|
+
});
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { execFileSync } from "node:child_process";
|
|
3
|
+
import {
|
|
4
|
+
mkdtempSync,
|
|
5
|
+
readFileSync,
|
|
6
|
+
readdirSync,
|
|
7
|
+
rmSync,
|
|
8
|
+
} from "node:fs";
|
|
9
|
+
import { tmpdir } from "node:os";
|
|
10
|
+
import path from "node:path";
|
|
11
|
+
import test from "node:test";
|
|
12
|
+
import { fileURLToPath } from "node:url";
|
|
13
|
+
|
|
14
|
+
const ROOT = path.dirname(path.dirname(path.dirname(fileURLToPath(import.meta.url))));
|
|
15
|
+
const CLI = path.join(ROOT, "bin", "go-scaffold.js");
|
|
16
|
+
|
|
17
|
+
function runCLI(cwd, ...args) {
|
|
18
|
+
return execFileSync("node", [CLI, ...args], {
|
|
19
|
+
cwd,
|
|
20
|
+
encoding: "utf8",
|
|
21
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
22
|
+
});
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function read(root, relativePath) {
|
|
26
|
+
return readFileSync(path.join(root, relativePath), "utf8");
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function migration(root, suffix) {
|
|
30
|
+
const filename = readdirSync(path.join(root, "migrations")).find((entry) =>
|
|
31
|
+
entry.endsWith(suffix)
|
|
32
|
+
);
|
|
33
|
+
assert.ok(filename, `missing migration ending with ${suffix}`);
|
|
34
|
+
return read(root, path.join("migrations", filename));
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
test("plural module input produces a singular entity with one explicit table name", () => {
|
|
38
|
+
const scratch = mkdtempSync(path.join(tmpdir(), "go-scaffold-naming-"));
|
|
39
|
+
try {
|
|
40
|
+
runCLI(scratch, "create", "sample", "--defaults", "--no-docker");
|
|
41
|
+
const project = path.join(scratch, "sample");
|
|
42
|
+
runCLI(project, "generate", "module", "orders");
|
|
43
|
+
|
|
44
|
+
const model = read(project, "internal/app/order/model/model.go");
|
|
45
|
+
assert.match(model, /type Order struct/);
|
|
46
|
+
assert.match(
|
|
47
|
+
model,
|
|
48
|
+
/func \(Order\) TableName\(\) string \{\s*return "orders"\s*\}/
|
|
49
|
+
);
|
|
50
|
+
assert.match(migration(project, "_create_orders.up.sql"), /CREATE TABLE orders/);
|
|
51
|
+
assert.match(read(project, "internal/app/order/handler.go"), /Group\("\/orders"/);
|
|
52
|
+
} finally {
|
|
53
|
+
rmSync(scratch, { recursive: true, force: true });
|
|
54
|
+
}
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
test("multi-word module keeps URL words and uses snake_case SQL identifiers", () => {
|
|
58
|
+
const scratch = mkdtempSync(path.join(tmpdir(), "go-scaffold-multiword-"));
|
|
59
|
+
try {
|
|
60
|
+
runCLI(scratch, "create", "sample", "--defaults", "--no-docker");
|
|
61
|
+
const project = path.join(scratch, "sample");
|
|
62
|
+
runCLI(project, "generate", "module", "order-items");
|
|
63
|
+
|
|
64
|
+
const model = read(project, "internal/app/orderitem/model/model.go");
|
|
65
|
+
assert.match(model, /type OrderItem struct/);
|
|
66
|
+
assert.match(
|
|
67
|
+
model,
|
|
68
|
+
/func \(OrderItem\) TableName\(\) string \{\s*return "order_items"\s*\}/
|
|
69
|
+
);
|
|
70
|
+
assert.match(
|
|
71
|
+
migration(project, "_create_order-items.up.sql"),
|
|
72
|
+
/CREATE TABLE order_items/
|
|
73
|
+
);
|
|
74
|
+
assert.match(
|
|
75
|
+
read(project, "internal/app/orderitem/handler.go"),
|
|
76
|
+
/Group\("\/order-items"/
|
|
77
|
+
);
|
|
78
|
+
} finally {
|
|
79
|
+
rmSync(scratch, { recursive: true, force: true });
|
|
80
|
+
}
|
|
81
|
+
});
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { execFileSync } from "node:child_process";
|
|
3
|
+
import { mkdtempSync, readFileSync, rmSync } from "node:fs";
|
|
4
|
+
import { tmpdir } from "node:os";
|
|
5
|
+
import path from "node:path";
|
|
6
|
+
import test from "node:test";
|
|
7
|
+
import { fileURLToPath } from "node:url";
|
|
8
|
+
|
|
9
|
+
const ROOT = path.dirname(path.dirname(path.dirname(fileURLToPath(import.meta.url))));
|
|
10
|
+
const CLI = path.join(ROOT, "bin", "go-scaffold.js");
|
|
11
|
+
|
|
12
|
+
function run(command, args, cwd) {
|
|
13
|
+
return execFileSync(command, args, {
|
|
14
|
+
cwd,
|
|
15
|
+
encoding: "utf8",
|
|
16
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
17
|
+
});
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function read(project, relativePath) {
|
|
21
|
+
return readFileSync(path.join(project, relativePath), "utf8");
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
test("generated modules have scalable service and handler unit-test seams", () => {
|
|
25
|
+
const scratch = mkdtempSync(path.join(tmpdir(), "go-scaffold-unit-seams-"));
|
|
26
|
+
try {
|
|
27
|
+
run("node", [CLI, "create", "sample", "--defaults", "--no-docker"], scratch);
|
|
28
|
+
const project = path.join(scratch, "sample");
|
|
29
|
+
run("node", [CLI, "generate", "module", "orders", "--full"], project);
|
|
30
|
+
|
|
31
|
+
const handler = read(project, "internal/app/order/handler.go");
|
|
32
|
+
assert.match(handler, /type service interface \{/);
|
|
33
|
+
assert.match(handler, /svc service/);
|
|
34
|
+
assert.doesNotMatch(handler, /svc \*Service/);
|
|
35
|
+
assert.match(handler, /Create\(context\.Context, createInput\)/);
|
|
36
|
+
assert.match(handler, /Update\(context\.Context, uuid\.UUID, updateInput\)/);
|
|
37
|
+
|
|
38
|
+
const service = read(project, "internal/app/order/service.go");
|
|
39
|
+
assert.match(service, /Create\(ctx context\.Context, in createInput\)/);
|
|
40
|
+
assert.match(service, /Update\(ctx context\.Context, id uuid\.UUID, in updateInput\)/);
|
|
41
|
+
|
|
42
|
+
const serviceTest = read(project, "internal/app/order/service_test.go");
|
|
43
|
+
assert.match(serviceTest, /type repositoryStub struct/);
|
|
44
|
+
assert.match(serviceTest, /createFn\s+func/);
|
|
45
|
+
assert.match(serviceTest, /findByIDFn\s+func/);
|
|
46
|
+
assert.match(serviceTest, /updateFn\s+func/);
|
|
47
|
+
assert.doesNotMatch(serviceTest, /type fakeRepo struct/);
|
|
48
|
+
|
|
49
|
+
const handlerTest = read(project, "internal/app/order/handler_test.go");
|
|
50
|
+
assert.match(handlerTest, /type serviceStub struct/);
|
|
51
|
+
assert.match(handlerTest, /createFn\s+func/);
|
|
52
|
+
assert.doesNotMatch(handlerTest, /gorm\.io\/driver\/postgres/);
|
|
53
|
+
assert.doesNotMatch(handlerTest, /TEST_DB_DSN/);
|
|
54
|
+
|
|
55
|
+
const repositoryTest = read(project, "internal/app/order/repository_test.go");
|
|
56
|
+
assert.match(repositoryTest, /REQUIRE_TEST_DB/);
|
|
57
|
+
assert.match(repositoryTest, /TEST_DB_DSN/);
|
|
58
|
+
assert.doesNotMatch(repositoryTest, /AutoMigrate/);
|
|
59
|
+
assert.doesNotMatch(repositoryTest, /DropTable/);
|
|
60
|
+
|
|
61
|
+
const ci = read(project, ".github/workflows/ci.yml");
|
|
62
|
+
assert.match(ci, /migrate -path migrations/);
|
|
63
|
+
assert.match(ci, /REQUIRE_TEST_DB: "true"/);
|
|
64
|
+
assert.match(ci, /TEST_DB_DSN:/);
|
|
65
|
+
|
|
66
|
+
const itemDocs = read(project, "docs/orders/item.yaml");
|
|
67
|
+
const deleteContract = itemDocs.slice(itemDocs.indexOf("delete:"));
|
|
68
|
+
assert.doesNotMatch(deleteContract, /"404"/);
|
|
69
|
+
|
|
70
|
+
run("node", [CLI, "generate", "method", "orders", "approve", "--type", "patch"], project);
|
|
71
|
+
const openapi = read(project, "docs/openapi.yaml");
|
|
72
|
+
assert.match(openapi, /\/v1\/orders\/\{id\}\/approve:/);
|
|
73
|
+
const approveDocs = read(project, "docs/orders/methods/approve.yaml");
|
|
74
|
+
assert.match(approveDocs, /^parameters:/);
|
|
75
|
+
assert.match(approveDocs, /^patch:/m);
|
|
76
|
+
assert.match(approveDocs, /operationId: approveOrder/);
|
|
77
|
+
assert.doesNotMatch(approveDocs, /requestBody:/);
|
|
78
|
+
|
|
79
|
+
run("node", [CLI, "generate", "method", "orders", "submit", "--type", "post"], project);
|
|
80
|
+
const submitDocs = read(project, "docs/orders/methods/submit.yaml");
|
|
81
|
+
assert.doesNotMatch(submitDocs, /^parameters:/);
|
|
82
|
+
assert.doesNotMatch(submitDocs, /in: path/);
|
|
83
|
+
assert.match(submitDocs, /^post:/m);
|
|
84
|
+
assert.match(submitDocs, /requestBody:/);
|
|
85
|
+
|
|
86
|
+
run("go", ["mod", "tidy"], project);
|
|
87
|
+
run("go", ["test", "./..."], project);
|
|
88
|
+
} finally {
|
|
89
|
+
rmSync(scratch, { recursive: true, force: true });
|
|
90
|
+
}
|
|
91
|
+
});
|