Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions apps/go-modular/.gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -7,3 +7,6 @@ docs/swagger.yaml
release/
temp/
tmp/

# Recorded webhook/contract fixtures are real test assets; the workspace .gitignore drops testdata/ globally.
!testdata/
23 changes: 22 additions & 1 deletion apps/go-modular/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,8 @@ moon go-modular:run # Execute `go run`
moon go-modular:build # Build the application
moon go-modular:start # Start application from build
moon go-modular:test # Run testing
moon go-modular:coverage # Run test coverage
moon go-modular:coverage # Tests + tiered coverage gate (scripts/coverage-gate.sh)
moon go-modular:test-contract # Contract tests (-tags contract), replay recorded fixtures
moon go-modular:format # Run code formatting
moon go-modular:tidy # Install dependencies
moon go-modular:docker-build # Build docker image
Expand All @@ -25,6 +26,26 @@ moon go-modular:build-otel # Build with OpenTelemetry auto-instrumenta
moon go-modular:start-otel # Start the instrumented build
```

## Testing

```sh
moon go-modular:test # Unit + integration tests (testcontainers needs Docker)
moon go-modular:coverage # Same, plus build/coverage.html and the coverage gate
```

The gate (`scripts/coverage-gate.sh`) reports three tiers β€” overall, `modules/...`, and
the packages named in `COVERAGE_CRITICAL_PACKAGES` β€” against the `COVERAGE_MIN*` floors
in `moon.yml`. Floors ship at 0 (report only); once your project has a baseline, set
each to measured βˆ’ 5 and only ever raise it.

- `pkg/testutils` β€” Postgres/Redis testcontainers + migration runner (`TestEnv`).
- `pkg/testutils/webhook` β€” replay captured provider webhooks and assert idempotency,
late-event handling and signature checks. Fixtures live in `testdata/webhooks/`.
- `pkg/testutils/contract` β€” record/replay third-party HTTP responses behind the
`contract` build tag (`moon go-modular:test-contract`). Fixtures in `testdata/contract/`.
- `internal/app/app_integration_test.go` β€” the end-to-end wiring test (real Postgres,
seeded admin sign-in, JWT-guarded route). Copy its shape for new modules.

## Observability

Distributed tracing uses OpenTelemetry compile-time auto-instrumentation, with
Expand Down
132 changes: 132 additions & 0 deletions apps/go-modular/internal/app/app_integration_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
package app

import (
"context"
"encoding/json"
"io"
"log/slog"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"

"go-modular/internal/config"
"go-modular/internal/server"
modAuth "go-modular/modules/auth"
modUser "go-modular/modules/user"
"go-modular/pkg/testutils"

"github.com/labstack/echo/v4"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"go.uber.org/fx"
"go.uber.org/fx/fxtest"
)

// The one end-to-end wiring test: real Postgres (testcontainers) β†’ migrations + seed β†’
// the fx graph exactly as New() builds it (minus the listener) β†’ healthz, seeded admin
// sign-in, JWT-guarded route. Everything the unit tests fake is real here. Copy this
// shape for every new module's wiring test.
func TestApp_EndToEnd(t *testing.T) {
te := testutils.NewTestEnv(t)
pool, pgURL, err := te.SetupPostgres()
require.NoError(t, err)
t.Cleanup(pool.Close)
te.SetupConfig()
te.RunAppMigrations()

t.Setenv("DATABASE_URL", pgURL)
t.Setenv("ENABLE_API_DOCS", "true")
cfg, err := config.Load("")
require.NoError(t, err)

var e *echo.Echo
app := fxtest.New(t,
fx.NopLogger,
fx.Supply(cfg, slog.New(slog.NewTextHandler(io.Discard, nil))),
postgresModule,
mailerModule,
// httpModule without runHTTPServer: the test drives echo directly.
fx.Provide(newEcho, newAPIV1, server.NewServerHandler),
fx.Invoke(registerServerRoutes),
modUser.Module,
modAuth.Module,
fx.Populate(&e),
)
app.RequireStart()
t.Cleanup(app.RequireStop)

do := func(method, path, body, bearer string) (*httptest.ResponseRecorder, map[string]any) {
req := httptest.NewRequest(method, path, strings.NewReader(body))
if body != "" {
req.Header.Set(echo.HeaderContentType, echo.MIMEApplicationJSON)
}
if bearer != "" {
req.Header.Set(echo.HeaderAuthorization, "Bearer "+bearer)
}
rec := httptest.NewRecorder()
e.ServeHTTP(rec, req)
var out map[string]any
_ = json.Unmarshal(rec.Body.Bytes(), &out)
return rec, out
}

t.Run("healthz reports the database up", func(t *testing.T) {
rec, body := do(http.MethodGet, "/healthz", "", "")
assert.Equal(t, http.StatusOK, rec.Code)
assert.Equal(t, "up", body["status"])
})

t.Run("protected user routes require a token", func(t *testing.T) {
rec, _ := do(http.MethodGet, "/api/v1/users", "", "")
assert.Equal(t, http.StatusUnauthorized, rec.Code)
})

var token string
t.Run("seeded admin can sign in", func(t *testing.T) {
rec, body := do(http.MethodPost, "/api/v1/auth/signin/username", `{"username":"admin","password":"secure.password"}`, "")
require.Equal(t, http.StatusOK, rec.Code, rec.Body.String())
token, _ = body["access_token"].(string)
require.NotEmpty(t, token)
assert.NotEmpty(t, body["refresh_token"])
assert.NotEmpty(t, body["session_id"])
})

t.Run("wrong password is a 401, not a 500", func(t *testing.T) {
rec, _ := do(http.MethodPost, "/api/v1/auth/signin/username", `{"username":"admin","password":"nope"}`, "")
assert.Equal(t, http.StatusUnauthorized, rec.Code)
})

t.Run("the access token opens the user routes", func(t *testing.T) {
rec, _ := do(http.MethodGet, "/api/v1/users", "", token)
assert.Equal(t, http.StatusOK, rec.Code)
var users []map[string]any
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &users))
assert.GreaterOrEqual(t, len(users), 2, "seeded admin + johndoe")
})

t.Run("global middleware is wired: cors, gzip", func(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, "/healthz", nil)
req.Header.Set(echo.HeaderOrigin, "https://anything.example")
req.Header.Set(echo.HeaderAcceptEncoding, "gzip")
rec := httptest.NewRecorder()
e.ServeHTTP(rec, req)
assert.NotEmpty(t, rec.Header().Get(echo.HeaderAccessControlAllowOrigin), "CORS middleware answered (origins come from config/.env)")
assert.Equal(t, "gzip", rec.Header().Get(echo.HeaderContentEncoding))
})
}

func TestConnectPostgresWithRetry_GivesUpAfterMaxRetries(t *testing.T) {
cfg := config.DefaultConfig()
cfg.Database.PgMaxRetries = 1 // no sleep between attempts when there is only one
cfg.Database.PostgresURL = "postgres://nobody:nothing@127.0.0.1:1/none?sslmode=disable&connect_timeout=1"

start := time.Now()
pg, err := connectPostgresWithRetry(&cfg, slog.New(slog.NewTextHandler(io.Discard, nil)))
assert.Nil(t, pg)
require.Error(t, err)
assert.Contains(t, err.Error(), "after 1 attempts")
assert.Less(t, time.Since(start), 15*time.Second)
_ = context.Background()
}
Loading