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. #3

Open
DariusKlein wants to merge 2 commits from feature/sharing-todos into main
12 changed files with 232 additions and 47 deletions

View File

@ -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(),
} }

View File

@ -3,7 +3,7 @@ module gitea.kleinsense.nl/DariusKlein/kleinTodo/client/todo
go 1.25.0 go 1.25.0
require ( require (
gitea.kleinsense.nl/DariusKlein/kleinTodo/common v0.0.0-20260118191144-e0c04fb9d1e9 gitea.kleinsense.nl/DariusKlein/kleinTodo/common v0.0.0-20260404115537-7cf6eabbdd1d
github.com/BurntSushi/toml v1.6.0 github.com/BurntSushi/toml v1.6.0
github.com/urfave/cli/v3 v3.8.0 github.com/urfave/cli/v3 v3.8.0
) )

View File

@ -1,5 +1,5 @@
gitea.kleinsense.nl/DariusKlein/kleinTodo/common v0.0.0-20260118191144-e0c04fb9d1e9 h1:EGFIRDjHIEt0IEFYeN2NEn/NyVglN6vXB6IRjm1rN0I= gitea.kleinsense.nl/DariusKlein/kleinTodo/common v0.0.0-20260404115537-7cf6eabbdd1d h1:YpsaXckG7ryvEe7cTwRrGNOB43wLnMlK0vXqYMHdrXQ=
gitea.kleinsense.nl/DariusKlein/kleinTodo/common v0.0.0-20260118191144-e0c04fb9d1e9/go.mod h1:bHquapurFm/eUTtrl9mGLEdAYc5cOeueHFvqjommp44= gitea.kleinsense.nl/DariusKlein/kleinTodo/common v0.0.0-20260404115537-7cf6eabbdd1d/go.mod h1:owENFzNmtoCmr7ZUjNbkO0i+ugwqKdXCVikfOOcOsWk=
github.com/BurntSushi/toml v1.6.0 h1:dRaEfpa2VI55EwlIW72hMRHdWouJeRF7TPYhI+AUQjk= github.com/BurntSushi/toml v1.6.0 h1:dRaEfpa2VI55EwlIW72hMRHdWouJeRF7TPYhI+AUQjk=
github.com/BurntSushi/toml v1.6.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho= github.com/BurntSushi/toml v1.6.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho=
github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k=

View File

@ -141,6 +141,21 @@ func handleUpdate(scanner *bufio.Scanner, todos []common.Todo, store *common.Bol
itemToUpdate.Status = newStatus itemToUpdate.Status = newStatus
} }
fmt.Printf("New shared with (comma separated) [%s]: ", strings.Join(itemToUpdate.SharedWith, ", "))
scanner.Scan()
newSharedWith := strings.TrimSpace(scanner.Text())
if newSharedWith != "" {
sharedSlice := strings.Split(newSharedWith, ",")
var sharedWith []string
for _, s := range sharedSlice {
s = strings.TrimSpace(s)
if s != "" {
sharedWith = append(sharedWith, s)
}
}
itemToUpdate.SharedWith = sharedWith
}
err = itemToUpdate.Store(store, cfg.Server.Credentials.Username) err = itemToUpdate.Store(store, cfg.Server.Credentials.Username)
if err != nil { if err != nil {
slog.Error(err.Error()) slog.Error(err.Error())

View File

@ -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
} }

View File

