diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..c3bf76c --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,69 @@ +## Project Overview + +Goravel Example (`github.com/goravel/example`) is the framework example package. It demonstrates how to use the Goravel framework to build a web application, including core architecture, common commands, and code rules. + +## Code Rules + +- Use `any` instead of `interface{}`. +- Never edit `mocks/` directly; run `go tool mockery` to regenerate. +- Follow standard Go formatting/naming; add comments where logic isn't self-evident. Go version is in go.mod. + +### Tests + +- Prefer `go test ` or `-run ` over `go test ./...` (slow). +- Use table-driven tests covering happy path, failure, and edge cases. +- Skip trivial getters/setters unless they contain non-trivial logic. +- Use `testify/assert` with `assert.*(t, *)` or `require.*(t, *)` directly, not `assert.New(t)`. +- Use `testify/suite` for related test groups, and use `s.*(*, *)` when asserting. +- Use the testify `EXPECT` method for mocks; avoid `mock.Anything`. +- Use `assert.AnError` if needed, and `assert.Equal` for error assertions. +- Assert full maps/structs/slices/arrays, not individual fields. +- Prefer direct value assertions over `mock.MatchedBy`; use it only for dynamically-generated args. +- Every mock must use `.Once()` or `.Times()`, only use `.Maybe()` when necessary; avoid no-op expectations. +- Name tests `Test_[Optional]`; use table style or sub-tests for multiple cases. +- Don't use `assert.*` with `if` statements; use `assert.*` directly for clarity and better failure messages. +- Use `t.Run()` for sub-tests when testing multiple cases for the same function, and use table-driven tests for multiple cases with similar setup/assertions. Avoid writing separate test functions for each case when they share common logic. +- The basic table-driven test pattern is: + +```go +func TestFunction(t *testing.T) { + // The name should start with `mock` to indicate it's a mocked function. + var ( + ctx context.Context + mockFunc *mocks.MockedInterface + ) + + beforeEach := func() { + mockFunc = mocks.NewMockedInterface(t) + } + + tests := []struct { + name string + input any + setup func() + expect any + expectError error + }{ + { + name: "should do something", + input: someInput, + setup: func() { + mockFunc.EXPECT().SomeMethod(someArgs).Return(someResult, nil).Once() + }, + expect: someResult, + expectError: nil, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + beforeEach() + tt.setup() + + result, err := FunctionUnderTest(tt.input) + assert.Equal(t, tt.expect, result) + assert.Equal(t, tt.expectError, err) + }) + } +} +``` diff --git a/CLAUDE.md b/CLAUDE.md new file mode 120000 index 0000000..47dc3e3 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1 @@ +AGENTS.md \ No newline at end of file diff --git a/database/migrations/20250330911908_add_columns_to_users_table.go b/database/migrations/20250330911908_add_columns_to_users_table.go index 22c0881..9d9678c 100755 --- a/database/migrations/20250330911908_add_columns_to_users_table.go +++ b/database/migrations/20250330911908_add_columns_to_users_table.go @@ -10,7 +10,7 @@ type M20250330911908AddColumnsToUsersTable struct{} // Signature The unique signature for the migration. func (r *M20250330911908AddColumnsToUsersTable) Signature() string { - return "20250331111908_add_columns_to_users_table" + return "20250330911908_add_columns_to_users_table" } // Up Run the migrations. @@ -23,5 +23,7 @@ func (r *M20250330911908AddColumnsToUsersTable) Up() error { // Down Reverse the migrations. func (r *M20250330911908AddColumnsToUsersTable) Down() error { - return nil + return facades.Schema().Table("users", func(table schema.Blueprint) { + table.DropColumn("alias", "email") + }) } diff --git a/database/migrations/20250331093125_alert_columns_of_users_table.go b/database/migrations/20250331093125_alert_columns_of_users_table.go index 8b7ee54..8afc3ab 100755 --- a/database/migrations/20250331093125_alert_columns_of_users_table.go +++ b/database/migrations/20250331093125_alert_columns_of_users_table.go @@ -27,5 +27,11 @@ func (r *M20250331093125AlertColumnsOfUsersTable) Up() error { // Down Reverse the migrations. func (r *M20250331093125AlertColumnsOfUsersTable) Down() error { + if facades.Schema().HasTable("users") { + return facades.Schema().Table("users", func(table schema.Blueprint) { + table.RenameColumn("mail", "email") + }) + } + return nil } diff --git a/database/seeders/database_seeder.go b/database/seeders/database_seeder.go index afce748..d5aafa0 100644 --- a/database/seeders/database_seeder.go +++ b/database/seeders/database_seeder.go @@ -1,5 +1,10 @@ package seeders +import ( + "goravel/app/facades" + "goravel/app/models" +) + type DatabaseSeeder struct { } @@ -10,5 +15,8 @@ func (s *DatabaseSeeder) Signature() string { // Run executes the seeder logic. func (s *DatabaseSeeder) Run() error { - return nil + return facades.Orm().Query().Create(&models.User{ + Name: "migration", + Mail: "migration@goravel.dev", + }) } diff --git a/go.mod b/go.mod index ed94133..3887ee6 100644 --- a/go.mod +++ b/go.mod @@ -10,7 +10,7 @@ require ( github.com/goravel/cos v1.17.0 github.com/goravel/example-proto v0.0.1 github.com/goravel/fiber v1.17.1-0.20260319150449-0a18b9c6e22b - github.com/goravel/framework v1.17.2-0.20260322042944-d61a6cc1601e + github.com/goravel/framework v1.17.2-0.20260329143353-aa89cf5921cb github.com/goravel/gin v1.17.1-0.20260319150458-6d1543fdf889 github.com/goravel/minio v1.17.0 github.com/goravel/mysql v1.17.0 @@ -31,7 +31,7 @@ require ( github.com/vektah/gqlparser/v2 v2.5.19 go.opentelemetry.io/otel v1.42.0 go.opentelemetry.io/otel/metric v1.42.0 - google.golang.org/grpc v1.79.2 + google.golang.org/grpc v1.79.3 ) require ( @@ -240,4 +240,4 @@ require ( gorm.io/plugin/dbresolver v1.6.2 // indirect ) -replace github.com/goravel/framework => github.com/goravel/framework v1.17.2-0.20260322042944-d61a6cc1601e +replace github.com/goravel/framework => github.com/goravel/framework v1.17.2-0.20260329094602-be6d0d1dcf97 diff --git a/go.sum b/go.sum index 09c8869..f1f72ed 100644 --- a/go.sum +++ b/go.sum @@ -291,8 +291,8 @@ github.com/goravel/example-proto v0.0.1 h1:ZxETeKREQWjuJ49bX/Hqj1NLR5Vyj489Ks6dR github.com/goravel/example-proto v0.0.1/go.mod h1:I8IPsHr4Ndf7KxmdsRpBR2LQ0Geo48+pjv9IIWf3mZg= github.com/goravel/fiber v1.17.1-0.20260319150449-0a18b9c6e22b h1:peMAbfUTyQJCtA4wABmOowETE8N5v6i0GEy3vqVWeCg= github.com/goravel/fiber v1.17.1-0.20260319150449-0a18b9c6e22b/go.mod h1:vVfU2LnxhCXOE1QH9U0/bFPBfIvbqCD3xyDkSm4wmVM= -github.com/goravel/framework v1.17.2-0.20260322042944-d61a6cc1601e h1:WXtXYc9lWIbpkbnGiPH4GtWopNh5KZaPEk/wipWPvDI= -github.com/goravel/framework v1.17.2-0.20260322042944-d61a6cc1601e/go.mod h1:2/eF2HWF3MFE1AIVnY5e+zebNCsawnMJRT14NzSiq/I= +github.com/goravel/framework v1.17.2-0.20260329094602-be6d0d1dcf97 h1:fD5VV8KfIGduf1bBigFCG3DGgdTUfz1Ek83c7cwcLaA= +github.com/goravel/framework v1.17.2-0.20260329094602-be6d0d1dcf97/go.mod h1:MksvSJ0GQTfVgIIbAqOPs7S3hQGtVCCfWhZSQNVSsBA= github.com/goravel/gin v1.17.1-0.20260319150458-6d1543fdf889 h1:P1wUP46zVOoD3wXFrydvoBs3q8SfJO7Gk7TU6emKNZE= github.com/goravel/gin v1.17.1-0.20260319150458-6d1543fdf889/go.mod h1:GsI8Ep1tfePcLHSx0vVtj+k3PLwpn/rUs4tKDFaH7b0= github.com/goravel/minio v1.17.0 h1:WGiPP/KZl/fuDpT9THRM83wjhLCqe1oIAyNVJvVjhS4= @@ -772,8 +772,8 @@ google.golang.org/genproto/googleapis/api v0.0.0-20260209200024-4cfbd4190f57 h1: google.golang.org/genproto/googleapis/api v0.0.0-20260209200024-4cfbd4190f57/go.mod h1:kSJwQxqmFXeo79zOmbrALdflXQeAYcUbgS7PbpMknCY= google.golang.org/genproto/googleapis/rpc v0.0.0-20260226221140-a57be14db171 h1:ggcbiqK8WWh6l1dnltU4BgWGIGo+EVYxCaAPih/zQXQ= google.golang.org/genproto/googleapis/rpc v0.0.0-20260226221140-a57be14db171/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= -google.golang.org/grpc v1.79.2 h1:fRMD94s2tITpyJGtBBn7MkMseNpOZU8ZxgC3MMBaXRU= -google.golang.org/grpc v1.79.2/go.mod h1:KmT0Kjez+0dde/v2j9vzwoAScgEPx/Bw1CYChhHLrHQ= +google.golang.org/grpc v1.79.3 h1:sybAEdRIEtvcD68Gx7dmnwjZKlyfuc61Dyo9pGXXkKE= +google.golang.org/grpc v1.79.3/go.mod h1:KmT0Kjez+0dde/v2j9vzwoAScgEPx/Bw1CYChhHLrHQ= google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= diff --git a/package_test.go b/package_test.go index d52a30b..9f18e84 100644 --- a/package_test.go +++ b/package_test.go @@ -163,7 +163,9 @@ func TestInstallAndUninstallLocalPackage(t *testing.T) { assert.True(t, file.Exists(path.Base("packages", "example"))) assert.True(t, file.Exists(path.Base("packages", "example", "setup", "setup.go"))) - assert.False(t, facades.Process().Run("go run . artisan package:install goravel/packages/example").Failed()) + result := facades.Process().Run("go run . artisan package:install goravel/packages/example") + assert.NoError(t, result.Error()) + assert.False(t, result.Failed()) assert.True(t, file.Contain(path.Bootstrap("providers.go"), "&example.ServiceProvider{},")) assert.True(t, file.Contain(path.Bootstrap("providers.go"), "goravel/packages/example")) diff --git a/tests/feature/http_test.go b/tests/feature/http_test.go index b375937..6c9e9b7 100644 --- a/tests/feature/http_test.go +++ b/tests/feature/http_test.go @@ -6,7 +6,6 @@ import ( "testing" contractshttp "github.com/goravel/framework/contracts/http" - "github.com/goravel/framework/support" "github.com/goravel/framework/support/http" "github.com/stretchr/testify/suite" @@ -103,9 +102,6 @@ func (s *HttpTestSuite) TestInputMapArray() { } func (s *HttpTestSuite) TestLang() { - // Change working directory to project root to use current lang files - s.T().Chdir(support.RelativePath) - tests := []struct { name string lang string diff --git a/tests/feature/migration_test.go b/tests/feature/migration_test.go index a31cf4f..b00bfbc 100644 --- a/tests/feature/migration_test.go +++ b/tests/feature/migration_test.go @@ -1,14 +1,25 @@ package feature import ( + "io" + "os" + "regexp" + "strings" "testing" + "time" + "github.com/spf13/cast" + + "github.com/goravel/framework/support/color" + "github.com/goravel/framework/support/file" + "github.com/goravel/framework/support/path" "github.com/goravel/mysql" "github.com/goravel/sqlite" "github.com/goravel/sqlserver" "github.com/stretchr/testify/suite" "goravel/app/facades" + "goravel/app/models" "goravel/tests" ) @@ -64,10 +75,197 @@ func (s *MigrationTestSuite) TestFirst_After() { s.Equal("mail", columns[0].Name) s.Equal("alias", columns[3].Name) } + func (s *MigrationTestSuite) TestMigrate() { s.True(facades.Schema().HasTable("users")) } +func (s *MigrationTestSuite) TestCommandMigrate() { + total, err := s.migrationCount() + s.Require().NoError(err) + + s.NoError(facades.Artisan().Call("--no-ansi migrate:reset")) + + count, err := s.migrationCount() + s.NoError(err) + s.Zero(count) + s.False(facades.Schema().HasTable("users")) + + s.NoError(facades.Artisan().Call("--no-ansi migrate")) + + count, err = s.migrationCount() + s.NoError(err) + s.Equal(total, count) + + s.True(facades.Schema().HasTable("users")) + s.True(facades.Schema().HasTable("jobs")) + s.True(facades.Schema().HasTable("failed_jobs")) + s.True(s.columnExists("users", "mail")) +} + +func (s *MigrationTestSuite) TestCommandMigrateReset() { + s.True(facades.Schema().HasTable("users")) + + s.NoError(facades.Artisan().Call("--no-ansi migrate:reset")) + + count, err := s.migrationCount() + s.NoError(err) + s.Zero(count) + + s.False(facades.Schema().HasTable("users")) + s.False(facades.Schema().HasTable("jobs")) + s.False(facades.Schema().HasTable("failed_jobs")) +} + +func (s *MigrationTestSuite) TestCommandMigrateRefresh() { + total, err := s.migrationCount() + s.Require().NoError(err) + + s.NoError(facades.Artisan().Call("--no-ansi migrate:refresh")) + afterRefresh, err := s.migrationCount() + s.NoError(err) + s.Equal(total, afterRefresh) + s.True(facades.Schema().HasTable("users")) + s.True(s.columnExists("users", "mail")) + + s.NoError(facades.Artisan().Call("--no-ansi migrate:refresh --step 1")) + + afterStepRefresh, err := s.migrationCount() + s.NoError(err) + s.Equal(total, afterStepRefresh) + + lastBatch, err := s.latestMigrationBatch() + s.NoError(err) + s.Equal(lastBatch, 2) + + s.True(facades.Schema().HasTable("users")) + s.True(s.columnExists("users", "mail")) +} + +func (s *MigrationTestSuite) TestCommandMigrateFresh() { + total, err := s.migrationCount() + s.Require().NoError(err) + + s.NoError(facades.Artisan().Call("--no-ansi migrate:fresh --seed --seeder DatabaseSeeder")) + + count, err := s.migrationCount() + s.NoError(err) + s.Equal(total, count) + + s.True(facades.Schema().HasTable("users")) + s.True(facades.Schema().HasTable("jobs")) + s.True(facades.Schema().HasTable("failed_jobs")) + s.True(s.columnExists("users", "mail")) + + var user models.User + s.NoError(facades.Orm().Query().Where("mail", "migration@goravel.dev").FirstOrFail(&user)) + s.Equal("migration", user.Name) +} + +func (s *MigrationTestSuite) TestCommandMigrateRollback() { + total, err := s.migrationCount() + s.Require().NoError(err) + + s.NoError(facades.Artisan().Call("--no-ansi migrate:rollback")) + afterDefaultRollback, err := s.migrationCount() + s.NoError(err) + s.Zero(afterDefaultRollback) + + s.RefreshDatabase() + + s.NoError(facades.Artisan().Call("--no-ansi migrate:rollback --step 1")) + afterStepRollback, err := s.migrationCount() + s.NoError(err) + s.Equal(total-1, afterStepRollback) + + s.RefreshDatabase() + + s.NoError(facades.Artisan().Call("--no-ansi migrate:rollback --step 1")) + s.NoError(facades.Artisan().Call("--no-ansi migrate")) + + latestBatch, err := s.latestMigrationBatch() + s.NoError(err) + s.Equal(2, latestBatch) + + s.NoError(facades.Artisan().Call("--no-ansi migrate:rollback --batch " + cast.ToString(latestBatch))) + afterBatchRollback, err := s.migrationCount() + s.NoError(err) + s.Equal(total-1, afterBatchRollback) +} + +func (s *MigrationTestSuite) TestCommandMigrateStatus() { + ranOutput := s.captureArtisanOutput("--no-ansi migrate:status") + s.Contains(ranOutput, "Migration name") + s.Contains(ranOutput, "Batch / Status") + s.Contains(ranOutput, "20210101000001_create_users_table") + s.Contains(ranOutput, "20210101000002_create_jobs_table") + s.Contains(ranOutput, "20250330911908_add_columns_to_users_table") + s.Contains(ranOutput, "20250331093125_alert_columns_of_users_table") + s.Contains(ranOutput, "Ran") + + s.NoError(facades.Artisan().Call("--no-ansi migrate:reset")) + + pendingOutput := s.captureArtisanOutput("--no-ansi migrate:status") + s.Contains(pendingOutput, "Migration name") + s.Contains(pendingOutput, "Batch / Status") + s.Contains(pendingOutput, "20210101000001_create_users_table") + s.Contains(pendingOutput, "20210101000002_create_jobs_table") + s.Contains(pendingOutput, "20250330911908_add_columns_to_users_table") + s.Contains(pendingOutput, "20250331093125_alert_columns_of_users_table") + s.Contains(pendingOutput, "Pending") +} + +func (s *MigrationTestSuite) TestCommandMakeMigration() { + migrationsPath := path.Bootstrap("migrations.go") + originalContent, err := os.ReadFile(migrationsPath) + if err != nil { + s.T().Fatalf("read %s failed: %v", migrationsPath, err) + } + + s.T().Cleanup(func() { + if err := os.WriteFile(migrationsPath, originalContent, 0o644); err != nil { + s.T().Fatalf("restore %s failed: %v", migrationsPath, err) + } + }) + + beforeFiles := s.listMigrationFiles() + + driver := facades.Orm().Config().Driver + migrationName := "test_" + driver + "_" + cast.ToString(time.Now().UnixNano()) + s.NoError(facades.Artisan().Call("--no-ansi make:migration " + migrationName)) + + afterFiles := s.listMigrationFiles() + var createdFiles []string + for item := range afterFiles { + if _, ok := beforeFiles[item]; !ok { + createdFiles = append(createdFiles, item) + } + } + + s.Require().NotEmpty(createdFiles) + + migrationPath := path.Migration(createdFiles[0]) + s.Require().FileExists(migrationPath) + + s.T().Cleanup(func() { + if migrationPath != "" { + s.NoError(file.Remove(migrationPath)) + } + }) + + content, err := os.ReadFile(migrationPath) + s.Require().NoError(err) + + re := regexp.MustCompile(`type\s+(M[^\s]+)\s+struct`) + matches := re.FindStringSubmatch(string(content)) + s.Require().Len(matches, 2) + + structName := matches[1] + updatedBootstrap, err := os.ReadFile(migrationsPath) + s.Require().NoError(err) + s.Contains(string(updatedBootstrap), "&migrations."+structName+"{}") +} + func (s *MigrationTestSuite) TestTableComment() { if facades.Schema().Orm().Config().Driver == sqlite.Name || facades.Schema().Orm().Config().Driver == sqlserver.Name { s.T().Skip("sqlite and sqlserver does not support table comment") @@ -82,3 +280,48 @@ func (s *MigrationTestSuite) TestTableComment() { } } } + +func (s *MigrationTestSuite) migrationCount() (int64, error) { + table := facades.Config().GetString("database.migrations.table") + return facades.DB().Table(table).Count() +} + +func (s *MigrationTestSuite) latestMigrationBatch() (int, error) { + table := facades.Config().GetString("database.migrations.table") + + var batch int + err := facades.DB().Table(table).OrderByDesc("batch").Limit(1).Value("batch", &batch) + if err != nil { + return 0, err + } + + return batch, nil +} + +func (s *MigrationTestSuite) columnExists(table, column string) bool { + return facades.Schema().HasColumn(table, column) +} + +func (s *MigrationTestSuite) captureArtisanOutput(command string) string { + return color.CaptureOutput(func(_ io.Writer) { + s.NoError(facades.Artisan().Call(command)) + }) +} + +func (s *MigrationTestSuite) listMigrationFiles() map[string]struct{} { + migrationDir := path.Migration() + entries, err := os.ReadDir(migrationDir) + s.NoError(err) + + files := make(map[string]struct{}) + for _, entry := range entries { + if entry.IsDir() { + continue + } + if strings.HasSuffix(entry.Name(), ".go") { + files[entry.Name()] = struct{}{} + } + } + + return files +}