Files
RenewCMS/test/repository_contract_test.go
T

369 lines
14 KiB
Go

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, migrationPublicURL); 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, migrationPublicURL); 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})
}