diff --git a/app/other/apis/tools/gen_audit_columns_test.go b/app/other/apis/tools/gen_audit_columns_test.go new file mode 100644 index 00000000..018f8a41 --- /dev/null +++ b/app/other/apis/tools/gen_audit_columns_test.go @@ -0,0 +1,298 @@ +package tools + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "testing" + "text/template" + + "github.com/gin-gonic/gin" + "github.com/go-admin-team/go-admin-core/v2/sdk/config" + "gorm.io/driver/mysql" + "gorm.io/gorm" + "gorm.io/gorm/logger" + + "go-admin/app/other/models/tools" +) + +// The config page reads a table's columns through SysTable.Get and saves them +// back through SysTable.Update. created_at and updated_at used to be filtered +// out of what Get returns, so no generated page could list, sort or filter by +// when a row was created unless someone edited sys_columns by hand. This +// drives that page's round trip, generates the table, and runs the result. +// genEnv is a generator pointed at the MySQL test database, writing into out +// and resolving its templates from this repository. +type genEnv struct { + db *gorm.DB + root string + out string +} + +func newGenEnv(t *testing.T) genEnv { + t.Helper() + dsn := os.Getenv(genMySQLDSNEnv) + if dsn == "" { + if os.Getenv("CI") != "" { + t.Fatalf("%s is not set while CI is: the generator must not go untested", genMySQLDSNEnv) + } + t.Skipf("%s is not set; the importer only reads MySQL", genMySQLDSNEnv) + } + gin.SetMode(gin.TestMode) + root, err := filepath.Abs("../../../..") + if err != nil { + t.Fatal(err) + } + db, err := gorm.Open(mysql.Open(dsn), &gorm.Config{Logger: logger.Default.LogMode(logger.Silent)}) + if err != nil { + t.Fatalf("connecting to %s: %v", genMySQLDSNEnv, err) + } + var dbName string + if err := db.Raw("SELECT DATABASE()").Scan(&dbName).Error; err != nil || dbName == "" { + t.Fatalf("reading the database name: %v", err) + } + + out := t.TempDir() + restore := [3]string{config.DatabaseConfig.Driver, config.GenConfig.DBName, config.GenConfig.FrontPath} + t.Cleanup(func() { + config.DatabaseConfig.Driver, config.GenConfig.DBName, config.GenConfig.FrontPath = restore[0], restore[1], restore[2] + }) + config.DatabaseConfig.Driver = "mysql" + config.GenConfig.DBName = dbName + config.GenConfig.FrontPath = filepath.Join(out, "ui") + + if err := db.AutoMigrate(&tools.SysTables{}, &tools.SysColumns{}); err != nil { + t.Fatalf("migrating the generator's own tables: %v", err) + } + if err := os.Symlink(filepath.Join(root, "template"), filepath.Join(out, "template")); err != nil { + t.Fatal(err) + } + t.Chdir(out) + return genEnv{db: db, root: root, out: out} +} + +// importTable creates table with the given column definitions and imports it +// into the generator, returning the route parameter that names it. +func (g genEnv) importTable(t *testing.T, table, columns string) gin.Params { + t.Helper() + dropFixture(t, g.db, table) + if err := g.db.Exec("CREATE TABLE " + table + " (" + columns + ")").Error; err != nil { + t.Fatalf("creating %s: %v", table, err) + } + t.Cleanup(func() { dropFixture(t, g.db, table) }) + if code := callHandler(t, g.db, SysTable{}.Insert, "/?tables="+table, nil); code != http.StatusOK { + t.Fatalf("importing %s: response code %d", table, code) + } + var row tools.SysTables + if err := g.db.Where("table_name = ?", table).First(&row).Error; err != nil { + t.Fatalf("finding %s after import: %v", table, err) + } + return gin.Params{{Key: "tableId", Value: itoa(row.TableId)}} +} + +func (g genEnv) page(t *testing.T, table string) string { + t.Helper() + b, err := os.ReadFile(filepath.Join(g.out, "ui", "views", "admin", strings.ReplaceAll(table, "_", "-"), "index.vue")) + if err != nil { + t.Fatal(err) + } + return string(b) +} + +func TestAuditTimestampsCanBeListedAndQueried(t *testing.T) { + g := newGenEnv(t) + db, root, out := g.db, g.root, g.out + const table = "gau_event" + tableID := g.importTable(t, table, "id int NOT NULL AUTO_INCREMENT PRIMARY KEY,"+auditColumns) + + // What the config page is shown. + var detail struct { + List []map[string]any `json:"list"` + Info map[string]any `json:"info"` + } + callJSON(t, db, SysTable{}.Get, http.MethodGet, "", tableID, &detail) + shown := map[string]map[string]any{} + for _, c := range detail.List { + shown[c["columnName"].(string)] = c + } + for _, name := range []string{"created_at", "updated_at"} { + if shown[name] == nil { + t.Errorf("the config page is not shown %s", name) + } + } + // Filled in by the framework on every write; a setting on them would + // either do nothing or break the generated model. + for _, name := range []string{"id", "create_by", "update_by", "deleted_at"} { + if shown[name] != nil { + t.Errorf("the config page is shown %s", name) + } + } + if t.Failed() { + return + } + + // What the page sends back once someone ticks created_at's list and query + // boxes and picks "greater than or equal". + shown["created_at"]["isList"] = "1" + shown["created_at"]["isQuery"] = "1" + shown["created_at"]["queryType"] = "GTE" + shown["updated_at"]["isList"] = "1" + // Ticked and then unticked: the checkbox's false-value. + shown["name"]["isQuery"] = "0" + detail.Info["columns"] = detail.List + body, _ := json.Marshal(detail.Info) + if code := callJSON(t, db, SysTable{}.Update, http.MethodPut, string(body), nil, nil); code != http.StatusOK { + t.Fatalf("saving the table config: response code %d", code) + } + + if code := callHandler(t, db, Gen{}.GenCode, "/", tableID); code != http.StatusOK { + t.Fatalf("generation failed with response code %d", code) + } + + vue := g.page(t, table) + for _, want := range []string{ + ``, + ``, + `v-model="table.query.createdAt"`, + } { + if !strings.Contains(vue, want) { + t.Errorf("the generated page has no %s", want) + } + } + for _, unwanted := range []string{"form.model.createdAt", "form.model.updatedAt"} { + if strings.Contains(vue, unwanted) { + t.Errorf("the generated form edits %s, which the framework fills in", unwanted) + } + } + + dto, err := os.ReadFile(filepath.Join(out, "app", "admin", "service", "dto", table+".go")) + if err != nil { + t.Fatal(err) + } + if strings.Contains(string(dto), `form:"name"`) { + t.Error("name, saved with isQuery \"0\", is a search field of the generated list request") + } + + generated := backendFiles(out, root, table) + test := filepath.Join(out, "generated_audit_test.go") + f, err := os.Create(test) + if err != nil { + t.Fatal(err) + } + if err := auditQueryTest.Execute(f, table); err != nil { + t.Fatal(err) + } + f.Close() + generated[filepath.Join(root, "app/admin/apis/zz_generated_audit_test.go")] = test + overlay := filepath.Join(out, "overlay.json") + b, _ := json.Marshal(map[string]any{"Replace": generated}) + if err := os.WriteFile(overlay, b, 0o644); err != nil { + t.Fatal(err) + } + + goCmd(t, root, "vet", "-overlay="+overlay, "./app/admin/...") + ran := goCmd(t, root, "test", "-overlay="+overlay, "-count=1", "-v", "-run", "^TestGeneratedAuditQuery$", "./app/admin/apis/") + if !strings.Contains(string(ran), "--- PASS: TestGeneratedAuditQuery ") { + t.Error("TestGeneratedAuditQuery did not run") + } +} + +// callJSON runs one handler with a JSON body and decodes the response's data +// into data when it is not nil. It returns the code the body carries. +func callJSON(t *testing.T, db *gorm.DB, h gin.HandlerFunc, method, body string, params gin.Params, data any) int { + t.Helper() + w := httptest.NewRecorder() + c, _ := gin.CreateTestContext(w) + c.Request = httptest.NewRequest(method, "/", strings.NewReader(body)) + c.Request.Header.Set("Content-Type", "application/json") + c.Params = params + c.Set("db", db) + h(c) + var res struct { + Code int `json:"code"` + Data json.RawMessage `json:"data"` + } + if err := json.Unmarshal(w.Body.Bytes(), &res); err != nil { + t.Fatalf("decoding the response: %v (body %q)", err, w.Body.String()) + } + if data != nil { + if err := json.Unmarshal(res.Data, data); err != nil { + t.Fatalf("decoding the response data: %v (body %q)", err, w.Body.String()) + } + } + if res.Code != http.StatusOK { + t.Logf("response: %s", w.Body.String()) + } + return res.Code +} + +// auditQueryTest lists the generated table filtered on created_at, sent in +// the format the generated page's date picker sends (value-format +// "YYYY-MM-DD[T]HH:mm:ssZ"). +var auditQueryTest = template.Must(template.New("audit").Parse(`package apis + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "net/url" + "os" + "testing" + "time" + + "github.com/gin-gonic/gin" + "gorm.io/driver/mysql" + "gorm.io/gorm" + "gorm.io/gorm/logger" +) + +func TestGeneratedAuditQuery(t *testing.T) { + db, err := gorm.Open(mysql.Open(os.Getenv("GO_ADMIN_TEST_MYSQL_DSN")), &gorm.Config{Logger: logger.Default.LogMode(logger.Silent)}) + if err != nil { + t.Fatal(err) + } + gin.SetMode(gin.TestMode) + r := gin.New() + r.Use(func(c *gin.Context) { c.Set("db", db.WithContext(c)); c.Next() }) + r.GET("/x", GauEvent{}.GetPage) + + old := time.Date(2020, 1, 1, 0, 0, 0, 0, time.Local) + recent := time.Now().Add(-time.Hour) + for name, at := range map[string]time.Time{"old": old, "recent": recent} { + if err := db.Exec("INSERT INTO {{.}} (name, created_at, updated_at) VALUES (?, ?, ?)", name, at, at).Error; err != nil { + t.Fatal(err) + } + } + + list := func(query string) []map[string]any { + t.Helper() + w := httptest.NewRecorder() + r.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/x?pageIndex=1&pageSize=10"+query, nil)) + var res struct { + Code int + Data struct{ List []map[string]any } + } + if err := json.Unmarshal(w.Body.Bytes(), &res); err != nil || res.Code != 200 { + t.Fatalf("listing %q: code %d, err %v, body %s", query, res.Code, err, w.Body.String()) + } + return res.Data.List + } + + all := list("") + if len(all) != 2 { + t.Fatalf("unfiltered list has %d rows, want 2", len(all)) + } + if all[0]["createdAt"] == nil || all[0]["updatedAt"] == nil { + t.Errorf("a listed row carries no createdAt/updatedAt: %v", all[0]) + } + + since := time.Date(2025, 1, 1, 0, 0, 0, 0, time.Local).Format("2006-01-02T15:04:05Z07:00") + got := list("&createdAt=" + url.QueryEscape(since)) + if len(got) != 1 || got[0]["name"] != "recent" { + t.Errorf("createdAt >= %s listed %v, want only the recent row", since, got) + } +} +`)) diff --git a/app/other/models/tools/sys_columns.go b/app/other/models/tools/sys_columns.go index e901cbfa..758a8a56 100644 --- a/app/other/models/tools/sys_columns.go +++ b/app/other/models/tools/sys_columns.go @@ -70,13 +70,11 @@ func (e *SysColumns) GetList(tx *gorm.DB, exclude bool) ([]SysColumns, error) { table := tx.Table("sys_columns") table = table.Where("table_id = ? ", e.TableId) if exclude { - notIn := make([]string, 0, 6) - notIn = append(notIn, "id") - notIn = append(notIn, "create_by") - notIn = append(notIn, "update_by") - notIn = append(notIn, "created_at") - notIn = append(notIn, "updated_at") - notIn = append(notIn, "deleted_at") + // Columns the framework fills in on every write, which a setting on the + // config page could not change. created_at and updated_at are not + // among them: whether a page lists or filters by them is a choice the + // page makes, and the templates already keep them out of the form. + notIn := []string{"id", "create_by", "update_by", "deleted_at"} table = table.Where(" column_name not in(?)", notIn) }