feat: optional MySQL/MariaDB database driver for deployments that cannot use local SQLite
This commit is contained in:
1 parent
81e299f09a
commit
d752a61e52
35 files changed
+2896
-76
No files matched your search
@@ -0,0 +1,559 @@
|
||||
package test
|
||||
|
||||
import (
|
||||
"RenewCMS/internal/infrastructure/config"
|
||||
"RenewCMS/internal/infrastructure/persistence/database"
|
||||
"bytes"
|
||||
"context"
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
mysqldriver "github.com/go-sql-driver/mysql"
|
||||
)
|
||||
|
||||
const mysqlTestPassword = "s3cr3t/Value@42:x"
|
||||
|
||||
func setMySQLEnv(t *testing.T) {
|
||||
t.Helper()
|
||||
setBaseSettingsEnv(t)
|
||||
t.Setenv("DB_DRIVER", "mysql")
|
||||
t.Setenv("DB_HOST", "db.local")
|
||||
t.Setenv("DB_USER", "app")
|
||||
t.Setenv("DB_PASSWORD", mysqlTestPassword)
|
||||
t.Setenv("DB_NAME", "cms")
|
||||
for _, optional := range []string{"DB_PORT", "DB_TLS", "DB_MAX_OPEN_CONNS", "DB_MAX_IDLE_CONNS", "DB_CONN_MAX_LIFETIME_SECONDS", "DATA_DIR"} {
|
||||
t.Setenv(optional, "")
|
||||
}
|
||||
}
|
||||
|
||||
func lookupFrom(values map[string]string) func(string) (string, bool) {
|
||||
return func(name string) (string, bool) {
|
||||
value, found := values[name]
|
||||
return value, found
|
||||
}
|
||||
}
|
||||
|
||||
func TestMySQLDriverSelection(t *testing.T) {
|
||||
t.Run("Driver selection", func(t *testing.T) {
|
||||
cases := []struct {
|
||||
value string
|
||||
set bool
|
||||
driver string
|
||||
}{
|
||||
{"", false, "sqlite"},
|
||||
{"sqlite", true, "sqlite"},
|
||||
{"mysql", true, "mysql"},
|
||||
{"oracle", true, "oracle"},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(fmt.Sprintf("DB_DRIVER %q", tc.value), func(t *testing.T) {
|
||||
setMySQLEnv(t)
|
||||
if tc.set {
|
||||
t.Setenv("DB_DRIVER", tc.value)
|
||||
} else {
|
||||
t.Setenv("DB_DRIVER", "")
|
||||
}
|
||||
|
||||
settings, err := config.NewSettingsFromEnv()
|
||||
|
||||
if err != nil {
|
||||
t.Fatalf("settings: %v", err)
|
||||
}
|
||||
if settings.Database.Driver != tc.driver {
|
||||
t.Errorf("expected driver %q, got %q", tc.driver, settings.Database.Driver)
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("an unregistered driver fails with its name", func(t *testing.T) {
|
||||
_, err := database.Connect(database.Config{Driver: "oracle", Source: "x"})
|
||||
|
||||
if err == nil || err.Error() != `unsupported database driver "oracle"` {
|
||||
t.Errorf("unexpected error %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestMySQLEnvironmentValidation(t *testing.T) {
|
||||
complete := map[string]string{"DB_HOST": "h", "DB_USER": "u", "DB_PASSWORD": "p", "DB_NAME": "n"}
|
||||
|
||||
t.Run("Missing MySQL variable", func(t *testing.T) {
|
||||
for _, missing := range []string{"DB_HOST", "DB_USER", "DB_PASSWORD", "DB_NAME"} {
|
||||
t.Run(missing, func(t *testing.T) {
|
||||
values := map[string]string{}
|
||||
for name, value := range complete {
|
||||
if name != missing {
|
||||
values[name] = value
|
||||
}
|
||||
}
|
||||
|
||||
err := config.MissingEnvVarError(config.DatabaseRequiredEnvVars("mysql"), lookupFrom(values))
|
||||
|
||||
expected := "Environment variable " + missing + " is not set"
|
||||
if err == nil || err.Error() != expected {
|
||||
t.Errorf("expected %q, got %v", expected, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("SQLite does not require MySQL variables", func(t *testing.T) {
|
||||
for _, driver := range []string{"sqlite", "", "oracle"} {
|
||||
if err := config.MissingEnvVarError(config.DatabaseRequiredEnvVars(driver), lookupFrom(nil)); err != nil {
|
||||
t.Errorf("driver %q: expected success, got %v", driver, err)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("all MySQL variables set passes validation", func(t *testing.T) {
|
||||
if err := config.MissingEnvVarError(config.DatabaseRequiredEnvVars("mysql"), lookupFrom(complete)); err != nil {
|
||||
t.Errorf("expected success, got %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestMySQLSettingsFromEnv(t *testing.T) {
|
||||
t.Run("defaults apply when the optional variables are empty", func(t *testing.T) {
|
||||
setMySQLEnv(t)
|
||||
|
||||
settings, err := config.NewSettingsFromEnv()
|
||||
|
||||
if err != nil {
|
||||
t.Fatalf("settings: %v", err)
|
||||
}
|
||||
database := settings.Database
|
||||
if database.Port != "3306" || database.TLS != "false" || database.MaxOpenConns != 10 || database.MaxIdleConns != 5 || database.ConnMaxLifetime != 300*time.Second {
|
||||
t.Errorf("unexpected defaults %+v", database)
|
||||
}
|
||||
if database.Host != "db.local" || database.User != "app" || database.Password != mysqlTestPassword || database.Name != "cms" {
|
||||
t.Errorf("unexpected connection fields %+v", database)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Invalid TLS option is rejected", func(t *testing.T) {
|
||||
setMySQLEnv(t)
|
||||
t.Setenv("DB_TLS", "maybe")
|
||||
|
||||
_, err := config.NewSettingsFromEnv()
|
||||
|
||||
if err == nil {
|
||||
t.Fatal("expected an error")
|
||||
}
|
||||
for _, expected := range []string{"DB_TLS", "false", "true", "skip-verify", "preferred"} {
|
||||
if !strings.Contains(err.Error(), expected) {
|
||||
t.Errorf("error %q must mention %q", err, expected)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("every accepted TLS option is read", func(t *testing.T) {
|
||||
for _, mode := range []string{"false", "true", "skip-verify", "preferred"} {
|
||||
setMySQLEnv(t)
|
||||
t.Setenv("DB_TLS", mode)
|
||||
|
||||
settings, err := config.NewSettingsFromEnv()
|
||||
|
||||
if err != nil || settings.Database.TLS != mode {
|
||||
t.Errorf("mode %q: settings %+v, err %v", mode, settings.Database, err)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("a non numeric pool variable names the variable", func(t *testing.T) {
|
||||
for _, name := range []string{"DB_MAX_OPEN_CONNS", "DB_MAX_IDLE_CONNS", "DB_CONN_MAX_LIFETIME_SECONDS"} {
|
||||
setMySQLEnv(t)
|
||||
t.Setenv(name, "many")
|
||||
|
||||
_, err := config.NewSettingsFromEnv()
|
||||
|
||||
if err == nil || !strings.Contains(err.Error(), name) {
|
||||
t.Errorf("%s: expected an error naming it, got %v", name, err)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("a negative pool variable is rejected", func(t *testing.T) {
|
||||
setMySQLEnv(t)
|
||||
t.Setenv("DB_MAX_OPEN_CONNS", "-1")
|
||||
|
||||
if _, err := config.NewSettingsFromEnv(); err == nil {
|
||||
t.Error("expected an error")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("zero is an accepted pool value", func(t *testing.T) {
|
||||
setMySQLEnv(t)
|
||||
t.Setenv("DB_MAX_OPEN_CONNS", "0")
|
||||
|
||||
settings, err := config.NewSettingsFromEnv()
|
||||
|
||||
if err != nil || settings.Database.MaxOpenConns != 0 {
|
||||
t.Errorf("unexpected settings %+v, err %v", settings.Database, err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("pool variables are read", func(t *testing.T) {
|
||||
setMySQLEnv(t)
|
||||
t.Setenv("DB_MAX_OPEN_CONNS", "4")
|
||||
t.Setenv("DB_MAX_IDLE_CONNS", "2")
|
||||
t.Setenv("DB_CONN_MAX_LIFETIME_SECONDS", "60")
|
||||
t.Setenv("DB_PORT", "3307")
|
||||
|
||||
settings, err := config.NewSettingsFromEnv()
|
||||
|
||||
database := settings.Database
|
||||
if err != nil || database.MaxOpenConns != 4 || database.MaxIdleConns != 2 || database.ConnMaxLifetime != 60*time.Second || database.Port != "3307" {
|
||||
t.Errorf("unexpected settings %+v, err %v", database, err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("SQLite ignores the MySQL variables", func(t *testing.T) {
|
||||
setMySQLEnv(t)
|
||||
t.Setenv("DB_DRIVER", "sqlite")
|
||||
t.Setenv("DB_TLS", "maybe")
|
||||
t.Setenv("DB_MAX_OPEN_CONNS", "many")
|
||||
|
||||
settings, err := config.NewSettingsFromEnv()
|
||||
|
||||
if err != nil || settings.Database.Driver != "sqlite" || settings.Database.Host != "" {
|
||||
t.Errorf("unexpected settings %+v, err %v", settings.Database, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestMySQLDSN(t *testing.T) {
|
||||
t.Run("DSN built from variables", func(t *testing.T) {
|
||||
cases := []struct {
|
||||
host, port, expectedPort, tls string
|
||||
}{
|
||||
{"db.local", "", "3306", "false"},
|
||||
{"db.local", "3307", "3307", "true"},
|
||||
{"mysql", "3306", "3306", "skip-verify"},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.host+":"+tc.port+" tls "+tc.tls, func(t *testing.T) {
|
||||
port := tc.port
|
||||
if port == "" {
|
||||
port = config.DefaultDatabasePort
|
||||
}
|
||||
|
||||
dsn := database.MySQLDSN(database.MySQLConfig{Host: tc.host, Port: port, User: "app", Password: "pw", Name: "cms", TLS: tc.tls})
|
||||
|
||||
parsed, err := mysqldriver.ParseDSN(dsn)
|
||||
if err != nil {
|
||||
t.Fatalf("parse: %v", err)
|
||||
}
|
||||
if parsed.Addr != tc.host+":"+tc.expectedPort || parsed.DBName != "cms" || parsed.User != "app" {
|
||||
t.Errorf("unexpected target %q db %q user %q", parsed.Addr, parsed.DBName, parsed.User)
|
||||
}
|
||||
for _, fragment := range []string{"parseTime=true", "loc=UTC", "charset=utf8mb4", "clientFoundRows=true", "tls=" + tc.tls} {
|
||||
if !strings.Contains(dsn, fragment) {
|
||||
t.Errorf("dsn must contain %q", fragment)
|
||||
}
|
||||
}
|
||||
if parsed.Timeout <= 0 || parsed.Timeout > 10*time.Second {
|
||||
t.Errorf("connect timeout must be short, got %v", parsed.Timeout)
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("a password with reserved characters survives the DSN", func(t *testing.T) {
|
||||
dsn := database.MySQLDSN(database.MySQLConfig{Host: "db.local", Port: "3306", User: "app", Password: mysqlTestPassword, Name: "cms", TLS: "false"})
|
||||
|
||||
parsed, err := mysqldriver.ParseDSN(dsn)
|
||||
|
||||
if err != nil || parsed.Passwd != mysqlTestPassword || parsed.DBName != "cms" || parsed.Addr != "db.local:3306" {
|
||||
t.Errorf("unexpected parse %+v err %v", parsed, err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("the connection uses a binary collation so Bob and bob stay distinct", func(t *testing.T) {
|
||||
parsed, _ := mysqldriver.ParseDSN(database.MySQLDSN(database.MySQLConfig{Host: "h", Port: "1", User: "u", Name: "n", TLS: "false"}))
|
||||
|
||||
if parsed.Collation != "utf8mb4_bin" {
|
||||
t.Errorf("unexpected collation %q", parsed.Collation)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestMySQLDataDir(t *testing.T) {
|
||||
t.Run("DataDir resolution", func(t *testing.T) {
|
||||
cases := []struct{ dbFile, dataDir, result string }{
|
||||
{"./data/renewcms.db", "", "./data"},
|
||||
{"/var/lib/cms/cms.db", "", "/var/lib/cms"},
|
||||
{"./data/renewcms.db", "/srv/x", "/srv/x"},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.dbFile+" "+tc.dataDir, func(t *testing.T) {
|
||||
settings := config.Settings{DBFile: tc.dbFile, DataDirPath: tc.dataDir}
|
||||
|
||||
if got := settings.DataDir(); filepath.Clean(got) != filepath.Clean(tc.result) {
|
||||
t.Errorf("expected %q, got %q", tc.result, got)
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Data directory with MySQL", func(t *testing.T) {
|
||||
setMySQLEnv(t)
|
||||
t.Setenv("DATA_DIR", "/data")
|
||||
t.Setenv("DB_FILE", "./elsewhere/ignored.db")
|
||||
|
||||
settings, err := config.NewSettingsFromEnv()
|
||||
|
||||
if err != nil || filepath.Clean(settings.DataDir()) != filepath.Clean("/data") {
|
||||
t.Errorf("unexpected data dir %q err %v", settings.DataDir(), err)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestMySQLConnectionPool(t *testing.T) {
|
||||
t.Run("Connection pool", func(t *testing.T) {
|
||||
pool := database.PoolSettings{MaxOpenConns: 4, MaxIdleConns: 2, ConnMaxLifetime: 60 * time.Second}
|
||||
connection, err := database.Connect(database.Config{Driver: database.DriverSQLite, Source: filepath.ToSlash(filepath.Join(t.TempDir(), "pool.db")), Pool: &pool})
|
||||
if err != nil {
|
||||
t.Fatalf("connect: %v", err)
|
||||
}
|
||||
defer connection.Close()
|
||||
sqlDb, _ := connection.DB.DB()
|
||||
|
||||
held := make([]*sql.Conn, 0, 4)
|
||||
for range 4 {
|
||||
conn, err := sqlDb.Conn(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("conn: %v", err)
|
||||
}
|
||||
held = append(held, conn)
|
||||
}
|
||||
blocked, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond)
|
||||
defer cancel()
|
||||
_, fifthErr := sqlDb.Conn(blocked)
|
||||
for _, conn := range held {
|
||||
_ = conn.Close()
|
||||
}
|
||||
|
||||
stats := sqlDb.Stats()
|
||||
if stats.MaxOpenConnections != 4 {
|
||||
t.Errorf("expected 4 open connections at most, got %d", stats.MaxOpenConnections)
|
||||
}
|
||||
if fifthErr == nil {
|
||||
t.Error("a fifth concurrent connection must wait for the pool")
|
||||
}
|
||||
if stats.Idle != 2 {
|
||||
t.Errorf("expected 2 idle connections, got %d", stats.Idle)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("the connection lifetime closes old connections", func(t *testing.T) {
|
||||
pool := database.PoolSettings{MaxOpenConns: 1, MaxIdleConns: 1, ConnMaxLifetime: 20 * time.Millisecond}
|
||||
connection, err := database.Connect(database.Config{Driver: database.DriverSQLite, Source: filepath.ToSlash(filepath.Join(t.TempDir(), "life.db")), Pool: &pool})
|
||||
if err != nil {
|
||||
t.Fatalf("connect: %v", err)
|
||||
}
|
||||
defer connection.Close()
|
||||
sqlDb, _ := connection.DB.DB()
|
||||
_ = sqlDb.Ping()
|
||||
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
_ = sqlDb.Ping()
|
||||
|
||||
if sqlDb.Stats().MaxLifetimeClosed == 0 {
|
||||
t.Error("an expired connection must be closed")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("settings without a pool leave the defaults untouched", func(t *testing.T) {
|
||||
connection, err := database.Connect(database.Config{Driver: database.DriverSQLite, Source: filepath.ToSlash(filepath.Join(t.TempDir(), "plain.db"))})
|
||||
if err != nil {
|
||||
t.Fatalf("connect: %v", err)
|
||||
}
|
||||
defer connection.Close()
|
||||
sqlDb, _ := connection.DB.DB()
|
||||
|
||||
if sqlDb.Stats().MaxOpenConnections != 0 {
|
||||
t.Errorf("expected an unlimited pool, got %d", sqlDb.Stats().MaxOpenConnections)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func closedLocalPort(t *testing.T) string {
|
||||
t.Helper()
|
||||
return freeTCPPort(t)
|
||||
}
|
||||
|
||||
func mysqlDSNWithTimeout(config database.MySQLConfig, timeout time.Duration) string {
|
||||
parsed, _ := mysqldriver.ParseDSN(database.MySQLDSN(config))
|
||||
parsed.Timeout = timeout
|
||||
return parsed.FormatDSN()
|
||||
}
|
||||
|
||||
func TestMySQLFailFast(t *testing.T) {
|
||||
t.Run("nothing listens on DB_PORT", func(t *testing.T) {
|
||||
port := closedLocalPort(t)
|
||||
dsn := mysqlDSNWithTimeout(database.MySQLConfig{Host: "127.0.0.1", Port: port, User: "app", Password: mysqlTestPassword, Name: "cms", TLS: "false"}, time.Second)
|
||||
started := time.Now()
|
||||
|
||||
_, err := database.Connect(database.Config{Driver: database.DriverMySQL, Source: dsn})
|
||||
|
||||
if err == nil {
|
||||
t.Fatal("expected an error")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "127.0.0.1:"+port) {
|
||||
t.Errorf("the error must name the host and port: %v", err)
|
||||
}
|
||||
if strings.Contains(err.Error(), mysqlTestPassword) {
|
||||
t.Errorf("the error must not contain the password: %v", err)
|
||||
}
|
||||
if elapsed := time.Since(started); elapsed > 4*time.Second {
|
||||
t.Errorf("startup must fail fast, took %v", elapsed)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("an empty password leaves the error message untouched", func(t *testing.T) {
|
||||
port := closedLocalPort(t)
|
||||
dsn := mysqlDSNWithTimeout(database.MySQLConfig{Host: "127.0.0.1", Port: port, User: "app", Name: "cms", TLS: "false"}, time.Second)
|
||||
|
||||
_, err := database.Connect(database.Config{Driver: database.DriverMySQL, Source: dsn})
|
||||
|
||||
if err == nil {
|
||||
t.Fatal("expected an error")
|
||||
}
|
||||
if strings.Contains(err.Error(), "***") {
|
||||
t.Errorf("an empty password must not be redacted into the message: %v", err)
|
||||
}
|
||||
if !strings.Contains(err.Error(), "127.0.0.1:"+port) {
|
||||
t.Errorf("the error must name the host and port: %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("an unusable DSN is rejected without echoing it", func(t *testing.T) {
|
||||
_, err := database.Connect(database.Config{Driver: database.DriverMySQL, Source: "user:" + mysqlTestPassword + "@broken"})
|
||||
|
||||
if err == nil || strings.Contains(err.Error(), mysqlTestPassword) {
|
||||
t.Errorf("unexpected error %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func startupEnvironment(t *testing.T, extra ...string) []string {
|
||||
t.Helper()
|
||||
dataDir := t.TempDir()
|
||||
return append(emptyEnvironment(),
|
||||
append([]string{
|
||||
"ENVIRONMENT=development", "PORT=" + freeTCPPort(t), "PUBLIC_URL=http://localhost", "JWT_SECRET=test-secret-for-startup",
|
||||
"SMTP_LOGIN=a@example.com", "SMTP_SENDER=a@example.com", "SMTP_PASSWORD=x", "SMTP_HOST=localhost", "SMTP_PORT=2525",
|
||||
"DB_FILE=" + filepath.Join(dataDir, "renewcms.db"), "UPLOAD_DIR=" + filepath.Join(dataDir, "uploads"),
|
||||
"UPDATE_FEED_URL=https://git.example",
|
||||
}, extra...)...)
|
||||
}
|
||||
|
||||
func runServerWithEnvironment(t *testing.T, binary string, environment []string) (string, int) {
|
||||
t.Helper()
|
||||
command := exec.Command(binary)
|
||||
command.Dir = t.TempDir()
|
||||
command.Env = environment
|
||||
var output bytes.Buffer
|
||||
command.Stdout, command.Stderr = &output, &output
|
||||
err := command.Run()
|
||||
if exitErr, ok := err.(*exec.ExitError); ok {
|
||||
return output.String(), exitErr.ExitCode()
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return output.String(), 0
|
||||
}
|
||||
|
||||
func TestMySQLStartupFailures(t *testing.T) {
|
||||
binary := buildServerBinary(t, "")
|
||||
|
||||
t.Run("an unsupported driver stops the server", func(t *testing.T) {
|
||||
output, code := runServerWithEnvironment(t, binary, startupEnvironment(t, "DB_DRIVER=oracle"))
|
||||
|
||||
if code == 0 || !strings.Contains(output, `unsupported database driver "oracle"`) {
|
||||
t.Errorf("code %d output %q", code, output)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Missing MySQL variable stops the server", func(t *testing.T) {
|
||||
complete := map[string]string{"DB_HOST": "127.0.0.1", "DB_USER": "app", "DB_PASSWORD": mysqlTestPassword, "DB_NAME": "cms"}
|
||||
for missing := range complete {
|
||||
t.Run(missing, func(t *testing.T) {
|
||||
extra := []string{"DB_DRIVER=mysql"}
|
||||
for name, value := range complete {
|
||||
if name != missing {
|
||||
extra = append(extra, name+"="+value)
|
||||
}
|
||||
}
|
||||
|
||||
output, code := runServerWithEnvironment(t, binary, startupEnvironment(t, extra...))
|
||||
|
||||
if code == 0 || !strings.Contains(output, "Environment variable "+missing+" is not set") {
|
||||
t.Errorf("code %d output %q", code, output)
|
||||
}
|
||||
if strings.Contains(output, mysqlTestPassword) {
|
||||
t.Errorf("the output must not contain the password: %q", output)
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("an invalid DB_TLS stops the server naming the variable", func(t *testing.T) {
|
||||
output, code := runServerWithEnvironment(t, binary, startupEnvironment(t,
|
||||
"DB_DRIVER=mysql", "DB_HOST=127.0.0.1", "DB_USER=app", "DB_PASSWORD="+mysqlTestPassword, "DB_NAME=cms", "DB_TLS=maybe"))
|
||||
|
||||
if code == 0 || !strings.Contains(output, "DB_TLS") || strings.Contains(output, mysqlTestPassword) {
|
||||
t.Errorf("code %d output %q", code, output)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Fail fast when the server cannot be used", func(t *testing.T) {
|
||||
port := closedLocalPort(t)
|
||||
|
||||
output, code := runServerWithEnvironment(t, binary, startupEnvironment(t,
|
||||
"DB_DRIVER=mysql", "DB_HOST=127.0.0.1", "DB_PORT="+port, "DB_USER=app", "DB_PASSWORD="+mysqlTestPassword, "DB_NAME=cms"))
|
||||
|
||||
if code == 0 || !strings.Contains(output, "Unable to open the database") {
|
||||
t.Errorf("code %d output %q", code, output)
|
||||
}
|
||||
if !strings.Contains(output, "127.0.0.1:"+port) {
|
||||
t.Errorf("the message must name the host and port: %q", output)
|
||||
}
|
||||
if strings.Contains(output, mysqlTestPassword) {
|
||||
t.Errorf("the message must not contain the password: %q", output)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("the SQLite default starts without any MySQL variable", func(t *testing.T) {
|
||||
output, code := runServerWithEnvironment(t, binary, startupEnvironment(t, "PORT=99999"))
|
||||
|
||||
bindFailed := strings.Contains(output, "invalid port")
|
||||
if code == 0 || !bindFailed || strings.Contains(output, "Environment variable DB_") {
|
||||
t.Errorf("startup must get as far as binding the port, code %d output %q", code, output)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestMySQLSettingsAreNeverPrinted(t *testing.T) {
|
||||
t.Run("Secrets are never exposed", func(t *testing.T) {
|
||||
setMySQLEnv(t)
|
||||
settings, err := config.NewSettingsFromEnv()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
dsn := mysqlDSNWithTimeout(database.MySQLConfig{Host: "127.0.0.1", Port: closedLocalPort(t), User: settings.Database.User, Password: settings.Database.Password, Name: settings.Database.Name, TLS: settings.Database.TLS}, time.Second)
|
||||
_, err = database.Connect(database.Config{Driver: database.DriverMySQL, Source: dsn})
|
||||
|
||||
if err == nil || strings.Contains(err.Error(), settings.Database.Password) {
|
||||
t.Errorf("unexpected error %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,311 @@
|
||||
package test
|
||||
|
||||
import (
|
||||
domainArticle "RenewCMS/internal/domain/article"
|
||||
domainInvite "RenewCMS/internal/domain/invite"
|
||||
"RenewCMS/internal/infrastructure/persistence/database"
|
||||
"RenewCMS/internal/infrastructure/persistence/models"
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
mysqldriver "github.com/go-sql-driver/mysql"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
func migratedMySQL(t *testing.T) (*mysqlTestServer, database.MySQLConfig, *gorm.DB) {
|
||||
t.Helper()
|
||||
server := connectMySQLTestServer(t)
|
||||
config := server.createDatabase(t)
|
||||
connection := server.connect(t, config)
|
||||
if err := database.Migrate(connection.DB); err != nil {
|
||||
t.Fatalf("migrate: %v", err)
|
||||
}
|
||||
return server, config, connection.DB
|
||||
}
|
||||
|
||||
func columnCount(t *testing.T, db *gorm.DB, query string, arguments ...any) int {
|
||||
t.Helper()
|
||||
var count int
|
||||
if err := db.Raw(query, arguments...).Scan(&count).Error; err != nil {
|
||||
t.Fatalf("query %q: %v", query, err)
|
||||
}
|
||||
return count
|
||||
}
|
||||
|
||||
func TestMySQLMigration(t *testing.T) {
|
||||
t.Run("Migration on MySQL", func(t *testing.T) {
|
||||
_, _, db := migratedMySQL(t)
|
||||
|
||||
for _, table := range []string{"articles", "images", "users", "invites", "api_keys", "purge_settings"} {
|
||||
if !db.Migrator().HasTable(table) {
|
||||
t.Errorf("table %s must exist", table)
|
||||
}
|
||||
}
|
||||
uniqueColumns := map[string][]string{
|
||||
"users": {"username", "email", "password_reset_code", "verification_code"},
|
||||
"api_keys": {"hashed_key"},
|
||||
}
|
||||
for table, columns := range uniqueColumns {
|
||||
for _, column := range columns {
|
||||
count := columnCount(t, db, "SELECT COUNT(*) FROM information_schema.statistics WHERE table_schema = DATABASE() AND table_name = ? AND column_name = ? AND non_unique = 0 AND seq_in_index = 1", table, column)
|
||||
if count == 0 {
|
||||
t.Errorf("a unique index on %s.%s must exist", table, column)
|
||||
}
|
||||
}
|
||||
}
|
||||
binaryTables := columnCount(t, db, "SELECT COUNT(*) FROM information_schema.tables WHERE table_schema = DATABASE() AND table_collation = 'utf8mb4_bin'")
|
||||
if binaryTables != 6 {
|
||||
t.Errorf("every table must use the utf8mb4_bin collation, got %d of 6", binaryTables)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("migrating twice changes nothing", func(t *testing.T) {
|
||||
_, _, db := migratedMySQL(t)
|
||||
|
||||
if err := database.Migrate(db); err != nil {
|
||||
t.Errorf("the second migration must succeed: %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("the first user lacking a role becomes owner and the others admin", func(t *testing.T) {
|
||||
_, _, db := migratedMySQL(t)
|
||||
for _, name := range []string{"first", "second", "third"} {
|
||||
insert := db.Exec("INSERT INTO users (username, password, email, role, is_verified, token_version) VALUES (?, 'x', ?, '', 1, 0)", name, name+"@x.example")
|
||||
if insert.Error != nil {
|
||||
t.Fatalf("insert %s: %v", name, insert.Error)
|
||||
}
|
||||
}
|
||||
|
||||
if err := database.Migrate(db); err != nil {
|
||||
t.Fatalf("migrate: %v", err)
|
||||
}
|
||||
|
||||
roles := map[string]string{}
|
||||
var users []models.User
|
||||
db.Find(&users)
|
||||
for _, user := range users {
|
||||
roles[user.Username] = user.Role
|
||||
}
|
||||
if roles["first"] != "owner" || roles["second"] != "admin" || roles["third"] != "admin" {
|
||||
t.Errorf("unexpected roles %v", roles)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestMySQLTimes(t *testing.T) {
|
||||
t.Run("times are stored and read back in UTC", func(t *testing.T) {
|
||||
_, _, db := migratedMySQL(t)
|
||||
repos := newContractRepositories(db)
|
||||
zone := time.FixedZone("plus2", 2*3600)
|
||||
expiration := time.Date(2030, 5, 6, 7, 8, 9, 0, zone)
|
||||
user := contractUser("zoned", "zoned@x.example")
|
||||
user.VerificationExpiration = expiration
|
||||
|
||||
created := mustCreateUser(t, repos, user)
|
||||
read, err := repos.users.Get(created.ID)
|
||||
|
||||
if err != nil || !read.VerificationExpiration.Equal(expiration) || read.VerificationExpiration.Location() != time.UTC {
|
||||
t.Errorf("expected %v in UTC, got %v (%v) err %v", expiration, read.VerificationExpiration, read.VerificationExpiration.Location(), err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("sessions allow the zero date the driver sends for a zero time", func(t *testing.T) {
|
||||
_, _, db := migratedMySQL(t)
|
||||
|
||||
var mode string
|
||||
if err := db.Raw("SELECT @@SESSION.sql_mode").Scan(&mode).Error; err != nil {
|
||||
t.Fatalf("read sql_mode: %v", err)
|
||||
}
|
||||
|
||||
if strings.Contains(mode, "NO_ZERO_DATE") || !strings.Contains(mode, "STRICT_TRANS_TABLES") {
|
||||
t.Errorf("unexpected session sql_mode %q", mode)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func populateForBackup(t *testing.T, repos contractRepositories) {
|
||||
t.Helper()
|
||||
trickyName := "o'brien \"quoted\" back\\slash\nline é😀"
|
||||
mustCreateUser(t, repos, contractUser(trickyName, "tricky@x.example"))
|
||||
second := contractUser("plain", "plain@x.example")
|
||||
second.PasswordResetCode = "reset-code"
|
||||
mustCreateUser(t, repos, second)
|
||||
if _, err := repos.articles.Create(domainArticle.Article{Title: "Title", Body: "line1\nline2 'q' \"d\" \\ \x00 end", IsOnline: true}); err != nil {
|
||||
t.Fatalf("create article: %v", err)
|
||||
}
|
||||
mustCreateImage(t, repos, "pic.png", strings.Repeat("c", 64))
|
||||
if _, err := repos.invites.Create(domainInvite.Invite{Email: "i@x.example", HashedCode: "h", ExpiresAt: time.Date(2030, 1, 1, 0, 0, 0, 0, time.UTC), InvitedBy: 1}); err != nil {
|
||||
t.Fatalf("create invite: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func steppingClock(start time.Time) func() time.Time {
|
||||
current := start
|
||||
return func() time.Time {
|
||||
current = current.Add(time.Second)
|
||||
return current
|
||||
}
|
||||
}
|
||||
|
||||
func TestMySQLBackup(t *testing.T) {
|
||||
t.Run("Backup before an update", func(t *testing.T) {
|
||||
_, _, db := migratedMySQL(t)
|
||||
populateForBackup(t, newContractRepositories(db))
|
||||
directory := filepath.Join(t.TempDir(), "backups")
|
||||
backup := database.NewMySQLBackup(db, directory, steppingClock(time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC)))
|
||||
|
||||
var paths []string
|
||||
for range 5 {
|
||||
path, err := backup.Backup()
|
||||
if err != nil {
|
||||
t.Fatalf("backup: %v", err)
|
||||
}
|
||||
paths = append(paths, path)
|
||||
}
|
||||
|
||||
entries, _ := os.ReadDir(directory)
|
||||
if len(entries) != 3 {
|
||||
t.Fatalf("only the 3 newest backups are kept, got %d", len(entries))
|
||||
}
|
||||
for _, path := range paths[2:] {
|
||||
if filepath.Ext(path) != ".sql" || !strings.HasPrefix(filepath.Base(path), "renewcms-") {
|
||||
t.Errorf("unexpected backup name %q", path)
|
||||
}
|
||||
if _, err := os.Stat(path); err != nil {
|
||||
t.Errorf("the newest backup %q must remain: %v", path, err)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("a failure returns an error and leaves no file", func(t *testing.T) {
|
||||
server := connectMySQLTestServer(t)
|
||||
connection := server.connect(t, server.createDatabase(t))
|
||||
directory := filepath.Join(t.TempDir(), "backups")
|
||||
backup := database.NewMySQLBackup(connection.DB, directory, time.Now)
|
||||
if err := connection.Close(); err != nil {
|
||||
t.Fatalf("close: %v", err)
|
||||
}
|
||||
|
||||
path, err := backup.Backup()
|
||||
|
||||
if err == nil || path != "" {
|
||||
t.Errorf("expected a failure, got %q %v", path, err)
|
||||
}
|
||||
if entries, _ := os.ReadDir(directory); len(entries) != 0 {
|
||||
t.Errorf("no file may remain, got %d entries", len(entries))
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("MySQL backup content", func(t *testing.T) {
|
||||
server, _, db := migratedMySQL(t)
|
||||
repos := newContractRepositories(db)
|
||||
populateForBackup(t, repos)
|
||||
directory := filepath.Join(t.TempDir(), "backups")
|
||||
backup := database.NewMySQLBackup(db, directory, time.Now)
|
||||
|
||||
path, err := backup.Backup()
|
||||
if err != nil {
|
||||
t.Fatalf("backup: %v", err)
|
||||
}
|
||||
content, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
dump := string(content)
|
||||
|
||||
for _, table := range []string{"articles", "images", "users", "invites", "api_keys", "purge_settings"} {
|
||||
if !strings.Contains(dump, "CREATE TABLE `"+table+"`") {
|
||||
t.Errorf("the dump must contain the schema of %s", table)
|
||||
}
|
||||
}
|
||||
for _, table := range []string{"articles", "images", "users", "invites"} {
|
||||
if !strings.Contains(dump, "INSERT INTO `"+table+"`") {
|
||||
t.Errorf("the dump must contain rows of %s", table)
|
||||
}
|
||||
}
|
||||
if password := server.config.Password; password != "" && strings.Contains(dump, password) {
|
||||
t.Errorf("the dump must not contain the DB_PASSWORD value")
|
||||
}
|
||||
|
||||
restoredConfig := server.createDatabase(t)
|
||||
replayDump(t, restoredConfig, dump)
|
||||
restored := server.connect(t, restoredConfig)
|
||||
restoredPath, err := database.NewMySQLBackup(restored.DB, filepath.Join(t.TempDir(), "again"), time.Now).Backup()
|
||||
if err != nil {
|
||||
t.Fatalf("backup of the restored database: %v", err)
|
||||
}
|
||||
restoredContent, _ := os.ReadFile(restoredPath)
|
||||
if line := firstDifferingLine(withoutRedundantCharset(dump), withoutRedundantCharset(string(restoredContent))); line != "" {
|
||||
t.Errorf("replaying the dump must restore the same schema and rows, first difference: %s", line)
|
||||
}
|
||||
restoredUsers := newContractRepositories(restored.DB).users.GetAll()
|
||||
if len(restoredUsers) != 2 || restoredUsers[0].Username != "o'brien \"quoted\" back\\slash\nline é😀" || restoredUsers[1].PasswordResetCode != "reset-code" {
|
||||
t.Errorf("unexpected restored users %+v", restoredUsers)
|
||||
}
|
||||
restoredArticles, _ := newContractRepositories(restored.DB).articles.Get(1)
|
||||
if restoredArticles.Body != "line1\nline2 'q' \"d\" \\ \x00 end" {
|
||||
t.Errorf("unexpected restored article body %q", restoredArticles.Body)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func replayDump(t *testing.T, config database.MySQLConfig, dump string) {
|
||||
t.Helper()
|
||||
parsed, err := mysqldriver.ParseDSN(database.MySQLDSN(config))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
parsed.MultiStatements = true
|
||||
replay, err := sql.Open("mysql", parsed.FormatDSN())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer replay.Close()
|
||||
if _, err := replay.Exec(dump); err != nil {
|
||||
t.Fatalf("replay: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMySQLUsersAreCaseSensitiveThroughTheDomain(t *testing.T) {
|
||||
t.Run("Bob and bob can both register", func(t *testing.T) {
|
||||
_, _, db := migratedMySQL(t)
|
||||
repos := newContractRepositories(db)
|
||||
mustCreateUser(t, repos, contractUser("Bob", "b1@x.example"))
|
||||
|
||||
other, err := repos.users.Create(contractUser("bob", "b2@x.example"))
|
||||
_, missing := repos.users.GetByUsername("BOB")
|
||||
|
||||
if err != nil || other.Username != "bob" || missing == nil {
|
||||
t.Errorf("created %+v err %v, uppercase lookup err %v", other, err, missing)
|
||||
}
|
||||
if _, err := repos.users.GetByEmail("B1@X.EXAMPLE"); err == nil {
|
||||
t.Error("emails are compared exactly like SQLite does")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func firstDifferingLine(expected string, actual string) string {
|
||||
expectedLines, actualLines := strings.Split(expected, "\n"), strings.Split(actual, "\n")
|
||||
for index := 0; index < max(len(expectedLines), len(actualLines)); index++ {
|
||||
var want, got string
|
||||
if index < len(expectedLines) {
|
||||
want = expectedLines[index]
|
||||
}
|
||||
if index < len(actualLines) {
|
||||
got = actualLines[index]
|
||||
}
|
||||
if want != got {
|
||||
return fmt.Sprintf("line %d: want %q got %q", index+1, want, got)
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func withoutRedundantCharset(dump string) string {
|
||||
return strings.ReplaceAll(dump, " CHARACTER SET utf8mb4", "")
|
||||
}
|
||||
@@ -0,0 +1,368 @@
|
||||
package test
|
||||
|
||||
import (
|
||||
domainArticle "RenewCMS/internal/domain/article"
|
||||
domainImage "RenewCMS/internal/domain/image"
|
||||
domainInvite "RenewCMS/internal/domain/invite"
|
||||
domainPurge "RenewCMS/internal/domain/purge"
|
||||
domainUser "RenewCMS/internal/domain/user"
|
||||
"RenewCMS/internal/infrastructure/persistence/database"
|
||||
persistence "RenewCMS/internal/infrastructure/persistence/repositories"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
mysqldriver "github.com/go-sql-driver/mysql"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/logger"
|
||||
)
|
||||
|
||||
const mysqlTestDSNVariable = "RENEWCMS_TEST_MYSQL_DSN"
|
||||
|
||||
var mysqlTestDatabaseCounter atomic.Int64
|
||||
|
||||
type contractRepositories struct {
|
||||
db *gorm.DB
|
||||
users *persistence.UserRepository
|
||||
invites *persistence.InviteRepository
|
||||
images *persistence.ImageRepository
|
||||
articles *persistence.ArticleRepository
|
||||
purge *persistence.PurgeSettingsRepository
|
||||
}
|
||||
|
||||
type contractBackend struct {
|
||||
name string
|
||||
open func(t *testing.T) *gorm.DB
|
||||
}
|
||||
|
||||
func openContractSQLite(t *testing.T) *gorm.DB {
|
||||
t.Helper()
|
||||
path := filepath.ToSlash(filepath.Join(t.TempDir(), "contract.db"))
|
||||
connection, err := database.Connect(database.Config{Driver: database.DriverSQLite, Source: path})
|
||||
if err != nil {
|
||||
t.Fatalf("open sqlite: %v", err)
|
||||
}
|
||||
connection.DB.Logger = logger.Discard
|
||||
if err := database.Migrate(connection.DB); err != nil {
|
||||
t.Fatalf("migrate sqlite: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = connection.Close() })
|
||||
return connection.DB
|
||||
}
|
||||
|
||||
type mysqlTestServer struct {
|
||||
admin *sql.DB
|
||||
config database.MySQLConfig
|
||||
}
|
||||
|
||||
func connectMySQLTestServer(t *testing.T) *mysqlTestServer {
|
||||
t.Helper()
|
||||
dsn := os.Getenv(mysqlTestDSNVariable)
|
||||
if dsn == "" {
|
||||
t.Skip(mysqlTestDSNVariable + " is not set; run task test:mysql to test against a real server")
|
||||
}
|
||||
parsed, err := mysqldriver.ParseDSN(dsn)
|
||||
if err != nil {
|
||||
t.Fatalf("parse %s: %v", mysqlTestDSNVariable, err)
|
||||
}
|
||||
host, port, err := net.SplitHostPort(parsed.Addr)
|
||||
if err != nil {
|
||||
t.Fatalf("split %q: %v", parsed.Addr, err)
|
||||
}
|
||||
admin, err := sql.Open("mysql", dsn)
|
||||
if err != nil {
|
||||
t.Fatalf("open admin: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = admin.Close() })
|
||||
return &mysqlTestServer{admin: admin, config: database.MySQLConfig{Host: host, Port: port, User: parsed.User, Password: parsed.Passwd, TLS: "false"}}
|
||||
}
|
||||
|
||||
func (s *mysqlTestServer) createDatabase(t *testing.T) database.MySQLConfig {
|
||||
t.Helper()
|
||||
name := fmt.Sprintf("renewcms_test_%d_%d", time.Now().UnixNano()%1_000_000_000, mysqlTestDatabaseCounter.Add(1))
|
||||
if _, err := s.admin.Exec("CREATE DATABASE " + name); err != nil {
|
||||
t.Fatalf("create database: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _, _ = s.admin.Exec("DROP DATABASE IF EXISTS " + name) })
|
||||
config := s.config
|
||||
config.Name = name
|
||||
return config
|
||||
}
|
||||
|
||||
func (s *mysqlTestServer) connect(t *testing.T, config database.MySQLConfig) *database.Connection {
|
||||
t.Helper()
|
||||
connection, err := database.Connect(database.Config{Driver: database.DriverMySQL, Source: database.MySQLDSN(config)})
|
||||
if err != nil {
|
||||
t.Fatalf("connect mysql: %v", err)
|
||||
}
|
||||
connection.DB.Logger = logger.Discard
|
||||
t.Cleanup(func() { _ = connection.Close() })
|
||||
return connection
|
||||
}
|
||||
|
||||
func openContractMySQL(t *testing.T) *gorm.DB {
|
||||
t.Helper()
|
||||
server := connectMySQLTestServer(t)
|
||||
connection := server.connect(t, server.createDatabase(t))
|
||||
if err := database.Migrate(connection.DB); err != nil {
|
||||
t.Fatalf("migrate mysql: %v", err)
|
||||
}
|
||||
return connection.DB
|
||||
}
|
||||
|
||||
func newContractRepositories(db *gorm.DB) contractRepositories {
|
||||
return contractRepositories{
|
||||
db: db,
|
||||
users: persistence.NewUserRepository(db),
|
||||
invites: persistence.NewInviteRepository(db),
|
||||
images: persistence.NewImageRepository(db),
|
||||
articles: persistence.NewArticleRepository(db),
|
||||
purge: persistence.NewPurgeSettingsRepository(db),
|
||||
}
|
||||
}
|
||||
|
||||
func contractUser(username string, email string) domainUser.User {
|
||||
return domainUser.User{Username: username, Password: "hashed", Email: email, Role: domainUser.RoleAdmin, IsVerified: true}
|
||||
}
|
||||
|
||||
func contractInvite(t *testing.T, repos contractRepositories, email string) domainInvite.Invite {
|
||||
t.Helper()
|
||||
created, err := repos.invites.Create(domainInvite.Invite{
|
||||
Email: email,
|
||||
HashedCode: "hashed-code",
|
||||
ExpiresAt: time.Date(2030, 1, 2, 3, 4, 5, 0, time.UTC),
|
||||
InvitedBy: 1,
|
||||
CreatedAt: time.Date(2029, 12, 30, 3, 4, 5, 0, time.UTC),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("create invite: %v", err)
|
||||
}
|
||||
return created
|
||||
}
|
||||
|
||||
func mustCreateUser(t *testing.T, repos contractRepositories, user domainUser.User) domainUser.User {
|
||||
t.Helper()
|
||||
created, err := repos.users.Create(user)
|
||||
if err != nil {
|
||||
t.Fatalf("create user %q: %v", user.Username, err)
|
||||
}
|
||||
return created
|
||||
}
|
||||
|
||||
func mustCreateImage(t *testing.T, repos contractRepositories, filename string, hash string) domainImage.Image {
|
||||
t.Helper()
|
||||
created, err := repos.images.Create(domainImage.Image{Path: "/static/uploadedImages/" + filename, Filename: filename, SHA256: hash, ContentType: "image/png", Size: 3})
|
||||
if err != nil {
|
||||
t.Fatalf("create image %q: %v", filename, err)
|
||||
}
|
||||
return created
|
||||
}
|
||||
|
||||
func runRepositoryContract(t *testing.T, backend contractBackend) {
|
||||
cases := []struct {
|
||||
name string
|
||||
run func(t *testing.T, repos contractRepositories)
|
||||
}{
|
||||
{`create user and read it back with its id and timestamps`, func(t *testing.T, repos contractRepositories) {
|
||||
created := mustCreateUser(t, repos, contractUser("Alice", "alice@x.example"))
|
||||
|
||||
read, err := repos.users.Get(created.ID)
|
||||
|
||||
if err != nil || created.ID == 0 || read.ID != created.ID || read.Username != "Alice" || read.Email != "alice@x.example" {
|
||||
t.Fatalf("created %+v read %+v err %v", created, read, err)
|
||||
}
|
||||
if created.CreatedAt.IsZero() || read.CreatedAt.IsZero() || read.UpdatedAt.IsZero() {
|
||||
t.Errorf("timestamps must be filled: created %v read %v / %v", created.CreatedAt, read.CreatedAt, read.UpdatedAt)
|
||||
}
|
||||
if difference := read.CreatedAt.Sub(created.CreatedAt).Abs(); difference > time.Second {
|
||||
t.Errorf("the stored timestamp %v must match the returned one %v", read.CreatedAt, created.CreatedAt)
|
||||
}
|
||||
if time.Since(read.CreatedAt).Abs() > time.Minute {
|
||||
t.Errorf("the timestamp %v must be the current instant", read.CreatedAt)
|
||||
}
|
||||
}},
|
||||
{`username "Bob" and "bob" are distinct users`, func(t *testing.T, repos contractRepositories) {
|
||||
upper := mustCreateUser(t, repos, contractUser("Bob", "upper@x.example"))
|
||||
lower := mustCreateUser(t, repos, contractUser("bob", "lower@x.example"))
|
||||
|
||||
foundUpper, upperErr := repos.users.GetByUsername("Bob")
|
||||
foundLower, lowerErr := repos.users.GetByUsername("bob")
|
||||
|
||||
if upperErr != nil || lowerErr != nil || foundUpper.ID != upper.ID || foundLower.ID != lower.ID || upper.ID == lower.ID {
|
||||
t.Errorf("Bob %+v (%v) and bob %+v (%v) must be distinct", foundUpper, upperErr, foundLower, lowerErr)
|
||||
}
|
||||
}},
|
||||
{`duplicate email returns an error and stores nothing`, func(t *testing.T, repos contractRepositories) {
|
||||
mustCreateUser(t, repos, contractUser("first", "same@x.example"))
|
||||
|
||||
_, err := repos.users.Create(contractUser("second", "same@x.example"))
|
||||
|
||||
if err == nil {
|
||||
t.Error("expected an error for the duplicate email")
|
||||
}
|
||||
if all := repos.users.GetAll(); len(all) != 1 || all[0].Username != "first" {
|
||||
t.Errorf("nothing may be stored for the failed create, got %+v", all)
|
||||
}
|
||||
}},
|
||||
{`unknown id returns domain ErrUserNotFound`, func(t *testing.T, repos contractRepositories) {
|
||||
_, err := repos.users.Get(4242)
|
||||
|
||||
if !errors.Is(err, domainUser.ErrUserNotFound) {
|
||||
t.Errorf("expected ErrUserNotFound, got %v", err)
|
||||
}
|
||||
}},
|
||||
{`accept invite twice: the second returns ErrInviteNotFound`, func(t *testing.T, repos contractRepositories) {
|
||||
pending := contractInvite(t, repos, "new@x.example")
|
||||
accepted := time.Now()
|
||||
|
||||
first, firstErr := repos.invites.AcceptWithUser(pending.ID, accepted, contractUser("lea", "new@x.example"))
|
||||
_, secondErr := repos.invites.AcceptWithUser(pending.ID, accepted, contractUser("lea2", "other@x.example"))
|
||||
|
||||
if firstErr != nil || first.ID == 0 {
|
||||
t.Fatalf("first accept: %+v %v", first, firstErr)
|
||||
}
|
||||
if !errors.Is(secondErr, domainInvite.ErrInviteNotFound) {
|
||||
t.Errorf("expected ErrInviteNotFound, got %v", secondErr)
|
||||
}
|
||||
if len(repos.users.GetAll()) != 1 {
|
||||
t.Errorf("only the first accept may create a user")
|
||||
}
|
||||
}},
|
||||
{`accept invite with a taken username leaves the invite unaccepted`, func(t *testing.T, repos contractRepositories) {
|
||||
mustCreateUser(t, repos, contractUser("marta", "marta@x.example"))
|
||||
pending := contractInvite(t, repos, "new@x.example")
|
||||
|
||||
_, err := repos.invites.AcceptWithUser(pending.ID, time.Now(), contractUser("marta", "new@x.example"))
|
||||
|
||||
if err == nil {
|
||||
t.Fatal("expected the taken username to be refused")
|
||||
}
|
||||
invites := repos.invites.GetAll()
|
||||
if len(invites) != 1 || invites[0].AcceptedAt != nil {
|
||||
t.Errorf("the invite must stay pending, got %+v", invites)
|
||||
}
|
||||
}},
|
||||
{`renew invite with unchanged values still succeeds`, func(t *testing.T, repos contractRepositories) {
|
||||
pending := contractInvite(t, repos, "new@x.example")
|
||||
|
||||
renewed, err := repos.invites.Renew(pending.ID, pending.HashedCode, pending.ExpiresAt, pending.CreatedAt)
|
||||
|
||||
if err != nil || renewed.ID != pending.ID || !renewed.ExpiresAt.Equal(pending.ExpiresAt) {
|
||||
t.Errorf("renew with identical values: %+v %v", renewed, err)
|
||||
}
|
||||
}},
|
||||
{`duplicate image hashes are grouped and counted`, func(t *testing.T, repos contractRepositories) {
|
||||
first := strings.Repeat("a", 64)
|
||||
second := strings.Repeat("b", 64)
|
||||
mustCreateImage(t, repos, "a1.png", first)
|
||||
mustCreateImage(t, repos, "a2.png", first)
|
||||
mustCreateImage(t, repos, "b1.png", second)
|
||||
mustCreateImage(t, repos, "none1.png", "")
|
||||
mustCreateImage(t, repos, "none2.png", "")
|
||||
|
||||
counts, countsErr := repos.images.Counts()
|
||||
duplicates, listErr := repos.images.ListDuplicates()
|
||||
|
||||
if countsErr != nil || counts.All != 5 || counts.DuplicateGroups != 1 {
|
||||
t.Errorf("counts %+v err %v", counts, countsErr)
|
||||
}
|
||||
if listErr != nil || len(duplicates) != 2 || duplicates[0].Filename != "a1.png" || duplicates[1].Filename != "a2.png" {
|
||||
t.Errorf("duplicates %+v err %v", duplicates, listErr)
|
||||
}
|
||||
}},
|
||||
{`media search treats % and _ literally`, func(t *testing.T, repos contractRepositories) {
|
||||
for _, filename := range []string{"100%.png", "a_b.png", "axb.png", "50x.png", "Plain.png"} {
|
||||
mustCreateImage(t, repos, filename, "")
|
||||
}
|
||||
|
||||
percent, percentErr := repos.images.List(domainImage.SearchQuery{Search: "%"})
|
||||
underscore, underscoreErr := repos.images.List(domainImage.SearchQuery{Search: "_"})
|
||||
caseless, caselessErr := repos.images.List(domainImage.SearchQuery{Search: "PLAIN"})
|
||||
|
||||
if percentErr != nil || len(percent) != 1 || percent[0].Filename != "100%.png" {
|
||||
t.Errorf("percent search %+v err %v", percent, percentErr)
|
||||
}
|
||||
if underscoreErr != nil || len(underscore) != 1 || underscore[0].Filename != "a_b.png" {
|
||||
t.Errorf("underscore search %+v err %v", underscore, underscoreErr)
|
||||
}
|
||||
if caselessErr != nil || len(caseless) != 1 || caseless[0].Filename != "Plain.png" {
|
||||
t.Errorf("case insensitive search %+v err %v", caseless, caselessErr)
|
||||
}
|
||||
}},
|
||||
{`article body of 1 MB round-trips`, func(t *testing.T, repos contractRepositories) {
|
||||
body := "é€😀" + strings.Repeat("0123456789abcdef", 1<<16)
|
||||
|
||||
created, err := repos.articles.Create(domainArticle.Article{Title: "Big", Body: body, IsOnline: true})
|
||||
if err != nil {
|
||||
t.Fatalf("create: %v", err)
|
||||
}
|
||||
read, err := repos.articles.Get(created.ID)
|
||||
|
||||
if err != nil || read.Body != body || !read.IsOnline {
|
||||
t.Errorf("the body must round-trip, got %d bytes err %v", len(read.Body), err)
|
||||
}
|
||||
found, err := repos.articles.FindByBodyContaining("€😀0123")
|
||||
if err != nil || len(found) != 1 {
|
||||
t.Errorf("body search found %d err %v", len(found), err)
|
||||
}
|
||||
}},
|
||||
{`zero reset expiration round-trips as a zero time`, func(t *testing.T, repos contractRepositories) {
|
||||
created := mustCreateUser(t, repos, contractUser("zero", "zero@x.example"))
|
||||
|
||||
fresh, freshErr := repos.users.Get(created.ID)
|
||||
withCode, codeErr := repos.users.UpdatePasswordResetCode(created.ID, "code", time.Date(2030, 1, 1, 0, 0, 0, 0, time.UTC))
|
||||
reset, resetErr := repos.users.ResetPassword(created.ID, "new-hash")
|
||||
reread, rereadErr := repos.users.Get(created.ID)
|
||||
|
||||
if freshErr != nil || !fresh.PasswordResetExpiration.IsZero() || !fresh.VerificationExpiration.IsZero() {
|
||||
t.Errorf("fresh user expirations %v / %v err %v", fresh.PasswordResetExpiration, fresh.VerificationExpiration, freshErr)
|
||||
}
|
||||
if codeErr != nil || withCode.PasswordResetExpiration.IsZero() {
|
||||
t.Errorf("the expiration must be stored: %+v %v", withCode, codeErr)
|
||||
}
|
||||
if resetErr != nil || rereadErr != nil || !reset.PasswordResetExpiration.IsZero() || !reread.PasswordResetExpiration.IsZero() || reread.TokenVersion != 1 {
|
||||
t.Errorf("the zero expiration must round-trip: %+v %v %v", reread, resetErr, rereadErr)
|
||||
}
|
||||
}},
|
||||
{`purge settings save, then SaveLastRun with identical values succeeds`, func(t *testing.T, repos contractRepositories) {
|
||||
enabledAt := time.Date(2030, 1, 2, 3, 4, 5, 0, time.UTC)
|
||||
lastRun := time.Date(2030, 2, 3, 4, 5, 6, 0, time.UTC)
|
||||
saved := domainPurge.Settings{AutoPurgeEnabled: true, AutoPurgeIntervalDays: 7, EnabledAt: &enabledAt}
|
||||
if err := repos.purge.Save(saved); err != nil {
|
||||
t.Fatalf("save: %v", err)
|
||||
}
|
||||
|
||||
firstErr := repos.purge.SaveLastRun(lastRun, 3, 4)
|
||||
secondErr := repos.purge.SaveLastRun(lastRun, 3, 4)
|
||||
|
||||
got, err := repos.purge.Get()
|
||||
if firstErr != nil || secondErr != nil || err != nil {
|
||||
t.Fatalf("SaveLastRun errors %v / %v, get %v", firstErr, secondErr, err)
|
||||
}
|
||||
if !got.AutoPurgeEnabled || got.AutoPurgeIntervalDays != 7 || got.LastPurgeMerged != 3 || got.LastPurgeArticlesUpdated != 4 || got.LastPurgeAt == nil || !got.LastPurgeAt.Equal(lastRun) {
|
||||
t.Errorf("the settings must survive an identical SaveLastRun, got %+v", got)
|
||||
}
|
||||
}},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
tc.run(t, newContractRepositories(backend.open(t)))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSQLiteRepositoryContract(t *testing.T) {
|
||||
runRepositoryContract(t, contractBackend{name: "sqlite", open: openContractSQLite})
|
||||
}
|
||||
|
||||
func TestMySQLRepositoryContract(t *testing.T) {
|
||||
runRepositoryContract(t, contractBackend{name: "mysql", open: openContractMySQL})
|
||||
}
|
||||
@@ -0,0 +1,328 @@
|
||||
package test
|
||||
|
||||
import (
|
||||
"RenewCMS/internal/infrastructure/persistence/database"
|
||||
"bytes"
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
type fakeDumpTable struct {
|
||||
create string
|
||||
columns []database.DumpColumn
|
||||
rows [][][]byte
|
||||
}
|
||||
|
||||
type fakeDumpSource struct {
|
||||
order []string
|
||||
tables map[string]fakeDumpTable
|
||||
failOn string
|
||||
closed bool
|
||||
rowsError error
|
||||
}
|
||||
|
||||
var _ database.DumpSource = &fakeDumpSource{}
|
||||
|
||||
type fakeDumpRows struct {
|
||||
table fakeDumpTable
|
||||
index int
|
||||
failed error
|
||||
}
|
||||
|
||||
var _ database.DumpRows = &fakeDumpRows{}
|
||||
|
||||
func (s *fakeDumpSource) Tables() ([]string, error) { return s.order, nil }
|
||||
|
||||
func (s *fakeDumpSource) CreateStatement(table string) (string, error) {
|
||||
if s.failOn == table {
|
||||
return "", errors.New("show create failed")
|
||||
}
|
||||
return s.tables[table].create, nil
|
||||
}
|
||||
|
||||
func (s *fakeDumpSource) Rows(table string) (database.DumpRows, error) {
|
||||
return &fakeDumpRows{table: s.tables[table], index: -1, failed: s.rowsError}, nil
|
||||
}
|
||||
|
||||
func (s *fakeDumpSource) Close() error {
|
||||
s.closed = true
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *fakeDumpRows) Columns() ([]database.DumpColumn, error) { return r.table.columns, nil }
|
||||
|
||||
func (r *fakeDumpRows) Next() bool {
|
||||
r.index++
|
||||
return r.index < len(r.table.rows)
|
||||
}
|
||||
|
||||
func (r *fakeDumpRows) Values() ([][]byte, error) { return r.table.rows[r.index], nil }
|
||||
func (r *fakeDumpRows) Err() error { return r.failed }
|
||||
func (r *fakeDumpRows) Close() error { return nil }
|
||||
|
||||
func newFakeDumpSource() *fakeDumpSource {
|
||||
return &fakeDumpSource{
|
||||
order: []string{"notes", "plain"},
|
||||
tables: map[string]fakeDumpTable{
|
||||
"notes": {
|
||||
create: "CREATE TABLE `notes` (\n `id` int unsigned NOT NULL,\n `body` longtext,\n `blob` longblob,\n `at` datetime(3),\n `ok` tinyint(1)\n)",
|
||||
columns: []database.DumpColumn{
|
||||
{Name: "id", Type: "UNSIGNED INT"},
|
||||
{Name: "body", Type: "LONGTEXT"},
|
||||
{Name: "blob", Type: "LONGBLOB"},
|
||||
{Name: "at", Type: "DATETIME"},
|
||||
{Name: "ok", Type: "TINYINT"},
|
||||
},
|
||||
rows: [][][]byte{
|
||||
{[]byte("1"), []byte("it's a \"test\"\nline\\two\r\x00\x1a"), []byte{0x00, 0xff, 0x10}, []byte("2026-01-02 03:04:05.123000"), []byte("1")},
|
||||
{[]byte("2"), nil, []byte{}, []byte("0000-00-00 00:00:00"), []byte("0")},
|
||||
},
|
||||
},
|
||||
"plain": {
|
||||
create: "CREATE TABLE `plain` (\n `a` int\n)",
|
||||
columns: []database.DumpColumn{{Name: "a", Type: "INT"}},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func TestDumpWriter(t *testing.T) {
|
||||
t.Run("the dump holds the schema and the rows of every table", func(t *testing.T) {
|
||||
var output bytes.Buffer
|
||||
|
||||
err := database.WriteDump(&output, newFakeDumpSource())
|
||||
|
||||
if err != nil {
|
||||
t.Fatalf("write: %v", err)
|
||||
}
|
||||
dump := output.String()
|
||||
for _, expected := range []string{
|
||||
"CREATE TABLE `notes` (",
|
||||
"CREATE TABLE `plain` (",
|
||||
"INSERT INTO `notes` (`id`,`body`,`blob`,`at`,`ok`) VALUES (1,'it\\'s a \"test\"\\nline\\\\two\\r\\0\\Z',0x00ff10,'2026-01-02 03:04:05.123000',1);",
|
||||
"INSERT INTO `notes` (`id`,`body`,`blob`,`at`,`ok`) VALUES (2,NULL,'','0000-00-00 00:00:00',0);",
|
||||
"SET sql_mode = 'NO_AUTO_VALUE_ON_ZERO';",
|
||||
} {
|
||||
if !strings.Contains(dump, expected) {
|
||||
t.Errorf("the dump must contain %q, got:\n%s", expected, dump)
|
||||
}
|
||||
}
|
||||
if strings.Contains(dump, "INSERT INTO `plain`") {
|
||||
t.Error("an empty table has no rows to insert")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("every statement after the schema stays on one line", func(t *testing.T) {
|
||||
var output bytes.Buffer
|
||||
_ = database.WriteDump(&output, newFakeDumpSource())
|
||||
|
||||
for _, line := range strings.Split(output.String(), "\n") {
|
||||
if strings.HasPrefix(line, "INSERT") && !strings.HasSuffix(line, ");") {
|
||||
t.Errorf("insert split over several lines: %q", line)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("a failing table stops the dump with the error", func(t *testing.T) {
|
||||
source := newFakeDumpSource()
|
||||
source.failOn = "plain"
|
||||
|
||||
err := database.WriteDump(&bytes.Buffer{}, source)
|
||||
|
||||
if err == nil || err.Error() != "show create failed" {
|
||||
t.Errorf("unexpected error %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("a row error stops the dump with the error", func(t *testing.T) {
|
||||
source := newFakeDumpSource()
|
||||
source.rowsError = errors.New("connection lost")
|
||||
|
||||
err := database.WriteDump(&bytes.Buffer{}, source)
|
||||
|
||||
if err == nil || err.Error() != "connection lost" {
|
||||
t.Errorf("unexpected error %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
type dumpBackupRig struct {
|
||||
directory string
|
||||
source *fakeDumpSource
|
||||
clock time.Time
|
||||
backup *database.DumpBackup
|
||||
}
|
||||
|
||||
func newDumpBackupRig(t *testing.T) *dumpBackupRig {
|
||||
t.Helper()
|
||||
rig := &dumpBackupRig{
|
||||
directory: filepath.Join(t.TempDir(), "nested", "backups"),
|
||||
source: newFakeDumpSource(),
|
||||
clock: time.Date(2026, 3, 4, 5, 6, 7, 0, time.UTC),
|
||||
}
|
||||
rig.backup = database.NewDumpBackup(func() (database.DumpSource, error) { return rig.source, nil }, rig.directory, func() time.Time {
|
||||
rig.clock = rig.clock.Add(time.Second)
|
||||
return rig.clock
|
||||
})
|
||||
return rig
|
||||
}
|
||||
|
||||
func (r *dumpBackupRig) names(t *testing.T) []string {
|
||||
t.Helper()
|
||||
entries, err := os.ReadDir(r.directory)
|
||||
if err != nil {
|
||||
t.Fatalf("read backups: %v", err)
|
||||
}
|
||||
names := make([]string, 0, len(entries))
|
||||
for _, entry := range entries {
|
||||
names = append(names, entry.Name())
|
||||
}
|
||||
return names
|
||||
}
|
||||
|
||||
func TestDumpBackup(t *testing.T) {
|
||||
t.Run("a file renewcms-<time>.sql exists in the backups directory", func(t *testing.T) {
|
||||
rig := newDumpBackupRig(t)
|
||||
|
||||
path, err := rig.backup.Backup()
|
||||
|
||||
if err != nil {
|
||||
t.Fatalf("backup: %v", err)
|
||||
}
|
||||
name := filepath.Base(path)
|
||||
if filepath.Dir(path) != rig.directory || !strings.HasPrefix(name, "renewcms-20260304T050608") || !strings.HasSuffix(name, ".sql") {
|
||||
t.Errorf("unexpected backup path %q", path)
|
||||
}
|
||||
content, err := os.ReadFile(path)
|
||||
if err != nil || !strings.Contains(string(content), "INSERT INTO `notes`") {
|
||||
t.Errorf("unexpected content %q err %v", content, err)
|
||||
}
|
||||
if !rig.source.closed {
|
||||
t.Error("the source must be released")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("only the 3 newest backups are kept", func(t *testing.T) {
|
||||
rig := newDumpBackupRig(t)
|
||||
var paths []string
|
||||
for range 5 {
|
||||
path, err := rig.backup.Backup()
|
||||
if err != nil {
|
||||
t.Fatalf("backup: %v", err)
|
||||
}
|
||||
paths = append(paths, path)
|
||||
}
|
||||
|
||||
names := rig.names(t)
|
||||
|
||||
if len(names) != 3 {
|
||||
t.Fatalf("expected 3 backups, got %v", names)
|
||||
}
|
||||
for _, newest := range paths[2:] {
|
||||
if _, err := os.Stat(newest); err != nil {
|
||||
t.Errorf("the newest backup %q must remain: %v", newest, err)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("a failure returns an error and leaves no file behind", func(t *testing.T) {
|
||||
rig := newDumpBackupRig(t)
|
||||
rig.source.failOn = "plain"
|
||||
|
||||
path, err := rig.backup.Backup()
|
||||
|
||||
if err == nil || path != "" {
|
||||
t.Fatalf("expected a failure, got path %q err %v", path, err)
|
||||
}
|
||||
if names := rig.names(t); len(names) != 0 {
|
||||
t.Errorf("no partial file may remain, got %v", names)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("a source that cannot be opened is a failure", func(t *testing.T) {
|
||||
directory := filepath.Join(t.TempDir(), "backups")
|
||||
backup := database.NewDumpBackup(func() (database.DumpSource, error) { return nil, errors.New("no connection") }, directory, time.Now)
|
||||
|
||||
path, err := backup.Backup()
|
||||
|
||||
if err == nil || path != "" {
|
||||
t.Errorf("expected a failure, got path %q err %v", path, err)
|
||||
}
|
||||
if entries, _ := os.ReadDir(directory); len(entries) != 0 {
|
||||
t.Errorf("no file may remain, got %d entries", len(entries))
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("a backup taken at the same instant is refused instead of overwritten", func(t *testing.T) {
|
||||
directory := filepath.Join(t.TempDir(), "backups")
|
||||
fixed := func() time.Time { return time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC) }
|
||||
backup := database.NewDumpBackup(func() (database.DumpSource, error) { return newFakeDumpSource(), nil }, directory, fixed)
|
||||
if _, err := backup.Backup(); err != nil {
|
||||
t.Fatalf("first backup: %v", err)
|
||||
}
|
||||
|
||||
_, err := backup.Backup()
|
||||
|
||||
if err == nil {
|
||||
t.Error("expected the second backup to fail")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestDumpBackupFiles(t *testing.T) {
|
||||
t.Run("backups sharing a modification time are pruned by name, oldest name first", func(t *testing.T) {
|
||||
rig := newDumpBackupRig(t)
|
||||
if err := os.MkdirAll(rig.directory, os.ModePerm); err != nil {
|
||||
t.Fatalf("mkdir: %v", err)
|
||||
}
|
||||
shared := time.Date(2020, 1, 1, 0, 0, 0, 0, time.UTC)
|
||||
for _, name := range []string{"renewcms-1.sql", "renewcms-2.sql", "renewcms-3.sql", "renewcms-4.sql"} {
|
||||
path := filepath.Join(rig.directory, name)
|
||||
if err := os.WriteFile(path, []byte("x"), 0o600); err != nil {
|
||||
t.Fatalf("write: %v", err)
|
||||
}
|
||||
if err := os.Chtimes(path, shared, shared); err != nil {
|
||||
t.Fatalf("chtimes: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
if _, err := rig.backup.Backup(); err != nil {
|
||||
t.Fatalf("backup: %v", err)
|
||||
}
|
||||
|
||||
names := rig.names(t)
|
||||
if len(names) != 3 || !strings.HasPrefix(names[0], "renewcms-2026") || names[1] != "renewcms-3.sql" || names[2] != "renewcms-4.sql" {
|
||||
t.Errorf("the new backup and the two highest names must survive, got %v", names)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("a target that cannot be renamed into is a failure and leaves no partial file", func(t *testing.T) {
|
||||
rig := newDumpBackupRig(t)
|
||||
blocker := ""
|
||||
rig.backup = database.NewDumpBackup(func() (database.DumpSource, error) {
|
||||
blocker = filepath.Join(rig.directory, "renewcms-"+rig.clock.UTC().Format("20060102T150405.000000000Z")+".sql")
|
||||
if err := os.MkdirAll(filepath.Join(blocker, "inner"), os.ModePerm); err != nil {
|
||||
t.Fatalf("block target: %v", err)
|
||||
}
|
||||
return rig.source, nil
|
||||
}, rig.directory, func() time.Time {
|
||||
rig.clock = rig.clock.Add(time.Second)
|
||||
return rig.clock
|
||||
})
|
||||
|
||||
path, err := rig.backup.Backup()
|
||||
|
||||
if err == nil || path != "" {
|
||||
t.Fatalf("expected a failure, got path %q err %v", path, err)
|
||||
}
|
||||
for _, name := range rig.names(t) {
|
||||
if strings.HasSuffix(name, ".partial") {
|
||||
t.Errorf("the partial file %q must be removed", name)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,177 @@
|
||||
package test
|
||||
|
||||
import (
|
||||
"RenewCMS/internal/infrastructure/persistence/database"
|
||||
"fmt"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/glebarez/sqlite"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/logger"
|
||||
)
|
||||
|
||||
type legacyArticle struct {
|
||||
gorm.Model
|
||||
ID uint32 `gorm:"primary_key;auto_increment;not_null"`
|
||||
Title string
|
||||
Body string
|
||||
IsOnline bool `gorm:"not_null;default:false"`
|
||||
CreatedAt time.Time `gorm:"autoCreateTime"`
|
||||
UpdatedAt time.Time `gorm:"autoUpdateTime"`
|
||||
}
|
||||
|
||||
func (legacyArticle) TableName() string { return "articles" }
|
||||
|
||||
type legacyImage struct {
|
||||
gorm.Model
|
||||
ID uint32 `gorm:"primary_key;auto_increment;not_null"`
|
||||
Path string
|
||||
Filename string
|
||||
Size int64
|
||||
ContentType string
|
||||
Width int
|
||||
Height int
|
||||
SHA256 string `gorm:"column:sha256;index"`
|
||||
UploadedByID uint32
|
||||
CreatedAt time.Time `gorm:"autoCreateTime"`
|
||||
UpdatedAt time.Time `gorm:"autoUpdateTime"`
|
||||
}
|
||||
|
||||
func (legacyImage) TableName() string { return "images" }
|
||||
|
||||
type legacyUser struct {
|
||||
ID uint32 `gorm:"primaryKey;autoIncrement"`
|
||||
Username string `gorm:"unique;not null"`
|
||||
Password string `gorm:"not null"`
|
||||
PasswordResetCode *string `gorm:"unique"`
|
||||
Email string `gorm:"unique;not null"`
|
||||
DisplayName string `gorm:"not null;default:''"`
|
||||
IsVerified bool `gorm:"default=false;not null"`
|
||||
Role string `gorm:"not null;default:''"`
|
||||
VerificationCode *string `gorm:"unique"`
|
||||
VerificationExpiration time.Time
|
||||
PasswordResetExpiration time.Time
|
||||
TokenVersion uint32 `gorm:"default:0;not null"`
|
||||
CreatedAt time.Time `gorm:"autoCreateTime"`
|
||||
UpdatedAt time.Time `gorm:"autoUpdateTime"`
|
||||
}
|
||||
|
||||
func (legacyUser) TableName() string { return "users" }
|
||||
|
||||
type legacyInvite struct {
|
||||
ID uint32 `gorm:"primaryKey;autoIncrement"`
|
||||
Email string `gorm:"index;not null"`
|
||||
HashedCode string `gorm:"not null"`
|
||||
ExpiresAt time.Time
|
||||
InvitedBy uint32 `gorm:"not null"`
|
||||
AcceptedAt *time.Time
|
||||
CreatedAt time.Time `gorm:"autoCreateTime"`
|
||||
}
|
||||
|
||||
func (legacyInvite) TableName() string { return "invites" }
|
||||
|
||||
type legacyAPIKey struct {
|
||||
ID uint32 `gorm:"primaryKey;autoIncrement"`
|
||||
UserID uint32 `gorm:"index;not null"`
|
||||
Name string `gorm:"not null"`
|
||||
Prefix string `gorm:"not null"`
|
||||
HashedKey string `gorm:"uniqueIndex;not null"`
|
||||
ExpiresAt *time.Time
|
||||
RevokedAt *time.Time
|
||||
LastUsedAt *time.Time
|
||||
CreatedAt time.Time `gorm:"autoCreateTime"`
|
||||
}
|
||||
|
||||
func (legacyAPIKey) TableName() string { return "api_keys" }
|
||||
|
||||
type legacyPurgeSettings struct {
|
||||
ID uint32 `gorm:"primaryKey"`
|
||||
AutoPurgeEnabled bool `gorm:"not null"`
|
||||
AutoPurgeIntervalDays int `gorm:"not null"`
|
||||
LastPurgeAt *time.Time
|
||||
LastPurgeMerged int `gorm:"not null"`
|
||||
LastPurgeArticlesUpdated int `gorm:"not null"`
|
||||
EnabledAt *time.Time
|
||||
}
|
||||
|
||||
func (legacyPurgeSettings) TableName() string { return "purge_settings" }
|
||||
|
||||
type sqliteSnapshot struct {
|
||||
schema map[string]string
|
||||
rows map[string][]map[string]any
|
||||
}
|
||||
|
||||
func takeSqliteSnapshot(t *testing.T, db *gorm.DB) sqliteSnapshot {
|
||||
t.Helper()
|
||||
snapshot := sqliteSnapshot{schema: map[string]string{}, rows: map[string][]map[string]any{}}
|
||||
var objects []struct{ Name, Sql string }
|
||||
if err := db.Raw("SELECT name, sql FROM sqlite_master WHERE sql IS NOT NULL ORDER BY name").Scan(&objects).Error; err != nil {
|
||||
t.Fatalf("read schema: %v", err)
|
||||
}
|
||||
for _, object := range objects {
|
||||
snapshot.schema[object.Name] = object.Sql
|
||||
}
|
||||
for _, table := range []string{"articles", "images", "users", "invites", "api_keys", "purge_settings"} {
|
||||
var rows []map[string]any
|
||||
if err := db.Raw(fmt.Sprintf("SELECT * FROM %s ORDER BY 1", table)).Scan(&rows).Error; err != nil {
|
||||
t.Fatalf("read %s: %v", table, err)
|
||||
}
|
||||
snapshot.rows[table] = rows
|
||||
}
|
||||
return snapshot
|
||||
}
|
||||
|
||||
func seedLegacyRows(t *testing.T, db *gorm.DB) {
|
||||
t.Helper()
|
||||
at := time.Date(2026, 1, 2, 3, 4, 5, 0, time.UTC)
|
||||
reset := "reset-1"
|
||||
seeds := []any{
|
||||
&legacyArticle{Title: "First", Body: "Body", IsOnline: true},
|
||||
&legacyImage{Path: "/static/uploadedImages/a.png", Filename: "a.png", Size: 12, ContentType: "image/png", Width: 1, Height: 2, SHA256: "abc", UploadedByID: 1},
|
||||
&legacyUser{Username: "Bob", Password: "x", Email: "bob@x.example", DisplayName: "Bob", IsVerified: true, Role: "owner", PasswordResetCode: &reset, VerificationExpiration: at},
|
||||
&legacyUser{Username: "bob", Password: "y", Email: "bob2@x.example", Role: "admin"},
|
||||
&legacyInvite{Email: "new@x.example", HashedCode: "h", ExpiresAt: at, InvitedBy: 1},
|
||||
&legacyAPIKey{UserID: 1, Name: "ci", Prefix: "rcms_abc", HashedKey: "0123456789abcdef", ExpiresAt: &at},
|
||||
&legacyPurgeSettings{ID: 1, AutoPurgeEnabled: true, AutoPurgeIntervalDays: 7, LastPurgeAt: &at},
|
||||
}
|
||||
for _, seed := range seeds {
|
||||
if err := db.Create(seed).Error; err != nil {
|
||||
t.Fatalf("seed %T: %v", seed, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSQLiteSchemaIsUntouchedByTheModelChanges(t *testing.T) {
|
||||
t.Run("SQLite schema is untouched by the model changes", func(t *testing.T) {
|
||||
path := filepath.ToSlash(filepath.Join(t.TempDir(), "legacy.db"))
|
||||
db, err := gorm.Open(sqlite.Open(database.SQLiteDSN(path)), &gorm.Config{Logger: logger.Discard})
|
||||
if err != nil {
|
||||
t.Fatalf("open: %v", err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
if sqlDB, err := db.DB(); err == nil {
|
||||
_ = sqlDB.Close()
|
||||
}
|
||||
})
|
||||
if err := db.AutoMigrate(&legacyArticle{}, &legacyImage{}, &legacyUser{}, &legacyInvite{}, &legacyAPIKey{}, &legacyPurgeSettings{}); err != nil {
|
||||
t.Fatalf("legacy migrate: %v", err)
|
||||
}
|
||||
seedLegacyRows(t, db)
|
||||
before := takeSqliteSnapshot(t, db)
|
||||
|
||||
if err := database.Migrate(db); err != nil {
|
||||
t.Fatalf("migrate: %v", err)
|
||||
}
|
||||
after := takeSqliteSnapshot(t, db)
|
||||
|
||||
if !reflect.DeepEqual(before.schema, after.schema) {
|
||||
t.Errorf("schema changed:\nbefore %v\nafter %v", before.schema, after.schema)
|
||||
}
|
||||
if !reflect.DeepEqual(before.rows, after.rows) {
|
||||
t.Errorf("rows changed:\nbefore %v\nafter %v", before.rows, after.rows)
|
||||
}
|
||||
})
|
||||
}
|
||||
Reference in new issue
Block a user