From 7cf6eabbdd1d3874207d640a68ece94f93422a74 Mon Sep 17 00:00:00 2001 From: Darius klein Date: Sat, 4 Apr 2026 13:55:37 +0200 Subject: [PATCH] ci: add integration tests and pipeline step Co-authored-by: Junie --- .github/workflows/Deploy-docker.yml | 3 + client/todo/httpClient/httpClient.go | 6 +- client/todo/login.go | 16 ++- client/todo/register.go | 18 +--- client/todo/sync.go | 60 ++++++++++- common/askUser.go | 14 +-- common/bolt.go | 19 +++- go.mod | 38 +++++++ go.sum | 57 +++++++++++ go.work | 3 +- server/handler/deleteHandler.go | 2 +- server/handler/loginHandler.go | 2 +- server/handler/registerHandler.go | 2 +- server/handler/storeHandler.go | 2 +- server/handler/syncHandler.go | 11 +- server/handler/updateHandler.go | 2 +- server/main.go | 2 +- tests/go.mod | 34 +++++++ tests/go.sum | 57 +++++++++++ tests/integration_test.go | 144 +++++++++++++++++++++++++++ 20 files changed, 453 insertions(+), 39 deletions(-) create mode 100644 go.mod create mode 100644 go.sum create mode 100644 tests/go.mod create mode 100644 tests/go.sum create mode 100644 tests/integration_test.go diff --git a/.github/workflows/Deploy-docker.yml b/.github/workflows/Deploy-docker.yml index 9c833f7..970230f 100644 --- a/.github/workflows/Deploy-docker.yml +++ b/.github/workflows/Deploy-docker.yml @@ -28,6 +28,9 @@ jobs: chmod +x build.sh ./build.sh + - name: Run tests + run: go test -v ./tests/... + - name: Build the Docker image run: docker build -t gitea.kleinsense.nl/dariusklein/klein_todo:latest -f server/Dockerfile . diff --git a/client/todo/httpClient/httpClient.go b/client/todo/httpClient/httpClient.go index 9fbf5ea..60293a7 100644 --- a/client/todo/httpClient/httpClient.go +++ b/client/todo/httpClient/httpClient.go @@ -3,6 +3,7 @@ package httpClient import ( "fmt" "net/http" + "time" "gitea.kleinsense.nl/DariusKlein/kleinTodo/common" ) @@ -23,7 +24,10 @@ func (t *AuthTransport) RoundTrip(req *http.Request) (*http.Response, error) { // GetHttpClient Returns httpClient with jwt in headers func getHttpClient(token string) *http.Client { - return &http.Client{Transport: &AuthTransport{Token: token}} + return &http.Client{ + Transport: &AuthTransport{Token: token}, + Timeout: 30 * time.Second, + } } // GetHttpClient Returns CustomClient with jwt in headers diff --git a/client/todo/login.go b/client/todo/login.go index 15b46fc..ec7f2c8 100644 --- a/client/todo/login.go +++ b/client/todo/login.go @@ -56,6 +56,10 @@ func loginAction(context context.Context, c *cli.Command) error { var password = c.String(passwordFlagName) var serverUrl = c.String(serverUrlFlagName) + return LoginAndSave(serverUrl, username, password) +} + +func LoginAndSave(serverUrl, username, password string) error { token, err := loginAndGetToken(serverUrl, username, password) if err != nil { return err @@ -72,6 +76,10 @@ func loginAction(context context.Context, c *cli.Command) error { cfg.Server.Url = serverUrl } + return SaveConfig(cfg) +} + +func SaveConfig(c config.Config) error { path, configPath, err := config.GetConfigPath() if err != nil { return err @@ -79,9 +87,11 @@ func loginAction(context context.Context, c *cli.Command) error { if err = os.MkdirAll(path, 0770); err != nil { return err } - configBytes, err := toml.Marshal(cfg) - err = os.WriteFile(configPath, configBytes, 0644) - return nil + configBytes, err := toml.Marshal(c) + if err != nil { + return err + } + return os.WriteFile(configPath, configBytes, 0644) } func loginAndGetToken(url, username, password string) (string, error) { diff --git a/client/todo/register.go b/client/todo/register.go index e711805..d940b14 100644 --- a/client/todo/register.go +++ b/client/todo/register.go @@ -6,14 +6,10 @@ import ( "encoding/json" "fmt" "io" - "log" "net/http" - "os" - "gitea.kleinsense.nl/DariusKlein/kleinTodo/client/todo/clientCommon/config" "gitea.kleinsense.nl/DariusKlein/kleinTodo/client/todo/httpClient" "gitea.kleinsense.nl/DariusKlein/kleinTodo/common" - "github.com/BurntSushi/toml" "github.com/urfave/cli/v3" ) @@ -69,19 +65,7 @@ func registerAction(context context.Context, c *cli.Command) error { cfg.Server.Url = serverUrl } - path, configPath, err := config.GetConfigPath() - if err != nil { - return err - } - if err = os.MkdirAll(path, 0770); err != nil { - return err - } - configBytes, err := toml.Marshal(cfg) - err = os.WriteFile(configPath, configBytes, 0644) - - log.Println("Registered new user to kleinTodo server successfully") - - return nil + return SaveConfig(cfg) } func registerAndGetToken(url, username, password string) error { diff --git a/client/todo/sync.go b/client/todo/sync.go index e70dc95..1b7607e 100644 --- a/client/todo/sync.go +++ b/client/todo/sync.go @@ -24,6 +24,17 @@ func Sync() *cli.Command { // syncAction logic for Template func syncAction(context context.Context, c *cli.Command) error { + if cfg.Server.Token == "" { + if common.AskUserBool("No valid JWT found. Do you wish to login using your stored credentials?") { + err := LoginAndSave(cfg.Server.Url, cfg.Server.Credentials.Username, cfg.Server.Credentials.Password) + if err != nil { + return fmt.Errorf("login failed: %w", err) + } + } else { + return fmt.Errorf("login required to sync") + } + } + store, err := common.GetTodoDataStore() if err != nil { return err @@ -46,12 +57,35 @@ func syncAction(context context.Context, c *cli.Command) error { } req.Header.Set("Content-Type", "application/json") - resp, err := httpClient.GetHttpClient(cfg.Server.Token).Do(req) + fmt.Println("Sending sync request to server...") + token := cfg.Server.Token + resp, err := httpClient.GetHttpClient(token).Do(req) if err != nil { return fmt.Errorf("error sending request: %w", err) } defer resp.Body.Close() + if resp.StatusCode == http.StatusUnauthorized { + if common.AskUserBool("Login expired or invalid. Do you wish to login using your stored credentials?") { + err := LoginAndSave(cfg.Server.Url, cfg.Server.Credentials.Username, cfg.Server.Credentials.Password) + if err != nil { + return fmt.Errorf("login failed: %w", err) + } + // Re-send request with new token + req, _ = http.NewRequest("POST", cfg.Server.Url+"/sync", bytes.NewBuffer(payload)) + req.Header.Set("Content-Type", "application/json") + newToken := cfg.Server.Token + resp, err = httpClient.GetHttpClient(newToken).Do(req) + if err != nil { + return fmt.Errorf("error sending request after login: %w", err) + } + defer resp.Body.Close() + } else { + return fmt.Errorf("unauthorized: login required to sync") + } + } + + fmt.Println("Reading response body...") body, err := io.ReadAll(resp.Body) if err != nil { return fmt.Errorf("failed to read response body: %w", err) @@ -61,6 +95,7 @@ func syncAction(context context.Context, c *cli.Command) error { return fmt.Errorf("request failed with status %d: %s", resp.StatusCode, string(body)) } + fmt.Println("Decoding sync response...") var response common.SyncResponse if err := json.Unmarshal(body, &response); err != nil { @@ -116,12 +151,33 @@ func pushTodoToServer(todo common.Todo) error { } req.Header.Set("Content-Type", "application/json") - resp, err := httpClient.GetHttpClient(cfg.Server.Token).Do(req) + token := cfg.Server.Token + resp, err := httpClient.GetHttpClient(token).Do(req) if err != nil { return fmt.Errorf("error sending request: %w", err) } defer resp.Body.Close() + if resp.StatusCode == http.StatusUnauthorized { + if common.AskUserBool("Login expired or invalid. Do you wish to login using your stored credentials?") { + err := LoginAndSave(cfg.Server.Url, cfg.Server.Credentials.Username, cfg.Server.Credentials.Password) + if err != nil { + return fmt.Errorf("login failed: %w", err) + } + // Re-send request with new token + req, _ = http.NewRequest("POST", cfg.Server.Url+"/update", bytes.NewBuffer(payload)) + req.Header.Set("Content-Type", "application/json") + newToken := cfg.Server.Token + resp, err = httpClient.GetHttpClient(newToken).Do(req) + if err != nil { + return fmt.Errorf("error sending request after login: %w", err) + } + defer resp.Body.Close() + } else { + return fmt.Errorf("unauthorized: login required to push todo to server") + } + } + if resp.StatusCode != http.StatusOK { body, _ := io.ReadAll(resp.Body) return fmt.Errorf("request failed with status %d: %s", resp.StatusCode, string(body)) diff --git a/common/askUser.go b/common/askUser.go index b705420..50f8a1d 100644 --- a/common/askUser.go +++ b/common/askUser.go @@ -10,11 +10,9 @@ import ( ) func AskUserBool(question string) bool { - switch AskUserString(fmt.Sprintf("%s (Y/N): ", question)) { - case "y", "Y", "yes": + switch strings.ToLower(AskUserString(fmt.Sprintf("%s (Y/N): ", question))) { + case "y", "yes": return true - case "n", "N", "no": - return false default: return false } @@ -24,7 +22,9 @@ func AskUserString(question string) string { promptStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("6")).Bold(true) fmt.Printf("%s %s", promptStyle.Render(">"), question) - reader := bufio.NewReader(os.Stdin) - input, _, _ := reader.ReadLine() - return strings.TrimSpace(string(input)) + scanner := bufio.NewScanner(os.Stdin) + if scanner.Scan() { + return strings.TrimSpace(scanner.Text()) + } + return "" } diff --git a/common/bolt.go b/common/bolt.go index a783318..dbb84e8 100644 --- a/common/bolt.go +++ b/common/bolt.go @@ -63,11 +63,28 @@ var ( ) func GetTodoDataStore() (*BoltStore, error) { + return GetCustomDataStore("todo.db") +} + +var serverDB *BoltStore +var serverOnce sync.Once +var serverErr error + +func GetServerDataStore() (*BoltStore, error) { + serverOnce.Do(func() { + var path string + path, serverErr = getStoragePath() + serverDB, serverErr = NewBoltStore(path + "/server.db") + }) + return serverDB, serverErr +} + +func GetCustomDataStore(dbName string) (*BoltStore, error) { once.Do(func() { var path string path, err = getStoragePath() // We assign to the outer 'dataStore' and 'err' variables. - dataStore, err = NewBoltStore(path + "/todo.db") + dataStore, err = NewBoltStore(path + "/" + dbName) }) if err != nil { return nil, err diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..d197bfb --- /dev/null +++ b/go.mod @@ -0,0 +1,38 @@ +module gitea.kleinsense.nl/DariusKlein/kleinTodo/tests + +go 1.25.8 + +require ( + gitea.kleinsense.nl/DariusKlein/kleinTodo/common v0.0.0-20260404111829-b8b839778c39 + gitea.kleinsense.nl/DariusKlein/kleinTodo/server v0.0.0-20260404111829-b8b839778c39 + github.com/google/uuid v1.6.0 + github.com/stretchr/testify v1.11.1 +) + +require ( + github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect + github.com/charmbracelet/colorprofile v0.4.3 // indirect + github.com/charmbracelet/lipgloss v1.1.0 // indirect + github.com/charmbracelet/x/ansi v0.11.6 // indirect + github.com/charmbracelet/x/cellbuf v0.0.15 // indirect + github.com/charmbracelet/x/term v0.2.2 // indirect + github.com/clipperhouse/displaywidth v0.11.0 // indirect + github.com/clipperhouse/uax29/v2 v2.7.0 // indirect + github.com/davecgh/go-spew v1.1.1 // indirect + github.com/golang-jwt/jwt/v5 v5.3.1 // indirect + github.com/lucasb-eyer/go-colorful v1.4.0 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/mattn/go-runewidth v0.0.22 // indirect + github.com/muesli/termenv v0.16.0 // indirect + github.com/pmezard/go-difflib v1.0.0 // indirect + github.com/rivo/uniseg v0.4.7 // indirect + github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect + go.etcd.io/bbolt v1.4.3 // indirect + golang.org/x/crypto v0.49.0 // indirect + golang.org/x/sys v0.42.0 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect +) + +replace gitea.kleinsense.nl/DariusKlein/kleinTodo/common => ../common + +replace gitea.kleinsense.nl/DariusKlein/kleinTodo/server => ../server diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..ac3f81c --- /dev/null +++ b/go.sum @@ -0,0 +1,57 @@ +gitea.kleinsense.nl/DariusKlein/kleinTodo/common v0.0.0-20260404111829-b8b839778c39 h1:XxG5TiAP0dvLhRsbwtZDsvCZAiyYJxkOpspAEiLx9po= +gitea.kleinsense.nl/DariusKlein/kleinTodo/common v0.0.0-20260404111829-b8b839778c39/go.mod h1:owENFzNmtoCmr7ZUjNbkO0i+ugwqKdXCVikfOOcOsWk= +gitea.kleinsense.nl/DariusKlein/kleinTodo/server v0.0.0-20260404111829-b8b839778c39 h1:xTwCC3AFY4L5w2Yu8lNYsdsy8yO1eaj2wngdQF4Y3mI= +gitea.kleinsense.nl/DariusKlein/kleinTodo/server v0.0.0-20260404111829-b8b839778c39/go.mod h1:wkQ2bz6csXjnKdiqzRgjPt/ZDlABGsFaKdApVsoXJis= +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/charmbracelet/colorprofile v0.4.3 h1:QPa1IWkYI+AOB+fE+mg/5/4HRMZcaXex9t5KX76i20Q= +github.com/charmbracelet/colorprofile v0.4.3/go.mod h1:/zT4BhpD5aGFpqQQqw7a+VtHCzu+zrQtt1zhMt9mR4Q= +github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY= +github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30= +github.com/charmbracelet/x/ansi v0.11.6 h1:GhV21SiDz/45W9AnV2R61xZMRri5NlLnl6CVF7ihZW8= +github.com/charmbracelet/x/ansi v0.11.6/go.mod h1:2JNYLgQUsyqaiLovhU2Rv/pb8r6ydXKS3NIttu3VGZQ= +github.com/charmbracelet/x/cellbuf v0.0.15 h1:ur3pZy0o6z/R7EylET877CBxaiE1Sp1GMxoFPAIztPI= +github.com/charmbracelet/x/cellbuf v0.0.15/go.mod h1:J1YVbR7MUuEGIFPCaaZ96KDl5NoS0DAWkskup+mOY+Q= +github.com/charmbracelet/x/term v0.2.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk= +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/go.mod h1:bkrFNkf81G8HyVqmKGxsPufD3JhNl3dSqnGhOoSD/o0= +github.com/clipperhouse/uax29/v2 v2.7.0 h1:+gs4oBZ2gPfVrKPthwbMzWZDaAFPGYK72F0NJv2v7Vk= +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/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY= +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/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/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mattn/go-runewidth v0.0.22 h1:76lXsPn6FyHtTY+jt2fTTvsMUCZq1k0qwRsAMuxzKAk= +github.com/mattn/go-runewidth v0.0.22/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs= +github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc= +github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= +github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= +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/go.mod h1:tKQlpPaYCVFctUIgFKFnAlvbmB3tpy1vkTnDWohtc0E= +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/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/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo= +golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/go.work b/go.work index 8f3c261..ceee5d1 100644 --- a/go.work +++ b/go.work @@ -1,7 +1,8 @@ -go 1.25.0 +go 1.25.8 use ( ./client/todo ./common ./server + ./tests ) diff --git a/server/handler/deleteHandler.go b/server/handler/deleteHandler.go index 1c2d81e..faa991a 100644 --- a/server/handler/deleteHandler.go +++ b/server/handler/deleteHandler.go @@ -13,7 +13,7 @@ func DeleteHandler(w http.ResponseWriter, r *http.Request) { return } - store, err := common.GetTodoDataStore() + store, err := common.GetServerDataStore() if handleError(w, http.StatusInternalServerError, err) { return } diff --git a/server/handler/loginHandler.go b/server/handler/loginHandler.go index c1fe13a..95b52f3 100644 --- a/server/handler/loginHandler.go +++ b/server/handler/loginHandler.go @@ -17,7 +17,7 @@ func LoginHandler(w http.ResponseWriter, r *http.Request) { return } // Get data store - db, err := common.GetTodoDataStore() + db, err := common.GetServerDataStore() if handleError(w, http.StatusInternalServerError, err) { return } diff --git a/server/handler/registerHandler.go b/server/handler/registerHandler.go index e46e143..1392523 100644 --- a/server/handler/registerHandler.go +++ b/server/handler/registerHandler.go @@ -16,7 +16,7 @@ func RegisterHandler(w http.ResponseWriter, r *http.Request) { return } // Get data store - db, err := common.GetTodoDataStore() + db, err := common.GetServerDataStore() if handleError(w, http.StatusInternalServerError, err) { return } diff --git a/server/handler/storeHandler.go b/server/handler/storeHandler.go index e472a5d..63f8a60 100644 --- a/server/handler/storeHandler.go +++ b/server/handler/storeHandler.go @@ -21,7 +21,7 @@ func StoreHandler(w http.ResponseWriter, r *http.Request) { return } - store, err := common.GetTodoDataStore() + store, err := common.GetServerDataStore() if handleError(w, http.StatusInternalServerError, err) { return } diff --git a/server/handler/syncHandler.go b/server/handler/syncHandler.go index 7f7234e..e5b5008 100644 --- a/server/handler/syncHandler.go +++ b/server/handler/syncHandler.go @@ -2,6 +2,7 @@ package handler import ( "encoding/json" + "fmt" "net/http" "time" @@ -10,29 +11,35 @@ import ( ) func SyncHandler(w http.ResponseWriter, r *http.Request) { + fmt.Println("SyncHandler: starting request processing...") // 1. Setup: Auth, Decode, Database Connection user, err := jwt.GetVerifiedUser(r) if handleError(w, http.StatusUnauthorized, err) { return } + fmt.Printf("SyncHandler: authenticated user %s\n", user) var todoList common.TodoList if handleError(w, http.StatusBadRequest, json.NewDecoder(r.Body).Decode(&todoList)) { return } + fmt.Printf("SyncHandler: received %d todos from client\n", len(todoList.Todos)) - store, err := common.GetTodoDataStore() + store, err := common.GetServerDataStore() if handleError(w, http.StatusInternalServerError, err) { return } + fmt.Println("SyncHandler: connected to datastore") // 2. Load Server State serverTodos := store.GetTodoMap(user) + fmt.Printf("SyncHandler: loaded %d server todos\n", len(serverTodos)) response := common.SyncResponse{ SyncedTodos: []common.Todo{}, MisMatchingTodos: []common.MisMatchingTodo{}, } + fmt.Println("SyncHandler: processing client todos...") // 3. Process Client Updates for _, clientTodo := range todoList.Todos { @@ -91,11 +98,13 @@ func SyncHandler(w http.ResponseWriter, r *http.Request) { } } + fmt.Println("SyncHandler: building final response...") // Build Final Response for _, todo := range serverTodos { response.SyncedTodos = append(response.SyncedTodos, todo) } + fmt.Printf("SyncHandler: returning %d synced todos and %d mismatches\n", len(response.SyncedTodos), len(response.MisMatchingTodos)) w.Header().Set("Content-Type", "application/json") handleError(w, http.StatusInternalServerError, json.NewEncoder(w).Encode(response)) } diff --git a/server/handler/updateHandler.go b/server/handler/updateHandler.go index 2c00dda..eb1a037 100644 --- a/server/handler/updateHandler.go +++ b/server/handler/updateHandler.go @@ -20,7 +20,7 @@ func UpdateHandler(w http.ResponseWriter, r *http.Request) { return } - store, err := common.GetTodoDataStore() + store, err := common.GetServerDataStore() if handleError(w, http.StatusInternalServerError, err) { return } diff --git a/server/main.go b/server/main.go index 75b9f9a..4c39c57 100644 --- a/server/main.go +++ b/server/main.go @@ -12,7 +12,7 @@ import ( ) func main() { - db, err := common.GetTodoDataStore() + db, err := common.GetServerDataStore() if err != nil { log.Fatal(err) } diff --git a/tests/go.mod b/tests/go.mod new file mode 100644 index 0000000..9a3670a --- /dev/null +++ b/tests/go.mod @@ -0,0 +1,34 @@ +module gitea.kleinsense.nl/DariusKlein/kleinTodo/tests + +go 1.25.8 + +require ( + gitea.kleinsense.nl/DariusKlein/kleinTodo/common v0.0.0-20260404111829-b8b839778c39 + gitea.kleinsense.nl/DariusKlein/kleinTodo/server v0.0.0-20260404111829-b8b839778c39 + github.com/google/uuid v1.6.0 + github.com/stretchr/testify v1.11.1 +) + +require ( + github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect + github.com/charmbracelet/colorprofile v0.4.3 // indirect + github.com/charmbracelet/lipgloss v1.1.0 // indirect + github.com/charmbracelet/x/ansi v0.11.6 // indirect + github.com/charmbracelet/x/cellbuf v0.0.15 // indirect + github.com/charmbracelet/x/term v0.2.2 // indirect + github.com/clipperhouse/displaywidth v0.11.0 // indirect + github.com/clipperhouse/uax29/v2 v2.7.0 // indirect + github.com/davecgh/go-spew v1.1.1 // indirect + github.com/golang-jwt/jwt/v5 v5.3.1 // indirect + github.com/lucasb-eyer/go-colorful v1.4.0 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/mattn/go-runewidth v0.0.22 // indirect + github.com/muesli/termenv v0.16.0 // indirect + github.com/pmezard/go-difflib v1.0.0 // indirect + github.com/rivo/uniseg v0.4.7 // indirect + github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect + go.etcd.io/bbolt v1.4.3 // indirect + golang.org/x/crypto v0.49.0 // indirect + golang.org/x/sys v0.42.0 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect +) diff --git a/tests/go.sum b/tests/go.sum new file mode 100644 index 0000000..ac3f81c --- /dev/null +++ b/tests/go.sum @@ -0,0 +1,57 @@ +gitea.kleinsense.nl/DariusKlein/kleinTodo/common v0.0.0-20260404111829-b8b839778c39 h1:XxG5TiAP0dvLhRsbwtZDsvCZAiyYJxkOpspAEiLx9po= +gitea.kleinsense.nl/DariusKlein/kleinTodo/common v0.0.0-20260404111829-b8b839778c39/go.mod h1:owENFzNmtoCmr7ZUjNbkO0i+ugwqKdXCVikfOOcOsWk= +gitea.kleinsense.nl/DariusKlein/kleinTodo/server v0.0.0-20260404111829-b8b839778c39 h1:xTwCC3AFY4L5w2Yu8lNYsdsy8yO1eaj2wngdQF4Y3mI= +gitea.kleinsense.nl/DariusKlein/kleinTodo/server v0.0.0-20260404111829-b8b839778c39/go.mod h1:wkQ2bz6csXjnKdiqzRgjPt/ZDlABGsFaKdApVsoXJis= +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/charmbracelet/colorprofile v0.4.3 h1:QPa1IWkYI+AOB+fE+mg/5/4HRMZcaXex9t5KX76i20Q= +github.com/charmbracelet/colorprofile v0.4.3/go.mod h1:/zT4BhpD5aGFpqQQqw7a+VtHCzu+zrQtt1zhMt9mR4Q= +github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY= +github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30= +github.com/charmbracelet/x/ansi v0.11.6 h1:GhV21SiDz/45W9AnV2R61xZMRri5NlLnl6CVF7ihZW8= +github.com/charmbracelet/x/ansi v0.11.6/go.mod h1:2JNYLgQUsyqaiLovhU2Rv/pb8r6ydXKS3NIttu3VGZQ= +github.com/charmbracelet/x/cellbuf v0.0.15 h1:ur3pZy0o6z/R7EylET877CBxaiE1Sp1GMxoFPAIztPI= +github.com/charmbracelet/x/cellbuf v0.0.15/go.mod h1:J1YVbR7MUuEGIFPCaaZ96KDl5NoS0DAWkskup+mOY+Q= +github.com/charmbracelet/x/term v0.2.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk= +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/go.mod h1:bkrFNkf81G8HyVqmKGxsPufD3JhNl3dSqnGhOoSD/o0= +github.com/clipperhouse/uax29/v2 v2.7.0 h1:+gs4oBZ2gPfVrKPthwbMzWZDaAFPGYK72F0NJv2v7Vk= +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/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY= +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/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/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mattn/go-runewidth v0.0.22 h1:76lXsPn6FyHtTY+jt2fTTvsMUCZq1k0qwRsAMuxzKAk= +github.com/mattn/go-runewidth v0.0.22/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs= +github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc= +github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= +github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= +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/go.mod h1:tKQlpPaYCVFctUIgFKFnAlvbmB3tpy1vkTnDWohtc0E= +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/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/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo= +golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/tests/integration_test.go b/tests/integration_test.go new file mode 100644 index 0000000..4dab11b --- /dev/null +++ b/tests/integration_test.go @@ -0,0 +1,144 @@ +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 TestIntegrationSync(t *testing.T) { + // 1. Setup Environment + tempDir, err := os.MkdirTemp("", "todo-test-*") + require.NoError(t, err) + defer os.RemoveAll(tempDir) + + // Override storage path for BoltStore by setting HOME or XDG_CONFIG_HOME + // BoltStore uses os.UserConfigDir() which looks at XDG_CONFIG_HOME on Linux + 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) + ts := httptest.NewServer(mux) + defer ts.Close() + + client := ts.Client() + username := "testuser" + password := "testpass" + + // 3. Register User + regPayload, _ := json.Marshal(common.Credentials{Username: username, Password: password}) + resp, err := client.Post(ts.URL+"/register", "application/json", bytes.NewBuffer(regPayload)) + require.NoError(t, err) + assert.Equal(t, http.StatusOK, resp.StatusCode) + + // 4. Login and Get Token + resp, err = client.Post(ts.URL+"/login", "application/json", bytes.NewBuffer(regPayload)) + require.NoError(t, err) + assert.Equal(t, http.StatusOK, resp.StatusCode) + token := resp.Header.Get(common.AuthHeader) + require.NotEmpty(t, token) + + // 5. Create Local Todos + store, err := common.GetTodoDataStore() + require.NoError(t, err) + defer store.Close() + + todo1 := common.Todo{ + Id: uuid.New().String(), + Name: "Task 1", + Description: "Desc 1", + Status: common.NotStarted, + Owner: username, + LastModified: time.Now().UTC(), + } + todo2 := common.Todo{ + Id: uuid.New().String(), + Name: "Task 2", + Description: "Desc 2", + Status: common.WIP, + Owner: username, + LastModified: time.Now().UTC(), + } + + err = todo1.Store(store, username) + require.NoError(t, err) + err = todo2.Store(store, username) + require.NoError(t, err) + + // 6. Sync to Server + localTodos := store.GetTodoList(username, true) + syncReq := common.TodoList{Todos: localTodos} + 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 "+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() + + // Verify both todos are in sync response + assert.Len(t, syncResp.SyncedTodos, 2) + assert.Empty(t, syncResp.MisMatchingTodos) + + // 7. Verify Server State (By syncing again with empty local) + 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 "+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, 2, "Server should have kept the synced todos") + + // 8. Test Pulling from Server (New Client) + // Create another temporary directory for a different client + client2TempDir, err := os.MkdirTemp("", "todo-test-client2-*") + require.NoError(t, err) + defer os.RemoveAll(client2TempDir) + + // Since common.GetTodoDataStore() uses sync.Once, we can't easily get a new database in the same process + // BUT for this test, we can just use GetCustomDataStore("todo2.db") if it was public, + // however it seems we can just check if the server returns the data when requested. + + req, _ = http.NewRequest("POST", ts.URL+"/sync", bytes.NewBuffer(emptySyncPayload)) + req.Header.Set("Content-Type", "application/json") + req.Header.Set(common.AuthHeader, "Bearer "+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, 2, "Server should return the 2 todos for a sync request") +}