diff --git a/api/.air.toml b/api/.air.toml new file mode 100644 index 0000000..78ea864 --- /dev/null +++ b/api/.air.toml @@ -0,0 +1,52 @@ +root = "." +testdata_dir = "testdata" +tmp_dir = "tmp" + +[build] + args_bin = [] + bin = "tmp\\main.exe" + cmd = "go build -o ./tmp/main.exe cmd/main.go" + delay = 1000 + exclude_dir = ["assets", "tmp", "vendor", "testdata"] + exclude_file = [] + exclude_regex = ["_test.go"] + exclude_unchanged = false + follow_symlink = false + full_bin = "" + include_dir = [] + include_ext = ["go", "tpl", "tmpl", "html"] + include_file = [] + kill_delay = "0s" + log = "build-errors.log" + poll = false + poll_interval = 0 + post_cmd = [] + pre_cmd = [] + rerun = false + rerun_delay = 500 + send_interrupt = false + stop_on_error = false + +[color] + app = "" + build = "yellow" + main = "magenta" + runner = "green" + watcher = "cyan" + +[log] + main_only = false + silent = false + time = false + +[misc] + clean_on_exit = false + +[proxy] + app_port = 0 + enabled = false + proxy_port = 0 + +[screen] + clear_on_rebuild = false + keep_scroll = true diff --git a/api/README.md b/api/README.md index 6e644f2..deebd36 100644 --- a/api/README.md +++ b/api/README.md @@ -1,6 +1,6 @@ # Ludo API documentation -Ludo is comprised of users, boards, and items. Each board has a list of users, and a list of items. Each item has a list ID which specifies which list it belongs to. However, there is no list type. This is to genericize lists and make them act more like a _group_. Ludo is meant to be used for all kinds of boards and viewing options, and it should be left up to each boards implementation to organize and display items. +Ludo is comprised of users, boards, and items. Each board has a list of users, and a list of items. ## Endpoints @@ -20,29 +20,30 @@ Users are not accounts. You are not _logged in_ as your user. Users are simply u Boards are containers for items and have users assosiated with them. A board may be connected to a GitHub repo. -| Method | Path | Response Type | Description | -| ------ | ---------------------------------------- | ------------- | --------------------------- | -| GET | `/boards` | `Board[]` | Get all boards | -| POST | `/boards` | `ID` | Create new board | -| GET | `/boards/{boardId}` | `Board` | Get board by ID | -| PATCH | `/boards/{boardId}` | - | Update an existing board | -| DELETE | `/boards/{boardId}` | - | Delete a board | -| GET | `/boards/{boardId}/users` | `[]User` | Get all users in board | -| GET | `/boards/{boardId}/items` | `[]Item` | Get all items in board | -| POST | `/boards/{boardId}/users/{userId}` | - | Add user to board | -| DELETE | `/boards/{boardId}/users/{userId}` | - | Remove user from board | -| GET | `/boards/{boardId}/lists/{listId}/items` | `[]Item` | Get all items in board list | +| Method | Path | Response Type | Description | +| ------ | ----------------------------------------- | ------------- | ------------------------------------------ | +| GET | `/boards` | `Board[]` | Get all boards | +| POST | `/boards` | `ID` | Create new board | +| GET | `/boards/{boardId}` | `Board` | Get board by ID | +| PATCH | `/boards/{boardId}` | - | Update an existing board | +| DELETE | `/boards/{boardId}` | - | Delete a board | +| GET | `/boards/{boardId}/users` | `[]User` | Get all users in board | +| GET | `/boards/{boardId}/items` | `[]Item` | Get all items in board | +| POST | `/boards/{boardId}/users/{userId}` | - | Add user to board | +| DELETE | `/boards/{boardId}/users/{userId}` | - | Remove user from board | +| GET | `/boards/{boardId}/status/{status}/items` | `[]Item` | Get all items in board with given `Status` | ### Items -| Method | Path | Response Type | Description | -| ------ | ------------------------------- | ------------- | ---------------------------- | -| GET | `/items` | `[]Item` | Get all items | -| POST | `/items` | `ID` | Create an item | -| GET | `/items/{itemId}` | `Item` | Get item by ID | -| PATCH | `/items/{itemId}` | - | Update an existing item | -| DELETE | `/items/{itemId}` | - | Delete an item | -| PATCH | `/items/{itemId}/move/{listId}` | - | Move an item to another list | +| Method | Path | Response Type | Description | +| ------ | ---------------------- | ------------- | ----------------------- | +| GET | `/items` | `[]Item` | Get all items | +| POST | `/items` | `ID` | Create an item | +| GET | `/items/{itemId}` | `Item` | Get item by ID | +| PATCH | `/items/{itemId}` | - | Update an existing item | +| DELETE | `/items/{itemId}` | - | Delete an item | +| GET | `/items/{itemId}/data` | `any` | Get items data field | +| PATCH | `/items/{itemId}/data` | - | Update items data field | ## Schemas @@ -93,7 +94,7 @@ When creating new objects you get its ID in response. ### Status -Item status is one of the following values. They are automatically updated if the item contains github repo/branch info. +Item status is one of the following values. They are automatically updated if the item contains github repo/branch info. Use the integer value as the status in requests. | Name | Value | Description | | ---------------- | ----- | -------------------------------------------------------- | @@ -102,4 +103,3 @@ Item status is one of the following values. They are automatically updated if th | StatusInProgress | 2 | Item is being worked on and tracks a branch | | StatusInReview | 3 | Item has a pull request open | | StatusClosed | 4 | Items pull request was merged or closed | - diff --git a/api/config/config.go b/api/config/config.go index 669e72d..beda1ec 100644 --- a/api/config/config.go +++ b/api/config/config.go @@ -12,8 +12,6 @@ type Config struct { Port string DatabaseURL string DatabaseToken string - IsDev bool - DBFile string GitHubAuthToken string } diff --git a/api/database/database_test.go b/api/database/database_test.go index 1d35c3f..8adf6f3 100644 --- a/api/database/database_test.go +++ b/api/database/database_test.go @@ -3,163 +3,173 @@ package database import ( "os" "testing" + + "github.com/stretchr/testify/assert" ) -func assert(t *testing.T, v bool, msg string, args ...any) { - if !v { - t.Fatalf(msg, args...) - } -} +func createTestDB(t *testing.T) *Database { + tmpfile, err := os.CreateTemp("", "testdb-*.sqlite") + assert.NoError(t, err) + + db := NewSQLite(tmpfile.Name()) + + t.Cleanup(func() { + os.Remove(tmpfile.Name()) + }) + + // Auto migrate schema + err = db.db.AutoMigrate(&Board{}, &Item{}, &User{}) + assert.NoError(t, err) -func assertNoErr(t *testing.T, err error) { - if err != nil { - t.Fatal(err) - } + return db } -func TestDatabase(t *testing.T) { - filename := "test.db" - db := NewSQLite(filename) - defer os.Remove(filename) +func TestUserMethods(t *testing.T) { + db := createTestDB(t) - t.Run("Create and get user", func(t *testing.T) { - id, err := db.CreateUser(User{ - DisplayName: "John", - GithubUsername: "johnpork_", - }) - assertNoErr(t, err) + // Create user + user := User{DisplayName: "Test User", GithubUsername: "testuser"} + id, err := db.CreateUser(user) + assert.NoError(t, err) - user, err := db.GetUserById(id) - assertNoErr(t, err) + // Get user + u, err := db.GetUserById(id) + assert.NoError(t, err) + assert.Equal(t, "Test User", u.DisplayName) - assert(t, user.ID == id, "id does not match") - assert(t, user.DisplayName == "John", "incorrect display name, got %s", user.DisplayName) - assert(t, user.GithubUsername == "johnpork_", "incorrect github name, got %s", user.GithubUsername) - }) + // Get all users + users, err := db.GetAllUsers() + assert.NoError(t, err) + assert.Len(t, users, 1) - t.Run("Create board and add items", func(t *testing.T) { - userID, err := db.CreateUser(User{ - DisplayName: "Alice", - GithubUsername: "alicehub", - }) - assertNoErr(t, err) - - board := Board{} - assertNoErr(t, db.db.Create(&board).Error) - - item := Item{ - BoardID: board.ID, - RepoName: "example-repo", - RepoURL: "https://github.com/example/repo", - Title: "Initial Task", - Description: "Setup project", - ConnectedBranch: "main", - Status: StatusInProgress, - CreatorID: userID, - AssigneeID: userID, - } - _, err = db.CreateItem(item) - assertNoErr(t, err) - - items, err := db.GetAllItems() - assertNoErr(t, err) - assert(t, len(items) == 1, "expected 1 item, got %d", len(items)) - - itemFromDB, err := db.GetItemById(items[0].ID) - assertNoErr(t, err) - assert(t, itemFromDB.Title == "Initial Task", "unexpected title, got %s", itemFromDB.Title) - }) + // Delete user + err = db.DeleteUserById(id) + assert.NoError(t, err) - t.Run("Get all users", func(t *testing.T) { - users, err := db.GetAllUsers() - assertNoErr(t, err) - assert(t, len(users) >= 2, "expected at least 2 users, got %d", len(users)) - }) + // Confirm user deleted + _, err = db.GetUserById(id) + assert.Error(t, err) +} - t.Run("Get all items with status", func(t *testing.T) { - board := Board{} - assertNoErr(t, db.db.Create(&board).Error) - - item := Item{ - BoardID: board.ID, - Title: "Status Test", - Status: StatusInReview, - } - _, err := db.CreateItem(item) - assertNoErr(t, err) - - items, err := db.GetAllItemsWithStatus(board.ID, StatusInReview) - assertNoErr(t, err) - assert(t, len(items) > 0, "expected at least 1 item with status 2") - }) +func TestBoardMethods(t *testing.T) { + db := createTestDB(t) + + // Create board + board := Board{Title: "Board 1", RepoURL: "https://github.com/example/repo"} + _, err := db.CreateBoard(board) + assert.NoError(t, err) + + // Get boards + boards, err := db.GetAllBoards() + assert.NoError(t, err) + assert.Len(t, boards, 1) + + // Get by ID + got, err := db.GetBoardById(boards[0].ID) + assert.NoError(t, err) + assert.Equal(t, "Board 1", got.Title) + + // Update board + got.Title = "Updated" + err = db.UpdateBoard(got, got.ID) + assert.NoError(t, err) + + // Get updated board + got2, err := db.GetBoardById(got.ID) + assert.NoError(t, err) + assert.Equal(t, "Updated", got2.Title) + + // Delete board + err = db.DeleteBoard(got.ID) + assert.NoError(t, err) +} - t.Run("Set item status", func(t *testing.T) { - items, _ := db.GetAllItems() - item := items[0] +func TestItemMethods(t *testing.T) { + db := createTestDB(t) - err := db.ChangeItemStatus(item.ID, StatusClosed) - assertNoErr(t, err) + // Create board + board := Board{Title: "Board", RepoURL: "url"} + _, err := db.CreateBoard(board) + assert.NoError(t, err) + boards, _ := db.GetAllBoards() - movedItem, err := db.GetItemById(item.ID) - assertNoErr(t, err) - assert(t, movedItem.Status == StatusClosed, "item was not set to status 3") - }) + // Create item + item := Item{BoardID: boards[0].ID, Title: "Item 1", Status: StatusBacklog} + itemID, err := db.CreateItem(item) + assert.NoError(t, err) - t.Run("Change item title", func(t *testing.T) { - items, _ := db.GetAllItems() - item := items[0] + // Get item + got, err := db.GetItemById(itemID) + assert.NoError(t, err) + assert.Equal(t, "Item 1", got.Title) - err := db.ChangeItemTitle(item.ID, "Updated Title") - assertNoErr(t, err) + // Change status + err = db.ChangeItemStatus(itemID, StatusInProgress) + assert.NoError(t, err) - updatedItem, err := db.GetItemById(item.ID) - assertNoErr(t, err) - assert(t, updatedItem.Title == "Updated Title", "title not updated correctly") - }) + // Get items with status + items, err := db.GetAllItemsWithStatus(boards[0].ID, StatusInProgress) + assert.NoError(t, err) + assert.Len(t, items, 1) - t.Run("Change item description", func(t *testing.T) { - items, _ := db.GetAllItems() - item := items[0] + // Set/Get item data + err = db.SetItemData(itemID, "some-data") + assert.NoError(t, err) - err := db.ChangeItemDescription(item.ID, "New Description") - assertNoErr(t, err) + data, err := db.GetItemData(itemID) + assert.NoError(t, err) + assert.Equal(t, "some-data", data) - updatedItem, err := db.GetItemById(item.ID) - assertNoErr(t, err) - assert(t, updatedItem.Description == "New Description", "description not updated correctly") - }) + // Delete item + assert.NoError(t, db.DeleteItemByID(itemID)) +} - t.Run("Delete item", func(t *testing.T) { - id, err := db.CreateItem(Item{}) - assertNoErr(t, err) +func TestBoardUsers(t *testing.T) { + db := createTestDB(t) - err = db.DeleteItemByID(id) - assertNoErr(t, err) + // Create user and board + user := User{DisplayName: "A"} + userId, _ := db.CreateUser(user) + board := Board{Title: "Board"} - _, err = db.GetItemById(id) - assert(t, err != nil, "item should have been deleted") - }) + _, err := db.CreateBoard(board) + assert.NoError(t, err) - t.Run("Delete user", func(t *testing.T) { - id, err := db.CreateUser(User{}) - assertNoErr(t, err) + boards, _ := db.GetAllBoards() - err = db.DeleteUserById(id) - assertNoErr(t, err) + // Add user to board + assert.NoError(t, db.AddUserToBoard(boards[0].ID, userId)) - _, err = db.GetUserById(id) - assert(t, err != nil, "user should have been deleted") - }) + // Get board users + users, err := db.GetBoardUsers(boards[0].ID) + assert.NoError(t, err) + assert.Len(t, users, 1) - t.Run("Get and set item data", func(t *testing.T) { - id, err := db.CreateItem(Item{}) - assertNoErr(t, err) + // Remove user from board + assert.NoError(t, db.RemoveUserFromBoard(boards[0].ID, userId)) - str := "Hello World" - assertNoErr(t, db.SetItemData(id, str)) + // Check no users left + users, err = db.GetBoardUsers(boards[0].ID) + assert.NoError(t, err) + assert.Len(t, users, 0) +} - data, err := db.GetItemData(id) - assertNoErr(t, err) - assert(t, data == str, "data didnt match expected value") - }) +func TestGetBoardItemsByStatus(t *testing.T) { + db := createTestDB(t) + + board := Board{Title: "Board"} + _, err := db.CreateBoard(board) + assert.NoError(t, err) + + boards, _ := db.GetAllBoards() + + // Create multiple items with different statuses + db.CreateItem(Item{BoardID: boards[0].ID, Title: "Item 1", Status: StatusBacklog}) + db.CreateItem(Item{BoardID: boards[0].ID, Title: "Item 2", Status: StatusInProgress}) + db.CreateItem(Item{BoardID: boards[0].ID, Title: "Item 3", Status: StatusBacklog}) + + // Fetch items with StatusBacklog + items, err := db.GetBoardItemsByStatus(boards[0].ID, StatusBacklog) + assert.NoError(t, err) + assert.Len(t, items, 2) } diff --git a/api/database/model.go b/api/database/model.go index e836d7c..453295e 100644 --- a/api/database/model.go +++ b/api/database/model.go @@ -21,8 +21,10 @@ type ID struct { type Board struct { Model - Items []Item `gorm:"foreignKey:BoardID" json:"items"` - Users []User `gorm:"many2many:board_users;" json:"users"` + Title string `json:"title"` + RepoURL string `json:"repoUrl"` + Items []Item `gorm:"foreignKey:BoardID" json:"items"` + Users []User `gorm:"many2many:board_users;" json:"users"` } type Status uint @@ -39,7 +41,7 @@ type Item struct { Model BoardID uint `json:"boardId"` RepoName string `json:"repoName"` - RepoURL string `json:"repoURL"` + RepoURL string `json:"repoUrl"` Title string `json:"title"` Description string `json:"description"` ConnectedBranch string `json:"connectedBranch"` diff --git a/api/database/query.go b/api/database/query.go index 668e1eb..45c124d 100644 --- a/api/database/query.go +++ b/api/database/query.go @@ -1,109 +1,188 @@ package database -import ( - "errors" -) - func (db *Database) GetUserById(id uint) (User, error) { var user User - if res := db.db.First(&user, id); res.Error != nil { - return User{}, errors.New("could not find usre with id") + if err := db.db.First(&user, id).Error; err != nil { + return User{}, err } return user, nil } func (db *Database) CreateUser(user User) (uint, error) { - if res := db.db.Create(&user); res.Error != nil { - return 0, errors.New("could not create a new user") + if err := db.db.Create(&user).Error; err != nil { + return 0, err } return user.ID, nil } func (db *Database) DeleteUserById(id uint) error { - if res := db.db.Delete(&User{}, id); res.Error != nil { - return errors.New("could not delete user") + if err := db.db.Delete(&User{}, id).Error; err != nil { + return err } return nil } func (db *Database) GetAllUsers() ([]User, error) { var users []User - if res := db.db.Find(&users); res.Error != nil { - return nil, errors.New("courl not get all userts") + if err := db.db.Find(&users).Error; err != nil { + return nil, err } return users, nil } func (db *Database) GetItemById(id uint) (Item, error) { var item Item - if res := db.db.First(&item, id); res.Error != nil { - return Item{}, errors.New("could not get item") + if err := db.db.First(&item, id).Error; err != nil { + return Item{}, err } return item, nil } func (db *Database) CreateItem(item Item) (id uint, err error) { - if res := db.db.Create(&item); res.Error != nil { - return id, errors.New("could not create item") + if err := db.db.Create(&item).Error; err != nil { + return id, err } return item.ID, nil } func (db *Database) GetAllItems() ([]Item, error) { var items []Item - if res := db.db.Find(&items); res.Error != nil { - return nil, errors.New("could not get all items") + if err := db.db.Find(&items).Error; err != nil { + return nil, err } return items, nil } func (db *Database) DeleteItemByID(id uint) error { - if res := db.db.Delete(&Item{}, id); res.Error != nil { - return errors.New("could not delete item") + if err := db.db.Delete(&Item{}, id).Error; err != nil { + return err } return nil } func (db *Database) GetAllItemsWithStatus(boardId uint, list Status) ([]Item, error) { var items []Item - if res := db.db.Find(&items, "status = ?", list); res.Error != nil { - return nil, errors.New("could not find items with status") + if err := db.db.Where("board_id = ? AND status = ?", boardId, list).Find(&items).Error; err != nil { + return nil, err } return items, nil } func (db *Database) ChangeItemStatus(id uint, list Status) error { - if res := db.db.Model(&Item{}).Where("id = ?", id).Update("status", list); res.Error != nil { - return errors.New("could not set item status") + if err := db.db.Model(&Item{}).Where("id = ?", id).Update("status", list).Error; err != nil { + return err } return nil } -func (db *Database) ChangeItemTitle(id uint, title string) error { - if res := db.db.Model(&Item{}).Where("id = ?", id).Update("title", title); res.Error != nil { - return errors.New("could not change item title") +func (db *Database) SetItemData(id uint, data string) error { + if err := db.db.Model(&Item{}).Where("id = ?", id).Update("data", data).Error; err != nil { + return err } return nil } -func (db *Database) ChangeItemDescription(id uint, description string) error { - if res := db.db.Model(&Item{}).Where("id = ?", id).Update("description", description); res.Error != nil { - return errors.New("could not change item description") +func (db *Database) GetItemData(id uint) (data string, err error) { + var item Item + if err := db.db.First(&item, id).Error; err != nil { + return data, err + } + return item.Data, nil +} + +func (db *Database) GetAllBoards() (boards []Board, err error) { + if err := db.db.Find(&boards).Error; err != nil { + return boards, err + } + return boards, err +} + +func (db *Database) CreateBoard(board Board) (id uint, err error) { + if err := db.db.Create(&board).Error; err != nil { + return id, err + } + return board.ID, nil +} + +func (db *Database) GetBoardById(id uint) (board Board, err error) { + if err := db.db.Find(&board, id).Error; err != nil { + return board, err + } + return board, nil +} + +func (db *Database) UpdateBoard(board Board, id uint) error { + if err := db.db.Model(&Board{}).Where("id = ?", id).Updates(board).Error; err != nil { + return err } return nil } -func (db *Database) SetItemData(id uint, data string) error { - if res := db.db.Model(&Item{}).Where("id = ?", id).Update("data", data); res.Error != nil { - return errors.New("could not set data field") +func (db *Database) DeleteBoard(id uint) error { + if err := db.db.Delete(&Board{}, id).Error; err != nil { + return err } return nil } -func (db *Database) GetItemData(id uint) (data string, err error) { - var item Item - if res := db.db.First(&item, id); res.Error != nil { - return data, errors.New("item not found") +func (d *Database) GetBoardUsers(boardId uint) ([]User, error) { + var board Board + if err := d.db.Preload("Users").First(&board, boardId).Error; err != nil { + return nil, err } - return item.Data, nil + return board.Users, nil +} + +func (d *Database) GetBoardItems(boardId uint) ([]Item, error) { + var items []Item + if err := d.db.Where("board_id = ?", boardId).Find(&items).Error; err != nil { + return nil, err + } + return items, nil +} + +func (d *Database) AddUserToBoard(boardId, userId uint) error { + var board Board + if err := d.db.First(&board, boardId).Error; err != nil { + return err + } + var user User + if err := d.db.First(&user, userId).Error; err != nil { + return err + } + return d.db.Model(&board).Association("Users").Append(&user) +} + +func (d *Database) RemoveUserFromBoard(boardId, userId uint) error { + var board Board + if err := d.db.First(&board, boardId).Error; err != nil { + return err + } + var user User + if err := d.db.First(&user, userId).Error; err != nil { + return err + } + return d.db.Model(&board).Association("Users").Delete(&user) +} + +func (d *Database) GetBoardItemsByStatus(boardId uint, status Status) ([]Item, error) { + var items []Item + if err := d.db.Where("board_id = ? AND status = ?", boardId, status).Find(&items).Error; err != nil { + return nil, err + } + return items, nil +} + +func (db *Database) UpdateItem(item Item, id uint) error { + if err := db.db.Model(&Item{}).Where("id = ?", id).Updates(item).Error; err != nil { + return err + } + return nil +} + +func (db *Database) UpdateUser(user User, id uint) error { + if err := db.db.Model(&User{}).Where("id = ?", id).Updates(user).Error; err != nil { + return err + } + return nil } diff --git a/api/server/handler.go b/api/server/handler.go index c976032..0ccae32 100644 --- a/api/server/handler.go +++ b/api/server/handler.go @@ -1,7 +1,13 @@ package server import ( + "encoding/json" + "io" "net/http" + "strconv" + + "github.com/echo-webkom/ludo/api/database" + "github.com/go-chi/chi/v5" ) func pingHandler() http.HandlerFunc { @@ -9,3 +15,401 @@ func pingHandler() http.HandlerFunc { w.Write([]byte("pong")) } } + +func respondJSON(w http.ResponseWriter, v any) { + b, err := json.Marshal(v) + if err != nil { + w.WriteHeader(http.StatusInternalServerError) + return + } + + if _, err := w.Write(b); err != nil { + w.WriteHeader(http.StatusInternalServerError) + } +} + +func getJSON(r *http.Request, v any) error { + b, err := io.ReadAll(r.Body) + if err != nil { + return err + } + + defer r.Body.Close() + return json.Unmarshal(b, v) +} + +func usersHandler(db *database.Database) chi.Router { + r := chi.NewRouter() + + // Get all users + r.Get("/", func(w http.ResponseWriter, r *http.Request) { + users, err := db.GetAllUsers() + if err != nil { + w.WriteHeader(http.StatusInternalServerError) + return + } + + respondJSON(w, &users) + }) + + // Create user + r.Post("/", func(w http.ResponseWriter, r *http.Request) { + var user database.User + if err := getJSON(r, &user); err != nil { + http.Error(w, "bad request data", http.StatusBadRequest) + return + } + + id, err := db.CreateUser(user) + if err != nil { + w.WriteHeader(http.StatusInternalServerError) + return + } + + respondJSON(w, &database.ID{ID: id}) + }) + + // Get user by id + r.Get("/{id}", func(w http.ResponseWriter, r *http.Request) { + id, err := strconv.Atoi(r.PathValue("id")) + if err != nil { + http.Error(w, "bad id", http.StatusBadRequest) + return + } + + user, err := db.GetUserById(uint(id)) + if err != nil { + w.WriteHeader(http.StatusInternalServerError) + return + } + + respondJSON(w, &user) + }) + + // Update user + r.Patch("/{id}", func(w http.ResponseWriter, r *http.Request) { + userId, err := strconv.Atoi(r.PathValue("id")) + if err != nil { + http.Error(w, "bad user id", http.StatusBadRequest) + return + } + + var user database.User + if err := getJSON(r, &user); err != nil { + http.Error(w, "bad request data", http.StatusBadRequest) + return + } + + if err := db.UpdateUser(user, uint(userId)); err != nil { + w.WriteHeader(http.StatusInternalServerError) + return + } + }) + + // Delete user by id + r.Delete("/{id}", func(w http.ResponseWriter, r *http.Request) { + id, err := strconv.Atoi(r.PathValue("id")) + if err != nil { + http.Error(w, "bad id", http.StatusBadRequest) + return + } + + if err := db.DeleteUserById(uint(id)); err != nil { + w.WriteHeader(http.StatusInternalServerError) + } + }) + + return r +} + +func itemsHandler(db *database.Database) chi.Router { + r := chi.NewRouter() + + // Get all items + r.Get("/", func(w http.ResponseWriter, r *http.Request) { + items, err := db.GetAllItems() + if err != nil { + w.WriteHeader(http.StatusInternalServerError) + return + } + + respondJSON(w, &items) + }) + + // Create item + r.Post("/", func(w http.ResponseWriter, r *http.Request) { + var item database.Item + if err := getJSON(r, &item); err != nil { + http.Error(w, "bad request data", http.StatusBadRequest) + return + } + + id, err := db.CreateItem(item) + if err != nil { + w.WriteHeader(http.StatusInternalServerError) + return + } + + respondJSON(w, &database.ID{ID: id}) + }) + + // Get item by id + r.Get("/{id}", func(w http.ResponseWriter, r *http.Request) { + id, err := strconv.Atoi(r.PathValue("id")) + if err != nil { + http.Error(w, "bad id", http.StatusBadRequest) + return + } + + item, err := db.GetItemById(uint(id)) + if err != nil { + w.WriteHeader(http.StatusInternalServerError) + return + } + + respondJSON(w, &item) + }) + + // Update item + r.Patch("/{id}", func(w http.ResponseWriter, r *http.Request) { + itemId, err := strconv.Atoi(r.PathValue("id")) + if err != nil { + http.Error(w, "bad item id", http.StatusBadRequest) + return + } + + var item database.Item + if err := getJSON(r, &item); err != nil { + http.Error(w, "bad request data", http.StatusBadRequest) + return + } + + if err := db.UpdateItem(item, uint(itemId)); err != nil { + w.WriteHeader(http.StatusInternalServerError) + return + } + }) + + // Delete item by id + r.Delete("/{id}", func(w http.ResponseWriter, r *http.Request) { + id, err := strconv.Atoi(r.PathValue("id")) + if err != nil { + http.Error(w, "bad id", http.StatusBadRequest) + return + } + + if err := db.DeleteItemByID(uint(id)); err != nil { + w.WriteHeader(http.StatusInternalServerError) + } + }) + + // Get item data + r.Get("/{id}/data", func(w http.ResponseWriter, r *http.Request) { + id, err := strconv.Atoi(r.PathValue("id")) + if err != nil { + http.Error(w, "bad id", http.StatusBadRequest) + return + } + + item, err := db.GetItemById(uint(id)) + if err != nil { + w.WriteHeader(http.StatusInternalServerError) + return + } + + w.Write([]byte(item.Data)) + }) + + // Set item data + r.Patch("/{id}/data", func(w http.ResponseWriter, r *http.Request) { + id, err := strconv.Atoi(r.PathValue("id")) + if err != nil { + http.Error(w, "bad id", http.StatusBadRequest) + return + } + + data, err := io.ReadAll(r.Body) + if err != nil { + w.WriteHeader(http.StatusInternalServerError) + return + } + + if err := db.SetItemData(uint(id), string(data)); err != nil { + w.WriteHeader(http.StatusInternalServerError) + return + } + }) + + return r +} + +func boardsHandler(db *database.Database) chi.Router { + r := chi.NewRouter() + + // Get all boards + r.Get("/", func(w http.ResponseWriter, r *http.Request) { + boards, err := db.GetAllBoards() + if err != nil { + w.WriteHeader(http.StatusInternalServerError) + return + } + respondJSON(w, &boards) + }) + + // Create board + r.Post("/", func(w http.ResponseWriter, r *http.Request) { + var board database.Board + if err := getJSON(r, &board); err != nil { + http.Error(w, "bad request data", http.StatusBadRequest) + return + } + + id, err := db.CreateBoard(board) + if err != nil { + w.WriteHeader(http.StatusInternalServerError) + return + } + + respondJSON(w, &database.ID{ID: id}) + }) + + // Get board by id + r.Get("/{boardId}", func(w http.ResponseWriter, r *http.Request) { + boardId, err := strconv.Atoi(r.PathValue("boardId")) + if err != nil { + http.Error(w, "bad boardId", http.StatusBadRequest) + return + } + + board, err := db.GetBoardById(uint(boardId)) + if err != nil { + w.WriteHeader(http.StatusInternalServerError) + return + } + + respondJSON(w, &board) + }) + + // Update board + r.Patch("/{boardId}", func(w http.ResponseWriter, r *http.Request) { + boardId, err := strconv.Atoi(r.PathValue("boardId")) + if err != nil { + http.Error(w, "bad boardId", http.StatusBadRequest) + return + } + + var board database.Board + if err := getJSON(r, &board); err != nil { + http.Error(w, "bad request data", http.StatusBadRequest) + return + } + + if err := db.UpdateBoard(board, uint(boardId)); err != nil { + w.WriteHeader(http.StatusInternalServerError) + return + } + }) + + // Delete baord + r.Delete("/{boardId}", func(w http.ResponseWriter, r *http.Request) { + boardId, err := strconv.Atoi(r.PathValue("boardId")) + if err != nil { + http.Error(w, "bad boardId", http.StatusBadRequest) + return + } + + if err := db.DeleteBoard(uint(boardId)); err != nil { + w.WriteHeader(http.StatusInternalServerError) + return + } + }) + + // Get all users in board + r.Get("/{boardId}/users", func(w http.ResponseWriter, r *http.Request) { + boardId, err := strconv.Atoi(r.PathValue("boardId")) + if err != nil { + http.Error(w, "bad boardId", http.StatusBadRequest) + return + } + + users, err := db.GetBoardUsers(uint(boardId)) + if err != nil { + w.WriteHeader(http.StatusInternalServerError) + return + } + + respondJSON(w, &users) + }) + + // Get all items in board + r.Get("/{boardId}/items", func(w http.ResponseWriter, r *http.Request) { + boardId, err := strconv.Atoi(r.PathValue("boardId")) + if err != nil { + http.Error(w, "bad boardId", http.StatusBadRequest) + return + } + + items, err := db.GetBoardItems(uint(boardId)) + if err != nil { + w.WriteHeader(http.StatusInternalServerError) + return + } + + respondJSON(w, &items) + }) + + // Add user to board + r.Post("/{boardId}/users/{userId}", func(w http.ResponseWriter, r *http.Request) { + boardId, err1 := strconv.Atoi(r.PathValue("boardId")) + userId, err2 := strconv.Atoi(r.PathValue("userId")) + if err1 != nil || err2 != nil { + http.Error(w, "bad id(s)", http.StatusBadRequest) + return + } + + if err := db.AddUserToBoard(uint(boardId), uint(userId)); err != nil { + w.WriteHeader(http.StatusInternalServerError) + return + } + }) + + // Remove user from board + r.Delete("/{boardId}/users/{userId}", func(w http.ResponseWriter, r *http.Request) { + boardId, err1 := strconv.Atoi(r.PathValue("boardId")) + userId, err2 := strconv.Atoi(r.PathValue("userId")) + if err1 != nil || err2 != nil { + http.Error(w, "bad id(s)", http.StatusBadRequest) + return + } + + if err := db.RemoveUserFromBoard(uint(boardId), uint(userId)); err != nil { + w.WriteHeader(http.StatusInternalServerError) + return + } + }) + + // Get all items with status + r.Get("/{boardId}/status/{status}/items", func(w http.ResponseWriter, r *http.Request) { + boardId, err := strconv.Atoi(r.PathValue("boardId")) + if err != nil { + http.Error(w, "bad boardId", http.StatusBadRequest) + return + } + + status, err := strconv.Atoi(r.PathValue("status")) + if err != nil { + http.Error(w, "bad status", http.StatusBadRequest) + return + } + + items, err := db.GetBoardItemsByStatus(uint(boardId), database.Status(status)) + if err != nil { + w.WriteHeader(http.StatusInternalServerError) + return + } + + respondJSON(w, &items) + }) + + return r +} diff --git a/api/server/server.go b/api/server/server.go index 409e230..9e121ff 100644 --- a/api/server/server.go +++ b/api/server/server.go @@ -23,6 +23,10 @@ func New(config *config.Config, db *database.Database) *Server { r.Get("/ping", pingHandler()) + r.Mount("/users", usersHandler(db)) + r.Mount("/items", itemsHandler(db)) + r.Mount("/boards", boardsHandler(db)) + return &Server{ router: r, port: config.Port, @@ -50,3 +54,7 @@ func (s *Server) ListenAndServe(notif *notifier.Notifier) { log.Fatal(err) } } + +func (s Server) ServeHTTP(w http.ResponseWriter, r *http.Request) { + s.router.ServeHTTP(w, r) +} diff --git a/api/server/server_test.go b/api/server/server_test.go new file mode 100644 index 0000000..781eaf5 --- /dev/null +++ b/api/server/server_test.go @@ -0,0 +1,205 @@ +package server_test + +import ( + "bytes" + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "os" + "strconv" + "testing" + + "github.com/echo-webkom/ludo/api/config" + "github.com/echo-webkom/ludo/api/database" + "github.com/echo-webkom/ludo/api/server" +) + +func setupTestServer(t *testing.T) (*server.Server, func()) { + tempFile, err := os.CreateTemp("", "testdb-*.sqlite") + if err != nil { + t.Fatalf("failed to create temp file: %v", err) + } + db := database.NewSQLite(tempFile.Name()) + + cfg := &config.Config{Port: ":8080"} + srv := server.New(cfg, db) + + cleanup := func() { + db.Close() + os.Remove(tempFile.Name()) + } + + return srv, cleanup +} + +func makeRequest(t *testing.T, handler http.Handler, method, path string, body any) *httptest.ResponseRecorder { + var bodyReader io.Reader + if body != nil { + b, err := json.Marshal(body) + if err != nil { + t.Fatalf("failed to marshal body: %v", err) + } + bodyReader = bytes.NewReader(b) + } + req := httptest.NewRequest(method, path, bodyReader) + w := httptest.NewRecorder() + handler.ServeHTTP(w, req) + return w +} + +func TestPing(t *testing.T) { + srv, cleanup := setupTestServer(t) + defer cleanup() + + resp := makeRequest(t, srv, http.MethodGet, "/ping", nil) + if resp.Code != http.StatusOK { + t.Fatalf("expected 200, got %d", resp.Code) + } +} + +func TestUserEndpoints(t *testing.T) { + s, cleanup := setupTestServer(t) + defer cleanup() + + // Create user + user := database.User{DisplayName: "Test User"} + resp := makeRequest(t, s, http.MethodPost, "/users", &user) + if resp.Code != http.StatusOK { + t.Fatalf("CreateUser failed, code %d", resp.Code) + } + var created database.ID + json.NewDecoder(resp.Body).Decode(&created) + + // Get user + resp = makeRequest(t, s, http.MethodGet, "/users/"+strconv.Itoa(int(created.ID)), nil) + if resp.Code != http.StatusOK { + t.Fatalf("GetUserById failed, code %d", resp.Code) + } + + // Get all users + resp = makeRequest(t, s, http.MethodGet, "/users", nil) + if resp.Code != http.StatusOK { + t.Fatalf("GetAllUsers failed, code %d", resp.Code) + } + + // Delete user + resp = makeRequest(t, s, http.MethodDelete, "/users/"+strconv.Itoa(int(created.ID)), nil) + if resp.Code != http.StatusOK { + t.Fatalf("DeleteUser failed, code %d", resp.Code) + } +} + +func TestItemEndpoints(t *testing.T) { + s, cleanup := setupTestServer(t) + defer cleanup() + + // Create item + item := database.Item{Data: "Test Data"} + resp := makeRequest(t, s, http.MethodPost, "/items", &item) + if resp.Code != http.StatusOK { + t.Fatalf("CreateItem failed, code %d", resp.Code) + } + var created database.ID + json.NewDecoder(resp.Body).Decode(&created) + + // Get item + resp = makeRequest(t, s, http.MethodGet, "/items/"+strconv.Itoa(int(created.ID)), nil) + if resp.Code != http.StatusOK { + t.Fatalf("GetItemById failed, code %d", resp.Code) + } + + // Get all items + resp = makeRequest(t, s, http.MethodGet, "/items", nil) + if resp.Code != http.StatusOK { + t.Fatalf("GetAllItems failed, code %d", resp.Code) + } + + // Get item data + resp = makeRequest(t, s, http.MethodGet, "/items/"+strconv.Itoa(int(created.ID))+"/data", nil) + if resp.Code != http.StatusOK { + t.Fatalf("GetItemData failed, code %d", resp.Code) + } + + // Update item data + resp = makeRequest(t, s, http.MethodPatch, "/items/"+strconv.Itoa(int(created.ID))+"/data", bytes.NewBufferString("Updated Data")) + if resp.Code != http.StatusOK { + t.Fatalf("SetItemData failed, code %d", resp.Code) + } + + // Delete item + resp = makeRequest(t, s, http.MethodDelete, "/items/"+strconv.Itoa(int(created.ID)), nil) + if resp.Code != http.StatusOK { + t.Fatalf("DeleteItem failed, code %d", resp.Code) + } +} + +func TestBoardEndpoints(t *testing.T) { + s, cleanup := setupTestServer(t) + defer cleanup() + + // Create board + board := database.Board{Title: "Test Board"} + resp := makeRequest(t, s, http.MethodPost, "/boards", &board) + if resp.Code != http.StatusOK { + t.Fatalf("CreateBoard failed, code %d", resp.Code) + } + var createdBoard database.ID + json.NewDecoder(resp.Body).Decode(&createdBoard) + + // Get board + resp = makeRequest(t, s, http.MethodGet, "/boards/"+strconv.Itoa(int(createdBoard.ID)), nil) + if resp.Code != http.StatusOK { + t.Fatalf("GetBoard failed, code %d", resp.Code) + } + + // Get all boards + resp = makeRequest(t, s, http.MethodGet, "/boards", nil) + if resp.Code != http.StatusOK { + t.Fatalf("GetAllBoards failed, code %d", resp.Code) + } + + // Add user to board (assuming user creation tested before) + user := database.User{DisplayName: "Board User"} + resp = makeRequest(t, s, http.MethodPost, "/users", &user) + if resp.Code != http.StatusOK { + t.Fatalf("CreateUser failed, code %d", resp.Code) + } + var userID database.ID + json.NewDecoder(resp.Body).Decode(&userID) + + resp = makeRequest(t, s, http.MethodPost, "/boards/"+strconv.Itoa(int(createdBoard.ID))+"/users/"+strconv.Itoa(int(userID.ID)), nil) + if resp.Code != http.StatusOK { + t.Fatalf("AddUserToBoard failed, code %d", resp.Code) + } + + // Get board users + resp = makeRequest(t, s, http.MethodGet, "/boards/"+strconv.Itoa(int(createdBoard.ID))+"/users", nil) + if resp.Code != http.StatusOK { + t.Fatalf("GetBoardUsers failed, code %d", resp.Code) + } + + // Get board items + resp = makeRequest(t, s, http.MethodGet, "/boards/"+strconv.Itoa(int(createdBoard.ID))+"/items", nil) + if resp.Code != http.StatusOK { + t.Fatalf("GetBoardItems failed, code %d", resp.Code) + } + + // Get board items by status + resp = makeRequest(t, s, http.MethodGet, "/boards/"+strconv.Itoa(int(createdBoard.ID))+"/status/1/items", nil) + if resp.Code != http.StatusOK { + t.Fatalf("GetBoardItemsByStatus failed, code %d", resp.Code) + } + + // Remove user from board + resp = makeRequest(t, s, http.MethodDelete, "/boards/"+strconv.Itoa(int(createdBoard.ID))+"/users/"+strconv.Itoa(int(userID.ID)), nil) + if resp.Code != http.StatusOK { + t.Fatalf("RemoveUserFromBoard failed, code %d", resp.Code) + } + + // Delete board + resp = makeRequest(t, s, http.MethodDelete, "/boards/"+strconv.Itoa(int(createdBoard.ID)), nil) + if resp.Code != http.StatusOK { + t.Fatalf("DeleteBoard failed, code %d", resp.Code) + } +} diff --git a/board/cmd/main.go b/board/cmd/main.go index 9f82060..10dd0f4 100644 --- a/board/cmd/main.go +++ b/board/cmd/main.go @@ -1,8 +1,6 @@ package main import ( - "net/http" - "github.com/echo-webkom/ludo/board/config" "github.com/echo-webkom/ludo/board/server" ) @@ -10,6 +8,4 @@ import ( func main() { config := config.Load() _ = server.New(config) - - http.ListenAndServe(config.Port, http.FileServer(http.Dir("web"))) } diff --git a/board/service/service.go b/board/service/service.go index f663796..c29dc88 100644 --- a/board/service/service.go +++ b/board/service/service.go @@ -6,7 +6,7 @@ import ( "fmt" "io" "net/http" - "path" + "net/url" "github.com/echo-webkom/ludo/api/database" "github.com/echo-webkom/ludo/board/config" @@ -16,17 +16,20 @@ type LudoService struct { config *config.Config } -func New() *LudoService { - return &LudoService{} +func New(config *config.Config) *LudoService { + return &LudoService{ + config: config, + } } // Send request to API using config URL. Method should be "GET", "POST" etc. +// // Endpoint is the target endpoint, eg. "/users/12482", notice path values -// should already be formatted. body is the request body, a reference to the -// object you want to encode as json, nil for empty body. response is a +// should already be formatted. Body is the request body, a reference to the +// object you want to encode as json, nil for empty body. Response is a // reference to the response type, or nil for no response. func (l *LudoService) request(method, endpoint string, body, response any) error { - var bodyReader *bytes.Reader + var bodyReader io.Reader if body != nil { bodyJson, err := json.Marshal(body) if err != nil { @@ -35,8 +38,12 @@ func (l *LudoService) request(method, endpoint string, body, response any) error bodyReader = bytes.NewReader(bodyJson) } - path := path.Join(l.config.ApiUrl, endpoint) - req, err := http.NewRequest(method, path, bodyReader) + url, err := url.JoinPath(l.config.ApiUrl, endpoint) + if err != nil { + return err + } + + req, err := http.NewRequest(method, url, bodyReader) if err != nil { return err } @@ -47,7 +54,7 @@ func (l *LudoService) request(method, endpoint string, body, response any) error } defer res.Body.Close() - if response != nil { + if res.StatusCode == http.StatusOK && response != nil { b, err := io.ReadAll(res.Body) if err != nil { return err diff --git a/go.mod b/go.mod index 0fbb4d4..7fb1e6d 100644 --- a/go.mod +++ b/go.mod @@ -12,7 +12,12 @@ require ( gorm.io/gorm v1.30.0 ) -require github.com/google/go-querystring v1.1.0 // indirect +require ( + github.com/davecgh/go-spew v1.1.1 // indirect + github.com/google/go-querystring v1.1.0 // indirect + github.com/pmezard/go-difflib v1.0.0 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect +) require ( github.com/antlr4-go/antlr/v4 v4.13.0 // indirect @@ -22,6 +27,7 @@ require ( github.com/jinzhu/inflection v1.0.0 // indirect github.com/jinzhu/now v1.1.5 // indirect github.com/joho/godotenv v1.5.1 // indirect + github.com/stretchr/testify v1.10.0 golang.org/x/exp v0.0.0-20240325151524-a685a6edb6d8 // indirect golang.org/x/text v0.20.0 // indirect ) diff --git a/go.sum b/go.sum index 3f16f09..b59e271 100644 --- a/go.sum +++ b/go.sum @@ -2,6 +2,8 @@ github.com/antlr4-go/antlr/v4 v4.13.0 h1:lxCg3LAv+EUK6t1i0y1V6/SLeUi0eKEKdhQAlS8 github.com/antlr4-go/antlr/v4 v4.13.0/go.mod h1:pfChB/xh/Unjila75QW7+VU4TSnWnnk9UTnmpPaOR2g= github.com/coder/websocket v1.8.12 h1:5bUXkEPPIbewrnkU8LTCLVaxi4N4J8ahufH2vlo4NAo= github.com/coder/websocket v1.8.12/go.mod h1:LNVeNrXQZfe5qhS9ALED3uA+l5pPqvwXg3CKoDBB2gs= +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/echo-webkom/cenv v1.14.1 h1:Z0lzzRLoNRsPsJtSyiEmrXr9R0uIX6D0z7mRcw6jJDs= github.com/echo-webkom/cenv v1.14.1/go.mod h1:qCDFjnypg03McDVq+xBfQKnAAa5FAOI9F4RuTPau7sA= github.com/go-chi/chi/v5 v5.2.2 h1:CMwsvRVTbXVytCk1Wd72Zy1LAsAh9GxMmSNWLHCG618= @@ -25,6 +27,10 @@ github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= github.com/mattn/go-sqlite3 v1.14.28 h1:ThEiQrnbtumT+QMknw63Befp/ce/nUPgBPMlRFEum7A= github.com/mattn/go-sqlite3 v1.14.28/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= +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/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= +github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= github.com/tursodatabase/libsql-client-go v0.0.0-20240902231107-85af5b9d094d h1:dOMI4+zEbDI37KGb0TI44GUAwxHF9cMsIoDTJ7UmgfU= github.com/tursodatabase/libsql-client-go v0.0.0-20240902231107-85af5b9d094d/go.mod h1:l8xTsYB90uaVdMHXMCxKKLSgw5wLYBwBKKefNIUnm9s= golang.org/x/exp v0.0.0-20240325151524-a685a6edb6d8 h1:aAcj0Da7eBAtrTp03QXWvm88pSyOt+UgdZw2BFZ+lEw= @@ -32,6 +38,9 @@ golang.org/x/exp v0.0.0-20240325151524-a685a6edb6d8/go.mod h1:CQ1k9gNrJ50XIzaKCR golang.org/x/text v0.20.0 h1:gK/Kv2otX8gz+wn7Rmb3vT96ZwuoxnQlY+HlJVj7Qug= golang.org/x/text v0.20.0/go.mod h1:D4IsuqiFMhST5bX19pQ9ikHC2GsaKyk/oF+pn3ducp4= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +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= gorm.io/driver/sqlite v1.6.0 h1:WHRRrIiulaPiPFmDcod6prc4l2VGVWHz80KspNsxSfQ= gorm.io/driver/sqlite v1.6.0/go.mod h1:AO9V1qIQddBESngQUKWL9yoH93HIeA1X6V633rBwyT8= gorm.io/gorm v1.30.0 h1:qbT5aPv1UH8gI99OsRlvDToLxW5zR7FzS9acZDOZcgs=