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}) }