369 lines
14 KiB
Go
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); 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})
|
|
}
|