diff --git a/.github/workflows/auto_request_review.yml b/.github/workflows/auto_request_review.yml index a1175e8c8..0f5eda810 100644 --- a/.github/workflows/auto_request_review.yml +++ b/.github/workflows/auto_request_review.yml @@ -8,7 +8,7 @@ jobs: auto-request-review: runs-on: ubuntu-latest steps: - - name: Request review from the TLs and 1 random team member + - name: Request review from the TLs and random team members uses: necojackarc/auto-request-review@v0.12.0 with: token: ${{ secrets.PAT_FOR_AUTO_REQUEST_REVIEW }} diff --git a/.github/workflows/go.yml b/.github/workflows/go.yml index 34c75bd09..1801e281f 100644 --- a/.github/workflows/go.yml +++ b/.github/workflows/go.yml @@ -69,9 +69,8 @@ jobs: - name: Install Dependencies run: cd backend && go get ./... - name: Migrate DB - run: | - cd backend/src && go run main.go --only-migrate + run: cd backend/src && go run main.go --only-migrate - name: Run Tests with Coverage - run: cd backend && go test -race -coverprofile=coverage.txt -covermode=atomic ./... + run: cd backend && go test -failfast -benchmem -race -coverprofile=coverage.txt ./... - name: Print Coverage run: cd backend && go tool cover -func=coverage.txt diff --git a/.gitignore b/.gitignore index 807d5c6b6..df5f21020 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,3 @@ .DS_Store .env -sac-cli \ No newline at end of file +sac-cli diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 8d51af442..28401a7f0 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -96,6 +96,39 @@ psql // opens psql shell CREATE DATABASE sac; ``` +# Commands + +### React Native + + ```bash + npx expo start --dev-client // runnning dev client + npx expo start --dev-client --ios // specific platform + yarn format // format code + yarn lint // lint code + yarn test // run tests + ``` + +### Go + + ```bash + go run main.go // run server + go test ./... // run tests + go fmt ./... // format code + go vet ./... // lint code + ``` + +### SAC CLI + + To install use `./install.sh` and then run `sac-cli` to see all commands. + + ```bash + sac-cli migrate // run migrations + sac-cli reset // reset database + sac-cli swagger // generate swagger docs + sac-cli lint // lint code + sac-cli format // format code + sac-cli test // run tests + ``` 4. **Create a user** @@ -124,15 +157,16 @@ go vet ./... // lint code ``` -### Others (WIP) +### Others ```bash sac-cli migrate // run migrations + sac-cli drop // drop database sac-cli reset // reset database sac-cli swagger // generate swagger docs - sac-cli lint // lint code - sac-cli format // format code - sac-cli test // run tests + sac-cli lint // lint code (WIP) + sac-cli format // format code (WIP) + sac-cli test // run tests (WIP) ``` # Git Flow diff --git a/backend/go.mod b/backend/go.mod index ee9f65bdd..4c453b144 100644 --- a/backend/go.mod +++ b/backend/go.mod @@ -15,10 +15,10 @@ require ( require ( github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect github.com/mcnijman/go-emailaddress v1.1.1 // indirect -) require github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect + require ( github.com/KyleBanks/depth v1.2.1 // indirect github.com/PuerkitoBio/purell v1.1.1 // indirect @@ -66,11 +66,11 @@ require ( github.com/valyala/tcplisten v1.0.0 // indirect go.uber.org/atomic v1.9.0 // indirect go.uber.org/multierr v1.9.0 // indirect - golang.org/x/crypto v0.17.0 // indirect + golang.org/x/crypto v0.18.0 // indirect golang.org/x/exp v0.0.0-20230905200255-921286631fa9 // indirect - golang.org/x/net v0.19.0 // indirect - golang.org/x/sys v0.15.0 // indirect - golang.org/x/text v0.14.0 + golang.org/x/net v0.20.0 // indirect + golang.org/x/sys v0.16.0 // indirect + golang.org/x/text v0.14.0 // indirect golang.org/x/tools v0.13.0 // indirect gopkg.in/ini.v1 v1.67.0 // indirect gopkg.in/yaml.v2 v2.4.0 // indirect diff --git a/backend/go.sum b/backend/go.sum index 0906300df..5fb160261 100644 --- a/backend/go.sum +++ b/backend/go.sum @@ -93,6 +93,8 @@ github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWE github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/mattn/go-runewidth v0.0.15 h1:UNAjwbU9l54TA3KzvqLGxwWjHmMgBUVhBiTjelZgg3U= github.com/mattn/go-runewidth v0.0.15/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= +github.com/mcnijman/go-emailaddress v1.1.1 h1:AGhgVDG3tCDaL0/Vc6erlPQjDuDN3dAT7rRdgFtetr0= +github.com/mcnijman/go-emailaddress v1.1.1/go.mod h1:5whZrhS8Xp5LxO8zOD35BC+b76kROtsh+dPomeRt/II= github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY= github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno= @@ -158,6 +160,8 @@ golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5y golang.org/x/crypto v0.7.0/go.mod h1:pYwdfH91IfpZVANVyUOhSIPZaFoJGxTFbZhFTx+dXZU= golang.org/x/crypto v0.17.0 h1:r8bRNjWL3GshPW3gkd+RpvzWrZAwPS49OmTGZ/uhM4k= golang.org/x/crypto v0.17.0/go.mod h1:gCAAfMLgwOJRpTjQ2zCCt2OcSfYMTeZVSRtQlPC7Nq4= +golang.org/x/crypto v0.18.0 h1:PGVlW0xEltQnzFZ55hkuX5+KLyrMYhHld1YHO4AKcdc= +golang.org/x/crypto v0.18.0/go.mod h1:R0j02AL6hcrfOiy9T4ZYp/rcWeMxM3L6QYxlOuEG1mg= golang.org/x/exp v0.0.0-20230905200255-921286631fa9 h1:GoHiUyI/Tp2nVkLI2mCxVkOjsbSXD66ic0XW0js0R9g= golang.org/x/exp v0.0.0-20230905200255-921286631fa9/go.mod h1:S2oDrQGGwySpoQPVqRShND87VCbxmc6bL1Yd2oYrm6k= golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= @@ -166,6 +170,7 @@ golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= golang.org/x/mod v0.9.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= golang.org/x/mod v0.12.0 h1:rmsUpXtvNzj340zd98LZ4KntptpfRHwpFOHG188oHXc= golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= +golang.org/x/net v0.0.0-20180911220305-26e67e76b6c3/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= golang.org/x/net v0.0.0-20210421230115-4e50805a0758/go.mod h1:72T/g9IO56b78aLF+1Kcs5dz7/ng1VjMUvfKvpfy+jM= @@ -175,6 +180,8 @@ golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= golang.org/x/net v0.8.0/go.mod h1:QVkue5JL9kW//ek3r6jTKnTFis1tRmNAW2P1shuFdJc= golang.org/x/net v0.19.0 h1:zTwKpTd2XuCqf8huc7Fo2iSy+4RHPd10s4KzeTnVr1c= golang.org/x/net v0.19.0/go.mod h1:CfAk/cbD4CthTvqiEl8NpboMuiuOYsAr/7NOjZJtv1U= +golang.org/x/net v0.20.0 h1:aCL9BSgETF1k+blQaYUBx9hJ9LOGP3gAVemcZlf1Kpo= +golang.org/x/net v0.20.0/go.mod h1:z8BVo6PvndSri0LbOE3hAn0apkU+1YvI6E70E9jsnvY= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= @@ -191,6 +198,8 @@ golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.13.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.15.0 h1:h48lPFYpsTvQJZF4EKyI4aLHaev3CxivZmv7yZig9pc= golang.org/x/sys v0.15.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.16.0 h1:xWw16ngr6ZMtmxDyKyIgsE93KNKz5HKmMa3b8ALHidU= +golang.org/x/sys v0.16.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= golang.org/x/term v0.3.0/go.mod h1:q750SLmJuPmVoN1blW3UFBPREJfb1KmY3vwxfr+nFDA= diff --git a/backend/src/auth/password.go b/backend/src/auth/password.go new file mode 100644 index 000000000..109980d02 --- /dev/null +++ b/backend/src/auth/password.go @@ -0,0 +1,119 @@ +package auth + +import ( + "crypto/rand" + "crypto/subtle" + "encoding/base64" + "errors" + "fmt" + "strings" + + "golang.org/x/crypto/argon2" +) + +type params struct { + memory uint32 + iterations uint32 + parallelism uint8 + saltLength uint32 + keyLength uint32 +} + +func ComputePasswordHash(password string) (*string, error) { + p := ¶ms{ + memory: 64 * 1024, + iterations: 3, + parallelism: 2, + saltLength: 16, + keyLength: 32, + } + + salt := make([]byte, p.saltLength) + + if _, err := rand.Read(salt); err != nil { + return nil, err + } + + hash := argon2.IDKey([]byte(password), + salt, + p.iterations, + p.memory, + p.parallelism, + p.keyLength, + ) + + b64Salt := base64.RawStdEncoding.EncodeToString(salt) + + b64Hash := base64.RawStdEncoding.EncodeToString(hash) + + encodedHash := fmt.Sprintf("$argon2id$v=%d$m=%d,t=%d,p=%d$%s$%s", argon2.Version, p.memory, p.iterations, p.parallelism, b64Salt, b64Hash) + + return &encodedHash, nil +} + +var ( + ErrInvalidHash = errors.New("the encoded hash is not in the correct format") + ErrIncompatibleVersion = errors.New("incompatible version of argon2") +) + +func ComparePasswordAndHash(password, encodedHash string) (bool, error) { + p, salt, hash, err := decodeHash(encodedHash) + + if err != nil { + return false, err + } + + otherHash := argon2.IDKey([]byte(password), salt, p.iterations, p.memory, p.parallelism, p.keyLength) + + if subtle.ConstantTimeCompare(hash, otherHash) == 1 { + return true, nil + } + + return false, nil +} + +func decodeHash(encodedHash string) (p *params, salt []byte, hash []byte, err error) { + vals := strings.Split(encodedHash, "$") + + if len(vals) != 6 { + return nil, nil, nil, ErrInvalidHash + } + + var version int + + _, err = fmt.Sscanf(vals[2], "v=%d", &version) + + if err != nil { + return nil, nil, nil, err + } + + if version != argon2.Version { + return nil, nil, nil, ErrIncompatibleVersion + } + + p = ¶ms{} + + _, err = fmt.Sscanf(vals[3], "m=%d,t=%d,p=%d", &p.memory, &p.iterations, &p.parallelism) + + if err != nil { + return nil, nil, nil, err + } + + salt, err = base64.RawStdEncoding.Strict().DecodeString(vals[4]) + + if err != nil { + return nil, nil, nil, err + } + + p.saltLength = uint32(len(salt)) + + hash, err = base64.RawStdEncoding.Strict().DecodeString(vals[5]) + + if err != nil { + return nil, nil, nil, err + } + + p.keyLength = uint32(len(hash)) + + return p, salt, hash, nil +} diff --git a/backend/src/config/config.go b/backend/src/config/config.go index 62197e9bc..9b3f85c67 100644 --- a/backend/src/config/config.go +++ b/backend/src/config/config.go @@ -116,7 +116,7 @@ func GetConfiguration(path string) (Settings, error) { superUserPrefix := fmt.Sprintf("%sSUPERUSER__", appPrefix) portStr := os.Getenv(fmt.Sprintf("%sPORT", appPrefix)) - portInt, err := strconv.Atoi(portStr) + portInt, err := strconv.ParseUint(portStr, 10, 16) if err != nil { return Settings{}, fmt.Errorf("failed to parse port: %w", err) @@ -124,7 +124,7 @@ func GetConfiguration(path string) (Settings, error) { return Settings{ Application: ApplicationSettings{ - Port: prodSettings.Application.Port, + Port: uint16(portInt), Host: prodSettings.Application.Host, BaseUrl: os.Getenv(fmt.Sprintf("%sBASE_URL", applicationPrefix)), }, diff --git a/backend/src/controllers/category.go b/backend/src/controllers/category.go index 321b83621..695e5d53e 100644 --- a/backend/src/controllers/category.go +++ b/backend/src/controllers/category.go @@ -3,6 +3,7 @@ package controllers import ( "github.com/GenerateNU/sac/backend/src/models" "github.com/GenerateNU/sac/backend/src/services" + "github.com/GenerateNU/sac/backend/src/utilities" "github.com/gofiber/fiber/v2" ) @@ -29,20 +30,15 @@ func NewCategoryController(categoryService services.CategoryServiceInterface) *C // @Failure 500 {string} string "failed to create category" // @Router /api/v1/category/ [post] func (t *CategoryController) CreateCategory(c *fiber.Ctx) error { - var categoryBody models.CreateCategoryRequestBody + var categoryBody models.CategoryRequestBody if err := c.BodyParser(&categoryBody); err != nil { - return fiber.NewError(fiber.StatusBadRequest, "failed to process the request") + return utilities.Error(c, fiber.StatusBadRequest, "failed to process the request") } - category := models.Category{ - Name: categoryBody.Name, - } - - newCategory, err := t.categoryService.CreateCategory(category) - + newCategory, err := t.categoryService.CreateCategory(categoryBody) if err != nil { - return err + return utilities.Error(c, fiber.StatusInternalServerError, err.Error()) } return c.Status(fiber.StatusCreated).JSON(newCategory) diff --git a/backend/src/controllers/tag.go b/backend/src/controllers/tag.go new file mode 100644 index 000000000..31222e98a --- /dev/null +++ b/backend/src/controllers/tag.go @@ -0,0 +1,119 @@ +package controllers + +import ( + "github.com/GenerateNU/sac/backend/src/models" + "github.com/GenerateNU/sac/backend/src/services" + "github.com/GenerateNU/sac/backend/src/utilities" + + "github.com/gofiber/fiber/v2" +) + +type TagController struct { + tagService services.TagServiceInterface +} + +func NewTagController(tagService services.TagServiceInterface) *TagController { + return &TagController{tagService: tagService} +} + +// CreateTag godoc +// +// @Summary Creates a tag +// @Description Creates a tag +// @ID create-tag +// @Tags tag +// @Accept json +// @Produce json +// @Success 201 {object} models.Tag +// @Failure 400 {string} string "failed to process the request" +// @Failure 400 {string} string "failed to validate the data" +// @Failure 500 {string} string "failed to create tag" +// @Router /api/v1/tags/ [post] +func (t *TagController) CreateTag(c *fiber.Ctx) error { + var tagBody models.TagRequestBody + + if err := c.BodyParser(&tagBody); err != nil { + return utilities.Error(c, fiber.StatusBadRequest, "failed to process the request") + } + + dbTag, err := t.tagService.CreateTag(tagBody) + if err != nil { + return utilities.Error(c, fiber.StatusInternalServerError, err.Error()) + } + + return c.Status(fiber.StatusCreated).JSON(&dbTag) +} + +// GetTag godoc +// +// @Summary Gets a tag +// @Description Returns a tag +// @ID get-tag +// @Tags tag +// @Produce json +// @Param id path int true "Tag ID" +// @Success 200 {object} models.Tag +// @Failure 400 {string} string "failed to validate id" +// @Failure 404 {string} string "faied to find tag" +// @Failure 500 {string} string "failed to retrieve tag" +// @Router /api/v1/tags/{id} [get] +func (t *TagController) GetTag(c *fiber.Ctx) error { + tag, err := t.tagService.GetTag(c.Params("id")) + if err != nil { + return utilities.Error(c, fiber.StatusInternalServerError, err.Error()) + } + + return c.Status(fiber.StatusOK).JSON(&tag) +} + +// UpdateTag godoc +// +// @Summary Updates a tag +// @Description Updates a tag +// @ID update-tag +// @Tags tag +// @Accept json +// @Produce json +// @Param id path int true "Tag ID" +// @Success 200 {object} models.Tag +// @Failure 400 {string} string "failed to process the request" +// @Failure 400 {string} string "failed to validate id" +// @Failure 400 {string} string "failed to validate the data" +// @Failure 404 {string} string "failed to find tag" +// @Failure 500 {string} string "failed to update tag" +// @Router /api/v1/tags/{id} [patch] +func (t *TagController) UpdateTag(c *fiber.Ctx) error { + var tagBody models.TagRequestBody + + if err := c.BodyParser(&tagBody); err != nil { + return utilities.Error(c, fiber.StatusBadRequest, "failed to process the request") + } + + tag, err := t.tagService.UpdateTag(c.Params("id"), tagBody) + if err != nil { + return utilities.Error(c, fiber.StatusInternalServerError, err.Error()) + } + + return c.Status(fiber.StatusOK).JSON(&tag) +} + +// DeleteTag godoc +// +// @Summary Deletes a tag +// @Description Deletes a tag +// @ID delete-tag +// @Tags tag +// @Param id path int true "Tag ID" +// @Success 204 {string} string "no content" +// @Failure 400 {string} string "failed to validate id" +// @Failure 404 {string} string "tag not found" +// @Failure 500 {string} string "failed to delete tag" +// @Router /api/v1/tags/{id} [delete] +func (t *TagController) DeleteTag(c *fiber.Ctx) error { + err := t.tagService.DeleteTag(c.Params("id")) + if err != nil { + return utilities.Error(c, fiber.StatusInternalServerError, err.Error()) + } + + return c.SendStatus(fiber.StatusNoContent) +} \ No newline at end of file diff --git a/backend/src/controllers/user.go b/backend/src/controllers/user.go index d3a1cedfa..612abd8b9 100644 --- a/backend/src/controllers/user.go +++ b/backend/src/controllers/user.go @@ -1,6 +1,7 @@ package controllers import ( + "github.com/GenerateNU/sac/backend/src/models" "github.com/GenerateNU/sac/backend/src/services" "github.com/GenerateNU/sac/backend/src/models" "github.com/gofiber/fiber/v2" @@ -26,9 +27,8 @@ func NewUserController(userService services.UserServiceInterface) *UserControlle // @Router /api/v1/users/ [get] func (u *UserController) GetAllUsers(c *fiber.Ctx) error { users, err := u.userService.GetAllUsers() - if err != nil { - return err + return utilities.Error(c, fiber.StatusInternalServerError, err.Error()) } return c.Status(fiber.StatusOK).JSON(users) @@ -59,4 +59,54 @@ func (u *UserController) CreateUser(c *fiber.Ctx) error { } return c.Status(fiber.StatusCreated).JSON(user) +// GetUser godoc +// +// @Summary Gets a user +// @Description Returns a user +// @ID get-user-by-id +// @Tags user +// @Produce json +// @Param id path string true "User ID" +// @Success 200 {object} models.User +// @Failure 404 {string} string "user not found" +// @Failure 400 {string} string "failed to validate id" +// @Failure 500 {string} string "failed to get user" +// @Router /api/v1/users/:id [get] +func (u *UserController) GetUser(c *fiber.Ctx) error { + user, err := u.userService.GetUser(c.Params("id")) + if err != nil { + return utilities.Error(c, fiber.StatusInternalServerError, err.Error()) + } + + return c.Status(fiber.StatusOK).JSON(user) +} + +// UpdateUser godoc +// +// @Summary Updates a user +// @Description Updates a user +// @ID update-user-by-id +// @Tags user +// @Produce json +// @Success 200 {object} models.User +// @Failure 404 {string} string "user not found" +// @Failure 400 {string} string "invalid request body" +// @Failure 400 {string} string "failed to validate id" +// @Failure 500 {string} string "database error" +// @Failure 500 {string} string "failed to hash password" +// @Router /api/v1/users/:id [patch] +func (u *UserController) UpdateUser(c *fiber.Ctx) error { + var user models.UserRequestBody + + if err := c.BodyParser(&user); err != nil { + return utilities.Error(c, fiber.StatusBadRequest, "invalid request body") + } + + updatedUser, err := u.userService.UpdateUser(c.Params("id"), user) + if err != nil { + return utilities.Error(c, fiber.StatusInternalServerError, err.Error()) + } + + // Return the updated user details + return c.Status(fiber.StatusOK).JSON(updatedUser) } diff --git a/backend/src/database/db.go b/backend/src/database/db.go index 7e86de0cf..4b0fba3ad 100644 --- a/backend/src/database/db.go +++ b/backend/src/database/db.go @@ -1,6 +1,7 @@ package database import ( + "github.com/GenerateNU/sac/backend/src/auth" "github.com/GenerateNU/sac/backend/src/config" "github.com/GenerateNU/sac/backend/src/models" @@ -55,17 +56,35 @@ func MigrateDB(settings config.Settings, db *gorm.DB) error { return err } + // Check if the database already has a super user + var superUser models.User + if err := db.Where("role = ?", models.Super).First(&superUser).Error; err != nil { + if err := createSuperUser(settings, db); err != nil { + return err + } + } + + return nil +} + +func createSuperUser(settings config.Settings, db *gorm.DB) error { tx := db.Begin() if err := tx.Error; err != nil { return err } + passwordHash, err := auth.ComputePasswordHash(settings.SuperUser.Password) + + if err != nil { + return err + } + superUser := models.User{ Role: models.Super, NUID: "000000000", Email: "generatesac@gmail.com", - PasswordHash: settings.SuperUser.Password, // TODO: hash this + PasswordHash: *passwordHash, FirstName: "SAC", LastName: "Super", College: models.KCCS, @@ -74,7 +93,13 @@ func MigrateDB(settings config.Settings, db *gorm.DB) error { var user models.User - if err := tx.Where("nuid = ?", superUser.NUID).First(&user).Error; err != nil { + if err := db.Where("nuid = ?", superUser.NUID).First(&user).Error; err != nil { + tx := db.Begin() + + if err := tx.Error; err != nil { + return err + } + if err := tx.Create(&superUser).Error; err != nil { tx.Rollback() return err @@ -99,7 +124,9 @@ func MigrateDB(settings config.Settings, db *gorm.DB) error { tx.Rollback() return err } - } - return tx.Commit().Error + return tx.Commit().Error + + } + return nil } diff --git a/backend/src/docs/docs.go b/backend/src/docs/docs.go index 8e021b92b..5eec86876 100644 --- a/backend/src/docs/docs.go +++ b/backend/src/docs/docs.go @@ -18,29 +18,244 @@ const docTemplate = `{ "host": "{{.Host}}", "basePath": "{{.BasePath}}", "paths": { + "/api/v1/category/": { + "post": { + "description": "Creates a category that is used to group tags", + "produces": [ + "application/json" + ], + "tags": [ + "category" + ], + "summary": "Create a category", + "operationId": "create-category", + "responses": { + "201": { + "description": "Created", + "schema": { + "$ref": "#/definitions/models.Category" + } + }, + "400": { + "description": "category with that name already exists", + "schema": { + "type": "string" + } + }, + "500": { + "description": "failed to create category", + "schema": { + "type": "string" + } + } + } + } + }, + "/api/v1/tags/": { + "post": { + "description": "Creates a tag", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "tag" + ], + "summary": "Creates a tag", + "operationId": "create-tag", + "responses": { + "201": { + "description": "Created", + "schema": { + "$ref": "#/definitions/models.Tag" + } + }, + "400": { + "description": "failed to validate the data", + "schema": { + "type": "string" + } + }, + "500": { + "description": "failed to create tag", + "schema": { + "type": "string" + } + } + } + } + }, + "/api/v1/tags/{id}": { + "get": { + "description": "Returns a tag", + "produces": [ + "application/json" + ], + "tags": [ + "tag" + ], + "summary": "Gets a tag", + "operationId": "get-tag", + "parameters": [ + { + "type": "integer", + "description": "Tag ID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/models.Tag" + } + }, + "400": { + "description": "failed to validate id", + "schema": { + "type": "string" + } + }, + "404": { + "description": "faied to find tag", + "schema": { + "type": "string" + } + }, + "500": { + "description": "failed to retrieve tag", + "schema": { + "type": "string" + } + } + } + }, + "delete": { + "description": "Deletes a tag", + "tags": [ + "tag" + ], + "summary": "Deletes a tag", + "operationId": "delete-tag", + "parameters": [ + { + "type": "integer", + "description": "Tag ID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "204": { + "description": "No Content" + }, + "400": { + "description": "failed to validate id", + "schema": { + "type": "string" + } + }, + "404": { + "description": "failed to find tag", + "schema": { + "type": "string" + } + }, + "500": { + "description": "failed to delete tag", + "schema": { + "type": "string" + } + } + } + }, + "patch": { + "description": "Updates a tag", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "tag" + ], + "summary": "Updates a tag", + "operationId": "update-tag", + "parameters": [ + { + "type": "integer", + "description": "Tag ID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/models.Tag" + } + }, + "400": { + "description": "failed to validate the data", + "schema": { + "type": "string" + } + }, + "404": { + "description": "failed to find tag", + "schema": { + "type": "string" + } + }, + "500": { + "description": "failed to update tag", + "schema": { + "type": "string" + } + } + } + } + }, "/api/v1/users/": { "get": { - "description": "Returns all users", + "description": "Returns specific user", "produces": [ "application/json" ], "tags": [ "user" ], - "summary": "Gets all users", - "operationId": "get-all-users", + "summary": "Gets specific user", + "operationId": "get-user", "responses": { "200": { "description": "OK", "schema": { - "type": "array", - "items": { - "$ref": "#/definitions/models.User" - } + "$ref": "#/definitions/models.User" + } + }, + "400": { + "description": "failed to validate id", + "schema": { + "type": "string" + } + }, + "404": { + "description": "failed to find user", + "schema": { + "type": "string" } }, "500": { - "description": "Failed to fetch users", + "description": "Internal Server Error", "schema": { "type": "string" } @@ -50,118 +265,24 @@ const docTemplate = `{ } }, "definitions": { - "models.Club": { + "models.Category": { "type": "object", "required": [ - "application_link", - "club_members", - "contacts", - "description", - "is_recruiting", - "name", - "num_members", - "point_of_contacts", - "preview", - "recruitment_cycle", - "recruitment_type" + "category_name" ], "properties": { - "application_link": { - "type": "string" - }, - "club_followers": { - "type": "array", - "items": { - "$ref": "#/definitions/models.User" - } - }, - "club_intended_applicants": { - "type": "array", - "items": { - "$ref": "#/definitions/models.User" - } - }, - "club_members": { - "description": "User", - "type": "array", - "items": { - "$ref": "#/definitions/models.User" - } - }, - "comments": { - "type": "array", - "items": { - "$ref": "#/definitions/models.Comment" - } - }, - "contacts": { - "type": "array", - "items": { - "$ref": "#/definitions/models.Contact" - } + "category_name": { + "type": "string", + "maxLength": 255 }, "created_at": { "type": "string", "example": "2023-09-20T16:34:50Z" }, - "description": { - "description": "MongoDB URI", - "type": "string" - }, - "events": { - "description": "Event", - "type": "array", - "items": { - "$ref": "#/definitions/models.Event" - } - }, "id": { "type": "integer", "example": 1 }, - "is_recruiting": { - "type": "boolean" - }, - "logo": { - "description": "S3 URI", - "type": "string" - }, - "name": { - "type": "string" - }, - "notifications": { - "type": "array", - "items": { - "$ref": "#/definitions/models.Notification" - } - }, - "num_members": { - "type": "integer" - }, - "parent_club": { - "type": "integer" - }, - "point_of_contacts": { - "type": "array", - "items": { - "$ref": "#/definitions/models.PointOfContact" - } - }, - "preview": { - "type": "string" - }, - "recruitment_cycle": { - "$ref": "#/definitions/models.RecruitmentCycle" - }, - "recruitment_type": { - "$ref": "#/definitions/models.RecruitmentType" - }, - "tags": { - "type": "array", - "items": { - "$ref": "#/definitions/models.Tag" - } - }, "updated_at": { "type": "string", "example": "2023-09-20T16:34:50Z" @@ -204,361 +325,38 @@ const docTemplate = `{ "CSSH" ] }, - "models.Comment": { - "type": "object", - "required": [ - "question" - ], - "properties": { - "answer": { - "type": "string" - }, - "answered_by": { - "$ref": "#/definitions/models.User" - }, - "asked_by": { - "$ref": "#/definitions/models.User" - }, - "asked_by_id": { - "type": "integer" - }, - "club": { - "$ref": "#/definitions/models.Club" - }, - "club_id": { - "type": "integer" - }, - "created_at": { - "type": "string", - "example": "2023-09-20T16:34:50Z" - }, - "id": { - "type": "integer", - "example": 1 - }, - "num_found_helpful": { - "type": "integer" - }, - "question": { - "type": "string" - }, - "updated_at": { - "type": "string", - "example": "2023-09-20T16:34:50Z" - }, - "user_id": { - "type": "integer" - } - } - }, - "models.Contact": { - "type": "object", - "required": [ - "content", - "type" - ], - "properties": { - "content": { - "description": "media URI", - "type": "string" - }, - "created_at": { - "type": "string", - "example": "2023-09-20T16:34:50Z" - }, - "id": { - "type": "integer", - "example": 1 - }, - "type": { - "$ref": "#/definitions/models.Media" - }, - "updated_at": { - "type": "string", - "example": "2023-09-20T16:34:50Z" - } - } - }, - "models.Event": { - "type": "object", - "required": [ - "clubs", - "content", - "end_time", - "event_type", - "location", - "name", - "preview", - "start_time" - ], - "properties": { - "clubs": { - "type": "array", - "items": { - "$ref": "#/definitions/models.Club" - } - }, - "content": { - "type": "string" - }, - "created_at": { - "type": "string", - "example": "2023-09-20T16:34:50Z" - }, - "end_time": { - "type": "string" - }, - "event_type": { - "$ref": "#/definitions/models.EventType" - }, - "id": { - "type": "integer", - "example": 1 - }, - "location": { - "type": "string" - }, - "name": { - "type": "string" - }, - "notifications": { - "type": "array", - "items": { - "$ref": "#/definitions/models.Notification" - } - }, - "preview": { - "type": "string" - }, - "start_time": { - "type": "string" - }, - "tags": { - "type": "array", - "items": { - "$ref": "#/definitions/models.Tag" - } - }, - "updated_at": { - "type": "string", - "example": "2023-09-20T16:34:50Z" - }, - "user_rsvps": { - "type": "array", - "items": { - "$ref": "#/definitions/models.User" - } - }, - "user_waitlists": { - "type": "array", - "items": { - "$ref": "#/definitions/models.User" - } - } - } - }, - "models.EventType": { - "type": "string", - "enum": [ - "open", - "membersOnly" - ], - "x-enum-varnames": [ - "Open", - "MembersOnly" - ] - }, - "models.Media": { - "type": "string", - "enum": [ - "facebook", - "instagram", - "twitter", - "linkedin", - "youtube", - "github", - "custom" - ], - "x-enum-varnames": [ - "Facebook", - "Instagram", - "Twitter", - "LinkedIn", - "YouTube", - "GitHub", - "Custom" - ] - }, - "models.Notification": { - "type": "object", - "required": [ - "content", - "deep_link", - "icon", - "reference_id", - "reference_type", - "send_at", - "title" - ], - "properties": { - "content": { - "type": "string" - }, - "created_at": { - "type": "string", - "example": "2023-09-20T16:34:50Z" - }, - "deep_link": { - "type": "string" - }, - "icon": { - "description": "S3 URI", - "type": "string" - }, - "id": { - "type": "integer", - "example": 1 - }, - "reference_id": { - "type": "integer" - }, - "reference_type": { - "$ref": "#/definitions/models.NotificationType" - }, - "send_at": { - "type": "string" - }, - "title": { - "type": "string" - }, - "updated_at": { - "type": "string", - "example": "2023-09-20T16:34:50Z" - } - } - }, - "models.NotificationType": { - "type": "string", - "enum": [ - "event", - "club" - ], - "x-enum-varnames": [ - "EventNotification", - "ClubNotification" - ] - }, - "models.PointOfContact": { - "type": "object", - "required": [ - "email", - "name", - "position" - ], - "properties": { - "created_at": { - "type": "string", - "example": "2023-09-20T16:34:50Z" - }, - "email": { - "type": "string" - }, - "id": { - "type": "integer", - "example": 1 - }, - "name": { - "type": "string" - }, - "photo": { - "description": "S3 URI, fallback to default logo if null", - "type": "string" - }, - "position": { - "type": "string" - }, - "updated_at": { - "type": "string", - "example": "2023-09-20T16:34:50Z" - } - } - }, - "models.RecruitmentCycle": { - "type": "string", - "enum": [ - "fall", - "spring", - "fallSpring", - "always" - ], - "x-enum-varnames": [ - "Fall", - "Spring", - "FallSpring", - "Always" - ] - }, - "models.RecruitmentType": { - "type": "string", - "enum": [ - "unrestricted", - "tryout", - "application" - ], - "x-enum-varnames": [ - "Unrestricted", - "Tryout", - "Application" - ] - }, "models.Tag": { "type": "object", "required": [ + "category_id", "name" ], "properties": { "category_id": { - "type": "integer" - }, - "clubs": { - "type": "array", - "items": { - "$ref": "#/definitions/models.Club" - } + "type": "integer", + "minimum": 1 }, "created_at": { "type": "string", "example": "2023-09-20T16:34:50Z" }, - "events": { - "type": "array", - "items": { - "$ref": "#/definitions/models.Event" - } - }, "id": { "type": "integer", "example": 1 }, "name": { - "type": "string" + "type": "string", + "maxLength": 255 }, "updated_at": { "type": "string", "example": "2023-09-20T16:34:50Z" - }, - "users": { - "type": "array", - "items": { - "$ref": "#/definitions/models.User" - } } } }, "models.User": { "type": "object", "required": [ - "club_members", "college", "email", "first_name", @@ -568,86 +366,57 @@ const docTemplate = `{ "year" ], "properties": { - "answered": { - "type": "array", - "items": { - "$ref": "#/definitions/models.Comment" - } - }, - "club_followers": { - "type": "array", - "items": { - "$ref": "#/definitions/models.Club" - } - }, - "club_intended_applicants": { - "type": "array", - "items": { - "$ref": "#/definitions/models.Club" - } - }, - "club_members": { - "type": "array", - "items": { - "$ref": "#/definitions/models.Club" - } - }, "college": { - "$ref": "#/definitions/models.College" - }, - "comments": { - "type": "array", - "items": { - "$ref": "#/definitions/models.Comment" - } + "maxLength": 255, + "allOf": [ + { + "$ref": "#/definitions/models.College" + } + ] }, "created_at": { "type": "string", "example": "2023-09-20T16:34:50Z" }, "email": { - "type": "string" - }, - "event_rsvps": { - "type": "array", - "items": { - "$ref": "#/definitions/models.Event" - } - }, - "event_waitlist": { - "type": "array", - "items": { - "$ref": "#/definitions/models.Event" - } + "type": "string", + "maxLength": 255 }, "first_name": { - "type": "string" + "type": "string", + "maxLength": 255 }, "id": { "type": "integer", "example": 1 }, "last_name": { - "type": "string" + "type": "string", + "maxLength": 255 }, "nuid": { "type": "string" }, - "tags": { - "type": "array", - "items": { - "$ref": "#/definitions/models.Tag" - } - }, "updated_at": { "type": "string", "example": "2023-09-20T16:34:50Z" }, "user_role": { - "$ref": "#/definitions/models.UserRole" + "maxLength": 255, + "allOf": [ + { + "$ref": "#/definitions/models.UserRole" + } + ] }, "year": { - "$ref": "#/definitions/models.Year" + "maximum": 6, + "minimum": 1, + "allOf": [ + { + "$ref": "#/definitions/models.Year" + } + ] } } }, @@ -690,7 +459,7 @@ const docTemplate = `{ var SwaggerInfo = &swag.Spec{ Version: "1.0", Host: "127.0.0.1:8080", - BasePath: "/", + BasePath: "/api/v1", Schemes: []string{}, Title: "SAC API", Description: "Backend Server for SAC App", diff --git a/backend/src/docs/swagger.json b/backend/src/docs/swagger.json index 0e6b0c012..39439eeed 100644 --- a/backend/src/docs/swagger.json +++ b/backend/src/docs/swagger.json @@ -10,31 +10,246 @@ "version": "1.0" }, "host": "127.0.0.1:8080", - "basePath": "/", + "basePath": "/api/v1", "paths": { + "/api/v1/category/": { + "post": { + "description": "Creates a category that is used to group tags", + "produces": [ + "application/json" + ], + "tags": [ + "category" + ], + "summary": "Create a category", + "operationId": "create-category", + "responses": { + "201": { + "description": "Created", + "schema": { + "$ref": "#/definitions/models.Category" + } + }, + "400": { + "description": "category with that name already exists", + "schema": { + "type": "string" + } + }, + "500": { + "description": "failed to create category", + "schema": { + "type": "string" + } + } + } + } + }, + "/api/v1/tags/": { + "post": { + "description": "Creates a tag", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "tag" + ], + "summary": "Creates a tag", + "operationId": "create-tag", + "responses": { + "201": { + "description": "Created", + "schema": { + "$ref": "#/definitions/models.Tag" + } + }, + "400": { + "description": "failed to validate the data", + "schema": { + "type": "string" + } + }, + "500": { + "description": "failed to create tag", + "schema": { + "type": "string" + } + } + } + } + }, + "/api/v1/tags/{id}": { + "get": { + "description": "Returns a tag", + "produces": [ + "application/json" + ], + "tags": [ + "tag" + ], + "summary": "Gets a tag", + "operationId": "get-tag", + "parameters": [ + { + "type": "integer", + "description": "Tag ID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/models.Tag" + } + }, + "400": { + "description": "failed to validate id", + "schema": { + "type": "string" + } + }, + "404": { + "description": "faied to find tag", + "schema": { + "type": "string" + } + }, + "500": { + "description": "failed to retrieve tag", + "schema": { + "type": "string" + } + } + } + }, + "delete": { + "description": "Deletes a tag", + "tags": [ + "tag" + ], + "summary": "Deletes a tag", + "operationId": "delete-tag", + "parameters": [ + { + "type": "integer", + "description": "Tag ID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "204": { + "description": "No Content" + }, + "400": { + "description": "failed to validate id", + "schema": { + "type": "string" + } + }, + "404": { + "description": "failed to find tag", + "schema": { + "type": "string" + } + }, + "500": { + "description": "failed to delete tag", + "schema": { + "type": "string" + } + } + } + }, + "patch": { + "description": "Updates a tag", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "tag" + ], + "summary": "Updates a tag", + "operationId": "update-tag", + "parameters": [ + { + "type": "integer", + "description": "Tag ID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/models.Tag" + } + }, + "400": { + "description": "failed to validate the data", + "schema": { + "type": "string" + } + }, + "404": { + "description": "failed to find tag", + "schema": { + "type": "string" + } + }, + "500": { + "description": "failed to update tag", + "schema": { + "type": "string" + } + } + } + } + }, "/api/v1/users/": { "get": { - "description": "Returns all users", + "description": "Returns specific user", "produces": [ "application/json" ], "tags": [ "user" ], - "summary": "Gets all users", - "operationId": "get-all-users", + "summary": "Gets specific user", + "operationId": "get-user", "responses": { "200": { "description": "OK", "schema": { - "type": "array", - "items": { - "$ref": "#/definitions/models.User" - } + "$ref": "#/definitions/models.User" + } + }, + "400": { + "description": "failed to validate id", + "schema": { + "type": "string" + } + }, + "404": { + "description": "failed to find user", + "schema": { + "type": "string" } }, "500": { - "description": "Failed to fetch users", + "description": "Internal Server Error", "schema": { "type": "string" } @@ -44,118 +259,24 @@ } }, "definitions": { - "models.Club": { + "models.Category": { "type": "object", "required": [ - "application_link", - "club_members", - "contacts", - "description", - "is_recruiting", - "name", - "num_members", - "point_of_contacts", - "preview", - "recruitment_cycle", - "recruitment_type" + "category_name" ], "properties": { - "application_link": { - "type": "string" - }, - "club_followers": { - "type": "array", - "items": { - "$ref": "#/definitions/models.User" - } - }, - "club_intended_applicants": { - "type": "array", - "items": { - "$ref": "#/definitions/models.User" - } - }, - "club_members": { - "description": "User", - "type": "array", - "items": { - "$ref": "#/definitions/models.User" - } - }, - "comments": { - "type": "array", - "items": { - "$ref": "#/definitions/models.Comment" - } - }, - "contacts": { - "type": "array", - "items": { - "$ref": "#/definitions/models.Contact" - } + "category_name": { + "type": "string", + "maxLength": 255 }, "created_at": { "type": "string", "example": "2023-09-20T16:34:50Z" }, - "description": { - "description": "MongoDB URI", - "type": "string" - }, - "events": { - "description": "Event", - "type": "array", - "items": { - "$ref": "#/definitions/models.Event" - } - }, "id": { "type": "integer", "example": 1 }, - "is_recruiting": { - "type": "boolean" - }, - "logo": { - "description": "S3 URI", - "type": "string" - }, - "name": { - "type": "string" - }, - "notifications": { - "type": "array", - "items": { - "$ref": "#/definitions/models.Notification" - } - }, - "num_members": { - "type": "integer" - }, - "parent_club": { - "type": "integer" - }, - "point_of_contacts": { - "type": "array", - "items": { - "$ref": "#/definitions/models.PointOfContact" - } - }, - "preview": { - "type": "string" - }, - "recruitment_cycle": { - "$ref": "#/definitions/models.RecruitmentCycle" - }, - "recruitment_type": { - "$ref": "#/definitions/models.RecruitmentType" - }, - "tags": { - "type": "array", - "items": { - "$ref": "#/definitions/models.Tag" - } - }, "updated_at": { "type": "string", "example": "2023-09-20T16:34:50Z" @@ -198,361 +319,38 @@ "CSSH" ] }, - "models.Comment": { - "type": "object", - "required": [ - "question" - ], - "properties": { - "answer": { - "type": "string" - }, - "answered_by": { - "$ref": "#/definitions/models.User" - }, - "asked_by": { - "$ref": "#/definitions/models.User" - }, - "asked_by_id": { - "type": "integer" - }, - "club": { - "$ref": "#/definitions/models.Club" - }, - "club_id": { - "type": "integer" - }, - "created_at": { - "type": "string", - "example": "2023-09-20T16:34:50Z" - }, - "id": { - "type": "integer", - "example": 1 - }, - "num_found_helpful": { - "type": "integer" - }, - "question": { - "type": "string" - }, - "updated_at": { - "type": "string", - "example": "2023-09-20T16:34:50Z" - }, - "user_id": { - "type": "integer" - } - } - }, - "models.Contact": { - "type": "object", - "required": [ - "content", - "type" - ], - "properties": { - "content": { - "description": "media URI", - "type": "string" - }, - "created_at": { - "type": "string", - "example": "2023-09-20T16:34:50Z" - }, - "id": { - "type": "integer", - "example": 1 - }, - "type": { - "$ref": "#/definitions/models.Media" - }, - "updated_at": { - "type": "string", - "example": "2023-09-20T16:34:50Z" - } - } - }, - "models.Event": { - "type": "object", - "required": [ - "clubs", - "content", - "end_time", - "event_type", - "location", - "name", - "preview", - "start_time" - ], - "properties": { - "clubs": { - "type": "array", - "items": { - "$ref": "#/definitions/models.Club" - } - }, - "content": { - "type": "string" - }, - "created_at": { - "type": "string", - "example": "2023-09-20T16:34:50Z" - }, - "end_time": { - "type": "string" - }, - "event_type": { - "$ref": "#/definitions/models.EventType" - }, - "id": { - "type": "integer", - "example": 1 - }, - "location": { - "type": "string" - }, - "name": { - "type": "string" - }, - "notifications": { - "type": "array", - "items": { - "$ref": "#/definitions/models.Notification" - } - }, - "preview": { - "type": "string" - }, - "start_time": { - "type": "string" - }, - "tags": { - "type": "array", - "items": { - "$ref": "#/definitions/models.Tag" - } - }, - "updated_at": { - "type": "string", - "example": "2023-09-20T16:34:50Z" - }, - "user_rsvps": { - "type": "array", - "items": { - "$ref": "#/definitions/models.User" - } - }, - "user_waitlists": { - "type": "array", - "items": { - "$ref": "#/definitions/models.User" - } - } - } - }, - "models.EventType": { - "type": "string", - "enum": [ - "open", - "membersOnly" - ], - "x-enum-varnames": [ - "Open", - "MembersOnly" - ] - }, - "models.Media": { - "type": "string", - "enum": [ - "facebook", - "instagram", - "twitter", - "linkedin", - "youtube", - "github", - "custom" - ], - "x-enum-varnames": [ - "Facebook", - "Instagram", - "Twitter", - "LinkedIn", - "YouTube", - "GitHub", - "Custom" - ] - }, - "models.Notification": { - "type": "object", - "required": [ - "content", - "deep_link", - "icon", - "reference_id", - "reference_type", - "send_at", - "title" - ], - "properties": { - "content": { - "type": "string" - }, - "created_at": { - "type": "string", - "example": "2023-09-20T16:34:50Z" - }, - "deep_link": { - "type": "string" - }, - "icon": { - "description": "S3 URI", - "type": "string" - }, - "id": { - "type": "integer", - "example": 1 - }, - "reference_id": { - "type": "integer" - }, - "reference_type": { - "$ref": "#/definitions/models.NotificationType" - }, - "send_at": { - "type": "string" - }, - "title": { - "type": "string" - }, - "updated_at": { - "type": "string", - "example": "2023-09-20T16:34:50Z" - } - } - }, - "models.NotificationType": { - "type": "string", - "enum": [ - "event", - "club" - ], - "x-enum-varnames": [ - "EventNotification", - "ClubNotification" - ] - }, - "models.PointOfContact": { - "type": "object", - "required": [ - "email", - "name", - "position" - ], - "properties": { - "created_at": { - "type": "string", - "example": "2023-09-20T16:34:50Z" - }, - "email": { - "type": "string" - }, - "id": { - "type": "integer", - "example": 1 - }, - "name": { - "type": "string" - }, - "photo": { - "description": "S3 URI, fallback to default logo if null", - "type": "string" - }, - "position": { - "type": "string" - }, - "updated_at": { - "type": "string", - "example": "2023-09-20T16:34:50Z" - } - } - }, - "models.RecruitmentCycle": { - "type": "string", - "enum": [ - "fall", - "spring", - "fallSpring", - "always" - ], - "x-enum-varnames": [ - "Fall", - "Spring", - "FallSpring", - "Always" - ] - }, - "models.RecruitmentType": { - "type": "string", - "enum": [ - "unrestricted", - "tryout", - "application" - ], - "x-enum-varnames": [ - "Unrestricted", - "Tryout", - "Application" - ] - }, "models.Tag": { "type": "object", "required": [ + "category_id", "name" ], "properties": { "category_id": { - "type": "integer" - }, - "clubs": { - "type": "array", - "items": { - "$ref": "#/definitions/models.Club" - } + "type": "integer", + "minimum": 1 }, "created_at": { "type": "string", "example": "2023-09-20T16:34:50Z" }, - "events": { - "type": "array", - "items": { - "$ref": "#/definitions/models.Event" - } - }, "id": { "type": "integer", "example": 1 }, "name": { - "type": "string" + "type": "string", + "maxLength": 255 }, "updated_at": { "type": "string", "example": "2023-09-20T16:34:50Z" - }, - "users": { - "type": "array", - "items": { - "$ref": "#/definitions/models.User" - } } } }, "models.User": { "type": "object", "required": [ - "club_members", "college", "email", "first_name", @@ -562,86 +360,57 @@ "year" ], "properties": { - "answered": { - "type": "array", - "items": { - "$ref": "#/definitions/models.Comment" - } - }, - "club_followers": { - "type": "array", - "items": { - "$ref": "#/definitions/models.Club" - } - }, - "club_intended_applicants": { - "type": "array", - "items": { - "$ref": "#/definitions/models.Club" - } - }, - "club_members": { - "type": "array", - "items": { - "$ref": "#/definitions/models.Club" - } - }, "college": { - "$ref": "#/definitions/models.College" - }, - "comments": { - "type": "array", - "items": { - "$ref": "#/definitions/models.Comment" - } + "maxLength": 255, + "allOf": [ + { + "$ref": "#/definitions/models.College" + } + ] }, "created_at": { "type": "string", "example": "2023-09-20T16:34:50Z" }, "email": { - "type": "string" - }, - "event_rsvps": { - "type": "array", - "items": { - "$ref": "#/definitions/models.Event" - } - }, - "event_waitlist": { - "type": "array", - "items": { - "$ref": "#/definitions/models.Event" - } + "type": "string", + "maxLength": 255 }, "first_name": { - "type": "string" + "type": "string", + "maxLength": 255 }, "id": { "type": "integer", "example": 1 }, "last_name": { - "type": "string" + "type": "string", + "maxLength": 255 }, "nuid": { "type": "string" }, - "tags": { - "type": "array", - "items": { - "$ref": "#/definitions/models.Tag" - } - }, "updated_at": { "type": "string", "example": "2023-09-20T16:34:50Z" }, "user_role": { - "$ref": "#/definitions/models.UserRole" + "maxLength": 255, + "allOf": [ + { + "$ref": "#/definitions/models.UserRole" + } + ] }, "year": { - "$ref": "#/definitions/models.Year" + "maximum": 6, + "minimum": 1, + "allOf": [ + { + "$ref": "#/definitions/models.Year" + } + ] } } }, diff --git a/backend/src/docs/swagger.yaml b/backend/src/docs/swagger.yaml index 0ec1252af..7315e5fd2 100644 --- a/backend/src/docs/swagger.yaml +++ b/backend/src/docs/swagger.yaml @@ -1,88 +1,21 @@ -basePath: / +basePath: /api/v1 definitions: - models.Club: + models.Category: properties: - application_link: + category_name: + maxLength: 255 type: string - club_followers: - items: - $ref: '#/definitions/models.User' - type: array - club_intended_applicants: - items: - $ref: '#/definitions/models.User' - type: array - club_members: - description: User - items: - $ref: '#/definitions/models.User' - type: array - comments: - items: - $ref: '#/definitions/models.Comment' - type: array - contacts: - items: - $ref: '#/definitions/models.Contact' - type: array created_at: example: "2023-09-20T16:34:50Z" type: string - description: - description: MongoDB URI - type: string - events: - description: Event - items: - $ref: '#/definitions/models.Event' - type: array id: example: 1 type: integer - is_recruiting: - type: boolean - logo: - description: S3 URI - type: string - name: - type: string - notifications: - items: - $ref: '#/definitions/models.Notification' - type: array - num_members: - type: integer - parent_club: - type: integer - point_of_contacts: - items: - $ref: '#/definitions/models.PointOfContact' - type: array - preview: - type: string - recruitment_cycle: - $ref: '#/definitions/models.RecruitmentCycle' - recruitment_type: - $ref: '#/definitions/models.RecruitmentType' - tags: - items: - $ref: '#/definitions/models.Tag' - type: array updated_at: example: "2023-09-20T16:34:50Z" type: string required: - - application_link - - club_members - - contacts - - description - - is_recruiting - - name - - num_members - - point_of_contacts - - preview - - recruitment_cycle - - recruitment_type + - category_name type: object models.College: enum: @@ -116,318 +49,63 @@ definitions: - CPS - CS - CSSH - models.Comment: - properties: - answer: - type: string - answered_by: - $ref: '#/definitions/models.User' - asked_by: - $ref: '#/definitions/models.User' - asked_by_id: - type: integer - club: - $ref: '#/definitions/models.Club' - club_id: - type: integer - created_at: - example: "2023-09-20T16:34:50Z" - type: string - id: - example: 1 - type: integer - num_found_helpful: - type: integer - question: - type: string - updated_at: - example: "2023-09-20T16:34:50Z" - type: string - user_id: - type: integer - required: - - question - type: object - models.Contact: - properties: - content: - description: media URI - type: string - created_at: - example: "2023-09-20T16:34:50Z" - type: string - id: - example: 1 - type: integer - type: - $ref: '#/definitions/models.Media' - updated_at: - example: "2023-09-20T16:34:50Z" - type: string - required: - - content - - type - type: object - models.Event: - properties: - clubs: - items: - $ref: '#/definitions/models.Club' - type: array - content: - type: string - created_at: - example: "2023-09-20T16:34:50Z" - type: string - end_time: - type: string - event_type: - $ref: '#/definitions/models.EventType' - id: - example: 1 - type: integer - location: - type: string - name: - type: string - notifications: - items: - $ref: '#/definitions/models.Notification' - type: array - preview: - type: string - start_time: - type: string - tags: - items: - $ref: '#/definitions/models.Tag' - type: array - updated_at: - example: "2023-09-20T16:34:50Z" - type: string - user_rsvps: - items: - $ref: '#/definitions/models.User' - type: array - user_waitlists: - items: - $ref: '#/definitions/models.User' - type: array - required: - - clubs - - content - - end_time - - event_type - - location - - name - - preview - - start_time - type: object - models.EventType: - enum: - - open - - membersOnly - type: string - x-enum-varnames: - - Open - - MembersOnly - models.Media: - enum: - - facebook - - instagram - - twitter - - linkedin - - youtube - - github - - custom - type: string - x-enum-varnames: - - Facebook - - Instagram - - Twitter - - LinkedIn - - YouTube - - GitHub - - Custom - models.Notification: - properties: - content: - type: string - created_at: - example: "2023-09-20T16:34:50Z" - type: string - deep_link: - type: string - icon: - description: S3 URI - type: string - id: - example: 1 - type: integer - reference_id: - type: integer - reference_type: - $ref: '#/definitions/models.NotificationType' - send_at: - type: string - title: - type: string - updated_at: - example: "2023-09-20T16:34:50Z" - type: string - required: - - content - - deep_link - - icon - - reference_id - - reference_type - - send_at - - title - type: object - models.NotificationType: - enum: - - event - - club - type: string - x-enum-varnames: - - EventNotification - - ClubNotification - models.PointOfContact: - properties: - created_at: - example: "2023-09-20T16:34:50Z" - type: string - email: - type: string - id: - example: 1 - type: integer - name: - type: string - photo: - description: S3 URI, fallback to default logo if null - type: string - position: - type: string - updated_at: - example: "2023-09-20T16:34:50Z" - type: string - required: - - email - - name - - position - type: object - models.RecruitmentCycle: - enum: - - fall - - spring - - fallSpring - - always - type: string - x-enum-varnames: - - Fall - - Spring - - FallSpring - - Always - models.RecruitmentType: - enum: - - unrestricted - - tryout - - application - type: string - x-enum-varnames: - - Unrestricted - - Tryout - - Application models.Tag: properties: category_id: + minimum: 1 type: integer - clubs: - items: - $ref: '#/definitions/models.Club' - type: array created_at: example: "2023-09-20T16:34:50Z" type: string - events: - items: - $ref: '#/definitions/models.Event' - type: array id: example: 1 type: integer name: + maxLength: 255 type: string updated_at: example: "2023-09-20T16:34:50Z" type: string - users: - items: - $ref: '#/definitions/models.User' - type: array required: + - category_id - name type: object models.User: properties: - answered: - items: - $ref: '#/definitions/models.Comment' - type: array - club_followers: - items: - $ref: '#/definitions/models.Club' - type: array - club_intended_applicants: - items: - $ref: '#/definitions/models.Club' - type: array - club_members: - items: - $ref: '#/definitions/models.Club' - type: array college: - $ref: '#/definitions/models.College' - comments: - items: - $ref: '#/definitions/models.Comment' - type: array + allOf: + - $ref: '#/definitions/models.College' + maxLength: 255 created_at: example: "2023-09-20T16:34:50Z" type: string email: + maxLength: 255 type: string - event_rsvps: - items: - $ref: '#/definitions/models.Event' - type: array - event_waitlist: - items: - $ref: '#/definitions/models.Event' - type: array first_name: + maxLength: 255 type: string id: example: 1 type: integer last_name: + maxLength: 255 type: string nuid: type: string - tags: - items: - $ref: '#/definitions/models.Tag' - type: array updated_at: example: "2023-09-20T16:34:50Z" type: string user_role: - $ref: '#/definitions/models.UserRole' + allOf: + - $ref: '#/definitions/models.UserRole' + maxLength: 255 year: - $ref: '#/definitions/models.Year' + allOf: + - $ref: '#/definitions/models.Year' + maximum: 6 + minimum: 1 required: - - club_members - college - email - first_name @@ -471,24 +149,168 @@ info: title: SAC API version: "1.0" paths: + /api/v1/category/: + post: + description: Creates a category that is used to group tags + operationId: create-category + produces: + - application/json + responses: + "201": + description: Created + schema: + $ref: '#/definitions/models.Category' + "400": + description: category with that name already exists + schema: + type: string + "500": + description: failed to create category + schema: + type: string + summary: Create a category + tags: + - category + /api/v1/tags/: + post: + consumes: + - application/json + description: Creates a tag + operationId: create-tag + produces: + - application/json + responses: + "201": + description: Created + schema: + $ref: '#/definitions/models.Tag' + "400": + description: failed to validate the data + schema: + type: string + "500": + description: failed to create tag + schema: + type: string + summary: Creates a tag + tags: + - tag + /api/v1/tags/{id}: + delete: + description: Deletes a tag + operationId: delete-tag + parameters: + - description: Tag ID + in: path + name: id + required: true + type: integer + responses: + "204": + description: No Content + "400": + description: failed to validate id + schema: + type: string + "404": + description: failed to find tag + schema: + type: string + "500": + description: failed to delete tag + schema: + type: string + summary: Deletes a tag + tags: + - tag + get: + description: Returns a tag + operationId: get-tag + parameters: + - description: Tag ID + in: path + name: id + required: true + type: integer + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/models.Tag' + "400": + description: failed to validate id + schema: + type: string + "404": + description: faied to find tag + schema: + type: string + "500": + description: failed to retrieve tag + schema: + type: string + summary: Gets a tag + tags: + - tag + patch: + consumes: + - application/json + description: Updates a tag + operationId: update-tag + parameters: + - description: Tag ID + in: path + name: id + required: true + type: integer + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/models.Tag' + "400": + description: failed to validate the data + schema: + type: string + "404": + description: failed to find tag + schema: + type: string + "500": + description: failed to update tag + schema: + type: string + summary: Updates a tag + tags: + - tag /api/v1/users/: get: - description: Returns all users - operationId: get-all-users + description: Returns specific user + operationId: get-user produces: - application/json responses: "200": description: OK schema: - items: - $ref: '#/definitions/models.User' - type: array + $ref: '#/definitions/models.User' + "400": + description: failed to validate id + schema: + type: string + "404": + description: failed to find user + schema: + type: string "500": - description: Failed to fetch users + description: Internal Server Error schema: type: string - summary: Gets all users + summary: Gets specific user tags: - user swagger: "2.0" diff --git a/backend/src/main.go b/backend/src/main.go index edaa150a1..d1c5d6bee 100644 --- a/backend/src/main.go +++ b/backend/src/main.go @@ -19,19 +19,18 @@ import ( // @BasePath /api/v1 func main() { onlyMigrate := flag.Bool("only-migrate", false, "Specify if you want to only perform the database migration") + configPath := flag.String("config", "../../config", "Specify the path to the config directory") flag.Parse() - config, err := config.GetConfiguration("../../config") - + config, err := config.GetConfiguration(*configPath) if err != nil { - panic(err) + panic(fmt.Sprintf("Error getting configuration: %s", err.Error())) } db, err := database.ConfigureDB(config) - if err != nil { - panic(err) + panic(fmt.Sprintf("Error configuring database: %s", err.Error())) } if *onlyMigrate { @@ -39,7 +38,6 @@ func main() { } err = database.ConnPooling(db) - if err != nil { panic(err) } diff --git a/backend/src/models/category.go b/backend/src/models/category.go index b1a27c52c..73af78ca1 100644 --- a/backend/src/models/category.go +++ b/backend/src/models/category.go @@ -6,10 +6,9 @@ type Category struct { types.Model Name string `gorm:"type:varchar(255)" json:"category_name" validate:"required,max=255"` - - Tag []Tag `gorm:"constraint:OnUpdate:CASCADE,OnDelete:CASCADE;" json:"-" validate:"-"` + Tag []Tag `gorm:"constraint:OnUpdate:CASCADE,OnDelete:CASCADE;" json:"-" validate:"-"` } -type CreateCategoryRequestBody struct { +type CategoryRequestBody struct { Name string `gorm:"type:varchar(255)" json:"category_name" validate:"required,max=255"` } diff --git a/backend/src/models/tag.go b/backend/src/models/tag.go index 25c26b25b..7594c8459 100644 --- a/backend/src/models/tag.go +++ b/backend/src/models/tag.go @@ -9,9 +9,14 @@ type Tag struct { Name string `gorm:"type:varchar(255)" json:"name" validate:"required,max=255"` - CategoryID uint `gorm:"foreignKey:CategoryID" json:"category_id" validate:"-"` + CategoryID uint `gorm:"foreignKey:CategoryID" json:"category_id" validate:"required,min=1"` User []User `gorm:"many2many:user_tags;constraint:OnUpdate:CASCADE,OnDelete:CASCADE;" json:"-" validate:"-"` Club []Club `gorm:"many2many:club_tags;constraint:OnUpdate:CASCADE,OnDelete:CASCADE;" json:"-" validate:"-"` Event []Event `gorm:"many2many:event_tags;constraint:OnUpdate:CASCADE,OnDelete:CASCADE;" json:"-" validate:"-"` } + +type TagRequestBody struct { + Name string `json:"name" validate:"required,max=255"` + CategoryID uint `json:"category_id" validate:"required,min=1"` +} \ No newline at end of file diff --git a/backend/src/models/user.go b/backend/src/models/user.go index ca43d0381..e4e300458 100644 --- a/backend/src/models/user.go +++ b/backend/src/models/user.go @@ -38,12 +38,12 @@ const ( type User struct { types.Model - Role UserRole `gorm:"type:varchar(255);" json:"user_role" validate:"required,max=255"` + Role UserRole `gorm:"type:varchar(255);" json:"user_role,omitempty" validate:"required,max=255"` NUID string `gorm:"column:nuid;type:varchar(9);unique" json:"nuid" validate:"required,numeric,len=9"` FirstName string `gorm:"type:varchar(255)" json:"first_name" validate:"required,max=255"` LastName string `gorm:"type:varchar(255)" json:"last_name" validate:"required,max=255"` Email string `gorm:"type:varchar(255);unique" json:"email" validate:"required,email,max=255"` - PasswordHash string `gorm:"type:text" json:"-" validate:"required"` + PasswordHash string `gorm:"type:varchar(97)" json:"-" validate:"required,len=97"` College College `gorm:"type:varchar(255)" json:"college" validate:"required,max=255"` Year Year `gorm:"type:smallint" json:"year" validate:"required,min=1,max=6"` @@ -57,13 +57,12 @@ type User struct { Waitlist []Event `gorm:"many2many:user_event_waitlists;constraint:OnUpdate:CASCADE,OnDelete:CASCADE;" json:"-" validate:"-"` } -// TODO: Should we change error message for missing required fields? -type CreateUserRequestBody struct { - NUID string `json:"nuid" validate:"required,number,len=9"` - FirstName string `json:"first_name" validate:"required,max=255"` - LastName string `json:"last_name" validate:"required,max=255"` - Email string `json:"email" validate:"required,neu_email"` - Password string `json:"password" validate:"required,password"` - College string `json:"college" validate:"required,oneof=CAMD DMSB KCCS CE BCHS SL CPS CS CSSH"` - Year uint `json:"year" validate:"required,min=1,max=6"` +type UserRequestBody struct { + NUID string `json:"nuid" validate:"required,len=9"` + FirstName string `json:"first_name" validate:"required,max=255"` + LastName string `json:"last_name" validate:"required,max=255"` + Email string `json:"email" validate:"required,email,neu_email,max=255"` + Password string `json:"password" validate:"required,password"` + College College `json:"college" validate:"required,oneof=CAMD DMSB KCCS CE BCHS SL CPS CS CSSH"` + Year Year `json:"year" validate:"required,min=1,max=6"` } diff --git a/backend/src/server/server.go b/backend/src/server/server.go index 6dc93c610..bdc77dc47 100644 --- a/backend/src/server/server.go +++ b/backend/src/server/server.go @@ -3,7 +3,8 @@ package server import ( "github.com/GenerateNU/sac/backend/src/controllers" "github.com/GenerateNU/sac/backend/src/services" - + "github.com/GenerateNU/sac/backend/src/utilities" + "github.com/go-playground/validator/v10" "github.com/goccy/go-json" "github.com/gofiber/fiber/v2" @@ -22,16 +23,20 @@ import ( // @contact.email oduneye.d@northeastern.edu and ladley.g@northeastern.edu // @host 127.0.0.1:8080 // @BasePath / - func Init(db *gorm.DB) *fiber.App { app := newFiberApp() + validate := validator.New(validator.WithRequiredStructEnabled()) + // MARK: Custom validator tags can be registered here. + utilities.RegisterCustomValidators(validate) + utilityRoutes(app) apiv1 := app.Group("/api/v1") - userRoutes(apiv1, &services.UserService{DB: db}) - categoryRoutes(apiv1, &services.CategoryService{DB: db}) + userRoutes(apiv1, &services.UserService{DB: db, Validate: validate}) + categoryRoutes(apiv1, &services.CategoryService{DB: db, Validate: validate}) + tagRoutes(apiv1, &services.TagService{DB: db, Validate: validate}) return app } @@ -65,6 +70,8 @@ func userRoutes(router fiber.Router, userService services.UserServiceInterface) users.Get("/", userController.GetAllUsers) users.Post("/", userController.CreateUser) + users.Get("/:id", userController.GetUser) + users.Patch("/:id", userController.UpdateUser) } func categoryRoutes(router fiber.Router, categoryService services.CategoryServiceInterface) { @@ -74,3 +81,14 @@ func categoryRoutes(router fiber.Router, categoryService services.CategoryServic categories.Post("/", categoryController.CreateCategory) } + +func tagRoutes(router fiber.Router, tagService services.TagServiceInterface) { + tagController := controllers.NewTagController(tagService) + + tags := router.Group("/tags") + + tags.Get("/:id", tagController.GetTag) + tags.Post("/", tagController.CreateTag) + tags.Patch("/:id", tagController.UpdateTag) + tags.Delete("/:id", tagController.DeleteTag) +} diff --git a/backend/src/services/category.go b/backend/src/services/category.go index 5e74a8c29..2c9380b91 100644 --- a/backend/src/services/category.go +++ b/backend/src/services/category.go @@ -4,27 +4,35 @@ import ( "github.com/GenerateNU/sac/backend/src/models" "github.com/GenerateNU/sac/backend/src/transactions" "github.com/GenerateNU/sac/backend/src/utilities" + "github.com/go-playground/validator/v10" - "github.com/gofiber/fiber/v2" "golang.org/x/text/cases" "golang.org/x/text/language" + + "github.com/gofiber/fiber/v2" "gorm.io/gorm" ) type CategoryServiceInterface interface { - CreateCategory(category models.Category) (*models.Category, error) + CreateCategory(categoryBody models.CategoryRequestBody) (*models.Category, error) } type CategoryService struct { - DB *gorm.DB + DB *gorm.DB + Validate *validator.Validate } -func (c *CategoryService) CreateCategory(category models.Category) (*models.Category, error) { - if err := utilities.ValidateData(category); err != nil { - return nil, fiber.NewError(fiber.StatusBadRequest, "failed to validate the data") +func (c *CategoryService) CreateCategory(categoryBody models.CategoryRequestBody) (*models.Category, error) { + if err := c.Validate.Struct(categoryBody); err != nil { + return nil, fiber.ErrBadRequest + } + + category, err := utilities.MapResponseToModel(categoryBody, &models.Category{}) + if err != nil { + return nil, fiber.ErrInternalServerError } category.Name = cases.Title(language.English).String(category.Name) - return transactions.CreateCategory(c.DB, category) + return transactions.CreateCategory(c.DB, *category) } diff --git a/backend/src/services/tag.go b/backend/src/services/tag.go new file mode 100644 index 000000000..6304d17ee --- /dev/null +++ b/backend/src/services/tag.go @@ -0,0 +1,71 @@ +package services + +import ( + "github.com/GenerateNU/sac/backend/src/models" + "github.com/GenerateNU/sac/backend/src/transactions" + "github.com/GenerateNU/sac/backend/src/utilities" + "github.com/go-playground/validator/v10" + "github.com/gofiber/fiber/v2" + "gorm.io/gorm" +) + +type TagServiceInterface interface { + CreateTag(tagBody models.TagRequestBody) (*models.Tag, error) + GetTag(id string) (*models.Tag, error) + UpdateTag(id string, tagBody models.TagRequestBody) (*models.Tag, error) + DeleteTag(id string) error +} + +type TagService struct { + DB *gorm.DB + Validate *validator.Validate +} + +func (t *TagService) CreateTag(tagBody models.TagRequestBody) (*models.Tag, error) { + if err := t.Validate.Struct(tagBody); err != nil { + return nil, fiber.ErrBadRequest + } + + tag, err := utilities.MapResponseToModel(tagBody, &models.Tag{}) + if err != nil { + return nil, fiber.ErrInternalServerError + } + + return transactions.CreateTag(t.DB, *tag) +} + +func (t *TagService) GetTag(id string) (*models.Tag, error) { + idAsUint, err := utilities.ValidateID(id) + if err != nil { + return nil, fiber.ErrBadRequest + } + + return transactions.GetTag(t.DB, *idAsUint) +} + +func (t *TagService) UpdateTag(id string, tagBody models.TagRequestBody) (*models.Tag, error) { + idAsUint, err := utilities.ValidateID(id) + if err != nil { + return nil, fiber.ErrBadRequest + } + + if err := t.Validate.Struct(tagBody); err != nil { + return nil, fiber.ErrBadRequest + } + + tag, err := utilities.MapResponseToModel(tagBody, &models.Tag{}) + if err != nil { + return nil, fiber.ErrInternalServerError + } + + return transactions.UpdateTag(t.DB, *idAsUint, *tag) +} + +func (t *TagService) DeleteTag(id string) error { + idAsUint, err := utilities.ValidateID(id) + if err != nil { + return fiber.ErrBadRequest + } + + return transactions.DeleteTag(t.DB, *idAsUint) +} diff --git a/backend/src/services/user.go b/backend/src/services/user.go index 559d082cf..f21f6f67d 100644 --- a/backend/src/services/user.go +++ b/backend/src/services/user.go @@ -1,21 +1,26 @@ package services import ( + "github.com/GenerateNU/sac/backend/src/auth" "github.com/GenerateNU/sac/backend/src/models" "github.com/GenerateNU/sac/backend/src/transactions" "github.com/GenerateNU/sac/backend/src/utilities" "github.com/go-playground/validator/v10" "github.com/gofiber/fiber/v2" + "gorm.io/gorm" ) type UserServiceInterface interface { GetAllUsers() ([]models.User, error) CreateUser(userBody models.CreateUserRequestBody) (*models.User, error) + GetUser(id string) (*models.User, error) + UpdateUser(id string, userBody models.UserRequestBody) (*models.User, error) } type UserService struct { - DB *gorm.DB + DB *gorm.DB + Validate *validator.Validate } // Gets all users (including soft deleted users) for testing @@ -55,3 +60,38 @@ func (u *UserService) CreateUser(userBody models.CreateUserRequestBody) (*models return transactions.CreateUser(u.DB, &user) } + + func (u *UserService) GetUser(id string) (*models.User, error) { + idAsUint, err := utilities.ValidateID(id) + if err != nil { + return nil, fiber.ErrBadRequest + } + + return transactions.GetUser(u.DB, *idAsUint) +} + +// Updates a user +func (u *UserService) UpdateUser(id string, userBody models.UserRequestBody) (*models.User, error) { + idAsUint, err := utilities.ValidateID(id) + if err != nil { + return nil, fiber.ErrBadRequest + } + + if err := u.Validate.Struct(userBody); err != nil { + return nil, fiber.ErrBadRequest + } + + passwordHash, err := auth.ComputePasswordHash(userBody.Password) + if err != nil { + return nil, fiber.ErrInternalServerError + } + + user, err := utilities.MapResponseToModel(userBody, &models.User{}) + if err != nil { + return nil, fiber.ErrInternalServerError + } + + user.PasswordHash = *passwordHash + + return transactions.UpdateUser(u.DB, *idAsUint, *user) +} diff --git a/backend/src/transactions/category.go b/backend/src/transactions/category.go index 209d941ed..017eb2199 100644 --- a/backend/src/transactions/category.go +++ b/backend/src/transactions/category.go @@ -14,14 +14,14 @@ func CreateCategory(db *gorm.DB, category models.Category) (*models.Category, er if err := db.Where("name = ?", category.Name).First(&existingCategory).Error; err != nil { if !errors.Is(err, gorm.ErrRecordNotFound) { - return nil, fiber.NewError(fiber.StatusInternalServerError, "failed to create category") + return nil, fiber.ErrInternalServerError } } else { - return nil, fiber.NewError(fiber.StatusBadRequest, "category with that name already exists") + return nil, fiber.ErrBadRequest } if err := db.Create(&category).Error; err != nil { - return nil, fiber.NewError(fiber.StatusInternalServerError, "failed to create category") + return nil, fiber.ErrInternalServerError } return &category, nil @@ -32,9 +32,9 @@ func GetCategory(db *gorm.DB, id uint) (*models.Category, error) { if err := db.First(&category, id).Error; err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { - return nil, fiber.NewError(fiber.StatusNotFound, "invalid category id") + return nil, fiber.ErrNotFound } else { - return nil, fiber.NewError(fiber.StatusInternalServerError, "unable to retrieve category") + return nil, fiber.ErrInternalServerError } } diff --git a/backend/src/transactions/tag.go b/backend/src/transactions/tag.go new file mode 100644 index 000000000..56e2ef248 --- /dev/null +++ b/backend/src/transactions/tag.go @@ -0,0 +1,57 @@ +package transactions + +import ( + "errors" + + "github.com/GenerateNU/sac/backend/src/models" + + "github.com/gofiber/fiber/v2" + "gorm.io/gorm" +) + +func CreateTag(db *gorm.DB, tag models.Tag) (*models.Tag, error) { + if err := db.Create(&tag).Error; err != nil { + return nil, fiber.ErrInternalServerError + } + + return &tag, nil +} + +func GetTag(db *gorm.DB, id uint) (*models.Tag, error) { + var tag models.Tag + + if err := db.First(&tag, id).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, fiber.ErrNotFound + } else { + return nil, fiber.ErrInternalServerError + } + } + + return &tag, nil +} + +func UpdateTag(db *gorm.DB, id uint, tag models.Tag) (*models.Tag, error) { + if err := db.Model(&models.Tag{}).Where("id = ?", id).Updates(tag).First(&tag, id).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, fiber.ErrNotFound + } else { + return nil, fiber.ErrInternalServerError + } + } + + return &tag, nil + +} + +func DeleteTag(db *gorm.DB, id uint) error { + if result := db.Delete(&models.Tag{}, id); result.RowsAffected == 0 { + if result.Error != nil { + return fiber.ErrInternalServerError + } else { + return fiber.ErrNotFound + } + } + + return nil +} diff --git a/backend/src/transactions/user.go b/backend/src/transactions/user.go index 5f3bbbf75..179719ea4 100644 --- a/backend/src/transactions/user.go +++ b/backend/src/transactions/user.go @@ -1,6 +1,8 @@ package transactions import ( + "errors" + "github.com/GenerateNU/sac/backend/src/models" "errors" "github.com/gofiber/fiber/v2" @@ -10,8 +12,8 @@ import ( func GetAllUsers(db *gorm.DB) ([]models.User, error) { var users []models.User - if err := db.Unscoped().Omit("password_hash").Find(&users).Error; err != nil { - return nil, fiber.NewError(fiber.StatusInternalServerError, "failed to get all users") + if err := db.Omit("password_hash").Find(&users).Error; err != nil { + return nil, fiber.ErrInternalServerError } return users, nil @@ -19,12 +21,12 @@ func GetAllUsers(db *gorm.DB) ([]models.User, error) { func GetUser(db *gorm.DB, id uint) (*models.User, error) { var user models.User - if err := db.First(&user, id).Error; err != nil { + if err := db.Omit("password_hash").First(&user, id).Error; err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { - return nil, fiber.NewError(fiber.StatusNotFound, err.Error()) + return nil, fiber.ErrNotFound + } else { + return nil, fiber.ErrInternalServerError } - - return nil, fiber.NewError(fiber.StatusInternalServerError, err.Error()) } return &user, nil @@ -56,3 +58,22 @@ func CreateUser(db *gorm.DB, user *models.User) (*models.User, error) { return user, nil } + +func UpdateUser(db *gorm.DB, id uint, user models.User) (*models.User, error) { + var existingUser models.User + + err := db.First(&existingUser, id).Error + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, fiber.ErrNotFound + } else { + return nil, fiber.ErrInternalServerError + } + } + + if err := db.Model(&existingUser).Updates(&user).Error; err != nil { + return nil, fiber.ErrInternalServerError + } + + return &existingUser, nil +} diff --git a/backend/src/utilities/error.go b/backend/src/utilities/error.go new file mode 100644 index 000000000..468f70004 --- /dev/null +++ b/backend/src/utilities/error.go @@ -0,0 +1,10 @@ +package utilities + +import ( + "github.com/gofiber/fiber/v2" +) + +// ErrorResponse sends a standardized error response +func Error(c *fiber.Ctx, statusCode int, message string) error { + return c.Status(statusCode).JSON(fiber.Map{"error": message}) +} diff --git a/backend/src/utilities/manipulator.go b/backend/src/utilities/manipulator.go new file mode 100644 index 000000000..bada3a068 --- /dev/null +++ b/backend/src/utilities/manipulator.go @@ -0,0 +1,25 @@ +package utilities + +import ( + "github.com/mitchellh/mapstructure" +) + +// MapResponseToModel maps response data to a target model using mapstructure +func MapResponseToModel[T any, U any](responseData T, targetModel *U) (*U, error) { + config := &mapstructure.DecoderConfig{ + Result: targetModel, + TagName: "json", + } + + decoder, err := mapstructure.NewDecoder(config) + if err != nil { + return nil, err + } + + err = decoder.Decode(responseData) + if err != nil { + return nil, err + } + + return targetModel, nil +} \ No newline at end of file diff --git a/backend/src/utilities/validator.go b/backend/src/utilities/validator.go index a257e7f50..3514a9c43 100644 --- a/backend/src/utilities/validator.go +++ b/backend/src/utilities/validator.go @@ -1,10 +1,20 @@ package utilities import ( + "regexp" + "strconv" + + "github.com/gofiber/fiber/v2" + "github.com/go-playground/validator/v10" "github.com/mcnijman/go-emailaddress" ) +func RegisterCustomValidators(validate *validator.Validate) { + validate.RegisterValidation("neu_email", ValidateEmail) + validate.RegisterValidation("password", ValidatePassword) +} + func ValidateEmail(fl validator.FieldLevel) bool { email, err := emailaddress.Parse(fl.Field().String()) if err != nil { @@ -28,7 +38,30 @@ func ValidateData(model interface{}) error { validate := validator.New(validator.WithRequiredStructEnabled()) if err := validate.Struct(model); err != nil { return err + } + if len(fl.Field().String()) < 8 { + return false + } + specialCharactersMatch, _ := regexp.MatchString("[@#%&*+]", fl.Field().String()) + numbersMatch, _ := regexp.MatchString("[0-9]", fl.Field().String()) + return specialCharactersMatch && numbersMatch +} + +// Validates that an id follows postgres uint format, returns a uint otherwise returns an error +func ValidateID(id string) (*uint, error) { + idAsInt, err := strconv.Atoi(id) + + errMsg := "failed to validate id" + + if err != nil { + return nil, fiber.NewError(fiber.StatusBadRequest, errMsg) } - return nil + if idAsInt < 1 { // postgres ids start at 1 + return nil, fiber.NewError(fiber.StatusBadRequest, errMsg) + } + + idAsUint := uint(idAsInt) + + return &idAsUint, nil } diff --git a/backend/tests/api/README.md b/backend/tests/api/README.md new file mode 100644 index 000000000..317eb2800 --- /dev/null +++ b/backend/tests/api/README.md @@ -0,0 +1,144 @@ +# Using the Integration Testing Helpers + +The integration testing helpers are a set of functions that reduce the boilerplate code required to write integration tests. They are located in the `backend/tests/helpers.go`. + +## Modeling a Request with `TestRequest` + +You can model a request with the `TestRequest` struct: + +```go +type TestRequest struct { + Method string + Path string + Body *map[string]interface{} + Headers *map[string]string +} +``` + +Since `Body` and `Headers` are pointers, if they don't set them when creating a `TestRequest`, they will be `nil`. + +Here is an example of creating a `TestRequest`, notice how instead of saying `Headers: nil`, we can simply omit the `Headers` field. + +```go +TestRequest{ + Method: "POST", + Path: "/api/v1/tags/", + Body: &map[string]interface{}{ + "name": tagName, + "category_id": 1, + }, +} +``` + +This handles a lot of the logic for you, for example, if the body is not nil, it will be marshalled into JSON and the `Content-Type` header will be set to `application/json`. + +## Testing that a Request Returns a XXX Status Code + +Say you want to test hitting the `[APP_ADDRESS]/health` endpoint with a GET request returns a `200` status code. + +```go +TestRequest{ + Method: "GET", + Path: "/health", + }.TestOnStatus(t, nil, 200).Close() +``` + +## Testing that a Request Returns a XXX Status Code and Assert Something About the Database + +Say you want to test that a creating a catgory with POST `[APP_ADDRESS]/api/v1/categories/` returns a `201` + +```go +TestRequest{ + Method: "POST", + Path: "/api/v1/categories/", + Body: &map[string]interface{}{ + "category_name": categoryName, + }, + }.TestOnStatusAndDB(t, nil, + DBTesterWithStatus{ + Status: 201, + DBTester: AssertRespCategorySameAsDBCategory, + }, + ).Close() +``` + +### DBTesters + +Often times there are common assertions you want to make about the database, for example, if the object in the response is the same as the object in the database. We can create a lambda function that takes in the `TestApp`, `*assert.A`, and `*http.Response` and makes the assertions we want. We can then pass this function to the `DBTesterWithStatus` struct. + +```go +var AssertRespCategorySameAsDBCategory = func(app TestApp, assert *assert.A, resp *http.Response) { + var respCategory models.Category + + err := json.NewDecoder(resp.Body).Decode(&respCategory) + + assert.NilError(err) + + dbCategory, err := transactions.GetCategory(app.Conn, respCategory.ID) + + assert.NilError(err) + + assert.Equal(dbCategory, &respCategory) +} +``` + +### Existing App Asserts + +Since the test suite creates a new database for each test, we can have a deterministic database state for each test. However, what if we have a multi step test that depends on the previous steps database state? That is where `ExistingAppAssert` comes in! This will allow us to keep using the database from a previous step in the test. + +Consider this example, to create a tag, we need to create a category first. This is a multi step test, so we need to use `ExistingAppAssert` to keep the database state from the previous step. + +```go +appAssert := TestRequest{ + Method: "POST", + Path: "/api/v1/categories/", + Body: &map[string]interface{}{ + "category_name": categoryName, + }, + }.TestOnStatusAndDB(t, nil, + DBTesterWithStatus{ + Status: 201, + DBTester: AssertRespCategorySameAsDBCategory, + }, + ) + +TestRequest{ + Method: "POST", + Path: "/api/v1/tags/", + Body: &map[string]interface{}{ + "name": tagName, + "category_id": 1, + }, + }.TestOnStatusAndDB(t, &appAssert, + DBTesterWithStatus{ + Status: 201, + DBTester: AssertRespTagSameAsDBTag, + }, + ).Close() +``` + +### Why Close? + +This closes the connection to the database. This is important because if you don't close the connection, we will run out of available connections and the tests will fail. **Call this on the last test request of a test** + +## Testing that a Request Returns a XXX Status Code, Assert Something About the Message, and Assert Something About the Database + +Say you want to test a bad request to POST `[APP_ADDRESS]/api/v1/categories/` endpoint returns a `400` status code, the message is `failed to process the request`, and that a category was not created. + +```go +TestRequest{ + Method: "POST", + Path: "/api/v1/categories/", + Body: &map[string]interface{}{ + "category_name": 1231, + }, + }.TestOnStatusMessageAndDB(t, nil, + StatusMessageDBTester{ + MessageWithStatus: MessageWithStatus{ + Status: 400, + Message: "failed to process the request", + }, + DBTester: AssertNoCategories, + }, + ).Close() +``` diff --git a/backend/tests/api/category_test.go b/backend/tests/api/category_test.go index 4119dc3cb..5ba99410c 100644 --- a/backend/tests/api/category_test.go +++ b/backend/tests/api/category_test.go @@ -1,7 +1,6 @@ package tests import ( - "fmt" "net/http" "testing" @@ -12,37 +11,37 @@ import ( "github.com/goccy/go-json" ) -func CreateSampleCategory(t *testing.T, categoryName string) ExistingAppAssert { +var AssertRespCategorySameAsDBCategory = func(app TestApp, assert *assert.A, resp *http.Response) { + var respCategory models.Category + + err := json.NewDecoder(resp.Body).Decode(&respCategory) + + assert.NilError(err) + + dbCategory, err := transactions.GetCategory(app.Conn, respCategory.ID) + + assert.NilError(err) + + assert.Equal(dbCategory, &respCategory) +} + +func CreateSampleCategory(t *testing.T, categoryName string, existingAppAssert *ExistingAppAssert) ExistingAppAssert { return TestRequest{ Method: "POST", Path: "/api/v1/categories/", Body: &map[string]interface{}{ "category_name": categoryName, }, - }.TestOnStatusAndDBKeepDB(t, nil, + }.TestOnStatusAndDB(t, existingAppAssert, DBTesterWithStatus{ - Status: 201, - DBTester: func(app TestApp, assert *assert.A, resp *http.Response) { - - var respCategory models.Category - - err := json.NewDecoder(resp.Body).Decode(&respCategory) - - assert.NilError(err) - - dbCategory, err := transactions.GetCategory(app.Conn, respCategory.ID) - - assert.NilError(err) - - assert.Equal(dbCategory, &respCategory) - }, + Status: 201, + DBTester: AssertRespCategorySameAsDBCategory, }, ) } func TestCreateCategoryWorks(t *testing.T) { - appAssert := CreateSampleCategory(t, "Science") - appAssert.App.DropDB() + CreateSampleCategory(t, "Science", nil).Close() } func TestCreateCategoryIgnoresid(t *testing.T) { @@ -55,22 +54,24 @@ func TestCreateCategoryIgnoresid(t *testing.T) { }, }.TestOnStatusAndDB(t, nil, DBTesterWithStatus{ - Status: 201, - DBTester: func(app TestApp, assert *assert.A, resp *http.Response) { - var respCategory models.Category + Status: 201, + DBTester: AssertRespCategorySameAsDBCategory, + }, + ).Close() +} - err := json.NewDecoder(resp.Body).Decode(&respCategory) +func AssertNoCategories(app TestApp, assert *assert.A, resp *http.Response) { + AssertNumCategoriesRemainsAtN(app, assert, resp, 0) +} - assert.NilError(err) +func AssertNumCategoriesRemainsAtN(app TestApp, assert *assert.A, resp *http.Response, n int) { + var categories []models.Category - dbCategory, err := transactions.GetCategory(app.Conn, respCategory.ID) + err := app.Conn.Find(&categories).Error - assert.NilError(err) + assert.NilError(err) - assert.NotEqual(12, dbCategory.ID) - }, - }, - ) + assert.Equal(n, len(categories)) } func TestCreateCategoryFailsIfNameIsNotString(t *testing.T) { @@ -80,11 +81,15 @@ func TestCreateCategoryFailsIfNameIsNotString(t *testing.T) { Body: &map[string]interface{}{ "category_name": 1231, }, - }.TestOnStatusAndMessage(t, nil, - MessageWithStatus{ - Status: 400, - Message: "failed to process the request", - }) + }.TestOnStatusMessageAndDB(t, nil, + StatusMessageDBTester{ + MessageWithStatus: MessageWithStatus{ + Status: 400, + Message: "failed to process the request", + }, + DBTester: AssertNoCategories, + }, + ).Close() } func TestCreateCategoryFailsIfNameIsMissing(t *testing.T) { @@ -92,34 +97,43 @@ func TestCreateCategoryFailsIfNameIsMissing(t *testing.T) { Method: "POST", Path: "/api/v1/categories/", Body: &map[string]interface{}{}, - }.TestOnStatusAndMessage(t, nil, - MessageWithStatus{ - Status: 400, - Message: "failed to validate the data", + }.TestOnStatusMessageAndDB(t, nil, + StatusMessageDBTester{ + MessageWithStatus: MessageWithStatus{ + Status: 400, + Message: "failed to validate the data", + }, + DBTester: AssertNoCategories, }, - ) + ).Close() } func TestCreateCategoryFailsIfCategoryWithThatNameAlreadyExists(t *testing.T) { - categoryName := "Science" + categoryName := "foo" - existingAppAssert := CreateSampleCategory(t, categoryName) + existingAppAssert := CreateSampleCategory(t, categoryName, nil) + + var TestNumCategoriesRemainsAt1 = func(app TestApp, assert *assert.A, resp *http.Response) { + AssertNumCategoriesRemainsAtN(app, assert, resp, 1) + } for _, permutation := range AllCasingPermutations(categoryName) { - fmt.Println(permutation) TestRequest{ Method: "POST", Path: "/api/v1/categories/", Body: &map[string]interface{}{ "category_name": permutation, }, - }.TestOnStatusAndMessageKeepDB(t, &existingAppAssert, - MessageWithStatus{ - Status: 400, - Message: "category with that name already exists", + }.TestOnStatusMessageAndDB(t, &existingAppAssert, + StatusMessageDBTester{ + MessageWithStatus: MessageWithStatus{ + Status: 400, + Message: "category with that name already exists", + }, + DBTester: TestNumCategoriesRemainsAt1, }, ) } - existingAppAssert.App.DropDB() + existingAppAssert.Close() } diff --git a/backend/tests/api/health_test.go b/backend/tests/api/health_test.go index a44d19d2c..71fe536dd 100644 --- a/backend/tests/api/health_test.go +++ b/backend/tests/api/health_test.go @@ -10,5 +10,5 @@ func TestHealthWorks(t *testing.T) { Path: "/health", }.TestOnStatus(t, nil, 200, - ) + ).Close() } diff --git a/backend/tests/api/helpers.go b/backend/tests/api/helpers.go index 534f00f4e..25d2d5a93 100644 --- a/backend/tests/api/helpers.go +++ b/backend/tests/api/helpers.go @@ -78,14 +78,15 @@ func generateRandomInt(max int64) int64 { } func generateRandomDBName() string { + prefix := "sac_test_" letterBytes := "abcdefghijklmnopqrstuvwxyz" - length := 36 + length := len(prefix) + 36 result := make([]byte, length) for i := 0; i < length; i++ { result[i] = letterBytes[generateRandomInt(int64(len(letterBytes)))] } - return string(result) + return fmt.Sprintf("%s%s", prefix, string(result)) } func configureDatabase(config config.Settings) (*gorm.DB, error) { @@ -116,27 +117,23 @@ func configureDatabase(config config.Settings) (*gorm.DB, error) { return dbWithDB, nil } -func (app TestApp) DropDB() { - db, err := app.Conn.DB() +type ExistingAppAssert struct { + App TestApp + Assert *assert.A +} + +func (eaa ExistingAppAssert) Close() { + db, err := eaa.App.Conn.DB() if err != nil { panic(err) } - db.Close() - - app.Conn, err = gorm.Open(gormPostgres.Open(app.Settings.Database.WithoutDb()), &gorm.Config{SkipDefaultTransaction: true}) + err = db.Close() if err != nil { panic(err) } - - app.Conn.Exec(fmt.Sprintf("DROP DATABASE %s;", app.Settings.Database.DatabaseName)) -} - -type ExistingAppAssert struct { - App TestApp - Assert *assert.A } type TestRequest struct { @@ -169,10 +166,18 @@ func (request TestRequest) Test(t *testing.T, existingAppAssert *ExistingAppAsse req = httptest.NewRequest(request.Method, address, bytes.NewBuffer(bodyBytes)) - if request.Headers != nil { - for key, value := range *request.Headers { - req.Header.Set(key, value) - } + if request.Headers == nil { + request.Headers = &map[string]string{} + } + + if _, ok := (*request.Headers)["Content-Type"]; !ok { + (*request.Headers)["Content-Type"] = "application/json" + } + } + + if request.Headers != nil { + for key, value := range *request.Headers { + req.Header.Add(key, value) } } @@ -188,39 +193,21 @@ func (request TestRequest) Test(t *testing.T, existingAppAssert *ExistingAppAsse func (request TestRequest) TestOnStatus(t *testing.T, existingAppAssert *ExistingAppAssert, status int) ExistingAppAssert { appAssert, resp := request.Test(t, existingAppAssert) - app, assert := appAssert.App, appAssert.Assert - if existingAppAssert != nil { - defer app.DropDB() - } + + _, assert := appAssert.App, appAssert.Assert assert.Equal(status, resp.StatusCode) return appAssert } -func (request TestRequest) TestWithJSONBody(t *testing.T, existingAppAssert *ExistingAppAssert) (ExistingAppAssert, *http.Response) { - if request.Headers == nil { - request.Headers = &map[string]string{"Content-Type": "application/json"} - } else if _, ok := (*request.Headers)["Content-Type"]; !ok { - (*request.Headers)["Content-Type"] = "application/json" - } - - return request.Test(t, existingAppAssert) -} - type MessageWithStatus struct { Status int Message string } func (request TestRequest) TestOnStatusAndMessage(t *testing.T, existingAppAssert *ExistingAppAssert, messagedStatus MessageWithStatus) ExistingAppAssert { - appAssert := request.TestOnStatusAndMessageKeepDB(t, existingAppAssert, messagedStatus) - appAssert.App.DropDB() - return appAssert -} - -func (request TestRequest) TestOnStatusAndMessageKeepDB(t *testing.T, existingAppAssert *ExistingAppAssert, messagedStatus MessageWithStatus) ExistingAppAssert { - appAssert, resp := request.TestWithJSONBody(t, existingAppAssert) + appAssert, resp := request.Test(t, existingAppAssert) assert := appAssert.Assert defer resp.Body.Close() @@ -238,6 +225,17 @@ func (request TestRequest) TestOnStatusAndMessageKeepDB(t *testing.T, existingAp return appAssert } +type StatusMessageDBTester struct { + MessageWithStatus MessageWithStatus + DBTester DBTester +} + +func (request TestRequest) TestOnStatusMessageAndDB(t *testing.T, existingAppAssert *ExistingAppAssert, statusMessageDBTester StatusMessageDBTester) ExistingAppAssert { + appAssert := request.TestOnStatusAndMessage(t, existingAppAssert, statusMessageDBTester.MessageWithStatus) + statusMessageDBTester.DBTester(appAssert.App, appAssert.Assert, nil) + return appAssert +} + type DBTester func(app TestApp, assert *assert.A, resp *http.Response) type DBTesterWithStatus struct { @@ -246,15 +244,8 @@ type DBTesterWithStatus struct { } func (request TestRequest) TestOnStatusAndDB(t *testing.T, existingAppAssert *ExistingAppAssert, dbTesterStatus DBTesterWithStatus) ExistingAppAssert { - appAssert := request.TestOnStatusAndDBKeepDB(t, existingAppAssert, dbTesterStatus) - appAssert.App.DropDB() - return appAssert -} - -func (request TestRequest) TestOnStatusAndDBKeepDB(t *testing.T, existingAppAssert *ExistingAppAssert, dbTesterStatus DBTesterWithStatus) ExistingAppAssert { - appAssert, resp := request.TestWithJSONBody(t, existingAppAssert) + appAssert, resp := request.Test(t, existingAppAssert) app, assert := appAssert.App, appAssert.Assert - defer resp.Body.Close() assert.Equal(dbTesterStatus.Status, resp.StatusCode) diff --git a/backend/tests/api/tag_test.go b/backend/tests/api/tag_test.go new file mode 100644 index 000000000..45c16d94d --- /dev/null +++ b/backend/tests/api/tag_test.go @@ -0,0 +1,296 @@ +package tests + +import ( + "fmt" + "net/http" + "testing" + + "github.com/GenerateNU/sac/backend/src/models" + "github.com/GenerateNU/sac/backend/src/transactions" + "github.com/huandu/go-assert" + + "github.com/goccy/go-json" +) + +var AssertRespTagSameAsDBTag = func(app TestApp, assert *assert.A, resp *http.Response) { + var respTag models.Tag + + err := json.NewDecoder(resp.Body).Decode(&respTag) + + assert.NilError(err) + + dbTag, err := transactions.GetTag(app.Conn, respTag.ID) + + assert.NilError(err) + + assert.Equal(dbTag, &respTag) +} + +func CreateSampleTag(t *testing.T, tagName string, categoryName string, existingAppAssert *ExistingAppAssert) ExistingAppAssert { + appAssert := CreateSampleCategory(t, categoryName, existingAppAssert) + + return TestRequest{ + Method: "POST", + Path: "/api/v1/tags/", + Body: &map[string]interface{}{ + "name": tagName, + "category_id": 1, + }, + }.TestOnStatusAndDB(t, &appAssert, + DBTesterWithStatus{ + Status: 201, + DBTester: AssertRespTagSameAsDBTag, + }, + ) +} + +func TestCreateTagWorks(t *testing.T) { + CreateSampleTag(t, "Generate", "Science", nil).Close() +} + +var AssertNoTags = func(app TestApp, assert *assert.A, resp *http.Response) { + var tags []models.Tag + + err := app.Conn.Find(&tags).Error + + assert.NilError(err) + + assert.Equal(0, len(tags)) +} + +func TestCreateTagFailsBadRequest(t *testing.T) { + badBodys := []map[string]interface{}{ + { + "name": "Generate", + "category_id": "1", + }, + { + "name": 1, + "category_id": 1, + }, + } + + for _, badBody := range badBodys { + TestRequest{ + Method: "POST", + Path: "/api/v1/tags/", + Body: &badBody, + }.TestOnStatusMessageAndDB(t, nil, + StatusMessageDBTester{ + MessageWithStatus: MessageWithStatus{ + Status: 400, + Message: "failed to process the request", + }, + DBTester: AssertNoTags, + }, + ).Close() + } +} + +func TestCreateTagFailsValidation(t *testing.T) { + badBodys := []map[string]interface{}{ + { + "name": "Generate", + }, + { + "category_id": 1, + }, + {}, + } + + for _, badBody := range badBodys { + TestRequest{ + Method: "POST", + Path: "/api/v1/tags/", + Body: &badBody, + }.TestOnStatusMessageAndDB(t, nil, + StatusMessageDBTester{ + MessageWithStatus: MessageWithStatus{ + Status: 400, + Message: "failed to validate the data", + }, + DBTester: AssertNoTags, + }, + ).Close() + } +} + +func TestGetTagWorks(t *testing.T) { + existingAppAssert := CreateSampleTag(t, "Generate", "Science", nil) + + TestRequest{ + Method: "GET", + Path: "/api/v1/tags/1", + }.TestOnStatusAndDB(t, &existingAppAssert, + DBTesterWithStatus{ + Status: 200, + DBTester: AssertRespTagSameAsDBTag, + }, + ).Close() +} + +func TestGetTagFailsBadRequest(t *testing.T) { + badRequests := []string{ + "0", + "-1", + "1.1", + "foo", + "null", + } + + for _, badRequest := range badRequests { + TestRequest{ + Method: "GET", + Path: fmt.Sprintf("/api/v1/tags/%s", badRequest), + }.TestOnStatusAndMessage(t, nil, + MessageWithStatus{ + Status: 400, + Message: "failed to validate id", + }, + ).Close() + } +} + +func TestGetTagFailsNotFound(t *testing.T) { + TestRequest{ + Method: "GET", + Path: "/api/v1/tags/1", + }.TestOnStatusAndMessage(t, nil, + MessageWithStatus{ + Status: 404, + Message: "failed to find tag", + }, + ).Close() +} + +func TestUpdateTagWorksUpdateName(t *testing.T) { + existingAppAssert := CreateSampleTag(t, "Generate", "Science", nil) + + TestRequest{ + Method: "PATCH", + Path: "/api/v1/tags/1", + Body: &map[string]interface{}{ + "name": "GenerateNU", + "category_id": 1, + }, + }.TestOnStatusAndDB(t, &existingAppAssert, + DBTesterWithStatus{ + Status: 200, + DBTester: AssertRespTagSameAsDBTag, + }, + ).Close() +} + +func TestUpdateTagWorksUpdateCategory(t *testing.T) { + existingAppAssert := CreateSampleTag(t, "Generate", "Science", nil) + existingAppAssert = CreateSampleCategory(t, "Technology", &existingAppAssert) + + TestRequest{ + Method: "PATCH", + Path: "/api/v1/tags/1", + Body: &map[string]interface{}{ + "name": "Generate", + "category_id": 2, + }, + }.TestOnStatusAndDB(t, &existingAppAssert, + DBTesterWithStatus{ + Status: 200, + DBTester: AssertRespTagSameAsDBTag, + }, + ).Close() +} + +func TestUpdateTagWorksWithSameDetails(t *testing.T) { + existingAppAssert := CreateSampleTag(t, "Generate", "Science", nil) + + TestRequest{ + Method: "PATCH", + Path: "/api/v1/tags/1", + Body: &map[string]interface{}{ + "name": "Generate", + "category_id": 1, + }, + }.TestOnStatusAndDB(t, &existingAppAssert, + DBTesterWithStatus{ + Status: 200, + DBTester: AssertRespTagSameAsDBTag, + }, + ).Close() +} + +func TestUpdateTagFailsBadRequest(t *testing.T) { + badBodys := []map[string]interface{}{ + { + "name": "Generate", + "category_id": "1", + }, + { + "name": 1, + "category_id": 1, + }, + } + + for _, badBody := range badBodys { + TestRequest{ + Method: "PATCH", + Path: "/api/v1/tags/1", + Body: &badBody, + }.TestOnStatusMessageAndDB(t, nil, + StatusMessageDBTester{ + MessageWithStatus: MessageWithStatus{ + Status: 400, + Message: "failed to process the request", + }, + DBTester: AssertNoTags, + }, + ).Close() + } +} + +func TestDeleteTagWorks(t *testing.T) { + existingAppAssert := CreateSampleTag(t, "Generate", "Science", nil) + + TestRequest{ + Method: "DELETE", + Path: "/api/v1/tags/1", + }.TestOnStatusAndDB(t, &existingAppAssert, + DBTesterWithStatus{ + Status: 204, + DBTester: AssertNoTags, + }, + ).Close() +} + +func TestDeleteTagFailsBadRequest(t *testing.T) { + badRequests := []string{ + "0", + "-1", + "1.1", + "foo", + "null", + } + + for _, badRequest := range badRequests { + TestRequest{ + Method: "DELETE", + Path: fmt.Sprintf("/api/v1/tags/%s", badRequest), + }.TestOnStatusAndMessage(t, nil, + MessageWithStatus{ + Status: 400, + Message: "failed to validate id", + }, + ).Close() + } +} + +func TestDeleteTagFailsNotFound(t *testing.T) { + TestRequest{ + Method: "DELETE", + Path: "/api/v1/tags/1", + }.TestOnStatusAndMessage(t, nil, + MessageWithStatus{ + Status: 404, + Message: "failed to find tag", + }, + ).Close() +} diff --git a/backend/tests/api/user_test.go b/backend/tests/api/user_test.go index 3bb429834..c021acdfd 100644 --- a/backend/tests/api/user_test.go +++ b/backend/tests/api/user_test.go @@ -3,7 +3,11 @@ package tests import ( "fmt" "math/rand" + "bytes" + "fmt" + "net/http" + "net/http/httptest" "testing" "github.com/GenerateNU/sac/backend/src/models" @@ -44,7 +48,191 @@ func TestGetAllUsersWorks(t *testing.T) { assert.Equal(dbUser, respUser) }, }, - ) + ).Close() +} + +var AssertRespUserSameAsDBUser = func(app TestApp, assert *assert.A, resp *http.Response) { + var respUser models.User + + err := json.NewDecoder(resp.Body).Decode(&respUser) + + assert.NilError(err) + + dbUser, err := transactions.GetUser(app.Conn, respUser.ID) + + assert.NilError(err) + + assert.Equal(dbUser.Role, respUser.Role) + assert.Equal(dbUser.NUID, respUser.NUID) + assert.Equal(dbUser.FirstName, respUser.FirstName) + assert.Equal(dbUser.LastName, respUser.LastName) + assert.Equal(dbUser.Email, respUser.Email) + assert.Equal(dbUser.College, respUser.College) + assert.Equal(dbUser.Year, respUser.Year) +} + +func TestGetUserWorks(t *testing.T) { + TestRequest{ + Method: "GET", + Path: "/api/v1/users/1", + }.TestOnStatusAndDB(t, nil, + DBTesterWithStatus{ + Status: 200, + DBTester: AssertRespUserSameAsDBUser, + }, + ).Close() +} + +func TestGetUserFailsBadRequest(t *testing.T) { + badRequests := []string{ + "0", + "-1", + "1.1", + "foo", + "null", + } + + for _, badRequest := range badRequests { + TestRequest{ + Method: "GET", + Path: fmt.Sprintf("/api/v1/tags/%s", badRequest), + }.TestOnStatusAndMessage(t, nil, + MessageWithStatus{ + Status: 400, + Message: "failed to validate id", + }, + ).Close() + } +} + +func TestGetUserFailsNotFound(t *testing.T) { + TestRequest{ + Method: "GET", + Path: "/api/v1/users/69", + }.TestOnStatusAndMessage(t, nil, + MessageWithStatus{ + Status: 404, + Message: "failed to find tag", + }, + ).Close() +} + +func TestUpdateUserWorks(t *testing.T) { + // initialize the test + app, assert := InitTest(t) + + user := models.User{ + Role: models.Student, + NUID: "123456789", + FirstName: "Melody", + LastName: "Yu", + Email: "melody.yu@northeastern.edu", + PasswordHash: "rainbows", + College: models.KCCS, + Year: models.Second, + } + + err := app.Conn.Create(&user).Error + assert.NilError(err) + + data := map[string]interface{}{ + "first_name": "Michael", + "last_name": "Brennan", + } + body, err := json.Marshal(data) + assert.NilError(err) + + req := httptest.NewRequest( + "PATCH", + fmt.Sprintf("%s/api/v1/users/%d", app.Address, user.ID), + bytes.NewBuffer(body)) + req.Header.Set("Content-Type", "application/json") + resp, _ := app.App.Test(req) + + var updatedUser models.User + err = json.NewDecoder(resp.Body).Decode(&updatedUser) + assert.NilError(err) + assert.Equal(resp.StatusCode, 200) + assert.Equal(updatedUser.FirstName, "Michael") + assert.Equal(updatedUser.LastName, "Brennan") +} + +func TestUpdateUserFailsOnInvalidParams(t *testing.T) { + // initialize the test + app, assert := InitTest(t) + + user := models.User{ + Role: models.Student, + NUID: "123456789", + FirstName: "Melody", + LastName: "Yu", + Email: "melody.yu@northeastern.edu", + PasswordHash: "rainbows", + College: models.KCCS, + Year: models.Second, + } + + err := app.Conn.Create(&user).Error + assert.NilError(err) + + // Each entry in invalid_datas represents JSON for a request that should fail (status code 400) + invalidDatas := []map[string]interface{}{ + {"email": "not.northeastern@gmail.com"}, + {"nuid": "1800-123-4567"}, + {"password": "1234"}, + {"year": 1963}, + {"college": "UT-Austin"}, + } + + for i := 0; i < len(invalidDatas); i++ { + body, err := json.Marshal(invalidDatas[i]) + assert.NilError(err) + + req := httptest.NewRequest( + "PATCH", + fmt.Sprintf("%s/api/v1/users/%d", app.Address, user.ID), + bytes.NewBuffer(body)) + req.Header.Set("Content-Type", "application/json") + resp, err := app.App.Test(req) + assert.NilError(err) + assert.Equal(resp.StatusCode, 400) + } +} + +func TestUpdateUserFailsOnInvalidId(t *testing.T) { + // initialize the test + app, assert := InitTest(t) + + user := models.User{ + Role: models.Student, + NUID: "123456789", + FirstName: "Melody", + LastName: "Yu", + Email: "melody.yu@northeastern.edu", + PasswordHash: "rainbows", + College: models.KCCS, + Year: models.Second, + } + + err := app.Conn.Create(&user).Error + assert.NilError(err) + + // User to update does not exist (should return 400) + data := map[string]interface{}{ + "first_name": "Michael", + "last_name": "Brennan", + } + body, err := json.Marshal(data) + assert.NilError(err) + + req := httptest.NewRequest( + "PATCH", + fmt.Sprintf("%s/api/v1/users/%d", app.Address, 12345678), + bytes.NewBuffer(body)) + req.Header.Set("Content-Type", "application/json") + resp, err := app.App.Test(req) + assert.NilError(err) + assert.Equal(resp.StatusCode, 404) } func CreateSampleUser(t *testing.T, email string, nuid string) ExistingAppAssert { diff --git a/backend/tests/utilities_test.go b/backend/tests/utilities_test.go index d7ed0bf33..0c73650b4 100644 --- a/backend/tests/utilities_test.go +++ b/backend/tests/utilities_test.go @@ -1,6 +1,7 @@ package tests import ( + "github.com/go-playground/validator/v10" "testing" "github.com/GenerateNU/sac/backend/src/utilities" @@ -18,3 +19,40 @@ func TestThatContainsWorks(t *testing.T) { assert.Assert(utilities.Contains(slice, "baz")) assert.Assert(!utilities.Contains(slice, "qux")) } + +func TestPasswordValidationWorks(t *testing.T) { + assert := assert.New(t) + + type Thing struct { + password string `validate:"password"` + } + + validate := validator.New() + validate.RegisterValidation("password", utilities.ValidatePassword) + + assert.NilError(validate.Struct(Thing{password: "password!56"})) + assert.NilError(validate.Struct(Thing{password: "cor+ect-h*rse-batte#ry-stap@le-100"})) + assert.NilError(validate.Struct(Thing{password: "1!gooood"})) + assert.Error(validate.Struct(Thing{password: "1!"})) + assert.Error(validate.Struct(Thing{password: "tooshor"})) + assert.Error(validate.Struct(Thing{password: "NoSpecialsOrNumbers"})) +} + +func TestEmailValidationWorks(t *testing.T) { + assert := assert.New(t) + + type Thing struct { + email string `validate:"neu_email"` + } + + validate := validator.New() + validate.RegisterValidation("neu_email", utilities.ValidateEmail) + + assert.NilError(validate.Struct(Thing{email: "brennan.mic@northeastern.edu"})) + assert.NilError(validate.Struct(Thing{email: "blerner@northeastern.edu"})) + assert.NilError(validate.Struct(Thing{email: "validemail@northeastern.edu"})) + assert.Error(validate.Struct(Thing{email: "notanortheasternemail@gmail.com"})) + assert.Error(validate.Struct(Thing{email: "random123@_#!$string"})) + assert.Error(validate.Struct(Thing{email: "local@mail"})) + +} diff --git a/cli/commands/clean_tests.go b/cli/commands/clean_tests.go new file mode 100644 index 000000000..a4c3ad3c6 --- /dev/null +++ b/cli/commands/clean_tests.go @@ -0,0 +1,43 @@ +package commands + +import ( + "fmt" + "os/exec" + + "github.com/urfave/cli/v2" +) + +func ClearDBCommand() *cli.Command { + command := cli.Command{ + Name: "clean", + Usage: "Remove databases used for testing", + Action: func(c *cli.Context) error { + if c.Args().Len() > 0 { + return cli.Exit("Invalid arguments", 1) + } + + ClearDB() + return nil + }, + } + + return &command +} + +func ClearDB() error { + + fmt.Println("Clearing databases") + + cmd := exec.Command("./scripts/clean_old_test_dbs.sh") + cmd.Dir = ROOT_DIR + + out, err := cmd.CombinedOutput() + if err != nil { + fmt.Println(string(out)) + return cli.Exit("Failed to clean old test databases", 1) + } + + fmt.Println("Databases cleared") + + return nil +} diff --git a/cli/commands/config.go b/cli/commands/config.go new file mode 100644 index 000000000..06ca14e6b --- /dev/null +++ b/cli/commands/config.go @@ -0,0 +1,11 @@ +package commands + +import ( + "path/filepath" + + "github.com/GenerateNU/sac/cli/utils" +) + +var ROOT_DIR, _ = utils.GetRootDir() +var FRONTEND_DIR = filepath.Join(ROOT_DIR, "/frontend") +var BACKEND_DIR = filepath.Join(ROOT_DIR, "/backend/src") diff --git a/cli/commands/drop.go b/cli/commands/drop.go new file mode 100644 index 000000000..527287d3d --- /dev/null +++ b/cli/commands/drop.go @@ -0,0 +1,43 @@ +package commands + +import ( + "fmt" + "os/exec" + + "github.com/urfave/cli/v2" +) + +func DropDBCommand() *cli.Command { + command := cli.Command{ + Name: "drop", + Usage: "Drops the database", + Action: func(c *cli.Context) error { + if c.Args().Len() > 0 { + return cli.Exit("Invalid arguments", 1) + } + + DropDB() + return nil + }, + } + + return &command +} + +func DropDB() error { + fmt.Println("Droping database") + + cmd := exec.Command("../../scripts/drop_db.sh") + cmd.Dir = BACKEND_DIR + + output, err := cmd.CombinedOutput() + if err != nil { + return cli.Exit("Error running drop_db.sh", 1) + } + + fmt.Println(string(output)) + + fmt.Println("Done droping database") + + return nil +} \ No newline at end of file diff --git a/cli/commands/format.go b/cli/commands/format.go index c3dc305ab..694e3fe57 100644 --- a/cli/commands/format.go +++ b/cli/commands/format.go @@ -2,9 +2,8 @@ package commands import ( "fmt" - "os" "os/exec" - "path/filepath" + "sync" "github.com/urfave/cli/v2" ) @@ -12,87 +11,84 @@ import ( func FormatCommand() *cli.Command { command := cli.Command{ Name: "format", - Usage: "Runs formatter", + Usage: "Runs formatting tools", Flags: []cli.Flag{ &cli.StringFlag{ Name: "frontend", Aliases: []string{"f"}, - Usage: "Runs frontend formatter", Value: "", + Usage: "Formats a specific frontend folder", }, &cli.BoolFlag{ Name: "backend", Aliases: []string{"b"}, - Usage: "Runs backend formatter", + Usage: "Formats the backend", }, }, - Action: Format, - } + Action: func(c *cli.Context) error { + if c.Args().Len() > 0 { + return cli.Exit("Invalid arguments", 1) + } - return &command -} + if c.String("frontend") == "" && !c.Bool("backend") { + return cli.Exit("Must specify frontend or backend", 1) + } -func Format(c *cli.Context) error { - if c.Args().Len() > 0 { - return cli.Exit("Invalid arguments", 1) - } + folder := c.String("frontend") + runFrontend := folder != "" + runBackend := c.Bool("backend") - currentDir, err := os.Getwd() - if err != nil { - return cli.Exit("Error getting current directory", 1) - } + Format(folder, runFrontend, runBackend) - frontendDir := filepath.Join(currentDir, "frontend/") - backendDir := filepath.Join(currentDir, "github.com/GenerateNU/sac/backend/") - list, err := os.ReadDir(frontendDir) - if err != nil { - return cli.Exit("Error reading frontend directory", 1) + return nil + }, } - if !c.IsSet("frontend") && !c.IsSet("backend") { - formatBackend(backendDir) - for _, f := range list { - if f.IsDir() { - formatFrontend(frontendDir, f.Name()) - } - } - } + return &command +} - if c.IsSet("frontend") && c.IsSet("backend") { - formatFrontend(frontendDir, c.String("frontend")) - formatBackend(backendDir) - } +func Format(folder string, runFrontend bool, runBackend bool) error { + var wg sync.WaitGroup - if c.IsSet("frontend") { - formatFrontend(frontendDir, c.String("frontend")) + // Start the backend if specified + if runBackend { + wg.Add(1) + go func() { + defer wg.Done() + BackendFormat() + }() } - if c.IsSet("backend") { - formatBackend(backendDir) + + // Start the frontend if specified + if runFrontend { + wg.Add(1) + go func() { + defer wg.Done() + FrontendFormat(folder) + }() } + wg.Wait() + return nil } -func formatFrontend(frontendDir string, folder string) { - cmd := exec.Command("yarn", "format") - cmd.Dir = filepath.Join(frontendDir, folder) - - err := cmd.Run() - if err != nil { - fmt.Println("Error formatting frontend, run yarn format in frontend folder") - } - - fmt.Println("frontend", cmd.Dir) // remove -} +func BackendFormat() error { + fmt.Println("Formatting backend") -func formatBackend(backendDir string) { cmd := exec.Command("go", "fmt", "./...") - cmd.Dir = filepath.Join(backendDir) + cmd.Dir = BACKEND_DIR err := cmd.Run() if err != nil { - fmt.Println("Error formatting backend, run go fmt ./... in backend folder") + return cli.Exit("Failed to format backend", 1) } - fmt.Println("backend", cmd.Dir) // remove + fmt.Println("Backend formatted") + return nil } + +func FrontendFormat(folder string) error { + fmt.Println("UNIMPLEMENTED") + return nil +} \ No newline at end of file diff --git a/cli/commands/lint.go b/cli/commands/lint.go index 6ee52b49f..ea1680fb8 100644 --- a/cli/commands/lint.go +++ b/cli/commands/lint.go @@ -2,6 +2,8 @@ package commands import ( "fmt" + "os/exec" + "sync" "github.com/urfave/cli/v2" ) @@ -9,25 +11,85 @@ import ( func LintCommand() *cli.Command { command := cli.Command{ Name: "lint", - Usage: "Runs linter", + Usage: "Runs linting tools", Flags: []cli.Flag{ &cli.StringFlag{ Name: "frontend", Aliases: []string{"f"}, - Usage: "Runs frontend linter", Value: "", + Usage: "Lint a specific frontend folder", }, &cli.BoolFlag{ Name: "backend", Aliases: []string{"b"}, - Usage: "Runs backend linter", + Usage: "Lint the backend", }, }, Action: func(c *cli.Context) error { - fmt.Println("lint", c.String("frontend"), c.Bool("backend")) + if c.Args().Len() > 0 { + return cli.Exit("Invalid arguments", 1) + } + + if c.String("frontend") == "" && !c.Bool("backend") { + return cli.Exit("Must specify frontend or backend", 1) + } + + folder := c.String("frontend") + runFrontend := folder != "" + runBackend := c.Bool("backend") + + Lint(folder, runFrontend, runBackend) + return nil }, } return &command -} \ No newline at end of file +} + +func Lint(folder string, runFrontend bool, runBackend bool) error { + var wg sync.WaitGroup + + // Start the backend if specified + if runBackend { + wg.Add(1) + go func() { + defer wg.Done() + BackendLint() + }() + } + + // Start the frontend if specified + if runFrontend { + wg.Add(1) + go func() { + defer wg.Done() + FrontendLint(folder) + }() + } + + wg.Wait() + + return nil +} + +func BackendLint() error { + fmt.Println("Linting backend") + + cmd := exec.Command("go", "vet", "./...") + cmd.Dir = BACKEND_DIR + + err := cmd.Run() + if err != nil { + return cli.Exit("Failed to lint backend", 1) + } + + fmt.Println("Backend linted") + + return nil +} + +func FrontendLint(folder string) error { + fmt.Println("UNIMPLEMENTED") + return nil +} diff --git a/cli/commands/migrate.go b/cli/commands/migrate.go index 0e10c320f..2ecc2b7a1 100644 --- a/cli/commands/migrate.go +++ b/cli/commands/migrate.go @@ -3,12 +3,11 @@ package commands import ( "fmt" "os/exec" - "sync" "github.com/urfave/cli/v2" ) -func MigrateCommand(backendDir string) *cli.Command { +func MigrateCommand() *cli.Command { command := cli.Command{ Name: "migrate", Usage: "Migrate the database", @@ -17,7 +16,7 @@ func MigrateCommand(backendDir string) *cli.Command { return cli.Exit("Invalid arguments", 1) } - Migrate(backendDir) + Migrate() return nil }, } @@ -25,35 +24,31 @@ func MigrateCommand(backendDir string) *cli.Command { return &command } -func Migrate(backendDir string) error { - var wg sync.WaitGroup +func Migrate() error { + fmt.Println("Migrating database") - wg.Add(1) - go func() { - defer wg.Done() + goCmd := exec.Command("go", "run", "main.go", "--only-migrate") + goCmd.Dir = BACKEND_DIR - cmd := exec.Command("go", "run", "main.go", "--only-migrate") - cmd.Dir = backendDir + output, err := goCmd.CombinedOutput() + if err != nil { + fmt.Println("Error running main.go:", err) + } + + fmt.Println(string(output)) - fmt.Println("Running main.go") - err := cmd.Run() - if err != nil { - fmt.Println("Error running main.go:", err) - } - }() + fmt.Println("Inserting data into database") - cmd := exec.Command("../../scripts/insert_db.sh") - cmd.Dir = backendDir + scriptCmd := exec.Command("./scripts/insert_db.sh") + scriptCmd.Dir = ROOT_DIR - output, err := cmd.CombinedOutput() + output, err = scriptCmd.CombinedOutput() if err != nil { return cli.Exit("Error running insert_db.sh", 1) } fmt.Println(string(output)) + fmt.Println("Done migrating database") - wg.Wait() - fmt.Println("Insert_db.sh completed") - return nil } diff --git a/cli/commands/reset.go b/cli/commands/reset.go index a62037370..44e16fd30 100644 --- a/cli/commands/reset.go +++ b/cli/commands/reset.go @@ -2,19 +2,44 @@ package commands import ( "fmt" + "os/exec" "github.com/urfave/cli/v2" ) func ResetDBCommand() *cli.Command { command := cli.Command{ - Name: "resetdb", + Name: "reset", Usage: "Resets the database", Action: func(c *cli.Context) error { - fmt.Println("resetdb") + if c.Args().Len() > 0 { + return cli.Exit("Invalid arguments", 1) + } + + ResetDB() return nil }, } return &command +} + +func ResetDB() error { + fmt.Println("Resetting database") + + DropDB() + + cmd := exec.Command("sleep", "1") + cmd.Dir = BACKEND_DIR + + err := cmd.Run() + if err != nil { + return cli.Exit("Error running sleep", 1) + } + + Migrate() + + fmt.Println("Done resetting database") + + return nil } \ No newline at end of file diff --git a/cli/commands/swagger.go b/cli/commands/swagger.go index cfaefe531..467c58b2c 100644 --- a/cli/commands/swagger.go +++ b/cli/commands/swagger.go @@ -7,7 +7,7 @@ import ( "github.com/urfave/cli/v2" ) -func SwaggerCommand(backendDir string) *cli.Command { +func SwaggerCommand() *cli.Command { command := cli.Command{ Name: "swagger", Usage: "Updates the swagger documentation", @@ -16,7 +16,7 @@ func SwaggerCommand(backendDir string) *cli.Command { return cli.Exit("Invalid arguments", 1) } - Swagger(backendDir) + Swagger() return nil }, } @@ -24,10 +24,9 @@ func SwaggerCommand(backendDir string) *cli.Command { return &command } - -func Swagger(backendDir string) error { +func Swagger() error { cmd := exec.Command("swag", "init") - cmd.Dir = backendDir + cmd.Dir = BACKEND_DIR out, err := cmd.CombinedOutput() if err != nil { @@ -37,4 +36,4 @@ func Swagger(backendDir string) error { fmt.Println(string(out)) return nil -} \ No newline at end of file +} diff --git a/cli/commands/test.go b/cli/commands/test.go index a4ea32ac3..50a58c934 100644 --- a/cli/commands/test.go +++ b/cli/commands/test.go @@ -8,8 +8,7 @@ import ( "github.com/urfave/cli/v2" ) - -func TestCommand(backendDir string, frontendDir string) *cli.Command { +func TestCommand() *cli.Command { command := cli.Command{ Name: "test", Usage: "Runs tests", @@ -35,13 +34,11 @@ func TestCommand(backendDir string, frontendDir string) *cli.Command { return cli.Exit("Must specify frontend or backend", 1) } - fmt.Println("Frontend", c.String("frontend")) - folder := c.String("frontend") runFrontend := folder != "" runBackend := c.Bool("backend") - Test(backendDir, frontendDir, folder, runFrontend, runBackend) + Test(folder, runFrontend, runBackend) return nil }, @@ -50,8 +47,7 @@ func TestCommand(backendDir string, frontendDir string) *cli.Command { return &command } - -func Test(backendDir string, frontendDir string, folder string, runFrontend bool, runBackend bool) error { +func Test(folder string, runFrontend bool, runBackend bool) error { var wg sync.WaitGroup // Start the backend if specified @@ -59,7 +55,7 @@ func Test(backendDir string, frontendDir string, folder string, runFrontend bool wg.Add(1) go func() { defer wg.Done() - BackendTest(backendDir) + BackendTest() }() } @@ -68,7 +64,7 @@ func Test(backendDir string, frontendDir string, folder string, runFrontend bool wg.Add(1) go func() { defer wg.Done() - FrontendTest(frontendDir, folder) + FrontendTest(folder) }() } @@ -77,9 +73,14 @@ func Test(backendDir string, frontendDir string, folder string, runFrontend bool return nil } -func BackendTest(backendDir string) error { +func BackendTest() error { + // rootDir, err := utils.GetRootDir() + // if err != nil { + // return cli.Exit("Couldn't find the project root", 1) + // } + cmd := exec.Command("go", "test", "./...") - cmd.Dir = backendDir + "/.." + cmd.Dir = BACKEND_DIR + "/.." out, err := cmd.CombinedOutput() if err != nil { @@ -89,21 +90,18 @@ func BackendTest(backendDir string) error { fmt.Println(string(out)) - return nil -} - -func FrontendTest(frontendDir string, folder string) error { - cmd := exec.Command("yarn", "run", "test") - cmd.Dir = frontendDir + cmd = exec.Command("./scripts/clean_old_test_dbs.sh") + cmd.Dir = ROOT_DIR - out, err := cmd.CombinedOutput() + err = cmd.Run() if err != nil { - fmt.Println(string(out)) - return cli.Exit("Failed to run frontend tests", 1) + return cli.Exit("Failed to clean old test databases", 1) } - fmt.Println(string(out)) - return nil } +func FrontendTest(folder string) error { + fmt.Println("UNIMPLEMENTED") + return nil +} diff --git a/cli/main.go b/cli/main.go index 2f309fb11..df7221ad8 100755 --- a/cli/main.go +++ b/cli/main.go @@ -3,34 +3,28 @@ package main import ( "log" "os" - "path/filepath" "github.com/GenerateNU/sac/cli/commands" - "github.com/urfave/cli/v2" ) func main() { - ROOT_DIR, err := os.Getwd() - if err != nil { - log.Fatal(err) - } - - BACKEND_DIR := filepath.Join(ROOT_DIR, "/backend/src") - FRONTEND_DIR := filepath.Join(ROOT_DIR, "/frontend") - app := &cli.App{ Name: "sac-cli", Usage: "CLI for SAC", Commands: []*cli.Command{ - commands.SwaggerCommand(BACKEND_DIR), - // commands.StartCommand(BACKEND_DIR, FRONTEND_DIR), // Dont use - commands.MigrateCommand(BACKEND_DIR), - commands.TestCommand(BACKEND_DIR, FRONTEND_DIR), + commands.SwaggerCommand(), + commands.ClearDBCommand(), + commands.MigrateCommand(), + commands.ResetDBCommand(), + commands.DropDBCommand(), + commands.TestCommand(), // TODO: frontend tests + commands.FormatCommand(), // TODO: frontend format + commands.LintCommand(), // TODO: frontend lint }, } - err = app.Run(os.Args) + err := app.Run(os.Args) if err != nil { log.Fatal(err) } diff --git a/cli/utils/path.go b/cli/utils/path.go new file mode 100644 index 000000000..d91a6b504 --- /dev/null +++ b/cli/utils/path.go @@ -0,0 +1,44 @@ +package utils + +import ( + "fmt" + "os" + "path/filepath" +) + + +func GetRootDir() (string, error) { + // Get the current working directory + currentDir, err := os.Getwd() + if err != nil { + return "", err + } + + // Find the closest directory containing "sac-cli" (the root directory) + rootDir, err := FindRootDir(currentDir) + if err != nil { + return "", err + } + + return rootDir, nil +} + +func FindRootDir(dir string) (string, error) { + // Check if "sac-cli" exists in the current directory + mainGoPath := filepath.Join(dir, "sac-cli") + _, err := os.Stat(mainGoPath) + if err == nil { + // "sac-cli" found, this is the root directory + return dir, nil + } + + // If not found, go up one level + parentDir := filepath.Dir(dir) + if parentDir == dir { + // Reached the top without finding "sac-cli" + return "", fmt.Errorf("could not find root directory containing sac-cli") + } + + // Recursively search in the parent directory + return FindRootDir(parentDir) +} \ No newline at end of file diff --git a/frontend/node_modules/.yarn-integrity b/frontend/node_modules/.yarn-integrity new file mode 100644 index 000000000..044a5ddd9 --- /dev/null +++ b/frontend/node_modules/.yarn-integrity @@ -0,0 +1,10 @@ +{ + "systemParams": "darwin-arm64-115", + "modulesFolders": [], + "flags": [], + "linkedModules": [], + "topLevelPatterns": [], + "lockfileEntries": {}, + "files": [], + "artifacts": {} +} \ No newline at end of file diff --git a/frontend/yarn.lock b/frontend/yarn.lock new file mode 100644 index 000000000..fb57ccd13 --- /dev/null +++ b/frontend/yarn.lock @@ -0,0 +1,4 @@ +# THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. +# yarn lockfile v1 + + diff --git a/go.work.sum b/go.work.sum index c33b0811d..58c872eb0 100644 --- a/go.work.sum +++ b/go.work.sum @@ -16,3 +16,5 @@ go.uber.org/zap v1.21.0/go.mod h1:wjWOCqI0f2ZZrJF/UufIOkiC8ii6tm1iqIsLo76RfJw= golang.org/x/net v0.0.0-20180911220305-26e67e76b6c3/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= google.golang.org/appengine v1.6.7/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc= google.golang.org/protobuf v1.31.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I= +github.com/golang/protobuf v1.5.3/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY= +github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= diff --git a/scripts/clean_old_test_dbs.sh b/scripts/clean_old_test_dbs.sh new file mode 100755 index 000000000..04c97262b --- /dev/null +++ b/scripts/clean_old_test_dbs.sh @@ -0,0 +1,17 @@ +#!/bin/bash + +SCRIPT_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" >/dev/null 2>&1 && pwd )" + +PGHOST="127.0.0.1" +PGPORT="5432" +PGUSER="postgres" +PGPASSWORD="postgres" +PGDATABASE="sac" + +DATABASES=$(psql -h "$PGHOST" -p "$PGPORT" -U "$PGUSER" -d "$PGDATABASE" -t -c "SELECT datname FROM pg_database WHERE datistemplate = false AND datname != 'postgres' AND datname != 'template0' AND datname != '$PGDATABASE' AND datname !='$(whoami)';") + + +for db in $DATABASES; do + echo "Dropping database $db" + psql -h "$PGHOST" -p "$PGPORT" -U "$PGUSER" -d "$PGDATABASE" -c "DROP DATABASE $db;" +done diff --git a/scripts/clean_prefixed_test_dbs.sh b/scripts/clean_prefixed_test_dbs.sh new file mode 100755 index 000000000..61797e999 --- /dev/null +++ b/scripts/clean_prefixed_test_dbs.sh @@ -0,0 +1,17 @@ +#!/bin/bash + +SCRIPT_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" >/dev/null 2>&1 && pwd )" + +PGHOST="127.0.0.1" +PGPORT="5432" +PGUSER="postgres" +PGPASSWORD="postgres" +PGDATABASE="sac" +PREFIX="sac_test_" + +DATABASES=$(psql -h "$PGHOST" -p "$PGPORT" -U "$PGUSER" -d "$PGDATABASE" -t -c "SELECT datname FROM pg_database WHERE datistemplate = false AND datname like '$PREFIX%';") + +for db in $DATABASES; do + echo "Dropping database $db" + psql -h "$PGHOST" -p "$PGPORT" -U "$PGUSER" -d "$PGDATABASE" -c "DROP DATABASE $db;" +done diff --git a/scripts/reset_db.sh b/scripts/drop_db.sh similarity index 100% rename from scripts/reset_db.sh rename to scripts/drop_db.sh diff --git a/scripts/insert_db.sh b/scripts/insert_db.sh index 36f5b7f28..ded8d2031 100755 --- a/scripts/insert_db.sh +++ b/scripts/insert_db.sh @@ -17,18 +17,7 @@ if psql -h "$PGHOST" -p "$PGPORT" -U "$PGUSER" -d "$PGDATABASE" -t -c "$CHECK_TA echo "Database $PGDATABASE exists with tables." else echo "Error: Database $PGDATABASE does not exist or has no tables. Running database migration." - go run ../backend/src/main.go - sleep 3 - - # Find the process running on port 8080 and kill it - PROCESS_ID=$(lsof -i :8080 | awk 'NR==2{print $2}') - if [ -n "$PROCESS_ID" ]; then - kill -INT $PROCESS_ID - echo "Killed process $PROCESS_ID running on port 8080." - else - echo "No process running on port 8080." - exit 0 - fi + go run ../backend/src/main.go --only-migrate fi # Insert data from data.sql @@ -37,4 +26,4 @@ if psql -h "$PGHOST" -p "$PGPORT" -U "$PGUSER" -d "$PGDATABASE" -a -f "$INSERTSQ else echo "Error: Failed to insert data." exit 1 -fi \ No newline at end of file +fi