diff --git a/.gitattributes b/.gitattributes index da23d37..0127dc5 100644 --- a/.gitattributes +++ b/.gitattributes @@ -1,2 +1,3 @@ *.sh text eol=lf +*.env text eol=lf Makefile text eol=lf \ No newline at end of file diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 6ee9dc7..579e4ad 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -32,21 +32,17 @@ jobs: - name: Set VERSION environment variable run: echo "VERSION=${{ steps.version.outputs.version }}" >> $GITHUB_ENV - - - name: Log in to DockerHub - uses: docker/login-action@v2 + + - name: Build and push Docker image using Kaniko + uses: aevea/action-kaniko@master with: + image: ${{ secrets.USER_SERVICE_NAME }} + tag: ${{ env.VERSION }} + path: ./src/ + build_file: Dockerfile username: ${{ secrets.DOCKER_USERNAME }} password: ${{ secrets.DOCKER_PASSWORD }} - - name: Build Docker image - run: | - docker build -t ${{ secrets.USER_SERVICE_NAME }}:${{ env.VERSION }} ./src - - - name: Push Docker image - run: | - docker push ${{ secrets.USER_SERVICE_NAME }}:${{ env.VERSION }} - - name: Push tag run: | git config user.name "github-actions" diff --git a/.github/workflows/sonarqube.yml b/.github/workflows/sonarqube.yml new file mode 100644 index 0000000..eb6016e --- /dev/null +++ b/.github/workflows/sonarqube.yml @@ -0,0 +1,39 @@ +name: Sonarqube Analysis + +on: + push: + branches: + - feature-sonarqube # Temporary + pull_request: + branches: + - master + - develop + +jobs: + sonarqube: + name: SonarQube + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 # Shallow clones should be disabled for a better relevancy of analysis + + - name: Set up Go + uses: actions/setup-go@v5 + with: + go-version: 1.24 + + - name: Cache Go modules + uses: actions/cache@v4 + with: + path: | + ~/go/pkg/mod + ~/.cache/go-build + key: ${{ runner.os }}-go-${{ hashFiles('**/go.sum') }} + restore-keys: | + ${{ runner.os }}-go- + + - name: SonarQube Scan + uses: SonarSource/sonarqube-scan-action@v6 + env: + SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} \ No newline at end of file diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 6009583..375ccf6 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -84,4 +84,6 @@ jobs: run: chmod 400 keys/private_key.key - name: Run tests - run: make test_integration \ No newline at end of file + run: | + chmod +x ./run-integration.sh + make test_integration MODE=ci \ No newline at end of file diff --git a/.gitignore b/.gitignore index 53a0a21..0249db8 100644 --- a/.gitignore +++ b/.gitignore @@ -3,7 +3,7 @@ test-out/ # If you prefer the allow list template instead of the deny list, see community template: # https://github.com/github/gitignore/blob/main/community/Golang/Go.AllowList.gitignore # -# Binaries for programs and plugins +# Binaries for programs and plugins. *.exe *.exe~ *.dll @@ -28,6 +28,7 @@ go.work.sum # env file .env +override.env # Editor/IDE # .idea/ diff --git a/Makefile b/Makefile index 1fdd78f..be7b594 100644 --- a/Makefile +++ b/Makefile @@ -1,5 +1,12 @@ .PHONY: run +# Usage: +# make [command] {MODE} +# +# command := run (default) / test / test_unit / test_integration +# MODE := ci / local +# + run: echo "Run using infrastructure/" @@ -9,5 +16,4 @@ test_unit: ./run-tests.sh test_integration: - docker compose -f compose.integration.yml up --build --abort-on-container-exit --exit-code-from test-runner - docker compose -f compose.integration.yml down + ./run-integration.sh $(MODE) \ No newline at end of file diff --git a/ci.override.env b/ci.override.env new file mode 100644 index 0000000..e86aab0 --- /dev/null +++ b/ci.override.env @@ -0,0 +1,7 @@ +RESERVATION_SERVICE_PATH=./services/reservation-service +RESERVATION_SERVICE_REPO=https://github.com/book-em/reservation-service.git +RESERVATION_SERVICE_BRANCH=feature-delete-user-complete + +ROOM_SERVICE_PATH=./services/room-service +ROOM_SERVICE_REPO=https://github.com/book-em/room-service.git +ROOM_SERVICE_BRANCH=feature-delete-user-complete \ No newline at end of file diff --git a/compose.integration.yml b/compose.integration.yml index f07f947..3229ea6 100644 --- a/compose.integration.yml +++ b/compose.integration.yml @@ -1,15 +1,28 @@ services: test-runner: build: - context: ./src + context: ${TEST_RUNNER_PATH}/src dockerfile: test.Dockerfile depends_on: db: condition: service_healthy user-service: condition: service_healthy + room-db: + condition: service_healthy + room-service: + condition: service_healthy + room-images: + condition: service_healthy + reservation-db: + condition: service_healthy + reservation-service: + condition: service_healthy + environment: + JWT_PUBLIC_KEY_PATH: /app/keys/public_key.pem volumes: - go-mod-cache:/go/pkg/mod + - ${USER_SERVICE_PATH}/keys/public_key.pem:/app/keys/public_key.pem:ro user-service: build: @@ -27,6 +40,8 @@ services: JWT_PRIVATE_KEY_PATH: /app/keys/private_key.key JWT_PUBLIC_KEY_PATH: /app/keys/public_key.pem ENABLE_TEST_MODE: "true" + SERVICE_NAME: user-service + DEPLOYMENT_ENV: test depends_on: db: condition: service_healthy @@ -56,5 +71,110 @@ services: timeout: 3s retries: 5 + room-service: + build: + context: ${ROOM_SERVICE_PATH}/src + dockerfile: Dockerfile + image: room-service-test + ports: + - "${ROOM_SERVICE_PORT}:8080" + environment: + DB_HOST: room-db + DB_PORT: 5432 + DB_NAME: bookem_roomdb_test + DB_USER: bookem_roomdb_user + DB_PASSWORD: testpass + JWT_PUBLIC_KEY_PATH: /app/keys/public_key.pem + ENABLE_TEST_MODE: "true" + depends_on: + room-db: + condition: service_healthy + volumes: + - ${ROOM_SERVICE_PATH}/keys/public_key.pem:/app/keys/public_key.pem:ro + - room_images:/app/images/ + + healthcheck: + test: ["CMD-SHELL", "wget --spider --tries=1 --no-verbose http://room-service:8080/healthz || exit 1"] + interval: 3s + timeout: 3s + retries: 5 + + room-db: + image: postgres:15-alpine + environment: + POSTGRES_DB: bookem_roomdb_test + POSTGRES_USER: bookem_roomdb_user + POSTGRES_PASSWORD: testpass + ports: + - "${ROOM_SERVICE_DB_PORT}:5432" + volumes: + - type: tmpfs + target: /var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U $$POSTGRES_USER -d $$POSTGRES_DB"] + interval: 3s + timeout: 3s + retries: 5 + + room-images: + restart: always + build: + context: ${ROOM_SERVICE_PATH}/nginx + dockerfile: Dockerfile + ports: + - "${ROOM_SERVICE_IMAGES_PORT}:80" + volumes: + - room_images:/usr/share/nginx/html/images/ + healthcheck: + test: service nginx status || exit 1 + interval: 15s + timeout: 3s + retries: 2 + + reservation-service: + build: + context: ${RESERVATION_SERVICE_PATH}/src + dockerfile: Dockerfile + image: reservation-service-test + ports: + - "${RESERVATION_SERVICE_PORT}:8080" + environment: + DB_HOST: reservation-db + DB_PORT: 5432 + DB_NAME: bookem_reservationdb_test + DB_USER: bookem_reservationdb_user + DB_PASSWORD: testpass + JWT_PUBLIC_KEY_PATH: /app/keys/public_key.pem + ENABLE_TEST_MODE: "true" + depends_on: + reservation-db: + condition: service_healthy + volumes: + - ${RESERVATION_SERVICE_PATH}/keys/public_key.pem:/app/keys/public_key.pem:ro + + healthcheck: + test: ["CMD-SHELL", "wget --spider --tries=1 --no-verbose http://reservation-service:8080/healthz || exit 1"] + interval: 3s + timeout: 3s + retries: 5 + + reservation-db: + image: postgres:15-alpine + environment: + POSTGRES_DB: bookem_reservationdb_test + POSTGRES_USER: bookem_reservationdb_user + POSTGRES_PASSWORD: testpass + ports: + - "${RESERVATION_SERVICE_DB_PORT}:5432" + volumes: + - type: tmpfs + target: /var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U $$POSTGRES_USER -d $$POSTGRES_DB"] + interval: 3s + timeout: 3s + retries: 5 + volumes: - go-mod-cache: \ No newline at end of file + go-mod-cache: + room_images: \ No newline at end of file diff --git a/default.env b/default.env new file mode 100644 index 0000000..3ab1ffc --- /dev/null +++ b/default.env @@ -0,0 +1,14 @@ +TEST_RUNNER_PATH=. + +RESERVATION_SERVICE_PATH=../reservation-service +RESERVATION_SERVICE_PORT=0 +RESERVATION_SERVICE_DB_PORT=0 + +USER_SERVICE_PATH=. +USER_SERVICE_PORT=0 +USER_SERVICE_DB_PORT=0 + +ROOM_SERVICE_PATH=../room-service +ROOM_SERVICE_PORT=0 +ROOM_SERVICE_DB_PORT=0 +ROOM_SERVICE_IMAGES_PORT=0 diff --git a/run-integration.sh b/run-integration.sh new file mode 100644 index 0000000..d23a3a8 --- /dev/null +++ b/run-integration.sh @@ -0,0 +1,58 @@ +#!/bin/bash +set -e + +# run-tests.sh [ci|local] +# +# - ci: Loads default + ci.override.env +# - local: Loads default + override.env + +# Determine mode + +mode="$1" +if [[ "$mode" != "ci" && "$mode" != "local" ]]; then + echo "Must specify 'ci' or 'local' as first argument" + exit 1 +fi + +# Determine which env files to read + +ENV_FILES=("./default.env") + +if [[ "$mode" == "ci" ]]; then + echo "Loading CI overrides..." + ENV_FILES+=("./ci.override.env") +else + echo "Loading local overrides..." + ENV_FILES+=("./override.env") +fi + +# Load env files + +for file in "${ENV_FILES[@]}"; do + if [[ -f "$file" ]]; then + echo "Loading env vars from $file" + set -o allexport + source "$file" + set +o allexport + else + echo "Skipping missing optional env file: $file" + fi +done + +# Clone repos if CI + +if [[ "$mode" == "ci" ]]; then + echo "Extracting the test private key for $USER_SERVICE_REPO" + tar -xvzf "$USER_SERVICE_PATH/keys/keys.tar.gz" -C "$USER_SERVICE_PATH/keys" + + echo "Cloning $RESERVATION_SERVICE_REPO branch $RESERVATION_SERVICE_REPO into $RESERVATION_SERVICE_PATH" + git clone --branch "$RESERVATION_SERVICE_BRANCH" "$RESERVATION_SERVICE_REPO" "$RESERVATION_SERVICE_PATH" + + echo "Cloning $ROOM_SERVICE_REPO branch $ROOM_SERVICE_BRANCH into $ROOM_SERVICE_PATH" + git clone --branch "$ROOM_SERVICE_BRANCH" "$ROOM_SERVICE_REPO" "$ROOM_SERVICE_PATH" +fi + +# Run integration tests + +docker compose -f ./compose.integration.yml up --build --abort-on-container-exit --exit-code-from test-runner +docker compose -f ./compose.integration.yml down \ No newline at end of file diff --git a/services/.gitignore b/services/.gitignore new file mode 100644 index 0000000..0a00d70 --- /dev/null +++ b/services/.gitignore @@ -0,0 +1 @@ +*/ \ No newline at end of file diff --git a/sonar-project.properties b/sonar-project.properties new file mode 100644 index 0000000..09f44f7 --- /dev/null +++ b/sonar-project.properties @@ -0,0 +1,14 @@ +sonar.projectKey=book-em_user-service +sonar.organization=book-em + + +# This is the name and version displayed in the SonarCloud UI. +sonar.projectName=user-service +sonar.projectVersion=1.0 + + +# Path is relative to the sonar-project.properties file. Replace "\" by "/" on Windows. +sonar.sources=. + +# Encoding of the source code. Default is default system encoding +#sonar.sourceEncoding=UTF-8 \ No newline at end of file diff --git a/src/api/handler.go b/src/api/handler.go index 1760b1c..9ad5d79 100644 --- a/src/api/handler.go +++ b/src/api/handler.go @@ -4,12 +4,13 @@ import ( "bookem-user-service/api/middleware" domain "bookem-user-service/domain" service "bookem-user-service/service" + utils "bookem-user-service/util" "fmt" - "log" "net/http" "strconv" "github.com/gin-gonic/gin" + "go.opentelemetry.io/otel/attribute" ) type Handler struct { @@ -20,121 +21,160 @@ func NewHandler(us service.Service) Handler { return Handler{us} } -func (h *Handler) registerUser(ctx *gin.Context) { +func (h *Handler) registerUser(c *gin.Context) { + utils.TEL.Push(c.Request.Context(), "register-user") + defer utils.TEL.Pop() + var dto domain.UserCreateDTO - if err := ctx.ShouldBindJSON(&dto); err != nil { - ctx.Error(fmt.Errorf("%w: %v", domain.ErrInvalidInput, err)) + + if err := c.ShouldBindJSON(&dto); err != nil { + c.Error(fmt.Errorf("%w: %v", domain.ErrInvalidInput, err)) + utils.TEL.Error("failed binding JSON", err) return } - user, err := h.service.Register(&dto) + user, err := h.service.Register(utils.TEL.Ctx(), &dto) if err != nil { - ctx.Error(err) + c.Error(err) + utils.TEL.Error("failed registering user", err) return } - ctx.JSON(http.StatusCreated, domain.NewUserDTO(user)) + c.JSON(http.StatusCreated, domain.NewUserDTO(user)) } -func (h *Handler) login(ctx *gin.Context) { +func (h *Handler) login(c *gin.Context) { + utils.TEL.Push(c.Request.Context(), "login-user") + defer utils.TEL.Pop() + var dto domain.LoginDTO - if err := ctx.ShouldBindJSON(&dto); err != nil { - ctx.Error(fmt.Errorf("%w: %v", domain.ErrInvalidInput, err)) + if err := c.ShouldBindJSON(&dto); err != nil { + c.Error(fmt.Errorf("%w: %v", domain.ErrInvalidInput, err)) + utils.TEL.Error("failed binding JSON", err) return } - jwt, err := h.service.Login(dto) + jwt, err := h.service.Login(utils.TEL.Ctx(), dto) if err != nil { - ctx.Error(err) + c.Error(err) + utils.TEL.Error("failed logging in user", err) return } - ctx.JSON(http.StatusOK, domain.JWTDTO{Jwt: jwt}) + c.JSON(http.StatusOK, domain.JWTDTO{Jwt: jwt}) } -func (h *Handler) update(ctx *gin.Context) { - jwt, err := middleware.GetJwt(ctx) +func (h *Handler) update(c *gin.Context) { + utils.TEL.Push(c.Request.Context(), "update-user") + defer utils.TEL.Pop() + + jwt, err := middleware.GetJwt(c) if err != nil { - ctx.Error(fmt.Errorf("%w: %v", domain.ErrUnauthenticated, err)) + c.Error(fmt.Errorf("%w: %v", domain.ErrUnauthenticated, err)) + utils.TEL.Error("unauthenticated", err) return } var dto domain.UserUpdateDTO - if err := ctx.ShouldBindJSON(&dto); err != nil { - ctx.Error(fmt.Errorf("%w: %v", domain.ErrInvalidInput, err)) + if err := c.ShouldBindJSON(&dto); err != nil { + c.Error(fmt.Errorf("%w: %v", domain.ErrInvalidInput, err)) + utils.TEL.Error("failed binding JSON", err) return } - _, err = h.service.Update(jwt.ID, dto) + utils.TEL.SetUser(jwt.ID) + + _, err = h.service.Update(utils.TEL.Ctx(), jwt.ID, dto) if err != nil { - ctx.Error(err) + c.Error(err) + utils.TEL.Error("failed updating user", err) return } - ctx.JSON(http.StatusNoContent, nil) + c.JSON(http.StatusNoContent, nil) } -func (h *Handler) changePassword(ctx *gin.Context) { - jwt, err := middleware.GetJwt(ctx) +func (h *Handler) changePassword(c *gin.Context) { + utils.TEL.Push(c.Request.Context(), "change-user-password") + defer utils.TEL.Pop() + + jwt, err := middleware.GetJwt(c) if err != nil { - ctx.Error(fmt.Errorf("%w: %v", domain.ErrUnauthenticated, err)) + c.Error(fmt.Errorf("%w: %v", domain.ErrUnauthenticated, err)) + utils.TEL.Error("unauthenticated", err) return } var dto domain.PasswordUpdateDTO - if err := ctx.ShouldBindJSON(&dto); err != nil { - ctx.Error(fmt.Errorf("%w: %v", domain.ErrInvalidInput, err)) + if err := c.ShouldBindJSON(&dto); err != nil { + c.Error(fmt.Errorf("%w: %v", domain.ErrInvalidInput, err)) + utils.TEL.Error("failed binding JSON", err) return } - _, err = h.service.ChangePassword(jwt.ID, dto) + utils.TEL.SetUser(jwt.ID) + + _, err = h.service.ChangePassword(utils.TEL.Ctx(), jwt.ID, dto) if err != nil { - ctx.Error(err) + c.Error(err) + utils.TEL.Error("failed changing password", err) return } - ctx.JSON(http.StatusNoContent, nil) + c.JSON(http.StatusNoContent, nil) } -func (h *Handler) findById(ctx *gin.Context) { - id, err := strconv.Atoi(ctx.Param("id")) +func (h *Handler) findById(c *gin.Context) { + utils.TEL.Push(c.Request.Context(), "find-user-by-id") + defer utils.TEL.Pop() + + id, err := strconv.Atoi(c.Param("id")) if err != nil { - log.Printf("Could not parse ID: %s", err.Error()) - ctx.Error(err) + c.Error(err) + utils.TEL.Error("failed parsing ID", err) return } - log.Printf("Find user by id %d", id) + utils.TEL.SetAttrib(attribute.Int("id", id)) + utils.TEL.Debug("find user", "id", id) - user, err := h.service.FindById(uint(id)) + user, err := h.service.FindById(utils.TEL.Ctx(), uint(id)) if err != nil { - ctx.Error(err) + c.Error(err) + utils.TEL.Error("failed finding user by ID", err) return } - ctx.JSON(http.StatusOK, domain.NewUserDTO(user)) + c.JSON(http.StatusOK, domain.NewUserDTO(user)) } -func (h *Handler) deleteById(ctx *gin.Context) { - id, err := strconv.Atoi(ctx.Param("id")) +func (h *Handler) delete(c *gin.Context) { + utils.TEL.Push(c.Request.Context(), "update-user") + defer utils.TEL.Pop() + + jwt, err := middleware.GetJwt(c) if err != nil { - log.Printf("Could not parse ID: %s", err.Error()) - ctx.Error(err) + c.Error(fmt.Errorf("%w: %v", domain.ErrUnauthenticated, err)) + utils.TEL.Error("unauthenticatetd", err) return } - jwt, err := middleware.GetJwt(ctx) + jwtString, err := middleware.GetJwtString(c) if err != nil { - ctx.Error(fmt.Errorf("%w: %v", domain.ErrUnauthenticated, err)) + c.Error(fmt.Errorf("%w: %v", domain.ErrUnauthenticated, err)) + utils.TEL.Error("unauthenticatetd", err) return } - err = h.service.Delete(jwt.ID, uint(id)) + utils.TEL.SetUser(jwt.ID) + + err = h.service.Delete(utils.TEL.Ctx(), jwt.ID, jwtString) if err != nil { - ctx.Error(err) + c.Error(err) + utils.TEL.Error("could not delete user", err) return } - ctx.JSON(http.StatusNoContent, nil) + c.JSON(http.StatusNoContent, nil) } diff --git a/src/api/middleware/error_middleware.go b/src/api/middleware/error_middleware.go index d530e24..9bd52f8 100644 --- a/src/api/middleware/error_middleware.go +++ b/src/api/middleware/error_middleware.go @@ -31,6 +31,8 @@ func mapErrorToStatus(err error) int { return http.StatusNotFound case errors.Is(err, domain.ErrWrongPassword): return http.StatusUnauthorized + case errors.Is(err, domain.ErrDeletedAccount): + return http.StatusUnauthorized default: return http.StatusInternalServerError } diff --git a/src/api/middleware/jwt.go b/src/api/middleware/jwt.go new file mode 100644 index 0000000..0075dc7 --- /dev/null +++ b/src/api/middleware/jwt.go @@ -0,0 +1,44 @@ +package middleware + +import ( + "fmt" + "os" + + "github.com/golang-jwt/jwt/v5" +) + +var JWT_PUBLIC_KEY_PATH = os.Getenv("JWT_PUBLIC_KEY_PATH") + +var ParseJWT = parseJWT + +// ParseJWT validates and extracts claims from an encoded JWT string. +func parseJWT(tokenString string) (jwt.MapClaims, error) { + publicKeyData, err := os.ReadFile(JWT_PUBLIC_KEY_PATH) + if err != nil { + return nil, fmt.Errorf("could not open public key %s: %w", JWT_PUBLIC_KEY_PATH, err) + } + + publicKey, err := jwt.ParseRSAPublicKeyFromPEM(publicKeyData) + if err != nil { + return nil, fmt.Errorf("could not parse public key: %w", err) + } + + token, err := jwt.Parse(tokenString, func(token *jwt.Token) (interface{}, error) { + if _, ok := token.Method.(*jwt.SigningMethodRSA); !ok { + return nil, fmt.Errorf("unexpected signing method") + } + return publicKey, nil + }) + + if err != nil { + return nil, err + } + + claims, ok := token.Claims.(jwt.MapClaims) + + if !ok { + return nil, fmt.Errorf("invalid jwt token or claims") + } + + return claims, nil +} diff --git a/src/api/middleware/jwt_middleware.go b/src/api/middleware/jwt_middleware.go index 6b29357..527a234 100644 --- a/src/api/middleware/jwt_middleware.go +++ b/src/api/middleware/jwt_middleware.go @@ -63,3 +63,18 @@ func GetJwt(ctx *gin.Context) (*Jwt, error) { return &jwt, nil } + +func GetJwtFromString(jwtString string) (*Jwt, error) { + jwtData, err := ParseJWT(jwtString) + if err != nil { + return nil, err + } + + jwt := Jwt{ + ID: uint(jwtData["sub"].(float64)), + Username: jwtData["username"].(string), + Role: domain.UserRole(jwtData["role"].(string)), + } + + return &jwt, nil +} diff --git a/src/api/middleware/prometheus_middleware.go b/src/api/middleware/prometheus_middleware.go new file mode 100644 index 0000000..bec6ae3 --- /dev/null +++ b/src/api/middleware/prometheus_middleware.go @@ -0,0 +1,49 @@ +package middleware + +import ( + utils "bookem-user-service/util" + "fmt" + + "github.com/gin-gonic/gin" + "github.com/prometheus/client_golang/prometheus" +) + +var ( + httpRequestsTotal = prometheus.NewCounterVec( + prometheus.CounterOpts{ + Name: "http_requests_total", + Help: "Total number of HTTP requests", + }, + []string{"method", "status", "endpoint"}, + ) + + httpResponseSizeBytes = prometheus.NewCounterVec( + prometheus.CounterOpts{ + Name: "http_response_size_bytes", + Help: "Total response size in bytes", + }, + []string{"endpoint", "status"}, + ) +) + +func PrometheusMiddleware() gin.HandlerFunc { + prometheus.MustRegister(httpRequestsTotal) + prometheus.MustRegister(httpResponseSizeBytes) + + return func(c *gin.Context) { + c.Next() + + endpoint := c.FullPath() + status := fmt.Sprintf("%d", c.Writer.Status()) + method := c.Request.Method + size := float64(c.Writer.Size()) + + httpRequestsTotal.WithLabelValues(method, status, endpoint).Inc() + + if size >= 0 { + httpResponseSizeBytes.WithLabelValues(endpoint, status).Add(float64(size)) + } else { + utils.TEL.Warn("Response size < 0, cannot push to Prometheus", "size", size) + } + } +} diff --git a/src/api/route.go b/src/api/route.go index bb0c6ed..5696a48 100644 --- a/src/api/route.go +++ b/src/api/route.go @@ -21,5 +21,5 @@ func (r *Route) Route(rg *gin.RouterGroup) { rg.PUT("/update", r.handler.update) rg.PUT("/password", r.handler.changePassword) rg.GET("/:id", r.handler.findById) - rg.DELETE("/:id", r.handler.deleteById) + rg.DELETE("/", r.handler.delete) } diff --git a/src/client/reservationclient/client.go b/src/client/reservationclient/client.go new file mode 100644 index 0000000..b61f08b --- /dev/null +++ b/src/client/reservationclient/client.go @@ -0,0 +1,94 @@ +package reservationclient + +import ( + utils "bookem-user-service/util" + "context" + "encoding/json" + "fmt" + "io" + "net/http" +) + +type ReservationClient interface { + // GetActiveGuestReservations finds all reservations made by `guest` that + // haven't completed yet. The user must be a guest. + GetActiveGuestReservations(ctx context.Context, jwt string) ([]ReservationDTO, error) + // GetActiveHostReservations finds all reservations made to rooms owned by + // `host` that haven't completed yet. The user must be a host. + GetActiveHostReservations(ctx context.Context, jwt string) ([]ReservationDTO, error) +} + +type reservationClient struct { + baseURL string +} + +func NewReservationClient() ReservationClient { + return &reservationClient{ + baseURL: "http://reservation-service:8080/api", // TODO: This should not be hardcoded + } +} + +func (c *reservationClient) GetActiveGuestReservations(ctx context.Context, jwt string) ([]ReservationDTO, error) { + utils.TEL.Push(ctx, "get-active-reservations-for-guest") + defer utils.TEL.Pop() + + req, err := http.NewRequest(http.MethodGet, fmt.Sprintf("%s/reservations/guest/active", c.baseURL), nil) + if err != nil { + utils.TEL.Error("preparing request error ", err) + return nil, err + } + req.Header.Add("Authorization", "Bearer "+jwt) + resp, err := http.DefaultClient.Do(req) + + if err != nil { + utils.TEL.Error("request error ", err) + return nil, err + } + + bodyBytes, err := io.ReadAll(resp.Body) + if err != nil { + utils.TEL.Error("parsing response error", err) + return nil, err + } + + var obj []ReservationDTO + if err := json.Unmarshal(bodyBytes, &obj); err != nil { + utils.TEL.Error("JSON unmarshall error", err) + return nil, err + } + + return obj, nil +} + +func (c *reservationClient) GetActiveHostReservations(ctx context.Context, jwt string) ([]ReservationDTO, error) { + utils.TEL.Push(ctx, "get-active-reservations-for-host") + defer utils.TEL.Pop() + + req, err := http.NewRequest(http.MethodGet, fmt.Sprintf("%s/reservations/host/active", c.baseURL), nil) + if err != nil { + return nil, err + } + req.Header.Add("Authorization", "Bearer "+jwt) + resp, err := http.DefaultClient.Do(req) + + if err != nil { + utils.TEL.Error("error ", err) + return nil, err + } + + bodyBytes, err := io.ReadAll(resp.Body) + if err != nil { + utils.TEL.Error("parsing response error", err) + + return nil, err + } + + var obj []ReservationDTO + if err := json.Unmarshal(bodyBytes, &obj); err != nil { + utils.TEL.Error("JSON unmarshall error", err) + + return nil, err + } + + return obj, nil +} diff --git a/src/client/reservationclient/model.go b/src/client/reservationclient/model.go new file mode 100644 index 0000000..a752013 --- /dev/null +++ b/src/client/reservationclient/model.go @@ -0,0 +1,35 @@ +package reservationclient + +import "time" + +type ReservationDTO struct { + ID uint `gorm:"primaryKey"` + RoomID uint `gorm:"not null"` + RoomAvailabilityID uint `gorm:"not null"` + RoomPriceID uint `gorm:"not null"` + GuestID uint `gorm:"not null"` + DateFrom time.Time `gorm:"not null"` + DateTo time.Time `gorm:"not null"` + GuestCount uint `gorm:"not null"` + Cancelled bool `gorm:"not null"` + Cost uint `gorm:"not null"` +} + +type CreateReservationRequestDTO struct { + RoomID uint `json:"roomId"` + DateFrom time.Time `json:"dateFrom"` + DateTo time.Time `json:"dateTo"` + GuestCount uint `json:"guestCount"` +} + +type ReservationRequestDTO struct { + ID uint `json:"id"` + RoomID uint `json:"roomId"` + DateFrom time.Time `json:"dateFrom"` + DateTo time.Time `json:"dateTo"` + GuestCount uint `json:"guestCount"` + GuestID uint `json:"guestId"` + Status string `json:"status"` + Cost uint `json:"cost"` + GuestCancelCount uint `json:"guestCancelCount"` +} diff --git a/src/client/roomclient/client.go b/src/client/roomclient/client.go index 7a8de21..cc53837 100644 --- a/src/client/roomclient/client.go +++ b/src/client/roomclient/client.go @@ -1,14 +1,16 @@ package roomclient -import "bookem-user-service/domain" +import ( + utils "bookem-user-service/util" + "context" + "encoding/json" + "fmt" + "io" + "net/http" +) type RoomClient interface { - // GetPendingGuestReservations finds all reservations made by `guest` that - // haven't completed yet. The user must be a guest. - GetPendingGuestReservations(guest *domain.User) ([]ReservationDTO, error) - // GetActiveHostReservations finds all reservations made to rooms owned by - // `host` that haven't completed yet. The user must be a host. - GetActiveHostReservations(host *domain.User) ([]ReservationDTO, error) + DeleteHostRooms(ctx context.Context, jwt string) ([]RoomDTO, error) } type roomClient struct { @@ -17,22 +19,38 @@ type roomClient struct { func NewRoomClient() RoomClient { return &roomClient{ - baseURL: "http://localhost:9999", // Placeholder URL for now + baseURL: "http://room-service:8080/api/", // TODO: This should not be hardcoded } } -func (c *roomClient) GetPendingGuestReservations(guest *domain.User) ([]ReservationDTO, error) { - if guest.Role != domain.Guest { - return []ReservationDTO{}, domain.ErrUnauthorized +func (c *roomClient) DeleteHostRooms(ctx context.Context, jwt string) ([]RoomDTO, error) { + utils.TEL.Push(ctx, "delete-host-rooms") + defer utils.TEL.Pop() + + req, err := http.NewRequest(http.MethodDelete, fmt.Sprintf("%shost/", c.baseURL), nil) + if err != nil { + utils.TEL.Error("preparing request error ", err) + return nil, err } + req.Header.Add("Authorization", "Bearer "+jwt) + resp, err := http.DefaultClient.Do(req) - return []ReservationDTO{}, nil -} + if err != nil { + utils.TEL.Error("request error ", err) + return nil, err + } + + bodyBytes, err := io.ReadAll(resp.Body) + if err != nil { + utils.TEL.Error("parsing response error", err) + return nil, err + } -func (c *roomClient) GetActiveHostReservations(host *domain.User) ([]ReservationDTO, error) { - if host.Role != domain.Host { - return []ReservationDTO{}, domain.ErrUnauthorized + var obj []RoomDTO + if err := json.Unmarshal(bodyBytes, &obj); err != nil { + utils.TEL.Error("JSON unmarshall error", err) + return nil, err } - return []ReservationDTO{}, nil + return obj, nil } diff --git a/src/client/roomclient/model.go b/src/client/roomclient/model.go index 5ff0045..9e4529b 100644 --- a/src/client/roomclient/model.go +++ b/src/client/roomclient/model.go @@ -1,7 +1,67 @@ package roomclient +import "time" + type RoomDTO struct { + ID uint `json:"id"` + HostID uint `json:"hostID"` + Name string `json:"name"` + Description string `json:"description"` + Address string `json:"address"` + MinGuests uint `json:"minGuests"` + MaxGuests uint `json:"maxGuests"` + Photos []string `json:"photos"` + Commodities []string `json:"commodities"` + AutoApprove bool `json:"autoApprove"` + Deleted bool `json:"deleted"` +} + +type CreateRoomDTO struct { + HostID uint `json:"hostID"` + Name string `json:"name"` + Description string `json:"description"` + Address string `json:"address"` + MinGuests uint `json:"minGuests"` + MaxGuests uint `json:"maxGuests"` + PhotosPayload []string `json:"photosPayload"` + Commodities []string `json:"commodities"` + AutoApprove bool `json:"autoApprove"` +} + +// --------------------------------------------------------------- + +type CreateRoomAvailabilityListDTO struct { + RoomID uint `json:"roomId"` + Items []CreateRoomAvailabilityItemDTO `json:"items"` } -type ReservationDTO struct { +type CreateRoomAvailabilityItemDTO struct { + // ExistingID is either the ID of an RoomAvailabilityItem that already + // exists, or 0 if this is a new item. When 0, a new one will be created in + // the DB. When not 0, it will reuse the existing object. + ExistingID uint `json:"existingId"` + DateFrom time.Time `json:"dateFrom"` + DateTo time.Time `json:"dateTo"` + Available bool `json:"available"` } + +// --------------------------------------------------------------- + +type CreateRoomPriceListDTO struct { + RoomID uint `json:"roomId"` + Items []CreateRoomPriceItemDTO `json:"items"` + BasePrice uint `json:"basePrice"` + PerGuest bool `json:"perGuest"` +} + +type CreateRoomPriceItemDTO struct { + // ExistingID is either the ID of an RoomPriceItem that already + // exists, or 0 if this is a new item. When 0, a new one will be created in + // the DB. When not 0, it will reuse the existing object. + ExistingID uint `json:"existingId"` + DateFrom time.Time `json:"dateFrom"` + DateTo time.Time `json:"dateTo"` + Price uint `json:"price"` +} + +// --------------------------------------------------------------- diff --git a/src/domain/dto.go b/src/domain/dto.go index 1065f6a..edf4876 100644 --- a/src/domain/dto.go +++ b/src/domain/dto.go @@ -18,6 +18,7 @@ type UserDTO struct { Surname string `json:"surname" ` Address string `json:"address" ` Role string `json:"role" ` + Deleted bool `json:"deleted" ` } type UserUpdateDTO struct { @@ -38,6 +39,7 @@ func NewUserDTO(user *User) UserDTO { Surname: user.Surname, Address: user.Address, Role: string(user.Role), + Deleted: user.Deleted, } } diff --git a/src/domain/error.go b/src/domain/error.go index c484cb8..a901327 100644 --- a/src/domain/error.go +++ b/src/domain/error.go @@ -9,6 +9,7 @@ var ( ErrDBInternal = errors.New("database internal error") ErrInvalidInput = errors.New("invalid input") ErrLoginFailed = errors.New("invalid user or password") + ErrDeletedAccount = errors.New("deleted account") ErrUnauthorized = errors.New("unauthorized") ErrPasswordsNotMatch = errors.New("confirm password does not match") ErrPasswordNotChanged = errors.New("password must be different") @@ -16,8 +17,8 @@ var ( ErrNotFound = errors.New("not found") ErrWrongPassword = errors.New("incorrect password") - ErrGuestHasReservations = errors.New("user has pending reservations") - ErrHostHasReservations = errors.New("user has room(s) with pending reservations") + ErrGuestHasReservations = errors.New("user has active reservations") + ErrHostHasReservations = errors.New("user has room(s) with active reservations") ErrCannotDeleteAdmin = errors.New("admin accounts cannot be deleted") ) diff --git a/src/domain/user.go b/src/domain/user.go index 067c867..7b628ac 100644 --- a/src/domain/user.go +++ b/src/domain/user.go @@ -17,4 +17,5 @@ type User struct { Surname string `json:"surname" gorm:"type:varchar(60);not null;"` Role UserRole `json:"role" gorm:"type:varchar(5);not null;check:role IN ('guest','host','admin')"` Address string `json:"address" gorm:"type:varchar(150);not null"` + Deleted bool `json:"deleted" gorm:"type:boolean;not null;default:false"` } diff --git a/src/go.mod b/src/go.mod index 46fe94f..284c94c 100644 --- a/src/go.mod +++ b/src/go.mod @@ -3,54 +3,80 @@ module bookem-user-service go 1.24.5 require ( + github.com/gin-contrib/cors v1.7.6 github.com/gin-gonic/gin v1.10.1 github.com/golang-jwt/jwt/v5 v5.2.3 github.com/lib/pq v1.10.9 - github.com/stretchr/testify v1.10.0 + github.com/prometheus/client_golang v1.23.2 + github.com/stretchr/testify v1.11.1 + go.opentelemetry.io/contrib/instrumentation/github.com/gin-gonic/gin/otelgin v0.63.0 + go.opentelemetry.io/otel v1.38.0 + go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.38.0 + go.opentelemetry.io/otel/sdk v1.38.0 + go.opentelemetry.io/otel/trace v1.38.0 ) require ( + github.com/beorn7/perks v1.0.1 // indirect + github.com/cenkalti/backoff/v5 v5.0.3 // indirect + github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/davecgh/go-spew v1.1.1 // indirect - github.com/gin-contrib/cors v1.7.6 // indirect + github.com/go-logr/logr v1.4.3 // indirect + github.com/go-logr/stdr v1.2.2 // indirect + github.com/google/uuid v1.6.0 // indirect + github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.2 // indirect github.com/jackc/pgpassfile v1.0.0 // indirect github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect github.com/jackc/pgx/v5 v5.6.0 // indirect github.com/jackc/puddle/v2 v2.2.2 // indirect github.com/jinzhu/inflection v1.0.0 // indirect github.com/jinzhu/now v1.1.5 // indirect - github.com/kr/text v0.2.0 // indirect + github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect + github.com/prometheus/client_model v0.6.2 // indirect + github.com/prometheus/common v0.66.1 // indirect + github.com/prometheus/procfs v0.16.1 // indirect github.com/rogpeppe/go-internal v1.14.1 // indirect + github.com/samber/lo v1.51.0 // indirect + github.com/samber/slog-common v0.19.0 // indirect github.com/stretchr/objx v0.5.2 // indirect - golang.org/x/sync v0.15.0 // indirect + go.opentelemetry.io/auto/sdk v1.1.0 // indirect + go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.38.0 // indirect + go.opentelemetry.io/otel/metric v1.38.0 // indirect + go.opentelemetry.io/proto/otlp v1.7.1 // indirect + go.yaml.in/yaml/v2 v2.4.2 // indirect + golang.org/x/sync v0.16.0 // indirect + google.golang.org/genproto/googleapis/api v0.0.0-20250825161204-c5933d9347a5 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20250825161204-c5933d9347a5 // indirect + google.golang.org/grpc v1.75.0 // indirect ) require ( - github.com/bytedance/sonic v1.13.3 // indirect - github.com/bytedance/sonic/loader v0.2.4 // indirect - github.com/cloudwego/base64x v0.1.5 // indirect - github.com/cloudwego/iasm v0.2.0 // indirect - github.com/gabriel-vasile/mimetype v1.4.9 // indirect + github.com/bytedance/sonic v1.14.0 // indirect + github.com/bytedance/sonic/loader v0.3.0 // indirect + github.com/cloudwego/base64x v0.1.6 // indirect + github.com/gabriel-vasile/mimetype v1.4.10 // indirect github.com/gin-contrib/sse v1.1.0 // indirect github.com/go-playground/locales v0.14.1 // indirect github.com/go-playground/universal-translator v0.18.1 // indirect - github.com/go-playground/validator/v10 v10.26.0 // indirect + github.com/go-playground/validator/v10 v10.27.0 // indirect github.com/goccy/go-json v0.10.5 // indirect github.com/json-iterator/go v1.1.12 // indirect - github.com/klauspost/cpuid/v2 v2.2.10 // indirect + github.com/klauspost/cpuid/v2 v2.3.0 // indirect github.com/leodido/go-urn v1.4.0 // indirect github.com/mattn/go-isatty v0.0.20 // indirect github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect github.com/modern-go/reflect2 v1.0.2 // indirect github.com/pelletier/go-toml/v2 v2.2.4 // indirect + github.com/samber/slog-multi v1.5.0 github.com/twitchyliquid64/golang-asm v0.15.1 // indirect github.com/ugorji/go/codec v1.3.0 // indirect - golang.org/x/arch v0.18.0 // indirect - golang.org/x/crypto v0.39.0 - golang.org/x/net v0.41.0 // indirect - golang.org/x/sys v0.33.0 // indirect - golang.org/x/text v0.26.0 // indirect - google.golang.org/protobuf v1.36.6 // indirect + golang.org/x/arch v0.20.0 // indirect + golang.org/x/crypto v0.41.0 + golang.org/x/net v0.43.0 // indirect + golang.org/x/sys v0.35.0 // indirect + golang.org/x/text v0.28.0 // indirect + google.golang.org/protobuf v1.36.8 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect gorm.io/driver/postgres v1.6.0 gorm.io/gorm v1.30.1 diff --git a/src/go.sum b/src/go.sum index 1a1459f..e89c01d 100644 --- a/src/go.sum +++ b/src/go.sum @@ -1,53 +1,52 @@ -github.com/bytedance/sonic v1.11.6 h1:oUp34TzMlL+OY1OUWxHqsdkgC/Zfc85zGqw9siXjrc0= -github.com/bytedance/sonic v1.11.6/go.mod h1:LysEHSvpvDySVdC2f87zGWf6CIKJcAvqab1ZaiQtds4= -github.com/bytedance/sonic v1.13.3 h1:MS8gmaH16Gtirygw7jV91pDCN33NyMrPbN7qiYhEsF0= -github.com/bytedance/sonic v1.13.3/go.mod h1:o68xyaF9u2gvVBuGHPlUVCy+ZfmNNO5ETf1+KgkJhz4= -github.com/bytedance/sonic/loader v0.1.1 h1:c+e5Pt1k/cy5wMveRDyk2X4B9hF4g7an8N3zCYjJFNM= -github.com/bytedance/sonic/loader v0.1.1/go.mod h1:ncP89zfokxS5LZrJxl5z0UJcsk4M4yY2JpfqGeCtNLU= -github.com/bytedance/sonic/loader v0.2.4 h1:ZWCw4stuXUsn1/+zQDqeE7JKP+QO47tz7QCNan80NzY= -github.com/bytedance/sonic/loader v0.2.4/go.mod h1:N8A3vUdtUebEY2/VQC0MyhYeKUFosQU6FxH2JmUe6VI= -github.com/cloudwego/base64x v0.1.4 h1:jwCgWpFanWmN8xoIUHa2rtzmkd5J2plF/dnLS6Xd/0Y= -github.com/cloudwego/base64x v0.1.4/go.mod h1:0zlkT4Wn5C6NdauXdJRhSKRlJvmclQ1hhJgA0rcu/8w= -github.com/cloudwego/base64x v0.1.5 h1:XPciSp1xaq2VCSt6lF0phncD4koWyULpl5bUxbfCyP4= -github.com/cloudwego/base64x v0.1.5/go.mod h1:0zlkT4Wn5C6NdauXdJRhSKRlJvmclQ1hhJgA0rcu/8w= -github.com/cloudwego/iasm v0.2.0 h1:1KNIy1I1H9hNNFEEH3DVnI4UujN+1zjpuk6gwHLTssg= -github.com/cloudwego/iasm v0.2.0/go.mod h1:8rXZaNYT2n95jn+zTI1sDr+IgcD2GVs0nlbbQPiEFhY= -github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= +github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= +github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= +github.com/bytedance/sonic v1.14.0 h1:/OfKt8HFw0kh2rj8N0F6C/qPGRESq0BbaNZgcNXXzQQ= +github.com/bytedance/sonic v1.14.0/go.mod h1:WoEbx8WTcFJfzCe0hbmyTGrfjt8PzNEBdxlNUO24NhA= +github.com/bytedance/sonic/loader v0.3.0 h1:dskwH8edlzNMctoruo8FPTJDF3vLtDT0sXZwvZJyqeA= +github.com/bytedance/sonic/loader v0.3.0/go.mod h1:N8A3vUdtUebEY2/VQC0MyhYeKUFosQU6FxH2JmUe6VI= +github.com/cenkalti/backoff/v5 v5.0.3 h1:ZN+IMa753KfX5hd8vVaMixjnqRZ3y8CuJKRKj1xcsSM= +github.com/cenkalti/backoff/v5 v5.0.3/go.mod h1:rkhZdG3JZukswDf7f0cwqPNk4K0sa+F97BxZthm/crw= +github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= +github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M= +github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/gabriel-vasile/mimetype v1.4.3 h1:in2uUcidCuFcDKtdcBxlR0rJ1+fsokWf+uqxgUFjbI0= -github.com/gabriel-vasile/mimetype v1.4.3/go.mod h1:d8uq/6HKRL6CGdk+aubisF/M5GcPfT7nKyLpA0lbSSk= -github.com/gabriel-vasile/mimetype v1.4.9 h1:5k+WDwEsD9eTLL8Tz3L0VnmVh9QxGjRmjBvAG7U/oYY= -github.com/gabriel-vasile/mimetype v1.4.9/go.mod h1:WnSQhFKJuBlRyLiKohA/2DtIlPFAbguNaG7QCHcyGok= +github.com/gabriel-vasile/mimetype v1.4.10 h1:zyueNbySn/z8mJZHLt6IPw0KoZsiQNszIpU+bX4+ZK0= +github.com/gabriel-vasile/mimetype v1.4.10/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s= github.com/gin-contrib/cors v1.7.6 h1:3gQ8GMzs1Ylpf70y8bMw4fVpycXIeX1ZemuSQIsnQQY= github.com/gin-contrib/cors v1.7.6/go.mod h1:Ulcl+xN4jel9t1Ry8vqph23a60FwH9xVLd+3ykmTjOk= -github.com/gin-contrib/sse v0.1.0 h1:Y/yl/+YNO8GZSjAhjMsSuLt29uWRFHdHYUb5lYOV9qE= -github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm+fLHvGI= github.com/gin-contrib/sse v1.1.0 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w= github.com/gin-contrib/sse v1.1.0/go.mod h1:hxRZ5gVpWMT7Z0B0gSNYqqsSCNIJMjzvm6fqCz9vjwM= github.com/gin-gonic/gin v1.10.1 h1:T0ujvqyCSqRopADpgPgiTT63DUQVSfojyME59Ei63pQ= github.com/gin-gonic/gin v1.10.1/go.mod h1:4PMNQiOhvDRa013RKVbsiNwoyezlm2rm0uX/T7kzp5Y= +github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= +github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= +github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= +github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s= github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4= github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA= github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY= github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY= github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY= -github.com/go-playground/validator/v10 v10.20.0 h1:K9ISHbSaI0lyB2eWMPJo+kOS/FBExVwjEviJTixqxL8= -github.com/go-playground/validator/v10 v10.20.0/go.mod h1:dbuPbCMFw/DrkbEynArYaCwl3amGuJotoKCe95atGMM= -github.com/go-playground/validator/v10 v10.26.0 h1:SP05Nqhjcvz81uJaRfEV0YBSSSGMc/iMaVtFbr3Sw2k= -github.com/go-playground/validator/v10 v10.26.0/go.mod h1:I5QpIEbmr8On7W0TktmJAumgzX4CA1XNl4ZmDuVHKKo= -github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU= -github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I= +github.com/go-playground/validator/v10 v10.27.0 h1:w8+XrWVMhGkxOaaowyKH35gFydVHOvC0/uWoy2Fzwn4= +github.com/go-playground/validator/v10 v10.27.0/go.mod h1:I5QpIEbmr8On7W0TktmJAumgzX4CA1XNl4ZmDuVHKKo= github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4= github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M= github.com/golang-jwt/jwt/v5 v5.2.3 h1:kkGXqQOBSDDWRhWNXTFpqGSCMyh/PLnqUvMGJPDJDs0= github.com/golang-jwt/jwt/v5 v5.2.3/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk= -github.com/google/go-cmp v0.5.5 h1:Khx7svrCpmxxtHBq5j2mp/xVjsi8hQMfNLvJFAlrGgU= -github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= +github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.2 h1:8Tjv8EJ+pM1xP8mK6egEbD1OgnVTyacbefKhmbLhIhU= +github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.2/go.mod h1:pkJQ2tZHJ0aFOVEEot6oZmaVEZcRme73eIFmhiVuRWs= github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo= @@ -62,16 +61,16 @@ github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ= github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8= github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= -github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg= -github.com/klauspost/cpuid/v2 v2.2.7 h1:ZWSB3igEs+d0qvnxR/ZBzXVmxkgt8DdzP6m9pfuVLDM= -github.com/klauspost/cpuid/v2 v2.2.7/go.mod h1:Lcz8mBdAVJIBVzewtcLocK12l3Y+JytZYpaMropDUws= -github.com/klauspost/cpuid/v2 v2.2.10 h1:tBs3QSyvjDyFTq3uoc/9xFpCuOsJQFNPiAhYdw2skhE= -github.com/klauspost/cpuid/v2 v2.2.10/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0= -github.com/knz/go-libedit v1.10.1/go.mod h1:MZTVkCWyz0oBc7JOWP3wNAzd002ZbM/5hgShxwh4x8M= -github.com/kr/pretty v0.3.0 h1:WgNl7dwNpEZ6jJ9k1snq4pZsg7DOEN8hP9Xw0Tsjwk0= -github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk= +github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo= +github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ= +github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y= +github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= +github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ= github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI= github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw= @@ -83,14 +82,28 @@ github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M= github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= -github.com/pelletier/go-toml/v2 v2.2.2 h1:aYUidT7k73Pcl9nb2gScu7NSrKCSHIDE89b3+6Wq+LM= -github.com/pelletier/go-toml/v2 v2.2.2/go.mod h1:1t835xjRzz80PqgE6HHgN2JOsmgYu/h4qDAS4n929Rs= +github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= +github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4= github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/prometheus/client_golang v1.23.2 h1:Je96obch5RDVy3FDMndoUsjAhG5Edi49h0RJWRi/o0o= +github.com/prometheus/client_golang v1.23.2/go.mod h1:Tb1a6LWHB3/SPIzCoaDXI4I8UHKeFTEQ1YCr+0Gyqmg= +github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk= +github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE= +github.com/prometheus/common v0.66.1 h1:h5E0h5/Y8niHc5DlaLlWLArTQI7tMrsfQjHV+d9ZoGs= +github.com/prometheus/common v0.66.1/go.mod h1:gcaUsgf3KfRSwHY4dIMXLPV0K/Wg1oZ8+SbZk/HH/dA= +github.com/prometheus/procfs v0.16.1 h1:hZ15bTNuirocR6u0JZ6BAHHmwS1p8B4P6MRqxtzMyRg= +github.com/prometheus/procfs v0.16.1/go.mod h1:teAbpZRB1iIAJYREa1LsoWUXykVXA1KlTmWl8x/U+Is= github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= +github.com/samber/lo v1.51.0 h1:kysRYLbHy/MB7kQZf5DSN50JHmMsNEdeY24VzJFu7wI= +github.com/samber/lo v1.51.0/go.mod h1:4+MXEGsJzbKGaUEQFKBq2xtfuznW9oz/WrgyzMzRoM0= +github.com/samber/slog-common v0.19.0 h1:fNcZb8B2uOLooeYwFpAlKjkQTUafdjfqKcwcC89G9YI= +github.com/samber/slog-common v0.19.0/go.mod h1:dTz+YOU76aH007YUU0DffsXNsGFQRQllPQh9XyNoA3M= +github.com/samber/slog-multi v1.5.0 h1:UDRJdsdb0R5vFQFy3l26rpX3rL3FEPJTJ2yKVjoiT1I= +github.com/samber/slog-multi v1.5.0/go.mod h1:im2Zi3mH/ivSY5XDj6LFcKToRIWPw1OcjSVSdXt+2d0= 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/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= @@ -101,49 +114,63 @@ github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/ github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= -github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= -github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= -github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= -github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI= github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08= -github.com/ugorji/go/codec v1.2.12 h1:9LC83zGrHhuUA9l16C9AHXAqEV/2wBQ4nkvumAE65EE= -github.com/ugorji/go/codec v1.2.12/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg= github.com/ugorji/go/codec v1.3.0 h1:Qd2W2sQawAfG8XSvzwhBeoGq71zXOC/Q1E9y/wUcsUA= github.com/ugorji/go/codec v1.3.0/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4= -golang.org/x/arch v0.0.0-20210923205945-b76863e36670/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8= -golang.org/x/arch v0.8.0 h1:3wRIsP3pM4yUptoR96otTUOXI367OS0+c9eeRi9doIc= -golang.org/x/arch v0.8.0/go.mod h1:FEVrYAQjsQXMVJ1nsMoVVXPZg6p2JE2mx8psSWTDQys= -golang.org/x/arch v0.18.0 h1:WN9poc33zL4AzGxqf8VtpKUnGvMi8O9lhNyBMF/85qc= -golang.org/x/arch v0.18.0/go.mod h1:bdwinDaKcfZUGpH09BB7ZmOfhalA8lQdzl62l8gGWsk= -golang.org/x/crypto v0.31.0 h1:ihbySMvVjLAeSH1IbfcRTkD/iNscyz8rGzjF/E5hV6U= -golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk= -golang.org/x/crypto v0.39.0 h1:SHs+kF4LP+f+p14esP5jAoDpHU8Gu/v9lFRK6IT5imM= -golang.org/x/crypto v0.39.0/go.mod h1:L+Xg3Wf6HoL4Bn4238Z6ft6KfEpN0tJGo53AAPC632U= -golang.org/x/net v0.25.0 h1:d/OCCoBEUq33pjydKrGQhw7IlUPI2Oylr+8qLx49kac= -golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM= -golang.org/x/net v0.41.0 h1:vBTly1HeNPEn3wtREYfy4GZ/NECgw2Cnl+nK6Nz3uvw= -golang.org/x/net v0.41.0/go.mod h1:B/K4NNqkfmg07DQYrbwvSluqCJOOXwUjeb/5lOisjbA= -golang.org/x/sync v0.10.0 h1:3NQrjDixjgGwUOCaF8w2+VYHv0Ve/vGYSbdkTa98gmQ= -golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= -golang.org/x/sync v0.15.0 h1:KWH3jNZsfyT6xfAfKiz6MRNmd46ByHDYaZ7KSkCtdW8= -golang.org/x/sync v0.15.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= -golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA= +go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A= +go.opentelemetry.io/contrib/instrumentation/github.com/gin-gonic/gin/otelgin v0.63.0 h1:5kSIJ0y8ckZZKoDhZHdVtcyjVi6rXyAwyaR8mp4zLbg= +go.opentelemetry.io/contrib/instrumentation/github.com/gin-gonic/gin/otelgin v0.63.0/go.mod h1:i+fIMHvcSQtsIY82/xgiVWRklrNt/O6QriHLjzGeY+s= +go.opentelemetry.io/contrib/propagators/b3 v1.38.0 h1:uHsCCOSKl0kLrV2dLkFK+8Ywk9iKa/fptkytc6aFFEo= +go.opentelemetry.io/contrib/propagators/b3 v1.38.0/go.mod h1:wMRSZJZcY8ya9mApLLhwIMjqmApy2o/Ml+62lhvxyHU= +go.opentelemetry.io/otel v1.38.0 h1:RkfdswUDRimDg0m2Az18RKOsnI8UDzppJAtj01/Ymk8= +go.opentelemetry.io/otel v1.38.0/go.mod h1:zcmtmQ1+YmQM9wrNsTGV/q/uyusom3P8RxwExxkZhjM= +go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.38.0 h1:GqRJVj7UmLjCVyVJ3ZFLdPRmhDUp2zFmQe3RHIOsw24= +go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.38.0/go.mod h1:ri3aaHSmCTVYu2AWv44YMauwAQc0aqI9gHKIcSbI1pU= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.38.0 h1:aTL7F04bJHUlztTsNGJ2l+6he8c+y/b//eR0jjjemT4= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.38.0/go.mod h1:kldtb7jDTeol0l3ewcmd8SDvx3EmIE7lyvqbasU3QC4= +go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.38.0 h1:kJxSDN4SgWWTjG/hPp3O7LCGLcHXFlvS2/FFOrwL+SE= +go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.38.0/go.mod h1:mgIOzS7iZeKJdeB8/NYHrJ48fdGc71Llo5bJ1J4DWUE= +go.opentelemetry.io/otel/metric v1.38.0 h1:Kl6lzIYGAh5M159u9NgiRkmoMKjvbsKtYRwgfrA6WpA= +go.opentelemetry.io/otel/metric v1.38.0/go.mod h1:kB5n/QoRM8YwmUahxvI3bO34eVtQf2i4utNVLr9gEmI= +go.opentelemetry.io/otel/sdk v1.38.0 h1:l48sr5YbNf2hpCUj/FoGhW9yDkl+Ma+LrVl8qaM5b+E= +go.opentelemetry.io/otel/sdk v1.38.0/go.mod h1:ghmNdGlVemJI3+ZB5iDEuk4bWA3GkTpW+DOoZMYBVVg= +go.opentelemetry.io/otel/sdk/metric v1.38.0 h1:aSH66iL0aZqo//xXzQLYozmWrXxyFkBJ6qT5wthqPoM= +go.opentelemetry.io/otel/sdk/metric v1.38.0/go.mod h1:dg9PBnW9XdQ1Hd6ZnRz689CbtrUp0wMMs9iPcgT9EZA= +go.opentelemetry.io/otel/trace v1.38.0 h1:Fxk5bKrDZJUH+AMyyIXGcFAPah0oRcT+LuNtJrmcNLE= +go.opentelemetry.io/otel/trace v1.38.0/go.mod h1:j1P9ivuFsTceSWe1oY+EeW3sc+Pp42sO++GHkg4wwhs= +go.opentelemetry.io/proto/otlp v1.7.1 h1:gTOMpGDb0WTBOP8JaO72iL3auEZhVmAQg4ipjOVAtj4= +go.opentelemetry.io/proto/otlp v1.7.1/go.mod h1:b2rVh6rfI/s2pHWNlB7ILJcRALpcNDzKhACevjI+ZnE= +go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= +go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= +go.yaml.in/yaml/v2 v2.4.2 h1:DzmwEr2rDGHl7lsFgAHxmNz/1NlQ7xLIrlN2h5d1eGI= +go.yaml.in/yaml/v2 v2.4.2/go.mod h1:081UH+NErpNdqlCXm3TtEran0rJZGxAYx9hb/ELlsPU= +golang.org/x/arch v0.20.0 h1:dx1zTU0MAE98U+TQ8BLl7XsJbgze2WnNKF/8tGp/Q6c= +golang.org/x/arch v0.20.0/go.mod h1:bdwinDaKcfZUGpH09BB7ZmOfhalA8lQdzl62l8gGWsk= +golang.org/x/crypto v0.41.0 h1:WKYxWedPGCTVVl5+WHSSrOBT0O8lx32+zxmHxijgXp4= +golang.org/x/crypto v0.41.0/go.mod h1:pO5AFd7FA68rFak7rOAGVuygIISepHftHnr8dr6+sUc= +golang.org/x/net v0.43.0 h1:lat02VYK2j4aLzMzecihNvTlJNQUq316m2Mr9rnM6YE= +golang.org/x/net v0.43.0/go.mod h1:vhO1fvI4dGsIjh73sWfUVjj3N7CA9WkKJNQm2svM6Jg= +golang.org/x/sync v0.16.0 h1:ycBJEhp9p4vXvUZNszeOq0kGTPghopOL8q0fq3vstxw= +golang.org/x/sync v0.16.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.28.0 h1:Fksou7UEQUWlKvIdsqzJmUmCX3cZuD2+P3XyyzwMhlA= -golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= -golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw= -golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= -golang.org/x/text v0.21.0 h1:zyQAAkrwaneQ066sspRyJaG9VNi/YJ1NfzcGB3hZ/qo= -golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ= -golang.org/x/text v0.26.0 h1:P42AVeLghgTYr4+xUnTRKDMqpar+PtX7KWuNQL21L8M= -golang.org/x/text v0.26.0/go.mod h1:QK15LZJUUQVJxhz7wXgxSy/CJaTFjd0G+YLonydOVQA= -golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543 h1:E7g+9GITq07hpfrRu66IVDexMakfv52eLZ2CXBWiKr4= -golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= -google.golang.org/protobuf v1.34.1 h1:9ddQBjfCyZPOHPUiPxpYESBLc+T8P3E+Vo4IbKZgFWg= -google.golang.org/protobuf v1.34.1/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos= -google.golang.org/protobuf v1.36.6 h1:z1NpPI8ku2WgiWnf+t9wTPsn6eP1L7ksHUlkfLvd9xY= -google.golang.org/protobuf v1.36.6/go.mod h1:jduwjTPXsFjZGTmRluh+L6NjiWu7pchiJ2/5YcXBHnY= +golang.org/x/sys v0.35.0 h1:vz1N37gP5bs89s7He8XuIYXpyY0+QlsKmzipCbUtyxI= +golang.org/x/sys v0.35.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= +golang.org/x/text v0.28.0 h1:rhazDwis8INMIwQ4tpjLDzUhx6RlXqZNPEM0huQojng= +golang.org/x/text v0.28.0/go.mod h1:U8nCwOR8jO/marOQ0QbDiOngZVEBB7MAiitBuMjXiNU= +gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk= +gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E= +google.golang.org/genproto/googleapis/api v0.0.0-20250825161204-c5933d9347a5 h1:BIRfGDEjiHRrk0QKZe3Xv2ieMhtgRGeLcZQ0mIVn4EY= +google.golang.org/genproto/googleapis/api v0.0.0-20250825161204-c5933d9347a5/go.mod h1:j3QtIyytwqGr1JUDtYXwtMXWPKsEa5LtzIFN1Wn5WvE= +google.golang.org/genproto/googleapis/rpc v0.0.0-20250825161204-c5933d9347a5 h1:eaY8u2EuxbRv7c3NiGK0/NedzVsCcV6hDuU5qPX5EGE= +google.golang.org/genproto/googleapis/rpc v0.0.0-20250825161204-c5933d9347a5/go.mod h1:M4/wBTSeyLxupu3W3tJtOgB14jILAS/XWPSSa3TAlJc= +google.golang.org/grpc v1.75.0 h1:+TW+dqTd2Biwe6KKfhE5JpiYIBWq865PhKGSXiivqt4= +google.golang.org/grpc v1.75.0/go.mod h1:JtPAzKiq4v1xcAB2hydNlWI2RnF85XXcV0mhKXr2ecQ= +google.golang.org/protobuf v1.36.8 h1:xHScyCOEuuwZEc6UtSOvPbAT4zRh0xcNRYekJwfqyMc= +google.golang.org/protobuf v1.36.8/go.mod h1:fuxRtAxBytpl4zzqUh6/eyUujkJdNiuEkXntxiD/uRU= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= @@ -154,5 +181,3 @@ gorm.io/driver/postgres v1.6.0 h1:2dxzU8xJ+ivvqTRph34QX+WrRaJlmfyPqXmoGVjMBa4= gorm.io/driver/postgres v1.6.0/go.mod h1:vUw0mrGgrTK+uPHEhAdV4sfFELrByKVGnaVRkXDhtWo= gorm.io/gorm v1.30.1 h1:lSHg33jJTBxs2mgJRfRZeLDG+WZaHYCk3Wtfl6Ngzo4= gorm.io/gorm v1.30.1/go.mod h1:8Z33v652h4//uMA76KjeDH8mJXPm1QNCYrMeatR0DOE= -nullprogram.com/x/optparse v1.0.0/go.mod h1:KdyPE+Igbe0jQUrVfMqDMeJQIJZEuyV7pjYmp6pbG50= -rsc.io/pdf v0.1.1/go.mod h1:n8OzWcQ6Sp37PL01nO98y4iUCRdTGarVfzxY20ICaU4= diff --git a/src/main.go b/src/main.go index 3cea061..100c1a1 100644 --- a/src/main.go +++ b/src/main.go @@ -1,6 +1,7 @@ package main import ( + "context" "database/sql" "fmt" "log" @@ -9,17 +10,23 @@ import ( "time" _ "github.com/lib/pq" + "github.com/prometheus/client_golang/prometheus/promhttp" "gorm.io/driver/postgres" "gorm.io/gorm" api "bookem-user-service/api" + "bookem-user-service/api/middleware" + "bookem-user-service/client/reservationclient" "bookem-user-service/client/roomclient" domain "bookem-user-service/domain" repo "bookem-user-service/repo" service "bookem-user-service/service" + utils "bookem-user-service/util" "github.com/gin-contrib/cors" "github.com/gin-gonic/gin" + + "go.opentelemetry.io/contrib/instrumentation/github.com/gin-gonic/gin/otelgin" ) var ( @@ -56,12 +63,24 @@ func connectToDb() { } func main() { + ctx := context.Background() + + shutdown2 := utils.TEL.Init( + ctx, + os.Getenv("SERVICE_NAME"), + os.Getenv("DEPLOYMENT_ENV"), + ) + defer shutdown2(ctx) + connectToDb() defer rawDB.Close() syncDatabase() server = gin.Default() + server.Use(middleware.PrometheusMiddleware()) + server.Use(otelgin.Middleware(os.Getenv("SERVICE_NAME"))) + server.Use(utils.TEL.GetLoggingMiddleware()) server.Use(cors.New(cors.Config{ AllowOrigins: []string{"http://localhost:5173", "http://localhost", "http://bookem.local"}, AllowMethods: []string{"GET", "POST", "PUT", "DELETE", "OPTIONS"}, @@ -71,6 +90,7 @@ func main() { MaxAge: 12 * time.Hour, })) + server.GET("/metrics", gin.WrapH(promhttp.Handler())) server.GET("/healthz", func(ctx *gin.Context) { err := rawDB.Ping() if err != nil { @@ -81,9 +101,10 @@ func main() { }) roomclient := roomclient.NewRoomClient() + reservationclient := reservationclient.NewReservationClient() repo := repo.NewRepository(dB) - service := service.NewService(repo, roomclient) + service := service.NewService(repo, roomclient, reservationclient) handler := api.NewHandler(service) route := *api.NewRoute(handler) diff --git a/src/repo/repo.go b/src/repo/repo.go index 545a524..8033065 100644 --- a/src/repo/repo.go +++ b/src/repo/repo.go @@ -12,7 +12,7 @@ type Repository interface { FindByUsernameOrEmailNotId(username, email string, id uint) (*domain.User, error) FindById(id uint) (*domain.User, error) Update(user *domain.User) error - Delete(id uint) + Delete(user *domain.User) error } type repository struct { @@ -58,6 +58,7 @@ func (r *repository) Update(user *domain.User) error { return r.db.Save(user).Error } -func (r *repository) Delete(id uint) { - r.db.Delete(&domain.User{}, id) +func (r *repository) Delete(user *domain.User) error { + user.Deleted = true + return r.Update(user) } diff --git a/src/service/service.go b/src/service/service.go index 47cec5d..0bd5821 100644 --- a/src/service/service.go +++ b/src/service/service.go @@ -1,40 +1,46 @@ package api import ( + "bookem-user-service/client/reservationclient" "bookem-user-service/client/roomclient" "bookem-user-service/domain" repo "bookem-user-service/repo" util "bookem-user-service/util" + "context" "fmt" - "log" "strings" ) type Service interface { - Register(input *domain.UserCreateDTO) (*domain.User, error) - Login(dto domain.LoginDTO) (string, error) - Update(callerID uint, dto domain.UserUpdateDTO) (*domain.User, error) - ChangePassword(callerID uint, dto domain.PasswordUpdateDTO) (*domain.User, error) - FindById(id uint) (*domain.User, error) - Delete(callerID uint, id uint) error + Register(ctx context.Context, input *domain.UserCreateDTO) (*domain.User, error) + Login(ctx context.Context, dto domain.LoginDTO) (string, error) + Update(ctx context.Context, callerID uint, dto domain.UserUpdateDTO) (*domain.User, error) + ChangePassword(ctx context.Context, callerID uint, dto domain.PasswordUpdateDTO) (*domain.User, error) + FindById(ctx context.Context, id uint) (*domain.User, error) + Delete(ctx context.Context, userId uint, jwt string) error /// canDeleteUser returns an error if the user cannot be deleted right now. /// The error specifies the reason why the operation cannot be done. - canDeleteUser(user *domain.User) error + canDeleteUser(ctx context.Context, user *domain.User, jwt string) error } type service struct { - repo repo.Repository - roomClient roomclient.RoomClient + repo repo.Repository + roomClient roomclient.RoomClient + reservationClient reservationclient.ReservationClient } -func NewService(r repo.Repository, roomClient roomclient.RoomClient) Service { - return &service{r, roomClient} +func NewService(r repo.Repository, roomClient roomclient.RoomClient, reservationClient reservationclient.ReservationClient) Service { + return &service{r, roomClient, reservationClient} } -func (s *service) Register(dto *domain.UserCreateDTO) (*domain.User, error) { +func (s *service) Register(ctx context.Context, dto *domain.UserCreateDTO) (*domain.User, error) { + util.TEL.Push(ctx, "hash-password") + defer util.TEL.Pop() + hashed, err := util.HashPassword(dto.Password) if err != nil { + util.TEL.Error("failed hashing password", err) return nil, domain.ErrHashingPassword } @@ -48,21 +54,30 @@ func (s *service) Register(dto *domain.UserCreateDTO) (*domain.User, error) { Address: dto.Address, } + util.TEL.Push(ctx, "db-query-user") + defer util.TEL.Pop() existing, _ := s.repo.FindByUsernameOrEmail(dto.Username, dto.Email) if existing != nil { if existing.Username == dto.Username { + util.TEL.Error("username exists", nil, "username", existing.Username, "id", existing.ID) return nil, domain.ErrUsernameExists } if existing.Email == dto.Email { + util.TEL.Error("email exists", nil, "email", existing.Email, "id", existing.ID) return nil, domain.ErrEmailExists } } + util.TEL.Push(ctx, "db-insert-user") + defer util.TEL.Pop() err = s.repo.Create(user) if err != nil { + util.TEL.Error("failed inserting user", err) return nil, fmt.Errorf("%w: %v", domain.ErrDBInternal, err) } + util.TEL.Info("Successfully created user", "id", user.ID) + return user, nil } @@ -70,50 +85,75 @@ func (s *service) Register(dto *domain.UserCreateDTO) (*domain.User, error) { // It can accept both an email or a username. // On success, it returns a JWT string. // On error, it returns an empty string. -func (s *service) Login(dto domain.LoginDTO) (string, error) { +func (s *service) Login(ctx context.Context, dto domain.LoginDTO) (string, error) { + util.TEL.Push(ctx, "find-user") + defer util.TEL.Pop() + user, _ := s.repo.FindByUsernameOrEmail(dto.UsernameOrEmail, dto.UsernameOrEmail) if user == nil { - log.Printf("User %s not found", dto.UsernameOrEmail) + util.TEL.Error("user not found", nil, "username_or_email", dto.UsernameOrEmail) return "", domain.ErrLoginFailed } + util.TEL.Push(ctx, "check-account-deletion") + defer util.TEL.Pop() + if user.Deleted { + util.TEL.Error("account is deleted", nil, "username_or_email", dto.UsernameOrEmail) + return "", domain.ErrDeletedAccount + } + + util.TEL.Push(ctx, "verify-password") + defer util.TEL.Pop() + err := util.VerifyPassword(user.Password, dto.Password) if err != nil { - log.Print(err) + util.TEL.Error("Password verification failed", err) return "", domain.ErrLoginFailed } + util.TEL.Push(ctx, "create-jwt") + defer util.TEL.Pop() + jwt, err := util.CreateJWT(int(user.ID), user.Username, user.Role) if err != nil { - log.Print(err) + util.TEL.Error("JWT Creation failed", err) return "", domain.ErrLoginFailed } + util.TEL.Info("Logged in user", "id", user.ID) + return jwt, nil } // Update updates the user (specified by his ID in the dto) with the new values // in the DTO. Fields with null values are skipped. -func (s *service) Update(callerID uint, dto domain.UserUpdateDTO) (*domain.User, error) { - log.Printf("User %d wants to update user %d", callerID, dto.Id) +func (s *service) Update(ctx context.Context, callerID uint, dto domain.UserUpdateDTO) (*domain.User, error) { + util.TEL.Info("user update request", "caller_id", callerID, "user_id", dto.Id) // Users can only update themselves. if callerID != dto.Id { + util.TEL.Error("user trying to update someone else", nil) return nil, domain.ErrUnauthorized } // Search for the user. - user, err := s.FindById(dto.Id) + util.TEL.Push(ctx, "find-user") + defer util.TEL.Pop() + + user, err := s.FindById(util.TEL.Ctx(), dto.Id) if err != nil { - log.Printf("User %d not fonud", dto.Id) + util.TEL.Error("user not found", err, "id", dto.Id) return nil, domain.ErrNotFound } // Check if the username or email is already taken by someone else. + util.TEL.Push(ctx, "assert-unique-credentials") + defer util.TEL.Pop() + if dto.Username != nil || dto.Email != nil { usernameSafe := "" if dto.Username != nil { @@ -132,9 +172,7 @@ func (s *service) Update(callerID uint, dto domain.UserUpdateDTO) (*domain.User, } else if emailSafe == otherUserWithUsernameOrEmail.Email { return nil, domain.ErrEmailExists } else { - log.Printf("DB found user matching [%s] or [%s] but the in-memory comparison failed.", usernameSafe, emailSafe) - log.Printf("User: %+v", otherUserWithUsernameOrEmail) - + util.TEL.Error("db malfunction, could not compare users", nil) return nil, domain.ErrDBInternal } } @@ -158,34 +196,49 @@ func (s *service) Update(callerID uint, dto domain.UserUpdateDTO) (*domain.User, user.Address = *dto.Address } + util.TEL.Push(ctx, "update-user") + defer util.TEL.Pop() + err = s.repo.Update(user) if err != nil { + util.TEL.Error("could not update user in DB", err) return nil, err } + util.TEL.Info("updated user", "user_id", user.ID) + return user, nil } // ChangePassword changes the user's password. -func (s *service) ChangePassword(callerID uint, dto domain.PasswordUpdateDTO) (*domain.User, error) { - log.Printf("User %d wants to change password of user %d", callerID, dto.Id) +func (s *service) ChangePassword(ctx context.Context, callerID uint, dto domain.PasswordUpdateDTO) (*domain.User, error) { + util.TEL.Info("password change request", "caller_id", callerID, "user_id", dto.Id) // User can only change his own password. if callerID != dto.Id { + util.TEL.Error("user trying to change password of someone else", nil) return nil, domain.ErrUnauthorized } // Search for the user. - user, err := s.FindById(dto.Id) + util.TEL.Push(ctx, "find-user") + defer util.TEL.Pop() + + user, err := s.FindById(util.TEL.Ctx(), dto.Id) if err != nil { + util.TEL.Error("user not found", err, "id", dto.Id) return nil, err } // Check if confirm password is valid. + util.TEL.Push(ctx, "password-validation") + defer util.TEL.Pop() + if dto.NewPasswordConfirm != dto.NewPassword { + util.TEL.Error("passwords do not match", nil) return nil, domain.ErrPasswordsNotMatch } @@ -193,12 +246,14 @@ func (s *service) ChangePassword(callerID uint, dto domain.PasswordUpdateDTO) (* err = util.VerifyPassword(user.Password, dto.OldPassword) if err != nil { + util.TEL.Error("old password is incorrect", err) return nil, domain.ErrWrongPassword } // Check if password is new. if dto.NewPassword == dto.OldPassword { + util.TEL.Error("new password hasn't changed", nil) return nil, domain.ErrPasswordNotChanged } @@ -206,81 +261,117 @@ func (s *service) ChangePassword(callerID uint, dto domain.PasswordUpdateDTO) (* passwordHashed, err := util.HashPassword(dto.NewPassword) if err != nil { + util.TEL.Error("password hashing failed", err) return nil, err } // Update. + util.TEL.Push(ctx, "update-user") + defer util.TEL.Pop() + user.Password = passwordHashed err = s.repo.Update(user) if err != nil { + util.TEL.Error("could not update user in DB", err) return nil, err } + util.TEL.Info("updated password", "user_id", user.ID) + return user, nil } -func (s *service) FindById(id uint) (*domain.User, error) { +func (s *service) FindById(ctx context.Context, id uint) (*domain.User, error) { + util.TEL.Info("Find user", "id", id) + + util.TEL.Push(ctx, "find-user-in-db") + defer util.TEL.Pop() + user, err := s.repo.FindById(id) if err != nil { + util.TEL.Error("user not found", err, "id", id) return nil, domain.ErrNotFound } return user, nil } -func (s *service) Delete(callerID uint, id uint) error { - log.Printf("User %d wants to delete user %d", callerID, id) - - // User can only delete himself. - - if id != callerID { - return domain.ErrUnauthorized - } +func (s *service) Delete(ctx context.Context, userId uint, jwt string) error { + util.TEL.Info("user wants to delete himself", "user_id", userId) // Search for the user. - user, err := s.FindById(id) + util.TEL.Push(ctx, "find-user") + defer util.TEL.Pop() + + user, err := s.FindById(util.TEL.Ctx(), userId) if err != nil { + util.TEL.Error("user not found", err, "id", userId) return err } // Check if user can be deleted. - err = s.canDeleteUser(user) + util.TEL.Push(ctx, "delete-safety-check") + defer util.TEL.Pop() + + err = s.canDeleteUser(util.TEL.Ctx(), user, jwt) if err != nil { + util.TEL.Error("cannot delete user", err, "id", userId) return err } + // Delete rooms + + if user.Role == domain.Host { + _, err := s.roomClient.DeleteHostRooms(ctx, jwt) + if err != nil { + util.TEL.Error("could not delete host rooms", err) + return err + } + } + // Delete user - s.repo.Delete(user.ID) - log.Printf("User %d deleted", id) + util.TEL.Push(ctx, "delete-user-in-db") + defer util.TEL.Pop() + + s.repo.Delete(user) + util.TEL.Info("User deleted", "id", userId) return nil } -func (s *service) canDeleteUser(user *domain.User) error { +func (s *service) canDeleteUser(ctx context.Context, user *domain.User, jwt string) error { + util.TEL.Info("check if user can be deleted", "id", user.ID) + switch user.Role { case domain.Guest: - reservations, err := s.roomClient.GetPendingGuestReservations(user) + util.TEL.Debug("user is guest - must not have any active reservations") + reservations, err := s.reservationClient.GetActiveGuestReservations(ctx, jwt) if err != nil { + util.TEL.Error("could not check", err) return err } if len(reservations) > 0 { + util.TEL.Error("guest has active reservations, cannot delete user", nil) return domain.ErrGuestHasReservations } return nil case domain.Host: - reservations, err := s.roomClient.GetActiveHostReservations(user) + util.TEL.Debug("user is host - rooms must not have any active reservations") + reservations, err := s.reservationClient.GetActiveHostReservations(ctx, jwt) if err != nil { return err } if len(reservations) > 0 { + util.TEL.Error("host's rooms have active reservations, cannot delete user", nil) return domain.ErrHostHasReservations } return nil default: + util.TEL.Error("users with this role cannot be deleted", nil, "role", user.Role) return domain.ErrCannotDeleteAdmin } } diff --git a/src/test/integration/delete_integration_test.go b/src/test/integration/delete_integration_test.go index 38a47f1..9e71c38 100644 --- a/src/test/integration/delete_integration_test.go +++ b/src/test/integration/delete_integration_test.go @@ -1,75 +1,129 @@ package test import ( + "bookem-user-service/client/reservationclient" "bookem-user-service/domain" "net/http" + "strconv" "testing" + "time" "github.com/stretchr/testify/require" ) func TestIntegration_Delete(t *testing.T) { { - resp, _ := registerUser("user_deleted_guest_01", "1234", domain.Guest) - id := getUserFromRegister(resp).Id - jwt := loginUser2("user_deleted_guest_01", "1234") - resp, err := deleteUserById(jwt, id) + resp, _ := registerUser("guest_idel_1", "1234", domain.Guest) + jwt := loginUser2("guest_idel_1", "1234") + resp, err := deleteUser(jwt) require.Nil(t, err) require.Equal(t, http.StatusNoContent, resp.StatusCode) } { - resp, _ := registerUser("user_deleted_host_01", "1234", domain.Host) - id := getUserFromRegister(resp).Id - jwt := loginUser2("user_deleted_host_01", "1234") - resp, err := deleteUserById(jwt, id) + resp, _ := registerUser("host_idel_1", "1234", domain.Host) + jwt := loginUser2("host_idel_1", "1234") + resp, err := deleteUser(jwt) require.Nil(t, err) require.Equal(t, http.StatusNoContent, resp.StatusCode) } } -func TestIntegration_Delete_WrongUser(t *testing.T) { - registerUser("user_deleted_guest_03", "1234", domain.Guest) - id := uint(999999) - jwt := loginUser2("user_deleted_guest_03", "1234") - resp, err := deleteUserById(jwt, id) +func TestIntegration_Delete_GuestHasActiveReservations(t *testing.T) { + hostUsername := "host_idel_2" + _, _, hostJwt, room := setupHostRoomAvailabilityPrice(hostUsername, t) + registerUser("guest_idel_2", "1234", domain.Guest) + guestJwt := loginUser2("guest_idel_2", "1234") + + dto := reservationclient.CreateReservationRequestDTO{ + RoomID: room.ID, + DateFrom: time.Date(2025, 9, 6, 0, 0, 0, 0, time.UTC), + DateTo: time.Date(2025, 9, 8, 0, 0, 0, 0, time.UTC), + GuestCount: 2, + } + + resp, err := createReservationRequest(guestJwt, dto) + require.NoError(t, err) + require.Equal(t, http.StatusCreated, resp.StatusCode) + req := responseToReservationRequest(resp) + + approveURL := URL_reservation + "req/" + strconv.FormatUint(uint64(req.ID), 10) + "/approve" + request, err := http.NewRequest(http.MethodPut, approveURL, nil) + require.NoError(t, err) + request.Header.Add("Authorization", "Bearer "+hostJwt) + + approveResp, err := http.DefaultClient.Do(request) + require.NoError(t, err) + require.Equal(t, http.StatusOK, approveResp.StatusCode) + + resp, err = deleteUser(hostJwt) require.Nil(t, err) - require.Equal(t, http.StatusUnauthorized, resp.StatusCode) + require.Equal(t, http.StatusNoContent, resp.StatusCode) } -func TestIntegration_Delete_GuestHasPendingReservations(t *testing.T) { - resp, _ := registerUser("user_deleted_guest_02", "1234", domain.Guest) - id := getUserFromRegister(resp).Id - jwt := loginUser2("user_deleted_guest_02", "1234") - resp, err := deleteUserById(jwt, id) +func TestIntegration_Delete_GuestHasNoActiveReservations(t *testing.T) { + resp, _ := registerUser("guest_idel_3", "1234", domain.Guest) + guestJwt := loginUser2("guest_idel_3", "1234") - // TODO: Once we can actually create reservations, that needs to happen here so we can trigger a 400. - // Until then, this test will pass. + hostUsername := "host_idel_3" + _, _, _, room := setupHostRoomAvailabilityPrice(hostUsername, t) + // This reservation request is inactive. + dto := reservationclient.CreateReservationRequestDTO{ + RoomID: room.ID, + DateFrom: time.Date(2025, 9, 6, 0, 0, 0, 0, time.UTC), + DateTo: time.Date(2025, 9, 8, 0, 0, 0, 0, time.UTC), + GuestCount: 2, + } + + resp, err := createReservationRequest(guestJwt, dto) + require.NoError(t, err) + require.Equal(t, http.StatusCreated, resp.StatusCode) + + resp, err = deleteUser(guestJwt) require.Nil(t, err) require.Equal(t, http.StatusNoContent, resp.StatusCode) } -func TestIntegration_Delete_HostHasPendingReservations(t *testing.T) { - resp, _ := registerUser("user_deleted_host_02", "1234", domain.Host) - id := getUserFromRegister(resp).Id - jwt := loginUser2("user_deleted_host_02", "1234") - resp, err := deleteUserById(jwt, id) +func TestIntegration_Delete_HostHasActiveReservations(t *testing.T) { + hostUsername := "host_idel_4" + _, _, hostJwt, room := setupHostRoomAvailabilityPrice(hostUsername, t) + + registerUser("guest_idel_4", "1234", domain.Guest) + guestJwt := loginUser2("guest_idel_4", "1234") + + dto := reservationclient.CreateReservationRequestDTO{ + RoomID: room.ID, + DateFrom: time.Date(2025, 9, 6, 0, 0, 0, 0, time.UTC), + DateTo: time.Date(2025, 9, 8, 0, 0, 0, 0, time.UTC), + GuestCount: 2, + } + + resp, err := createReservationRequest(guestJwt, dto) + require.NoError(t, err) + require.Equal(t, http.StatusCreated, resp.StatusCode) + req := responseToReservationRequest(resp) + + approveURL := URL_reservation + "req/" + strconv.FormatUint(uint64(req.ID), 10) + "/approve" + request, err := http.NewRequest(http.MethodPut, approveURL, nil) + require.NoError(t, err) + request.Header.Add("Authorization", "Bearer "+hostJwt) - // TODO: Once we can actually create reservations, that needs to happen here so we can trigger a 400. - // Until then, this test will pass. + approveResp, err := http.DefaultClient.Do(request) + require.NoError(t, err) + require.Equal(t, http.StatusOK, approveResp.StatusCode) + resp, err = deleteUser(hostJwt) require.Nil(t, err) require.Equal(t, http.StatusNoContent, resp.StatusCode) } func TestIntegration_Delete_UserIsAdmin(t *testing.T) { - resp, _ := registerUser("user_deleted_admin_01", "1234", domain.Admin) - id := getUserFromRegister(resp).Id - jwt := loginUser2("user_deleted_admin_01", "1234") - resp, err := deleteUserById(jwt, id) + resp, _ := registerUser("admin1", "1234", domain.Admin) + jwt := loginUser2("admin1", "1234") + resp, err := deleteUser(jwt) require.Nil(t, err) require.Equal(t, http.StatusBadRequest, resp.StatusCode) diff --git a/src/test/integration/util.go b/src/test/integration/util.go index 703093c..3f2fed6 100644 --- a/src/test/integration/util.go +++ b/src/test/integration/util.go @@ -1,6 +1,9 @@ package test import ( + middleware "bookem-user-service/api/middleware" + "bookem-user-service/client/reservationclient" + "bookem-user-service/client/roomclient" "bookem-user-service/domain" "bytes" "encoding/json" @@ -9,9 +12,15 @@ import ( "math/rand" "net/http" "strings" + "testing" + "time" + + "github.com/stretchr/testify/require" ) const URL = "http://user-service:8080/api/" +const URL_room = "http://room-service:8080/api/" +const URL_reservation = "http://reservation-service:8080/api/" var letterRunes = []rune("abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ") @@ -147,11 +156,212 @@ func findUserById(id uint) (*http.Response, error) { return resp, err } -func deleteUserById(jwt string, id uint) (*http.Response, error) { - req, err := http.NewRequest(http.MethodDelete, URL+fmt.Sprintf("%d", id), nil) +func deleteUser(jwt string) (*http.Response, error) { + req, err := http.NewRequest(http.MethodDelete, URL, nil) if err != nil { return nil, err } req.Header.Add("Authorization", "Bearer "+jwt) return http.DefaultClient.Do(req) } + +func createRoom(jwt string, dto roomclient.CreateRoomDTO) (*http.Response, error) { + jsonBytes, err := json.Marshal(dto) + if err != nil { + return nil, err + } + + req, err := http.NewRequest(http.MethodPost, URL_room+"new", bytes.NewBuffer(jsonBytes)) + if err != nil { + return nil, err + } + req.Header.Add("Authorization", "Bearer "+jwt) + return http.DefaultClient.Do(req) +} + +func createReservationRequest(jwt string, dto reservationclient.CreateReservationRequestDTO) (*http.Response, error) { + jsonBytes, err := json.Marshal(dto) + if err != nil { + return nil, err + } + + req, err := http.NewRequest(http.MethodPost, URL_reservation+"req", bytes.NewBuffer(jsonBytes)) + if err != nil { + return nil, err + } + req.Header.Add("Authorization", "Bearer "+jwt) + return http.DefaultClient.Do(req) +} + +func responseToReservationRequest(resp *http.Response) reservationclient.ReservationRequestDTO { + bodyBytes, err := io.ReadAll(resp.Body) + if err != nil { + panic(fmt.Sprintf("failed to read response body: %v", err)) + } + + var obj reservationclient.ReservationRequestDTO + if err := json.Unmarshal(bodyBytes, &obj); err != nil { + panic(fmt.Sprintf("failed to unmarshal: %v", err)) + } + + return obj +} + +func responseToRoom(resp *http.Response) roomclient.RoomDTO { + bodyBytes, err := io.ReadAll(resp.Body) + if err != nil { + panic(fmt.Sprintf("failed to read response body: %v", err)) + } + + var obj roomclient.RoomDTO + if err := json.Unmarshal(bodyBytes, &obj); err != nil { + fmt.Print(string(bodyBytes)) + panic(fmt.Sprintf("failed to unmarshal: %v", err)) + } + + return obj +} + +func createRoomAvailability(jwt string, dto roomclient.CreateRoomAvailabilityListDTO) (*http.Response, error) { + jsonBytes, err := json.Marshal(dto) + if err != nil { + return nil, err + } + + req, err := http.NewRequest(http.MethodPost, URL_room+"available", bytes.NewBuffer(jsonBytes)) + if err != nil { + return nil, err + } + req.Header.Add("Authorization", "Bearer "+jwt) + return http.DefaultClient.Do(req) +} + +func createRoomPrice(jwt string, dto roomclient.CreateRoomPriceListDTO) (*http.Response, error) { + jsonBytes, err := json.Marshal(dto) + if err != nil { + return nil, err + } + + req, err := http.NewRequest(http.MethodPost, URL_room+"price", bytes.NewBuffer(jsonBytes)) + if err != nil { + return nil, err + } + req.Header.Add("Authorization", "Bearer "+jwt) + return http.DefaultClient.Do(req) +} + +func setupHostRoomAvailabilityPrice(hostUsername string, t *testing.T) (string, string, string, roomclient.RoomDTO) { + // Step 1: Register unique host + username := hostUsername + password := "pass" + registerUser(username, password, domain.Host) + jwt := loginUser2(username, password) + jwtObj, err := middleware.GetJwtFromString(jwt) + require.NoError(t, err) + + // Step 2: Create room + roomDTO := roomclient.CreateRoomDTO{ + HostID: jwtObj.ID, + Name: "Room_" + genName(6), + Description: "Test room", + Address: "Test address", + MinGuests: 1, + MaxGuests: 4, + PhotosPayload: []string{SMALL_IMG}, + Commodities: []string{"WiFi", "AC"}, + AutoApprove: false, + } + roomResp, err := createRoom(jwt, roomDTO) + require.NoError(t, err) + defer roomResp.Body.Close() + room := responseToRoom(roomResp) + + // Step 3: Create availability list + availabilityDTO := roomclient.CreateRoomAvailabilityListDTO{ + RoomID: room.ID, + Items: []roomclient.CreateRoomAvailabilityItemDTO{ + { + ExistingID: 0, + DateFrom: time.Date(2025, 9, 1, 0, 0, 0, 0, time.UTC), + DateTo: time.Date(2025, 9, 10, 0, 0, 0, 0, time.UTC), + Available: true, + }, + { + ExistingID: 0, + DateFrom: time.Date(2025, 9, 15, 0, 0, 0, 0, time.UTC), + DateTo: time.Date(2025, 9, 20, 0, 0, 0, 0, time.UTC), + Available: true, + }, + { + ExistingID: 0, + DateFrom: time.Date(2025, 9, 22, 0, 0, 0, 0, time.UTC), + DateTo: time.Date(2025, 9, 30, 0, 0, 0, 0, time.UTC), + Available: true, + }, + { + ExistingID: 0, + DateFrom: time.Date(2025, 12, 1, 0, 0, 0, 0, time.UTC), + DateTo: time.Date(2025, 12, 10, 0, 0, 0, 0, time.UTC), + Available: true, + }, + }, + } + availResp, err := createRoomAvailability(jwt, availabilityDTO) + require.NoError(t, err) + defer availResp.Body.Close() + + // Step 4: Create price list + priceDTO := roomclient.CreateRoomPriceListDTO{ + RoomID: room.ID, + BasePrice: 80, + PerGuest: false, + Items: []roomclient.CreateRoomPriceItemDTO{ + { + ExistingID: 0, + DateFrom: time.Date(2025, 9, 1, 0, 0, 0, 0, time.UTC), + DateTo: time.Date(2025, 9, 10, 0, 0, 0, 0, time.UTC), + Price: 100, + }, + { + ExistingID: 0, + DateFrom: time.Date(2025, 9, 15, 0, 0, 0, 0, time.UTC), + DateTo: time.Date(2025, 9, 20, 0, 0, 0, 0, time.UTC), + Price: 120, + }, + { + ExistingID: 0, + DateFrom: time.Date(2025, 9, 22, 0, 0, 0, 0, time.UTC), + DateTo: time.Date(2025, 9, 30, 0, 0, 0, 0, time.UTC), + Price: 200, + }, + { + ExistingID: 0, + DateFrom: time.Date(2025, 12, 1, 0, 0, 0, 0, time.UTC), + DateTo: time.Date(2025, 12, 10, 0, 0, 0, 0, time.UTC), + Price: 200, + }, + }, + } + priceResp, err := createRoomPrice(jwt, priceDTO) + require.NoError(t, err) + defer priceResp.Body.Close() + + return username, password, jwt, room +} + +// ----------------------------------------------- Mock data + +const ( + SMALL_IMG = "data:image/jpeg;base64,/9j/4AAQSkZJRgABAQEASABIAAD/2wBDAAMCAgMCAgMDAwMEAwMEBQgFBQQEBQoHBwYIDAoMDAsKCwsNDhIQDQ4RDgsLEBYQERMUFRUVDA8XGBYUGBIUFRT/wAALCAABAAEBAREA/8QAFAABAAAAAAAAAAAAAAAAAAAACf/EABQQAQAAAAAAAAAAAAAAAAAAAAD/2gAIAQEAAD8AKp//2Q==" +) + +var DefaultRoomCreateDTO = roomclient.CreateRoomDTO{ + HostID: 1, + Name: "Room Name", + Description: "Room Desc", + Address: "Room Address", + MinGuests: 1, + MaxGuests: 5, + PhotosPayload: []string{SMALL_IMG}, + Commodities: []string{"WiFi"}, +} diff --git a/src/test/unit/change_password_test.go b/src/test/unit/change_password_test.go index 74910b2..1d321e0 100644 --- a/src/test/unit/change_password_test.go +++ b/src/test/unit/change_password_test.go @@ -1,6 +1,7 @@ package test import ( + "context" "fmt" "testing" @@ -11,7 +12,7 @@ import ( ) func TestChangePassword_Success(t *testing.T) { - svc, mockRepo, _ := createTestService() + svc, mockRepo, _, _ := createTestService() // Prepare @@ -33,7 +34,7 @@ func TestChangePassword_Success(t *testing.T) { // Verify - newUser, err := svc.ChangePassword(1, dto) + newUser, err := svc.ChangePassword(context.Background(), 1, dto) t.Log(oldHashed) @@ -42,7 +43,7 @@ func TestChangePassword_Success(t *testing.T) { } func TestChangePassword_SomeoneElse(t *testing.T) { - svc, _, _ := createTestService() + svc, _, _, _ := createTestService() // Prepare @@ -50,7 +51,7 @@ func TestChangePassword_SomeoneElse(t *testing.T) { // Verify - newUser, err := svc.ChangePassword(1, dto) + newUser, err := svc.ChangePassword(context.Background(), 1, dto) assert.Nil(t, newUser) assert.Error(t, err) @@ -58,7 +59,7 @@ func TestChangePassword_SomeoneElse(t *testing.T) { } func TestChangePassword_UserNotFound(t *testing.T) { - svc, mockRepo, _ := createTestService() + svc, mockRepo, _, _ := createTestService() // Prepare @@ -70,14 +71,14 @@ func TestChangePassword_UserNotFound(t *testing.T) { // Verify - newUser, err := svc.ChangePassword(1, dto) + newUser, err := svc.ChangePassword(context.Background(), 1, dto) assert.Nil(t, newUser) assert.Error(t, err) } func TestChangePassword_PasswordsNotMatch(t *testing.T) { - svc, mockRepo, _ := createTestService() + svc, mockRepo, _, _ := createTestService() // Prepare @@ -94,7 +95,7 @@ func TestChangePassword_PasswordsNotMatch(t *testing.T) { // Verify - newUser, err := svc.ChangePassword(1, dto) + newUser, err := svc.ChangePassword(context.Background(), 1, dto) assert.Nil(t, newUser) assert.Error(t, err) @@ -102,7 +103,7 @@ func TestChangePassword_PasswordsNotMatch(t *testing.T) { } func TestChangePassword_BadOldPassword(t *testing.T) { - svc, mockRepo, _ := createTestService() + svc, mockRepo, _, _ := createTestService() // Prepare @@ -119,14 +120,14 @@ func TestChangePassword_BadOldPassword(t *testing.T) { // Verify - newUser, err := svc.ChangePassword(1, dto) + newUser, err := svc.ChangePassword(context.Background(), 1, dto) assert.Nil(t, newUser) assert.Error(t, err) } func TestChangePassword_PasswordIsTheSame(t *testing.T) { - svc, mockRepo, _ := createTestService() + svc, mockRepo, _, _ := createTestService() // Prepare @@ -142,7 +143,7 @@ func TestChangePassword_PasswordIsTheSame(t *testing.T) { // Verify - newUser, err := svc.ChangePassword(1, dto) + newUser, err := svc.ChangePassword(context.Background(), 1, dto) assert.Nil(t, newUser) assert.Error(t, err) diff --git a/src/test/unit/delete_test.go b/src/test/unit/delete_test.go index 43ee20e..6097244 100644 --- a/src/test/unit/delete_test.go +++ b/src/test/unit/delete_test.go @@ -1,110 +1,116 @@ package test import ( - "fmt" - "testing" - + "bookem-user-service/client/reservationclient" "bookem-user-service/client/roomclient" domain "bookem-user-service/domain" + "context" + "fmt" + "testing" assert "github.com/stretchr/testify/assert" ) -func TestDelete_Success(t *testing.T) { - svc, mockRepo, mockRoomClient := createTestService() +func TestDelete_GuestSuccess(t *testing.T) { + svc, mockRepo, _, mockReservationClient := createTestService() id := uint(1) - callerID := uint(1) + jwt := "token" user := defaultUser user.ID = id user.Role = domain.Guest + mockRepo.On("FindById", id).Return(user, nil) - mockRepo.On("Delete", id).Return() - mockRoomClient.On("GetPendingGuestReservations", user).Return([]roomclient.ReservationDTO{}, nil) + mockRepo.On("Delete", user).Return(nil) + mockReservationClient.On("GetActiveGuestReservations", context.Background(), jwt).Return([]reservationclient.ReservationDTO{}, nil) - err := svc.Delete(callerID, id) + err := svc.Delete(context.Background(), id, jwt) assert.NoError(t, err) } -func TestDelete_TriedToDeleteSomeoneElse(t *testing.T) { - svc, mockRepo, _ := createTestService() +func TestDelete_HostSuccess(t *testing.T) { + svc, mockRepo, mockRoomClient, mockReservationClient := createTestService() id := uint(1) - callerID := uint(2) + jwt := "token" user := defaultUser user.ID = id + user.Role = domain.Host + mockRepo.On("FindById", id).Return(user, nil) + mockRepo.On("Delete", user).Return(nil) + mockReservationClient.On("GetActiveHostReservations", context.Background(), jwt).Return([]reservationclient.ReservationDTO{}, nil) + mockRoomClient.On("DeleteHostRooms", context.Background(), jwt).Return([]roomclient.RoomDTO{}, nil) - err := svc.Delete(callerID, id) + err := svc.Delete(context.Background(), id, jwt) - assert.Error(t, err) - assert.Equal(t, domain.ErrUnauthorized, err) + assert.NoError(t, err) } func TestDelete_UserNotFound(t *testing.T) { - svc, mockRepo, _ := createTestService() + svc, mockRepo, _, _ := createTestService() id := uint(1) - callerID := uint(1) + jwt := "token" mockRepo.On("FindById", id).Return(nil, fmt.Errorf("user not found")) - err := svc.Delete(callerID, id) + err := svc.Delete(context.Background(), id, jwt) assert.Error(t, err) assert.Equal(t, domain.ErrNotFound, err) } -func TestDelete_GuestHasPendingReservations(t *testing.T) { - svc, mockRepo, mockRoomClient := createTestService() +func TestDelete_GuestHasActiveReservations(t *testing.T) { + svc, mockRepo, _, mockReservationClient := createTestService() id := uint(1) - callerID := uint(1) + jwt := "token" user := defaultUser user.ID = id user.Role = domain.Guest mockRepo.On("FindById", id).Return(user, nil) - mockRepo.On("Delete", id).Return() - reservation := roomclient.ReservationDTO{} - mockRoomClient.On("GetPendingGuestReservations", user).Return([]roomclient.ReservationDTO{reservation}, nil) + mockRepo.On("Delete", id).Return(nil) + reservation := reservationclient.ReservationDTO{} + mockReservationClient.On("GetActiveGuestReservations", context.Background(), jwt).Return([]reservationclient.ReservationDTO{reservation}, nil) - err := svc.Delete(callerID, id) + err := svc.Delete(context.Background(), id, jwt) assert.Error(t, err) assert.Equal(t, domain.ErrGuestHasReservations, err) } -func TestDelete_HostHasPendingReservations(t *testing.T) { - svc, mockRepo, mockRoomClient := createTestService() +func TestDelete_HostHasActiveReservations(t *testing.T) { + svc, mockRepo, _, mockReservationClient := createTestService() id := uint(1) - callerID := uint(1) + jwt := "token" user := defaultUser user.ID = id user.Role = domain.Host mockRepo.On("FindById", id).Return(user, nil) - mockRepo.On("Delete", id).Return() - reservation := roomclient.ReservationDTO{} - mockRoomClient.On("GetActiveHostReservations", user).Return([]roomclient.ReservationDTO{reservation}, nil) + mockRepo.On("Delete", id).Return(nil) + reservation := reservationclient.ReservationDTO{} + mockReservationClient.On("GetActiveHostReservations", context.Background(), jwt).Return([]reservationclient.ReservationDTO{reservation}, nil) - err := svc.Delete(callerID, id) + err := svc.Delete(context.Background(), id, jwt) assert.Error(t, err) assert.Equal(t, domain.ErrHostHasReservations, err) } func TestDelete_TriedDeletingAdmin(t *testing.T) { - svc, mockRepo, _ := createTestService() + svc, mockRepo, _, _ := createTestService() id := uint(1) - callerID := uint(1) + jwt := "token" user := defaultUser user.ID = id user.Role = domain.Admin mockRepo.On("FindById", id).Return(user, nil) - mockRepo.On("Delete", id).Return() + mockRepo.On("Delete", id).Return(nil) - err := svc.Delete(callerID, id) + err := svc.Delete(context.Background(), id, jwt) assert.Error(t, err) assert.Equal(t, domain.ErrCannotDeleteAdmin, err) diff --git a/src/test/unit/find_test.go b/src/test/unit/find_test.go index 2c7828a..e4e41a4 100644 --- a/src/test/unit/find_test.go +++ b/src/test/unit/find_test.go @@ -1,6 +1,7 @@ package test import ( + "context" "fmt" "testing" @@ -8,7 +9,7 @@ import ( ) func TestFindById_Success(t *testing.T) { - svc, mockRepo, _ := createTestService() + svc, mockRepo, _, _ := createTestService() id := uint(1) @@ -16,7 +17,7 @@ func TestFindById_Success(t *testing.T) { user.ID = id mockRepo.On("FindById", id).Return(user, nil) - userGot, err := svc.FindById(id) + userGot, err := svc.FindById(context.Background(), id) assert.NoError(t, err) assert.NotNil(t, userGot) @@ -25,7 +26,7 @@ func TestFindById_Success(t *testing.T) { } func TestFindById_UserNotFound(t *testing.T) { - svc, mockRepo, _ := createTestService() + svc, mockRepo, _, _ := createTestService() id := uint(1) @@ -33,7 +34,7 @@ func TestFindById_UserNotFound(t *testing.T) { user.ID = id mockRepo.On("FindById", id).Return(nil, fmt.Errorf("no such user")) - userGot, err := svc.FindById(id) + userGot, err := svc.FindById(context.Background(), id) assert.Error(t, err) assert.Nil(t, userGot) diff --git a/src/test/unit/login_test.go b/src/test/unit/login_test.go index ec43989..049c2a7 100644 --- a/src/test/unit/login_test.go +++ b/src/test/unit/login_test.go @@ -1,6 +1,7 @@ package test import ( + "context" "fmt" "testing" @@ -11,7 +12,7 @@ import ( ) func TestLogin_Success(t *testing.T) { - svc, mockRepo, _ := createTestService() + svc, mockRepo, _, _ := createTestService() // Prepare @@ -45,14 +46,14 @@ func TestLogin_Success(t *testing.T) { // Verify - jwt, err := svc.Login(dto) + jwt, err := svc.Login(context.Background(), dto) assert.NoError(t, err) assert.NotEqual(t, "", jwt) } func TestLogin_UserNotFound(t *testing.T) { - svc, mockRepo, _ := createTestService() + svc, mockRepo, _, _ := createTestService() dto := domain.LoginDTO{ UsernameOrEmail: "user123", @@ -64,14 +65,14 @@ func TestLogin_UserNotFound(t *testing.T) { dto.UsernameOrEmail, dto.UsernameOrEmail, ).Return(nil, fmt.Errorf("no such user")) - jwt, err := svc.Login(dto) + jwt, err := svc.Login(context.Background(), dto) assert.ErrorIs(t, err, domain.ErrLoginFailed) assert.Equal(t, "", jwt) } func TestLogin_WrongPassword(t *testing.T) { - svc, mockRepo, _ := createTestService() + svc, mockRepo, _, _ := createTestService() dto := domain.LoginDTO{ UsernameOrEmail: "user123", @@ -96,14 +97,14 @@ func TestLogin_WrongPassword(t *testing.T) { dto.UsernameOrEmail, dto.UsernameOrEmail, ).Return(&user, nil) - jwt, err := svc.Login(dto) + jwt, err := svc.Login(context.Background(), dto) assert.ErrorIs(t, err, domain.ErrLoginFailed) assert.Equal(t, "", jwt) } func TestLogin_JWTFailed(t *testing.T) { - svc, mockRepo, _ := createTestService() + svc, mockRepo, _, _ := createTestService() // Prepare @@ -137,7 +138,7 @@ func TestLogin_JWTFailed(t *testing.T) { // Verify - jwt, err := svc.Login(dto) + jwt, err := svc.Login(context.Background(), dto) assert.Error(t, err) assert.Equal(t, "", jwt) diff --git a/src/test/unit/register_test.go b/src/test/unit/register_test.go index 240d9d2..8ff1227 100644 --- a/src/test/unit/register_test.go +++ b/src/test/unit/register_test.go @@ -2,6 +2,7 @@ package test import ( domain "bookem-user-service/domain" + "context" "errors" "strings" "testing" @@ -11,14 +12,14 @@ import ( ) func TestSuccess(t *testing.T) { - svc, mockRepo, _ := createTestService() + svc, mockRepo, _, _ := createTestService() dto := *defaultUserDTO mockRepo.On("FindByUsernameOrEmail", dto.Username, dto.Email).Return(nil, nil) mockRepo.On("Create", mock.AnythingOfType("*domain.User")).Return(nil) - user, err := svc.Register(&dto) + user, err := svc.Register(context.Background(), &dto) assert.NoError(t, err) assert.Equal(t, dto.Username, user.Username) @@ -28,7 +29,7 @@ func TestSuccess(t *testing.T) { } func TestUsernameExists(t *testing.T) { - svc, mockRepo, _ := createTestService() + svc, mockRepo, _, _ := createTestService() dto := *defaultUserDTO dto.Username = "username" @@ -38,14 +39,14 @@ func TestUsernameExists(t *testing.T) { mockRepo.On("FindByUsernameOrEmail", dto.Username, dto.Email).Return(&existing, nil) - user, err := svc.Register(&dto) + user, err := svc.Register(context.Background(), &dto) assert.Nil(t, user) assert.ErrorIs(t, err, domain.ErrUsernameExists) } func TestEmailExists(t *testing.T) { - svc, mockRepo, _ := createTestService() + svc, mockRepo, _, _ := createTestService() dto := *defaultUserDTO dto.Username = "user1" @@ -57,21 +58,21 @@ func TestEmailExists(t *testing.T) { mockRepo.On("FindByUsernameOrEmail", dto.Username, dto.Email).Return(&existing, nil) - user, err := svc.Register(&dto) + user, err := svc.Register(context.Background(), &dto) assert.Nil(t, user) assert.ErrorIs(t, err, domain.ErrEmailExists) } func TestCreateFails(t *testing.T) { - svc, mockRepo, _ := createTestService() + svc, mockRepo, _, _ := createTestService() dto := *defaultUserDTO mockRepo.On("FindByUsernameOrEmail", dto.Username, dto.Email).Return(nil, nil) mockRepo.On("Create", mock.Anything).Return(errors.New("db down")) - user, err := svc.Register(&dto) + user, err := svc.Register(context.Background(), &dto) assert.Nil(t, user) assert.ErrorContains(t, err, "db down") diff --git a/src/test/unit/update_test.go b/src/test/unit/update_test.go index 4304b03..f43398a 100644 --- a/src/test/unit/update_test.go +++ b/src/test/unit/update_test.go @@ -1,6 +1,7 @@ package test import ( + "context" "fmt" "testing" @@ -10,7 +11,7 @@ import ( ) func TestUpdate_Success(t *testing.T) { - svc, mockRepo, _ := createTestService() + svc, mockRepo, _, _ := createTestService() // Prepare @@ -33,14 +34,14 @@ func TestUpdate_Success(t *testing.T) { // Verify - newUser, err := svc.Update(1, dto) + newUser, err := svc.Update(context.Background(), 1, dto) assert.NoError(t, err) assert.Equal(t, userAfter, *newUser) } func TestUpdate_SomeoneElse(t *testing.T) { - svc, _, _ := createTestService() + svc, _, _, _ := createTestService() // Prepare @@ -50,7 +51,7 @@ func TestUpdate_SomeoneElse(t *testing.T) { // Verify - newUser, err := svc.Update(1, dto) + newUser, err := svc.Update(context.Background(), 1, dto) assert.Nil(t, newUser) assert.Error(t, err) @@ -58,7 +59,7 @@ func TestUpdate_SomeoneElse(t *testing.T) { } func TestUpdate_UserNotFound(t *testing.T) { - svc, mockRepo, _ := createTestService() + svc, mockRepo, _, _ := createTestService() // Prepare @@ -72,14 +73,14 @@ func TestUpdate_UserNotFound(t *testing.T) { // Verify - newUser, err := svc.Update(uint(1), dto) + newUser, err := svc.Update(context.Background(), uint(1), dto) assert.Nil(t, newUser) assert.Error(t, err) } func TestUpdate_UsernameTaken(t *testing.T) { - svc, mockRepo, _ := createTestService() + svc, mockRepo, _, _ := createTestService() // Prepare @@ -98,7 +99,7 @@ func TestUpdate_UsernameTaken(t *testing.T) { // Verify - newUser, err := svc.Update(1, dto) + newUser, err := svc.Update(context.Background(), 1, dto) assert.Nil(t, newUser) assert.Error(t, err) @@ -106,7 +107,7 @@ func TestUpdate_UsernameTaken(t *testing.T) { } func TestUpdate_EmailTaken(t *testing.T) { - svc, mockRepo, _ := createTestService() + svc, mockRepo, _, _ := createTestService() // Prepare @@ -125,7 +126,7 @@ func TestUpdate_EmailTaken(t *testing.T) { // Verify - newUser, err := svc.Update(1, dto) + newUser, err := svc.Update(context.Background(), 1, dto) assert.Nil(t, newUser) assert.Error(t, err) @@ -133,7 +134,7 @@ func TestUpdate_EmailTaken(t *testing.T) { } func TestUpdate_UsernameTakenEmailOk(t *testing.T) { - svc, mockRepo, _ := createTestService() + svc, mockRepo, _, _ := createTestService() // Prepare @@ -155,7 +156,7 @@ func TestUpdate_UsernameTakenEmailOk(t *testing.T) { // Verify - newUser, err := svc.Update(1, dto) + newUser, err := svc.Update(context.Background(), 1, dto) assert.Nil(t, newUser) assert.Error(t, err) @@ -163,7 +164,7 @@ func TestUpdate_UsernameTakenEmailOk(t *testing.T) { } func TestUpdate_UsernameOkEmailTaken(t *testing.T) { - svc, mockRepo, _ := createTestService() + svc, mockRepo, _, _ := createTestService() // Prepare @@ -185,7 +186,7 @@ func TestUpdate_UsernameOkEmailTaken(t *testing.T) { // Verify - newUser, err := svc.Update(1, dto) + newUser, err := svc.Update(context.Background(), 1, dto) assert.Nil(t, newUser) assert.Error(t, err) diff --git a/src/test/unit/utils.go b/src/test/unit/utils.go index e38f8e4..ae704f3 100644 --- a/src/test/unit/utils.go +++ b/src/test/unit/utils.go @@ -1,20 +1,23 @@ package test import ( + "bookem-user-service/client/reservationclient" "bookem-user-service/client/roomclient" domain "bookem-user-service/domain" service "bookem-user-service/service" + "context" mock "github.com/stretchr/testify/mock" ) -func createTestService() (service.Service, *MockRepo, *MockRoomClient) { +func createTestService() (service.Service, *MockRepo, *MockRoomClient, *MockReservationClient) { mockRepo := new(MockRepo) mockRoomClient := new(MockRoomClient) + mockReservationClient := new(MockReservationClient) - svc := service.NewService(mockRepo, mockRoomClient) + svc := service.NewService(mockRepo, mockRoomClient, mockReservationClient) - return svc, mockRepo, mockRoomClient + return svc, mockRepo, mockRoomClient, mockReservationClient } // ---------------------------------------------- Mock repo @@ -53,8 +56,9 @@ func (m *MockRepo) Update(user *domain.User) error { return args.Error(0) } -func (m *MockRepo) Delete(id uint) { - m.Called(id) +func (m *MockRepo) Delete(user *domain.User) error { + args := m.Called(user) + return args.Error(0) } // ---------------------------------------------- Mock room client @@ -63,15 +67,27 @@ type MockRoomClient struct { mock.Mock } -func (m *MockRoomClient) GetPendingGuestReservations(guest *domain.User) ([]roomclient.ReservationDTO, error) { - args := m.Called(guest) - reservations, _ := args.Get(0).([]roomclient.ReservationDTO) +func (m *MockRoomClient) DeleteHostRooms(ctx context.Context, jwt string) ([]roomclient.RoomDTO, error) { + args := m.Called(ctx, jwt) + rooms, _ := args.Get(0).([]roomclient.RoomDTO) + return rooms, args.Error(1) +} + +// ---------------------------------------------- Mock reservation client + +type MockReservationClient struct { + mock.Mock +} + +func (m *MockReservationClient) GetActiveGuestReservations(ctx context.Context, jwt string) ([]reservationclient.ReservationDTO, error) { + args := m.Called(ctx, jwt) + reservations, _ := args.Get(0).([]reservationclient.ReservationDTO) return reservations, args.Error(1) } -func (m *MockRoomClient) GetActiveHostReservations(host *domain.User) ([]roomclient.ReservationDTO, error) { - args := m.Called(host) - reservations, _ := args.Get(0).([]roomclient.ReservationDTO) +func (m *MockReservationClient) GetActiveHostReservations(ctx context.Context, jwt string) ([]reservationclient.ReservationDTO, error) { + args := m.Called(ctx, jwt) + reservations, _ := args.Get(0).([]reservationclient.ReservationDTO) return reservations, args.Error(1) } diff --git a/src/util/telemetry.go b/src/util/telemetry.go new file mode 100644 index 0000000..8857f1d --- /dev/null +++ b/src/util/telemetry.go @@ -0,0 +1,213 @@ +package utils + +import ( + "context" + "fmt" + "log" + "log/slog" + "net/http" + "os" + "time" + + "github.com/gin-gonic/gin" + slogmulti "github.com/samber/slog-multi" + "go.opentelemetry.io/otel" + "go.opentelemetry.io/otel/attribute" + "go.opentelemetry.io/otel/codes" + "go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp" + "go.opentelemetry.io/otel/propagation" + "go.opentelemetry.io/otel/sdk/resource" + sdktrace "go.opentelemetry.io/otel/sdk/trace" + semconv "go.opentelemetry.io/otel/semconv/v1.21.0" + "go.opentelemetry.io/otel/trace" +) + +type SpanPair struct { + Ctx context.Context + Span trace.Span +} + +type Telemetry struct { + // During tests, the tracer is not set up, so we silently ignore tracing. + // This has to be done manually (i.e. don't call tracer methods, don't touch + // spans etc.) + tracerReady bool + Tracer trace.Tracer + + SpanStack []SpanPair + + loggerReady bool + logger *slog.Logger +} + +var TEL Telemetry + +func (t *Telemetry) Init(ctx context.Context, serviceName, deploymentEnvironment string) func(context.Context) error { + // [0] Init logger + { + err := t.initLogger() + if err != nil { + // log.Printf instead of the logger here! + log.Printf("Could not initialize logger: %v", err) + } + + } + // [1] Init tracer + { + exp, err := otlptracehttp.New(ctx) + if err != nil { + log.Fatalf("Failed to create an OTLP HTTP exporter: %v", err) + } + + tp := sdktrace.NewTracerProvider( + sdktrace.WithBatcher(exp), + sdktrace.WithResource(resource.NewWithAttributes( + semconv.SchemaURL, + semconv.ServiceName(serviceName), + semconv.DeploymentEnvironment(deploymentEnvironment), + )), + ) + otel.SetTracerProvider(tp) + otel.SetTextMapPropagator(propagation.TraceContext{}) + + t.Tracer = otel.Tracer(serviceName) + t.tracerReady = true + return tp.Shutdown + } +} + +func (t *Telemetry) initLogger() error { + err := os.MkdirAll("/app/logs", 0755) + if err != nil { + return err + } + + logFile, err := os.OpenFile("/app/logs/app.log", os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0666) + if err != nil { + return err + } + + t.logger = slog.New( + slogmulti.Fanout( + slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelDebug}), + slog.NewJSONHandler(logFile, &slog.HandlerOptions{Level: slog.LevelDebug}), + ), + ) + + t.loggerReady = true + t.Debug("Logger initialized") + return nil +} + +func (t *Telemetry) GetLoggingMiddleware() gin.HandlerFunc { + return func(c *gin.Context) { + c.Next() + t.logger.Info("request", + slog.String("method", c.Request.Method), + slog.String("path", c.Request.URL.Path), + slog.Int("status", c.Writer.Status()), + slog.String("client_ip", c.ClientIP()), + slog.String("user_agent", c.Request.UserAgent()), + slog.Time("timestamp", time.Now()), + ) + } +} + +func (t *Telemetry) Push(ctx context.Context, name string, attrs ...attribute.KeyValue) { + if t.tracerReady { + newCtx, span := t.Tracer.Start(ctx, name, trace.WithAttributes(attrs...)) + t.SpanStack = append(t.SpanStack, SpanPair{Ctx: newCtx, Span: span}) + } else { + newCtx := ctx + var span trace.Span + t.SpanStack = append(t.SpanStack, SpanPair{Ctx: newCtx, Span: span}) + } +} + +func (t *Telemetry) Pop() { + top := t.SpanStack[len(t.SpanStack)-1] + if t.tracerReady { + top.Span.End() + } + t.SpanStack = t.SpanStack[:len(t.SpanStack)-1] +} + +func (t *Telemetry) Top() SpanPair { + return t.SpanStack[len(t.SpanStack)-1] +} + +func (t *Telemetry) Ctx() context.Context { + if len(t.SpanStack) > 0 { + return t.Top().Ctx + } else { + return context.Background() // Ehh... + } +} + +func (t *Telemetry) SetAttrib(kv ...attribute.KeyValue) { + if t.tracerReady { + t.Top().Span.SetAttributes(kv...) + } +} + +func (t *Telemetry) SetUser(id uint) { + if t.tracerReady { + t.Top().Span.SetAttributes(attribute.String("user.id", fmt.Sprintf("%d", id))) + } +} + +func (t *Telemetry) Inject(outgoingRequest *http.Request) { + otel.GetTextMapPropagator().Inject(t.Ctx(), propagation.HeaderCarrier(outgoingRequest.Header)) +} + +func (t *Telemetry) Info(msg string, attrs ...any) { + if t.loggerReady { + t.logger.Info(msg, attrs...) + } + if span := t.currentSpan(); span != nil { + span.AddEvent(msg) + } +} + +func (t *Telemetry) Warn(msg string, attrs ...any) { + if t.loggerReady { + t.logger.Warn(msg, attrs...) + } + if span := t.currentSpan(); span != nil { + span.AddEvent(msg) + } +} + +func (t *Telemetry) Debug(msg string, attrs ...any) { + if t.loggerReady { + t.logger.Debug(msg, attrs...) + } + if span := t.currentSpan(); span != nil { + span.AddEvent(msg) + } +} + +func (t *Telemetry) Error(msg string, err error, attrs ...any) { + if t.loggerReady { + if err != nil { + attrs = append(attrs, slog.Any("error", err)) + } + t.logger.Error(msg, attrs...) + } + + if span := t.currentSpan(); span != nil { + span.AddEvent(msg, trace.WithAttributes(attribute.Bool("error", true))) + span.SetStatus(codes.Error, "error") + if err != nil { + span.AddEvent(msg, trace.WithAttributes(attribute.String("error.message", err.Error()))) + span.SetStatus(codes.Error, err.Error()) + } + } +} + +func (t *Telemetry) currentSpan() trace.Span { + if t.tracerReady && len(t.SpanStack) > 0 { + return t.SpanStack[len(t.SpanStack)-1].Span + } + return nil +}