From 5a540184c924cf3b819a54ceff9938514ca35af9 Mon Sep 17 00:00:00 2001 From: ari melody Date: Fri, 31 Jul 2026 04:20:39 +0100 Subject: [PATCH 1/3] add in-memory DB for accounts, with tests! --- go.mod | 2 + go.sum | 4 + main.go | 10 +- model/log.go | 7 +- repository/account/interface.go | 16 +- repository/account/memory.go | 185 +++++++++++++++++++ repository/account/postgres.go | 41 ++--- service/account/account.go | 12 +- service/account/account_test.go | 302 ++++++++++++++++++++++++++++++++ 9 files changed, 534 insertions(+), 45 deletions(-) create mode 100644 repository/account/memory.go create mode 100644 service/account/account_test.go diff --git a/go.mod b/go.mod index a1c6c76..4d676a2 100644 --- a/go.mod +++ b/go.mod @@ -10,7 +10,9 @@ require ( require golang.org/x/crypto v0.27.0 // indirect require ( + github.com/google/go-cmp v0.5.9 // indirect github.com/gorilla/websocket v1.5.3 // indirect github.com/pelletier/go-toml/v2 v2.2.3 // indirect github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e // indirect + gotest.tools/v3 v3.5.2 // indirect ) diff --git a/go.sum b/go.sum index f2ec7e7..4aa1986 100644 --- a/go.sum +++ b/go.sum @@ -2,6 +2,8 @@ filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA= filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4= github.com/go-sql-driver/mysql v1.8.1 h1:LedoTUt/eveggdHS9qUFC1EFSa8bU2+1pZjSRpvNJ1Y= github.com/go-sql-driver/mysql v1.8.1/go.mod h1:wEBSXgmK//2ZFJyE+qWnIsVGmvmEKlqwuVSjsCm7DZg= +github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38= +github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg= github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= github.com/jmoiron/sqlx v1.4.0 h1:1PLqN7S1UYp5t4SrVVnt4nUVNemrDAtxlulVe+Qgm3o= @@ -16,3 +18,5 @@ github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e h1:MRM5ITcdelLK2j1 github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e/go.mod h1:XV66xRDqSt+GTGFMVlhk3ULuV0y9ZmzeVGR4mloJI3M= golang.org/x/crypto v0.27.0 h1:GXm2NjJrPaiv/h1tb2UH8QfgC/hOf/+z0p6PT8o1w7A= golang.org/x/crypto v0.27.0/go.mod h1:1Xngt8kV6Dvbssa53Ziq6Eqn0HqbZi5Z6R0ZpwQzt70= +gotest.tools/v3 v3.5.2 h1:7koQfIKdy+I8UTetycgUqXWSDwpgv193Ka+qRsmBY8Q= +gotest.tools/v3 v3.5.2/go.mod h1:LtdLGcnqToBH83WByAAi/wiwSFCArdFIUV/xxN4pcjA= diff --git a/main.go b/main.go index 235b2e8..e3055c1 100644 --- a/main.go +++ b/main.go @@ -41,13 +41,13 @@ const DB_VERSION = 1 const DEFAULT_PORT int64 = 8080 const HRT_DATE int64 = 1756478697 -const DEFAULT_LOG_FLAGS = log.Ldate | log.Ltime | log.Lmicroseconds //go:embed "public" var publicFS embed.FS func main() { - logger := log.New(os.Stderr, "main", DEFAULT_LOG_FLAGS) + // TODO: switch to a new logger. this one kinda sucks + logger := log.New(os.Stderr, "main", model.DEFAULT_LOG_FLAGS) logger.Print("made with <3 by ari melody\n\n") @@ -94,13 +94,13 @@ func main() { logRepo := logRepo.NewLogRepositoryPostgres(psqlDB) app.Log = logService.NewLogService( logRepo, - log.New(os.Stderr, "logger", DEFAULT_LOG_FLAGS), + log.New(os.Stderr, "logger", model.DEFAULT_LOG_FLAGS), ) accountRepo := accountRepo.NewAccountRepositoryPostgres(psqlDB) app.AccountService = accountService.NewAccountService( accountRepo, - log.New(os.Stderr, "account-repo", DEFAULT_LOG_FLAGS), + log.New(os.Stderr, "account-repo", model.DEFAULT_LOG_FLAGS), ) // handle command arguments @@ -494,7 +494,7 @@ func main() { go cursor.StartCursor(&app) - httpLogger := log.New(os.Stderr, "http", DEFAULT_LOG_FLAGS) + httpLogger := log.New(os.Stderr, "http", model.DEFAULT_LOG_FLAGS) // start the web server! mux := createServeMux(&app) diff --git a/model/log.go b/model/log.go index af4df5e..09e5af2 100644 --- a/model/log.go +++ b/model/log.go @@ -1,6 +1,9 @@ package model -import "time" +import ( + "log" + "time" +) type ( LogLevel int @@ -15,6 +18,8 @@ type ( ) const ( + DEFAULT_LOG_FLAGS = log.Ldate | log.Ltime | log.Lmicroseconds + LOG_ACCOUNT string = "account" LOG_MUSIC string = "music" LOG_ARTIST string = "artist" diff --git a/repository/account/interface.go b/repository/account/interface.go index 5e047d6..f715511 100644 --- a/repository/account/interface.go +++ b/repository/account/interface.go @@ -3,12 +3,15 @@ package account import "arimelody-web/model" type AccountRepository interface { - GetAll() ([]model.Account, error) + GetAll() ([]*model.Account, error) GetCount() (int, error) GetByID(id string) (*model.Account, error) GetByUsername(username string) (*model.Account, error) GetByEmail(email string) (*model.Account, error) - GetBySession(sessionToken string) (*model.Account, error) + + // Pulled this function: Cross-cutting concerns between accounts and sessions. + // Instead, fetch account ID from session and use GetByID() + // GetBySession(sessionToken string) (*model.Account, error) // Create an account, returning the new account ID. Create(username string, password string, email *string, avatarURL *string) (string, error) @@ -23,11 +26,12 @@ type AccountRepository interface { ChangeAvatarURL(id string, avatarURL string) error RemoveAvatar(id string) error - Delete(accountID string) error + Delete(id string) error // Increment the number of account login failure attempts, // returning the current fail count. - IncrementFails(accountID string) (int, error) - Lock(accountID string) error - Unlock(accountID string) error + IncrementFails(id string) (int, error) + ResetFails(id string) error + Lock(id string) error + Unlock(id string) error } diff --git a/repository/account/memory.go b/repository/account/memory.go new file mode 100644 index 0000000..3f4ff2b --- /dev/null +++ b/repository/account/memory.go @@ -0,0 +1,185 @@ +package account + +import ( + "arimelody-web/model" + "database/sql" + "errors" + "strconv" +) + +type ( + AccountRepositoryMemory struct { + accounts []*model.Account + } +) + +var _ AccountRepository = new(AccountRepositoryMemory) + +func NewAccountRepositoryMemory() *AccountRepositoryMemory { + return &AccountRepositoryMemory{ accounts: make([]*model.Account, 0) } +} + +func (repo *AccountRepositoryMemory) GetAll() ([]*model.Account, error) { + return repo.accounts, nil +} +func (repo *AccountRepositoryMemory) GetCount() (int, error) { + return len(repo.accounts), nil +} +func (repo *AccountRepositoryMemory) GetByID(id string) (*model.Account, error) { + for _, account := range repo.accounts { + if account.ID == id { return account, nil } + } + return nil, nil +} +func (repo *AccountRepositoryMemory) GetByUsername(username string) (*model.Account, error) { + for _, account := range repo.accounts { + if account.Username == username { return account, nil } + } + return nil, nil +} +func (repo *AccountRepositoryMemory) GetByEmail(email string) (*model.Account, error) { + for _, account := range repo.accounts { + if account.Email.Valid && account.Email.String == email { + return account, nil + } + } + return nil, nil +} + +// Create an account, returning the new account ID. +func (repo *AccountRepositoryMemory) Create(username string, password string, email *string, avatarURL *string) (string, error) { + if account, err := repo.GetByUsername(username); err != nil { + return "", errors.New("Failed to fetch other acccounts by username") + } else if account != nil { + return "", errors.New("Account with this username already exists") + } + + emailRef := "" + if email != nil { emailRef = *email } + avatarURLRef := "" + if avatarURL != nil { avatarURLRef = *avatarURL } + + id := strconv.Itoa(len(repo.accounts)) + + repo.accounts = append(repo.accounts, &model.Account{ + ID: id, + Username: username, + Password: password, + Email: sql.NullString{ String: emailRef, Valid: email != nil }, + AvatarURL: sql.NullString{ String: avatarURLRef, Valid: avatarURL != nil }, + }) + + return id, nil +} + +// Intended for large profile updates. For smaller adjusments, +// more specialised Change* and Remove* functions should be used. +func (repo *AccountRepositoryMemory) Update(id string, username string, password string, email *string, avatarUrl *string) error { + if account, err := repo.GetByUsername(username); err != nil { + return errors.New("Failed to fetch other acccounts by username") + } else if account != nil && account.ID != id { + return errors.New("Account with this username already exists") + } + + account, err := repo.GetByID(id) + if err != nil { return err } + + account.Username = username + account.Password = password + account.Email.Valid = email != nil + if account.Email.Valid { account.Email.String = *email } + account.AvatarURL.Valid = avatarUrl != nil + if account.AvatarURL.Valid { account.AvatarURL.String = *avatarUrl } + + return nil +} +func (repo *AccountRepositoryMemory) ChangeUsername(id string, username string) error { + if account, err := repo.GetByUsername(username); err != nil { + return errors.New("Failed to fetch other acccounts by username") + } else if account != nil && account.ID != id { + return errors.New("Account with this username already exists") + } + account, err := repo.GetByID(id) + if err != nil { return err } + account.Username = username + return nil +} +func (repo *AccountRepositoryMemory) ChangePassword(id string, password string) error { + account, err := repo.GetByID(id) + if err != nil { return err } + account.Password = password + return nil +} +func (repo *AccountRepositoryMemory) ChangeEmail(id string, email string) error { + account, err := repo.GetByID(id) + if err != nil { return err } + account.Email.Valid = true + account.Email.String = email + return nil +} +func (repo *AccountRepositoryMemory) RemoveEmail(id string) error { + account, err := repo.GetByID(id) + if err != nil { return err } + account.Email.Valid = false + account.Email.String = "" + return nil +} +func (repo *AccountRepositoryMemory) ChangeAvatarURL(id string, avatarURL string) error { + account, err := repo.GetByID(id) + if err != nil { return err } + account.AvatarURL.Valid = true + account.AvatarURL.String = avatarURL + return nil +} +func (repo *AccountRepositoryMemory) RemoveAvatar(id string) error { + account, err := repo.GetByID(id) + if err != nil { return err } + account.AvatarURL.Valid = false + account.AvatarURL.String = "" + return nil +} + +func (repo *AccountRepositoryMemory) Delete(id string) error { + accountIndex := -1 + for index, account := range repo.accounts { + if account.ID == id { + accountIndex = index + break + } + } + if accountIndex == -1 { return nil } + + repo.accounts = append( + repo.accounts[:accountIndex], + repo.accounts[accountIndex+1:]..., + ) + + return nil +} + +// Increment the number of account login failure attempts, +// returning the current fail count. +func (repo *AccountRepositoryMemory) IncrementFails(id string) (int, error) { + account, err := repo.GetByID(id) + if err != nil { return 0, err } + account.FailAttempts += 1 + return account.FailAttempts, nil +} +func (repo *AccountRepositoryMemory) ResetFails(id string) error { + account, err := repo.GetByID(id) + if err != nil { return err } + account.FailAttempts = 0 + return nil +} +func (repo *AccountRepositoryMemory) Lock(id string) error { + account, err := repo.GetByID(id) + if err != nil { return err } + account.Locked = true + return nil +} +func (repo *AccountRepositoryMemory) Unlock(id string) error { + account, err := repo.GetByID(id) + if err != nil { return err } + account.Locked = false + return nil +} diff --git a/repository/account/postgres.go b/repository/account/postgres.go index f38cf3e..74ac4c3 100644 --- a/repository/account/postgres.go +++ b/repository/account/postgres.go @@ -20,8 +20,8 @@ func NewAccountRepositoryPostgres(db *sqlx.DB) *AccountRepositoryPostgres { return &AccountRepositoryPostgres{ db: db } } -func (repo *AccountRepositoryPostgres) GetAll() ([]model.Account, error) { - var accounts = []model.Account{} +func (repo *AccountRepositoryPostgres) GetAll() ([]*model.Account, error) { + var accounts = []*model.Account{} err := repo.db.Select(&accounts, "SELECT * FROM account ORDER BY created_at ASC") if err != nil { @@ -79,22 +79,6 @@ func (repo *AccountRepositoryPostgres) GetByEmail(email string) (*model.Account, return &account, nil } -func (repo *AccountRepositoryPostgres) GetBySession(sessionToken string) (*model.Account, error) { - if sessionToken == "" { return nil, nil } - - account := model.Account{} - - err := repo.db.Get(&account, "SELECT account.* FROM account JOIN token ON id=account WHERE token=$1", sessionToken) - if err != nil { - if strings.Contains(err.Error(), "no rows") { - return nil, nil - } - return nil, err - } - - return &account, nil -} - func (repo *AccountRepositoryPostgres) Create( username string, password string, @@ -175,25 +159,30 @@ func (repo *AccountRepositoryPostgres) RemoveAvatar(id string) error { return err } -func (repo *AccountRepositoryPostgres) Delete(accountID string) error { - _, err := repo.db.Exec("DELETE FROM account WHERE id=$1", accountID) +func (repo *AccountRepositoryPostgres) Delete(id string) error { + _, err := repo.db.Exec("DELETE FROM account WHERE id=$1", id) return err } // Increment the number of account login failure attempts, // returning the current fail count. -func (repo *AccountRepositoryPostgres) IncrementFails(accountID string) (int, error) { +func (repo *AccountRepositoryPostgres) IncrementFails(id string) (int, error) { failAttempts := 0 - err := repo.db.Get(&failAttempts, "UPDATE account SET fail_attempts = fail_attempts + 1 WHERE id=$1 RETURNING fail_attempts", accountID) + err := repo.db.Get(&failAttempts, "UPDATE account SET fail_attempts = fail_attempts + 1 WHERE id=$1 RETURNING fail_attempts", id) return failAttempts, err } -func (repo *AccountRepositoryPostgres) Lock(accountID string) error { - _, err := repo.db.Exec("UPDATE account SET locked = true WHERE id=$1", accountID) +func (repo *AccountRepositoryPostgres) ResetFails(id string) error { + _, err := repo.db.Exec("UPDATE account SET fail_attempts = 0 WHERE id=$1", id) return err } -func (repo *AccountRepositoryPostgres) Unlock(accountID string) error { - _, err := repo.db.Exec("UPDATE account SET locked = false, fail_attempts = 0 WHERE id=$1", accountID) +func (repo *AccountRepositoryPostgres) Lock(id string) error { + _, err := repo.db.Exec("UPDATE account SET locked = true WHERE id=$1", id) + return err +} + +func (repo *AccountRepositoryPostgres) Unlock(id string) error { + _, err := repo.db.Exec("UPDATE account SET locked = false, fail_attempts = 0 WHERE id=$1", id) return err } diff --git a/service/account/account.go b/service/account/account.go index 29be2cc..88b06f9 100644 --- a/service/account/account.go +++ b/service/account/account.go @@ -19,7 +19,7 @@ func NewAccountService(repo repository.AccountRepository, logger *log.Logger) (* } } -func (s *AccountService) GetAll() ([]model.Account, error) { +func (s *AccountService) GetAll() ([]*model.Account, error) { return s.repo.GetAll() } @@ -39,12 +39,6 @@ func (s *AccountService) GetByEmail(email string) (*model.Account, error) { return s.repo.GetByEmail(email) } -func (s *AccountService) GetBySession(sessionToken string) (*model.Account, error) { - if sessionToken == "" { return nil, nil } - - return s.repo.GetBySession(sessionToken) -} - func (s *AccountService) Create( username string, password string, @@ -131,6 +125,10 @@ func (s *AccountService) IncrementFails(accountID string) (int, error) { return s.repo.IncrementFails(accountID) } +func (s *AccountService) ResetFails(accountID string) (error) { + return s.repo.ResetFails(accountID) +} + func (s *AccountService) Lock(accountID string) error { return s.repo.Lock(accountID) } diff --git a/service/account/account_test.go b/service/account/account_test.go new file mode 100644 index 0000000..dc5c78d --- /dev/null +++ b/service/account/account_test.go @@ -0,0 +1,302 @@ +package account + +import ( + "arimelody-web/model" + accountRepo "arimelody-web/repository/account" + "log" + "os" + "testing" + "gotest.tools/v3/assert" +) + +var ( + service *AccountService +) + +func init() { + devNullFile, err := os.OpenFile(os.DevNull, os.O_RDWR, 0666) + if err != nil { panic(err) } + defer devNullFile.Close() + + repo := accountRepo.NewAccountRepositoryMemory() + service = NewAccountService( + repo, + log.New(devNullFile, "", model.DEFAULT_LOG_FLAGS), + ) +} + +// Tests the account lifecycle. Users: +// - [x] can create account +// - [x] can't create duplicate account +// - [x] can change username +// - [x] can change password +// - [x] can change email +// - [x] can change avatar URL +// - [x] can increment auth failures +// - [x] can reset auth failures +// - [x] can lock account +// - [x] can unlock account +// - [x] can delete account +func Test_Account(t *testing.T) { + username := "testificate" + password := "the amazing digital data breach" + email := "goober@arimelody.space" + avatarURL := "/img/default-avatar.webp" + + var id string + var err error + + t.Run("accounts should start empty", func(t *testing.T) { + t.Run("count is zero", func(t *testing.T) { + if num, err := service.GetCount(); err != nil { + t.Errorf("Failed to get number of accounts: %v", err) + } else { + assert.Equal(t, num, 0) + } + }) + + t.Run("service returns empty array", func(t *testing.T) { + if accounts, err := service.GetAll(); err != nil { + t.Errorf("Failed to get number of accounts: %v", err) + } else { + assert.Equal(t, len(accounts), 0) + } + }) + }) + + t.Run("can create account", func(t *testing.T) { + id, err = service.Create(username, password, &email, &avatarURL) + if err != nil { + t.Errorf("Failed to create account: %v", err) + } + + t.Run("and fetch by ID", func(t *testing.T) { + account, err := service.GetByID(id) + if err != nil { + t.Errorf("Failed to get account after creation: %v", err) + } + + assert.Equal(t, account.Username, username) + assert.Equal(t, account.Password, password) + assert.Equal(t, account.Email.String, email) + assert.Equal(t, account.AvatarURL.String, avatarURL) + assert.Equal(t, account.FailAttempts, 0) + assert.Equal(t, account.Locked, false) + }) + t.Run("and fetch by username", func(t *testing.T) { + account, err := service.GetByUsername(username) + if err != nil { + t.Errorf("Failed to get account after creation: %v", err) + } + + assert.Equal(t, account.ID, id) + assert.Equal(t, account.Password, password) + assert.Equal(t, account.Email.String, email) + assert.Equal(t, account.AvatarURL.String, avatarURL) + assert.Equal(t, account.FailAttempts, 0) + assert.Equal(t, account.Locked, false) + }) + t.Run("and fetch by email", func(t *testing.T) { + account, err := service.GetByEmail(email) + if err != nil { + t.Errorf("Failed to get account after creation: %v", err) + } + + assert.Equal(t, account.ID, id) + assert.Equal(t, account.Username, username) + assert.Equal(t, account.Password, password) + assert.Equal(t, account.AvatarURL.String, avatarURL) + assert.Equal(t, account.FailAttempts, 0) + assert.Equal(t, account.Locked, false) + }) + }) + + t.Run("number of accounts should increment", func(t *testing.T) { + t.Run("count is one", func(t *testing.T) { + if num, err := service.GetCount(); err != nil { + t.Errorf("Failed to get number of accounts: %v", err) + } else { + assert.Equal(t, num, 1) + } + }) + + t.Run("service returns array with one account", func(t *testing.T) { + if accounts, err := service.GetAll(); err != nil { + t.Errorf("Failed to get number of accounts: %v", err) + } else { + assert.Equal(t, len(accounts), 1) + } + }) + }) + + t.Run("can't create duplicate account", func(t *testing.T) { + _, err := service.Create(username, password, &email, &avatarURL) + if err == nil { + t.Error("Duplicate account was created") + } + }) + + t.Run("can change username", func(t *testing.T) { + testUsername := "some_other_name" + if err := service.ChangeUsername(id, testUsername); err != nil { + t.Errorf("Failed to change username: %v", err) + } + + if account, err := service.GetByID(id); err != nil { + t.Errorf("Failed to get account: %v", err) + } else if account == nil { + t.Error("Account is nil after update") + } else if account.Username != testUsername { + t.Error("Username did not update") + } + }) + + t.Run("can change password", func(t *testing.T) { + testPassword := "other more different password" + if err := service.ChangePassword(id, testPassword); err != nil { + t.Errorf("Failed to change password: %v", err) + } + + if account, err := service.GetByID(id); err != nil { + t.Errorf("Failed to get account: %v", err) + } else if account == nil { + t.Error("Account is nil after update") + } else if account.Password != testPassword { + t.Error("Password did not update") + } + }) + + t.Run("can change email", func(t *testing.T) { + testEmail := "brandnewemail@for.me" + if err := service.ChangeEmail(id, testEmail); err != nil { + t.Errorf("Failed to change email: %v", err) + } + + if account, err := service.GetByID(id); err != nil { + t.Errorf("Failed to get account: %v", err) + } else if account == nil { + t.Error("Account is nil after update") + } else if !account.Email.Valid || account.Email.String != testEmail { + t.Error("Email did not update") + } + }) + + t.Run("can remove email", func(t *testing.T) { + if err := service.ChangeEmail(id, ""); err != nil { + t.Errorf("Failed to change email: %v", err) + } + + if account, err := service.GetByID(id); err != nil { + t.Errorf("Failed to get account: %v", err) + } else if account == nil { + t.Error("Account is nil after update") + } else if account.Email.Valid || len(account.Email.String) > 0 { + t.Error("Email did not update") + } + }) + + t.Run("can change avatar URL", func(t *testing.T) { + testAvatarURL := "/img/some-other-avatar.webp" + if err := service.ChangeAvatarURL(id, testAvatarURL); err != nil { + t.Errorf("Failed to change avatar URL: %v", err) + } + + if account, err := service.GetByID(id); err != nil { + t.Errorf("Failed to get account: %v", err) + } else if account == nil { + t.Error("Account is nil after update") + } else if !account.AvatarURL.Valid || account.AvatarURL.String != testAvatarURL { + t.Error("Avatar URL did not update") + } + }) + + t.Run("can remove avatar URL", func(t *testing.T) { + if err := service.ChangeAvatarURL(id, ""); err != nil { + t.Errorf("Failed to change avatar URL: %v", err) + } + + if account, err := service.GetByID(id); err != nil { + t.Errorf("Failed to get account: %v", err) + } else if account == nil { + t.Error("Account is nil after update") + } else if account.AvatarURL.Valid || len(account.AvatarURL.String) > 0 { + t.Error("Avatar URL did not update") + } + }) + + t.Run("can increment auth failures", func(t *testing.T) { + if num, err := service.IncrementFails(id); err != nil { + t.Errorf("Failed to increment account auth failures: %v", err) + } else { + assert.Equal(t, num, 1) + } + + if account, err := service.GetByID(id); err != nil { + t.Errorf("Failed to get account: %v", err) + } else if account == nil { + t.Error("Account is nil after update") + } else { + assert.Equal(t, account.FailAttempts, 1) + } + }) + + t.Run("can reset auth failures", func(t *testing.T) { + if err := service.ResetFails(id); err != nil { + t.Errorf("Failed to reset account auth failures: %v", err) + } + + if account, err := service.GetByID(id); err != nil { + t.Errorf("Failed to get account: %v", err) + } else if account == nil { + t.Error("Account is nil after update") + } else { + assert.Equal(t, account.FailAttempts, 0) + } + }) + + t.Run("can lock account", func(t *testing.T) { + if err := service.Lock(id); err != nil { + t.Errorf("Failed to lock account: %v", err) + } + + if account, err := service.GetByID(id); err != nil { + t.Errorf("Failed to get account: %v", err) + } else if account == nil { + t.Error("Account is nil after update") + } else { + assert.Equal(t, account.Locked, true) + } + }) + + t.Run("can unlock account", func(t *testing.T) { + if err := service.Unlock(id); err != nil { + t.Errorf("Failed to unlock account: %v", err) + } + + if account, err := service.GetByID(id); err != nil { + t.Errorf("Failed to get account: %v", err) + } else if account == nil { + t.Error("Account is nil after update") + } else { + assert.Equal(t, account.Locked, false) + } + }) + + t.Run("can delete account", func(t *testing.T) { + if err = service.Delete(id); err != nil { + t.Errorf("Failed to delete account: %v", err) + } + + if account, err := service.GetByID(id); err != nil { + t.Errorf("Failed to get account after deletion: %v", err) + } else if account != nil { + t.Error("Account still exists after deletion") + } + }) + + t.Cleanup(func() { + if err := service.Delete(id); err != nil { + t.Errorf("Failed to clean up test case: %v", err) + } + }) +} From 8be785cd8b1c19616d1901af1f178337101e69b7 Mon Sep 17 00:00:00 2001 From: ari melody Date: Fri, 31 Jul 2026 04:46:32 +0100 Subject: [PATCH 2/3] 100% test coverage on account service! --- main.go | 10 +----- repository/account/interface.go | 3 +- repository/account/memory.go | 13 +++++++ service/account/account.go | 20 ----------- service/account/account_test.go | 61 +++++++++++++++++++++++++++++++++ 5 files changed, 76 insertions(+), 31 deletions(-) diff --git a/main.go b/main.go index e3055c1..b662cc8 100644 --- a/main.go +++ b/main.go @@ -306,15 +306,7 @@ func main() { } account.Password = string(hashedPassword) - var email *string = nil - if account.Email.Valid { email = &account.Email.String } - var avatarURL *string = nil - if account.AvatarURL.Valid { email = &account.AvatarURL.String } - if err = app.AccountService.Update( - account.ID, - username, string(hashedPassword), - email, avatarURL, - ); err != nil { + if err = app.AccountService.ChangePassword(account.ID, string(hashedPassword)); err != nil { logger.Fatalf("FATAL: Failed to update password: %v\n", err) } diff --git a/repository/account/interface.go b/repository/account/interface.go index f715511..fda2b1a 100644 --- a/repository/account/interface.go +++ b/repository/account/interface.go @@ -16,8 +16,7 @@ type AccountRepository interface { // Create an account, returning the new account ID. Create(username string, password string, email *string, avatarURL *string) (string, error) - // Intended for large profile updates. For smaller adjusments, - // more specialised Change* and Remove* functions should be used. + // Deprecated in favour of more specialised Change* and Remove* functions. Update(id string, username string, password string, email *string, avatarUrl *string) error ChangeUsername(id string, username string) error ChangePassword(id string, password string) error diff --git a/repository/account/memory.go b/repository/account/memory.go index 3f4ff2b..1b94167 100644 --- a/repository/account/memory.go +++ b/repository/account/memory.go @@ -99,20 +99,27 @@ func (repo *AccountRepositoryMemory) ChangeUsername(id string, username string) } else if account != nil && account.ID != id { return errors.New("Account with this username already exists") } + account, err := repo.GetByID(id) if err != nil { return err } + if account == nil { return errors.New("Account does not exist") } + account.Username = username return nil } func (repo *AccountRepositoryMemory) ChangePassword(id string, password string) error { account, err := repo.GetByID(id) if err != nil { return err } + if account == nil { return errors.New("Account does not exist") } + account.Password = password return nil } func (repo *AccountRepositoryMemory) ChangeEmail(id string, email string) error { account, err := repo.GetByID(id) if err != nil { return err } + if account == nil { return errors.New("Account does not exist") } + account.Email.Valid = true account.Email.String = email return nil @@ -120,6 +127,8 @@ func (repo *AccountRepositoryMemory) ChangeEmail(id string, email string) error func (repo *AccountRepositoryMemory) RemoveEmail(id string) error { account, err := repo.GetByID(id) if err != nil { return err } + if account == nil { return errors.New("Account does not exist") } + account.Email.Valid = false account.Email.String = "" return nil @@ -127,6 +136,8 @@ func (repo *AccountRepositoryMemory) RemoveEmail(id string) error { func (repo *AccountRepositoryMemory) ChangeAvatarURL(id string, avatarURL string) error { account, err := repo.GetByID(id) if err != nil { return err } + if account == nil { return errors.New("Account does not exist") } + account.AvatarURL.Valid = true account.AvatarURL.String = avatarURL return nil @@ -134,6 +145,8 @@ func (repo *AccountRepositoryMemory) ChangeAvatarURL(id string, avatarURL string func (repo *AccountRepositoryMemory) RemoveAvatar(id string) error { account, err := repo.GetByID(id) if err != nil { return err } + if account == nil { return errors.New("Account does not exist") } + account.AvatarURL.Valid = false account.AvatarURL.String = "" return nil diff --git a/service/account/account.go b/service/account/account.go index 88b06f9..730fc8b 100644 --- a/service/account/account.go +++ b/service/account/account.go @@ -60,26 +60,6 @@ func (s *AccountService) Create( return id, nil } -// Intended for large profile updates. For smaller adjusments, -// more specialised Change* and Remove* functions should be used. -func (s *AccountService) Update( - id string, - username string, - password string, - email *string, - avatarUrl *string, -) error { - if len(username) == 0 { return errors.New("Username cannot be empty") } - if len(password) == 0 { return errors.New("Password cannot be empty") } - if email != nil && len(*email) == 0 { return errors.New("Email cannot be empty") } - - if err := s.repo.Update(id, username, password, email, avatarUrl); err != nil { - return err - } - - s.log.Printf("Updated account '%s' (%s)", username, id) - return nil -} func (s *AccountService) ChangeUsername(id string, username string) error { if len(username) == 0 { return errors.New("Username cannot be empty") } if err := s.repo.ChangeUsername(id, username); err != nil { diff --git a/service/account/account_test.go b/service/account/account_test.go index dc5c78d..6487d64 100644 --- a/service/account/account_test.go +++ b/service/account/account_test.go @@ -149,6 +149,12 @@ func Test_Account(t *testing.T) { } else if account.Username != testUsername { t.Error("Username did not update") } + + t.Run("but not to an invalid value", func(t *testing.T) { + if err := service.ChangeUsername(id, ""); err == nil { + t.Error("Could change username to invalid value") + } + }) }) t.Run("can change password", func(t *testing.T) { @@ -164,6 +170,12 @@ func Test_Account(t *testing.T) { } else if account.Password != testPassword { t.Error("Password did not update") } + + t.Run("but not to an invalid value", func(t *testing.T) { + if err := service.ChangePassword(id, ""); err == nil { + t.Error("Could change password to invalid value") + } + }) }) t.Run("can change email", func(t *testing.T) { @@ -294,6 +306,55 @@ func Test_Account(t *testing.T) { } }) + t.Run("can't create an account with invalid", func(t *testing.T) { + t.Run("username", func(t *testing.T) { + if _, err := service.Create("", password, &email, &avatarURL); err == nil { + t.Error("Could create account with empty username") + } + }) + + t.Run("password", func(t *testing.T) { + if _, err := service.Create(username, "", &email, &avatarURL); err == nil { + t.Error("Could create account with empty password") + } + }) + + t.Run("email", func(t *testing.T) { + testEmail := "" + if _, err := service.Create(username, password, &testEmail, &avatarURL); err == nil { + t.Error("Could create account with empty (non-nil) email") + } + }) + }) + + t.Run("can't update account that doesn't exist", func(t *testing.T) { + garbageAccountID := "adsginh534g9405gmb40i9bm" + + t.Run("username", func(t *testing.T) { + if err := service.ChangeUsername(garbageAccountID, "some-username"); err == nil { + t.Error("Could update non-existent account's username") + } + }) + + t.Run("password", func(t *testing.T) { + if err := service.ChangePassword(garbageAccountID, "some-password"); err == nil { + t.Error("Could update non-existent account's password") + } + }) + + t.Run("email", func(t *testing.T) { + if err := service.ChangeEmail(garbageAccountID, "some-email@real.gov"); err == nil { + t.Error("Could update non-existent account's email") + } + }) + + t.Run("avatar URL", func(t *testing.T) { + if err := service.ChangeAvatarURL(garbageAccountID, "/img/null.webp"); err == nil { + t.Error("Could update non-existent account's avatar URL") + } + }) + }) + t.Cleanup(func() { if err := service.Delete(id); err != nil { t.Errorf("Failed to clean up test case: %v", err) From 89a5fdc4e50b141e8ea5f167214908ab240bea20 Mon Sep 17 00:00:00 2001 From: ari melody Date: Fri, 31 Jul 2026 05:14:19 +0100 Subject: [PATCH 3/3] refreshed release model tests: 100% coverage! --- model/release_test.go | 292 ++++++++++++++++++++---------------------- 1 file changed, 142 insertions(+), 150 deletions(-) diff --git a/model/release_test.go b/model/release_test.go index b0ddaf5..fc0e221 100644 --- a/model/release_test.go +++ b/model/release_test.go @@ -1,157 +1,149 @@ package model import ( - "testing" - "time" + "strings" + "testing" + "time" + + "gotest.tools/v3/assert" ) -func Test_Release_DescriptionHTML(t *testing.T) { - release := Release{ - Description: "this is\na test\ndescription!", - } +func Test_Release(t *testing.T) { + t.Run("prints correct description HTML", func(t *testing.T) { + release := Release{ + Description: "this is\na test\ndescription!", + } - // descriptions are set by privileged users, - // so we'll allow HTML injection here - want := "this is
a test
description!" - got := release.GetDescriptionHTML() - if want != string(got) { - t.Errorf(`release description incorrectly formatted (want "%s", got "%s")`, want, got) - } -} - -func Test_Release_ReleaseDate(t *testing.T) { - release := Release{ - ReleaseDate: time.Date(2025, time.July, 26, 16, 0, 0, 0, time.UTC), - } - - want := "2025-07-26T16:00" - got := release.TextReleaseDate() - if want != got { - t.Errorf(`release date incorrectly formatted (want "%s", got "%s")`, want, got) - } - - want = "26 July 2025" - got = release.PrintReleaseDate() - if want != got { - t.Errorf(`release date (print) incorrectly formatted (want "%s", got "%s")`, want, got) - } -} - -func Test_Release_Artwork(t *testing.T) { - want := "testartwork.png" - release := Release{ Artwork: want } - - got := release.GetArtwork() - if want != got { - t.Errorf(`correct value not returned when artwork is populated (want "%s", got "%s")`, want, got) - } - - release = Release{} - - want = "/img/default-cover-art.png" - got = release.GetArtwork() - if want != got { - t.Errorf(`default value not returned when artwork is empty (want "%s", got "%s")`, want, got) - } -} - -func Test_Release_IsSingle(t *testing.T) { - release := Release{ - Tracks: []*Track{}, - } - - if release.IsSingle() { - t.Errorf("IsSingle() == true when no tracks are present") - } - - release.Tracks = append(release.Tracks, &Track{}) - if !release.IsSingle() { - t.Errorf("IsSingle() == false when one track is present") - } - - release.Tracks = append(release.Tracks, &Track{}) - if release.IsSingle() { - t.Errorf("IsSingle() == true when >1 tracks are present") - } -} - -func Test_Release_IsReleased(t *testing.T) { - release := Release { - ReleaseDate: time.Now(), - } - - if !release.IsReleased() { - t.Errorf("IsRelease() == false when release date in the past") - } - - release.ReleaseDate = time.Now().Add(time.Hour) - if release.IsReleased() { - t.Errorf("IsRelease() == true when release date in the future") - } -} - -func Test_Release_PrintArtists(t *testing.T) { - artist1 := "ari melody" - artist2 := "aridoodle" - artist3 := "idk" - artist4 := "guest" - - release := Release { - Credits: []*Credit{ - { Artist: Artist{ Name: artist1 }, Primary: true }, - { Artist: Artist{ Name: artist2 }, Primary: true }, - { Artist: Artist{ Name: artist3 }, Primary: false }, - { Artist: Artist{ Name: artist4 }, Primary: true }, - }, - } - - { - want := []string{ artist1, artist2, artist4 } - got := release.GetUniqueArtistNames(true) - if len(want) != len(got) { - t.Errorf(`len(GetUniqueArtistNames) (primary only) == %d, want %d`, len(got), len(want)) - } - for i := range got { - if want[i] != got[i] { - t.Errorf(`GetUniqueArtistNames[%d] (primary only) == %s, want %s`, i, got[i], want[i]) - } - } - - want = []string{ artist1, artist2, artist3, artist4 } - got = release.GetUniqueArtistNames(false) - if len(want) != len(got) { - t.Errorf(`len(GetUniqueArtistNames) == %d, want %d`, len(got), len(want)) - } - for i := range got { - if want[i] != got[i] { - t.Errorf(`GetUniqueArtistNames[%d] == %s, want %s`, i, got[i], want[i]) - } - } - } - - { - want := "ari melody, aridoodle & guest" - got := release.PrintArtists(true, true) - if want != got { - t.Errorf(`PrintArtists (primary only, ampersand) == "%s", want "%s"`, want, got) - } - - want = "ari melody, aridoodle, guest" - got = release.PrintArtists(true, false) - if want != got { - t.Errorf(`PrintArtists (primary only) == "%s", want "%s"`, want, got) - } - - want = "ari melody, aridoodle, idk & guest" - got = release.PrintArtists(false, true) - if want != got { - t.Errorf(`PrintArtists (all, ampersand) == "%s", want "%s"`, want, got) - } - - want = "ari melody, aridoodle, idk, guest" - got = release.PrintArtists(false, false) - if want != got { - t.Errorf(`PrintArtists (all) == "%s", want "%s"`, want, got) - } - } + // descriptions are set by privileged users, + // so we'll allow HTML injection here + assert.Equal( + t, + string(release.GetDescriptionHTML()), + "this is
a test
description!", + ) + }) + + t.Run("prints correct release date", func(t *testing.T) { + release := Release{ + ReleaseDate: time.Date(2025, time.July, 26, 16, 0, 0, 0, time.UTC), + } + + assert.Equal(t, release.TextReleaseDate(), "2025-07-26T16:00") + assert.Equal(t, release.PrintReleaseDate(), "26 July 2025") + }) + + t.Run("returns correct artwork", func(t *testing.T) { + artwork := "testartwork.png" + release := Release{ Artwork: artwork } + assert.Equal(t, release.GetArtwork(), artwork) + }) + + t.Run("returns placeholder artwork when empty", func(t *testing.T) { + release := Release{} + assert.Equal(t, release.GetArtwork(), "/img/default-cover-art.png") + }) + + t.Run("singles", func(t *testing.T) { + release := Release{ + Tracks: []*Track{}, + } + + t.Run("false when no tracks are present", func(t *testing.T) { + assert.Equal(t, release.IsSingle(), false) + }) + + release.Tracks = append(release.Tracks, &Track{}) + t.Run("true when one track is present", func(t *testing.T) { + assert.Equal(t, release.IsSingle(), true) + }) + + release.Tracks = append(release.Tracks, &Track{}) + t.Run("false when >1 tracks are present", func(t *testing.T) { + assert.Equal(t, release.IsSingle(), false) + }) + }) + + t.Run("released", func(t *testing.T) { + release := Release { + ReleaseDate: time.Now(), + } + + t.Run("true when release date in the past", func(t *testing.T) { + assert.Equal(t, release.IsReleased(), true) + }) + + release.ReleaseDate = time.Now().Add(time.Hour) + t.Run("false when release date in the future", func(t *testing.T) { + assert.Equal(t, release.IsReleased(), false) + }) + }) + + t.Run("printing artists", func(t *testing.T) { + artist1 := "ari melody" + artist2 := "aridoodle" + artist3 := "idk" + artist4 := "guest" + + release := Release{} + t.Run("prints \"Unknown Artist\" when release has no credits", func(t *testing.T) { + assert.Equal(t, release.PrintArtists(false, true), "Unknown Artist") + }) + + release.Credits = append( + release.Credits, + &Credit{ Artist: Artist{ Name: artist1 }, Primary: true }, + ) + t.Run("prints ONLY first artist name when release has one credit", func(t *testing.T) { + assert.Equal(t, release.PrintArtists(false, true), artist1) + }) + + release.Credits = append(release.Credits, []*Credit{ + { Artist: Artist{ Name: artist2 }, Primary: true }, + { Artist: Artist{ Name: artist3 }, Primary: false }, + { Artist: Artist{ Name: artist4 }, Primary: true }, + }...) + t.Run("can get only unique primary artist names", func(t *testing.T) { + assert.Equal( + t, + strings.Join(release.GetUniqueArtistNames(true), " "), + strings.Join([]string{ artist1, artist2, artist4 }, " "), + ) + }) + t.Run("can get only unique artist names", func(t *testing.T) { + assert.Equal( + t, + strings.Join(release.GetUniqueArtistNames(false), " "), + strings.Join([]string{ artist1, artist2, artist3, artist4 }, " "), + ) + }) + + t.Run("can print only primary artists, with ampersands", func(t *testing.T) { + assert.Equal( + t, + release.PrintArtists(true, true), + "ari melody, aridoodle & guest", + ) + }) + t.Run("can print only primary artists, without ampersands", func(t *testing.T) { + assert.Equal( + t, + release.PrintArtists(true, false), + "ari melody, aridoodle, guest", + ) + }) + t.Run("can print all artists, with ampersands", func(t *testing.T) { + assert.Equal( + t, + release.PrintArtists(false, true), + "ari melody, aridoodle, idk & guest", + ) + }) + t.Run("can print all artists, without ampersands", func(t *testing.T) { + assert.Equal( + t, + release.PrintArtists(false, false), + "ari melody, aridoodle, idk, guest", + ) + }) + }) }