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.
All checks were successful
build and deploy kleinTodo / build (pull_request) Successful in 1m12s
All checks were successful
build and deploy kleinTodo / build (pull_request) Successful in 1m12s
Co-authored-by: Junie <junie@jetbrains.com>
This commit is contained in:
parent
7cf6eabbdd
commit
f0126c656a
@ -50,12 +50,24 @@ func CreateNewTodo() common.Todo {
|
|||||||
for i, l := range labelSlice {
|
for i, l := range labelSlice {
|
||||||
labelSlice[i] = strings.TrimSpace(l)
|
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{
|
return common.Todo{
|
||||||
Name: common.AskUserString("Name:\n"),
|
Name: common.AskUserString("Name:\n"),
|
||||||
Description: common.AskUserString("Description:\n"),
|
Description: common.AskUserString("Description:\n"),
|
||||||
Status: common.AskUserString(fmt.Sprintf("Status (%s, %s, %s, %s, %s, %s):\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)),
|
common.NotStarted, common.Done, common.WIP, common.Pending, common.Blocked, common.Failed)),
|
||||||
Labels: labelSlice,
|
Labels: labelSlice,
|
||||||
|
SharedWith: sharedWith,
|
||||||
Owner: cfg.Server.Credentials.Username,
|
Owner: cfg.Server.Credentials.Username,
|
||||||
LastModified: time.Now().UTC(),
|
LastModified: time.Now().UTC(),
|
||||||
}
|
}
|
||||||
|
|||||||
@ -7,6 +7,8 @@ import (
|
|||||||
"path/filepath"
|
"path/filepath"
|
||||||
"sync"
|
"sync"
|
||||||
|
|
||||||
|
"slices"
|
||||||
|
|
||||||
bolt "go.etcd.io/bbolt"
|
bolt "go.etcd.io/bbolt"
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -219,25 +221,38 @@ func (s *BoltStore) ExistsByKey(bucket, key string) (bool, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (s *BoltStore) GetTodoMap(user string) map[string]Todo {
|
func (s *BoltStore) GetTodoMap(user string) map[string]Todo {
|
||||||
storedTodoJsons := s.GetAllFromBucket(user)
|
|
||||||
|
|
||||||
serverTodos := make(map[string]Todo)
|
serverTodos := make(map[string]Todo)
|
||||||
for key, val := range storedTodoJsons {
|
|
||||||
|
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
|
var todo Todo
|
||||||
if json.Unmarshal([]byte(val), &todo) == nil {
|
if json.Unmarshal(v, &todo) == nil {
|
||||||
serverTodos[key] = todo
|
if bucketName == user || slices.Contains(todo.SharedWith, user) {
|
||||||
|
serverTodos[string(k)] = todo
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
return serverTodos
|
return serverTodos
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *BoltStore) GetTodoList(user string, includeDeleted bool) []Todo {
|
func (s *BoltStore) GetTodoList(user string, includeDeleted bool) []Todo {
|
||||||
storedTodoJsons := s.GetAllFromBucket(user)
|
|
||||||
|
|
||||||
var storedTodos []Todo
|
var storedTodos []Todo
|
||||||
for _, val := range storedTodoJsons {
|
|
||||||
|
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
|
var todo Todo
|
||||||
if json.Unmarshal([]byte(val), &todo) == nil {
|
if json.Unmarshal(v, &todo) == nil {
|
||||||
|
if bucketName == user || slices.Contains(todo.SharedWith, user) {
|
||||||
var include = false
|
var include = false
|
||||||
if includeDeleted {
|
if includeDeleted {
|
||||||
include = true
|
include = true
|
||||||
@ -249,5 +264,10 @@ func (s *BoltStore) GetTodoList(user string, includeDeleted bool) []Todo {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
return storedTodos
|
return storedTodos
|
||||||
}
|
}
|
||||||
|
|||||||
@ -4,6 +4,7 @@ import (
|
|||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"hash/fnv"
|
"hash/fnv"
|
||||||
|
"slices"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@ -12,7 +13,10 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
func (todo *Todo) Store(store *BoltStore, user string) error {
|
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")
|
return fmt.Errorf("unauthorized user")
|
||||||
}
|
}
|
||||||
if todo.Id == "" {
|
if todo.Id == "" {
|
||||||
@ -23,7 +27,7 @@ func (todo *Todo) Store(store *BoltStore, user string) error {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
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 {
|
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,
|
Description: todoRequest.Description,
|
||||||
Status: todoRequest.Status,
|
Status: todoRequest.Status,
|
||||||
Owner: user,
|
Owner: user,
|
||||||
|
SharedWith: todoRequest.SharedWith,
|
||||||
LastModified: time.Now().UTC(),
|
LastModified: time.Now().UTC(),
|
||||||
}
|
}
|
||||||
todoJson, err := json.Marshal(todo)
|
todoJson, err := json.Marshal(todo)
|
||||||
@ -114,6 +119,7 @@ func (t Todo) IsEqual(other Todo) bool {
|
|||||||
t.Description == other.Description &&
|
t.Description == other.Description &&
|
||||||
t.Status == other.Status &&
|
t.Status == other.Status &&
|
||||||
t.Owner == other.Owner &&
|
t.Owner == other.Owner &&
|
||||||
|
slices.Equal(t.SharedWith, other.SharedWith) &&
|
||||||
t.Deleted == other.Deleted
|
t.Deleted == other.Deleted
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -122,5 +128,6 @@ func (t Todo) IsEqualIgnoringStatus(other Todo) bool {
|
|||||||
t.Name == other.Name &&
|
t.Name == other.Name &&
|
||||||
t.Description == other.Description &&
|
t.Description == other.Description &&
|
||||||
t.Owner == other.Owner &&
|
t.Owner == other.Owner &&
|
||||||
|
slices.Equal(t.SharedWith, other.SharedWith) &&
|
||||||
t.Deleted == other.Deleted
|
t.Deleted == other.Deleted
|
||||||
}
|
}
|
||||||
|
|||||||
@ -13,6 +13,7 @@ type Todo struct {
|
|||||||
Description string `json:"description"`
|
Description string `json:"description"`
|
||||||
Status string `json:"status"`
|
Status string `json:"status"`
|
||||||
Owner string `json:"owner"`
|
Owner string `json:"owner"`
|
||||||
|
SharedWith []string `json:"shared_with,omitzero"`
|
||||||
Labels []string `json:"labels"`
|
Labels []string `json:"labels"`
|
||||||
LastModified time.Time `json:"last_modified"`
|
LastModified time.Time `json:"last_modified"`
|
||||||
Deleted bool `json:"deleted"`
|
Deleted bool `json:"deleted"`
|
||||||
@ -22,6 +23,7 @@ type StoreTodoRequest struct {
|
|||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
Description string `json:"description"`
|
Description string `json:"description"`
|
||||||
Status string `json:"status"`
|
Status string `json:"status"`
|
||||||
|
SharedWith []string `json:"shared_with,omitzero"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type TodoList struct {
|
type TodoList struct {
|
||||||
|
|||||||
134
tests/sharing_test.go
Normal file
134
tests/sharing_test.go
Normal file
@ -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")
|
||||||
|
}
|
||||||
Loading…
x
Reference in New Issue
Block a user