From 5c416e3a327e55d9b20b987611e6cd032243008b Mon Sep 17 00:00:00 2001 From: Sung <8265228+sungwoncho@users.noreply.github.com> Date: Sat, 1 Nov 2025 19:59:51 -0700 Subject: [PATCH] Add user list command (#714) --- pkg/e2e/server_test.go | 20 +++++++++++ pkg/server/app/users.go | 11 ++++++ pkg/server/app/users_test.go | 66 ++++++++++++++++++++++++++++++++++++ pkg/server/cmd/helpers.go | 4 +-- pkg/server/cmd/user.go | 31 +++++++++++++++-- pkg/server/cmd/user_test.go | 49 ++++++++++++++++++++++++++ 6 files changed, 176 insertions(+), 5 deletions(-) diff --git a/pkg/e2e/server_test.go b/pkg/e2e/server_test.go index 7c646e8d..e8ca3da6 100644 --- a/pkg/e2e/server_test.go +++ b/pkg/e2e/server_test.go @@ -331,3 +331,23 @@ func TestServerUserCreateHelp(t *testing.T) { assert.Equal(t, strings.Contains(outputStr, "--password"), true, "help should show --password (double dash)") assert.Equal(t, strings.Contains(outputStr, "--dbPath"), true, "help should show --dbPath (double dash)") } + +func TestServerUserList(t *testing.T) { + tmpDB := t.TempDir() + "/test.db" + + // Create two users + exec.Command(testServerBinary, "user", "create", "--dbPath", tmpDB, "--email", "alice@example.com", "--password", "password123").CombinedOutput() + exec.Command(testServerBinary, "user", "create", "--dbPath", tmpDB, "--email", "bob@example.com", "--password", "password123").CombinedOutput() + + // List users + listCmd := exec.Command(testServerBinary, "user", "list", "--dbPath", tmpDB) + output, err := listCmd.CombinedOutput() + + if err != nil { + t.Fatalf("user list failed: %v\nOutput: %s", err, output) + } + + outputStr := string(output) + assert.Equal(t, strings.Contains(outputStr, "alice@example.com"), true, "output should have alice") + assert.Equal(t, strings.Contains(outputStr, "bob@example.com"), true, "output should have bob") +} diff --git a/pkg/server/app/users.go b/pkg/server/app/users.go index 760b3354..9c167b69 100644 --- a/pkg/server/app/users.go +++ b/pkg/server/app/users.go @@ -116,6 +116,17 @@ func (a *App) GetUserByEmail(email string) (*database.User, error) { return &user, nil } +// GetAllUsers retrieves all users from the database +func (a *App) GetAllUsers() ([]database.User, error) { + var users []database.User + err := a.DB.Find(&users).Error + if err != nil { + return nil, pkgErrors.Wrap(err, "finding users") + } + + return users, nil +} + // Authenticate authenticates a user func (a *App) Authenticate(email, password string) (*database.User, error) { user, err := a.GetUserByEmail(email) diff --git a/pkg/server/app/users_test.go b/pkg/server/app/users_test.go index f345b217..52183520 100644 --- a/pkg/server/app/users_test.go +++ b/pkg/server/app/users_test.go @@ -108,6 +108,72 @@ func TestGetUserByEmail(t *testing.T) { }) } +func TestGetAllUsers(t *testing.T) { + t.Run("success with multiple users", func(t *testing.T) { + db := testutils.InitMemoryDB(t) + + user1 := testutils.SetupUserData(db, "alice@example.com", "password123") + user2 := testutils.SetupUserData(db, "bob@example.com", "password123") + user3 := testutils.SetupUserData(db, "charlie@example.com", "password123") + + a := NewTest() + a.DB = db + + users, err := a.GetAllUsers() + + assert.Equal(t, err, nil, "should not error") + assert.Equal(t, len(users), 3, "should return 3 users") + + // Verify all users are returned + emails := make(map[string]bool) + for _, user := range users { + emails[user.Email.String] = true + } + assert.Equal(t, emails["alice@example.com"], true, "alice should be in results") + assert.Equal(t, emails["bob@example.com"], true, "bob should be in results") + assert.Equal(t, emails["charlie@example.com"], true, "charlie should be in results") + + // Verify user details match + for _, user := range users { + if user.Email.String == "alice@example.com" { + assert.Equal(t, user.ID, user1.ID, "alice ID mismatch") + } else if user.Email.String == "bob@example.com" { + assert.Equal(t, user.ID, user2.ID, "bob ID mismatch") + } else if user.Email.String == "charlie@example.com" { + assert.Equal(t, user.ID, user3.ID, "charlie ID mismatch") + } + } + }) + + t.Run("empty database", func(t *testing.T) { + db := testutils.InitMemoryDB(t) + + a := NewTest() + a.DB = db + + users, err := a.GetAllUsers() + + assert.Equal(t, err, nil, "should not error") + assert.Equal(t, len(users), 0, "should return 0 users") + }) + + t.Run("single user", func(t *testing.T) { + db := testutils.InitMemoryDB(t) + + user := testutils.SetupUserData(db, "alice@example.com", "password123") + + a := NewTest() + a.DB = db + + users, err := a.GetAllUsers() + + assert.Equal(t, err, nil, "should not error") + assert.Equal(t, len(users), 1, "should return 1 user") + assert.Equal(t, users[0].Email.String, "alice@example.com", "email mismatch") + assert.Equal(t, users[0].ID, user.ID, "user ID mismatch") + }) +} + func TestCreateUser(t *testing.T) { t.Run("success", func(t *testing.T) { db := testutils.InitMemoryDB(t) diff --git a/pkg/server/cmd/helpers.go b/pkg/server/cmd/helpers.go index 2a322476..ba90a7ce 100644 --- a/pkg/server/cmd/helpers.go +++ b/pkg/server/cmd/helpers.go @@ -111,8 +111,8 @@ func requireString(fs *flag.FlagSet, value, fieldName string) { } } -// setupAppWithDB creates config, initializes app, and returns cleanup function -func setupAppWithDB(fs *flag.FlagSet, dbPath string) (*app.App, func()) { +// createApp creates config, initializes app, and returns cleanup function +func createApp(fs *flag.FlagSet, dbPath string) (*app.App, func()) { cfg, err := config.New(config.Params{ DBPath: dbPath, }) diff --git a/pkg/server/cmd/user.go b/pkg/server/cmd/user.go index 01b753b1..7a98344d 100644 --- a/pkg/server/cmd/user.go +++ b/pkg/server/cmd/user.go @@ -51,7 +51,7 @@ func userCreateCmd(args []string) { requireString(fs, *email, "email") requireString(fs, *password, "password") - a, cleanup := setupAppWithDB(fs, *dbPath) + a, cleanup := createApp(fs, *dbPath) defer cleanup() _, err := a.CreateUser(*email, *password, *password) @@ -74,7 +74,7 @@ func userRemoveCmd(args []string, stdin io.Reader) { requireString(fs, *email, "email") - a, cleanup := setupAppWithDB(fs, *dbPath) + a, cleanup := createApp(fs, *dbPath) defer cleanup() // Check if user exists first @@ -127,7 +127,7 @@ func userResetPasswordCmd(args []string) { requireString(fs, *email, "email") requireString(fs, *password, "password") - a, cleanup := setupAppWithDB(fs, *dbPath) + a, cleanup := createApp(fs, *dbPath) defer cleanup() // Find the user @@ -151,6 +151,27 @@ func userResetPasswordCmd(args []string) { fmt.Printf("Email: %s\n", *email) } +func userListCmd(args []string, output io.Writer) { + fs := setupFlagSet("list", "dnote-server user list") + + dbPath := fs.String("dbPath", "", "Path to SQLite database file (env: DBPath, default: $XDG_DATA_HOME/dnote/server.db)") + + fs.Parse(args) + + a, cleanup := createApp(fs, *dbPath) + defer cleanup() + + users, err := a.GetAllUsers() + if err != nil { + log.ErrorWrap(err, "listing users") + os.Exit(1) + } + + for _, user := range users { + fmt.Fprintf(output, "%s,%s,%s\n", user.UUID, user.Email.String, user.CreatedAt.UTC().Format("2006-01-02T15:04:05Z")) + } +} + func userCmd(args []string) { if len(args) < 1 { fmt.Println(`Usage: @@ -158,6 +179,7 @@ func userCmd(args []string) { Available commands: create: Create a new user + list: List all users remove: Remove a user reset-password: Reset a user's password`) os.Exit(1) @@ -172,6 +194,8 @@ Available commands: switch subcommand { case "create": userCreateCmd(subArgs) + case "list": + userListCmd(subArgs, os.Stdout) case "remove": userRemoveCmd(subArgs, os.Stdin) case "reset-password": @@ -180,6 +204,7 @@ Available commands: fmt.Printf("Unknown subcommand: %s\n\n", subcommand) fmt.Println(`Available commands: create: Create a new user + list: List all users remove: Remove a user (only if they have no notes or books) reset-password: Reset a user's password`) os.Exit(1) diff --git a/pkg/server/cmd/user_test.go b/pkg/server/cmd/user_test.go index 834ae3bd..84e5f4de 100644 --- a/pkg/server/cmd/user_test.go +++ b/pkg/server/cmd/user_test.go @@ -16,6 +16,8 @@ package cmd import ( + "bytes" + "fmt" "strings" "testing" @@ -107,3 +109,50 @@ func TestUserResetPasswordCmd(t *testing.T) { err = bcrypt.CompareHashAndPassword([]byte(updatedUser.Password.String), []byte("oldpassword123")) assert.Equal(t, err != nil, true, "old password should not match") } + +func TestUserListCmd(t *testing.T) { + t.Run("multiple users", func(t *testing.T) { + tmpDB := t.TempDir() + "/test.db" + + // Create multiple users + db := testutils.InitDB(tmpDB) + user1 := testutils.SetupUserData(db, "alice@example.com", "password123") + user2 := testutils.SetupUserData(db, "bob@example.com", "password123") + user3 := testutils.SetupUserData(db, "charlie@example.com", "password123") + sqlDB, _ := db.DB() + sqlDB.Close() + + // Capture output + var buf bytes.Buffer + userListCmd([]string{"--dbPath", tmpDB}, &buf) + + // Verify output matches expected format + output := strings.TrimSpace(buf.String()) + lines := strings.Split(output, "\n") + + expectedLine1 := fmt.Sprintf("%s,alice@example.com,%s", user1.UUID, user1.CreatedAt.UTC().Format("2006-01-02T15:04:05Z")) + expectedLine2 := fmt.Sprintf("%s,bob@example.com,%s", user2.UUID, user2.CreatedAt.UTC().Format("2006-01-02T15:04:05Z")) + expectedLine3 := fmt.Sprintf("%s,charlie@example.com,%s", user3.UUID, user3.CreatedAt.UTC().Format("2006-01-02T15:04:05Z")) + + assert.Equal(t, lines[0], expectedLine1, "line 1 should match") + assert.Equal(t, lines[1], expectedLine2, "line 2 should match") + assert.Equal(t, lines[2], expectedLine3, "line 3 should match") + }) + + t.Run("empty database", func(t *testing.T) { + tmpDB := t.TempDir() + "/test.db" + + // Initialize empty database + db := testutils.InitDB(tmpDB) + sqlDB, _ := db.DB() + sqlDB.Close() + + // Capture output + var buf bytes.Buffer + userListCmd([]string{"--dbPath", tmpDB}, &buf) + + // Verify no output + output := buf.String() + assert.Equal(t, output, "", "should have no output for empty database") + }) +}