From ad4c8c9ab4b664325cbdbecd83f3bc2821257fcb Mon Sep 17 00:00:00 2001 From: Hayden <64056131+hay-kot@users.noreply.github.com> Date: Sat, 3 Sep 2022 18:42:03 -0800 Subject: [PATCH] end-to-end testing setup --- backend/.dockerignore => .dockerignore | 0 .gitignore | 4 + Dockerfile | 36 +++++ Taskfile.yml | 4 +- backend/Dockerfile | 23 ---- backend/app/api/app.go | 2 +- backend/app/api/base/base_ctrl.go | 40 ------ backend/app/api/base/base_ctrl_test.go | 33 ----- backend/app/api/docs/docs.go | 69 +++++----- backend/app/api/docs/swagger.json | 69 +++++----- backend/app/api/docs/swagger.yaml | 40 +++--- backend/app/api/main.go | 3 +- backend/app/api/public/.gitkeep | 0 backend/app/api/routes.go | 129 +++++++++++++----- backend/app/api/seed.go | 22 +-- backend/app/api/v1/controller.go | 23 ++++ backend/app/api/v1/controller_test.go | 24 ++++ backend/app/api/v1/v1_ctrl_user.go | 23 +++- backend/config.template.yml | 4 +- backend/ent/schema/group.go | 17 ++- backend/ent/schema/item.go | 5 +- backend/ent/schema/user.go | 5 +- backend/internal/config/conf.go | 2 +- backend/internal/repo/repo_users.go | 28 ++-- backend/internal/services/mappers/user.go | 20 +++ backend/internal/services/service_user.go | 35 ++--- .../docker-compose.yml => docker-compose.yml | 6 +- frontend/components/Form/Multiselect.vue | 2 - frontend/components/Form/TextArea.vue | 2 - frontend/lib/api/__test__/public.test.ts | 57 ++++++++ frontend/lib/api/base/urls.ts | 55 ++++---- frontend/lib/api/public.ts | 11 ++ frontend/lib/api/user.ts | 4 + frontend/nuxt.config.ts | 2 + frontend/package.json | 9 +- frontend/pages/label/[id].vue | 1 - frontend/pages/location/[id].vue | 1 - frontend/pnpm-lock.yaml | 15 ++ frontend/test/config.ts | 4 + frontend/test/setup.ts | 20 +++ frontend/test/vitest.config.ts | 8 ++ 41 files changed, 544 insertions(+), 313 deletions(-) rename backend/.dockerignore => .dockerignore (100%) create mode 100644 Dockerfile delete mode 100644 backend/Dockerfile delete mode 100644 backend/app/api/base/base_ctrl.go delete mode 100644 backend/app/api/base/base_ctrl_test.go create mode 100644 backend/app/api/public/.gitkeep create mode 100644 backend/internal/services/mappers/user.go rename backend/docker-compose.yml => docker-compose.yml (57%) create mode 100644 frontend/lib/api/__test__/public.test.ts create mode 100644 frontend/test/config.ts create mode 100644 frontend/test/setup.ts create mode 100644 frontend/test/vitest.config.ts diff --git a/backend/.dockerignore b/.dockerignore similarity index 100% rename from backend/.dockerignore rename to .dockerignore diff --git a/.gitignore b/.gitignore index 5f78192..ad0d618 100644 --- a/.gitignore +++ b/.gitignore @@ -33,3 +33,7 @@ node_modules go.work .task/ backend/.env + +# Output Directory for Nuxt/Frontend during build step +backend/app/api/public/* +!backend/app/api/public/.gitkeep \ No newline at end of file diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..829d5f2 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,36 @@ +# Build Nuxt +FROM node:17-alpine as frontend-builder +WORKDIR /app +RUN npm install -g pnpm +COPY frontend/package.json frontend/pnpm-lock.yaml ./ +RUN pnpm install --frozen-lockfile --shamefully-hoist +COPY frontend . +RUN pnpm build + + + +# Build API +FROM golang:alpine AS builder +RUN apk update +RUN apk upgrade +RUN apk add --update git build-base gcc g++ +WORKDIR /go/src/app +COPY ./backend . +COPY --from=frontend-builder app/.output go/src/app/app/api/public +RUN go get -d -v ./... +RUN CGO_ENABLED=1 GOOS=linux go build -o /go/bin/api -v ./app/api/*.go + + +# Production Stage +FROM alpine:latest + +RUN apk --no-cache add ca-certificates +COPY ./backend/config.template.yml /app/config.yml +COPY --from=builder /go/bin/api /app + +RUN chmod +x /app/api + +LABEL Name=homebox Version=0.0.1 +EXPOSE 7745 +WORKDIR /app +CMD [ "./api" ] diff --git a/Taskfile.yml b/Taskfile.yml index 2bbb6f9..8aca42a 100644 --- a/Taskfile.yml +++ b/Taskfile.yml @@ -31,11 +31,11 @@ tasks: - cd backend && go test -race -coverprofile=coverage.out -covermode=atomic ./app/... ./internal/... ./pkgs/... -v -cover silent: true - client:test: + test:integration: cmds: - cd backend && go run ./app/api/ & - sleep 5 - - cd client && npm run test:ci + - cd frontend && pnpm run test:ci silent: true docker:build: diff --git a/backend/Dockerfile b/backend/Dockerfile deleted file mode 100644 index fd75d96..0000000 --- a/backend/Dockerfile +++ /dev/null @@ -1,23 +0,0 @@ -# Build API -FROM golang:alpine AS builder -RUN apk add --no-cache git build-base -WORKDIR /go/src/app -COPY . . -RUN go get -d -v ./... -RUN go build -o /go/bin/api -v ./app/api/*.go - - -# Production Stage -FROM alpine:latest - -RUN apk --no-cache add ca-certificates -COPY ./config.template.yml /app/config.yml -COPY --from=builder /go/bin/api /app - -RUN chmod +x /app/api -RUN chmod +x /bin/manage - -LABEL Name=gowebtemplate Version=0.0.1 -EXPOSE 7745 -WORKDIR /app -CMD [ "./api" ] diff --git a/backend/app/api/app.go b/backend/app/api/app.go index fe2b694..8e1f742 100644 --- a/backend/app/api/app.go +++ b/backend/app/api/app.go @@ -36,7 +36,7 @@ func NewApp(conf *config.Config) *app { return s } -func (a *app) StartReoccurringTasks(t time.Duration, fn func()) { +func (a *app) StartBgTask(t time.Duration, fn func()) { for { a.server.Background(fn) time.Sleep(t) diff --git a/backend/app/api/base/base_ctrl.go b/backend/app/api/base/base_ctrl.go deleted file mode 100644 index 7f62541..0000000 --- a/backend/app/api/base/base_ctrl.go +++ /dev/null @@ -1,40 +0,0 @@ -package base - -import ( - "net/http" - - "github.com/hay-kot/content/backend/internal/types" - "github.com/hay-kot/content/backend/pkgs/server" -) - -type ReadyFunc func() bool - -type BaseController struct { - svr *server.Server -} - -func NewBaseController(svr *server.Server) *BaseController { - h := &BaseController{ - svr: svr, - } - return h -} - -// HandleBase godoc -// @Summary Retrieves the basic information about the API -// @Tags Base -// @Produce json -// @Success 200 {object} server.Result{item=types.ApiSummary} -// @Router /status [GET] -func (ctrl *BaseController) HandleBase(ready ReadyFunc, versions ...string) http.HandlerFunc { - return func(w http.ResponseWriter, r *http.Request) { - data := types.ApiSummary{ - Healthy: ready(), - Versions: versions, - Title: "Go API Template", - Message: "Welcome to the Go API Template Application!", - } - - server.Respond(w, http.StatusOK, server.Wrap(data)) - } -} diff --git a/backend/app/api/base/base_ctrl_test.go b/backend/app/api/base/base_ctrl_test.go deleted file mode 100644 index c4b987b..0000000 --- a/backend/app/api/base/base_ctrl_test.go +++ /dev/null @@ -1,33 +0,0 @@ -package base - -import ( - "net/http" - "net/http/httptest" - "testing" -) - -func GetTestHandler(t *testing.T) *BaseController { - return NewBaseController(nil) -} - -func TestHandlersv1_HandleBase(t *testing.T) { - // Setup - hdlrFunc := GetTestHandler(t).HandleBase(func() bool { return true }, "v1") - - // Call Handler Func - rr := httptest.NewRecorder() - hdlrFunc(rr, nil) - - // Validate Status Code - if rr.Code != http.StatusOK { - t.Errorf("Expected status code to be %d, got %d", http.StatusOK, rr.Code) - } - - // Validate Json Payload - expected := `{"item":{"health":true,"versions":["v1"],"title":"Go API Template","message":"Welcome to the Go API Template Application!"}}` - - if rr.Body.String() != expected { - t.Errorf("Expected json to be %s, got %s", expected, rr.Body.String()) - } - -} diff --git a/backend/app/api/docs/docs.go b/backend/app/api/docs/docs.go index 3037137..4e23987 100644 --- a/backend/app/api/docs/docs.go +++ b/backend/app/api/docs/docs.go @@ -21,37 +21,6 @@ const docTemplate = `{ "host": "{{.Host}}", "basePath": "{{.BasePath}}", "paths": { - "/status": { - "get": { - "produces": [ - "application/json" - ], - "tags": [ - "Base" - ], - "summary": "Retrieves the basic information about the API", - "responses": { - "200": { - "description": "OK", - "schema": { - "allOf": [ - { - "$ref": "#/definitions/server.Result" - }, - { - "type": "object", - "properties": { - "item": { - "$ref": "#/definitions/types.ApiSummary" - } - } - } - ] - } - } - } - } - }, "/v1/items": { "get": { "security": [ @@ -544,6 +513,25 @@ const docTemplate = `{ } } }, + "/v1/status": { + "get": { + "produces": [ + "application/json" + ], + "tags": [ + "Base" + ], + "summary": "Retrieves the basic information about the API", + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/types.ApiSummary" + } + } + } + } + }, "/v1/users/login": { "post": { "consumes": [ @@ -726,6 +714,25 @@ const docTemplate = `{ } } } + }, + "delete": { + "security": [ + { + "Bearer": [] + } + ], + "produces": [ + "application/json" + ], + "tags": [ + "User" + ], + "summary": "Deletes the user account", + "responses": { + "204": { + "description": "" + } + } } }, "/v1/users/self/password": { diff --git a/backend/app/api/docs/swagger.json b/backend/app/api/docs/swagger.json index 3a8d485..7fc1b9f 100644 --- a/backend/app/api/docs/swagger.json +++ b/backend/app/api/docs/swagger.json @@ -13,37 +13,6 @@ }, "basePath": "/api", "paths": { - "/status": { - "get": { - "produces": [ - "application/json" - ], - "tags": [ - "Base" - ], - "summary": "Retrieves the basic information about the API", - "responses": { - "200": { - "description": "OK", - "schema": { - "allOf": [ - { - "$ref": "#/definitions/server.Result" - }, - { - "type": "object", - "properties": { - "item": { - "$ref": "#/definitions/types.ApiSummary" - } - } - } - ] - } - } - } - } - }, "/v1/items": { "get": { "security": [ @@ -536,6 +505,25 @@ } } }, + "/v1/status": { + "get": { + "produces": [ + "application/json" + ], + "tags": [ + "Base" + ], + "summary": "Retrieves the basic information about the API", + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/types.ApiSummary" + } + } + } + } + }, "/v1/users/login": { "post": { "consumes": [ @@ -718,6 +706,25 @@ } } } + }, + "delete": { + "security": [ + { + "Bearer": [] + } + ], + "produces": [ + "application/json" + ], + "tags": [ + "User" + ], + "summary": "Deletes the user account", + "responses": { + "204": { + "description": "" + } + } } }, "/v1/users/self/password": { diff --git a/backend/app/api/docs/swagger.yaml b/backend/app/api/docs/swagger.yaml index c5ee5af..23d526e 100644 --- a/backend/app/api/docs/swagger.yaml +++ b/backend/app/api/docs/swagger.yaml @@ -580,23 +580,6 @@ info: title: Go API Templates version: "1.0" paths: - /status: - get: - produces: - - application/json - responses: - "200": - description: OK - schema: - allOf: - - $ref: '#/definitions/server.Result' - - properties: - item: - $ref: '#/definitions/types.ApiSummary' - type: object - summary: Retrieves the basic information about the API - tags: - - Base /v1/items: get: produces: @@ -888,6 +871,18 @@ paths: summary: updates a location tags: - Locations + /v1/status: + get: + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/types.ApiSummary' + summary: Retrieves the basic information about the API + tags: + - Base /v1/users/login: post: consumes: @@ -955,6 +950,17 @@ paths: tags: - User /v1/users/self: + delete: + produces: + - application/json + responses: + "204": + description: "" + security: + - Bearer: [] + summary: Deletes the user account + tags: + - User get: produces: - application/json diff --git a/backend/app/api/main.go b/backend/app/api/main.go index a3a5137..99a668a 100644 --- a/backend/app/api/main.go +++ b/backend/app/api/main.go @@ -30,6 +30,7 @@ func main() { // Logger Init // zerolog.TimeFieldFormat = zerolog.TimeFormatUnix log.Logger = log.Output(zerolog.ConsoleWriter{Out: os.Stderr}) + log.Level(zerolog.DebugLevel) cfgFile := "config.yml" @@ -89,7 +90,7 @@ func run(cfg *config.Config) error { // ========================================================================= // Start Reoccurring Tasks - go app.StartReoccurringTasks(time.Duration(24)*time.Hour, func() { + go app.StartBgTask(time.Duration(24)*time.Hour, func() { _, err := app.repos.AuthTokens.PurgeExpiredTokens(context.Background()) if err != nil { log.Error(). diff --git a/backend/app/api/public/.gitkeep b/backend/app/api/public/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/api/routes.go b/backend/app/api/routes.go index 9b5d877..a2b34f3 100644 --- a/backend/app/api/routes.go +++ b/backend/app/api/routes.go @@ -1,17 +1,27 @@ package main import ( + "embed" + "errors" "fmt" + "io" + "io/fs" + "mime" "net/http" + "path" + "path/filepath" "github.com/go-chi/chi/v5" - "github.com/hay-kot/content/backend/app/api/base" _ "github.com/hay-kot/content/backend/app/api/docs" v1 "github.com/hay-kot/content/backend/app/api/v1" "github.com/hay-kot/content/backend/internal/repo" + "github.com/rs/zerolog/log" httpSwagger "github.com/swaggo/http-swagger" // http-swagger middleware ) +//go:embed all:public/* +var public embed.FS + const prefix = "/api" // registerRoutes registers all the routes for the API @@ -22,54 +32,54 @@ func (a *app) newRouter(repos *repo.AllRepos) *chi.Mux { // ========================================================================= // Base Routes + DumpEmbedContents() + r.Get("/swagger/*", httpSwagger.Handler( httpSwagger.URL(fmt.Sprintf("%s://%s/swagger/doc.json", a.conf.Swagger.Scheme, a.conf.Swagger.Host)), )) - // Server Favicon - r.Get("/favicon.ico", func(w http.ResponseWriter, r *http.Request) { - http.ServeFile(w, r, "static/favicon.ico") - }) - - baseHandler := base.NewBaseController(a.server) - r.Get(prefix+"/status", baseHandler.HandleBase(func() bool { return true }, "v1")) - // ========================================================================= // API Version 1 v1Base := v1.BaseUrlFunc(prefix) + v1Ctrl := v1.NewControllerV1(a.services) { - v1Handlers := v1.NewControllerV1(a.services) - r.Post(v1Base("/users/register"), v1Handlers.HandleUserRegistration()) - r.Post(v1Base("/users/login"), v1Handlers.HandleAuthLogin()) + r.Get(v1Base("/status"), v1Ctrl.HandleBase(func() bool { return true }, "v1")) + + r.Post(v1Base("/users/register"), v1Ctrl.HandleUserRegistration()) + r.Post(v1Base("/users/login"), v1Ctrl.HandleAuthLogin()) + r.Group(func(r chi.Router) { r.Use(a.mwAuthToken) - r.Get(v1Base("/users/self"), v1Handlers.HandleUserSelf()) - r.Put(v1Base("/users/self"), v1Handlers.HandleUserUpdate()) - r.Put(v1Base("/users/self/password"), v1Handlers.HandleUserUpdatePassword()) - r.Post(v1Base("/users/logout"), v1Handlers.HandleAuthLogout()) - r.Get(v1Base("/users/refresh"), v1Handlers.HandleAuthRefresh()) + r.Get(v1Base("/users/self"), v1Ctrl.HandleUserSelf()) + r.Put(v1Base("/users/self"), v1Ctrl.HandleUserSelfUpdate()) + r.Delete(v1Base("/users/self"), v1Ctrl.HandleUserSelfDelete()) + r.Put(v1Base("/users/self/password"), v1Ctrl.HandleUserUpdatePassword()) + r.Post(v1Base("/users/logout"), v1Ctrl.HandleAuthLogout()) + r.Get(v1Base("/users/refresh"), v1Ctrl.HandleAuthRefresh()) - r.Get(v1Base("/locations"), v1Handlers.HandleLocationGetAll()) - r.Post(v1Base("/locations"), v1Handlers.HandleLocationCreate()) - r.Get(v1Base("/locations/{id}"), v1Handlers.HandleLocationGet()) - r.Put(v1Base("/locations/{id}"), v1Handlers.HandleLocationUpdate()) - r.Delete(v1Base("/locations/{id}"), v1Handlers.HandleLocationDelete()) + r.Get(v1Base("/locations"), v1Ctrl.HandleLocationGetAll()) + r.Post(v1Base("/locations"), v1Ctrl.HandleLocationCreate()) + r.Get(v1Base("/locations/{id}"), v1Ctrl.HandleLocationGet()) + r.Put(v1Base("/locations/{id}"), v1Ctrl.HandleLocationUpdate()) + r.Delete(v1Base("/locations/{id}"), v1Ctrl.HandleLocationDelete()) - r.Get(v1Base("/labels"), v1Handlers.HandleLabelsGetAll()) - r.Post(v1Base("/labels"), v1Handlers.HandleLabelsCreate()) - r.Get(v1Base("/labels/{id}"), v1Handlers.HandleLabelGet()) - r.Put(v1Base("/labels/{id}"), v1Handlers.HandleLabelUpdate()) - r.Delete(v1Base("/labels/{id}"), v1Handlers.HandleLabelDelete()) + r.Get(v1Base("/labels"), v1Ctrl.HandleLabelsGetAll()) + r.Post(v1Base("/labels"), v1Ctrl.HandleLabelsCreate()) + r.Get(v1Base("/labels/{id}"), v1Ctrl.HandleLabelGet()) + r.Put(v1Base("/labels/{id}"), v1Ctrl.HandleLabelUpdate()) + r.Delete(v1Base("/labels/{id}"), v1Ctrl.HandleLabelDelete()) - r.Get(v1Base("/items"), v1Handlers.HandleItemsGetAll()) - r.Post(v1Base("/items"), v1Handlers.HandleItemsCreate()) - r.Get(v1Base("/items/{id}"), v1Handlers.HandleItemGet()) - r.Put(v1Base("/items/{id}"), v1Handlers.HandleItemUpdate()) - r.Delete(v1Base("/items/{id}"), v1Handlers.HandleItemDelete()) + r.Get(v1Base("/items"), v1Ctrl.HandleItemsGetAll()) + r.Post(v1Base("/items"), v1Ctrl.HandleItemsCreate()) + r.Get(v1Base("/items/{id}"), v1Ctrl.HandleItemGet()) + r.Put(v1Base("/items/{id}"), v1Ctrl.HandleItemUpdate()) + r.Delete(v1Base("/items/{id}"), v1Ctrl.HandleItemDelete()) }) } + r.NotFound(NotFoundHandler) + return r } @@ -78,7 +88,7 @@ func (a *app) newRouter(repos *repo.AllRepos) *chi.Mux { func (a *app) LogRoutes(r *chi.Mux) { desiredSpaces := 10 - walkFunc := func(method string, route string, handler http.Handler, middlewares ...func(http.Handler) http.Handler) error { + walkFunc := func(method string, route string, handler http.Handler, middleware ...func(http.Handler) http.Handler) error { text := "[" + method + "]" for len(text) < desiredSpaces { @@ -93,3 +103,56 @@ func (a *app) LogRoutes(r *chi.Mux) { fmt.Printf("Logging err: %s\n", err.Error()) } } + +var ErrDir = errors.New("path is dir") + +func init() { + mime.AddExtensionType(".js", "application/javascript") + mime.AddExtensionType(".mjs", "application/javascript") +} + +func tryRead(fs embed.FS, prefix, requestedPath string, w http.ResponseWriter) error { + f, err := fs.Open(path.Join(prefix, requestedPath)) + if err != nil { + return err + } + defer f.Close() + + stat, _ := f.Stat() + if stat.IsDir() { + return ErrDir + } + + contentType := mime.TypeByExtension(filepath.Ext(requestedPath)) + w.Header().Set("Content-Type", contentType) + _, err = io.Copy(w, f) + return err +} + +func NotFoundHandler(w http.ResponseWriter, r *http.Request) { + err := tryRead(public, "public", r.URL.Path, w) + if err == nil { + return + } + log.Debug(). + Str("path", r.URL.Path). + Msg("served from embed not found - serving index.html") + err = tryRead(public, "public", "index.html", w) + if err != nil { + panic(err) + } +} + +func DumpEmbedContents() { + // recursively prints all contents in the embed.FS + err := fs.WalkDir(public, ".", func(path string, d fs.DirEntry, err error) error { + if err != nil { + return err + } + fmt.Println(path) + return nil + }) + if err != nil { + panic(err) + } +} diff --git a/backend/app/api/seed.go b/backend/app/api/seed.go index 94382dd..76d9daa 100644 --- a/backend/app/api/seed.go +++ b/backend/app/api/seed.go @@ -4,6 +4,7 @@ import ( "context" "github.com/google/uuid" + "github.com/hay-kot/content/backend/ent" "github.com/hay-kot/content/backend/internal/repo" "github.com/hay-kot/content/backend/internal/types" "github.com/hay-kot/content/backend/pkgs/hasher" @@ -60,25 +61,28 @@ func (a *app) SeedDatabase(repos *repo.AllRepos) { log.Fatal().Err(err).Msg("failed to create default group") } - for _, user := range a.conf.Seed.Users { + for _, seedUser := range a.conf.Seed.Users { // Check if User Exists - usr, _ := repos.Users.GetOneEmail(context.Background(), user.Email) + usr, err := repos.Users.GetOneEmail(context.Background(), seedUser.Email) + if err != nil && !ent.IsNotFound(err) { + log.Fatal().Err(err).Msg("failed to get user") + } - if usr.ID != uuid.Nil { - log.Info().Str("email", user.Email).Msg("user already exists, skipping") + if usr != nil && usr.ID != uuid.Nil { + log.Info().Str("email", seedUser.Email).Msg("user already exists, skipping") continue } - hashedPw, err := hasher.HashPassword(user.Password) + hashedPw, err := hasher.HashPassword(seedUser.Password) if err != nil { log.Fatal().Err(err).Msg("failed to hash password") } _, err = repos.Users.Create(context.Background(), types.UserCreate{ - Name: user.Name, - Email: user.Email, - IsSuperuser: user.IsSuperuser, + Name: seedUser.Name, + Email: seedUser.Email, + IsSuperuser: seedUser.IsSuperuser, Password: hashedPw, GroupID: group.ID, }) @@ -87,6 +91,6 @@ func (a *app) SeedDatabase(repos *repo.AllRepos) { log.Fatal().Err(err).Msg("failed to create user") } - log.Info().Str("email", user.Email).Msg("created user") + log.Info().Str("email", seedUser.Email).Msg("created user") } } diff --git a/backend/app/api/v1/controller.go b/backend/app/api/v1/controller.go index 72be1b1..3ba0f7b 100644 --- a/backend/app/api/v1/controller.go +++ b/backend/app/api/v1/controller.go @@ -1,7 +1,11 @@ package v1 import ( + "net/http" + "github.com/hay-kot/content/backend/internal/services" + "github.com/hay-kot/content/backend/internal/types" + "github.com/hay-kot/content/backend/pkgs/server" ) type V1Controller struct { @@ -24,3 +28,22 @@ func NewControllerV1(svc *services.AllServices) *V1Controller { return ctrl } + +type ReadyFunc func() bool + +// HandleBase godoc +// @Summary Retrieves the basic information about the API +// @Tags Base +// @Produce json +// @Success 200 {object} types.ApiSummary +// @Router /v1/status [GET] +func (ctrl *V1Controller) HandleBase(ready ReadyFunc, versions ...string) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + server.Respond(w, http.StatusOK, types.ApiSummary{ + Healthy: ready(), + Versions: versions, + Title: "Go API Template", + Message: "Welcome to the Go API Template Application!", + }) + } +} diff --git a/backend/app/api/v1/controller_test.go b/backend/app/api/v1/controller_test.go index 21a33cd..b2f87cd 100644 --- a/backend/app/api/v1/controller_test.go +++ b/backend/app/api/v1/controller_test.go @@ -1,6 +1,8 @@ package v1 import ( + "net/http" + "net/http/httptest" "testing" "github.com/stretchr/testify/assert" @@ -16,3 +18,25 @@ func Test_NewHandlerV1(t *testing.T) { assert.Equal(t, "/testing/v1/v1/abc123", v1Base("/abc123")) assert.Equal(t, "/testing/v1/v1/abc123", v1Base("/abc123")) } + +func TestHandlersv1_HandleBase(t *testing.T) { + // Setup + hdlrFunc := mockHandler.HandleBase(func() bool { return true }, "v1") + + // Call Handler Func + rr := httptest.NewRecorder() + hdlrFunc(rr, nil) + + // Validate Status Code + if rr.Code != http.StatusOK { + t.Errorf("Expected status code to be %d, got %d", http.StatusOK, rr.Code) + } + + // Validate Json Payload + expected := `{"health":true,"versions":["v1"],"title":"Go API Template","message":"Welcome to the Go API Template Application!"}` + + if rr.Body.String() != expected { + t.Errorf("Expected json to be %s, got %s", expected, rr.Body.String()) + } + +} diff --git a/backend/app/api/v1/v1_ctrl_user.go b/backend/app/api/v1/v1_ctrl_user.go index 5eede9c..520fd81 100644 --- a/backend/app/api/v1/v1_ctrl_user.go +++ b/backend/app/api/v1/v1_ctrl_user.go @@ -58,7 +58,7 @@ func (ctrl *V1Controller) HandleUserSelf() http.HandlerFunc { } } -// HandleUserUpdate godoc +// HandleUserSelfUpdate godoc // @Summary Update the current user // @Tags User // @Produce json @@ -66,7 +66,7 @@ func (ctrl *V1Controller) HandleUserSelf() http.HandlerFunc { // @Success 200 {object} server.Result{item=types.UserUpdate} // @Router /v1/users/self [PUT] // @Security Bearer -func (ctrl *V1Controller) HandleUserUpdate() http.HandlerFunc { +func (ctrl *V1Controller) HandleUserSelfUpdate() http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { updateData := types.UserUpdate{} if err := server.Decode(r, &updateData); err != nil { @@ -99,3 +99,22 @@ func (ctrl *V1Controller) HandleUserUpdatePassword() http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { } } + +// HandleUserSelfDelete godoc +// @Summary Deletes the user account +// @Tags User +// @Produce json +// @Success 204 +// @Router /v1/users/self [DELETE] +// @Security Bearer +func (ctrl *V1Controller) HandleUserSelfDelete() http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + actor := services.UseUserCtx(r.Context()) + if err := ctrl.svc.User.DeleteSelf(r.Context(), actor.ID); err != nil { + server.RespondError(w, http.StatusInternalServerError, err) + return + } + + server.Respond(w, http.StatusNoContent, nil) + } +} diff --git a/backend/config.template.yml b/backend/config.template.yml index d80911b..366e2a3 100644 --- a/backend/config.template.yml +++ b/backend/config.template.yml @@ -4,8 +4,8 @@ swagger: host: localhost:7745 scheme: http web: - port: 3915 - host: 127.0.0.1 + port: 7745 + host: database: driver: sqlite3 sqlite-url: ./ent.db?_fk=1 diff --git a/backend/ent/schema/group.go b/backend/ent/schema/group.go index 2929a62..68c3b99 100644 --- a/backend/ent/schema/group.go +++ b/backend/ent/schema/group.go @@ -2,6 +2,7 @@ package schema import ( "entgo.io/ent" + "entgo.io/ent/dialect/entsql" "entgo.io/ent/schema/edge" "entgo.io/ent/schema/field" "github.com/hay-kot/content/backend/ent/schema/mixins" @@ -33,9 +34,17 @@ func (Group) Fields() []ent.Field { // Edges of the Home. func (Group) Edges() []ent.Edge { return []ent.Edge{ - edge.To("users", User.Type), - edge.To("locations", Location.Type), - edge.To("items", Item.Type), - edge.To("labels", Label.Type), + edge.To("users", User.Type).Annotations(entsql.Annotation{ + OnDelete: entsql.Cascade, + }), + edge.To("locations", Location.Type).Annotations(entsql.Annotation{ + OnDelete: entsql.Cascade, + }), + edge.To("items", Item.Type).Annotations(entsql.Annotation{ + OnDelete: entsql.Cascade, + }), + edge.To("labels", Label.Type).Annotations(entsql.Annotation{ + OnDelete: entsql.Cascade, + }), } } diff --git a/backend/ent/schema/item.go b/backend/ent/schema/item.go index e2af747..affc0bc 100644 --- a/backend/ent/schema/item.go +++ b/backend/ent/schema/item.go @@ -2,6 +2,7 @@ package schema import ( "entgo.io/ent" + "entgo.io/ent/dialect/entsql" "entgo.io/ent/schema/edge" "entgo.io/ent/schema/field" "github.com/google/uuid" @@ -73,7 +74,9 @@ func (Item) Edges() []ent.Edge { edge.From("location", Location.Type). Ref("items"). Unique(), - edge.To("fields", ItemField.Type), + edge.To("fields", ItemField.Type).Annotations(entsql.Annotation{ + OnDelete: entsql.Cascade, + }), edge.From("label", Label.Type). Ref("items"), } diff --git a/backend/ent/schema/user.go b/backend/ent/schema/user.go index 32246a4..6fe72b6 100644 --- a/backend/ent/schema/user.go +++ b/backend/ent/schema/user.go @@ -2,6 +2,7 @@ package schema import ( "entgo.io/ent" + "entgo.io/ent/dialect/entsql" "entgo.io/ent/schema/edge" "entgo.io/ent/schema/field" "github.com/hay-kot/content/backend/ent/schema/mixins" @@ -44,6 +45,8 @@ func (User) Edges() []ent.Edge { Ref("users"). Required(). Unique(), - edge.To("auth_tokens", AuthTokens.Type), + edge.To("auth_tokens", AuthTokens.Type).Annotations(entsql.Annotation{ + OnDelete: entsql.Cascade, + }), } } diff --git a/backend/internal/config/conf.go b/backend/internal/config/conf.go index d072795..5f6c7e1 100644 --- a/backend/internal/config/conf.go +++ b/backend/internal/config/conf.go @@ -33,7 +33,7 @@ type SwaggerConf struct { type WebConfig struct { Port string `yaml:"port" conf:"default:7745"` - Host string `yaml:"host" conf:"default:127.0.0.1"` + Host string `yaml:"host"` } // NewConfig parses the CLI/Config file and returns a Config struct. If the file argument is an empty string, the diff --git a/backend/internal/repo/repo_users.go b/backend/internal/repo/repo_users.go index 75c568e..8fc348e 100644 --- a/backend/internal/repo/repo_users.go +++ b/backend/internal/repo/repo_users.go @@ -14,23 +14,17 @@ type EntUserRepository struct { } func (e *EntUserRepository) GetOneId(ctx context.Context, id uuid.UUID) (*ent.User, error) { - usr, err := e.db.User.Query().Where(user.ID(id)).Only(ctx) - - if err != nil { - return usr, err - } - - return usr, nil + return e.db.User.Query(). + Where(user.ID(id)). + WithGroup(). + Only(ctx) } func (e *EntUserRepository) GetOneEmail(ctx context.Context, email string) (*ent.User, error) { - usr, err := e.db.User.Query().Where(user.Email(email)).Only(ctx) - - if err != nil { - return usr, err - } - - return usr, nil + return e.db.User.Query(). + Where(user.Email(email)). + WithGroup(). + Only(ctx) } func (e *EntUserRepository) GetAll(ctx context.Context) ([]*ent.User, error) { @@ -59,7 +53,11 @@ func (e *EntUserRepository) Create(ctx context.Context, usr types.UserCreate) (* SetGroupID(usr.GroupID). Save(ctx) - return entUser, err + if err != nil { + return entUser, err + } + + return e.GetOneId(ctx, entUser.ID) } func (e *EntUserRepository) Update(ctx context.Context, ID uuid.UUID, data types.UserUpdate) error { diff --git a/backend/internal/services/mappers/user.go b/backend/internal/services/mappers/user.go new file mode 100644 index 0000000..37c702b --- /dev/null +++ b/backend/internal/services/mappers/user.go @@ -0,0 +1,20 @@ +package mappers + +import ( + "github.com/hay-kot/content/backend/ent" + "github.com/hay-kot/content/backend/internal/types" +) + +func ToOutUser(user *ent.User, err error) (*types.UserOut, error) { + if err != nil { + return &types.UserOut{}, err + } + return &types.UserOut{ + ID: user.ID, + Name: user.Name, + Email: user.Email, + IsSuperuser: user.IsSuperuser, + GroupName: user.Edges.Group.Name, + GroupID: user.Edges.Group.ID, + }, nil +} diff --git a/backend/internal/services/service_user.go b/backend/internal/services/service_user.go index 137ad7c..80c7d10 100644 --- a/backend/internal/services/service_user.go +++ b/backend/internal/services/service_user.go @@ -6,8 +6,8 @@ import ( "time" "github.com/google/uuid" - "github.com/hay-kot/content/backend/ent" "github.com/hay-kot/content/backend/internal/repo" + "github.com/hay-kot/content/backend/internal/services/mappers" "github.com/hay-kot/content/backend/internal/types" "github.com/hay-kot/content/backend/pkgs/hasher" ) @@ -23,24 +23,6 @@ type UserService struct { repos *repo.AllRepos } -func ToOutUser(user *ent.User, err error) (*types.UserOut, error) { - if err != nil { - return &types.UserOut{}, err - } - return &types.UserOut{ - ID: user.ID, - Name: user.Name, - Email: user.Email, - IsSuperuser: user.IsSuperuser, - GroupName: user.Edges.Group.Name, - GroupID: user.Edges.Group.ID, - }, nil -} - -func (UserService) toOutUser(user *ent.User, err error) (*types.UserOut, error) { - return ToOutUser(user, err) -} - func (svc *UserService) RegisterUser(ctx context.Context, data types.UserRegistration) (*types.UserOut, error) { group, err := svc.repos.Groups.Create(ctx, data.GroupName) if err != nil { @@ -48,7 +30,6 @@ func (svc *UserService) RegisterUser(ctx context.Context, data types.UserRegistr } hashed, _ := hasher.HashPassword(data.User.Password) - usrCreate := types.UserCreate{ Name: data.User.Name, Email: data.User.Email, @@ -57,23 +38,22 @@ func (svc *UserService) RegisterUser(ctx context.Context, data types.UserRegistr GroupID: group.ID, } - return svc.toOutUser(svc.repos.Users.Create(ctx, usrCreate)) + return mappers.ToOutUser(svc.repos.Users.Create(ctx, usrCreate)) } // GetSelf returns the user that is currently logged in based of the token provided within func (svc *UserService) GetSelf(ctx context.Context, requestToken string) (*types.UserOut, error) { hash := hasher.HashToken(requestToken) - return svc.toOutUser(svc.repos.AuthTokens.GetUserFromToken(ctx, hash)) + return mappers.ToOutUser(svc.repos.AuthTokens.GetUserFromToken(ctx, hash)) } func (svc *UserService) UpdateSelf(ctx context.Context, ID uuid.UUID, data types.UserUpdate) (*types.UserOut, error) { err := svc.repos.Users.Update(ctx, ID, data) - if err != nil { return &types.UserOut{}, err } - return svc.toOutUser(svc.repos.Users.GetOneId(ctx, ID)) + return mappers.ToOutUser(svc.repos.Users.GetOneId(ctx, ID)) } // ============================================================================ @@ -120,3 +100,10 @@ func (svc *UserService) RenewToken(ctx context.Context, token string) (types.Use return newToken, nil } + +// DeleteSelf deletes the user that is currently logged based of the provided UUID +// There is _NO_ protection against deleting the wrong user, as such this should only +// be used when the identify of the user has been confirmed. +func (svc *UserService) DeleteSelf(ctx context.Context, ID uuid.UUID) error { + return svc.repos.Users.Delete(ctx, ID) +} diff --git a/backend/docker-compose.yml b/docker-compose.yml similarity index 57% rename from backend/docker-compose.yml rename to docker-compose.yml index 298153c..61d20ea 100644 --- a/backend/docker-compose.yml +++ b/docker-compose.yml @@ -1,10 +1,10 @@ version: "3.4" services: - gowebtemplate: - image: gowebtemplate + homebox: + image: homebox build: context: . dockerfile: ./Dockerfile ports: - - 3001:7745 + - 3100:7745 diff --git a/frontend/components/Form/Multiselect.vue b/frontend/components/Form/Multiselect.vue index d03e7ac..c01f681 100644 --- a/frontend/components/Form/Multiselect.vue +++ b/frontend/components/Form/Multiselect.vue @@ -59,10 +59,8 @@ const item = props.items[index]; if (selectedIndexes.value[index]) { - console.log(value); value.value = [...value.value, item]; } else { - console.log(value); value.value = value.value.filter(itm => itm !== item); } } diff --git a/frontend/components/Form/TextArea.vue b/frontend/components/Form/TextArea.vue index 672df9c..8d6b9f2 100644 --- a/frontend/components/Form/TextArea.vue +++ b/frontend/components/Form/TextArea.vue @@ -38,8 +38,6 @@ const value = useVModel(props, 'modelValue', emit); const valueLen = computed(() => { - console.log(value.value.length); - return value.value ? value.value.length : 0; }); diff --git a/frontend/lib/api/__test__/public.test.ts b/frontend/lib/api/__test__/public.test.ts new file mode 100644 index 0000000..c19f2fd --- /dev/null +++ b/frontend/lib/api/__test__/public.test.ts @@ -0,0 +1,57 @@ +import { describe, it, expect } from 'vitest'; +import { Requests } from '../../requests'; +import { OverrideParts } from '../base/urls'; +import { PublicApi } from '../public'; +import * as config from '../../../test/config'; +import { UserApi } from '../user'; + +function client() { + OverrideParts(config.BASE_URL, '/api/v1'); + const requests = new Requests(''); + return new PublicApi(requests); +} + +function userClient(token: string) { + OverrideParts(config.BASE_URL, '/api/v1'); + const requests = new Requests('', token); + return new UserApi(requests); +} + +describe('[GET] /api/v1/status', () => { + it('basic query parameter', async () => { + const api = client(); + const { response, data } = await api.status(); + expect(response.status).toBe(200); + expect(data.health).toBe(true); + }); +}); + +describe('first time user workflow (register, login)', () => { + const api = client(); + const userData = { + groupName: 'test-group', + user: { + email: 'test-user@email.com', + name: 'test-user', + password: 'test-password', + }, + }; + + it('user should be able to register', async () => { + const { response } = await api.register(userData); + expect(response.status).toBe(204); + }); + + it('user should be able to login', async () => { + const { response, data } = await api.login(userData.user.email, userData.user.password); + expect(response.status).toBe(200); + expect(data.token).toBeTruthy(); + + // Cleanup + const userApi = userClient(data.token); + { + const { response } = await userApi.deleteAccount(); + expect(response.status).toBe(204); + } + }); +}); diff --git a/frontend/lib/api/base/urls.ts b/frontend/lib/api/base/urls.ts index 5acf4ed..bb40883 100644 --- a/frontend/lib/api/base/urls.ts +++ b/frontend/lib/api/base/urls.ts @@ -1,31 +1,28 @@ -export const prefix = '/api/v1'; +const parts = { + host: 'http://localhost.com', + prefix: '/api/v1', +}; -export type QueryValue = - | string - | string[] - | number - | number[] - | boolean - | null - | undefined; - -export function UrlBuilder( - rest: string, - params: Record = {} -): string { - // we use a stub base URL to leverage the URL class - const url = new URL(prefix + rest, 'http://localhost.com'); - - for (const [key, value] of Object.entries(params)) { - if (Array.isArray(value)) { - for (const item of value) { - url.searchParams.append(key, String(item)); - } - } else { - url.searchParams.append(key, String(value)); - } - } - - // we return the path only, without the base URL - return url.toString().replace('http://localhost.com', ''); +export function OverrideParts(host: string, prefix: string) { + parts.host = host; + parts.prefix = prefix; +} + +export type QueryValue = string | string[] | number | number[] | boolean | null | undefined; + +export function UrlBuilder(rest: string, params: Record = {}): string { + const url = new URL(parts.prefix + rest, parts.host); + + for (const [key, value] of Object.entries(params)) { + if (Array.isArray(value)) { + for (const item of value) { + url.searchParams.append(key, String(item)); + } + } else { + url.searchParams.append(key, String(value)); + } + } + + // we return the path only, without the base URL + return url.toString().replace('http://localhost.com', ''); } diff --git a/frontend/lib/api/public.ts b/frontend/lib/api/public.ts index 5a06150..d6a1eae 100644 --- a/frontend/lib/api/public.ts +++ b/frontend/lib/api/public.ts @@ -19,7 +19,18 @@ export type RegisterPayload = { groupName: string; }; +export type StatusResult = { + health: boolean; + versions: string[]; + title: string; + message: string; +}; + export class PublicApi extends BaseAPI { + public status() { + return this.http.get(UrlBuilder('/status')); + } + public login(username: string, password: string) { return this.http.post(UrlBuilder('/users/login'), { username, diff --git a/frontend/lib/api/user.ts b/frontend/lib/api/user.ts index 9df5ab5..58af3be 100644 --- a/frontend/lib/api/user.ts +++ b/frontend/lib/api/user.ts @@ -36,4 +36,8 @@ export class UserApi extends BaseAPI { public logout() { return this.http.post(UrlBuilder('/users/logout'), {}); } + + public deleteAccount() { + return this.http.delete(UrlBuilder('/users/self')); + } } diff --git a/frontend/nuxt.config.ts b/frontend/nuxt.config.ts index cabf97e..ddc6aec 100644 --- a/frontend/nuxt.config.ts +++ b/frontend/nuxt.config.ts @@ -2,12 +2,14 @@ import { defineNuxtConfig } from 'nuxt'; // https://v3.nuxtjs.org/api/configuration/nuxt.config export default defineNuxtConfig({ + target: 'static', ssr: false, modules: ['@nuxtjs/tailwindcss', '@pinia/nuxt', '@vueuse/nuxt'], meta: { title: 'Homebox', link: [{ rel: 'icon', type: 'image/x-icon', href: '/favicon.svg' }], }, + outDir: '../backend/app/api/public', vite: { server: { proxy: { diff --git a/frontend/package.json b/frontend/package.json index bab5508..5d5691e 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,15 +1,16 @@ { "private": true, "scripts": { - "build": "nuxt build", + "build": "nuxt generate", "dev": "nuxt dev", - "generate": "nuxt generate", "preview": "nuxt preview", "postinstall": "nuxt prepare", - "test": "vitest", - "test:ci": "vitest --run" + "test:ci": "TEST_SHUTDOWN_API_SERVER=true vitest --run --config ./test/vitest.config.ts", + "test:local": "TEST_SHUTDOWN_API_SERVER=false && vitest --run --config ./test/vitest.config.ts", + "test:watch": " TEST_SHUTDOWN_API_SERVER=false vitest --config ./test/vitest.config.ts" }, "devDependencies": { + "isomorphic-fetch": "^3.0.0", "nuxt": "3.0.0-rc.8", "vitest": "^0.22.1" }, diff --git a/frontend/pages/label/[id].vue b/frontend/pages/label/[id].vue index 3814357..1f58404 100644 --- a/frontend/pages/label/[id].vue +++ b/frontend/pages/label/[id].vue @@ -92,7 +92,6 @@ } toast.success('Label updated'); - console.log(data); label.value = data; updateModal.value = false; updating.value = false; diff --git a/frontend/pages/location/[id].vue b/frontend/pages/location/[id].vue index 67433c8..b625cf1 100644 --- a/frontend/pages/location/[id].vue +++ b/frontend/pages/location/[id].vue @@ -92,7 +92,6 @@ } toast.success('Location updated'); - console.log(data); location.value = data; updateModal.value = false; updating.value = false; diff --git a/frontend/pnpm-lock.yaml b/frontend/pnpm-lock.yaml index 0cf39e5..c7a48a5 100644 --- a/frontend/pnpm-lock.yaml +++ b/frontend/pnpm-lock.yaml @@ -10,6 +10,7 @@ specifiers: '@vueuse/nuxt': ^9.1.1 autoprefixer: ^10.4.8 daisyui: ^2.24.0 + isomorphic-fetch: ^3.0.0 nuxt: 3.0.0-rc.8 pinia: ^2.0.21 postcss: ^8.4.16 @@ -33,6 +34,7 @@ dependencies: vue: 3.2.38 devDependencies: + isomorphic-fetch: 3.0.0 nuxt: 3.0.0-rc.8 vitest: 0.22.1 @@ -3180,6 +3182,15 @@ packages: resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} dev: true + /isomorphic-fetch/3.0.0: + resolution: {integrity: sha512-qvUtwJ3j6qwsF3jLxkZ72qCgjMysPzDfeV240JHiGZsANBYd+EEuu35v7dfrJ9Up0Ak07D7GGSkGhCHTqg/5wA==} + dependencies: + node-fetch: 2.6.7 + whatwg-fetch: 3.6.2 + transitivePeerDependencies: + - encoding + dev: true + /jest-worker/26.6.2: resolution: {integrity: sha512-KWYVV1c4i+jbMpaBC+U++4Va0cp8OisU185o73T1vo99hqi7w8tSJfUXYswwqqrjzwxa6KpRK54WhPvwf5w6PQ==} engines: {node: '>= 10.13.0'} @@ -5720,6 +5731,10 @@ packages: /webpack-virtual-modules/0.4.4: resolution: {integrity: sha512-h9atBP/bsZohWpHnr+2sic8Iecb60GxftXsWNLLLSqewgIsGzByd2gcIID4nXcG+3tNe4GQG3dLcff3kXupdRA==} + /whatwg-fetch/3.6.2: + resolution: {integrity: sha512-bJlen0FcuU/0EMLrdbJ7zOnW6ITZLrZMIarMUVmdKtsGvZna8vxKYaexICWPfZ8qwf9fzNq+UEIZrnSaApt6RA==} + dev: true + /whatwg-url/5.0.0: resolution: {integrity: sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==} dependencies: diff --git a/frontend/test/config.ts b/frontend/test/config.ts new file mode 100644 index 0000000..d8db927 --- /dev/null +++ b/frontend/test/config.ts @@ -0,0 +1,4 @@ +export const PORT = "7745"; +export const HOST = "http://127.0.0.1"; +export const BASE_URL = HOST + ":" + PORT; + diff --git a/frontend/test/setup.ts b/frontend/test/setup.ts new file mode 100644 index 0000000..9dfd30f --- /dev/null +++ b/frontend/test/setup.ts @@ -0,0 +1,20 @@ +import { exec } from 'child_process'; +import * as config from './config'; + +export const setup = () => { + console.log('Starting Client Tests'); + console.log({ + PORT: config.PORT, + HOST: config.HOST, + BASE_URL: config.BASE_URL, + }); +}; + +export const teardown = () => { + if (process.env.TEST_SHUTDOWN_API_SERVER) { + const pc = exec('pkill -SIGTERM api'); // Kill background API process + pc.stdout.on('data', data => { + console.log(`stdout: ${data}`); + }); + } +}; diff --git a/frontend/test/vitest.config.ts b/frontend/test/vitest.config.ts new file mode 100644 index 0000000..25f08e4 --- /dev/null +++ b/frontend/test/vitest.config.ts @@ -0,0 +1,8 @@ +/// +import { defineConfig } from "vite"; + +export default defineConfig({ + test: { + globalSetup: "./test/setup.ts", + }, +});