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