diff --git a/.env b/.env new file mode 100644 index 0000000..805ae21 --- /dev/null +++ b/.env @@ -0,0 +1,4 @@ +TODO_PORT=7540 +TODO_DBFILE="./storage/scheduler.db" +TODO_PASSWORD="password" +TODO_JWT_SECRET_KEY="secret_key" \ No newline at end of file diff --git a/.github/workflows/push.yaml b/.github/workflows/push.yaml new file mode 100644 index 0000000..a422ff6 --- /dev/null +++ b/.github/workflows/push.yaml @@ -0,0 +1,49 @@ +name: golang-pipeline +on: push +jobs: + test: + runs-on: ubuntu-latest + container: golang:1.23 + steps: + - uses: actions/checkout@v4 + + - name: Run Unit Tests + env: + ENV TODO_DBFILE: /storage/scheduler.db + run: GOOS=linux GOARCH=amd64 go test ./... + + - name: Vet + run: | + go vet ./... + + deploy: + name: Push Docker image to Docker Hub + runs-on: ubuntu-latest + needs: test + if: startsWith(github.ref, 'refs/tags') + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up Docker buildx + uses: docker/setup-buildx-action@v3 + + - name: Log in to Docker Hub + uses: docker/login-action@v3 + with: + username: ${{ secrets.DOCKER_USERNAME }} + password: ${{ secrets.DOCKER_ACCESS_TOKEN }} + + - name: Extract metadata (tags, labels) for Docker + id: meta + uses: docker/metadata-action@v5.5.1 + with: + images: denisushakov/todo-rest + + - name: Build and push Docker Inage + uses: docker/build-push-action@v5 + with: + context: . + push: true + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} \ No newline at end of file diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..845e927 --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +.vscode + +.exe \ No newline at end of file diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..1827302 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,26 @@ +FROM golang:1.23.2-alpine AS builder + +RUN apk add --no-cache build-base + +WORKDIR /app + +COPY . . + +RUN go mod download + +RUN CGO_ENABLED=1 GOOS=linux GOARCH=amd64 go build -o my_app ./cmd/scheduler/main.go + +FROM alpine:3.18 + +ENV TODO_PORT=7540 +ENV TODO_DBFILE=/app/storage/scheduler.db + +WORKDIR /app + +COPY --from=builder /app/my_app /app/ +COPY --from=builder /app/.env . +COPY --from=builder /app/web ./web + +RUN mkdir -p /app/storage + +CMD ["./my_app"] \ No newline at end of file diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..9fcd886 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2024 Denis Ushakov + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. \ No newline at end of file diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..c9a6dbd --- /dev/null +++ b/Makefile @@ -0,0 +1,23 @@ +IMAGE_NAME = todo-app +CONTAINER_NAME = todo-container +PORT = 7540 + +build: + docker build -t $(IMAGE_NAME) . + +run: + docker run -d -p $(PORT):$(PORT) --name $(CONTAINER_NAME) $(IMAGE_NAME) + +stop: + docker stop $(CONTAINER_NAME) + +rm: + docker rm $(CONTAINER_NAME) + +restart: stop rm build run + +exec: + docker exec -it $(CONTAINER_NAME) /bin/sh + +rmi: + docker rmi $(IMAGE_NAME) \ No newline at end of file diff --git a/README.md b/README.md index 597678a..2cad674 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,141 @@ -# Файлы для итогового задания +# Todo-Rest API -В директории `tests` находятся тесты для проверки API, которое должно быть реализовано в веб-сервере. +**Todo-Rest API** — это серверное приложение для управления задачами с использованием Go. Оно предоставляет REST API для создания, редактирования и удаления задач. Приложение поддерживает аутентификацию с использованием JWT-токенов, а также хранит данные задач в SQLite базе данных. -Директория `web` содержит файлы фронтенда. \ No newline at end of file +## Описание +Данное приложение представляет собой API для системы управления задачами, которое позволяет пользователям создавать задачи с указанием сроков, описания и повторяющихся событий. Также реализован механизм аутентификации и авторизации на основе токенов. + +## Функциональные возможности: + - Создание, редактирование и удаление задач + - Поддержка повторяющихся задач + - Аутентификация с использованием JWT + - Хранение данных в базе данных SQLite + - Валидация данных на уровне API + - Поддержка переменных окружения для конфигурации +## Технологии + - Go 1.23.2 + - SQLite для хранения данных + - Chi для роутинга + - Docker для контейнеризации + - Buildx и GitHub Actions для CI/CD + - JWT для аутентификации + - Окружение для разработки: WSL/Ubuntu + +## Установка и запуск + +### Локальная установка +Для установки и запуска приложения на локальной машине, следуйте инструкциям ниже: + +1. Склонируйте репозиторий: + +```bash +git clone https://github.com/denisushakov/todo-rest.git +``` + +2. Перейдите в папку проекта: + +```bash +cd todo-rest +``` + +3. Установите зависимости: + +```bash +go mod download +``` + +4. Запустите приложение: + +```bash +go run ./cmd/scheduler/main.go +``` + +### Запуск в Docker +Для запуска приложения в контейнере Docker, выполните следующие шаги: + +1. Соберите Docker-образ: + +```bash +docker build -t todo-rest . +``` + +2. Запустите контейнер: + +```bash +docker run -d -p 7540:7540 --name todo-rest todo-rest +``` + +### Переменные окружения +Приложение использует следующие переменные окружения для конфигурации: + + - TODO_PORT — порт, на котором работает сервер (по умолчанию: 7540) + - TODO_DBFILE — путь к файлу базы данных SQLite (по умолчанию: ./storage/scheduler.db) + - TODO_PASSWORD — пароль для аутентификации +Вы можете изменить их, добавив файл .env в корень проекта. + +## Пример использования API + +1. Создание задачи + +Запрос: + +```bash +POST /api/task +Content-Type: application/json + +{ + "title": "Новая задача", + "description": "Описание задачи", + "date": "2024-12-01", + "repeat": "d 5" +} +``` + +Ответ: + +```bash +{ + "id": "1", + "title": "Новая задача", + "description": "Описание задачи", + "date": "2024-12-01", + "repeat": "d 5" +} +``` + +2. Получение задачи + +Запрос: + +```bash +GET /api/task?id=1 +``` + +Ответ: + +```bash +{ + "id": "1", + "title": "Новая задача", + "description": "Описание задачи", + "date": "2024-12-01", + "repeat": "d 5" +} +``` + +## Тестирование + +Для запуска тестов выполните команду: + +```bash +go test ./tests +``` + +Вы также можете запустить тесты в Docker-контейнере: + +```bash +docker run --rm todo-rest go test ./tests +``` + +## Лицензия +Данный проект лицензируется под лицензией MIT. См. файл LICENSE для получения подробной информации. \ No newline at end of file diff --git a/app b/app new file mode 100644 index 0000000..7f6b32d Binary files /dev/null and b/app differ diff --git a/cmd/scheduler/main.go b/cmd/scheduler/main.go new file mode 100644 index 0000000..5199a94 --- /dev/null +++ b/cmd/scheduler/main.go @@ -0,0 +1,24 @@ +package main + +import ( + "log" + "net/http" + + "github.com/denisushakov/todo-rest/internal/config" + "github.com/denisushakov/todo-rest/pkg/router" +) + +func main() { + config.MustLoad() + + port := ":" + config.Port + + router := router.SetupRouter() + + log.Printf("Server is running at %s", port) + if err := http.ListenAndServe(port, router); err != nil { + log.Fatalf("failed to start server: %v", err) + } + + log.Fatalf("server stopped") +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..94d1626 --- /dev/null +++ b/go.mod @@ -0,0 +1,19 @@ +module github.com/denisushakov/todo-rest + +go 1.23.2 + +require ( + github.com/go-chi/chi/v5 v5.1.0 + github.com/golang-jwt/jwt/v5 v5.2.1 + github.com/jmoiron/sqlx v1.4.0 + github.com/mattn/go-sqlite3 v1.14.23 + github.com/stretchr/testify v1.9.0 + github.com/subosito/gotenv v1.6.0 +) + +require ( + github.com/davecgh/go-spew v1.1.1 // indirect + github.com/pmezard/go-difflib v1.0.0 // indirect + golang.org/x/text v0.12.0 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..ff12929 --- /dev/null +++ b/go.sum @@ -0,0 +1,29 @@ +filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA= +filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4= +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/go-chi/chi/v5 v5.1.0 h1:acVI1TYaD+hhedDJ3r54HyA6sExp3HfXq7QWEEY/xMw= +github.com/go-chi/chi/v5 v5.1.0/go.mod h1:DslCQbL2OYiznFReuXYUmQ2hGd1aDpCnlMNITLSKoi8= +github.com/go-sql-driver/mysql v1.8.1 h1:LedoTUt/eveggdHS9qUFC1EFSa8bU2+1pZjSRpvNJ1Y= +github.com/go-sql-driver/mysql v1.8.1/go.mod h1:wEBSXgmK//2ZFJyE+qWnIsVGmvmEKlqwuVSjsCm7DZg= +github.com/golang-jwt/jwt/v5 v5.2.1 h1:OuVbFODueb089Lh128TAcimifWaLhJwVflnrgM17wHk= +github.com/golang-jwt/jwt/v5 v5.2.1/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk= +github.com/jmoiron/sqlx v1.4.0 h1:1PLqN7S1UYp5t4SrVVnt4nUVNemrDAtxlulVe+Qgm3o= +github.com/jmoiron/sqlx v1.4.0/go.mod h1:ZrZ7UsYB/weZdl2Bxg6jCRO9c3YHl8r3ahlKmRT4JLY= +github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw= +github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o= +github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= +github.com/mattn/go-sqlite3 v1.14.23 h1:gbShiuAP1W5j9UOksQ06aiiqPMxYecovVGwmTxWtuw0= +github.com/mattn/go-sqlite3 v1.14.23/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= +github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8= +github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU= +golang.org/x/text v0.12.0 h1:k+n5B8goJNdU7hSvEtMUz3d1Q6D/XW4COJSJR6fN0mc= +golang.org/x/text v0.12.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/internal/config/config.go b/internal/config/config.go new file mode 100644 index 0000000..2c0bdcc --- /dev/null +++ b/internal/config/config.go @@ -0,0 +1,67 @@ +package config + +import ( + "log" + "os" + "path/filepath" + + "github.com/subosito/gotenv" +) + +const ( + DefaultPort = "8080" + DefaultDBFile = "./storage/scheduler.db" + DefaultWebDir = "./web" + MaxTaskLimit = 50 +) + +var ( + Port string + DBFilePath string + WebDirPath string + Password string + SecretKeyBytes []byte +) + +func MustLoad() { + + dir, err := os.Getwd() // current directory + if err != nil { + log.Fatalf("failed to get current directory: %v", err) + } + + if filepath.Base(dir) == "tests" { + dir = filepath.Dir(dir) + } + + err = gotenv.Load(absPath(dir, ".env")) + if err != nil { + log.Fatalf("env file is not set: %v", err) + } + + WebDirPath = absPath(dir, DefaultWebDir) + + Port = os.Getenv("TODO_PORT") + if Port == "" { + Port = DefaultPort + } + + DBFilePath = os.Getenv("TODO_DBFILE") + if DBFilePath == "" { + DBFilePath = DefaultDBFile + } + DBFilePath = absPath(dir, DBFilePath) + + Password = os.Getenv("TODO_PASSWORD") + + secretKey := os.Getenv("TODO_JWT_SECRET_KEY") + if secretKey == "" { + log.Fatal("secret key is empty") + } + SecretKeyBytes = []byte(secretKey) + +} + +func absPath(dir, path string) string { + return filepath.Join(dir, path) +} diff --git a/internal/http-server/handlers/interfaces.go b/internal/http-server/handlers/interfaces.go new file mode 100644 index 0000000..6fa59a2 --- /dev/null +++ b/internal/http-server/handlers/interfaces.go @@ -0,0 +1,24 @@ +package handlers + +import "github.com/denisushakov/todo-rest/pkg/models" + +type TaskSaver interface { + SaveTask(task *models.Task) (int64, error) +} + +type TaskGetter interface { + GetTasks(search string) ([]*models.Task, error) + GetTaskByID(id string) (*models.Task, error) +} + +type TaskUpdater interface { + UpdateTask(task *models.Task) error +} + +type TaskConditionUpdater interface { + MarkTaskCompleted(id string) error +} + +type TaskRemover interface { + DeleteTask(id string) error +} diff --git a/internal/http-server/handlers/nextdate.go b/internal/http-server/handlers/nextdate.go new file mode 100644 index 0000000..fa80c86 --- /dev/null +++ b/internal/http-server/handlers/nextdate.go @@ -0,0 +1,32 @@ +package handlers + +import ( + "log" + "net/http" + "time" + + "github.com/denisushakov/todo-rest/internal/scheduler" +) + +func GetNextDate(w http.ResponseWriter, r *http.Request) { + now := r.FormValue("now") + date := r.FormValue("date") + repeat := r.FormValue("repeat") + + nowDate, err := time.Parse("20060102", now) + if err != nil { + writeErrorResponse(w, err, http.StatusBadRequest) + log.Printf("time cannot pasre: %s", err) + return + } + + newDate, err := scheduler.NextDate(nowDate, date, repeat) + if err != nil { + writeErrorResponse(w, err, http.StatusBadRequest) + log.Printf("new date not created: %s", err) + return + } + + w.WriteHeader(http.StatusOK) + w.Write([]byte(newDate)) +} diff --git a/internal/http-server/handlers/routes.go b/internal/http-server/handlers/routes.go new file mode 100644 index 0000000..d7be5ac --- /dev/null +++ b/internal/http-server/handlers/routes.go @@ -0,0 +1,27 @@ +package handlers + +import ( + mwAuth "github.com/denisushakov/todo-rest/internal/http-server/middleware/auth" + "github.com/denisushakov/todo-rest/internal/scheduler" + "github.com/go-chi/chi/v5" +) + +func RegisterRoutes(router *chi.Mux, scheduler *scheduler.Planner) { + router.Get("/api/nextdate", GetNextDate) + router.Post("/api/signin", LoginHandler) + + router.Route("/api", func(r chi.Router) { + r.Use(mwAuth.Auth) + + r.Get("/tasks", GetTasks(scheduler)) + r.Post("/task", SaveTask(scheduler)) + + r.Get("/task", GetTaskByID(scheduler)) + + r.Put("/task", UpdateTask(scheduler)) + + r.Post("/task/done", MarkTaskCompleted(scheduler)) + + r.Delete("/task", DeleteTask(scheduler)) + }) +} diff --git a/internal/http-server/handlers/sign.go b/internal/http-server/handlers/sign.go new file mode 100644 index 0000000..8dfb457 --- /dev/null +++ b/internal/http-server/handlers/sign.go @@ -0,0 +1,51 @@ +package handlers + +import ( + "encoding/json" + "fmt" + "net/http" + + "github.com/denisushakov/todo-rest/internal/config" + "github.com/denisushakov/todo-rest/internal/http-server/middleware/auth" + "github.com/denisushakov/todo-rest/pkg/models" + "github.com/golang-jwt/jwt/v5" +) + +func LoginHandler(w http.ResponseWriter, r *http.Request) { + var auth models.Auth + + if err := json.NewDecoder(r.Body).Decode(&auth); err != nil { + writeErrorResponse(w, err, http.StatusBadRequest) + return + } + + if auth.Password != config.Password { + http.Error(w, `{"error": "wrong password"}`, http.StatusUnauthorized) + return + } + + token, err := GenerateToken(auth.Password) + if err != nil { + http.Error(w, `{"error": "token invalid"}`, http.StatusInternalServerError) + return + } + + w.Header().Set("Content-Type", "application/json; charset=UTF-8") + json.NewEncoder(w).Encode(map[string]interface{}{"token": token}) +} + +func GenerateToken(password string) (string, error) { + hashString := auth.GetHashString(password) + + claims := jwt.MapClaims{ + "password_hash": hashString, + } + + jwtToken := jwt.NewWithClaims(jwt.SigningMethodHS256, claims) + + signedToken, err := jwtToken.SignedString(config.SecretKeyBytes) + if err != nil { + return "", fmt.Errorf("failed to sign jwt: %w", err) + } + return signedToken, nil +} diff --git a/internal/http-server/handlers/tasks.go b/internal/http-server/handlers/tasks.go new file mode 100644 index 0000000..5d5197d --- /dev/null +++ b/internal/http-server/handlers/tasks.go @@ -0,0 +1,162 @@ +package handlers + +import ( + "database/sql" + "encoding/json" + "errors" + "log" + "net/http" + + "github.com/denisushakov/todo-rest/internal/storage/sqlite" + "github.com/denisushakov/todo-rest/pkg/models" +) + +type ErrorResponse struct { + Error string `json:"error"` +} + +func writeErrorResponse(w http.ResponseWriter, err error, statusCode int) { + response := ErrorResponse{ + Error: err.Error(), + } + jsonResponse, err := json.Marshal(response) + if err != nil { + log.Printf("error marshal JSON: %v", err) + http.Error(w, "Internal Server Error", http.StatusInternalServerError) + return + } + w.WriteHeader(statusCode) + w.Write(jsonResponse) +} + +func SaveTask(taskSaver TaskSaver) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + var task models.Task + + if err := json.NewDecoder(r.Body).Decode(&task); err != nil { + writeErrorResponse(w, err, http.StatusBadRequest) + return + } + + id, err := taskSaver.SaveTask(&task) + if err != nil { + switch { + case errors.Is(err, sql.ErrConnDone): + writeErrorResponse(w, err, http.StatusInternalServerError) + default: + writeErrorResponse(w, err, http.StatusBadRequest) + } + return + } + + w.Header().Set("Content-Type", "application/json; charset=UTF-8") + json.NewEncoder(w).Encode(map[string]interface{}{"id": id}) + } +} + +func GetTasks(taskGetter TaskGetter) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + search := r.URL.Query().Get("search") + + tasks, err := taskGetter.GetTasks(search) + if err != nil { + switch { + case errors.Is(err, sql.ErrConnDone): + writeErrorResponse(w, err, http.StatusInternalServerError) + default: + writeErrorResponse(w, err, http.StatusBadRequest) + } + return + } + + w.Header().Set("Content-Type", "application/json; charset=UTF-8") + json.NewEncoder(w).Encode(map[string]interface{}{"tasks": tasks}) + } +} + +func GetTaskByID(taskGetter TaskGetter) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + id := r.URL.Query().Get("id") + + if id == "" { + http.Error(w, `{"error": "id not specified"}`, http.StatusBadRequest) + return + } + + task, err := taskGetter.GetTaskByID(id) + if err != nil { + switch { + case errors.Is(err, sql.ErrNoRows): + writeErrorResponse(w, err, http.StatusNotFound) + default: + writeErrorResponse(w, err, http.StatusInternalServerError) + } + return + } + + w.Header().Set("Content-Type", "application/json; charset=UTF-8") + json.NewEncoder(w).Encode(task) + } +} + +func UpdateTask(taskUpdater TaskUpdater) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + var task models.Task + + if err := json.NewDecoder(r.Body).Decode(&task); err != nil { + writeErrorResponse(w, err, http.StatusBadRequest) + return + } + + if err := taskUpdater.UpdateTask(&task); err != nil { + switch { + case errors.Is(err, sqlite.ErrNotFound): + writeErrorResponse(w, err, http.StatusNotFound) + default: + writeErrorResponse(w, err, http.StatusInternalServerError) + } + return + } + + w.Header().Set("Content-Type", "application/json; charset=UTF-8") + json.NewEncoder(w).Encode(map[string]interface{}{}) + } +} + +func MarkTaskCompleted(taskConditionUpdater TaskConditionUpdater) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + id := r.URL.Query().Get("id") + + if id == "" { + http.Error(w, `{"error": "id not specified"}`, http.StatusBadRequest) + return + } + + if err := taskConditionUpdater.MarkTaskCompleted(id); err != nil { + writeErrorResponse(w, err, http.StatusNotFound) + return + } + + w.Header().Set("Content-Type", "application/json; charset=UTF-8") + json.NewEncoder(w).Encode(map[string]interface{}{}) + } +} + +func DeleteTask(taskRemover TaskRemover) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + id := r.URL.Query().Get("id") + + if id == "" { + http.Error(w, `{"error": "id not specified"}`, http.StatusBadRequest) + return + } + + if err := taskRemover.DeleteTask(id); err != nil { + writeErrorResponse(w, err, http.StatusNotFound) + return + } + + w.Header().Set("Content-Type", "application/json; charset=UTF-8") + json.NewEncoder(w).Encode(map[string]interface{}{}) + } +} diff --git a/internal/http-server/middleware/auth/auth.go b/internal/http-server/middleware/auth/auth.go new file mode 100644 index 0000000..c517592 --- /dev/null +++ b/internal/http-server/middleware/auth/auth.go @@ -0,0 +1,66 @@ +package auth + +import ( + "crypto/sha256" + "encoding/hex" + "net/http" + + "github.com/denisushakov/todo-rest/internal/config" + "github.com/golang-jwt/jwt/v5" +) + +func Auth(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + pass := config.Password + if pass == "" { + next.ServeHTTP(w, r) + return + } + + cookie, err := r.Cookie("token") + if err != nil { + http.Error(w, "Authentication required", http.StatusUnauthorized) + return + } + + jwtToken, err := jwt.Parse(cookie.Value, func(jwtToken *jwt.Token) (interface{}, error) { + return config.SecretKeyBytes, nil + }) + if err != nil || !jwtToken.Valid { + http.Error(w, `{"error": "invalid token"}`, http.StatusUnauthorized) + return + } + + claims, ok := jwtToken.Claims.(jwt.MapClaims) + if !ok { + http.Error(w, `{"error": "invalid token"}`, http.StatusUnauthorized) + return + } + + hashRow, ok := claims["password_hash"] + if !ok { + http.Error(w, `{"error": "haven't hash password's"}`, http.StatusUnauthorized) + return + } + + hash, ok := hashRow.(string) + if !ok { + http.Error(w, `{"error": "haven't password"}`, http.StatusUnauthorized) + return + } + + newHashString := GetHashString(pass) + + if hash != newHashString { + http.Error(w, `{"error": "authentification required"}`, http.StatusUnauthorized) + return + } + + next.ServeHTTP(w, r) + }) +} + +func GetHashString(val string) string { + hash := sha256.Sum256([]byte(val)) + return hex.EncodeToString(hash[:]) +} diff --git a/internal/scheduler/scheduler.go b/internal/scheduler/scheduler.go new file mode 100644 index 0000000..88bc1f2 --- /dev/null +++ b/internal/scheduler/scheduler.go @@ -0,0 +1,284 @@ +package scheduler + +import ( + "fmt" + "math" + "sort" + "strconv" + "strings" + "time" +) + +func NextDate(now time.Time, date string, repeat string) (string, error) { + if repeat == "" { + return "", fmt.Errorf("colomn 'repeat' is empty") + } + + currentDate, err := time.Parse("20060102", date) + if err != nil { + return "", fmt.Errorf("invalid date format: %w", err) + } + + s := strings.Split(repeat, " ") + + var newDate time.Time + switch s[0] { + case "d": + if len(s) != 2 { + return "", fmt.Errorf("invalid repeat format: %s", repeat) + } + days, err := ParseDays(s[1]) + if err != nil { + return "", fmt.Errorf("invalid day range: %s", s[1]) + } + newDate = NextNearestDay(now, currentDate, days) + case "y": + newDate = NextNearestYear(now, currentDate) + case "w": + if len(s) != 2 { + return "", fmt.Errorf("incorrect repeat format: %s", repeat) + } + weekDays, err := ParseWeekDays(s[1]) + if err != nil { + return "", err + } + newDate = NextNearestWeekDay(now, currentDate, weekDays) + case "m": + if len(s) < 2 || len(s) > 3 { + return "", fmt.Errorf("incorrect repeat format: %s", repeat) + } + days, err := ParseDaysInMonth(s[1]) + if err != nil { + return "", err + } + + if len(s) == 2 { + newDate = NextNearestDayInAllMonths(now, currentDate, days) + } else { + months, err := ParsevalidMonths(s[2]) + if err != nil { + return "", err + } + newDate = NextNearestDayInMonth(now, currentDate, days, months) + } + default: + return "", fmt.Errorf("invalid repeat format: %s", repeat) + } + + return newDate.Format("20060102"), nil +} + +func ParseDays(daysStr string) (int, error) { + days, err := strconv.Atoi(daysStr) + if err != nil { + return 0, fmt.Errorf("invalid day format: %v", err) + } + if days < 1 || days > 400 { + return 0, fmt.Errorf("day must be between 1 and 400") + } + return days, nil +} + +func ParseWeekDays(weekDaysStr string) ([]int, error) { + weekDays := strings.Split(weekDaysStr, ",") + wDays := make([]int, len(weekDays)) + for idx, weekDay := range weekDays { + wd, err := strconv.Atoi(weekDay) + if err != nil { + return nil, fmt.Errorf("invalid day format: %w", err) + } + if wd < 1 || wd > 7 { + return nil, fmt.Errorf("day must be between 1 and 7") + } + wDays[idx] = wd + } + return wDays, nil +} + +func ParsevalidMonths(monthsStr string) ([]int, error) { + months := strings.Split(monthsStr, ",") + result := make([]int, len(months)) + for idx, month := range months { + m, err := strconv.Atoi(month) + if err != nil { + return nil, fmt.Errorf("invalid day format: %w", err) + } + if m < 1 || m > 12 { + return nil, fmt.Errorf("month must be between 1 and 12") + } + result[idx] = m + } + sort.Ints(result) + return result, nil +} + +func ParseDaysInMonth(daysStr string) ([]int, error) { + monthDays := strings.Split(daysStr, ",") + mDays := make([]int, len(monthDays)) + for idx, monthDay := range monthDays { + md, err := strconv.Atoi(monthDay) + if err != nil { + return nil, fmt.Errorf("invalid day format: %w", err) + } + if md < -2 || md > 31 || md == 0 { + return nil, fmt.Errorf("day must be between 1 and 31 or -1, -2") + } + mDays[idx] = md + } + mDays = customSort(mDays) + return mDays, nil +} + +func NextNearestDay(now, date time.Time, days int) time.Time { + newDate := date.AddDate(0, 0, days) + if !newDate.After(now) { + dif := now.Sub(newDate).Hours() / 24 + + interval := int(math.Ceil(float64(dif) / float64(days))) + newDate = newDate.AddDate(0, 0, interval*days) + } + return newDate +} + +func NextNearestYear(now, date time.Time) time.Time { + newDate := date.AddDate(1, 0, 0) + if !newDate.After(now) { + year := now.Year() - newDate.Year() + newDate = newDate.AddDate(year, 0, 0) + } + return newDate +} + +func NextNearestWeekDay(now time.Time, date time.Time, weekDays []int) time.Time { + if len(weekDays) == 0 { + return date + } + distances := make([]int, len(weekDays)) + + dif := now.Sub(date).Hours() / 24 + curdate := date + if dif > 7 { + curdate = now + } + currentWeekDay := int(curdate.Weekday()) + + for idx, day := range weekDays { + daysUntilTarget := (day - currentWeekDay + 7) % 7 + if daysUntilTarget == 0 { + daysUntilTarget = 7 + } + distances[idx] = daysUntilTarget + } + sort.Ints(distances) + + var newDate time.Time + for _, dist := range distances { + newDate = curdate.AddDate(0, 0, dist) + if newDate.After(now) { + break + } + } + + return newDate +} + +func NextNearestDayInMonth(now time.Time, date time.Time, days, months []int) time.Time { + if date.Before(now) { + date = now + } + + currentMonth := date.Month() + currentDay := date.Day() + currentYear := date.Year() + + for _, val := range months { + month := time.Month(val) + if month == currentMonth { + if newDate, ok := checkDay(currentYear, month, currentDay, days); ok { + return newDate + } + } else if month >= currentMonth { + if newDate, ok := checkDay(currentYear, month, 0, days); ok { + return newDate + } + } + } + + return time.Time{} +} + +func NextNearestDayInAllMonths(now time.Time, date time.Time, days []int) time.Time { + if date.Before(now) { + date = now + } + + currentMonth := date.Month() + currentDay := date.Day() + currentYear := date.Year() + + if newDate, ok := checkDay(currentYear, currentMonth, currentDay, days); ok { + return newDate + } + + currentMonth++ + if currentMonth > 12 { + currentMonth = time.January + currentYear++ + } + + if newDate, ok := checkDay(currentYear, currentMonth, 0, days); ok { + return newDate + } + + return time.Time{} +} + +func customSort(arr []int) []int { + var result []int + var last, nextToLast bool + + for _, num := range arr { + if num == -1 { + last = true + } else if num == -2 { + nextToLast = true + } else { + result = append(result, num) + } + } + + sort.Ints(result) + + if nextToLast { + result = append(result, -2) + } + + if last { + result = append(result, -1) + } + + return result +} + +func daysInMonth(year int, month time.Month) int { + nextMonth := time.Date(year, month+1, 1, 0, 0, 0, 0, time.UTC) + return nextMonth.AddDate(0, 0, -1).Day() +} + +func checkDay(year int, month time.Month, curDay int, days []int) (time.Time, bool) { + maxDay := daysInMonth(year, month) + for _, day := range days { + checkDay := day + if day == -1 { + checkDay = maxDay + } else if day == -2 { + checkDay = maxDay - 1 + } else if day > maxDay { + continue + } + if checkDay > curDay { + return time.Date(year, month, checkDay, 0, 0, 0, 0, time.UTC), true + } + } + return time.Time{}, false +} diff --git a/internal/scheduler/task.go b/internal/scheduler/task.go new file mode 100644 index 0000000..f3b89e3 --- /dev/null +++ b/internal/scheduler/task.go @@ -0,0 +1,154 @@ +package scheduler + +import ( + "fmt" + "strconv" + "time" + + "github.com/denisushakov/todo-rest/internal/storage/sqlite" + "github.com/denisushakov/todo-rest/pkg/models" +) + +type Planner struct { + Storage *sqlite.Storage +} + +func NewScheduler(dataBase *sqlite.Storage) *Planner { + return &Planner{ + Storage: dataBase, + } +} + +type TaskScheduler interface { + SaveTask(*models.Task) (int64, error) + GetTasks(string) ([]*models.Task, error) + GetTaskByID(string) (*models.Task, error) + UpdateTask(*models.Task) error + MarkTaskCompleted(string) error + DeleteTask(string) error +} + +func (s *Planner) SaveTask(task *models.Task) (int64, error) { + if err := check(task); err != nil { + return 0, err + } + + id, err := s.Storage.SaveTask(task) + if err != nil { + return 0, err + } + return id, nil +} + +func (s *Planner) GetTasks(search string) ([]*models.Task, error) { + + var sr_st sqlite.Search + if search != "" { + sr_st.Active = true + date, err := time.Parse("02.01.2006", search) + if err != nil { + sr_st.Search = search + } else { + sr_st.Date = date.Format("20060102") + } + } + + tasks, err := s.Storage.GetTasks(&sr_st) + if err != nil { + return nil, err + } + + return tasks, nil +} + +func (s *Planner) GetTaskByID(id string) (*models.Task, error) { + task, err := s.Storage.GetTaskByID(id) + if err != nil { + return nil, err + } + return task, nil +} + +func (s *Planner) UpdateTask(task *models.Task) error { + if task.ID == "" { + return fmt.Errorf("id is empty") + } + + if _, err := strconv.Atoi(task.ID); err != nil { + return fmt.Errorf("id is not a number: %w", err) + } + + if err := check(task); err != nil { + return err + } + + if err := s.Storage.UpdateTask(task); err != nil { + return err + } + return nil +} + +func check(task *models.Task) error { + if task.Title == "" { + return fmt.Errorf("empty title field") + } + + var now = time.Now().Truncate(24 * time.Hour) + var nextdate string + + if task.Date == "" { + nextdate = now.Format("20060102") + } else { + date, err := time.Parse("20060102", task.Date) + if err != nil { + return fmt.Errorf("%w", err) + } + nextdate = date.Format("20060102") + if date.Before(now) { + if task.Repeat == "" { + nextdate = now.Format("20060102") + } else { + nextdate, err = NextDate(now, task.Date, task.Repeat) + if err != nil { + return fmt.Errorf("%w", err) + } + } + } + } + task.Date = nextdate + + return nil +} + +func (s *Planner) MarkTaskCompleted(id string) error { + var now = time.Now().Truncate(24 * time.Hour) + task, err := s.GetTaskByID(id) + if err != nil { + return err + } + if task.Repeat == "" { + if err := s.Storage.DeleteTask(id); err != nil { + return err + } + } else { + nextdate, err := NextDate(now, task.Date, task.Repeat) + if err != nil { + return err + } + task.Date = nextdate + + err = s.Storage.UpdateTask(task) + if err != nil { + return err + } + } + + return nil +} + +func (s *Planner) DeleteTask(id string) error { + if err := s.Storage.DeleteTask(id); err != nil { + return err + } + return nil +} diff --git a/internal/storage/sqlite/sqlite.go b/internal/storage/sqlite/sqlite.go new file mode 100644 index 0000000..3309896 --- /dev/null +++ b/internal/storage/sqlite/sqlite.go @@ -0,0 +1,183 @@ +package sqlite + +import ( + "database/sql" + "errors" + "fmt" + + "github.com/denisushakov/todo-rest/internal/config" + "github.com/denisushakov/todo-rest/pkg/models" + + _ "github.com/mattn/go-sqlite3" +) + +var ( + ErrNotFound = errors.New("record not found") +) + +type Storage struct { + db *sql.DB +} + +type Search struct { + Search string + Date string + Active bool +} + +func New(storagePath string) (*Storage, error) { + + db, err := sql.Open("sqlite3", storagePath) + if err != nil { + return nil, fmt.Errorf("error opening database: %w", err) + } + + stmt, err := db.Prepare(` + CREATE TABLE IF NOT EXISTS scheduler( + id INTEGER PRIMARY KEY AUTOINCREMENT, + date TEXT NOT NULL, + title TEXT NOT NULL, + comment TEXT, + repeat TEXT NOT NULL + CHECK (length(repeat) <= 128)); + CREATE INDEX IF NOT EXISTS idx_scheduler_date ON scheduler (date) + `) + if err != nil { + return nil, fmt.Errorf("error creating table: %w", err) + } + + if _, err := stmt.Exec(); err != nil { + return nil, fmt.Errorf("database not opened: %w", err) + } + + return &Storage{db: db}, nil +} + +func (s *Storage) SaveTask(task *models.Task) (int64, error) { + + stmt, err := s.db.Prepare("INSERT INTO scheduler (date, title, comment, repeat) VALUES (?, ?, ?, ?)") + if err != nil { + return 0, fmt.Errorf("%w", err) + } + defer stmt.Close() + + res, err := stmt.Exec(task.Date, task.Title, task.Comment, task.Repeat) + if err != nil { + return 0, fmt.Errorf("%w", err) + } + + id, err := res.LastInsertId() + if err != nil { + return 0, fmt.Errorf("%w", err) + } + + return id, nil +} + +func (s *Storage) GetTasks(search_st *Search) ([]*models.Task, error) { + var query string + args := []any{} + + query = "SELECT * FROM scheduler ORDER BY date LIMIT :limit" + if search_st.Active && search_st.Search != "" { + query = "SELECT * FROM scheduler WHERE title LIKE :search OR comment LIKE :search ORDER BY date LIMIT :limit" + args = append(args, sql.Named("search", fmt.Sprintf("%%%s%%", search_st.Search))) + } else if search_st.Active && search_st.Date != "" { + query = "SELECT * FROM scheduler WHERE date = :date LIMIT :limit" + args = append(args, sql.Named("date", search_st.Date)) + } + + args = append(args, sql.Named("limit", config.MaxTaskLimit)) + + stmt, err := s.db.Prepare(query) + if err != nil { + return nil, fmt.Errorf("%w", err) + } + + rows, err := stmt.Query(args...) + if err != nil { + return nil, fmt.Errorf("failed to get tasks: %w", err) + } + defer rows.Close() + + tasks := make([]*models.Task, 0, 10) + + for rows.Next() { + var task models.Task + rows.Scan(&task.ID, &task.Date, &task.Title, &task.Comment, &task.Repeat) + tasks = append(tasks, &task) + } + + return tasks, nil +} + +func (s *Storage) GetTaskByID(id string) (*models.Task, error) { + var task models.Task + + query := "SELECT * FROM scheduler WHERE id = ?" + + stmt, err := s.db.Prepare(query) + if err != nil { + return nil, fmt.Errorf("%w", err) + } + + err = stmt.QueryRow(id).Scan(&task.ID, &task.Date, &task.Title, &task.Comment, &task.Repeat) + if err != nil { + return nil, err + } + + return &task, nil +} + +func (s *Storage) UpdateTask(task *models.Task) error { + query := `UPDATE scheduler SET + date = :date, + title = :title, + comment = :comment, + repeat = :repeat + WHERE id = :id` + + stmt, err := s.db.Prepare(query) + if err != nil { + return fmt.Errorf("%w", err) + } + + res, err := stmt.Exec( + sql.Named("id", &task.ID), + sql.Named("date", &task.Date), + sql.Named("title", &task.Title), + sql.Named("comment", &task.Comment), + sql.Named("repeat", &task.Repeat), + ) + + if err != nil { + return fmt.Errorf("%w", err) + } + + num, err := res.RowsAffected() + if err != nil { + return fmt.Errorf("%w", err) + } + if num == 0 { + return ErrNotFound + } + + return nil +} + +func (s *Storage) DeleteTask(id string) error { + res, err := s.db.Exec("DELETE FROM scheduler WHERE id = :id", sql.Named("id", id)) + if err != nil { + return fmt.Errorf("%w", err) + } + + num, err := res.RowsAffected() + if err != nil { + return fmt.Errorf("%w", err) + } + if num == 0 { + return ErrNotFound + } + + return nil +} diff --git a/internal/storage/storage.go b/internal/storage/storage.go new file mode 100644 index 0000000..82be054 --- /dev/null +++ b/internal/storage/storage.go @@ -0,0 +1 @@ +package storage diff --git a/pkg/models/auth.go b/pkg/models/auth.go new file mode 100644 index 0000000..4cd78e1 --- /dev/null +++ b/pkg/models/auth.go @@ -0,0 +1,5 @@ +package models + +type Auth struct { + Password string `json:"password"` +} diff --git a/pkg/models/task.go b/pkg/models/task.go new file mode 100644 index 0000000..e4e26f0 --- /dev/null +++ b/pkg/models/task.go @@ -0,0 +1,9 @@ +package models + +type Task struct { + ID string `json:"id"` + Date string `json:"date"` + Title string `json:"title"` + Comment string `json:"comment,omitempty"` + Repeat string `json:"repeat,omitempty"` +} diff --git a/pkg/router/router.go b/pkg/router/router.go new file mode 100644 index 0000000..7b1ef86 --- /dev/null +++ b/pkg/router/router.go @@ -0,0 +1,35 @@ +package router + +import ( + "log" + "net/http" + + "github.com/denisushakov/todo-rest/internal/scheduler" + "github.com/denisushakov/todo-rest/internal/storage/sqlite" + + "github.com/denisushakov/todo-rest/internal/config" + "github.com/denisushakov/todo-rest/internal/http-server/handlers" + "github.com/go-chi/chi/v5" + "github.com/go-chi/chi/v5/middleware" +) + +func SetupRouter() *chi.Mux { + + webDir := config.WebDirPath + + storage, err := sqlite.New(config.DBFilePath) + if err != nil { + log.Fatalf("Failed to connect to the database: %v", err) + } + planner := scheduler.NewScheduler(storage) + + router := chi.NewRouter() + + router.Use(middleware.URLFormat) + + router.Handle("/*", http.FileServer(http.Dir(webDir))) + + handlers.RegisterRoutes(router, planner) + + return router +} diff --git a/storage/scheduler.db b/storage/scheduler.db new file mode 100644 index 0000000..05201ba Binary files /dev/null and b/storage/scheduler.db differ diff --git a/tests/addtask_4_test.go b/tests/addtask_4_test.go index f28b4c7..63a336f 100644 --- a/tests/addtask_4_test.go +++ b/tests/addtask_4_test.go @@ -14,7 +14,7 @@ import ( "github.com/stretchr/testify/assert" ) -func requestJSON(apipath string, values map[string]any, method string) ([]byte, error) { +func requestJSON(url, apipath string, values map[string]any, method string) ([]byte, error) { var ( data []byte err error @@ -28,7 +28,7 @@ func requestJSON(apipath string, values map[string]any, method string) ([]byte, } var resp *http.Response - req, err := http.NewRequest(method, getURL(apipath), bytes.NewBuffer(data)) + req, err := http.NewRequest(method, getURL(apipath, url), bytes.NewBuffer(data)) if err != nil { return nil, err } @@ -60,13 +60,13 @@ func requestJSON(apipath string, values map[string]any, method string) ([]byte, return io.ReadAll(resp.Body) } -func postJSON(apipath string, values map[string]any, method string) (map[string]any, error) { +func postJSON(url, apipath string, values map[string]any, method string) (map[string]any, error) { var ( m map[string]any err error ) - body, err := requestJSON(apipath, values, method) + body, err := requestJSON(url, apipath, values, method) if err != nil { return nil, err } @@ -82,6 +82,10 @@ type task struct { } func TestAddTask(t *testing.T) { + // Создаем мок-сервер с реальными обработчиками + server := createTestServer() + defer server.Close() + db := openDB(t) defer db.Close() @@ -93,7 +97,7 @@ func TestAddTask(t *testing.T) { {"20240212", "Заголовок", "", "ooops"}, } for _, v := range tbl { - m, err := postJSON("api/task", map[string]any{ + m, err := postJSON(server.URL, "api/task", map[string]any{ "date": v.date, "title": v.title, "comment": v.comment, @@ -114,7 +118,7 @@ func TestAddTask(t *testing.T) { if today { v.date = now.Format(`20060102`) } - m, err := postJSON("api/task", map[string]any{ + m, err := postJSON(server.URL, "api/task", map[string]any{ "date": v.date, "title": v.title, "comment": v.comment, diff --git a/tests/app_1_test.go b/tests/app_1_test.go index f7d69cc..70c0bbf 100644 --- a/tests/app_1_test.go +++ b/tests/app_1_test.go @@ -6,27 +6,19 @@ import ( "net/http" "os" "path/filepath" - "strconv" "strings" "testing" "github.com/stretchr/testify/assert" ) -func getURL(path string) string { - port := Port - envPort := os.Getenv("TODO_PORT") - if len(envPort) > 0 { - if eport, err := strconv.ParseInt(envPort, 10, 32); err == nil { - port = int(eport) - } - } +func getURL(path string, url string) string { path = strings.TrimPrefix(strings.ReplaceAll(path, `\`, `/`), `../web/`) - return fmt.Sprintf("http://localhost:%d/%s", port, path) + return fmt.Sprintf("%s/%s", url, path) } -func getBody(path string) ([]byte, error) { - resp, err := http.Get(getURL(path)) +func getBody(path string, url string) ([]byte, error) { + resp, err := http.Get(getURL(path, url)) if err != nil { return nil, err } @@ -56,12 +48,16 @@ func walkDir(path string, f func(fname string) error) error { } func TestApp(t *testing.T) { + // Создаем мок-сервер с реальными обработчиками + server := createTestServer() + defer server.Close() + cmp := func(fname string) error { fbody, err := os.ReadFile(fname) if err != nil { return err } - body, err := getBody(fname) + body, err := getBody(fname, server.URL) if err != nil { return err } diff --git a/tests/db_2_test.go b/tests/db_2_test.go index b028ee6..3ff2632 100644 --- a/tests/db_2_test.go +++ b/tests/db_2_test.go @@ -5,6 +5,7 @@ import ( "testing" "time" + "github.com/denisushakov/todo-rest/internal/config" "github.com/jmoiron/sqlx" _ "github.com/mattn/go-sqlite3" "github.com/stretchr/testify/assert" @@ -29,6 +30,11 @@ func openDB(t *testing.T) *sqlx.DB { if len(envFile) > 0 { dbfile = envFile } + // ++ + if config.DBFilePath != "" { + dbfile = config.DBFilePath + } + // -- db, err := sqlx.Connect("sqlite3", dbfile) assert.NoError(t, err) return db @@ -48,6 +54,7 @@ func TestDB(t *testing.T) { assert.NoError(t, err) id, err := res.LastInsertId() + assert.NoError(t, err) var task Task err = db.Get(&task, `SELECT * FROM scheduler WHERE id=?`, id) diff --git a/tests/nextdate_3_test.go b/tests/nextdate_3_test.go index b844cc0..27a7b21 100644 --- a/tests/nextdate_3_test.go +++ b/tests/nextdate_3_test.go @@ -17,6 +17,10 @@ type nextDate struct { } func TestNextDate(t *testing.T) { + // Создаем мок-сервер с реальными обработчиками + server := createTestServer() + defer server.Close() + tbl := []nextDate{ {"20240126", "", ""}, {"20240126", "k 34", ""}, @@ -41,7 +45,7 @@ func TestNextDate(t *testing.T) { for _, v := range tbl { urlPath := fmt.Sprintf("api/nextdate?now=20240126&date=%s&repeat=%s", url.QueryEscape(v.date), url.QueryEscape(v.repeat)) - get, err := getBody(urlPath) + get, err := getBody(urlPath, server.URL) assert.NoError(t, err) next := strings.TrimSpace(string(get)) _, err = time.Parse("20060102", next) diff --git a/tests/server_test.go b/tests/server_test.go new file mode 100644 index 0000000..74a82bf --- /dev/null +++ b/tests/server_test.go @@ -0,0 +1,18 @@ +package tests + +import ( + "net/http/httptest" + + "github.com/denisushakov/todo-rest/internal/config" + "github.com/denisushakov/todo-rest/pkg/router" + + _ "github.com/mattn/go-sqlite3" +) + +func createTestServer() *httptest.Server { + config.MustLoad() + + router := router.SetupRouter() + + return httptest.NewServer(router) +} diff --git a/tests/settings.go b/tests/settings.go index 3908fdf..46a2ec0 100644 --- a/tests/settings.go +++ b/tests/settings.go @@ -1,7 +1,7 @@ package tests var Port = 7540 -var DBFile = "../scheduler.db" -var FullNextDate = false -var Search = false -var Token = `` +var DBFile = "./storage/scheduler.db" +var FullNextDate = true +var Search = true +var Token = `eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJwYXNzd29yZF9oYXNoIjoiNWU4ODQ4OThkYTI4MDQ3MTUxZDBlNTZmOGRjNjI5Mjc3MzYwM2QwZDZhYWJiZGQ2MmExMWVmNzIxZDE1NDJkOCJ9.TAdhLIYQhadNfB3MOpkGTD07A8tdXX7ue_ghYOif71w` diff --git a/tests/task_6_test.go b/tests/task_6_test.go index 9e7649d..16149a6 100644 --- a/tests/task_6_test.go +++ b/tests/task_6_test.go @@ -12,6 +12,10 @@ import ( ) func TestTask(t *testing.T) { + // Создаем мок-сервер с реальными обработчиками + server := createTestServer() + defer server.Close() + db := openDB(t) defer db.Close() @@ -24,9 +28,9 @@ func TestTask(t *testing.T) { repeat: "d 5", } - todo := addTask(t, task) + todo := addTask(t, server.URL, task) - body, err := requestJSON("api/task", nil, http.MethodGet) + body, err := requestJSON(server.URL, "api/task", nil, http.MethodGet) assert.NoError(t, err) var m map[string]string err = json.Unmarshal(body, &m) @@ -36,7 +40,7 @@ func TestTask(t *testing.T) { assert.False(t, !ok || len(fmt.Sprint(e)) == 0, "Ожидается ошибка для вызова /api/task") - body, err = requestJSON("api/task?id="+todo, nil, http.MethodGet) + body, err = requestJSON(server.URL, "api/task?id="+todo, nil, http.MethodGet) assert.NoError(t, err) err = json.Unmarshal(body, &m) assert.NoError(t, err) @@ -54,6 +58,10 @@ type fulltask struct { } func TestEditTask(t *testing.T) { + // Создаем мок-сервер с реальными обработчиками + server := createTestServer() + defer server.Close() + db := openDB(t) defer db.Close() @@ -66,7 +74,7 @@ func TestEditTask(t *testing.T) { repeat: "", } - id := addTask(t, tsk) + id := addTask(t, server.URL, tsk) tbl := []fulltask{ {"", task{"20240129", "Тест", "", ""}}, @@ -78,7 +86,7 @@ func TestEditTask(t *testing.T) { {id, task{"20240212", "Заголовок", "", "ooops"}}, } for _, v := range tbl { - m, err := postJSON("api/task", map[string]any{ + m, err := postJSON(server.URL, "api/task", map[string]any{ "id": v.id, "date": v.date, "title": v.title, @@ -96,7 +104,7 @@ func TestEditTask(t *testing.T) { } updateTask := func(newVals map[string]any) { - mupd, err := postJSON("api/task", newVals, http.MethodPut) + mupd, err := postJSON(server.URL, "api/task", newVals, http.MethodPut) assert.NoError(t, err) e, ok := mupd["error"] diff --git a/tests/task_7_test.go b/tests/task_7_test.go index b511db5..b8ca762 100644 --- a/tests/task_7_test.go +++ b/tests/task_7_test.go @@ -9,8 +9,8 @@ import ( "github.com/stretchr/testify/assert" ) -func notFoundTask(t *testing.T, id string) { - body, err := requestJSON("api/task?id="+id, nil, http.MethodGet) +func notFoundTask(t *testing.T, url, id string) { + body, err := requestJSON(url, "api/task?id="+id, nil, http.MethodGet) assert.NoError(t, err) var m map[string]any err = json.Unmarshal(body, &m) @@ -20,27 +20,31 @@ func notFoundTask(t *testing.T, id string) { } func TestDone(t *testing.T) { + // Создаем мок-сервер с реальными обработчиками + server := createTestServer() + defer server.Close() + db := openDB(t) defer db.Close() now := time.Now() - id := addTask(t, task{ + id := addTask(t, server.URL, task{ date: now.Format(`20060102`), title: "Свести баланс", }) - ret, err := postJSON("api/task/done?id="+id, nil, http.MethodPost) + ret, err := postJSON(server.URL, "api/task/done?id="+id, nil, http.MethodPost) assert.NoError(t, err) assert.Empty(t, ret) - notFoundTask(t, id) + notFoundTask(t, server.URL, id) - id = addTask(t, task{ + id = addTask(t, server.URL, task{ title: "Проверить работу /api/task/done", repeat: "d 3", }) for i := 0; i < 3; i++ { - ret, err := postJSON("api/task/done?id="+id, nil, http.MethodPost) + ret, err := postJSON(server.URL, "api/task/done?id="+id, nil, http.MethodPost) assert.NoError(t, err) assert.Empty(t, ret) @@ -53,23 +57,27 @@ func TestDone(t *testing.T) { } func TestDelTask(t *testing.T) { + // Создаем мок-сервер с реальными обработчиками + server := createTestServer() + defer server.Close() + db := openDB(t) defer db.Close() - id := addTask(t, task{ + id := addTask(t, server.URL, task{ title: "Временная задача", repeat: "d 3", }) - ret, err := postJSON("api/task?id="+id, nil, http.MethodDelete) + ret, err := postJSON(server.URL, "api/task?id="+id, nil, http.MethodDelete) assert.NoError(t, err) assert.Empty(t, ret) - notFoundTask(t, id) + notFoundTask(t, server.URL, id) - ret, err = postJSON("api/task", nil, http.MethodDelete) + ret, err = postJSON(server.URL, "api/task", nil, http.MethodDelete) assert.NoError(t, err) assert.NotEmpty(t, ret) - ret, err = postJSON("api/task?id=wjhgese", nil, http.MethodDelete) + ret, err = postJSON(server.URL, "api/task?id=wjhgese", nil, http.MethodDelete) assert.NoError(t, err) assert.NotEmpty(t, ret) } diff --git a/tests/tasks_5_test.go b/tests/tasks_5_test.go index a12b5d1..463ba63 100644 --- a/tests/tasks_5_test.go +++ b/tests/tasks_5_test.go @@ -10,8 +10,8 @@ import ( "github.com/stretchr/testify/assert" ) -func addTask(t *testing.T, task task) string { - ret, err := postJSON("api/task", map[string]any{ +func addTask(t *testing.T, urlPath string, task task) string { + ret, err := postJSON(urlPath, "api/task", map[string]any{ "date": task.date, "title": task.title, "comment": task.comment, @@ -24,12 +24,12 @@ func addTask(t *testing.T, task task) string { return id } -func getTasks(t *testing.T, search string) []map[string]string { +func getTasks(t *testing.T, urlPath, search string) []map[string]string { url := "api/tasks" if Search { url += "?search=" + search } - body, err := requestJSON(url, nil, http.MethodGet) + body, err := requestJSON(urlPath, url, nil, http.MethodGet) assert.NoError(t, err) var m map[string][]map[string]string @@ -39,6 +39,10 @@ func getTasks(t *testing.T, search string) []map[string]string { } func TestTasks(t *testing.T) { + // Создаем мок-сервер с реальными обработчиками + server := createTestServer() + defer server.Close() + db := openDB(t) defer db.Close() @@ -46,11 +50,11 @@ func TestTasks(t *testing.T) { _, err := db.Exec("DELETE FROM scheduler") assert.NoError(t, err) - tasks := getTasks(t, "") + tasks := getTasks(t, server.URL, "") assert.NotNil(t, tasks) assert.Empty(t, tasks) - addTask(t, task{ + addTask(t, server.URL, task{ date: now.Format(`20060102`), title: "Просмотр фильма", comment: "с попкорном", @@ -58,51 +62,51 @@ func TestTasks(t *testing.T) { }) now = now.AddDate(0, 0, 1) date := now.Format(`20060102`) - addTask(t, task{ + addTask(t, server.URL, task{ date: date, title: "Сходить в бассейн", comment: "", repeat: "", }) - addTask(t, task{ + addTask(t, server.URL, task{ date: date, title: "Оплатить коммуналку", comment: "", repeat: "d 30", }) - tasks = getTasks(t, "") + tasks = getTasks(t, server.URL, "") assert.Equal(t, len(tasks), 3) now = now.AddDate(0, 0, 2) date = now.Format(`20060102`) - addTask(t, task{ + addTask(t, server.URL, task{ date: date, title: "Поплавать", comment: "Бассейн с тренером", repeat: "d 7", }) - addTask(t, task{ + addTask(t, server.URL, task{ date: date, title: "Позвонить в УК", comment: "Разобраться с горячей водой", repeat: "", }) - addTask(t, task{ + addTask(t, server.URL, task{ date: date, title: "Встретится с Васей", comment: "в 18:00", repeat: "", }) - tasks = getTasks(t, "") + tasks = getTasks(t, server.URL, "") assert.Equal(t, len(tasks), 6) if !Search { return } - tasks = getTasks(t, "УК") + tasks = getTasks(t, server.URL, "УК") assert.Equal(t, len(tasks), 1) - tasks = getTasks(t, now.Format(`02.01.2006`)) + tasks = getTasks(t, server.URL, now.Format(`02.01.2006`)) assert.Equal(t, len(tasks), 3) }