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,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})
|
||||
}
|
||||
Reference in new issue
Block a user