From f0126c656a56c492b7674713b137c67672d4b62f Mon Sep 17 00:00:00 2001 From: Darius klein Date: Sat, 4 Apr 2026 14:23:57 +0200 Subject: [PATCH] Add support for shared todo items across multiple users. Patchnotes: Added SharedWith field; Implemented authorization for shared users; Updated BoltStore for shared todo retrieval; Updated client to prompt for shared users; Added integration tests. Co-authored-by: Junie --- client/todo/add.go | 12 ++++ common/bolt.go | 66 +++++++++++++-------- common/todo.go | 11 +++- common/types.go | 8 ++- tests/sharing_test.go | 134 ++++++++++++++++++++++++++++++++++++++++++ 5 files changed, 203 insertions(+), 28 deletions(-) create mode 100644 tests/sharing_test.go diff --git a/client/todo/add.go b/client/todo/add.go index a080660..f45116c 100644 --- a/client/todo/add.go +++ b/client/todo/add.go @@ -50,12 +50,24 @@ func CreateNewTodo() common.Todo { for i, l := range labelSlice { labelSlice[i] = strings.TrimSpace(l) } + + sharedInput := common.AskUserString("Shared with (comma separated usernames):\n") + sharedSlice := strings.Split(sharedInput, ",") + var sharedWith []string + for _, s := range sharedSlice { + s = strings.TrimSpace(s) + if s != "" { + sharedWith = append(sharedWith, s) + } + } + return common.Todo{ Name: common.AskUserString("Name:\n"), Description: common.AskUserString("Description:\n"), Status: common.AskUserString(fmt.Sprintf("Status (%s, %s, %s, %s, %s, %s):\n", common.NotStarted, common.Done, common.WIP, common.Pending, common.Blocked, common.Failed)), Labels: labelSlice, + SharedWith: sharedWith, Owner: cfg.Server.Credentials.Username, LastModified: time.Now().UTC(), } diff --git a/common/bolt.go b/common/bolt.go index dbb84e8..d0ebfae 100644 --- a/common/bolt.go +++ b/common/bolt.go @@ -7,6 +7,8 @@ import ( "path/filepath" "sync" + "slices" + bolt "go.etcd.io/bbolt" ) @@ -219,35 +221,53 @@ func (s *BoltStore) ExistsByKey(bucket, key string) (bool, error) { } func (s *BoltStore) GetTodoMap(user string) map[string]Todo { - storedTodoJsons := s.GetAllFromBucket(user) - serverTodos := make(map[string]Todo) - for key, val := range storedTodoJsons { - var todo Todo - if json.Unmarshal([]byte(val), &todo) == nil { - serverTodos[key] = todo - } - } + + s.DB.View(func(tx *bolt.Tx) error { + return tx.ForEach(func(name []byte, b *bolt.Bucket) error { + bucketName := string(name) + c := b.Cursor() + for k, v := c.First(); k != nil; k, v = c.Next() { + var todo Todo + if json.Unmarshal(v, &todo) == nil { + if bucketName == user || slices.Contains(todo.SharedWith, user) { + serverTodos[string(k)] = todo + } + } + } + return nil + }) + }) + return serverTodos } func (s *BoltStore) GetTodoList(user string, includeDeleted bool) []Todo { - storedTodoJsons := s.GetAllFromBucket(user) - var storedTodos []Todo - for _, val := range storedTodoJsons { - var todo Todo - if json.Unmarshal([]byte(val), &todo) == nil { - var include = false - if includeDeleted { - include = true - } else { - include = !todo.Deleted + + s.DB.View(func(tx *bolt.Tx) error { + return tx.ForEach(func(name []byte, b *bolt.Bucket) error { + bucketName := string(name) + c := b.Cursor() + for _, v := c.First(); v != nil; _, v = c.Next() { + var todo Todo + if json.Unmarshal(v, &todo) == nil { + if bucketName == user || slices.Contains(todo.SharedWith, user) { + var include = false + if includeDeleted { + include = true + } else { + include = !todo.Deleted + } + if include { + storedTodos = append(storedTodos, todo) + } + } + } } - if include { - storedTodos = append(storedTodos, todo) - } - } - } + return nil + }) + }) + return storedTodos } diff --git a/common/todo.go b/common/todo.go index 439a5e2..eabf998 100644 --- a/common/todo.go +++ b/common/todo.go @@ -4,6 +4,7 @@ import ( "encoding/json" "fmt" "hash/fnv" + "slices" "strings" "time" @@ -12,7 +13,10 @@ import ( ) func (todo *Todo) Store(store *BoltStore, user string) error { - if todo.Owner != user { + isOwner := todo.Owner == user + isShared := slices.Contains(todo.SharedWith, user) + + if !isOwner && !isShared { return fmt.Errorf("unauthorized user") } if todo.Id == "" { @@ -23,7 +27,7 @@ func (todo *Todo) Store(store *BoltStore, user string) error { if err != nil { return err } - return store.SaveValueToBucket(user, todo.Id, string(todoJson)) + return store.SaveValueToBucket(todo.Owner, todo.Id, string(todoJson)) } func (todoRequest StoreTodoRequest) Store(store *BoltStore, user string) error { @@ -33,6 +37,7 @@ func (todoRequest StoreTodoRequest) Store(store *BoltStore, user string) error { Description: todoRequest.Description, Status: todoRequest.Status, Owner: user, + SharedWith: todoRequest.SharedWith, LastModified: time.Now().UTC(), } todoJson, err := json.Marshal(todo) @@ -114,6 +119,7 @@ func (t Todo) IsEqual(other Todo) bool { t.Description == other.Description && t.Status == other.Status && t.Owner == other.Owner && + slices.Equal(t.SharedWith, other.SharedWith) && t.Deleted == other.Deleted } @@ -122,5 +128,6 @@ func (t Todo) IsEqualIgnoringStatus(other Todo) bool { t.Name == other.Name && t.Description == other.Description && t.Owner == other.Owner && + slices.Equal(t.SharedWith, other.SharedWith) && t.Deleted == other.Deleted } diff --git a/common/types.go b/common/types.go index fed8e8f..beef494 100644 --- a/common/types.go +++ b/common/types.go @@ -13,15 +13,17 @@ type Todo struct { Description string `json:"description"` Status string `json:"status"` Owner string `json:"owner"` + SharedWith []string `json:"shared_with,omitzero"` Labels []string `json:"labels"` LastModified time.Time `json:"last_modified"` Deleted bool `json:"deleted"` } type StoreTodoRequest struct { - Name string `json:"name"` - Description string `json:"description"` - Status string `json:"status"` + Name string `json:"name"` + Description string `json:"description"` + Status string `json:"status"` + SharedWith []string `json:"shared_with,omitzero"` } type TodoList struct { diff --git a/tests/sharing_test.go b/tests/sharing_test.go new file mode 100644 index 0000000..06a4bda --- /dev/null +++ b/tests/sharing_test.go @@ -0,0 +1,134 @@ +package tests + +import ( + "bytes" + "encoding/json" + "net/http" + "net/http/httptest" + "os" + "testing" + "time" + + "gitea.kleinsense.nl/DariusKlein/kleinTodo/common" + "gitea.kleinsense.nl/DariusKlein/kleinTodo/server/handler" + "github.com/google/uuid" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestSharedTodoSync(t *testing.T) { + // 1. Setup Environment + tempDir, err := os.MkdirTemp("", "todo-test-*") + require.NoError(t, err) + defer os.RemoveAll(tempDir) + + os.Setenv("XDG_CONFIG_HOME", tempDir) + os.Setenv("JWT_SECRET", "test-secret-123") + + // 2. Initialize Server + mux := http.NewServeMux() + mux.HandleFunc("POST /register", handler.RegisterHandler) + mux.HandleFunc("POST /login", handler.LoginHandler) + mux.HandleFunc("POST /sync", handler.SyncHandler) + mux.HandleFunc("POST /store", handler.StoreHandler) + ts := httptest.NewServer(mux) + defer ts.Close() + + client := ts.Client() + + // 3. Register two users + users := []struct { + username, password string + token string + }{ + {"owner", "pass1", ""}, + {"shared", "pass2", ""}, + } + + for i, u := range users { + regPayload, _ := json.Marshal(common.Credentials{Username: u.username, Password: u.password}) + resp, err := client.Post(ts.URL+"/register", "application/json", bytes.NewBuffer(regPayload)) + require.NoError(t, err) + assert.Equal(t, http.StatusOK, resp.StatusCode) + + resp, err = client.Post(ts.URL+"/login", "application/json", bytes.NewBuffer(regPayload)) + require.NoError(t, err) + assert.Equal(t, http.StatusOK, resp.StatusCode) + users[i].token = resp.Header.Get(common.AuthHeader) + } + + // 4. Owner creates a shared todo + sharedTodo := common.Todo{ + Id: uuid.New().String(), + Name: "Shared Task", + Description: "This is shared", + Status: common.NotStarted, + Owner: "owner", + SharedWith: []string{"shared"}, + LastModified: time.Now().UTC(), + } + + syncReq := common.TodoList{Todos: []common.Todo{sharedTodo}} + syncPayload, _ := json.Marshal(syncReq) + + req, _ := http.NewRequest("POST", ts.URL+"/sync", bytes.NewBuffer(syncPayload)) + req.Header.Set("Content-Type", "application/json") + req.Header.Set(common.AuthHeader, "Bearer "+users[0].token) + + resp, err := client.Do(req) + require.NoError(t, err) + assert.Equal(t, http.StatusOK, resp.StatusCode) + + // 5. Shared user pulls todos + emptySyncReq := common.TodoList{Todos: []common.Todo{}} + emptySyncPayload, _ := json.Marshal(emptySyncReq) + req, _ = http.NewRequest("POST", ts.URL+"/sync", bytes.NewBuffer(emptySyncPayload)) + req.Header.Set("Content-Type", "application/json") + req.Header.Set(common.AuthHeader, "Bearer "+users[1].token) + + resp, err = client.Do(req) + require.NoError(t, err) + assert.Equal(t, http.StatusOK, resp.StatusCode) + + var syncResp common.SyncResponse + err = json.NewDecoder(resp.Body).Decode(&syncResp) + require.NoError(t, err) + resp.Body.Close() + + // Shared user should see the todo + assert.Len(t, syncResp.SyncedTodos, 1) + assert.Equal(t, "Shared Task", syncResp.SyncedTodos[0].Name) + assert.Equal(t, "owner", syncResp.SyncedTodos[0].Owner) + assert.Contains(t, syncResp.SyncedTodos[0].SharedWith, "shared") + + // 6. Shared user updates the todo status + updatedTodo := syncResp.SyncedTodos[0] + updatedTodo.Status = common.Done + updatedTodo.LastModified = time.Now().UTC() + + updateSyncReq := common.TodoList{Todos: []common.Todo{updatedTodo}} + updateSyncPayload, _ := json.Marshal(updateSyncReq) + req, _ = http.NewRequest("POST", ts.URL+"/sync", bytes.NewBuffer(updateSyncPayload)) + req.Header.Set("Content-Type", "application/json") + req.Header.Set(common.AuthHeader, "Bearer "+users[1].token) + + resp, err = client.Do(req) + require.NoError(t, err) + assert.Equal(t, http.StatusOK, resp.StatusCode) + + // 7. Owner pulls todos and checks status + req, _ = http.NewRequest("POST", ts.URL+"/sync", bytes.NewBuffer(emptySyncPayload)) + req.Header.Set("Content-Type", "application/json") + req.Header.Set(common.AuthHeader, "Bearer "+users[0].token) + + resp, err = client.Do(req) + require.NoError(t, err) + assert.Equal(t, http.StatusOK, resp.StatusCode) + + err = json.NewDecoder(resp.Body).Decode(&syncResp) + require.NoError(t, err) + resp.Body.Close() + + assert.Len(t, syncResp.SyncedTodos, 1) + assert.Equal(t, common.Done, syncResp.SyncedTodos[0].Status, "Owner should see status update from shared user") +}