diff --git a/go.mod b/go.mod index 2cfe2f5..ca74e72 100644 --- a/go.mod +++ b/go.mod @@ -5,8 +5,10 @@ go 1.20 require ( github.com/MadAppGang/httplog v1.3.0 github.com/glebarez/sqlite v1.9.0 + github.com/go-chi/cors v1.2.1 github.com/go-chi/jwtauth/v5 v5.1.1 github.com/go-playground/validator/v10 v10.15.0 + github.com/joho/godotenv v1.5.1 go.uber.org/dig v1.17.0 golang.org/x/crypto v0.10.0 gorm.io/gorm v1.25.2 @@ -14,19 +16,18 @@ require ( require ( github.com/TylerBrock/colorjson v0.0.0-20200706003622-8a50f05110d2 // indirect + github.com/davecgh/go-spew v1.1.1 // indirect github.com/decred/dcrd/dcrec/secp256k1/v4 v4.2.0 // indirect github.com/dustin/go-humanize v1.0.1 // indirect github.com/fatih/color v1.13.0 // indirect github.com/gabriel-vasile/mimetype v1.4.2 // indirect github.com/glebarez/go-sqlite v1.21.2 // indirect - github.com/go-chi/cors v1.2.1 // indirect github.com/go-playground/locales v0.14.1 // indirect github.com/go-playground/universal-translator v0.18.1 // indirect github.com/goccy/go-json v0.10.2 // indirect github.com/google/uuid v1.3.0 // indirect github.com/jinzhu/inflection v1.0.0 // indirect github.com/jinzhu/now v1.1.5 // indirect - github.com/joho/godotenv v1.5.1 // indirect github.com/leodido/go-urn v1.2.4 // indirect github.com/lestrrat-go/blackmagic v1.0.1 // indirect github.com/lestrrat-go/httpcc v1.0.1 // indirect @@ -34,10 +35,14 @@ require ( github.com/lestrrat-go/iter v1.0.2 // indirect github.com/lestrrat-go/jwx/v2 v2.0.11 // indirect github.com/lestrrat-go/option v1.0.1 // indirect + github.com/pmezard/go-difflib v1.0.0 // indirect github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect github.com/segmentio/asm v1.2.0 // indirect + github.com/stretchr/objx v0.5.0 // indirect + github.com/stretchr/testify v1.8.4 // indirect golang.org/x/net v0.10.0 // indirect golang.org/x/text v0.10.0 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect modernc.org/libc v1.24.1 // indirect modernc.org/mathutil v1.5.0 // indirect modernc.org/memory v1.6.0 // indirect diff --git a/go.sum b/go.sum index 16f0a1c..d451fa0 100644 --- a/go.sum +++ b/go.sum @@ -75,6 +75,7 @@ github.com/segmentio/asm v1.2.0 h1:9BQrFxC+YOHJlTlHGkTrFWf59nbL3XnCoFLTwDCI7ys= github.com/segmentio/asm v1.2.0/go.mod h1:BqMnlJP91P8d+4ibuonYZw9mfnzI9HfxselHZr5aAcs= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= +github.com/stretchr/objx v0.5.0 h1:1zr/of2m5FGMsad5YfcqgdqdWrIhu+EBEJRhR1U7z/c= github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= diff --git a/main/main.go b/main/main.go new file mode 100644 index 0000000..e68cb2e --- /dev/null +++ b/main/main.go @@ -0,0 +1,11 @@ +package main + +import "GohCMS2/main/server" + +func main() { + router := server.InitServer() + err := server.StartServer(router) + if err != nil { + panic(err) + } +} diff --git a/cmd/main.go b/main/route/route.go similarity index 50% rename from cmd/main.go rename to main/route/route.go index ac7f6d3..1b1ae38 100644 --- a/cmd/main.go +++ b/main/route/route.go @@ -1,51 +1,48 @@ -package main +package route import ( "GohCMS2/api" "encoding/json" - "fmt" "github.com/MadAppGang/httplog" "github.com/go-chi/chi/v5" "github.com/go-chi/cors" "github.com/go-chi/jwtauth/v5" - "github.com/joho/godotenv" - "log" "net/http" "os" "strings" ) -var envVarsToLoad = []string{"PORT", "ENVIRONMENT", "CORS_ALLOWED_ORIGINS"} +const keyContentType = "Content-Type" -func jsonContentTypeMiddleware(next http.Handler) http.Handler { +func JsonContentTypeMiddleware(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Content-Type", "application/json") + w.Header().Set(keyContentType, "application/json") next.ServeHTTP(w, r) }) } -func htmlContentTypeMiddleware(next http.Handler) http.Handler { +func HtmlContentTypeMiddleware(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Content-Type", "text/html") + w.Header().Set(keyContentType, "text/html") next.ServeHTTP(w, r) }) } -func initJwt() { +func InitJwt() { api.TokenAuth = jwtauth.New("HS256", []byte("secret"), nil) } -func getHelloWorld(w http.ResponseWriter, _ *http.Request) { +func GetHelloWorld(w http.ResponseWriter, _ *http.Request) { msg, _ := json.Marshal(map[string]string{"message": "Hello World"}) _, _ = w.Write(msg) } -func initBackendRoutes() *chi.Mux { +func InitBackendRoutes() *chi.Mux { r := chi.NewRouter() r.Use(httplog.LoggerWithName("backend")) - r.Use(jsonContentTypeMiddleware) - r.Get("/", getHelloWorld) + r.Use(JsonContentTypeMiddleware) + r.Get("/", GetHelloWorld) r.Group(func(r chi.Router) { r.Use(jwtauth.Verifier(api.TokenAuth)) r.Use(jwtauth.Authenticator) @@ -56,25 +53,25 @@ func initBackendRoutes() *chi.Mux { return r } -func initFrontendRoutes() *chi.Mux { +func InitFrontendRoutes() *chi.Mux { r := chi.NewRouter() r.Use(httplog.LoggerWithName("frontend")) - r.Use(htmlContentTypeMiddleware) + r.Use(HtmlContentTypeMiddleware) r.Mount("/", api.NewPageRouter()) return r } -func initRoutes() *chi.Mux { - backend := initBackendRoutes() - frontend := initFrontendRoutes() +func InitRoutes() *chi.Mux { + backend := InitBackendRoutes() + frontend := InitFrontendRoutes() apiRouter := chi.NewRouter() apiRouter.Use(cors.Handler(cors.Options{ AllowedOrigins: strings.Split(os.Getenv("CORS_ALLOWED_ORIGINS"), ";"), AllowedMethods: []string{"GET", "POST", "PUT", "DELETE", "OPTIONS"}, - AllowedHeaders: []string{"Accept", "Authorization", "Content-Type", "X-CSRF-Token"}, + AllowedHeaders: []string{"Accept", "Authorization", keyContentType, "X-CSRF-Token"}, ExposedHeaders: []string{"Link"}, AllowCredentials: false, MaxAge: 300, @@ -84,29 +81,3 @@ func initRoutes() *chi.Mux { return apiRouter } - -func initEnvVariables() { - err := godotenv.Load() - if err != nil { - log.Fatal("Error loading .env file") - } - for _, envVar := range envVarsToLoad { - if _, ok := os.LookupEnv(envVar); !ok { - panic(fmt.Sprintf("Environment variable %s is not set", envVar)) - } - } -} - -func main() { - initEnvVariables() - api.InitContainer() - api.InitValidator() - initJwt() - router := initRoutes() - - fmt.Println("Server starting on http://localhost:" + os.Getenv("PORT")) - err := http.ListenAndServe(":"+os.Getenv("PORT"), router) - if err != nil { - panic(err) - } -} diff --git a/main/server/server.go b/main/server/server.go new file mode 100644 index 0000000..7362ac9 --- /dev/null +++ b/main/server/server.go @@ -0,0 +1,47 @@ +package server + +import ( + "GohCMS2/api" + "GohCMS2/main/route" + "fmt" + "github.com/go-chi/chi/v5" + "github.com/joho/godotenv" + "net/http" + "os" +) + +var possibleEnvFileLocations = []string{".env", "../.env"} +var envVarsToLoad = []string{"PORT", "ENVIRONMENT", "CORS_ALLOWED_ORIGINS"} + +func initEnvVariables() { + var err error + for _, envLocation := range possibleEnvFileLocations { + err = godotenv.Load(envLocation) + if err == nil { + break + } + } + if err != nil { + panic("Could not load .env file") + } + + for _, envVar := range envVarsToLoad { + if _, ok := os.LookupEnv(envVar); !ok { + panic(fmt.Sprintf("Environment variable %s is not set", envVar)) + } + } +} + +func InitServer() *chi.Mux { + initEnvVariables() + api.InitContainer() + api.InitValidator() + route.InitJwt() + return route.InitRoutes() +} + +func StartServer(router *chi.Mux) error { + fmt.Println("Server starting on http://localhost:" + os.Getenv("PORT")) + err := http.ListenAndServe(":"+os.Getenv("PORT"), router) + return err +} diff --git a/test/article_test.go b/test/article_test.go new file mode 100644 index 0000000..407abc1 --- /dev/null +++ b/test/article_test.go @@ -0,0 +1,133 @@ +package test + +import ( + "GohCMS2/domain/article" + "bytes" + "encoding/json" + "github.com/stretchr/testify/assert" + "io" + "net/http" + "strconv" + "testing" +) + +func TestArticle(t *testing.T) { + StartServerIfNotAlready() + WaitForServer() + + t.Run("Create", func(t *testing.T) { + t.Run("Should return an article with the given title and body", func(t *testing.T) { + jsonBody, err := json.Marshal(map[string]string{ + "title": "Test Title", + "body": "Test Body", + }) + if err != nil { + t.Fatal(err) + } + + r, err := ApiRequest("POST", "/article", bytes.NewBuffer(jsonBody)) + + var response article.Article + bd, err := io.ReadAll(r.Body) + if err != nil { + t.Fatal(err) + } + + err = json.Unmarshal(bd, &response) + if err != nil { + t.Fatal(err) + } + + assert.Equal(t, http.StatusOK, r.StatusCode) + assert.Equal(t, "Test Title", response.Title) + assert.Equal(t, "Test Body", response.Body) + }) + + t.Run("Should return an error if the title is missing", func(t *testing.T) { + jsonBody, err := json.Marshal(map[string]string{ + "body": "Test Body", + }) + if err != nil { + t.Fatal(err) + } + + r, err := ApiRequest("POST", "/article", bytes.NewBuffer(jsonBody)) + + assert.Equal(t, http.StatusBadRequest, r.StatusCode) + }) + + t.Run("Should return an error if the title is too short", func(t *testing.T) { + jsonBody, err := json.Marshal(map[string]string{ + "title": "Te", + "body": "Test Body", + }) + if err != nil { + t.Fatal(err) + } + + r, err := ApiRequest("POST", "/article", bytes.NewBuffer(jsonBody)) + + assert.Equal(t, http.StatusBadRequest, r.StatusCode) + }) + }) + + t.Run("Get", func(t *testing.T) { + t.Run("Should return an article with the given id", func(t *testing.T) { + var createdArticle article.Article + var articleToCreate = article.Article{ + Title: "Test Title", + Body: "Test Body", + } + db := GetDb() + db.Create(&articleToCreate).Scan(&createdArticle) + + r, err := ApiRequest("GET", "/article/"+strconv.Itoa(int(createdArticle.ID)), nil) + + var response article.Article + bd, err := io.ReadAll(r.Body) + if err != nil { + t.Fatal(err) + } + + err = json.Unmarshal(bd, &response) + if err != nil { + t.Fatal(err) + } + + assert.Equal(t, http.StatusOK, r.StatusCode) + assert.Equal(t, createdArticle.ID, response.ID) + assert.Equal(t, createdArticle.Title, response.Title) + assert.Equal(t, createdArticle.Body, response.Body) + }) + }) + + t.Run("GetAll", func(t *testing.T) { + t.Run("Should return all articles", func(t *testing.T) { + var createdArticle article.Article + var articleToCreate = article.Article{ + Title: "Test Title", + Body: "Test Body", + } + db := GetDb() + db.Create(&articleToCreate).Scan(&createdArticle) + + r, err := ApiRequest("GET", "/article", nil) + + var response []article.Article + bd, err := io.ReadAll(r.Body) + if err != nil { + t.Fatal(err) + } + + err = json.Unmarshal(bd, &response) + if err != nil { + t.Fatal(err) + } + + assert.Equal(t, http.StatusOK, r.StatusCode) + assert.Equal(t, createdArticle.ID, response[0].ID) + assert.Equal(t, createdArticle.Title, response[0].Title) + assert.Equal(t, createdArticle.Body, response[0].Body) + }) + }) +} diff --git a/test/utils.go b/test/utils.go new file mode 100644 index 0000000..98012dc --- /dev/null +++ b/test/utils.go @@ -0,0 +1,108 @@ +package test + +import ( + "GohCMS2/adapters/secondary/gateways/models" + "GohCMS2/api" + "GohCMS2/main/server" + "github.com/glebarez/sqlite" + "gorm.io/gorm" + "io" + "net/http" + "os" + "time" +) + +var ApiUrl string +var AuthorizationCookie *http.Cookie +var HttpClient = http.Client{} + +func DeleteTestDb() error { + return os.Remove("test.db") +} + +func GetDb() *gorm.DB { + db, err := gorm.Open(sqlite.Open("test.db"), &gorm.Config{}) + if err != nil { + panic(err) + } + _ = db.AutoMigrate(&models.Article{}, &models.User{}) + return db +} + +func StartServerIfNotAlready() { + _, err := http.Get(ApiUrl) + if err == nil { + return + } + err = DeleteTestDb() + if err != nil { + panic(err) + } + go func(url *string) { + router := server.InitServer() + *url = "http://localhost:" + os.Getenv("PORT") + "/v1" + err := server.StartServer(router) + if err != nil { + panic(err) + } + }(&ApiUrl) +} + +func getAuthorizationCookie(userId uint32) *http.Cookie { + _, tokenString, err := api.TokenAuth.Encode(map[string]interface{}{"user_id": userId}) + if err != nil { + panic(err) + } + return &http.Cookie{ + Name: "jwt", + Value: tokenString, + Expires: time.Now().Add(24 * time.Hour), + Secure: false, + HttpOnly: true, + Path: "/", + } +} + +func SetAuthorizationCookieIfNotAlready(r *http.Request) { + if AuthorizationCookie != nil { + r.AddCookie(AuthorizationCookie) + return + } + db := GetDb() + user := models.User{ + Username: "testuser", + Password: "testpassword", + Email: "testemail@a.com", + } + var createdUser models.User + db.Create(&user).Scan(&createdUser) + AuthorizationCookie = getAuthorizationCookie(createdUser.ID) + r.AddCookie(AuthorizationCookie) +} + +func WaitForServer() { + for { + time.Sleep(100 * time.Millisecond) + if ApiUrl == "" { + continue + } + _, err := http.Get(ApiUrl) + if err == nil { + break + } + } +} + +func ApiRequest(method string, route string, body io.Reader) (*http.Response, error) { + request, err := http.NewRequest(method, ApiUrl+route, body) + if err != nil { + return nil, err + } + SetAuthorizationCookieIfNotAlready(request) + + response, err := HttpClient.Do(request) + if err != nil { + return nil, err + } + return response, nil +}