feat: optional MySQL/MariaDB database driver for deployments that cannot use local SQLite

This commit is contained in:
fsylvain committed 2026-10-06 04:08:33 +02:00
1 parent 81e299f09a
commit d752a61e52
35 files changed
+2896 -76

No files matched your search

+559
View File
@@ -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)
}
})
}
+311
View File
@@ -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", "")
}
+368
View File
@@ -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})
}
+328
View File
@@ -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)
}
}
})
}
+177
View File
@@ -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)
}
})
}