@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
@@ -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 {
@@ -18,3 +18,10 @@ type {{pascalName}} struct {
18
18
  CreatedAt time.Time `json:"created_at"`
19
19
  UpdatedAt time.Time `json:"updated_at"`
20
20
  }
21
+
22
+ // TableName pins the persistence contract to the versioned SQL migrations.
23
+ // Do not rely on GORM's English inflector: irregular/plural module names must
24
+ // resolve to exactly the same table in development and production.
25
+ func ({{pascalName}}) TableName() string {
26
+ return "{{tableName}}"
27
+ }
@@ -0,0 +1,79 @@
1
+ package {{pkg}}
2
+
3
+ import (
4
+ "context"
5
+ "os"
6
+ "testing"
7
+
8
+ "{{goModule}}/internal/app/{{modulePath}}/model"
9
+
10
+ "github.com/google/uuid"
11
+ "gorm.io/driver/postgres"
12
+ "gorm.io/gorm"
13
+ )
14
+
15
+ // repositoryDBForTest expects the test database schema to come from the same
16
+ // versioned SQL migrations used in production. Unit tests stay database-free;
17
+ // CI sets REQUIRE_TEST_DB=true so an unavailable/unmigrated database fails
18
+ // instead of becoming a false-green skip.
19
+ func repositoryDBForTest(t *testing.T) *gorm.DB {
20
+ t.Helper()
21
+ dsn := os.Getenv("TEST_DB_DSN")
22
+ if dsn == "" {
23
+ if os.Getenv("REQUIRE_TEST_DB") == "true" {
24
+ t.Fatal("TEST_DB_DSN is required when REQUIRE_TEST_DB=true")
25
+ }
26
+ t.Skip("repository integration test skipped: set TEST_DB_DSN to a migrated PostgreSQL database")
27
+ }
28
+
29
+ db, err := gorm.Open(postgres.Open(dsn), &gorm.Config{TranslateError: true})
30
+ if err != nil {
31
+ if os.Getenv("REQUIRE_TEST_DB") == "true" {
32
+ t.Fatalf("open required test database: %v", err)
33
+ }
34
+ t.Skipf("repository integration test skipped: %v", err)
35
+ }
36
+
37
+ sqlDB, err := db.DB()
38
+ if err != nil {
39
+ t.Fatalf("get SQL database handle: %v", err)
40
+ }
41
+ if err := sqlDB.Ping(); err != nil {
42
+ if os.Getenv("REQUIRE_TEST_DB") == "true" {
43
+ t.Fatalf("ping required test database: %v", err)
44
+ }
45
+ t.Skipf("repository integration test skipped: %v", err)
46
+ }
47
+
48
+ tx := db.Begin()
49
+ if tx.Error != nil {
50
+ t.Fatalf("begin test transaction: %v", tx.Error)
51
+ }
52
+ t.Cleanup(func() {
53
+ if err := tx.Rollback().Error; err != nil {
54
+ t.Errorf("rollback test transaction: %v", err)
55
+ }
56
+ })
57
+ return tx
58
+ }
59
+
60
+ func TestRepository_CreateFindDelete(t *testing.T) {
61
+ repo := NewRepository(repositoryDBForTest(t))
62
+ ctx := context.Background()
63
+ id := uuid.New()
64
+ item := &model.{{pascalName}}{ID: id}
65
+
66
+ if err := repo.Create(ctx, item); err != nil {
67
+ t.Fatalf("create: %v", err)
68
+ }
69
+ found, err := repo.FindByID(ctx, id)
70
+ if err != nil {
71
+ t.Fatalf("find by id: %v", err)
72
+ }
73
+ if found.ID != id {
74
+ t.Fatalf("want id %s, got %s", id, found.ID)
75
+ }
76
+ if err := repo.Delete(ctx, id); err != nil {
77
+ t.Fatalf("delete: %v", err)
78
+ }
79
+ }
@@ -33,8 +33,9 @@ func NewService(repo repository) *Service {
33
33
  return &Service{repo: repo}
34
34
  }
35
35
 
36
- func (s *Service) Create(ctx context.Context) (*model.{{pascalName}}, error) {
37
- // TODO: accept and set real fields from createInput
36
+ func (s *Service) Create(ctx context.Context, in createInput) (*model.{{pascalName}}, error) {
37
+ // TODO: set real fields from in
38
+ _ = in
38
39
  m := &model.{{pascalName}}{ID: id.New()}
39
40
  if err := s.repo.Create(ctx, m); err != nil {
40
41
  if dberr.IsDuplicate(err) {
@@ -61,12 +62,13 @@ func (s *Service) Get(ctx context.Context, id uuid.UUID) (*model.{{pascalName}},
61
62
  return m, nil
62
63
  }
63
64
 
64
- func (s *Service) Update(ctx context.Context, id uuid.UUID) (*model.{{pascalName}}, error) {
65
+ func (s *Service) Update(ctx context.Context, id uuid.UUID, in updateInput) (*model.{{pascalName}}, error) {
65
66
  m, err := s.repo.FindByID(ctx, id)
66
67
  if err != nil {
67
68
  return nil, wrapFindErr(err)
68
69
  }
69
- // TODO: apply real fields from updateInput before saving
70
+ // TODO: apply real fields from in before saving
71
+ _ = in
70
72
  if err := s.repo.Update(ctx, m); err != nil {
71
73
  if dberr.IsDuplicate(err) {
72
74
  return nil, errConflict()
@@ -12,24 +12,54 @@ import (
12
12
  "gorm.io/gorm"
13
13
  )
14
14
 
15
- // fakeRepo = mock of the repository interface, so the service can be tested without a DB
16
- type fakeRepo struct {
17
- err error
18
- m *model.{{pascalName}}
15
+ // repositoryStub exposes one function per dependency operation. Tests configure
16
+ // only the calls they expect; an unexpected call panics instead of silently
17
+ // returning a shared zero value/error that can hide broken orchestration.
18
+ type repositoryStub struct {
19
+ createFn func(context.Context, *model.{{pascalName}}) error
20
+ findAllFn func(context.Context, int, int) ([]model.{{pascalName}}, error)
21
+ findByIDFn func(context.Context, uuid.UUID) (*model.{{pascalName}}, error)
22
+ updateFn func(context.Context, *model.{{pascalName}}) error
23
+ deleteFn func(context.Context, uuid.UUID) error
24
+ // go-scaffold:repository-stub-fields
19
25
  }
20
26
 
21
- func (f *fakeRepo) Create(context.Context, *model.{{pascalName}}) error { return f.err }
22
- func (f *fakeRepo) FindAll(context.Context, int, int) ([]model.{{pascalName}}, error) { return nil, f.err }
23
- func (f *fakeRepo) FindByID(context.Context, uuid.UUID) (*model.{{pascalName}}, error) {
24
- if f.err != nil {
25
- return nil, f.err
27
+ func (s *repositoryStub) Create(ctx context.Context, m *model.{{pascalName}}) error {
28
+ if s.createFn == nil {
29
+ panic("unexpected repository.Create call")
26
30
  }
27
- return f.m, nil
31
+ return s.createFn(ctx, m)
28
32
  }
29
- func (f *fakeRepo) Update(context.Context, *model.{{pascalName}}) error { return f.err }
30
- func (f *fakeRepo) Delete(context.Context, uuid.UUID) error { return f.err }
31
33
 
32
- // go-scaffold:fake-repo-methods
34
+ func (s *repositoryStub) FindAll(ctx context.Context, limit, offset int) ([]model.{{pascalName}}, error) {
35
+ if s.findAllFn == nil {
36
+ panic("unexpected repository.FindAll call")
37
+ }
38
+ return s.findAllFn(ctx, limit, offset)
39
+ }
40
+
41
+ func (s *repositoryStub) FindByID(ctx context.Context, id uuid.UUID) (*model.{{pascalName}}, error) {
42
+ if s.findByIDFn == nil {
43
+ panic("unexpected repository.FindByID call")
44
+ }
45
+ return s.findByIDFn(ctx, id)
46
+ }
47
+
48
+ func (s *repositoryStub) Update(ctx context.Context, m *model.{{pascalName}}) error {
49
+ if s.updateFn == nil {
50
+ panic("unexpected repository.Update call")
51
+ }
52
+ return s.updateFn(ctx, m)
53
+ }
54
+
55
+ func (s *repositoryStub) Delete(ctx context.Context, id uuid.UUID) error {
56
+ if s.deleteFn == nil {
57
+ panic("unexpected repository.Delete call")
58
+ }
59
+ return s.deleteFn(ctx, id)
60
+ }
61
+
62
+ // go-scaffold:repository-stub-methods
33
63
 
34
64
  func status(t *testing.T, err error) int {
35
65
  t.Helper()
@@ -41,24 +71,45 @@ func status(t *testing.T, err error) int {
41
71
  }
42
72
 
43
73
  func TestService_Create_Duplicate(t *testing.T) {
44
- svc := NewService(&fakeRepo{err: gorm.ErrDuplicatedKey})
45
- _, err := svc.Create(context.Background())
74
+ repo := &repositoryStub{
75
+ createFn: func(context.Context, *model.{{pascalName}}) error {
76
+ return gorm.ErrDuplicatedKey
77
+ },
78
+ }
79
+ svc := NewService(repo)
80
+
81
+ _, err := svc.Create(context.Background(), createInput{})
82
+
46
83
  if got := status(t, err); got != 409 {
47
84
  t.Fatalf("want 409 conflict, got %d", got)
48
85
  }
49
86
  }
50
87
 
51
88
  func TestService_Get_NotFound(t *testing.T) {
52
- svc := NewService(&fakeRepo{err: gorm.ErrRecordNotFound})
89
+ repo := &repositoryStub{
90
+ findByIDFn: func(context.Context, uuid.UUID) (*model.{{pascalName}}, error) {
91
+ return nil, gorm.ErrRecordNotFound
92
+ },
93
+ }
94
+ svc := NewService(repo)
95
+
53
96
  _, err := svc.Get(context.Background(), uuid.New())
97
+
54
98
  if got := status(t, err); got != 404 {
55
99
  t.Fatalf("want 404, got %d", got)
56
100
  }
57
101
  }
58
102
 
59
103
  func TestService_List_DBError_Becomes500(t *testing.T) {
60
- svc := NewService(&fakeRepo{err: errors.New("connection refused")})
104
+ repo := &repositoryStub{
105
+ findAllFn: func(context.Context, int, int) ([]model.{{pascalName}}, error) {
106
+ return nil, errors.New("connection refused")
107
+ },
108
+ }
109
+ svc := NewService(repo)
110
+
61
111
  _, err := svc.List(context.Background(), 20, 0)
112
+
62
113
  if got := status(t, err); got != 500 {
63
114
  t.Fatalf("want 500, got %d", got)
64
115
  }
@@ -0,0 +1,46 @@
1
+ import assert from "node:assert/strict";
2
+ import { execFileSync } from "node:child_process";
3
+ import { existsSync, mkdtempSync, readFileSync, rmSync } from "node:fs";
4
+ import { tmpdir } from "node:os";
5
+ import path from "node:path";
6
+ import test from "node:test";
7
+ import { fileURLToPath } from "node:url";
8
+
9
+ const ROOT = path.dirname(path.dirname(path.dirname(fileURLToPath(import.meta.url))));
10
+ const CLI = path.join(ROOT, "bin", "go-scaffold.js");
11
+
12
+ function runCLI(cwd, ...args) {
13
+ return execFileSync("node", [CLI, ...args], {
14
+ cwd,
15
+ encoding: "utf8",
16
+ stdio: ["ignore", "pipe", "pipe"],
17
+ });
18
+ }
19
+
20
+ test("generate module defaults to a safe minimal module", () => {
21
+ const scratch = mkdtempSync(path.join(tmpdir(), "go-scaffold-default-module-"));
22
+ try {
23
+ runCLI(scratch, "create", "sample", "--defaults", "--no-docker");
24
+ const project = path.join(scratch, "sample");
25
+
26
+ const output = runCLI(project, "generate", "module", "orders");
27
+ const handler = readFileSync(
28
+ path.join(project, "internal", "app", "order", "handler.go"),
29
+ "utf8"
30
+ );
31
+
32
+ assert.match(output, /registered empty route group/);
33
+ assert.doesNotMatch(handler, /g\.POST\(/);
34
+ assert.equal(existsSync(path.join(project, "docs", "orders")), false);
35
+
36
+ const legacyOutput = runCLI(project, "generate", "module", "widgets", "--no-full");
37
+ const legacyHandler = readFileSync(
38
+ path.join(project, "internal", "app", "widget", "handler.go"),
39
+ "utf8"
40
+ );
41
+ assert.match(legacyOutput, /registered empty route group/);
42
+ assert.doesNotMatch(legacyHandler, /g\.POST\(/);
43
+ } finally {
44
+ rmSync(scratch, { recursive: true, force: true });
45
+ }
46
+ });
@@ -0,0 +1,81 @@
1
+ import assert from "node:assert/strict";
2
+ import { execFileSync } from "node:child_process";
3
+ import {
4
+ mkdtempSync,
5
+ readFileSync,
6
+ readdirSync,
7
+ rmSync,
8
+ } from "node:fs";
9
+ import { tmpdir } from "node:os";
10
+ import path from "node:path";
11
+ import test from "node:test";
12
+ import { fileURLToPath } from "node:url";
13
+
14
+ const ROOT = path.dirname(path.dirname(path.dirname(fileURLToPath(import.meta.url))));
15
+ const CLI = path.join(ROOT, "bin", "go-scaffold.js");
16
+
17
+ function runCLI(cwd, ...args) {
18
+ return execFileSync("node", [CLI, ...args], {
19
+ cwd,
20
+ encoding: "utf8",
21
+ stdio: ["ignore", "pipe", "pipe"],
22
+ });
23
+ }
24
+
25
+ function read(root, relativePath) {
26
+ return readFileSync(path.join(root, relativePath), "utf8");
27
+ }
28
+
29
+ function migration(root, suffix) {
30
+ const filename = readdirSync(path.join(root, "migrations")).find((entry) =>
31
+ entry.endsWith(suffix)
32
+ );
33
+ assert.ok(filename, `missing migration ending with ${suffix}`);
34
+ return read(root, path.join("migrations", filename));
35
+ }
36
+
37
+ test("plural module input produces a singular entity with one explicit table name", () => {
38
+ const scratch = mkdtempSync(path.join(tmpdir(), "go-scaffold-naming-"));
39
+ try {
40
+ runCLI(scratch, "create", "sample", "--defaults", "--no-docker");
41
+ const project = path.join(scratch, "sample");
42
+ runCLI(project, "generate", "module", "orders");
43
+
44
+ const model = read(project, "internal/app/order/model/model.go");
45
+ assert.match(model, /type Order struct/);
46
+ assert.match(
47
+ model,
48
+ /func \(Order\) TableName\(\) string \{\s*return "orders"\s*\}/
49
+ );
50
+ assert.match(migration(project, "_create_orders.up.sql"), /CREATE TABLE orders/);
51
+ assert.match(read(project, "internal/app/order/handler.go"), /Group\("\/orders"/);
52
+ } finally {
53
+ rmSync(scratch, { recursive: true, force: true });
54
+ }
55
+ });
56
+
57
+ test("multi-word module keeps URL words and uses snake_case SQL identifiers", () => {
58
+ const scratch = mkdtempSync(path.join(tmpdir(), "go-scaffold-multiword-"));
59
+ try {
60
+ runCLI(scratch, "create", "sample", "--defaults", "--no-docker");
61
+ const project = path.join(scratch, "sample");
62
+ runCLI(project, "generate", "module", "order-items");
63
+
64
+ const model = read(project, "internal/app/orderitem/model/model.go");
65
+ assert.match(model, /type OrderItem struct/);
66
+ assert.match(
67
+ model,
68
+ /func \(OrderItem\) TableName\(\) string \{\s*return "order_items"\s*\}/
69
+ );
70
+ assert.match(
71
+ migration(project, "_create_order-items.up.sql"),
72
+ /CREATE TABLE order_items/
73
+ );
74
+ assert.match(
75
+ read(project, "internal/app/orderitem/handler.go"),
76
+ /Group\("\/order-items"/
77
+ );
78
+ } finally {
79
+ rmSync(scratch, { recursive: true, force: true });
80
+ }
81
+ });
@@ -0,0 +1,91 @@
1
+ import assert from "node:assert/strict";
2
+ import { execFileSync } from "node:child_process";
3
+ import { mkdtempSync, readFileSync, rmSync } from "node:fs";
4
+ import { tmpdir } from "node:os";
5
+ import path from "node:path";
6
+ import test from "node:test";
7
+ import { fileURLToPath } from "node:url";
8
+
9
+ const ROOT = path.dirname(path.dirname(path.dirname(fileURLToPath(import.meta.url))));
10
+ const CLI = path.join(ROOT, "bin", "go-scaffold.js");
11
+
12
+ function run(command, args, cwd) {
13
+ return execFileSync(command, args, {
14
+ cwd,
15
+ encoding: "utf8",
16
+ stdio: ["ignore", "pipe", "pipe"],
17
+ });
18
+ }
19
+
20
+ function read(project, relativePath) {
21
+ return readFileSync(path.join(project, relativePath), "utf8");
22
+ }
23
+
24
+ test("generated modules have scalable service and handler unit-test seams", () => {
25
+ const scratch = mkdtempSync(path.join(tmpdir(), "go-scaffold-unit-seams-"));
26
+ try {
27
+ run("node", [CLI, "create", "sample", "--defaults", "--no-docker"], scratch);
28
+ const project = path.join(scratch, "sample");
29
+ run("node", [CLI, "generate", "module", "orders", "--full"], project);
30
+
31
+ const handler = read(project, "internal/app/order/handler.go");
32
+ assert.match(handler, /type service interface \{/);
33
+ assert.match(handler, /svc service/);
34
+ assert.doesNotMatch(handler, /svc \*Service/);
35
+ assert.match(handler, /Create\(context\.Context, createInput\)/);
36
+ assert.match(handler, /Update\(context\.Context, uuid\.UUID, updateInput\)/);
37
+
38
+ const service = read(project, "internal/app/order/service.go");
39
+ assert.match(service, /Create\(ctx context\.Context, in createInput\)/);
40
+ assert.match(service, /Update\(ctx context\.Context, id uuid\.UUID, in updateInput\)/);
41
+
42
+ const serviceTest = read(project, "internal/app/order/service_test.go");
43
+ assert.match(serviceTest, /type repositoryStub struct/);
44
+ assert.match(serviceTest, /createFn\s+func/);
45
+ assert.match(serviceTest, /findByIDFn\s+func/);
46
+ assert.match(serviceTest, /updateFn\s+func/);
47
+ assert.doesNotMatch(serviceTest, /type fakeRepo struct/);
48
+
49
+ const handlerTest = read(project, "internal/app/order/handler_test.go");
50
+ assert.match(handlerTest, /type serviceStub struct/);
51
+ assert.match(handlerTest, /createFn\s+func/);
52
+ assert.doesNotMatch(handlerTest, /gorm\.io\/driver\/postgres/);
53
+ assert.doesNotMatch(handlerTest, /TEST_DB_DSN/);
54
+
55
+ const repositoryTest = read(project, "internal/app/order/repository_test.go");
56
+ assert.match(repositoryTest, /REQUIRE_TEST_DB/);
57
+ assert.match(repositoryTest, /TEST_DB_DSN/);
58
+ assert.doesNotMatch(repositoryTest, /AutoMigrate/);
59
+ assert.doesNotMatch(repositoryTest, /DropTable/);
60
+
61
+ const ci = read(project, ".github/workflows/ci.yml");
62
+ assert.match(ci, /migrate -path migrations/);
63
+ assert.match(ci, /REQUIRE_TEST_DB: "true"/);
64
+ assert.match(ci, /TEST_DB_DSN:/);
65
+
66
+ const itemDocs = read(project, "docs/orders/item.yaml");
67
+ const deleteContract = itemDocs.slice(itemDocs.indexOf("delete:"));
68
+ assert.doesNotMatch(deleteContract, /"404"/);
69
+
70
+ run("node", [CLI, "generate", "method", "orders", "approve", "--type", "patch"], project);
71
+ const openapi = read(project, "docs/openapi.yaml");
72
+ assert.match(openapi, /\/v1\/orders\/\{id\}\/approve:/);
73
+ const approveDocs = read(project, "docs/orders/methods/approve.yaml");
74
+ assert.match(approveDocs, /^parameters:/);
75
+ assert.match(approveDocs, /^patch:/m);
76
+ assert.match(approveDocs, /operationId: approveOrder/);
77
+ assert.doesNotMatch(approveDocs, /requestBody:/);
78
+
79
+ run("node", [CLI, "generate", "method", "orders", "submit", "--type", "post"], project);
80
+ const submitDocs = read(project, "docs/orders/methods/submit.yaml");
81
+ assert.doesNotMatch(submitDocs, /^parameters:/);
82
+ assert.doesNotMatch(submitDocs, /in: path/);
83
+ assert.match(submitDocs, /^post:/m);
84
+ assert.match(submitDocs, /requestBody:/);
85
+
86
+ run("go", ["mod", "tidy"], project);
87
+ run("go", ["test", "./..."], project);
88
+ } finally {
89
+ rmSync(scratch, { recursive: true, force: true });
90
+ }
91
+ });