@ -3,9 +3,8 @@ module gitea.kleinsense.nl/DariusKlein/kleinTodo/common
go 1.25.0 go 1.25.0
require ( require (
github.com/BurntSushi/toml v1.5.0
github.com/charmbracelet/lipgloss v1.1.0 github.com/charmbracelet/lipgloss v1.1.0
github.com/golang-jwt/jwt/v5 v5.3.0 github.com/golang-jwt/jwt/v5 v5.3.1
github.com/google/uuid v1.6.0 github.com/google/uuid v1.6.0
go.etcd.io/bbolt v1.4.3 go.etcd.io/bbolt v1.4.3
golang.org/x/crypto v0.49.0 golang.org/x/crypto v0.49.0
@ -18,7 +17,6 @@ require (
github.com/charmbracelet/x/cellbuf v0.0.15 // indirect github.com/charmbracelet/x/cellbuf v0.0.15 // indirect
github.com/charmbracelet/x/term v0.2.2 // indirect github.com/charmbracelet/x/term v0.2.2 // indirect
github.com/clipperhouse/displaywidth v0.11.0 // indirect github.com/clipperhouse/displaywidth v0.11.0 // indirect
github.com/clipperhouse/stringish v0.1.1 // indirect
github.com/clipperhouse/uax29/v2 v2.7.0 // indirect github.com/clipperhouse/uax29/v2 v2.7.0 // indirect
github.com/lucasb-eyer/go-colorful v1.4.0 // indirect github.com/lucasb-eyer/go-colorful v1.4.0 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect github.com/mattn/go-isatty v0.0.20 // indirect

View File

@ -1,5 +1,3 @@
github.com/BurntSushi/toml v1.5.0 h1:W5quZX/G/csjUnuI8SUYlsHs9M38FC7znL0lIO+DvMg=
github.com/BurntSushi/toml v1.5.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho=
github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k=
github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8=
github.com/charmbracelet/colorprofile v0.4.3 h1:QPa1IWkYI+AOB+fE+mg/5/4HRMZcaXex9t5KX76i20Q= github.com/charmbracelet/colorprofile v0.4.3 h1:QPa1IWkYI+AOB+fE+mg/5/4HRMZcaXex9t5KX76i20Q=
@ -14,14 +12,12 @@ github.com/charmbracelet/x/term v0.2.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSg
github.com/charmbracelet/x/term v0.2.2/go.mod h1:kF8CY5RddLWrsgVwpw4kAa6TESp6EB5y3uxGLeCqzAI= github.com/charmbracelet/x/term v0.2.2/go.mod h1:kF8CY5RddLWrsgVwpw4kAa6TESp6EB5y3uxGLeCqzAI=
github.com/clipperhouse/displaywidth v0.11.0 h1:lBc6kY44VFw+TDx4I8opi/EtL9m20WSEFgwIwO+UVM8= github.com/clipperhouse/displaywidth v0.11.0 h1:lBc6kY44VFw+TDx4I8opi/EtL9m20WSEFgwIwO+UVM8=
github.com/clipperhouse/displaywidth v0.11.0/go.mod h1:bkrFNkf81G8HyVqmKGxsPufD3JhNl3dSqnGhOoSD/o0= github.com/clipperhouse/displaywidth v0.11.0/go.mod h1:bkrFNkf81G8HyVqmKGxsPufD3JhNl3dSqnGhOoSD/o0=
github.com/clipperhouse/stringish v0.1.1 h1:+NSqMOr3GR6k1FdRhhnXrLfztGzuG+VuFDfatpWHKCs=
github.com/clipperhouse/stringish v0.1.1/go.mod h1:v/WhFtE1q0ovMta2+m+UbpZ+2/HEXNWYXQgCt4hdOzA=
github.com/clipperhouse/uax29/v2 v2.7.0 h1:+gs4oBZ2gPfVrKPthwbMzWZDaAFPGYK72F0NJv2v7Vk= github.com/clipperhouse/uax29/v2 v2.7.0 h1:+gs4oBZ2gPfVrKPthwbMzWZDaAFPGYK72F0NJv2v7Vk=
github.com/clipperhouse/uax29/v2 v2.7.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM= github.com/clipperhouse/uax29/v2 v2.7.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/golang-jwt/jwt/v5 v5.3.0 h1:pv4AsKCKKZuqlgs5sUmn4x8UlGa0kEVt/puTpKx9vvo= github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY=
github.com/golang-jwt/jwt/v5 v5.3.0/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/lucasb-eyer/go-colorful v1.4.0 h1:UtrWVfLdarDgc44HcS7pYloGHJUjHV/4FwW4TvVgFr4= github.com/lucasb-eyer/go-colorful v1.4.0 h1:UtrWVfLdarDgc44HcS7pYloGHJUjHV/4FwW4TvVgFr4=
@ -42,15 +38,13 @@ github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavM
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
go.etcd.io/bbolt v1.4.3 h1:dEadXpI6G79deX5prL3QRNP6JB8UxVkqo4UPnHaNXJo= go.etcd.io/bbolt v1.4.3 h1:dEadXpI6G79deX5prL3QRNP6JB8UxVkqo4UPnHaNXJo=
go.etcd.io/bbolt v1.4.3/go.mod h1:tKQlpPaYCVFctUIgFKFnAlvbmB3tpy1vkTnDWohtc0E= go.etcd.io/bbolt v1.4.3/go.mod h1:tKQlpPaYCVFctUIgFKFnAlvbmB3tpy1vkTnDWohtc0E=
golang.org/x/crypto v0.41.0 h1:WKYxWedPGCTVVl5+WHSSrOBT0O8lx32+zxmHxijgXp4=
golang.org/x/crypto v0.41.0/go.mod h1:pO5AFd7FA68rFak7rOAGVuygIISepHftHnr8dr6+sUc=
golang.org/x/crypto v0.49.0 h1:+Ng2ULVvLHnJ/ZFEq4KdcDd/cfjrrjjNSXNzxg0Y4U4= golang.org/x/crypto v0.49.0 h1:+Ng2ULVvLHnJ/ZFEq4KdcDd/cfjrrjjNSXNzxg0Y4U4=
golang.org/x/crypto v0.49.0/go.mod h1:ErX4dUh2UM+CFYiXZRTcMpEcN8b/1gxEuv3nODoYtCA= golang.org/x/crypto v0.49.0/go.mod h1:ErX4dUh2UM+CFYiXZRTcMpEcN8b/1gxEuv3nODoYtCA=
golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM8rJBtfilJ2qTU199MI=
golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo=
golang.org/x/sync v0.16.0 h1:ycBJEhp9p4vXvUZNszeOq0kGTPghopOL8q0fq3vstxw= golang.org/x/sync v0.16.0 h1:ycBJEhp9p4vXvUZNszeOq0kGTPghopOL8q0fq3vstxw=
golang.org/x/sync v0.16.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= golang.org/x/sync v0.16.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.35.0 h1:vz1N37gP5bs89s7He8XuIYXpyY0+QlsKmzipCbUtyxI=
golang.org/x/sys v0.35.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo= golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo=
golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=

View File

@ -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)
@ -103,6 +108,9 @@ func (todo Todo) PrintIndexed(index int) {
uuidStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("240")) uuidStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("240"))
lastMod := todo.LastModified.Format("2006-01-02 15:04") lastMod := todo.LastModified.Format("2006-01-02 15:04")
metaLine := fmt.Sprintf(" Last Modified: %s • ID: %s", lastMod, todo.Id) metaLine := fmt.Sprintf(" Last Modified: %s • ID: %s", lastMod, todo.Id)
if len(todo.SharedWith) > 0 {
metaLine = fmt.Sprintf("%s • Shared with: %s", metaLine, strings.Join(todo.SharedWith, ", "))
}
fmt.Println(uuidStyle.Render(metaLine)) fmt.Println(uuidStyle.Render(metaLine))
fmt.Printf(" %s----------------------------%s\n", ColorGray, ColorReset) fmt.Printf(" %s----------------------------%s\n", ColorGray, ColorReset)
@ -114,6 +122,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 +131,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
} }

