Files
go-admin/test/e2e-apporder/e2e_test.go
T
zhangwenjian b082ea5d85 test✅: fail the migrate exit-code test with bad SQL instead of a missing file
A missing config/db.sql now falls back to the embedded copy, so it no longer
makes the migration fail. Corrupt the on-disk copy, which takes precedence.
2026-10-07 17:40:06 +08:00

313 lines
9.5 KiB
Go

package main
import (
"database/sql"
"fmt"
"os"
"os/exec"
"path/filepath"
"strings"
"sync"
"testing"
_ "github.com/glebarez/go-sqlite"
)
// The whole 008 chain, against a binary that was actually built and a
// database that was actually migrated.
//
// Everything below this level is covered by unit tests with an injected
// engine and a hand-built schema, which is where the shapes are pinned down.
// What only this can catch is the wiring: that an application's init()
// reaches both registries, that `install` finds a manifest through
// app.Snapshot, that the seeder writes what the uninstaller looks for, and
// that the command exits non-zero when a migration fails - the last of which
// a deployment reads to decide whether to start the new version.
const settings = `settings:
application:
host: 0.0.0.0
mode: dev
name: e2e
port: 8000
readtimeout: 10000
writertimeout: 20000
database:
driver: sqlite3
source: ./e2e.db
jwt:
secret: e2e
timeout: 3600
logger:
path: temp/logs
stdout: default
level: error
enableddb: false
queue:
memory:
poolSize: 10
`
type env struct {
t *testing.T
dir string
bin string
}
// The binary is built once for the whole package. Every test drives the same
// one against its own directory and its own database, and a binary is
// read-only, so there is nothing to isolate - building it per test was three
// links of the same thing.
var (
buildOnce sync.Once
sharedDir string
sharedBin string
buildErr error
)
func TestMain(m *testing.M) {
code := m.Run()
if sharedDir != "" {
os.RemoveAll(sharedDir)
}
os.Exit(code)
}
// binary builds the go-admin binary with the example application linked in,
// on the first call that needs it.
func binary(t *testing.T) string {
t.Helper()
buildOnce.Do(func() {
sharedDir, buildErr = os.MkdirTemp("", "go-admin-e2e")
if buildErr != nil {
return
}
sharedBin = filepath.Join(sharedDir, "go-admin-e2e")
out, err := exec.Command("go", "build", "-tags", "sqlite3", "-o", sharedBin, ".").CombinedOutput()
if err != nil {
buildErr = fmt.Errorf("building the binary: %v\n%s", err, out)
}
})
if buildErr != nil {
t.Fatal(buildErr)
}
return sharedBin
}
// newEnv lays out a working directory for the binary to run in.
func newEnv(t *testing.T) *env {
t.Helper()
if testing.Short() {
t.Skip("builds a binary and migrates a database; skipped under -short")
}
dir := t.TempDir()
if err := os.MkdirAll(filepath.Join(dir, "config"), 0o755); err != nil {
t.Fatal(err)
}
if err := os.MkdirAll(filepath.Join(dir, "temp", "logs"), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(dir, "config", "settings.yml"), []byte(settings), 0o644); err != nil {
t.Fatal(err)
}
// The framework's first migration reads this file rather than carrying
// the rows in Go.
seedSQL, err := os.ReadFile(filepath.Join("..", "..", "config", "db.sql"))
if err != nil {
t.Fatalf("reading config/db.sql: %v", err)
}
if err := os.WriteFile(filepath.Join(dir, "config", "db.sql"), seedSQL, 0o644); err != nil {
t.Fatal(err)
}
return &env{t: t, dir: dir, bin: binary(t)}
}
// run executes the binary and returns its combined output and exit code.
func (e *env) run(args ...string) (string, int) {
e.t.Helper()
args = append(args, "-c", "config/settings.yml")
cmd := exec.Command(e.bin, args...)
cmd.Dir = e.dir
out, err := cmd.CombinedOutput()
code := 0
if ee, ok := err.(*exec.ExitError); ok {
code = ee.ExitCode()
} else if err != nil {
e.t.Fatalf("running %v: %v", args, err)
}
return string(out), code
}
func (e *env) mustRun(args ...string) string {
e.t.Helper()
out, code := e.run(args...)
if code != 0 {
e.t.Fatalf("%v exited %d:\n%s", args, code, out)
}
return out
}
// open connects to the database the binary uses.
//
// Per call, and closed again straight away, on purpose: the binary under test
// writes this same file, and a connection the test process holds open across
// a run of it is a second writer for no reason. The cost is a few
// milliseconds against a build measured in seconds.
func (e *env) open() *sql.DB {
e.t.Helper()
db, err := sql.Open("sqlite", filepath.Join(e.dir, "e2e.db"))
if err != nil {
e.t.Fatal(err)
}
return db
}
// exec runs one statement against the database the binary uses.
func (e *env) exec(stmt string) {
e.t.Helper()
db := e.open()
defer db.Close()
if _, err := db.Exec(stmt); err != nil {
e.t.Fatalf("%s: %v", stmt, err)
}
}
func (e *env) count(query string, args ...any) int {
e.t.Helper()
db := e.open()
defer db.Close()
var n int
if err := db.QueryRow(query, args...).Scan(&n); err != nil {
e.t.Fatalf("%s: %v", query, err)
}
return n
}
// seeded is what installing this application writes, with the counts a
// finished install leaves behind. An uninstall wants every one of them at
// zero, and a reinstall wants them back - which is why one list serves all
// three checks instead of three lists drifting apart.
var seeded = []struct {
what string
query string
want int
}{
{"menus", "SELECT COUNT(*) FROM sys_menu WHERE app_code = 'order'", 4},
{"apis", "SELECT COUNT(*) FROM sys_api WHERE app_code = 'order'", 4},
{"ledger", "SELECT COUNT(*) FROM sys_app_casbin_grant WHERE app_code = 'order'", 4},
{"policies", "SELECT COUNT(*) FROM casbin_rule WHERE v1 LIKE '/api/v1/order%'", 4},
{"migration records", "SELECT COUNT(*) FROM sys_migration WHERE app_code = 'order'", 1},
{"sys_app rows", "SELECT COUNT(*) FROM sys_app WHERE app_code = 'order'", 1},
}
// assertSeeded checks every row of seeded. gone flips the expectation to
// zero, which is the whole of what an uninstall has to leave.
func (e *env) assertSeeded(when string, gone bool) {
e.t.Helper()
for _, c := range seeded {
want := c.want
if gone {
want = 0
}
if n := e.count(c.query); n != want {
e.t.Errorf("%s, %s = %d, want %d", when, c.what, n, want)
}
}
}
func TestInstallUninstallReinstall(t *testing.T) {
e := newEnv(t)
// Framework only. The application's migration must not run here, or the
// install below has nothing left to do and the interesting half of it
// goes untested - which is what happens if this uses plain `migrate`.
e.mustRun("migrate", "--app", "core")
if n := e.count("SELECT COUNT(*) FROM sys_migration WHERE app_code = 'order'"); n != 0 {
t.Fatalf("the application's migration ran during the framework's: %d rows", n)
}
if n := e.count("SELECT COUNT(*) FROM sqlite_master WHERE name = 'app_order'"); n != 0 {
t.Fatal("the application's own table exists before it was installed")
}
// A1.
out := e.mustRun("migrate", "install", "order")
if !strings.Contains(out, "order-1793800000000") {
t.Errorf("the install did not report applying the migration:\n%s", out)
}
// A7.
if !strings.Contains(out, "rebuild") {
t.Errorf("the install did not say the code is not running yet:\n%s", out)
}
e.assertSeeded("after install", false)
if n := e.count("SELECT COUNT(*) FROM sqlite_master WHERE name = 'app_order'"); n != 1 {
t.Error("the application's own table was not created")
}
if n := e.count("SELECT COUNT(*) FROM sys_app WHERE app_code = 'order' AND status = 2"); n != 1 {
t.Error("sys_app does not say the install finished")
}
// A2: installing the same version again does nothing and says so.
out = e.mustRun("migrate", "install", "order")
if !strings.Contains(out, "already installed") {
t.Errorf("a second install was not reported as a no-op:\n%s", out)
}
// A3: the application's own data is not the uninstaller's to remove.
e.exec("INSERT INTO app_order (created_at, updated_at) VALUES (datetime('now'), datetime('now'))")
if n := e.count("SELECT COUNT(*) FROM app_order"); n != 1 {
t.Fatalf("the business row was not written: %d", n)
}
out = e.mustRun("migrate", "uninstall", "order")
if !strings.Contains(out, "own tables were not touched") {
t.Errorf("the uninstall did not say what it left alone:\n%s", out)
}
e.assertSeeded("after uninstall", true)
if n := e.count("SELECT COUNT(*) FROM sqlite_master WHERE name = 'app_order'"); n != 1 {
t.Error("the uninstall dropped the application's own table")
}
if n := e.count("SELECT COUNT(*) FROM app_order"); n != 1 {
t.Errorf("the uninstall removed %d business row(s)", 1-n)
}
// A4: the migration records had to go, or this reinstall finds every
// version applied, seeds nothing, and reports success.
e.mustRun("migrate", "install", "order")
e.assertSeeded("after reinstall", false)
if n := e.count("SELECT COUNT(*) FROM app_order"); n != 1 {
t.Error("the business row did not survive an uninstall and reinstall")
}
}
// A deployment decides whether to start the new version on this exit code.
func TestMigrateExitsNonZeroWhenItFails(t *testing.T) {
e := newEnv(t)
// The framework's first migration runs config/db.sql, and a copy on disk
// takes precedence over the embedded one. Statements that do not parse
// make that migration fail, which is the cheapest real failure to arrange.
// (Deleting the file no longer does: the embedded copy takes over.)
bad := "THIS IS NOT SQL;\nNEITHER IS THIS;\n"
if err := os.WriteFile(filepath.Join(e.dir, "config", "db.sql"), []byte(bad), 0o600); err != nil {
t.Fatal(err)
}
out, code := e.run("migrate")
if code == 0 {
t.Errorf("a failed migration exited 0:\n%s", out)
}
}
func TestUnknownAppIsRefused(t *testing.T) {
e := newEnv(t)
e.mustRun("migrate", "--app", "core")
out, code := e.run("migrate", "install", "ordr")
if code == 0 {
t.Errorf("a mistyped code was installed:\n%s", out)
}
if !strings.Contains(out, "order") {
t.Errorf("the refusal does not name what is registered:\n%s", out)
}
}