Add Go Todo App implementation
This commit adds a complete Go implementation of the Todo application. The application uses Gin framework for the web server, GORM for database access, and SQLite for storage. Key features: - Todo CRUD operations with the same API endpoints - Health check endpoint - Database migrations - Tests for models, services, and API handlers - Documentation for the API - Configurable settings
This commit is contained in:
parent
163b82dab7
commit
887703b6a2
32
go-todo-app/.gitignore
vendored
Normal file
32
go-todo-app/.gitignore
vendored
Normal file
@ -0,0 +1,32 @@
|
|||||||
|
# Binaries for programs and plugins
|
||||||
|
*.exe
|
||||||
|
*.exe~
|
||||||
|
*.dll
|
||||||
|
*.so
|
||||||
|
*.dylib
|
||||||
|
|
||||||
|
# Test binary, built with `go test -c`
|
||||||
|
*.test
|
||||||
|
|
||||||
|
# Output of the go coverage tool, specifically when used with LiteIDE
|
||||||
|
*.out
|
||||||
|
|
||||||
|
# Dependency directories (remove the comment below to include it)
|
||||||
|
# vendor/
|
||||||
|
|
||||||
|
# Go workspace file
|
||||||
|
go.work
|
||||||
|
|
||||||
|
# IDE specific files
|
||||||
|
.idea/
|
||||||
|
.vscode/
|
||||||
|
*.swp
|
||||||
|
*.swo
|
||||||
|
|
||||||
|
# SQLite database
|
||||||
|
*.db
|
||||||
|
*.sqlite
|
||||||
|
*.sqlite3
|
||||||
|
|
||||||
|
# Binary
|
||||||
|
/go-todo-app
|
36
go-todo-app/Makefile
Normal file
36
go-todo-app/Makefile
Normal file
@ -0,0 +1,36 @@
|
|||||||
|
.PHONY: build run clean test
|
||||||
|
|
||||||
|
# Build variables
|
||||||
|
BINARY_NAME=go-todo-app
|
||||||
|
MAIN_PATH=./cmd/api
|
||||||
|
|
||||||
|
# Default target
|
||||||
|
all: build
|
||||||
|
|
||||||
|
# Build the application
|
||||||
|
build:
|
||||||
|
go build -o $(BINARY_NAME) $(MAIN_PATH)
|
||||||
|
|
||||||
|
# Run the application in development mode
|
||||||
|
run:
|
||||||
|
go run $(MAIN_PATH)/main.go
|
||||||
|
|
||||||
|
# Clean build artifacts
|
||||||
|
clean:
|
||||||
|
go clean
|
||||||
|
rm -f $(BINARY_NAME)
|
||||||
|
|
||||||
|
# Run tests
|
||||||
|
test:
|
||||||
|
go test -v ./...
|
||||||
|
|
||||||
|
# Format code
|
||||||
|
fmt:
|
||||||
|
go fmt ./...
|
||||||
|
|
||||||
|
# Check for issues
|
||||||
|
vet:
|
||||||
|
go vet ./...
|
||||||
|
|
||||||
|
# All-in-one check
|
||||||
|
check: fmt vet test
|
162
go-todo-app/README.md
Normal file
162
go-todo-app/README.md
Normal file
@ -0,0 +1,162 @@
|
|||||||
|
# Go Todo App
|
||||||
|
|
||||||
|
A simple Todo application API built with Go, Gin, and SQLite. This is a Go rewrite of the original FastAPI Python application.
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
- RESTful API for managing todo items
|
||||||
|
- SQLite database for persistent storage
|
||||||
|
- CORS support for cross-origin requests
|
||||||
|
- Health check endpoint
|
||||||
|
- Pagination and filtering support
|
||||||
|
|
||||||
|
## Technology Stack
|
||||||
|
|
||||||
|
- [Go](https://golang.org/) - Programming language
|
||||||
|
- [Gin](https://github.com/gin-gonic/gin) - Web framework
|
||||||
|
- [GORM](https://gorm.io/) - ORM library
|
||||||
|
- [SQLite](https://www.sqlite.org/) - Database
|
||||||
|
- [Viper](https://github.com/spf13/viper) - Configuration management
|
||||||
|
|
||||||
|
## API Endpoints
|
||||||
|
|
||||||
|
| Method | URL | Description |
|
||||||
|
|--------|--------------------|-----------------------------|
|
||||||
|
| GET | /health | Health check |
|
||||||
|
| GET | /api/todos | List all todos |
|
||||||
|
| POST | /api/todos | Create a new todo |
|
||||||
|
| GET | /api/todos/:id | Get a specific todo |
|
||||||
|
| PUT | /api/todos/:id | Update a todo |
|
||||||
|
| DELETE | /api/todos/:id | Delete a todo |
|
||||||
|
|
||||||
|
## Query Parameters
|
||||||
|
|
||||||
|
The `GET /api/todos` endpoint supports the following query parameters:
|
||||||
|
|
||||||
|
- `skip` - Number of items to skip (default: 0)
|
||||||
|
- `limit` - Maximum number of items to return (default: 100)
|
||||||
|
- `completed` - Filter by completion status (boolean, optional)
|
||||||
|
|
||||||
|
## Prerequisites
|
||||||
|
|
||||||
|
- Go 1.18 or higher
|
||||||
|
- Git
|
||||||
|
|
||||||
|
## Getting Started
|
||||||
|
|
||||||
|
### Clone the repository
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git clone https://github.com/yourusername/go-todo-app.git
|
||||||
|
cd go-todo-app
|
||||||
|
```
|
||||||
|
|
||||||
|
### Build and run the application
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Build the application
|
||||||
|
go build -o go-todo-app ./cmd/api
|
||||||
|
|
||||||
|
# Run the application
|
||||||
|
./go-todo-app
|
||||||
|
```
|
||||||
|
|
||||||
|
The API will be available at http://localhost:8000
|
||||||
|
|
||||||
|
### Development mode
|
||||||
|
|
||||||
|
```bash
|
||||||
|
go run cmd/api/main.go
|
||||||
|
```
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
The application can be configured using environment variables or a config file. The following settings are available:
|
||||||
|
|
||||||
|
| Environment Variable | Description | Default Value |
|
||||||
|
|----------------------|-------------------------------|---------------------------------------------|
|
||||||
|
| APP_NAME | Application name | Go Todo App |
|
||||||
|
| APP_DESCRIPTION | Application description | A simple Todo application API... |
|
||||||
|
| APP_VERSION | Application version | 0.1.0 |
|
||||||
|
| SERVER_PORT | HTTP server port | 8000 |
|
||||||
|
| DB_PATH | Database directory path | /app/storage/db |
|
||||||
|
| DB_NAME | Database file name | db.sqlite |
|
||||||
|
|
||||||
|
## Project Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
.
|
||||||
|
├── cmd/
|
||||||
|
│ └── api/
|
||||||
|
│ └── main.go # Application entry point
|
||||||
|
├── internal/
|
||||||
|
│ ├── config/ # Application configuration
|
||||||
|
│ │ └── config.go
|
||||||
|
│ ├── api/ # API routes and handlers
|
||||||
|
│ │ ├── api.go # API initialization
|
||||||
|
│ │ ├── middleware.go # Middleware functions
|
||||||
|
│ │ ├── todo.go # Todo route handlers
|
||||||
|
│ │ └── health.go # Health check route handler
|
||||||
|
│ ├── database/ # Database connection and initialization
|
||||||
|
│ │ └── db.go
|
||||||
|
│ ├── model/ # Database models (GORM)
|
||||||
|
│ │ └── todo.go
|
||||||
|
│ ├── dto/ # Data transfer objects
|
||||||
|
│ │ └── todo.go
|
||||||
|
│ └── service/ # Business logic
|
||||||
|
│ └── todo.go
|
||||||
|
├── migrations/ # Database migration files
|
||||||
|
├── storage/ # Storage directory
|
||||||
|
│ └── db/ # Database files location
|
||||||
|
├── go.mod # Go module file
|
||||||
|
├── go.sum # Go dependencies checksums
|
||||||
|
└── README.md # Project documentation
|
||||||
|
```
|
||||||
|
|
||||||
|
## Example Usage
|
||||||
|
|
||||||
|
### Create a todo
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -X POST http://localhost:8000/api/todos \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{"title": "Learn Go", "description": "Learn Go programming language", "completed": false}'
|
||||||
|
```
|
||||||
|
|
||||||
|
### Get all todos
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl http://localhost:8000/api/todos
|
||||||
|
```
|
||||||
|
|
||||||
|
### Get a specific todo
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl http://localhost:8000/api/todos/1
|
||||||
|
```
|
||||||
|
|
||||||
|
### Update a todo
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -X PUT http://localhost:8000/api/todos/1 \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{"completed": true}'
|
||||||
|
```
|
||||||
|
|
||||||
|
### Delete a todo
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -X DELETE http://localhost:8000/api/todos/1
|
||||||
|
```
|
||||||
|
|
||||||
|
### Get completed todos
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl http://localhost:8000/api/todos?completed=true
|
||||||
|
```
|
||||||
|
|
||||||
|
### Health check
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl http://localhost:8000/health
|
||||||
|
```
|
42
go-todo-app/cmd/api/main.go
Normal file
42
go-todo-app/cmd/api/main.go
Normal file
@ -0,0 +1,42 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"path/filepath"
|
||||||
|
|
||||||
|
"github.com/simpletodoapp/go-todo-app/internal/api"
|
||||||
|
"github.com/simpletodoapp/go-todo-app/internal/config"
|
||||||
|
"github.com/simpletodoapp/go-todo-app/internal/database"
|
||||||
|
)
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
// Load configuration
|
||||||
|
cfg, err := config.LoadConfig()
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("Failed to load config: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Connect to database
|
||||||
|
db, err := database.NewDatabase(cfg)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("Failed to initialize database: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Run migrations
|
||||||
|
migrationRunner := database.NewMigrationRunner(db, cfg)
|
||||||
|
migrationsPath := filepath.Join(".", "migrations")
|
||||||
|
if err := migrationRunner.RunMigrations(migrationsPath); err != nil {
|
||||||
|
log.Fatalf("Failed to run migrations: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Setup router
|
||||||
|
router := api.SetupRouter(cfg, db)
|
||||||
|
|
||||||
|
// Start server
|
||||||
|
serverAddr := fmt.Sprintf(":%s", cfg.ServerPort)
|
||||||
|
log.Printf("Starting server on %s", serverAddr)
|
||||||
|
if err := router.Run(serverAddr); err != nil {
|
||||||
|
log.Fatalf("Failed to start server: %v", err)
|
||||||
|
}
|
||||||
|
}
|
52
go-todo-app/go.mod
Normal file
52
go-todo-app/go.mod
Normal file
@ -0,0 +1,52 @@
|
|||||||
|
module github.com/simpletodoapp/go-todo-app
|
||||||
|
|
||||||
|
go 1.20
|
||||||
|
|
||||||
|
require (
|
||||||
|
github.com/gin-contrib/cors v1.4.0
|
||||||
|
github.com/gin-gonic/gin v1.9.1
|
||||||
|
github.com/spf13/viper v1.16.0
|
||||||
|
gorm.io/driver/sqlite v1.5.3
|
||||||
|
gorm.io/gorm v1.25.4
|
||||||
|
)
|
||||||
|
|
||||||
|
require (
|
||||||
|
github.com/bytedance/sonic v1.10.0 // indirect
|
||||||
|
github.com/chenzhuoyu/base64x v0.0.0-20230717121745-296ad89f973d // indirect
|
||||||
|
github.com/chenzhuoyu/iasm v0.9.0 // indirect
|
||||||
|
github.com/fsnotify/fsnotify v1.6.0 // indirect
|
||||||
|
github.com/gabriel-vasile/mimetype v1.4.2 // indirect
|
||||||
|
github.com/gin-contrib/sse v0.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.15.3 // indirect
|
||||||
|
github.com/goccy/go-json v0.10.2 // indirect
|
||||||
|
github.com/hashicorp/hcl v1.0.0 // indirect
|
||||||
|
github.com/jinzhu/inflection v1.0.0 // indirect
|
||||||
|
github.com/jinzhu/now v1.1.5 // indirect
|
||||||
|
github.com/json-iterator/go v1.1.12 // indirect
|
||||||
|
github.com/klauspost/cpuid/v2 v2.2.5 // indirect
|
||||||
|
github.com/leodido/go-urn v1.2.4 // indirect
|
||||||
|
github.com/magiconair/properties v1.8.7 // indirect
|
||||||
|
github.com/mattn/go-isatty v0.0.19 // indirect
|
||||||
|
github.com/mattn/go-sqlite3 v1.14.17 // indirect
|
||||||
|
github.com/mitchellh/mapstructure v1.5.0 // 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.1.0 // indirect
|
||||||
|
github.com/spf13/afero v1.9.5 // indirect
|
||||||
|
github.com/spf13/cast v1.5.1 // indirect
|
||||||
|
github.com/spf13/jwalterweatherman v1.1.0 // indirect
|
||||||
|
github.com/spf13/pflag v1.0.5 // indirect
|
||||||
|
github.com/subosito/gotenv v1.6.0 // indirect
|
||||||
|
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
|
||||||
|
github.com/ugorji/go/codec v1.2.11 // indirect
|
||||||
|
golang.org/x/arch v0.5.0 // indirect
|
||||||
|
golang.org/x/crypto v0.13.0 // indirect
|
||||||
|
golang.org/x/net v0.15.0 // indirect
|
||||||
|
golang.org/x/sys v0.12.0 // indirect
|
||||||
|
golang.org/x/text v0.13.0 // indirect
|
||||||
|
google.golang.org/protobuf v1.31.0 // indirect
|
||||||
|
gopkg.in/ini.v1 v1.67.0 // indirect
|
||||||
|
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||||
|
)
|
32
go-todo-app/internal/api/api.go
Normal file
32
go-todo-app/internal/api/api.go
Normal file
@ -0,0 +1,32 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
"github.com/simpletodoapp/go-todo-app/internal/config"
|
||||||
|
"github.com/simpletodoapp/go-todo-app/internal/database"
|
||||||
|
"github.com/simpletodoapp/go-todo-app/internal/service"
|
||||||
|
)
|
||||||
|
|
||||||
|
// SetupRouter initializes the API router and routes
|
||||||
|
func SetupRouter(cfg *config.Config, db *database.Database) *gin.Engine {
|
||||||
|
router := gin.Default()
|
||||||
|
|
||||||
|
// Setup middleware
|
||||||
|
SetupMiddleware(router)
|
||||||
|
|
||||||
|
// Setup API documentation
|
||||||
|
SetupDocs(router, cfg)
|
||||||
|
|
||||||
|
// Create services
|
||||||
|
todoService := service.NewTodoService(db.DB)
|
||||||
|
|
||||||
|
// Register handlers
|
||||||
|
healthHandler := NewHealthHandler(db)
|
||||||
|
healthHandler.RegisterRoutes(router)
|
||||||
|
|
||||||
|
todoHandler := NewTodoHandler(todoService)
|
||||||
|
api := router.Group("/api")
|
||||||
|
todoHandler.RegisterRoutes(api)
|
||||||
|
|
||||||
|
return router
|
||||||
|
}
|
154
go-todo-app/internal/api/docs.go
Normal file
154
go-todo-app/internal/api/docs.go
Normal file
@ -0,0 +1,154 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
"github.com/simpletodoapp/go-todo-app/internal/config"
|
||||||
|
)
|
||||||
|
|
||||||
|
// SetupDocs sets up the API documentation
|
||||||
|
func SetupDocs(router *gin.Engine, cfg *config.Config) {
|
||||||
|
// Simple documentation page
|
||||||
|
router.GET("/docs", func(c *gin.Context) {
|
||||||
|
html := fmt.Sprintf(`
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<title>%s API Documentation</title>
|
||||||
|
<style>
|
||||||
|
body { font-family: Arial, sans-serif; line-height: 1.6; padding: 20px; max-width: 800px; margin: 0 auto; }
|
||||||
|
pre { background-color: #f5f5f5; padding: 10px; border-radius: 5px; overflow-x: auto; }
|
||||||
|
h1, h2, h3 { color: #333; }
|
||||||
|
.endpoint { margin-bottom: 30px; border-bottom: 1px solid #eee; padding-bottom: 20px; }
|
||||||
|
.method { font-weight: bold; display: inline-block; width: 80px; }
|
||||||
|
.url { font-family: monospace; }
|
||||||
|
.description { margin: 10px 0; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<h1>%s API Documentation</h1>
|
||||||
|
<p>%s</p>
|
||||||
|
<p>Version: %s</p>
|
||||||
|
|
||||||
|
<h2>Endpoints</h2>
|
||||||
|
|
||||||
|
<div class="endpoint">
|
||||||
|
<h3>Health Check</h3>
|
||||||
|
<p><span class="method">GET</span> <span class="url">/health</span></p>
|
||||||
|
<p class="description">Check the health of the application and database.</p>
|
||||||
|
<h4>Response</h4>
|
||||||
|
<pre>{
|
||||||
|
"status": "healthy",
|
||||||
|
"db_status": "healthy"
|
||||||
|
}</pre>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="endpoint">
|
||||||
|
<h3>List Todos</h3>
|
||||||
|
<p><span class="method">GET</span> <span class="url">/api/todos</span></p>
|
||||||
|
<p class="description">Retrieve a list of todos with optional filtering and pagination.</p>
|
||||||
|
<h4>Query Parameters</h4>
|
||||||
|
<ul>
|
||||||
|
<li><code>skip</code> - Number of items to skip (default: 0)</li>
|
||||||
|
<li><code>limit</code> - Maximum number of items to return (default: 100)</li>
|
||||||
|
<li><code>completed</code> - Filter by completion status (boolean, optional)</li>
|
||||||
|
</ul>
|
||||||
|
<h4>Response</h4>
|
||||||
|
<pre>[
|
||||||
|
{
|
||||||
|
"id": 1,
|
||||||
|
"title": "Learn Go",
|
||||||
|
"description": "Learn Go programming language",
|
||||||
|
"completed": false,
|
||||||
|
"created_at": "2023-09-10T12:00:00Z",
|
||||||
|
"updated_at": "2023-09-10T12:00:00Z"
|
||||||
|
}
|
||||||
|
]</pre>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="endpoint">
|
||||||
|
<h3>Get Todo</h3>
|
||||||
|
<p><span class="method">GET</span> <span class="url">/api/todos/:id</span></p>
|
||||||
|
<p class="description">Retrieve a specific todo by ID.</p>
|
||||||
|
<h4>Path Parameters</h4>
|
||||||
|
<ul>
|
||||||
|
<li><code>id</code> - Todo ID</li>
|
||||||
|
</ul>
|
||||||
|
<h4>Response</h4>
|
||||||
|
<pre>{
|
||||||
|
"id": 1,
|
||||||
|
"title": "Learn Go",
|
||||||
|
"description": "Learn Go programming language",
|
||||||
|
"completed": false,
|
||||||
|
"created_at": "2023-09-10T12:00:00Z",
|
||||||
|
"updated_at": "2023-09-10T12:00:00Z"
|
||||||
|
}</pre>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="endpoint">
|
||||||
|
<h3>Create Todo</h3>
|
||||||
|
<p><span class="method">POST</span> <span class="url">/api/todos</span></p>
|
||||||
|
<p class="description">Create a new todo item.</p>
|
||||||
|
<h4>Request Body</h4>
|
||||||
|
<pre>{
|
||||||
|
"title": "Learn Go",
|
||||||
|
"description": "Learn Go programming language",
|
||||||
|
"completed": false
|
||||||
|
}</pre>
|
||||||
|
<h4>Response</h4>
|
||||||
|
<pre>{
|
||||||
|
"id": 1,
|
||||||
|
"title": "Learn Go",
|
||||||
|
"description": "Learn Go programming language",
|
||||||
|
"completed": false,
|
||||||
|
"created_at": "2023-09-10T12:00:00Z",
|
||||||
|
"updated_at": "2023-09-10T12:00:00Z"
|
||||||
|
}</pre>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="endpoint">
|
||||||
|
<h3>Update Todo</h3>
|
||||||
|
<p><span class="method">PUT</span> <span class="url">/api/todos/:id</span></p>
|
||||||
|
<p class="description">Update an existing todo.</p>
|
||||||
|
<h4>Path Parameters</h4>
|
||||||
|
<ul>
|
||||||
|
<li><code>id</code> - Todo ID</li>
|
||||||
|
</ul>
|
||||||
|
<h4>Request Body</h4>
|
||||||
|
<pre>{
|
||||||
|
"title": "Updated title", // Optional
|
||||||
|
"description": "Updated description", // Optional
|
||||||
|
"completed": true // Optional
|
||||||
|
}</pre>
|
||||||
|
<h4>Response</h4>
|
||||||
|
<pre>{
|
||||||
|
"id": 1,
|
||||||
|
"title": "Updated title",
|
||||||
|
"description": "Updated description",
|
||||||
|
"completed": true,
|
||||||
|
"created_at": "2023-09-10T12:00:00Z",
|
||||||
|
"updated_at": "2023-09-10T12:05:00Z"
|
||||||
|
}</pre>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="endpoint">
|
||||||
|
<h3>Delete Todo</h3>
|
||||||
|
<p><span class="method">DELETE</span> <span class="url">/api/todos/:id</span></p>
|
||||||
|
<p class="description">Delete a todo.</p>
|
||||||
|
<h4>Path Parameters</h4>
|
||||||
|
<ul>
|
||||||
|
<li><code>id</code> - Todo ID</li>
|
||||||
|
</ul>
|
||||||
|
<h4>Response</h4>
|
||||||
|
<p>No content (HTTP 204)</p>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
`, cfg.AppName, cfg.AppName, cfg.AppDescription, cfg.AppVersion)
|
||||||
|
|
||||||
|
c.Header("Content-Type", "text/html")
|
||||||
|
c.String(http.StatusOK, html)
|
||||||
|
})
|
||||||
|
}
|
41
go-todo-app/internal/api/health.go
Normal file
41
go-todo-app/internal/api/health.go
Normal file
@ -0,0 +1,41 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
"github.com/simpletodoapp/go-todo-app/internal/database"
|
||||||
|
"github.com/simpletodoapp/go-todo-app/internal/dto"
|
||||||
|
)
|
||||||
|
|
||||||
|
// HealthHandler handles health check requests
|
||||||
|
type HealthHandler struct {
|
||||||
|
db *database.Database
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewHealthHandler creates a new health check handler
|
||||||
|
func NewHealthHandler(db *database.Database) *HealthHandler {
|
||||||
|
return &HealthHandler{db: db}
|
||||||
|
}
|
||||||
|
|
||||||
|
// RegisterRoutes registers the health check routes
|
||||||
|
func (h *HealthHandler) RegisterRoutes(router *gin.Engine) {
|
||||||
|
router.GET("/health", h.HealthCheck)
|
||||||
|
}
|
||||||
|
|
||||||
|
// HealthCheck handles GET /health
|
||||||
|
// Returns the health status of the application
|
||||||
|
func (h *HealthHandler) HealthCheck(c *gin.Context) {
|
||||||
|
// Check database health
|
||||||
|
dbStatus := "healthy"
|
||||||
|
if err := h.db.Health(); err != nil {
|
||||||
|
dbStatus = "unhealthy"
|
||||||
|
}
|
||||||
|
|
||||||
|
response := dto.HealthResponse{
|
||||||
|
Status: "healthy",
|
||||||
|
DBStatus: dbStatus,
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, response)
|
||||||
|
}
|
27
go-todo-app/internal/api/middleware.go
Normal file
27
go-todo-app/internal/api/middleware.go
Normal file
@ -0,0 +1,27 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/gin-contrib/cors"
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
)
|
||||||
|
|
||||||
|
// SetupMiddleware configures middleware for the router
|
||||||
|
func SetupMiddleware(router *gin.Engine) {
|
||||||
|
// CORS middleware
|
||||||
|
router.Use(cors.New(cors.Config{
|
||||||
|
AllowOrigins: []string{"*"},
|
||||||
|
AllowMethods: []string{"GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"},
|
||||||
|
AllowHeaders: []string{"Origin", "Content-Type", "Accept", "Authorization"},
|
||||||
|
ExposeHeaders: []string{"Content-Length"},
|
||||||
|
AllowCredentials: true,
|
||||||
|
MaxAge: 12 * time.Hour,
|
||||||
|
}))
|
||||||
|
|
||||||
|
// Request logging middleware
|
||||||
|
router.Use(gin.Logger())
|
||||||
|
|
||||||
|
// Recovery middleware
|
||||||
|
router.Use(gin.Recovery())
|
||||||
|
}
|
150
go-todo-app/internal/api/todo.go
Normal file
150
go-todo-app/internal/api/todo.go
Normal file
@ -0,0 +1,150 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
"github.com/simpletodoapp/go-todo-app/internal/dto"
|
||||||
|
"github.com/simpletodoapp/go-todo-app/internal/service"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TodoHandler handles todo-related API requests
|
||||||
|
type TodoHandler struct {
|
||||||
|
todoService *service.TodoService
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewTodoHandler creates a new todo handler
|
||||||
|
func NewTodoHandler(todoService *service.TodoService) *TodoHandler {
|
||||||
|
return &TodoHandler{todoService: todoService}
|
||||||
|
}
|
||||||
|
|
||||||
|
// RegisterRoutes registers the todo routes
|
||||||
|
func (h *TodoHandler) RegisterRoutes(router *gin.RouterGroup) {
|
||||||
|
todos := router.Group("/todos")
|
||||||
|
{
|
||||||
|
todos.GET("", h.GetTodos)
|
||||||
|
todos.POST("", h.CreateTodo)
|
||||||
|
todos.GET("/:id", h.GetTodo)
|
||||||
|
todos.PUT("/:id", h.UpdateTodo)
|
||||||
|
todos.DELETE("/:id", h.DeleteTodo)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetTodos handles GET /api/todos
|
||||||
|
// Returns a list of todos with optional filtering and pagination
|
||||||
|
func (h *TodoHandler) GetTodos(c *gin.Context) {
|
||||||
|
// Parse query parameters
|
||||||
|
skip, _ := strconv.Atoi(c.DefaultQuery("skip", "0"))
|
||||||
|
limit, _ := strconv.Atoi(c.DefaultQuery("limit", "100"))
|
||||||
|
|
||||||
|
var completed *bool
|
||||||
|
if completedStr, exists := c.GetQuery("completed"); exists {
|
||||||
|
completedBool, err := strconv.ParseBool(completedStr)
|
||||||
|
if err == nil {
|
||||||
|
completed = &completedBool
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get todos from service
|
||||||
|
todos, err := h.todoService.GetTodos(skip, limit, completed)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, todos)
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetTodo handles GET /api/todos/:id
|
||||||
|
// Returns a specific todo by ID
|
||||||
|
func (h *TodoHandler) GetTodo(c *gin.Context) {
|
||||||
|
id, err := strconv.ParseUint(c.Param("id"), 10, 32)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid ID format"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
todo, err := h.todoService.GetTodoByID(uint(id))
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if todo == nil {
|
||||||
|
c.JSON(http.StatusNotFound, gin.H{"error": "Todo not found"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, todo)
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateTodo handles POST /api/todos
|
||||||
|
// Creates a new todo
|
||||||
|
func (h *TodoHandler) CreateTodo(c *gin.Context) {
|
||||||
|
var todoCreate dto.TodoCreate
|
||||||
|
if err := c.ShouldBindJSON(&todoCreate); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
todo, err := h.todoService.CreateTodo(todoCreate)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusCreated, todo)
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateTodo handles PUT /api/todos/:id
|
||||||
|
// Updates an existing todo
|
||||||
|
func (h *TodoHandler) UpdateTodo(c *gin.Context) {
|
||||||
|
id, err := strconv.ParseUint(c.Param("id"), 10, 32)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid ID format"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var todoUpdate dto.TodoUpdate
|
||||||
|
if err := c.ShouldBindJSON(&todoUpdate); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
todo, err := h.todoService.UpdateTodo(uint(id), todoUpdate)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if todo == nil {
|
||||||
|
c.JSON(http.StatusNotFound, gin.H{"error": "Todo not found"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, todo)
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteTodo handles DELETE /api/todos/:id
|
||||||
|
// Deletes a todo
|
||||||
|
func (h *TodoHandler) DeleteTodo(c *gin.Context) {
|
||||||
|
id, err := strconv.ParseUint(c.Param("id"), 10, 32)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid ID format"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
deleted, err := h.todoService.DeleteTodo(uint(id))
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if !deleted {
|
||||||
|
c.JSON(http.StatusNotFound, gin.H{"error": "Todo not found"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.Status(http.StatusNoContent)
|
||||||
|
}
|
222
go-todo-app/internal/api/todo_test.go
Normal file
222
go-todo-app/internal/api/todo_test.go
Normal file
@ -0,0 +1,222 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"strconv"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
"github.com/simpletodoapp/go-todo-app/internal/dto"
|
||||||
|
"github.com/simpletodoapp/go-todo-app/internal/model"
|
||||||
|
"github.com/simpletodoapp/go-todo-app/internal/service"
|
||||||
|
"gorm.io/driver/sqlite"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
// setupTestRouter sets up a test router and database
|
||||||
|
func setupTestRouter(t *testing.T) (*gin.Engine, *gorm.DB) {
|
||||||
|
gin.SetMode(gin.TestMode)
|
||||||
|
router := gin.Default()
|
||||||
|
|
||||||
|
// Create an in-memory database
|
||||||
|
db, err := gorm.Open(sqlite.Open("file::memory:?cache=shared"), &gorm.Config{})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to connect to in-memory database: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Auto-migrate the schema
|
||||||
|
err = db.AutoMigrate(&model.Todo{})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to migrate test database: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create services and handlers
|
||||||
|
todoService := service.NewTodoService(db)
|
||||||
|
todoHandler := NewTodoHandler(todoService)
|
||||||
|
|
||||||
|
// Register routes
|
||||||
|
api := router.Group("/api")
|
||||||
|
todoHandler.RegisterRoutes(api)
|
||||||
|
|
||||||
|
return router, db
|
||||||
|
}
|
||||||
|
|
||||||
|
// populateTestTodos adds test todos to the database
|
||||||
|
func populateTestTodos(t *testing.T, db *gorm.DB) {
|
||||||
|
todos := []model.Todo{
|
||||||
|
{
|
||||||
|
Title: "Test Todo 1",
|
||||||
|
Description: "Test Description 1",
|
||||||
|
Completed: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Title: "Test Todo 2",
|
||||||
|
Description: "Test Description 2",
|
||||||
|
Completed: true,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, todo := range todos {
|
||||||
|
if err := db.Create(&todo).Error; err != nil {
|
||||||
|
t.Fatalf("Failed to create test todo: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetTodosHandler(t *testing.T) {
|
||||||
|
router, db := setupTestRouter(t)
|
||||||
|
populateTestTodos(t, db)
|
||||||
|
|
||||||
|
// Test GET /api/todos
|
||||||
|
req, _ := http.NewRequest("GET", "/api/todos", nil)
|
||||||
|
resp := httptest.NewRecorder()
|
||||||
|
router.ServeHTTP(resp, req)
|
||||||
|
|
||||||
|
if resp.Code != http.StatusOK {
|
||||||
|
t.Errorf("Expected status code %d, got %d", http.StatusOK, resp.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
var todos []model.Todo
|
||||||
|
if err := json.Unmarshal(resp.Body.Bytes(), &todos); err != nil {
|
||||||
|
t.Fatalf("Failed to unmarshal response: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(todos) != 2 {
|
||||||
|
t.Errorf("Expected 2 todos, got %d", len(todos))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetTodoHandler(t *testing.T) {
|
||||||
|
router, db := setupTestRouter(t)
|
||||||
|
populateTestTodos(t, db)
|
||||||
|
|
||||||
|
// Test GET /api/todos/:id
|
||||||
|
req, _ := http.NewRequest("GET", "/api/todos/1", nil)
|
||||||
|
resp := httptest.NewRecorder()
|
||||||
|
router.ServeHTTP(resp, req)
|
||||||
|
|
||||||
|
if resp.Code != http.StatusOK {
|
||||||
|
t.Errorf("Expected status code %d, got %d", http.StatusOK, resp.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
var todo model.Todo
|
||||||
|
if err := json.Unmarshal(resp.Body.Bytes(), &todo); err != nil {
|
||||||
|
t.Fatalf("Failed to unmarshal response: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if todo.ID != 1 {
|
||||||
|
t.Errorf("Expected todo ID to be 1, got %d", todo.ID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test non-existent todo
|
||||||
|
req, _ = http.NewRequest("GET", "/api/todos/999", nil)
|
||||||
|
resp = httptest.NewRecorder()
|
||||||
|
router.ServeHTTP(resp, req)
|
||||||
|
|
||||||
|
if resp.Code != http.StatusNotFound {
|
||||||
|
t.Errorf("Expected status code %d, got %d", http.StatusNotFound, resp.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCreateTodoHandler(t *testing.T) {
|
||||||
|
router, _ := setupTestRouter(t)
|
||||||
|
|
||||||
|
// Create request body
|
||||||
|
todoCreate := dto.TodoCreate{
|
||||||
|
TodoBase: dto.TodoBase{
|
||||||
|
Title: "New Todo",
|
||||||
|
Description: "New Description",
|
||||||
|
Completed: false,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
body, _ := json.Marshal(todoCreate)
|
||||||
|
|
||||||
|
// Test POST /api/todos
|
||||||
|
req, _ := http.NewRequest("POST", "/api/todos", bytes.NewBuffer(body))
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
resp := httptest.NewRecorder()
|
||||||
|
router.ServeHTTP(resp, req)
|
||||||
|
|
||||||
|
if resp.Code != http.StatusCreated {
|
||||||
|
t.Errorf("Expected status code %d, got %d", http.StatusCreated, resp.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
var todo model.Todo
|
||||||
|
if err := json.Unmarshal(resp.Body.Bytes(), &todo); err != nil {
|
||||||
|
t.Fatalf("Failed to unmarshal response: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if todo.Title != "New Todo" {
|
||||||
|
t.Errorf("Expected title to be 'New Todo', got '%s'", todo.Title)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUpdateTodoHandler(t *testing.T) {
|
||||||
|
router, db := setupTestRouter(t)
|
||||||
|
populateTestTodos(t, db)
|
||||||
|
|
||||||
|
// Create request body
|
||||||
|
title := "Updated Title"
|
||||||
|
completed := true
|
||||||
|
todoUpdate := dto.TodoUpdate{
|
||||||
|
Title: &title,
|
||||||
|
Completed: &completed,
|
||||||
|
}
|
||||||
|
body, _ := json.Marshal(todoUpdate)
|
||||||
|
|
||||||
|
// Test PUT /api/todos/:id
|
||||||
|
req, _ := http.NewRequest("PUT", "/api/todos/1", bytes.NewBuffer(body))
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
resp := httptest.NewRecorder()
|
||||||
|
router.ServeHTTP(resp, req)
|
||||||
|
|
||||||
|
if resp.Code != http.StatusOK {
|
||||||
|
t.Errorf("Expected status code %d, got %d", http.StatusOK, resp.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
var todo model.Todo
|
||||||
|
if err := json.Unmarshal(resp.Body.Bytes(), &todo); err != nil {
|
||||||
|
t.Fatalf("Failed to unmarshal response: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if todo.Title != "Updated Title" {
|
||||||
|
t.Errorf("Expected title to be 'Updated Title', got '%s'", todo.Title)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !todo.Completed {
|
||||||
|
t.Errorf("Expected completed to be true, got %t", todo.Completed)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDeleteTodoHandler(t *testing.T) {
|
||||||
|
router, db := setupTestRouter(t)
|
||||||
|
populateTestTodos(t, db)
|
||||||
|
|
||||||
|
// Test DELETE /api/todos/:id
|
||||||
|
req, _ := http.NewRequest("DELETE", "/api/todos/1", nil)
|
||||||
|
resp := httptest.NewRecorder()
|
||||||
|
router.ServeHTTP(resp, req)
|
||||||
|
|
||||||
|
if resp.Code != http.StatusNoContent {
|
||||||
|
t.Errorf("Expected status code %d, got %d", http.StatusNoContent, resp.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify the todo was deleted
|
||||||
|
var count int64
|
||||||
|
db.Model(&model.Todo{}).Where("id = ?", 1).Count(&count)
|
||||||
|
if count != 0 {
|
||||||
|
t.Errorf("Expected todo to be deleted, but it still exists")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test deleting non-existent todo
|
||||||
|
req, _ = http.NewRequest("DELETE", "/api/todos/999", nil)
|
||||||
|
resp = httptest.NewRecorder()
|
||||||
|
router.ServeHTTP(resp, req)
|
||||||
|
|
||||||
|
if resp.Code != http.StatusNotFound {
|
||||||
|
t.Errorf("Expected status code %d, got %d", http.StatusNotFound, resp.Code)
|
||||||
|
}
|
||||||
|
}
|
69
go-todo-app/internal/config/config.go
Normal file
69
go-todo-app/internal/config/config.go
Normal file
@ -0,0 +1,69 @@
|
|||||||
|
package config
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/spf13/viper"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Config holds the application configuration
|
||||||
|
type Config struct {
|
||||||
|
AppName string `mapstructure:"APP_NAME"`
|
||||||
|
AppDescription string `mapstructure:"APP_DESCRIPTION"`
|
||||||
|
AppVersion string `mapstructure:"APP_VERSION"`
|
||||||
|
ServerPort string `mapstructure:"SERVER_PORT"`
|
||||||
|
DBPath string `mapstructure:"DB_PATH"`
|
||||||
|
DBName string `mapstructure:"DB_NAME"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// LoadConfig reads the configuration from environment variables or config file
|
||||||
|
func LoadConfig() (*Config, error) {
|
||||||
|
// Set default values
|
||||||
|
config := &Config{
|
||||||
|
AppName: "Go Todo App",
|
||||||
|
AppDescription: "A simple Todo application API built with Go, Gin and SQLite",
|
||||||
|
AppVersion: "0.1.0",
|
||||||
|
ServerPort: "8000",
|
||||||
|
DBPath: "/app/storage/db",
|
||||||
|
DBName: "db.sqlite",
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set up viper
|
||||||
|
v := viper.New()
|
||||||
|
v.SetConfigName("config")
|
||||||
|
v.SetConfigType("yaml")
|
||||||
|
v.AddConfigPath(".")
|
||||||
|
v.AddConfigPath("./config")
|
||||||
|
|
||||||
|
// Match environment variables with config fields
|
||||||
|
v.SetEnvKeyReplacer(strings.NewReplacer(".", "_"))
|
||||||
|
v.AutomaticEnv()
|
||||||
|
|
||||||
|
// Try to read from config file (optional)
|
||||||
|
if err := v.ReadInConfig(); err != nil {
|
||||||
|
if _, ok := err.(viper.ConfigFileNotFoundError); !ok {
|
||||||
|
return nil, fmt.Errorf("error reading config file: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Override config from environment variables
|
||||||
|
if err := v.Unmarshal(config); err != nil {
|
||||||
|
return nil, fmt.Errorf("error unmarshaling config: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ensure DB directory exists
|
||||||
|
dbDir := config.DBPath
|
||||||
|
if err := os.MkdirAll(dbDir, 0755); err != nil {
|
||||||
|
return nil, fmt.Errorf("error creating DB directory: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return config, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetDatabaseURL returns the full SQLite database connection string
|
||||||
|
func (c *Config) GetDatabaseURL() string {
|
||||||
|
return fmt.Sprintf("file:%s", filepath.Join(c.DBPath, c.DBName))
|
||||||
|
}
|
48
go-todo-app/internal/database/db.go
Normal file
48
go-todo-app/internal/database/db.go
Normal file
@ -0,0 +1,48 @@
|
|||||||
|
package database
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
|
||||||
|
"github.com/simpletodoapp/go-todo-app/internal/config"
|
||||||
|
"github.com/simpletodoapp/go-todo-app/internal/model"
|
||||||
|
"gorm.io/driver/sqlite"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
"gorm.io/gorm/logger"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Database holds the database connection
|
||||||
|
type Database struct {
|
||||||
|
DB *gorm.DB
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewDatabase initializes a new database connection
|
||||||
|
func NewDatabase(cfg *config.Config) (*Database, error) {
|
||||||
|
// Configure GORM
|
||||||
|
gormConfig := &gorm.Config{
|
||||||
|
Logger: logger.Default.LogMode(logger.Info),
|
||||||
|
}
|
||||||
|
|
||||||
|
// Connect to SQLite database
|
||||||
|
log.Printf("Connecting to database: %s", cfg.GetDatabaseURL())
|
||||||
|
db, err := gorm.Open(sqlite.Open(cfg.GetDatabaseURL()), gormConfig)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to connect to database: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Auto-migrate the schema
|
||||||
|
if err := db.AutoMigrate(&model.Todo{}); err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to migrate database: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return &Database{DB: db}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Health checks the database connection
|
||||||
|
func (d *Database) Health() error {
|
||||||
|
sqlDB, err := d.DB.DB()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return sqlDB.Ping()
|
||||||
|
}
|
152
go-todo-app/internal/database/migrations.go
Normal file
152
go-todo-app/internal/database/migrations.go
Normal file
@ -0,0 +1,152 @@
|
|||||||
|
package database
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"io/ioutil"
|
||||||
|
"log"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/simpletodoapp/go-todo-app/internal/config"
|
||||||
|
)
|
||||||
|
|
||||||
|
// MigrationRunner handles database migrations
|
||||||
|
type MigrationRunner struct {
|
||||||
|
db *Database
|
||||||
|
config *config.Config
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewMigrationRunner creates a new migration runner
|
||||||
|
func NewMigrationRunner(db *Database, cfg *config.Config) *MigrationRunner {
|
||||||
|
return &MigrationRunner{
|
||||||
|
db: db,
|
||||||
|
config: cfg,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// RunMigrations executes all pending migrations
|
||||||
|
func (m *MigrationRunner) RunMigrations(migrationsPath string) error {
|
||||||
|
log.Println("Running migrations...")
|
||||||
|
|
||||||
|
// Get all migration files
|
||||||
|
files, err := getMigrationFiles(migrationsPath)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(files) == 0 {
|
||||||
|
log.Println("No migration files found")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create migrations table if it doesn't exist
|
||||||
|
err = m.createMigrationsTable()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get applied migrations
|
||||||
|
appliedMigrations, err := m.getAppliedMigrations()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply pending migrations
|
||||||
|
for _, file := range files {
|
||||||
|
migrationName := filepath.Base(file)
|
||||||
|
if !contains(appliedMigrations, migrationName) {
|
||||||
|
log.Printf("Applying migration: %s", migrationName)
|
||||||
|
|
||||||
|
// Read migration file
|
||||||
|
content, err := ioutil.ReadFile(file)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to read migration file: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Split into up and down migrations
|
||||||
|
parts := strings.Split(string(content), "-- Down migration")
|
||||||
|
upMigration := parts[0]
|
||||||
|
|
||||||
|
// Execute migration
|
||||||
|
tx := m.db.DB.Begin()
|
||||||
|
if err := tx.Exec(upMigration).Error; err != nil {
|
||||||
|
tx.Rollback()
|
||||||
|
return fmt.Errorf("failed to apply migration: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Record applied migration
|
||||||
|
if err := tx.Exec("INSERT INTO migrations (name, applied_at) VALUES (?, CURRENT_TIMESTAMP)", migrationName).Error; err != nil {
|
||||||
|
tx.Rollback()
|
||||||
|
return fmt.Errorf("failed to record migration: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
tx.Commit()
|
||||||
|
log.Printf("Migration applied: %s", migrationName)
|
||||||
|
} else {
|
||||||
|
log.Printf("Migration already applied: %s", migrationName)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Println("Migrations completed successfully")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// createMigrationsTable creates the migrations table if it doesn't exist
|
||||||
|
func (m *MigrationRunner) createMigrationsTable() error {
|
||||||
|
query := `
|
||||||
|
CREATE TABLE IF NOT EXISTS migrations (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
applied_at DATETIME NOT NULL
|
||||||
|
)
|
||||||
|
`
|
||||||
|
|
||||||
|
return m.db.DB.Exec(query).Error
|
||||||
|
}
|
||||||
|
|
||||||
|
// getAppliedMigrations gets the list of already applied migrations
|
||||||
|
func (m *MigrationRunner) getAppliedMigrations() ([]string, error) {
|
||||||
|
var migrations []string
|
||||||
|
var records []struct {
|
||||||
|
Name string
|
||||||
|
}
|
||||||
|
|
||||||
|
result := m.db.DB.Raw("SELECT name FROM migrations ORDER BY id ASC").Scan(&records)
|
||||||
|
if result.Error != nil {
|
||||||
|
return nil, fmt.Errorf("failed to get applied migrations: %v", result.Error)
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, record := range records {
|
||||||
|
migrations = append(migrations, record.Name)
|
||||||
|
}
|
||||||
|
|
||||||
|
return migrations, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// getMigrationFiles gets all migration files sorted by filename
|
||||||
|
func getMigrationFiles(migrationsPath string) ([]string, error) {
|
||||||
|
files, err := filepath.Glob(filepath.Join(migrationsPath, "*.sql"))
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to read migration files: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if directory exists
|
||||||
|
if _, err := os.Stat(migrationsPath); os.IsNotExist(err) {
|
||||||
|
return []string{}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
sort.Strings(files)
|
||||||
|
return files, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// contains checks if a string is in a slice
|
||||||
|
func contains(slice []string, item string) bool {
|
||||||
|
for _, s := range slice {
|
||||||
|
if s == item {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
40
go-todo-app/internal/dto/todo.go
Normal file
40
go-todo-app/internal/dto/todo.go
Normal file
@ -0,0 +1,40 @@
|
|||||||
|
package dto
|
||||||
|
|
||||||
|
import (
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TodoBase contains the common fields for todo operations
|
||||||
|
type TodoBase struct {
|
||||||
|
Title string `json:"title" binding:"required"`
|
||||||
|
Description string `json:"description"`
|
||||||
|
Completed bool `json:"completed" default:"false"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// TodoCreate represents the data needed to create a new todo
|
||||||
|
type TodoCreate struct {
|
||||||
|
TodoBase
|
||||||
|
}
|
||||||
|
|
||||||
|
// TodoUpdate represents the data that can be updated for a todo
|
||||||
|
type TodoUpdate struct {
|
||||||
|
Title *string `json:"title"`
|
||||||
|
Description *string `json:"description"`
|
||||||
|
Completed *bool `json:"completed"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// TodoResponse represents the todo data sent back to the client
|
||||||
|
type TodoResponse struct {
|
||||||
|
ID uint `json:"id"`
|
||||||
|
Title string `json:"title"`
|
||||||
|
Description string `json:"description"`
|
||||||
|
Completed bool `json:"completed"`
|
||||||
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
UpdatedAt time.Time `json:"updated_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// HealthResponse represents the health check response
|
||||||
|
type HealthResponse struct {
|
||||||
|
Status string `json:"status"`
|
||||||
|
DBStatus string `json:"db_status"`
|
||||||
|
}
|
20
go-todo-app/internal/model/todo.go
Normal file
20
go-todo-app/internal/model/todo.go
Normal file
@ -0,0 +1,20 @@
|
|||||||
|
package model
|
||||||
|
|
||||||
|
import (
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Todo represents a todo item in the database
|
||||||
|
type Todo struct {
|
||||||
|
ID uint `gorm:"primaryKey" json:"id"`
|
||||||
|
Title string `gorm:"index" json:"title"`
|
||||||
|
Description string `gorm:"default:null" json:"description"`
|
||||||
|
Completed bool `gorm:"default:false" json:"completed"`
|
||||||
|
CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"`
|
||||||
|
UpdatedAt time.Time `gorm:"autoUpdateTime" json:"updated_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// TableName overrides the table name
|
||||||
|
func (Todo) TableName() string {
|
||||||
|
return "todos"
|
||||||
|
}
|
51
go-todo-app/internal/model/todo_test.go
Normal file
51
go-todo-app/internal/model/todo_test.go
Normal file
@ -0,0 +1,51 @@
|
|||||||
|
package model
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestTodoTableName(t *testing.T) {
|
||||||
|
todo := Todo{}
|
||||||
|
if todo.TableName() != "todos" {
|
||||||
|
t.Errorf("Expected table name to be 'todos', got '%s'", todo.TableName())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTodoFields(t *testing.T) {
|
||||||
|
// Create a todo
|
||||||
|
now := time.Now()
|
||||||
|
todo := Todo{
|
||||||
|
ID: 1,
|
||||||
|
Title: "Test Todo",
|
||||||
|
Description: "Test Description",
|
||||||
|
Completed: false,
|
||||||
|
CreatedAt: now,
|
||||||
|
UpdatedAt: now,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check field values
|
||||||
|
if todo.ID != 1 {
|
||||||
|
t.Errorf("Expected ID to be 1, got %d", todo.ID)
|
||||||
|
}
|
||||||
|
|
||||||
|
if todo.Title != "Test Todo" {
|
||||||
|
t.Errorf("Expected Title to be 'Test Todo', got '%s'", todo.Title)
|
||||||
|
}
|
||||||
|
|
||||||
|
if todo.Description != "Test Description" {
|
||||||
|
t.Errorf("Expected Description to be 'Test Description', got '%s'", todo.Description)
|
||||||
|
}
|
||||||
|
|
||||||
|
if todo.Completed != false {
|
||||||
|
t.Errorf("Expected Completed to be false, got %t", todo.Completed)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !todo.CreatedAt.Equal(now) {
|
||||||
|
t.Errorf("Expected CreatedAt to be %v, got %v", now, todo.CreatedAt)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !todo.UpdatedAt.Equal(now) {
|
||||||
|
t.Errorf("Expected UpdatedAt to be %v, got %v", now, todo.UpdatedAt)
|
||||||
|
}
|
||||||
|
}
|
126
go-todo-app/internal/service/todo.go
Normal file
126
go-todo-app/internal/service/todo.go
Normal file
@ -0,0 +1,126 @@
|
|||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"github.com/simpletodoapp/go-todo-app/internal/dto"
|
||||||
|
"github.com/simpletodoapp/go-todo-app/internal/model"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TodoService handles todo operations
|
||||||
|
type TodoService struct {
|
||||||
|
db *gorm.DB
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewTodoService creates a new todo service
|
||||||
|
func NewTodoService(db *gorm.DB) *TodoService {
|
||||||
|
return &TodoService{db: db}
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetTodos retrieves todos with optional filters and pagination
|
||||||
|
func (s *TodoService) GetTodos(skip, limit int, completed *bool) ([]model.Todo, error) {
|
||||||
|
var todos []model.Todo
|
||||||
|
query := s.db
|
||||||
|
|
||||||
|
// Apply completed filter if provided
|
||||||
|
if completed != nil {
|
||||||
|
query = query.Where("completed = ?", *completed)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply pagination
|
||||||
|
result := query.Offset(skip).Limit(limit).Find(&todos)
|
||||||
|
if result.Error != nil {
|
||||||
|
return nil, fmt.Errorf("error getting todos: %v", result.Error)
|
||||||
|
}
|
||||||
|
|
||||||
|
return todos, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetTodoByID retrieves a specific todo by ID
|
||||||
|
func (s *TodoService) GetTodoByID(id uint) (*model.Todo, error) {
|
||||||
|
var todo model.Todo
|
||||||
|
result := s.db.First(&todo, id)
|
||||||
|
|
||||||
|
if result.Error != nil {
|
||||||
|
if errors.Is(result.Error, gorm.ErrRecordNotFound) {
|
||||||
|
return nil, nil // Todo not found, but not an error
|
||||||
|
}
|
||||||
|
return nil, fmt.Errorf("error getting todo: %v", result.Error)
|
||||||
|
}
|
||||||
|
|
||||||
|
return &todo, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateTodo creates a new todo
|
||||||
|
func (s *TodoService) CreateTodo(todoCreate dto.TodoCreate) (*model.Todo, error) {
|
||||||
|
todo := model.Todo{
|
||||||
|
Title: todoCreate.Title,
|
||||||
|
Description: todoCreate.Description,
|
||||||
|
Completed: todoCreate.Completed,
|
||||||
|
}
|
||||||
|
|
||||||
|
result := s.db.Create(&todo)
|
||||||
|
if result.Error != nil {
|
||||||
|
return nil, fmt.Errorf("error creating todo: %v", result.Error)
|
||||||
|
}
|
||||||
|
|
||||||
|
return &todo, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateTodo updates an existing todo
|
||||||
|
func (s *TodoService) UpdateTodo(id uint, todoUpdate dto.TodoUpdate) (*model.Todo, error) {
|
||||||
|
// Get existing todo
|
||||||
|
todo, err := s.GetTodoByID(id)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if todo == nil {
|
||||||
|
return nil, nil // Not found
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update fields if provided
|
||||||
|
updates := map[string]interface{}{}
|
||||||
|
|
||||||
|
if todoUpdate.Title != nil {
|
||||||
|
updates["title"] = *todoUpdate.Title
|
||||||
|
}
|
||||||
|
|
||||||
|
if todoUpdate.Description != nil {
|
||||||
|
updates["description"] = *todoUpdate.Description
|
||||||
|
}
|
||||||
|
|
||||||
|
if todoUpdate.Completed != nil {
|
||||||
|
updates["completed"] = *todoUpdate.Completed
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply updates
|
||||||
|
result := s.db.Model(&todo).Updates(updates)
|
||||||
|
if result.Error != nil {
|
||||||
|
return nil, fmt.Errorf("error updating todo: %v", result.Error)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Refresh the todo from database
|
||||||
|
return s.GetTodoByID(id)
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteTodo deletes a todo by ID
|
||||||
|
func (s *TodoService) DeleteTodo(id uint) (bool, error) {
|
||||||
|
// Check if todo exists
|
||||||
|
todo, err := s.GetTodoByID(id)
|
||||||
|
if err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
if todo == nil {
|
||||||
|
return false, nil // Not found
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete the todo
|
||||||
|
result := s.db.Delete(&model.Todo{}, id)
|
||||||
|
if result.Error != nil {
|
||||||
|
return false, fmt.Errorf("error deleting todo: %v", result.Error)
|
||||||
|
}
|
||||||
|
|
||||||
|
return true, nil
|
||||||
|
}
|
228
go-todo-app/internal/service/todo_test.go
Normal file
228
go-todo-app/internal/service/todo_test.go
Normal file
@ -0,0 +1,228 @@
|
|||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/simpletodoapp/go-todo-app/internal/dto"
|
||||||
|
"github.com/simpletodoapp/go-todo-app/internal/model"
|
||||||
|
"gorm.io/driver/sqlite"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
// setupTestDB sets up an in-memory SQLite database for testing
|
||||||
|
func setupTestDB(t *testing.T) *gorm.DB {
|
||||||
|
db, err := gorm.Open(sqlite.Open("file::memory:?cache=shared"), &gorm.Config{})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to connect to in-memory database: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Auto-migrate the schema
|
||||||
|
err = db.AutoMigrate(&model.Todo{})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to migrate test database: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return db
|
||||||
|
}
|
||||||
|
|
||||||
|
// populateTestData adds test data to the database
|
||||||
|
func populateTestData(t *testing.T, db *gorm.DB) {
|
||||||
|
todos := []model.Todo{
|
||||||
|
{
|
||||||
|
Title: "Test Todo 1",
|
||||||
|
Description: "Test Description 1",
|
||||||
|
Completed: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Title: "Test Todo 2",
|
||||||
|
Description: "Test Description 2",
|
||||||
|
Completed: true,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, todo := range todos {
|
||||||
|
if err := db.Create(&todo).Error; err != nil {
|
||||||
|
t.Fatalf("Failed to create test data: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetTodos(t *testing.T) {
|
||||||
|
db := setupTestDB(t)
|
||||||
|
populateTestData(t, db)
|
||||||
|
|
||||||
|
service := NewTodoService(db)
|
||||||
|
|
||||||
|
// Test getting all todos
|
||||||
|
todos, err := service.GetTodos(0, 100, nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to get todos: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(todos) != 2 {
|
||||||
|
t.Errorf("Expected 2 todos, got %d", len(todos))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test filtering by completed
|
||||||
|
completed := true
|
||||||
|
todos, err = service.GetTodos(0, 100, &completed)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to get completed todos: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(todos) != 1 {
|
||||||
|
t.Errorf("Expected 1 completed todo, got %d", len(todos))
|
||||||
|
}
|
||||||
|
|
||||||
|
if !todos[0].Completed {
|
||||||
|
t.Errorf("Expected todo to be completed, got %t", todos[0].Completed)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test pagination
|
||||||
|
todos, err = service.GetTodos(1, 1, nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to get paginated todos: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(todos) != 1 {
|
||||||
|
t.Errorf("Expected 1 todo with pagination, got %d", len(todos))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCreateTodo(t *testing.T) {
|
||||||
|
db := setupTestDB(t)
|
||||||
|
service := NewTodoService(db)
|
||||||
|
|
||||||
|
// Create a new todo
|
||||||
|
todoCreate := dto.TodoCreate{
|
||||||
|
TodoBase: dto.TodoBase{
|
||||||
|
Title: "New Todo",
|
||||||
|
Description: "New Description",
|
||||||
|
Completed: false,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
todo, err := service.CreateTodo(todoCreate)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to create todo: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if todo.ID == 0 {
|
||||||
|
t.Errorf("Expected todo ID to be set, got %d", todo.ID)
|
||||||
|
}
|
||||||
|
|
||||||
|
if todo.Title != "New Todo" {
|
||||||
|
t.Errorf("Expected title to be 'New Todo', got '%s'", todo.Title)
|
||||||
|
}
|
||||||
|
|
||||||
|
if todo.Description != "New Description" {
|
||||||
|
t.Errorf("Expected description to be 'New Description', got '%s'", todo.Description)
|
||||||
|
}
|
||||||
|
|
||||||
|
if todo.Completed != false {
|
||||||
|
t.Errorf("Expected completed to be false, got %t", todo.Completed)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetTodoByID(t *testing.T) {
|
||||||
|
db := setupTestDB(t)
|
||||||
|
populateTestData(t, db)
|
||||||
|
service := NewTodoService(db)
|
||||||
|
|
||||||
|
// Get a todo by ID
|
||||||
|
todo, err := service.GetTodoByID(1)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to get todo by ID: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if todo == nil {
|
||||||
|
t.Fatalf("Expected todo to be found, got nil")
|
||||||
|
}
|
||||||
|
|
||||||
|
if todo.ID != 1 {
|
||||||
|
t.Errorf("Expected todo ID to be 1, got %d", todo.ID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test non-existent todo
|
||||||
|
todo, err = service.GetTodoByID(999)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Expected no error for non-existent todo, got %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if todo != nil {
|
||||||
|
t.Errorf("Expected nil for non-existent todo, got %+v", todo)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUpdateTodo(t *testing.T) {
|
||||||
|
db := setupTestDB(t)
|
||||||
|
populateTestData(t, db)
|
||||||
|
service := NewTodoService(db)
|
||||||
|
|
||||||
|
// Update a todo
|
||||||
|
title := "Updated Title"
|
||||||
|
completed := true
|
||||||
|
todoUpdate := dto.TodoUpdate{
|
||||||
|
Title: &title,
|
||||||
|
Completed: &completed,
|
||||||
|
}
|
||||||
|
|
||||||
|
todo, err := service.UpdateTodo(1, todoUpdate)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to update todo: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if todo.Title != "Updated Title" {
|
||||||
|
t.Errorf("Expected title to be 'Updated Title', got '%s'", todo.Title)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !todo.Completed {
|
||||||
|
t.Errorf("Expected completed to be true, got %t", todo.Completed)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test updating non-existent todo
|
||||||
|
todo, err = service.UpdateTodo(999, todoUpdate)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Expected no error for non-existent todo, got %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if todo != nil {
|
||||||
|
t.Errorf("Expected nil for non-existent todo, got %+v", todo)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDeleteTodo(t *testing.T) {
|
||||||
|
db := setupTestDB(t)
|
||||||
|
populateTestData(t, db)
|
||||||
|
service := NewTodoService(db)
|
||||||
|
|
||||||
|
// Delete a todo
|
||||||
|
deleted, err := service.DeleteTodo(1)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to delete todo: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !deleted {
|
||||||
|
t.Errorf("Expected todo to be deleted")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try to get the deleted todo
|
||||||
|
todo, err := service.GetTodoByID(1)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Unexpected error after deletion: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if todo != nil {
|
||||||
|
t.Errorf("Expected todo to be nil after deletion, got %+v", todo)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test deleting non-existent todo
|
||||||
|
deleted, err = service.DeleteTodo(999)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Expected no error for non-existent todo, got %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if deleted {
|
||||||
|
t.Errorf("Expected false for deleting non-existent todo, got %t", deleted)
|
||||||
|
}
|
||||||
|
}
|
14
go-todo-app/migrations/00001_create_todos_table.sql
Normal file
14
go-todo-app/migrations/00001_create_todos_table.sql
Normal file
@ -0,0 +1,14 @@
|
|||||||
|
-- Up migration
|
||||||
|
CREATE TABLE todos (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
title TEXT NOT NULL,
|
||||||
|
description TEXT,
|
||||||
|
completed BOOLEAN DEFAULT FALSE,
|
||||||
|
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX idx_todos_title ON todos(title);
|
||||||
|
|
||||||
|
-- Down migration
|
||||||
|
DROP TABLE IF EXISTS todos;
|
0
go-todo-app/storage/db/.gitkeep
Normal file
0
go-todo-app/storage/db/.gitkeep
Normal file
Loading…
x
Reference in New Issue
Block a user