Compare commits
32 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| ed50d2427e | |||
| c20720f120 | |||
| a07d033e93 | |||
| 67fc508384 | |||
| 92fe617f73 | |||
| 94c5c30d09 | |||
| 2b059ec334 | |||
| 77cfb709d8 | |||
| aae68ba20e | |||
| 8883cdcb86 | |||
| aa8f7af55d | |||
| 1aedc2b69a | |||
| e9e7e9dbac | |||
| 88b5d11113 | |||
| 29f0f9b468 | |||
| 7575853355 | |||
| 788b6cf430 | |||
| 568ba3b016 | |||
| 935f742ac9 | |||
| 38e640c617 | |||
| f780c69806 | |||
| 5d92820591 | |||
| a2aa8b2a76 | |||
| 90d5628a42 | |||
| 9a0637aa4c | |||
| d3111ad52c | |||
| 6fb4823183 | |||
| 8d631f8fab | |||
| 06f95afc92 | |||
| f37c1ac19b | |||
| 00f6b51228 | |||
| a95a703905 |
+29
-3
@@ -1,12 +1,38 @@
|
||||
DB_PATH=./data/apocapoc.db
|
||||
|
||||
PORT=8080
|
||||
HOST=0.0.0.0
|
||||
APP_URL=http://localhost:8080
|
||||
|
||||
JWT_SECRET=change-me-in-production
|
||||
JWT_EXPIRY=1h
|
||||
REFRESH_TOKEN_EXPIRY=7d
|
||||
|
||||
CORS_ORIGINS=http://localhost:3000
|
||||
|
||||
DEFAULT_TIMEZONE=UTC
|
||||
|
||||
# Email Configuration (optional - required for email features)
|
||||
SMTP_HOST=
|
||||
SMTP_PORT=587
|
||||
SMTP_USER=
|
||||
# Note: Escape $ signs with $$ (e.g., pa$word becomes pa$$word)
|
||||
SMTP_PASSWORD=
|
||||
SMTP_FROM=noreply@example.com
|
||||
|
||||
# Application Branding (optional - override support email if needed)
|
||||
SUPPORT_EMAIL=contact@apocapoc.app
|
||||
|
||||
# Email Features
|
||||
SEND_WELCOME_EMAIL=false
|
||||
|
||||
# Registration Control
|
||||
REGISTRATION_MODE=open
|
||||
|
||||
# Logging Configuration
|
||||
LOG_LEVEL=info
|
||||
ENVIRONMENT=production
|
||||
|
||||
# Backup Configuration
|
||||
BACKUP_ENABLED=false
|
||||
BACKUP_INTERVAL=24h
|
||||
BACKUP_RETENTION_DAYS=7
|
||||
BACKUP_PATH=./data/backups
|
||||
BACKUP_COMPRESS=true
|
||||
|
||||
@@ -17,7 +17,7 @@ jobs:
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version: '1.21'
|
||||
go-version: '1.24'
|
||||
|
||||
- name: Run tests
|
||||
run: go test ./... -v -race -coverprofile=coverage.txt -covermode=atomic
|
||||
@@ -40,7 +40,7 @@ jobs:
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version: '1.21'
|
||||
go-version: '1.24'
|
||||
|
||||
- name: Run go vet
|
||||
run: go vet ./...
|
||||
@@ -69,7 +69,7 @@ jobs:
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version: '1.21'
|
||||
go-version: '1.24'
|
||||
|
||||
- name: Run GoReleaser
|
||||
uses: goreleaser/goreleaser-action@v6
|
||||
|
||||
@@ -4,7 +4,10 @@ data/
|
||||
*.db-shm
|
||||
*.db-wal
|
||||
apocapoc-api
|
||||
/api
|
||||
dist/
|
||||
bin/
|
||||
.internal-notes/
|
||||
docker-compose.yml
|
||||
coverage.out
|
||||
coverage.txt
|
||||
|
||||
@@ -1,17 +1,30 @@
|
||||
# Apocapoc
|
||||
# Apocapoc API - Self-Hosted Habit Tracker
|
||||
|
||||
Self-hosted habit tracking service with a clean, hexagonal architecture.
|
||||
REST API for habit tracking built with Go. Self-hosted alternative for developers who want control over their data.
|
||||
|
||||
[](https://golang.org/)
|
||||
[](LICENSE)
|
||||
|
||||
## Table of Contents
|
||||
|
||||
- [Features](#features)
|
||||
- [Quick Start](#quick-start)
|
||||
- [Docker Compose (Recommended)](#using-docker-compose-recommended)
|
||||
- [Binary Installation](#using-the-binary)
|
||||
- [Use Cases](#use-cases)
|
||||
- [API Documentation](#api-documentation)
|
||||
- [Development](#development)
|
||||
- [Architecture](#architecture)
|
||||
|
||||
## Features
|
||||
|
||||
- **Multiple habit types**: Boolean (check), Counter, Value
|
||||
- **Flexible scheduling**: Daily, Weekly, Monthly with specific days
|
||||
- **Carry-over support**: Choose if incomplete habits persist or expire
|
||||
- **Full history tracking**: Complete audit trail of all interactions
|
||||
- **Statistics**: Track streaks, completion rates, and progress
|
||||
- **Self-hosted first**: Easy deployment with SQLite
|
||||
- **Security**: JWT authentication, rate limiting on auth endpoints
|
||||
- **API Documentation**: Interactive Swagger UI
|
||||
- Multiple habit types: Boolean, Counter, Value
|
||||
- Flexible scheduling: Daily, Weekly, Monthly
|
||||
- Statistics: Streaks and completions tracking
|
||||
- JWT authentication, rate limiting, optional email verification
|
||||
- Registration modes: Open or closed
|
||||
- SQLite database (single file)
|
||||
- Swagger UI at `/api/v1/docs`
|
||||
|
||||
## Quick Start
|
||||
|
||||
@@ -27,11 +40,21 @@ services:
|
||||
- "8080:8080"
|
||||
environment:
|
||||
- DB_PATH=/data/apocapoc.db
|
||||
- PORT=8080
|
||||
- APP_URL=http://localhost:8080
|
||||
- JWT_SECRET=YOUR_SECRET_HERE
|
||||
- JWT_EXPIRY=24h
|
||||
- REFRESH_TOKEN_EXPIRY=168h
|
||||
- CORS_ORIGINS=http://localhost:3000
|
||||
- JWT_EXPIRY=1h
|
||||
- REFRESH_TOKEN_EXPIRY=7d
|
||||
- DEFAULT_TIMEZONE=UTC
|
||||
- REGISTRATION_MODE=open
|
||||
# Email configuration (optional)
|
||||
# - SMTP_HOST=smtp.example.com
|
||||
# - SMTP_PORT=587
|
||||
# - SMTP_USER=your-email@example.com
|
||||
# - SMTP_PASSWORD=your-password
|
||||
# - SMTP_FROM=noreply@example.com
|
||||
# - SUPPORT_EMAIL=contact@apocapoc.app
|
||||
# - SEND_WELCOME_EMAIL=false
|
||||
volumes:
|
||||
- habit-data:/data
|
||||
restart: unless-stopped
|
||||
@@ -48,65 +71,79 @@ volumes:
|
||||
docker-compose up -d
|
||||
```
|
||||
|
||||
The API will be available at `http://localhost:8080`
|
||||
API available at `http://localhost:8080`
|
||||
|
||||
**Available image tags:**
|
||||
- `latest`: Latest stable release (recommended for production)
|
||||
- `1`, `1.0`, `1.0.0`: Specific version tags
|
||||
- `edge`: Latest development build from main branch (unstable)
|
||||
- `sha-abc123`: Specific commit (for debugging)
|
||||
**Image tags:**
|
||||
- `latest`: Stable release (recommended)
|
||||
- `1`, `1.0`, `1.0.0`: Specific versions
|
||||
- `edge`: Development build (unstable)
|
||||
|
||||
**Configuration options:**
|
||||
- `JWT_SECRET`: **Required**. Use a long random string
|
||||
- `JWT_EXPIRY`: Token expiration (e.g., `24h`, `48h`)
|
||||
- `REFRESH_TOKEN_EXPIRY`: Refresh token expiration (e.g., `168h` = 7 days)
|
||||
- `CORS_ORIGINS`: Comma-separated list of allowed origins
|
||||
- `DEFAULT_TIMEZONE`: Timezone for date calculations (e.g., `UTC`, `Europe/Madrid`)
|
||||
**Configuration:**
|
||||
|
||||
*Required:*
|
||||
- `JWT_SECRET`: Long random string (required)
|
||||
- `DB_PATH`: Database path (default: `./data/apocapoc.db`)
|
||||
|
||||
*Application:*
|
||||
- `PORT`: HTTP port (default: `8080`)
|
||||
- `APP_URL`: Public URL for email links
|
||||
- `DEFAULT_TIMEZONE`: e.g., `UTC`, `Europe/Madrid`
|
||||
|
||||
*Authentication:*
|
||||
- `JWT_EXPIRY`: e.g., `1h`, `24h`
|
||||
- `REFRESH_TOKEN_EXPIRY`: e.g., `7d`, `168h`
|
||||
- `REGISTRATION_MODE`: `open` or `closed`
|
||||
|
||||
*Email (optional):*
|
||||
- `SMTP_HOST`, `SMTP_PORT`, `SMTP_USER`, `SMTP_PASSWORD`, `SMTP_FROM`
|
||||
- `SUPPORT_EMAIL`: Default `contact@apocapoc.app`
|
||||
- `SEND_WELCOME_EMAIL`: `true`/`false`
|
||||
|
||||
Without SMTP config, users are auto-verified.
|
||||
|
||||
### Using the binary
|
||||
|
||||
1. Download the latest release from [GitHub Releases](https://github.com/davidfolch/apocapoc-api/releases)
|
||||
2. Extract the archive:
|
||||
```bash
|
||||
tar -xzf apocapoc-api_*_linux_amd64.tar.gz
|
||||
```
|
||||
3. Copy `.env.example` to `.env` and configure
|
||||
4. Run the binary:
|
||||
```bash
|
||||
./apocapoc-api
|
||||
```
|
||||
1. Download from [GitHub Releases](https://github.com/davidfolch/apocapoc-api/releases)
|
||||
2. Extract: `tar -xzf apocapoc-api_*_linux_amd64.tar.gz`
|
||||
3. Configure: `cp .env.example .env` (edit as needed)
|
||||
4. Run: `./apocapoc-api`
|
||||
|
||||
The API will be available at `http://localhost:8080`
|
||||
API available at `http://localhost:8080`
|
||||
|
||||
**Note:** Linux binaries only (amd64 and arm64). For other platforms, use Docker.
|
||||
*Note: Linux only (amd64/arm64). Use Docker for other platforms.*
|
||||
|
||||
## Development
|
||||
|
||||
### Prerequisites
|
||||
|
||||
- Go 1.23+
|
||||
- SQLite
|
||||
- Docker (optional)
|
||||
|
||||
### Running locally with Docker (Recommended)
|
||||
**Prerequisites:** Go 1.23+, SQLite, Docker (optional)
|
||||
|
||||
**With Docker:**
|
||||
```bash
|
||||
cp docker-compose.example.yml docker-compose.yml
|
||||
docker-compose up --build
|
||||
```
|
||||
|
||||
The API will be available at `http://localhost:8080`
|
||||
|
||||
### Running locally with Go
|
||||
|
||||
**With Go:**
|
||||
```bash
|
||||
cp .env.example .env
|
||||
go run cmd/api/main.go
|
||||
```
|
||||
|
||||
API runs on `http://localhost:8080`
|
||||
|
||||
## Use Cases
|
||||
|
||||
- Build your own web or mobile frontend
|
||||
- Create custom CLI tools or scripts
|
||||
- Keep your habit data on your own infrastructure
|
||||
- Experiment with a small REST API in Go
|
||||
|
||||
## API Documentation
|
||||
|
||||
Once running, visit `http://localhost:8080/api/v1/docs` for interactive Swagger documentation.
|
||||
**Live:** [apocapoc.app/api/v1/docs](https://apocapoc.app/api/v1/docs)
|
||||
|
||||
**Local:** `http://localhost:8080/api/v1/docs`
|
||||
|
||||
Includes endpoint reference, schemas, authentication examples, and live testing.
|
||||
|
||||
## Architecture
|
||||
|
||||
@@ -122,6 +159,10 @@ This project follows hexagonal (ports & adapters) architecture:
|
||||
- 📧 Email: contact@apocapoc.app
|
||||
- 🐛 Issues: [GitHub Issues](https://github.com/davidfolch/apocapoc-api/issues)
|
||||
|
||||
## Keywords
|
||||
|
||||
`habit-tracker` `habit-tracking` `rest-api` `self-hosted` `golang` `api` `habits` `productivity` `docker` `sqlite` `hexagonal-architecture` `clean-architecture` `habit-tracker-api` `self-hosted-api` `personal-analytics` `privacy` `open-source`
|
||||
|
||||
## License
|
||||
|
||||
MIT
|
||||
|
||||
+106
-14
@@ -1,19 +1,28 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/signal"
|
||||
"strconv"
|
||||
"strings"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"apocapoc-api/internal/application/commands"
|
||||
"apocapoc-api/internal/application/queries"
|
||||
"apocapoc-api/internal/domain/services"
|
||||
"apocapoc-api/internal/i18n"
|
||||
"apocapoc-api/internal/infrastructure/auth"
|
||||
"apocapoc-api/internal/infrastructure/backup"
|
||||
"apocapoc-api/internal/infrastructure/config"
|
||||
"apocapoc-api/internal/infrastructure/crypto"
|
||||
"apocapoc-api/internal/infrastructure/email"
|
||||
httpInfra "apocapoc-api/internal/infrastructure/http"
|
||||
"apocapoc-api/internal/infrastructure/logger"
|
||||
"apocapoc-api/internal/infrastructure/persistence/sqlite"
|
||||
)
|
||||
|
||||
@@ -41,59 +50,142 @@ func main() {
|
||||
log.Fatalf("Failed to load config: %v", err)
|
||||
}
|
||||
|
||||
logger.Init(logger.Config{
|
||||
Level: cfg.LogLevel,
|
||||
Environment: cfg.Environment,
|
||||
})
|
||||
|
||||
db, err := sqlite.NewDatabase(cfg.DBPath)
|
||||
if err != nil {
|
||||
log.Fatalf("Failed to connect to database: %v", err)
|
||||
logger.Fatal().Err(err).Msg("Failed to connect to database")
|
||||
}
|
||||
defer db.Close()
|
||||
|
||||
backupInterval, err := parseDuration(cfg.BackupInterval)
|
||||
if err != nil {
|
||||
logger.Fatal().Err(err).Msg("Invalid BACKUP_INTERVAL")
|
||||
}
|
||||
|
||||
backupRetentionDays, err := strconv.Atoi(cfg.BackupRetentionDays)
|
||||
if err != nil {
|
||||
logger.Fatal().Err(err).Msg("Invalid BACKUP_RETENTION_DAYS")
|
||||
}
|
||||
|
||||
backupScheduler := backup.NewScheduler(db.Conn(), backup.Config{
|
||||
Enabled: cfg.BackupEnabled == "true",
|
||||
Interval: backupInterval,
|
||||
RetentionDays: backupRetentionDays,
|
||||
Path: cfg.BackupPath,
|
||||
Compress: cfg.BackupCompress == "true",
|
||||
DatabasePath: cfg.DBPath,
|
||||
})
|
||||
backupScheduler.Start()
|
||||
defer backupScheduler.Stop()
|
||||
|
||||
jwtExpiryHours, err := parseJWTExpiry(cfg.JWTExpiry)
|
||||
if err != nil {
|
||||
log.Fatalf("Invalid JWT_EXPIRY: %v", err)
|
||||
logger.Fatal().Err(err).Msg("Invalid JWT_EXPIRY")
|
||||
}
|
||||
|
||||
refreshTokenExpiry, err := parseDuration(cfg.RefreshTokenExpiry)
|
||||
if err != nil {
|
||||
log.Fatalf("Invalid REFRESH_TOKEN_EXPIRY: %v", err)
|
||||
logger.Fatal().Err(err).Msg("Invalid REFRESH_TOKEN_EXPIRY")
|
||||
}
|
||||
|
||||
jwtService := auth.NewJWTService(cfg.JWTSecret, jwtExpiryHours)
|
||||
passwordHasher := crypto.NewBcryptHasher()
|
||||
|
||||
var emailService services.EmailService = &services.NoOpEmailService{}
|
||||
if cfg.SMTPHost != "" {
|
||||
smtpPort, err := strconv.Atoi(cfg.SMTPPort)
|
||||
if err != nil {
|
||||
logger.Fatal().Err(err).Msg("Invalid SMTP_PORT")
|
||||
}
|
||||
|
||||
emailService = email.NewSMTPService(email.SMTPConfig{
|
||||
Host: cfg.SMTPHost,
|
||||
Port: smtpPort,
|
||||
Username: cfg.SMTPUser,
|
||||
Password: cfg.SMTPPassword,
|
||||
From: cfg.SMTPFrom,
|
||||
SupportEmail: cfg.SupportEmail,
|
||||
})
|
||||
}
|
||||
|
||||
sendWelcomeEmail := cfg.SendWelcomeEmail == "true"
|
||||
|
||||
userRepo := sqlite.NewUserRepository(db.Conn())
|
||||
habitRepo := sqlite.NewHabitRepository(db.Conn())
|
||||
entryRepo := sqlite.NewHabitEntryRepository(db.Conn())
|
||||
refreshTokenRepo := sqlite.NewRefreshTokenRepository(db.Conn())
|
||||
passwordResetTokenRepo := sqlite.NewPasswordResetTokenRepository(db.Conn())
|
||||
|
||||
registerHandler := commands.NewRegisterUserHandler(userRepo, passwordHasher)
|
||||
translator, err := i18n.NewTranslator()
|
||||
if err != nil {
|
||||
logger.Fatal().Err(err).Msg("Failed to create translator")
|
||||
}
|
||||
|
||||
registerHandler := commands.NewRegisterUserHandler(userRepo, passwordHasher, emailService, cfg.AppURL, cfg.RegistrationMode, sendWelcomeEmail)
|
||||
loginHandler := queries.NewLoginUserHandler(userRepo, passwordHasher)
|
||||
refreshTokenHandler := queries.NewRefreshTokenHandler(refreshTokenRepo, userRepo)
|
||||
revokeTokenHandler := commands.NewRevokeTokenHandler(refreshTokenRepo)
|
||||
revokeAllTokensHandler := commands.NewRevokeAllTokensHandler(refreshTokenRepo)
|
||||
verifyEmailHandler := commands.NewVerifyEmailHandler(userRepo, emailService, sendWelcomeEmail)
|
||||
resendVerificationEmailHandler := commands.NewResendVerificationEmailHandler(userRepo, emailService, cfg.AppURL)
|
||||
requestPasswordResetHandler := commands.NewRequestPasswordResetHandler(userRepo, passwordResetTokenRepo, emailService, cfg.AppURL)
|
||||
resetPasswordHandler := commands.NewResetPasswordHandler(userRepo, passwordResetTokenRepo, passwordHasher)
|
||||
deleteUserHandler := commands.NewDeleteUserHandler(userRepo)
|
||||
createHandler := commands.NewCreateHabitHandler(habitRepo)
|
||||
getTodaysHandler := queries.NewGetTodaysHabitsHandler(habitRepo, entryRepo)
|
||||
getUserHabitsHandler := queries.NewGetUserHabitsHandler(habitRepo)
|
||||
getHabitByIDHandler := queries.NewGetHabitByIDHandler(habitRepo)
|
||||
getHabitEntriesHandler := queries.NewGetHabitEntriesHandler(habitRepo, entryRepo)
|
||||
getHabitStatsHandler := queries.NewGetHabitStatsHandler(habitRepo, entryRepo)
|
||||
exportUserDataHandler := queries.NewExportUserDataHandler(habitRepo, entryRepo)
|
||||
updateHandler := commands.NewUpdateHabitHandler(habitRepo)
|
||||
archiveHandler := commands.NewArchiveHabitHandler(habitRepo)
|
||||
markHandler := commands.NewMarkHabitHandler(entryRepo, habitRepo)
|
||||
unmarkHandler := commands.NewUnmarkHabitHandler(habitRepo, entryRepo)
|
||||
getSyncChangesHandler := queries.NewGetSyncChangesHandler(habitRepo, entryRepo)
|
||||
applySyncBatchHandler := commands.NewApplySyncBatchHandler(habitRepo, entryRepo)
|
||||
|
||||
authHandlers := httpInfra.NewAuthHandlers(registerHandler, loginHandler, refreshTokenHandler, revokeTokenHandler, revokeAllTokensHandler, jwtService, refreshTokenRepo, refreshTokenExpiry)
|
||||
habitHandlers := httpInfra.NewHabitHandlers(createHandler, getTodaysHandler, getUserHabitsHandler, getHabitByIDHandler, getHabitEntriesHandler, updateHandler, archiveHandler, markHandler, unmarkHandler)
|
||||
statsHandlers := httpInfra.NewStatsHandlers(getHabitStatsHandler)
|
||||
healthHandlers := httpInfra.NewHealthHandlers(db.Conn())
|
||||
authHandlers := httpInfra.NewAuthHandlers(registerHandler, loginHandler, refreshTokenHandler, revokeTokenHandler, revokeAllTokensHandler, verifyEmailHandler, resendVerificationEmailHandler, requestPasswordResetHandler, resetPasswordHandler, jwtService, refreshTokenRepo, refreshTokenExpiry, translator)
|
||||
habitHandlers := httpInfra.NewHabitHandlers(createHandler, getTodaysHandler, getUserHabitsHandler, getHabitByIDHandler, getHabitEntriesHandler, updateHandler, archiveHandler, markHandler, unmarkHandler, translator)
|
||||
statsHandlers := httpInfra.NewStatsHandlers(getHabitStatsHandler, translator)
|
||||
healthHandlers := httpInfra.NewHealthHandlers(db.Conn(), emailService)
|
||||
userHandlers := httpInfra.NewUserHandlers(deleteUserHandler, translator)
|
||||
exportHandlers := httpInfra.NewExportHandlers(exportUserDataHandler, translator)
|
||||
syncHandlers := httpInfra.NewSyncHandlers(getSyncChangesHandler, applySyncBatchHandler, translator)
|
||||
|
||||
router := httpInfra.NewRouter(cfg.CORSOrigins, habitHandlers, authHandlers, statsHandlers, healthHandlers, jwtService)
|
||||
router := httpInfra.NewRouter(cfg.AppURL, habitHandlers, authHandlers, statsHandlers, healthHandlers, userHandlers, exportHandlers, syncHandlers, jwtService, translator)
|
||||
|
||||
addr := fmt.Sprintf("%s:%s", cfg.Host, cfg.Port)
|
||||
log.Printf("Server starting on %s", addr)
|
||||
|
||||
if err := http.ListenAndServe(addr, router); err != nil {
|
||||
log.Fatalf("Server failed: %v", err)
|
||||
addr := fmt.Sprintf("0.0.0.0:%s", cfg.Port)
|
||||
server := &http.Server{
|
||||
Addr: addr,
|
||||
Handler: router,
|
||||
}
|
||||
|
||||
shutdown := make(chan os.Signal, 1)
|
||||
signal.Notify(shutdown, syscall.SIGINT, syscall.SIGTERM)
|
||||
|
||||
go func() {
|
||||
logger.Info().Str("address", addr).Msg("Server starting")
|
||||
if err := server.ListenAndServe(); err != nil && err != http.ErrServerClosed {
|
||||
logger.Fatal().Err(err).Msg("Server failed")
|
||||
}
|
||||
}()
|
||||
|
||||
<-shutdown
|
||||
logger.Info().Msg("Shutting down gracefully...")
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
defer cancel()
|
||||
|
||||
if err := server.Shutdown(ctx); err != nil {
|
||||
logger.Fatal().Err(err).Msg("Server forced to shutdown")
|
||||
}
|
||||
|
||||
logger.Info().Msg("Server stopped")
|
||||
}
|
||||
|
||||
func parseJWTExpiry(expiry string) (int, error) {
|
||||
|
||||
-241
@@ -1,241 +0,0 @@
|
||||
mode: set
|
||||
apocapoc-api/internal/application/commands/archive_habit.go:19.90,23.2 1 1
|
||||
apocapoc-api/internal/application/commands/archive_habit.go:25.90,27.16 2 1
|
||||
apocapoc-api/internal/application/commands/archive_habit.go:27.16,29.3 1 1
|
||||
apocapoc-api/internal/application/commands/archive_habit.go:31.2,31.32 1 1
|
||||
apocapoc-api/internal/application/commands/archive_habit.go:31.32,33.3 1 1
|
||||
apocapoc-api/internal/application/commands/archive_habit.go:35.2,37.39 2 1
|
||||
apocapoc-api/internal/application/commands/create_habit.go:29.88,31.2 1 1
|
||||
apocapoc-api/internal/application/commands/create_habit.go:33.98,34.25 1 1
|
||||
apocapoc-api/internal/application/commands/create_habit.go:34.25,36.3 1 1
|
||||
apocapoc-api/internal/application/commands/create_habit.go:38.2,38.30 1 1
|
||||
apocapoc-api/internal/application/commands/create_habit.go:38.30,40.3 1 1
|
||||
apocapoc-api/internal/application/commands/create_habit.go:42.2,42.82 1 1
|
||||
apocapoc-api/internal/application/commands/create_habit.go:42.82,44.3 1 1
|
||||
apocapoc-api/internal/application/commands/create_habit.go:46.2,46.84 1 1
|
||||
apocapoc-api/internal/application/commands/create_habit.go:46.84,48.3 1 1
|
||||
apocapoc-api/internal/application/commands/create_habit.go:50.2,56.55 6 1
|
||||
apocapoc-api/internal/application/commands/create_habit.go:56.55,58.3 1 0
|
||||
apocapoc-api/internal/application/commands/create_habit.go:60.2,60.22 1 1
|
||||
apocapoc-api/internal/application/commands/mark_habit.go:29.21,34.2 1 1
|
||||
apocapoc-api/internal/application/commands/mark_habit.go:36.84,38.16 2 1
|
||||
apocapoc-api/internal/application/commands/mark_habit.go:38.16,40.3 1 0
|
||||
apocapoc-api/internal/application/commands/mark_habit.go:42.2,42.18 1 1
|
||||
apocapoc-api/internal/application/commands/mark_habit.go:42.18,44.3 1 1
|
||||
apocapoc-api/internal/application/commands/mark_habit.go:46.2,46.23 1 1
|
||||
apocapoc-api/internal/application/commands/mark_habit.go:46.23,48.3 1 1
|
||||
apocapoc-api/internal/application/commands/mark_habit.go:50.2,50.70 1 1
|
||||
apocapoc-api/internal/application/commands/mark_habit.go:50.70,51.43 1 1
|
||||
apocapoc-api/internal/application/commands/mark_habit.go:51.43,53.4 1 1
|
||||
apocapoc-api/internal/application/commands/mark_habit.go:56.2,58.50 2 1
|
||||
apocapoc-api/internal/application/commands/mark_habit.go:58.50,64.31 4 1
|
||||
apocapoc-api/internal/application/commands/mark_habit.go:64.31,68.24 3 1
|
||||
apocapoc-api/internal/application/commands/mark_habit.go:68.24,70.5 1 1
|
||||
apocapoc-api/internal/application/commands/mark_habit.go:72.4,73.34 2 1
|
||||
apocapoc-api/internal/application/commands/mark_habit.go:73.34,75.5 1 1
|
||||
apocapoc-api/internal/application/commands/mark_habit.go:75.10,77.5 1 0
|
||||
apocapoc-api/internal/application/commands/mark_habit.go:79.4,79.20 1 1
|
||||
apocapoc-api/internal/application/commands/mark_habit.go:79.20,81.5 1 1
|
||||
apocapoc-api/internal/application/commands/mark_habit.go:83.4,87.49 4 1
|
||||
apocapoc-api/internal/application/commands/mark_habit.go:88.9,89.24 1 1
|
||||
apocapoc-api/internal/application/commands/mark_habit.go:89.24,91.18 2 1
|
||||
apocapoc-api/internal/application/commands/mark_habit.go:91.18,93.6 1 1
|
||||
apocapoc-api/internal/application/commands/mark_habit.go:94.5,94.24 1 1
|
||||
apocapoc-api/internal/application/commands/mark_habit.go:95.10,98.5 2 1
|
||||
apocapoc-api/internal/application/commands/mark_habit.go:100.8,102.3 1 1
|
||||
apocapoc-api/internal/application/commands/mark_habit.go:104.2,106.39 2 1
|
||||
apocapoc-api/internal/application/commands/register_user.go:24.128,29.2 1 1
|
||||
apocapoc-api/internal/application/commands/register_user.go:31.100,32.95 1 1
|
||||
apocapoc-api/internal/application/commands/register_user.go:32.95,34.3 1 1
|
||||
apocapoc-api/internal/application/commands/register_user.go:36.2,37.21 2 1
|
||||
apocapoc-api/internal/application/commands/register_user.go:37.21,39.3 1 1
|
||||
apocapoc-api/internal/application/commands/register_user.go:41.2,42.16 2 1
|
||||
apocapoc-api/internal/application/commands/register_user.go:42.16,44.3 1 1
|
||||
apocapoc-api/internal/application/commands/register_user.go:46.2,48.53 2 1
|
||||
apocapoc-api/internal/application/commands/register_user.go:48.53,50.3 1 1
|
||||
apocapoc-api/internal/application/commands/register_user.go:52.2,52.21 1 1
|
||||
apocapoc-api/internal/application/commands/revoke_all_tokens.go:18.110,22.2 1 0
|
||||
apocapoc-api/internal/application/commands/revoke_all_tokens.go:24.96,25.22 1 0
|
||||
apocapoc-api/internal/application/commands/revoke_all_tokens.go:25.22,27.3 1 0
|
||||
apocapoc-api/internal/application/commands/revoke_all_tokens.go:29.2,29.62 1 0
|
||||
apocapoc-api/internal/application/commands/revoke_token.go:18.102,22.2 1 0
|
||||
apocapoc-api/internal/application/commands/revoke_token.go:24.88,25.28 1 0
|
||||
apocapoc-api/internal/application/commands/revoke_token.go:25.28,27.3 1 0
|
||||
apocapoc-api/internal/application/commands/revoke_token.go:29.2,29.64 1 0
|
||||
apocapoc-api/internal/application/commands/unmark_habit.go:25.23,30.2 1 1
|
||||
apocapoc-api/internal/application/commands/unmark_habit.go:32.88,34.16 2 1
|
||||
apocapoc-api/internal/application/commands/unmark_habit.go:34.16,36.3 1 1
|
||||
apocapoc-api/internal/application/commands/unmark_habit.go:38.2,38.32 1 1
|
||||
apocapoc-api/internal/application/commands/unmark_habit.go:38.32,40.3 1 1
|
||||
apocapoc-api/internal/application/commands/unmark_habit.go:42.2,52.16 4 1
|
||||
apocapoc-api/internal/application/commands/unmark_habit.go:52.16,54.3 1 0
|
||||
apocapoc-api/internal/application/commands/unmark_habit.go:56.2,57.32 2 1
|
||||
apocapoc-api/internal/application/commands/unmark_habit.go:57.32,58.51 1 1
|
||||
apocapoc-api/internal/application/commands/unmark_habit.go:58.51,60.9 2 1
|
||||
apocapoc-api/internal/application/commands/unmark_habit.go:64.2,64.25 1 1
|
||||
apocapoc-api/internal/application/commands/unmark_habit.go:64.25,66.3 1 1
|
||||
apocapoc-api/internal/application/commands/unmark_habit.go:68.2,68.47 1 1
|
||||
apocapoc-api/internal/application/commands/update_habit.go:26.88,30.2 1 1
|
||||
apocapoc-api/internal/application/commands/update_habit.go:32.88,33.39 1 1
|
||||
apocapoc-api/internal/application/commands/update_habit.go:33.39,35.3 1 1
|
||||
apocapoc-api/internal/application/commands/update_habit.go:37.2,38.16 2 1
|
||||
apocapoc-api/internal/application/commands/update_habit.go:38.16,40.3 1 1
|
||||
apocapoc-api/internal/application/commands/update_habit.go:42.2,42.32 1 1
|
||||
apocapoc-api/internal/application/commands/update_habit.go:42.32,44.3 1 1
|
||||
apocapoc-api/internal/application/commands/update_habit.go:46.2,46.23 1 1
|
||||
apocapoc-api/internal/application/commands/update_habit.go:46.23,48.3 1 1
|
||||
apocapoc-api/internal/application/commands/update_habit.go:50.2,57.39 7 1
|
||||
apocapoc-api/internal/domain/entities/habit.go:32.10,42.2 1 1
|
||||
apocapoc-api/internal/domain/entities/habit.go:44.27,47.2 2 1
|
||||
apocapoc-api/internal/domain/entities/habit.go:49.33,51.2 1 1
|
||||
apocapoc-api/internal/domain/entities/habit_entry.go:13.89,20.2 1 1
|
||||
apocapoc-api/internal/domain/entities/refresh_token.go:14.79,22.2 1 0
|
||||
apocapoc-api/internal/domain/entities/refresh_token.go:24.40,25.25 1 0
|
||||
apocapoc-api/internal/domain/entities/refresh_token.go:25.25,27.3 1 0
|
||||
apocapoc-api/internal/domain/entities/refresh_token.go:28.2,28.40 1 0
|
||||
apocapoc-api/internal/domain/entities/refresh_token.go:31.34,34.2 2 0
|
||||
apocapoc-api/internal/domain/entities/user.go:14.58,16.20 2 1
|
||||
apocapoc-api/internal/domain/entities/user.go:16.20,18.3 1 1
|
||||
apocapoc-api/internal/domain/entities/user.go:19.2,25.3 1 1
|
||||
apocapoc-api/internal/application/queries/get_habit_by_id.go:19.90,23.2 1 1
|
||||
apocapoc-api/internal/application/queries/get_habit_by_id.go:25.103,27.16 2 1
|
||||
apocapoc-api/internal/application/queries/get_habit_by_id.go:27.16,29.3 1 1
|
||||
apocapoc-api/internal/application/queries/get_habit_by_id.go:31.2,31.34 1 1
|
||||
apocapoc-api/internal/application/queries/get_habit_by_id.go:31.34,33.3 1 1
|
||||
apocapoc-api/internal/application/queries/get_habit_by_id.go:35.2,44.8 1 1
|
||||
apocapoc-api/internal/application/queries/get_habit_entries.go:44.27,49.2 1 1
|
||||
apocapoc-api/internal/application/queries/get_habit_entries.go:51.122,53.16 2 1
|
||||
apocapoc-api/internal/application/queries/get_habit_entries.go:53.16,55.3 1 1
|
||||
apocapoc-api/internal/application/queries/get_habit_entries.go:57.2,57.34 1 1
|
||||
apocapoc-api/internal/application/queries/get_habit_entries.go:57.34,59.3 1 1
|
||||
apocapoc-api/internal/application/queries/get_habit_entries.go:61.2,62.42 2 1
|
||||
apocapoc-api/internal/application/queries/get_habit_entries.go:62.42,64.3 1 1
|
||||
apocapoc-api/internal/application/queries/get_habit_entries.go:66.2,67.42 2 1
|
||||
apocapoc-api/internal/application/queries/get_habit_entries.go:67.42,69.3 1 1
|
||||
apocapoc-api/internal/application/queries/get_habit_entries.go:69.8,69.32 1 1
|
||||
apocapoc-api/internal/application/queries/get_habit_entries.go:69.32,71.3 1 1
|
||||
apocapoc-api/internal/application/queries/get_habit_entries.go:73.2,73.44 1 1
|
||||
apocapoc-api/internal/application/queries/get_habit_entries.go:73.44,75.3 1 1
|
||||
apocapoc-api/internal/application/queries/get_habit_entries.go:77.2,79.42 2 1
|
||||
apocapoc-api/internal/application/queries/get_habit_entries.go:79.42,81.3 1 1
|
||||
apocapoc-api/internal/application/queries/get_habit_entries.go:81.8,81.30 1 1
|
||||
apocapoc-api/internal/application/queries/get_habit_entries.go:81.30,83.3 1 0
|
||||
apocapoc-api/internal/application/queries/get_habit_entries.go:83.8,83.28 1 1
|
||||
apocapoc-api/internal/application/queries/get_habit_entries.go:83.28,85.3 1 0
|
||||
apocapoc-api/internal/application/queries/get_habit_entries.go:85.8,87.3 1 1
|
||||
apocapoc-api/internal/application/queries/get_habit_entries.go:89.2,89.16 1 1
|
||||
apocapoc-api/internal/application/queries/get_habit_entries.go:89.16,91.3 1 0
|
||||
apocapoc-api/internal/application/queries/get_habit_entries.go:93.2,95.21 2 1
|
||||
apocapoc-api/internal/application/queries/get_habit_entries.go:95.21,97.17 2 1
|
||||
apocapoc-api/internal/application/queries/get_habit_entries.go:97.17,99.4 1 0
|
||||
apocapoc-api/internal/application/queries/get_habit_entries.go:100.3,101.28 2 1
|
||||
apocapoc-api/internal/application/queries/get_habit_entries.go:101.28,102.26 1 1
|
||||
apocapoc-api/internal/application/queries/get_habit_entries.go:102.26,104.5 1 1
|
||||
apocapoc-api/internal/application/queries/get_habit_entries.go:105.4,105.33 1 1
|
||||
apocapoc-api/internal/application/queries/get_habit_entries.go:106.9,108.4 1 0
|
||||
apocapoc-api/internal/application/queries/get_habit_entries.go:111.2,112.32 2 1
|
||||
apocapoc-api/internal/application/queries/get_habit_entries.go:112.32,120.3 1 1
|
||||
apocapoc-api/internal/application/queries/get_habit_entries.go:122.2,127.8 1 1
|
||||
apocapoc-api/internal/application/queries/get_habit_stats.go:36.25,41.2 1 0
|
||||
apocapoc-api/internal/application/queries/get_habit_stats.go:43.110,45.16 2 0
|
||||
apocapoc-api/internal/application/queries/get_habit_stats.go:45.16,47.3 1 0
|
||||
apocapoc-api/internal/application/queries/get_habit_stats.go:49.2,49.34 1 0
|
||||
apocapoc-api/internal/application/queries/get_habit_stats.go:49.34,51.3 1 0
|
||||
apocapoc-api/internal/application/queries/get_habit_stats.go:53.2,54.16 2 0
|
||||
apocapoc-api/internal/application/queries/get_habit_stats.go:54.16,56.3 1 0
|
||||
apocapoc-api/internal/application/queries/get_habit_stats.go:58.2,63.23 2 0
|
||||
apocapoc-api/internal/application/queries/get_habit_stats.go:63.23,65.3 1 0
|
||||
apocapoc-api/internal/application/queries/get_habit_stats.go:67.2,74.19 7 0
|
||||
apocapoc-api/internal/application/queries/get_habit_stats.go:77.65,78.23 1 0
|
||||
apocapoc-api/internal/application/queries/get_habit_stats.go:78.23,80.3 1 0
|
||||
apocapoc-api/internal/application/queries/get_habit_stats.go:82.2,83.32 2 0
|
||||
apocapoc-api/internal/application/queries/get_habit_stats.go:83.32,86.3 2 0
|
||||
apocapoc-api/internal/application/queries/get_habit_stats.go:88.2,91.6 3 0
|
||||
apocapoc-api/internal/application/queries/get_habit_stats.go:91.6,93.24 2 0
|
||||
apocapoc-api/internal/application/queries/get_habit_stats.go:93.24,94.9 1 0
|
||||
apocapoc-api/internal/application/queries/get_habit_stats.go:96.3,97.46 2 0
|
||||
apocapoc-api/internal/application/queries/get_habit_stats.go:100.2,100.15 1 0
|
||||
apocapoc-api/internal/application/queries/get_habit_stats.go:103.65,104.23 1 0
|
||||
apocapoc-api/internal/application/queries/get_habit_stats.go:104.23,106.3 1 0
|
||||
apocapoc-api/internal/application/queries/get_habit_stats.go:108.2,110.32 3 0
|
||||
apocapoc-api/internal/application/queries/get_habit_stats.go:110.32,113.24 3 0
|
||||
apocapoc-api/internal/application/queries/get_habit_stats.go:113.24,116.4 2 0
|
||||
apocapoc-api/internal/application/queries/get_habit_stats.go:119.2,119.21 1 0
|
||||
apocapoc-api/internal/application/queries/get_habit_stats.go:119.21,121.3 1 0
|
||||
apocapoc-api/internal/application/queries/get_habit_stats.go:123.2,126.34 3 0
|
||||
apocapoc-api/internal/application/queries/get_habit_stats.go:126.34,128.16 2 0
|
||||
apocapoc-api/internal/application/queries/get_habit_stats.go:128.16,130.37 2 0
|
||||
apocapoc-api/internal/application/queries/get_habit_stats.go:130.37,132.5 1 0
|
||||
apocapoc-api/internal/application/queries/get_habit_stats.go:133.9,135.4 1 0
|
||||
apocapoc-api/internal/application/queries/get_habit_stats.go:138.2,138.22 1 0
|
||||
apocapoc-api/internal/application/queries/get_habit_stats.go:141.91,142.23 1 0
|
||||
apocapoc-api/internal/application/queries/get_habit_stats.go:142.23,144.3 1 0
|
||||
apocapoc-api/internal/application/queries/get_habit_stats.go:146.2,147.28 2 0
|
||||
apocapoc-api/internal/application/queries/get_habit_stats.go:147.28,149.3 1 0
|
||||
apocapoc-api/internal/application/queries/get_habit_stats.go:151.2,152.16 2 0
|
||||
apocapoc-api/internal/application/queries/get_habit_stats.go:152.16,154.3 1 0
|
||||
apocapoc-api/internal/application/queries/get_habit_stats.go:156.2,156.13 1 0
|
||||
apocapoc-api/internal/application/queries/get_habit_stats.go:159.77,163.32 3 0
|
||||
apocapoc-api/internal/application/queries/get_habit_stats.go:163.32,164.40 1 0
|
||||
apocapoc-api/internal/application/queries/get_habit_stats.go:164.40,166.4 1 0
|
||||
apocapoc-api/internal/application/queries/get_habit_stats.go:169.2,169.14 1 0
|
||||
apocapoc-api/internal/application/queries/get_todays_habits.go:36.27,41.2 1 1
|
||||
apocapoc-api/internal/application/queries/get_todays_habits.go:46.29,48.16 2 1
|
||||
apocapoc-api/internal/application/queries/get_todays_habits.go:48.16,50.3 1 0
|
||||
apocapoc-api/internal/application/queries/get_todays_habits.go:52.2,54.31 2 1
|
||||
apocapoc-api/internal/application/queries/get_todays_habits.go:54.31,62.40 2 1
|
||||
apocapoc-api/internal/application/queries/get_todays_habits.go:62.40,63.12 1 1
|
||||
apocapoc-api/internal/application/queries/get_todays_habits.go:66.3,74.33 3 1
|
||||
apocapoc-api/internal/application/queries/get_todays_habits.go:74.33,75.45 1 1
|
||||
apocapoc-api/internal/application/queries/get_todays_habits.go:75.45,77.10 2 1
|
||||
apocapoc-api/internal/application/queries/get_todays_habits.go:81.3,81.19 1 1
|
||||
apocapoc-api/internal/application/queries/get_todays_habits.go:81.19,91.4 1 1
|
||||
apocapoc-api/internal/application/queries/get_todays_habits.go:94.2,94.20 1 1
|
||||
apocapoc-api/internal/application/queries/get_user_habits.go:29.92,33.2 1 1
|
||||
apocapoc-api/internal/application/queries/get_user_habits.go:35.106,37.16 2 1
|
||||
apocapoc-api/internal/application/queries/get_user_habits.go:37.16,39.3 1 0
|
||||
apocapoc-api/internal/application/queries/get_user_habits.go:41.2,42.31 2 1
|
||||
apocapoc-api/internal/application/queries/get_user_habits.go:42.31,53.3 1 1
|
||||
apocapoc-api/internal/application/queries/get_user_habits.go:55.2,55.20 1 1
|
||||
apocapoc-api/internal/application/queries/login_user.go:27.122,32.2 1 0
|
||||
apocapoc-api/internal/application/queries/login_user.go:34.104,35.47 1 0
|
||||
apocapoc-api/internal/application/queries/login_user.go:35.47,37.3 1 0
|
||||
apocapoc-api/internal/application/queries/login_user.go:39.2,40.16 2 0
|
||||
apocapoc-api/internal/application/queries/login_user.go:40.16,42.3 1 0
|
||||
apocapoc-api/internal/application/queries/login_user.go:44.2,44.84 1 0
|
||||
apocapoc-api/internal/application/queries/login_user.go:44.84,46.3 1 0
|
||||
apocapoc-api/internal/application/queries/login_user.go:48.2,52.8 1 0
|
||||
apocapoc-api/internal/application/queries/refresh_token.go:33.24,38.2 1 1
|
||||
apocapoc-api/internal/application/queries/refresh_token.go:40.113,41.30 1 1
|
||||
apocapoc-api/internal/application/queries/refresh_token.go:41.30,43.3 1 1
|
||||
apocapoc-api/internal/application/queries/refresh_token.go:45.2,46.16 2 1
|
||||
apocapoc-api/internal/application/queries/refresh_token.go:46.16,48.3 1 1
|
||||
apocapoc-api/internal/application/queries/refresh_token.go:50.2,50.29 1 1
|
||||
apocapoc-api/internal/application/queries/refresh_token.go:50.29,52.3 1 1
|
||||
apocapoc-api/internal/application/queries/refresh_token.go:54.2,55.16 2 1
|
||||
apocapoc-api/internal/application/queries/refresh_token.go:55.16,57.3 1 0
|
||||
apocapoc-api/internal/application/queries/refresh_token.go:59.2,63.8 1 1
|
||||
apocapoc-api/internal/application/queries/refresh_token.go:66.45,68.40 2 0
|
||||
apocapoc-api/internal/application/queries/refresh_token.go:68.40,70.3 1 0
|
||||
apocapoc-api/internal/application/queries/refresh_token.go:71.2,71.50 1 0
|
||||
apocapoc-api/internal/application/queries/refresh_token.go:74.102,76.16 2 0
|
||||
apocapoc-api/internal/application/queries/refresh_token.go:76.16,78.3 1 0
|
||||
apocapoc-api/internal/application/queries/refresh_token.go:80.2,81.64 2 0
|
||||
apocapoc-api/internal/domain/value_objects/frequency.go:16.35,17.11 1 1
|
||||
apocapoc-api/internal/domain/value_objects/frequency.go:18.57,19.14 1 1
|
||||
apocapoc-api/internal/domain/value_objects/frequency.go:21.2,21.14 1 1
|
||||
apocapoc-api/internal/domain/value_objects/frequency.go:24.50,26.2 1 1
|
||||
apocapoc-api/internal/domain/value_objects/frequency.go:28.54,30.49 2 1
|
||||
apocapoc-api/internal/domain/value_objects/frequency.go:30.49,32.3 1 0
|
||||
apocapoc-api/internal/domain/value_objects/frequency.go:34.2,35.18 2 1
|
||||
apocapoc-api/internal/domain/value_objects/frequency.go:35.18,37.3 1 1
|
||||
apocapoc-api/internal/domain/value_objects/frequency.go:39.2,39.12 1 1
|
||||
apocapoc-api/internal/domain/value_objects/habit_type.go:16.36,17.12 1 1
|
||||
apocapoc-api/internal/domain/value_objects/habit_type.go:18.58,19.14 1 1
|
||||
apocapoc-api/internal/domain/value_objects/habit_type.go:21.2,21.14 1 1
|
||||
apocapoc-api/internal/domain/value_objects/habit_type.go:24.51,26.2 1 1
|
||||
apocapoc-api/internal/domain/value_objects/habit_type.go:28.55,30.49 2 1
|
||||
apocapoc-api/internal/domain/value_objects/habit_type.go:30.49,32.3 1 0
|
||||
apocapoc-api/internal/domain/value_objects/habit_type.go:34.2,35.19 2 1
|
||||
apocapoc-api/internal/domain/value_objects/habit_type.go:35.19,37.3 1 1
|
||||
apocapoc-api/internal/domain/value_objects/habit_type.go:39.2,39.12 1 1
|
||||
-730
@@ -1,730 +0,0 @@
|
||||
mode: atomic
|
||||
apocapoc-api/cmd/api/main.go:38.13,40.16 2 0
|
||||
apocapoc-api/cmd/api/main.go:40.16,42.3 1 0
|
||||
apocapoc-api/cmd/api/main.go:44.2,45.16 2 0
|
||||
apocapoc-api/cmd/api/main.go:45.16,47.3 1 0
|
||||
apocapoc-api/cmd/api/main.go:48.2,51.16 3 0
|
||||
apocapoc-api/cmd/api/main.go:51.16,53.3 1 0
|
||||
apocapoc-api/cmd/api/main.go:55.2,56.16 2 0
|
||||
apocapoc-api/cmd/api/main.go:56.16,58.3 1 0
|
||||
apocapoc-api/cmd/api/main.go:60.2,94.58 29 0
|
||||
apocapoc-api/cmd/api/main.go:94.58,96.3 1 0
|
||||
apocapoc-api/cmd/api/main.go:99.49,101.36 2 0
|
||||
apocapoc-api/cmd/api/main.go:101.36,104.3 2 0
|
||||
apocapoc-api/cmd/api/main.go:105.2,105.68 1 0
|
||||
apocapoc-api/cmd/api/main.go:108.60,110.38 2 0
|
||||
apocapoc-api/cmd/api/main.go:110.38,113.17 3 0
|
||||
apocapoc-api/cmd/api/main.go:113.17,115.4 1 0
|
||||
apocapoc-api/cmd/api/main.go:116.3,116.48 1 0
|
||||
apocapoc-api/cmd/api/main.go:118.2,118.38 1 0
|
||||
apocapoc-api/cmd/api/main.go:118.38,121.17 3 0
|
||||
apocapoc-api/cmd/api/main.go:121.17,123.4 1 0
|
||||
apocapoc-api/cmd/api/main.go:124.3,124.45 1 0
|
||||
apocapoc-api/cmd/api/main.go:126.2,126.38 1 0
|
||||
apocapoc-api/cmd/api/main.go:126.38,129.17 3 0
|
||||
apocapoc-api/cmd/api/main.go:129.17,131.4 1 0
|
||||
apocapoc-api/cmd/api/main.go:132.3,132.50 1 0
|
||||
apocapoc-api/cmd/api/main.go:134.2,134.84 1 0
|
||||
apocapoc-api/docs/docs.go:1223.13,1225.2 1 0
|
||||
apocapoc-api/internal/application/queries/get_habit_by_id.go:19.90,23.2 1 3
|
||||
apocapoc-api/internal/application/queries/get_habit_by_id.go:25.103,27.16 2 3
|
||||
apocapoc-api/internal/application/queries/get_habit_by_id.go:27.16,29.3 1 1
|
||||
apocapoc-api/internal/application/queries/get_habit_by_id.go:31.2,31.34 1 2
|
||||
apocapoc-api/internal/application/queries/get_habit_by_id.go:31.34,33.3 1 1
|
||||
apocapoc-api/internal/application/queries/get_habit_by_id.go:35.2,44.8 1 1
|
||||
apocapoc-api/internal/application/queries/get_habit_entries.go:44.27,49.2 1 7
|
||||
apocapoc-api/internal/application/queries/get_habit_entries.go:51.122,53.16 2 7
|
||||
apocapoc-api/internal/application/queries/get_habit_entries.go:53.16,55.3 1 1
|
||||
apocapoc-api/internal/application/queries/get_habit_entries.go:57.2,57.34 1 6
|
||||
apocapoc-api/internal/application/queries/get_habit_entries.go:57.34,59.3 1 1
|
||||
apocapoc-api/internal/application/queries/get_habit_entries.go:61.2,62.42 2 5
|
||||
apocapoc-api/internal/application/queries/get_habit_entries.go:62.42,64.3 1 2
|
||||
apocapoc-api/internal/application/queries/get_habit_entries.go:66.2,67.42 2 5
|
||||
apocapoc-api/internal/application/queries/get_habit_entries.go:67.42,69.3 1 3
|
||||
apocapoc-api/internal/application/queries/get_habit_entries.go:69.8,69.32 1 2
|
||||
apocapoc-api/internal/application/queries/get_habit_entries.go:69.32,71.3 1 1
|
||||
apocapoc-api/internal/application/queries/get_habit_entries.go:73.2,73.44 1 5
|
||||
apocapoc-api/internal/application/queries/get_habit_entries.go:73.44,75.3 1 2
|
||||
apocapoc-api/internal/application/queries/get_habit_entries.go:77.2,79.42 2 3
|
||||
apocapoc-api/internal/application/queries/get_habit_entries.go:79.42,81.3 1 1
|
||||
apocapoc-api/internal/application/queries/get_habit_entries.go:81.8,81.30 1 2
|
||||
apocapoc-api/internal/application/queries/get_habit_entries.go:81.30,83.3 1 0
|
||||
apocapoc-api/internal/application/queries/get_habit_entries.go:83.8,83.28 1 2
|
||||
apocapoc-api/internal/application/queries/get_habit_entries.go:83.28,85.3 1 0
|
||||
apocapoc-api/internal/application/queries/get_habit_entries.go:85.8,87.3 1 2
|
||||
apocapoc-api/internal/application/queries/get_habit_entries.go:89.2,89.16 1 3
|
||||
apocapoc-api/internal/application/queries/get_habit_entries.go:89.16,91.3 1 0
|
||||
apocapoc-api/internal/application/queries/get_habit_entries.go:93.2,95.21 2 3
|
||||
apocapoc-api/internal/application/queries/get_habit_entries.go:95.21,97.17 2 2
|
||||
apocapoc-api/internal/application/queries/get_habit_entries.go:97.17,99.4 1 0
|
||||
apocapoc-api/internal/application/queries/get_habit_entries.go:100.3,101.28 2 2
|
||||
apocapoc-api/internal/application/queries/get_habit_entries.go:101.28,102.26 1 2
|
||||
apocapoc-api/internal/application/queries/get_habit_entries.go:102.26,104.5 1 1
|
||||
apocapoc-api/internal/application/queries/get_habit_entries.go:105.4,105.33 1 2
|
||||
apocapoc-api/internal/application/queries/get_habit_entries.go:106.9,108.4 1 0
|
||||
apocapoc-api/internal/application/queries/get_habit_entries.go:111.2,112.32 2 3
|
||||
apocapoc-api/internal/application/queries/get_habit_entries.go:112.32,120.3 1 6
|
||||
apocapoc-api/internal/application/queries/get_habit_entries.go:122.2,127.8 1 3
|
||||
apocapoc-api/internal/application/queries/get_habit_stats.go:36.25,41.2 1 0
|
||||
apocapoc-api/internal/application/queries/get_habit_stats.go:43.110,45.16 2 0
|
||||
apocapoc-api/internal/application/queries/get_habit_stats.go:45.16,47.3 1 0
|
||||
apocapoc-api/internal/application/queries/get_habit_stats.go:49.2,49.34 1 0
|
||||
apocapoc-api/internal/application/queries/get_habit_stats.go:49.34,51.3 1 0
|
||||
apocapoc-api/internal/application/queries/get_habit_stats.go:53.2,54.16 2 0
|
||||
apocapoc-api/internal/application/queries/get_habit_stats.go:54.16,56.3 1 0
|
||||
apocapoc-api/internal/application/queries/get_habit_stats.go:58.2,63.23 2 0
|
||||
apocapoc-api/internal/application/queries/get_habit_stats.go:63.23,65.3 1 0
|
||||
apocapoc-api/internal/application/queries/get_habit_stats.go:67.2,74.19 7 0
|
||||
apocapoc-api/internal/application/queries/get_habit_stats.go:77.65,78.23 1 0
|
||||
apocapoc-api/internal/application/queries/get_habit_stats.go:78.23,80.3 1 0
|
||||
apocapoc-api/internal/application/queries/get_habit_stats.go:82.2,83.32 2 0
|
||||
apocapoc-api/internal/application/queries/get_habit_stats.go:83.32,86.3 2 0
|
||||
apocapoc-api/internal/application/queries/get_habit_stats.go:88.2,91.6 3 0
|
||||
apocapoc-api/internal/application/queries/get_habit_stats.go:91.6,93.24 2 0
|
||||
apocapoc-api/internal/application/queries/get_habit_stats.go:93.24,94.9 1 0
|
||||
apocapoc-api/internal/application/queries/get_habit_stats.go:96.3,97.46 2 0
|
||||
apocapoc-api/internal/application/queries/get_habit_stats.go:100.2,100.15 1 0
|
||||
apocapoc-api/internal/application/queries/get_habit_stats.go:103.65,104.23 1 0
|
||||
apocapoc-api/internal/application/queries/get_habit_stats.go:104.23,106.3 1 0
|
||||
apocapoc-api/internal/application/queries/get_habit_stats.go:108.2,110.32 3 0
|
||||
apocapoc-api/internal/application/queries/get_habit_stats.go:110.32,113.24 3 0
|
||||
apocapoc-api/internal/application/queries/get_habit_stats.go:113.24,116.4 2 0
|
||||
apocapoc-api/internal/application/queries/get_habit_stats.go:119.2,119.21 1 0
|
||||
apocapoc-api/internal/application/queries/get_habit_stats.go:119.21,121.3 1 0
|
||||
apocapoc-api/internal/application/queries/get_habit_stats.go:123.2,126.34 3 0
|
||||
apocapoc-api/internal/application/queries/get_habit_stats.go:126.34,128.16 2 0
|
||||
apocapoc-api/internal/application/queries/get_habit_stats.go:128.16,130.37 2 0
|
||||
apocapoc-api/internal/application/queries/get_habit_stats.go:130.37,132.5 1 0
|
||||
apocapoc-api/internal/application/queries/get_habit_stats.go:133.9,135.4 1 0
|
||||
apocapoc-api/internal/application/queries/get_habit_stats.go:138.2,138.22 1 0
|
||||
apocapoc-api/internal/application/queries/get_habit_stats.go:141.91,142.23 1 0
|
||||
apocapoc-api/internal/application/queries/get_habit_stats.go:142.23,144.3 1 0
|
||||
apocapoc-api/internal/application/queries/get_habit_stats.go:146.2,147.28 2 0
|
||||
apocapoc-api/internal/application/queries/get_habit_stats.go:147.28,149.3 1 0
|
||||
apocapoc-api/internal/application/queries/get_habit_stats.go:151.2,152.16 2 0
|
||||
apocapoc-api/internal/application/queries/get_habit_stats.go:152.16,154.3 1 0
|
||||
apocapoc-api/internal/application/queries/get_habit_stats.go:156.2,156.13 1 0
|
||||
apocapoc-api/internal/application/queries/get_habit_stats.go:159.77,163.32 3 0
|
||||
apocapoc-api/internal/application/queries/get_habit_stats.go:163.32,164.40 1 0
|
||||
apocapoc-api/internal/application/queries/get_habit_stats.go:164.40,166.4 1 0
|
||||
apocapoc-api/internal/application/queries/get_habit_stats.go:169.2,169.14 1 0
|
||||
apocapoc-api/internal/application/queries/get_todays_habits.go:36.27,41.2 1 6
|
||||
apocapoc-api/internal/application/queries/get_todays_habits.go:46.29,48.16 2 6
|
||||
apocapoc-api/internal/application/queries/get_todays_habits.go:48.16,50.3 1 0
|
||||
apocapoc-api/internal/application/queries/get_todays_habits.go:52.2,54.31 2 6
|
||||
apocapoc-api/internal/application/queries/get_todays_habits.go:54.31,62.40 2 6
|
||||
apocapoc-api/internal/application/queries/get_todays_habits.go:62.40,63.12 1 2
|
||||
apocapoc-api/internal/application/queries/get_todays_habits.go:66.3,74.33 3 4
|
||||
apocapoc-api/internal/application/queries/get_todays_habits.go:74.33,75.45 1 1
|
||||
apocapoc-api/internal/application/queries/get_todays_habits.go:75.45,77.10 2 1
|
||||
apocapoc-api/internal/application/queries/get_todays_habits.go:81.3,81.19 1 4
|
||||
apocapoc-api/internal/application/queries/get_todays_habits.go:81.19,91.4 1 3
|
||||
apocapoc-api/internal/application/queries/get_todays_habits.go:94.2,94.20 1 6
|
||||
apocapoc-api/internal/application/queries/get_user_habits.go:29.92,33.2 1 3
|
||||
apocapoc-api/internal/application/queries/get_user_habits.go:35.106,37.16 2 3
|
||||
apocapoc-api/internal/application/queries/get_user_habits.go:37.16,39.3 1 0
|
||||
apocapoc-api/internal/application/queries/get_user_habits.go:41.2,42.31 2 3
|
||||
apocapoc-api/internal/application/queries/get_user_habits.go:42.31,53.3 1 3
|
||||
apocapoc-api/internal/application/queries/get_user_habits.go:55.2,55.20 1 3
|
||||
apocapoc-api/internal/application/queries/login_user.go:27.122,32.2 1 0
|
||||
apocapoc-api/internal/application/queries/login_user.go:34.104,35.47 1 0
|
||||
apocapoc-api/internal/application/queries/login_user.go:35.47,37.3 1 0
|
||||
apocapoc-api/internal/application/queries/login_user.go:39.2,40.16 2 0
|
||||
apocapoc-api/internal/application/queries/login_user.go:40.16,42.3 1 0
|
||||
apocapoc-api/internal/application/queries/login_user.go:44.2,44.84 1 0
|
||||
apocapoc-api/internal/application/queries/login_user.go:44.84,46.3 1 0
|
||||
apocapoc-api/internal/application/queries/login_user.go:48.2,52.8 1 0
|
||||
apocapoc-api/internal/application/queries/refresh_token.go:33.24,38.2 1 5
|
||||
apocapoc-api/internal/application/queries/refresh_token.go:40.113,41.30 1 5
|
||||
apocapoc-api/internal/application/queries/refresh_token.go:41.30,43.3 1 1
|
||||
apocapoc-api/internal/application/queries/refresh_token.go:45.2,46.16 2 4
|
||||
apocapoc-api/internal/application/queries/refresh_token.go:46.16,48.3 1 1
|
||||
apocapoc-api/internal/application/queries/refresh_token.go:50.2,50.29 1 3
|
||||
apocapoc-api/internal/application/queries/refresh_token.go:50.29,52.3 1 2
|
||||
apocapoc-api/internal/application/queries/refresh_token.go:54.2,55.16 2 1
|
||||
apocapoc-api/internal/application/queries/refresh_token.go:55.16,57.3 1 0
|
||||
apocapoc-api/internal/application/queries/refresh_token.go:59.2,63.8 1 1
|
||||
apocapoc-api/internal/application/queries/refresh_token.go:66.45,68.40 2 0
|
||||
apocapoc-api/internal/application/queries/refresh_token.go:68.40,70.3 1 0
|
||||
apocapoc-api/internal/application/queries/refresh_token.go:71.2,71.50 1 0
|
||||
apocapoc-api/internal/application/queries/refresh_token.go:74.102,76.16 2 0
|
||||
apocapoc-api/internal/application/queries/refresh_token.go:76.16,78.3 1 0
|
||||
apocapoc-api/internal/application/queries/refresh_token.go:80.2,81.64 2 0
|
||||
apocapoc-api/internal/application/commands/archive_habit.go:19.90,23.2 1 4
|
||||
apocapoc-api/internal/application/commands/archive_habit.go:25.90,27.16 2 4
|
||||
apocapoc-api/internal/application/commands/archive_habit.go:27.16,29.3 1 1
|
||||
apocapoc-api/internal/application/commands/archive_habit.go:31.2,31.32 1 3
|
||||
apocapoc-api/internal/application/commands/archive_habit.go:31.32,33.3 1 1
|
||||
apocapoc-api/internal/application/commands/archive_habit.go:35.2,37.39 2 2
|
||||
apocapoc-api/internal/application/commands/create_habit.go:29.88,31.2 1 8
|
||||
apocapoc-api/internal/application/commands/create_habit.go:33.98,34.25 1 8
|
||||
apocapoc-api/internal/application/commands/create_habit.go:34.25,36.3 1 1
|
||||
apocapoc-api/internal/application/commands/create_habit.go:38.2,38.30 1 7
|
||||
apocapoc-api/internal/application/commands/create_habit.go:38.30,40.3 1 1
|
||||
apocapoc-api/internal/application/commands/create_habit.go:42.2,42.82 1 6
|
||||
apocapoc-api/internal/application/commands/create_habit.go:42.82,44.3 1 1
|
||||
apocapoc-api/internal/application/commands/create_habit.go:46.2,46.84 1 5
|
||||
apocapoc-api/internal/application/commands/create_habit.go:46.84,48.3 1 1
|
||||
apocapoc-api/internal/application/commands/create_habit.go:50.2,56.55 6 4
|
||||
apocapoc-api/internal/application/commands/create_habit.go:56.55,58.3 1 0
|
||||
apocapoc-api/internal/application/commands/create_habit.go:60.2,60.22 1 4
|
||||
apocapoc-api/internal/application/commands/mark_habit.go:29.21,34.2 1 14
|
||||
apocapoc-api/internal/application/commands/mark_habit.go:36.84,38.16 2 14
|
||||
apocapoc-api/internal/application/commands/mark_habit.go:38.16,40.3 1 0
|
||||
apocapoc-api/internal/application/commands/mark_habit.go:42.2,42.18 1 14
|
||||
apocapoc-api/internal/application/commands/mark_habit.go:42.18,44.3 1 1
|
||||
apocapoc-api/internal/application/commands/mark_habit.go:46.2,46.23 1 13
|
||||
apocapoc-api/internal/application/commands/mark_habit.go:46.23,48.3 1 1
|
||||
apocapoc-api/internal/application/commands/mark_habit.go:50.2,50.70 1 12
|
||||
apocapoc-api/internal/application/commands/mark_habit.go:50.70,51.43 1 7
|
||||
apocapoc-api/internal/application/commands/mark_habit.go:51.43,53.4 1 1
|
||||
apocapoc-api/internal/application/commands/mark_habit.go:56.2,58.50 2 11
|
||||
apocapoc-api/internal/application/commands/mark_habit.go:58.50,64.31 4 8
|
||||
apocapoc-api/internal/application/commands/mark_habit.go:64.31,68.24 3 5
|
||||
apocapoc-api/internal/application/commands/mark_habit.go:68.24,70.5 1 4
|
||||
apocapoc-api/internal/application/commands/mark_habit.go:72.4,73.34 2 5
|
||||
apocapoc-api/internal/application/commands/mark_habit.go:73.34,75.5 1 5
|
||||
apocapoc-api/internal/application/commands/mark_habit.go:75.10,77.5 1 0
|
||||
apocapoc-api/internal/application/commands/mark_habit.go:79.4,79.20 1 5
|
||||
apocapoc-api/internal/application/commands/mark_habit.go:79.20,81.5 1 2
|
||||
apocapoc-api/internal/application/commands/mark_habit.go:83.4,87.49 4 5
|
||||
apocapoc-api/internal/application/commands/mark_habit.go:88.9,89.24 1 3
|
||||
apocapoc-api/internal/application/commands/mark_habit.go:89.24,91.18 2 2
|
||||
apocapoc-api/internal/application/commands/mark_habit.go:91.18,93.6 1 1
|
||||
apocapoc-api/internal/application/commands/mark_habit.go:94.5,94.24 1 2
|
||||
apocapoc-api/internal/application/commands/mark_habit.go:95.10,98.5 2 1
|
||||
apocapoc-api/internal/application/commands/mark_habit.go:100.8,102.3 1 3
|
||||
apocapoc-api/internal/application/commands/mark_habit.go:104.2,106.39 2 6
|
||||
apocapoc-api/internal/application/commands/register_user.go:24.128,29.2 1 8
|
||||
apocapoc-api/internal/application/commands/register_user.go:31.100,32.95 1 27
|
||||
apocapoc-api/internal/application/commands/register_user.go:32.95,34.3 1 18
|
||||
apocapoc-api/internal/application/commands/register_user.go:36.2,37.21 2 9
|
||||
apocapoc-api/internal/application/commands/register_user.go:37.21,39.3 1 1
|
||||
apocapoc-api/internal/application/commands/register_user.go:41.2,42.16 2 8
|
||||
apocapoc-api/internal/application/commands/register_user.go:42.16,44.3 1 1
|
||||
apocapoc-api/internal/application/commands/register_user.go:46.2,48.53 2 7
|
||||
apocapoc-api/internal/application/commands/register_user.go:48.53,50.3 1 1
|
||||
apocapoc-api/internal/application/commands/register_user.go:52.2,52.21 1 6
|
||||
apocapoc-api/internal/application/commands/revoke_all_tokens.go:18.110,22.2 1 0
|
||||
apocapoc-api/internal/application/commands/revoke_all_tokens.go:24.96,25.22 1 0
|
||||
apocapoc-api/internal/application/commands/revoke_all_tokens.go:25.22,27.3 1 0
|
||||
apocapoc-api/internal/application/commands/revoke_all_tokens.go:29.2,29.62 1 0
|
||||
apocapoc-api/internal/application/commands/revoke_token.go:18.102,22.2 1 0
|
||||
apocapoc-api/internal/application/commands/revoke_token.go:24.88,25.28 1 0
|
||||
apocapoc-api/internal/application/commands/revoke_token.go:25.28,27.3 1 0
|
||||
apocapoc-api/internal/application/commands/revoke_token.go:29.2,29.64 1 0
|
||||
apocapoc-api/internal/application/commands/unmark_habit.go:25.23,30.2 1 4
|
||||
apocapoc-api/internal/application/commands/unmark_habit.go:32.88,34.16 2 4
|
||||
apocapoc-api/internal/application/commands/unmark_habit.go:34.16,36.3 1 1
|
||||
apocapoc-api/internal/application/commands/unmark_habit.go:38.2,38.32 1 3
|
||||
apocapoc-api/internal/application/commands/unmark_habit.go:38.32,40.3 1 1
|
||||
apocapoc-api/internal/application/commands/unmark_habit.go:42.2,52.16 4 2
|
||||
apocapoc-api/internal/application/commands/unmark_habit.go:52.16,54.3 1 0
|
||||
apocapoc-api/internal/application/commands/unmark_habit.go:56.2,57.32 2 2
|
||||
apocapoc-api/internal/application/commands/unmark_habit.go:57.32,58.51 1 1
|
||||
apocapoc-api/internal/application/commands/unmark_habit.go:58.51,60.9 2 1
|
||||
apocapoc-api/internal/application/commands/unmark_habit.go:64.2,64.25 1 2
|
||||
apocapoc-api/internal/application/commands/unmark_habit.go:64.25,66.3 1 1
|
||||
apocapoc-api/internal/application/commands/unmark_habit.go:68.2,68.47 1 1
|
||||
apocapoc-api/internal/application/commands/update_habit.go:26.88,30.2 1 5
|
||||
apocapoc-api/internal/application/commands/update_habit.go:32.88,33.39 1 5
|
||||
apocapoc-api/internal/application/commands/update_habit.go:33.39,35.3 1 1
|
||||
apocapoc-api/internal/application/commands/update_habit.go:37.2,38.16 2 4
|
||||
apocapoc-api/internal/application/commands/update_habit.go:38.16,40.3 1 1
|
||||
apocapoc-api/internal/application/commands/update_habit.go:42.2,42.32 1 3
|
||||
apocapoc-api/internal/application/commands/update_habit.go:42.32,44.3 1 1
|
||||
apocapoc-api/internal/application/commands/update_habit.go:46.2,46.23 1 2
|
||||
apocapoc-api/internal/application/commands/update_habit.go:46.23,48.3 1 1
|
||||
apocapoc-api/internal/application/commands/update_habit.go:50.2,57.39 7 1
|
||||
apocapoc-api/internal/infrastructure/auth/jwt.go:21.64,26.2 1 0
|
||||
apocapoc-api/internal/infrastructure/auth/jwt.go:28.74,40.2 3 0
|
||||
apocapoc-api/internal/infrastructure/auth/jwt.go:42.73,43.104 1 0
|
||||
apocapoc-api/internal/infrastructure/auth/jwt.go:43.104,44.58 1 0
|
||||
apocapoc-api/internal/infrastructure/auth/jwt.go:44.58,46.4 1 0
|
||||
apocapoc-api/internal/infrastructure/auth/jwt.go:47.3,47.23 1 0
|
||||
apocapoc-api/internal/infrastructure/auth/jwt.go:50.2,50.16 1 0
|
||||
apocapoc-api/internal/infrastructure/auth/jwt.go:50.16,52.3 1 0
|
||||
apocapoc-api/internal/infrastructure/auth/jwt.go:54.2,54.61 1 0
|
||||
apocapoc-api/internal/infrastructure/auth/jwt.go:54.61,56.3 1 0
|
||||
apocapoc-api/internal/infrastructure/auth/jwt.go:58.2,58.41 1 0
|
||||
apocapoc-api/internal/infrastructure/config/config.go:21.30,35.22 3 0
|
||||
apocapoc-api/internal/infrastructure/config/config.go:35.22,37.3 1 0
|
||||
apocapoc-api/internal/infrastructure/config/config.go:38.2,38.25 1 0
|
||||
apocapoc-api/internal/infrastructure/config/config.go:38.25,40.3 1 0
|
||||
apocapoc-api/internal/infrastructure/config/config.go:41.2,41.25 1 0
|
||||
apocapoc-api/internal/infrastructure/config/config.go:41.25,43.3 1 0
|
||||
apocapoc-api/internal/infrastructure/config/config.go:44.2,44.34 1 0
|
||||
apocapoc-api/internal/infrastructure/config/config.go:44.34,46.3 1 0
|
||||
apocapoc-api/internal/infrastructure/config/config.go:47.2,47.27 1 0
|
||||
apocapoc-api/internal/infrastructure/config/config.go:47.27,49.3 1 0
|
||||
apocapoc-api/internal/infrastructure/config/config.go:50.2,50.31 1 0
|
||||
apocapoc-api/internal/infrastructure/config/config.go:50.31,52.3 1 0
|
||||
apocapoc-api/internal/infrastructure/config/config.go:54.2,54.17 1 0
|
||||
apocapoc-api/internal/infrastructure/config/config.go:57.55,58.42 1 0
|
||||
apocapoc-api/internal/infrastructure/config/config.go:58.42,60.3 1 0
|
||||
apocapoc-api/internal/infrastructure/config/config.go:61.2,61.21 1 0
|
||||
apocapoc-api/internal/infrastructure/crypto/bcrypt_hasher.go:11.48,13.2 1 0
|
||||
apocapoc-api/internal/infrastructure/crypto/bcrypt_hasher.go:15.62,17.16 2 0
|
||||
apocapoc-api/internal/infrastructure/crypto/bcrypt_hasher.go:17.16,19.3 1 0
|
||||
apocapoc-api/internal/infrastructure/crypto/bcrypt_hasher.go:20.2,20.33 1 0
|
||||
apocapoc-api/internal/infrastructure/crypto/bcrypt_hasher.go:23.71,25.2 1 0
|
||||
apocapoc-api/internal/domain/entities/habit.go:32.10,42.2 1 5
|
||||
apocapoc-api/internal/domain/entities/habit.go:44.27,47.2 2 2
|
||||
apocapoc-api/internal/domain/entities/habit.go:49.33,51.2 1 2
|
||||
apocapoc-api/internal/domain/entities/habit_entry.go:13.89,20.2 1 2
|
||||
apocapoc-api/internal/domain/entities/refresh_token.go:14.79,22.2 1 0
|
||||
apocapoc-api/internal/domain/entities/refresh_token.go:24.40,25.25 1 0
|
||||
apocapoc-api/internal/domain/entities/refresh_token.go:25.25,27.3 1 0
|
||||
apocapoc-api/internal/domain/entities/refresh_token.go:28.2,28.40 1 0
|
||||
apocapoc-api/internal/domain/entities/refresh_token.go:31.34,34.2 2 0
|
||||
apocapoc-api/internal/domain/entities/user.go:14.58,16.20 2 2
|
||||
apocapoc-api/internal/domain/entities/user.go:16.20,18.3 1 1
|
||||
apocapoc-api/internal/domain/entities/user.go:19.2,25.3 1 2
|
||||
apocapoc-api/internal/domain/value_objects/frequency.go:16.35,17.11 1 12
|
||||
apocapoc-api/internal/domain/value_objects/frequency.go:18.57,19.14 1 7
|
||||
apocapoc-api/internal/domain/value_objects/frequency.go:21.2,21.14 1 5
|
||||
apocapoc-api/internal/domain/value_objects/frequency.go:24.50,26.2 1 4
|
||||
apocapoc-api/internal/domain/value_objects/frequency.go:28.54,30.49 2 6
|
||||
apocapoc-api/internal/domain/value_objects/frequency.go:30.49,32.3 1 0
|
||||
apocapoc-api/internal/domain/value_objects/frequency.go:34.2,35.18 2 6
|
||||
apocapoc-api/internal/domain/value_objects/frequency.go:35.18,37.3 1 2
|
||||
apocapoc-api/internal/domain/value_objects/frequency.go:39.2,39.12 1 4
|
||||
apocapoc-api/internal/domain/value_objects/habit_type.go:16.36,17.12 1 12
|
||||
apocapoc-api/internal/domain/value_objects/habit_type.go:18.58,19.14 1 7
|
||||
apocapoc-api/internal/domain/value_objects/habit_type.go:21.2,21.14 1 5
|
||||
apocapoc-api/internal/domain/value_objects/habit_type.go:24.51,26.2 1 4
|
||||
apocapoc-api/internal/domain/value_objects/habit_type.go:28.55,30.49 2 6
|
||||
apocapoc-api/internal/domain/value_objects/habit_type.go:30.49,32.3 1 0
|
||||
apocapoc-api/internal/domain/value_objects/habit_type.go:34.2,35.19 2 6
|
||||
apocapoc-api/internal/domain/value_objects/habit_type.go:35.19,37.3 1 2
|
||||
apocapoc-api/internal/domain/value_objects/habit_type.go:39.2,39.12 1 4
|
||||
apocapoc-api/internal/shared/utils/date_utils.go:10.8,11.19 1 10
|
||||
apocapoc-api/internal/shared/utils/date_utils.go:12.15,13.14 1 1
|
||||
apocapoc-api/internal/shared/utils/date_utils.go:14.16,16.41 2 4
|
||||
apocapoc-api/internal/shared/utils/date_utils.go:17.17,19.38 2 4
|
||||
apocapoc-api/internal/shared/utils/date_utils.go:21.2,21.14 1 1
|
||||
apocapoc-api/internal/shared/utils/date_utils.go:24.42,25.29 1 8
|
||||
apocapoc-api/internal/shared/utils/date_utils.go:25.29,26.18 1 14
|
||||
apocapoc-api/internal/shared/utils/date_utils.go:26.18,28.4 1 4
|
||||
apocapoc-api/internal/shared/utils/date_utils.go:30.2,30.14 1 4
|
||||
apocapoc-api/internal/infrastructure/persistence/sqlite/database.go:16.52,18.30 2 5
|
||||
apocapoc-api/internal/infrastructure/persistence/sqlite/database.go:18.30,19.48 1 1
|
||||
apocapoc-api/internal/infrastructure/persistence/sqlite/database.go:19.48,21.4 1 0
|
||||
apocapoc-api/internal/infrastructure/persistence/sqlite/database.go:24.2,25.16 2 5
|
||||
apocapoc-api/internal/infrastructure/persistence/sqlite/database.go:25.16,27.3 1 0
|
||||
apocapoc-api/internal/infrastructure/persistence/sqlite/database.go:29.2,31.36 2 5
|
||||
apocapoc-api/internal/infrastructure/persistence/sqlite/database.go:31.36,33.3 1 0
|
||||
apocapoc-api/internal/infrastructure/persistence/sqlite/database.go:35.2,35.44 1 5
|
||||
apocapoc-api/internal/infrastructure/persistence/sqlite/database.go:35.44,37.3 1 0
|
||||
apocapoc-api/internal/infrastructure/persistence/sqlite/database.go:39.2,39.35 1 5
|
||||
apocapoc-api/internal/infrastructure/persistence/sqlite/database.go:42.35,44.2 1 5
|
||||
apocapoc-api/internal/infrastructure/persistence/sqlite/database.go:46.36,48.2 1 2
|
||||
apocapoc-api/internal/infrastructure/persistence/sqlite/habit_entry_repository.go:19.64,21.2 1 7
|
||||
apocapoc-api/internal/infrastructure/persistence/sqlite/habit_entry_repository.go:23.94,39.16 4 12
|
||||
apocapoc-api/internal/infrastructure/persistence/sqlite/habit_entry_repository.go:39.16,40.35 1 1
|
||||
apocapoc-api/internal/infrastructure/persistence/sqlite/habit_entry_repository.go:40.35,42.4 1 1
|
||||
apocapoc-api/internal/infrastructure/persistence/sqlite/habit_entry_repository.go:43.3,43.55 1 0
|
||||
apocapoc-api/internal/infrastructure/persistence/sqlite/habit_entry_repository.go:46.2,46.12 1 11
|
||||
apocapoc-api/internal/infrastructure/persistence/sqlite/habit_entry_repository.go:53.35,68.16 3 3
|
||||
apocapoc-api/internal/infrastructure/persistence/sqlite/habit_entry_repository.go:68.16,70.3 1 0
|
||||
apocapoc-api/internal/infrastructure/persistence/sqlite/habit_entry_repository.go:71.2,73.28 2 3
|
||||
apocapoc-api/internal/infrastructure/persistence/sqlite/habit_entry_repository.go:76.94,84.16 3 1
|
||||
apocapoc-api/internal/infrastructure/persistence/sqlite/habit_entry_repository.go:84.16,86.3 1 0
|
||||
apocapoc-api/internal/infrastructure/persistence/sqlite/habit_entry_repository.go:88.2,89.15 2 1
|
||||
apocapoc-api/internal/infrastructure/persistence/sqlite/habit_entry_repository.go:89.15,91.3 1 1
|
||||
apocapoc-api/internal/infrastructure/persistence/sqlite/habit_entry_repository.go:93.2,93.12 1 0
|
||||
apocapoc-api/internal/infrastructure/persistence/sqlite/habit_entry_repository.go:96.92,99.18 2 3
|
||||
apocapoc-api/internal/infrastructure/persistence/sqlite/habit_entry_repository.go:99.18,113.17 3 6
|
||||
apocapoc-api/internal/infrastructure/persistence/sqlite/habit_entry_repository.go:113.17,115.4 1 0
|
||||
apocapoc-api/internal/infrastructure/persistence/sqlite/habit_entry_repository.go:117.3,118.17 2 6
|
||||
apocapoc-api/internal/infrastructure/persistence/sqlite/habit_entry_repository.go:118.17,120.18 2 6
|
||||
apocapoc-api/internal/infrastructure/persistence/sqlite/habit_entry_repository.go:120.18,122.5 1 0
|
||||
apocapoc-api/internal/infrastructure/persistence/sqlite/habit_entry_repository.go:124.3,126.36 2 6
|
||||
apocapoc-api/internal/infrastructure/persistence/sqlite/habit_entry_repository.go:129.2,129.21 1 3
|
||||
apocapoc-api/internal/infrastructure/persistence/sqlite/habit_entry_repository.go:132.103,152.26 4 1
|
||||
apocapoc-api/internal/infrastructure/persistence/sqlite/habit_entry_repository.go:152.26,154.3 1 0
|
||||
apocapoc-api/internal/infrastructure/persistence/sqlite/habit_entry_repository.go:155.2,155.16 1 1
|
||||
apocapoc-api/internal/infrastructure/persistence/sqlite/habit_entry_repository.go:155.16,157.3 1 0
|
||||
apocapoc-api/internal/infrastructure/persistence/sqlite/habit_entry_repository.go:159.2,160.16 2 1
|
||||
apocapoc-api/internal/infrastructure/persistence/sqlite/habit_entry_repository.go:160.16,162.17 2 1
|
||||
apocapoc-api/internal/infrastructure/persistence/sqlite/habit_entry_repository.go:162.17,164.4 1 0
|
||||
apocapoc-api/internal/infrastructure/persistence/sqlite/habit_entry_repository.go:166.2,168.20 2 1
|
||||
apocapoc-api/internal/infrastructure/persistence/sqlite/habit_entry_repository.go:171.115,180.16 3 0
|
||||
apocapoc-api/internal/infrastructure/persistence/sqlite/habit_entry_repository.go:180.16,182.3 1 0
|
||||
apocapoc-api/internal/infrastructure/persistence/sqlite/habit_entry_repository.go:183.2,185.28 2 0
|
||||
apocapoc-api/internal/infrastructure/persistence/sqlite/habit_entry_repository.go:188.144,198.16 3 0
|
||||
apocapoc-api/internal/infrastructure/persistence/sqlite/habit_entry_repository.go:198.16,200.3 1 0
|
||||
apocapoc-api/internal/infrastructure/persistence/sqlite/habit_entry_repository.go:201.2,203.28 2 0
|
||||
apocapoc-api/internal/infrastructure/persistence/sqlite/habit_entry_repository.go:206.77,210.16 3 0
|
||||
apocapoc-api/internal/infrastructure/persistence/sqlite/habit_entry_repository.go:210.16,212.3 1 0
|
||||
apocapoc-api/internal/infrastructure/persistence/sqlite/habit_entry_repository.go:214.2,215.15 2 0
|
||||
apocapoc-api/internal/infrastructure/persistence/sqlite/habit_entry_repository.go:215.15,217.3 1 0
|
||||
apocapoc-api/internal/infrastructure/persistence/sqlite/habit_entry_repository.go:219.2,219.12 1 0
|
||||
apocapoc-api/internal/infrastructure/persistence/sqlite/habit_repository.go:19.54,21.2 1 8
|
||||
apocapoc-api/internal/infrastructure/persistence/sqlite/habit_repository.go:23.84,51.16 6 8
|
||||
apocapoc-api/internal/infrastructure/persistence/sqlite/habit_repository.go:51.16,53.3 1 0
|
||||
apocapoc-api/internal/infrastructure/persistence/sqlite/habit_repository.go:55.2,55.12 1 8
|
||||
apocapoc-api/internal/infrastructure/persistence/sqlite/habit_repository.go:58.93,90.26 4 5
|
||||
apocapoc-api/internal/infrastructure/persistence/sqlite/habit_repository.go:90.26,92.3 1 1
|
||||
apocapoc-api/internal/infrastructure/persistence/sqlite/habit_repository.go:93.2,93.16 1 4
|
||||
apocapoc-api/internal/infrastructure/persistence/sqlite/habit_repository.go:93.16,95.3 1 0
|
||||
apocapoc-api/internal/infrastructure/persistence/sqlite/habit_repository.go:97.2,97.24 1 4
|
||||
apocapoc-api/internal/infrastructure/persistence/sqlite/habit_repository.go:97.24,99.3 1 4
|
||||
apocapoc-api/internal/infrastructure/persistence/sqlite/habit_repository.go:100.2,100.25 1 4
|
||||
apocapoc-api/internal/infrastructure/persistence/sqlite/habit_repository.go:100.25,102.3 1 4
|
||||
apocapoc-api/internal/infrastructure/persistence/sqlite/habit_repository.go:103.2,103.22 1 4
|
||||
apocapoc-api/internal/infrastructure/persistence/sqlite/habit_repository.go:103.22,105.3 1 1
|
||||
apocapoc-api/internal/infrastructure/persistence/sqlite/habit_repository.go:107.2,107.20 1 4
|
||||
apocapoc-api/internal/infrastructure/persistence/sqlite/habit_repository.go:110.109,121.16 3 1
|
||||
apocapoc-api/internal/infrastructure/persistence/sqlite/habit_repository.go:121.16,123.3 1 0
|
||||
apocapoc-api/internal/infrastructure/persistence/sqlite/habit_repository.go:124.2,126.27 2 1
|
||||
apocapoc-api/internal/infrastructure/persistence/sqlite/habit_repository.go:129.84,155.16 5 4
|
||||
apocapoc-api/internal/infrastructure/persistence/sqlite/habit_repository.go:155.16,157.3 1 0
|
||||
apocapoc-api/internal/infrastructure/persistence/sqlite/habit_repository.go:159.2,160.15 2 4
|
||||
apocapoc-api/internal/infrastructure/persistence/sqlite/habit_repository.go:160.15,162.3 1 1
|
||||
apocapoc-api/internal/infrastructure/persistence/sqlite/habit_repository.go:164.2,164.12 1 3
|
||||
apocapoc-api/internal/infrastructure/persistence/sqlite/habit_repository.go:167.81,170.18 2 1
|
||||
apocapoc-api/internal/infrastructure/persistence/sqlite/habit_repository.go:170.18,194.17 3 2
|
||||
apocapoc-api/internal/infrastructure/persistence/sqlite/habit_repository.go:194.17,196.4 1 0
|
||||
apocapoc-api/internal/infrastructure/persistence/sqlite/habit_repository.go:198.3,198.25 1 2
|
||||
apocapoc-api/internal/infrastructure/persistence/sqlite/habit_repository.go:198.25,200.4 1 2
|
||||
apocapoc-api/internal/infrastructure/persistence/sqlite/habit_repository.go:201.3,201.26 1 2
|
||||
apocapoc-api/internal/infrastructure/persistence/sqlite/habit_repository.go:201.26,203.4 1 2
|
||||
apocapoc-api/internal/infrastructure/persistence/sqlite/habit_repository.go:204.3,204.23 1 2
|
||||
apocapoc-api/internal/infrastructure/persistence/sqlite/habit_repository.go:204.23,206.4 1 0
|
||||
apocapoc-api/internal/infrastructure/persistence/sqlite/habit_repository.go:208.3,208.34 1 2
|
||||
apocapoc-api/internal/infrastructure/persistence/sqlite/habit_repository.go:211.2,211.20 1 1
|
||||
apocapoc-api/internal/infrastructure/persistence/sqlite/habit_repository.go:214.103,225.16 3 0
|
||||
apocapoc-api/internal/infrastructure/persistence/sqlite/habit_repository.go:225.16,227.3 1 0
|
||||
apocapoc-api/internal/infrastructure/persistence/sqlite/habit_repository.go:228.2,230.27 2 0
|
||||
apocapoc-api/internal/infrastructure/persistence/sqlite/habit_repository.go:233.72,237.16 3 0
|
||||
apocapoc-api/internal/infrastructure/persistence/sqlite/habit_repository.go:237.16,239.3 1 0
|
||||
apocapoc-api/internal/infrastructure/persistence/sqlite/habit_repository.go:241.2,242.15 2 0
|
||||
apocapoc-api/internal/infrastructure/persistence/sqlite/habit_repository.go:242.15,244.3 1 0
|
||||
apocapoc-api/internal/infrastructure/persistence/sqlite/habit_repository.go:246.2,246.12 1 0
|
||||
apocapoc-api/internal/infrastructure/persistence/sqlite/migrations.go:7.38,16.39 2 34
|
||||
apocapoc-api/internal/infrastructure/persistence/sqlite/migrations.go:16.39,17.47 1 170
|
||||
apocapoc-api/internal/infrastructure/persistence/sqlite/migrations.go:17.47,19.4 1 0
|
||||
apocapoc-api/internal/infrastructure/persistence/sqlite/migrations.go:21.2,21.12 1 34
|
||||
apocapoc-api/internal/infrastructure/persistence/sqlite/refresh_token_repository.go:19.68,21.2 1 0
|
||||
apocapoc-api/internal/infrastructure/persistence/sqlite/refresh_token_repository.go:23.98,40.16 4 0
|
||||
apocapoc-api/internal/infrastructure/persistence/sqlite/refresh_token_repository.go:40.16,42.3 1 0
|
||||
apocapoc-api/internal/infrastructure/persistence/sqlite/refresh_token_repository.go:44.2,44.12 1 0
|
||||
apocapoc-api/internal/infrastructure/persistence/sqlite/refresh_token_repository.go:47.113,66.26 5 0
|
||||
apocapoc-api/internal/infrastructure/persistence/sqlite/refresh_token_repository.go:66.26,68.3 1 0
|
||||
apocapoc-api/internal/infrastructure/persistence/sqlite/refresh_token_repository.go:69.2,69.16 1 0
|
||||
apocapoc-api/internal/infrastructure/persistence/sqlite/refresh_token_repository.go:69.16,71.3 1 0
|
||||
apocapoc-api/internal/infrastructure/persistence/sqlite/refresh_token_repository.go:73.2,73.21 1 0
|
||||
apocapoc-api/internal/infrastructure/persistence/sqlite/refresh_token_repository.go:73.21,75.3 1 0
|
||||
apocapoc-api/internal/infrastructure/persistence/sqlite/refresh_token_repository.go:77.2,77.17 1 0
|
||||
apocapoc-api/internal/infrastructure/persistence/sqlite/refresh_token_repository.go:80.117,89.16 3 0
|
||||
apocapoc-api/internal/infrastructure/persistence/sqlite/refresh_token_repository.go:89.16,91.3 1 0
|
||||
apocapoc-api/internal/infrastructure/persistence/sqlite/refresh_token_repository.go:92.2,95.18 3 0
|
||||
apocapoc-api/internal/infrastructure/persistence/sqlite/refresh_token_repository.go:95.18,107.17 4 0
|
||||
apocapoc-api/internal/infrastructure/persistence/sqlite/refresh_token_repository.go:107.17,109.4 1 0
|
||||
apocapoc-api/internal/infrastructure/persistence/sqlite/refresh_token_repository.go:111.3,111.22 1 0
|
||||
apocapoc-api/internal/infrastructure/persistence/sqlite/refresh_token_repository.go:111.22,113.4 1 0
|
||||
apocapoc-api/internal/infrastructure/persistence/sqlite/refresh_token_repository.go:115.3,115.31 1 0
|
||||
apocapoc-api/internal/infrastructure/persistence/sqlite/refresh_token_repository.go:118.2,118.20 1 0
|
||||
apocapoc-api/internal/infrastructure/persistence/sqlite/refresh_token_repository.go:121.89,129.16 3 0
|
||||
apocapoc-api/internal/infrastructure/persistence/sqlite/refresh_token_repository.go:129.16,131.3 1 0
|
||||
apocapoc-api/internal/infrastructure/persistence/sqlite/refresh_token_repository.go:133.2,134.15 2 0
|
||||
apocapoc-api/internal/infrastructure/persistence/sqlite/refresh_token_repository.go:134.15,136.3 1 0
|
||||
apocapoc-api/internal/infrastructure/persistence/sqlite/refresh_token_repository.go:138.2,138.12 1 0
|
||||
apocapoc-api/internal/infrastructure/persistence/sqlite/refresh_token_repository.go:141.94,149.16 3 0
|
||||
apocapoc-api/internal/infrastructure/persistence/sqlite/refresh_token_repository.go:149.16,151.3 1 0
|
||||
apocapoc-api/internal/infrastructure/persistence/sqlite/refresh_token_repository.go:153.2,153.12 1 0
|
||||
apocapoc-api/internal/infrastructure/persistence/sqlite/refresh_token_repository.go:156.75,163.16 3 0
|
||||
apocapoc-api/internal/infrastructure/persistence/sqlite/refresh_token_repository.go:163.16,165.3 1 0
|
||||
apocapoc-api/internal/infrastructure/persistence/sqlite/refresh_token_repository.go:167.2,167.12 1 0
|
||||
apocapoc-api/internal/infrastructure/persistence/sqlite/user_repository.go:19.52,21.2 1 8
|
||||
apocapoc-api/internal/infrastructure/persistence/sqlite/user_repository.go:23.81,40.16 4 6
|
||||
apocapoc-api/internal/infrastructure/persistence/sqlite/user_repository.go:40.16,41.35 1 1
|
||||
apocapoc-api/internal/infrastructure/persistence/sqlite/user_repository.go:41.35,43.4 1 1
|
||||
apocapoc-api/internal/infrastructure/persistence/sqlite/user_repository.go:44.3,44.54 1 0
|
||||
apocapoc-api/internal/infrastructure/persistence/sqlite/user_repository.go:47.2,47.12 1 5
|
||||
apocapoc-api/internal/infrastructure/persistence/sqlite/user_repository.go:50.91,67.26 4 3
|
||||
apocapoc-api/internal/infrastructure/persistence/sqlite/user_repository.go:67.26,69.3 1 1
|
||||
apocapoc-api/internal/infrastructure/persistence/sqlite/user_repository.go:70.2,70.16 1 2
|
||||
apocapoc-api/internal/infrastructure/persistence/sqlite/user_repository.go:70.16,72.3 1 0
|
||||
apocapoc-api/internal/infrastructure/persistence/sqlite/user_repository.go:74.2,74.19 1 2
|
||||
apocapoc-api/internal/infrastructure/persistence/sqlite/user_repository.go:77.97,94.26 4 2
|
||||
apocapoc-api/internal/infrastructure/persistence/sqlite/user_repository.go:94.26,96.3 1 1
|
||||
apocapoc-api/internal/infrastructure/persistence/sqlite/user_repository.go:97.2,97.16 1 1
|
||||
apocapoc-api/internal/infrastructure/persistence/sqlite/user_repository.go:97.16,99.3 1 0
|
||||
apocapoc-api/internal/infrastructure/persistence/sqlite/user_repository.go:101.2,101.19 1 1
|
||||
apocapoc-api/internal/infrastructure/persistence/sqlite/user_repository.go:104.81,119.16 3 2
|
||||
apocapoc-api/internal/infrastructure/persistence/sqlite/user_repository.go:119.16,121.3 1 0
|
||||
apocapoc-api/internal/infrastructure/persistence/sqlite/user_repository.go:123.2,124.15 2 2
|
||||
apocapoc-api/internal/infrastructure/persistence/sqlite/user_repository.go:124.15,126.3 1 1
|
||||
apocapoc-api/internal/infrastructure/persistence/sqlite/user_repository.go:128.2,128.12 1 1
|
||||
apocapoc-api/internal/infrastructure/persistence/sqlite/user_repository.go:131.46,133.2 1 2
|
||||
apocapoc-api/internal/shared/validation/common_passwords.go:60.45,63.2 2 10
|
||||
apocapoc-api/internal/shared/validation/validator.go:19.41,21.2 1 12
|
||||
apocapoc-api/internal/shared/validation/validator.go:23.40,26.17 2 26
|
||||
apocapoc-api/internal/shared/validation/validator.go:26.17,28.3 1 2
|
||||
apocapoc-api/internal/shared/validation/validator.go:30.2,30.22 1 24
|
||||
apocapoc-api/internal/shared/validation/validator.go:30.22,32.3 1 1
|
||||
apocapoc-api/internal/shared/validation/validator.go:34.2,34.36 1 23
|
||||
apocapoc-api/internal/shared/validation/validator.go:34.36,36.3 1 8
|
||||
apocapoc-api/internal/shared/validation/validator.go:38.2,39.24 2 15
|
||||
apocapoc-api/internal/shared/validation/validator.go:39.24,41.3 1 1
|
||||
apocapoc-api/internal/shared/validation/validator.go:43.2,43.12 1 14
|
||||
apocapoc-api/internal/shared/validation/validator.go:46.46,47.20 1 23
|
||||
apocapoc-api/internal/shared/validation/validator.go:47.20,49.3 1 2
|
||||
apocapoc-api/internal/shared/validation/validator.go:51.2,51.23 1 21
|
||||
apocapoc-api/internal/shared/validation/validator.go:51.23,53.3 1 3
|
||||
apocapoc-api/internal/shared/validation/validator.go:55.2,55.25 1 18
|
||||
apocapoc-api/internal/shared/validation/validator.go:55.25,57.3 1 1
|
||||
apocapoc-api/internal/shared/validation/validator.go:59.2,66.32 2 17
|
||||
apocapoc-api/internal/shared/validation/validator.go:66.32,67.10 1 292
|
||||
apocapoc-api/internal/shared/validation/validator.go:68.30,69.19 1 58
|
||||
apocapoc-api/internal/shared/validation/validator.go:70.30,71.19 1 130
|
||||
apocapoc-api/internal/shared/validation/validator.go:72.30,73.19 1 58
|
||||
apocapoc-api/internal/shared/validation/validator.go:74.56,75.21 1 45
|
||||
apocapoc-api/internal/shared/validation/validator.go:79.2,79.15 1 17
|
||||
apocapoc-api/internal/shared/validation/validator.go:79.15,81.3 1 2
|
||||
apocapoc-api/internal/shared/validation/validator.go:83.2,83.15 1 15
|
||||
apocapoc-api/internal/shared/validation/validator.go:83.15,85.3 1 1
|
||||
apocapoc-api/internal/shared/validation/validator.go:87.2,87.15 1 14
|
||||
apocapoc-api/internal/shared/validation/validator.go:87.15,89.3 1 2
|
||||
apocapoc-api/internal/shared/validation/validator.go:91.2,91.17 1 12
|
||||
apocapoc-api/internal/shared/validation/validator.go:91.17,93.3 1 2
|
||||
apocapoc-api/internal/shared/validation/validator.go:95.2,95.32 1 10
|
||||
apocapoc-api/internal/shared/validation/validator.go:95.32,97.3 1 0
|
||||
apocapoc-api/internal/shared/validation/validator.go:99.2,99.12 1 10
|
||||
apocapoc-api/internal/shared/validation/validator.go:102.46,105.20 2 18
|
||||
apocapoc-api/internal/shared/validation/validator.go:105.20,107.3 1 2
|
||||
apocapoc-api/internal/shared/validation/validator.go:109.2,110.16 2 16
|
||||
apocapoc-api/internal/shared/validation/validator.go:110.16,112.3 1 7
|
||||
apocapoc-api/internal/shared/validation/validator.go:114.2,114.12 1 9
|
||||
apocapoc-api/internal/shared/validation/validator.go:117.67,118.45 1 9
|
||||
apocapoc-api/internal/shared/validation/validator.go:118.45,120.3 1 3
|
||||
apocapoc-api/internal/shared/validation/validator.go:122.2,122.51 1 6
|
||||
apocapoc-api/internal/shared/validation/validator.go:122.51,124.3 1 2
|
||||
apocapoc-api/internal/shared/validation/validator.go:126.2,126.51 1 4
|
||||
apocapoc-api/internal/shared/validation/validator.go:126.51,128.3 1 2
|
||||
apocapoc-api/internal/shared/validation/validator.go:130.2,130.12 1 2
|
||||
apocapoc-api/internal/infrastructure/http/auth_handlers.go:35.17,46.2 1 3
|
||||
apocapoc-api/internal/infrastructure/http/auth_handlers.go:85.73,87.61 2 10
|
||||
apocapoc-api/internal/infrastructure/http/auth_handlers.go:87.61,90.3 2 0
|
||||
apocapoc-api/internal/infrastructure/http/auth_handlers.go:92.2,99.16 3 10
|
||||
apocapoc-api/internal/infrastructure/http/auth_handlers.go:99.16,100.36 1 3
|
||||
apocapoc-api/internal/infrastructure/http/auth_handlers.go:100.36,103.4 2 2
|
||||
apocapoc-api/internal/infrastructure/http/auth_handlers.go:104.3,104.37 1 1
|
||||
apocapoc-api/internal/infrastructure/http/auth_handlers.go:104.37,107.4 2 1
|
||||
apocapoc-api/internal/infrastructure/http/auth_handlers.go:108.3,109.9 2 0
|
||||
apocapoc-api/internal/infrastructure/http/auth_handlers.go:112.2,113.16 2 7
|
||||
apocapoc-api/internal/infrastructure/http/auth_handlers.go:113.16,116.3 2 0
|
||||
apocapoc-api/internal/infrastructure/http/auth_handlers.go:118.2,119.16 2 7
|
||||
apocapoc-api/internal/infrastructure/http/auth_handlers.go:119.16,122.3 2 0
|
||||
apocapoc-api/internal/infrastructure/http/auth_handlers.go:124.2,124.77 1 7
|
||||
apocapoc-api/internal/infrastructure/http/auth_handlers.go:124.77,127.3 2 0
|
||||
apocapoc-api/internal/infrastructure/http/auth_handlers.go:129.2,133.4 1 7
|
||||
apocapoc-api/internal/infrastructure/http/auth_handlers.go:148.70,150.61 2 3
|
||||
apocapoc-api/internal/infrastructure/http/auth_handlers.go:150.61,153.3 2 0
|
||||
apocapoc-api/internal/infrastructure/http/auth_handlers.go:155.2,161.16 3 3
|
||||
apocapoc-api/internal/infrastructure/http/auth_handlers.go:161.16,162.65 1 2
|
||||
apocapoc-api/internal/infrastructure/http/auth_handlers.go:162.65,165.4 2 2
|
||||
apocapoc-api/internal/infrastructure/http/auth_handlers.go:166.3,167.9 2 0
|
||||
apocapoc-api/internal/infrastructure/http/auth_handlers.go:170.2,171.16 2 1
|
||||
apocapoc-api/internal/infrastructure/http/auth_handlers.go:171.16,174.3 2 0
|
||||
apocapoc-api/internal/infrastructure/http/auth_handlers.go:176.2,177.16 2 1
|
||||
apocapoc-api/internal/infrastructure/http/auth_handlers.go:177.16,180.3 2 0
|
||||
apocapoc-api/internal/infrastructure/http/auth_handlers.go:182.2,182.77 1 1
|
||||
apocapoc-api/internal/infrastructure/http/auth_handlers.go:182.77,185.3 2 0
|
||||
apocapoc-api/internal/infrastructure/http/auth_handlers.go:187.2,191.4 1 1
|
||||
apocapoc-api/internal/infrastructure/http/auth_handlers.go:206.72,208.61 2 0
|
||||
apocapoc-api/internal/infrastructure/http/auth_handlers.go:208.61,211.3 2 0
|
||||
apocapoc-api/internal/infrastructure/http/auth_handlers.go:213.2,218.16 3 0
|
||||
apocapoc-api/internal/infrastructure/http/auth_handlers.go:218.16,219.65 1 0
|
||||
apocapoc-api/internal/infrastructure/http/auth_handlers.go:219.65,222.4 2 0
|
||||
apocapoc-api/internal/infrastructure/http/auth_handlers.go:223.3,224.9 2 0
|
||||
apocapoc-api/internal/infrastructure/http/auth_handlers.go:227.2,228.16 2 0
|
||||
apocapoc-api/internal/infrastructure/http/auth_handlers.go:228.16,231.3 2 0
|
||||
apocapoc-api/internal/infrastructure/http/auth_handlers.go:233.2,234.16 2 0
|
||||
apocapoc-api/internal/infrastructure/http/auth_handlers.go:234.16,237.3 2 0
|
||||
apocapoc-api/internal/infrastructure/http/auth_handlers.go:239.2,239.80 1 0
|
||||
apocapoc-api/internal/infrastructure/http/auth_handlers.go:239.80,242.3 2 0
|
||||
apocapoc-api/internal/infrastructure/http/auth_handlers.go:244.2,244.88 1 0
|
||||
apocapoc-api/internal/infrastructure/http/auth_handlers.go:244.89,245.3 0 0
|
||||
apocapoc-api/internal/infrastructure/http/auth_handlers.go:247.2,251.4 1 0
|
||||
apocapoc-api/internal/infrastructure/http/auth_handlers.go:266.71,268.61 2 0
|
||||
apocapoc-api/internal/infrastructure/http/auth_handlers.go:268.61,271.3 2 0
|
||||
apocapoc-api/internal/infrastructure/http/auth_handlers.go:273.2,278.16 3 0
|
||||
apocapoc-api/internal/infrastructure/http/auth_handlers.go:278.16,279.32 1 0
|
||||
apocapoc-api/internal/infrastructure/http/auth_handlers.go:279.32,282.4 2 0
|
||||
apocapoc-api/internal/infrastructure/http/auth_handlers.go:283.3,283.36 1 0
|
||||
apocapoc-api/internal/infrastructure/http/auth_handlers.go:283.36,286.4 2 0
|
||||
apocapoc-api/internal/infrastructure/http/auth_handlers.go:287.3,288.9 2 0
|
||||
apocapoc-api/internal/infrastructure/http/auth_handlers.go:291.2,293.4 1 0
|
||||
apocapoc-api/internal/infrastructure/http/auth_middleware.go:15.82,16.46 1 6
|
||||
apocapoc-api/internal/infrastructure/http/auth_middleware.go:16.46,17.72 1 6
|
||||
apocapoc-api/internal/infrastructure/http/auth_middleware.go:17.72,19.24 2 19
|
||||
apocapoc-api/internal/infrastructure/http/auth_middleware.go:19.24,22.5 2 1
|
||||
apocapoc-api/internal/infrastructure/http/auth_middleware.go:24.4,25.47 2 18
|
||||
apocapoc-api/internal/infrastructure/http/auth_middleware.go:25.47,28.5 2 0
|
||||
apocapoc-api/internal/infrastructure/http/auth_middleware.go:30.4,32.18 3 18
|
||||
apocapoc-api/internal/infrastructure/http/auth_middleware.go:32.18,35.5 2 0
|
||||
apocapoc-api/internal/infrastructure/http/auth_middleware.go:37.4,38.41 2 18
|
||||
apocapoc-api/internal/infrastructure/http/auth_middleware.go:43.63,46.2 2 33
|
||||
apocapoc-api/internal/infrastructure/http/habit_handlers.go:38.18,50.2 1 3
|
||||
apocapoc-api/internal/infrastructure/http/habit_handlers.go:65.77,67.61 2 4
|
||||
apocapoc-api/internal/infrastructure/http/habit_handlers.go:67.61,70.3 2 0
|
||||
apocapoc-api/internal/infrastructure/http/habit_handlers.go:72.2,73.9 2 4
|
||||
apocapoc-api/internal/infrastructure/http/habit_handlers.go:73.9,76.3 2 0
|
||||
apocapoc-api/internal/infrastructure/http/habit_handlers.go:78.2,92.16 3 4
|
||||
apocapoc-api/internal/infrastructure/http/habit_handlers.go:92.16,93.36 1 0
|
||||
apocapoc-api/internal/infrastructure/http/habit_handlers.go:93.36,96.4 2 0
|
||||
apocapoc-api/internal/infrastructure/http/habit_handlers.go:97.3,98.9 2 0
|
||||
apocapoc-api/internal/infrastructure/http/habit_handlers.go:101.2,101.70 1 4
|
||||
apocapoc-api/internal/infrastructure/http/habit_handlers.go:114.79,116.9 2 2
|
||||
apocapoc-api/internal/infrastructure/http/habit_handlers.go:116.9,119.3 2 0
|
||||
apocapoc-api/internal/infrastructure/http/habit_handlers.go:121.2,126.16 3 2
|
||||
apocapoc-api/internal/infrastructure/http/habit_handlers.go:126.16,129.3 2 0
|
||||
apocapoc-api/internal/infrastructure/http/habit_handlers.go:131.2,132.31 2 2
|
||||
apocapoc-api/internal/infrastructure/http/habit_handlers.go:132.31,143.3 1 1
|
||||
apocapoc-api/internal/infrastructure/http/habit_handlers.go:145.2,145.41 1 2
|
||||
apocapoc-api/internal/infrastructure/http/habit_handlers.go:161.78,165.9 3 3
|
||||
apocapoc-api/internal/infrastructure/http/habit_handlers.go:165.9,168.3 2 0
|
||||
apocapoc-api/internal/infrastructure/http/habit_handlers.go:170.2,176.16 3 3
|
||||
apocapoc-api/internal/infrastructure/http/habit_handlers.go:176.16,177.32 1 1
|
||||
apocapoc-api/internal/infrastructure/http/habit_handlers.go:177.32,180.4 2 0
|
||||
apocapoc-api/internal/infrastructure/http/habit_handlers.go:181.3,181.36 1 1
|
||||
apocapoc-api/internal/infrastructure/http/habit_handlers.go:181.36,184.4 2 1
|
||||
apocapoc-api/internal/infrastructure/http/habit_handlers.go:185.3,186.9 2 0
|
||||
apocapoc-api/internal/infrastructure/http/habit_handlers.go:189.2,200.41 2 2
|
||||
apocapoc-api/internal/infrastructure/http/habit_handlers.go:219.77,223.9 3 1
|
||||
apocapoc-api/internal/infrastructure/http/habit_handlers.go:223.9,226.3 2 0
|
||||
apocapoc-api/internal/infrastructure/http/habit_handlers.go:228.2,229.61 2 1
|
||||
apocapoc-api/internal/infrastructure/http/habit_handlers.go:229.61,232.3 2 0
|
||||
apocapoc-api/internal/infrastructure/http/habit_handlers.go:234.2,245.65 2 1
|
||||
apocapoc-api/internal/infrastructure/http/habit_handlers.go:245.65,246.32 1 0
|
||||
apocapoc-api/internal/infrastructure/http/habit_handlers.go:246.32,249.4 2 0
|
||||
apocapoc-api/internal/infrastructure/http/habit_handlers.go:250.3,250.36 1 0
|
||||
apocapoc-api/internal/infrastructure/http/habit_handlers.go:250.36,253.4 2 0
|
||||
apocapoc-api/internal/infrastructure/http/habit_handlers.go:254.3,254.36 1 0
|
||||
apocapoc-api/internal/infrastructure/http/habit_handlers.go:254.36,257.4 2 0
|
||||
apocapoc-api/internal/infrastructure/http/habit_handlers.go:258.3,259.9 2 0
|
||||
apocapoc-api/internal/infrastructure/http/habit_handlers.go:262.2,262.71 1 1
|
||||
apocapoc-api/internal/infrastructure/http/habit_handlers.go:278.78,282.9 3 1
|
||||
apocapoc-api/internal/infrastructure/http/habit_handlers.go:282.9,285.3 2 0
|
||||
apocapoc-api/internal/infrastructure/http/habit_handlers.go:287.2,292.66 2 1
|
||||
apocapoc-api/internal/infrastructure/http/habit_handlers.go:292.66,293.32 1 0
|
||||
apocapoc-api/internal/infrastructure/http/habit_handlers.go:293.32,296.4 2 0
|
||||
apocapoc-api/internal/infrastructure/http/habit_handlers.go:297.3,297.36 1 0
|
||||
apocapoc-api/internal/infrastructure/http/habit_handlers.go:297.36,300.4 2 0
|
||||
apocapoc-api/internal/infrastructure/http/habit_handlers.go:301.3,302.9 2 0
|
||||
apocapoc-api/internal/infrastructure/http/habit_handlers.go:305.2,305.72 1 1
|
||||
apocapoc-api/internal/infrastructure/http/habit_handlers.go:326.81,330.9 3 3
|
||||
apocapoc-api/internal/infrastructure/http/habit_handlers.go:330.9,333.3 2 0
|
||||
apocapoc-api/internal/infrastructure/http/habit_handlers.go:335.2,340.57 2 3
|
||||
apocapoc-api/internal/infrastructure/http/habit_handlers.go:340.57,342.17 2 0
|
||||
apocapoc-api/internal/infrastructure/http/habit_handlers.go:342.17,345.4 2 0
|
||||
apocapoc-api/internal/infrastructure/http/habit_handlers.go:346.3,346.21 1 0
|
||||
apocapoc-api/internal/infrastructure/http/habit_handlers.go:349.2,349.51 1 3
|
||||
apocapoc-api/internal/infrastructure/http/habit_handlers.go:349.51,351.17 2 0
|
||||
apocapoc-api/internal/infrastructure/http/habit_handlers.go:351.17,354.4 2 0
|
||||
apocapoc-api/internal/infrastructure/http/habit_handlers.go:355.3,355.17 1 0
|
||||
apocapoc-api/internal/infrastructure/http/habit_handlers.go:358.2,359.42 2 3
|
||||
apocapoc-api/internal/infrastructure/http/habit_handlers.go:359.42,361.3 1 0
|
||||
apocapoc-api/internal/infrastructure/http/habit_handlers.go:363.2,364.42 2 3
|
||||
apocapoc-api/internal/infrastructure/http/habit_handlers.go:364.42,366.3 1 3
|
||||
apocapoc-api/internal/infrastructure/http/habit_handlers.go:366.8,366.32 1 0
|
||||
apocapoc-api/internal/infrastructure/http/habit_handlers.go:366.32,368.3 1 0
|
||||
apocapoc-api/internal/infrastructure/http/habit_handlers.go:370.2,370.57 1 3
|
||||
apocapoc-api/internal/infrastructure/http/habit_handlers.go:370.57,372.29 2 3
|
||||
apocapoc-api/internal/infrastructure/http/habit_handlers.go:372.29,375.4 2 0
|
||||
apocapoc-api/internal/infrastructure/http/habit_handlers.go:376.3,376.20 1 3
|
||||
apocapoc-api/internal/infrastructure/http/habit_handlers.go:377.8,377.31 1 0
|
||||
apocapoc-api/internal/infrastructure/http/habit_handlers.go:377.31,379.3 1 0
|
||||
apocapoc-api/internal/infrastructure/http/habit_handlers.go:381.2,381.60 1 3
|
||||
apocapoc-api/internal/infrastructure/http/habit_handlers.go:381.60,383.45 2 3
|
||||
apocapoc-api/internal/infrastructure/http/habit_handlers.go:383.45,386.4 2 0
|
||||
apocapoc-api/internal/infrastructure/http/habit_handlers.go:387.3,387.22 1 3
|
||||
apocapoc-api/internal/infrastructure/http/habit_handlers.go:388.8,388.31 1 0
|
||||
apocapoc-api/internal/infrastructure/http/habit_handlers.go:388.31,390.3 1 0
|
||||
apocapoc-api/internal/infrastructure/http/habit_handlers.go:392.2,392.44 1 3
|
||||
apocapoc-api/internal/infrastructure/http/habit_handlers.go:392.44,395.3 2 0
|
||||
apocapoc-api/internal/infrastructure/http/habit_handlers.go:397.2,398.16 2 3
|
||||
apocapoc-api/internal/infrastructure/http/habit_handlers.go:398.16,399.32 1 0
|
||||
apocapoc-api/internal/infrastructure/http/habit_handlers.go:399.32,402.4 2 0
|
||||
apocapoc-api/internal/infrastructure/http/habit_handlers.go:403.3,403.36 1 0
|
||||
apocapoc-api/internal/infrastructure/http/habit_handlers.go:403.36,406.4 2 0
|
||||
apocapoc-api/internal/infrastructure/http/habit_handlers.go:407.3,408.9 2 0
|
||||
apocapoc-api/internal/infrastructure/http/habit_handlers.go:411.2,412.39 2 3
|
||||
apocapoc-api/internal/infrastructure/http/habit_handlers.go:412.39,420.3 1 2
|
||||
apocapoc-api/internal/infrastructure/http/habit_handlers.go:422.2,429.41 2 3
|
||||
apocapoc-api/internal/infrastructure/http/habit_handlers.go:442.81,444.9 2 0
|
||||
apocapoc-api/internal/infrastructure/http/habit_handlers.go:444.9,447.3 2 0
|
||||
apocapoc-api/internal/infrastructure/http/habit_handlers.go:449.2,458.16 4 0
|
||||
apocapoc-api/internal/infrastructure/http/habit_handlers.go:458.16,461.3 2 0
|
||||
apocapoc-api/internal/infrastructure/http/habit_handlers.go:463.2,464.31 2 0
|
||||
apocapoc-api/internal/infrastructure/http/habit_handlers.go:464.31,474.3 1 0
|
||||
apocapoc-api/internal/infrastructure/http/habit_handlers.go:476.2,476.41 1 0
|
||||
apocapoc-api/internal/infrastructure/http/habit_handlers.go:494.75,498.61 3 3
|
||||
apocapoc-api/internal/infrastructure/http/habit_handlers.go:498.61,501.3 2 0
|
||||
apocapoc-api/internal/infrastructure/http/habit_handlers.go:503.2,504.16 2 3
|
||||
apocapoc-api/internal/infrastructure/http/habit_handlers.go:504.16,507.3 2 0
|
||||
apocapoc-api/internal/infrastructure/http/habit_handlers.go:509.2,515.63 2 3
|
||||
apocapoc-api/internal/infrastructure/http/habit_handlers.go:515.63,516.37 1 1
|
||||
apocapoc-api/internal/infrastructure/http/habit_handlers.go:516.37,519.4 2 1
|
||||
apocapoc-api/internal/infrastructure/http/habit_handlers.go:520.3,520.32 1 0
|
||||
apocapoc-api/internal/infrastructure/http/habit_handlers.go:520.32,523.4 2 0
|
||||
apocapoc-api/internal/infrastructure/http/habit_handlers.go:524.3,525.9 2 0
|
||||
apocapoc-api/internal/infrastructure/http/habit_handlers.go:528.2,528.70 1 2
|
||||
apocapoc-api/internal/infrastructure/http/habit_handlers.go:546.77,551.9 4 1
|
||||
apocapoc-api/internal/infrastructure/http/habit_handlers.go:551.9,554.3 2 0
|
||||
apocapoc-api/internal/infrastructure/http/habit_handlers.go:556.2,557.16 2 1
|
||||
apocapoc-api/internal/infrastructure/http/habit_handlers.go:557.16,560.3 2 0
|
||||
apocapoc-api/internal/infrastructure/http/habit_handlers.go:562.2,568.65 2 1
|
||||
apocapoc-api/internal/infrastructure/http/habit_handlers.go:568.65,569.32 1 0
|
||||
apocapoc-api/internal/infrastructure/http/habit_handlers.go:569.32,572.4 2 0
|
||||
apocapoc-api/internal/infrastructure/http/habit_handlers.go:573.3,573.36 1 0
|
||||
apocapoc-api/internal/infrastructure/http/habit_handlers.go:573.36,576.4 2 0
|
||||
apocapoc-api/internal/infrastructure/http/habit_handlers.go:577.3,578.9 2 0
|
||||
apocapoc-api/internal/infrastructure/http/habit_handlers.go:581.2,581.72 1 1
|
||||
apocapoc-api/internal/infrastructure/http/habit_handlers.go:584.71,588.2 3 32
|
||||
apocapoc-api/internal/infrastructure/http/habit_handlers.go:590.70,592.2 1 8
|
||||
apocapoc-api/internal/infrastructure/http/health_handlers.go:15.52,19.2 1 3
|
||||
apocapoc-api/internal/infrastructure/http/health_handlers.go:35.73,40.36 4 0
|
||||
apocapoc-api/internal/infrastructure/http/health_handlers.go:40.36,44.3 3 0
|
||||
apocapoc-api/internal/infrastructure/http/health_handlers.go:46.2,55.38 4 0
|
||||
apocapoc-api/internal/infrastructure/http/health_handlers.go:58.45,66.11 7 0
|
||||
apocapoc-api/internal/infrastructure/http/health_handlers.go:66.11,68.3 1 0
|
||||
apocapoc-api/internal/infrastructure/http/health_handlers.go:69.2,69.11 1 0
|
||||
apocapoc-api/internal/infrastructure/http/health_handlers.go:69.11,71.3 1 0
|
||||
apocapoc-api/internal/infrastructure/http/health_handlers.go:72.2,72.32 1 0
|
||||
apocapoc-api/internal/infrastructure/http/health_handlers.go:75.48,76.16 1 0
|
||||
apocapoc-api/internal/infrastructure/http/health_handlers.go:76.16,78.3 1 0
|
||||
apocapoc-api/internal/infrastructure/http/health_handlers.go:79.2,79.32 1 0
|
||||
apocapoc-api/internal/infrastructure/http/health_handlers.go:82.30,83.12 1 0
|
||||
apocapoc-api/internal/infrastructure/http/health_handlers.go:83.12,85.3 1 0
|
||||
apocapoc-api/internal/infrastructure/http/health_handlers.go:86.2,86.56 1 0
|
||||
apocapoc-api/internal/infrastructure/http/rate_limit_middleware.go:13.130,17.63 1 6
|
||||
apocapoc-api/internal/infrastructure/http/rate_limit_middleware.go:17.63,19.11 2 18
|
||||
apocapoc-api/internal/infrastructure/http/rate_limit_middleware.go:19.11,21.5 1 0
|
||||
apocapoc-api/internal/infrastructure/http/rate_limit_middleware.go:22.4,22.32 1 18
|
||||
apocapoc-api/internal/infrastructure/http/rate_limit_middleware.go:24.74,28.4 3 0
|
||||
apocapoc-api/internal/infrastructure/http/rate_limit_middleware.go:31.2,31.46 1 6
|
||||
apocapoc-api/internal/infrastructure/http/rate_limit_middleware.go:31.46,32.72 1 6
|
||||
apocapoc-api/internal/infrastructure/http/rate_limit_middleware.go:32.72,36.4 2 18
|
||||
apocapoc-api/internal/infrastructure/http/router.go:18.194,30.69 5 3
|
||||
apocapoc-api/internal/infrastructure/http/router.go:30.69,32.3 1 0
|
||||
apocapoc-api/internal/infrastructure/http/router.go:33.2,39.45 3 3
|
||||
apocapoc-api/internal/infrastructure/http/router.go:39.45,45.3 5 3
|
||||
apocapoc-api/internal/infrastructure/http/router.go:47.2,47.47 1 3
|
||||
apocapoc-api/internal/infrastructure/http/router.go:47.47,60.3 11 3
|
||||
apocapoc-api/internal/infrastructure/http/router.go:62.2,62.46 1 3
|
||||
apocapoc-api/internal/infrastructure/http/router.go:62.46,66.3 3 3
|
||||
apocapoc-api/internal/infrastructure/http/router.go:68.2,68.10 1 3
|
||||
apocapoc-api/internal/infrastructure/http/stats_handlers.go:18.18,22.2 1 3
|
||||
apocapoc-api/internal/infrastructure/http/stats_handlers.go:37.79,41.9 3 0
|
||||
apocapoc-api/internal/infrastructure/http/stats_handlers.go:41.9,44.3 2 0
|
||||
apocapoc-api/internal/infrastructure/http/stats_handlers.go:46.2,52.16 3 0
|
||||
apocapoc-api/internal/infrastructure/http/stats_handlers.go:52.16,53.32 1 0
|
||||
apocapoc-api/internal/infrastructure/http/stats_handlers.go:53.32,56.4 2 0
|
||||
apocapoc-api/internal/infrastructure/http/stats_handlers.go:57.3,57.36 1 0
|
||||
apocapoc-api/internal/infrastructure/http/stats_handlers.go:57.36,60.4 2 0
|
||||
apocapoc-api/internal/infrastructure/http/stats_handlers.go:61.3,62.9 2 0
|
||||
apocapoc-api/internal/infrastructure/http/stats_handlers.go:65.2,65.38 1 0
|
||||
+850
-16
@@ -24,6 +24,67 @@ const docTemplate = `{
|
||||
"host": "{{.Host}}",
|
||||
"basePath": "{{.BasePath}}",
|
||||
"paths": {
|
||||
"/auth/forgot-password": {
|
||||
"post": {
|
||||
"description": "Request a password reset email with a reset token",
|
||||
"consumes": [
|
||||
"application/json"
|
||||
],
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
"tags": [
|
||||
"auth"
|
||||
],
|
||||
"summary": "Request password reset",
|
||||
"parameters": [
|
||||
{
|
||||
"description": "User email",
|
||||
"name": "request",
|
||||
"in": "body",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"$ref": "#/definitions/http.ForgotPasswordRequest"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Reset email sent successfully",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"additionalProperties": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"description": "Invalid email",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/http.ErrorResponse"
|
||||
}
|
||||
},
|
||||
"403": {
|
||||
"description": "Email not verified",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/http.ErrorResponse"
|
||||
}
|
||||
},
|
||||
"404": {
|
||||
"description": "User not found",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/http.ErrorResponse"
|
||||
}
|
||||
},
|
||||
"500": {
|
||||
"description": "Internal server error",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/http.ErrorResponse"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/auth/login": {
|
||||
"post": {
|
||||
"description": "Authenticate user with email and password. Returns both access token and refresh token. The access token is used for API requests, the refresh token is used to obtain new access tokens when they expire.",
|
||||
@@ -67,6 +128,12 @@ const docTemplate = `{
|
||||
"$ref": "#/definitions/http.ErrorResponse"
|
||||
}
|
||||
},
|
||||
"403": {
|
||||
"description": "Email not verified",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/http.ErrorResponse"
|
||||
}
|
||||
},
|
||||
"500": {
|
||||
"description": "Internal server error",
|
||||
"schema": {
|
||||
@@ -185,7 +252,7 @@ const docTemplate = `{
|
||||
},
|
||||
"/auth/register": {
|
||||
"post": {
|
||||
"description": "Create a new user account and receive both access token and refresh token. Store both tokens securely - the refresh token is used to obtain new access tokens when they expire.",
|
||||
"description": "Create a new user account. If email verification is enabled, you will receive a verification email. Otherwise, you can login immediately.",
|
||||
"consumes": [
|
||||
"application/json"
|
||||
],
|
||||
@@ -209,13 +276,19 @@ const docTemplate = `{
|
||||
],
|
||||
"responses": {
|
||||
"201": {
|
||||
"description": "Returns access token, refresh token, and user ID",
|
||||
"description": "Returns user ID and message about next steps",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/http.AuthResponse"
|
||||
"$ref": "#/definitions/http.RegisterResponse"
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"description": "Invalid input: email format, password requirements, or timezone",
|
||||
"description": "Invalid input: email format or password requirements",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/http.ValidationErrorResponse"
|
||||
}
|
||||
},
|
||||
"403": {
|
||||
"description": "Registration is closed",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/http.ErrorResponse"
|
||||
}
|
||||
@@ -235,6 +308,220 @@ const docTemplate = `{
|
||||
}
|
||||
}
|
||||
},
|
||||
"/auth/resend-verification": {
|
||||
"post": {
|
||||
"description": "Resend the email verification link to the user's email address",
|
||||
"consumes": [
|
||||
"application/json"
|
||||
],
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
"tags": [
|
||||
"auth"
|
||||
],
|
||||
"summary": "Resend verification email",
|
||||
"parameters": [
|
||||
{
|
||||
"description": "User email",
|
||||
"name": "request",
|
||||
"in": "body",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"$ref": "#/definitions/http.ResendVerificationRequest"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Verification email sent",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"additionalProperties": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"description": "Invalid email",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/http.ErrorResponse"
|
||||
}
|
||||
},
|
||||
"404": {
|
||||
"description": "User not found",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/http.ErrorResponse"
|
||||
}
|
||||
},
|
||||
"409": {
|
||||
"description": "Email already verified",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/http.ErrorResponse"
|
||||
}
|
||||
},
|
||||
"500": {
|
||||
"description": "Internal server error",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/http.ErrorResponse"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/auth/reset-password": {
|
||||
"post": {
|
||||
"description": "Reset user password using the reset token from email",
|
||||
"consumes": [
|
||||
"application/json"
|
||||
],
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
"tags": [
|
||||
"auth"
|
||||
],
|
||||
"summary": "Reset password",
|
||||
"parameters": [
|
||||
{
|
||||
"description": "Reset token and new password",
|
||||
"name": "request",
|
||||
"in": "body",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"$ref": "#/definitions/http.ResetPasswordRequest"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Password reset successfully",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"additionalProperties": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"description": "Invalid token or password requirements not met",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/http.ErrorResponse"
|
||||
}
|
||||
},
|
||||
"404": {
|
||||
"description": "User not found",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/http.ErrorResponse"
|
||||
}
|
||||
},
|
||||
"500": {
|
||||
"description": "Internal server error",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/http.ErrorResponse"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/auth/verify-email": {
|
||||
"post": {
|
||||
"description": "Verify user email address using the token sent via email",
|
||||
"consumes": [
|
||||
"application/json"
|
||||
],
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
"tags": [
|
||||
"auth"
|
||||
],
|
||||
"summary": "Verify email address",
|
||||
"parameters": [
|
||||
{
|
||||
"description": "Verification token",
|
||||
"name": "request",
|
||||
"in": "body",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"$ref": "#/definitions/http.VerifyEmailRequest"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Email verified successfully",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"additionalProperties": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"description": "Invalid or expired token",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/http.ErrorResponse"
|
||||
}
|
||||
},
|
||||
"409": {
|
||||
"description": "Email already verified",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/http.ErrorResponse"
|
||||
}
|
||||
},
|
||||
"500": {
|
||||
"description": "Internal server error",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/http.ErrorResponse"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/export": {
|
||||
"get": {
|
||||
"security": [
|
||||
{
|
||||
"BearerAuth": []
|
||||
}
|
||||
],
|
||||
"description": "Export all user habits and entries in JSON format with gzip compression. Limited to 1 export per hour.",
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
"tags": [
|
||||
"export"
|
||||
],
|
||||
"summary": "Export user data",
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Compressed JSON export",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/queries.ExportUserDataResult"
|
||||
}
|
||||
},
|
||||
"401": {
|
||||
"description": "Unauthorized",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/http.ErrorResponse"
|
||||
}
|
||||
},
|
||||
"429": {
|
||||
"description": "Rate limit exceeded",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/http.ErrorResponse"
|
||||
}
|
||||
},
|
||||
"500": {
|
||||
"description": "Internal server error",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/http.ErrorResponse"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/habits": {
|
||||
"get": {
|
||||
"security": [
|
||||
@@ -242,7 +529,7 @@ const docTemplate = `{
|
||||
"BearerAuth": []
|
||||
}
|
||||
],
|
||||
"description": "Get all active habits for the authenticated user",
|
||||
"description": "Get all active habits for the authenticated user with optional pagination and filters",
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
@@ -250,14 +537,49 @@ const docTemplate = `{
|
||||
"habits"
|
||||
],
|
||||
"summary": "Get all user habits",
|
||||
"parameters": [
|
||||
{
|
||||
"type": "integer",
|
||||
"description": "Page number (default: 1)",
|
||||
"name": "page",
|
||||
"in": "query"
|
||||
},
|
||||
{
|
||||
"type": "integer",
|
||||
"description": "Page size (default: 50, max: 100)",
|
||||
"name": "page_size",
|
||||
"in": "query"
|
||||
},
|
||||
{
|
||||
"type": "string",
|
||||
"description": "Filter by type (BOOLEAN, COUNTER, VALUE)",
|
||||
"name": "type",
|
||||
"in": "query"
|
||||
},
|
||||
{
|
||||
"type": "string",
|
||||
"description": "Filter by frequency (DAILY, WEEKLY, MONTHLY)",
|
||||
"name": "frequency",
|
||||
"in": "query"
|
||||
},
|
||||
{
|
||||
"type": "boolean",
|
||||
"description": "Include archived habits (default: false)",
|
||||
"name": "archived",
|
||||
"in": "query"
|
||||
},
|
||||
{
|
||||
"type": "string",
|
||||
"description": "Search by name or description",
|
||||
"name": "search",
|
||||
"in": "query"
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK",
|
||||
"schema": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/definitions/http.UserHabitResponse"
|
||||
}
|
||||
"$ref": "#/definitions/http.GetUserHabitsResponse"
|
||||
}
|
||||
},
|
||||
"401": {
|
||||
@@ -340,7 +662,7 @@ const docTemplate = `{
|
||||
"BearerAuth": []
|
||||
}
|
||||
],
|
||||
"description": "Get all habits scheduled for today for the authenticated user",
|
||||
"description": "Get all habits scheduled for today for the authenticated user. Includes the entry for today if it exists. Requires timezone as query parameter (e.g., ?timezone=America/New_York).",
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
@@ -348,6 +670,15 @@ const docTemplate = `{
|
||||
"habits"
|
||||
],
|
||||
"summary": "Get today's habits",
|
||||
"parameters": [
|
||||
{
|
||||
"type": "string",
|
||||
"description": "IANA timezone (e.g., 'America/New_York', 'Europe/Madrid', 'UTC')",
|
||||
"name": "timezone",
|
||||
"in": "query",
|
||||
"required": true
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK",
|
||||
@@ -358,6 +689,12 @@ const docTemplate = `{
|
||||
}
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"description": "Invalid or missing timezone",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/http.ErrorResponse"
|
||||
}
|
||||
},
|
||||
"401": {
|
||||
"description": "Unauthorized",
|
||||
"schema": {
|
||||
@@ -835,7 +1172,7 @@ const docTemplate = `{
|
||||
"BearerAuth": []
|
||||
}
|
||||
],
|
||||
"description": "Get statistics for a specific habit including streaks and completion rates",
|
||||
"description": "Get statistics for a specific habit including streaks and completions",
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
@@ -885,6 +1222,167 @@ const docTemplate = `{
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/sync/batch": {
|
||||
"post": {
|
||||
"security": [
|
||||
{
|
||||
"BearerAuth": []
|
||||
}
|
||||
],
|
||||
"description": "Apply a batch of changes from the client for offline sync (Last-Write-Wins)",
|
||||
"consumes": [
|
||||
"application/json"
|
||||
],
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
"tags": [
|
||||
"sync"
|
||||
],
|
||||
"summary": "Apply sync batch",
|
||||
"parameters": [
|
||||
{
|
||||
"description": "Sync batch data",
|
||||
"name": "request",
|
||||
"in": "body",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"$ref": "#/definitions/http.SyncBatchRequest"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"additionalProperties": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"description": "Bad Request",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/http.ErrorResponse"
|
||||
}
|
||||
},
|
||||
"401": {
|
||||
"description": "Unauthorized",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/http.ErrorResponse"
|
||||
}
|
||||
},
|
||||
"500": {
|
||||
"description": "Internal Server Error",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/http.ErrorResponse"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/sync/changes": {
|
||||
"get": {
|
||||
"security": [
|
||||
{
|
||||
"BearerAuth": []
|
||||
}
|
||||
],
|
||||
"description": "Get all changes (habits and entries) since a given timestamp for offline sync",
|
||||
"consumes": [
|
||||
"application/json"
|
||||
],
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
"tags": [
|
||||
"sync"
|
||||
],
|
||||
"summary": "Get sync changes",
|
||||
"parameters": [
|
||||
{
|
||||
"type": "string",
|
||||
"description": "ISO 8601 timestamp (e.g., 2025-01-01T00:00:00Z)",
|
||||
"name": "since",
|
||||
"in": "query",
|
||||
"required": true
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/http.SyncChangesResponse"
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"description": "Bad Request",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/http.ErrorResponse"
|
||||
}
|
||||
},
|
||||
"401": {
|
||||
"description": "Unauthorized",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/http.ErrorResponse"
|
||||
}
|
||||
},
|
||||
"500": {
|
||||
"description": "Internal Server Error",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/http.ErrorResponse"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/users/me": {
|
||||
"delete": {
|
||||
"security": [
|
||||
{
|
||||
"BearerAuth": []
|
||||
}
|
||||
],
|
||||
"description": "Permanently delete the authenticated user's account and all associated data (habits, entries, tokens). This action cannot be undone.",
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
"tags": [
|
||||
"users"
|
||||
],
|
||||
"summary": "Delete user account",
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Account deleted successfully",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"additionalProperties": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"401": {
|
||||
"description": "Unauthorized - invalid or missing token",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/http.ErrorResponse"
|
||||
}
|
||||
},
|
||||
"404": {
|
||||
"description": "User not found",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/http.ErrorResponse"
|
||||
}
|
||||
},
|
||||
"500": {
|
||||
"description": "Internal server error",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/http.ErrorResponse"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"definitions": {
|
||||
@@ -940,6 +1438,29 @@ const docTemplate = `{
|
||||
}
|
||||
}
|
||||
},
|
||||
"http.EntryChangesDTO": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"created": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/definitions/http.SyncHabitEntryDTO"
|
||||
}
|
||||
},
|
||||
"deleted": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"updated": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/definitions/http.SyncHabitEntryDTO"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"http.ErrorResponse": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
@@ -948,6 +1469,51 @@ const docTemplate = `{
|
||||
}
|
||||
}
|
||||
},
|
||||
"http.ForgotPasswordRequest": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"email": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"http.GetUserHabitsResponse": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"data": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/definitions/http.UserHabitResponse"
|
||||
}
|
||||
},
|
||||
"pagination": {
|
||||
"$ref": "#/definitions/pagination.Response"
|
||||
}
|
||||
}
|
||||
},
|
||||
"http.HabitChangesDTO": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"created": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/definitions/http.SyncHabitDTO"
|
||||
}
|
||||
},
|
||||
"deleted": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"updated": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/definitions/http.SyncHabitDTO"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"http.HabitEntriesResponse": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
@@ -994,6 +1560,9 @@ const docTemplate = `{
|
||||
"database": {
|
||||
"type": "string"
|
||||
},
|
||||
"smtp": {
|
||||
"type": "string"
|
||||
},
|
||||
"status": {
|
||||
"type": "string"
|
||||
},
|
||||
@@ -1048,15 +1617,157 @@ const docTemplate = `{
|
||||
},
|
||||
"password": {
|
||||
"type": "string"
|
||||
},
|
||||
"timezone": {
|
||||
}
|
||||
}
|
||||
},
|
||||
"http.RegisterResponse": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"message": {
|
||||
"type": "string"
|
||||
},
|
||||
"user_id": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"http.ResendVerificationRequest": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"email": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"http.ResetPasswordRequest": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"new_password": {
|
||||
"type": "string"
|
||||
},
|
||||
"token": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"http.SyncBatchRequest": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"entries": {
|
||||
"$ref": "#/definitions/http.EntryChangesDTO"
|
||||
},
|
||||
"habits": {
|
||||
"$ref": "#/definitions/http.HabitChangesDTO"
|
||||
}
|
||||
}
|
||||
},
|
||||
"http.SyncChangesResponse": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"entries": {
|
||||
"$ref": "#/definitions/http.EntryChangesDTO"
|
||||
},
|
||||
"habits": {
|
||||
"$ref": "#/definitions/http.HabitChangesDTO"
|
||||
}
|
||||
}
|
||||
},
|
||||
"http.SyncHabitDTO": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"archived_at": {
|
||||
"type": "string"
|
||||
},
|
||||
"carry_over": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"created_at": {
|
||||
"type": "string"
|
||||
},
|
||||
"description": {
|
||||
"type": "string"
|
||||
},
|
||||
"frequency": {
|
||||
"$ref": "#/definitions/value_objects.Frequency"
|
||||
},
|
||||
"id": {
|
||||
"type": "string"
|
||||
},
|
||||
"is_negative": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"name": {
|
||||
"type": "string"
|
||||
},
|
||||
"specific_dates": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "integer"
|
||||
}
|
||||
},
|
||||
"specific_days": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "integer"
|
||||
}
|
||||
},
|
||||
"target_value": {
|
||||
"type": "number"
|
||||
},
|
||||
"type": {
|
||||
"$ref": "#/definitions/value_objects.HabitType"
|
||||
},
|
||||
"updated_at": {
|
||||
"type": "string"
|
||||
},
|
||||
"user_id": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"http.SyncHabitEntryDTO": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"completed_at": {
|
||||
"type": "string"
|
||||
},
|
||||
"habit_id": {
|
||||
"type": "string"
|
||||
},
|
||||
"id": {
|
||||
"type": "string"
|
||||
},
|
||||
"scheduled_date": {
|
||||
"type": "string"
|
||||
},
|
||||
"updated_at": {
|
||||
"type": "string"
|
||||
},
|
||||
"value": {
|
||||
"type": "number"
|
||||
}
|
||||
}
|
||||
},
|
||||
"http.TodaysHabitEntryResponse": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"completed_at": {
|
||||
"type": "string"
|
||||
},
|
||||
"id": {
|
||||
"type": "string"
|
||||
},
|
||||
"value": {
|
||||
"type": "number"
|
||||
}
|
||||
}
|
||||
},
|
||||
"http.TodaysHabitResponse": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"entry": {
|
||||
"$ref": "#/definitions/http.TodaysHabitEntryResponse"
|
||||
},
|
||||
"id": {
|
||||
"type": "string"
|
||||
},
|
||||
@@ -1089,6 +1800,9 @@ const docTemplate = `{
|
||||
"description": {
|
||||
"type": "string"
|
||||
},
|
||||
"frequency": {
|
||||
"$ref": "#/definitions/value_objects.Frequency"
|
||||
},
|
||||
"name": {
|
||||
"type": "string"
|
||||
},
|
||||
@@ -1141,12 +1855,132 @@ const docTemplate = `{
|
||||
}
|
||||
}
|
||||
},
|
||||
"http.ValidationErrorResponse": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"error": {
|
||||
"type": "string"
|
||||
},
|
||||
"field": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"http.VerifyEmailRequest": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"token": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"pagination.Response": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"page": {
|
||||
"type": "integer"
|
||||
},
|
||||
"page_size": {
|
||||
"type": "integer"
|
||||
},
|
||||
"total_items": {
|
||||
"type": "integer"
|
||||
},
|
||||
"total_pages": {
|
||||
"type": "integer"
|
||||
}
|
||||
}
|
||||
},
|
||||
"queries.ExportEntryDTO": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"completed_at": {
|
||||
"type": "string"
|
||||
},
|
||||
"habit_id": {
|
||||
"type": "string"
|
||||
},
|
||||
"id": {
|
||||
"type": "string"
|
||||
},
|
||||
"scheduled_date": {
|
||||
"type": "string"
|
||||
},
|
||||
"value": {
|
||||
"type": "number"
|
||||
}
|
||||
}
|
||||
},
|
||||
"queries.ExportHabitDTO": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"archived_at": {
|
||||
"type": "string"
|
||||
},
|
||||
"carry_over": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"created_at": {
|
||||
"type": "string"
|
||||
},
|
||||
"description": {
|
||||
"type": "string"
|
||||
},
|
||||
"frequency": {
|
||||
"$ref": "#/definitions/value_objects.Frequency"
|
||||
},
|
||||
"id": {
|
||||
"type": "string"
|
||||
},
|
||||
"is_negative": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"name": {
|
||||
"type": "string"
|
||||
},
|
||||
"specific_dates": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "integer"
|
||||
}
|
||||
},
|
||||
"specific_days": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "integer"
|
||||
}
|
||||
},
|
||||
"target_value": {
|
||||
"type": "number"
|
||||
},
|
||||
"type": {
|
||||
"$ref": "#/definitions/value_objects.HabitType"
|
||||
}
|
||||
}
|
||||
},
|
||||
"queries.ExportUserDataResult": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"entries": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/definitions/queries.ExportEntryDTO"
|
||||
}
|
||||
},
|
||||
"exported_at": {
|
||||
"type": "string"
|
||||
},
|
||||
"habits": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/definitions/queries.ExportHabitDTO"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"queries.HabitStatsDTO": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"completion_rate": {
|
||||
"type": "number"
|
||||
},
|
||||
"completions_this_month": {
|
||||
"type": "integer"
|
||||
},
|
||||
|
||||
+850
-16
@@ -16,6 +16,67 @@
|
||||
},
|
||||
"basePath": "/api/v1",
|
||||
"paths": {
|
||||
"/auth/forgot-password": {
|
||||
"post": {
|
||||
"description": "Request a password reset email with a reset token",
|
||||
"consumes": [
|
||||
"application/json"
|
||||
],
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
"tags": [
|
||||
"auth"
|
||||
],
|
||||
"summary": "Request password reset",
|
||||
"parameters": [
|
||||
{
|
||||
"description": "User email",
|
||||
"name": "request",
|
||||
"in": "body",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"$ref": "#/definitions/http.ForgotPasswordRequest"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Reset email sent successfully",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"additionalProperties": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"description": "Invalid email",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/http.ErrorResponse"
|
||||
}
|
||||
},
|
||||
"403": {
|
||||
"description": "Email not verified",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/http.ErrorResponse"
|
||||
}
|
||||
},
|
||||
"404": {
|
||||
"description": "User not found",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/http.ErrorResponse"
|
||||
}
|
||||
},
|
||||
"500": {
|
||||
"description": "Internal server error",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/http.ErrorResponse"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/auth/login": {
|
||||
"post": {
|
||||
"description": "Authenticate user with email and password. Returns both access token and refresh token. The access token is used for API requests, the refresh token is used to obtain new access tokens when they expire.",
|
||||
@@ -59,6 +120,12 @@
|
||||
"$ref": "#/definitions/http.ErrorResponse"
|
||||
}
|
||||
},
|
||||
"403": {
|
||||
"description": "Email not verified",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/http.ErrorResponse"
|
||||
}
|
||||
},
|
||||
"500": {
|
||||
"description": "Internal server error",
|
||||
"schema": {
|
||||
@@ -177,7 +244,7 @@
|
||||
},
|
||||
"/auth/register": {
|
||||
"post": {
|
||||
"description": "Create a new user account and receive both access token and refresh token. Store both tokens securely - the refresh token is used to obtain new access tokens when they expire.",
|
||||
"description": "Create a new user account. If email verification is enabled, you will receive a verification email. Otherwise, you can login immediately.",
|
||||
"consumes": [
|
||||
"application/json"
|
||||
],
|
||||
@@ -201,13 +268,19 @@
|
||||
],
|
||||
"responses": {
|
||||
"201": {
|
||||
"description": "Returns access token, refresh token, and user ID",
|
||||
"description": "Returns user ID and message about next steps",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/http.AuthResponse"
|
||||
"$ref": "#/definitions/http.RegisterResponse"
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"description": "Invalid input: email format, password requirements, or timezone",
|
||||
"description": "Invalid input: email format or password requirements",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/http.ValidationErrorResponse"
|
||||
}
|
||||
},
|
||||
"403": {
|
||||
"description": "Registration is closed",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/http.ErrorResponse"
|
||||
}
|
||||
@@ -227,6 +300,220 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"/auth/resend-verification": {
|
||||
"post": {
|
||||
"description": "Resend the email verification link to the user's email address",
|
||||
"consumes": [
|
||||
"application/json"
|
||||
],
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
"tags": [
|
||||
"auth"
|
||||
],
|
||||
"summary": "Resend verification email",
|
||||
"parameters": [
|
||||
{
|
||||
"description": "User email",
|
||||
"name": "request",
|
||||
"in": "body",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"$ref": "#/definitions/http.ResendVerificationRequest"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Verification email sent",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"additionalProperties": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"description": "Invalid email",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/http.ErrorResponse"
|
||||
}
|
||||
},
|
||||
"404": {
|
||||
"description": "User not found",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/http.ErrorResponse"
|
||||
}
|
||||
},
|
||||
"409": {
|
||||
"description": "Email already verified",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/http.ErrorResponse"
|
||||
}
|
||||
},
|
||||
"500": {
|
||||
"description": "Internal server error",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/http.ErrorResponse"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/auth/reset-password": {
|
||||
"post": {
|
||||
"description": "Reset user password using the reset token from email",
|
||||
"consumes": [
|
||||
"application/json"
|
||||
],
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
"tags": [
|
||||
"auth"
|
||||
],
|
||||
"summary": "Reset password",
|
||||
"parameters": [
|
||||
{
|
||||
"description": "Reset token and new password",
|
||||
"name": "request",
|
||||
"in": "body",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"$ref": "#/definitions/http.ResetPasswordRequest"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Password reset successfully",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"additionalProperties": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"description": "Invalid token or password requirements not met",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/http.ErrorResponse"
|
||||
}
|
||||
},
|
||||
"404": {
|
||||
"description": "User not found",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/http.ErrorResponse"
|
||||
}
|
||||
},
|
||||
"500": {
|
||||
"description": "Internal server error",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/http.ErrorResponse"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/auth/verify-email": {
|
||||
"post": {
|
||||
"description": "Verify user email address using the token sent via email",
|
||||
"consumes": [
|
||||
"application/json"
|
||||
],
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
"tags": [
|
||||
"auth"
|
||||
],
|
||||
"summary": "Verify email address",
|
||||
"parameters": [
|
||||
{
|
||||
"description": "Verification token",
|
||||
"name": "request",
|
||||
"in": "body",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"$ref": "#/definitions/http.VerifyEmailRequest"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Email verified successfully",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"additionalProperties": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"description": "Invalid or expired token",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/http.ErrorResponse"
|
||||
}
|
||||
},
|
||||
"409": {
|
||||
"description": "Email already verified",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/http.ErrorResponse"
|
||||
}
|
||||
},
|
||||
"500": {
|
||||
"description": "Internal server error",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/http.ErrorResponse"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/export": {
|
||||
"get": {
|
||||
"security": [
|
||||
{
|
||||
"BearerAuth": []
|
||||
}
|
||||
],
|
||||
"description": "Export all user habits and entries in JSON format with gzip compression. Limited to 1 export per hour.",
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
"tags": [
|
||||
"export"
|
||||
],
|
||||
"summary": "Export user data",
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Compressed JSON export",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/queries.ExportUserDataResult"
|
||||
}
|
||||
},
|
||||
"401": {
|
||||
"description": "Unauthorized",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/http.ErrorResponse"
|
||||
}
|
||||
},
|
||||
"429": {
|
||||
"description": "Rate limit exceeded",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/http.ErrorResponse"
|
||||
}
|
||||
},
|
||||
"500": {
|
||||
"description": "Internal server error",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/http.ErrorResponse"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/habits": {
|
||||
"get": {
|
||||
"security": [
|
||||
@@ -234,7 +521,7 @@
|
||||
"BearerAuth": []
|
||||
}
|
||||
],
|
||||
"description": "Get all active habits for the authenticated user",
|
||||
"description": "Get all active habits for the authenticated user with optional pagination and filters",
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
@@ -242,14 +529,49 @@
|
||||
"habits"
|
||||
],
|
||||
"summary": "Get all user habits",
|
||||
"parameters": [
|
||||
{
|
||||
"type": "integer",
|
||||
"description": "Page number (default: 1)",
|
||||
"name": "page",
|
||||
"in": "query"
|
||||
},
|
||||
{
|
||||
"type": "integer",
|
||||
"description": "Page size (default: 50, max: 100)",
|
||||
"name": "page_size",
|
||||
"in": "query"
|
||||
},
|
||||
{
|
||||
"type": "string",
|
||||
"description": "Filter by type (BOOLEAN, COUNTER, VALUE)",
|
||||
"name": "type",
|
||||
"in": "query"
|
||||
},
|
||||
{
|
||||
"type": "string",
|
||||
"description": "Filter by frequency (DAILY, WEEKLY, MONTHLY)",
|
||||
"name": "frequency",
|
||||
"in": "query"
|
||||
},
|
||||
{
|
||||
"type": "boolean",
|
||||
"description": "Include archived habits (default: false)",
|
||||
"name": "archived",
|
||||
"in": "query"
|
||||
},
|
||||
{
|
||||
"type": "string",
|
||||
"description": "Search by name or description",
|
||||
"name": "search",
|
||||
"in": "query"
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK",
|
||||
"schema": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/definitions/http.UserHabitResponse"
|
||||
}
|
||||
"$ref": "#/definitions/http.GetUserHabitsResponse"
|
||||
}
|
||||
},
|
||||
"401": {
|
||||
@@ -332,7 +654,7 @@
|
||||
"BearerAuth": []
|
||||
}
|
||||
],
|
||||
"description": "Get all habits scheduled for today for the authenticated user",
|
||||
"description": "Get all habits scheduled for today for the authenticated user. Includes the entry for today if it exists. Requires timezone as query parameter (e.g., ?timezone=America/New_York).",
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
@@ -340,6 +662,15 @@
|
||||
"habits"
|
||||
],
|
||||
"summary": "Get today's habits",
|
||||
"parameters": [
|
||||
{
|
||||
"type": "string",
|
||||
"description": "IANA timezone (e.g., 'America/New_York', 'Europe/Madrid', 'UTC')",
|
||||
"name": "timezone",
|
||||
"in": "query",
|
||||
"required": true
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK",
|
||||
@@ -350,6 +681,12 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"description": "Invalid or missing timezone",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/http.ErrorResponse"
|
||||
}
|
||||
},
|
||||
"401": {
|
||||
"description": "Unauthorized",
|
||||
"schema": {
|
||||
@@ -827,7 +1164,7 @@
|
||||
"BearerAuth": []
|
||||
}
|
||||
],
|
||||
"description": "Get statistics for a specific habit including streaks and completion rates",
|
||||
"description": "Get statistics for a specific habit including streaks and completions",
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
@@ -877,6 +1214,167 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/sync/batch": {
|
||||
"post": {
|
||||
"security": [
|
||||
{
|
||||
"BearerAuth": []
|
||||
}
|
||||
],
|
||||
"description": "Apply a batch of changes from the client for offline sync (Last-Write-Wins)",
|
||||
"consumes": [
|
||||
"application/json"
|
||||
],
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
"tags": [
|
||||
"sync"
|
||||
],
|
||||
"summary": "Apply sync batch",
|
||||
"parameters": [
|
||||
{
|
||||
"description": "Sync batch data",
|
||||
"name": "request",
|
||||
"in": "body",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"$ref": "#/definitions/http.SyncBatchRequest"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"additionalProperties": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"description": "Bad Request",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/http.ErrorResponse"
|
||||
}
|
||||
},
|
||||
"401": {
|
||||
"description": "Unauthorized",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/http.ErrorResponse"
|
||||
}
|
||||
},
|
||||
"500": {
|
||||
"description": "Internal Server Error",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/http.ErrorResponse"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/sync/changes": {
|
||||
"get": {
|
||||
"security": [
|
||||
{
|
||||
"BearerAuth": []
|
||||
}
|
||||
],
|
||||
"description": "Get all changes (habits and entries) since a given timestamp for offline sync",
|
||||
"consumes": [
|
||||
"application/json"
|
||||
],
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
"tags": [
|
||||
"sync"
|
||||
],
|
||||
"summary": "Get sync changes",
|
||||
"parameters": [
|
||||
{
|
||||
"type": "string",
|
||||
"description": "ISO 8601 timestamp (e.g., 2025-01-01T00:00:00Z)",
|
||||
"name": "since",
|
||||
"in": "query",
|
||||
"required": true
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/http.SyncChangesResponse"
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"description": "Bad Request",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/http.ErrorResponse"
|
||||
}
|
||||
},
|
||||
"401": {
|
||||
"description": "Unauthorized",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/http.ErrorResponse"
|
||||
}
|
||||
},
|
||||
"500": {
|
||||
"description": "Internal Server Error",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/http.ErrorResponse"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/users/me": {
|
||||
"delete": {
|
||||
"security": [
|
||||
{
|
||||
"BearerAuth": []
|
||||
}
|
||||
],
|
||||
"description": "Permanently delete the authenticated user's account and all associated data (habits, entries, tokens). This action cannot be undone.",
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
"tags": [
|
||||
"users"
|
||||
],
|
||||
"summary": "Delete user account",
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Account deleted successfully",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"additionalProperties": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"401": {
|
||||
"description": "Unauthorized - invalid or missing token",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/http.ErrorResponse"
|
||||
}
|
||||
},
|
||||
"404": {
|
||||
"description": "User not found",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/http.ErrorResponse"
|
||||
}
|
||||
},
|
||||
"500": {
|
||||
"description": "Internal server error",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/http.ErrorResponse"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"definitions": {
|
||||
@@ -932,6 +1430,29 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"http.EntryChangesDTO": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"created": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/definitions/http.SyncHabitEntryDTO"
|
||||
}
|
||||
},
|
||||
"deleted": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"updated": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/definitions/http.SyncHabitEntryDTO"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"http.ErrorResponse": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
@@ -940,6 +1461,51 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"http.ForgotPasswordRequest": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"email": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"http.GetUserHabitsResponse": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"data": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/definitions/http.UserHabitResponse"
|
||||
}
|
||||
},
|
||||
"pagination": {
|
||||
"$ref": "#/definitions/pagination.Response"
|
||||
}
|
||||
}
|
||||
},
|
||||
"http.HabitChangesDTO": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"created": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/definitions/http.SyncHabitDTO"
|
||||
}
|
||||
},
|
||||
"deleted": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"updated": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/definitions/http.SyncHabitDTO"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"http.HabitEntriesResponse": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
@@ -986,6 +1552,9 @@
|
||||
"database": {
|
||||
"type": "string"
|
||||
},
|
||||
"smtp": {
|
||||
"type": "string"
|
||||
},
|
||||
"status": {
|
||||
"type": "string"
|
||||
},
|
||||
@@ -1040,15 +1609,157 @@
|
||||
},
|
||||
"password": {
|
||||
"type": "string"
|
||||
},
|
||||
"timezone": {
|
||||
}
|
||||
}
|
||||
},
|
||||
"http.RegisterResponse": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"message": {
|
||||
"type": "string"
|
||||
},
|
||||
"user_id": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"http.ResendVerificationRequest": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"email": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"http.ResetPasswordRequest": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"new_password": {
|
||||
"type": "string"
|
||||
},
|
||||
"token": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"http.SyncBatchRequest": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"entries": {
|
||||
"$ref": "#/definitions/http.EntryChangesDTO"
|
||||
},
|
||||
"habits": {
|
||||
"$ref": "#/definitions/http.HabitChangesDTO"
|
||||
}
|
||||
}
|
||||
},
|
||||
"http.SyncChangesResponse": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"entries": {
|
||||
"$ref": "#/definitions/http.EntryChangesDTO"
|
||||
},
|
||||
"habits": {
|
||||
"$ref": "#/definitions/http.HabitChangesDTO"
|
||||
}
|
||||
}
|
||||
},
|
||||
"http.SyncHabitDTO": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"archived_at": {
|
||||
"type": "string"
|
||||
},
|
||||
"carry_over": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"created_at": {
|
||||
"type": "string"
|
||||
},
|
||||
"description": {
|
||||
"type": "string"
|
||||
},
|
||||
"frequency": {
|
||||
"$ref": "#/definitions/value_objects.Frequency"
|
||||
},
|
||||
"id": {
|
||||
"type": "string"
|
||||
},
|
||||
"is_negative": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"name": {
|
||||
"type": "string"
|
||||
},
|
||||
"specific_dates": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "integer"
|
||||
}
|
||||
},
|
||||
"specific_days": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "integer"
|
||||
}
|
||||
},
|
||||
"target_value": {
|
||||
"type": "number"
|
||||
},
|
||||
"type": {
|
||||
"$ref": "#/definitions/value_objects.HabitType"
|
||||
},
|
||||
"updated_at": {
|
||||
"type": "string"
|
||||
},
|
||||
"user_id": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"http.SyncHabitEntryDTO": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"completed_at": {
|
||||
"type": "string"
|
||||
},
|
||||
"habit_id": {
|
||||
"type": "string"
|
||||
},
|
||||
"id": {
|
||||
"type": "string"
|
||||
},
|
||||
"scheduled_date": {
|
||||
"type": "string"
|
||||
},
|
||||
"updated_at": {
|
||||
"type": "string"
|
||||
},
|
||||
"value": {
|
||||
"type": "number"
|
||||
}
|
||||
}
|
||||
},
|
||||
"http.TodaysHabitEntryResponse": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"completed_at": {
|
||||
"type": "string"
|
||||
},
|
||||
"id": {
|
||||
"type": "string"
|
||||
},
|
||||
"value": {
|
||||
"type": "number"
|
||||
}
|
||||
}
|
||||
},
|
||||
"http.TodaysHabitResponse": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"entry": {
|
||||
"$ref": "#/definitions/http.TodaysHabitEntryResponse"
|
||||
},
|
||||
"id": {
|
||||
"type": "string"
|
||||
},
|
||||
@@ -1081,6 +1792,9 @@
|
||||
"description": {
|
||||
"type": "string"
|
||||
},
|
||||
"frequency": {
|
||||
"$ref": "#/definitions/value_objects.Frequency"
|
||||
},
|
||||
"name": {
|
||||
"type": "string"
|
||||
},
|
||||
@@ -1133,12 +1847,132 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"http.ValidationErrorResponse": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"error": {
|
||||
"type": "string"
|
||||
},
|
||||
"field": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"http.VerifyEmailRequest": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"token": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"pagination.Response": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"page": {
|
||||
"type": "integer"
|
||||
},
|
||||
"page_size": {
|
||||
"type": "integer"
|
||||
},
|
||||
"total_items": {
|
||||
"type": "integer"
|
||||
},
|
||||
"total_pages": {
|
||||
"type": "integer"
|
||||
}
|
||||
}
|
||||
},
|
||||
"queries.ExportEntryDTO": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"completed_at": {
|
||||
"type": "string"
|
||||
},
|
||||
"habit_id": {
|
||||
"type": "string"
|
||||
},
|
||||
"id": {
|
||||
"type": "string"
|
||||
},
|
||||
"scheduled_date": {
|
||||
"type": "string"
|
||||
},
|
||||
"value": {
|
||||
"type": "number"
|
||||
}
|
||||
}
|
||||
},
|
||||
"queries.ExportHabitDTO": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"archived_at": {
|
||||
"type": "string"
|
||||
},
|
||||
"carry_over": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"created_at": {
|
||||
"type": "string"
|
||||
},
|
||||
"description": {
|
||||
"type": "string"
|
||||
},
|
||||
"frequency": {
|
||||
"$ref": "#/definitions/value_objects.Frequency"
|
||||
},
|
||||
"id": {
|
||||
"type": "string"
|
||||
},
|
||||
"is_negative": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"name": {
|
||||
"type": "string"
|
||||
},
|
||||
"specific_dates": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "integer"
|
||||
}
|
||||
},
|
||||
"specific_days": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "integer"
|
||||
}
|
||||
},
|
||||
"target_value": {
|
||||
"type": "number"
|
||||
},
|
||||
"type": {
|
||||
"$ref": "#/definitions/value_objects.HabitType"
|
||||
}
|
||||
}
|
||||
},
|
||||
"queries.ExportUserDataResult": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"entries": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/definitions/queries.ExportEntryDTO"
|
||||
}
|
||||
},
|
||||
"exported_at": {
|
||||
"type": "string"
|
||||
},
|
||||
"habits": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/definitions/queries.ExportHabitDTO"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"queries.HabitStatsDTO": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"completion_rate": {
|
||||
"type": "number"
|
||||
},
|
||||
"completions_this_month": {
|
||||
"type": "integer"
|
||||
},
|
||||
|
||||
+560
-16
@@ -34,11 +34,55 @@ definitions:
|
||||
type:
|
||||
$ref: '#/definitions/value_objects.HabitType'
|
||||
type: object
|
||||
http.EntryChangesDTO:
|
||||
properties:
|
||||
created:
|
||||
items:
|
||||
$ref: '#/definitions/http.SyncHabitEntryDTO'
|
||||
type: array
|
||||
deleted:
|
||||
items:
|
||||
type: string
|
||||
type: array
|
||||
updated:
|
||||
items:
|
||||
$ref: '#/definitions/http.SyncHabitEntryDTO'
|
||||
type: array
|
||||
type: object
|
||||
http.ErrorResponse:
|
||||
properties:
|
||||
error:
|
||||
type: string
|
||||
type: object
|
||||
http.ForgotPasswordRequest:
|
||||
properties:
|
||||
email:
|
||||
type: string
|
||||
type: object
|
||||
http.GetUserHabitsResponse:
|
||||
properties:
|
||||
data:
|
||||
items:
|
||||
$ref: '#/definitions/http.UserHabitResponse'
|
||||
type: array
|
||||
pagination:
|
||||
$ref: '#/definitions/pagination.Response'
|
||||
type: object
|
||||
http.HabitChangesDTO:
|
||||
properties:
|
||||
created:
|
||||
items:
|
||||
$ref: '#/definitions/http.SyncHabitDTO'
|
||||
type: array
|
||||
deleted:
|
||||
items:
|
||||
type: string
|
||||
type: array
|
||||
updated:
|
||||
items:
|
||||
$ref: '#/definitions/http.SyncHabitDTO'
|
||||
type: array
|
||||
type: object
|
||||
http.HabitEntriesResponse:
|
||||
properties:
|
||||
entries:
|
||||
@@ -69,6 +113,8 @@ definitions:
|
||||
properties:
|
||||
database:
|
||||
type: string
|
||||
smtp:
|
||||
type: string
|
||||
status:
|
||||
type: string
|
||||
uptime:
|
||||
@@ -104,11 +150,103 @@ definitions:
|
||||
type: string
|
||||
password:
|
||||
type: string
|
||||
timezone:
|
||||
type: object
|
||||
http.RegisterResponse:
|
||||
properties:
|
||||
message:
|
||||
type: string
|
||||
user_id:
|
||||
type: string
|
||||
type: object
|
||||
http.ResendVerificationRequest:
|
||||
properties:
|
||||
email:
|
||||
type: string
|
||||
type: object
|
||||
http.ResetPasswordRequest:
|
||||
properties:
|
||||
new_password:
|
||||
type: string
|
||||
token:
|
||||
type: string
|
||||
type: object
|
||||
http.SyncBatchRequest:
|
||||
properties:
|
||||
entries:
|
||||
$ref: '#/definitions/http.EntryChangesDTO'
|
||||
habits:
|
||||
$ref: '#/definitions/http.HabitChangesDTO'
|
||||
type: object
|
||||
http.SyncChangesResponse:
|
||||
properties:
|
||||
entries:
|
||||
$ref: '#/definitions/http.EntryChangesDTO'
|
||||
habits:
|
||||
$ref: '#/definitions/http.HabitChangesDTO'
|
||||
type: object
|
||||
http.SyncHabitDTO:
|
||||
properties:
|
||||
archived_at:
|
||||
type: string
|
||||
carry_over:
|
||||
type: boolean
|
||||
created_at:
|
||||
type: string
|
||||
description:
|
||||
type: string
|
||||
frequency:
|
||||
$ref: '#/definitions/value_objects.Frequency'
|
||||
id:
|
||||
type: string
|
||||
is_negative:
|
||||
type: boolean
|
||||
name:
|
||||
type: string
|
||||
specific_dates:
|
||||
items:
|
||||
type: integer
|
||||
type: array
|
||||
specific_days:
|
||||
items:
|
||||
type: integer
|
||||
type: array
|
||||
target_value:
|
||||
type: number
|
||||
type:
|
||||
$ref: '#/definitions/value_objects.HabitType'
|
||||
updated_at:
|
||||
type: string
|
||||
user_id:
|
||||
type: string
|
||||
type: object
|
||||
http.SyncHabitEntryDTO:
|
||||
properties:
|
||||
completed_at:
|
||||
type: string
|
||||
habit_id:
|
||||
type: string
|
||||
id:
|
||||
type: string
|
||||
scheduled_date:
|
||||
type: string
|
||||
updated_at:
|
||||
type: string
|
||||
value:
|
||||
type: number
|
||||
type: object
|
||||
http.TodaysHabitEntryResponse:
|
||||
properties:
|
||||
completed_at:
|
||||
type: string
|
||||
id:
|
||||
type: string
|
||||
value:
|
||||
type: number
|
||||
type: object
|
||||
http.TodaysHabitResponse:
|
||||
properties:
|
||||
entry:
|
||||
$ref: '#/definitions/http.TodaysHabitEntryResponse'
|
||||
id:
|
||||
type: string
|
||||
is_carried_over:
|
||||
@@ -130,6 +268,8 @@ definitions:
|
||||
type: boolean
|
||||
description:
|
||||
type: string
|
||||
frequency:
|
||||
$ref: '#/definitions/value_objects.Frequency'
|
||||
name:
|
||||
type: string
|
||||
specific_dates:
|
||||
@@ -164,10 +304,88 @@ definitions:
|
||||
type:
|
||||
$ref: '#/definitions/value_objects.HabitType'
|
||||
type: object
|
||||
http.ValidationErrorResponse:
|
||||
properties:
|
||||
error:
|
||||
type: string
|
||||
field:
|
||||
type: string
|
||||
type: object
|
||||
http.VerifyEmailRequest:
|
||||
properties:
|
||||
token:
|
||||
type: string
|
||||
type: object
|
||||
pagination.Response:
|
||||
properties:
|
||||
page:
|
||||
type: integer
|
||||
page_size:
|
||||
type: integer
|
||||
total_items:
|
||||
type: integer
|
||||
total_pages:
|
||||
type: integer
|
||||
type: object
|
||||
queries.ExportEntryDTO:
|
||||
properties:
|
||||
completed_at:
|
||||
type: string
|
||||
habit_id:
|
||||
type: string
|
||||
id:
|
||||
type: string
|
||||
scheduled_date:
|
||||
type: string
|
||||
value:
|
||||
type: number
|
||||
type: object
|
||||
queries.ExportHabitDTO:
|
||||
properties:
|
||||
archived_at:
|
||||
type: string
|
||||
carry_over:
|
||||
type: boolean
|
||||
created_at:
|
||||
type: string
|
||||
description:
|
||||
type: string
|
||||
frequency:
|
||||
$ref: '#/definitions/value_objects.Frequency'
|
||||
id:
|
||||
type: string
|
||||
is_negative:
|
||||
type: boolean
|
||||
name:
|
||||
type: string
|
||||
specific_dates:
|
||||
items:
|
||||
type: integer
|
||||
type: array
|
||||
specific_days:
|
||||
items:
|
||||
type: integer
|
||||
type: array
|
||||
target_value:
|
||||
type: number
|
||||
type:
|
||||
$ref: '#/definitions/value_objects.HabitType'
|
||||
type: object
|
||||
queries.ExportUserDataResult:
|
||||
properties:
|
||||
entries:
|
||||
items:
|
||||
$ref: '#/definitions/queries.ExportEntryDTO'
|
||||
type: array
|
||||
exported_at:
|
||||
type: string
|
||||
habits:
|
||||
items:
|
||||
$ref: '#/definitions/queries.ExportHabitDTO'
|
||||
type: array
|
||||
type: object
|
||||
queries.HabitStatsDTO:
|
||||
properties:
|
||||
completion_rate:
|
||||
type: number
|
||||
completions_this_month:
|
||||
type: integer
|
||||
completions_this_week:
|
||||
@@ -215,6 +433,46 @@ info:
|
||||
termsOfService: http://swagger.io/terms/
|
||||
title: Apocapoc API
|
||||
paths:
|
||||
/auth/forgot-password:
|
||||
post:
|
||||
consumes:
|
||||
- application/json
|
||||
description: Request a password reset email with a reset token
|
||||
parameters:
|
||||
- description: User email
|
||||
in: body
|
||||
name: request
|
||||
required: true
|
||||
schema:
|
||||
$ref: '#/definitions/http.ForgotPasswordRequest'
|
||||
produces:
|
||||
- application/json
|
||||
responses:
|
||||
"200":
|
||||
description: Reset email sent successfully
|
||||
schema:
|
||||
additionalProperties:
|
||||
type: string
|
||||
type: object
|
||||
"400":
|
||||
description: Invalid email
|
||||
schema:
|
||||
$ref: '#/definitions/http.ErrorResponse'
|
||||
"403":
|
||||
description: Email not verified
|
||||
schema:
|
||||
$ref: '#/definitions/http.ErrorResponse'
|
||||
"404":
|
||||
description: User not found
|
||||
schema:
|
||||
$ref: '#/definitions/http.ErrorResponse'
|
||||
"500":
|
||||
description: Internal server error
|
||||
schema:
|
||||
$ref: '#/definitions/http.ErrorResponse'
|
||||
summary: Request password reset
|
||||
tags:
|
||||
- auth
|
||||
/auth/login:
|
||||
post:
|
||||
consumes:
|
||||
@@ -244,6 +502,10 @@ paths:
|
||||
description: Invalid email or password
|
||||
schema:
|
||||
$ref: '#/definitions/http.ErrorResponse'
|
||||
"403":
|
||||
description: Email not verified
|
||||
schema:
|
||||
$ref: '#/definitions/http.ErrorResponse'
|
||||
"500":
|
||||
description: Internal server error
|
||||
schema:
|
||||
@@ -333,9 +595,8 @@ paths:
|
||||
post:
|
||||
consumes:
|
||||
- application/json
|
||||
description: Create a new user account and receive both access token and refresh
|
||||
token. Store both tokens securely - the refresh token is used to obtain new
|
||||
access tokens when they expire.
|
||||
description: Create a new user account. If email verification is enabled, you
|
||||
will receive a verification email. Otherwise, you can login immediately.
|
||||
parameters:
|
||||
- description: 'Registration data (password requires: min 8 chars, uppercase,
|
||||
lowercase, digit, special char)'
|
||||
@@ -348,11 +609,15 @@ paths:
|
||||
- application/json
|
||||
responses:
|
||||
"201":
|
||||
description: Returns access token, refresh token, and user ID
|
||||
description: Returns user ID and message about next steps
|
||||
schema:
|
||||
$ref: '#/definitions/http.AuthResponse'
|
||||
$ref: '#/definitions/http.RegisterResponse'
|
||||
"400":
|
||||
description: 'Invalid input: email format, password requirements, or timezone'
|
||||
description: 'Invalid input: email format or password requirements'
|
||||
schema:
|
||||
$ref: '#/definitions/http.ValidationErrorResponse'
|
||||
"403":
|
||||
description: Registration is closed
|
||||
schema:
|
||||
$ref: '#/definitions/http.ErrorResponse'
|
||||
"409":
|
||||
@@ -366,18 +631,182 @@ paths:
|
||||
summary: Register a new user
|
||||
tags:
|
||||
- auth
|
||||
/auth/resend-verification:
|
||||
post:
|
||||
consumes:
|
||||
- application/json
|
||||
description: Resend the email verification link to the user's email address
|
||||
parameters:
|
||||
- description: User email
|
||||
in: body
|
||||
name: request
|
||||
required: true
|
||||
schema:
|
||||
$ref: '#/definitions/http.ResendVerificationRequest'
|
||||
produces:
|
||||
- application/json
|
||||
responses:
|
||||
"200":
|
||||
description: Verification email sent
|
||||
schema:
|
||||
additionalProperties:
|
||||
type: string
|
||||
type: object
|
||||
"400":
|
||||
description: Invalid email
|
||||
schema:
|
||||
$ref: '#/definitions/http.ErrorResponse'
|
||||
"404":
|
||||
description: User not found
|
||||
schema:
|
||||
$ref: '#/definitions/http.ErrorResponse'
|
||||
"409":
|
||||
description: Email already verified
|
||||
schema:
|
||||
$ref: '#/definitions/http.ErrorResponse'
|
||||
"500":
|
||||
description: Internal server error
|
||||
schema:
|
||||
$ref: '#/definitions/http.ErrorResponse'
|
||||
summary: Resend verification email
|
||||
tags:
|
||||
- auth
|
||||
/auth/reset-password:
|
||||
post:
|
||||
consumes:
|
||||
- application/json
|
||||
description: Reset user password using the reset token from email
|
||||
parameters:
|
||||
- description: Reset token and new password
|
||||
in: body
|
||||
name: request
|
||||
required: true
|
||||
schema:
|
||||
$ref: '#/definitions/http.ResetPasswordRequest'
|
||||
produces:
|
||||
- application/json
|
||||
responses:
|
||||
"200":
|
||||
description: Password reset successfully
|
||||
schema:
|
||||
additionalProperties:
|
||||
type: string
|
||||
type: object
|
||||
"400":
|
||||
description: Invalid token or password requirements not met
|
||||
schema:
|
||||
$ref: '#/definitions/http.ErrorResponse'
|
||||
"404":
|
||||
description: User not found
|
||||
schema:
|
||||
$ref: '#/definitions/http.ErrorResponse'
|
||||
"500":
|
||||
description: Internal server error
|
||||
schema:
|
||||
$ref: '#/definitions/http.ErrorResponse'
|
||||
summary: Reset password
|
||||
tags:
|
||||
- auth
|
||||
/auth/verify-email:
|
||||
post:
|
||||
consumes:
|
||||
- application/json
|
||||
description: Verify user email address using the token sent via email
|
||||
parameters:
|
||||
- description: Verification token
|
||||
in: body
|
||||
name: request
|
||||
required: true
|
||||
schema:
|
||||
$ref: '#/definitions/http.VerifyEmailRequest'
|
||||
produces:
|
||||
- application/json
|
||||
responses:
|
||||
"200":
|
||||
description: Email verified successfully
|
||||
schema:
|
||||
additionalProperties:
|
||||
type: string
|
||||
type: object
|
||||
"400":
|
||||
description: Invalid or expired token
|
||||
schema:
|
||||
$ref: '#/definitions/http.ErrorResponse'
|
||||
"409":
|
||||
description: Email already verified
|
||||
schema:
|
||||
$ref: '#/definitions/http.ErrorResponse'
|
||||
"500":
|
||||
description: Internal server error
|
||||
schema:
|
||||
$ref: '#/definitions/http.ErrorResponse'
|
||||
summary: Verify email address
|
||||
tags:
|
||||
- auth
|
||||
/export:
|
||||
get:
|
||||
description: Export all user habits and entries in JSON format with gzip compression.
|
||||
Limited to 1 export per hour.
|
||||
produces:
|
||||
- application/json
|
||||
responses:
|
||||
"200":
|
||||
description: Compressed JSON export
|
||||
schema:
|
||||
$ref: '#/definitions/queries.ExportUserDataResult'
|
||||
"401":
|
||||
description: Unauthorized
|
||||
schema:
|
||||
$ref: '#/definitions/http.ErrorResponse'
|
||||
"429":
|
||||
description: Rate limit exceeded
|
||||
schema:
|
||||
$ref: '#/definitions/http.ErrorResponse'
|
||||
"500":
|
||||
description: Internal server error
|
||||
schema:
|
||||
$ref: '#/definitions/http.ErrorResponse'
|
||||
security:
|
||||
- BearerAuth: []
|
||||
summary: Export user data
|
||||
tags:
|
||||
- export
|
||||
/habits:
|
||||
get:
|
||||
description: Get all active habits for the authenticated user
|
||||
description: Get all active habits for the authenticated user with optional
|
||||
pagination and filters
|
||||
parameters:
|
||||
- description: 'Page number (default: 1)'
|
||||
in: query
|
||||
name: page
|
||||
type: integer
|
||||
- description: 'Page size (default: 50, max: 100)'
|
||||
in: query
|
||||
name: page_size
|
||||
type: integer
|
||||
- description: Filter by type (BOOLEAN, COUNTER, VALUE)
|
||||
in: query
|
||||
name: type
|
||||
type: string
|
||||
- description: Filter by frequency (DAILY, WEEKLY, MONTHLY)
|
||||
in: query
|
||||
name: frequency
|
||||
type: string
|
||||
- description: 'Include archived habits (default: false)'
|
||||
in: query
|
||||
name: archived
|
||||
type: boolean
|
||||
- description: Search by name or description
|
||||
in: query
|
||||
name: search
|
||||
type: string
|
||||
produces:
|
||||
- application/json
|
||||
responses:
|
||||
"200":
|
||||
description: OK
|
||||
schema:
|
||||
items:
|
||||
$ref: '#/definitions/http.UserHabitResponse'
|
||||
type: array
|
||||
$ref: '#/definitions/http.GetUserHabitsResponse'
|
||||
"401":
|
||||
description: Unauthorized
|
||||
schema:
|
||||
@@ -708,7 +1137,15 @@ paths:
|
||||
- habits
|
||||
/habits/today:
|
||||
get:
|
||||
description: Get all habits scheduled for today for the authenticated user
|
||||
description: Get all habits scheduled for today for the authenticated user.
|
||||
Includes the entry for today if it exists. Requires timezone as query parameter
|
||||
(e.g., ?timezone=America/New_York).
|
||||
parameters:
|
||||
- description: IANA timezone (e.g., 'America/New_York', 'Europe/Madrid', 'UTC')
|
||||
in: query
|
||||
name: timezone
|
||||
required: true
|
||||
type: string
|
||||
produces:
|
||||
- application/json
|
||||
responses:
|
||||
@@ -718,6 +1155,10 @@ paths:
|
||||
items:
|
||||
$ref: '#/definitions/http.TodaysHabitResponse'
|
||||
type: array
|
||||
"400":
|
||||
description: Invalid or missing timezone
|
||||
schema:
|
||||
$ref: '#/definitions/http.ErrorResponse'
|
||||
"401":
|
||||
description: Unauthorized
|
||||
schema:
|
||||
@@ -750,8 +1191,7 @@ paths:
|
||||
- system
|
||||
/stats/habits/{id}:
|
||||
get:
|
||||
description: Get statistics for a specific habit including streaks and completion
|
||||
rates
|
||||
description: Get statistics for a specific habit including streaks and completions
|
||||
parameters:
|
||||
- description: Habit ID
|
||||
in: path
|
||||
@@ -786,6 +1226,110 @@ paths:
|
||||
summary: Get habit statistics
|
||||
tags:
|
||||
- stats
|
||||
/sync/batch:
|
||||
post:
|
||||
consumes:
|
||||
- application/json
|
||||
description: Apply a batch of changes from the client for offline sync (Last-Write-Wins)
|
||||
parameters:
|
||||
- description: Sync batch data
|
||||
in: body
|
||||
name: request
|
||||
required: true
|
||||
schema:
|
||||
$ref: '#/definitions/http.SyncBatchRequest'
|
||||
produces:
|
||||
- application/json
|
||||
responses:
|
||||
"200":
|
||||
description: OK
|
||||
schema:
|
||||
additionalProperties:
|
||||
type: string
|
||||
type: object
|
||||
"400":
|
||||
description: Bad Request
|
||||
schema:
|
||||
$ref: '#/definitions/http.ErrorResponse'
|
||||
"401":
|
||||
description: Unauthorized
|
||||
schema:
|
||||
$ref: '#/definitions/http.ErrorResponse'
|
||||
"500":
|
||||
description: Internal Server Error
|
||||
schema:
|
||||
$ref: '#/definitions/http.ErrorResponse'
|
||||
security:
|
||||
- BearerAuth: []
|
||||
summary: Apply sync batch
|
||||
tags:
|
||||
- sync
|
||||
/sync/changes:
|
||||
get:
|
||||
consumes:
|
||||
- application/json
|
||||
description: Get all changes (habits and entries) since a given timestamp for
|
||||
offline sync
|
||||
parameters:
|
||||
- description: ISO 8601 timestamp (e.g., 2025-01-01T00:00:00Z)
|
||||
in: query
|
||||
name: since
|
||||
required: true
|
||||
type: string
|
||||
produces:
|
||||
- application/json
|
||||
responses:
|
||||
"200":
|
||||
description: OK
|
||||
schema:
|
||||
$ref: '#/definitions/http.SyncChangesResponse'
|
||||
"400":
|
||||
description: Bad Request
|
||||
schema:
|
||||
$ref: '#/definitions/http.ErrorResponse'
|
||||
"401":
|
||||
description: Unauthorized
|
||||
schema:
|
||||
$ref: '#/definitions/http.ErrorResponse'
|
||||
"500":
|
||||
description: Internal Server Error
|
||||
schema:
|
||||
$ref: '#/definitions/http.ErrorResponse'
|
||||
security:
|
||||
- BearerAuth: []
|
||||
summary: Get sync changes
|
||||
tags:
|
||||
- sync
|
||||
/users/me:
|
||||
delete:
|
||||
description: Permanently delete the authenticated user's account and all associated
|
||||
data (habits, entries, tokens). This action cannot be undone.
|
||||
produces:
|
||||
- application/json
|
||||
responses:
|
||||
"200":
|
||||
description: Account deleted successfully
|
||||
schema:
|
||||
additionalProperties:
|
||||
type: string
|
||||
type: object
|
||||
"401":
|
||||
description: Unauthorized - invalid or missing token
|
||||
schema:
|
||||
$ref: '#/definitions/http.ErrorResponse'
|
||||
"404":
|
||||
description: User not found
|
||||
schema:
|
||||
$ref: '#/definitions/http.ErrorResponse'
|
||||
"500":
|
||||
description: Internal server error
|
||||
schema:
|
||||
$ref: '#/definitions/http.ErrorResponse'
|
||||
security:
|
||||
- BearerAuth: []
|
||||
summary: Delete user account
|
||||
tags:
|
||||
- users
|
||||
securityDefinitions:
|
||||
BearerAuth:
|
||||
description: Type "Bearer" followed by a space and JWT token.
|
||||
|
||||
@@ -11,9 +11,12 @@ require (
|
||||
github.com/golang-jwt/jwt/v5 v5.3.0
|
||||
github.com/google/uuid v1.6.0
|
||||
github.com/joho/godotenv v1.5.1
|
||||
github.com/rs/zerolog v1.34.0
|
||||
github.com/swaggo/http-swagger v1.3.4
|
||||
github.com/swaggo/swag v1.16.4
|
||||
golang.org/x/crypto v0.45.0
|
||||
golang.org/x/text v0.31.0
|
||||
gopkg.in/mail.v2 v2.3.1
|
||||
modernc.org/sqlite v1.40.1
|
||||
)
|
||||
|
||||
@@ -27,14 +30,17 @@ require (
|
||||
github.com/go-openapi/swag v0.19.15 // indirect
|
||||
github.com/josharian/intern v1.0.0 // indirect
|
||||
github.com/mailru/easyjson v0.7.6 // indirect
|
||||
github.com/mattn/go-colorable v0.1.13 // indirect
|
||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||
github.com/ncruces/go-strftime v0.1.9 // indirect
|
||||
github.com/pkg/errors v0.9.1 // indirect
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
||||
github.com/swaggo/files v0.0.0-20220610200504-28940afbdbfe // indirect
|
||||
golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b // indirect
|
||||
golang.org/x/net v0.47.0 // indirect
|
||||
golang.org/x/sys v0.38.0 // indirect
|
||||
golang.org/x/tools v0.36.0 // indirect
|
||||
golang.org/x/tools v0.38.0 // indirect
|
||||
gopkg.in/alexcesaro/quotedprintable.v3 v3.0.0-20150716171945-2caba252f4dc // indirect
|
||||
gopkg.in/yaml.v2 v2.4.0 // indirect
|
||||
modernc.org/libc v1.66.10 // indirect
|
||||
modernc.org/mathutil v1.7.1 // indirect
|
||||
|
||||
@@ -2,6 +2,7 @@ github.com/KyleBanks/depth v1.2.1 h1:5h8fQADFrWtarTdtDudMmGsC7GPbOAu6RVB3ffsVFHc
|
||||
github.com/KyleBanks/depth v1.2.1/go.mod h1:jzSb9d0L43HxTQfT+oSA1EEp2q+ne2uh6XgeJcm8brE=
|
||||
github.com/cespare/xxhash/v2 v2.1.2 h1:YRXhKfTDauu4ajMg1TPgFO5jnlC2HCbmLXMcTG5cbYE=
|
||||
github.com/cespare/xxhash/v2 v2.1.2/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
||||
github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc=
|
||||
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
@@ -24,6 +25,7 @@ github.com/go-openapi/spec v0.20.6/go.mod h1:2OpW+JddWPrpXSCIX8eOx7lZ5iyuWj3RYR6
|
||||
github.com/go-openapi/swag v0.19.5/go.mod h1:POnQmlKehdgb5mhVOsnJFsivZCEZ/vjK9gh66Z9tfKk=
|
||||
github.com/go-openapi/swag v0.19.15 h1:D2NRCBzS9/pEY3gP9Nl8aDqGUcPFrwG2p+CNFrLyrCM=
|
||||
github.com/go-openapi/swag v0.19.15/go.mod h1:QYRuS/SOXUCsnplDa677K7+DxSOj6IPNl/eQntq43wQ=
|
||||
github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
|
||||
github.com/golang-jwt/jwt/v5 v5.3.0 h1:pv4AsKCKKZuqlgs5sUmn4x8UlGa0kEVt/puTpKx9vvo=
|
||||
github.com/golang-jwt/jwt/v5 v5.3.0/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
|
||||
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs=
|
||||
@@ -43,16 +45,25 @@ github.com/mailru/easyjson v0.0.0-20190614124828-94de47d64c63/go.mod h1:C1wdFJiN
|
||||
github.com/mailru/easyjson v0.0.0-20190626092158-b2ccc519800e/go.mod h1:C1wdFJiN94OJF2b5HbByQZoLdCWB1Yqtg26g4irojpc=
|
||||
github.com/mailru/easyjson v0.7.6 h1:8yTIVnZgCoiM1TgqoeTl+LfU5Jg6/xL3QhGQnimLYnA=
|
||||
github.com/mailru/easyjson v0.7.6/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc=
|
||||
github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA=
|
||||
github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
|
||||
github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
|
||||
github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||
github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4=
|
||||
github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
|
||||
github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e h1:fD57ERR4JtEqsWbfPhv4DMiApHyliiK5xCTNVSPiaAs=
|
||||
github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno=
|
||||
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
|
||||
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||
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/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
||||
github.com/rs/xid v1.6.0/go.mod h1:7XoLgs4eV+QndskICGsho+ADou8ySMSjJKDIan90Nz0=
|
||||
github.com/rs/zerolog v1.34.0 h1:k43nTLIwcTVQAncfCw4KZ2VY6ukYoZaBPNOE8txlOeY=
|
||||
github.com/rs/zerolog v1.34.0/go.mod h1:bJsvje4Z08ROH4Nhs5iH600c3IkWhwp44iRc54W6wYQ=
|
||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||
github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
@@ -68,27 +79,35 @@ golang.org/x/crypto v0.45.0 h1:jMBrvKuj23MTlT0bQEOBcAE0mjg8mK9RXFhRH6nyF3Q=
|
||||
golang.org/x/crypto v0.45.0/go.mod h1:XTGrrkGJve7CYK7J8PEww4aY7gM3qMCElcJQ8n8JdX4=
|
||||
golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b h1:M2rDM6z3Fhozi9O7NWsxAkg/yqS/lQJ6PmkyIV3YP+o=
|
||||
golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b/go.mod h1:3//PLf8L/X+8b4vuAfHzxeRUl04Adcb341+IGKfnqS8=
|
||||
golang.org/x/mod v0.27.0 h1:kb+q2PyFnEADO2IEF935ehFUXlWiNjJWtRNgBLSfbxQ=
|
||||
golang.org/x/mod v0.27.0/go.mod h1:rWI627Fq0DEoudcK+MBkNkCe0EetEaDSwJJkCcjpazc=
|
||||
golang.org/x/mod v0.29.0 h1:HV8lRxZC4l2cr3Zq1LvtOsi/ThTgWnUk/y64QSs8GwA=
|
||||
golang.org/x/mod v0.29.0/go.mod h1:NyhrlYXJ2H4eJiRy/WDBO6HMqZQ6q9nk4JzS3NuCK+w=
|
||||
golang.org/x/net v0.0.0-20210805182204-aaa1db679c0d/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
|
||||
golang.org/x/net v0.47.0 h1:Mx+4dIFzqraBXUugkia1OOvlD6LemFo1ALMHjrXDOhY=
|
||||
golang.org/x/net v0.47.0/go.mod h1:/jNxtkgq5yWUGYkaZGqo27cfGZ1c5Nen03aYrrKpVRU=
|
||||
golang.org/x/sync v0.16.0 h1:ycBJEhp9p4vXvUZNszeOq0kGTPghopOL8q0fq3vstxw=
|
||||
golang.org/x/sync v0.16.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
|
||||
golang.org/x/sync v0.18.0 h1:kr88TuHDroi+UVf+0hZnirlk8o8T+4MrK6mr60WkH/I=
|
||||
golang.org/x/sync v0.18.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
|
||||
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc=
|
||||
golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
golang.org/x/text v0.31.0 h1:aC8ghyu4JhP8VojJ2lEHBnochRno1sgL6nEi9WGFGMM=
|
||||
golang.org/x/text v0.31.0/go.mod h1:tKRAlv61yKIjGGHX/4tP1LTbc13YSec1pxVEWXzfoeM=
|
||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||
golang.org/x/tools v0.36.0 h1:kWS0uv/zsvHEle1LbV5LE8QujrxB3wfQyxHfhOk0Qkg=
|
||||
golang.org/x/tools v0.36.0/go.mod h1:WBDiHKJK8YgLHlcQPYQzNCkUxUypCaa5ZegCVutKm+s=
|
||||
golang.org/x/tools v0.38.0 h1:Hx2Xv8hISq8Lm16jvBZ2VQf+RLmbd7wVUsALibYI/IQ=
|
||||
golang.org/x/tools v0.38.0/go.mod h1:yEsQ/d/YK8cjh0L6rZlY8tgtlKiBNTL14pGDJPJpYQs=
|
||||
gopkg.in/alexcesaro/quotedprintable.v3 v3.0.0-20150716171945-2caba252f4dc h1:2gGKlE2+asNV9m7xrywl36YYNnBG5ZQ0r/BOOxqPpmk=
|
||||
gopkg.in/alexcesaro/quotedprintable.v3 v3.0.0-20150716171945-2caba252f4dc/go.mod h1:m7x9LTH6d71AHyAX77c9yqWCCa3UKHcVEj9y7hAtKDk=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f h1:BLraFXnmrev5lT+xlilqcH8XK9/i0At2xKjWk4p6zsU=
|
||||
gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/mail.v2 v2.3.1 h1:WYFn/oANrAGP2C0dcV6/pbkPzv8yGzqTjPmTeO7qoXk=
|
||||
gopkg.in/mail.v2 v2.3.1/go.mod h1:htwXN1Qh09vZJ1NVKxQqHPBaCBbzKhp5GzuJEA4VJWw=
|
||||
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||
gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
|
||||
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
|
||||
|
||||
@@ -0,0 +1,140 @@
|
||||
package commands
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"apocapoc-api/internal/domain/entities"
|
||||
"apocapoc-api/internal/domain/repositories"
|
||||
"apocapoc-api/internal/shared/errors"
|
||||
)
|
||||
|
||||
type HabitBatchChanges struct {
|
||||
Created []*entities.Habit
|
||||
Updated []*entities.Habit
|
||||
Deleted []string
|
||||
}
|
||||
|
||||
type EntryBatchChanges struct {
|
||||
Created []*entities.HabitEntry
|
||||
Updated []*entities.HabitEntry
|
||||
Deleted []string
|
||||
}
|
||||
|
||||
type ApplySyncBatchCommand struct {
|
||||
UserID string
|
||||
Habits HabitBatchChanges
|
||||
Entries EntryBatchChanges
|
||||
}
|
||||
|
||||
type ApplySyncBatchHandler struct {
|
||||
habitRepo repositories.HabitRepository
|
||||
entryRepo repositories.HabitEntryRepository
|
||||
}
|
||||
|
||||
func NewApplySyncBatchHandler(
|
||||
habitRepo repositories.HabitRepository,
|
||||
entryRepo repositories.HabitEntryRepository,
|
||||
) *ApplySyncBatchHandler {
|
||||
return &ApplySyncBatchHandler{
|
||||
habitRepo: habitRepo,
|
||||
entryRepo: entryRepo,
|
||||
}
|
||||
}
|
||||
|
||||
func (h *ApplySyncBatchHandler) Handle(ctx context.Context, cmd ApplySyncBatchCommand) error {
|
||||
if cmd.UserID == "" {
|
||||
return errors.ErrInvalidInput
|
||||
}
|
||||
|
||||
for _, habit := range cmd.Habits.Created {
|
||||
if habit.UserID != cmd.UserID {
|
||||
return errors.ErrUnauthorized
|
||||
}
|
||||
if err := h.habitRepo.Create(ctx, habit); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
for _, habit := range cmd.Habits.Updated {
|
||||
if habit.UserID != cmd.UserID {
|
||||
return errors.ErrUnauthorized
|
||||
}
|
||||
|
||||
existing, err := h.habitRepo.FindByID(ctx, habit.ID)
|
||||
if err != nil {
|
||||
if err == errors.ErrNotFound {
|
||||
if err := h.habitRepo.Create(ctx, habit); err != nil {
|
||||
return err
|
||||
}
|
||||
continue
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
if existing.UserID != cmd.UserID {
|
||||
return errors.ErrUnauthorized
|
||||
}
|
||||
|
||||
if shouldApplyUpdate(existing.UpdatedAt, habit.UpdatedAt) {
|
||||
if err := h.habitRepo.Update(ctx, habit); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for _, id := range cmd.Habits.Deleted {
|
||||
existing, err := h.habitRepo.FindByID(ctx, id)
|
||||
if err != nil {
|
||||
if err == errors.ErrNotFound {
|
||||
continue
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
if existing.UserID != cmd.UserID {
|
||||
return errors.ErrUnauthorized
|
||||
}
|
||||
|
||||
if err := h.habitRepo.SoftDelete(ctx, id); err != nil && err != errors.ErrNotFound {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
for _, entry := range cmd.Entries.Created {
|
||||
if err := h.entryRepo.Create(ctx, entry); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
for _, entry := range cmd.Entries.Updated {
|
||||
existing, err := h.entryRepo.FindByID(ctx, entry.ID)
|
||||
if err != nil {
|
||||
if err == errors.ErrNotFound {
|
||||
if err := h.entryRepo.Create(ctx, entry); err != nil {
|
||||
return err
|
||||
}
|
||||
continue
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
if shouldApplyUpdate(existing.UpdatedAt, entry.UpdatedAt) {
|
||||
if err := h.entryRepo.Update(ctx, entry); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for _, id := range cmd.Entries.Deleted {
|
||||
if err := h.entryRepo.SoftDelete(ctx, id); err != nil && err != errors.ErrNotFound {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func shouldApplyUpdate(serverTime, clientTime time.Time) bool {
|
||||
return clientTime.After(serverTime)
|
||||
}
|
||||
@@ -0,0 +1,401 @@
|
||||
package commands
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"apocapoc-api/internal/domain/entities"
|
||||
"apocapoc-api/internal/domain/repositories"
|
||||
"apocapoc-api/internal/domain/value_objects"
|
||||
"apocapoc-api/internal/shared/errors"
|
||||
"apocapoc-api/internal/shared/pagination"
|
||||
)
|
||||
|
||||
type mockHabitRepoForBatch struct {
|
||||
habits map[string]*entities.Habit
|
||||
createFunc func(ctx context.Context, habit *entities.Habit) error
|
||||
updateFunc func(ctx context.Context, habit *entities.Habit) error
|
||||
softDeleteFunc func(ctx context.Context, id string) error
|
||||
}
|
||||
|
||||
func (m *mockHabitRepoForBatch) FindByID(ctx context.Context, id string) (*entities.Habit, error) {
|
||||
habit, ok := m.habits[id]
|
||||
if !ok {
|
||||
return nil, errors.ErrNotFound
|
||||
}
|
||||
return habit, nil
|
||||
}
|
||||
|
||||
func (m *mockHabitRepoForBatch) Create(ctx context.Context, habit *entities.Habit) error {
|
||||
if m.createFunc != nil {
|
||||
return m.createFunc(ctx, habit)
|
||||
}
|
||||
m.habits[habit.ID] = habit
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *mockHabitRepoForBatch) Update(ctx context.Context, habit *entities.Habit) error {
|
||||
if m.updateFunc != nil {
|
||||
return m.updateFunc(ctx, habit)
|
||||
}
|
||||
m.habits[habit.ID] = habit
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *mockHabitRepoForBatch) SoftDelete(ctx context.Context, id string) error {
|
||||
if m.softDeleteFunc != nil {
|
||||
return m.softDeleteFunc(ctx, id)
|
||||
}
|
||||
delete(m.habits, id)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *mockHabitRepoForBatch) FindByUserID(ctx context.Context, userID string) ([]*entities.Habit, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (m *mockHabitRepoForBatch) FindActiveByUserID(ctx context.Context, userID string) ([]*entities.Habit, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (m *mockHabitRepoForBatch) Delete(ctx context.Context, id string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *mockHabitRepoForBatch) FindActiveByUserIDWithPagination(ctx context.Context, userID string, params pagination.Params) ([]*entities.Habit, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (m *mockHabitRepoForBatch) FindByUserIDFiltered(ctx context.Context, userID string, filter repositories.HabitFilter, paginationParams *pagination.Params) ([]*entities.Habit, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (m *mockHabitRepoForBatch) CountActiveByUserID(ctx context.Context, userID string) (int, error) {
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
func (m *mockHabitRepoForBatch) CountByUserIDFiltered(ctx context.Context, userID string, filter repositories.HabitFilter) (int, error) {
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
func (m *mockHabitRepoForBatch) GetChangesSince(ctx context.Context, userID string, since time.Time) (*repositories.HabitChanges, error) {
|
||||
return &repositories.HabitChanges{
|
||||
Created: []*entities.Habit{},
|
||||
Updated: []*entities.Habit{},
|
||||
Deleted: []string{},
|
||||
}, nil
|
||||
}
|
||||
|
||||
type mockEntryRepoForBatch struct {
|
||||
entries map[string]*entities.HabitEntry
|
||||
createFunc func(ctx context.Context, entry *entities.HabitEntry) error
|
||||
updateFunc func(ctx context.Context, entry *entities.HabitEntry) error
|
||||
softDeleteFunc func(ctx context.Context, id string) error
|
||||
}
|
||||
|
||||
func (m *mockEntryRepoForBatch) FindByID(ctx context.Context, id string) (*entities.HabitEntry, error) {
|
||||
entry, ok := m.entries[id]
|
||||
if !ok {
|
||||
return nil, errors.ErrNotFound
|
||||
}
|
||||
return entry, nil
|
||||
}
|
||||
|
||||
func (m *mockEntryRepoForBatch) Create(ctx context.Context, entry *entities.HabitEntry) error {
|
||||
if m.createFunc != nil {
|
||||
return m.createFunc(ctx, entry)
|
||||
}
|
||||
m.entries[entry.ID] = entry
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *mockEntryRepoForBatch) Update(ctx context.Context, entry *entities.HabitEntry) error {
|
||||
if m.updateFunc != nil {
|
||||
return m.updateFunc(ctx, entry)
|
||||
}
|
||||
m.entries[entry.ID] = entry
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *mockEntryRepoForBatch) SoftDelete(ctx context.Context, id string) error {
|
||||
if m.softDeleteFunc != nil {
|
||||
return m.softDeleteFunc(ctx, id)
|
||||
}
|
||||
delete(m.entries, id)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *mockEntryRepoForBatch) FindByHabitID(ctx context.Context, habitID string) ([]*entities.HabitEntry, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (m *mockEntryRepoForBatch) FindByHabitIDAndDateRange(ctx context.Context, habitID string, from, to time.Time) ([]*entities.HabitEntry, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (m *mockEntryRepoForBatch) FindByUserID(ctx context.Context, userID string) ([]*entities.HabitEntry, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (m *mockEntryRepoForBatch) FindPendingByHabitID(ctx context.Context, habitID string, beforeDate time.Time) ([]*entities.HabitEntry, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (m *mockEntryRepoForBatch) Delete(ctx context.Context, id string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *mockEntryRepoForBatch) GetChangesSince(ctx context.Context, userID string, since time.Time) (*repositories.HabitEntryChanges, error) {
|
||||
return &repositories.HabitEntryChanges{
|
||||
Created: []*entities.HabitEntry{},
|
||||
Updated: []*entities.HabitEntry{},
|
||||
Deleted: []string{},
|
||||
}, nil
|
||||
}
|
||||
|
||||
func TestApplySyncBatchHandler_CreateNewHabits(t *testing.T) {
|
||||
habitRepo := &mockHabitRepoForBatch{
|
||||
habits: make(map[string]*entities.Habit),
|
||||
}
|
||||
entryRepo := &mockEntryRepoForBatch{
|
||||
entries: make(map[string]*entities.HabitEntry),
|
||||
}
|
||||
|
||||
handler := NewApplySyncBatchHandler(habitRepo, entryRepo)
|
||||
|
||||
newHabit := entities.NewHabit("user-123", "New Habit", value_objects.HabitTypeBoolean, value_objects.FrequencyDaily, false, false)
|
||||
newHabit.ID = "habit-new"
|
||||
|
||||
cmd := ApplySyncBatchCommand{
|
||||
UserID: "user-123",
|
||||
Habits: HabitBatchChanges{
|
||||
Created: []*entities.Habit{newHabit},
|
||||
Updated: []*entities.Habit{},
|
||||
Deleted: []string{},
|
||||
},
|
||||
Entries: EntryBatchChanges{
|
||||
Created: []*entities.HabitEntry{},
|
||||
Updated: []*entities.HabitEntry{},
|
||||
Deleted: []string{},
|
||||
},
|
||||
}
|
||||
|
||||
err := handler.Handle(context.Background(), cmd)
|
||||
|
||||
if err != nil {
|
||||
t.Fatalf("Expected no error, got %v", err)
|
||||
}
|
||||
|
||||
if len(habitRepo.habits) != 1 {
|
||||
t.Errorf("Expected 1 habit created, got %d", len(habitRepo.habits))
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplySyncBatchHandler_UpdateExistingHabits(t *testing.T) {
|
||||
oldTime := time.Now().Add(-1 * time.Hour)
|
||||
newTime := time.Now()
|
||||
|
||||
existingHabit := entities.NewHabit("user-123", "Old Name", value_objects.HabitTypeBoolean, value_objects.FrequencyDaily, false, false)
|
||||
existingHabit.ID = "habit-1"
|
||||
existingHabit.UpdatedAt = oldTime
|
||||
|
||||
habitRepo := &mockHabitRepoForBatch{
|
||||
habits: map[string]*entities.Habit{
|
||||
"habit-1": existingHabit,
|
||||
},
|
||||
}
|
||||
entryRepo := &mockEntryRepoForBatch{
|
||||
entries: make(map[string]*entities.HabitEntry),
|
||||
}
|
||||
|
||||
handler := NewApplySyncBatchHandler(habitRepo, entryRepo)
|
||||
|
||||
updatedHabit := entities.NewHabit("user-123", "New Name", value_objects.HabitTypeCounter, value_objects.FrequencyWeekly, false, false)
|
||||
updatedHabit.ID = "habit-1"
|
||||
updatedHabit.UpdatedAt = newTime
|
||||
|
||||
cmd := ApplySyncBatchCommand{
|
||||
UserID: "user-123",
|
||||
Habits: HabitBatchChanges{
|
||||
Created: []*entities.Habit{},
|
||||
Updated: []*entities.Habit{updatedHabit},
|
||||
Deleted: []string{},
|
||||
},
|
||||
Entries: EntryBatchChanges{
|
||||
Created: []*entities.HabitEntry{},
|
||||
Updated: []*entities.HabitEntry{},
|
||||
Deleted: []string{},
|
||||
},
|
||||
}
|
||||
|
||||
err := handler.Handle(context.Background(), cmd)
|
||||
|
||||
if err != nil {
|
||||
t.Fatalf("Expected no error, got %v", err)
|
||||
}
|
||||
|
||||
if habitRepo.habits["habit-1"].Name != "New Name" {
|
||||
t.Errorf("Expected habit name to be updated to 'New Name', got '%s'", habitRepo.habits["habit-1"].Name)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplySyncBatchHandler_LastWriteWins(t *testing.T) {
|
||||
serverTime := time.Now()
|
||||
clientTime := serverTime.Add(-30 * time.Minute)
|
||||
|
||||
serverHabit := entities.NewHabit("user-123", "Server Version", value_objects.HabitTypeBoolean, value_objects.FrequencyDaily, false, false)
|
||||
serverHabit.ID = "habit-1"
|
||||
serverHabit.UpdatedAt = serverTime
|
||||
|
||||
habitRepo := &mockHabitRepoForBatch{
|
||||
habits: map[string]*entities.Habit{
|
||||
"habit-1": serverHabit,
|
||||
},
|
||||
}
|
||||
entryRepo := &mockEntryRepoForBatch{
|
||||
entries: make(map[string]*entities.HabitEntry),
|
||||
}
|
||||
|
||||
handler := NewApplySyncBatchHandler(habitRepo, entryRepo)
|
||||
|
||||
clientHabit := entities.NewHabit("user-123", "Client Version", value_objects.HabitTypeCounter, value_objects.FrequencyWeekly, false, false)
|
||||
clientHabit.ID = "habit-1"
|
||||
clientHabit.UpdatedAt = clientTime
|
||||
|
||||
cmd := ApplySyncBatchCommand{
|
||||
UserID: "user-123",
|
||||
Habits: HabitBatchChanges{
|
||||
Created: []*entities.Habit{},
|
||||
Updated: []*entities.Habit{clientHabit},
|
||||
Deleted: []string{},
|
||||
},
|
||||
Entries: EntryBatchChanges{
|
||||
Created: []*entities.HabitEntry{},
|
||||
Updated: []*entities.HabitEntry{},
|
||||
Deleted: []string{},
|
||||
},
|
||||
}
|
||||
|
||||
err := handler.Handle(context.Background(), cmd)
|
||||
|
||||
if err != nil {
|
||||
t.Fatalf("Expected no error, got %v", err)
|
||||
}
|
||||
|
||||
if habitRepo.habits["habit-1"].Name != "Server Version" {
|
||||
t.Errorf("Expected server version to win (Last-Write-Wins), got '%s'", habitRepo.habits["habit-1"].Name)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplySyncBatchHandler_DeleteHabits(t *testing.T) {
|
||||
existingHabit := entities.NewHabit("user-123", "To Delete", value_objects.HabitTypeBoolean, value_objects.FrequencyDaily, false, false)
|
||||
existingHabit.ID = "habit-1"
|
||||
|
||||
habitRepo := &mockHabitRepoForBatch{
|
||||
habits: map[string]*entities.Habit{
|
||||
"habit-1": existingHabit,
|
||||
},
|
||||
}
|
||||
entryRepo := &mockEntryRepoForBatch{
|
||||
entries: make(map[string]*entities.HabitEntry),
|
||||
}
|
||||
|
||||
handler := NewApplySyncBatchHandler(habitRepo, entryRepo)
|
||||
|
||||
cmd := ApplySyncBatchCommand{
|
||||
UserID: "user-123",
|
||||
Habits: HabitBatchChanges{
|
||||
Created: []*entities.Habit{},
|
||||
Updated: []*entities.Habit{},
|
||||
Deleted: []string{"habit-1"},
|
||||
},
|
||||
Entries: EntryBatchChanges{
|
||||
Created: []*entities.HabitEntry{},
|
||||
Updated: []*entities.HabitEntry{},
|
||||
Deleted: []string{},
|
||||
},
|
||||
}
|
||||
|
||||
err := handler.Handle(context.Background(), cmd)
|
||||
|
||||
if err != nil {
|
||||
t.Fatalf("Expected no error, got %v", err)
|
||||
}
|
||||
|
||||
if len(habitRepo.habits) != 0 {
|
||||
t.Errorf("Expected habit to be deleted, but still exists")
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplySyncBatchHandler_ValidatesUserOwnership(t *testing.T) {
|
||||
habitRepo := &mockHabitRepoForBatch{
|
||||
habits: make(map[string]*entities.Habit),
|
||||
}
|
||||
entryRepo := &mockEntryRepoForBatch{
|
||||
entries: make(map[string]*entities.HabitEntry),
|
||||
}
|
||||
|
||||
handler := NewApplySyncBatchHandler(habitRepo, entryRepo)
|
||||
|
||||
habitForDifferentUser := entities.NewHabit("user-456", "Not Yours", value_objects.HabitTypeBoolean, value_objects.FrequencyDaily, false, false)
|
||||
habitForDifferentUser.ID = "habit-1"
|
||||
|
||||
cmd := ApplySyncBatchCommand{
|
||||
UserID: "user-123",
|
||||
Habits: HabitBatchChanges{
|
||||
Created: []*entities.Habit{habitForDifferentUser},
|
||||
Updated: []*entities.Habit{},
|
||||
Deleted: []string{},
|
||||
},
|
||||
Entries: EntryBatchChanges{
|
||||
Created: []*entities.HabitEntry{},
|
||||
Updated: []*entities.HabitEntry{},
|
||||
Deleted: []string{},
|
||||
},
|
||||
}
|
||||
|
||||
err := handler.Handle(context.Background(), cmd)
|
||||
|
||||
if err == nil {
|
||||
t.Error("Expected error for user mismatch, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplySyncBatchHandler_ProcessesEntries(t *testing.T) {
|
||||
habitRepo := &mockHabitRepoForBatch{
|
||||
habits: make(map[string]*entities.Habit),
|
||||
}
|
||||
entryRepo := &mockEntryRepoForBatch{
|
||||
entries: make(map[string]*entities.HabitEntry),
|
||||
}
|
||||
|
||||
handler := NewApplySyncBatchHandler(habitRepo, entryRepo)
|
||||
|
||||
newEntry := entities.NewHabitEntry("habit-1", time.Now(), nil)
|
||||
newEntry.ID = "entry-new"
|
||||
|
||||
cmd := ApplySyncBatchCommand{
|
||||
UserID: "user-123",
|
||||
Habits: HabitBatchChanges{
|
||||
Created: []*entities.Habit{},
|
||||
Updated: []*entities.Habit{},
|
||||
Deleted: []string{},
|
||||
},
|
||||
Entries: EntryBatchChanges{
|
||||
Created: []*entities.HabitEntry{newEntry},
|
||||
Updated: []*entities.HabitEntry{},
|
||||
Deleted: []string{},
|
||||
},
|
||||
}
|
||||
|
||||
err := handler.Handle(context.Background(), cmd)
|
||||
|
||||
if err != nil {
|
||||
t.Fatalf("Expected no error, got %v", err)
|
||||
}
|
||||
|
||||
if len(entryRepo.entries) != 1 {
|
||||
t.Errorf("Expected 1 entry created, got %d", len(entryRepo.entries))
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@ package commands
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"apocapoc-api/internal/domain/entities"
|
||||
"apocapoc-api/internal/domain/repositories"
|
||||
@@ -32,19 +33,19 @@ func NewCreateHabitHandler(habitRepo repositories.HabitRepository) *CreateHabitH
|
||||
|
||||
func (h *CreateHabitHandler) Handle(ctx context.Context, cmd CreateHabitCommand) (string, error) {
|
||||
if !cmd.Type.IsValid() {
|
||||
return "", errors.ErrInvalidInput
|
||||
return "", fmt.Errorf("%w: type: type_invalid", errors.ErrInvalidInput)
|
||||
}
|
||||
|
||||
if !cmd.Frequency.IsValid() {
|
||||
return "", errors.ErrInvalidInput
|
||||
return "", fmt.Errorf("%w: frequency: frequency_invalid", errors.ErrInvalidInput)
|
||||
}
|
||||
|
||||
if cmd.Frequency == value_objects.FrequencyWeekly && len(cmd.SpecificDays) == 0 {
|
||||
return "", errors.ErrInvalidInput
|
||||
return "", fmt.Errorf("%w: specific_days: specific_days_required", errors.ErrInvalidInput)
|
||||
}
|
||||
|
||||
if cmd.Frequency == value_objects.FrequencyMonthly && len(cmd.SpecificDates) == 0 {
|
||||
return "", errors.ErrInvalidInput
|
||||
return "", fmt.Errorf("%w: specific_dates: specific_dates_required", errors.ErrInvalidInput)
|
||||
}
|
||||
|
||||
habit := entities.NewHabit(cmd.UserID, cmd.Name, cmd.Type, cmd.Frequency, cmd.CarryOver, cmd.IsNegative)
|
||||
|
||||
@@ -1,8 +1,13 @@
|
||||
package commands
|
||||
|
||||
import (
|
||||
"apocapoc-api/internal/domain/repositories"
|
||||
"apocapoc-api/internal/shared/pagination"
|
||||
"context"
|
||||
stderrors "errors"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"apocapoc-api/internal/domain/entities"
|
||||
"apocapoc-api/internal/shared/errors"
|
||||
@@ -36,6 +41,34 @@ func (m *mockHabitRepo) Delete(ctx context.Context, id string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *mockHabitRepo) FindActiveByUserIDWithPagination(ctx context.Context, userID string, params pagination.Params) ([]*entities.Habit, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (m *mockHabitRepo) FindByUserIDFiltered(ctx context.Context, userID string, filter repositories.HabitFilter, paginationParams *pagination.Params) ([]*entities.Habit, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (m *mockHabitRepo) CountActiveByUserID(ctx context.Context, userID string) (int, error) {
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
func (m *mockHabitRepo) CountByUserIDFiltered(ctx context.Context, userID string, filter repositories.HabitFilter) (int, error) {
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
func (m *mockHabitRepo) GetChangesSince(ctx context.Context, userID string, since time.Time) (*repositories.HabitChanges, error) {
|
||||
return &repositories.HabitChanges{
|
||||
Created: []*entities.Habit{},
|
||||
Updated: []*entities.Habit{},
|
||||
Deleted: []string{},
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (m *mockHabitRepo) SoftDelete(ctx context.Context, id string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func TestCreateHabitHandler_Success(t *testing.T) {
|
||||
mock := &mockHabitRepo{
|
||||
createFunc: func(ctx context.Context, habit *entities.Habit) error {
|
||||
@@ -79,8 +112,31 @@ func TestCreateHabitHandler_InvalidType(t *testing.T) {
|
||||
|
||||
_, err := handler.Handle(context.Background(), cmd)
|
||||
|
||||
if err != errors.ErrInvalidInput {
|
||||
t.Errorf("Expected ErrInvalidInput, got %v", err)
|
||||
if !stderrors.Is(err, errors.ErrInvalidInput) {
|
||||
t.Fatalf("Expected ErrInvalidInput wrapper, got %v", err)
|
||||
}
|
||||
if !strings.Contains(err.Error(), "type: type_invalid") {
|
||||
t.Errorf("Expected 'type: type_invalid' in error, got %q", err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateHabitHandler_EmptyType(t *testing.T) {
|
||||
mock := &mockHabitRepo{}
|
||||
handler := NewCreateHabitHandler(mock)
|
||||
|
||||
cmd := CreateHabitCommand{
|
||||
UserID: "user-123",
|
||||
Name: "Exercise",
|
||||
Frequency: "DAILY",
|
||||
}
|
||||
|
||||
_, err := handler.Handle(context.Background(), cmd)
|
||||
|
||||
if !stderrors.Is(err, errors.ErrInvalidInput) {
|
||||
t.Fatalf("Expected ErrInvalidInput wrapper, got %v", err)
|
||||
}
|
||||
if !strings.Contains(err.Error(), "type: type_invalid") {
|
||||
t.Errorf("Expected 'type: type_invalid' in error, got %q", err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -97,8 +153,11 @@ func TestCreateHabitHandler_InvalidFrequency(t *testing.T) {
|
||||
|
||||
_, err := handler.Handle(context.Background(), cmd)
|
||||
|
||||
if err != errors.ErrInvalidInput {
|
||||
t.Errorf("Expected ErrInvalidInput, got %v", err)
|
||||
if !stderrors.Is(err, errors.ErrInvalidInput) {
|
||||
t.Fatalf("Expected ErrInvalidInput wrapper, got %v", err)
|
||||
}
|
||||
if !strings.Contains(err.Error(), "frequency: frequency_invalid") {
|
||||
t.Errorf("Expected 'frequency: frequency_invalid' in error, got %q", err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -116,8 +175,11 @@ func TestCreateHabitHandler_WeeklyWithoutSpecificDays(t *testing.T) {
|
||||
|
||||
_, err := handler.Handle(context.Background(), cmd)
|
||||
|
||||
if err != errors.ErrInvalidInput {
|
||||
t.Errorf("Expected ErrInvalidInput, got %v", err)
|
||||
if !stderrors.Is(err, errors.ErrInvalidInput) {
|
||||
t.Fatalf("Expected ErrInvalidInput wrapper, got %v", err)
|
||||
}
|
||||
if !strings.Contains(err.Error(), "specific_days: specific_days_required") {
|
||||
t.Errorf("Expected 'specific_days: specific_days_required' in error, got %q", err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -135,8 +197,11 @@ func TestCreateHabitHandler_MonthlyWithoutSpecificDates(t *testing.T) {
|
||||
|
||||
_, err := handler.Handle(context.Background(), cmd)
|
||||
|
||||
if err != errors.ErrInvalidInput {
|
||||
t.Errorf("Expected ErrInvalidInput, got %v", err)
|
||||
if !stderrors.Is(err, errors.ErrInvalidInput) {
|
||||
t.Fatalf("Expected ErrInvalidInput wrapper, got %v", err)
|
||||
}
|
||||
if !strings.Contains(err.Error(), "specific_dates: specific_dates_required") {
|
||||
t.Errorf("Expected 'specific_dates: specific_dates_required' in error, got %q", err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
package commands
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"apocapoc-api/internal/domain/repositories"
|
||||
"apocapoc-api/internal/shared/errors"
|
||||
)
|
||||
|
||||
type DeleteUserCommand struct {
|
||||
UserID string
|
||||
}
|
||||
|
||||
type DeleteUserHandler struct {
|
||||
userRepo repositories.UserRepository
|
||||
}
|
||||
|
||||
func NewDeleteUserHandler(userRepo repositories.UserRepository) *DeleteUserHandler {
|
||||
return &DeleteUserHandler{
|
||||
userRepo: userRepo,
|
||||
}
|
||||
}
|
||||
|
||||
func (h *DeleteUserHandler) Handle(ctx context.Context, cmd DeleteUserCommand) error {
|
||||
if cmd.UserID == "" {
|
||||
return errors.ErrInvalidInput
|
||||
}
|
||||
|
||||
user, err := h.userRepo.FindByID(ctx, cmd.UserID)
|
||||
if err != nil {
|
||||
return errors.ErrNotFound
|
||||
}
|
||||
|
||||
if err := h.userRepo.Delete(ctx, user.ID); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
package commands
|
||||
|
||||
import (
|
||||
"apocapoc-api/internal/domain/repositories"
|
||||
"apocapoc-api/internal/shared/pagination"
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"apocapoc-api/internal/domain/entities"
|
||||
"apocapoc-api/internal/shared/errors"
|
||||
)
|
||||
|
||||
type mockDeleteUserRepo struct {
|
||||
findByIDFunc func(ctx context.Context, id string) (*entities.User, error)
|
||||
deleteFunc func(ctx context.Context, id string) error
|
||||
}
|
||||
|
||||
func (m *mockDeleteUserRepo) FindByID(ctx context.Context, id string) (*entities.User, error) {
|
||||
if m.findByIDFunc != nil {
|
||||
return m.findByIDFunc(ctx, id)
|
||||
}
|
||||
return nil, errors.ErrNotFound
|
||||
}
|
||||
|
||||
func (m *mockDeleteUserRepo) FindByEmail(ctx context.Context, email string) (*entities.User, error) {
|
||||
return nil, errors.ErrNotFound
|
||||
}
|
||||
|
||||
func (m *mockDeleteUserRepo) FindByVerificationToken(ctx context.Context, token string) (*entities.User, error) {
|
||||
return nil, errors.ErrNotFound
|
||||
}
|
||||
|
||||
func (m *mockDeleteUserRepo) Create(ctx context.Context, user *entities.User) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *mockDeleteUserRepo) Update(ctx context.Context, user *entities.User) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *mockDeleteUserRepo) Delete(ctx context.Context, id string) error {
|
||||
if m.deleteFunc != nil {
|
||||
return m.deleteFunc(ctx, id)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func TestDeleteUserHandler_Success(t *testing.T) {
|
||||
var deletedID string
|
||||
|
||||
repo := &mockDeleteUserRepo{
|
||||
findByIDFunc: func(ctx context.Context, id string) (*entities.User, error) {
|
||||
user := entities.NewUser("test@example.com", "hashedPassword")
|
||||
user.ID = id
|
||||
return user, nil
|
||||
},
|
||||
deleteFunc: func(ctx context.Context, id string) error {
|
||||
deletedID = id
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
handler := NewDeleteUserHandler(repo)
|
||||
|
||||
cmd := DeleteUserCommand{
|
||||
UserID: "user-123",
|
||||
}
|
||||
|
||||
err := handler.Handle(context.Background(), cmd)
|
||||
if err != nil {
|
||||
t.Fatalf("Handle() unexpected error = %v", err)
|
||||
}
|
||||
|
||||
if deletedID != "user-123" {
|
||||
t.Errorf("deletedID = %v, want %v", deletedID, "user-123")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeleteUserHandler_EmptyUserID(t *testing.T) {
|
||||
repo := &mockDeleteUserRepo{}
|
||||
handler := NewDeleteUserHandler(repo)
|
||||
|
||||
cmd := DeleteUserCommand{
|
||||
UserID: "",
|
||||
}
|
||||
|
||||
err := handler.Handle(context.Background(), cmd)
|
||||
if err != errors.ErrInvalidInput {
|
||||
t.Errorf("Handle() error = %v, want %v", err, errors.ErrInvalidInput)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeleteUserHandler_UserNotFound(t *testing.T) {
|
||||
repo := &mockDeleteUserRepo{
|
||||
findByIDFunc: func(ctx context.Context, id string) (*entities.User, error) {
|
||||
return nil, errors.ErrNotFound
|
||||
},
|
||||
}
|
||||
|
||||
handler := NewDeleteUserHandler(repo)
|
||||
|
||||
cmd := DeleteUserCommand{
|
||||
UserID: "non-existent-user",
|
||||
}
|
||||
|
||||
err := handler.Handle(context.Background(), cmd)
|
||||
if err != errors.ErrNotFound {
|
||||
t.Errorf("Handle() error = %v, want %v", err, errors.ErrNotFound)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeleteUserHandler_DeleteError(t *testing.T) {
|
||||
customError := errors.ErrNotFound
|
||||
|
||||
repo := &mockDeleteUserRepo{
|
||||
findByIDFunc: func(ctx context.Context, id string) (*entities.User, error) {
|
||||
user := entities.NewUser("test@example.com", "hashedPassword")
|
||||
user.ID = id
|
||||
return user, nil
|
||||
},
|
||||
deleteFunc: func(ctx context.Context, id string) error {
|
||||
return customError
|
||||
},
|
||||
}
|
||||
|
||||
handler := NewDeleteUserHandler(repo)
|
||||
|
||||
cmd := DeleteUserCommand{
|
||||
UserID: "user-123",
|
||||
}
|
||||
|
||||
err := handler.Handle(context.Background(), cmd)
|
||||
if err != customError {
|
||||
t.Errorf("Handle() error = %v, want %v", err, customError)
|
||||
}
|
||||
}
|
||||
|
||||
func (m *mockDeleteUserRepo) FindActiveByUserIDWithPagination(ctx context.Context, userID string, params pagination.Params) ([]*entities.Habit, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (m *mockDeleteUserRepo) CountActiveByUserID(ctx context.Context, userID string) (int, error) {
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
func (m *mockDeleteUserRepo) FindByUserIDFiltered(ctx context.Context, userID string, filter repositories.HabitFilter, paginationParams *pagination.Params) ([]*entities.Habit, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (m *mockDeleteUserRepo) CountByUserIDFiltered(ctx context.Context, userID string, filter repositories.HabitFilter) (int, error) {
|
||||
return 0, nil
|
||||
}
|
||||
@@ -1,6 +1,8 @@
|
||||
package commands
|
||||
|
||||
import (
|
||||
"apocapoc-api/internal/domain/repositories"
|
||||
"apocapoc-api/internal/shared/pagination"
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
@@ -38,6 +40,10 @@ func (m *mockEntryRepo) FindByHabitIDAndDateRange(ctx context.Context, habitID s
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (m *mockEntryRepo) FindByUserID(ctx context.Context, userID string) ([]*entities.HabitEntry, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (m *mockEntryRepo) FindPendingByHabitID(ctx context.Context, habitID string, beforeDate time.Time) ([]*entities.HabitEntry, error) {
|
||||
return nil, nil
|
||||
}
|
||||
@@ -53,6 +59,18 @@ func (m *mockEntryRepo) Delete(ctx context.Context, id string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *mockEntryRepo) GetChangesSince(ctx context.Context, userID string, since time.Time) (*repositories.HabitEntryChanges, error) {
|
||||
return &repositories.HabitEntryChanges{
|
||||
Created: []*entities.HabitEntry{},
|
||||
Updated: []*entities.HabitEntry{},
|
||||
Deleted: []string{},
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (m *mockEntryRepo) SoftDelete(ctx context.Context, id string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
type mockHabitRepoForMark struct {
|
||||
habit *entities.Habit
|
||||
}
|
||||
@@ -537,3 +555,47 @@ func TestMarkHabitHandler_CounterFirstMarkWithNegative(t *testing.T) {
|
||||
t.Fatalf("Expected no error, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func (m *mockEntryRepo) FindActiveByUserIDWithPagination(ctx context.Context, userID string, params pagination.Params) ([]*entities.Habit, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (m *mockEntryRepo) CountActiveByUserID(ctx context.Context, userID string) (int, error) {
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
func (m *mockHabitRepoForMark) FindActiveByUserIDWithPagination(ctx context.Context, userID string, params pagination.Params) ([]*entities.Habit, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (m *mockHabitRepoForMark) CountActiveByUserID(ctx context.Context, userID string) (int, error) {
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
func (m *mockEntryRepo) FindByUserIDFiltered(ctx context.Context, userID string, filter repositories.HabitFilter, paginationParams *pagination.Params) ([]*entities.Habit, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (m *mockEntryRepo) CountByUserIDFiltered(ctx context.Context, userID string, filter repositories.HabitFilter) (int, error) {
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
func (m *mockHabitRepoForMark) FindByUserIDFiltered(ctx context.Context, userID string, filter repositories.HabitFilter, paginationParams *pagination.Params) ([]*entities.Habit, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (m *mockHabitRepoForMark) CountByUserIDFiltered(ctx context.Context, userID string, filter repositories.HabitFilter) (int, error) {
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
func (m *mockHabitRepoForMark) GetChangesSince(ctx context.Context, userID string, since time.Time) (*repositories.HabitChanges, error) {
|
||||
return &repositories.HabitChanges{
|
||||
Created: []*entities.Habit{},
|
||||
Updated: []*entities.Habit{},
|
||||
Deleted: []string{},
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (m *mockHabitRepoForMark) SoftDelete(ctx context.Context, id string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -2,6 +2,10 @@ package commands
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"apocapoc-api/internal/domain/entities"
|
||||
"apocapoc-api/internal/domain/repositories"
|
||||
@@ -13,41 +17,119 @@ import (
|
||||
type RegisterUserCommand struct {
|
||||
Email string
|
||||
Password string
|
||||
Timezone string
|
||||
}
|
||||
|
||||
type RegisterUserResult struct {
|
||||
UserID string
|
||||
EmailVerificationRequired bool
|
||||
}
|
||||
|
||||
type RegisterUserHandler struct {
|
||||
userRepo repositories.UserRepository
|
||||
passwordHasher services.PasswordHasher
|
||||
userRepo repositories.UserRepository
|
||||
passwordHasher services.PasswordHasher
|
||||
emailService services.EmailService
|
||||
appURL string
|
||||
registrationMode string
|
||||
sendWelcomeEmail bool
|
||||
}
|
||||
|
||||
func NewRegisterUserHandler(userRepo repositories.UserRepository, passwordHasher services.PasswordHasher) *RegisterUserHandler {
|
||||
func NewRegisterUserHandler(
|
||||
userRepo repositories.UserRepository,
|
||||
passwordHasher services.PasswordHasher,
|
||||
emailService services.EmailService,
|
||||
appURL string,
|
||||
registrationMode string,
|
||||
sendWelcomeEmail bool,
|
||||
) *RegisterUserHandler {
|
||||
return &RegisterUserHandler{
|
||||
userRepo: userRepo,
|
||||
passwordHasher: passwordHasher,
|
||||
userRepo: userRepo,
|
||||
passwordHasher: passwordHasher,
|
||||
emailService: emailService,
|
||||
appURL: appURL,
|
||||
registrationMode: registrationMode,
|
||||
sendWelcomeEmail: sendWelcomeEmail,
|
||||
}
|
||||
}
|
||||
|
||||
func (h *RegisterUserHandler) Handle(ctx context.Context, cmd RegisterUserCommand) (string, error) {
|
||||
if err := validation.ValidateRegistration(cmd.Email, cmd.Password, cmd.Timezone); err != nil {
|
||||
return "", errors.ErrInvalidInput
|
||||
func (h *RegisterUserHandler) Handle(ctx context.Context, cmd RegisterUserCommand) (*RegisterUserResult, error) {
|
||||
if h.registrationMode == "closed" {
|
||||
return nil, errors.ErrRegistrationClosed
|
||||
}
|
||||
|
||||
if err := validation.ValidateRegistration(cmd.Email, cmd.Password); err != nil {
|
||||
return nil, fmt.Errorf("%w: %v", errors.ErrInvalidInput, err)
|
||||
}
|
||||
|
||||
existing, _ := h.userRepo.FindByEmail(ctx, cmd.Email)
|
||||
if existing != nil {
|
||||
return "", errors.ErrAlreadyExists
|
||||
return nil, errors.ErrAlreadyExists
|
||||
}
|
||||
|
||||
hashedPassword, err := h.passwordHasher.Hash(cmd.Password)
|
||||
if err != nil {
|
||||
return "", err
|
||||
return nil, err
|
||||
}
|
||||
|
||||
user := entities.NewUser(cmd.Email, hashedPassword, cmd.Timezone)
|
||||
user := entities.NewUser(cmd.Email, hashedPassword)
|
||||
|
||||
emailVerificationRequired := false
|
||||
if h.emailService.IsEnabled() {
|
||||
token, err := h.generateVerificationToken()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to generate verification token: %w", err)
|
||||
}
|
||||
|
||||
expiry := time.Now().Add(24 * time.Hour)
|
||||
user.EmailVerificationToken = &token
|
||||
user.EmailVerificationExpiry = &expiry
|
||||
emailVerificationRequired = true
|
||||
|
||||
if err := h.sendVerificationEmail(user); err != nil {
|
||||
return nil, fmt.Errorf("failed to send verification email: %w", err)
|
||||
}
|
||||
} else {
|
||||
user.EmailVerified = true
|
||||
}
|
||||
|
||||
if err := h.userRepo.Create(ctx, user); err != nil {
|
||||
return "", err
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return user.ID, nil
|
||||
return &RegisterUserResult{
|
||||
UserID: user.ID,
|
||||
EmailVerificationRequired: emailVerificationRequired,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (h *RegisterUserHandler) generateVerificationToken() (string, error) {
|
||||
bytes := make([]byte, 32)
|
||||
if _, err := rand.Read(bytes); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return hex.EncodeToString(bytes), nil
|
||||
}
|
||||
|
||||
func (h *RegisterUserHandler) sendVerificationEmail(user *entities.User) error {
|
||||
if user.EmailVerificationToken == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
verificationLink := fmt.Sprintf("%s/verify-email?token=%s", h.appURL, *user.EmailVerificationToken)
|
||||
|
||||
emailBody := fmt.Sprintf(`
|
||||
<h2>Welcome! Please verify your email</h2>
|
||||
<p>Thank you for registering. Please click the link below to verify your email address:</p>
|
||||
<p><a href="%s">Verify Email</a></p>
|
||||
<p>This link will expire in 24 hours.</p>
|
||||
<p>If you didn't create an account, you can safely ignore this email.</p>
|
||||
`, verificationLink)
|
||||
|
||||
message := services.EmailMessage{
|
||||
To: user.Email,
|
||||
Subject: "Verify your email address",
|
||||
Body: emailBody,
|
||||
IsHTML: true,
|
||||
}
|
||||
|
||||
return h.emailService.Send(message)
|
||||
}
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
package commands
|
||||
|
||||
import (
|
||||
"apocapoc-api/internal/domain/repositories"
|
||||
"apocapoc-api/internal/domain/services"
|
||||
"apocapoc-api/internal/shared/pagination"
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
@@ -25,6 +28,10 @@ func (m *mockUserRepo) FindByID(ctx context.Context, id string) (*entities.User,
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (m *mockUserRepo) FindByVerificationToken(ctx context.Context, token string) (*entities.User, error) {
|
||||
return nil, appErrors.ErrNotFound
|
||||
}
|
||||
|
||||
func (m *mockUserRepo) Create(ctx context.Context, user *entities.User) error {
|
||||
if m.createFunc != nil {
|
||||
return m.createFunc(ctx, user)
|
||||
@@ -36,6 +43,10 @@ func (m *mockUserRepo) Update(ctx context.Context, user *entities.User) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *mockUserRepo) Delete(ctx context.Context, id string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
type mockPasswordHasher struct {
|
||||
hashFunc func(password string) (string, error)
|
||||
}
|
||||
@@ -61,23 +72,26 @@ func TestRegisterUserHandler_Success(t *testing.T) {
|
||||
},
|
||||
}
|
||||
hasher := &mockPasswordHasher{}
|
||||
handler := NewRegisterUserHandler(repo, hasher)
|
||||
handler := NewRegisterUserHandler(repo, hasher, &services.NoOpEmailService{}, "", "open", false)
|
||||
|
||||
cmd := RegisterUserCommand{
|
||||
Email: "test@example.com",
|
||||
Password: "Secure123!",
|
||||
Timezone: "UTC",
|
||||
}
|
||||
|
||||
userID, err := handler.Handle(context.Background(), cmd)
|
||||
result, err := handler.Handle(context.Background(), cmd)
|
||||
if err != nil {
|
||||
t.Fatalf("expected no error, got %v", err)
|
||||
}
|
||||
|
||||
if userID == "" {
|
||||
if result.UserID == "" {
|
||||
t.Error("expected user ID, got empty string")
|
||||
}
|
||||
|
||||
if result.EmailVerificationRequired {
|
||||
t.Error("expected email verification to not be required when email is disabled")
|
||||
}
|
||||
|
||||
if createdUser == nil {
|
||||
t.Fatal("expected user to be created")
|
||||
}
|
||||
@@ -85,16 +99,12 @@ func TestRegisterUserHandler_Success(t *testing.T) {
|
||||
if createdUser.Email != cmd.Email {
|
||||
t.Errorf("expected email %q, got %q", cmd.Email, createdUser.Email)
|
||||
}
|
||||
|
||||
if createdUser.Timezone != cmd.Timezone {
|
||||
t.Errorf("expected timezone %q, got %q", cmd.Timezone, createdUser.Timezone)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRegisterUserHandler_InvalidEmail(t *testing.T) {
|
||||
repo := &mockUserRepo{}
|
||||
hasher := &mockPasswordHasher{}
|
||||
handler := NewRegisterUserHandler(repo, hasher)
|
||||
handler := NewRegisterUserHandler(repo, hasher, &services.NoOpEmailService{}, "", "open", false)
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
@@ -113,11 +123,10 @@ func TestRegisterUserHandler_InvalidEmail(t *testing.T) {
|
||||
cmd := RegisterUserCommand{
|
||||
Email: tt.email,
|
||||
Password: "Secure123!",
|
||||
Timezone: "UTC",
|
||||
}
|
||||
|
||||
_, err := handler.Handle(context.Background(), cmd)
|
||||
if err != appErrors.ErrInvalidInput {
|
||||
if !errors.Is(err, appErrors.ErrInvalidInput) {
|
||||
t.Errorf("expected ErrInvalidInput, got %v", err)
|
||||
}
|
||||
})
|
||||
@@ -127,7 +136,7 @@ func TestRegisterUserHandler_InvalidEmail(t *testing.T) {
|
||||
func TestRegisterUserHandler_InvalidPassword(t *testing.T) {
|
||||
repo := &mockUserRepo{}
|
||||
hasher := &mockPasswordHasher{}
|
||||
handler := NewRegisterUserHandler(repo, hasher)
|
||||
handler := NewRegisterUserHandler(repo, hasher, &services.NoOpEmailService{}, "", "open", false)
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
@@ -148,42 +157,10 @@ func TestRegisterUserHandler_InvalidPassword(t *testing.T) {
|
||||
cmd := RegisterUserCommand{
|
||||
Email: "test@example.com",
|
||||
Password: tt.password,
|
||||
Timezone: "UTC",
|
||||
}
|
||||
|
||||
_, err := handler.Handle(context.Background(), cmd)
|
||||
if err != appErrors.ErrInvalidInput {
|
||||
t.Errorf("expected ErrInvalidInput, got %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestRegisterUserHandler_InvalidTimezone(t *testing.T) {
|
||||
repo := &mockUserRepo{}
|
||||
hasher := &mockPasswordHasher{}
|
||||
handler := NewRegisterUserHandler(repo, hasher)
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
timezone string
|
||||
}{
|
||||
{"empty timezone", ""},
|
||||
{"invalid timezone", "InvalidTimezone"},
|
||||
{"numeric format", "GMT+1"},
|
||||
{"partial timezone", "America"},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
cmd := RegisterUserCommand{
|
||||
Email: "test@example.com",
|
||||
Password: "Secure123!",
|
||||
Timezone: tt.timezone,
|
||||
}
|
||||
|
||||
_, err := handler.Handle(context.Background(), cmd)
|
||||
if err != appErrors.ErrInvalidInput {
|
||||
if !errors.Is(err, appErrors.ErrInvalidInput) {
|
||||
t.Errorf("expected ErrInvalidInput, got %v", err)
|
||||
}
|
||||
})
|
||||
@@ -191,23 +168,22 @@ func TestRegisterUserHandler_InvalidTimezone(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestRegisterUserHandler_EmailAlreadyExists(t *testing.T) {
|
||||
existingUser := entities.NewUser("test@example.com", "hashed", "UTC")
|
||||
existingUser := entities.NewUser("test@example.com", "hashed")
|
||||
repo := &mockUserRepo{
|
||||
findByEmailFunc: func(ctx context.Context, email string) (*entities.User, error) {
|
||||
return existingUser, nil
|
||||
},
|
||||
}
|
||||
hasher := &mockPasswordHasher{}
|
||||
handler := NewRegisterUserHandler(repo, hasher)
|
||||
handler := NewRegisterUserHandler(repo, hasher, &services.NoOpEmailService{}, "", "open", false)
|
||||
|
||||
cmd := RegisterUserCommand{
|
||||
Email: "test@example.com",
|
||||
Password: "Secure123!",
|
||||
Timezone: "UTC",
|
||||
}
|
||||
|
||||
_, err := handler.Handle(context.Background(), cmd)
|
||||
if err != appErrors.ErrAlreadyExists {
|
||||
if !errors.Is(err, appErrors.ErrAlreadyExists) {
|
||||
t.Errorf("expected ErrAlreadyExists, got %v", err)
|
||||
}
|
||||
}
|
||||
@@ -220,12 +196,11 @@ func TestRegisterUserHandler_PasswordHashingError(t *testing.T) {
|
||||
return "", expectedErr
|
||||
},
|
||||
}
|
||||
handler := NewRegisterUserHandler(repo, hasher)
|
||||
handler := NewRegisterUserHandler(repo, hasher, &services.NoOpEmailService{}, "", "open", false)
|
||||
|
||||
cmd := RegisterUserCommand{
|
||||
Email: "test@example.com",
|
||||
Password: "Secure123!",
|
||||
Timezone: "UTC",
|
||||
}
|
||||
|
||||
_, err := handler.Handle(context.Background(), cmd)
|
||||
@@ -242,12 +217,11 @@ func TestRegisterUserHandler_RepositoryError(t *testing.T) {
|
||||
},
|
||||
}
|
||||
hasher := &mockPasswordHasher{}
|
||||
handler := NewRegisterUserHandler(repo, hasher)
|
||||
handler := NewRegisterUserHandler(repo, hasher, &services.NoOpEmailService{}, "", "open", false)
|
||||
|
||||
cmd := RegisterUserCommand{
|
||||
Email: "test@example.com",
|
||||
Password: "Secure123!",
|
||||
Timezone: "UTC",
|
||||
}
|
||||
|
||||
_, err := handler.Handle(context.Background(), cmd)
|
||||
@@ -259,7 +233,7 @@ func TestRegisterUserHandler_RepositoryError(t *testing.T) {
|
||||
func TestRegisterUserHandler_EdgeCases(t *testing.T) {
|
||||
repo := &mockUserRepo{}
|
||||
hasher := &mockPasswordHasher{}
|
||||
handler := NewRegisterUserHandler(repo, hasher)
|
||||
handler := NewRegisterUserHandler(repo, hasher, &services.NoOpEmailService{}, "", "open", false)
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
@@ -271,7 +245,6 @@ func TestRegisterUserHandler_EdgeCases(t *testing.T) {
|
||||
RegisterUserCommand{
|
||||
Email: "user+tag@example.com",
|
||||
Password: "Secure123!",
|
||||
Timezone: "UTC",
|
||||
},
|
||||
nil,
|
||||
},
|
||||
@@ -280,16 +253,6 @@ func TestRegisterUserHandler_EdgeCases(t *testing.T) {
|
||||
RegisterUserCommand{
|
||||
Email: "user@mail.example.com",
|
||||
Password: "Secure123!",
|
||||
Timezone: "UTC",
|
||||
},
|
||||
nil,
|
||||
},
|
||||
{
|
||||
"complex timezone",
|
||||
RegisterUserCommand{
|
||||
Email: "user@example.com",
|
||||
Password: "Secure123!",
|
||||
Timezone: "America/Argentina/Buenos_Aires",
|
||||
},
|
||||
nil,
|
||||
},
|
||||
@@ -298,7 +261,6 @@ func TestRegisterUserHandler_EdgeCases(t *testing.T) {
|
||||
RegisterUserCommand{
|
||||
Email: "user@example.com",
|
||||
Password: "Sëcure123!",
|
||||
Timezone: "UTC",
|
||||
},
|
||||
nil,
|
||||
},
|
||||
@@ -307,7 +269,6 @@ func TestRegisterUserHandler_EdgeCases(t *testing.T) {
|
||||
RegisterUserCommand{
|
||||
Email: "user@example.com",
|
||||
Password: "ValidP@ss1" + string(make([]byte, 100)),
|
||||
Timezone: "UTC",
|
||||
},
|
||||
nil,
|
||||
},
|
||||
@@ -322,3 +283,34 @@ func TestRegisterUserHandler_EdgeCases(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
func TestRegisterUserHandler_ClosedRegistration(t *testing.T) {
|
||||
repo := &mockUserRepo{}
|
||||
hasher := &mockPasswordHasher{}
|
||||
handler := NewRegisterUserHandler(repo, hasher, &services.NoOpEmailService{}, "", "closed", false)
|
||||
|
||||
cmd := RegisterUserCommand{
|
||||
Email: "test@example.com",
|
||||
Password: "Secure123!",
|
||||
}
|
||||
|
||||
_, err := handler.Handle(context.Background(), cmd)
|
||||
if !errors.Is(err, appErrors.ErrRegistrationClosed) {
|
||||
t.Errorf("expected ErrRegistrationClosed, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func (m *mockUserRepo) FindActiveByUserIDWithPagination(ctx context.Context, userID string, params pagination.Params) ([]*entities.Habit, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (m *mockUserRepo) CountActiveByUserID(ctx context.Context, userID string) (int, error) {
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
func (m *mockUserRepo) FindByUserIDFiltered(ctx context.Context, userID string, filter repositories.HabitFilter, paginationParams *pagination.Params) ([]*entities.Habit, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (m *mockUserRepo) CountByUserIDFiltered(ctx context.Context, userID string, filter repositories.HabitFilter) (int, error) {
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
package commands
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"apocapoc-api/internal/domain/entities"
|
||||
"apocapoc-api/internal/domain/repositories"
|
||||
"apocapoc-api/internal/domain/services"
|
||||
"apocapoc-api/internal/shared/errors"
|
||||
)
|
||||
|
||||
type RequestPasswordResetCommand struct {
|
||||
Email string
|
||||
}
|
||||
|
||||
type RequestPasswordResetHandler struct {
|
||||
userRepo repositories.UserRepository
|
||||
passwordResetTokenRepo repositories.PasswordResetTokenRepository
|
||||
emailService services.EmailService
|
||||
appURL string
|
||||
}
|
||||
|
||||
func NewRequestPasswordResetHandler(
|
||||
userRepo repositories.UserRepository,
|
||||
passwordResetTokenRepo repositories.PasswordResetTokenRepository,
|
||||
emailService services.EmailService,
|
||||
appURL string,
|
||||
) *RequestPasswordResetHandler {
|
||||
return &RequestPasswordResetHandler{
|
||||
userRepo: userRepo,
|
||||
passwordResetTokenRepo: passwordResetTokenRepo,
|
||||
emailService: emailService,
|
||||
appURL: appURL,
|
||||
}
|
||||
}
|
||||
|
||||
func (h *RequestPasswordResetHandler) Handle(ctx context.Context, cmd RequestPasswordResetCommand) error {
|
||||
if cmd.Email == "" {
|
||||
return errors.ErrInvalidInput
|
||||
}
|
||||
|
||||
user, err := h.userRepo.FindByEmail(ctx, cmd.Email)
|
||||
if err != nil {
|
||||
return errors.ErrNotFound
|
||||
}
|
||||
|
||||
if !user.EmailVerified {
|
||||
return errors.ErrEmailNotVerified
|
||||
}
|
||||
|
||||
tokenStr, err := generatePasswordResetToken()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to generate reset token: %w", err)
|
||||
}
|
||||
|
||||
expiresAt := time.Now().Add(1 * time.Hour)
|
||||
resetToken := entities.NewPasswordResetToken(user.ID, tokenStr, expiresAt)
|
||||
|
||||
if err := h.passwordResetTokenRepo.Create(ctx, resetToken); err != nil {
|
||||
return fmt.Errorf("failed to save reset token: %w", err)
|
||||
}
|
||||
|
||||
resetLink := fmt.Sprintf("%s/reset-password?token=%s", h.appURL, tokenStr)
|
||||
|
||||
emailBody := fmt.Sprintf(`
|
||||
<h2>Password Reset Request</h2>
|
||||
<p>You requested to reset your password. Click the link below to reset it:</p>
|
||||
<p><a href="%s">Reset Password</a></p>
|
||||
<p>This link will expire in 1 hour.</p>
|
||||
<p>If you didn't request this, you can safely ignore this email.</p>
|
||||
`, resetLink)
|
||||
|
||||
message := services.EmailMessage{
|
||||
To: user.Email,
|
||||
Subject: "Password Reset Request",
|
||||
Body: emailBody,
|
||||
IsHTML: true,
|
||||
}
|
||||
|
||||
if err := h.emailService.Send(message); err != nil {
|
||||
return fmt.Errorf("failed to send reset email: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func generatePasswordResetToken() (string, error) {
|
||||
bytes := make([]byte, 32)
|
||||
if _, err := rand.Read(bytes); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return hex.EncodeToString(bytes), nil
|
||||
}
|
||||
@@ -0,0 +1,378 @@
|
||||
package commands
|
||||
|
||||
import (
|
||||
"apocapoc-api/internal/domain/repositories"
|
||||
"apocapoc-api/internal/shared/pagination"
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"apocapoc-api/internal/domain/entities"
|
||||
"apocapoc-api/internal/domain/services"
|
||||
"apocapoc-api/internal/shared/errors"
|
||||
)
|
||||
|
||||
type mockRequestResetUserRepo struct {
|
||||
findByEmailFunc func(ctx context.Context, email string) (*entities.User, error)
|
||||
users map[string]*entities.User
|
||||
}
|
||||
|
||||
func (m *mockRequestResetUserRepo) FindByEmail(ctx context.Context, email string) (*entities.User, error) {
|
||||
if m.findByEmailFunc != nil {
|
||||
return m.findByEmailFunc(ctx, email)
|
||||
}
|
||||
if user, ok := m.users[email]; ok {
|
||||
return user, nil
|
||||
}
|
||||
return nil, errors.ErrNotFound
|
||||
}
|
||||
|
||||
func (m *mockRequestResetUserRepo) FindByID(ctx context.Context, id string) (*entities.User, error) {
|
||||
return nil, errors.ErrNotFound
|
||||
}
|
||||
|
||||
func (m *mockRequestResetUserRepo) FindByVerificationToken(ctx context.Context, token string) (*entities.User, error) {
|
||||
return nil, errors.ErrNotFound
|
||||
}
|
||||
|
||||
func (m *mockRequestResetUserRepo) Create(ctx context.Context, user *entities.User) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *mockRequestResetUserRepo) Update(ctx context.Context, user *entities.User) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *mockRequestResetUserRepo) Delete(ctx context.Context, id string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
type mockRequestResetTokenRepo struct {
|
||||
createFunc func(ctx context.Context, token *entities.PasswordResetToken) error
|
||||
tokens []*entities.PasswordResetToken
|
||||
}
|
||||
|
||||
func (m *mockRequestResetTokenRepo) Create(ctx context.Context, token *entities.PasswordResetToken) error {
|
||||
if m.createFunc != nil {
|
||||
return m.createFunc(ctx, token)
|
||||
}
|
||||
m.tokens = append(m.tokens, token)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *mockRequestResetTokenRepo) FindByToken(ctx context.Context, token string) (*entities.PasswordResetToken, error) {
|
||||
return nil, errors.ErrNotFound
|
||||
}
|
||||
|
||||
func (m *mockRequestResetTokenRepo) Update(ctx context.Context, token *entities.PasswordResetToken) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *mockRequestResetTokenRepo) DeleteExpired(ctx context.Context) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
type mockRequestResetEmailService struct {
|
||||
sendFunc func(message services.EmailMessage) error
|
||||
sentMessages []services.EmailMessage
|
||||
}
|
||||
|
||||
func (m *mockRequestResetEmailService) Send(message services.EmailMessage) error {
|
||||
if m.sendFunc != nil {
|
||||
return m.sendFunc(message)
|
||||
}
|
||||
m.sentMessages = append(m.sentMessages, message)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *mockRequestResetEmailService) HealthCheck() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *mockRequestResetEmailService) IsEnabled() bool {
|
||||
return true
|
||||
}
|
||||
|
||||
func TestRequestPasswordResetHandler_Success(t *testing.T) {
|
||||
user := entities.NewUser("test@example.com", "hash")
|
||||
user.ID = "user-123"
|
||||
user.EmailVerified = true
|
||||
|
||||
userRepo := &mockRequestResetUserRepo{
|
||||
users: map[string]*entities.User{
|
||||
user.Email: user,
|
||||
},
|
||||
}
|
||||
|
||||
tokenRepo := &mockRequestResetTokenRepo{
|
||||
tokens: []*entities.PasswordResetToken{},
|
||||
}
|
||||
|
||||
emailService := &mockRequestResetEmailService{
|
||||
sentMessages: []services.EmailMessage{},
|
||||
}
|
||||
|
||||
handler := NewRequestPasswordResetHandler(userRepo, tokenRepo, emailService, "http://localhost:8080")
|
||||
|
||||
cmd := RequestPasswordResetCommand{
|
||||
Email: user.Email,
|
||||
}
|
||||
|
||||
err := handler.Handle(context.Background(), cmd)
|
||||
if err != nil {
|
||||
t.Fatalf("Handle() unexpected error = %v", err)
|
||||
}
|
||||
|
||||
if len(tokenRepo.tokens) != 1 {
|
||||
t.Fatalf("Expected 1 token created, got %d", len(tokenRepo.tokens))
|
||||
}
|
||||
|
||||
createdToken := tokenRepo.tokens[0]
|
||||
if createdToken.UserID != user.ID {
|
||||
t.Errorf("Token UserID = %v, want %v", createdToken.UserID, user.ID)
|
||||
}
|
||||
|
||||
if createdToken.Token == "" {
|
||||
t.Error("Token string is empty")
|
||||
}
|
||||
|
||||
if createdToken.ExpiresAt.Before(time.Now()) {
|
||||
t.Error("Token already expired")
|
||||
}
|
||||
|
||||
expectedExpiry := time.Now().Add(1 * time.Hour)
|
||||
diff := createdToken.ExpiresAt.Sub(expectedExpiry)
|
||||
if diff > time.Minute || diff < -time.Minute {
|
||||
t.Errorf("Token expiry = %v, expected around %v", createdToken.ExpiresAt, expectedExpiry)
|
||||
}
|
||||
|
||||
if len(emailService.sentMessages) != 1 {
|
||||
t.Fatalf("Expected 1 email sent, got %d", len(emailService.sentMessages))
|
||||
}
|
||||
|
||||
sentEmail := emailService.sentMessages[0]
|
||||
if sentEmail.To != user.Email {
|
||||
t.Errorf("Email To = %v, want %v", sentEmail.To, user.Email)
|
||||
}
|
||||
|
||||
if sentEmail.Subject != "Password Reset Request" {
|
||||
t.Errorf("Email Subject = %v, want %v", sentEmail.Subject, "Password Reset Request")
|
||||
}
|
||||
|
||||
if !sentEmail.IsHTML {
|
||||
t.Error("Email should be HTML")
|
||||
}
|
||||
|
||||
if sentEmail.Body == "" {
|
||||
t.Error("Email body is empty")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRequestPasswordResetHandler_EmptyEmail(t *testing.T) {
|
||||
handler := NewRequestPasswordResetHandler(
|
||||
&mockRequestResetUserRepo{users: make(map[string]*entities.User)},
|
||||
&mockRequestResetTokenRepo{tokens: []*entities.PasswordResetToken{}},
|
||||
&mockRequestResetEmailService{},
|
||||
"http://localhost:8080",
|
||||
)
|
||||
|
||||
cmd := RequestPasswordResetCommand{
|
||||
Email: "",
|
||||
}
|
||||
|
||||
err := handler.Handle(context.Background(), cmd)
|
||||
if err != errors.ErrInvalidInput {
|
||||
t.Errorf("Handle() error = %v, want %v", err, errors.ErrInvalidInput)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRequestPasswordResetHandler_UserNotFound(t *testing.T) {
|
||||
handler := NewRequestPasswordResetHandler(
|
||||
&mockRequestResetUserRepo{users: make(map[string]*entities.User)},
|
||||
&mockRequestResetTokenRepo{tokens: []*entities.PasswordResetToken{}},
|
||||
&mockRequestResetEmailService{},
|
||||
"http://localhost:8080",
|
||||
)
|
||||
|
||||
cmd := RequestPasswordResetCommand{
|
||||
Email: "nonexistent@example.com",
|
||||
}
|
||||
|
||||
err := handler.Handle(context.Background(), cmd)
|
||||
if err != errors.ErrNotFound {
|
||||
t.Errorf("Handle() error = %v, want %v", err, errors.ErrNotFound)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRequestPasswordResetHandler_EmailNotVerified(t *testing.T) {
|
||||
user := entities.NewUser("test@example.com", "hash")
|
||||
user.ID = "user-123"
|
||||
user.EmailVerified = false
|
||||
|
||||
userRepo := &mockRequestResetUserRepo{
|
||||
users: map[string]*entities.User{
|
||||
user.Email: user,
|
||||
},
|
||||
}
|
||||
|
||||
handler := NewRequestPasswordResetHandler(
|
||||
userRepo,
|
||||
&mockRequestResetTokenRepo{tokens: []*entities.PasswordResetToken{}},
|
||||
&mockRequestResetEmailService{},
|
||||
"http://localhost:8080",
|
||||
)
|
||||
|
||||
cmd := RequestPasswordResetCommand{
|
||||
Email: user.Email,
|
||||
}
|
||||
|
||||
err := handler.Handle(context.Background(), cmd)
|
||||
if err != errors.ErrEmailNotVerified {
|
||||
t.Errorf("Handle() error = %v, want %v", err, errors.ErrEmailNotVerified)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRequestPasswordResetHandler_TokenCreationFailure(t *testing.T) {
|
||||
user := entities.NewUser("test@example.com", "hash")
|
||||
user.ID = "user-123"
|
||||
user.EmailVerified = true
|
||||
|
||||
userRepo := &mockRequestResetUserRepo{
|
||||
users: map[string]*entities.User{
|
||||
user.Email: user,
|
||||
},
|
||||
}
|
||||
|
||||
tokenRepo := &mockRequestResetTokenRepo{
|
||||
createFunc: func(ctx context.Context, token *entities.PasswordResetToken) error {
|
||||
return errors.ErrInvalidInput
|
||||
},
|
||||
}
|
||||
|
||||
emailService := &mockRequestResetEmailService{}
|
||||
|
||||
handler := NewRequestPasswordResetHandler(userRepo, tokenRepo, emailService, "http://localhost:8080")
|
||||
|
||||
cmd := RequestPasswordResetCommand{
|
||||
Email: user.Email,
|
||||
}
|
||||
|
||||
err := handler.Handle(context.Background(), cmd)
|
||||
if err == nil {
|
||||
t.Fatal("Handle() expected error but got nil")
|
||||
}
|
||||
|
||||
if len(emailService.sentMessages) != 0 {
|
||||
t.Error("Email should not be sent if token creation fails")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRequestPasswordResetHandler_EmailSendFailure(t *testing.T) {
|
||||
user := entities.NewUser("test@example.com", "hash")
|
||||
user.ID = "user-123"
|
||||
user.EmailVerified = true
|
||||
|
||||
userRepo := &mockRequestResetUserRepo{
|
||||
users: map[string]*entities.User{
|
||||
user.Email: user,
|
||||
},
|
||||
}
|
||||
|
||||
tokenRepo := &mockRequestResetTokenRepo{
|
||||
tokens: []*entities.PasswordResetToken{},
|
||||
}
|
||||
|
||||
emailService := &mockRequestResetEmailService{
|
||||
sendFunc: func(message services.EmailMessage) error {
|
||||
return errors.ErrInvalidInput
|
||||
},
|
||||
}
|
||||
|
||||
handler := NewRequestPasswordResetHandler(userRepo, tokenRepo, emailService, "http://localhost:8080")
|
||||
|
||||
cmd := RequestPasswordResetCommand{
|
||||
Email: user.Email,
|
||||
}
|
||||
|
||||
err := handler.Handle(context.Background(), cmd)
|
||||
if err == nil {
|
||||
t.Fatal("Handle() expected error but got nil")
|
||||
}
|
||||
|
||||
if len(tokenRepo.tokens) != 1 {
|
||||
t.Error("Token should be created even if email sending fails")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRequestPasswordResetHandler_ResetLinkFormat(t *testing.T) {
|
||||
user := entities.NewUser("test@example.com", "hash")
|
||||
user.ID = "user-123"
|
||||
user.EmailVerified = true
|
||||
|
||||
userRepo := &mockRequestResetUserRepo{
|
||||
users: map[string]*entities.User{
|
||||
user.Email: user,
|
||||
},
|
||||
}
|
||||
|
||||
tokenRepo := &mockRequestResetTokenRepo{
|
||||
tokens: []*entities.PasswordResetToken{},
|
||||
}
|
||||
|
||||
emailService := &mockRequestResetEmailService{
|
||||
sentMessages: []services.EmailMessage{},
|
||||
}
|
||||
|
||||
appURL := "https://myapp.com"
|
||||
handler := NewRequestPasswordResetHandler(userRepo, tokenRepo, emailService, appURL)
|
||||
|
||||
cmd := RequestPasswordResetCommand{
|
||||
Email: user.Email,
|
||||
}
|
||||
|
||||
err := handler.Handle(context.Background(), cmd)
|
||||
if err != nil {
|
||||
t.Fatalf("Handle() unexpected error = %v", err)
|
||||
}
|
||||
|
||||
if len(emailService.sentMessages) != 1 {
|
||||
t.Fatal("Expected 1 email sent")
|
||||
}
|
||||
|
||||
sentEmail := emailService.sentMessages[0]
|
||||
if sentEmail.Body == "" {
|
||||
t.Fatal("Email body is empty")
|
||||
}
|
||||
}
|
||||
|
||||
func (m *mockRequestResetTokenRepo) FindActiveByUserIDWithPagination(ctx context.Context, userID string, params pagination.Params) ([]*entities.Habit, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (m *mockRequestResetTokenRepo) CountActiveByUserID(ctx context.Context, userID string) (int, error) {
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
func (m *mockRequestResetUserRepo) FindActiveByUserIDWithPagination(ctx context.Context, userID string, params pagination.Params) ([]*entities.Habit, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (m *mockRequestResetUserRepo) CountActiveByUserID(ctx context.Context, userID string) (int, error) {
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
func (m *mockRequestResetTokenRepo) FindByUserIDFiltered(ctx context.Context, userID string, filter repositories.HabitFilter, paginationParams *pagination.Params) ([]*entities.Habit, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (m *mockRequestResetTokenRepo) CountByUserIDFiltered(ctx context.Context, userID string, filter repositories.HabitFilter) (int, error) {
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
func (m *mockRequestResetUserRepo) FindByUserIDFiltered(ctx context.Context, userID string, filter repositories.HabitFilter, paginationParams *pagination.Params) ([]*entities.Habit, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (m *mockRequestResetUserRepo) CountByUserIDFiltered(ctx context.Context, userID string, filter repositories.HabitFilter) (int, error) {
|
||||
return 0, nil
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
package commands
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"apocapoc-api/internal/domain/repositories"
|
||||
"apocapoc-api/internal/domain/services"
|
||||
"apocapoc-api/internal/shared/errors"
|
||||
)
|
||||
|
||||
type ResendVerificationEmailCommand struct {
|
||||
Email string
|
||||
}
|
||||
|
||||
type ResendVerificationEmailHandler struct {
|
||||
userRepo repositories.UserRepository
|
||||
emailService services.EmailService
|
||||
appURL string
|
||||
}
|
||||
|
||||
func NewResendVerificationEmailHandler(
|
||||
userRepo repositories.UserRepository,
|
||||
emailService services.EmailService,
|
||||
appURL string,
|
||||
) *ResendVerificationEmailHandler {
|
||||
return &ResendVerificationEmailHandler{
|
||||
userRepo: userRepo,
|
||||
emailService: emailService,
|
||||
appURL: appURL,
|
||||
}
|
||||
}
|
||||
|
||||
func (h *ResendVerificationEmailHandler) Handle(ctx context.Context, cmd ResendVerificationEmailCommand) error {
|
||||
if cmd.Email == "" {
|
||||
return errors.ErrInvalidInput
|
||||
}
|
||||
|
||||
user, err := h.userRepo.FindByEmail(ctx, cmd.Email)
|
||||
if err != nil {
|
||||
return errors.ErrNotFound
|
||||
}
|
||||
|
||||
if user.EmailVerified {
|
||||
return errors.ErrAlreadyExists
|
||||
}
|
||||
|
||||
token, err := generateVerificationToken()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to generate verification token: %w", err)
|
||||
}
|
||||
|
||||
expiry := time.Now().Add(24 * time.Hour)
|
||||
user.EmailVerificationToken = &token
|
||||
user.EmailVerificationExpiry = &expiry
|
||||
user.UpdatedAt = time.Now()
|
||||
|
||||
if err := h.userRepo.Update(ctx, user); err != nil {
|
||||
return fmt.Errorf("failed to update user: %w", err)
|
||||
}
|
||||
|
||||
verificationLink := fmt.Sprintf("%s/verify-email?token=%s", h.appURL, token)
|
||||
|
||||
emailBody := fmt.Sprintf(`
|
||||
<h2>Verify your email address</h2>
|
||||
<p>Please click the link below to verify your email address:</p>
|
||||
<p><a href="%s">Verify Email</a></p>
|
||||
<p>This link will expire in 24 hours.</p>
|
||||
<p>If you didn't create an account, you can safely ignore this email.</p>
|
||||
`, verificationLink)
|
||||
|
||||
message := services.EmailMessage{
|
||||
To: user.Email,
|
||||
Subject: "Verify your email address",
|
||||
Body: emailBody,
|
||||
IsHTML: true,
|
||||
}
|
||||
|
||||
if err := h.emailService.Send(message); err != nil {
|
||||
return fmt.Errorf("failed to send verification email: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func generateVerificationToken() (string, error) {
|
||||
bytes := make([]byte, 32)
|
||||
if _, err := rand.Read(bytes); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return hex.EncodeToString(bytes), nil
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
package commands
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"apocapoc-api/internal/domain/repositories"
|
||||
"apocapoc-api/internal/domain/services"
|
||||
"apocapoc-api/internal/shared/errors"
|
||||
"apocapoc-api/internal/shared/validation"
|
||||
)
|
||||
|
||||
type ResetPasswordCommand struct {
|
||||
Token string
|
||||
NewPassword string
|
||||
}
|
||||
|
||||
type ResetPasswordHandler struct {
|
||||
userRepo repositories.UserRepository
|
||||
passwordResetTokenRepo repositories.PasswordResetTokenRepository
|
||||
passwordHasher services.PasswordHasher
|
||||
}
|
||||
|
||||
func NewResetPasswordHandler(
|
||||
userRepo repositories.UserRepository,
|
||||
passwordResetTokenRepo repositories.PasswordResetTokenRepository,
|
||||
passwordHasher services.PasswordHasher,
|
||||
) *ResetPasswordHandler {
|
||||
return &ResetPasswordHandler{
|
||||
userRepo: userRepo,
|
||||
passwordResetTokenRepo: passwordResetTokenRepo,
|
||||
passwordHasher: passwordHasher,
|
||||
}
|
||||
}
|
||||
|
||||
func (h *ResetPasswordHandler) Handle(ctx context.Context, cmd ResetPasswordCommand) error {
|
||||
if cmd.Token == "" || cmd.NewPassword == "" {
|
||||
return errors.ErrInvalidInput
|
||||
}
|
||||
|
||||
if err := validation.ValidatePassword(cmd.NewPassword); err != nil {
|
||||
return errors.ErrInvalidInput
|
||||
}
|
||||
|
||||
resetToken, err := h.passwordResetTokenRepo.FindByToken(ctx, cmd.Token)
|
||||
if err != nil {
|
||||
return errors.ErrInvalidInput
|
||||
}
|
||||
|
||||
if resetToken.IsExpired() {
|
||||
return errors.ErrInvalidInput
|
||||
}
|
||||
|
||||
if resetToken.IsUsed() {
|
||||
return errors.ErrInvalidInput
|
||||
}
|
||||
|
||||
user, err := h.userRepo.FindByID(ctx, resetToken.UserID)
|
||||
if err != nil {
|
||||
return errors.ErrNotFound
|
||||
}
|
||||
|
||||
hashedPassword, err := h.passwordHasher.Hash(cmd.NewPassword)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to hash password: %w", err)
|
||||
}
|
||||
|
||||
user.PasswordHash = hashedPassword
|
||||
|
||||
if err := h.userRepo.Update(ctx, user); err != nil {
|
||||
return fmt.Errorf("failed to update password: %w", err)
|
||||
}
|
||||
|
||||
resetToken.MarkAsUsed()
|
||||
if err := h.passwordResetTokenRepo.Update(ctx, resetToken); err != nil {
|
||||
return fmt.Errorf("failed to mark token as used: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,415 @@
|
||||
package commands
|
||||
|
||||
import (
|
||||
"apocapoc-api/internal/domain/repositories"
|
||||
"apocapoc-api/internal/shared/pagination"
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"apocapoc-api/internal/domain/entities"
|
||||
"apocapoc-api/internal/shared/errors"
|
||||
)
|
||||
|
||||
type mockResetPasswordUserRepo struct {
|
||||
findByIDFunc func(ctx context.Context, id string) (*entities.User, error)
|
||||
updateFunc func(ctx context.Context, user *entities.User) error
|
||||
users map[string]*entities.User
|
||||
}
|
||||
|
||||
func (m *mockResetPasswordUserRepo) FindByID(ctx context.Context, id string) (*entities.User, error) {
|
||||
if m.findByIDFunc != nil {
|
||||
return m.findByIDFunc(ctx, id)
|
||||
}
|
||||
if user, ok := m.users[id]; ok {
|
||||
return user, nil
|
||||
}
|
||||
return nil, errors.ErrNotFound
|
||||
}
|
||||
|
||||
func (m *mockResetPasswordUserRepo) Update(ctx context.Context, user *entities.User) error {
|
||||
if m.updateFunc != nil {
|
||||
return m.updateFunc(ctx, user)
|
||||
}
|
||||
m.users[user.ID] = user
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *mockResetPasswordUserRepo) FindByEmail(ctx context.Context, email string) (*entities.User, error) {
|
||||
return nil, errors.ErrNotFound
|
||||
}
|
||||
|
||||
func (m *mockResetPasswordUserRepo) FindByVerificationToken(ctx context.Context, token string) (*entities.User, error) {
|
||||
return nil, errors.ErrNotFound
|
||||
}
|
||||
|
||||
func (m *mockResetPasswordUserRepo) Create(ctx context.Context, user *entities.User) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *mockResetPasswordUserRepo) Delete(ctx context.Context, id string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
type mockPasswordResetTokenRepo struct {
|
||||
findByTokenFunc func(ctx context.Context, token string) (*entities.PasswordResetToken, error)
|
||||
updateFunc func(ctx context.Context, token *entities.PasswordResetToken) error
|
||||
tokens map[string]*entities.PasswordResetToken
|
||||
}
|
||||
|
||||
func (m *mockPasswordResetTokenRepo) Create(ctx context.Context, token *entities.PasswordResetToken) error {
|
||||
m.tokens[token.Token] = token
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *mockPasswordResetTokenRepo) FindByToken(ctx context.Context, token string) (*entities.PasswordResetToken, error) {
|
||||
if m.findByTokenFunc != nil {
|
||||
return m.findByTokenFunc(ctx, token)
|
||||
}
|
||||
if t, ok := m.tokens[token]; ok {
|
||||
return t, nil
|
||||
}
|
||||
return nil, errors.ErrNotFound
|
||||
}
|
||||
|
||||
func (m *mockPasswordResetTokenRepo) Update(ctx context.Context, token *entities.PasswordResetToken) error {
|
||||
if m.updateFunc != nil {
|
||||
return m.updateFunc(ctx, token)
|
||||
}
|
||||
m.tokens[token.Token] = token
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *mockPasswordResetTokenRepo) DeleteExpired(ctx context.Context) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
type mockResetPasswordHasher struct {
|
||||
hashFunc func(password string) (string, error)
|
||||
}
|
||||
|
||||
func (m *mockResetPasswordHasher) Hash(password string) (string, error) {
|
||||
if m.hashFunc != nil {
|
||||
return m.hashFunc(password)
|
||||
}
|
||||
return "hashed_" + password, nil
|
||||
}
|
||||
|
||||
func (m *mockResetPasswordHasher) Compare(hashedPassword, password string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func TestResetPasswordHandler_Success(t *testing.T) {
|
||||
user := entities.NewUser("test@example.com", "old_hash")
|
||||
user.ID = "user-123"
|
||||
|
||||
resetToken := entities.NewPasswordResetToken(
|
||||
user.ID,
|
||||
"reset-token",
|
||||
time.Now().Add(1*time.Hour),
|
||||
)
|
||||
|
||||
var updatedUser *entities.User
|
||||
var updatedToken *entities.PasswordResetToken
|
||||
|
||||
userRepo := &mockResetPasswordUserRepo{
|
||||
users: map[string]*entities.User{
|
||||
user.ID: user,
|
||||
},
|
||||
updateFunc: func(ctx context.Context, u *entities.User) error {
|
||||
updatedUser = u
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
tokenRepo := &mockPasswordResetTokenRepo{
|
||||
tokens: map[string]*entities.PasswordResetToken{
|
||||
resetToken.Token: resetToken,
|
||||
},
|
||||
updateFunc: func(ctx context.Context, t *entities.PasswordResetToken) error {
|
||||
updatedToken = t
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
hasher := &mockResetPasswordHasher{}
|
||||
|
||||
handler := NewResetPasswordHandler(userRepo, tokenRepo, hasher)
|
||||
|
||||
cmd := ResetPasswordCommand{
|
||||
Token: "reset-token",
|
||||
NewPassword: "NewP@ssw0rd123",
|
||||
}
|
||||
|
||||
err := handler.Handle(context.Background(), cmd)
|
||||
if err != nil {
|
||||
t.Fatalf("Handle() unexpected error = %v", err)
|
||||
}
|
||||
|
||||
if updatedUser == nil {
|
||||
t.Fatal("User was not updated")
|
||||
}
|
||||
|
||||
if updatedUser.PasswordHash != "hashed_NewP@ssw0rd123" {
|
||||
t.Errorf("PasswordHash = %v, want %v", updatedUser.PasswordHash, "hashed_NewP@ssw0rd123")
|
||||
}
|
||||
|
||||
if updatedToken == nil {
|
||||
t.Fatal("Token was not updated")
|
||||
}
|
||||
|
||||
if !updatedToken.IsUsed() {
|
||||
t.Error("Token should be marked as used")
|
||||
}
|
||||
}
|
||||
|
||||
func TestResetPasswordHandler_EmptyToken(t *testing.T) {
|
||||
handler := NewResetPasswordHandler(
|
||||
&mockResetPasswordUserRepo{users: make(map[string]*entities.User)},
|
||||
&mockPasswordResetTokenRepo{tokens: make(map[string]*entities.PasswordResetToken)},
|
||||
&mockResetPasswordHasher{},
|
||||
)
|
||||
|
||||
cmd := ResetPasswordCommand{
|
||||
Token: "",
|
||||
NewPassword: "NewP@ssw0rd123",
|
||||
}
|
||||
|
||||
err := handler.Handle(context.Background(), cmd)
|
||||
if err != errors.ErrInvalidInput {
|
||||
t.Errorf("Handle() error = %v, want %v", err, errors.ErrInvalidInput)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResetPasswordHandler_EmptyPassword(t *testing.T) {
|
||||
handler := NewResetPasswordHandler(
|
||||
&mockResetPasswordUserRepo{users: make(map[string]*entities.User)},
|
||||
&mockPasswordResetTokenRepo{tokens: make(map[string]*entities.PasswordResetToken)},
|
||||
&mockResetPasswordHasher{},
|
||||
)
|
||||
|
||||
cmd := ResetPasswordCommand{
|
||||
Token: "reset-token",
|
||||
NewPassword: "",
|
||||
}
|
||||
|
||||
err := handler.Handle(context.Background(), cmd)
|
||||
if err != errors.ErrInvalidInput {
|
||||
t.Errorf("Handle() error = %v, want %v", err, errors.ErrInvalidInput)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResetPasswordHandler_InvalidPassword(t *testing.T) {
|
||||
handler := NewResetPasswordHandler(
|
||||
&mockResetPasswordUserRepo{users: make(map[string]*entities.User)},
|
||||
&mockPasswordResetTokenRepo{tokens: make(map[string]*entities.PasswordResetToken)},
|
||||
&mockResetPasswordHasher{},
|
||||
)
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
password string
|
||||
}{
|
||||
{"too short", "Short1!"},
|
||||
{"no uppercase", "password123!"},
|
||||
{"no lowercase", "PASSWORD123!"},
|
||||
{"no digit", "Password!"},
|
||||
{"no special char", "Password123"},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
cmd := ResetPasswordCommand{
|
||||
Token: "reset-token",
|
||||
NewPassword: tt.password,
|
||||
}
|
||||
|
||||
err := handler.Handle(context.Background(), cmd)
|
||||
if err != errors.ErrInvalidInput {
|
||||
t.Errorf("Handle() error = %v, want %v", err, errors.ErrInvalidInput)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestResetPasswordHandler_TokenNotFound(t *testing.T) {
|
||||
handler := NewResetPasswordHandler(
|
||||
&mockResetPasswordUserRepo{users: make(map[string]*entities.User)},
|
||||
&mockPasswordResetTokenRepo{tokens: make(map[string]*entities.PasswordResetToken)},
|
||||
&mockResetPasswordHasher{},
|
||||
)
|
||||
|
||||
cmd := ResetPasswordCommand{
|
||||
Token: "non-existent-token",
|
||||
NewPassword: "NewP@ssw0rd123",
|
||||
}
|
||||
|
||||
err := handler.Handle(context.Background(), cmd)
|
||||
if err != errors.ErrInvalidInput {
|
||||
t.Errorf("Handle() error = %v, want %v", err, errors.ErrInvalidInput)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResetPasswordHandler_ExpiredToken(t *testing.T) {
|
||||
resetToken := entities.NewPasswordResetToken(
|
||||
"user-123",
|
||||
"expired-token",
|
||||
time.Now().Add(-1*time.Hour),
|
||||
)
|
||||
|
||||
tokenRepo := &mockPasswordResetTokenRepo{
|
||||
tokens: map[string]*entities.PasswordResetToken{
|
||||
resetToken.Token: resetToken,
|
||||
},
|
||||
}
|
||||
|
||||
handler := NewResetPasswordHandler(
|
||||
&mockResetPasswordUserRepo{users: make(map[string]*entities.User)},
|
||||
tokenRepo,
|
||||
&mockResetPasswordHasher{},
|
||||
)
|
||||
|
||||
cmd := ResetPasswordCommand{
|
||||
Token: "expired-token",
|
||||
NewPassword: "NewP@ssw0rd123",
|
||||
}
|
||||
|
||||
err := handler.Handle(context.Background(), cmd)
|
||||
if err != errors.ErrInvalidInput {
|
||||
t.Errorf("Handle() error = %v, want %v", err, errors.ErrInvalidInput)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResetPasswordHandler_UsedToken(t *testing.T) {
|
||||
resetToken := entities.NewPasswordResetToken(
|
||||
"user-123",
|
||||
"used-token",
|
||||
time.Now().Add(1*time.Hour),
|
||||
)
|
||||
resetToken.MarkAsUsed()
|
||||
|
||||
tokenRepo := &mockPasswordResetTokenRepo{
|
||||
tokens: map[string]*entities.PasswordResetToken{
|
||||
resetToken.Token: resetToken,
|
||||
},
|
||||
}
|
||||
|
||||
handler := NewResetPasswordHandler(
|
||||
&mockResetPasswordUserRepo{users: make(map[string]*entities.User)},
|
||||
tokenRepo,
|
||||
&mockResetPasswordHasher{},
|
||||
)
|
||||
|
||||
cmd := ResetPasswordCommand{
|
||||
Token: "used-token",
|
||||
NewPassword: "NewP@ssw0rd123",
|
||||
}
|
||||
|
||||
err := handler.Handle(context.Background(), cmd)
|
||||
if err != errors.ErrInvalidInput {
|
||||
t.Errorf("Handle() error = %v, want %v", err, errors.ErrInvalidInput)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResetPasswordHandler_UserNotFound(t *testing.T) {
|
||||
resetToken := entities.NewPasswordResetToken(
|
||||
"non-existent-user",
|
||||
"reset-token",
|
||||
time.Now().Add(1*time.Hour),
|
||||
)
|
||||
|
||||
tokenRepo := &mockPasswordResetTokenRepo{
|
||||
tokens: map[string]*entities.PasswordResetToken{
|
||||
resetToken.Token: resetToken,
|
||||
},
|
||||
}
|
||||
|
||||
userRepo := &mockResetPasswordUserRepo{
|
||||
users: make(map[string]*entities.User),
|
||||
}
|
||||
|
||||
handler := NewResetPasswordHandler(userRepo, tokenRepo, &mockResetPasswordHasher{})
|
||||
|
||||
cmd := ResetPasswordCommand{
|
||||
Token: "reset-token",
|
||||
NewPassword: "NewP@ssw0rd123",
|
||||
}
|
||||
|
||||
err := handler.Handle(context.Background(), cmd)
|
||||
if err != errors.ErrNotFound {
|
||||
t.Errorf("Handle() error = %v, want %v", err, errors.ErrNotFound)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResetPasswordHandler_HashingError(t *testing.T) {
|
||||
user := entities.NewUser("test@example.com", "old_hash")
|
||||
user.ID = "user-123"
|
||||
|
||||
resetToken := entities.NewPasswordResetToken(
|
||||
user.ID,
|
||||
"reset-token",
|
||||
time.Now().Add(1*time.Hour),
|
||||
)
|
||||
|
||||
userRepo := &mockResetPasswordUserRepo{
|
||||
users: map[string]*entities.User{
|
||||
user.ID: user,
|
||||
},
|
||||
}
|
||||
|
||||
tokenRepo := &mockPasswordResetTokenRepo{
|
||||
tokens: map[string]*entities.PasswordResetToken{
|
||||
resetToken.Token: resetToken,
|
||||
},
|
||||
}
|
||||
|
||||
hasher := &mockResetPasswordHasher{
|
||||
hashFunc: func(password string) (string, error) {
|
||||
return "", errors.ErrInvalidInput
|
||||
},
|
||||
}
|
||||
|
||||
handler := NewResetPasswordHandler(userRepo, tokenRepo, hasher)
|
||||
|
||||
cmd := ResetPasswordCommand{
|
||||
Token: "reset-token",
|
||||
NewPassword: "NewP@ssw0rd123",
|
||||
}
|
||||
|
||||
err := handler.Handle(context.Background(), cmd)
|
||||
if err == nil {
|
||||
t.Fatal("Handle() expected error but got nil")
|
||||
}
|
||||
}
|
||||
|
||||
func (m *mockPasswordResetTokenRepo) FindActiveByUserIDWithPagination(ctx context.Context, userID string, params pagination.Params) ([]*entities.Habit, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (m *mockPasswordResetTokenRepo) CountActiveByUserID(ctx context.Context, userID string) (int, error) {
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
func (m *mockResetPasswordUserRepo) FindActiveByUserIDWithPagination(ctx context.Context, userID string, params pagination.Params) ([]*entities.Habit, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (m *mockResetPasswordUserRepo) CountActiveByUserID(ctx context.Context, userID string) (int, error) {
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
func (m *mockPasswordResetTokenRepo) FindByUserIDFiltered(ctx context.Context, userID string, filter repositories.HabitFilter, paginationParams *pagination.Params) ([]*entities.Habit, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (m *mockPasswordResetTokenRepo) CountByUserIDFiltered(ctx context.Context, userID string, filter repositories.HabitFilter) (int, error) {
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
func (m *mockResetPasswordUserRepo) FindByUserIDFiltered(ctx context.Context, userID string, filter repositories.HabitFilter, paginationParams *pagination.Params) ([]*entities.Habit, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (m *mockResetPasswordUserRepo) CountByUserIDFiltered(ctx context.Context, userID string, filter repositories.HabitFilter) (int, error) {
|
||||
return 0, nil
|
||||
}
|
||||
@@ -0,0 +1,206 @@
|
||||
package commands
|
||||
|
||||
import (
|
||||
"apocapoc-api/internal/domain/repositories"
|
||||
"apocapoc-api/internal/shared/pagination"
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"apocapoc-api/internal/domain/entities"
|
||||
"apocapoc-api/internal/shared/errors"
|
||||
)
|
||||
|
||||
type mockRefreshTokenRepo struct {
|
||||
tokens map[string]*entities.RefreshToken
|
||||
}
|
||||
|
||||
func (m *mockRefreshTokenRepo) Create(ctx context.Context, token *entities.RefreshToken) error {
|
||||
m.tokens[token.Token] = token
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *mockRefreshTokenRepo) FindByToken(ctx context.Context, token string) (*entities.RefreshToken, error) {
|
||||
if t, ok := m.tokens[token]; ok {
|
||||
return t, nil
|
||||
}
|
||||
return nil, errors.ErrNotFound
|
||||
}
|
||||
|
||||
func (m *mockRefreshTokenRepo) FindByUserID(ctx context.Context, userID string) ([]*entities.RefreshToken, error) {
|
||||
var tokens []*entities.RefreshToken
|
||||
for _, t := range m.tokens {
|
||||
if t.UserID == userID {
|
||||
tokens = append(tokens, t)
|
||||
}
|
||||
}
|
||||
return tokens, nil
|
||||
}
|
||||
|
||||
func (m *mockRefreshTokenRepo) RevokeByToken(ctx context.Context, token string) error {
|
||||
if t, ok := m.tokens[token]; ok {
|
||||
t.Revoke()
|
||||
return nil
|
||||
}
|
||||
return errors.ErrNotFound
|
||||
}
|
||||
|
||||
func (m *mockRefreshTokenRepo) RevokeAllByUserID(ctx context.Context, userID string) error {
|
||||
for _, t := range m.tokens {
|
||||
if t.UserID == userID {
|
||||
t.Revoke()
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *mockRefreshTokenRepo) DeleteExpired(ctx context.Context) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func TestRevokeTokenHandler_Handle(t *testing.T) {
|
||||
repo := &mockRefreshTokenRepo{
|
||||
tokens: make(map[string]*entities.RefreshToken),
|
||||
}
|
||||
|
||||
handler := NewRevokeTokenHandler(repo)
|
||||
|
||||
token := entities.NewRefreshToken("user-123", "valid-token", time.Now().Add(24*time.Hour))
|
||||
repo.Create(context.Background(), token)
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
cmd RevokeTokenCommand
|
||||
expectError bool
|
||||
expectedErr error
|
||||
}{
|
||||
{
|
||||
name: "revoke valid token",
|
||||
cmd: RevokeTokenCommand{
|
||||
RefreshToken: "valid-token",
|
||||
},
|
||||
expectError: false,
|
||||
},
|
||||
{
|
||||
name: "revoke empty token",
|
||||
cmd: RevokeTokenCommand{
|
||||
RefreshToken: "",
|
||||
},
|
||||
expectError: true,
|
||||
expectedErr: errors.ErrInvalidInput,
|
||||
},
|
||||
{
|
||||
name: "revoke non-existent token",
|
||||
cmd: RevokeTokenCommand{
|
||||
RefreshToken: "non-existent-token",
|
||||
},
|
||||
expectError: true,
|
||||
expectedErr: errors.ErrNotFound,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
err := handler.Handle(context.Background(), tt.cmd)
|
||||
|
||||
if tt.expectError {
|
||||
if err == nil {
|
||||
t.Fatal("Handle() expected error but got nil")
|
||||
}
|
||||
if tt.expectedErr != nil && err != tt.expectedErr {
|
||||
t.Errorf("Handle() error = %v, want %v", err, tt.expectedErr)
|
||||
}
|
||||
} else {
|
||||
if err != nil {
|
||||
t.Fatalf("Handle() unexpected error = %v", err)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestRevokeAllTokensHandler_Handle(t *testing.T) {
|
||||
repo := &mockRefreshTokenRepo{
|
||||
tokens: make(map[string]*entities.RefreshToken),
|
||||
}
|
||||
|
||||
handler := NewRevokeAllTokensHandler(repo)
|
||||
|
||||
token1 := entities.NewRefreshToken("user-123", "token-1", time.Now().Add(24*time.Hour))
|
||||
token2 := entities.NewRefreshToken("user-123", "token-2", time.Now().Add(24*time.Hour))
|
||||
token3 := entities.NewRefreshToken("user-456", "token-3", time.Now().Add(24*time.Hour))
|
||||
|
||||
repo.Create(context.Background(), token1)
|
||||
repo.Create(context.Background(), token2)
|
||||
repo.Create(context.Background(), token3)
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
cmd RevokeAllTokensCommand
|
||||
expectError bool
|
||||
expectedErr error
|
||||
}{
|
||||
{
|
||||
name: "revoke all tokens for user",
|
||||
cmd: RevokeAllTokensCommand{
|
||||
UserID: "user-123",
|
||||
},
|
||||
expectError: false,
|
||||
},
|
||||
{
|
||||
name: "revoke with empty user ID",
|
||||
cmd: RevokeAllTokensCommand{
|
||||
UserID: "",
|
||||
},
|
||||
expectError: true,
|
||||
expectedErr: errors.ErrInvalidInput,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
err := handler.Handle(context.Background(), tt.cmd)
|
||||
|
||||
if tt.expectError {
|
||||
if err == nil {
|
||||
t.Fatal("Handle() expected error but got nil")
|
||||
}
|
||||
if tt.expectedErr != nil && err != tt.expectedErr {
|
||||
t.Errorf("Handle() error = %v, want %v", err, tt.expectedErr)
|
||||
}
|
||||
} else {
|
||||
if err != nil {
|
||||
t.Fatalf("Handle() unexpected error = %v", err)
|
||||
}
|
||||
|
||||
if !tt.expectError && tt.cmd.UserID == "user-123" {
|
||||
if repo.tokens["token-1"].RevokedAt == nil {
|
||||
t.Error("token-1 should be revoked")
|
||||
}
|
||||
if repo.tokens["token-2"].RevokedAt == nil {
|
||||
t.Error("token-2 should be revoked")
|
||||
}
|
||||
if repo.tokens["token-3"].RevokedAt != nil {
|
||||
t.Error("token-3 should not be revoked")
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func (m *mockRefreshTokenRepo) FindActiveByUserIDWithPagination(ctx context.Context, userID string, params pagination.Params) ([]*entities.Habit, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (m *mockRefreshTokenRepo) CountActiveByUserID(ctx context.Context, userID string) (int, error) {
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
func (m *mockRefreshTokenRepo) FindByUserIDFiltered(ctx context.Context, userID string, filter repositories.HabitFilter, paginationParams *pagination.Params) ([]*entities.Habit, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (m *mockRefreshTokenRepo) CountByUserIDFiltered(ctx context.Context, userID string, filter repositories.HabitFilter) (int, error) {
|
||||
return 0, nil
|
||||
}
|
||||
@@ -1,6 +1,8 @@
|
||||
package commands
|
||||
|
||||
import (
|
||||
"apocapoc-api/internal/domain/repositories"
|
||||
"apocapoc-api/internal/shared/pagination"
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
@@ -137,3 +139,31 @@ func TestUnmarkHabitHandler_ReturnsErrorWhenEntryNotFound(t *testing.T) {
|
||||
t.Errorf("Expected ErrNotFound for missing entry, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func (m *mockEntryRepoForUnmark) FindActiveByUserIDWithPagination(ctx context.Context, userID string, params pagination.Params) ([]*entities.Habit, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (m *mockEntryRepoForUnmark) CountActiveByUserID(ctx context.Context, userID string) (int, error) {
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
func (m *mockEntryRepoForUnmark) FindByUserIDFiltered(ctx context.Context, userID string, filter repositories.HabitFilter, paginationParams *pagination.Params) ([]*entities.Habit, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (m *mockEntryRepoForUnmark) CountByUserIDFiltered(ctx context.Context, userID string, filter repositories.HabitFilter) (int, error) {
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
func (m *mockEntryRepoForUnmark) GetChangesSince(ctx context.Context, userID string, since time.Time) (*repositories.HabitEntryChanges, error) {
|
||||
return &repositories.HabitEntryChanges{
|
||||
Created: []*entities.HabitEntry{},
|
||||
Updated: []*entities.HabitEntry{},
|
||||
Deleted: []string{},
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (m *mockEntryRepoForUnmark) SoftDelete(ctx context.Context, id string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"strings"
|
||||
|
||||
"apocapoc-api/internal/domain/repositories"
|
||||
"apocapoc-api/internal/domain/value_objects"
|
||||
"apocapoc-api/internal/shared/errors"
|
||||
)
|
||||
|
||||
@@ -13,10 +14,11 @@ type UpdateHabitCommand struct {
|
||||
UserID string
|
||||
Name string
|
||||
Description string
|
||||
CarryOver bool
|
||||
TargetValue *float64
|
||||
Frequency value_objects.Frequency
|
||||
SpecificDays []int
|
||||
SpecificDates []int
|
||||
CarryOver bool
|
||||
TargetValue *float64
|
||||
}
|
||||
|
||||
type UpdateHabitHandler struct {
|
||||
@@ -34,6 +36,18 @@ func (h *UpdateHabitHandler) Handle(ctx context.Context, cmd UpdateHabitCommand)
|
||||
return errors.ErrInvalidInput
|
||||
}
|
||||
|
||||
if !cmd.Frequency.IsValid() {
|
||||
return errors.ErrInvalidInput
|
||||
}
|
||||
|
||||
if cmd.Frequency == value_objects.FrequencyWeekly && len(cmd.SpecificDays) == 0 {
|
||||
return errors.ErrInvalidInput
|
||||
}
|
||||
|
||||
if cmd.Frequency == value_objects.FrequencyMonthly && len(cmd.SpecificDates) == 0 {
|
||||
return errors.ErrInvalidInput
|
||||
}
|
||||
|
||||
habit, err := h.habitRepo.FindByID(ctx, cmd.HabitID)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -49,10 +63,11 @@ func (h *UpdateHabitHandler) Handle(ctx context.Context, cmd UpdateHabitCommand)
|
||||
|
||||
habit.Name = cmd.Name
|
||||
habit.Description = cmd.Description
|
||||
habit.CarryOver = cmd.CarryOver
|
||||
habit.TargetValue = cmd.TargetValue
|
||||
habit.Frequency = cmd.Frequency
|
||||
habit.SpecificDays = cmd.SpecificDays
|
||||
habit.SpecificDates = cmd.SpecificDates
|
||||
habit.CarryOver = cmd.CarryOver
|
||||
habit.TargetValue = cmd.TargetValue
|
||||
|
||||
return h.habitRepo.Update(ctx, habit)
|
||||
}
|
||||
|
||||
@@ -48,9 +48,10 @@ func TestUpdateHabitHandler_UpdatesSuccessfully(t *testing.T) {
|
||||
UserID: "user-123",
|
||||
Name: "Morning Exercise",
|
||||
Description: "Updated description",
|
||||
Frequency: value_objects.FrequencyWeekly,
|
||||
SpecificDays: []int{1, 3, 5},
|
||||
CarryOver: true,
|
||||
TargetValue: &newTargetValue,
|
||||
SpecificDays: []int{1, 3, 5},
|
||||
}
|
||||
|
||||
err := handler.Handle(context.Background(), cmd)
|
||||
@@ -67,6 +68,14 @@ func TestUpdateHabitHandler_UpdatesSuccessfully(t *testing.T) {
|
||||
t.Errorf("Expected description to be updated, got %s", habitRepo.updatedHabit.Description)
|
||||
}
|
||||
|
||||
if habitRepo.updatedHabit.Frequency != value_objects.FrequencyWeekly {
|
||||
t.Errorf("Expected frequency WEEKLY, got %s", habitRepo.updatedHabit.Frequency)
|
||||
}
|
||||
|
||||
if len(habitRepo.updatedHabit.SpecificDays) != 3 {
|
||||
t.Errorf("Expected 3 specific days, got %d", len(habitRepo.updatedHabit.SpecificDays))
|
||||
}
|
||||
|
||||
if !habitRepo.updatedHabit.CarryOver {
|
||||
t.Error("Expected CarryOver to be true")
|
||||
}
|
||||
@@ -74,10 +83,6 @@ func TestUpdateHabitHandler_UpdatesSuccessfully(t *testing.T) {
|
||||
if habitRepo.updatedHabit.TargetValue == nil || *habitRepo.updatedHabit.TargetValue != 5.0 {
|
||||
t.Errorf("Expected target value 5.0, got %v", habitRepo.updatedHabit.TargetValue)
|
||||
}
|
||||
|
||||
if len(habitRepo.updatedHabit.SpecificDays) != 3 {
|
||||
t.Errorf("Expected 3 specific days, got %d", len(habitRepo.updatedHabit.SpecificDays))
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateHabitHandler_ReturnsErrorWhenHabitNotFound(t *testing.T) {
|
||||
@@ -88,9 +93,10 @@ func TestUpdateHabitHandler_ReturnsErrorWhenHabitNotFound(t *testing.T) {
|
||||
handler := NewUpdateHabitHandler(habitRepo)
|
||||
|
||||
cmd := UpdateHabitCommand{
|
||||
HabitID: "non-existent",
|
||||
UserID: "user-123",
|
||||
Name: "Exercise",
|
||||
HabitID: "non-existent",
|
||||
UserID: "user-123",
|
||||
Name: "Exercise",
|
||||
Frequency: value_objects.FrequencyDaily,
|
||||
}
|
||||
|
||||
err := handler.Handle(context.Background(), cmd)
|
||||
@@ -111,9 +117,10 @@ func TestUpdateHabitHandler_ReturnsErrorWhenUserDoesNotOwnHabit(t *testing.T) {
|
||||
handler := NewUpdateHabitHandler(habitRepo)
|
||||
|
||||
cmd := UpdateHabitCommand{
|
||||
HabitID: "habit-1",
|
||||
UserID: "user-456", // Different user
|
||||
Name: "Exercise",
|
||||
HabitID: "habit-1",
|
||||
UserID: "user-456", // Different user
|
||||
Name: "Exercise",
|
||||
Frequency: value_objects.FrequencyDaily,
|
||||
}
|
||||
|
||||
err := handler.Handle(context.Background(), cmd)
|
||||
@@ -135,9 +142,10 @@ func TestUpdateHabitHandler_CannotUpdateArchivedHabit(t *testing.T) {
|
||||
handler := NewUpdateHabitHandler(habitRepo)
|
||||
|
||||
cmd := UpdateHabitCommand{
|
||||
HabitID: "habit-1",
|
||||
UserID: "user-123",
|
||||
Name: "Updated Exercise",
|
||||
HabitID: "habit-1",
|
||||
UserID: "user-123",
|
||||
Name: "Updated Exercise",
|
||||
Frequency: value_objects.FrequencyDaily,
|
||||
}
|
||||
|
||||
err := handler.Handle(context.Background(), cmd)
|
||||
@@ -158,9 +166,10 @@ func TestUpdateHabitHandler_ValidatesInput(t *testing.T) {
|
||||
handler := NewUpdateHabitHandler(habitRepo)
|
||||
|
||||
cmd := UpdateHabitCommand{
|
||||
HabitID: "habit-1",
|
||||
UserID: "user-123",
|
||||
Name: "", // Empty name
|
||||
HabitID: "habit-1",
|
||||
UserID: "user-123",
|
||||
Name: "", // Empty name
|
||||
Frequency: value_objects.FrequencyDaily,
|
||||
}
|
||||
|
||||
err := handler.Handle(context.Background(), cmd)
|
||||
@@ -169,3 +178,57 @@ func TestUpdateHabitHandler_ValidatesInput(t *testing.T) {
|
||||
t.Errorf("Expected ErrInvalidInput for empty name, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateHabitHandler_InvalidFrequency(t *testing.T) {
|
||||
habitRepo := &mockHabitRepoForUpdate{}
|
||||
handler := NewUpdateHabitHandler(habitRepo)
|
||||
|
||||
cmd := UpdateHabitCommand{
|
||||
HabitID: "habit-1",
|
||||
UserID: "user-123",
|
||||
Name: "Exercise",
|
||||
Frequency: "INVALID",
|
||||
}
|
||||
|
||||
err := handler.Handle(context.Background(), cmd)
|
||||
|
||||
if err != errors.ErrInvalidInput {
|
||||
t.Errorf("Expected ErrInvalidInput for invalid frequency, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateHabitHandler_WeeklyRequiresSpecificDays(t *testing.T) {
|
||||
habitRepo := &mockHabitRepoForUpdate{}
|
||||
handler := NewUpdateHabitHandler(habitRepo)
|
||||
|
||||
cmd := UpdateHabitCommand{
|
||||
HabitID: "habit-1",
|
||||
UserID: "user-123",
|
||||
Name: "Exercise",
|
||||
Frequency: value_objects.FrequencyWeekly,
|
||||
}
|
||||
|
||||
err := handler.Handle(context.Background(), cmd)
|
||||
|
||||
if err != errors.ErrInvalidInput {
|
||||
t.Errorf("Expected ErrInvalidInput for weekly without specific days, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateHabitHandler_MonthlyRequiresSpecificDates(t *testing.T) {
|
||||
habitRepo := &mockHabitRepoForUpdate{}
|
||||
handler := NewUpdateHabitHandler(habitRepo)
|
||||
|
||||
cmd := UpdateHabitCommand{
|
||||
HabitID: "habit-1",
|
||||
UserID: "user-123",
|
||||
Name: "Exercise",
|
||||
Frequency: value_objects.FrequencyMonthly,
|
||||
}
|
||||
|
||||
err := handler.Handle(context.Background(), cmd)
|
||||
|
||||
if err != errors.ErrInvalidInput {
|
||||
t.Errorf("Expected ErrInvalidInput for monthly without specific dates, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
package commands
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"apocapoc-api/internal/domain/entities"
|
||||
"apocapoc-api/internal/domain/repositories"
|
||||
"apocapoc-api/internal/domain/services"
|
||||
"apocapoc-api/internal/shared/errors"
|
||||
)
|
||||
|
||||
type VerifyEmailCommand struct {
|
||||
Token string
|
||||
}
|
||||
|
||||
type VerifyEmailHandler struct {
|
||||
userRepo repositories.UserRepository
|
||||
emailService services.EmailService
|
||||
sendWelcomeEmail bool
|
||||
}
|
||||
|
||||
func NewVerifyEmailHandler(
|
||||
userRepo repositories.UserRepository,
|
||||
emailService services.EmailService,
|
||||
sendWelcomeEmail bool,
|
||||
) *VerifyEmailHandler {
|
||||
return &VerifyEmailHandler{
|
||||
userRepo: userRepo,
|
||||
emailService: emailService,
|
||||
sendWelcomeEmail: sendWelcomeEmail,
|
||||
}
|
||||
}
|
||||
|
||||
func (h *VerifyEmailHandler) Handle(ctx context.Context, cmd VerifyEmailCommand) error {
|
||||
if cmd.Token == "" {
|
||||
return errors.ErrInvalidInput
|
||||
}
|
||||
|
||||
user, err := h.userRepo.FindByVerificationToken(ctx, cmd.Token)
|
||||
if err != nil {
|
||||
return errors.ErrInvalidInput
|
||||
}
|
||||
|
||||
if user.EmailVerified {
|
||||
return errors.ErrAlreadyExists
|
||||
}
|
||||
|
||||
if user.EmailVerificationExpiry == nil || user.EmailVerificationExpiry.Before(time.Now()) {
|
||||
return errors.ErrInvalidInput
|
||||
}
|
||||
|
||||
user.EmailVerified = true
|
||||
user.EmailVerificationToken = nil
|
||||
user.EmailVerificationExpiry = nil
|
||||
user.UpdatedAt = time.Now()
|
||||
|
||||
if err := h.userRepo.Update(ctx, user); err != nil {
|
||||
return fmt.Errorf("failed to verify email: %w", err)
|
||||
}
|
||||
|
||||
if h.sendWelcomeEmail && h.emailService != nil {
|
||||
h.sendWelcomeEmailToUser(user)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (h *VerifyEmailHandler) sendWelcomeEmailToUser(user *entities.User) error {
|
||||
emailBody := fmt.Sprintf(`
|
||||
<h2>Welcome to Apocapoc!</h2>
|
||||
<p>Your email has been successfully verified.</p>
|
||||
<p>You can now start tracking your habits and building better routines.</p>
|
||||
<p>If you have any questions or need help, please don't hesitate to contact us.</p>
|
||||
`)
|
||||
|
||||
message := services.EmailMessage{
|
||||
To: user.Email,
|
||||
Subject: "Welcome to Apocapoc!",
|
||||
Body: emailBody,
|
||||
IsHTML: true,
|
||||
}
|
||||
|
||||
return h.emailService.Send(message)
|
||||
}
|
||||
@@ -0,0 +1,364 @@
|
||||
package commands
|
||||
|
||||
import (
|
||||
"apocapoc-api/internal/domain/repositories"
|
||||
"apocapoc-api/internal/shared/pagination"
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"apocapoc-api/internal/domain/entities"
|
||||
"apocapoc-api/internal/domain/services"
|
||||
"apocapoc-api/internal/shared/errors"
|
||||
)
|
||||
|
||||
type mockVerifyEmailUserRepo struct {
|
||||
findByVerificationTokenFunc func(ctx context.Context, token string) (*entities.User, error)
|
||||
updateFunc func(ctx context.Context, user *entities.User) error
|
||||
users map[string]*entities.User
|
||||
}
|
||||
|
||||
func (m *mockVerifyEmailUserRepo) FindByVerificationToken(ctx context.Context, token string) (*entities.User, error) {
|
||||
if m.findByVerificationTokenFunc != nil {
|
||||
return m.findByVerificationTokenFunc(ctx, token)
|
||||
}
|
||||
if user, ok := m.users[token]; ok {
|
||||
return user, nil
|
||||
}
|
||||
return nil, errors.ErrNotFound
|
||||
}
|
||||
|
||||
func (m *mockVerifyEmailUserRepo) Update(ctx context.Context, user *entities.User) error {
|
||||
if m.updateFunc != nil {
|
||||
return m.updateFunc(ctx, user)
|
||||
}
|
||||
m.users[user.Email] = user
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *mockVerifyEmailUserRepo) FindByID(ctx context.Context, id string) (*entities.User, error) {
|
||||
return nil, errors.ErrNotFound
|
||||
}
|
||||
|
||||
func (m *mockVerifyEmailUserRepo) FindByEmail(ctx context.Context, email string) (*entities.User, error) {
|
||||
return nil, errors.ErrNotFound
|
||||
}
|
||||
|
||||
func (m *mockVerifyEmailUserRepo) Create(ctx context.Context, user *entities.User) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *mockVerifyEmailUserRepo) Delete(ctx context.Context, id string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
type mockEmailService struct {
|
||||
sendFunc func(message services.EmailMessage) error
|
||||
sentMessages []services.EmailMessage
|
||||
}
|
||||
|
||||
func (m *mockEmailService) Send(message services.EmailMessage) error {
|
||||
m.sentMessages = append(m.sentMessages, message)
|
||||
if m.sendFunc != nil {
|
||||
return m.sendFunc(message)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *mockEmailService) HealthCheck() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *mockEmailService) IsEnabled() bool {
|
||||
return true
|
||||
}
|
||||
|
||||
func TestVerifyEmailHandler_Success(t *testing.T) {
|
||||
token := "valid-token"
|
||||
expiry := time.Now().Add(24 * time.Hour)
|
||||
|
||||
user := entities.NewUser("test@example.com", "hashedPassword")
|
||||
user.ID = "user-123"
|
||||
user.EmailVerified = false
|
||||
user.EmailVerificationToken = &token
|
||||
user.EmailVerificationExpiry = &expiry
|
||||
|
||||
var updatedUser *entities.User
|
||||
repo := &mockVerifyEmailUserRepo{
|
||||
users: map[string]*entities.User{
|
||||
token: user,
|
||||
},
|
||||
updateFunc: func(ctx context.Context, u *entities.User) error {
|
||||
updatedUser = u
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
handler := NewVerifyEmailHandler(repo, nil, false)
|
||||
|
||||
cmd := VerifyEmailCommand{
|
||||
Token: token,
|
||||
}
|
||||
|
||||
err := handler.Handle(context.Background(), cmd)
|
||||
if err != nil {
|
||||
t.Fatalf("Handle() unexpected error = %v", err)
|
||||
}
|
||||
|
||||
if updatedUser == nil {
|
||||
t.Fatal("User was not updated")
|
||||
}
|
||||
|
||||
if !updatedUser.EmailVerified {
|
||||
t.Error("EmailVerified should be true")
|
||||
}
|
||||
|
||||
if updatedUser.EmailVerificationToken != nil {
|
||||
t.Error("EmailVerificationToken should be nil after verification")
|
||||
}
|
||||
|
||||
if updatedUser.EmailVerificationExpiry != nil {
|
||||
t.Error("EmailVerificationExpiry should be nil after verification")
|
||||
}
|
||||
}
|
||||
|
||||
func TestVerifyEmailHandler_EmptyToken(t *testing.T) {
|
||||
repo := &mockVerifyEmailUserRepo{
|
||||
users: make(map[string]*entities.User),
|
||||
}
|
||||
handler := NewVerifyEmailHandler(repo, nil, false)
|
||||
|
||||
cmd := VerifyEmailCommand{
|
||||
Token: "",
|
||||
}
|
||||
|
||||
err := handler.Handle(context.Background(), cmd)
|
||||
if err != errors.ErrInvalidInput {
|
||||
t.Errorf("Handle() error = %v, want %v", err, errors.ErrInvalidInput)
|
||||
}
|
||||
}
|
||||
|
||||
func TestVerifyEmailHandler_TokenNotFound(t *testing.T) {
|
||||
repo := &mockVerifyEmailUserRepo{
|
||||
users: make(map[string]*entities.User),
|
||||
}
|
||||
handler := NewVerifyEmailHandler(repo, nil, false)
|
||||
|
||||
cmd := VerifyEmailCommand{
|
||||
Token: "non-existent-token",
|
||||
}
|
||||
|
||||
err := handler.Handle(context.Background(), cmd)
|
||||
if err != errors.ErrInvalidInput {
|
||||
t.Errorf("Handle() error = %v, want %v", err, errors.ErrInvalidInput)
|
||||
}
|
||||
}
|
||||
|
||||
func TestVerifyEmailHandler_AlreadyVerified(t *testing.T) {
|
||||
token := "valid-token"
|
||||
expiry := time.Now().Add(24 * time.Hour)
|
||||
|
||||
user := entities.NewUser("test@example.com", "hashedPassword")
|
||||
user.ID = "user-123"
|
||||
user.EmailVerified = true
|
||||
user.EmailVerificationToken = &token
|
||||
user.EmailVerificationExpiry = &expiry
|
||||
|
||||
repo := &mockVerifyEmailUserRepo{
|
||||
users: map[string]*entities.User{
|
||||
token: user,
|
||||
},
|
||||
}
|
||||
|
||||
handler := NewVerifyEmailHandler(repo, nil, false)
|
||||
|
||||
cmd := VerifyEmailCommand{
|
||||
Token: token,
|
||||
}
|
||||
|
||||
err := handler.Handle(context.Background(), cmd)
|
||||
if err != errors.ErrAlreadyExists {
|
||||
t.Errorf("Handle() error = %v, want %v", err, errors.ErrAlreadyExists)
|
||||
}
|
||||
}
|
||||
|
||||
func TestVerifyEmailHandler_ExpiredToken(t *testing.T) {
|
||||
token := "expired-token"
|
||||
expiry := time.Now().Add(-1 * time.Hour)
|
||||
|
||||
user := entities.NewUser("test@example.com", "hashedPassword")
|
||||
user.ID = "user-123"
|
||||
user.EmailVerified = false
|
||||
user.EmailVerificationToken = &token
|
||||
user.EmailVerificationExpiry = &expiry
|
||||
|
||||
repo := &mockVerifyEmailUserRepo{
|
||||
users: map[string]*entities.User{
|
||||
token: user,
|
||||
},
|
||||
}
|
||||
|
||||
handler := NewVerifyEmailHandler(repo, nil, false)
|
||||
|
||||
cmd := VerifyEmailCommand{
|
||||
Token: token,
|
||||
}
|
||||
|
||||
err := handler.Handle(context.Background(), cmd)
|
||||
if err != errors.ErrInvalidInput {
|
||||
t.Errorf("Handle() error = %v, want %v", err, errors.ErrInvalidInput)
|
||||
}
|
||||
}
|
||||
|
||||
func TestVerifyEmailHandler_NilExpiry(t *testing.T) {
|
||||
token := "valid-token"
|
||||
|
||||
user := entities.NewUser("test@example.com", "hashedPassword")
|
||||
user.ID = "user-123"
|
||||
user.EmailVerified = false
|
||||
user.EmailVerificationToken = &token
|
||||
user.EmailVerificationExpiry = nil
|
||||
|
||||
repo := &mockVerifyEmailUserRepo{
|
||||
users: map[string]*entities.User{
|
||||
token: user,
|
||||
},
|
||||
}
|
||||
|
||||
handler := NewVerifyEmailHandler(repo, nil, false)
|
||||
|
||||
cmd := VerifyEmailCommand{
|
||||
Token: token,
|
||||
}
|
||||
|
||||
err := handler.Handle(context.Background(), cmd)
|
||||
if err != errors.ErrInvalidInput {
|
||||
t.Errorf("Handle() error = %v, want %v", err, errors.ErrInvalidInput)
|
||||
}
|
||||
}
|
||||
|
||||
func TestVerifyEmailHandler_WithWelcomeEmail(t *testing.T) {
|
||||
token := "valid-token"
|
||||
expiry := time.Now().Add(24 * time.Hour)
|
||||
|
||||
user := entities.NewUser("test@example.com", "hashedPassword")
|
||||
user.ID = "user-123"
|
||||
user.EmailVerified = false
|
||||
user.EmailVerificationToken = &token
|
||||
user.EmailVerificationExpiry = &expiry
|
||||
|
||||
repo := &mockVerifyEmailUserRepo{
|
||||
users: map[string]*entities.User{
|
||||
token: user,
|
||||
},
|
||||
}
|
||||
|
||||
emailService := &mockEmailService{}
|
||||
handler := NewVerifyEmailHandler(repo, emailService, true)
|
||||
|
||||
cmd := VerifyEmailCommand{
|
||||
Token: token,
|
||||
}
|
||||
|
||||
err := handler.Handle(context.Background(), cmd)
|
||||
if err != nil {
|
||||
t.Fatalf("Handle() unexpected error = %v", err)
|
||||
}
|
||||
|
||||
if len(emailService.sentMessages) != 1 {
|
||||
t.Fatalf("Expected 1 email sent, got %d", len(emailService.sentMessages))
|
||||
}
|
||||
|
||||
sentEmail := emailService.sentMessages[0]
|
||||
if sentEmail.To != "test@example.com" {
|
||||
t.Errorf("Email To = %v, want %v", sentEmail.To, "test@example.com")
|
||||
}
|
||||
|
||||
if sentEmail.Subject != "Welcome to Apocapoc!" {
|
||||
t.Errorf("Email Subject = %v, want %v", sentEmail.Subject, "Welcome to Apocapoc!")
|
||||
}
|
||||
|
||||
if !sentEmail.IsHTML {
|
||||
t.Error("Email should be HTML")
|
||||
}
|
||||
}
|
||||
|
||||
func TestVerifyEmailHandler_WithoutWelcomeEmail(t *testing.T) {
|
||||
token := "valid-token"
|
||||
expiry := time.Now().Add(24 * time.Hour)
|
||||
|
||||
user := entities.NewUser("test@example.com", "hashedPassword")
|
||||
user.ID = "user-123"
|
||||
user.EmailVerified = false
|
||||
user.EmailVerificationToken = &token
|
||||
user.EmailVerificationExpiry = &expiry
|
||||
|
||||
repo := &mockVerifyEmailUserRepo{
|
||||
users: map[string]*entities.User{
|
||||
token: user,
|
||||
},
|
||||
}
|
||||
|
||||
emailService := &mockEmailService{}
|
||||
handler := NewVerifyEmailHandler(repo, emailService, false)
|
||||
|
||||
cmd := VerifyEmailCommand{
|
||||
Token: token,
|
||||
}
|
||||
|
||||
err := handler.Handle(context.Background(), cmd)
|
||||
if err != nil {
|
||||
t.Fatalf("Handle() unexpected error = %v", err)
|
||||
}
|
||||
|
||||
if len(emailService.sentMessages) != 0 {
|
||||
t.Errorf("Expected 0 emails sent, got %d", len(emailService.sentMessages))
|
||||
}
|
||||
}
|
||||
|
||||
func TestVerifyEmailHandler_UpdateError(t *testing.T) {
|
||||
token := "valid-token"
|
||||
expiry := time.Now().Add(24 * time.Hour)
|
||||
|
||||
user := entities.NewUser("test@example.com", "hashedPassword")
|
||||
user.ID = "user-123"
|
||||
user.EmailVerified = false
|
||||
user.EmailVerificationToken = &token
|
||||
user.EmailVerificationExpiry = &expiry
|
||||
|
||||
repo := &mockVerifyEmailUserRepo{
|
||||
users: map[string]*entities.User{
|
||||
token: user,
|
||||
},
|
||||
updateFunc: func(ctx context.Context, u *entities.User) error {
|
||||
return errors.ErrNotFound
|
||||
},
|
||||
}
|
||||
|
||||
handler := NewVerifyEmailHandler(repo, nil, false)
|
||||
|
||||
cmd := VerifyEmailCommand{
|
||||
Token: token,
|
||||
}
|
||||
|
||||
err := handler.Handle(context.Background(), cmd)
|
||||
if err == nil {
|
||||
t.Fatal("Handle() expected error but got nil")
|
||||
}
|
||||
}
|
||||
|
||||
func (m *mockVerifyEmailUserRepo) FindActiveByUserIDWithPagination(ctx context.Context, userID string, params pagination.Params) ([]*entities.Habit, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (m *mockVerifyEmailUserRepo) CountActiveByUserID(ctx context.Context, userID string) (int, error) {
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
func (m *mockVerifyEmailUserRepo) FindByUserIDFiltered(ctx context.Context, userID string, filter repositories.HabitFilter, paginationParams *pagination.Params) ([]*entities.Habit, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (m *mockVerifyEmailUserRepo) CountByUserIDFiltered(ctx context.Context, userID string, filter repositories.HabitFilter) (int, error) {
|
||||
return 0, nil
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
package queries
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"apocapoc-api/internal/domain/repositories"
|
||||
"apocapoc-api/internal/domain/value_objects"
|
||||
)
|
||||
|
||||
type ExportHabitDTO struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description"`
|
||||
Type value_objects.HabitType `json:"type"`
|
||||
Frequency value_objects.Frequency `json:"frequency"`
|
||||
SpecificDays []int `json:"specific_days,omitempty"`
|
||||
SpecificDates []int `json:"specific_dates,omitempty"`
|
||||
CarryOver bool `json:"carry_over"`
|
||||
IsNegative bool `json:"is_negative"`
|
||||
TargetValue *float64 `json:"target_value,omitempty"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
ArchivedAt *time.Time `json:"archived_at,omitempty"`
|
||||
}
|
||||
|
||||
type ExportEntryDTO struct {
|
||||
ID string `json:"id"`
|
||||
HabitID string `json:"habit_id"`
|
||||
ScheduledDate time.Time `json:"scheduled_date"`
|
||||
CompletedAt time.Time `json:"completed_at"`
|
||||
Value *float64 `json:"value,omitempty"`
|
||||
}
|
||||
|
||||
type ExportUserDataResult struct {
|
||||
ExportedAt time.Time `json:"exported_at"`
|
||||
Habits []ExportHabitDTO `json:"habits"`
|
||||
Entries []ExportEntryDTO `json:"entries"`
|
||||
}
|
||||
|
||||
type ExportUserDataQuery struct {
|
||||
UserID string
|
||||
}
|
||||
|
||||
type ExportUserDataHandler struct {
|
||||
habitRepo repositories.HabitRepository
|
||||
entryRepo repositories.HabitEntryRepository
|
||||
}
|
||||
|
||||
func NewExportUserDataHandler(
|
||||
habitRepo repositories.HabitRepository,
|
||||
entryRepo repositories.HabitEntryRepository,
|
||||
) *ExportUserDataHandler {
|
||||
return &ExportUserDataHandler{
|
||||
habitRepo: habitRepo,
|
||||
entryRepo: entryRepo,
|
||||
}
|
||||
}
|
||||
|
||||
func (h *ExportUserDataHandler) Handle(ctx context.Context, query ExportUserDataQuery) (*ExportUserDataResult, error) {
|
||||
habits, err := h.habitRepo.FindByUserID(ctx, query.UserID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
entries, err := h.entryRepo.FindByUserID(ctx, query.UserID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
habitDTOs := make([]ExportHabitDTO, 0, len(habits))
|
||||
for _, habit := range habits {
|
||||
habitDTOs = append(habitDTOs, ExportHabitDTO{
|
||||
ID: habit.ID,
|
||||
Name: habit.Name,
|
||||
Description: habit.Description,
|
||||
Type: habit.Type,
|
||||
Frequency: habit.Frequency,
|
||||
SpecificDays: habit.SpecificDays,
|
||||
SpecificDates: habit.SpecificDates,
|
||||
CarryOver: habit.CarryOver,
|
||||
IsNegative: habit.IsNegative,
|
||||
TargetValue: habit.TargetValue,
|
||||
CreatedAt: habit.CreatedAt,
|
||||
ArchivedAt: habit.ArchivedAt,
|
||||
})
|
||||
}
|
||||
|
||||
entryDTOs := make([]ExportEntryDTO, 0, len(entries))
|
||||
for _, entry := range entries {
|
||||
entryDTOs = append(entryDTOs, ExportEntryDTO{
|
||||
ID: entry.ID,
|
||||
HabitID: entry.HabitID,
|
||||
ScheduledDate: entry.ScheduledDate,
|
||||
CompletedAt: entry.CompletedAt,
|
||||
Value: entry.Value,
|
||||
})
|
||||
}
|
||||
|
||||
return &ExportUserDataResult{
|
||||
ExportedAt: time.Now(),
|
||||
Habits: habitDTOs,
|
||||
Entries: entryDTOs,
|
||||
}, nil
|
||||
}
|
||||
@@ -6,18 +6,18 @@ import (
|
||||
|
||||
"apocapoc-api/internal/domain/entities"
|
||||
"apocapoc-api/internal/domain/repositories"
|
||||
"apocapoc-api/internal/domain/services"
|
||||
"apocapoc-api/internal/shared/errors"
|
||||
)
|
||||
|
||||
type HabitStatsDTO struct {
|
||||
HabitID string `json:"habit_id"`
|
||||
HabitName string `json:"habit_name"`
|
||||
TotalCompletions int `json:"total_completions"`
|
||||
CurrentStreak int `json:"current_streak"`
|
||||
LongestStreak int `json:"longest_streak"`
|
||||
CompletionRate float64 `json:"completion_rate"`
|
||||
CompletionsThisWeek int `json:"completions_this_week"`
|
||||
CompletionsThisMonth int `json:"completions_this_month"`
|
||||
HabitID string `json:"habit_id"`
|
||||
HabitName string `json:"habit_name"`
|
||||
TotalCompletions int `json:"total_completions"`
|
||||
CurrentStreak int `json:"current_streak"`
|
||||
LongestStreak int `json:"longest_streak"`
|
||||
CompletionsThisWeek int `json:"completions_this_week"`
|
||||
CompletionsThisMonth int `json:"completions_this_month"`
|
||||
}
|
||||
|
||||
type GetHabitStatsQuery struct {
|
||||
@@ -60,102 +60,21 @@ func (h *GetHabitStatsHandler) Handle(ctx context.Context, query GetHabitStatsQu
|
||||
HabitName: habit.Name,
|
||||
}
|
||||
|
||||
if len(entries) == 0 {
|
||||
if len(entries) == 0 && !habit.IsNegative {
|
||||
return stats, nil
|
||||
}
|
||||
|
||||
stats.TotalCompletions = len(entries)
|
||||
stats.CurrentStreak = calculateCurrentStreak(entries)
|
||||
stats.LongestStreak = calculateLongestStreak(entries)
|
||||
stats.CompletionRate = calculateCompletionRate(entries, habit.CreatedAt)
|
||||
stats.CompletionsThisWeek = countCompletionsInPeriod(entries, 7)
|
||||
stats.CompletionsThisMonth = countCompletionsInPeriod(entries, 30)
|
||||
|
||||
streaks := services.CalculateStreaks(entries, habit, time.Now().UTC())
|
||||
stats.CurrentStreak = streaks.Current
|
||||
stats.LongestStreak = streaks.Longest
|
||||
|
||||
return stats, nil
|
||||
}
|
||||
|
||||
func calculateCurrentStreak(entries []*entities.HabitEntry) int {
|
||||
if len(entries) == 0 {
|
||||
return 0
|
||||
}
|
||||
|
||||
dateMap := make(map[string]bool)
|
||||
for _, entry := range entries {
|
||||
dateStr := entry.ScheduledDate.Format("2006-01-02")
|
||||
dateMap[dateStr] = true
|
||||
}
|
||||
|
||||
streak := 0
|
||||
currentDate := time.Now().UTC()
|
||||
|
||||
for {
|
||||
dateStr := currentDate.Format("2006-01-02")
|
||||
if !dateMap[dateStr] {
|
||||
break
|
||||
}
|
||||
streak++
|
||||
currentDate = currentDate.AddDate(0, 0, -1)
|
||||
}
|
||||
|
||||
return streak
|
||||
}
|
||||
|
||||
func calculateLongestStreak(entries []*entities.HabitEntry) int {
|
||||
if len(entries) == 0 {
|
||||
return 0
|
||||
}
|
||||
|
||||
dateMap := make(map[string]bool)
|
||||
var dates []time.Time
|
||||
for _, entry := range entries {
|
||||
date := time.Date(entry.ScheduledDate.Year(), entry.ScheduledDate.Month(), entry.ScheduledDate.Day(), 0, 0, 0, 0, time.UTC)
|
||||
dateStr := date.Format("2006-01-02")
|
||||
if !dateMap[dateStr] {
|
||||
dateMap[dateStr] = true
|
||||
dates = append(dates, date)
|
||||
}
|
||||
}
|
||||
|
||||
if len(dates) == 0 {
|
||||
return 0
|
||||
}
|
||||
|
||||
longestStreak := 1
|
||||
currentStreak := 1
|
||||
|
||||
for i := 1; i < len(dates); i++ {
|
||||
diff := dates[i].Sub(dates[i-1]).Hours() / 24
|
||||
if diff == 1 {
|
||||
currentStreak++
|
||||
if currentStreak > longestStreak {
|
||||
longestStreak = currentStreak
|
||||
}
|
||||
} else {
|
||||
currentStreak = 1
|
||||
}
|
||||
}
|
||||
|
||||
return longestStreak
|
||||
}
|
||||
|
||||
func calculateCompletionRate(entries []*entities.HabitEntry, createdAt time.Time) float64 {
|
||||
if len(entries) == 0 {
|
||||
return 0
|
||||
}
|
||||
|
||||
daysSinceCreation := int(time.Since(createdAt).Hours() / 24)
|
||||
if daysSinceCreation == 0 {
|
||||
daysSinceCreation = 1
|
||||
}
|
||||
|
||||
rate := float64(len(entries)) / float64(daysSinceCreation) * 100
|
||||
if rate > 100 {
|
||||
rate = 100
|
||||
}
|
||||
|
||||
return rate
|
||||
}
|
||||
|
||||
func countCompletionsInPeriod(entries []*entities.HabitEntry, days int) int {
|
||||
cutoff := time.Now().UTC().AddDate(0, 0, -days)
|
||||
count := 0
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
package queries
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"apocapoc-api/internal/domain/entities"
|
||||
"apocapoc-api/internal/domain/repositories"
|
||||
"apocapoc-api/internal/shared/errors"
|
||||
)
|
||||
|
||||
type HabitChangesDTO struct {
|
||||
Created []*entities.Habit
|
||||
Updated []*entities.Habit
|
||||
Deleted []string
|
||||
}
|
||||
|
||||
type EntryChangesDTO struct {
|
||||
Created []*entities.HabitEntry
|
||||
Updated []*entities.HabitEntry
|
||||
Deleted []string
|
||||
}
|
||||
|
||||
type SyncChangesDTO struct {
|
||||
Habits HabitChangesDTO
|
||||
Entries EntryChangesDTO
|
||||
}
|
||||
|
||||
type GetSyncChangesQuery struct {
|
||||
UserID string
|
||||
Since time.Time
|
||||
}
|
||||
|
||||
type GetSyncChangesHandler struct {
|
||||
habitRepo repositories.HabitRepository
|
||||
entryRepo repositories.HabitEntryRepository
|
||||
}
|
||||
|
||||
func NewGetSyncChangesHandler(
|
||||
habitRepo repositories.HabitRepository,
|
||||
entryRepo repositories.HabitEntryRepository,
|
||||
) *GetSyncChangesHandler {
|
||||
return &GetSyncChangesHandler{
|
||||
habitRepo: habitRepo,
|
||||
entryRepo: entryRepo,
|
||||
}
|
||||
}
|
||||
|
||||
func (h *GetSyncChangesHandler) Handle(ctx context.Context, query GetSyncChangesQuery) (*SyncChangesDTO, error) {
|
||||
if query.UserID == "" {
|
||||
return nil, errors.ErrInvalidInput
|
||||
}
|
||||
|
||||
habitChanges, err := h.habitRepo.GetChangesSince(ctx, query.UserID, query.Since)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
entryChanges, err := h.entryRepo.GetChangesSince(ctx, query.UserID, query.Since)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &SyncChangesDTO{
|
||||
Habits: HabitChangesDTO{
|
||||
Created: habitChanges.Created,
|
||||
Updated: habitChanges.Updated,
|
||||
Deleted: habitChanges.Deleted,
|
||||
},
|
||||
Entries: EntryChangesDTO{
|
||||
Created: entryChanges.Created,
|
||||
Updated: entryChanges.Updated,
|
||||
Deleted: entryChanges.Deleted,
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
@@ -0,0 +1,255 @@
|
||||
package queries
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"apocapoc-api/internal/domain/entities"
|
||||
"apocapoc-api/internal/domain/repositories"
|
||||
"apocapoc-api/internal/domain/value_objects"
|
||||
"apocapoc-api/internal/shared/pagination"
|
||||
)
|
||||
|
||||
type mockHabitRepoForSync struct {
|
||||
changes *repositories.HabitChanges
|
||||
err error
|
||||
}
|
||||
|
||||
func (m *mockHabitRepoForSync) GetChangesSince(ctx context.Context, userID string, since time.Time) (*repositories.HabitChanges, error) {
|
||||
return m.changes, m.err
|
||||
}
|
||||
|
||||
func (m *mockHabitRepoForSync) Create(ctx context.Context, habit *entities.Habit) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *mockHabitRepoForSync) FindByID(ctx context.Context, id string) (*entities.Habit, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (m *mockHabitRepoForSync) FindByUserID(ctx context.Context, userID string) ([]*entities.Habit, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (m *mockHabitRepoForSync) FindActiveByUserID(ctx context.Context, userID string) ([]*entities.Habit, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (m *mockHabitRepoForSync) Update(ctx context.Context, habit *entities.Habit) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *mockHabitRepoForSync) Delete(ctx context.Context, id string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *mockHabitRepoForSync) FindActiveByUserIDWithPagination(ctx context.Context, userID string, params pagination.Params) ([]*entities.Habit, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (m *mockHabitRepoForSync) FindByUserIDFiltered(ctx context.Context, userID string, filter repositories.HabitFilter, paginationParams *pagination.Params) ([]*entities.Habit, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (m *mockHabitRepoForSync) CountActiveByUserID(ctx context.Context, userID string) (int, error) {
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
func (m *mockHabitRepoForSync) CountByUserIDFiltered(ctx context.Context, userID string, filter repositories.HabitFilter) (int, error) {
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
func (m *mockHabitRepoForSync) SoftDelete(ctx context.Context, id string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
type mockEntryRepoForSync struct {
|
||||
changes *repositories.HabitEntryChanges
|
||||
err error
|
||||
}
|
||||
|
||||
func (m *mockEntryRepoForSync) GetChangesSince(ctx context.Context, userID string, since time.Time) (*repositories.HabitEntryChanges, error) {
|
||||
return m.changes, m.err
|
||||
}
|
||||
|
||||
func (m *mockEntryRepoForSync) Create(ctx context.Context, entry *entities.HabitEntry) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *mockEntryRepoForSync) FindByID(ctx context.Context, id string) (*entities.HabitEntry, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (m *mockEntryRepoForSync) FindByHabitID(ctx context.Context, habitID string) ([]*entities.HabitEntry, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (m *mockEntryRepoForSync) FindByHabitIDAndDateRange(ctx context.Context, habitID string, from, to time.Time) ([]*entities.HabitEntry, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (m *mockEntryRepoForSync) FindByUserID(ctx context.Context, userID string) ([]*entities.HabitEntry, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (m *mockEntryRepoForSync) FindPendingByHabitID(ctx context.Context, habitID string, beforeDate time.Time) ([]*entities.HabitEntry, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (m *mockEntryRepoForSync) Update(ctx context.Context, entry *entities.HabitEntry) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *mockEntryRepoForSync) Delete(ctx context.Context, id string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *mockEntryRepoForSync) SoftDelete(ctx context.Context, id string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func TestGetSyncChangesHandler_Success(t *testing.T) {
|
||||
now := time.Now()
|
||||
since := now.Add(-1 * time.Hour)
|
||||
|
||||
createdHabit := entities.NewHabit("user-123", "New Habit", value_objects.HabitTypeBoolean, value_objects.FrequencyDaily, false, false)
|
||||
createdHabit.ID = "habit-1"
|
||||
createdHabit.CreatedAt = now
|
||||
createdHabit.UpdatedAt = now
|
||||
|
||||
updatedHabit := entities.NewHabit("user-123", "Updated Habit", value_objects.HabitTypeCounter, value_objects.FrequencyWeekly, false, false)
|
||||
updatedHabit.ID = "habit-2"
|
||||
updatedHabit.CreatedAt = since.Add(-1 * time.Hour)
|
||||
updatedHabit.UpdatedAt = now
|
||||
|
||||
habitRepo := &mockHabitRepoForSync{
|
||||
changes: &repositories.HabitChanges{
|
||||
Created: []*entities.Habit{createdHabit},
|
||||
Updated: []*entities.Habit{updatedHabit},
|
||||
Deleted: []string{"habit-3"},
|
||||
},
|
||||
}
|
||||
|
||||
createdEntry := entities.NewHabitEntry("habit-1", now, nil)
|
||||
createdEntry.ID = "entry-1"
|
||||
|
||||
updatedEntry := entities.NewHabitEntry("habit-2", now, nil)
|
||||
updatedEntry.ID = "entry-2"
|
||||
updatedEntry.UpdatedAt = now
|
||||
|
||||
entryRepo := &mockEntryRepoForSync{
|
||||
changes: &repositories.HabitEntryChanges{
|
||||
Created: []*entities.HabitEntry{createdEntry},
|
||||
Updated: []*entities.HabitEntry{updatedEntry},
|
||||
Deleted: []string{"entry-3"},
|
||||
},
|
||||
}
|
||||
|
||||
handler := NewGetSyncChangesHandler(habitRepo, entryRepo)
|
||||
|
||||
query := GetSyncChangesQuery{
|
||||
UserID: "user-123",
|
||||
Since: since,
|
||||
}
|
||||
|
||||
result, err := handler.Handle(context.Background(), query)
|
||||
|
||||
if err != nil {
|
||||
t.Fatalf("Expected no error, got %v", err)
|
||||
}
|
||||
|
||||
if len(result.Habits.Created) != 1 {
|
||||
t.Errorf("Expected 1 created habit, got %d", len(result.Habits.Created))
|
||||
}
|
||||
|
||||
if len(result.Habits.Updated) != 1 {
|
||||
t.Errorf("Expected 1 updated habit, got %d", len(result.Habits.Updated))
|
||||
}
|
||||
|
||||
if len(result.Habits.Deleted) != 1 {
|
||||
t.Errorf("Expected 1 deleted habit, got %d", len(result.Habits.Deleted))
|
||||
}
|
||||
|
||||
if len(result.Entries.Created) != 1 {
|
||||
t.Errorf("Expected 1 created entry, got %d", len(result.Entries.Created))
|
||||
}
|
||||
|
||||
if len(result.Entries.Updated) != 1 {
|
||||
t.Errorf("Expected 1 updated entry, got %d", len(result.Entries.Updated))
|
||||
}
|
||||
|
||||
if len(result.Entries.Deleted) != 1 {
|
||||
t.Errorf("Expected 1 deleted entry, got %d", len(result.Entries.Deleted))
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetSyncChangesHandler_EmptyChanges(t *testing.T) {
|
||||
habitRepo := &mockHabitRepoForSync{
|
||||
changes: &repositories.HabitChanges{
|
||||
Created: []*entities.Habit{},
|
||||
Updated: []*entities.Habit{},
|
||||
Deleted: []string{},
|
||||
},
|
||||
}
|
||||
|
||||
entryRepo := &mockEntryRepoForSync{
|
||||
changes: &repositories.HabitEntryChanges{
|
||||
Created: []*entities.HabitEntry{},
|
||||
Updated: []*entities.HabitEntry{},
|
||||
Deleted: []string{},
|
||||
},
|
||||
}
|
||||
|
||||
handler := NewGetSyncChangesHandler(habitRepo, entryRepo)
|
||||
|
||||
query := GetSyncChangesQuery{
|
||||
UserID: "user-123",
|
||||
Since: time.Now().Add(-1 * time.Hour),
|
||||
}
|
||||
|
||||
result, err := handler.Handle(context.Background(), query)
|
||||
|
||||
if err != nil {
|
||||
t.Fatalf("Expected no error, got %v", err)
|
||||
}
|
||||
|
||||
if len(result.Habits.Created) != 0 {
|
||||
t.Errorf("Expected 0 created habits, got %d", len(result.Habits.Created))
|
||||
}
|
||||
|
||||
if len(result.Entries.Created) != 0 {
|
||||
t.Errorf("Expected 0 created entries, got %d", len(result.Entries.Created))
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetSyncChangesHandler_InvalidUserID(t *testing.T) {
|
||||
habitRepo := &mockHabitRepoForSync{
|
||||
changes: &repositories.HabitChanges{
|
||||
Created: []*entities.Habit{},
|
||||
Updated: []*entities.Habit{},
|
||||
Deleted: []string{},
|
||||
},
|
||||
}
|
||||
|
||||
entryRepo := &mockEntryRepoForSync{
|
||||
changes: &repositories.HabitEntryChanges{
|
||||
Created: []*entities.HabitEntry{},
|
||||
Updated: []*entities.HabitEntry{},
|
||||
Deleted: []string{},
|
||||
},
|
||||
}
|
||||
|
||||
handler := NewGetSyncChangesHandler(habitRepo, entryRepo)
|
||||
|
||||
query := GetSyncChangesQuery{
|
||||
UserID: "",
|
||||
Since: time.Now(),
|
||||
}
|
||||
|
||||
_, err := handler.Handle(context.Background(), query)
|
||||
|
||||
if err == nil {
|
||||
t.Error("Expected error for empty UserID, got nil")
|
||||
}
|
||||
}
|
||||
@@ -9,6 +9,12 @@ import (
|
||||
"apocapoc-api/internal/shared/utils"
|
||||
)
|
||||
|
||||
type TodaysHabitEntryDTO struct {
|
||||
ID string
|
||||
Value *float64
|
||||
CompletedAt time.Time
|
||||
}
|
||||
|
||||
type TodaysHabitDTO struct {
|
||||
ID string
|
||||
Name string
|
||||
@@ -17,6 +23,7 @@ type TodaysHabitDTO struct {
|
||||
IsNegative bool
|
||||
ScheduledDate time.Time
|
||||
IsCarriedOver bool
|
||||
Entry *TodaysHabitEntryDTO
|
||||
}
|
||||
|
||||
type GetTodaysHabitsQuery struct {
|
||||
@@ -66,29 +73,29 @@ func (h *GetTodaysHabitsHandler) Handle(
|
||||
entries, _ := h.entryRepo.FindByHabitIDAndDateRange(
|
||||
ctx,
|
||||
habit.ID,
|
||||
query.Date.AddDate(0, 0, -30),
|
||||
query.Date,
|
||||
query.Date,
|
||||
)
|
||||
|
||||
isCompleted := false
|
||||
for _, entry := range entries {
|
||||
if entry.ScheduledDate.Equal(query.Date) {
|
||||
isCompleted = true
|
||||
break
|
||||
var entryDTO *TodaysHabitEntryDTO
|
||||
if len(entries) > 0 && entries[0].ScheduledDate.Format("2006-01-02") == query.Date.Format("2006-01-02") {
|
||||
entryDTO = &TodaysHabitEntryDTO{
|
||||
ID: entries[0].ID,
|
||||
Value: entries[0].Value,
|
||||
CompletedAt: entries[0].CompletedAt,
|
||||
}
|
||||
}
|
||||
|
||||
if !isCompleted {
|
||||
result = append(result, TodaysHabitDTO{
|
||||
ID: habit.ID,
|
||||
Name: habit.Name,
|
||||
Type: habit.Type,
|
||||
TargetValue: habit.TargetValue,
|
||||
IsNegative: habit.IsNegative,
|
||||
ScheduledDate: query.Date,
|
||||
IsCarriedOver: !shouldAppear && habit.CarryOver,
|
||||
})
|
||||
}
|
||||
result = append(result, TodaysHabitDTO{
|
||||
ID: habit.ID,
|
||||
Name: habit.Name,
|
||||
Type: habit.Type,
|
||||
TargetValue: habit.TargetValue,
|
||||
IsNegative: habit.IsNegative,
|
||||
ScheduledDate: query.Date,
|
||||
IsCarriedOver: !shouldAppear && habit.CarryOver,
|
||||
Entry: entryDTO,
|
||||
})
|
||||
}
|
||||
|
||||
return result, nil
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
package queries
|
||||
|
||||
import (
|
||||
"apocapoc-api/internal/domain/repositories"
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"apocapoc-api/internal/domain/entities"
|
||||
"apocapoc-api/internal/domain/value_objects"
|
||||
"apocapoc-api/internal/shared/pagination"
|
||||
)
|
||||
|
||||
type mockHabitRepo struct {
|
||||
@@ -37,6 +39,14 @@ func (m *mockHabitRepo) Delete(ctx context.Context, id string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *mockHabitRepo) FindActiveByUserIDWithPagination(ctx context.Context, userID string, params pagination.Params) ([]*entities.Habit, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (m *mockHabitRepo) CountActiveByUserID(ctx context.Context, userID string) (int, error) {
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
type mockEntryRepo struct {
|
||||
entries []*entities.HabitEntry
|
||||
}
|
||||
@@ -63,6 +73,10 @@ func (m *mockEntryRepo) FindByHabitIDAndDateRange(ctx context.Context, habitID s
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (m *mockEntryRepo) FindByUserID(ctx context.Context, userID string) ([]*entities.HabitEntry, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (m *mockEntryRepo) FindPendingByHabitID(ctx context.Context, habitID string, beforeDate time.Time) ([]*entities.HabitEntry, error) {
|
||||
return nil, nil
|
||||
}
|
||||
@@ -75,6 +89,18 @@ func (m *mockEntryRepo) Delete(ctx context.Context, id string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *mockEntryRepo) GetChangesSince(ctx context.Context, userID string, since time.Time) (*repositories.HabitEntryChanges, error) {
|
||||
return &repositories.HabitEntryChanges{
|
||||
Created: []*entities.HabitEntry{},
|
||||
Updated: []*entities.HabitEntry{},
|
||||
Deleted: []string{},
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (m *mockEntryRepo) SoftDelete(ctx context.Context, id string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func TestGetTodaysHabitsHandler_DailyHabitNoEntries(t *testing.T) {
|
||||
habit := entities.NewHabit("user-123", "Exercise", value_objects.HabitTypeBoolean, value_objects.FrequencyDaily, false, false)
|
||||
habit.ID = "habit-1"
|
||||
@@ -107,6 +133,10 @@ func TestGetTodaysHabitsHandler_DailyHabitNoEntries(t *testing.T) {
|
||||
if results[0].IsCarriedOver {
|
||||
t.Error("Expected IsCarriedOver to be false")
|
||||
}
|
||||
|
||||
if results[0].Entry != nil {
|
||||
t.Error("Expected entry to be nil when no entry exists")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetTodaysHabitsHandler_DailyHabitAlreadyCompleted(t *testing.T) {
|
||||
@@ -115,6 +145,7 @@ func TestGetTodaysHabitsHandler_DailyHabitAlreadyCompleted(t *testing.T) {
|
||||
|
||||
targetDate := time.Date(2025, 1, 15, 0, 0, 0, 0, time.UTC)
|
||||
entry := entities.NewHabitEntry("habit-1", targetDate, nil)
|
||||
entry.ID = "entry-1"
|
||||
|
||||
habitRepo := &mockHabitRepo{habits: []*entities.Habit{habit}}
|
||||
entryRepo := &mockEntryRepo{entries: []*entities.HabitEntry{entry}}
|
||||
@@ -133,8 +164,61 @@ func TestGetTodaysHabitsHandler_DailyHabitAlreadyCompleted(t *testing.T) {
|
||||
t.Fatalf("Expected no error, got %v", err)
|
||||
}
|
||||
|
||||
if len(results) != 0 {
|
||||
t.Fatalf("Expected 0 habits (already completed), got %d", len(results))
|
||||
if len(results) != 1 {
|
||||
t.Fatalf("Expected 1 habit (with entry), got %d", len(results))
|
||||
}
|
||||
|
||||
if results[0].Entry == nil {
|
||||
t.Fatal("Expected entry to be present")
|
||||
}
|
||||
|
||||
if results[0].Entry.ID != "entry-1" {
|
||||
t.Errorf("Expected entry ID entry-1, got %s", results[0].Entry.ID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetTodaysHabitsHandler_HabitWithValueEntry(t *testing.T) {
|
||||
habit := entities.NewHabit("user-123", "Water", value_objects.HabitTypeValue, value_objects.FrequencyDaily, false, false)
|
||||
habit.ID = "habit-1"
|
||||
targetValue := 2000.0
|
||||
habit.TargetValue = &targetValue
|
||||
|
||||
targetDate := time.Date(2025, 1, 15, 0, 0, 0, 0, time.UTC)
|
||||
value := 1500.0
|
||||
entry := entities.NewHabitEntry("habit-1", targetDate, &value)
|
||||
entry.ID = "entry-1"
|
||||
|
||||
habitRepo := &mockHabitRepo{habits: []*entities.Habit{habit}}
|
||||
entryRepo := &mockEntryRepo{entries: []*entities.HabitEntry{entry}}
|
||||
|
||||
handler := NewGetTodaysHabitsHandler(habitRepo, entryRepo)
|
||||
|
||||
query := GetTodaysHabitsQuery{
|
||||
UserID: "user-123",
|
||||
Timezone: "UTC",
|
||||
Date: targetDate,
|
||||
}
|
||||
|
||||
results, err := handler.Handle(context.Background(), query)
|
||||
|
||||
if err != nil {
|
||||
t.Fatalf("Expected no error, got %v", err)
|
||||
}
|
||||
|
||||
if len(results) != 1 {
|
||||
t.Fatalf("Expected 1 habit, got %d", len(results))
|
||||
}
|
||||
|
||||
if results[0].Entry == nil {
|
||||
t.Fatal("Expected entry to be present")
|
||||
}
|
||||
|
||||
if results[0].Entry.Value == nil {
|
||||
t.Fatal("Expected entry value to be present")
|
||||
}
|
||||
|
||||
if *results[0].Entry.Value != 1500.0 {
|
||||
t.Errorf("Expected entry value 1500.0, got %f", *results[0].Entry.Value)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -257,3 +341,23 @@ func TestGetTodaysHabitsHandler_CarryOverDisabled(t *testing.T) {
|
||||
t.Fatalf("Expected 0 habits (no carry-over), got %d", len(results))
|
||||
}
|
||||
}
|
||||
|
||||
func (m *mockHabitRepo) FindByUserIDFiltered(ctx context.Context, userID string, filter repositories.HabitFilter, paginationParams *pagination.Params) ([]*entities.Habit, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (m *mockHabitRepo) CountByUserIDFiltered(ctx context.Context, userID string, filter repositories.HabitFilter) (int, error) {
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
func (m *mockHabitRepo) GetChangesSince(ctx context.Context, userID string, since time.Time) (*repositories.HabitChanges, error) {
|
||||
return &repositories.HabitChanges{
|
||||
Created: []*entities.Habit{},
|
||||
Updated: []*entities.Habit{},
|
||||
Deleted: []string{},
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (m *mockHabitRepo) SoftDelete(ctx context.Context, id string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -3,8 +3,10 @@ package queries
|
||||
import (
|
||||
"context"
|
||||
|
||||
"apocapoc-api/internal/domain/entities"
|
||||
"apocapoc-api/internal/domain/repositories"
|
||||
"apocapoc-api/internal/domain/value_objects"
|
||||
"apocapoc-api/internal/shared/pagination"
|
||||
)
|
||||
|
||||
type HabitDTO struct {
|
||||
@@ -18,8 +20,22 @@ type HabitDTO struct {
|
||||
SpecificDays []int
|
||||
}
|
||||
|
||||
type FilterParams struct {
|
||||
Type *value_objects.HabitType
|
||||
Frequency *value_objects.Frequency
|
||||
IncludeArchived bool
|
||||
Search string
|
||||
}
|
||||
|
||||
type GetUserHabitsQuery struct {
|
||||
UserID string
|
||||
UserID string
|
||||
PaginationParams *pagination.Params
|
||||
FilterParams *FilterParams
|
||||
}
|
||||
|
||||
type GetUserHabitsResult struct {
|
||||
Habits []HabitDTO
|
||||
Pagination *pagination.Response
|
||||
}
|
||||
|
||||
type GetUserHabitsHandler struct {
|
||||
@@ -32,15 +48,56 @@ func NewGetUserHabitsHandler(habitRepo repositories.HabitRepository) *GetUserHab
|
||||
}
|
||||
}
|
||||
|
||||
func (h *GetUserHabitsHandler) Handle(ctx context.Context, query GetUserHabitsQuery) ([]HabitDTO, error) {
|
||||
habits, err := h.habitRepo.FindActiveByUserID(ctx, query.UserID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
func (h *GetUserHabitsHandler) Handle(ctx context.Context, query GetUserHabitsQuery) (*GetUserHabitsResult, error) {
|
||||
var habits []*entities.Habit
|
||||
var paginationResponse *pagination.Response
|
||||
var err error
|
||||
|
||||
if query.FilterParams != nil {
|
||||
filter := repositories.HabitFilter{
|
||||
Type: query.FilterParams.Type,
|
||||
Frequency: query.FilterParams.Frequency,
|
||||
IncludeArchived: query.FilterParams.IncludeArchived,
|
||||
Search: query.FilterParams.Search,
|
||||
}
|
||||
|
||||
habits, err = h.habitRepo.FindByUserIDFiltered(ctx, query.UserID, filter, query.PaginationParams)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if query.PaginationParams != nil {
|
||||
totalItems, err := h.habitRepo.CountByUserIDFiltered(ctx, query.UserID, filter)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
response := pagination.NewResponse(*query.PaginationParams, totalItems)
|
||||
paginationResponse = &response
|
||||
}
|
||||
} else if query.PaginationParams != nil {
|
||||
habits, err = h.habitRepo.FindActiveByUserIDWithPagination(ctx, query.UserID, *query.PaginationParams)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
totalItems, err := h.habitRepo.CountActiveByUserID(ctx, query.UserID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
response := pagination.NewResponse(*query.PaginationParams, totalItems)
|
||||
paginationResponse = &response
|
||||
} else {
|
||||
habits, err = h.habitRepo.FindActiveByUserID(ctx, query.UserID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
var result []HabitDTO
|
||||
var habitDTOs []HabitDTO
|
||||
for _, habit := range habits {
|
||||
result = append(result, HabitDTO{
|
||||
habitDTOs = append(habitDTOs, HabitDTO{
|
||||
ID: habit.ID,
|
||||
Name: habit.Name,
|
||||
Type: habit.Type,
|
||||
@@ -52,5 +109,8 @@ func (h *GetUserHabitsHandler) Handle(ctx context.Context, query GetUserHabitsQu
|
||||
})
|
||||
}
|
||||
|
||||
return result, nil
|
||||
return &GetUserHabitsResult{
|
||||
Habits: habitDTOs,
|
||||
Pagination: paginationResponse,
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -1,13 +1,64 @@
|
||||
package queries
|
||||
|
||||
import (
|
||||
"apocapoc-api/internal/domain/repositories"
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"apocapoc-api/internal/domain/entities"
|
||||
"apocapoc-api/internal/domain/value_objects"
|
||||
"apocapoc-api/internal/shared/pagination"
|
||||
)
|
||||
|
||||
type mockGetUserHabitsRepo struct {
|
||||
habits []*entities.Habit
|
||||
}
|
||||
|
||||
func (m *mockGetUserHabitsRepo) Create(ctx context.Context, habit *entities.Habit) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *mockGetUserHabitsRepo) FindByID(ctx context.Context, id string) (*entities.Habit, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (m *mockGetUserHabitsRepo) FindByUserID(ctx context.Context, userID string) ([]*entities.Habit, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (m *mockGetUserHabitsRepo) FindActiveByUserID(ctx context.Context, userID string) ([]*entities.Habit, error) {
|
||||
return m.habits, nil
|
||||
}
|
||||
|
||||
func (m *mockGetUserHabitsRepo) FindActiveByUserIDWithPagination(ctx context.Context, userID string, params pagination.Params) ([]*entities.Habit, error) {
|
||||
offset := params.Offset()
|
||||
limit := params.Limit()
|
||||
|
||||
if offset >= len(m.habits) {
|
||||
return []*entities.Habit{}, nil
|
||||
}
|
||||
|
||||
end := offset + limit
|
||||
if end > len(m.habits) {
|
||||
end = len(m.habits)
|
||||
}
|
||||
|
||||
return m.habits[offset:end], nil
|
||||
}
|
||||
|
||||
func (m *mockGetUserHabitsRepo) CountActiveByUserID(ctx context.Context, userID string) (int, error) {
|
||||
return len(m.habits), nil
|
||||
}
|
||||
|
||||
func (m *mockGetUserHabitsRepo) Update(ctx context.Context, habit *entities.Habit) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *mockGetUserHabitsRepo) Delete(ctx context.Context, id string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func TestGetUserHabitsHandler_ReturnsAllActiveHabits(t *testing.T) {
|
||||
habit1 := entities.NewHabit("user-123", "Exercise", value_objects.HabitTypeBoolean, value_objects.FrequencyDaily, false, false)
|
||||
habit1.ID = "habit-1"
|
||||
@@ -15,7 +66,7 @@ func TestGetUserHabitsHandler_ReturnsAllActiveHabits(t *testing.T) {
|
||||
habit2 := entities.NewHabit("user-123", "Read", value_objects.HabitTypeBoolean, value_objects.FrequencyWeekly, false, false)
|
||||
habit2.ID = "habit-2"
|
||||
|
||||
habitRepo := &mockHabitRepo{habits: []*entities.Habit{habit1, habit2}}
|
||||
habitRepo := &mockGetUserHabitsRepo{habits: []*entities.Habit{habit1, habit2}}
|
||||
|
||||
handler := NewGetUserHabitsHandler(habitRepo)
|
||||
|
||||
@@ -23,27 +74,31 @@ func TestGetUserHabitsHandler_ReturnsAllActiveHabits(t *testing.T) {
|
||||
UserID: "user-123",
|
||||
}
|
||||
|
||||
results, err := handler.Handle(context.Background(), query)
|
||||
result, err := handler.Handle(context.Background(), query)
|
||||
|
||||
if err != nil {
|
||||
t.Fatalf("Expected no error, got %v", err)
|
||||
}
|
||||
|
||||
if len(results) != 2 {
|
||||
t.Fatalf("Expected 2 habits, got %d", len(results))
|
||||
if len(result.Habits) != 2 {
|
||||
t.Fatalf("Expected 2 habits, got %d", len(result.Habits))
|
||||
}
|
||||
|
||||
if results[0].ID != "habit-1" {
|
||||
t.Errorf("Expected first habit ID habit-1, got %s", results[0].ID)
|
||||
if result.Habits[0].ID != "habit-1" {
|
||||
t.Errorf("Expected first habit ID habit-1, got %s", result.Habits[0].ID)
|
||||
}
|
||||
|
||||
if results[1].ID != "habit-2" {
|
||||
t.Errorf("Expected second habit ID habit-2, got %s", results[1].ID)
|
||||
if result.Habits[1].ID != "habit-2" {
|
||||
t.Errorf("Expected second habit ID habit-2, got %s", result.Habits[1].ID)
|
||||
}
|
||||
|
||||
if result.Pagination != nil {
|
||||
t.Error("Expected no pagination when not requested")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetUserHabitsHandler_ReturnsEmptyListForUserWithNoHabits(t *testing.T) {
|
||||
habitRepo := &mockHabitRepo{habits: []*entities.Habit{}}
|
||||
habitRepo := &mockGetUserHabitsRepo{habits: []*entities.Habit{}}
|
||||
|
||||
handler := NewGetUserHabitsHandler(habitRepo)
|
||||
|
||||
@@ -51,14 +106,14 @@ func TestGetUserHabitsHandler_ReturnsEmptyListForUserWithNoHabits(t *testing.T)
|
||||
UserID: "user-456",
|
||||
}
|
||||
|
||||
results, err := handler.Handle(context.Background(), query)
|
||||
result, err := handler.Handle(context.Background(), query)
|
||||
|
||||
if err != nil {
|
||||
t.Fatalf("Expected no error, got %v", err)
|
||||
}
|
||||
|
||||
if len(results) != 0 {
|
||||
t.Fatalf("Expected 0 habits, got %d", len(results))
|
||||
if len(result.Habits) != 0 {
|
||||
t.Fatalf("Expected 0 habits, got %d", len(result.Habits))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -68,7 +123,7 @@ func TestGetUserHabitsHandler_IncludesAllHabitFields(t *testing.T) {
|
||||
habit.ID = "habit-1"
|
||||
habit.TargetValue = &targetValue
|
||||
|
||||
habitRepo := &mockHabitRepo{habits: []*entities.Habit{habit}}
|
||||
habitRepo := &mockGetUserHabitsRepo{habits: []*entities.Habit{habit}}
|
||||
|
||||
handler := NewGetUserHabitsHandler(habitRepo)
|
||||
|
||||
@@ -76,35 +131,302 @@ func TestGetUserHabitsHandler_IncludesAllHabitFields(t *testing.T) {
|
||||
UserID: "user-123",
|
||||
}
|
||||
|
||||
results, err := handler.Handle(context.Background(), query)
|
||||
result, err := handler.Handle(context.Background(), query)
|
||||
|
||||
if err != nil {
|
||||
t.Fatalf("Expected no error, got %v", err)
|
||||
}
|
||||
|
||||
if len(results) != 1 {
|
||||
t.Fatalf("Expected 1 habit, got %d", len(results))
|
||||
if len(result.Habits) != 1 {
|
||||
t.Fatalf("Expected 1 habit, got %d", len(result.Habits))
|
||||
}
|
||||
|
||||
result := results[0]
|
||||
habitDTO := result.Habits[0]
|
||||
|
||||
if result.Name != "Drink Water" {
|
||||
t.Errorf("Expected name 'Drink Water', got %s", result.Name)
|
||||
if habitDTO.Name != "Drink Water" {
|
||||
t.Errorf("Expected name 'Drink Water', got %s", habitDTO.Name)
|
||||
}
|
||||
|
||||
if result.Type != value_objects.HabitTypeValue {
|
||||
t.Errorf("Expected type %s, got %s", value_objects.HabitTypeValue, result.Type)
|
||||
if habitDTO.Type != value_objects.HabitTypeValue {
|
||||
t.Errorf("Expected type %s, got %s", value_objects.HabitTypeValue, habitDTO.Type)
|
||||
}
|
||||
|
||||
if result.Frequency != value_objects.FrequencyDaily {
|
||||
t.Errorf("Expected frequency %s, got %s", value_objects.FrequencyDaily, result.Frequency)
|
||||
if habitDTO.Frequency != value_objects.FrequencyDaily {
|
||||
t.Errorf("Expected frequency %s, got %s", value_objects.FrequencyDaily, habitDTO.Frequency)
|
||||
}
|
||||
|
||||
if result.TargetValue == nil || *result.TargetValue != 5.0 {
|
||||
t.Errorf("Expected target value 5.0, got %v", result.TargetValue)
|
||||
if habitDTO.TargetValue == nil || *habitDTO.TargetValue != 5.0 {
|
||||
t.Errorf("Expected target value 5.0, got %v", habitDTO.TargetValue)
|
||||
}
|
||||
|
||||
if !result.CarryOver {
|
||||
if !habitDTO.CarryOver {
|
||||
t.Error("Expected carry over to be true")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetUserHabitsHandler_WithPagination(t *testing.T) {
|
||||
var habits []*entities.Habit
|
||||
for i := 1; i <= 10; i++ {
|
||||
habit := entities.NewHabit("user-123", "Habit", value_objects.HabitTypeBoolean, value_objects.FrequencyDaily, false, false)
|
||||
habit.ID = "habit-" + string(rune(i+'0'))
|
||||
habits = append(habits, habit)
|
||||
}
|
||||
|
||||
habitRepo := &mockGetUserHabitsRepo{habits: habits}
|
||||
handler := NewGetUserHabitsHandler(habitRepo)
|
||||
|
||||
t.Run("FirstPage", func(t *testing.T) {
|
||||
params := pagination.NewParams(1, 5)
|
||||
query := GetUserHabitsQuery{
|
||||
UserID: "user-123",
|
||||
PaginationParams: ¶ms,
|
||||
}
|
||||
|
||||
result, err := handler.Handle(context.Background(), query)
|
||||
|
||||
if err != nil {
|
||||
t.Fatalf("Expected no error, got %v", err)
|
||||
}
|
||||
|
||||
if len(result.Habits) != 5 {
|
||||
t.Errorf("Expected 5 habits on first page, got %d", len(result.Habits))
|
||||
}
|
||||
|
||||
if result.Pagination == nil {
|
||||
t.Fatal("Expected pagination metadata")
|
||||
}
|
||||
|
||||
if result.Pagination.Page != 1 {
|
||||
t.Errorf("Expected page 1, got %d", result.Pagination.Page)
|
||||
}
|
||||
|
||||
if result.Pagination.PageSize != 5 {
|
||||
t.Errorf("Expected page_size 5, got %d", result.Pagination.PageSize)
|
||||
}
|
||||
|
||||
if result.Pagination.TotalItems != 10 {
|
||||
t.Errorf("Expected total_items 10, got %d", result.Pagination.TotalItems)
|
||||
}
|
||||
|
||||
if result.Pagination.TotalPages != 2 {
|
||||
t.Errorf("Expected total_pages 2, got %d", result.Pagination.TotalPages)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("SecondPage", func(t *testing.T) {
|
||||
params := pagination.NewParams(2, 5)
|
||||
query := GetUserHabitsQuery{
|
||||
UserID: "user-123",
|
||||
PaginationParams: ¶ms,
|
||||
}
|
||||
|
||||
result, err := handler.Handle(context.Background(), query)
|
||||
|
||||
if err != nil {
|
||||
t.Fatalf("Expected no error, got %v", err)
|
||||
}
|
||||
|
||||
if len(result.Habits) != 5 {
|
||||
t.Errorf("Expected 5 habits on second page, got %d", len(result.Habits))
|
||||
}
|
||||
|
||||
if result.Pagination.Page != 2 {
|
||||
t.Errorf("Expected page 2, got %d", result.Pagination.Page)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("PageBeyondTotal", func(t *testing.T) {
|
||||
params := pagination.NewParams(10, 5)
|
||||
query := GetUserHabitsQuery{
|
||||
UserID: "user-123",
|
||||
PaginationParams: ¶ms,
|
||||
}
|
||||
|
||||
result, err := handler.Handle(context.Background(), query)
|
||||
|
||||
if err != nil {
|
||||
t.Fatalf("Expected no error, got %v", err)
|
||||
}
|
||||
|
||||
if len(result.Habits) != 0 {
|
||||
t.Errorf("Expected 0 habits beyond total, got %d", len(result.Habits))
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("CustomPageSize", func(t *testing.T) {
|
||||
params := pagination.NewParams(1, 3)
|
||||
query := GetUserHabitsQuery{
|
||||
UserID: "user-123",
|
||||
PaginationParams: ¶ms,
|
||||
}
|
||||
|
||||
result, err := handler.Handle(context.Background(), query)
|
||||
|
||||
if err != nil {
|
||||
t.Fatalf("Expected no error, got %v", err)
|
||||
}
|
||||
|
||||
if len(result.Habits) != 3 {
|
||||
t.Errorf("Expected 3 habits with page_size=3, got %d", len(result.Habits))
|
||||
}
|
||||
|
||||
if result.Pagination.PageSize != 3 {
|
||||
t.Errorf("Expected page_size 3, got %d", result.Pagination.PageSize)
|
||||
}
|
||||
|
||||
if result.Pagination.TotalPages != 4 {
|
||||
t.Errorf("Expected total_pages 4 (10 items / 3 per page), got %d", result.Pagination.TotalPages)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func (m *mockGetUserHabitsRepo) FindByUserIDFiltered(ctx context.Context, userID string, filter repositories.HabitFilter, paginationParams *pagination.Params) ([]*entities.Habit, error) {
|
||||
var filtered []*entities.Habit
|
||||
|
||||
for _, habit := range m.habits {
|
||||
if filter.Type != nil && habit.Type != *filter.Type {
|
||||
continue
|
||||
}
|
||||
if filter.Frequency != nil && habit.Frequency != *filter.Frequency {
|
||||
continue
|
||||
}
|
||||
if !filter.IncludeArchived && habit.ArchivedAt != nil {
|
||||
continue
|
||||
}
|
||||
filtered = append(filtered, habit)
|
||||
}
|
||||
|
||||
if paginationParams != nil {
|
||||
offset := paginationParams.Offset()
|
||||
limit := paginationParams.Limit()
|
||||
|
||||
if offset >= len(filtered) {
|
||||
return []*entities.Habit{}, nil
|
||||
}
|
||||
|
||||
end := offset + limit
|
||||
if end > len(filtered) {
|
||||
end = len(filtered)
|
||||
}
|
||||
|
||||
return filtered[offset:end], nil
|
||||
}
|
||||
|
||||
return filtered, nil
|
||||
}
|
||||
|
||||
func (m *mockGetUserHabitsRepo) CountByUserIDFiltered(ctx context.Context, userID string, filter repositories.HabitFilter) (int, error) {
|
||||
count := 0
|
||||
|
||||
for _, habit := range m.habits {
|
||||
if filter.Type != nil && habit.Type != *filter.Type {
|
||||
continue
|
||||
}
|
||||
if filter.Frequency != nil && habit.Frequency != *filter.Frequency {
|
||||
continue
|
||||
}
|
||||
if !filter.IncludeArchived && habit.ArchivedAt != nil {
|
||||
continue
|
||||
}
|
||||
count++
|
||||
}
|
||||
|
||||
return count, nil
|
||||
}
|
||||
|
||||
func (m *mockGetUserHabitsRepo) GetChangesSince(ctx context.Context, userID string, since time.Time) (*repositories.HabitChanges, error) {
|
||||
return &repositories.HabitChanges{
|
||||
Created: []*entities.Habit{},
|
||||
Updated: []*entities.Habit{},
|
||||
Deleted: []string{},
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (m *mockGetUserHabitsRepo) SoftDelete(ctx context.Context, id string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func TestGetUserHabitsHandler_WithFilters(t *testing.T) {
|
||||
habit1 := entities.NewHabit("user-123", "Exercise", value_objects.HabitTypeBoolean, value_objects.FrequencyDaily, false, false)
|
||||
habit1.ID = "habit-1"
|
||||
|
||||
habit2 := entities.NewHabit("user-123", "Read", value_objects.HabitTypeCounter, value_objects.FrequencyWeekly, false, false)
|
||||
habit2.ID = "habit-2"
|
||||
|
||||
habit3 := entities.NewHabit("user-123", "Water", value_objects.HabitTypeValue, value_objects.FrequencyDaily, false, false)
|
||||
habit3.ID = "habit-3"
|
||||
|
||||
habitRepo := &mockGetUserHabitsRepo{habits: []*entities.Habit{habit1, habit2, habit3}}
|
||||
handler := NewGetUserHabitsHandler(habitRepo)
|
||||
|
||||
t.Run("FilterByType", func(t *testing.T) {
|
||||
habitType := value_objects.HabitTypeBoolean
|
||||
query := GetUserHabitsQuery{
|
||||
UserID: "user-123",
|
||||
FilterParams: &FilterParams{
|
||||
Type: &habitType,
|
||||
},
|
||||
}
|
||||
|
||||
result, err := handler.Handle(context.Background(), query)
|
||||
if err != nil {
|
||||
t.Fatalf("Expected no error, got %v", err)
|
||||
}
|
||||
|
||||
if len(result.Habits) != 1 {
|
||||
t.Errorf("Expected 1 BOOLEAN habit, got %d", len(result.Habits))
|
||||
}
|
||||
|
||||
if result.Habits[0].Type != value_objects.HabitTypeBoolean {
|
||||
t.Errorf("Expected BOOLEAN type, got %s", result.Habits[0].Type)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("FilterByFrequency", func(t *testing.T) {
|
||||
frequency := value_objects.FrequencyDaily
|
||||
query := GetUserHabitsQuery{
|
||||
UserID: "user-123",
|
||||
FilterParams: &FilterParams{
|
||||
Frequency: &frequency,
|
||||
},
|
||||
}
|
||||
|
||||
result, err := handler.Handle(context.Background(), query)
|
||||
if err != nil {
|
||||
t.Fatalf("Expected no error, got %v", err)
|
||||
}
|
||||
|
||||
if len(result.Habits) != 2 {
|
||||
t.Errorf("Expected 2 DAILY habits, got %d", len(result.Habits))
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("FilterWithPagination", func(t *testing.T) {
|
||||
frequency := value_objects.FrequencyDaily
|
||||
params := pagination.NewParams(1, 1)
|
||||
query := GetUserHabitsQuery{
|
||||
UserID: "user-123",
|
||||
FilterParams: &FilterParams{
|
||||
Frequency: &frequency,
|
||||
},
|
||||
PaginationParams: ¶ms,
|
||||
}
|
||||
|
||||
result, err := handler.Handle(context.Background(), query)
|
||||
if err != nil {
|
||||
t.Fatalf("Expected no error, got %v", err)
|
||||
}
|
||||
|
||||
if len(result.Habits) != 1 {
|
||||
t.Errorf("Expected 1 habit on first page, got %d", len(result.Habits))
|
||||
}
|
||||
|
||||
if result.Pagination == nil {
|
||||
t.Fatal("Expected pagination metadata")
|
||||
}
|
||||
|
||||
if result.Pagination.TotalItems != 2 {
|
||||
t.Errorf("Expected 2 total DAILY habits, got %d", result.Pagination.TotalItems)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -14,9 +14,8 @@ type LoginUserQuery struct {
|
||||
}
|
||||
|
||||
type LoginUserResult struct {
|
||||
UserID string
|
||||
Email string
|
||||
Timezone string
|
||||
UserID string
|
||||
Email string
|
||||
}
|
||||
|
||||
type LoginUserHandler struct {
|
||||
@@ -45,9 +44,12 @@ func (h *LoginUserHandler) Handle(ctx context.Context, query LoginUserQuery) (*L
|
||||
return nil, errors.ErrNotFound
|
||||
}
|
||||
|
||||
if !user.EmailVerified {
|
||||
return nil, errors.ErrEmailNotVerified
|
||||
}
|
||||
|
||||
return &LoginUserResult{
|
||||
UserID: user.ID,
|
||||
Email: user.Email,
|
||||
Timezone: user.Timezone,
|
||||
UserID: user.ID,
|
||||
Email: user.Email,
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -0,0 +1,224 @@
|
||||
package queries
|
||||
|
||||
import (
|
||||
"apocapoc-api/internal/domain/repositories"
|
||||
"apocapoc-api/internal/shared/pagination"
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"apocapoc-api/internal/domain/entities"
|
||||
"apocapoc-api/internal/shared/errors"
|
||||
)
|
||||
|
||||
type mockLoginUserRepo struct {
|
||||
findByEmailFunc func(ctx context.Context, email string) (*entities.User, error)
|
||||
}
|
||||
|
||||
func (m *mockLoginUserRepo) FindByEmail(ctx context.Context, email string) (*entities.User, error) {
|
||||
if m.findByEmailFunc != nil {
|
||||
return m.findByEmailFunc(ctx, email)
|
||||
}
|
||||
return nil, errors.ErrNotFound
|
||||
}
|
||||
|
||||
func (m *mockLoginUserRepo) FindByID(ctx context.Context, id string) (*entities.User, error) {
|
||||
return nil, errors.ErrNotFound
|
||||
}
|
||||
|
||||
func (m *mockLoginUserRepo) FindByVerificationToken(ctx context.Context, token string) (*entities.User, error) {
|
||||
return nil, errors.ErrNotFound
|
||||
}
|
||||
|
||||
func (m *mockLoginUserRepo) Create(ctx context.Context, user *entities.User) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *mockLoginUserRepo) Update(ctx context.Context, user *entities.User) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *mockLoginUserRepo) Delete(ctx context.Context, id string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
type mockLoginPasswordHasher struct {
|
||||
compareFunc func(hashedPassword, password string) error
|
||||
}
|
||||
|
||||
func (m *mockLoginPasswordHasher) Hash(password string) (string, error) {
|
||||
return "hashed_" + password, nil
|
||||
}
|
||||
|
||||
func (m *mockLoginPasswordHasher) Compare(hashedPassword, password string) error {
|
||||
if m.compareFunc != nil {
|
||||
return m.compareFunc(hashedPassword, password)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func TestLoginUserHandler_Success(t *testing.T) {
|
||||
user := entities.NewUser("test@example.com", "hashed_password")
|
||||
user.ID = "user-123"
|
||||
user.EmailVerified = true
|
||||
|
||||
repo := &mockLoginUserRepo{
|
||||
findByEmailFunc: func(ctx context.Context, email string) (*entities.User, error) {
|
||||
return user, nil
|
||||
},
|
||||
}
|
||||
|
||||
hasher := &mockLoginPasswordHasher{
|
||||
compareFunc: func(hashedPassword, password string) error {
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
handler := NewLoginUserHandler(repo, hasher)
|
||||
|
||||
query := LoginUserQuery{
|
||||
Email: "test@example.com",
|
||||
Password: "password123",
|
||||
}
|
||||
|
||||
result, err := handler.Handle(context.Background(), query)
|
||||
if err != nil {
|
||||
t.Fatalf("Handle() unexpected error = %v", err)
|
||||
}
|
||||
|
||||
if result.UserID != "user-123" {
|
||||
t.Errorf("UserID = %v, want %v", result.UserID, "user-123")
|
||||
}
|
||||
|
||||
if result.Email != "test@example.com" {
|
||||
t.Errorf("Email = %v, want %v", result.Email, "test@example.com")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoginUserHandler_EmptyEmail(t *testing.T) {
|
||||
repo := &mockLoginUserRepo{}
|
||||
hasher := &mockLoginPasswordHasher{}
|
||||
handler := NewLoginUserHandler(repo, hasher)
|
||||
|
||||
query := LoginUserQuery{
|
||||
Email: "",
|
||||
Password: "password123",
|
||||
}
|
||||
|
||||
_, err := handler.Handle(context.Background(), query)
|
||||
if err != errors.ErrInvalidInput {
|
||||
t.Errorf("Handle() error = %v, want %v", err, errors.ErrInvalidInput)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoginUserHandler_EmptyPassword(t *testing.T) {
|
||||
repo := &mockLoginUserRepo{}
|
||||
hasher := &mockLoginPasswordHasher{}
|
||||
handler := NewLoginUserHandler(repo, hasher)
|
||||
|
||||
query := LoginUserQuery{
|
||||
Email: "test@example.com",
|
||||
Password: "",
|
||||
}
|
||||
|
||||
_, err := handler.Handle(context.Background(), query)
|
||||
if err != errors.ErrInvalidInput {
|
||||
t.Errorf("Handle() error = %v, want %v", err, errors.ErrInvalidInput)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoginUserHandler_UserNotFound(t *testing.T) {
|
||||
repo := &mockLoginUserRepo{
|
||||
findByEmailFunc: func(ctx context.Context, email string) (*entities.User, error) {
|
||||
return nil, errors.ErrNotFound
|
||||
},
|
||||
}
|
||||
|
||||
hasher := &mockLoginPasswordHasher{}
|
||||
handler := NewLoginUserHandler(repo, hasher)
|
||||
|
||||
query := LoginUserQuery{
|
||||
Email: "nonexistent@example.com",
|
||||
Password: "password123",
|
||||
}
|
||||
|
||||
_, err := handler.Handle(context.Background(), query)
|
||||
if err != errors.ErrNotFound {
|
||||
t.Errorf("Handle() error = %v, want %v", err, errors.ErrNotFound)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoginUserHandler_InvalidPassword(t *testing.T) {
|
||||
user := entities.NewUser("test@example.com", "hashed_password")
|
||||
user.ID = "user-123"
|
||||
user.EmailVerified = true
|
||||
|
||||
repo := &mockLoginUserRepo{
|
||||
findByEmailFunc: func(ctx context.Context, email string) (*entities.User, error) {
|
||||
return user, nil
|
||||
},
|
||||
}
|
||||
|
||||
hasher := &mockLoginPasswordHasher{
|
||||
compareFunc: func(hashedPassword, password string) error {
|
||||
return errors.ErrInvalidInput
|
||||
},
|
||||
}
|
||||
|
||||
handler := NewLoginUserHandler(repo, hasher)
|
||||
|
||||
query := LoginUserQuery{
|
||||
Email: "test@example.com",
|
||||
Password: "wrongpassword",
|
||||
}
|
||||
|
||||
_, err := handler.Handle(context.Background(), query)
|
||||
if err != errors.ErrNotFound {
|
||||
t.Errorf("Handle() error = %v, want %v", err, errors.ErrNotFound)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoginUserHandler_EmailNotVerified(t *testing.T) {
|
||||
user := entities.NewUser("test@example.com", "hashed_password")
|
||||
user.ID = "user-123"
|
||||
user.EmailVerified = false
|
||||
|
||||
repo := &mockLoginUserRepo{
|
||||
findByEmailFunc: func(ctx context.Context, email string) (*entities.User, error) {
|
||||
return user, nil
|
||||
},
|
||||
}
|
||||
|
||||
hasher := &mockLoginPasswordHasher{
|
||||
compareFunc: func(hashedPassword, password string) error {
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
handler := NewLoginUserHandler(repo, hasher)
|
||||
|
||||
query := LoginUserQuery{
|
||||
Email: "test@example.com",
|
||||
Password: "password123",
|
||||
}
|
||||
|
||||
_, err := handler.Handle(context.Background(), query)
|
||||
if err != errors.ErrEmailNotVerified {
|
||||
t.Errorf("Handle() error = %v, want %v", err, errors.ErrEmailNotVerified)
|
||||
}
|
||||
}
|
||||
|
||||
func (m *mockLoginUserRepo) FindActiveByUserIDWithPagination(ctx context.Context, userID string, params pagination.Params) ([]*entities.Habit, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (m *mockLoginUserRepo) CountActiveByUserID(ctx context.Context, userID string) (int, error) {
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
func (m *mockLoginUserRepo) FindByUserIDFiltered(ctx context.Context, userID string, filter repositories.HabitFilter, paginationParams *pagination.Params) ([]*entities.Habit, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (m *mockLoginUserRepo) CountByUserIDFiltered(ctx context.Context, userID string, filter repositories.HabitFilter) (int, error) {
|
||||
return 0, nil
|
||||
}
|
||||
@@ -17,9 +17,8 @@ type RefreshTokenQuery struct {
|
||||
}
|
||||
|
||||
type RefreshTokenResult struct {
|
||||
UserID string
|
||||
Email string
|
||||
Timezone string
|
||||
UserID string
|
||||
Email string
|
||||
}
|
||||
|
||||
type RefreshTokenHandler struct {
|
||||
@@ -57,9 +56,8 @@ func (h *RefreshTokenHandler) Handle(ctx context.Context, query RefreshTokenQuer
|
||||
}
|
||||
|
||||
return &RefreshTokenResult{
|
||||
UserID: user.ID,
|
||||
Email: user.Email,
|
||||
Timezone: user.Timezone,
|
||||
UserID: user.ID,
|
||||
Email: user.Email,
|
||||
}, nil
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
package queries
|
||||
|
||||
import (
|
||||
"apocapoc-api/internal/domain/repositories"
|
||||
"apocapoc-api/internal/shared/pagination"
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
@@ -59,10 +61,18 @@ func (m *mockUserRepositoryForRefresh) FindByEmail(ctx context.Context, email st
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (m *mockUserRepositoryForRefresh) FindByVerificationToken(ctx context.Context, token string) (*entities.User, error) {
|
||||
return nil, errors.ErrNotFound
|
||||
}
|
||||
|
||||
func (m *mockUserRepositoryForRefresh) Update(ctx context.Context, user *entities.User) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *mockUserRepositoryForRefresh) Delete(ctx context.Context, id string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func TestRefreshTokenHandler_Success(t *testing.T) {
|
||||
refreshTokenRepo := &mockRefreshTokenRepository{
|
||||
findByTokenFunc: func(ctx context.Context, token string) (*entities.RefreshToken, error) {
|
||||
@@ -72,7 +82,7 @@ func TestRefreshTokenHandler_Success(t *testing.T) {
|
||||
|
||||
userRepo := &mockUserRepositoryForRefresh{
|
||||
findByIDFunc: func(ctx context.Context, id string) (*entities.User, error) {
|
||||
user := entities.NewUser("test@example.com", "hash", "UTC")
|
||||
user := entities.NewUser("test@example.com", "hash")
|
||||
user.ID = id
|
||||
return user, nil
|
||||
},
|
||||
@@ -174,3 +184,119 @@ func TestRefreshTokenHandler_EmptyToken(t *testing.T) {
|
||||
t.Errorf("Expected ErrInvalidInput, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerateRefreshToken(t *testing.T) {
|
||||
token1, err := GenerateRefreshToken()
|
||||
if err != nil {
|
||||
t.Fatalf("GenerateRefreshToken() unexpected error = %v", err)
|
||||
}
|
||||
|
||||
if token1 == "" {
|
||||
t.Fatal("GenerateRefreshToken() returned empty token")
|
||||
}
|
||||
|
||||
token2, err := GenerateRefreshToken()
|
||||
if err != nil {
|
||||
t.Fatalf("GenerateRefreshToken() unexpected error = %v", err)
|
||||
}
|
||||
|
||||
if token1 == token2 {
|
||||
t.Error("GenerateRefreshToken() generated identical tokens")
|
||||
}
|
||||
|
||||
if len(token1) < 20 {
|
||||
t.Errorf("GenerateRefreshToken() token too short: %d characters", len(token1))
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateRefreshToken(t *testing.T) {
|
||||
userID := "user-123"
|
||||
expiryDuration := 7 * 24 * time.Hour
|
||||
|
||||
token, err := CreateRefreshToken(userID, expiryDuration)
|
||||
if err != nil {
|
||||
t.Fatalf("CreateRefreshToken() unexpected error = %v", err)
|
||||
}
|
||||
|
||||
if token == nil {
|
||||
t.Fatal("CreateRefreshToken() returned nil")
|
||||
}
|
||||
|
||||
if token.UserID != userID {
|
||||
t.Errorf("UserID = %v, want %v", token.UserID, userID)
|
||||
}
|
||||
|
||||
if token.Token == "" {
|
||||
t.Error("Token is empty")
|
||||
}
|
||||
|
||||
if token.ExpiresAt.IsZero() {
|
||||
t.Error("ExpiresAt is zero")
|
||||
}
|
||||
|
||||
if token.CreatedAt.IsZero() {
|
||||
t.Error("CreatedAt is zero")
|
||||
}
|
||||
|
||||
expectedExpiry := time.Now().Add(expiryDuration)
|
||||
diff := token.ExpiresAt.Sub(expectedExpiry)
|
||||
if diff > time.Second || diff < -time.Second {
|
||||
t.Errorf("ExpiresAt difference too large: %v", diff)
|
||||
}
|
||||
|
||||
if !token.IsValid() {
|
||||
t.Error("Token should be valid")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateRefreshToken_MultipleCalls(t *testing.T) {
|
||||
token1, err := CreateRefreshToken("user-1", 24*time.Hour)
|
||||
if err != nil {
|
||||
t.Fatalf("CreateRefreshToken(1) unexpected error = %v", err)
|
||||
}
|
||||
|
||||
token2, err := CreateRefreshToken("user-2", 24*time.Hour)
|
||||
if err != nil {
|
||||
t.Fatalf("CreateRefreshToken(2) unexpected error = %v", err)
|
||||
}
|
||||
|
||||
if token1.Token == token2.Token {
|
||||
t.Error("CreateRefreshToken() generated identical tokens for different users")
|
||||
}
|
||||
|
||||
if token1.UserID == token2.UserID {
|
||||
t.Error("UserIDs should be different")
|
||||
}
|
||||
}
|
||||
|
||||
func (m *mockRefreshTokenRepository) FindActiveByUserIDWithPagination(ctx context.Context, userID string, params pagination.Params) ([]*entities.Habit, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (m *mockRefreshTokenRepository) CountActiveByUserID(ctx context.Context, userID string) (int, error) {
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
func (m *mockUserRepositoryForRefresh) FindActiveByUserIDWithPagination(ctx context.Context, userID string, params pagination.Params) ([]*entities.Habit, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (m *mockUserRepositoryForRefresh) CountActiveByUserID(ctx context.Context, userID string) (int, error) {
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
func (m *mockRefreshTokenRepository) FindByUserIDFiltered(ctx context.Context, userID string, filter repositories.HabitFilter, paginationParams *pagination.Params) ([]*entities.Habit, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (m *mockRefreshTokenRepository) CountByUserIDFiltered(ctx context.Context, userID string, filter repositories.HabitFilter) (int, error) {
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
func (m *mockUserRepositoryForRefresh) FindByUserIDFiltered(ctx context.Context, userID string, filter repositories.HabitFilter, paginationParams *pagination.Params) ([]*entities.Habit, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (m *mockUserRepositoryForRefresh) CountByUserIDFiltered(ctx context.Context, userID string, filter repositories.HabitFilter) (int, error) {
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
@@ -19,7 +19,9 @@ type Habit struct {
|
||||
IsNegative bool
|
||||
TargetValue *float64
|
||||
CreatedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
ArchivedAt *time.Time
|
||||
DeletedAt *time.Time
|
||||
}
|
||||
|
||||
func NewHabit(
|
||||
@@ -30,6 +32,7 @@ func NewHabit(
|
||||
carryOver bool,
|
||||
isNegative bool,
|
||||
) *Habit {
|
||||
now := time.Now()
|
||||
return &Habit{
|
||||
UserID: userID,
|
||||
Name: name,
|
||||
@@ -37,15 +40,31 @@ func NewHabit(
|
||||
Frequency: frequency,
|
||||
CarryOver: carryOver,
|
||||
IsNegative: isNegative,
|
||||
CreatedAt: time.Now(),
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
}
|
||||
}
|
||||
|
||||
func (h *Habit) Archive() {
|
||||
now := time.Now()
|
||||
h.ArchivedAt = &now
|
||||
h.UpdatedAt = now
|
||||
}
|
||||
|
||||
func (h *Habit) IsActive() bool {
|
||||
return h.ArchivedAt == nil
|
||||
}
|
||||
|
||||
func (h *Habit) Delete() {
|
||||
now := time.Now()
|
||||
h.DeletedAt = &now
|
||||
h.UpdatedAt = now
|
||||
}
|
||||
|
||||
func (h *Habit) IsDeleted() bool {
|
||||
return h.DeletedAt != nil
|
||||
}
|
||||
|
||||
func (h *Habit) Touch() {
|
||||
h.UpdatedAt = time.Now()
|
||||
}
|
||||
|
||||
@@ -8,13 +8,27 @@ type HabitEntry struct {
|
||||
ScheduledDate time.Time
|
||||
CompletedAt time.Time
|
||||
Value *float64
|
||||
UpdatedAt time.Time
|
||||
DeletedAt *time.Time
|
||||
}
|
||||
|
||||
func NewHabitEntry(habitID string, scheduledDate time.Time, value *float64) *HabitEntry {
|
||||
now := time.Now()
|
||||
return &HabitEntry{
|
||||
HabitID: habitID,
|
||||
ScheduledDate: scheduledDate,
|
||||
CompletedAt: time.Now(),
|
||||
CompletedAt: now,
|
||||
Value: value,
|
||||
UpdatedAt: now,
|
||||
}
|
||||
}
|
||||
|
||||
func (e *HabitEntry) Delete() {
|
||||
now := time.Now()
|
||||
e.DeletedAt = &now
|
||||
e.UpdatedAt = now
|
||||
}
|
||||
|
||||
func (e *HabitEntry) IsDeleted() bool {
|
||||
return e.DeletedAt != nil
|
||||
}
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
package entities
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
type PasswordResetToken struct {
|
||||
ID string
|
||||
UserID string
|
||||
Token string
|
||||
ExpiresAt time.Time
|
||||
UsedAt *time.Time
|
||||
CreatedAt time.Time
|
||||
}
|
||||
|
||||
func NewPasswordResetToken(userID, token string, expiresAt time.Time) *PasswordResetToken {
|
||||
return &PasswordResetToken{
|
||||
ID: uuid.NewString(),
|
||||
UserID: userID,
|
||||
Token: token,
|
||||
ExpiresAt: expiresAt,
|
||||
UsedAt: nil,
|
||||
CreatedAt: time.Now(),
|
||||
}
|
||||
}
|
||||
|
||||
func (t *PasswordResetToken) IsExpired() bool {
|
||||
return time.Now().After(t.ExpiresAt)
|
||||
}
|
||||
|
||||
func (t *PasswordResetToken) IsUsed() bool {
|
||||
return t.UsedAt != nil
|
||||
}
|
||||
|
||||
func (t *PasswordResetToken) MarkAsUsed() {
|
||||
now := time.Now()
|
||||
t.UsedAt = &now
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
package entities
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestNewPasswordResetToken(t *testing.T) {
|
||||
userID := "user-123"
|
||||
token := "reset-token-abc"
|
||||
expiresAt := time.Now().Add(1 * time.Hour)
|
||||
|
||||
prt := NewPasswordResetToken(userID, token, expiresAt)
|
||||
|
||||
if prt == nil {
|
||||
t.Fatal("NewPasswordResetToken() returned nil")
|
||||
}
|
||||
|
||||
if prt.ID == "" {
|
||||
t.Error("ID is empty")
|
||||
}
|
||||
|
||||
if prt.UserID != userID {
|
||||
t.Errorf("UserID = %v, want %v", prt.UserID, userID)
|
||||
}
|
||||
|
||||
if prt.Token != token {
|
||||
t.Errorf("Token = %v, want %v", prt.Token, token)
|
||||
}
|
||||
|
||||
if !prt.ExpiresAt.Equal(expiresAt) {
|
||||
t.Errorf("ExpiresAt = %v, want %v", prt.ExpiresAt, expiresAt)
|
||||
}
|
||||
|
||||
if prt.UsedAt != nil {
|
||||
t.Error("UsedAt should be nil for new token")
|
||||
}
|
||||
|
||||
if prt.CreatedAt.IsZero() {
|
||||
t.Error("CreatedAt is zero")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPasswordResetToken_IsExpired(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
expiresAt time.Time
|
||||
want bool
|
||||
}{
|
||||
{
|
||||
name: "future expiry",
|
||||
expiresAt: time.Now().Add(1 * time.Hour),
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "past expiry",
|
||||
expiresAt: time.Now().Add(-1 * time.Hour),
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "expires in 1 second",
|
||||
expiresAt: time.Now().Add(1 * time.Second),
|
||||
want: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
prt := NewPasswordResetToken("user-123", "token", tt.expiresAt)
|
||||
got := prt.IsExpired()
|
||||
if got != tt.want {
|
||||
t.Errorf("IsExpired() = %v, want %v", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestPasswordResetToken_IsUsed(t *testing.T) {
|
||||
prt := NewPasswordResetToken("user-123", "token", time.Now().Add(1*time.Hour))
|
||||
|
||||
if prt.IsUsed() {
|
||||
t.Error("IsUsed() should return false for new token")
|
||||
}
|
||||
|
||||
prt.MarkAsUsed()
|
||||
|
||||
if !prt.IsUsed() {
|
||||
t.Error("IsUsed() should return true after MarkAsUsed()")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPasswordResetToken_MarkAsUsed(t *testing.T) {
|
||||
prt := NewPasswordResetToken("user-123", "token", time.Now().Add(1*time.Hour))
|
||||
|
||||
if prt.UsedAt != nil {
|
||||
t.Error("UsedAt should be nil before MarkAsUsed()")
|
||||
}
|
||||
|
||||
prt.MarkAsUsed()
|
||||
|
||||
if prt.UsedAt == nil {
|
||||
t.Fatal("UsedAt should not be nil after MarkAsUsed()")
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
diff := now.Sub(*prt.UsedAt)
|
||||
if diff > time.Second || diff < 0 {
|
||||
t.Errorf("UsedAt difference too large: %v", diff)
|
||||
}
|
||||
|
||||
firstUsedAt := prt.UsedAt
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
prt.MarkAsUsed()
|
||||
|
||||
if prt.UsedAt == firstUsedAt {
|
||||
t.Error("MarkAsUsed() should update UsedAt on subsequent calls")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPasswordResetToken_MultipleTokens(t *testing.T) {
|
||||
token1 := NewPasswordResetToken("user-1", "token-1", time.Now().Add(1*time.Hour))
|
||||
token2 := NewPasswordResetToken("user-2", "token-2", time.Now().Add(1*time.Hour))
|
||||
|
||||
if token1.ID == token2.ID {
|
||||
t.Error("NewPasswordResetToken() generated identical IDs")
|
||||
}
|
||||
|
||||
if token1.UserID == token2.UserID {
|
||||
t.Error("UserIDs should be different")
|
||||
}
|
||||
|
||||
if token1.Token == token2.Token {
|
||||
t.Error("Tokens should be different")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
package entities
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestNewRefreshToken(t *testing.T) {
|
||||
userID := "user-123"
|
||||
token := "refresh-token-abc"
|
||||
expiresAt := time.Now().Add(7 * 24 * time.Hour)
|
||||
|
||||
rt := NewRefreshToken(userID, token, expiresAt)
|
||||
|
||||
if rt == nil {
|
||||
t.Fatal("NewRefreshToken() returned nil")
|
||||
}
|
||||
|
||||
if rt.UserID != userID {
|
||||
t.Errorf("UserID = %v, want %v", rt.UserID, userID)
|
||||
}
|
||||
|
||||
if rt.Token != token {
|
||||
t.Errorf("Token = %v, want %v", rt.Token, token)
|
||||
}
|
||||
|
||||
if !rt.ExpiresAt.Equal(expiresAt) {
|
||||
t.Errorf("ExpiresAt = %v, want %v", rt.ExpiresAt, expiresAt)
|
||||
}
|
||||
|
||||
if rt.RevokedAt != nil {
|
||||
t.Error("RevokedAt should be nil for new token")
|
||||
}
|
||||
|
||||
if rt.CreatedAt.IsZero() {
|
||||
t.Error("CreatedAt is zero")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRefreshToken_IsValid(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
expiresAt time.Time
|
||||
revoked bool
|
||||
want bool
|
||||
}{
|
||||
{
|
||||
name: "valid token",
|
||||
expiresAt: time.Now().Add(24 * time.Hour),
|
||||
revoked: false,
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "expired token",
|
||||
expiresAt: time.Now().Add(-1 * time.Hour),
|
||||
revoked: false,
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "revoked token",
|
||||
expiresAt: time.Now().Add(24 * time.Hour),
|
||||
revoked: true,
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "expired and revoked",
|
||||
expiresAt: time.Now().Add(-1 * time.Hour),
|
||||
revoked: true,
|
||||
want: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
rt := NewRefreshToken("user-123", "token", tt.expiresAt)
|
||||
if tt.revoked {
|
||||
rt.Revoke()
|
||||
}
|
||||
|
||||
got := rt.IsValid()
|
||||
if got != tt.want {
|
||||
t.Errorf("IsValid() = %v, want %v", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestRefreshToken_Revoke(t *testing.T) {
|
||||
rt := NewRefreshToken("user-123", "token", time.Now().Add(24*time.Hour))
|
||||
|
||||
if rt.RevokedAt != nil {
|
||||
t.Error("RevokedAt should be nil before Revoke()")
|
||||
}
|
||||
|
||||
if !rt.IsValid() {
|
||||
t.Error("Token should be valid before Revoke()")
|
||||
}
|
||||
|
||||
rt.Revoke()
|
||||
|
||||
if rt.RevokedAt == nil {
|
||||
t.Fatal("RevokedAt should not be nil after Revoke()")
|
||||
}
|
||||
|
||||
if rt.IsValid() {
|
||||
t.Error("Token should not be valid after Revoke()")
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
diff := now.Sub(*rt.RevokedAt)
|
||||
if diff > time.Second || diff < 0 {
|
||||
t.Errorf("RevokedAt difference too large: %v", diff)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRefreshToken_RevokeMultipleTimes(t *testing.T) {
|
||||
rt := NewRefreshToken("user-123", "token", time.Now().Add(24*time.Hour))
|
||||
|
||||
rt.Revoke()
|
||||
firstRevokedAt := rt.RevokedAt
|
||||
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
rt.Revoke()
|
||||
|
||||
if rt.RevokedAt == firstRevokedAt {
|
||||
t.Error("Revoke() should update RevokedAt on subsequent calls")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRefreshToken_ExpirationCheck(t *testing.T) {
|
||||
expiresIn := 100 * time.Millisecond
|
||||
rt := NewRefreshToken("user-123", "token", time.Now().Add(expiresIn))
|
||||
|
||||
if !rt.IsValid() {
|
||||
t.Error("Token should be valid initially")
|
||||
}
|
||||
|
||||
time.Sleep(expiresIn + 10*time.Millisecond)
|
||||
|
||||
if rt.IsValid() {
|
||||
t.Error("Token should be invalid after expiration")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRefreshToken_MultipleTokens(t *testing.T) {
|
||||
token1 := NewRefreshToken("user-1", "token-1", time.Now().Add(24*time.Hour))
|
||||
token2 := NewRefreshToken("user-2", "token-2", time.Now().Add(24*time.Hour))
|
||||
|
||||
if token1.UserID == token2.UserID {
|
||||
t.Error("UserIDs should be different")
|
||||
}
|
||||
|
||||
if token1.Token == token2.Token {
|
||||
t.Error("Tokens should be different")
|
||||
}
|
||||
|
||||
if token1.CreatedAt.Equal(token2.CreatedAt) {
|
||||
t.Log("Warning: CreatedAt timestamps are identical (possible race condition in test)")
|
||||
}
|
||||
}
|
||||
@@ -3,23 +3,21 @@ package entities
|
||||
import "time"
|
||||
|
||||
type User struct {
|
||||
ID string
|
||||
Email string
|
||||
PasswordHash string
|
||||
Timezone string
|
||||
CreatedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
ID string
|
||||
Email string
|
||||
PasswordHash string
|
||||
EmailVerified bool
|
||||
EmailVerificationToken *string
|
||||
EmailVerificationExpiry *time.Time
|
||||
CreatedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
}
|
||||
|
||||
func NewUser(email, passwordHash, timezone string) *User {
|
||||
func NewUser(email, passwordHash string) *User {
|
||||
now := time.Now()
|
||||
if timezone == "" {
|
||||
timezone = "UTC"
|
||||
}
|
||||
return &User{
|
||||
Email: email,
|
||||
PasswordHash: passwordHash,
|
||||
Timezone: timezone,
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
}
|
||||
|
||||
@@ -8,9 +8,8 @@ import (
|
||||
func TestNewUser(t *testing.T) {
|
||||
email := "test@example.com"
|
||||
passwordHash := "hashed_password_123"
|
||||
timezone := "Europe/Madrid"
|
||||
|
||||
user := NewUser(email, passwordHash, timezone)
|
||||
user := NewUser(email, passwordHash)
|
||||
|
||||
if user.Email != email {
|
||||
t.Errorf("Expected email %s, got %s", email, user.Email)
|
||||
@@ -20,10 +19,6 @@ func TestNewUser(t *testing.T) {
|
||||
t.Errorf("Expected password hash %s, got %s", passwordHash, user.PasswordHash)
|
||||
}
|
||||
|
||||
if user.Timezone != timezone {
|
||||
t.Errorf("Expected timezone %s, got %s", timezone, user.Timezone)
|
||||
}
|
||||
|
||||
if user.CreatedAt.IsZero() {
|
||||
t.Error("CreatedAt should not be zero")
|
||||
}
|
||||
@@ -37,11 +32,3 @@ func TestNewUser(t *testing.T) {
|
||||
t.Errorf("CreatedAt and UpdatedAt should be nearly identical, diff: %v", diff)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUser_DefaultTimezone(t *testing.T) {
|
||||
user := NewUser("test@example.com", "hash", "")
|
||||
|
||||
if user.Timezone != "UTC" {
|
||||
t.Errorf("Expected default timezone UTC, got %s", user.Timezone)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,12 +7,23 @@ import (
|
||||
"apocapoc-api/internal/domain/entities"
|
||||
)
|
||||
|
||||
type HabitEntryChanges struct {
|
||||
Created []*entities.HabitEntry
|
||||
Updated []*entities.HabitEntry
|
||||
Deleted []string
|
||||
}
|
||||
|
||||
type HabitEntryRepository interface {
|
||||
Create(ctx context.Context, entry *entities.HabitEntry) error
|
||||
FindByID(ctx context.Context, id string) (*entities.HabitEntry, error)
|
||||
FindByHabitID(ctx context.Context, habitID string) ([]*entities.HabitEntry, error)
|
||||
FindByHabitIDAndDateRange(ctx context.Context, habitID string, from, to time.Time) ([]*entities.HabitEntry, error)
|
||||
FindByUserID(ctx context.Context, userID string) ([]*entities.HabitEntry, error)
|
||||
FindPendingByHabitID(ctx context.Context, habitID string, beforeDate time.Time) ([]*entities.HabitEntry, error)
|
||||
Update(ctx context.Context, entry *entities.HabitEntry) error
|
||||
Delete(ctx context.Context, id string) error
|
||||
|
||||
// Sync methods
|
||||
GetChangesSince(ctx context.Context, userID string, since time.Time) (*HabitEntryChanges, error)
|
||||
SoftDelete(ctx context.Context, id string) error
|
||||
}
|
||||
|
||||
@@ -2,15 +2,39 @@ package repositories
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"apocapoc-api/internal/domain/entities"
|
||||
"apocapoc-api/internal/domain/value_objects"
|
||||
"apocapoc-api/internal/shared/pagination"
|
||||
)
|
||||
|
||||
type HabitFilter struct {
|
||||
Type *value_objects.HabitType
|
||||
Frequency *value_objects.Frequency
|
||||
IncludeArchived bool
|
||||
Search string
|
||||
}
|
||||
|
||||
type HabitChanges struct {
|
||||
Created []*entities.Habit
|
||||
Updated []*entities.Habit
|
||||
Deleted []string
|
||||
}
|
||||
|
||||
type HabitRepository interface {
|
||||
Create(ctx context.Context, habit *entities.Habit) error
|
||||
FindByID(ctx context.Context, id string) (*entities.Habit, error)
|
||||
FindByUserID(ctx context.Context, userID string) ([]*entities.Habit, error)
|
||||
FindActiveByUserID(ctx context.Context, userID string) ([]*entities.Habit, error)
|
||||
FindActiveByUserIDWithPagination(ctx context.Context, userID string, params pagination.Params) ([]*entities.Habit, error)
|
||||
FindByUserIDFiltered(ctx context.Context, userID string, filter HabitFilter, paginationParams *pagination.Params) ([]*entities.Habit, error)
|
||||
CountActiveByUserID(ctx context.Context, userID string) (int, error)
|
||||
CountByUserIDFiltered(ctx context.Context, userID string, filter HabitFilter) (int, error)
|
||||
Update(ctx context.Context, habit *entities.Habit) error
|
||||
Delete(ctx context.Context, id string) error
|
||||
|
||||
// Sync methods
|
||||
GetChangesSince(ctx context.Context, userID string, since time.Time) (*HabitChanges, error)
|
||||
SoftDelete(ctx context.Context, id string) error
|
||||
}
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
package repositories
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"apocapoc-api/internal/domain/entities"
|
||||
)
|
||||
|
||||
type PasswordResetTokenRepository interface {
|
||||
Create(ctx context.Context, token *entities.PasswordResetToken) error
|
||||
FindByToken(ctx context.Context, token string) (*entities.PasswordResetToken, error)
|
||||
Update(ctx context.Context, token *entities.PasswordResetToken) error
|
||||
DeleteExpired(ctx context.Context) error
|
||||
}
|
||||
@@ -10,5 +10,7 @@ type UserRepository interface {
|
||||
Create(ctx context.Context, user *entities.User) error
|
||||
FindByID(ctx context.Context, id string) (*entities.User, error)
|
||||
FindByEmail(ctx context.Context, email string) (*entities.User, error)
|
||||
FindByVerificationToken(ctx context.Context, token string) (*entities.User, error)
|
||||
Update(ctx context.Context, user *entities.User) error
|
||||
Delete(ctx context.Context, id string) error
|
||||
}
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
package services
|
||||
|
||||
type EmailMessage struct {
|
||||
To string
|
||||
Subject string
|
||||
Body string
|
||||
IsHTML bool
|
||||
}
|
||||
|
||||
type EmailService interface {
|
||||
Send(message EmailMessage) error
|
||||
HealthCheck() error
|
||||
IsEnabled() bool
|
||||
}
|
||||
|
||||
type NoOpEmailService struct{}
|
||||
|
||||
func (n *NoOpEmailService) Send(_ EmailMessage) error { return nil }
|
||||
func (n *NoOpEmailService) HealthCheck() error { return nil }
|
||||
func (n *NoOpEmailService) IsEnabled() bool { return false }
|
||||
@@ -0,0 +1,99 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"apocapoc-api/internal/domain/entities"
|
||||
"apocapoc-api/internal/shared/utils"
|
||||
)
|
||||
|
||||
type StreakResult struct {
|
||||
Current int
|
||||
Longest int
|
||||
}
|
||||
|
||||
func CalculateStreaks(entries []*entities.HabitEntry, habit *entities.Habit, now time.Time) StreakResult {
|
||||
entryMap := buildEntryMap(entries)
|
||||
scheduled := allScheduledDates(habit, now)
|
||||
|
||||
if len(scheduled) == 0 {
|
||||
return StreakResult{}
|
||||
}
|
||||
|
||||
today := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, time.UTC)
|
||||
longest := 0
|
||||
current := 0
|
||||
|
||||
for _, d := range scheduled {
|
||||
if IsDaySuccessful(d.Format("2006-01-02"), entryMap, habit) {
|
||||
current++
|
||||
if current > longest {
|
||||
longest = current
|
||||
}
|
||||
} else if d.Equal(today) && !habit.IsNegative {
|
||||
continue
|
||||
} else {
|
||||
current = 0
|
||||
}
|
||||
}
|
||||
|
||||
return StreakResult{Current: current, Longest: longest}
|
||||
}
|
||||
|
||||
func buildEntryMap(entries []*entities.HabitEntry) map[string]*entities.HabitEntry {
|
||||
m := make(map[string]*entities.HabitEntry)
|
||||
for _, e := range entries {
|
||||
m[e.ScheduledDate.Format("2006-01-02")] = e
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
func allScheduledDates(habit *entities.Habit, now time.Time) []time.Time {
|
||||
createdUTC := habit.CreatedAt.UTC()
|
||||
start := time.Date(createdUTC.Year(), createdUTC.Month(), createdUTC.Day(), 0, 0, 0, 0, time.UTC)
|
||||
today := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, time.UTC)
|
||||
freq := string(habit.Frequency)
|
||||
|
||||
var dates []time.Time
|
||||
for d := start; !d.After(today); d = d.AddDate(0, 0, 1) {
|
||||
if utils.ShouldAppearToday(freq, habit.SpecificDays, habit.SpecificDates, d) {
|
||||
dates = append(dates, d)
|
||||
}
|
||||
}
|
||||
return dates
|
||||
}
|
||||
|
||||
func IsDaySuccessful(dateStr string, entryMap map[string]*entities.HabitEntry, habit *entities.Habit) bool {
|
||||
entry, hasEntry := entryMap[dateStr]
|
||||
habitType := string(habit.Type)
|
||||
|
||||
if !habit.IsNegative {
|
||||
if !hasEntry {
|
||||
return false
|
||||
}
|
||||
if habit.TargetValue != nil && entry.Value != nil {
|
||||
return *entry.Value >= *habit.TargetValue
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
if habitType == "VALUE" && habit.TargetValue != nil {
|
||||
if !hasEntry {
|
||||
return false
|
||||
}
|
||||
if entry.Value != nil {
|
||||
return *entry.Value <= *habit.TargetValue
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
if !hasEntry {
|
||||
return true
|
||||
}
|
||||
|
||||
if habit.TargetValue != nil && entry.Value != nil {
|
||||
return *entry.Value <= *habit.TargetValue
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
@@ -0,0 +1,337 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"apocapoc-api/internal/domain/entities"
|
||||
"apocapoc-api/internal/domain/value_objects"
|
||||
)
|
||||
|
||||
func makeEntry(d time.Time, value *float64) *entities.HabitEntry {
|
||||
return &entities.HabitEntry{ScheduledDate: d, Value: value}
|
||||
}
|
||||
|
||||
func floatPtr(v float64) *float64 { return &v }
|
||||
|
||||
func dt(year, month, day int) time.Time {
|
||||
return time.Date(year, time.Month(month), day, 0, 0, 0, 0, time.UTC)
|
||||
}
|
||||
|
||||
func habit(t value_objects.HabitType, negative bool, target *float64) *entities.Habit {
|
||||
return &entities.Habit{
|
||||
Type: t,
|
||||
Frequency: value_objects.FrequencyDaily,
|
||||
IsNegative: negative,
|
||||
TargetValue: target,
|
||||
CreatedAt: dt(2026, 1, 1),
|
||||
}
|
||||
}
|
||||
|
||||
// --- IsDaySuccessful ---
|
||||
|
||||
func TestIsDaySuccessful_BooleanPositive(t *testing.T) {
|
||||
h := habit(value_objects.HabitTypeBoolean, false, nil)
|
||||
em := map[string]*entities.HabitEntry{"2026-03-01": makeEntry(dt(2026, 3, 1), nil)}
|
||||
|
||||
if !IsDaySuccessful("2026-03-01", em, h) {
|
||||
t.Error("entry should be success")
|
||||
}
|
||||
if IsDaySuccessful("2026-03-01", map[string]*entities.HabitEntry{}, h) {
|
||||
t.Error("no entry should be failure")
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsDaySuccessful_BooleanNegative(t *testing.T) {
|
||||
h := habit(value_objects.HabitTypeBoolean, true, nil)
|
||||
em := map[string]*entities.HabitEntry{"2026-03-01": makeEntry(dt(2026, 3, 1), nil)}
|
||||
|
||||
if !IsDaySuccessful("2026-03-01", map[string]*entities.HabitEntry{}, h) {
|
||||
t.Error("no entry should be success (resisted)")
|
||||
}
|
||||
if IsDaySuccessful("2026-03-01", em, h) {
|
||||
t.Error("entry should be failure")
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsDaySuccessful_CounterPositiveNoTarget(t *testing.T) {
|
||||
h := habit(value_objects.HabitTypeCounter, false, nil)
|
||||
|
||||
if !IsDaySuccessful("2026-03-01", map[string]*entities.HabitEntry{"2026-03-01": makeEntry(dt(2026, 3, 1), floatPtr(5))}, h) {
|
||||
t.Error("entry should be success")
|
||||
}
|
||||
if IsDaySuccessful("2026-03-01", map[string]*entities.HabitEntry{}, h) {
|
||||
t.Error("no entry should be failure")
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsDaySuccessful_CounterPositiveWithTarget(t *testing.T) {
|
||||
h := habit(value_objects.HabitTypeCounter, false, floatPtr(8))
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
value *float64
|
||||
has bool
|
||||
success bool
|
||||
}{
|
||||
{"value >= target", floatPtr(10), true, true},
|
||||
{"value == target", floatPtr(8), true, true},
|
||||
{"value < target", floatPtr(3), true, false},
|
||||
{"no entry", nil, false, false},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
em := map[string]*entities.HabitEntry{}
|
||||
if tc.has {
|
||||
em["2026-03-01"] = makeEntry(dt(2026, 3, 1), tc.value)
|
||||
}
|
||||
if IsDaySuccessful("2026-03-01", em, h) != tc.success {
|
||||
t.Errorf("Expected %v", tc.success)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsDaySuccessful_CounterNegativeNoTarget(t *testing.T) {
|
||||
h := habit(value_objects.HabitTypeCounter, true, nil)
|
||||
|
||||
if !IsDaySuccessful("2026-03-01", map[string]*entities.HabitEntry{}, h) {
|
||||
t.Error("no entry should be success")
|
||||
}
|
||||
if IsDaySuccessful("2026-03-01", map[string]*entities.HabitEntry{"2026-03-01": makeEntry(dt(2026, 3, 1), floatPtr(3))}, h) {
|
||||
t.Error("entry should be failure")
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsDaySuccessful_CounterNegativeWithTarget(t *testing.T) {
|
||||
h := habit(value_objects.HabitTypeCounter, true, floatPtr(2))
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
value *float64
|
||||
has bool
|
||||
success bool
|
||||
}{
|
||||
{"no entry", nil, false, true},
|
||||
{"within limit", floatPtr(1), true, true},
|
||||
{"at limit", floatPtr(2), true, true},
|
||||
{"over limit", floatPtr(5), true, false},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
em := map[string]*entities.HabitEntry{}
|
||||
if tc.has {
|
||||
em["2026-03-01"] = makeEntry(dt(2026, 3, 1), tc.value)
|
||||
}
|
||||
if IsDaySuccessful("2026-03-01", em, h) != tc.success {
|
||||
t.Errorf("Expected %v", tc.success)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsDaySuccessful_ValuePositiveNoTarget(t *testing.T) {
|
||||
h := habit(value_objects.HabitTypeValue, false, nil)
|
||||
|
||||
if !IsDaySuccessful("2026-03-01", map[string]*entities.HabitEntry{"2026-03-01": makeEntry(dt(2026, 3, 1), floatPtr(72))}, h) {
|
||||
t.Error("entry should be success")
|
||||
}
|
||||
if IsDaySuccessful("2026-03-01", map[string]*entities.HabitEntry{}, h) {
|
||||
t.Error("no entry should be failure")
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsDaySuccessful_ValuePositiveWithTarget(t *testing.T) {
|
||||
h := habit(value_objects.HabitTypeValue, false, floatPtr(7))
|
||||
|
||||
if !IsDaySuccessful("2026-03-01", map[string]*entities.HabitEntry{"2026-03-01": makeEntry(dt(2026, 3, 1), floatPtr(8))}, h) {
|
||||
t.Error("value >= target should be success")
|
||||
}
|
||||
if IsDaySuccessful("2026-03-01", map[string]*entities.HabitEntry{"2026-03-01": makeEntry(dt(2026, 3, 1), floatPtr(5))}, h) {
|
||||
t.Error("value < target should be failure")
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsDaySuccessful_ValueNegativeNoTarget(t *testing.T) {
|
||||
h := habit(value_objects.HabitTypeValue, true, nil)
|
||||
|
||||
if !IsDaySuccessful("2026-03-01", map[string]*entities.HabitEntry{}, h) {
|
||||
t.Error("no entry should be success")
|
||||
}
|
||||
if IsDaySuccessful("2026-03-01", map[string]*entities.HabitEntry{"2026-03-01": makeEntry(dt(2026, 3, 1), floatPtr(3))}, h) {
|
||||
t.Error("entry should be failure")
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsDaySuccessful_ValueNegativeWithTarget(t *testing.T) {
|
||||
h := habit(value_objects.HabitTypeValue, true, floatPtr(70))
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
value *float64
|
||||
has bool
|
||||
success bool
|
||||
}{
|
||||
{"below target", floatPtr(68), true, true},
|
||||
{"above target", floatPtr(75), true, false},
|
||||
{"no entry (didnt track)", nil, false, false},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
em := map[string]*entities.HabitEntry{}
|
||||
if tc.has {
|
||||
em["2026-03-01"] = makeEntry(dt(2026, 3, 1), tc.value)
|
||||
}
|
||||
if IsDaySuccessful("2026-03-01", em, h) != tc.success {
|
||||
t.Errorf("Expected %v", tc.success)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// --- CalculateStreaks ---
|
||||
|
||||
func TestStreaks_DailyBooleanPositive(t *testing.T) {
|
||||
h := habit(value_objects.HabitTypeBoolean, false, nil)
|
||||
|
||||
t.Run("3 consecutive days", func(t *testing.T) {
|
||||
entries := []*entities.HabitEntry{
|
||||
makeEntry(dt(2026, 3, 1), nil),
|
||||
makeEntry(dt(2026, 3, 2), nil),
|
||||
makeEntry(dt(2026, 3, 3), nil),
|
||||
}
|
||||
r := CalculateStreaks(entries, h, dt(2026, 3, 3))
|
||||
if r.Current != 3 || r.Longest != 3 {
|
||||
t.Errorf("Expected current=3 longest=3, got current=%d longest=%d", r.Current, r.Longest)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("gap finds longest and current separately", func(t *testing.T) {
|
||||
entries := []*entities.HabitEntry{
|
||||
makeEntry(dt(2026, 3, 1), nil),
|
||||
makeEntry(dt(2026, 3, 2), nil),
|
||||
makeEntry(dt(2026, 3, 3), nil),
|
||||
// gap Mar 4
|
||||
makeEntry(dt(2026, 3, 5), nil),
|
||||
}
|
||||
r := CalculateStreaks(entries, h, dt(2026, 3, 5))
|
||||
if r.Current != 1 || r.Longest != 3 {
|
||||
t.Errorf("Expected current=1 longest=3, got current=%d longest=%d", r.Current, r.Longest)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("today not completed doesnt break streak", func(t *testing.T) {
|
||||
entries := []*entities.HabitEntry{
|
||||
makeEntry(dt(2026, 3, 1), nil),
|
||||
makeEntry(dt(2026, 3, 2), nil),
|
||||
}
|
||||
r := CalculateStreaks(entries, h, dt(2026, 3, 3))
|
||||
if r.Current != 2 || r.Longest != 2 {
|
||||
t.Errorf("Expected current=2 longest=2, got current=%d longest=%d", r.Current, r.Longest)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("missed yesterday breaks streak", func(t *testing.T) {
|
||||
entries := []*entities.HabitEntry{
|
||||
makeEntry(dt(2026, 3, 1), nil),
|
||||
makeEntry(dt(2026, 3, 2), nil),
|
||||
}
|
||||
r := CalculateStreaks(entries, h, dt(2026, 3, 4))
|
||||
if r.Current != 0 || r.Longest != 2 {
|
||||
t.Errorf("Expected current=0 longest=2, got current=%d longest=%d", r.Current, r.Longest)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestStreaks_DailyBooleanNegative(t *testing.T) {
|
||||
h := habit(value_objects.HabitTypeBoolean, true, nil)
|
||||
h.CreatedAt = dt(2026, 3, 1)
|
||||
|
||||
t.Run("3 days no entries is 3 streak", func(t *testing.T) {
|
||||
r := CalculateStreaks(nil, h, dt(2026, 3, 3))
|
||||
if r.Current != 3 || r.Longest != 3 {
|
||||
t.Errorf("Expected current=3 longest=3, got current=%d longest=%d", r.Current, r.Longest)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("entry breaks streak", func(t *testing.T) {
|
||||
entries := []*entities.HabitEntry{makeEntry(dt(2026, 3, 2), nil)}
|
||||
r := CalculateStreaks(entries, h, dt(2026, 3, 3))
|
||||
if r.Current != 1 || r.Longest != 1 {
|
||||
t.Errorf("Expected current=1 longest=1, got current=%d longest=%d", r.Current, r.Longest)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestStreaks_WeeklyBooleanPositive(t *testing.T) {
|
||||
h := &entities.Habit{
|
||||
Type: value_objects.HabitTypeBoolean,
|
||||
Frequency: value_objects.FrequencyWeekly,
|
||||
SpecificDays: []int{1, 3, 5},
|
||||
CreatedAt: dt(2026, 3, 1),
|
||||
}
|
||||
|
||||
t.Run("3 consecutive scheduled days", func(t *testing.T) {
|
||||
entries := []*entities.HabitEntry{
|
||||
makeEntry(dt(2026, 3, 2), nil),
|
||||
makeEntry(dt(2026, 3, 4), nil),
|
||||
makeEntry(dt(2026, 3, 6), nil),
|
||||
}
|
||||
r := CalculateStreaks(entries, h, dt(2026, 3, 6))
|
||||
if r.Current != 3 || r.Longest != 3 {
|
||||
t.Errorf("Expected current=3 longest=3, got current=%d longest=%d", r.Current, r.Longest)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("missed Wednesday breaks streak", func(t *testing.T) {
|
||||
entries := []*entities.HabitEntry{
|
||||
makeEntry(dt(2026, 3, 2), nil),
|
||||
makeEntry(dt(2026, 3, 6), nil),
|
||||
}
|
||||
r := CalculateStreaks(entries, h, dt(2026, 3, 6))
|
||||
if r.Current != 1 || r.Longest != 1 {
|
||||
t.Errorf("Expected current=1 longest=1, got current=%d longest=%d", r.Current, r.Longest)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestStreaks_CounterWithTarget(t *testing.T) {
|
||||
h := habit(value_objects.HabitTypeCounter, false, floatPtr(8))
|
||||
|
||||
t.Run("all meet target", func(t *testing.T) {
|
||||
entries := []*entities.HabitEntry{
|
||||
makeEntry(dt(2026, 3, 1), floatPtr(8)),
|
||||
makeEntry(dt(2026, 3, 2), floatPtr(10)),
|
||||
makeEntry(dt(2026, 3, 3), floatPtr(9)),
|
||||
}
|
||||
r := CalculateStreaks(entries, h, dt(2026, 3, 3))
|
||||
if r.Current != 3 {
|
||||
t.Errorf("Expected current=3, got %d", r.Current)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestStreaks_WeeklyCounterNegativeWithTarget(t *testing.T) {
|
||||
h := &entities.Habit{
|
||||
Type: value_objects.HabitTypeCounter,
|
||||
Frequency: value_objects.FrequencyWeekly,
|
||||
SpecificDays: []int{1, 5},
|
||||
IsNegative: true,
|
||||
TargetValue: floatPtr(2),
|
||||
CreatedAt: dt(2026, 3, 1),
|
||||
}
|
||||
|
||||
t.Run("within limit and no entry both count as success", func(t *testing.T) {
|
||||
entries := []*entities.HabitEntry{
|
||||
makeEntry(dt(2026, 3, 2), floatPtr(1)),
|
||||
makeEntry(dt(2026, 3, 9), floatPtr(5)),
|
||||
}
|
||||
r := CalculateStreaks(entries, h, dt(2026, 3, 13))
|
||||
if r.Current != 1 || r.Longest != 2 {
|
||||
t.Errorf("Expected current=1 longest=2, got current=%d longest=%d", r.Current, r.Longest)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
package i18n
|
||||
|
||||
import (
|
||||
_ "embed"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"golang.org/x/text/language"
|
||||
)
|
||||
|
||||
//go:embed locales/en.json
|
||||
var enTranslations []byte
|
||||
|
||||
//go:embed locales/es.json
|
||||
var esTranslations []byte
|
||||
|
||||
type Translations struct {
|
||||
Errors map[string]string `json:"errors"`
|
||||
Success map[string]string `json:"success"`
|
||||
Validation map[string]string `json:"validation"`
|
||||
Emails map[string]string `json:"emails"`
|
||||
}
|
||||
|
||||
type Translator struct {
|
||||
translations map[language.Tag]Translations
|
||||
matcher language.Matcher
|
||||
}
|
||||
|
||||
func NewTranslator() (*Translator, error) {
|
||||
var enTrans, esTrans Translations
|
||||
|
||||
if err := json.Unmarshal(enTranslations, &enTrans); err != nil {
|
||||
return nil, fmt.Errorf("failed to load English translations: %w", err)
|
||||
}
|
||||
|
||||
if err := json.Unmarshal(esTranslations, &esTrans); err != nil {
|
||||
return nil, fmt.Errorf("failed to load Spanish translations: %w", err)
|
||||
}
|
||||
|
||||
translations := map[language.Tag]Translations{
|
||||
language.English: enTrans,
|
||||
language.Spanish: esTrans,
|
||||
}
|
||||
|
||||
matcher := language.NewMatcher([]language.Tag{
|
||||
language.English,
|
||||
language.Spanish,
|
||||
})
|
||||
|
||||
return &Translator{
|
||||
translations: translations,
|
||||
matcher: matcher,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (t *Translator) GetLanguage(acceptLanguage string) language.Tag {
|
||||
if acceptLanguage == "" {
|
||||
return language.English
|
||||
}
|
||||
|
||||
tags, _, err := language.ParseAcceptLanguage(acceptLanguage)
|
||||
if err != nil || len(tags) == 0 {
|
||||
return language.English
|
||||
}
|
||||
|
||||
_, index, _ := t.matcher.Match(tags...)
|
||||
supportedTags := []language.Tag{language.English, language.Spanish}
|
||||
if index < len(supportedTags) {
|
||||
return supportedTags[index]
|
||||
}
|
||||
|
||||
return language.English
|
||||
}
|
||||
|
||||
func (t *Translator) Translate(lang language.Tag, category, key string) string {
|
||||
trans, ok := t.translations[lang]
|
||||
if !ok {
|
||||
trans = t.translations[language.English]
|
||||
}
|
||||
|
||||
var categoryMap map[string]string
|
||||
switch category {
|
||||
case "errors":
|
||||
categoryMap = trans.Errors
|
||||
case "success":
|
||||
categoryMap = trans.Success
|
||||
case "validation":
|
||||
categoryMap = trans.Validation
|
||||
case "emails":
|
||||
categoryMap = trans.Emails
|
||||
default:
|
||||
return key
|
||||
}
|
||||
|
||||
if value, ok := categoryMap[key]; ok {
|
||||
return value
|
||||
}
|
||||
|
||||
return key
|
||||
}
|
||||
|
||||
func (t *Translator) Error(lang language.Tag, key string) string {
|
||||
return t.Translate(lang, "errors", key)
|
||||
}
|
||||
|
||||
func (t *Translator) Success(lang language.Tag, key string) string {
|
||||
return t.Translate(lang, "success", key)
|
||||
}
|
||||
|
||||
func (t *Translator) Validation(lang language.Tag, key string) string {
|
||||
return t.Translate(lang, "validation", key)
|
||||
}
|
||||
|
||||
func (t *Translator) Email(lang language.Tag, key string) string {
|
||||
return t.Translate(lang, "emails", key)
|
||||
}
|
||||
|
||||
func (t *Translator) TranslateValidationError(lang language.Tag, field, validationKey string) string {
|
||||
message := t.Validation(lang, validationKey)
|
||||
return strings.ReplaceAll(message, field, field)
|
||||
}
|
||||
@@ -0,0 +1,252 @@
|
||||
package i18n
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"golang.org/x/text/language"
|
||||
)
|
||||
|
||||
func TestNewTranslator(t *testing.T) {
|
||||
translator, err := NewTranslator()
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create translator: %v", err)
|
||||
}
|
||||
|
||||
if translator == nil {
|
||||
t.Fatal("Expected translator to be non-nil")
|
||||
}
|
||||
|
||||
if translator.translations == nil {
|
||||
t.Fatal("Expected translations map to be initialized")
|
||||
}
|
||||
|
||||
if len(translator.translations) != 2 {
|
||||
t.Errorf("Expected 2 languages, got %d", len(translator.translations))
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetLanguage(t *testing.T) {
|
||||
translator, _ := NewTranslator()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
acceptLanguage string
|
||||
expected language.Tag
|
||||
}{
|
||||
{
|
||||
name: "English",
|
||||
acceptLanguage: "en-US",
|
||||
expected: language.English,
|
||||
},
|
||||
{
|
||||
name: "Spanish",
|
||||
acceptLanguage: "es-ES",
|
||||
expected: language.Spanish,
|
||||
},
|
||||
{
|
||||
name: "Empty defaults to English",
|
||||
acceptLanguage: "",
|
||||
expected: language.English,
|
||||
},
|
||||
{
|
||||
name: "Unknown language defaults to English",
|
||||
acceptLanguage: "fr-FR",
|
||||
expected: language.English,
|
||||
},
|
||||
{
|
||||
name: "Spanish with quality",
|
||||
acceptLanguage: "es-ES,es;q=0.9",
|
||||
expected: language.Spanish,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
result := translator.GetLanguage(tt.acceptLanguage)
|
||||
if result != tt.expected {
|
||||
t.Errorf("Expected %v, got %v", tt.expected, result)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestError(t *testing.T) {
|
||||
translator, _ := NewTranslator()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
lang language.Tag
|
||||
key string
|
||||
expected string
|
||||
}{
|
||||
{
|
||||
name: "English error message",
|
||||
lang: language.English,
|
||||
key: "invalid_request_body",
|
||||
expected: "Invalid request body",
|
||||
},
|
||||
{
|
||||
name: "Spanish error message",
|
||||
lang: language.Spanish,
|
||||
key: "invalid_request_body",
|
||||
expected: "Cuerpo de solicitud inválido",
|
||||
},
|
||||
{
|
||||
name: "Missing key returns key",
|
||||
lang: language.English,
|
||||
key: "non_existent_key",
|
||||
expected: "non_existent_key",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
result := translator.Error(tt.lang, tt.key)
|
||||
if result != tt.expected {
|
||||
t.Errorf("Expected '%s', got '%s'", tt.expected, result)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSuccess(t *testing.T) {
|
||||
translator, _ := NewTranslator()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
lang language.Tag
|
||||
key string
|
||||
expected string
|
||||
}{
|
||||
{
|
||||
name: "English success message",
|
||||
lang: language.English,
|
||||
key: "logged_out",
|
||||
expected: "Successfully logged out",
|
||||
},
|
||||
{
|
||||
name: "Spanish success message",
|
||||
lang: language.Spanish,
|
||||
key: "logged_out",
|
||||
expected: "Sesión cerrada exitosamente",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
result := translator.Success(tt.lang, tt.key)
|
||||
if result != tt.expected {
|
||||
t.Errorf("Expected '%s', got '%s'", tt.expected, result)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidation(t *testing.T) {
|
||||
translator, _ := NewTranslator()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
lang language.Tag
|
||||
key string
|
||||
expected string
|
||||
}{
|
||||
{
|
||||
name: "English validation message",
|
||||
lang: language.English,
|
||||
key: "email_required",
|
||||
expected: "email is required",
|
||||
},
|
||||
{
|
||||
name: "Spanish validation message",
|
||||
lang: language.Spanish,
|
||||
key: "email_required",
|
||||
expected: "el email es requerido",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
result := translator.Validation(tt.lang, tt.key)
|
||||
if result != tt.expected {
|
||||
t.Errorf("Expected '%s', got '%s'", tt.expected, result)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestEmail(t *testing.T) {
|
||||
translator, _ := NewTranslator()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
lang language.Tag
|
||||
key string
|
||||
expected string
|
||||
}{
|
||||
{
|
||||
name: "English email message",
|
||||
lang: language.English,
|
||||
key: "welcome_subject",
|
||||
expected: "Welcome to Apocapoc!",
|
||||
},
|
||||
{
|
||||
name: "Spanish email message",
|
||||
lang: language.Spanish,
|
||||
key: "welcome_subject",
|
||||
expected: "¡Bienvenido a Apocapoc!",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
result := translator.Email(tt.lang, tt.key)
|
||||
if result != tt.expected {
|
||||
t.Errorf("Expected '%s', got '%s'", tt.expected, result)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestTranslate(t *testing.T) {
|
||||
translator, _ := NewTranslator()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
lang language.Tag
|
||||
category string
|
||||
key string
|
||||
expected string
|
||||
}{
|
||||
{
|
||||
name: "Valid category and key",
|
||||
lang: language.English,
|
||||
category: "errors",
|
||||
key: "user_not_found",
|
||||
expected: "User not found",
|
||||
},
|
||||
{
|
||||
name: "Invalid category returns key",
|
||||
lang: language.English,
|
||||
category: "invalid_category",
|
||||
key: "some_key",
|
||||
expected: "some_key",
|
||||
},
|
||||
{
|
||||
name: "Unsupported language fallback to English",
|
||||
lang: language.French,
|
||||
category: "errors",
|
||||
key: "user_not_found",
|
||||
expected: "User not found",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
result := translator.Translate(tt.lang, tt.category, tt.key)
|
||||
if result != tt.expected {
|
||||
t.Errorf("Expected '%s', got '%s'", tt.expected, result)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
{
|
||||
"errors": {
|
||||
"invalid_request_body": "Invalid request body",
|
||||
"email_already_registered": "Email already registered",
|
||||
"registration_closed": "Registration is currently closed",
|
||||
"failed_register_user": "Failed to register user",
|
||||
"invalid_credentials": "Invalid email or password",
|
||||
"email_not_verified": "Please verify your email before logging in",
|
||||
"failed_login": "Failed to login",
|
||||
"failed_generate_token": "Failed to generate token",
|
||||
"failed_create_refresh_token": "Failed to create refresh token",
|
||||
"failed_save_refresh_token": "Failed to save refresh token",
|
||||
"invalid_expired_refresh_token": "Invalid or expired refresh token",
|
||||
"failed_refresh_token": "Failed to refresh token",
|
||||
"refresh_token_not_found": "Refresh token not found",
|
||||
"invalid_refresh_token": "Invalid refresh token",
|
||||
"user_not_authenticated": "User not authenticated",
|
||||
"failed_get_user": "Failed to get user",
|
||||
"failed_get_habits": "Failed to get habits",
|
||||
"failed_create_habit": "Failed to create habit",
|
||||
"habit_not_found": "Habit not found",
|
||||
"access_denied": "Access denied",
|
||||
"failed_get_habit": "Failed to get habit",
|
||||
"invalid_input": "Invalid input",
|
||||
"failed_update_habit": "Failed to update habit",
|
||||
"failed_archive_habit": "Failed to archive habit",
|
||||
"failed_get_habit_entries": "Failed to get habit entries",
|
||||
"invalid_date_format": "Invalid date format (use YYYY-MM-DD)",
|
||||
"invalid_page_parameter": "Invalid 'page' parameter",
|
||||
"invalid_limit_parameter": "Invalid 'limit' parameter (must be 1-100)",
|
||||
"pagination_required": "Pagination required: provide 'limit' parameter or use date range ≤ 1 year",
|
||||
"invalid_from_date_format": "Invalid 'from' date format (use YYYY-MM-DD)",
|
||||
"invalid_to_date_format": "Invalid 'to' date format (use YYYY-MM-DD)",
|
||||
"habit_already_marked": "Habit already marked for this date",
|
||||
"failed_mark_habit": "Failed to mark habit",
|
||||
"habit_entry_not_found": "Habit entry not found",
|
||||
"failed_unmark_habit": "Failed to unmark habit",
|
||||
"invalid_expired_verification_token": "Invalid or expired verification token",
|
||||
"email_already_verified": "Email already verified",
|
||||
"failed_verify_email": "Failed to verify email",
|
||||
"invalid_email": "Invalid email",
|
||||
"user_not_found": "User not found",
|
||||
"failed_send_verification_email": "Failed to send verification email",
|
||||
"email_not_verified_reset": "Please verify your email before resetting password",
|
||||
"failed_send_reset_email": "Failed to send reset email",
|
||||
"invalid_token_or_password": "Invalid or expired token, or password requirements not met",
|
||||
"failed_reset_password": "Failed to reset password",
|
||||
"failed_delete_user": "Failed to delete user",
|
||||
"failed_get_stats": "Failed to get statistics",
|
||||
"export_failed": "Failed to export data",
|
||||
"timezone_required": "Timezone is required",
|
||||
"invalid_timezone": "Invalid timezone (must be a valid IANA timezone)"
|
||||
},
|
||||
"success": {
|
||||
"registration_with_verification": "Registration successful. Please check your email to verify your account.",
|
||||
"registration_without_verification": "Registration successful. You can now login.",
|
||||
"logged_out": "Successfully logged out",
|
||||
"email_verified": "Email verified successfully",
|
||||
"verification_email_sent": "Verification email sent successfully",
|
||||
"password_reset_email_sent": "Password reset email sent successfully",
|
||||
"password_reset": "Password reset successfully",
|
||||
"user_deleted": "User and all associated data deleted successfully"
|
||||
},
|
||||
"validation": {
|
||||
"email_required": "email is required",
|
||||
"email_invalid_format": "email must be a valid email address",
|
||||
"email_too_long": "email must not exceed 254 characters",
|
||||
"password_required": "password is required",
|
||||
"password_min_length": "password must be at least 8 characters long",
|
||||
"password_max_length": "password must not exceed 128 characters",
|
||||
"password_uppercase": "password must contain at least one uppercase letter",
|
||||
"password_lowercase": "password must contain at least one lowercase letter",
|
||||
"password_digit": "password must contain at least one digit",
|
||||
"password_special_char": "password must contain at least one special character (!@#$%^&*)",
|
||||
"timezone_required": "timezone is required",
|
||||
"timezone_invalid": "timezone is not a valid IANA timezone",
|
||||
"name_required": "name is required",
|
||||
"name_too_long": "name must not exceed 255 characters",
|
||||
"type_invalid": "type must be one of: BOOLEAN, COUNTER, VALUE",
|
||||
"frequency_invalid": "frequency must be one of: DAILY, WEEKLY, MONTHLY",
|
||||
"specific_days_required": "specific_days is required for WEEKLY frequency",
|
||||
"specific_days_invalid": "specific_days must contain values between 0-6 (0=Sunday, 6=Saturday)",
|
||||
"specific_dates_required": "specific_dates is required for MONTHLY frequency",
|
||||
"specific_dates_invalid": "specific_dates must contain values between 1-31",
|
||||
"target_value_required": "target_value is required for VALUE type",
|
||||
"target_value_positive": "target_value must be positive"
|
||||
},
|
||||
"emails": {
|
||||
"verify_email_subject": "Verify your email address",
|
||||
"verify_email_title": "Welcome! Please verify your email",
|
||||
"verify_email_body": "Thank you for registering. Please click the link below to verify your email address:",
|
||||
"verify_email_link": "Verify Email",
|
||||
"verify_email_expiry": "This link will expire in 24 hours.",
|
||||
"verify_email_ignore": "If you didn't create an account, you can safely ignore this email.",
|
||||
"resend_verification_subject": "Verify your email address",
|
||||
"resend_verification_title": "Verify your email address",
|
||||
"resend_verification_body": "Please click the link below to verify your email address:",
|
||||
"resend_verification_link": "Verify Email",
|
||||
"resend_verification_expiry": "This link will expire in 24 hours.",
|
||||
"resend_verification_ignore": "If you didn't request this, you can safely ignore this email.",
|
||||
"password_reset_subject": "Password Reset Request",
|
||||
"password_reset_title": "Password Reset Request",
|
||||
"password_reset_body": "You have requested to reset your password. Please click the link below:",
|
||||
"password_reset_link": "Reset Password",
|
||||
"password_reset_expiry": "This link will expire in 1 hour.",
|
||||
"password_reset_ignore": "If you didn't request this, you can safely ignore this email.",
|
||||
"welcome_subject": "Welcome to Apocapoc!",
|
||||
"welcome_title": "Welcome to Apocapoc!",
|
||||
"welcome_body": "Your email has been verified successfully. You can now start using all features of Apocapoc.",
|
||||
"welcome_enjoy": "Enjoy building better habits!"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
{
|
||||
"errors": {
|
||||
"invalid_request_body": "Cuerpo de solicitud inválido",
|
||||
"email_already_registered": "El correo electrónico ya está registrado",
|
||||
"registration_closed": "El registro está actualmente cerrado",
|
||||
"failed_register_user": "Error al registrar usuario",
|
||||
"invalid_credentials": "Correo electrónico o contraseña inválidos",
|
||||
"email_not_verified": "Por favor verifica tu correo electrónico antes de iniciar sesión",
|
||||
"failed_login": "Error al iniciar sesión",
|
||||
"failed_generate_token": "Error al generar token",
|
||||
"failed_create_refresh_token": "Error al crear token de actualización",
|
||||
"failed_save_refresh_token": "Error al guardar token de actualización",
|
||||
"invalid_expired_refresh_token": "Token de actualización inválido o expirado",
|
||||
"failed_refresh_token": "Error al actualizar token",
|
||||
"refresh_token_not_found": "Token de actualización no encontrado",
|
||||
"invalid_refresh_token": "Token de actualización inválido",
|
||||
"user_not_authenticated": "Usuario no autenticado",
|
||||
"failed_get_user": "Error al obtener usuario",
|
||||
"failed_get_habits": "Error al obtener hábitos",
|
||||
"failed_create_habit": "Error al crear hábito",
|
||||
"habit_not_found": "Hábito no encontrado",
|
||||
"access_denied": "Acceso denegado",
|
||||
"failed_get_habit": "Error al obtener hábito",
|
||||
"invalid_input": "Entrada inválida",
|
||||
"failed_update_habit": "Error al actualizar hábito",
|
||||
"failed_archive_habit": "Error al archivar hábito",
|
||||
"failed_get_habit_entries": "Error al obtener entradas de hábito",
|
||||
"invalid_date_format": "Formato de fecha inválido (usa AAAA-MM-DD)",
|
||||
"invalid_page_parameter": "Parámetro 'page' inválido",
|
||||
"invalid_limit_parameter": "Parámetro 'limit' inválido (debe ser 1-100)",
|
||||
"pagination_required": "Se requiere paginación: proporciona el parámetro 'limit' o usa un rango de fechas ≤ 1 año",
|
||||
"invalid_from_date_format": "Formato de fecha 'from' inválido (usa AAAA-MM-DD)",
|
||||
"invalid_to_date_format": "Formato de fecha 'to' inválido (usa AAAA-MM-DD)",
|
||||
"habit_already_marked": "El hábito ya está marcado para esta fecha",
|
||||
"failed_mark_habit": "Error al marcar hábito",
|
||||
"habit_entry_not_found": "Entrada de hábito no encontrada",
|
||||
"failed_unmark_habit": "Error al desmarcar hábito",
|
||||
"invalid_expired_verification_token": "Token de verificación inválido o expirado",
|
||||
"email_already_verified": "El correo electrónico ya está verificado",
|
||||
"failed_verify_email": "Error al verificar correo electrónico",
|
||||
"invalid_email": "Correo electrónico inválido",
|
||||
"user_not_found": "Usuario no encontrado",
|
||||
"failed_send_verification_email": "Error al enviar correo de verificación",
|
||||
"email_not_verified_reset": "Por favor verifica tu correo electrónico antes de restablecer la contraseña",
|
||||
"failed_send_reset_email": "Error al enviar correo de restablecimiento",
|
||||
"invalid_token_or_password": "Token inválido o expirado, o no se cumplen los requisitos de contraseña",
|
||||
"failed_reset_password": "Error al restablecer contraseña",
|
||||
"failed_delete_user": "Error al eliminar usuario",
|
||||
"failed_get_stats": "Error al obtener estadísticas",
|
||||
"export_failed": "Error al exportar datos",
|
||||
"timezone_required": "La zona horaria es requerida",
|
||||
"invalid_timezone": "Zona horaria inválida (debe ser una zona horaria IANA válida)"
|
||||
},
|
||||
"success": {
|
||||
"registration_with_verification": "Registro exitoso. Por favor revisa tu correo electrónico para verificar tu cuenta.",
|
||||
"registration_without_verification": "Registro exitoso. Ya puedes iniciar sesión.",
|
||||
"logged_out": "Sesión cerrada exitosamente",
|
||||
"email_verified": "Correo electrónico verificado exitosamente",
|
||||
"verification_email_sent": "Correo de verificación enviado exitosamente",
|
||||
"password_reset_email_sent": "Correo de restablecimiento de contraseña enviado exitosamente",
|
||||
"password_reset": "Contraseña restablecida exitosamente",
|
||||
"user_deleted": "Usuario y todos los datos asociados eliminados exitosamente"
|
||||
},
|
||||
"validation": {
|
||||
"email_required": "el email es requerido",
|
||||
"email_invalid_format": "el email debe ser una dirección de correo válida",
|
||||
"email_too_long": "el email no debe exceder 254 caracteres",
|
||||
"password_required": "la password es requerida",
|
||||
"password_min_length": "la password debe tener al menos 8 caracteres",
|
||||
"password_max_length": "la password no debe exceder 128 caracteres",
|
||||
"password_uppercase": "la password debe contener al menos una letra mayúscula",
|
||||
"password_lowercase": "la password debe contener al menos una letra minúscula",
|
||||
"password_digit": "la password debe contener al menos un dígito",
|
||||
"password_special_char": "la password debe contener al menos un carácter especial (!@#$%^&*)",
|
||||
"timezone_required": "la timezone es requerida",
|
||||
"timezone_invalid": "la timezone no es una zona horaria IANA válida",
|
||||
"name_required": "el name es requerido",
|
||||
"name_too_long": "el name no debe exceder 255 caracteres",
|
||||
"type_invalid": "el type debe ser uno de: BOOLEAN, COUNTER, VALUE",
|
||||
"frequency_invalid": "la frequency debe ser una de: DAILY, WEEKLY, MONTHLY",
|
||||
"specific_days_required": "specific_days es requerido para frecuencia WEEKLY",
|
||||
"specific_days_invalid": "specific_days debe contener valores entre 0-6 (0=Domingo, 6=Sábado)",
|
||||
"specific_dates_required": "specific_dates es requerido para frecuencia MONTHLY",
|
||||
"specific_dates_invalid": "specific_dates debe contener valores entre 1-31",
|
||||
"target_value_required": "target_value es requerido para tipo VALUE",
|
||||
"target_value_positive": "target_value debe ser positivo"
|
||||
},
|
||||
"emails": {
|
||||
"verify_email_subject": "Verifica tu dirección de correo electrónico",
|
||||
"verify_email_title": "¡Bienvenido! Por favor verifica tu correo electrónico",
|
||||
"verify_email_body": "Gracias por registrarte. Por favor haz clic en el enlace a continuación para verificar tu dirección de correo electrónico:",
|
||||
"verify_email_link": "Verificar correo electrónico",
|
||||
"verify_email_expiry": "Este enlace expirará en 24 horas.",
|
||||
"verify_email_ignore": "Si no creaste una cuenta, puedes ignorar este correo de forma segura.",
|
||||
"resend_verification_subject": "Verifica tu dirección de correo electrónico",
|
||||
"resend_verification_title": "Verifica tu dirección de correo electrónico",
|
||||
"resend_verification_body": "Por favor haz clic en el enlace a continuación para verificar tu dirección de correo electrónico:",
|
||||
"resend_verification_link": "Verificar correo electrónico",
|
||||
"resend_verification_expiry": "Este enlace expirará en 24 horas.",
|
||||
"resend_verification_ignore": "Si no solicitaste esto, puedes ignorar este correo de forma segura.",
|
||||
"password_reset_subject": "Solicitud de restablecimiento de contraseña",
|
||||
"password_reset_title": "Solicitud de restablecimiento de contraseña",
|
||||
"password_reset_body": "Has solicitado restablecer tu contraseña. Por favor haz clic en el enlace a continuación:",
|
||||
"password_reset_link": "Restablecer contraseña",
|
||||
"password_reset_expiry": "Este enlace expirará en 1 hora.",
|
||||
"password_reset_ignore": "Si no solicitaste esto, puedes ignorar este correo de forma segura.",
|
||||
"welcome_subject": "¡Bienvenido a Apocapoc!",
|
||||
"welcome_title": "¡Bienvenido a Apocapoc!",
|
||||
"welcome_body": "Tu correo electrónico ha sido verificado exitosamente. Ya puedes comenzar a usar todas las funcionalidades de Apocapoc.",
|
||||
"welcome_enjoy": "¡Disfruta construyendo mejores hábitos!"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
package i18n
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
|
||||
"golang.org/x/text/language"
|
||||
)
|
||||
|
||||
type contextKey string
|
||||
|
||||
const languageKey contextKey = "language"
|
||||
|
||||
func LanguageMiddleware(translator *Translator) func(http.Handler) http.Handler {
|
||||
return func(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
acceptLanguage := r.Header.Get("Accept-Language")
|
||||
lang := translator.GetLanguage(acceptLanguage)
|
||||
ctx := context.WithValue(r.Context(), languageKey, lang)
|
||||
next.ServeHTTP(w, r.WithContext(ctx))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func GetLanguageFromContext(ctx context.Context) language.Tag {
|
||||
if lang, ok := ctx.Value(languageKey).(language.Tag); ok {
|
||||
return lang
|
||||
}
|
||||
return language.English
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
package i18n
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"golang.org/x/text/language"
|
||||
)
|
||||
|
||||
func TestLanguageMiddleware(t *testing.T) {
|
||||
translator, _ := NewTranslator()
|
||||
middleware := LanguageMiddleware(translator)
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
acceptLanguage string
|
||||
expectedLang language.Tag
|
||||
}{
|
||||
{
|
||||
name: "English header",
|
||||
acceptLanguage: "en-US",
|
||||
expectedLang: language.English,
|
||||
},
|
||||
{
|
||||
name: "Spanish header",
|
||||
acceptLanguage: "es-ES",
|
||||
expectedLang: language.Spanish,
|
||||
},
|
||||
{
|
||||
name: "No header defaults to English",
|
||||
acceptLanguage: "",
|
||||
expectedLang: language.English,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
var capturedLang language.Tag
|
||||
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
capturedLang = GetLanguageFromContext(r.Context())
|
||||
w.WriteHeader(http.StatusOK)
|
||||
})
|
||||
|
||||
wrappedHandler := middleware(handler)
|
||||
|
||||
req := httptest.NewRequest("GET", "/test", nil)
|
||||
if tt.acceptLanguage != "" {
|
||||
req.Header.Set("Accept-Language", tt.acceptLanguage)
|
||||
}
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
wrappedHandler.ServeHTTP(w, req)
|
||||
|
||||
if capturedLang != tt.expectedLang {
|
||||
t.Errorf("Expected language %v, got %v", tt.expectedLang, capturedLang)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetLanguageFromContext(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
ctx context.Context
|
||||
expected language.Tag
|
||||
}{
|
||||
{
|
||||
name: "Context with English",
|
||||
ctx: context.WithValue(context.Background(), languageKey, language.English),
|
||||
expected: language.English,
|
||||
},
|
||||
{
|
||||
name: "Context with Spanish",
|
||||
ctx: context.WithValue(context.Background(), languageKey, language.Spanish),
|
||||
expected: language.Spanish,
|
||||
},
|
||||
{
|
||||
name: "Context without language defaults to English",
|
||||
ctx: context.Background(),
|
||||
expected: language.English,
|
||||
},
|
||||
{
|
||||
name: "Context with wrong value type defaults to English",
|
||||
ctx: context.WithValue(context.Background(), languageKey, "invalid"),
|
||||
expected: language.English,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
result := GetLanguageFromContext(tt.ctx)
|
||||
if result != tt.expected {
|
||||
t.Errorf("Expected %v, got %v", tt.expected, result)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,357 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/golang-jwt/jwt/v5"
|
||||
)
|
||||
|
||||
func TestNewJWTService(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
secret string
|
||||
expiryHours int
|
||||
}{
|
||||
{
|
||||
name: "standard configuration",
|
||||
secret: "my-secret-key",
|
||||
expiryHours: 24,
|
||||
},
|
||||
{
|
||||
name: "short expiry",
|
||||
secret: "test-secret",
|
||||
expiryHours: 1,
|
||||
},
|
||||
{
|
||||
name: "long expiry",
|
||||
secret: "test-secret",
|
||||
expiryHours: 168,
|
||||
},
|
||||
{
|
||||
name: "empty secret",
|
||||
secret: "",
|
||||
expiryHours: 24,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
service := NewJWTService(tt.secret, tt.expiryHours)
|
||||
if service == nil {
|
||||
t.Fatal("NewJWTService() returned nil")
|
||||
}
|
||||
|
||||
if string(service.secret) != tt.secret {
|
||||
t.Errorf("secret = %v, want %v", string(service.secret), tt.secret)
|
||||
}
|
||||
|
||||
expectedExpiry := time.Duration(tt.expiryHours) * time.Hour
|
||||
if service.expiry != expectedExpiry {
|
||||
t.Errorf("expiry = %v, want %v", service.expiry, expectedExpiry)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestJWTService_GenerateToken(t *testing.T) {
|
||||
service := NewJWTService("test-secret-key", 24)
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
userID string
|
||||
email string
|
||||
}{
|
||||
{
|
||||
name: "standard user",
|
||||
userID: "user-123",
|
||||
email: "user@example.com",
|
||||
},
|
||||
{
|
||||
name: "empty user ID",
|
||||
userID: "",
|
||||
email: "user@example.com",
|
||||
},
|
||||
{
|
||||
name: "empty email",
|
||||
userID: "user-123",
|
||||
email: "",
|
||||
},
|
||||
{
|
||||
name: "both empty",
|
||||
userID: "",
|
||||
email: "",
|
||||
},
|
||||
{
|
||||
name: "special characters in email",
|
||||
userID: "user-456",
|
||||
email: "user+test@example.com",
|
||||
},
|
||||
{
|
||||
name: "uuid as user ID",
|
||||
userID: "550e8400-e29b-41d4-a716-446655440000",
|
||||
email: "uuid@example.com",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
token, err := service.GenerateToken(tt.userID, tt.email)
|
||||
if err != nil {
|
||||
t.Fatalf("GenerateToken() error = %v", err)
|
||||
}
|
||||
|
||||
if token == "" {
|
||||
t.Fatal("GenerateToken() returned empty token")
|
||||
}
|
||||
|
||||
claims, err := service.ValidateToken(token)
|
||||
if err != nil {
|
||||
t.Fatalf("ValidateToken() error = %v", err)
|
||||
}
|
||||
|
||||
if claims.UserID != tt.userID {
|
||||
t.Errorf("UserID = %v, want %v", claims.UserID, tt.userID)
|
||||
}
|
||||
|
||||
if claims.Email != tt.email {
|
||||
t.Errorf("Email = %v, want %v", claims.Email, tt.email)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestJWTService_ValidateToken(t *testing.T) {
|
||||
secret := "test-secret-key"
|
||||
service := NewJWTService(secret, 24)
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
setupToken func() string
|
||||
expectError bool
|
||||
errorMsg string
|
||||
}{
|
||||
{
|
||||
name: "valid token",
|
||||
setupToken: func() string {
|
||||
token, _ := service.GenerateToken("user-123", "user@example.com")
|
||||
return token
|
||||
},
|
||||
expectError: false,
|
||||
},
|
||||
{
|
||||
name: "invalid token format",
|
||||
setupToken: func() string {
|
||||
return "not.a.valid.token"
|
||||
},
|
||||
expectError: true,
|
||||
},
|
||||
{
|
||||
name: "empty token",
|
||||
setupToken: func() string {
|
||||
return ""
|
||||
},
|
||||
expectError: true,
|
||||
},
|
||||
{
|
||||
name: "token with wrong secret",
|
||||
setupToken: func() string {
|
||||
wrongService := NewJWTService("wrong-secret", 24)
|
||||
token, _ := wrongService.GenerateToken("user-123", "user@example.com")
|
||||
return token
|
||||
},
|
||||
expectError: true,
|
||||
},
|
||||
{
|
||||
name: "expired token",
|
||||
setupToken: func() string {
|
||||
expiredService := NewJWTService(secret, -1)
|
||||
token, _ := expiredService.GenerateToken("user-123", "user@example.com")
|
||||
return token
|
||||
},
|
||||
expectError: true,
|
||||
},
|
||||
{
|
||||
name: "malformed token",
|
||||
setupToken: func() string {
|
||||
return "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.malformed"
|
||||
},
|
||||
expectError: true,
|
||||
},
|
||||
{
|
||||
name: "token with invalid signature",
|
||||
setupToken: func() string {
|
||||
token, _ := service.GenerateToken("user-123", "user@example.com")
|
||||
return token[:len(token)-5] + "xxxxx"
|
||||
},
|
||||
expectError: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
token := tt.setupToken()
|
||||
claims, err := service.ValidateToken(token)
|
||||
|
||||
if tt.expectError {
|
||||
if err == nil {
|
||||
t.Fatal("ValidateToken() expected error but got nil")
|
||||
}
|
||||
} else {
|
||||
if err != nil {
|
||||
t.Fatalf("ValidateToken() unexpected error = %v", err)
|
||||
}
|
||||
if claims == nil {
|
||||
t.Fatal("ValidateToken() returned nil claims")
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestJWTService_ValidateTokenClaims(t *testing.T) {
|
||||
service := NewJWTService("test-secret-key", 24)
|
||||
|
||||
userID := "user-123"
|
||||
email := "user@example.com"
|
||||
|
||||
token, err := service.GenerateToken(userID, email)
|
||||
if err != nil {
|
||||
t.Fatalf("GenerateToken() error = %v", err)
|
||||
}
|
||||
|
||||
claims, err := service.ValidateToken(token)
|
||||
if err != nil {
|
||||
t.Fatalf("ValidateToken() error = %v", err)
|
||||
}
|
||||
|
||||
if claims.UserID != userID {
|
||||
t.Errorf("UserID = %v, want %v", claims.UserID, userID)
|
||||
}
|
||||
|
||||
if claims.Email != email {
|
||||
t.Errorf("Email = %v, want %v", claims.Email, email)
|
||||
}
|
||||
|
||||
if claims.ExpiresAt == nil {
|
||||
t.Fatal("ExpiresAt is nil")
|
||||
}
|
||||
|
||||
if claims.IssuedAt == nil {
|
||||
t.Fatal("IssuedAt is nil")
|
||||
}
|
||||
|
||||
if claims.ExpiresAt.Before(claims.IssuedAt.Time) {
|
||||
t.Error("ExpiresAt is before IssuedAt")
|
||||
}
|
||||
|
||||
expectedExpiry := claims.IssuedAt.Add(24 * time.Hour)
|
||||
if !claims.ExpiresAt.Time.Equal(expectedExpiry) {
|
||||
diff := claims.ExpiresAt.Time.Sub(expectedExpiry)
|
||||
if diff > time.Second || diff < -time.Second {
|
||||
t.Errorf("ExpiresAt = %v, want approximately %v (diff: %v)", claims.ExpiresAt.Time, expectedExpiry, diff)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestJWTService_TokenExpiry(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
expiryHours int
|
||||
}{
|
||||
{
|
||||
name: "1 hour expiry",
|
||||
expiryHours: 1,
|
||||
},
|
||||
{
|
||||
name: "24 hours expiry",
|
||||
expiryHours: 24,
|
||||
},
|
||||
{
|
||||
name: "168 hours (1 week) expiry",
|
||||
expiryHours: 168,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
service := NewJWTService("test-secret", tt.expiryHours)
|
||||
token, err := service.GenerateToken("user-123", "user@example.com")
|
||||
if err != nil {
|
||||
t.Fatalf("GenerateToken() error = %v", err)
|
||||
}
|
||||
|
||||
claims, err := service.ValidateToken(token)
|
||||
if err != nil {
|
||||
t.Fatalf("ValidateToken() error = %v", err)
|
||||
}
|
||||
|
||||
expectedExpiry := time.Now().Add(time.Duration(tt.expiryHours) * time.Hour)
|
||||
diff := claims.ExpiresAt.Time.Sub(expectedExpiry)
|
||||
|
||||
if diff > time.Second || diff < -time.Second {
|
||||
t.Errorf("ExpiresAt difference too large: %v", diff)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestJWTService_ValidateTokenWithWrongSigningMethod(t *testing.T) {
|
||||
service := NewJWTService("test-secret", 24)
|
||||
|
||||
claims := &Claims{
|
||||
UserID: "user-123",
|
||||
Email: "user@example.com",
|
||||
RegisteredClaims: jwt.RegisteredClaims{
|
||||
ExpiresAt: jwt.NewNumericDate(time.Now().Add(24 * time.Hour)),
|
||||
IssuedAt: jwt.NewNumericDate(time.Now()),
|
||||
},
|
||||
}
|
||||
|
||||
token := jwt.NewWithClaims(jwt.SigningMethodNone, claims)
|
||||
tokenString, err := token.SignedString(jwt.UnsafeAllowNoneSignatureType)
|
||||
if err != nil {
|
||||
t.Fatalf("SignedString() error = %v", err)
|
||||
}
|
||||
|
||||
_, err = service.ValidateToken(tokenString)
|
||||
if err == nil {
|
||||
t.Fatal("ValidateToken() expected error for none signing method but got nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestJWTService_MultipleTokens(t *testing.T) {
|
||||
service := NewJWTService("test-secret", 24)
|
||||
|
||||
token1, err := service.GenerateToken("user-1", "user1@example.com")
|
||||
if err != nil {
|
||||
t.Fatalf("GenerateToken(1) error = %v", err)
|
||||
}
|
||||
|
||||
token2, err := service.GenerateToken("user-2", "user2@example.com")
|
||||
if err != nil {
|
||||
t.Fatalf("GenerateToken(2) error = %v", err)
|
||||
}
|
||||
|
||||
if token1 == token2 {
|
||||
t.Error("Generated identical tokens for different users")
|
||||
}
|
||||
|
||||
claims1, err := service.ValidateToken(token1)
|
||||
if err != nil {
|
||||
t.Fatalf("ValidateToken(1) error = %v", err)
|
||||
}
|
||||
|
||||
claims2, err := service.ValidateToken(token2)
|
||||
if err != nil {
|
||||
t.Fatalf("ValidateToken(2) error = %v", err)
|
||||
}
|
||||
|
||||
if claims1.UserID == claims2.UserID {
|
||||
t.Error("Claims have same UserID")
|
||||
}
|
||||
|
||||
if claims1.Email == claims2.Email {
|
||||
t.Error("Claims have same Email")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
package backup
|
||||
|
||||
import (
|
||||
"compress/gzip"
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"time"
|
||||
|
||||
"apocapoc-api/internal/infrastructure/logger"
|
||||
)
|
||||
|
||||
type Config struct {
|
||||
Enabled bool
|
||||
Interval time.Duration
|
||||
RetentionDays int
|
||||
Path string
|
||||
Compress bool
|
||||
DatabasePath string
|
||||
}
|
||||
|
||||
func CreateBackup(db *sql.DB, config Config) error {
|
||||
if !config.Enabled {
|
||||
return nil
|
||||
}
|
||||
|
||||
if err := os.MkdirAll(config.Path, 0755); err != nil {
|
||||
return fmt.Errorf("failed to create backup directory: %w", err)
|
||||
}
|
||||
|
||||
timestamp := time.Now().Format("20060102_150405")
|
||||
filename := fmt.Sprintf("apocapoc_%s.db", timestamp)
|
||||
backupPath := filepath.Join(config.Path, filename)
|
||||
|
||||
logger.Info().
|
||||
Str("backup_path", backupPath).
|
||||
Msg("Starting database backup")
|
||||
|
||||
if err := backupDatabase(db, backupPath); err != nil {
|
||||
logger.Error().
|
||||
Err(err).
|
||||
Str("backup_path", backupPath).
|
||||
Msg("Backup failed")
|
||||
return fmt.Errorf("backup failed: %w", err)
|
||||
}
|
||||
|
||||
if config.Compress {
|
||||
compressedPath := backupPath + ".gz"
|
||||
if err := compressFile(backupPath, compressedPath); err != nil {
|
||||
logger.Warn().
|
||||
Err(err).
|
||||
Str("backup_path", backupPath).
|
||||
Msg("Compression failed, keeping uncompressed backup")
|
||||
} else {
|
||||
os.Remove(backupPath)
|
||||
backupPath = compressedPath
|
||||
}
|
||||
}
|
||||
|
||||
logger.Info().
|
||||
Str("backup_path", backupPath).
|
||||
Msg("Backup completed successfully")
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func backupDatabase(db *sql.DB, destPath string) error {
|
||||
_, err := db.Exec(fmt.Sprintf("VACUUM INTO '%s'", destPath))
|
||||
if err != nil {
|
||||
return fmt.Errorf("vacuum into failed: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func compressFile(srcPath, destPath string) error {
|
||||
srcFile, err := os.Open(srcPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer srcFile.Close()
|
||||
|
||||
destFile, err := os.Create(destPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer destFile.Close()
|
||||
|
||||
gzipWriter := gzip.NewWriter(destFile)
|
||||
defer gzipWriter.Close()
|
||||
|
||||
_, err = io.Copy(gzipWriter, srcFile)
|
||||
return err
|
||||
}
|
||||
@@ -0,0 +1,169 @@
|
||||
package backup
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
_ "modernc.org/sqlite"
|
||||
)
|
||||
|
||||
func TestCreateBackup(t *testing.T) {
|
||||
tempDir := t.TempDir()
|
||||
dbPath := filepath.Join(tempDir, "test.db")
|
||||
|
||||
db, err := sql.Open("sqlite", dbPath)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create test database: %v", err)
|
||||
}
|
||||
defer db.Close()
|
||||
|
||||
_, err = db.Exec("CREATE TABLE test (id INTEGER PRIMARY KEY, name TEXT)")
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create test table: %v", err)
|
||||
}
|
||||
|
||||
_, err = db.Exec("INSERT INTO test (name) VALUES ('test1'), ('test2')")
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to insert test data: %v", err)
|
||||
}
|
||||
|
||||
backupPath := filepath.Join(tempDir, "backups")
|
||||
config := Config{
|
||||
Enabled: true,
|
||||
Interval: 24 * time.Hour,
|
||||
RetentionDays: 7,
|
||||
Path: backupPath,
|
||||
Compress: false,
|
||||
DatabasePath: dbPath,
|
||||
}
|
||||
|
||||
err = CreateBackup(db, config)
|
||||
if err != nil {
|
||||
t.Fatalf("CreateBackup failed: %v", err)
|
||||
}
|
||||
|
||||
files, err := os.ReadDir(backupPath)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to read backup directory: %v", err)
|
||||
}
|
||||
|
||||
if len(files) != 1 {
|
||||
t.Errorf("Expected 1 backup file, got %d", len(files))
|
||||
}
|
||||
|
||||
if len(files) > 0 && filepath.Ext(files[0].Name()) != ".db" {
|
||||
t.Errorf("Expected backup file to have .db extension, got %s", files[0].Name())
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateBackupWithCompression(t *testing.T) {
|
||||
tempDir := t.TempDir()
|
||||
dbPath := filepath.Join(tempDir, "test.db")
|
||||
|
||||
db, err := sql.Open("sqlite", dbPath)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create test database: %v", err)
|
||||
}
|
||||
defer db.Close()
|
||||
|
||||
_, err = db.Exec("CREATE TABLE test (id INTEGER PRIMARY KEY, name TEXT)")
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create test table: %v", err)
|
||||
}
|
||||
|
||||
backupPath := filepath.Join(tempDir, "backups")
|
||||
config := Config{
|
||||
Enabled: true,
|
||||
Interval: 24 * time.Hour,
|
||||
RetentionDays: 7,
|
||||
Path: backupPath,
|
||||
Compress: true,
|
||||
DatabasePath: dbPath,
|
||||
}
|
||||
|
||||
err = CreateBackup(db, config)
|
||||
if err != nil {
|
||||
t.Fatalf("CreateBackup failed: %v", err)
|
||||
}
|
||||
|
||||
files, err := os.ReadDir(backupPath)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to read backup directory: %v", err)
|
||||
}
|
||||
|
||||
if len(files) != 1 {
|
||||
t.Errorf("Expected 1 backup file, got %d", len(files))
|
||||
}
|
||||
|
||||
if len(files) > 0 && filepath.Ext(files[0].Name()) != ".gz" {
|
||||
t.Errorf("Expected backup file to have .gz extension, got %s", files[0].Name())
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateBackupDisabled(t *testing.T) {
|
||||
tempDir := t.TempDir()
|
||||
dbPath := filepath.Join(tempDir, "test.db")
|
||||
|
||||
db, err := sql.Open("sqlite", dbPath)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create test database: %v", err)
|
||||
}
|
||||
defer db.Close()
|
||||
|
||||
backupPath := filepath.Join(tempDir, "backups")
|
||||
config := Config{
|
||||
Enabled: false,
|
||||
Interval: 24 * time.Hour,
|
||||
RetentionDays: 7,
|
||||
Path: backupPath,
|
||||
Compress: false,
|
||||
DatabasePath: dbPath,
|
||||
}
|
||||
|
||||
err = CreateBackup(db, config)
|
||||
if err != nil {
|
||||
t.Fatalf("CreateBackup failed: %v", err)
|
||||
}
|
||||
|
||||
_, err = os.Stat(backupPath)
|
||||
if !os.IsNotExist(err) {
|
||||
t.Error("Backup directory should not exist when backup is disabled")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCleanOldBackups(t *testing.T) {
|
||||
tempDir := t.TempDir()
|
||||
backupPath := filepath.Join(tempDir, "backups")
|
||||
os.MkdirAll(backupPath, 0755)
|
||||
|
||||
oldFile := filepath.Join(backupPath, "apocapoc_20200101_120000.db")
|
||||
recentFile := filepath.Join(backupPath, "apocapoc_"+time.Now().Format("20060102_150405")+".db")
|
||||
|
||||
os.WriteFile(oldFile, []byte("old"), 0644)
|
||||
os.WriteFile(recentFile, []byte("recent"), 0644)
|
||||
|
||||
oldTime := time.Now().AddDate(0, 0, -10)
|
||||
os.Chtimes(oldFile, oldTime, oldTime)
|
||||
|
||||
config := Config{
|
||||
Enabled: true,
|
||||
RetentionDays: 7,
|
||||
Path: backupPath,
|
||||
}
|
||||
|
||||
err := CleanOldBackups(config)
|
||||
if err != nil {
|
||||
t.Fatalf("CleanOldBackups failed: %v", err)
|
||||
}
|
||||
|
||||
if _, err := os.Stat(oldFile); !os.IsNotExist(err) {
|
||||
t.Error("Old backup file should have been deleted")
|
||||
}
|
||||
|
||||
if _, err := os.Stat(recentFile); err != nil {
|
||||
t.Error("Recent backup file should still exist")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
package backup
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"apocapoc-api/internal/infrastructure/logger"
|
||||
)
|
||||
|
||||
func CleanOldBackups(config Config) error {
|
||||
if !config.Enabled {
|
||||
return nil
|
||||
}
|
||||
|
||||
if config.RetentionDays <= 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
cutoffTime := time.Now().AddDate(0, 0, -config.RetentionDays)
|
||||
|
||||
files, err := os.ReadDir(config.Path)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
deletedCount := 0
|
||||
for _, file := range files {
|
||||
if file.IsDir() {
|
||||
continue
|
||||
}
|
||||
|
||||
if !strings.HasPrefix(file.Name(), "apocapoc_") {
|
||||
continue
|
||||
}
|
||||
|
||||
if !strings.HasSuffix(file.Name(), ".db") && !strings.HasSuffix(file.Name(), ".db.gz") {
|
||||
continue
|
||||
}
|
||||
|
||||
filePath := filepath.Join(config.Path, file.Name())
|
||||
info, err := os.Stat(filePath)
|
||||
if err != nil {
|
||||
logger.Warn().
|
||||
Err(err).
|
||||
Str("file", filePath).
|
||||
Msg("Failed to stat backup file")
|
||||
continue
|
||||
}
|
||||
|
||||
if info.ModTime().Before(cutoffTime) {
|
||||
if err := os.Remove(filePath); err != nil {
|
||||
logger.Warn().
|
||||
Err(err).
|
||||
Str("file", filePath).
|
||||
Msg("Failed to delete old backup")
|
||||
continue
|
||||
}
|
||||
|
||||
logger.Info().
|
||||
Str("file", file.Name()).
|
||||
Time("mod_time", info.ModTime()).
|
||||
Msg("Deleted old backup")
|
||||
deletedCount++
|
||||
}
|
||||
}
|
||||
|
||||
if deletedCount > 0 {
|
||||
logger.Info().
|
||||
Int("deleted_count", deletedCount).
|
||||
Int("retention_days", config.RetentionDays).
|
||||
Msg("Backup cleanup completed")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
package backup
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"time"
|
||||
|
||||
"apocapoc-api/internal/infrastructure/logger"
|
||||
)
|
||||
|
||||
type Scheduler struct {
|
||||
db *sql.DB
|
||||
config Config
|
||||
stopCh chan struct{}
|
||||
}
|
||||
|
||||
func NewScheduler(db *sql.DB, config Config) *Scheduler {
|
||||
return &Scheduler{
|
||||
db: db,
|
||||
config: config,
|
||||
stopCh: make(chan struct{}),
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Scheduler) Start() {
|
||||
if !s.config.Enabled {
|
||||
logger.Info().Msg("Backup scheduler is disabled")
|
||||
return
|
||||
}
|
||||
|
||||
logger.Info().
|
||||
Dur("interval", s.config.Interval).
|
||||
Int("retention_days", s.config.RetentionDays).
|
||||
Str("path", s.config.Path).
|
||||
Bool("compress", s.config.Compress).
|
||||
Msg("Starting backup scheduler")
|
||||
|
||||
go s.run()
|
||||
}
|
||||
|
||||
func (s *Scheduler) run() {
|
||||
if err := CreateBackup(s.db, s.config); err != nil {
|
||||
logger.Error().Err(err).Msg("Initial backup failed")
|
||||
}
|
||||
|
||||
if err := CleanOldBackups(s.config); err != nil {
|
||||
logger.Error().Err(err).Msg("Initial cleanup failed")
|
||||
}
|
||||
|
||||
ticker := time.NewTicker(s.config.Interval)
|
||||
defer ticker.Stop()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-ticker.C:
|
||||
logger.Debug().Msg("Running scheduled backup")
|
||||
|
||||
if err := CreateBackup(s.db, s.config); err != nil {
|
||||
logger.Error().Err(err).Msg("Scheduled backup failed")
|
||||
continue
|
||||
}
|
||||
|
||||
if err := CleanOldBackups(s.config); err != nil {
|
||||
logger.Error().Err(err).Msg("Backup cleanup failed")
|
||||
}
|
||||
|
||||
case <-s.stopCh:
|
||||
logger.Info().Msg("Backup scheduler stopped")
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Scheduler) Stop() {
|
||||
if s.config.Enabled {
|
||||
close(s.stopCh)
|
||||
}
|
||||
}
|
||||
@@ -8,33 +8,64 @@ import (
|
||||
)
|
||||
|
||||
type Config struct {
|
||||
DBPath string
|
||||
Port string
|
||||
Host string
|
||||
JWTSecret string
|
||||
JWTExpiry string
|
||||
RefreshTokenExpiry string
|
||||
CORSOrigins string
|
||||
DefaultTimezone string
|
||||
DBPath string
|
||||
Port string
|
||||
AppURL string
|
||||
JWTSecret string
|
||||
JWTExpiry string
|
||||
RefreshTokenExpiry string
|
||||
DefaultTimezone string
|
||||
SMTPHost string
|
||||
SMTPPort string
|
||||
SMTPUser string
|
||||
SMTPPassword string
|
||||
SMTPFrom string
|
||||
SupportEmail string
|
||||
SendWelcomeEmail string
|
||||
RegistrationMode string
|
||||
LogLevel string
|
||||
Environment string
|
||||
BackupEnabled string
|
||||
BackupInterval string
|
||||
BackupRetentionDays string
|
||||
BackupPath string
|
||||
BackupCompress string
|
||||
}
|
||||
|
||||
func Load() (*Config, error) {
|
||||
godotenv.Load()
|
||||
|
||||
cfg := &Config{
|
||||
DBPath: os.Getenv("DB_PATH"),
|
||||
Port: getEnvOrDefault("PORT", "8080"),
|
||||
Host: getEnvOrDefault("HOST", "0.0.0.0"),
|
||||
JWTSecret: os.Getenv("JWT_SECRET"),
|
||||
JWTExpiry: os.Getenv("JWT_EXPIRY"),
|
||||
RefreshTokenExpiry: os.Getenv("REFRESH_TOKEN_EXPIRY"),
|
||||
CORSOrigins: os.Getenv("CORS_ORIGINS"),
|
||||
DefaultTimezone: os.Getenv("DEFAULT_TIMEZONE"),
|
||||
DBPath: os.Getenv("DB_PATH"),
|
||||
Port: getEnvOrDefault("PORT", "8080"),
|
||||
AppURL: getEnvOrDefault("APP_URL", "http://localhost:8080"),
|
||||
JWTSecret: os.Getenv("JWT_SECRET"),
|
||||
JWTExpiry: os.Getenv("JWT_EXPIRY"),
|
||||
RefreshTokenExpiry: os.Getenv("REFRESH_TOKEN_EXPIRY"),
|
||||
DefaultTimezone: os.Getenv("DEFAULT_TIMEZONE"),
|
||||
SMTPHost: os.Getenv("SMTP_HOST"),
|
||||
SMTPPort: getEnvOrDefault("SMTP_PORT", "587"),
|
||||
SMTPUser: os.Getenv("SMTP_USER"),
|
||||
SMTPPassword: os.Getenv("SMTP_PASSWORD"),
|
||||
SMTPFrom: os.Getenv("SMTP_FROM"),
|
||||
SupportEmail: getEnvOrDefault("SUPPORT_EMAIL", "contact@apocapoc.app"),
|
||||
SendWelcomeEmail: getEnvOrDefault("SEND_WELCOME_EMAIL", "false"),
|
||||
RegistrationMode: getEnvOrDefault("REGISTRATION_MODE", "open"),
|
||||
LogLevel: getEnvOrDefault("LOG_LEVEL", "info"),
|
||||
Environment: getEnvOrDefault("ENVIRONMENT", "production"),
|
||||
BackupEnabled: getEnvOrDefault("BACKUP_ENABLED", "false"),
|
||||
BackupInterval: getEnvOrDefault("BACKUP_INTERVAL", "24h"),
|
||||
BackupRetentionDays: getEnvOrDefault("BACKUP_RETENTION_DAYS", "7"),
|
||||
BackupPath: getEnvOrDefault("BACKUP_PATH", "./data/backups"),
|
||||
BackupCompress: getEnvOrDefault("BACKUP_COMPRESS", "true"),
|
||||
}
|
||||
|
||||
if cfg.DBPath == "" {
|
||||
return nil, fmt.Errorf("DB_PATH is required")
|
||||
}
|
||||
if cfg.AppURL == "" {
|
||||
return nil, fmt.Errorf("APP_URL is required")
|
||||
}
|
||||
if cfg.JWTSecret == "" {
|
||||
return nil, fmt.Errorf("JWT_SECRET is required")
|
||||
}
|
||||
@@ -44,9 +75,6 @@ func Load() (*Config, error) {
|
||||
if cfg.RefreshTokenExpiry == "" {
|
||||
return nil, fmt.Errorf("REFRESH_TOKEN_EXPIRY is required")
|
||||
}
|
||||
if cfg.CORSOrigins == "" {
|
||||
return nil, fmt.Errorf("CORS_ORIGINS is required")
|
||||
}
|
||||
if cfg.DefaultTimezone == "" {
|
||||
return nil, fmt.Errorf("DEFAULT_TIMEZONE is required")
|
||||
}
|
||||
|
||||
@@ -0,0 +1,315 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"os"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestLoad_Success(t *testing.T) {
|
||||
os.Setenv("DB_PATH", "/test/db.sqlite")
|
||||
os.Setenv("APP_URL", "http://test.com")
|
||||
os.Setenv("JWT_SECRET", "test-secret")
|
||||
os.Setenv("JWT_EXPIRY", "1h")
|
||||
os.Setenv("REFRESH_TOKEN_EXPIRY", "7d")
|
||||
os.Setenv("DEFAULT_TIMEZONE", "UTC")
|
||||
defer clearEnv()
|
||||
|
||||
cfg, err := Load()
|
||||
if err != nil {
|
||||
t.Fatalf("Load() error = %v", err)
|
||||
}
|
||||
|
||||
if cfg.DBPath != "/test/db.sqlite" {
|
||||
t.Errorf("DBPath = %v, want %v", cfg.DBPath, "/test/db.sqlite")
|
||||
}
|
||||
if cfg.AppURL != "http://test.com" {
|
||||
t.Errorf("AppURL = %v, want %v", cfg.AppURL, "http://test.com")
|
||||
}
|
||||
if cfg.JWTSecret != "test-secret" {
|
||||
t.Errorf("JWTSecret = %v, want %v", cfg.JWTSecret, "test-secret")
|
||||
}
|
||||
if cfg.JWTExpiry != "1h" {
|
||||
t.Errorf("JWTExpiry = %v, want %v", cfg.JWTExpiry, "1h")
|
||||
}
|
||||
if cfg.RefreshTokenExpiry != "7d" {
|
||||
t.Errorf("RefreshTokenExpiry = %v, want %v", cfg.RefreshTokenExpiry, "7d")
|
||||
}
|
||||
if cfg.DefaultTimezone != "UTC" {
|
||||
t.Errorf("DefaultTimezone = %v, want %v", cfg.DefaultTimezone, "UTC")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoad_WithDefaults(t *testing.T) {
|
||||
os.Setenv("DB_PATH", "/test/db.sqlite")
|
||||
os.Setenv("APP_URL", "http://test.com")
|
||||
os.Setenv("JWT_SECRET", "test-secret")
|
||||
os.Setenv("JWT_EXPIRY", "1h")
|
||||
os.Setenv("REFRESH_TOKEN_EXPIRY", "7d")
|
||||
os.Setenv("DEFAULT_TIMEZONE", "UTC")
|
||||
defer clearEnv()
|
||||
|
||||
cfg, err := Load()
|
||||
if err != nil {
|
||||
t.Fatalf("Load() error = %v", err)
|
||||
}
|
||||
|
||||
if cfg.Port != "8080" {
|
||||
t.Errorf("Port = %v, want default %v", cfg.Port, "8080")
|
||||
}
|
||||
|
||||
if cfg.SMTPPort != "587" {
|
||||
t.Errorf("SMTPPort = %v, want default %v", cfg.SMTPPort, "587")
|
||||
}
|
||||
|
||||
if cfg.SupportEmail != "contact@apocapoc.app" {
|
||||
t.Errorf("SupportEmail = %v, want default %v", cfg.SupportEmail, "contact@apocapoc.app")
|
||||
}
|
||||
|
||||
if cfg.SendWelcomeEmail != "false" {
|
||||
t.Errorf("SendWelcomeEmail = %v, want default %v", cfg.SendWelcomeEmail, "false")
|
||||
}
|
||||
|
||||
if cfg.RegistrationMode != "open" {
|
||||
t.Errorf("RegistrationMode = %v, want default %v", cfg.RegistrationMode, "open")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoad_WithCustomDefaults(t *testing.T) {
|
||||
os.Setenv("DB_PATH", "/test/db.sqlite")
|
||||
os.Setenv("APP_URL", "http://test.com")
|
||||
os.Setenv("JWT_SECRET", "test-secret")
|
||||
os.Setenv("JWT_EXPIRY", "1h")
|
||||
os.Setenv("REFRESH_TOKEN_EXPIRY", "7d")
|
||||
os.Setenv("DEFAULT_TIMEZONE", "UTC")
|
||||
os.Setenv("PORT", "3000")
|
||||
os.Setenv("SMTP_PORT", "465")
|
||||
os.Setenv("SUPPORT_EMAIL", "support@test.com")
|
||||
os.Setenv("SEND_WELCOME_EMAIL", "true")
|
||||
os.Setenv("REGISTRATION_MODE", "closed")
|
||||
defer clearEnv()
|
||||
|
||||
cfg, err := Load()
|
||||
if err != nil {
|
||||
t.Fatalf("Load() error = %v", err)
|
||||
}
|
||||
|
||||
if cfg.Port != "3000" {
|
||||
t.Errorf("Port = %v, want %v", cfg.Port, "3000")
|
||||
}
|
||||
|
||||
if cfg.SMTPPort != "465" {
|
||||
t.Errorf("SMTPPort = %v, want %v", cfg.SMTPPort, "465")
|
||||
}
|
||||
|
||||
if cfg.SupportEmail != "support@test.com" {
|
||||
t.Errorf("SupportEmail = %v, want %v", cfg.SupportEmail, "support@test.com")
|
||||
}
|
||||
|
||||
if cfg.SendWelcomeEmail != "true" {
|
||||
t.Errorf("SendWelcomeEmail = %v, want %v", cfg.SendWelcomeEmail, "true")
|
||||
}
|
||||
|
||||
if cfg.RegistrationMode != "closed" {
|
||||
t.Errorf("RegistrationMode = %v, want %v", cfg.RegistrationMode, "closed")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoad_MissingDBPath(t *testing.T) {
|
||||
clearEnv()
|
||||
os.Setenv("APP_URL", "http://test.com")
|
||||
os.Setenv("JWT_SECRET", "test-secret")
|
||||
os.Setenv("JWT_EXPIRY", "1h")
|
||||
os.Setenv("REFRESH_TOKEN_EXPIRY", "7d")
|
||||
os.Setenv("DEFAULT_TIMEZONE", "UTC")
|
||||
defer clearEnv()
|
||||
|
||||
_, err := Load()
|
||||
if err == nil {
|
||||
t.Fatal("Load() expected error for missing DB_PATH but got nil")
|
||||
}
|
||||
|
||||
expectedMsg := "DB_PATH is required"
|
||||
if err.Error() != expectedMsg {
|
||||
t.Errorf("Load() error = %v, want %v", err.Error(), expectedMsg)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoad_MissingJWTSecret(t *testing.T) {
|
||||
clearEnv()
|
||||
os.Setenv("DB_PATH", "/test/db.sqlite")
|
||||
os.Setenv("APP_URL", "http://test.com")
|
||||
os.Setenv("JWT_EXPIRY", "1h")
|
||||
os.Setenv("REFRESH_TOKEN_EXPIRY", "7d")
|
||||
os.Setenv("DEFAULT_TIMEZONE", "UTC")
|
||||
defer clearEnv()
|
||||
|
||||
_, err := Load()
|
||||
if err == nil {
|
||||
t.Fatal("Load() expected error for missing JWT_SECRET but got nil")
|
||||
}
|
||||
|
||||
expectedMsg := "JWT_SECRET is required"
|
||||
if err.Error() != expectedMsg {
|
||||
t.Errorf("Load() error = %v, want %v", err.Error(), expectedMsg)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoad_MissingJWTExpiry(t *testing.T) {
|
||||
clearEnv()
|
||||
os.Setenv("DB_PATH", "/test/db.sqlite")
|
||||
os.Setenv("APP_URL", "http://test.com")
|
||||
os.Setenv("JWT_SECRET", "test-secret")
|
||||
os.Setenv("REFRESH_TOKEN_EXPIRY", "7d")
|
||||
os.Setenv("DEFAULT_TIMEZONE", "UTC")
|
||||
defer clearEnv()
|
||||
|
||||
_, err := Load()
|
||||
if err == nil {
|
||||
t.Fatal("Load() expected error for missing JWT_EXPIRY but got nil")
|
||||
}
|
||||
|
||||
expectedMsg := "JWT_EXPIRY is required"
|
||||
if err.Error() != expectedMsg {
|
||||
t.Errorf("Load() error = %v, want %v", err.Error(), expectedMsg)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoad_MissingRefreshTokenExpiry(t *testing.T) {
|
||||
clearEnv()
|
||||
os.Setenv("DB_PATH", "/test/db.sqlite")
|
||||
os.Setenv("APP_URL", "http://test.com")
|
||||
os.Setenv("JWT_SECRET", "test-secret")
|
||||
os.Setenv("JWT_EXPIRY", "1h")
|
||||
os.Setenv("DEFAULT_TIMEZONE", "UTC")
|
||||
defer clearEnv()
|
||||
|
||||
_, err := Load()
|
||||
if err == nil {
|
||||
t.Fatal("Load() expected error for missing REFRESH_TOKEN_EXPIRY but got nil")
|
||||
}
|
||||
|
||||
expectedMsg := "REFRESH_TOKEN_EXPIRY is required"
|
||||
if err.Error() != expectedMsg {
|
||||
t.Errorf("Load() error = %v, want %v", err.Error(), expectedMsg)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoad_MissingDefaultTimezone(t *testing.T) {
|
||||
clearEnv()
|
||||
os.Setenv("DB_PATH", "/test/db.sqlite")
|
||||
os.Setenv("APP_URL", "http://test.com")
|
||||
os.Setenv("JWT_SECRET", "test-secret")
|
||||
os.Setenv("JWT_EXPIRY", "1h")
|
||||
os.Setenv("REFRESH_TOKEN_EXPIRY", "7d")
|
||||
defer clearEnv()
|
||||
|
||||
_, err := Load()
|
||||
if err == nil {
|
||||
t.Fatal("Load() expected error for missing DEFAULT_TIMEZONE but got nil")
|
||||
}
|
||||
|
||||
expectedMsg := "DEFAULT_TIMEZONE is required"
|
||||
if err.Error() != expectedMsg {
|
||||
t.Errorf("Load() error = %v, want %v", err.Error(), expectedMsg)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoad_WithSMTPConfig(t *testing.T) {
|
||||
os.Setenv("DB_PATH", "/test/db.sqlite")
|
||||
os.Setenv("APP_URL", "http://test.com")
|
||||
os.Setenv("JWT_SECRET", "test-secret")
|
||||
os.Setenv("JWT_EXPIRY", "1h")
|
||||
os.Setenv("REFRESH_TOKEN_EXPIRY", "7d")
|
||||
os.Setenv("DEFAULT_TIMEZONE", "UTC")
|
||||
os.Setenv("SMTP_HOST", "smtp.test.com")
|
||||
os.Setenv("SMTP_PORT", "587")
|
||||
os.Setenv("SMTP_USER", "user@test.com")
|
||||
os.Setenv("SMTP_PASSWORD", "test-password")
|
||||
os.Setenv("SMTP_FROM", "noreply@test.com")
|
||||
defer clearEnv()
|
||||
|
||||
cfg, err := Load()
|
||||
if err != nil {
|
||||
t.Fatalf("Load() error = %v", err)
|
||||
}
|
||||
|
||||
if cfg.SMTPHost != "smtp.test.com" {
|
||||
t.Errorf("SMTPHost = %v, want %v", cfg.SMTPHost, "smtp.test.com")
|
||||
}
|
||||
if cfg.SMTPPort != "587" {
|
||||
t.Errorf("SMTPPort = %v, want %v", cfg.SMTPPort, "587")
|
||||
}
|
||||
if cfg.SMTPUser != "user@test.com" {
|
||||
t.Errorf("SMTPUser = %v, want %v", cfg.SMTPUser, "user@test.com")
|
||||
}
|
||||
if cfg.SMTPPassword != "test-password" {
|
||||
t.Errorf("SMTPPassword = %v, want %v", cfg.SMTPPassword, "test-password")
|
||||
}
|
||||
if cfg.SMTPFrom != "noreply@test.com" {
|
||||
t.Errorf("SMTPFrom = %v, want %v", cfg.SMTPFrom, "noreply@test.com")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetEnvOrDefault(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
key string
|
||||
defaultValue string
|
||||
envValue string
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "env value exists",
|
||||
key: "TEST_KEY",
|
||||
defaultValue: "default",
|
||||
envValue: "custom",
|
||||
want: "custom",
|
||||
},
|
||||
{
|
||||
name: "env value empty uses default",
|
||||
key: "TEST_KEY_EMPTY",
|
||||
defaultValue: "default",
|
||||
envValue: "",
|
||||
want: "default",
|
||||
},
|
||||
{
|
||||
name: "env value not set uses default",
|
||||
key: "TEST_KEY_NOT_SET",
|
||||
defaultValue: "default",
|
||||
envValue: "",
|
||||
want: "default",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if tt.envValue != "" {
|
||||
os.Setenv(tt.key, tt.envValue)
|
||||
defer os.Unsetenv(tt.key)
|
||||
}
|
||||
|
||||
got := getEnvOrDefault(tt.key, tt.defaultValue)
|
||||
if got != tt.want {
|
||||
t.Errorf("getEnvOrDefault() = %v, want %v", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func clearEnv() {
|
||||
os.Unsetenv("DB_PATH")
|
||||
os.Unsetenv("PORT")
|
||||
os.Unsetenv("APP_URL")
|
||||
os.Unsetenv("JWT_SECRET")
|
||||
os.Unsetenv("JWT_EXPIRY")
|
||||
os.Unsetenv("REFRESH_TOKEN_EXPIRY")
|
||||
os.Unsetenv("DEFAULT_TIMEZONE")
|
||||
os.Unsetenv("SMTP_HOST")
|
||||
os.Unsetenv("SMTP_PORT")
|
||||
os.Unsetenv("SMTP_USER")
|
||||
os.Unsetenv("SMTP_PASSWORD")
|
||||
os.Unsetenv("SMTP_FROM")
|
||||
os.Unsetenv("SUPPORT_EMAIL")
|
||||
os.Unsetenv("SEND_WELCOME_EMAIL")
|
||||
os.Unsetenv("REGISTRATION_MODE")
|
||||
}
|
||||
@@ -0,0 +1,217 @@
|
||||
package crypto
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
)
|
||||
|
||||
func TestNewBcryptHasher(t *testing.T) {
|
||||
hasher := NewBcryptHasher()
|
||||
if hasher == nil {
|
||||
t.Fatal("NewBcryptHasher() returned nil")
|
||||
}
|
||||
|
||||
_, ok := hasher.(*BcryptHasher)
|
||||
if !ok {
|
||||
t.Fatal("NewBcryptHasher() did not return *BcryptHasher")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBcryptHasher_Hash(t *testing.T) {
|
||||
hasher := NewBcryptHasher()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
password string
|
||||
}{
|
||||
{
|
||||
name: "simple password",
|
||||
password: "password123",
|
||||
},
|
||||
{
|
||||
name: "complex password",
|
||||
password: "P@ssw0rd!123$%^&*()",
|
||||
},
|
||||
{
|
||||
name: "empty password",
|
||||
password: "",
|
||||
},
|
||||
{
|
||||
name: "unicode password",
|
||||
password: "pässwörd123",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
hashed, err := hasher.Hash(tt.password)
|
||||
if err != nil {
|
||||
t.Fatalf("Hash() error = %v", err)
|
||||
}
|
||||
|
||||
if hashed == "" {
|
||||
t.Fatal("Hash() returned empty string")
|
||||
}
|
||||
|
||||
if hashed == tt.password {
|
||||
t.Fatal("Hash() returned the same as input password")
|
||||
}
|
||||
|
||||
err = bcrypt.CompareHashAndPassword([]byte(hashed), []byte(tt.password))
|
||||
if err != nil {
|
||||
t.Fatalf("Generated hash does not match original password: %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestBcryptHasher_Compare(t *testing.T) {
|
||||
hasher := NewBcryptHasher()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
password string
|
||||
compareWith string
|
||||
expectError bool
|
||||
errorAssertion func(error) bool
|
||||
}{
|
||||
{
|
||||
name: "matching passwords",
|
||||
password: "password123",
|
||||
compareWith: "password123",
|
||||
expectError: false,
|
||||
},
|
||||
{
|
||||
name: "non-matching passwords",
|
||||
password: "password123",
|
||||
compareWith: "wrongpassword",
|
||||
expectError: true,
|
||||
errorAssertion: func(err error) bool {
|
||||
return err == bcrypt.ErrMismatchedHashAndPassword
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "empty password comparison",
|
||||
password: "",
|
||||
compareWith: "",
|
||||
expectError: false,
|
||||
},
|
||||
{
|
||||
name: "unicode password match",
|
||||
password: "pässwörd123",
|
||||
compareWith: "pässwörd123",
|
||||
expectError: false,
|
||||
},
|
||||
{
|
||||
name: "unicode password mismatch",
|
||||
password: "pässwörd123",
|
||||
compareWith: "password123",
|
||||
expectError: true,
|
||||
errorAssertion: func(err error) bool {
|
||||
return err == bcrypt.ErrMismatchedHashAndPassword
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "case sensitive",
|
||||
password: "Password123",
|
||||
compareWith: "password123",
|
||||
expectError: true,
|
||||
errorAssertion: func(err error) bool {
|
||||
return err == bcrypt.ErrMismatchedHashAndPassword
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
hashed, err := hasher.Hash(tt.password)
|
||||
if err != nil {
|
||||
t.Fatalf("Hash() error = %v", err)
|
||||
}
|
||||
|
||||
err = hasher.Compare(hashed, tt.compareWith)
|
||||
if tt.expectError {
|
||||
if err == nil {
|
||||
t.Fatal("Compare() expected error but got nil")
|
||||
}
|
||||
if tt.errorAssertion != nil && !tt.errorAssertion(err) {
|
||||
t.Fatalf("Compare() error = %v, but assertion failed", err)
|
||||
}
|
||||
} else {
|
||||
if err != nil {
|
||||
t.Fatalf("Compare() unexpected error = %v", err)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestBcryptHasher_CompareWithInvalidHash(t *testing.T) {
|
||||
hasher := NewBcryptHasher()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
invalidHash string
|
||||
password string
|
||||
expectError bool
|
||||
}{
|
||||
{
|
||||
name: "invalid hash format",
|
||||
invalidHash: "not-a-valid-hash",
|
||||
password: "password123",
|
||||
expectError: true,
|
||||
},
|
||||
{
|
||||
name: "empty hash",
|
||||
invalidHash: "",
|
||||
password: "password123",
|
||||
expectError: true,
|
||||
},
|
||||
{
|
||||
name: "corrupted hash",
|
||||
invalidHash: "$2a$10$invalidhashdata",
|
||||
password: "password123",
|
||||
expectError: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
err := hasher.Compare(tt.invalidHash, tt.password)
|
||||
if !tt.expectError && err != nil {
|
||||
t.Fatalf("Compare() unexpected error = %v", err)
|
||||
}
|
||||
if tt.expectError && err == nil {
|
||||
t.Fatal("Compare() expected error but got nil")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestBcryptHasher_HashGeneratesDifferentHashes(t *testing.T) {
|
||||
hasher := NewBcryptHasher()
|
||||
password := "samePassword123"
|
||||
|
||||
hash1, err := hasher.Hash(password)
|
||||
if err != nil {
|
||||
t.Fatalf("Hash() error = %v", err)
|
||||
}
|
||||
|
||||
hash2, err := hasher.Hash(password)
|
||||
if err != nil {
|
||||
t.Fatalf("Hash() error = %v", err)
|
||||
}
|
||||
|
||||
if hash1 == hash2 {
|
||||
t.Fatal("Hash() generated identical hashes for same password (should use salt)")
|
||||
}
|
||||
|
||||
if err := hasher.Compare(hash1, password); err != nil {
|
||||
t.Fatalf("First hash doesn't match password: %v", err)
|
||||
}
|
||||
|
||||
if err := hasher.Compare(hash2, password); err != nil {
|
||||
t.Fatalf("Second hash doesn't match password: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
package email
|
||||
|
||||
import (
|
||||
"crypto/tls"
|
||||
"fmt"
|
||||
"log"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"apocapoc-api/internal/domain/services"
|
||||
|
||||
"gopkg.in/mail.v2"
|
||||
)
|
||||
|
||||
type SMTPConfig struct {
|
||||
Host string
|
||||
Port int
|
||||
Username string
|
||||
Password string
|
||||
From string
|
||||
SupportEmail string
|
||||
}
|
||||
|
||||
type SMTPService struct {
|
||||
config SMTPConfig
|
||||
}
|
||||
|
||||
func NewSMTPService(config SMTPConfig) *SMTPService {
|
||||
return &SMTPService{
|
||||
config: config,
|
||||
}
|
||||
}
|
||||
|
||||
func (s *SMTPService) Send(message services.EmailMessage) error {
|
||||
m := mail.NewMessage()
|
||||
m.SetHeader("From", s.config.From)
|
||||
m.SetHeader("To", message.To)
|
||||
m.SetHeader("Subject", message.Subject)
|
||||
|
||||
if message.IsHTML {
|
||||
m.SetBody("text/html", message.Body)
|
||||
} else {
|
||||
m.SetBody("text/plain", message.Body)
|
||||
}
|
||||
|
||||
dialer := mail.NewDialer(s.config.Host, s.config.Port, s.config.Username, s.config.Password)
|
||||
dialer.TLSConfig = &tls.Config{
|
||||
ServerName: s.config.Host,
|
||||
}
|
||||
|
||||
// Use SSL from start for port 465, STARTTLS for other ports
|
||||
if s.config.Port == 465 {
|
||||
dialer.SSL = true
|
||||
}
|
||||
|
||||
if err := s.sendWithRetry(dialer, m); err != nil {
|
||||
log.Printf("[EMAIL] status=failed to=%s subject=%q error=%q", message.To, message.Subject, err.Error())
|
||||
return fmt.Errorf("failed to send email: %w", err)
|
||||
}
|
||||
|
||||
log.Printf("[EMAIL] status=sent to=%s subject=%q", message.To, message.Subject)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *SMTPService) sendWithRetry(dialer *mail.Dialer, message *mail.Message) error {
|
||||
maxRetries := 3
|
||||
var lastErr error
|
||||
|
||||
for i := 0; i < maxRetries; i++ {
|
||||
if err := dialer.DialAndSend(message); err == nil {
|
||||
return nil
|
||||
} else {
|
||||
lastErr = err
|
||||
|
||||
if isAuthError(err) {
|
||||
return fmt.Errorf("SMTP authentication failed. Please check your SMTP credentials (username, password, and from address)")
|
||||
}
|
||||
|
||||
if isConfigError(err) {
|
||||
return fmt.Errorf("SMTP configuration error: %w", err)
|
||||
}
|
||||
|
||||
if i < maxRetries-1 {
|
||||
time.Sleep(time.Second * time.Duration(i+1))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return fmt.Errorf("failed to send email after %d attempts: %w", maxRetries, lastErr)
|
||||
}
|
||||
|
||||
func isAuthError(err error) bool {
|
||||
errStr := strings.ToLower(err.Error())
|
||||
return strings.Contains(errStr, "authentication failed") ||
|
||||
strings.Contains(errStr, "535") ||
|
||||
strings.Contains(errStr, "invalid credentials")
|
||||
}
|
||||
|
||||
func isConfigError(err error) bool {
|
||||
errStr := strings.ToLower(err.Error())
|
||||
return strings.Contains(errStr, "connection refused") ||
|
||||
strings.Contains(errStr, "no such host") ||
|
||||
strings.Contains(errStr, "network is unreachable")
|
||||
}
|
||||
|
||||
func (s *SMTPService) GetConfig() SMTPConfig {
|
||||
return s.config
|
||||
}
|
||||
|
||||
func (s *SMTPService) IsEnabled() bool {
|
||||
return true
|
||||
}
|
||||
|
||||
func (s *SMTPService) HealthCheck() error {
|
||||
dialer := mail.NewDialer(s.config.Host, s.config.Port, s.config.Username, s.config.Password)
|
||||
dialer.TLSConfig = &tls.Config{
|
||||
ServerName: s.config.Host,
|
||||
}
|
||||
|
||||
if s.config.Port == 465 {
|
||||
dialer.SSL = true
|
||||
}
|
||||
|
||||
smtpCloser, err := dialer.Dial()
|
||||
if err != nil {
|
||||
if isAuthError(err) {
|
||||
return fmt.Errorf("SMTP authentication failed: %w", err)
|
||||
}
|
||||
if isConfigError(err) {
|
||||
return fmt.Errorf("SMTP connection failed: %w", err)
|
||||
}
|
||||
return fmt.Errorf("SMTP error: %w", err)
|
||||
}
|
||||
defer smtpCloser.Close()
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,299 @@
|
||||
package email
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"apocapoc-api/internal/domain/services"
|
||||
)
|
||||
|
||||
func TestNewSMTPService(t *testing.T) {
|
||||
config := SMTPConfig{
|
||||
Host: "smtp.example.com",
|
||||
Port: 587,
|
||||
Username: "user@example.com",
|
||||
Password: "password",
|
||||
From: "noreply@example.com",
|
||||
SupportEmail: "support@example.com",
|
||||
}
|
||||
|
||||
service := NewSMTPService(config)
|
||||
|
||||
if service == nil {
|
||||
t.Fatal("Expected service to be created")
|
||||
}
|
||||
|
||||
if service.GetConfig().Host != config.Host {
|
||||
t.Errorf("Expected host %s, got %s", config.Host, service.GetConfig().Host)
|
||||
}
|
||||
|
||||
if service.GetConfig().Port != config.Port {
|
||||
t.Errorf("Expected port %d, got %d", config.Port, service.GetConfig().Port)
|
||||
}
|
||||
|
||||
if service.GetConfig().From != config.From {
|
||||
t.Errorf("Expected from %s, got %s", config.From, service.GetConfig().From)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSMTPService_ConfigValidation(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
config SMTPConfig
|
||||
}{
|
||||
{
|
||||
name: "Port 587 (STARTTLS)",
|
||||
config: SMTPConfig{
|
||||
Host: "smtp.example.com",
|
||||
Port: 587,
|
||||
Username: "user@example.com",
|
||||
Password: "password",
|
||||
From: "noreply@example.com",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "Port 465 (SSL)",
|
||||
config: SMTPConfig{
|
||||
Host: "smtp.example.com",
|
||||
Port: 465,
|
||||
Username: "user@example.com",
|
||||
Password: "password",
|
||||
From: "noreply@example.com",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "Port 25 (Plain)",
|
||||
config: SMTPConfig{
|
||||
Host: "smtp.example.com",
|
||||
Port: 25,
|
||||
Username: "user@example.com",
|
||||
Password: "password",
|
||||
From: "noreply@example.com",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
service := NewSMTPService(tt.config)
|
||||
if service == nil {
|
||||
t.Fatal("Expected service to be created")
|
||||
}
|
||||
|
||||
if service.GetConfig().Port != tt.config.Port {
|
||||
t.Errorf("Expected port %d, got %d", tt.config.Port, service.GetConfig().Port)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSMTPService_MessageTypes(t *testing.T) {
|
||||
config := SMTPConfig{
|
||||
Host: "smtp.example.com",
|
||||
Port: 587,
|
||||
Username: "user@example.com",
|
||||
Password: "password",
|
||||
From: "noreply@example.com",
|
||||
SupportEmail: "support@example.com",
|
||||
}
|
||||
|
||||
service := NewSMTPService(config)
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
message services.EmailMessage
|
||||
}{
|
||||
{
|
||||
name: "HTML message",
|
||||
message: services.EmailMessage{
|
||||
To: "recipient@example.com",
|
||||
Subject: "Test Email",
|
||||
Body: "<h1>Test</h1>",
|
||||
IsHTML: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "Plain text message",
|
||||
message: services.EmailMessage{
|
||||
To: "recipient@example.com",
|
||||
Subject: "Test Email",
|
||||
Body: "Plain text body",
|
||||
IsHTML: false,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "Message with special characters",
|
||||
message: services.EmailMessage{
|
||||
To: "recipient@example.com",
|
||||
Subject: "Test Email with émojis 🎉",
|
||||
Body: "<p>Special chars: ñ, á, ü, €</p>",
|
||||
IsHTML: true,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if tt.message.To == "" {
|
||||
t.Error("Expected recipient to be set")
|
||||
}
|
||||
|
||||
if tt.message.Subject == "" {
|
||||
t.Error("Expected subject to be set")
|
||||
}
|
||||
|
||||
if tt.message.Body == "" {
|
||||
t.Error("Expected body to be set")
|
||||
}
|
||||
|
||||
if service == nil {
|
||||
t.Fatal("Service should not be nil")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsAuthError(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
errStr string
|
||||
expected bool
|
||||
}{
|
||||
{
|
||||
name: "Authentication failed error",
|
||||
errStr: "535 Authentication failed",
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "Invalid credentials error",
|
||||
errStr: "Invalid credentials provided",
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "535 error code",
|
||||
errStr: "535 5.7.8 Error",
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "Connection refused (not auth)",
|
||||
errStr: "connection refused",
|
||||
expected: false,
|
||||
},
|
||||
{
|
||||
name: "Generic error (not auth)",
|
||||
errStr: "some other error",
|
||||
expected: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
err := &mockError{msg: tt.errStr}
|
||||
result := isAuthError(err)
|
||||
if result != tt.expected {
|
||||
t.Errorf("Expected %v, got %v for error: %s", tt.expected, result, tt.errStr)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsConfigError(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
errStr string
|
||||
expected bool
|
||||
}{
|
||||
{
|
||||
name: "Connection refused",
|
||||
errStr: "connection refused",
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "No such host",
|
||||
errStr: "no such host smtp.invalid.com",
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "Network unreachable",
|
||||
errStr: "network is unreachable",
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "Authentication error (not config)",
|
||||
errStr: "authentication failed",
|
||||
expected: false,
|
||||
},
|
||||
{
|
||||
name: "Generic error (not config)",
|
||||
errStr: "some other error",
|
||||
expected: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
err := &mockError{msg: tt.errStr}
|
||||
result := isConfigError(err)
|
||||
if result != tt.expected {
|
||||
t.Errorf("Expected %v, got %v for error: %s", tt.expected, result, tt.errStr)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSMTPService_Send_InvalidConfig(t *testing.T) {
|
||||
config := SMTPConfig{
|
||||
Host: "invalid.smtp.server.that.does.not.exist",
|
||||
Port: 587,
|
||||
Username: "user@example.com",
|
||||
Password: "password",
|
||||
From: "noreply@example.com",
|
||||
}
|
||||
|
||||
service := NewSMTPService(config)
|
||||
|
||||
message := services.EmailMessage{
|
||||
To: "test@example.com",
|
||||
Subject: "Test",
|
||||
Body: "Test body",
|
||||
IsHTML: false,
|
||||
}
|
||||
|
||||
err := service.Send(message)
|
||||
if err == nil {
|
||||
t.Error("Expected error when sending to invalid SMTP server")
|
||||
}
|
||||
|
||||
if !strings.Contains(err.Error(), "failed to send email") {
|
||||
t.Errorf("Expected error message to contain 'failed to send email', got: %s", err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
func TestSMTPService_HealthCheck_InvalidConfig(t *testing.T) {
|
||||
config := SMTPConfig{
|
||||
Host: "invalid.smtp.server.that.does.not.exist",
|
||||
Port: 587,
|
||||
Username: "user@example.com",
|
||||
Password: "password",
|
||||
From: "noreply@example.com",
|
||||
}
|
||||
|
||||
service := NewSMTPService(config)
|
||||
|
||||
err := service.HealthCheck()
|
||||
if err == nil {
|
||||
t.Error("Expected error when health checking invalid SMTP server")
|
||||
}
|
||||
|
||||
if !strings.Contains(err.Error(), "SMTP") {
|
||||
t.Errorf("Expected error message to contain 'SMTP', got: %s", err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
type mockError struct {
|
||||
msg string
|
||||
}
|
||||
|
||||
func (e *mockError) Error() string {
|
||||
return e.msg
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
package email
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"html/template"
|
||||
)
|
||||
|
||||
type TemplateData struct {
|
||||
AppName string
|
||||
AppURL string
|
||||
SupportEmail string
|
||||
Data map[string]interface{}
|
||||
}
|
||||
|
||||
type TemplateRenderer struct {
|
||||
appName string
|
||||
appURL string
|
||||
supportEmail string
|
||||
}
|
||||
|
||||
func NewTemplateRenderer(appName, appURL, supportEmail string) *TemplateRenderer {
|
||||
return &TemplateRenderer{
|
||||
appName: appName,
|
||||
appURL: appURL,
|
||||
supportEmail: supportEmail,
|
||||
}
|
||||
}
|
||||
|
||||
func (r *TemplateRenderer) Render(templateContent string, data map[string]interface{}) (string, error) {
|
||||
tmpl, err := template.New("email").Parse(templateContent)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to parse template: %w", err)
|
||||
}
|
||||
|
||||
templateData := TemplateData{
|
||||
AppName: r.appName,
|
||||
AppURL: r.appURL,
|
||||
SupportEmail: r.supportEmail,
|
||||
Data: data,
|
||||
}
|
||||
|
||||
var buf bytes.Buffer
|
||||
if err := tmpl.Execute(&buf, templateData); err != nil {
|
||||
return "", fmt.Errorf("failed to execute template: %w", err)
|
||||
}
|
||||
|
||||
return buf.String(), nil
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
package email
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestNewTemplateRenderer(t *testing.T) {
|
||||
renderer := NewTemplateRenderer("Test App", "https://example.com", "support@example.com")
|
||||
|
||||
if renderer == nil {
|
||||
t.Fatal("Expected renderer to be created")
|
||||
}
|
||||
|
||||
if renderer.appName != "Test App" {
|
||||
t.Errorf("Expected app name 'Test App', got '%s'", renderer.appName)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTemplateRenderer_Render(t *testing.T) {
|
||||
renderer := NewTemplateRenderer("Test App", "https://example.com", "support@example.com")
|
||||
|
||||
template := `Hello {{.Data.Name}}, welcome to {{.AppName}}!`
|
||||
data := map[string]interface{}{
|
||||
"Name": "John",
|
||||
}
|
||||
|
||||
result, err := renderer.Render(template, data)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to render template: %v", err)
|
||||
}
|
||||
|
||||
expected := "Hello John, welcome to Test App!"
|
||||
if result != expected {
|
||||
t.Errorf("Expected '%s', got '%s'", expected, result)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTemplateRenderer_RenderWithAllVariables(t *testing.T) {
|
||||
renderer := NewTemplateRenderer("My App", "https://myapp.com", "help@myapp.com")
|
||||
|
||||
template := `
|
||||
App: {{.AppName}}
|
||||
URL: {{.AppURL}}
|
||||
Support: {{.SupportEmail}}
|
||||
User: {{.Data.User}}
|
||||
`
|
||||
data := map[string]interface{}{
|
||||
"User": "Alice",
|
||||
}
|
||||
|
||||
result, err := renderer.Render(template, data)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to render template: %v", err)
|
||||
}
|
||||
|
||||
if !strings.Contains(result, "My App") {
|
||||
t.Error("Expected result to contain app name")
|
||||
}
|
||||
if !strings.Contains(result, "https://myapp.com") {
|
||||
t.Error("Expected result to contain app URL")
|
||||
}
|
||||
if !strings.Contains(result, "help@myapp.com") {
|
||||
t.Error("Expected result to contain support email")
|
||||
}
|
||||
if !strings.Contains(result, "Alice") {
|
||||
t.Error("Expected result to contain user name")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTemplateRenderer_RenderInvalidTemplate(t *testing.T) {
|
||||
renderer := NewTemplateRenderer("Test App", "https://example.com", "support@example.com")
|
||||
|
||||
template := `{{.Data.Invalid}}`
|
||||
data := map[string]interface{}{}
|
||||
|
||||
result, err := renderer.Render(template, data)
|
||||
if err != nil {
|
||||
t.Fatalf("Template should render even with missing data: %v", err)
|
||||
}
|
||||
|
||||
if result != "<no value>" {
|
||||
t.Logf("Got result: %s", result)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTemplateRenderer_RenderSyntaxError(t *testing.T) {
|
||||
renderer := NewTemplateRenderer("Test App", "https://example.com", "support@example.com")
|
||||
|
||||
template := `{{.Data.Name`
|
||||
data := map[string]interface{}{}
|
||||
|
||||
_, err := renderer.Render(template, data)
|
||||
if err == nil {
|
||||
t.Error("Expected error for invalid template syntax")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<style>
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif;
|
||||
line-height: 1.6;
|
||||
color: #333;
|
||||
max-width: 600px;
|
||||
margin: 0 auto;
|
||||
padding: 20px;
|
||||
background-color: #f4f4f4;
|
||||
}
|
||||
.container {
|
||||
background-color: #ffffff;
|
||||
border-radius: 8px;
|
||||
padding: 30px;
|
||||
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
|
||||
}
|
||||
.header {
|
||||
text-align: center;
|
||||
margin-bottom: 30px;
|
||||
padding-bottom: 20px;
|
||||
border-bottom: 2px solid #f0f0f0;
|
||||
}
|
||||
.header h1 {
|
||||
margin: 0;
|
||||
color: #2c3e50;
|
||||
font-size: 24px;
|
||||
}
|
||||
.content {
|
||||
margin-bottom: 30px;
|
||||
}
|
||||
.button {
|
||||
display: inline-block;
|
||||
padding: 12px 24px;
|
||||
background-color: #3498db;
|
||||
color: #ffffff !important;
|
||||
text-decoration: none;
|
||||
border-radius: 4px;
|
||||
margin: 20px 0;
|
||||
font-weight: 600;
|
||||
}
|
||||
.button:hover {
|
||||
background-color: #2980b9;
|
||||
}
|
||||
.footer {
|
||||
text-align: center;
|
||||
margin-top: 30px;
|
||||
padding-top: 20px;
|
||||
border-top: 2px solid #f0f0f0;
|
||||
font-size: 12px;
|
||||
color: #7f8c8d;
|
||||
}
|
||||
.footer a {
|
||||
color: #3498db;
|
||||
text-decoration: none;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<div class="header">
|
||||
<h1>{{.AppName}}</h1>
|
||||
</div>
|
||||
<div class="content">
|
||||
{{.Content}}
|
||||
</div>
|
||||
<div class="footer">
|
||||
<p>Need help? Contact us at <a href="mailto:{{.SupportEmail}}">{{.SupportEmail}}</a></p>
|
||||
<p>© {{.AppName}}. All rights reserved.</p>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -2,25 +2,32 @@ package http
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"apocapoc-api/internal/application/commands"
|
||||
"apocapoc-api/internal/application/queries"
|
||||
"apocapoc-api/internal/domain/repositories"
|
||||
"apocapoc-api/internal/i18n"
|
||||
"apocapoc-api/internal/infrastructure/auth"
|
||||
"apocapoc-api/internal/shared/errors"
|
||||
appErrors "apocapoc-api/internal/shared/errors"
|
||||
)
|
||||
|
||||
type AuthHandlers struct {
|
||||
registerHandler *commands.RegisterUserHandler
|
||||
loginHandler *queries.LoginUserHandler
|
||||
refreshTokenHandler *queries.RefreshTokenHandler
|
||||
revokeTokenHandler *commands.RevokeTokenHandler
|
||||
revokeAllTokensHandler *commands.RevokeAllTokensHandler
|
||||
jwtService *auth.JWTService
|
||||
refreshTokenRepo repositories.RefreshTokenRepository
|
||||
refreshTokenExpiry time.Duration
|
||||
registerHandler *commands.RegisterUserHandler
|
||||
loginHandler *queries.LoginUserHandler
|
||||
refreshTokenHandler *queries.RefreshTokenHandler
|
||||
revokeTokenHandler *commands.RevokeTokenHandler
|
||||
revokeAllTokensHandler *commands.RevokeAllTokensHandler
|
||||
verifyEmailHandler *commands.VerifyEmailHandler
|
||||
resendVerificationEmailHandler *commands.ResendVerificationEmailHandler
|
||||
requestPasswordResetHandler *commands.RequestPasswordResetHandler
|
||||
resetPasswordHandler *commands.ResetPasswordHandler
|
||||
jwtService *auth.JWTService
|
||||
refreshTokenRepo repositories.RefreshTokenRepository
|
||||
refreshTokenExpiry time.Duration
|
||||
translator *i18n.Translator
|
||||
}
|
||||
|
||||
func NewAuthHandlers(
|
||||
@@ -29,26 +36,35 @@ func NewAuthHandlers(
|
||||
refreshTokenHandler *queries.RefreshTokenHandler,
|
||||
revokeTokenHandler *commands.RevokeTokenHandler,
|
||||
revokeAllTokensHandler *commands.RevokeAllTokensHandler,
|
||||
verifyEmailHandler *commands.VerifyEmailHandler,
|
||||
resendVerificationEmailHandler *commands.ResendVerificationEmailHandler,
|
||||
requestPasswordResetHandler *commands.RequestPasswordResetHandler,
|
||||
resetPasswordHandler *commands.ResetPasswordHandler,
|
||||
jwtService *auth.JWTService,
|
||||
refreshTokenRepo repositories.RefreshTokenRepository,
|
||||
refreshTokenExpiry time.Duration,
|
||||
translator *i18n.Translator,
|
||||
) *AuthHandlers {
|
||||
return &AuthHandlers{
|
||||
registerHandler: registerHandler,
|
||||
loginHandler: loginHandler,
|
||||
refreshTokenHandler: refreshTokenHandler,
|
||||
revokeTokenHandler: revokeTokenHandler,
|
||||
revokeAllTokensHandler: revokeAllTokensHandler,
|
||||
jwtService: jwtService,
|
||||
refreshTokenRepo: refreshTokenRepo,
|
||||
refreshTokenExpiry: refreshTokenExpiry,
|
||||
registerHandler: registerHandler,
|
||||
loginHandler: loginHandler,
|
||||
refreshTokenHandler: refreshTokenHandler,
|
||||
revokeTokenHandler: revokeTokenHandler,
|
||||
revokeAllTokensHandler: revokeAllTokensHandler,
|
||||
verifyEmailHandler: verifyEmailHandler,
|
||||
resendVerificationEmailHandler: resendVerificationEmailHandler,
|
||||
requestPasswordResetHandler: requestPasswordResetHandler,
|
||||
resetPasswordHandler: resetPasswordHandler,
|
||||
jwtService: jwtService,
|
||||
refreshTokenRepo: refreshTokenRepo,
|
||||
refreshTokenExpiry: refreshTokenExpiry,
|
||||
translator: translator,
|
||||
}
|
||||
}
|
||||
|
||||
type RegisterRequest struct {
|
||||
Email string `json:"email"`
|
||||
Password string `json:"password"`
|
||||
Timezone string `json:"timezone"`
|
||||
}
|
||||
|
||||
type LoginRequest struct {
|
||||
@@ -62,6 +78,11 @@ type AuthResponse struct {
|
||||
UserID string `json:"user_id"`
|
||||
}
|
||||
|
||||
type RegisterResponse struct {
|
||||
UserID string `json:"user_id"`
|
||||
Message string `json:"message"`
|
||||
}
|
||||
|
||||
type RefreshRequest struct {
|
||||
RefreshToken string `json:"refresh_token"`
|
||||
}
|
||||
@@ -72,64 +93,58 @@ type LogoutRequest struct {
|
||||
|
||||
// Register godoc
|
||||
// @Summary Register a new user
|
||||
// @Description Create a new user account and receive both access token and refresh token. Store both tokens securely - the refresh token is used to obtain new access tokens when they expire.
|
||||
// @Description Create a new user account. If email verification is enabled, you will receive a verification email. Otherwise, you can login immediately.
|
||||
// @Tags auth
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param request body RegisterRequest true "Registration data (password requires: min 8 chars, uppercase, lowercase, digit, special char)"
|
||||
// @Success 201 {object} AuthResponse "Returns access token, refresh token, and user ID"
|
||||
// @Failure 400 {object} ErrorResponse "Invalid input: email format, password requirements, or timezone"
|
||||
// @Success 201 {object} RegisterResponse "Returns user ID and message about next steps"
|
||||
// @Failure 400 {object} ValidationErrorResponse "Invalid input: email format or password requirements"
|
||||
// @Failure 403 {object} ErrorResponse "Registration is closed"
|
||||
// @Failure 409 {object} ErrorResponse "Email already registered"
|
||||
// @Failure 500 {object} ErrorResponse "Internal server error"
|
||||
// @Router /auth/register [post]
|
||||
func (h *AuthHandlers) Register(w http.ResponseWriter, r *http.Request) {
|
||||
var req RegisterRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
respondError(w, http.StatusBadRequest, "Invalid request body")
|
||||
respondErrorI18n(w, r, h.translator, http.StatusBadRequest, "invalid_request_body")
|
||||
return
|
||||
}
|
||||
|
||||
cmd := commands.RegisterUserCommand{
|
||||
Email: req.Email,
|
||||
Password: req.Password,
|
||||
Timezone: req.Timezone,
|
||||
}
|
||||
|
||||
userID, err := h.registerHandler.Handle(r.Context(), cmd)
|
||||
result, err := h.registerHandler.Handle(r.Context(), cmd)
|
||||
if err != nil {
|
||||
if err == errors.ErrInvalidInput {
|
||||
respondError(w, http.StatusBadRequest, "Invalid email or password (min 8 characters)")
|
||||
if errors.Is(err, appErrors.ErrInvalidInput) {
|
||||
respondValidationErrorI18n(w, r, h.translator, err)
|
||||
return
|
||||
}
|
||||
if err == errors.ErrAlreadyExists {
|
||||
respondError(w, http.StatusConflict, "Email already registered")
|
||||
if err == appErrors.ErrAlreadyExists {
|
||||
respondErrorI18n(w, r, h.translator, http.StatusConflict, "email_already_registered")
|
||||
return
|
||||
}
|
||||
respondError(w, http.StatusInternalServerError, "Failed to register user")
|
||||
if err == appErrors.ErrRegistrationClosed {
|
||||
respondErrorI18n(w, r, h.translator, http.StatusForbidden, "registration_closed")
|
||||
return
|
||||
}
|
||||
respondErrorI18n(w, r, h.translator, http.StatusInternalServerError, "failed_register_user")
|
||||
return
|
||||
}
|
||||
|
||||
token, err := h.jwtService.GenerateToken(userID, req.Email)
|
||||
if err != nil {
|
||||
respondError(w, http.StatusInternalServerError, "Failed to generate token")
|
||||
return
|
||||
lang := i18n.GetLanguageFromContext(r.Context())
|
||||
var message string
|
||||
if result.EmailVerificationRequired {
|
||||
message = h.translator.Success(lang, "registration_with_verification")
|
||||
} else {
|
||||
message = h.translator.Success(lang, "registration_without_verification")
|
||||
}
|
||||
|
||||
refreshToken, err := queries.CreateRefreshToken(userID, h.refreshTokenExpiry)
|
||||
if err != nil {
|
||||
respondError(w, http.StatusInternalServerError, "Failed to create refresh token")
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.refreshTokenRepo.Create(r.Context(), refreshToken); err != nil {
|
||||
respondError(w, http.StatusInternalServerError, "Failed to save refresh token")
|
||||
return
|
||||
}
|
||||
|
||||
respondJSON(w, http.StatusCreated, AuthResponse{
|
||||
Token: token,
|
||||
RefreshToken: refreshToken.Token,
|
||||
UserID: userID,
|
||||
respondJSON(w, http.StatusCreated, RegisterResponse{
|
||||
UserID: result.UserID,
|
||||
Message: message,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -143,12 +158,13 @@ func (h *AuthHandlers) Register(w http.ResponseWriter, r *http.Request) {
|
||||
// @Success 200 {object} AuthResponse "Returns access token, refresh token, and user ID"
|
||||
// @Failure 400 {object} ErrorResponse "Invalid request body"
|
||||
// @Failure 401 {object} ErrorResponse "Invalid email or password"
|
||||
// @Failure 403 {object} ErrorResponse "Email not verified"
|
||||
// @Failure 500 {object} ErrorResponse "Internal server error"
|
||||
// @Router /auth/login [post]
|
||||
func (h *AuthHandlers) Login(w http.ResponseWriter, r *http.Request) {
|
||||
var req LoginRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
respondError(w, http.StatusBadRequest, "Invalid request body")
|
||||
respondErrorI18n(w, r, h.translator, http.StatusBadRequest, "invalid_request_body")
|
||||
return
|
||||
}
|
||||
|
||||
@@ -159,28 +175,32 @@ func (h *AuthHandlers) Login(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
result, err := h.loginHandler.Handle(r.Context(), query)
|
||||
if err != nil {
|
||||
if err == errors.ErrNotFound || err == errors.ErrInvalidInput {
|
||||
respondError(w, http.StatusUnauthorized, "Invalid email or password")
|
||||
if err == appErrors.ErrNotFound || err == appErrors.ErrInvalidInput {
|
||||
respondErrorI18n(w, r, h.translator, http.StatusUnauthorized, "invalid_credentials")
|
||||
return
|
||||
}
|
||||
respondError(w, http.StatusInternalServerError, "Failed to login")
|
||||
if err == appErrors.ErrEmailNotVerified {
|
||||
respondErrorI18n(w, r, h.translator, http.StatusForbidden, "email_not_verified")
|
||||
return
|
||||
}
|
||||
respondErrorI18n(w, r, h.translator, http.StatusInternalServerError, "failed_login")
|
||||
return
|
||||
}
|
||||
|
||||
token, err := h.jwtService.GenerateToken(result.UserID, result.Email)
|
||||
if err != nil {
|
||||
respondError(w, http.StatusInternalServerError, "Failed to generate token")
|
||||
respondErrorI18n(w, r, h.translator, http.StatusInternalServerError, "failed_generate_token")
|
||||
return
|
||||
}
|
||||
|
||||
refreshToken, err := queries.CreateRefreshToken(result.UserID, h.refreshTokenExpiry)
|
||||
if err != nil {
|
||||
respondError(w, http.StatusInternalServerError, "Failed to create refresh token")
|
||||
respondErrorI18n(w, r, h.translator, http.StatusInternalServerError, "failed_create_refresh_token")
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.refreshTokenRepo.Create(r.Context(), refreshToken); err != nil {
|
||||
respondError(w, http.StatusInternalServerError, "Failed to save refresh token")
|
||||
respondErrorI18n(w, r, h.translator, http.StatusInternalServerError, "failed_save_refresh_token")
|
||||
return
|
||||
}
|
||||
|
||||
@@ -206,7 +226,7 @@ func (h *AuthHandlers) Login(w http.ResponseWriter, r *http.Request) {
|
||||
func (h *AuthHandlers) Refresh(w http.ResponseWriter, r *http.Request) {
|
||||
var req RefreshRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
respondError(w, http.StatusBadRequest, "Invalid request body")
|
||||
respondErrorI18n(w, r, h.translator, http.StatusBadRequest, "invalid_request_body")
|
||||
return
|
||||
}
|
||||
|
||||
@@ -216,28 +236,28 @@ func (h *AuthHandlers) Refresh(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
result, err := h.refreshTokenHandler.Handle(r.Context(), query)
|
||||
if err != nil {
|
||||
if err == errors.ErrNotFound || err == errors.ErrInvalidInput {
|
||||
respondError(w, http.StatusUnauthorized, "Invalid or expired refresh token")
|
||||
if err == appErrors.ErrNotFound || err == appErrors.ErrInvalidInput {
|
||||
respondErrorI18n(w, r, h.translator, http.StatusUnauthorized, "invalid_expired_refresh_token")
|
||||
return
|
||||
}
|
||||
respondError(w, http.StatusInternalServerError, "Failed to refresh token")
|
||||
respondErrorI18n(w, r, h.translator, http.StatusInternalServerError, "failed_refresh_token")
|
||||
return
|
||||
}
|
||||
|
||||
token, err := h.jwtService.GenerateToken(result.UserID, result.Email)
|
||||
if err != nil {
|
||||
respondError(w, http.StatusInternalServerError, "Failed to generate token")
|
||||
respondErrorI18n(w, r, h.translator, http.StatusInternalServerError, "failed_generate_token")
|
||||
return
|
||||
}
|
||||
|
||||
newRefreshToken, err := queries.CreateRefreshToken(result.UserID, h.refreshTokenExpiry)
|
||||
if err != nil {
|
||||
respondError(w, http.StatusInternalServerError, "Failed to create refresh token")
|
||||
respondErrorI18n(w, r, h.translator, http.StatusInternalServerError, "failed_create_refresh_token")
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.refreshTokenRepo.Create(r.Context(), newRefreshToken); err != nil {
|
||||
respondError(w, http.StatusInternalServerError, "Failed to save refresh token")
|
||||
respondErrorI18n(w, r, h.translator, http.StatusInternalServerError, "failed_save_refresh_token")
|
||||
return
|
||||
}
|
||||
|
||||
@@ -266,7 +286,7 @@ func (h *AuthHandlers) Refresh(w http.ResponseWriter, r *http.Request) {
|
||||
func (h *AuthHandlers) Logout(w http.ResponseWriter, r *http.Request) {
|
||||
var req LogoutRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
respondError(w, http.StatusBadRequest, "Invalid request body")
|
||||
respondErrorI18n(w, r, h.translator, http.StatusBadRequest, "invalid_request_body")
|
||||
return
|
||||
}
|
||||
|
||||
@@ -276,19 +296,220 @@ func (h *AuthHandlers) Logout(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
err := h.revokeTokenHandler.Handle(r.Context(), cmd)
|
||||
if err != nil {
|
||||
if err == errors.ErrNotFound {
|
||||
respondError(w, http.StatusNotFound, "Refresh token not found")
|
||||
if err == appErrors.ErrNotFound {
|
||||
respondErrorI18n(w, r, h.translator, http.StatusNotFound, "refresh_token_not_found")
|
||||
return
|
||||
}
|
||||
if err == errors.ErrInvalidInput {
|
||||
respondError(w, http.StatusBadRequest, "Invalid refresh token")
|
||||
if err == appErrors.ErrInvalidInput {
|
||||
respondErrorI18n(w, r, h.translator, http.StatusBadRequest, "invalid_refresh_token")
|
||||
return
|
||||
}
|
||||
respondError(w, http.StatusInternalServerError, "Failed to revoke token")
|
||||
respondErrorI18n(w, r, h.translator, http.StatusInternalServerError, "failed_refresh_token")
|
||||
return
|
||||
}
|
||||
|
||||
lang := i18n.GetLanguageFromContext(r.Context())
|
||||
respondJSON(w, http.StatusOK, map[string]string{
|
||||
"message": "Successfully logged out",
|
||||
"message": h.translator.Success(lang, "logged_out"),
|
||||
})
|
||||
}
|
||||
|
||||
type VerifyEmailRequest struct {
|
||||
Token string `json:"token"`
|
||||
}
|
||||
|
||||
type ResendVerificationRequest struct {
|
||||
Email string `json:"email"`
|
||||
}
|
||||
|
||||
// VerifyEmail godoc
|
||||
// @Summary Verify email address
|
||||
// @Description Verify user email address using the token sent via email
|
||||
// @Tags auth
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param request body VerifyEmailRequest true "Verification token"
|
||||
// @Success 200 {object} map[string]string "Email verified successfully"
|
||||
// @Failure 400 {object} ErrorResponse "Invalid or expired token"
|
||||
// @Failure 409 {object} ErrorResponse "Email already verified"
|
||||
// @Failure 500 {object} ErrorResponse "Internal server error"
|
||||
// @Router /auth/verify-email [post]
|
||||
func (h *AuthHandlers) VerifyEmail(w http.ResponseWriter, r *http.Request) {
|
||||
var req VerifyEmailRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
respondErrorI18n(w, r, h.translator, http.StatusBadRequest, "invalid_request_body")
|
||||
return
|
||||
}
|
||||
|
||||
cmd := commands.VerifyEmailCommand{
|
||||
Token: req.Token,
|
||||
}
|
||||
|
||||
err := h.verifyEmailHandler.Handle(r.Context(), cmd)
|
||||
if err != nil {
|
||||
if err == appErrors.ErrInvalidInput {
|
||||
respondErrorI18n(w, r, h.translator, http.StatusBadRequest, "invalid_expired_verification_token")
|
||||
return
|
||||
}
|
||||
if err == appErrors.ErrAlreadyExists {
|
||||
respondErrorI18n(w, r, h.translator, http.StatusConflict, "email_already_verified")
|
||||
return
|
||||
}
|
||||
respondErrorI18n(w, r, h.translator, http.StatusInternalServerError, "failed_verify_email")
|
||||
return
|
||||
}
|
||||
|
||||
lang := i18n.GetLanguageFromContext(r.Context())
|
||||
respondJSON(w, http.StatusOK, map[string]string{
|
||||
"message": h.translator.Success(lang, "email_verified"),
|
||||
})
|
||||
}
|
||||
|
||||
// ResendVerification godoc
|
||||
// @Summary Resend verification email
|
||||
// @Description Resend the email verification link to the user's email address
|
||||
// @Tags auth
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param request body ResendVerificationRequest true "User email"
|
||||
// @Success 200 {object} map[string]string "Verification email sent"
|
||||
// @Failure 400 {object} ErrorResponse "Invalid email"
|
||||
// @Failure 404 {object} ErrorResponse "User not found"
|
||||
// @Failure 409 {object} ErrorResponse "Email already verified"
|
||||
// @Failure 500 {object} ErrorResponse "Internal server error"
|
||||
// @Router /auth/resend-verification [post]
|
||||
func (h *AuthHandlers) ResendVerification(w http.ResponseWriter, r *http.Request) {
|
||||
var req ResendVerificationRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
respondErrorI18n(w, r, h.translator, http.StatusBadRequest, "invalid_request_body")
|
||||
return
|
||||
}
|
||||
|
||||
cmd := commands.ResendVerificationEmailCommand{
|
||||
Email: req.Email,
|
||||
}
|
||||
|
||||
err := h.resendVerificationEmailHandler.Handle(r.Context(), cmd)
|
||||
if err != nil {
|
||||
if err == appErrors.ErrInvalidInput {
|
||||
respondErrorI18n(w, r, h.translator, http.StatusBadRequest, "invalid_email")
|
||||
return
|
||||
}
|
||||
if err == appErrors.ErrNotFound {
|
||||
respondErrorI18n(w, r, h.translator, http.StatusNotFound, "user_not_found")
|
||||
return
|
||||
}
|
||||
if err == appErrors.ErrAlreadyExists {
|
||||
respondErrorI18n(w, r, h.translator, http.StatusConflict, "email_already_verified")
|
||||
return
|
||||
}
|
||||
respondErrorI18n(w, r, h.translator, http.StatusInternalServerError, "failed_send_verification_email")
|
||||
return
|
||||
}
|
||||
|
||||
lang := i18n.GetLanguageFromContext(r.Context())
|
||||
respondJSON(w, http.StatusOK, map[string]string{
|
||||
"message": h.translator.Success(lang, "verification_email_sent"),
|
||||
})
|
||||
}
|
||||
|
||||
type ForgotPasswordRequest struct {
|
||||
Email string `json:"email"`
|
||||
}
|
||||
|
||||
type ResetPasswordRequest struct {
|
||||
Token string `json:"token"`
|
||||
NewPassword string `json:"new_password"`
|
||||
}
|
||||
|
||||
// ForgotPassword godoc
|
||||
// @Summary Request password reset
|
||||
// @Description Request a password reset email with a reset token
|
||||
// @Tags auth
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param request body ForgotPasswordRequest true "User email"
|
||||
// @Success 200 {object} map[string]string "Reset email sent successfully"
|
||||
// @Failure 400 {object} ErrorResponse "Invalid email"
|
||||
// @Failure 403 {object} ErrorResponse "Email not verified"
|
||||
// @Failure 404 {object} ErrorResponse "User not found"
|
||||
// @Failure 500 {object} ErrorResponse "Internal server error"
|
||||
// @Router /auth/forgot-password [post]
|
||||
func (h *AuthHandlers) ForgotPassword(w http.ResponseWriter, r *http.Request) {
|
||||
var req ForgotPasswordRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
respondErrorI18n(w, r, h.translator, http.StatusBadRequest, "invalid_request_body")
|
||||
return
|
||||
}
|
||||
|
||||
cmd := commands.RequestPasswordResetCommand{
|
||||
Email: req.Email,
|
||||
}
|
||||
|
||||
err := h.requestPasswordResetHandler.Handle(r.Context(), cmd)
|
||||
if err != nil {
|
||||
if err == appErrors.ErrInvalidInput {
|
||||
respondErrorI18n(w, r, h.translator, http.StatusBadRequest, "invalid_email")
|
||||
return
|
||||
}
|
||||
if err == appErrors.ErrNotFound {
|
||||
respondErrorI18n(w, r, h.translator, http.StatusNotFound, "user_not_found")
|
||||
return
|
||||
}
|
||||
if err == appErrors.ErrEmailNotVerified {
|
||||
respondErrorI18n(w, r, h.translator, http.StatusForbidden, "email_not_verified_reset")
|
||||
return
|
||||
}
|
||||
respondErrorI18n(w, r, h.translator, http.StatusInternalServerError, "failed_send_reset_email")
|
||||
return
|
||||
}
|
||||
|
||||
lang := i18n.GetLanguageFromContext(r.Context())
|
||||
respondJSON(w, http.StatusOK, map[string]string{
|
||||
"message": h.translator.Success(lang, "password_reset_email_sent"),
|
||||
})
|
||||
}
|
||||
|
||||
// ResetPassword godoc
|
||||
// @Summary Reset password
|
||||
// @Description Reset user password using the reset token from email
|
||||
// @Tags auth
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param request body ResetPasswordRequest true "Reset token and new password"
|
||||
// @Success 200 {object} map[string]string "Password reset successfully"
|
||||
// @Failure 400 {object} ErrorResponse "Invalid token or password requirements not met"
|
||||
// @Failure 404 {object} ErrorResponse "User not found"
|
||||
// @Failure 500 {object} ErrorResponse "Internal server error"
|
||||
// @Router /auth/reset-password [post]
|
||||
func (h *AuthHandlers) ResetPassword(w http.ResponseWriter, r *http.Request) {
|
||||
var req ResetPasswordRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
respondErrorI18n(w, r, h.translator, http.StatusBadRequest, "invalid_request_body")
|
||||
return
|
||||
}
|
||||
|
||||
cmd := commands.ResetPasswordCommand{
|
||||
Token: req.Token,
|
||||
NewPassword: req.NewPassword,
|
||||
}
|
||||
|
||||
err := h.resetPasswordHandler.Handle(r.Context(), cmd)
|
||||
if err != nil {
|
||||
if err == appErrors.ErrInvalidInput {
|
||||
respondErrorI18n(w, r, h.translator, http.StatusBadRequest, "invalid_token_or_password")
|
||||
return
|
||||
}
|
||||
if err == appErrors.ErrNotFound {
|
||||
respondErrorI18n(w, r, h.translator, http.StatusNotFound, "user_not_found")
|
||||
return
|
||||
}
|
||||
respondErrorI18n(w, r, h.translator, http.StatusInternalServerError, "failed_reset_password")
|
||||
return
|
||||
}
|
||||
|
||||
lang := i18n.GetLanguageFromContext(r.Context())
|
||||
respondJSON(w, http.StatusOK, map[string]string{
|
||||
"message": h.translator.Success(lang, "password_reset"),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -13,7 +13,6 @@ func TestAuthFlow(t *testing.T) {
|
||||
reqBody := RegisterRequest{
|
||||
Email: "test@example.com",
|
||||
Password: "Password123!",
|
||||
Timezone: "UTC",
|
||||
}
|
||||
|
||||
rr := makeRequest(t, *ts.Router, "POST", "/api/v1/auth/register", reqBody, "")
|
||||
@@ -22,22 +21,21 @@ func TestAuthFlow(t *testing.T) {
|
||||
t.Errorf("Expected status 201, got %d. Body: %s", rr.Code, rr.Body.String())
|
||||
}
|
||||
|
||||
var resp AuthResponse
|
||||
var resp RegisterResponse
|
||||
decodeResponse(t, rr, &resp)
|
||||
|
||||
if resp.Token == "" {
|
||||
t.Error("Expected token in response")
|
||||
}
|
||||
if resp.UserID == "" {
|
||||
t.Error("Expected user ID in response")
|
||||
}
|
||||
if resp.Message == "" {
|
||||
t.Error("Expected message in response")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Register duplicate email", func(t *testing.T) {
|
||||
reqBody := RegisterRequest{
|
||||
Email: "duplicate@example.com",
|
||||
Password: "Password123!",
|
||||
Timezone: "UTC",
|
||||
}
|
||||
|
||||
makeRequest(t, *ts.Router, "POST", "/api/v1/auth/register", reqBody, "")
|
||||
@@ -53,7 +51,6 @@ func TestAuthFlow(t *testing.T) {
|
||||
reqBody := RegisterRequest{
|
||||
Email: "invalid-email",
|
||||
Password: "Password123!",
|
||||
Timezone: "UTC",
|
||||
}
|
||||
|
||||
rr := makeRequest(t, *ts.Router, "POST", "/api/v1/auth/register", reqBody, "")
|
||||
@@ -67,7 +64,6 @@ func TestAuthFlow(t *testing.T) {
|
||||
reqBody := RegisterRequest{
|
||||
Email: "short@example.com",
|
||||
Password: "123",
|
||||
Timezone: "UTC",
|
||||
}
|
||||
|
||||
rr := makeRequest(t, *ts.Router, "POST", "/api/v1/auth/register", reqBody, "")
|
||||
@@ -81,7 +77,6 @@ func TestAuthFlow(t *testing.T) {
|
||||
registerBody := RegisterRequest{
|
||||
Email: "login@example.com",
|
||||
Password: "Password123!",
|
||||
Timezone: "UTC",
|
||||
}
|
||||
makeRequest(t, *ts.Router, "POST", "/api/v1/auth/register", registerBody, "")
|
||||
|
||||
@@ -108,7 +103,6 @@ func TestAuthFlow(t *testing.T) {
|
||||
registerBody := RegisterRequest{
|
||||
Email: "wrongpass@example.com",
|
||||
Password: "Password123!",
|
||||
Timezone: "UTC",
|
||||
}
|
||||
makeRequest(t, *ts.Router, "POST", "/api/v1/auth/register", registerBody, "")
|
||||
|
||||
@@ -137,3 +131,94 @@ func TestAuthFlow(t *testing.T) {
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestRefreshTokenFlow(t *testing.T) {
|
||||
ts := setupTestServer(t)
|
||||
defer ts.Close()
|
||||
|
||||
t.Run("Complete refresh token flow", func(t *testing.T) {
|
||||
registerBody := RegisterRequest{
|
||||
Email: "refresh@example.com",
|
||||
Password: "Password123!",
|
||||
}
|
||||
makeRequest(t, *ts.Router, "POST", "/api/v1/auth/register", registerBody, "")
|
||||
|
||||
loginBody := LoginRequest{
|
||||
Email: "refresh@example.com",
|
||||
Password: "Password123!",
|
||||
}
|
||||
rr := makeRequest(t, *ts.Router, "POST", "/api/v1/auth/login", loginBody, "")
|
||||
|
||||
var loginResp AuthResponse
|
||||
decodeResponse(t, rr, &loginResp)
|
||||
|
||||
if loginResp.RefreshToken == "" {
|
||||
t.Fatal("Expected refresh token in login response")
|
||||
}
|
||||
|
||||
refreshReq := map[string]string{
|
||||
"refresh_token": loginResp.RefreshToken,
|
||||
}
|
||||
rr = makeRequest(t, *ts.Router, "POST", "/api/v1/auth/refresh", refreshReq, "")
|
||||
|
||||
if rr.Code != http.StatusOK {
|
||||
t.Fatalf("Expected status 200, got %d. Body: %s", rr.Code, rr.Body.String())
|
||||
}
|
||||
|
||||
var refreshResp AuthResponse
|
||||
decodeResponse(t, rr, &refreshResp)
|
||||
|
||||
if refreshResp.Token == "" {
|
||||
t.Error("Expected new access token in refresh response")
|
||||
}
|
||||
if refreshResp.RefreshToken == "" {
|
||||
t.Error("Expected new refresh token in refresh response")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Refresh with invalid token", func(t *testing.T) {
|
||||
refreshReq := map[string]string{
|
||||
"refresh_token": "invalid-token",
|
||||
}
|
||||
rr := makeRequest(t, *ts.Router, "POST", "/api/v1/auth/refresh", refreshReq, "")
|
||||
|
||||
if rr.Code != http.StatusUnauthorized {
|
||||
t.Errorf("Expected status 401, got %d", rr.Code)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Logout invalidates refresh token", func(t *testing.T) {
|
||||
registerBody := RegisterRequest{
|
||||
Email: "logout@example.com",
|
||||
Password: "Password123!",
|
||||
}
|
||||
makeRequest(t, *ts.Router, "POST", "/api/v1/auth/register", registerBody, "")
|
||||
|
||||
loginBody := LoginRequest{
|
||||
Email: "logout@example.com",
|
||||
Password: "Password123!",
|
||||
}
|
||||
rr := makeRequest(t, *ts.Router, "POST", "/api/v1/auth/login", loginBody, "")
|
||||
|
||||
var loginResp AuthResponse
|
||||
decodeResponse(t, rr, &loginResp)
|
||||
|
||||
logoutReq := map[string]string{
|
||||
"refresh_token": loginResp.RefreshToken,
|
||||
}
|
||||
rr = makeRequest(t, *ts.Router, "POST", "/api/v1/auth/logout", logoutReq, loginResp.Token)
|
||||
|
||||
if rr.Code != http.StatusOK {
|
||||
t.Fatalf("Expected status 200 for logout, got %d", rr.Code)
|
||||
}
|
||||
|
||||
refreshReq := map[string]string{
|
||||
"refresh_token": loginResp.RefreshToken,
|
||||
}
|
||||
rr = makeRequest(t, *ts.Router, "POST", "/api/v1/auth/refresh", refreshReq, "")
|
||||
|
||||
if rr.Code != http.StatusUnauthorized {
|
||||
t.Errorf("Expected status 401 when using logged out token, got %d", rr.Code)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"strings"
|
||||
|
||||
"apocapoc-api/internal/infrastructure/auth"
|
||||
"apocapoc-api/internal/infrastructure/logger"
|
||||
)
|
||||
|
||||
type contextKey string
|
||||
@@ -35,6 +36,7 @@ func AuthMiddleware(jwtService *auth.JWTService) func(http.Handler) http.Handler
|
||||
}
|
||||
|
||||
ctx := context.WithValue(r.Context(), UserIDKey, claims.UserID)
|
||||
ctx = logger.AddUserID(ctx, claims.UserID)
|
||||
next.ServeHTTP(w, r.WithContext(ctx))
|
||||
})
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"time"
|
||||
|
||||
"apocapoc-api/internal/domain/value_objects"
|
||||
"apocapoc-api/internal/shared/pagination"
|
||||
)
|
||||
|
||||
type CreateHabitRequest struct {
|
||||
@@ -19,12 +20,13 @@ type CreateHabitRequest struct {
|
||||
}
|
||||
|
||||
type UpdateHabitRequest struct {
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description"`
|
||||
SpecificDays []int `json:"specific_days,omitempty"`
|
||||
SpecificDates []int `json:"specific_dates,omitempty"`
|
||||
CarryOver bool `json:"carry_over"`
|
||||
TargetValue *float64 `json:"target_value,omitempty"`
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description"`
|
||||
Frequency value_objects.Frequency `json:"frequency"`
|
||||
SpecificDays []int `json:"specific_days,omitempty"`
|
||||
SpecificDates []int `json:"specific_dates,omitempty"`
|
||||
CarryOver bool `json:"carry_over"`
|
||||
TargetValue *float64 `json:"target_value,omitempty"`
|
||||
}
|
||||
|
||||
type HabitResponse struct {
|
||||
@@ -48,14 +50,21 @@ type MarkHabitRequest struct {
|
||||
Value *float64 `json:"value,omitempty"`
|
||||
}
|
||||
|
||||
type TodaysHabitEntryResponse struct {
|
||||
ID string `json:"id"`
|
||||
Value *float64 `json:"value,omitempty"`
|
||||
CompletedAt time.Time `json:"completed_at"`
|
||||
}
|
||||
|
||||
type TodaysHabitResponse struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Type value_objects.HabitType `json:"type"`
|
||||
TargetValue *float64 `json:"target_value,omitempty"`
|
||||
IsNegative bool `json:"is_negative"`
|
||||
ScheduledDate time.Time `json:"scheduled_date"`
|
||||
IsCarriedOver bool `json:"is_carried_over"`
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Type value_objects.HabitType `json:"type"`
|
||||
TargetValue *float64 `json:"target_value,omitempty"`
|
||||
IsNegative bool `json:"is_negative"`
|
||||
ScheduledDate time.Time `json:"scheduled_date"`
|
||||
IsCarriedOver bool `json:"is_carried_over"`
|
||||
Entry *TodaysHabitEntryResponse `json:"entry,omitempty"`
|
||||
}
|
||||
|
||||
type UserHabitResponse struct {
|
||||
@@ -69,6 +78,11 @@ type UserHabitResponse struct {
|
||||
IsNegative bool `json:"is_negative"`
|
||||
}
|
||||
|
||||
type GetUserHabitsResponse struct {
|
||||
Data []UserHabitResponse `json:"data"`
|
||||
Pagination *pagination.Response `json:"pagination,omitempty"`
|
||||
}
|
||||
|
||||
type HabitEntryResponse struct {
|
||||
ID string `json:"id"`
|
||||
HabitID string `json:"habit_id"`
|
||||
@@ -87,3 +101,56 @@ type HabitEntriesResponse struct {
|
||||
type ErrorResponse struct {
|
||||
Error string `json:"error"`
|
||||
}
|
||||
|
||||
type ValidationErrorResponse struct {
|
||||
Error string `json:"error"`
|
||||
Field string `json:"field"`
|
||||
}
|
||||
|
||||
type SyncHabitDTO struct {
|
||||
ID string `json:"id"`
|
||||
UserID string `json:"user_id"`
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description"`
|
||||
Type value_objects.HabitType `json:"type"`
|
||||
Frequency value_objects.Frequency `json:"frequency"`
|
||||
SpecificDays []int `json:"specific_days,omitempty"`
|
||||
SpecificDates []int `json:"specific_dates,omitempty"`
|
||||
CarryOver bool `json:"carry_over"`
|
||||
IsNegative bool `json:"is_negative"`
|
||||
TargetValue *float64 `json:"target_value,omitempty"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
ArchivedAt *time.Time `json:"archived_at,omitempty"`
|
||||
}
|
||||
|
||||
type SyncHabitEntryDTO struct {
|
||||
ID string `json:"id"`
|
||||
HabitID string `json:"habit_id"`
|
||||
ScheduledDate time.Time `json:"scheduled_date"`
|
||||
CompletedAt time.Time `json:"completed_at"`
|
||||
Value *float64 `json:"value,omitempty"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
type HabitChangesDTO struct {
|
||||
Created []SyncHabitDTO `json:"created"`
|
||||
Updated []SyncHabitDTO `json:"updated"`
|
||||
Deleted []string `json:"deleted"`
|
||||
}
|
||||
|
||||
type EntryChangesDTO struct {
|
||||
Created []SyncHabitEntryDTO `json:"created"`
|
||||
Updated []SyncHabitEntryDTO `json:"updated"`
|
||||
Deleted []string `json:"deleted"`
|
||||
}
|
||||
|
||||
type SyncChangesResponse struct {
|
||||
Habits HabitChangesDTO `json:"habits"`
|
||||
Entries EntryChangesDTO `json:"entries"`
|
||||
}
|
||||
|
||||
type SyncBatchRequest struct {
|
||||
Habits HabitChangesDTO `json:"habits"`
|
||||
Entries EntryChangesDTO `json:"entries"`
|
||||
}
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
package http
|
||||
|
||||
import (
|
||||
"compress/gzip"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
|
||||
"apocapoc-api/internal/application/queries"
|
||||
"apocapoc-api/internal/i18n"
|
||||
)
|
||||
|
||||
type ExportHandlers struct {
|
||||
exportHandler *queries.ExportUserDataHandler
|
||||
translator *i18n.Translator
|
||||
}
|
||||
|
||||
func NewExportHandlers(
|
||||
exportHandler *queries.ExportUserDataHandler,
|
||||
translator *i18n.Translator,
|
||||
) *ExportHandlers {
|
||||
return &ExportHandlers{
|
||||
exportHandler: exportHandler,
|
||||
translator: translator,
|
||||
}
|
||||
}
|
||||
|
||||
// ExportData godoc
|
||||
// @Summary Export user data
|
||||
// @Description Export all user habits and entries in JSON format with gzip compression. Limited to 1 export per hour.
|
||||
// @Tags export
|
||||
// @Security BearerAuth
|
||||
// @Produce json
|
||||
// @Success 200 {object} queries.ExportUserDataResult "Compressed JSON export"
|
||||
// @Failure 401 {object} ErrorResponse "Unauthorized"
|
||||
// @Failure 429 {object} ErrorResponse "Rate limit exceeded"
|
||||
// @Failure 500 {object} ErrorResponse "Internal server error"
|
||||
// @Router /export [get]
|
||||
func (h *ExportHandlers) ExportData(w http.ResponseWriter, r *http.Request) {
|
||||
userID := r.Context().Value("user_id").(string)
|
||||
|
||||
query := queries.ExportUserDataQuery{
|
||||
UserID: userID,
|
||||
}
|
||||
|
||||
result, err := h.exportHandler.Handle(r.Context(), query)
|
||||
if err != nil {
|
||||
respondErrorI18n(w, r, h.translator, http.StatusInternalServerError, "export_failed")
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.Header().Set("Content-Encoding", "gzip")
|
||||
w.Header().Set("Content-Disposition", "attachment; filename=\"apocapoc-export.json.gz\"")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
|
||||
gzipWriter := gzip.NewWriter(w)
|
||||
defer gzipWriter.Close()
|
||||
|
||||
encoder := json.NewEncoder(gzipWriter)
|
||||
encoder.SetIndent("", " ")
|
||||
|
||||
if err := encoder.Encode(result); err != nil {
|
||||
return
|
||||
}
|
||||
}
|
||||
@@ -10,22 +10,14 @@ func TestHabitEntriesFlow(t *testing.T) {
|
||||
ts := setupTestServer(t)
|
||||
defer ts.Close()
|
||||
|
||||
registerBody := RegisterRequest{
|
||||
Email: "entryuser@example.com",
|
||||
Password: "Password123!",
|
||||
Timezone: "UTC",
|
||||
}
|
||||
rr := makeRequest(t, *ts.Router, "POST", "/api/v1/auth/register", registerBody, "")
|
||||
var authResp AuthResponse
|
||||
decodeResponse(t, rr, &authResp)
|
||||
token := authResp.Token
|
||||
token := registerAndLogin(t, *ts.Router, "entryuser@example.com", "Password123!")
|
||||
|
||||
habitBody := CreateHabitRequest{
|
||||
Name: "Reading",
|
||||
Type: "BOOLEAN",
|
||||
Frequency: "DAILY",
|
||||
}
|
||||
rr = makeRequest(t, *ts.Router, "POST", "/api/v1/habits", habitBody, token)
|
||||
rr := makeRequest(t, *ts.Router, "POST", "/api/v1/habits", habitBody, token)
|
||||
var habitResp map[string]string
|
||||
decodeResponse(t, rr, &habitResp)
|
||||
habitID := habitResp["id"]
|
||||
|
||||
@@ -2,13 +2,18 @@ package http
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
stderrors "errors"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"apocapoc-api/internal/application/commands"
|
||||
"apocapoc-api/internal/application/queries"
|
||||
"apocapoc-api/internal/domain/value_objects"
|
||||
"apocapoc-api/internal/i18n"
|
||||
"apocapoc-api/internal/shared/errors"
|
||||
"apocapoc-api/internal/shared/pagination"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
)
|
||||
@@ -23,6 +28,7 @@ type HabitHandlers struct {
|
||||
archiveHandler *commands.ArchiveHabitHandler
|
||||
markHandler *commands.MarkHabitHandler
|
||||
unmarkHandler *commands.UnmarkHabitHandler
|
||||
translator *i18n.Translator
|
||||
}
|
||||
|
||||
func NewHabitHandlers(
|
||||
@@ -35,6 +41,7 @@ func NewHabitHandlers(
|
||||
archiveHandler *commands.ArchiveHabitHandler,
|
||||
markHandler *commands.MarkHabitHandler,
|
||||
unmarkHandler *commands.UnmarkHabitHandler,
|
||||
translator *i18n.Translator,
|
||||
) *HabitHandlers {
|
||||
return &HabitHandlers{
|
||||
createHandler: createHandler,
|
||||
@@ -46,6 +53,7 @@ func NewHabitHandlers(
|
||||
archiveHandler: archiveHandler,
|
||||
markHandler: markHandler,
|
||||
unmarkHandler: unmarkHandler,
|
||||
translator: translator,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -65,13 +73,13 @@ func NewHabitHandlers(
|
||||
func (h *HabitHandlers) CreateHabit(w http.ResponseWriter, r *http.Request) {
|
||||
var req CreateHabitRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
respondError(w, http.StatusBadRequest, "Invalid request body")
|
||||
respondErrorI18n(w, r, h.translator, http.StatusBadRequest, "invalid_request_body")
|
||||
return
|
||||
}
|
||||
|
||||
userID, ok := GetUserIDFromContext(r.Context())
|
||||
if !ok {
|
||||
respondError(w, http.StatusUnauthorized, "User not authenticated")
|
||||
respondErrorI18n(w, r, h.translator, http.StatusUnauthorized, "user_not_authenticated")
|
||||
return
|
||||
}
|
||||
|
||||
@@ -90,11 +98,11 @@ func (h *HabitHandlers) CreateHabit(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
habitID, err := h.createHandler.Handle(r.Context(), cmd)
|
||||
if err != nil {
|
||||
if err == errors.ErrInvalidInput {
|
||||
respondError(w, http.StatusBadRequest, err.Error())
|
||||
if stderrors.Is(err, errors.ErrInvalidInput) {
|
||||
respondValidationErrorI18n(w, r, h.translator, err)
|
||||
return
|
||||
}
|
||||
respondError(w, http.StatusInternalServerError, "Failed to create habit")
|
||||
respondErrorI18n(w, r, h.translator, http.StatusInternalServerError, "failed_create_habit")
|
||||
return
|
||||
}
|
||||
|
||||
@@ -103,18 +111,24 @@ func (h *HabitHandlers) CreateHabit(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
// GetUserHabits godoc
|
||||
// @Summary Get all user habits
|
||||
// @Description Get all active habits for the authenticated user
|
||||
// @Description Get all active habits for the authenticated user with optional pagination and filters
|
||||
// @Tags habits
|
||||
// @Produce json
|
||||
// @Security BearerAuth
|
||||
// @Success 200 {array} UserHabitResponse
|
||||
// @Param page query int false "Page number (default: 1)"
|
||||
// @Param page_size query int false "Page size (default: 50, max: 100)"
|
||||
// @Param type query string false "Filter by type (BOOLEAN, COUNTER, VALUE)"
|
||||
// @Param frequency query string false "Filter by frequency (DAILY, WEEKLY, MONTHLY)"
|
||||
// @Param archived query boolean false "Include archived habits (default: false)"
|
||||
// @Param search query string false "Search by name or description"
|
||||
// @Success 200 {object} GetUserHabitsResponse
|
||||
// @Failure 401 {object} ErrorResponse
|
||||
// @Failure 500 {object} ErrorResponse
|
||||
// @Router /habits [get]
|
||||
func (h *HabitHandlers) GetUserHabits(w http.ResponseWriter, r *http.Request) {
|
||||
userID, ok := GetUserIDFromContext(r.Context())
|
||||
if !ok {
|
||||
respondError(w, http.StatusUnauthorized, "User not authenticated")
|
||||
respondErrorI18n(w, r, h.translator, http.StatusUnauthorized, "user_not_authenticated")
|
||||
return
|
||||
}
|
||||
|
||||
@@ -122,15 +136,71 @@ func (h *HabitHandlers) GetUserHabits(w http.ResponseWriter, r *http.Request) {
|
||||
UserID: userID,
|
||||
}
|
||||
|
||||
habits, err := h.getUserHabitsHandler.Handle(r.Context(), query)
|
||||
pageStr := r.URL.Query().Get("page")
|
||||
pageSizeStr := r.URL.Query().Get("page_size")
|
||||
|
||||
if pageStr != "" || pageSizeStr != "" {
|
||||
page := 1
|
||||
pageSize := 50
|
||||
|
||||
if pageStr != "" {
|
||||
if p, err := strconv.Atoi(pageStr); err == nil && p > 0 {
|
||||
page = p
|
||||
}
|
||||
}
|
||||
|
||||
if pageSizeStr != "" {
|
||||
if ps, err := strconv.Atoi(pageSizeStr); err == nil && ps > 0 {
|
||||
pageSize = ps
|
||||
}
|
||||
}
|
||||
|
||||
params := pagination.NewParams(page, pageSize)
|
||||
query.PaginationParams = ¶ms
|
||||
}
|
||||
|
||||
typeStr := r.URL.Query().Get("type")
|
||||
frequencyStr := r.URL.Query().Get("frequency")
|
||||
archivedStr := r.URL.Query().Get("archived")
|
||||
searchStr := r.URL.Query().Get("search")
|
||||
|
||||
if typeStr != "" || frequencyStr != "" || archivedStr != "" || searchStr != "" {
|
||||
filterParams := &queries.FilterParams{}
|
||||
|
||||
if typeStr != "" {
|
||||
habitType := value_objects.HabitType(typeStr)
|
||||
if habitType.IsValid() {
|
||||
filterParams.Type = &habitType
|
||||
}
|
||||
}
|
||||
|
||||
if frequencyStr != "" {
|
||||
frequency := value_objects.Frequency(frequencyStr)
|
||||
if frequency.IsValid() {
|
||||
filterParams.Frequency = &frequency
|
||||
}
|
||||
}
|
||||
|
||||
if archivedStr == "true" {
|
||||
filterParams.IncludeArchived = true
|
||||
}
|
||||
|
||||
if searchStr != "" {
|
||||
filterParams.Search = searchStr
|
||||
}
|
||||
|
||||
query.FilterParams = filterParams
|
||||
}
|
||||
|
||||
result, err := h.getUserHabitsHandler.Handle(r.Context(), query)
|
||||
if err != nil {
|
||||
respondError(w, http.StatusInternalServerError, "Failed to get habits")
|
||||
respondErrorI18n(w, r, h.translator, http.StatusInternalServerError, "failed_get_habits")
|
||||
return
|
||||
}
|
||||
|
||||
response := make([]UserHabitResponse, len(habits))
|
||||
for i, habit := range habits {
|
||||
response[i] = UserHabitResponse{
|
||||
habitResponses := make([]UserHabitResponse, len(result.Habits))
|
||||
for i, habit := range result.Habits {
|
||||
habitResponses[i] = UserHabitResponse{
|
||||
ID: habit.ID,
|
||||
Name: habit.Name,
|
||||
Type: habit.Type,
|
||||
@@ -142,7 +212,15 @@ func (h *HabitHandlers) GetUserHabits(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
}
|
||||
|
||||
respondJSON(w, http.StatusOK, response)
|
||||
if result.Pagination != nil {
|
||||
response := GetUserHabitsResponse{
|
||||
Data: habitResponses,
|
||||
Pagination: result.Pagination,
|
||||
}
|
||||
respondJSON(w, http.StatusOK, response)
|
||||
} else {
|
||||
respondJSON(w, http.StatusOK, habitResponses)
|
||||
}
|
||||
}
|
||||
|
||||
// GetHabitByID godoc
|
||||
@@ -163,7 +241,7 @@ func (h *HabitHandlers) GetHabitByID(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
userID, ok := GetUserIDFromContext(r.Context())
|
||||
if !ok {
|
||||
respondError(w, http.StatusUnauthorized, "User not authenticated")
|
||||
respondErrorI18n(w, r, h.translator, http.StatusUnauthorized, "user_not_authenticated")
|
||||
return
|
||||
}
|
||||
|
||||
@@ -175,14 +253,14 @@ func (h *HabitHandlers) GetHabitByID(w http.ResponseWriter, r *http.Request) {
|
||||
habit, err := h.getHabitByIDHandler.Handle(r.Context(), query)
|
||||
if err != nil {
|
||||
if err == errors.ErrNotFound {
|
||||
respondError(w, http.StatusNotFound, "Habit not found")
|
||||
respondErrorI18n(w, r, h.translator, http.StatusNotFound, "habit_not_found")
|
||||
return
|
||||
}
|
||||
if err == errors.ErrUnauthorized {
|
||||
respondError(w, http.StatusForbidden, "Access denied")
|
||||
respondErrorI18n(w, r, h.translator, http.StatusForbidden, "access_denied")
|
||||
return
|
||||
}
|
||||
respondError(w, http.StatusInternalServerError, "Failed to get habit")
|
||||
respondErrorI18n(w, r, h.translator, http.StatusInternalServerError, "failed_get_habit")
|
||||
return
|
||||
}
|
||||
|
||||
@@ -221,13 +299,13 @@ func (h *HabitHandlers) UpdateHabit(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
userID, ok := GetUserIDFromContext(r.Context())
|
||||
if !ok {
|
||||
respondError(w, http.StatusUnauthorized, "User not authenticated")
|
||||
respondErrorI18n(w, r, h.translator, http.StatusUnauthorized, "user_not_authenticated")
|
||||
return
|
||||
}
|
||||
|
||||
var req UpdateHabitRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
respondError(w, http.StatusBadRequest, "Invalid request body")
|
||||
respondErrorI18n(w, r, h.translator, http.StatusBadRequest, "invalid_request_body")
|
||||
return
|
||||
}
|
||||
|
||||
@@ -236,26 +314,27 @@ func (h *HabitHandlers) UpdateHabit(w http.ResponseWriter, r *http.Request) {
|
||||
UserID: userID,
|
||||
Name: req.Name,
|
||||
Description: req.Description,
|
||||
CarryOver: req.CarryOver,
|
||||
TargetValue: req.TargetValue,
|
||||
Frequency: req.Frequency,
|
||||
SpecificDays: req.SpecificDays,
|
||||
SpecificDates: req.SpecificDates,
|
||||
CarryOver: req.CarryOver,
|
||||
TargetValue: req.TargetValue,
|
||||
}
|
||||
|
||||
if err := h.updateHandler.Handle(r.Context(), cmd); err != nil {
|
||||
if err == errors.ErrNotFound {
|
||||
respondError(w, http.StatusNotFound, "Habit not found")
|
||||
respondErrorI18n(w, r, h.translator, http.StatusNotFound, "habit_not_found")
|
||||
return
|
||||
}
|
||||
if err == errors.ErrUnauthorized {
|
||||
respondError(w, http.StatusForbidden, "Access denied")
|
||||
respondErrorI18n(w, r, h.translator, http.StatusForbidden, "access_denied")
|
||||
return
|
||||
}
|
||||
if err == errors.ErrInvalidInput {
|
||||
respondError(w, http.StatusBadRequest, "Invalid input")
|
||||
respondErrorI18n(w, r, h.translator, http.StatusBadRequest, "invalid_input")
|
||||
return
|
||||
}
|
||||
respondError(w, http.StatusInternalServerError, "Failed to update habit")
|
||||
respondErrorI18n(w, r, h.translator, http.StatusInternalServerError, "failed_update_habit")
|
||||
return
|
||||
}
|
||||
|
||||
@@ -280,7 +359,7 @@ func (h *HabitHandlers) ArchiveHabit(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
userID, ok := GetUserIDFromContext(r.Context())
|
||||
if !ok {
|
||||
respondError(w, http.StatusUnauthorized, "User not authenticated")
|
||||
respondErrorI18n(w, r, h.translator, http.StatusUnauthorized, "user_not_authenticated")
|
||||
return
|
||||
}
|
||||
|
||||
@@ -291,14 +370,14 @@ func (h *HabitHandlers) ArchiveHabit(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
if err := h.archiveHandler.Handle(r.Context(), cmd); err != nil {
|
||||
if err == errors.ErrNotFound {
|
||||
respondError(w, http.StatusNotFound, "Habit not found")
|
||||
respondErrorI18n(w, r, h.translator, http.StatusNotFound, "habit_not_found")
|
||||
return
|
||||
}
|
||||
if err == errors.ErrUnauthorized {
|
||||
respondError(w, http.StatusForbidden, "Access denied")
|
||||
respondErrorI18n(w, r, h.translator, http.StatusForbidden, "access_denied")
|
||||
return
|
||||
}
|
||||
respondError(w, http.StatusInternalServerError, "Failed to archive habit")
|
||||
respondErrorI18n(w, r, h.translator, http.StatusInternalServerError, "failed_archive_habit")
|
||||
return
|
||||
}
|
||||
|
||||
@@ -328,7 +407,7 @@ func (h *HabitHandlers) GetHabitEntries(w http.ResponseWriter, r *http.Request)
|
||||
|
||||
userID, ok := GetUserIDFromContext(r.Context())
|
||||
if !ok {
|
||||
respondError(w, http.StatusUnauthorized, "User not authenticated")
|
||||
respondErrorI18n(w, r, h.translator, http.StatusUnauthorized, "user_not_authenticated")
|
||||
return
|
||||
}
|
||||
|
||||
@@ -340,7 +419,7 @@ func (h *HabitHandlers) GetHabitEntries(w http.ResponseWriter, r *http.Request)
|
||||
if fromStr := r.URL.Query().Get("from"); fromStr != "" {
|
||||
from, err := time.Parse("2006-01-02", fromStr)
|
||||
if err != nil {
|
||||
respondError(w, http.StatusBadRequest, "Invalid 'from' date format (use YYYY-MM-DD)")
|
||||
respondErrorI18n(w, r, h.translator, http.StatusBadRequest, "invalid_from_date_format")
|
||||
return
|
||||
}
|
||||
query.From = &from
|
||||
@@ -349,7 +428,7 @@ func (h *HabitHandlers) GetHabitEntries(w http.ResponseWriter, r *http.Request)
|
||||
if toStr := r.URL.Query().Get("to"); toStr != "" {
|
||||
to, err := time.Parse("2006-01-02", toStr)
|
||||
if err != nil {
|
||||
respondError(w, http.StatusBadRequest, "Invalid 'to' date format (use YYYY-MM-DD)")
|
||||
respondErrorI18n(w, r, h.translator, http.StatusBadRequest, "invalid_to_date_format")
|
||||
return
|
||||
}
|
||||
query.To = &to
|
||||
@@ -370,7 +449,7 @@ func (h *HabitHandlers) GetHabitEntries(w http.ResponseWriter, r *http.Request)
|
||||
if pageStr := r.URL.Query().Get("page"); pageStr != "" {
|
||||
page, err := strconv.Atoi(pageStr)
|
||||
if err != nil || page < 1 {
|
||||
respondError(w, http.StatusBadRequest, "Invalid 'page' parameter")
|
||||
respondErrorI18n(w, r, h.translator, http.StatusBadRequest, "invalid_page_parameter")
|
||||
return
|
||||
}
|
||||
query.Page = page
|
||||
@@ -381,7 +460,7 @@ func (h *HabitHandlers) GetHabitEntries(w http.ResponseWriter, r *http.Request)
|
||||
if limitStr := r.URL.Query().Get("limit"); limitStr != "" {
|
||||
limit, err := strconv.Atoi(limitStr)
|
||||
if err != nil || limit < 1 || limit > 100 {
|
||||
respondError(w, http.StatusBadRequest, "Invalid 'limit' parameter (must be 1-100)")
|
||||
respondErrorI18n(w, r, h.translator, http.StatusBadRequest, "invalid_limit_parameter")
|
||||
return
|
||||
}
|
||||
query.Limit = limit
|
||||
@@ -397,14 +476,14 @@ func (h *HabitHandlers) GetHabitEntries(w http.ResponseWriter, r *http.Request)
|
||||
result, err := h.getHabitEntriesHandler.Handle(r.Context(), query)
|
||||
if err != nil {
|
||||
if err == errors.ErrNotFound {
|
||||
respondError(w, http.StatusNotFound, "Habit not found")
|
||||
respondErrorI18n(w, r, h.translator, http.StatusNotFound, "habit_not_found")
|
||||
return
|
||||
}
|
||||
if err == errors.ErrUnauthorized {
|
||||
respondError(w, http.StatusForbidden, "Access denied")
|
||||
respondErrorI18n(w, r, h.translator, http.StatusForbidden, "access_denied")
|
||||
return
|
||||
}
|
||||
respondError(w, http.StatusInternalServerError, "Failed to get habit entries")
|
||||
respondErrorI18n(w, r, h.translator, http.StatusInternalServerError, "failed_get_habit_entries")
|
||||
return
|
||||
}
|
||||
|
||||
@@ -431,37 +510,61 @@ func (h *HabitHandlers) GetHabitEntries(w http.ResponseWriter, r *http.Request)
|
||||
|
||||
// GetTodaysHabits godoc
|
||||
// @Summary Get today's habits
|
||||
// @Description Get all habits scheduled for today for the authenticated user
|
||||
// @Description Get all habits scheduled for today for the authenticated user. Includes the entry for today if it exists. Requires timezone as query parameter (e.g., ?timezone=America/New_York).
|
||||
// @Tags habits
|
||||
// @Produce json
|
||||
// @Security BearerAuth
|
||||
// @Param timezone query string true "IANA timezone (e.g., 'America/New_York', 'Europe/Madrid', 'UTC')"
|
||||
// @Success 200 {array} TodaysHabitResponse
|
||||
// @Failure 400 {object} ErrorResponse "Invalid or missing timezone"
|
||||
// @Failure 401 {object} ErrorResponse
|
||||
// @Failure 500 {object} ErrorResponse
|
||||
// @Router /habits/today [get]
|
||||
func (h *HabitHandlers) GetTodaysHabits(w http.ResponseWriter, r *http.Request) {
|
||||
userID, ok := GetUserIDFromContext(r.Context())
|
||||
if !ok {
|
||||
respondError(w, http.StatusUnauthorized, "User not authenticated")
|
||||
respondErrorI18n(w, r, h.translator, http.StatusUnauthorized, "user_not_authenticated")
|
||||
return
|
||||
}
|
||||
|
||||
timezone := "UTC"
|
||||
timezone := r.URL.Query().Get("timezone")
|
||||
if timezone == "" {
|
||||
respondErrorI18n(w, r, h.translator, http.StatusBadRequest, "timezone_required")
|
||||
return
|
||||
}
|
||||
|
||||
loc, err := time.LoadLocation(timezone)
|
||||
if err != nil {
|
||||
respondErrorI18n(w, r, h.translator, http.StatusBadRequest, "invalid_timezone")
|
||||
return
|
||||
}
|
||||
|
||||
today := time.Now().In(loc)
|
||||
todayDate := time.Date(today.Year(), today.Month(), today.Day(), 0, 0, 0, 0, time.UTC)
|
||||
|
||||
query := queries.GetTodaysHabitsQuery{
|
||||
UserID: userID,
|
||||
Timezone: timezone,
|
||||
Date: time.Now().UTC(),
|
||||
Date: todayDate,
|
||||
}
|
||||
|
||||
habits, err := h.getTodaysHandler.Handle(r.Context(), query)
|
||||
if err != nil {
|
||||
respondError(w, http.StatusInternalServerError, "Failed to get habits")
|
||||
respondErrorI18n(w, r, h.translator, http.StatusInternalServerError, "failed_get_habits")
|
||||
return
|
||||
}
|
||||
|
||||
response := make([]TodaysHabitResponse, len(habits))
|
||||
for i, habit := range habits {
|
||||
var entryResponse *TodaysHabitEntryResponse
|
||||
if habit.Entry != nil {
|
||||
entryResponse = &TodaysHabitEntryResponse{
|
||||
ID: habit.Entry.ID,
|
||||
Value: habit.Entry.Value,
|
||||
CompletedAt: habit.Entry.CompletedAt,
|
||||
}
|
||||
}
|
||||
|
||||
response[i] = TodaysHabitResponse{
|
||||
ID: habit.ID,
|
||||
Name: habit.Name,
|
||||
@@ -470,6 +573,7 @@ func (h *HabitHandlers) GetTodaysHabits(w http.ResponseWriter, r *http.Request)
|
||||
IsNegative: habit.IsNegative,
|
||||
ScheduledDate: habit.ScheduledDate,
|
||||
IsCarriedOver: habit.IsCarriedOver,
|
||||
Entry: entryResponse,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -496,13 +600,13 @@ func (h *HabitHandlers) MarkHabit(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
var req MarkHabitRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
respondError(w, http.StatusBadRequest, "Invalid request body")
|
||||
respondErrorI18n(w, r, h.translator, http.StatusBadRequest, "invalid_request_body")
|
||||
return
|
||||
}
|
||||
|
||||
scheduledDate, err := time.Parse("2006-01-02", req.ScheduledDate)
|
||||
if err != nil {
|
||||
respondError(w, http.StatusBadRequest, "Invalid date format (use YYYY-MM-DD)")
|
||||
respondErrorI18n(w, r, h.translator, http.StatusBadRequest, "invalid_date_format")
|
||||
return
|
||||
}
|
||||
|
||||
@@ -514,14 +618,14 @@ func (h *HabitHandlers) MarkHabit(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
if err := h.markHandler.Handle(r.Context(), cmd); err != nil {
|
||||
if err == errors.ErrAlreadyExists {
|
||||
respondError(w, http.StatusConflict, "Habit already marked for this date")
|
||||
respondErrorI18n(w, r, h.translator, http.StatusConflict, "habit_already_marked")
|
||||
return
|
||||
}
|
||||
if err == errors.ErrNotFound {
|
||||
respondError(w, http.StatusNotFound, "Habit not found")
|
||||
respondErrorI18n(w, r, h.translator, http.StatusNotFound, "habit_not_found")
|
||||
return
|
||||
}
|
||||
respondError(w, http.StatusInternalServerError, "Failed to mark habit")
|
||||
respondErrorI18n(w, r, h.translator, http.StatusInternalServerError, "failed_mark_habit")
|
||||
return
|
||||
}
|
||||
|
||||
@@ -549,13 +653,13 @@ func (h *HabitHandlers) UnmarkHabit(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
userID, ok := GetUserIDFromContext(r.Context())
|
||||
if !ok {
|
||||
respondError(w, http.StatusUnauthorized, "User not authenticated")
|
||||
respondErrorI18n(w, r, h.translator, http.StatusUnauthorized, "user_not_authenticated")
|
||||
return
|
||||
}
|
||||
|
||||
scheduledDate, err := time.Parse("2006-01-02", dateStr)
|
||||
if err != nil {
|
||||
respondError(w, http.StatusBadRequest, "Invalid date format (use YYYY-MM-DD)")
|
||||
respondErrorI18n(w, r, h.translator, http.StatusBadRequest, "invalid_date_format")
|
||||
return
|
||||
}
|
||||
|
||||
@@ -567,14 +671,14 @@ func (h *HabitHandlers) UnmarkHabit(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
if err := h.unmarkHandler.Handle(r.Context(), cmd); err != nil {
|
||||
if err == errors.ErrNotFound {
|
||||
respondError(w, http.StatusNotFound, "Habit entry not found")
|
||||
respondErrorI18n(w, r, h.translator, http.StatusNotFound, "habit_entry_not_found")
|
||||
return
|
||||
}
|
||||
if err == errors.ErrUnauthorized {
|
||||
respondError(w, http.StatusForbidden, "Access denied")
|
||||
respondErrorI18n(w, r, h.translator, http.StatusForbidden, "access_denied")
|
||||
return
|
||||
}
|
||||
respondError(w, http.StatusInternalServerError, "Failed to unmark habit")
|
||||
respondErrorI18n(w, r, h.translator, http.StatusInternalServerError, "failed_unmark_habit")
|
||||
return
|
||||
}
|
||||
|
||||
@@ -590,3 +694,25 @@ func respondJSON(w http.ResponseWriter, status int, data interface{}) {
|
||||
func respondError(w http.ResponseWriter, status int, message string) {
|
||||
respondJSON(w, status, ErrorResponse{Error: message})
|
||||
}
|
||||
|
||||
func respondValidationError(w http.ResponseWriter, err error) {
|
||||
errMsg := err.Error()
|
||||
var field string
|
||||
|
||||
if strings.Contains(errMsg, ": ") {
|
||||
parts := strings.SplitN(errMsg, ": ", 3)
|
||||
if len(parts) >= 3 {
|
||||
field = parts[1]
|
||||
errMsg = parts[2]
|
||||
respondJSON(w, http.StatusBadRequest, ValidationErrorResponse{
|
||||
Error: errMsg,
|
||||
Field: field,
|
||||
})
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
respondJSON(w, http.StatusBadRequest, ErrorResponse{
|
||||
Error: errMsg,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -9,15 +9,7 @@ func TestHabitCRUDFlow(t *testing.T) {
|
||||
ts := setupTestServer(t)
|
||||
defer ts.Close()
|
||||
|
||||
registerBody := RegisterRequest{
|
||||
Email: "habituser@example.com",
|
||||
Password: "Password123!",
|
||||
Timezone: "UTC",
|
||||
}
|
||||
rr := makeRequest(t, *ts.Router, "POST", "/api/v1/auth/register", registerBody, "")
|
||||
var authResp AuthResponse
|
||||
decodeResponse(t, rr, &authResp)
|
||||
token := authResp.Token
|
||||
token := registerAndLogin(t, *ts.Router, "habituser@example.com", "Password123!")
|
||||
|
||||
var habitID string
|
||||
|
||||
@@ -97,6 +89,7 @@ func TestHabitCRUDFlow(t *testing.T) {
|
||||
reqBody := UpdateHabitRequest{
|
||||
Name: "Morning Exercise",
|
||||
Description: "Updated description",
|
||||
Frequency: "DAILY",
|
||||
}
|
||||
|
||||
rr := makeRequest(t, *ts.Router, "PUT", "/api/v1/habits/"+habitID, reqBody, token)
|
||||
@@ -130,31 +123,64 @@ func TestHabitCRUDFlow(t *testing.T) {
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Access other user's habit", func(t *testing.T) {
|
||||
registerBody := RegisterRequest{
|
||||
Email: "otheruser@example.com",
|
||||
Password: "Password123!",
|
||||
Timezone: "UTC",
|
||||
t.Run("Create habit with missing type returns field-level error", func(t *testing.T) {
|
||||
token := registerAndLogin(t, *ts.Router, "validationuser@example.com", "Password123!")
|
||||
|
||||
reqBody := map[string]any{"name": "No Type"}
|
||||
rr := makeRequest(t, *ts.Router, "POST", "/api/v1/habits", reqBody, token)
|
||||
|
||||
if rr.Code != http.StatusBadRequest {
|
||||
t.Fatalf("Expected 400, got %d. Body: %s", rr.Code, rr.Body.String())
|
||||
}
|
||||
rr := makeRequest(t, *ts.Router, "POST", "/api/v1/auth/register", registerBody, "")
|
||||
var authResp AuthResponse
|
||||
decodeResponse(t, rr, &authResp)
|
||||
otherToken := authResp.Token
|
||||
|
||||
var resp ValidationErrorResponse
|
||||
decodeResponse(t, rr, &resp)
|
||||
if resp.Field != "type" {
|
||||
t.Errorf("Expected field 'type', got %q. Body: %s", resp.Field, rr.Body.String())
|
||||
}
|
||||
if resp.Error == "" {
|
||||
t.Errorf("Expected non-empty translated error message. Body: %s", rr.Body.String())
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Create weekly habit without specific_days returns field-level error", func(t *testing.T) {
|
||||
token := registerAndLogin(t, *ts.Router, "weeklyuser@example.com", "Password123!")
|
||||
|
||||
reqBody := CreateHabitRequest{
|
||||
Name: "Cut nails",
|
||||
Type: "BOOLEAN",
|
||||
Frequency: "WEEKLY",
|
||||
}
|
||||
rr := makeRequest(t, *ts.Router, "POST", "/api/v1/habits", reqBody, token)
|
||||
|
||||
if rr.Code != http.StatusBadRequest {
|
||||
t.Fatalf("Expected 400, got %d. Body: %s", rr.Code, rr.Body.String())
|
||||
}
|
||||
|
||||
var resp ValidationErrorResponse
|
||||
decodeResponse(t, rr, &resp)
|
||||
if resp.Field != "specific_days" {
|
||||
t.Errorf("Expected field 'specific_days', got %q. Body: %s", resp.Field, rr.Body.String())
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Access other user's habit", func(t *testing.T) {
|
||||
otherToken := registerAndLogin(t, *ts.Router, "otheruser@example.com", "Password123!")
|
||||
|
||||
reqBody := CreateHabitRequest{
|
||||
Name: "Other User Habit",
|
||||
Type: "BOOLEAN",
|
||||
Frequency: "DAILY",
|
||||
}
|
||||
rr = makeRequest(t, *ts.Router, "POST", "/api/v1/habits", reqBody, otherToken)
|
||||
rr := makeRequest(t, *ts.Router, "POST", "/api/v1/habits", reqBody, otherToken)
|
||||
var createResp map[string]string
|
||||
decodeResponse(t, rr, &createResp)
|
||||
otherHabitID := createResp["id"]
|
||||
|
||||
rr = makeRequest(t, *ts.Router, "GET", "/api/v1/habits/"+otherHabitID, nil, token)
|
||||
rr2 := makeRequest(t, *ts.Router, "GET", "/api/v1/habits/"+otherHabitID, nil, token)
|
||||
|
||||
if rr.Code != http.StatusForbidden {
|
||||
t.Errorf("Expected status 403, got %d", rr.Code)
|
||||
if rr2.Code != http.StatusForbidden {
|
||||
t.Errorf("Expected status 403, got %d", rr2.Code)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package http
|
||||
|
||||
import (
|
||||
"apocapoc-api/internal/domain/services"
|
||||
"database/sql"
|
||||
"net/http"
|
||||
"time"
|
||||
@@ -9,18 +10,21 @@ import (
|
||||
var startTime = time.Now()
|
||||
|
||||
type HealthHandlers struct {
|
||||
db *sql.DB
|
||||
db *sql.DB
|
||||
emailService services.EmailService
|
||||
}
|
||||
|
||||
func NewHealthHandlers(db *sql.DB) *HealthHandlers {
|
||||
func NewHealthHandlers(db *sql.DB, emailService services.EmailService) *HealthHandlers {
|
||||
return &HealthHandlers{
|
||||
db: db,
|
||||
db: db,
|
||||
emailService: emailService,
|
||||
}
|
||||
}
|
||||
|
||||
type HealthResponse struct {
|
||||
Status string `json:"status"`
|
||||
Database string `json:"database"`
|
||||
SMTP string `json:"smtp"`
|
||||
Uptime string `json:"uptime"`
|
||||
}
|
||||
|
||||
@@ -34,6 +38,7 @@ type HealthResponse struct {
|
||||
// @Router /health [get]
|
||||
func (h *HealthHandlers) Health(w http.ResponseWriter, r *http.Request) {
|
||||
dbStatus := "ok"
|
||||
smtpStatus := "ok"
|
||||
overallStatus := "ok"
|
||||
statusCode := http.StatusOK
|
||||
|
||||
@@ -43,12 +48,25 @@ func (h *HealthHandlers) Health(w http.ResponseWriter, r *http.Request) {
|
||||
statusCode = http.StatusServiceUnavailable
|
||||
}
|
||||
|
||||
if h.emailService != nil {
|
||||
if err := h.emailService.HealthCheck(); err != nil {
|
||||
smtpStatus = "error"
|
||||
if overallStatus != "degraded" {
|
||||
overallStatus = "degraded"
|
||||
statusCode = http.StatusServiceUnavailable
|
||||
}
|
||||
}
|
||||
} else {
|
||||
smtpStatus = "disabled"
|
||||
}
|
||||
|
||||
uptime := time.Since(startTime)
|
||||
uptimeStr := formatDuration(uptime)
|
||||
|
||||
response := HealthResponse{
|
||||
Status: overallStatus,
|
||||
Database: dbStatus,
|
||||
SMTP: smtpStatus,
|
||||
Uptime: uptimeStr,
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
package http
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"apocapoc-api/internal/i18n"
|
||||
|
||||
"golang.org/x/text/language"
|
||||
)
|
||||
|
||||
func respondErrorI18n(w http.ResponseWriter, r *http.Request, translator *i18n.Translator, status int, key string) {
|
||||
lang := i18n.GetLanguageFromContext(r.Context())
|
||||
message := translator.Error(lang, key)
|
||||
respondJSON(w, status, ErrorResponse{Error: message})
|
||||
}
|
||||
|
||||
func respondSuccessI18n(w http.ResponseWriter, r *http.Request, translator *i18n.Translator, key string) {
|
||||
lang := i18n.GetLanguageFromContext(r.Context())
|
||||
message := translator.Success(lang, key)
|
||||
respondJSON(w, http.StatusOK, map[string]string{"message": message})
|
||||
}
|
||||
|
||||
func respondValidationErrorI18n(w http.ResponseWriter, r *http.Request, translator *i18n.Translator, err error) {
|
||||
lang := i18n.GetLanguageFromContext(r.Context())
|
||||
errMsg := err.Error()
|
||||
var field string
|
||||
var translatedMsg string
|
||||
|
||||
if strings.Contains(errMsg, ": ") {
|
||||
parts := strings.SplitN(errMsg, ": ", 3)
|
||||
if len(parts) >= 3 {
|
||||
field = parts[1]
|
||||
validationKey := parts[2]
|
||||
translatedMsg = translator.Validation(lang, validationKey)
|
||||
respondJSON(w, http.StatusBadRequest, ValidationErrorResponse{
|
||||
Error: translatedMsg,
|
||||
Field: field,
|
||||
})
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
respondJSON(w, http.StatusBadRequest, ErrorResponse{
|
||||
Error: errMsg,
|
||||
})
|
||||
}
|
||||
|
||||
func getLanguageFromRequest(r *http.Request, translator *i18n.Translator) language.Tag {
|
||||
lang := i18n.GetLanguageFromContext(r.Context())
|
||||
return lang
|
||||
}
|
||||
@@ -11,6 +11,8 @@ import (
|
||||
|
||||
"apocapoc-api/internal/application/commands"
|
||||
"apocapoc-api/internal/application/queries"
|
||||
"apocapoc-api/internal/domain/services"
|
||||
"apocapoc-api/internal/i18n"
|
||||
"apocapoc-api/internal/infrastructure/auth"
|
||||
"apocapoc-api/internal/infrastructure/crypto"
|
||||
"apocapoc-api/internal/infrastructure/persistence/sqlite"
|
||||
@@ -40,18 +42,25 @@ func setupTestServer(t *testing.T) *TestServer {
|
||||
habitRepo := sqlite.NewHabitRepository(db)
|
||||
entryRepo := sqlite.NewHabitEntryRepository(db)
|
||||
refreshTokenRepo := sqlite.NewRefreshTokenRepository(db)
|
||||
passwordResetTokenRepo := sqlite.NewPasswordResetTokenRepository(db)
|
||||
|
||||
registerHandler := commands.NewRegisterUserHandler(userRepo, passwordHasher)
|
||||
noOpEmail := &services.NoOpEmailService{}
|
||||
registerHandler := commands.NewRegisterUserHandler(userRepo, passwordHasher, noOpEmail, "", "open", false)
|
||||
loginHandler := queries.NewLoginUserHandler(userRepo, passwordHasher)
|
||||
refreshTokenHandler := queries.NewRefreshTokenHandler(refreshTokenRepo, userRepo)
|
||||
revokeTokenHandler := commands.NewRevokeTokenHandler(refreshTokenRepo)
|
||||
revokeAllTokensHandler := commands.NewRevokeAllTokensHandler(refreshTokenRepo)
|
||||
verifyEmailHandler := commands.NewVerifyEmailHandler(userRepo, noOpEmail, false)
|
||||
resendVerificationEmailHandler := commands.NewResendVerificationEmailHandler(userRepo, noOpEmail, "")
|
||||
requestPasswordResetHandler := commands.NewRequestPasswordResetHandler(userRepo, passwordResetTokenRepo, noOpEmail, "")
|
||||
resetPasswordHandler := commands.NewResetPasswordHandler(userRepo, passwordResetTokenRepo, passwordHasher)
|
||||
createHandler := commands.NewCreateHabitHandler(habitRepo)
|
||||
getTodaysHandler := queries.NewGetTodaysHabitsHandler(habitRepo, entryRepo)
|
||||
getUserHabitsHandler := queries.NewGetUserHabitsHandler(habitRepo)
|
||||
getHabitByIDHandler := queries.NewGetHabitByIDHandler(habitRepo)
|
||||
getHabitEntriesHandler := queries.NewGetHabitEntriesHandler(habitRepo, entryRepo)
|
||||
getHabitStatsHandler := queries.NewGetHabitStatsHandler(habitRepo, entryRepo)
|
||||
exportUserDataHandler := queries.NewExportUserDataHandler(habitRepo, entryRepo)
|
||||
updateHandler := commands.NewUpdateHabitHandler(habitRepo)
|
||||
archiveHandler := commands.NewArchiveHabitHandler(habitRepo)
|
||||
markHandler := commands.NewMarkHabitHandler(entryRepo, habitRepo)
|
||||
@@ -59,12 +68,22 @@ func setupTestServer(t *testing.T) *TestServer {
|
||||
|
||||
refreshTokenExpiry := 7 * 24 * time.Hour
|
||||
|
||||
authHandlers := NewAuthHandlers(registerHandler, loginHandler, refreshTokenHandler, revokeTokenHandler, revokeAllTokensHandler, jwtService, refreshTokenRepo, refreshTokenExpiry)
|
||||
habitHandlers := NewHabitHandlers(createHandler, getTodaysHandler, getUserHabitsHandler, getHabitByIDHandler, getHabitEntriesHandler, updateHandler, archiveHandler, markHandler, unmarkHandler)
|
||||
statsHandlers := NewStatsHandlers(getHabitStatsHandler)
|
||||
healthHandlers := NewHealthHandlers(db)
|
||||
deleteUserHandler := commands.NewDeleteUserHandler(userRepo)
|
||||
|
||||
router := NewRouter("*", habitHandlers, authHandlers, statsHandlers, healthHandlers, jwtService)
|
||||
translator, _ := i18n.NewTranslator()
|
||||
|
||||
getSyncChangesHandler := queries.NewGetSyncChangesHandler(habitRepo, entryRepo)
|
||||
applySyncBatchHandler := commands.NewApplySyncBatchHandler(habitRepo, entryRepo)
|
||||
|
||||
authHandlers := NewAuthHandlers(registerHandler, loginHandler, refreshTokenHandler, revokeTokenHandler, revokeAllTokensHandler, verifyEmailHandler, resendVerificationEmailHandler, requestPasswordResetHandler, resetPasswordHandler, jwtService, refreshTokenRepo, refreshTokenExpiry, translator)
|
||||
habitHandlers := NewHabitHandlers(createHandler, getTodaysHandler, getUserHabitsHandler, getHabitByIDHandler, getHabitEntriesHandler, updateHandler, archiveHandler, markHandler, unmarkHandler, translator)
|
||||
statsHandlers := NewStatsHandlers(getHabitStatsHandler, translator)
|
||||
healthHandlers := NewHealthHandlers(db, nil)
|
||||
userHandlers := NewUserHandlers(deleteUserHandler, translator)
|
||||
exportHandlers := NewExportHandlers(exportUserDataHandler, translator)
|
||||
syncHandlers := NewSyncHandlers(getSyncChangesHandler, applySyncBatchHandler, translator)
|
||||
|
||||
router := NewRouter("http://localhost:3000", habitHandlers, authHandlers, statsHandlers, healthHandlers, userHandlers, exportHandlers, syncHandlers, jwtService, translator)
|
||||
|
||||
handler := http.Handler(router)
|
||||
return &TestServer{
|
||||
@@ -104,3 +123,21 @@ func decodeResponse(t *testing.T, rr *httptest.ResponseRecorder, target interfac
|
||||
t.Fatalf("Failed to decode response: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func registerAndLogin(t *testing.T, router http.Handler, email, password string) string {
|
||||
registerBody := RegisterRequest{
|
||||
Email: email,
|
||||
Password: password,
|
||||
}
|
||||
makeRequest(t, router, "POST", "/api/v1/auth/register", registerBody, "")
|
||||
|
||||
loginBody := LoginRequest{
|
||||
Email: email,
|
||||
Password: password,
|
||||
}
|
||||
rr := makeRequest(t, router, "POST", "/api/v1/auth/login", loginBody, "")
|
||||
|
||||
var authResp AuthResponse
|
||||
decodeResponse(t, rr, &authResp)
|
||||
return authResp.Token
|
||||
}
|
||||
|
||||
@@ -1,8 +1,12 @@
|
||||
package http
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"apocapoc-api/internal/infrastructure/auth"
|
||||
@@ -36,3 +40,39 @@ func RateLimitByUser(jwtService *auth.JWTService, requestsPerMinute int, duratio
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func RateLimitByEmail(requests int, duration time.Duration) func(http.Handler) http.Handler {
|
||||
limiter := httprate.NewRateLimiter(
|
||||
requests,
|
||||
duration,
|
||||
httprate.WithKeyFuncs(func(r *http.Request) (string, error) {
|
||||
body, err := io.ReadAll(r.Body)
|
||||
if err != nil {
|
||||
return r.RemoteAddr, nil
|
||||
}
|
||||
r.Body = io.NopCloser(bytes.NewBuffer(body))
|
||||
|
||||
var data map[string]interface{}
|
||||
if err := json.Unmarshal(body, &data); err != nil {
|
||||
return r.RemoteAddr, nil
|
||||
}
|
||||
|
||||
if email, ok := data["email"].(string); ok && email != "" {
|
||||
return "email:" + strings.ToLower(email), nil
|
||||
}
|
||||
|
||||
return r.RemoteAddr, nil
|
||||
}),
|
||||
httprate.WithLimitHandler(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusTooManyRequests)
|
||||
w.Write([]byte(`{"error":"Too many password reset attempts. Please try again later."}`))
|
||||
}),
|
||||
)
|
||||
|
||||
return func(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
limiter.Handler(next).ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
package http
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestGlobalRateLimiting(t *testing.T) {
|
||||
ts := setupTestServer(t)
|
||||
defer ts.Close()
|
||||
|
||||
token := registerAndLogin(t, *ts.Router, "ratelimit@example.com", "Password123!")
|
||||
|
||||
t.Run("Request within rate limit succeeds", func(t *testing.T) {
|
||||
for i := 0; i < 10; i++ {
|
||||
rr := makeRequest(t, *ts.Router, "GET", "/api/v1/habits", nil, token)
|
||||
if rr.Code == http.StatusTooManyRequests {
|
||||
t.Errorf("Request %d hit rate limit unexpectedly", i+1)
|
||||
break
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestPasswordResetRateLimiting(t *testing.T) {
|
||||
ts := setupTestServer(t)
|
||||
defer ts.Close()
|
||||
|
||||
registerBody := RegisterRequest{
|
||||
Email: "resetlimit@example.com",
|
||||
Password: "Password123!",
|
||||
}
|
||||
makeRequest(t, *ts.Router, "POST", "/api/v1/auth/register", registerBody, "")
|
||||
|
||||
t.Run("Email-based rate limit for password reset", func(t *testing.T) {
|
||||
resetReq := map[string]string{
|
||||
"email": "resetlimit@example.com",
|
||||
}
|
||||
|
||||
for i := 0; i < 3; i++ {
|
||||
rr := makeRequest(t, *ts.Router, "POST", "/api/v1/auth/forgot-password", resetReq, "")
|
||||
if rr.Code == http.StatusTooManyRequests {
|
||||
t.Fatalf("Request %d hit rate limit too early (limit is 3)", i+1)
|
||||
}
|
||||
}
|
||||
|
||||
rr := makeRequest(t, *ts.Router, "POST", "/api/v1/auth/forgot-password", resetReq, "")
|
||||
if rr.Code != http.StatusTooManyRequests {
|
||||
t.Errorf("Expected status 429 after 4th request, got %d", rr.Code)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Different emails have separate rate limits", func(t *testing.T) {
|
||||
registerBody2 := RegisterRequest{
|
||||
Email: "resetlimit2@example.com",
|
||||
Password: "Password123!",
|
||||
}
|
||||
makeRequest(t, *ts.Router, "POST", "/api/v1/auth/register", registerBody2, "")
|
||||
|
||||
resetReq := map[string]string{
|
||||
"email": "resetlimit2@example.com",
|
||||
}
|
||||
|
||||
rr := makeRequest(t, *ts.Router, "POST", "/api/v1/auth/forgot-password", resetReq, "")
|
||||
if rr.Code == http.StatusTooManyRequests {
|
||||
t.Error("Different email should not be affected by previous email's rate limit")
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -4,7 +4,9 @@ import (
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"apocapoc-api/internal/i18n"
|
||||
"apocapoc-api/internal/infrastructure/auth"
|
||||
"apocapoc-api/internal/infrastructure/logger"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/go-chi/chi/v5/middleware"
|
||||
@@ -15,15 +17,16 @@ import (
|
||||
_ "apocapoc-api/docs"
|
||||
)
|
||||
|
||||
func NewRouter(corsOrigins string, habitHandlers *HabitHandlers, authHandlers *AuthHandlers, statsHandlers *StatsHandlers, healthHandlers *HealthHandlers, jwtService *auth.JWTService) *chi.Mux {
|
||||
func NewRouter(appURL string, habitHandlers *HabitHandlers, authHandlers *AuthHandlers, statsHandlers *StatsHandlers, healthHandlers *HealthHandlers, userHandlers *UserHandlers, exportHandlers *ExportHandlers, syncHandlers *SyncHandlers, jwtService *auth.JWTService, translator *i18n.Translator) *chi.Mux {
|
||||
r := chi.NewRouter()
|
||||
|
||||
r.Use(middleware.Logger)
|
||||
r.Use(logger.Middleware)
|
||||
r.Use(middleware.Recoverer)
|
||||
r.Use(i18n.LanguageMiddleware(translator))
|
||||
r.Use(cors.Handler(cors.Options{
|
||||
AllowedOrigins: []string{corsOrigins},
|
||||
AllowedOrigins: []string{appURL},
|
||||
AllowedMethods: []string{"GET", "POST", "PUT", "DELETE", "OPTIONS"},
|
||||
AllowedHeaders: []string{"Accept", "Authorization", "Content-Type"},
|
||||
AllowedHeaders: []string{"Accept", "Authorization", "Content-Type", "Accept-Language"},
|
||||
AllowCredentials: true,
|
||||
}))
|
||||
|
||||
@@ -42,6 +45,12 @@ func NewRouter(corsOrigins string, habitHandlers *HabitHandlers, authHandlers *A
|
||||
r.Post("/login", authHandlers.Login)
|
||||
r.Post("/refresh", authHandlers.Refresh)
|
||||
r.Post("/logout", authHandlers.Logout)
|
||||
r.Post("/verify-email", authHandlers.VerifyEmail)
|
||||
r.Post("/resend-verification", authHandlers.ResendVerification)
|
||||
|
||||
r.With(RateLimitByEmail(3, 1*time.Hour)).Post("/forgot-password", authHandlers.ForgotPassword)
|
||||
|
||||
r.Post("/reset-password", authHandlers.ResetPassword)
|
||||
})
|
||||
|
||||
r.Route("/api/v1/habits", func(r chi.Router) {
|
||||
@@ -65,5 +74,24 @@ func NewRouter(corsOrigins string, habitHandlers *HabitHandlers, authHandlers *A
|
||||
r.Get("/habits/{id}", statsHandlers.GetHabitStats)
|
||||
})
|
||||
|
||||
r.Route("/api/v1/users", func(r chi.Router) {
|
||||
r.Use(AuthMiddleware(jwtService))
|
||||
r.Use(RateLimitByUser(jwtService, 100, 1*time.Minute))
|
||||
r.Delete("/me", userHandlers.DeleteAccount)
|
||||
})
|
||||
|
||||
r.Route("/api/v1/export", func(r chi.Router) {
|
||||
r.Use(AuthMiddleware(jwtService))
|
||||
r.Use(RateLimitByUser(jwtService, 1, 1*time.Hour))
|
||||
r.Get("/", exportHandlers.ExportData)
|
||||
})
|
||||
|
||||
r.Route("/api/v1/sync", func(r chi.Router) {
|
||||
r.Use(AuthMiddleware(jwtService))
|
||||
r.Use(RateLimitByUser(jwtService, 100, 1*time.Minute))
|
||||
r.Get("/changes", syncHandlers.GetSyncChanges)
|
||||
r.Post("/batch", syncHandlers.ApplySyncBatch)
|
||||
})
|
||||
|
||||
return r
|
||||
}
|
||||
|
||||
@@ -6,24 +6,28 @@ import (
|
||||
"apocapoc-api/internal/application/queries"
|
||||
"apocapoc-api/internal/shared/errors"
|
||||
|
||||
"apocapoc-api/internal/i18n"
|
||||
"github.com/go-chi/chi/v5"
|
||||
)
|
||||
|
||||
type StatsHandlers struct {
|
||||
getHabitStatsHandler *queries.GetHabitStatsHandler
|
||||
translator *i18n.Translator
|
||||
}
|
||||
|
||||
func NewStatsHandlers(
|
||||
getHabitStatsHandler *queries.GetHabitStatsHandler,
|
||||
translator *i18n.Translator,
|
||||
) *StatsHandlers {
|
||||
return &StatsHandlers{
|
||||
getHabitStatsHandler: getHabitStatsHandler,
|
||||
translator: translator,
|
||||
}
|
||||
}
|
||||
|
||||
// GetHabitStats godoc
|
||||
// @Summary Get habit statistics
|
||||
// @Description Get statistics for a specific habit including streaks and completion rates
|
||||
// @Description Get statistics for a specific habit including streaks and completions
|
||||
// @Tags stats
|
||||
// @Produce json
|
||||
// @Security BearerAuth
|
||||
@@ -39,7 +43,7 @@ func (h *StatsHandlers) GetHabitStats(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
userID, ok := GetUserIDFromContext(r.Context())
|
||||
if !ok {
|
||||
respondError(w, http.StatusUnauthorized, "User not authenticated")
|
||||
respondErrorI18n(w, r, h.translator, http.StatusUnauthorized, "user_not_authenticated")
|
||||
return
|
||||
}
|
||||
|
||||
@@ -51,14 +55,14 @@ func (h *StatsHandlers) GetHabitStats(w http.ResponseWriter, r *http.Request) {
|
||||
stats, err := h.getHabitStatsHandler.Handle(r.Context(), query)
|
||||
if err != nil {
|
||||
if err == errors.ErrNotFound {
|
||||
respondError(w, http.StatusNotFound, "Habit not found")
|
||||
respondErrorI18n(w, r, h.translator, http.StatusNotFound, "habit_not_found")
|
||||
return
|
||||
}
|
||||
if err == errors.ErrUnauthorized {
|
||||
respondError(w, http.StatusForbidden, "Access denied")
|
||||
respondErrorI18n(w, r, h.translator, http.StatusForbidden, "access_denied")
|
||||
return
|
||||
}
|
||||
respondError(w, http.StatusInternalServerError, "Failed to get habit stats")
|
||||
respondErrorI18n(w, r, h.translator, http.StatusInternalServerError, "failed_get_stats")
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,160 @@
|
||||
package http
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"apocapoc-api/internal/application/queries"
|
||||
)
|
||||
|
||||
func TestHabitStatsFlow(t *testing.T) {
|
||||
ts := setupTestServer(t)
|
||||
defer ts.Close()
|
||||
|
||||
token := registerAndLogin(t, *ts.Router, "statsuser@example.com", "Password123!")
|
||||
|
||||
habitBody := CreateHabitRequest{
|
||||
Name: "Meditation",
|
||||
Type: "BOOLEAN",
|
||||
Frequency: "DAILY",
|
||||
}
|
||||
rr := makeRequest(t, *ts.Router, "POST", "/api/v1/habits", habitBody, token)
|
||||
var habitResp map[string]string
|
||||
decodeResponse(t, rr, &habitResp)
|
||||
habitID := habitResp["id"]
|
||||
|
||||
t.Run("Stats for new habit should be zero", func(t *testing.T) {
|
||||
rr := makeRequest(t, *ts.Router, "GET", "/api/v1/stats/habits/"+habitID, nil, token)
|
||||
|
||||
if rr.Code != http.StatusOK {
|
||||
t.Fatalf("Expected status 200, got %d. Body: %s", rr.Code, rr.Body.String())
|
||||
}
|
||||
|
||||
var stats queries.HabitStatsDTO
|
||||
decodeResponse(t, rr, &stats)
|
||||
|
||||
if stats.TotalCompletions != 0 {
|
||||
t.Errorf("Expected 0 total completions, got %d", stats.TotalCompletions)
|
||||
}
|
||||
if stats.CurrentStreak != 0 {
|
||||
t.Errorf("Expected 0 current streak, got %d", stats.CurrentStreak)
|
||||
}
|
||||
if stats.LongestStreak != 0 {
|
||||
t.Errorf("Expected 0 longest streak, got %d", stats.LongestStreak)
|
||||
}
|
||||
})
|
||||
|
||||
today := time.Now().UTC().Format("2006-01-02")
|
||||
|
||||
t.Run("Stats after marking habit today", func(t *testing.T) {
|
||||
markReq := MarkHabitRequest{
|
||||
ScheduledDate: today,
|
||||
}
|
||||
rr := makeRequest(t, *ts.Router, "POST", "/api/v1/habits/"+habitID+"/mark", markReq, token)
|
||||
if rr.Code != http.StatusOK {
|
||||
t.Fatalf("Failed to mark habit: %d - %s", rr.Code, rr.Body.String())
|
||||
}
|
||||
|
||||
rr = makeRequest(t, *ts.Router, "GET", "/api/v1/stats/habits/"+habitID, nil, token)
|
||||
|
||||
if rr.Code != http.StatusOK {
|
||||
t.Fatalf("Expected status 200, got %d", rr.Code)
|
||||
}
|
||||
|
||||
var stats queries.HabitStatsDTO
|
||||
decodeResponse(t, rr, &stats)
|
||||
|
||||
if stats.TotalCompletions != 1 {
|
||||
t.Errorf("Expected 1 total completion, got %d", stats.TotalCompletions)
|
||||
}
|
||||
if stats.CurrentStreak != 1 {
|
||||
t.Errorf("Expected current streak of 1 (today completed counts), got %d", stats.CurrentStreak)
|
||||
}
|
||||
if stats.LongestStreak != 1 {
|
||||
t.Errorf("Expected longest streak of 1, got %d", stats.LongestStreak)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Stats after unmarking habit", func(t *testing.T) {
|
||||
rr := makeRequest(t, *ts.Router, "DELETE", "/api/v1/habits/"+habitID+"/entries/"+today, nil, token)
|
||||
if rr.Code != http.StatusOK {
|
||||
t.Fatalf("Failed to unmark habit: %d", rr.Code)
|
||||
}
|
||||
|
||||
rr = makeRequest(t, *ts.Router, "GET", "/api/v1/stats/habits/"+habitID, nil, token)
|
||||
|
||||
var stats queries.HabitStatsDTO
|
||||
decodeResponse(t, rr, &stats)
|
||||
|
||||
if stats.TotalCompletions != 0 {
|
||||
t.Errorf("Expected 0 total completions after unmark, got %d", stats.TotalCompletions)
|
||||
}
|
||||
if stats.CurrentStreak != 0 {
|
||||
t.Errorf("Expected 0 current streak after unmark, got %d", stats.CurrentStreak)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestHabitUpdateAffectsStats(t *testing.T) {
|
||||
ts := setupTestServer(t)
|
||||
defer ts.Close()
|
||||
|
||||
token := registerAndLogin(t, *ts.Router, "updatestats@example.com", "Password123!")
|
||||
|
||||
habitBody := CreateHabitRequest{
|
||||
Name: "Running",
|
||||
Type: "BOOLEAN",
|
||||
Frequency: "DAILY",
|
||||
}
|
||||
rr := makeRequest(t, *ts.Router, "POST", "/api/v1/habits", habitBody, token)
|
||||
var habitResp map[string]string
|
||||
decodeResponse(t, rr, &habitResp)
|
||||
habitID := habitResp["id"]
|
||||
|
||||
today := time.Now().UTC().Format("2006-01-02")
|
||||
markReq := MarkHabitRequest{
|
||||
ScheduledDate: today,
|
||||
}
|
||||
makeRequest(t, *ts.Router, "POST", "/api/v1/habits/"+habitID+"/mark", markReq, token)
|
||||
|
||||
t.Run("Stats remain after updating habit name", func(t *testing.T) {
|
||||
updateReq := UpdateHabitRequest{
|
||||
Name: "Morning Running",
|
||||
Frequency: "DAILY",
|
||||
}
|
||||
rr := makeRequest(t, *ts.Router, "PUT", "/api/v1/habits/"+habitID, updateReq, token)
|
||||
if rr.Code != http.StatusOK {
|
||||
t.Fatalf("Failed to update habit: %d", rr.Code)
|
||||
}
|
||||
|
||||
rr = makeRequest(t, *ts.Router, "GET", "/api/v1/stats/habits/"+habitID, nil, token)
|
||||
|
||||
var stats queries.HabitStatsDTO
|
||||
decodeResponse(t, rr, &stats)
|
||||
|
||||
if stats.TotalCompletions != 1 {
|
||||
t.Errorf("Expected stats to persist after update, got %d completions", stats.TotalCompletions)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Stats remain available after archiving habit", func(t *testing.T) {
|
||||
rr := makeRequest(t, *ts.Router, "DELETE", "/api/v1/habits/"+habitID, nil, token)
|
||||
if rr.Code != http.StatusOK {
|
||||
t.Fatalf("Failed to archive habit: %d", rr.Code)
|
||||
}
|
||||
|
||||
rr = makeRequest(t, *ts.Router, "GET", "/api/v1/stats/habits/"+habitID, nil, token)
|
||||
|
||||
if rr.Code != http.StatusOK {
|
||||
t.Errorf("Expected stats to remain available for archived habit, got %d", rr.Code)
|
||||
}
|
||||
|
||||
var stats queries.HabitStatsDTO
|
||||
decodeResponse(t, rr, &stats)
|
||||
|
||||
if stats.TotalCompletions != 1 {
|
||||
t.Errorf("Expected stats to persist after archiving, got %d completions", stats.TotalCompletions)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,231 @@
|
||||
package http
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"apocapoc-api/internal/application/commands"
|
||||
"apocapoc-api/internal/application/queries"
|
||||
"apocapoc-api/internal/domain/entities"
|
||||
"apocapoc-api/internal/i18n"
|
||||
"apocapoc-api/internal/shared/errors"
|
||||
)
|
||||
|
||||
type SyncHandlers struct {
|
||||
getSyncChangesHandler *queries.GetSyncChangesHandler
|
||||
applySyncBatchHandler *commands.ApplySyncBatchHandler
|
||||
translator *i18n.Translator
|
||||
}
|
||||
|
||||
func NewSyncHandlers(
|
||||
getSyncChangesHandler *queries.GetSyncChangesHandler,
|
||||
applySyncBatchHandler *commands.ApplySyncBatchHandler,
|
||||
translator *i18n.Translator,
|
||||
) *SyncHandlers {
|
||||
return &SyncHandlers{
|
||||
getSyncChangesHandler: getSyncChangesHandler,
|
||||
applySyncBatchHandler: applySyncBatchHandler,
|
||||
translator: translator,
|
||||
}
|
||||
}
|
||||
|
||||
// GetSyncChanges godoc
|
||||
// @Summary Get sync changes
|
||||
// @Description Get all changes (habits and entries) since a given timestamp for offline sync
|
||||
// @Tags sync
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Security BearerAuth
|
||||
// @Param since query string true "ISO 8601 timestamp (e.g., 2025-01-01T00:00:00Z)"
|
||||
// @Success 200 {object} SyncChangesResponse
|
||||
// @Failure 400 {object} ErrorResponse
|
||||
// @Failure 401 {object} ErrorResponse
|
||||
// @Failure 500 {object} ErrorResponse
|
||||
// @Router /sync/changes [get]
|
||||
func (h *SyncHandlers) GetSyncChanges(w http.ResponseWriter, r *http.Request) {
|
||||
userID, ok := GetUserIDFromContext(r.Context())
|
||||
if !ok {
|
||||
respondErrorI18n(w, r, h.translator, http.StatusUnauthorized, "user_not_authenticated")
|
||||
return
|
||||
}
|
||||
|
||||
sinceStr := r.URL.Query().Get("since")
|
||||
if sinceStr == "" {
|
||||
respondErrorI18n(w, r, h.translator, http.StatusBadRequest, "missing_since_parameter")
|
||||
return
|
||||
}
|
||||
|
||||
since, err := time.Parse(time.RFC3339, sinceStr)
|
||||
if err != nil {
|
||||
respondErrorI18n(w, r, h.translator, http.StatusBadRequest, "invalid_since_format")
|
||||
return
|
||||
}
|
||||
|
||||
query := queries.GetSyncChangesQuery{
|
||||
UserID: userID,
|
||||
Since: since,
|
||||
}
|
||||
|
||||
result, err := h.getSyncChangesHandler.Handle(r.Context(), query)
|
||||
if err != nil {
|
||||
if err == errors.ErrInvalidInput {
|
||||
respondErrorI18n(w, r, h.translator, http.StatusBadRequest, "invalid_input")
|
||||
return
|
||||
}
|
||||
respondErrorI18n(w, r, h.translator, http.StatusInternalServerError, "internal_server_error")
|
||||
return
|
||||
}
|
||||
|
||||
response := SyncChangesResponse{
|
||||
Habits: HabitChangesDTO{
|
||||
Created: toHabitDTOs(result.Habits.Created),
|
||||
Updated: toHabitDTOs(result.Habits.Updated),
|
||||
Deleted: result.Habits.Deleted,
|
||||
},
|
||||
Entries: EntryChangesDTO{
|
||||
Created: toHabitEntryDTOs(result.Entries.Created),
|
||||
Updated: toHabitEntryDTOs(result.Entries.Updated),
|
||||
Deleted: result.Entries.Deleted,
|
||||
},
|
||||
}
|
||||
|
||||
respondJSON(w, http.StatusOK, response)
|
||||
}
|
||||
|
||||
// ApplySyncBatch godoc
|
||||
// @Summary Apply sync batch
|
||||
// @Description Apply a batch of changes from the client for offline sync (Last-Write-Wins)
|
||||
// @Tags sync
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Security BearerAuth
|
||||
// @Param request body SyncBatchRequest true "Sync batch data"
|
||||
// @Success 200 {object} map[string]string
|
||||
// @Failure 400 {object} ErrorResponse
|
||||
// @Failure 401 {object} ErrorResponse
|
||||
// @Failure 500 {object} ErrorResponse
|
||||
// @Router /sync/batch [post]
|
||||
func (h *SyncHandlers) ApplySyncBatch(w http.ResponseWriter, r *http.Request) {
|
||||
userID, ok := GetUserIDFromContext(r.Context())
|
||||
if !ok {
|
||||
respondErrorI18n(w, r, h.translator, http.StatusUnauthorized, "user_not_authenticated")
|
||||
return
|
||||
}
|
||||
|
||||
var req SyncBatchRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
respondErrorI18n(w, r, h.translator, http.StatusBadRequest, "invalid_request_body")
|
||||
return
|
||||
}
|
||||
|
||||
habitChanges := commands.HabitBatchChanges{
|
||||
Created: fromHabitDTOs(req.Habits.Created),
|
||||
Updated: fromHabitDTOs(req.Habits.Updated),
|
||||
Deleted: req.Habits.Deleted,
|
||||
}
|
||||
|
||||
entryChanges := commands.EntryBatchChanges{
|
||||
Created: fromHabitEntryDTOs(req.Entries.Created),
|
||||
Updated: fromHabitEntryDTOs(req.Entries.Updated),
|
||||
Deleted: req.Entries.Deleted,
|
||||
}
|
||||
|
||||
cmd := commands.ApplySyncBatchCommand{
|
||||
UserID: userID,
|
||||
Habits: habitChanges,
|
||||
Entries: entryChanges,
|
||||
}
|
||||
|
||||
err := h.applySyncBatchHandler.Handle(r.Context(), cmd)
|
||||
if err != nil {
|
||||
if err == errors.ErrInvalidInput {
|
||||
respondErrorI18n(w, r, h.translator, http.StatusBadRequest, "invalid_input")
|
||||
return
|
||||
}
|
||||
if err == errors.ErrUnauthorized {
|
||||
respondErrorI18n(w, r, h.translator, http.StatusForbidden, "forbidden")
|
||||
return
|
||||
}
|
||||
respondErrorI18n(w, r, h.translator, http.StatusInternalServerError, "internal_server_error")
|
||||
return
|
||||
}
|
||||
|
||||
respondJSON(w, http.StatusOK, map[string]string{"message": "sync_batch_applied"})
|
||||
}
|
||||
|
||||
func toHabitDTOs(habits []*entities.Habit) []SyncHabitDTO {
|
||||
dtos := make([]SyncHabitDTO, len(habits))
|
||||
for i, h := range habits {
|
||||
dtos[i] = SyncHabitDTO{
|
||||
ID: h.ID,
|
||||
UserID: h.UserID,
|
||||
Name: h.Name,
|
||||
Description: h.Description,
|
||||
Type: h.Type,
|
||||
Frequency: h.Frequency,
|
||||
SpecificDays: h.SpecificDays,
|
||||
SpecificDates: h.SpecificDates,
|
||||
CarryOver: h.CarryOver,
|
||||
IsNegative: h.IsNegative,
|
||||
TargetValue: h.TargetValue,
|
||||
CreatedAt: h.CreatedAt,
|
||||
UpdatedAt: h.UpdatedAt,
|
||||
ArchivedAt: h.ArchivedAt,
|
||||
}
|
||||
}
|
||||
return dtos
|
||||
}
|
||||
|
||||
func fromHabitDTOs(dtos []SyncHabitDTO) []*entities.Habit {
|
||||
habits := make([]*entities.Habit, len(dtos))
|
||||
for i, dto := range dtos {
|
||||
habits[i] = &entities.Habit{
|
||||
ID: dto.ID,
|
||||
UserID: dto.UserID,
|
||||
Name: dto.Name,
|
||||
Description: dto.Description,
|
||||
Type: dto.Type,
|
||||
Frequency: dto.Frequency,
|
||||
SpecificDays: dto.SpecificDays,
|
||||
SpecificDates: dto.SpecificDates,
|
||||
CarryOver: dto.CarryOver,
|
||||
IsNegative: dto.IsNegative,
|
||||
TargetValue: dto.TargetValue,
|
||||
CreatedAt: dto.CreatedAt,
|
||||
UpdatedAt: dto.UpdatedAt,
|
||||
ArchivedAt: dto.ArchivedAt,
|
||||
}
|
||||
}
|
||||
return habits
|
||||
}
|
||||
|
||||
func toHabitEntryDTOs(entries []*entities.HabitEntry) []SyncHabitEntryDTO {
|
||||
dtos := make([]SyncHabitEntryDTO, len(entries))
|
||||
for i, e := range entries {
|
||||
dtos[i] = SyncHabitEntryDTO{
|
||||
ID: e.ID,
|
||||
HabitID: e.HabitID,
|
||||
ScheduledDate: e.ScheduledDate,
|
||||
CompletedAt: e.CompletedAt,
|
||||
Value: e.Value,
|
||||
UpdatedAt: e.UpdatedAt,
|
||||
}
|
||||
}
|
||||
return dtos
|
||||
}
|
||||
|
||||
func fromHabitEntryDTOs(dtos []SyncHabitEntryDTO) []*entities.HabitEntry {
|
||||
entries := make([]*entities.HabitEntry, len(dtos))
|
||||
for i, dto := range dtos {
|
||||
entries[i] = &entities.HabitEntry{
|
||||
ID: dto.ID,
|
||||
HabitID: dto.HabitID,
|
||||
ScheduledDate: dto.ScheduledDate,
|
||||
CompletedAt: dto.CompletedAt,
|
||||
Value: dto.Value,
|
||||
UpdatedAt: dto.UpdatedAt,
|
||||
}
|
||||
}
|
||||
return entries
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
package http
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"apocapoc-api/internal/application/commands"
|
||||
"apocapoc-api/internal/i18n"
|
||||
"apocapoc-api/internal/shared/errors"
|
||||
)
|
||||
|
||||
type UserHandlers struct {
|
||||
deleteUserHandler *commands.DeleteUserHandler
|
||||
translator *i18n.Translator
|
||||
}
|
||||
|
||||
func NewUserHandlers(deleteUserHandler *commands.DeleteUserHandler, translator *i18n.Translator) *UserHandlers {
|
||||
return &UserHandlers{
|
||||
deleteUserHandler: deleteUserHandler,
|
||||
translator: translator,
|
||||
}
|
||||
}
|
||||
|
||||
// DeleteAccount godoc
|
||||
// @Summary Delete user account
|
||||
// @Description Permanently delete the authenticated user's account and all associated data (habits, entries, tokens). This action cannot be undone.
|
||||
// @Tags users
|
||||
// @Security BearerAuth
|
||||
// @Produce json
|
||||
// @Success 200 {object} map[string]string "Account deleted successfully"
|
||||
// @Failure 401 {object} ErrorResponse "Unauthorized - invalid or missing token"
|
||||
// @Failure 404 {object} ErrorResponse "User not found"
|
||||
// @Failure 500 {object} ErrorResponse "Internal server error"
|
||||
// @Router /users/me [delete]
|
||||
func (h *UserHandlers) DeleteAccount(w http.ResponseWriter, r *http.Request) {
|
||||
userID := r.Context().Value("user_id").(string)
|
||||
|
||||
cmd := commands.DeleteUserCommand{
|
||||
UserID: userID,
|
||||
}
|
||||
|
||||
err := h.deleteUserHandler.Handle(r.Context(), cmd)
|
||||
if err != nil {
|
||||
if err == errors.ErrNotFound {
|
||||
respondErrorI18n(w, r, h.translator, http.StatusNotFound, "user_not_found")
|
||||
return
|
||||
}
|
||||
respondErrorI18n(w, r, h.translator, http.StatusInternalServerError, "failed_delete_user")
|
||||
return
|
||||
}
|
||||
|
||||
lang := i18n.GetLanguageFromContext(r.Context())
|
||||
respondJSON(w, http.StatusOK, map[string]string{
|
||||
"message": h.translator.Success(lang, "user_deleted"),
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
package logger
|
||||
|
||||
import (
|
||||
"io"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/rs/zerolog"
|
||||
"github.com/rs/zerolog/pkgerrors"
|
||||
)
|
||||
|
||||
var Log zerolog.Logger
|
||||
|
||||
type Config struct {
|
||||
Level string
|
||||
Environment string
|
||||
}
|
||||
|
||||
func Init(config Config) {
|
||||
zerolog.ErrorStackMarshaler = pkgerrors.MarshalStack
|
||||
zerolog.TimeFieldFormat = time.RFC3339
|
||||
|
||||
level := parseLogLevel(config.Level)
|
||||
zerolog.SetGlobalLevel(level)
|
||||
|
||||
var output io.Writer = os.Stdout
|
||||
|
||||
if config.Environment == "development" {
|
||||
output = zerolog.ConsoleWriter{
|
||||
Out: os.Stdout,
|
||||
TimeFormat: "15:04:05",
|
||||
NoColor: false,
|
||||
}
|
||||
}
|
||||
|
||||
Log = zerolog.New(output).
|
||||
With().
|
||||
Timestamp().
|
||||
Caller().
|
||||
Logger()
|
||||
|
||||
Log.Info().
|
||||
Str("level", level.String()).
|
||||
Str("environment", config.Environment).
|
||||
Msg("Logger initialized")
|
||||
}
|
||||
|
||||
func parseLogLevel(level string) zerolog.Level {
|
||||
switch strings.ToLower(level) {
|
||||
case "debug":
|
||||
return zerolog.DebugLevel
|
||||
case "info":
|
||||
return zerolog.InfoLevel
|
||||
case "warn", "warning":
|
||||
return zerolog.WarnLevel
|
||||
case "error":
|
||||
return zerolog.ErrorLevel
|
||||
case "fatal":
|
||||
return zerolog.FatalLevel
|
||||
case "panic":
|
||||
return zerolog.PanicLevel
|
||||
default:
|
||||
return zerolog.InfoLevel
|
||||
}
|
||||
}
|
||||
|
||||
func Debug() *zerolog.Event {
|
||||
return Log.Debug()
|
||||
}
|
||||
|
||||
func Info() *zerolog.Event {
|
||||
return Log.Info()
|
||||
}
|
||||
|
||||
func Warn() *zerolog.Event {
|
||||
return Log.Warn()
|
||||
}
|
||||
|
||||
func Error() *zerolog.Event {
|
||||
return Log.Error()
|
||||
}
|
||||
|
||||
func Fatal() *zerolog.Event {
|
||||
return Log.Fatal()
|
||||
}
|
||||
|
||||
func With() zerolog.Context {
|
||||
return Log.With()
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
package logger
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/rs/zerolog"
|
||||
)
|
||||
|
||||
type contextKey string
|
||||
|
||||
const (
|
||||
RequestIDKey contextKey = "request_id"
|
||||
UserIDKey contextKey = "user_id"
|
||||
)
|
||||
|
||||
type responseWriter struct {
|
||||
http.ResponseWriter
|
||||
status int
|
||||
size int
|
||||
}
|
||||
|
||||
func (rw *responseWriter) WriteHeader(status int) {
|
||||
rw.status = status
|
||||
rw.ResponseWriter.WriteHeader(status)
|
||||
}
|
||||
|
||||
func (rw *responseWriter) Write(b []byte) (int, error) {
|
||||
size, err := rw.ResponseWriter.Write(b)
|
||||
rw.size += size
|
||||
return size, err
|
||||
}
|
||||
|
||||
func Middleware(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
start := time.Now()
|
||||
|
||||
requestID := uuid.New().String()
|
||||
ctx := context.WithValue(r.Context(), RequestIDKey, requestID)
|
||||
|
||||
logger := Log.With().
|
||||
Str("request_id", requestID).
|
||||
Str("method", r.Method).
|
||||
Str("path", r.URL.Path).
|
||||
Str("remote_addr", r.RemoteAddr).
|
||||
Str("user_agent", r.UserAgent()).
|
||||
Logger()
|
||||
|
||||
ctx = logger.WithContext(ctx)
|
||||
r = r.WithContext(ctx)
|
||||
|
||||
rw := &responseWriter{
|
||||
ResponseWriter: w,
|
||||
status: http.StatusOK,
|
||||
}
|
||||
|
||||
next.ServeHTTP(rw, r)
|
||||
|
||||
duration := time.Since(start)
|
||||
|
||||
event := logger.Info()
|
||||
if rw.status >= 400 && rw.status < 500 {
|
||||
event = logger.Warn()
|
||||
} else if rw.status >= 500 {
|
||||
event = logger.Error()
|
||||
}
|
||||
|
||||
event.
|
||||
Int("status", rw.status).
|
||||
Int("size", rw.size).
|
||||
Dur("duration", duration).
|
||||
Msg("HTTP request")
|
||||
})
|
||||
}
|
||||
|
||||
func FromContext(ctx context.Context) *zerolog.Logger {
|
||||
logger := zerolog.Ctx(ctx)
|
||||
if logger == nil || logger.GetLevel() == zerolog.Disabled {
|
||||
return &Log
|
||||
}
|
||||
return logger
|
||||
}
|
||||
|
||||
func AddUserID(ctx context.Context, userID string) context.Context {
|
||||
logger := FromContext(ctx)
|
||||
updatedLogger := logger.With().Str("user_id", userID).Logger()
|
||||
return updatedLogger.WithContext(ctx)
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"time"
|
||||
|
||||
"apocapoc-api/internal/domain/entities"
|
||||
"apocapoc-api/internal/domain/repositories"
|
||||
"apocapoc-api/internal/shared/errors"
|
||||
|
||||
"github.com/google/uuid"
|
||||
@@ -24,8 +25,8 @@ func (r *HabitEntryRepository) Create(ctx context.Context, entry *entities.Habit
|
||||
entry.ID = uuid.New().String()
|
||||
|
||||
query := `
|
||||
INSERT INTO habit_entries (id, habit_id, scheduled_date, completed_at, value)
|
||||
VALUES (?, ?, ?, ?, ?)
|
||||
INSERT INTO habit_entries (id, habit_id, scheduled_date, completed_at, value, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?)
|
||||
`
|
||||
|
||||
_, err := r.db.ExecContext(ctx, query,
|
||||
@@ -34,6 +35,7 @@ func (r *HabitEntryRepository) Create(ctx context.Context, entry *entities.Habit
|
||||
entry.ScheduledDate.Format("2006-01-02"),
|
||||
entry.CompletedAt,
|
||||
entry.Value,
|
||||
entry.UpdatedAt,
|
||||
)
|
||||
|
||||
if err != nil {
|
||||
@@ -52,11 +54,12 @@ func (r *HabitEntryRepository) FindByHabitIDAndDateRange(
|
||||
from, to time.Time,
|
||||
) ([]*entities.HabitEntry, error) {
|
||||
query := `
|
||||
SELECT id, habit_id, scheduled_date, completed_at, value
|
||||
SELECT id, habit_id, scheduled_date, completed_at, value, updated_at, deleted_at
|
||||
FROM habit_entries
|
||||
WHERE habit_id = ?
|
||||
AND scheduled_date >= ?
|
||||
AND scheduled_date <= ?
|
||||
AND deleted_at IS NULL
|
||||
ORDER BY scheduled_date ASC
|
||||
`
|
||||
|
||||
@@ -74,13 +77,15 @@ func (r *HabitEntryRepository) FindByHabitIDAndDateRange(
|
||||
}
|
||||
|
||||
func (r *HabitEntryRepository) Update(ctx context.Context, entry *entities.HabitEntry) error {
|
||||
entry.UpdatedAt = time.Now()
|
||||
|
||||
query := `
|
||||
UPDATE habit_entries
|
||||
SET value = ?, completed_at = ?
|
||||
WHERE id = ?
|
||||
SET value = ?, completed_at = ?, updated_at = ?
|
||||
WHERE id = ? AND deleted_at IS NULL
|
||||
`
|
||||
|
||||
result, err := r.db.ExecContext(ctx, query, entry.Value, entry.CompletedAt, entry.ID)
|
||||
result, err := r.db.ExecContext(ctx, query, entry.Value, entry.CompletedAt, entry.UpdatedAt, entry.ID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to update entry: %w", err)
|
||||
}
|
||||
@@ -100,6 +105,8 @@ func (r *HabitEntryRepository) scanEntries(rows *sql.Rows) ([]*entities.HabitEnt
|
||||
var (
|
||||
entry entities.HabitEntry
|
||||
scheduledDate string
|
||||
updatedAt sql.NullTime
|
||||
deletedAt sql.NullTime
|
||||
)
|
||||
|
||||
err := rows.Scan(
|
||||
@@ -108,6 +115,8 @@ func (r *HabitEntryRepository) scanEntries(rows *sql.Rows) ([]*entities.HabitEnt
|
||||
&scheduledDate,
|
||||
&entry.CompletedAt,
|
||||
&entry.Value,
|
||||
&updatedAt,
|
||||
&deletedAt,
|
||||
)
|
||||
|
||||
if err != nil {
|
||||
@@ -123,6 +132,13 @@ func (r *HabitEntryRepository) scanEntries(rows *sql.Rows) ([]*entities.HabitEnt
|
||||
}
|
||||
entry.ScheduledDate = parsedDate
|
||||
|
||||
if updatedAt.Valid {
|
||||
entry.UpdatedAt = updatedAt.Time
|
||||
}
|
||||
if deletedAt.Valid {
|
||||
entry.DeletedAt = &deletedAt.Time
|
||||
}
|
||||
|
||||
entries = append(entries, &entry)
|
||||
}
|
||||
|
||||
@@ -131,14 +147,16 @@ func (r *HabitEntryRepository) scanEntries(rows *sql.Rows) ([]*entities.HabitEnt
|
||||
|
||||
func (r *HabitEntryRepository) FindByID(ctx context.Context, id string) (*entities.HabitEntry, error) {
|
||||
query := `
|
||||
SELECT id, habit_id, scheduled_date, completed_at, value
|
||||
SELECT id, habit_id, scheduled_date, completed_at, value, updated_at, deleted_at
|
||||
FROM habit_entries
|
||||
WHERE id = ?
|
||||
WHERE id = ? AND deleted_at IS NULL
|
||||
`
|
||||
|
||||
var (
|
||||
entry entities.HabitEntry
|
||||
scheduledDate string
|
||||
updatedAt sql.NullTime
|
||||
deletedAt sql.NullTime
|
||||
)
|
||||
|
||||
err := r.db.QueryRowContext(ctx, query, id).Scan(
|
||||
@@ -147,6 +165,8 @@ func (r *HabitEntryRepository) FindByID(ctx context.Context, id string) (*entiti
|
||||
&scheduledDate,
|
||||
&entry.CompletedAt,
|
||||
&entry.Value,
|
||||
&updatedAt,
|
||||
&deletedAt,
|
||||
)
|
||||
|
||||
if err == sql.ErrNoRows {
|
||||
@@ -165,14 +185,21 @@ func (r *HabitEntryRepository) FindByID(ctx context.Context, id string) (*entiti
|
||||
}
|
||||
entry.ScheduledDate = parsedDate
|
||||
|
||||
if updatedAt.Valid {
|
||||
entry.UpdatedAt = updatedAt.Time
|
||||
}
|
||||
if deletedAt.Valid {
|
||||
entry.DeletedAt = &deletedAt.Time
|
||||
}
|
||||
|
||||
return &entry, nil
|
||||
}
|
||||
|
||||
func (r *HabitEntryRepository) FindByHabitID(ctx context.Context, habitID string) ([]*entities.HabitEntry, error) {
|
||||
query := `
|
||||
SELECT id, habit_id, scheduled_date, completed_at, value
|
||||
SELECT id, habit_id, scheduled_date, completed_at, value, updated_at, deleted_at
|
||||
FROM habit_entries
|
||||
WHERE habit_id = ?
|
||||
WHERE habit_id = ? AND deleted_at IS NULL
|
||||
ORDER BY scheduled_date DESC
|
||||
`
|
||||
|
||||
@@ -185,12 +212,31 @@ func (r *HabitEntryRepository) FindByHabitID(ctx context.Context, habitID string
|
||||
return r.scanEntries(rows)
|
||||
}
|
||||
|
||||
func (r *HabitEntryRepository) FindByUserID(ctx context.Context, userID string) ([]*entities.HabitEntry, error) {
|
||||
query := `
|
||||
SELECT he.id, he.habit_id, he.scheduled_date, he.completed_at, he.value, he.updated_at, he.deleted_at
|
||||
FROM habit_entries he
|
||||
INNER JOIN habits h ON he.habit_id = h.id
|
||||
WHERE h.user_id = ? AND he.deleted_at IS NULL
|
||||
ORDER BY he.scheduled_date DESC
|
||||
`
|
||||
|
||||
rows, err := r.db.QueryContext(ctx, query, userID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to find entries: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
return r.scanEntries(rows)
|
||||
}
|
||||
|
||||
func (r *HabitEntryRepository) FindPendingByHabitID(ctx context.Context, habitID string, beforeDate time.Time) ([]*entities.HabitEntry, error) {
|
||||
query := `
|
||||
SELECT id, habit_id, scheduled_date, completed_at, value
|
||||
SELECT id, habit_id, scheduled_date, completed_at, value, updated_at, deleted_at
|
||||
FROM habit_entries
|
||||
WHERE habit_id = ?
|
||||
AND scheduled_date < ?
|
||||
AND deleted_at IS NULL
|
||||
ORDER BY scheduled_date DESC
|
||||
`
|
||||
|
||||
@@ -218,3 +264,128 @@ func (r *HabitEntryRepository) Delete(ctx context.Context, id string) error {
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *HabitEntryRepository) GetChangesSince(ctx context.Context, userID string, since time.Time) (*repositories.HabitEntryChanges, error) {
|
||||
changes := &repositories.HabitEntryChanges{
|
||||
Created: []*entities.HabitEntry{},
|
||||
Updated: []*entities.HabitEntry{},
|
||||
Deleted: []string{},
|
||||
}
|
||||
|
||||
query := `
|
||||
SELECT he.id, he.habit_id, he.scheduled_date, he.completed_at, he.value, he.updated_at, he.deleted_at
|
||||
FROM habit_entries he
|
||||
INNER JOIN habits h ON he.habit_id = h.id
|
||||
WHERE h.user_id = ?
|
||||
AND he.updated_at > ?
|
||||
AND he.deleted_at IS NULL
|
||||
ORDER BY he.updated_at ASC
|
||||
`
|
||||
|
||||
rows, err := r.db.QueryContext(ctx, query, userID, since)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to query habit entry changes: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
for rows.Next() {
|
||||
var (
|
||||
entry entities.HabitEntry
|
||||
scheduledDate string
|
||||
updatedAt sql.NullTime
|
||||
deletedAt sql.NullTime
|
||||
)
|
||||
|
||||
err := rows.Scan(
|
||||
&entry.ID,
|
||||
&entry.HabitID,
|
||||
&scheduledDate,
|
||||
&entry.CompletedAt,
|
||||
&entry.Value,
|
||||
&updatedAt,
|
||||
&deletedAt,
|
||||
)
|
||||
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to scan habit entry: %w", err)
|
||||
}
|
||||
|
||||
parsedDate, err := time.Parse("2006-01-02", scheduledDate)
|
||||
if err != nil {
|
||||
parsedDate, err = time.Parse(time.RFC3339, scheduledDate)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to parse scheduled_date: %w", err)
|
||||
}
|
||||
}
|
||||
entry.ScheduledDate = parsedDate
|
||||
|
||||
if updatedAt.Valid {
|
||||
entry.UpdatedAt = updatedAt.Time
|
||||
}
|
||||
if deletedAt.Valid {
|
||||
entry.DeletedAt = &deletedAt.Time
|
||||
}
|
||||
|
||||
if entry.CompletedAt.After(since) {
|
||||
changes.Created = append(changes.Created, &entry)
|
||||
} else {
|
||||
changes.Updated = append(changes.Updated, &entry)
|
||||
}
|
||||
}
|
||||
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, fmt.Errorf("error iterating habit entries: %w", err)
|
||||
}
|
||||
|
||||
queryDeleted := `
|
||||
SELECT he.id
|
||||
FROM habit_entries he
|
||||
INNER JOIN habits h ON he.habit_id = h.id
|
||||
WHERE h.user_id = ?
|
||||
AND he.deleted_at IS NOT NULL
|
||||
AND he.deleted_at > ?
|
||||
ORDER BY he.deleted_at ASC
|
||||
`
|
||||
|
||||
rowsDeleted, err := r.db.QueryContext(ctx, queryDeleted, userID, since)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to query deleted habit entries: %w", err)
|
||||
}
|
||||
defer rowsDeleted.Close()
|
||||
|
||||
for rowsDeleted.Next() {
|
||||
var id string
|
||||
if err := rowsDeleted.Scan(&id); err != nil {
|
||||
return nil, fmt.Errorf("failed to scan deleted habit entry id: %w", err)
|
||||
}
|
||||
changes.Deleted = append(changes.Deleted, id)
|
||||
}
|
||||
|
||||
if err := rowsDeleted.Err(); err != nil {
|
||||
return nil, fmt.Errorf("error iterating deleted habit entries: %w", err)
|
||||
}
|
||||
|
||||
return changes, nil
|
||||
}
|
||||
|
||||
func (r *HabitEntryRepository) SoftDelete(ctx context.Context, id string) error {
|
||||
now := time.Now()
|
||||
|
||||
query := `
|
||||
UPDATE habit_entries
|
||||
SET deleted_at = ?, updated_at = ?
|
||||
WHERE id = ? AND deleted_at IS NULL
|
||||
`
|
||||
|
||||
result, err := r.db.ExecContext(ctx, query, now, now, id)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to soft delete habit entry: %w", err)
|
||||
}
|
||||
|
||||
rows, _ := result.RowsAffected()
|
||||
if rows == 0 {
|
||||
return errors.ErrNotFound
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -5,9 +5,12 @@ import (
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"apocapoc-api/internal/domain/entities"
|
||||
"apocapoc-api/internal/domain/repositories"
|
||||
"apocapoc-api/internal/shared/errors"
|
||||
"apocapoc-api/internal/shared/pagination"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
@@ -29,8 +32,8 @@ func (r *HabitRepository) Create(ctx context.Context, habit *entities.Habit) err
|
||||
query := `
|
||||
INSERT INTO habits (
|
||||
id, user_id, name, description, type, frequency,
|
||||
specific_days, specific_dates, carry_over, is_negative, target_value, created_at
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
specific_days, specific_dates, carry_over, is_negative, target_value, created_at, updated_at
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`
|
||||
|
||||
_, err := r.db.ExecContext(ctx, query,
|
||||
@@ -46,6 +49,7 @@ func (r *HabitRepository) Create(ctx context.Context, habit *entities.Habit) err
|
||||
habit.IsNegative,
|
||||
habit.TargetValue,
|
||||
habit.CreatedAt,
|
||||
habit.UpdatedAt,
|
||||
)
|
||||
|
||||
if err != nil {
|
||||
@@ -59,16 +63,18 @@ func (r *HabitRepository) FindByID(ctx context.Context, id string) (*entities.Ha
|
||||
query := `
|
||||
SELECT id, user_id, name, description, type, frequency,
|
||||
specific_days, specific_dates, carry_over, is_negative, target_value,
|
||||
created_at, archived_at
|
||||
created_at, updated_at, archived_at, deleted_at
|
||||
FROM habits
|
||||
WHERE id = ?
|
||||
WHERE id = ? AND deleted_at IS NULL
|
||||
`
|
||||
|
||||
var (
|
||||
habit entities.Habit
|
||||
specificDays sql.NullString
|
||||
specificDates sql.NullString
|
||||
updatedAt sql.NullTime
|
||||
archivedAt sql.NullTime
|
||||
deletedAt sql.NullTime
|
||||
)
|
||||
|
||||
err := r.db.QueryRowContext(ctx, query, id).Scan(
|
||||
@@ -84,7 +90,9 @@ func (r *HabitRepository) FindByID(ctx context.Context, id string) (*entities.Ha
|
||||
&habit.IsNegative,
|
||||
&habit.TargetValue,
|
||||
&habit.CreatedAt,
|
||||
&updatedAt,
|
||||
&archivedAt,
|
||||
&deletedAt,
|
||||
)
|
||||
|
||||
if err == sql.ErrNoRows {
|
||||
@@ -94,15 +102,22 @@ func (r *HabitRepository) FindByID(ctx context.Context, id string) (*entities.Ha
|
||||
return nil, fmt.Errorf("failed to find habit: %w", err)
|
||||
}
|
||||
|
||||
if updatedAt.Valid {
|
||||
habit.UpdatedAt = updatedAt.Time
|
||||
}
|
||||
if archivedAt.Valid {
|
||||
habit.ArchivedAt = &archivedAt.Time
|
||||
}
|
||||
if deletedAt.Valid {
|
||||
habit.DeletedAt = &deletedAt.Time
|
||||
}
|
||||
|
||||
if specificDays.Valid {
|
||||
json.Unmarshal([]byte(specificDays.String), &habit.SpecificDays)
|
||||
}
|
||||
if specificDates.Valid {
|
||||
json.Unmarshal([]byte(specificDates.String), &habit.SpecificDates)
|
||||
}
|
||||
if archivedAt.Valid {
|
||||
habit.ArchivedAt = &archivedAt.Time
|
||||
}
|
||||
|
||||
return &habit, nil
|
||||
}
|
||||
@@ -111,9 +126,9 @@ func (r *HabitRepository) FindActiveByUserID(ctx context.Context, userID string)
|
||||
query := `
|
||||
SELECT id, user_id, name, description, type, frequency,
|
||||
specific_days, specific_dates, carry_over, is_negative, target_value,
|
||||
created_at, archived_at
|
||||
created_at, updated_at, archived_at, deleted_at
|
||||
FROM habits
|
||||
WHERE user_id = ? AND archived_at IS NULL
|
||||
WHERE user_id = ? AND archived_at IS NULL AND deleted_at IS NULL
|
||||
ORDER BY created_at DESC
|
||||
`
|
||||
|
||||
@@ -127,6 +142,8 @@ func (r *HabitRepository) FindActiveByUserID(ctx context.Context, userID string)
|
||||
}
|
||||
|
||||
func (r *HabitRepository) Update(ctx context.Context, habit *entities.Habit) error {
|
||||
habit.Touch()
|
||||
|
||||
specificDays, _ := json.Marshal(habit.SpecificDays)
|
||||
specificDates, _ := json.Marshal(habit.SpecificDates)
|
||||
|
||||
@@ -134,8 +151,8 @@ func (r *HabitRepository) Update(ctx context.Context, habit *entities.Habit) err
|
||||
UPDATE habits
|
||||
SET name = ?, description = ?, type = ?, frequency = ?,
|
||||
specific_days = ?, specific_dates = ?, carry_over = ?, is_negative = ?,
|
||||
target_value = ?, archived_at = ?
|
||||
WHERE id = ?
|
||||
target_value = ?, archived_at = ?, updated_at = ?
|
||||
WHERE id = ? AND deleted_at IS NULL
|
||||
`
|
||||
|
||||
result, err := r.db.ExecContext(ctx, query,
|
||||
@@ -149,6 +166,7 @@ func (r *HabitRepository) Update(ctx context.Context, habit *entities.Habit) err
|
||||
habit.IsNegative,
|
||||
habit.TargetValue,
|
||||
habit.ArchivedAt,
|
||||
habit.UpdatedAt,
|
||||
habit.ID,
|
||||
)
|
||||
|
||||
@@ -172,7 +190,9 @@ func (r *HabitRepository) scanHabits(rows *sql.Rows) ([]*entities.Habit, error)
|
||||
habit entities.Habit
|
||||
specificDays sql.NullString
|
||||
specificDates sql.NullString
|
||||
updatedAt sql.NullTime
|
||||
archivedAt sql.NullTime
|
||||
deletedAt sql.NullTime
|
||||
)
|
||||
|
||||
err := rows.Scan(
|
||||
@@ -188,7 +208,9 @@ func (r *HabitRepository) scanHabits(rows *sql.Rows) ([]*entities.Habit, error)
|
||||
&habit.IsNegative,
|
||||
&habit.TargetValue,
|
||||
&habit.CreatedAt,
|
||||
&updatedAt,
|
||||
&archivedAt,
|
||||
&deletedAt,
|
||||
)
|
||||
|
||||
if err != nil {
|
||||
@@ -201,9 +223,15 @@ func (r *HabitRepository) scanHabits(rows *sql.Rows) ([]*entities.Habit, error)
|
||||
if specificDates.Valid {
|
||||
json.Unmarshal([]byte(specificDates.String), &habit.SpecificDates)
|
||||
}
|
||||
if updatedAt.Valid {
|
||||
habit.UpdatedAt = updatedAt.Time
|
||||
}
|
||||
if archivedAt.Valid {
|
||||
habit.ArchivedAt = &archivedAt.Time
|
||||
}
|
||||
if deletedAt.Valid {
|
||||
habit.DeletedAt = &deletedAt.Time
|
||||
}
|
||||
|
||||
habits = append(habits, &habit)
|
||||
}
|
||||
@@ -215,9 +243,9 @@ func (r *HabitRepository) FindByUserID(ctx context.Context, userID string) ([]*e
|
||||
query := `
|
||||
SELECT id, user_id, name, description, type, frequency,
|
||||
specific_days, specific_dates, carry_over, is_negative, target_value,
|
||||
created_at, archived_at
|
||||
created_at, updated_at, archived_at, deleted_at
|
||||
FROM habits
|
||||
WHERE user_id = ?
|
||||
WHERE user_id = ? AND deleted_at IS NULL
|
||||
ORDER BY created_at DESC
|
||||
`
|
||||
|
||||
@@ -245,3 +273,273 @@ func (r *HabitRepository) Delete(ctx context.Context, id string) error {
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *HabitRepository) FindActiveByUserIDWithPagination(ctx context.Context, userID string, params pagination.Params) ([]*entities.Habit, error) {
|
||||
query := `
|
||||
SELECT id, user_id, name, description, type, frequency,
|
||||
specific_days, specific_dates, carry_over, is_negative, target_value,
|
||||
created_at, updated_at, archived_at, deleted_at
|
||||
FROM habits
|
||||
WHERE user_id = ? AND archived_at IS NULL AND deleted_at IS NULL
|
||||
ORDER BY created_at DESC
|
||||
LIMIT ? OFFSET ?
|
||||
`
|
||||
|
||||
rows, err := r.db.QueryContext(ctx, query, userID, params.Limit(), params.Offset())
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to find habits: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
return r.scanHabits(rows)
|
||||
}
|
||||
|
||||
func (r *HabitRepository) CountActiveByUserID(ctx context.Context, userID string) (int, error) {
|
||||
query := `
|
||||
SELECT COUNT(*)
|
||||
FROM habits
|
||||
WHERE user_id = ? AND archived_at IS NULL AND deleted_at IS NULL
|
||||
`
|
||||
|
||||
var count int
|
||||
err := r.db.QueryRowContext(ctx, query, userID).Scan(&count)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("failed to count habits: %w", err)
|
||||
}
|
||||
|
||||
return count, nil
|
||||
}
|
||||
|
||||
func (r *HabitRepository) FindByUserIDFiltered(ctx context.Context, userID string, filter repositories.HabitFilter, paginationParams *pagination.Params) ([]*entities.Habit, error) {
|
||||
baseQuery := `
|
||||
SELECT id, user_id, name, description, type, frequency,
|
||||
specific_days, specific_dates, carry_over, is_negative, target_value,
|
||||
created_at, updated_at, archived_at, deleted_at
|
||||
FROM habits
|
||||
WHERE user_id = ?`
|
||||
|
||||
args := []interface{}{userID}
|
||||
conditions := []string{}
|
||||
|
||||
// Always exclude soft deleted
|
||||
conditions = append(conditions, "deleted_at IS NULL")
|
||||
|
||||
if !filter.IncludeArchived {
|
||||
conditions = append(conditions, "archived_at IS NULL")
|
||||
}
|
||||
|
||||
if filter.Type != nil {
|
||||
conditions = append(conditions, "type = ?")
|
||||
args = append(args, string(*filter.Type))
|
||||
}
|
||||
|
||||
if filter.Frequency != nil {
|
||||
conditions = append(conditions, "frequency = ?")
|
||||
args = append(args, string(*filter.Frequency))
|
||||
}
|
||||
|
||||
if filter.Search != "" {
|
||||
conditions = append(conditions, "(name LIKE ? OR description LIKE ?)")
|
||||
searchPattern := "%" + filter.Search + "%"
|
||||
args = append(args, searchPattern, searchPattern)
|
||||
}
|
||||
|
||||
for _, condition := range conditions {
|
||||
baseQuery += " AND " + condition
|
||||
}
|
||||
|
||||
baseQuery += " ORDER BY created_at DESC"
|
||||
|
||||
if paginationParams != nil {
|
||||
baseQuery += " LIMIT ? OFFSET ?"
|
||||
args = append(args, paginationParams.Limit(), paginationParams.Offset())
|
||||
}
|
||||
|
||||
rows, err := r.db.QueryContext(ctx, baseQuery, args...)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to find habits: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
return r.scanHabits(rows)
|
||||
}
|
||||
|
||||
func (r *HabitRepository) CountByUserIDFiltered(ctx context.Context, userID string, filter repositories.HabitFilter) (int, error) {
|
||||
baseQuery := `SELECT COUNT(*) FROM habits WHERE user_id = ?`
|
||||
|
||||
args := []interface{}{userID}
|
||||
conditions := []string{}
|
||||
|
||||
// Always exclude soft deleted
|
||||
conditions = append(conditions, "deleted_at IS NULL")
|
||||
|
||||
if !filter.IncludeArchived {
|
||||
conditions = append(conditions, "archived_at IS NULL")
|
||||
}
|
||||
|
||||
if filter.Type != nil {
|
||||
conditions = append(conditions, "type = ?")
|
||||
args = append(args, string(*filter.Type))
|
||||
}
|
||||
|
||||
if filter.Frequency != nil {
|
||||
conditions = append(conditions, "frequency = ?")
|
||||
args = append(args, string(*filter.Frequency))
|
||||
}
|
||||
|
||||
if filter.Search != "" {
|
||||
conditions = append(conditions, "(name LIKE ? OR description LIKE ?)")
|
||||
searchPattern := "%" + filter.Search + "%"
|
||||
args = append(args, searchPattern, searchPattern)
|
||||
}
|
||||
|
||||
for _, condition := range conditions {
|
||||
baseQuery += " AND " + condition
|
||||
}
|
||||
|
||||
var count int
|
||||
err := r.db.QueryRowContext(ctx, baseQuery, args...).Scan(&count)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("failed to count habits: %w", err)
|
||||
}
|
||||
|
||||
return count, nil
|
||||
}
|
||||
|
||||
func (r *HabitRepository) GetChangesSince(ctx context.Context, userID string, since time.Time) (*repositories.HabitChanges, error) {
|
||||
changes := &repositories.HabitChanges{
|
||||
Created: []*entities.Habit{},
|
||||
Updated: []*entities.Habit{},
|
||||
Deleted: []string{},
|
||||
}
|
||||
|
||||
// Get created and updated habits (not deleted)
|
||||
query := `
|
||||
SELECT id, user_id, name, description, type, frequency,
|
||||
specific_days, specific_dates, carry_over, is_negative, target_value,
|
||||
created_at, updated_at, archived_at, deleted_at
|
||||
FROM habits
|
||||
WHERE user_id = ?
|
||||
AND updated_at > ?
|
||||
AND deleted_at IS NULL
|
||||
ORDER BY updated_at ASC
|
||||
`
|
||||
|
||||
rows, err := r.db.QueryContext(ctx, query, userID, since)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to query habits changes: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
for rows.Next() {
|
||||
var (
|
||||
habit entities.Habit
|
||||
specificDays sql.NullString
|
||||
specificDates sql.NullString
|
||||
updatedAt sql.NullTime
|
||||
archivedAt sql.NullTime
|
||||
deletedAt sql.NullTime
|
||||
)
|
||||
|
||||
err := rows.Scan(
|
||||
&habit.ID,
|
||||
&habit.UserID,
|
||||
&habit.Name,
|
||||
&habit.Description,
|
||||
&habit.Type,
|
||||
&habit.Frequency,
|
||||
&specificDays,
|
||||
&specificDates,
|
||||
&habit.CarryOver,
|
||||
&habit.IsNegative,
|
||||
&habit.TargetValue,
|
||||
&habit.CreatedAt,
|
||||
&updatedAt,
|
||||
&archivedAt,
|
||||
&deletedAt,
|
||||
)
|
||||
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to scan habit: %w", err)
|
||||
}
|
||||
|
||||
if specificDays.Valid {
|
||||
json.Unmarshal([]byte(specificDays.String), &habit.SpecificDays)
|
||||
}
|
||||
if specificDates.Valid {
|
||||
json.Unmarshal([]byte(specificDates.String), &habit.SpecificDates)
|
||||
}
|
||||
if updatedAt.Valid {
|
||||
habit.UpdatedAt = updatedAt.Time
|
||||
}
|
||||
if archivedAt.Valid {
|
||||
habit.ArchivedAt = &archivedAt.Time
|
||||
}
|
||||
if deletedAt.Valid {
|
||||
habit.DeletedAt = &deletedAt.Time
|
||||
}
|
||||
|
||||
// Classify as created or updated based on when it was created
|
||||
if habit.CreatedAt.After(since) {
|
||||
changes.Created = append(changes.Created, &habit)
|
||||
} else {
|
||||
changes.Updated = append(changes.Updated, &habit)
|
||||
}
|
||||
}
|
||||
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, fmt.Errorf("error iterating habits: %w", err)
|
||||
}
|
||||
|
||||
// Get deleted habits
|
||||
queryDeleted := `
|
||||
SELECT id
|
||||
FROM habits
|
||||
WHERE user_id = ?
|
||||
AND deleted_at IS NOT NULL
|
||||
AND deleted_at > ?
|
||||
ORDER BY deleted_at ASC
|
||||
`
|
||||
|
||||
rowsDeleted, err := r.db.QueryContext(ctx, queryDeleted, userID, since)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to query deleted habits: %w", err)
|
||||
}
|
||||
defer rowsDeleted.Close()
|
||||
|
||||
for rowsDeleted.Next() {
|
||||
var id string
|
||||
if err := rowsDeleted.Scan(&id); err != nil {
|
||||
return nil, fmt.Errorf("failed to scan deleted habit id: %w", err)
|
||||
}
|
||||
changes.Deleted = append(changes.Deleted, id)
|
||||
}
|
||||
|
||||
if err := rowsDeleted.Err(); err != nil {
|
||||
return nil, fmt.Errorf("error iterating deleted habits: %w", err)
|
||||
}
|
||||
|
||||
return changes, nil
|
||||
}
|
||||
|
||||
func (r *HabitRepository) SoftDelete(ctx context.Context, id string) error {
|
||||
now := time.Now()
|
||||
|
||||
query := `
|
||||
UPDATE habits
|
||||
SET deleted_at = ?, updated_at = ?
|
||||
WHERE id = ? AND deleted_at IS NULL
|
||||
`
|
||||
|
||||
result, err := r.db.ExecContext(ctx, query, now, now, id)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to soft delete habit: %w", err)
|
||||
}
|
||||
|
||||
rows, _ := result.RowsAffected()
|
||||
if rows == 0 {
|
||||
return errors.ErrNotFound
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -6,8 +6,10 @@ import (
|
||||
"time"
|
||||
|
||||
"apocapoc-api/internal/domain/entities"
|
||||
"apocapoc-api/internal/domain/repositories"
|
||||
"apocapoc-api/internal/domain/value_objects"
|
||||
"apocapoc-api/internal/shared/errors"
|
||||
"apocapoc-api/internal/shared/pagination"
|
||||
)
|
||||
|
||||
func TestHabitRepositoryCreate(t *testing.T) {
|
||||
@@ -262,3 +264,372 @@ func TestHabitRepositoryArchive(t *testing.T) {
|
||||
t.Error("Expected habit to be archived")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHabitRepositoryFindActiveByUserIDWithPagination(t *testing.T) {
|
||||
db := setupTestDB(t)
|
||||
defer db.Close()
|
||||
|
||||
repo := NewHabitRepository(db)
|
||||
ctx := context.Background()
|
||||
|
||||
userID := "user-pagination-test"
|
||||
|
||||
for i := 1; i <= 10; i++ {
|
||||
habit := entities.NewHabit(
|
||||
userID,
|
||||
"Habit "+string(rune(i+'0')),
|
||||
value_objects.HabitTypeBoolean,
|
||||
value_objects.FrequencyDaily,
|
||||
false,
|
||||
false,
|
||||
)
|
||||
err := repo.Create(ctx, habit)
|
||||
if err != nil {
|
||||
t.Fatalf("Create failed: %v", err)
|
||||
}
|
||||
time.Sleep(1 * time.Millisecond)
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
allHabits, _ := repo.FindActiveByUserID(ctx, userID)
|
||||
allHabits[0].ArchivedAt = &now
|
||||
repo.Update(ctx, allHabits[0])
|
||||
|
||||
t.Run("FirstPage", func(t *testing.T) {
|
||||
params := pagination.NewParams(1, 5)
|
||||
habits, err := repo.FindActiveByUserIDWithPagination(ctx, userID, params)
|
||||
if err != nil {
|
||||
t.Fatalf("FindActiveByUserIDWithPagination failed: %v", err)
|
||||
}
|
||||
|
||||
if len(habits) != 5 {
|
||||
t.Errorf("Expected 5 habits on first page, got %d", len(habits))
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("SecondPage", func(t *testing.T) {
|
||||
params := pagination.NewParams(2, 5)
|
||||
habits, err := repo.FindActiveByUserIDWithPagination(ctx, userID, params)
|
||||
if err != nil {
|
||||
t.Fatalf("FindActiveByUserIDWithPagination failed: %v", err)
|
||||
}
|
||||
|
||||
if len(habits) != 4 {
|
||||
t.Errorf("Expected 4 habits on second page (9 total active), got %d", len(habits))
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("PageBeyondTotal", func(t *testing.T) {
|
||||
params := pagination.NewParams(10, 5)
|
||||
habits, err := repo.FindActiveByUserIDWithPagination(ctx, userID, params)
|
||||
if err != nil {
|
||||
t.Fatalf("FindActiveByUserIDWithPagination failed: %v", err)
|
||||
}
|
||||
|
||||
if len(habits) != 0 {
|
||||
t.Errorf("Expected 0 habits beyond total pages, got %d", len(habits))
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("CustomPageSize", func(t *testing.T) {
|
||||
params := pagination.NewParams(1, 3)
|
||||
habits, err := repo.FindActiveByUserIDWithPagination(ctx, userID, params)
|
||||
if err != nil {
|
||||
t.Fatalf("FindActiveByUserIDWithPagination failed: %v", err)
|
||||
}
|
||||
|
||||
if len(habits) != 3 {
|
||||
t.Errorf("Expected 3 habits with page_size=3, got %d", len(habits))
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("ExcludesArchived", func(t *testing.T) {
|
||||
params := pagination.NewParams(1, 20)
|
||||
habits, err := repo.FindActiveByUserIDWithPagination(ctx, userID, params)
|
||||
if err != nil {
|
||||
t.Fatalf("FindActiveByUserIDWithPagination failed: %v", err)
|
||||
}
|
||||
|
||||
if len(habits) != 9 {
|
||||
t.Errorf("Expected 9 active habits (1 archived), got %d", len(habits))
|
||||
}
|
||||
|
||||
for _, habit := range habits {
|
||||
if habit.ArchivedAt != nil {
|
||||
t.Error("Expected no archived habits in results")
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestHabitRepositoryCountActiveByUserID(t *testing.T) {
|
||||
db := setupTestDB(t)
|
||||
defer db.Close()
|
||||
|
||||
repo := NewHabitRepository(db)
|
||||
ctx := context.Background()
|
||||
|
||||
userID := "user-count-test"
|
||||
|
||||
t.Run("NoHabits", func(t *testing.T) {
|
||||
count, err := repo.CountActiveByUserID(ctx, "non-existent-user")
|
||||
if err != nil {
|
||||
t.Fatalf("CountActiveByUserID failed: %v", err)
|
||||
}
|
||||
|
||||
if count != 0 {
|
||||
t.Errorf("Expected count 0 for non-existent user, got %d", count)
|
||||
}
|
||||
})
|
||||
|
||||
for i := 1; i <= 7; i++ {
|
||||
habit := entities.NewHabit(
|
||||
userID,
|
||||
"Habit "+string(rune(i+'0')),
|
||||
value_objects.HabitTypeBoolean,
|
||||
value_objects.FrequencyDaily,
|
||||
false,
|
||||
false,
|
||||
)
|
||||
repo.Create(ctx, habit)
|
||||
}
|
||||
|
||||
t.Run("AllActive", func(t *testing.T) {
|
||||
count, err := repo.CountActiveByUserID(ctx, userID)
|
||||
if err != nil {
|
||||
t.Fatalf("CountActiveByUserID failed: %v", err)
|
||||
}
|
||||
|
||||
if count != 7 {
|
||||
t.Errorf("Expected count 7, got %d", count)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("WithArchived", func(t *testing.T) {
|
||||
habits, _ := repo.FindActiveByUserID(ctx, userID)
|
||||
now := time.Now()
|
||||
habits[0].ArchivedAt = &now
|
||||
habits[1].ArchivedAt = &now
|
||||
repo.Update(ctx, habits[0])
|
||||
repo.Update(ctx, habits[1])
|
||||
|
||||
count, err := repo.CountActiveByUserID(ctx, userID)
|
||||
if err != nil {
|
||||
t.Fatalf("CountActiveByUserID failed: %v", err)
|
||||
}
|
||||
|
||||
if count != 5 {
|
||||
t.Errorf("Expected count 5 (7 total - 2 archived), got %d", count)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestHabitRepositoryFindByUserIDFiltered(t *testing.T) {
|
||||
db := setupTestDB(t)
|
||||
defer db.Close()
|
||||
|
||||
repo := NewHabitRepository(db)
|
||||
ctx := context.Background()
|
||||
|
||||
userID := "user-filter-test"
|
||||
|
||||
habit1 := entities.NewHabit(userID, "Morning Exercise", value_objects.HabitTypeBoolean, value_objects.FrequencyDaily, false, false)
|
||||
habit1.Description = "Daily morning workout"
|
||||
repo.Create(ctx, habit1)
|
||||
time.Sleep(1 * time.Millisecond)
|
||||
|
||||
habit2 := entities.NewHabit(userID, "Read Books", value_objects.HabitTypeCounter, value_objects.FrequencyWeekly, false, false)
|
||||
habit2.Description = "Read at least 3 books per week"
|
||||
repo.Create(ctx, habit2)
|
||||
time.Sleep(1 * time.Millisecond)
|
||||
|
||||
habit3 := entities.NewHabit(userID, "Drink Water", value_objects.HabitTypeValue, value_objects.FrequencyDaily, false, false)
|
||||
habit3.Description = "Drink 2 liters of water daily"
|
||||
repo.Create(ctx, habit3)
|
||||
time.Sleep(1 * time.Millisecond)
|
||||
|
||||
habit4 := entities.NewHabit(userID, "Weekly Run", value_objects.HabitTypeBoolean, value_objects.FrequencyWeekly, false, false)
|
||||
repo.Create(ctx, habit4)
|
||||
time.Sleep(1 * time.Millisecond)
|
||||
|
||||
now := time.Now()
|
||||
habit4.ArchivedAt = &now
|
||||
repo.Update(ctx, habit4)
|
||||
|
||||
t.Run("FilterByType", func(t *testing.T) {
|
||||
habitType := value_objects.HabitTypeBoolean
|
||||
filter := repositories.HabitFilter{
|
||||
Type: &habitType,
|
||||
}
|
||||
|
||||
habits, err := repo.FindByUserIDFiltered(ctx, userID, filter, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("FindByUserIDFiltered failed: %v", err)
|
||||
}
|
||||
|
||||
if len(habits) != 1 {
|
||||
t.Errorf("Expected 1 active BOOLEAN habit, got %d", len(habits))
|
||||
}
|
||||
|
||||
if habits[0].Type != value_objects.HabitTypeBoolean {
|
||||
t.Errorf("Expected BOOLEAN type, got %s", habits[0].Type)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("FilterByFrequency", func(t *testing.T) {
|
||||
frequency := value_objects.FrequencyDaily
|
||||
filter := repositories.HabitFilter{
|
||||
Frequency: &frequency,
|
||||
}
|
||||
|
||||
habits, err := repo.FindByUserIDFiltered(ctx, userID, filter, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("FindByUserIDFiltered failed: %v", err)
|
||||
}
|
||||
|
||||
if len(habits) != 2 {
|
||||
t.Errorf("Expected 2 DAILY habits, got %d", len(habits))
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("FilterIncludeArchived", func(t *testing.T) {
|
||||
filter := repositories.HabitFilter{
|
||||
IncludeArchived: true,
|
||||
}
|
||||
|
||||
habits, err := repo.FindByUserIDFiltered(ctx, userID, filter, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("FindByUserIDFiltered failed: %v", err)
|
||||
}
|
||||
|
||||
if len(habits) != 4 {
|
||||
t.Errorf("Expected 4 habits (including archived), got %d", len(habits))
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("FilterBySearch", func(t *testing.T) {
|
||||
filter := repositories.HabitFilter{
|
||||
Search: "Exercise",
|
||||
}
|
||||
|
||||
habits, err := repo.FindByUserIDFiltered(ctx, userID, filter, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("FindByUserIDFiltered failed: %v", err)
|
||||
}
|
||||
|
||||
if len(habits) != 1 {
|
||||
t.Errorf("Expected 1 habit matching 'Exercise', got %d", len(habits))
|
||||
}
|
||||
|
||||
if habits[0].Name != "Morning Exercise" {
|
||||
t.Errorf("Expected 'Morning Exercise', got %s", habits[0].Name)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("FilterBySearchInDescription", func(t *testing.T) {
|
||||
filter := repositories.HabitFilter{
|
||||
Search: "books",
|
||||
}
|
||||
|
||||
habits, err := repo.FindByUserIDFiltered(ctx, userID, filter, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("FindByUserIDFiltered failed: %v", err)
|
||||
}
|
||||
|
||||
if len(habits) != 1 {
|
||||
t.Errorf("Expected 1 habit matching 'books' in description, got %d", len(habits))
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("CombineFilters", func(t *testing.T) {
|
||||
habitType := value_objects.HabitTypeBoolean
|
||||
frequency := value_objects.FrequencyWeekly
|
||||
filter := repositories.HabitFilter{
|
||||
Type: &habitType,
|
||||
Frequency: &frequency,
|
||||
IncludeArchived: true,
|
||||
}
|
||||
|
||||
habits, err := repo.FindByUserIDFiltered(ctx, userID, filter, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("FindByUserIDFiltered failed: %v", err)
|
||||
}
|
||||
|
||||
if len(habits) != 1 {
|
||||
t.Errorf("Expected 1 BOOLEAN WEEKLY habit (archived), got %d", len(habits))
|
||||
}
|
||||
|
||||
if habits[0].Name != "Weekly Run" {
|
||||
t.Errorf("Expected 'Weekly Run', got %s", habits[0].Name)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("WithPagination", func(t *testing.T) {
|
||||
filter := repositories.HabitFilter{}
|
||||
params := pagination.NewParams(1, 2)
|
||||
|
||||
habits, err := repo.FindByUserIDFiltered(ctx, userID, filter, ¶ms)
|
||||
if err != nil {
|
||||
t.Fatalf("FindByUserIDFiltered failed: %v", err)
|
||||
}
|
||||
|
||||
if len(habits) != 2 {
|
||||
t.Errorf("Expected 2 habits on first page, got %d", len(habits))
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestHabitRepositoryCountByUserIDFiltered(t *testing.T) {
|
||||
db := setupTestDB(t)
|
||||
defer db.Close()
|
||||
|
||||
repo := NewHabitRepository(db)
|
||||
ctx := context.Background()
|
||||
|
||||
userID := "user-count-filter-test"
|
||||
|
||||
habit1 := entities.NewHabit(userID, "Test1", value_objects.HabitTypeBoolean, value_objects.FrequencyDaily, false, false)
|
||||
repo.Create(ctx, habit1)
|
||||
|
||||
habit2 := entities.NewHabit(userID, "Test2", value_objects.HabitTypeCounter, value_objects.FrequencyDaily, false, false)
|
||||
repo.Create(ctx, habit2)
|
||||
|
||||
habit3 := entities.NewHabit(userID, "Test3", value_objects.HabitTypeBoolean, value_objects.FrequencyWeekly, false, false)
|
||||
now := time.Now()
|
||||
habit3.ArchivedAt = &now
|
||||
repo.Create(ctx, habit3)
|
||||
repo.Update(ctx, habit3)
|
||||
|
||||
t.Run("CountByType", func(t *testing.T) {
|
||||
habitType := value_objects.HabitTypeBoolean
|
||||
filter := repositories.HabitFilter{
|
||||
Type: &habitType,
|
||||
}
|
||||
|
||||
count, err := repo.CountByUserIDFiltered(ctx, userID, filter)
|
||||
if err != nil {
|
||||
t.Fatalf("CountByUserIDFiltered failed: %v", err)
|
||||
}
|
||||
|
||||
if count != 1 {
|
||||
t.Errorf("Expected 1 active BOOLEAN habit, got %d", count)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("CountWithArchived", func(t *testing.T) {
|
||||
habitType := value_objects.HabitTypeBoolean
|
||||
filter := repositories.HabitFilter{
|
||||
Type: &habitType,
|
||||
IncludeArchived: true,
|
||||
}
|
||||
|
||||
count, err := repo.CountByUserIDFiltered(ctx, userID, filter)
|
||||
if err != nil {
|
||||
t.Fatalf("CountByUserIDFiltered failed: %v", err)
|
||||
}
|
||||
|
||||
if count != 2 {
|
||||
t.Errorf("Expected 2 BOOLEAN habits (including archived), got %d", count)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user