View File

@ -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 {

View File

@ -2,7 +2,7 @@ module gitea.kleinsense.nl/DariusKlein/kleinTodo/server
go 1.25.0 go 1.25.0
require gitea.kleinsense.nl/DariusKlein/kleinTodo/common v0.0.0-20260118191144-e0c04fb9d1e9 require gitea.kleinsense.nl/DariusKlein/kleinTodo/common v0.0.0-20260404115537-7cf6eabbdd1d
require ( require (
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect

View File

@ -1,5 +1,5 @@
gitea.kleinsense.nl/DariusKlein/kleinTodo/common v0.0.0-20260118191144-e0c04fb9d1e9 h1:EGFIRDjHIEt0IEFYeN2NEn/NyVglN6vXB6IRjm1rN0I= gitea.kleinsense.nl/DariusKlein/kleinTodo/common v0.0.0-20260404115537-7cf6eabbdd1d h1:YpsaXckG7ryvEe7cTwRrGNOB43wLnMlK0vXqYMHdrXQ=
gitea.kleinsense.nl/DariusKlein/kleinTodo/common v0.0.0-20260118191144-e0c04fb9d1e9/go.mod h1:bHquapurFm/eUTtrl9mGLEdAYc5cOeueHFvqjommp44= gitea.kleinsense.nl/DariusKlein/kleinTodo/common v0.0.0-20260404115537-7cf6eabbdd1d/go.mod h1:owENFzNmtoCmr7ZUjNbkO0i+ugwqKdXCVikfOOcOsWk=
github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k=
github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8=
github.com/charmbracelet/colorprofile v0.4.3 h1:QPa1IWkYI+AOB+fE+mg/5/4HRMZcaXex9t5KX76i20Q= github.com/charmbracelet/colorprofile v0.4.3 h1:QPa1IWkYI+AOB+fE+mg/5/4HRMZcaXex9t5KX76i20Q=

134
tests/sharing_test.go Normal file
View 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")
}