Compare commits
19 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 88b5d11113 | |||
| 29f0f9b468 | |||
| 7575853355 | |||
| 788b6cf430 | |||
| 568ba3b016 | |||
| 935f742ac9 | |||
| 38e640c617 | |||
| f780c69806 | |||
| 5d92820591 | |||
| a2aa8b2a76 | |||
| 90d5628a42 | |||
| 9a0637aa4c | |||
| d3111ad52c | |||
| 6fb4823183 | |||
| 8d631f8fab | |||
| 06f95afc92 | |||
| f37c1ac19b | |||
| 00f6b51228 | |||
| a95a703905 |
+22
-3
@@ -1,12 +1,31 @@
|
||||
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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -8,3 +8,5 @@ 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, completion rates, progress 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,77 @@ 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.
|
||||
Access the interactive Swagger UI at `http://localhost:8080/api/v1/docs`
|
||||
|
||||
Includes endpoint reference, schemas, authentication examples, and live testing.
|
||||
|
||||
## Architecture
|
||||
|
||||
@@ -122,6 +157,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
|
||||
|
||||
+53
-12
@@ -10,10 +10,13 @@ import (
|
||||
|
||||
"apocapoc-api/internal/application/commands"
|
||||
"apocapoc-api/internal/application/queries"
|
||||
"apocapoc-api/internal/i18n"
|
||||
"apocapoc-api/internal/infrastructure/auth"
|
||||
"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,58 +44,96 @@ 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()
|
||||
|
||||
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 *email.SMTPService
|
||||
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)
|
||||
|
||||
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)
|
||||
|
||||
router := httpInfra.NewRouter(cfg.CORSOrigins, habitHandlers, authHandlers, statsHandlers, healthHandlers, jwtService)
|
||||
router := httpInfra.NewRouter(cfg.AppURL, habitHandlers, authHandlers, statsHandlers, healthHandlers, userHandlers, exportHandlers, jwtService, translator)
|
||||
|
||||
addr := fmt.Sprintf("%s:%s", cfg.Host, cfg.Port)
|
||||
log.Printf("Server starting on %s", addr)
|
||||
addr := fmt.Sprintf("0.0.0.0:%s", cfg.Port)
|
||||
logger.Info().Str("address", addr).Msg("Server starting")
|
||||
|
||||
if err := http.ListenAndServe(addr, router); err != nil {
|
||||
log.Fatalf("Server failed: %v", err)
|
||||
logger.Fatal().Err(err).Msg("Server failed")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
-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
|
||||
@@ -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=
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
package commands
|
||||
|
||||
import (
|
||||
"apocapoc-api/internal/domain/repositories"
|
||||
"apocapoc-api/internal/shared/pagination"
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
@@ -229,3 +231,19 @@ func TestCreateHabitHandler_NegativeHabit(t *testing.T) {
|
||||
t.Error("Expected habit ID to be returned")
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -537,3 +543,35 @@ 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
|
||||
}
|
||||
|
||||
@@ -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 != nil {
|
||||
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,8 @@
|
||||
package commands
|
||||
|
||||
import (
|
||||
"apocapoc-api/internal/domain/repositories"
|
||||
"apocapoc-api/internal/shared/pagination"
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
@@ -25,6 +27,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 +42,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 +71,26 @@ func TestRegisterUserHandler_Success(t *testing.T) {
|
||||
},
|
||||
}
|
||||
hasher := &mockPasswordHasher{}
|
||||
handler := NewRegisterUserHandler(repo, hasher)
|
||||
handler := NewRegisterUserHandler(repo, hasher, nil, "", "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 emailService is nil")
|
||||
}
|
||||
|
||||
if createdUser == nil {
|
||||
t.Fatal("expected user to be created")
|
||||
}
|
||||
@@ -85,16 +98,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, nil, "", "open", false)
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
@@ -113,11 +122,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 +135,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, nil, "", "open", false)
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
@@ -148,42 +156,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 +167,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, nil, "", "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 +195,11 @@ func TestRegisterUserHandler_PasswordHashingError(t *testing.T) {
|
||||
return "", expectedErr
|
||||
},
|
||||
}
|
||||
handler := NewRegisterUserHandler(repo, hasher)
|
||||
handler := NewRegisterUserHandler(repo, hasher, nil, "", "open", false)
|
||||
|
||||
cmd := RegisterUserCommand{
|
||||
Email: "test@example.com",
|
||||
Password: "Secure123!",
|
||||
Timezone: "UTC",
|
||||
}
|
||||
|
||||
_, err := handler.Handle(context.Background(), cmd)
|
||||
@@ -242,12 +216,11 @@ func TestRegisterUserHandler_RepositoryError(t *testing.T) {
|
||||
},
|
||||
}
|
||||
hasher := &mockPasswordHasher{}
|
||||
handler := NewRegisterUserHandler(repo, hasher)
|
||||
handler := NewRegisterUserHandler(repo, hasher, nil, "", "open", false)
|
||||
|
||||
cmd := RegisterUserCommand{
|
||||
Email: "test@example.com",
|
||||
Password: "Secure123!",
|
||||
Timezone: "UTC",
|
||||
}
|
||||
|
||||
_, err := handler.Handle(context.Background(), cmd)
|
||||
@@ -259,7 +232,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, nil, "", "open", false)
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
@@ -271,7 +244,6 @@ func TestRegisterUserHandler_EdgeCases(t *testing.T) {
|
||||
RegisterUserCommand{
|
||||
Email: "user+tag@example.com",
|
||||
Password: "Secure123!",
|
||||
Timezone: "UTC",
|
||||
},
|
||||
nil,
|
||||
},
|
||||
@@ -280,16 +252,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 +260,6 @@ func TestRegisterUserHandler_EdgeCases(t *testing.T) {
|
||||
RegisterUserCommand{
|
||||
Email: "user@example.com",
|
||||
Password: "Sëcure123!",
|
||||
Timezone: "UTC",
|
||||
},
|
||||
nil,
|
||||
},
|
||||
@@ -307,7 +268,6 @@ func TestRegisterUserHandler_EdgeCases(t *testing.T) {
|
||||
RegisterUserCommand{
|
||||
Email: "user@example.com",
|
||||
Password: "ValidP@ss1" + string(make([]byte, 100)),
|
||||
Timezone: "UTC",
|
||||
},
|
||||
nil,
|
||||
},
|
||||
@@ -322,3 +282,34 @@ func TestRegisterUserHandler_EdgeCases(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
func TestRegisterUserHandler_ClosedRegistration(t *testing.T) {
|
||||
repo := &mockUserRepo{}
|
||||
hasher := &mockPasswordHasher{}
|
||||
handler := NewRegisterUserHandler(repo, hasher, nil, "", "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,374 @@
|
||||
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 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,19 @@ 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
|
||||
}
|
||||
|
||||
@@ -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,360 @@
|
||||
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 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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -107,6 +121,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 +133,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 +152,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 +329,11 @@ 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
|
||||
}
|
||||
|
||||
@@ -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,63 @@
|
||||
package queries
|
||||
|
||||
import (
|
||||
"apocapoc-api/internal/domain/repositories"
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"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 +65,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 +73,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 +105,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 +122,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 +130,290 @@ 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 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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ type HabitEntryRepository interface {
|
||||
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
|
||||
|
||||
@@ -4,13 +4,26 @@ import (
|
||||
"context"
|
||||
|
||||
"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 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
|
||||
}
|
||||
|
||||
@@ -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,13 @@
|
||||
package services
|
||||
|
||||
type EmailMessage struct {
|
||||
To string
|
||||
Subject string
|
||||
Body string
|
||||
IsHTML bool
|
||||
}
|
||||
|
||||
type EmailService interface {
|
||||
Send(message EmailMessage) error
|
||||
HealthCheck() error
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -10,12 +10,21 @@ import (
|
||||
type Config struct {
|
||||
DBPath string
|
||||
Port string
|
||||
Host string
|
||||
AppURL string
|
||||
JWTSecret string
|
||||
JWTExpiry string
|
||||
RefreshTokenExpiry string
|
||||
CORSOrigins string
|
||||
DefaultTimezone string
|
||||
SMTPHost string
|
||||
SMTPPort string
|
||||
SMTPUser string
|
||||
SMTPPassword string
|
||||
SMTPFrom string
|
||||
SupportEmail string
|
||||
SendWelcomeEmail string
|
||||
RegistrationMode string
|
||||
LogLevel string
|
||||
Environment string
|
||||
}
|
||||
|
||||
func Load() (*Config, error) {
|
||||
@@ -24,17 +33,29 @@ func Load() (*Config, error) {
|
||||
cfg := &Config{
|
||||
DBPath: os.Getenv("DB_PATH"),
|
||||
Port: getEnvOrDefault("PORT", "8080"),
|
||||
Host: getEnvOrDefault("HOST", "0.0.0.0"),
|
||||
AppURL: getEnvOrDefault("APP_URL", "http://localhost:8080"),
|
||||
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"),
|
||||
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"),
|
||||
}
|
||||
|
||||
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 +65,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,133 @@
|
||||
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) 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, "")
|
||||
|
||||
|
||||
@@ -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 {
|
||||
@@ -48,14 +49,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 +77,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 +100,8 @@ type HabitEntriesResponse struct {
|
||||
type ErrorResponse struct {
|
||||
Error string `json:"error"`
|
||||
}
|
||||
|
||||
type ValidationErrorResponse struct {
|
||||
Error string `json:"error"`
|
||||
Field string `json:"field"`
|
||||
}
|
||||
|
||||
@@ -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"]
|
||||
|
||||
@@ -4,11 +4,15 @@ import (
|
||||
"encoding/json"
|
||||
"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 +27,7 @@ type HabitHandlers struct {
|
||||
archiveHandler *commands.ArchiveHabitHandler
|
||||
markHandler *commands.MarkHabitHandler
|
||||
unmarkHandler *commands.UnmarkHabitHandler
|
||||
translator *i18n.Translator
|
||||
}
|
||||
|
||||
func NewHabitHandlers(
|
||||
@@ -35,6 +40,7 @@ func NewHabitHandlers(
|
||||
archiveHandler *commands.ArchiveHabitHandler,
|
||||
markHandler *commands.MarkHabitHandler,
|
||||
unmarkHandler *commands.UnmarkHabitHandler,
|
||||
translator *i18n.Translator,
|
||||
) *HabitHandlers {
|
||||
return &HabitHandlers{
|
||||
createHandler: createHandler,
|
||||
@@ -46,6 +52,7 @@ func NewHabitHandlers(
|
||||
archiveHandler: archiveHandler,
|
||||
markHandler: markHandler,
|
||||
unmarkHandler: unmarkHandler,
|
||||
translator: translator,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -65,13 +72,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
|
||||
}
|
||||
|
||||
@@ -94,7 +101,7 @@ func (h *HabitHandlers) CreateHabit(w http.ResponseWriter, r *http.Request) {
|
||||
respondError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
respondError(w, http.StatusInternalServerError, "Failed to create habit")
|
||||
respondErrorI18n(w, r, h.translator, http.StatusInternalServerError, "failed_create_habit")
|
||||
return
|
||||
}
|
||||
|
||||
@@ -103,18 +110,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 +135,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 +211,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 +240,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 +252,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 +298,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
|
||||
}
|
||||
|
||||
@@ -244,18 +321,18 @@ func (h *HabitHandlers) UpdateHabit(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
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 +357,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 +368,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 +405,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 +417,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 +426,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 +447,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 +458,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 +474,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 +508,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 +571,7 @@ func (h *HabitHandlers) GetTodaysHabits(w http.ResponseWriter, r *http.Request)
|
||||
IsNegative: habit.IsNegative,
|
||||
ScheduledDate: habit.ScheduledDate,
|
||||
IsCarriedOver: habit.IsCarriedOver,
|
||||
Entry: entryResponse,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -496,13 +598,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 +616,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 +651,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 +669,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 +692,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
|
||||
|
||||
@@ -131,30 +123,22 @@ 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",
|
||||
}
|
||||
rr := makeRequest(t, *ts.Router, "POST", "/api/v1/auth/register", registerBody, "")
|
||||
var authResp AuthResponse
|
||||
decodeResponse(t, rr, &authResp)
|
||||
otherToken := authResp.Token
|
||||
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,7 @@ import (
|
||||
|
||||
"apocapoc-api/internal/application/commands"
|
||||
"apocapoc-api/internal/application/queries"
|
||||
"apocapoc-api/internal/i18n"
|
||||
"apocapoc-api/internal/infrastructure/auth"
|
||||
"apocapoc-api/internal/infrastructure/crypto"
|
||||
"apocapoc-api/internal/infrastructure/persistence/sqlite"
|
||||
@@ -40,18 +41,24 @@ 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)
|
||||
registerHandler := commands.NewRegisterUserHandler(userRepo, passwordHasher, nil, "", "open", false)
|
||||
loginHandler := queries.NewLoginUserHandler(userRepo, passwordHasher)
|
||||
refreshTokenHandler := queries.NewRefreshTokenHandler(refreshTokenRepo, userRepo)
|
||||
revokeTokenHandler := commands.NewRevokeTokenHandler(refreshTokenRepo)
|
||||
revokeAllTokensHandler := commands.NewRevokeAllTokensHandler(refreshTokenRepo)
|
||||
verifyEmailHandler := commands.NewVerifyEmailHandler(userRepo, nil, false)
|
||||
resendVerificationEmailHandler := commands.NewResendVerificationEmailHandler(userRepo, nil, "")
|
||||
requestPasswordResetHandler := commands.NewRequestPasswordResetHandler(userRepo, passwordResetTokenRepo, nil, "")
|
||||
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 +66,18 @@ 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()
|
||||
|
||||
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)
|
||||
|
||||
router := NewRouter("http://localhost:3000", habitHandlers, authHandlers, statsHandlers, healthHandlers, userHandlers, exportHandlers, jwtService, translator)
|
||||
|
||||
handler := http.Handler(router)
|
||||
return &TestServer{
|
||||
@@ -104,3 +117,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)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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, 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,17 @@ 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)
|
||||
})
|
||||
|
||||
return r
|
||||
}
|
||||
|
||||
@@ -6,18 +6,22 @@ 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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,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)
|
||||
}
|
||||
@@ -185,6 +185,24 @@ 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
|
||||
FROM habit_entries he
|
||||
INNER JOIN habits h ON he.habit_id = h.id
|
||||
WHERE h.user_id = ?
|
||||
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
|
||||
|
||||
@@ -7,7 +7,9 @@ import (
|
||||
"fmt"
|
||||
|
||||
"apocapoc-api/internal/domain/entities"
|
||||
"apocapoc-api/internal/domain/repositories"
|
||||
"apocapoc-api/internal/shared/errors"
|
||||
"apocapoc-api/internal/shared/pagination"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
@@ -245,3 +247,129 @@ 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, archived_at
|
||||
FROM habits
|
||||
WHERE user_id = ? AND archived_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
|
||||
`
|
||||
|
||||
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, archived_at
|
||||
FROM habits
|
||||
WHERE user_id = ?`
|
||||
|
||||
args := []interface{}{userID}
|
||||
conditions := []string{}
|
||||
|
||||
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{}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ package sqlite
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
func RunMigrations(db *sql.DB) error {
|
||||
@@ -10,6 +11,7 @@ func RunMigrations(db *sql.DB) error {
|
||||
createHabitsTable,
|
||||
createHabitEntriesTable,
|
||||
createRefreshTokensTable,
|
||||
createPasswordResetTokensTable,
|
||||
createIndexes,
|
||||
}
|
||||
|
||||
@@ -18,15 +20,76 @@ func RunMigrations(db *sql.DB) error {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
if err := addEmailVerificationColumns(db); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := removeTimezoneColumn(db); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func addEmailVerificationColumns(db *sql.DB) error {
|
||||
columns := []struct {
|
||||
name string
|
||||
definition string
|
||||
}{
|
||||
{"email_verified", "ALTER TABLE users ADD COLUMN email_verified BOOLEAN DEFAULT 0"},
|
||||
{"email_verification_token", "ALTER TABLE users ADD COLUMN email_verification_token TEXT"},
|
||||
{"email_verification_expiry", "ALTER TABLE users ADD COLUMN email_verification_expiry DATETIME"},
|
||||
}
|
||||
|
||||
for _, col := range columns {
|
||||
exists, err := columnExists(db, "users", col.name)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if !exists {
|
||||
if _, err := db.Exec(col.definition); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func removeTimezoneColumn(db *sql.DB) error {
|
||||
exists, err := columnExists(db, "users", "timezone")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if !exists {
|
||||
return nil
|
||||
}
|
||||
|
||||
if _, err := db.Exec("ALTER TABLE users DROP COLUMN timezone"); err != nil {
|
||||
return fmt.Errorf("failed to drop timezone column: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func columnExists(db *sql.DB, table, column string) (bool, error) {
|
||||
query := fmt.Sprintf("SELECT COUNT(*) FROM pragma_table_info('%s') WHERE name = ?", table)
|
||||
var count int
|
||||
err := db.QueryRow(query, column).Scan(&count)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
return count > 0, nil
|
||||
}
|
||||
|
||||
const createUsersTable = `
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
id TEXT PRIMARY KEY,
|
||||
email TEXT UNIQUE NOT NULL,
|
||||
password_hash TEXT NOT NULL,
|
||||
timezone TEXT DEFAULT 'UTC',
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
@@ -75,6 +138,18 @@ CREATE TABLE IF NOT EXISTS refresh_tokens (
|
||||
);
|
||||
`
|
||||
|
||||
const createPasswordResetTokensTable = `
|
||||
CREATE TABLE IF NOT EXISTS password_reset_tokens (
|
||||
id TEXT PRIMARY KEY,
|
||||
user_id TEXT NOT NULL,
|
||||
token TEXT UNIQUE NOT NULL,
|
||||
expires_at DATETIME NOT NULL,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
used_at DATETIME,
|
||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||
);
|
||||
`
|
||||
|
||||
const createIndexes = `
|
||||
CREATE INDEX IF NOT EXISTS idx_habits_user ON habits(user_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_habits_active ON habits(user_id, archived_at);
|
||||
@@ -82,4 +157,6 @@ CREATE INDEX IF NOT EXISTS idx_entries_habit ON habit_entries(habit_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_entries_scheduled ON habit_entries(scheduled_date);
|
||||
CREATE INDEX IF NOT EXISTS idx_refresh_tokens_user ON refresh_tokens(user_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_refresh_tokens_token ON refresh_tokens(token);
|
||||
CREATE INDEX IF NOT EXISTS idx_password_reset_tokens_user ON password_reset_tokens(user_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_password_reset_tokens_token ON password_reset_tokens(token);
|
||||
`
|
||||
|
||||
@@ -43,7 +43,7 @@ func TestUsersTableSchema(t *testing.T) {
|
||||
t.Fatalf("RunMigrations failed: %v", err)
|
||||
}
|
||||
|
||||
columns := []string{"id", "email", "password_hash", "timezone", "created_at", "updated_at"}
|
||||
columns := []string{"id", "email", "password_hash", "email_verified", "email_verification_token", "email_verification_expiry", "created_at", "updated_at"}
|
||||
for _, col := range columns {
|
||||
query := "SELECT " + col + " FROM users LIMIT 0"
|
||||
rows, err := db.Query(query)
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
package sqlite
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"time"
|
||||
|
||||
"apocapoc-api/internal/domain/entities"
|
||||
"apocapoc-api/internal/shared/errors"
|
||||
)
|
||||
|
||||
type PasswordResetTokenRepository struct {
|
||||
db *sql.DB
|
||||
}
|
||||
|
||||
func NewPasswordResetTokenRepository(db *sql.DB) *PasswordResetTokenRepository {
|
||||
return &PasswordResetTokenRepository{db: db}
|
||||
}
|
||||
|
||||
func (r *PasswordResetTokenRepository) Create(ctx context.Context, token *entities.PasswordResetToken) error {
|
||||
query := `
|
||||
INSERT INTO password_reset_tokens (id, user_id, token, expires_at, created_at, used_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?)
|
||||
`
|
||||
|
||||
_, err := r.db.ExecContext(ctx, query,
|
||||
token.ID,
|
||||
token.UserID,
|
||||
token.Token,
|
||||
token.ExpiresAt,
|
||||
token.CreatedAt,
|
||||
token.UsedAt,
|
||||
)
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
func (r *PasswordResetTokenRepository) FindByToken(ctx context.Context, tokenStr string) (*entities.PasswordResetToken, error) {
|
||||
query := `
|
||||
SELECT id, user_id, token, expires_at, created_at, used_at
|
||||
FROM password_reset_tokens
|
||||
WHERE token = ?
|
||||
`
|
||||
|
||||
token := &entities.PasswordResetToken{}
|
||||
err := r.db.QueryRowContext(ctx, query, tokenStr).Scan(
|
||||
&token.ID,
|
||||
&token.UserID,
|
||||
&token.Token,
|
||||
&token.ExpiresAt,
|
||||
&token.CreatedAt,
|
||||
&token.UsedAt,
|
||||
)
|
||||
|
||||
if err == sql.ErrNoRows {
|
||||
return nil, errors.ErrNotFound
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return token, nil
|
||||
}
|
||||
|
||||
func (r *PasswordResetTokenRepository) Update(ctx context.Context, token *entities.PasswordResetToken) error {
|
||||
query := `
|
||||
UPDATE password_reset_tokens
|
||||
SET used_at = ?
|
||||
WHERE id = ?
|
||||
`
|
||||
|
||||
result, err := r.db.ExecContext(ctx, query, token.UsedAt, token.ID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
rows, err := result.RowsAffected()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if rows == 0 {
|
||||
return errors.ErrNotFound
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *PasswordResetTokenRepository) DeleteExpired(ctx context.Context) error {
|
||||
query := `
|
||||
DELETE FROM password_reset_tokens
|
||||
WHERE expires_at < ?
|
||||
`
|
||||
|
||||
_, err := r.db.ExecContext(ctx, query, time.Now())
|
||||
return err
|
||||
}
|
||||
@@ -24,15 +24,17 @@ func (r *UserRepository) Create(ctx context.Context, user *entities.User) error
|
||||
user.ID = uuid.New().String()
|
||||
|
||||
query := `
|
||||
INSERT INTO users (id, email, password_hash, timezone, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?)
|
||||
INSERT INTO users (id, email, password_hash, email_verified, email_verification_token, email_verification_expiry, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`
|
||||
|
||||
_, err := r.db.ExecContext(ctx, query,
|
||||
user.ID,
|
||||
user.Email,
|
||||
user.PasswordHash,
|
||||
user.Timezone,
|
||||
user.EmailVerified,
|
||||
user.EmailVerificationToken,
|
||||
user.EmailVerificationExpiry,
|
||||
user.CreatedAt,
|
||||
user.UpdatedAt,
|
||||
)
|
||||
@@ -49,7 +51,7 @@ func (r *UserRepository) Create(ctx context.Context, user *entities.User) error
|
||||
|
||||
func (r *UserRepository) FindByID(ctx context.Context, id string) (*entities.User, error) {
|
||||
query := `
|
||||
SELECT id, email, password_hash, timezone, created_at, updated_at
|
||||
SELECT id, email, password_hash, email_verified, email_verification_token, email_verification_expiry, created_at, updated_at
|
||||
FROM users
|
||||
WHERE id = ?
|
||||
`
|
||||
@@ -59,7 +61,9 @@ func (r *UserRepository) FindByID(ctx context.Context, id string) (*entities.Use
|
||||
&user.ID,
|
||||
&user.Email,
|
||||
&user.PasswordHash,
|
||||
&user.Timezone,
|
||||
&user.EmailVerified,
|
||||
&user.EmailVerificationToken,
|
||||
&user.EmailVerificationExpiry,
|
||||
&user.CreatedAt,
|
||||
&user.UpdatedAt,
|
||||
)
|
||||
@@ -76,7 +80,7 @@ func (r *UserRepository) FindByID(ctx context.Context, id string) (*entities.Use
|
||||
|
||||
func (r *UserRepository) FindByEmail(ctx context.Context, email string) (*entities.User, error) {
|
||||
query := `
|
||||
SELECT id, email, password_hash, timezone, created_at, updated_at
|
||||
SELECT id, email, password_hash, email_verified, email_verification_token, email_verification_expiry, created_at, updated_at
|
||||
FROM users
|
||||
WHERE email = ?
|
||||
`
|
||||
@@ -86,7 +90,9 @@ func (r *UserRepository) FindByEmail(ctx context.Context, email string) (*entiti
|
||||
&user.ID,
|
||||
&user.Email,
|
||||
&user.PasswordHash,
|
||||
&user.Timezone,
|
||||
&user.EmailVerified,
|
||||
&user.EmailVerificationToken,
|
||||
&user.EmailVerificationExpiry,
|
||||
&user.CreatedAt,
|
||||
&user.UpdatedAt,
|
||||
)
|
||||
@@ -101,17 +107,48 @@ func (r *UserRepository) FindByEmail(ctx context.Context, email string) (*entiti
|
||||
return &user, nil
|
||||
}
|
||||
|
||||
func (r *UserRepository) FindByVerificationToken(ctx context.Context, token string) (*entities.User, error) {
|
||||
query := `
|
||||
SELECT id, email, password_hash, email_verified, email_verification_token, email_verification_expiry, created_at, updated_at
|
||||
FROM users
|
||||
WHERE email_verification_token = ?
|
||||
`
|
||||
|
||||
var user entities.User
|
||||
err := r.db.QueryRowContext(ctx, query, token).Scan(
|
||||
&user.ID,
|
||||
&user.Email,
|
||||
&user.PasswordHash,
|
||||
&user.EmailVerified,
|
||||
&user.EmailVerificationToken,
|
||||
&user.EmailVerificationExpiry,
|
||||
&user.CreatedAt,
|
||||
&user.UpdatedAt,
|
||||
)
|
||||
|
||||
if err == sql.ErrNoRows {
|
||||
return nil, errors.ErrNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to find user by verification token: %w", err)
|
||||
}
|
||||
|
||||
return &user, nil
|
||||
}
|
||||
|
||||
func (r *UserRepository) Update(ctx context.Context, user *entities.User) error {
|
||||
query := `
|
||||
UPDATE users
|
||||
SET email = ?, password_hash = ?, timezone = ?, updated_at = ?
|
||||
SET email = ?, password_hash = ?, email_verified = ?, email_verification_token = ?, email_verification_expiry = ?, updated_at = ?
|
||||
WHERE id = ?
|
||||
`
|
||||
|
||||
result, err := r.db.ExecContext(ctx, query,
|
||||
user.Email,
|
||||
user.PasswordHash,
|
||||
user.Timezone,
|
||||
user.EmailVerified,
|
||||
user.EmailVerificationToken,
|
||||
user.EmailVerificationExpiry,
|
||||
user.UpdatedAt,
|
||||
user.ID,
|
||||
)
|
||||
@@ -128,6 +165,22 @@ func (r *UserRepository) Update(ctx context.Context, user *entities.User) error
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *UserRepository) Delete(ctx context.Context, id string) error {
|
||||
query := `DELETE FROM users WHERE id = ?`
|
||||
|
||||
result, err := r.db.ExecContext(ctx, query, id)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to delete user: %w", err)
|
||||
}
|
||||
|
||||
rows, _ := result.RowsAffected()
|
||||
if rows == 0 {
|
||||
return errors.ErrNotFound
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func isUniqueConstraintError(err error) bool {
|
||||
return err != nil && strings.Contains(err.Error(), "UNIQUE constraint failed")
|
||||
}
|
||||
|
||||
@@ -35,7 +35,6 @@ func TestUserRepositoryCreate(t *testing.T) {
|
||||
user := &entities.User{
|
||||
Email: "test@example.com",
|
||||
PasswordHash: "hashed_password",
|
||||
Timezone: "UTC",
|
||||
CreatedAt: time.Now(),
|
||||
UpdatedAt: time.Now(),
|
||||
}
|
||||
@@ -60,7 +59,6 @@ func TestUserRepositoryCreateDuplicateEmail(t *testing.T) {
|
||||
user1 := &entities.User{
|
||||
Email: "duplicate@example.com",
|
||||
PasswordHash: "hash1",
|
||||
Timezone: "UTC",
|
||||
CreatedAt: time.Now(),
|
||||
UpdatedAt: time.Now(),
|
||||
}
|
||||
@@ -73,7 +71,6 @@ func TestUserRepositoryCreateDuplicateEmail(t *testing.T) {
|
||||
user2 := &entities.User{
|
||||
Email: "duplicate@example.com",
|
||||
PasswordHash: "hash2",
|
||||
Timezone: "UTC",
|
||||
CreatedAt: time.Now(),
|
||||
UpdatedAt: time.Now(),
|
||||
}
|
||||
@@ -94,7 +91,6 @@ func TestUserRepositoryFindByID(t *testing.T) {
|
||||
user := &entities.User{
|
||||
Email: "find@example.com",
|
||||
PasswordHash: "hashed",
|
||||
Timezone: "America/New_York",
|
||||
CreatedAt: time.Now(),
|
||||
UpdatedAt: time.Now(),
|
||||
}
|
||||
@@ -115,9 +111,6 @@ func TestUserRepositoryFindByID(t *testing.T) {
|
||||
if found.Email != user.Email {
|
||||
t.Errorf("Expected email %s, got %s", user.Email, found.Email)
|
||||
}
|
||||
if found.Timezone != user.Timezone {
|
||||
t.Errorf("Expected timezone %s, got %s", user.Timezone, found.Timezone)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUserRepositoryFindByIDNotFound(t *testing.T) {
|
||||
@@ -143,7 +136,6 @@ func TestUserRepositoryFindByEmail(t *testing.T) {
|
||||
user := &entities.User{
|
||||
Email: "email@test.com",
|
||||
PasswordHash: "hashed",
|
||||
Timezone: "UTC",
|
||||
CreatedAt: time.Now(),
|
||||
UpdatedAt: time.Now(),
|
||||
}
|
||||
@@ -189,7 +181,6 @@ func TestUserRepositoryUpdate(t *testing.T) {
|
||||
user := &entities.User{
|
||||
Email: "original@example.com",
|
||||
PasswordHash: "hash1",
|
||||
Timezone: "UTC",
|
||||
CreatedAt: time.Now(),
|
||||
UpdatedAt: time.Now(),
|
||||
}
|
||||
@@ -200,7 +191,6 @@ func TestUserRepositoryUpdate(t *testing.T) {
|
||||
}
|
||||
|
||||
user.Email = "updated@example.com"
|
||||
user.Timezone = "Europe/Madrid"
|
||||
user.UpdatedAt = time.Now()
|
||||
|
||||
err = repo.Update(ctx, user)
|
||||
@@ -216,9 +206,6 @@ func TestUserRepositoryUpdate(t *testing.T) {
|
||||
if found.Email != "updated@example.com" {
|
||||
t.Errorf("Expected email updated@example.com, got %s", found.Email)
|
||||
}
|
||||
if found.Timezone != "Europe/Madrid" {
|
||||
t.Errorf("Expected timezone Europe/Madrid, got %s", found.Timezone)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUserRepositoryUpdateNotFound(t *testing.T) {
|
||||
@@ -232,7 +219,6 @@ func TestUserRepositoryUpdateNotFound(t *testing.T) {
|
||||
ID: "non-existent",
|
||||
Email: "test@example.com",
|
||||
PasswordHash: "hash",
|
||||
Timezone: "UTC",
|
||||
CreatedAt: time.Now(),
|
||||
UpdatedAt: time.Now(),
|
||||
}
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
package constants
|
||||
|
||||
const (
|
||||
AppName = "Apocapoc"
|
||||
)
|
||||
@@ -8,4 +8,6 @@ var (
|
||||
ErrInvalidInput = errors.New("invalid input")
|
||||
ErrUnauthorized = errors.New("unauthorized")
|
||||
ErrInvalidCredentials = errors.New("invalid credentials")
|
||||
ErrEmailNotVerified = errors.New("email not verified")
|
||||
ErrRegistrationClosed = errors.New("registration is closed")
|
||||
)
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
package pagination
|
||||
|
||||
type Params struct {
|
||||
Page int
|
||||
PageSize int
|
||||
}
|
||||
|
||||
func NewParams(page, pageSize int) Params {
|
||||
if page < 1 {
|
||||
page = 1
|
||||
}
|
||||
if pageSize < 1 {
|
||||
pageSize = 50
|
||||
}
|
||||
if pageSize > 100 {
|
||||
pageSize = 100
|
||||
}
|
||||
return Params{
|
||||
Page: page,
|
||||
PageSize: pageSize,
|
||||
}
|
||||
}
|
||||
|
||||
func (p Params) Offset() int {
|
||||
return (p.Page - 1) * p.PageSize
|
||||
}
|
||||
|
||||
func (p Params) Limit() int {
|
||||
return p.PageSize
|
||||
}
|
||||
|
||||
type Response struct {
|
||||
Page int `json:"page"`
|
||||
PageSize int `json:"page_size"`
|
||||
TotalItems int `json:"total_items"`
|
||||
TotalPages int `json:"total_pages"`
|
||||
}
|
||||
|
||||
func NewResponse(params Params, totalItems int) Response {
|
||||
totalPages := totalItems / params.PageSize
|
||||
if totalItems%params.PageSize > 0 {
|
||||
totalPages++
|
||||
}
|
||||
if totalPages < 1 {
|
||||
totalPages = 1
|
||||
}
|
||||
|
||||
return Response{
|
||||
Page: params.Page,
|
||||
PageSize: params.PageSize,
|
||||
TotalItems: totalItems,
|
||||
TotalPages: totalPages,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
package pagination
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestNewParams(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
page int
|
||||
pageSize int
|
||||
expectedPage int
|
||||
expectedSize int
|
||||
}{
|
||||
{"valid params", 1, 20, 1, 20},
|
||||
{"valid params page 2", 2, 50, 2, 50},
|
||||
{"page less than 1 defaults to 1", 0, 20, 1, 20},
|
||||
{"negative page defaults to 1", -5, 20, 1, 20},
|
||||
{"pageSize less than 1 defaults to 50", 1, 0, 1, 50},
|
||||
{"negative pageSize defaults to 50", 1, -10, 1, 50},
|
||||
{"pageSize greater than 100 caps at 100", 1, 200, 1, 100},
|
||||
{"page 100", 1, 101, 1, 100},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
params := NewParams(tt.page, tt.pageSize)
|
||||
if params.Page != tt.expectedPage {
|
||||
t.Errorf("Page = %d, want %d", params.Page, tt.expectedPage)
|
||||
}
|
||||
if params.PageSize != tt.expectedSize {
|
||||
t.Errorf("PageSize = %d, want %d", params.PageSize, tt.expectedSize)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestParamsOffset(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
page int
|
||||
pageSize int
|
||||
expectedOffset int
|
||||
}{
|
||||
{"first page", 1, 20, 0},
|
||||
{"second page", 2, 20, 20},
|
||||
{"third page", 3, 20, 40},
|
||||
{"page 10 size 50", 10, 50, 450},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
params := Params{Page: tt.page, PageSize: tt.pageSize}
|
||||
offset := params.Offset()
|
||||
if offset != tt.expectedOffset {
|
||||
t.Errorf("Offset() = %d, want %d", offset, tt.expectedOffset)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestParamsLimit(t *testing.T) {
|
||||
params := Params{Page: 1, PageSize: 25}
|
||||
if params.Limit() != 25 {
|
||||
t.Errorf("Limit() = %d, want 25", params.Limit())
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewResponse(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
params Params
|
||||
totalItems int
|
||||
expectedPages int
|
||||
expectedTotal int
|
||||
}{
|
||||
{"exact division", Params{1, 20}, 100, 5, 100},
|
||||
{"with remainder", Params{1, 20}, 105, 6, 105},
|
||||
{"less than page size", Params{1, 20}, 15, 1, 15},
|
||||
{"zero items", Params{1, 20}, 0, 1, 0},
|
||||
{"one item", Params{1, 20}, 1, 1, 1},
|
||||
{"large dataset", Params{1, 50}, 1000, 20, 1000},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
response := NewResponse(tt.params, tt.totalItems)
|
||||
if response.Page != tt.params.Page {
|
||||
t.Errorf("Page = %d, want %d", response.Page, tt.params.Page)
|
||||
}
|
||||
if response.PageSize != tt.params.PageSize {
|
||||
t.Errorf("PageSize = %d, want %d", response.PageSize, tt.params.PageSize)
|
||||
}
|
||||
if response.TotalPages != tt.expectedPages {
|
||||
t.Errorf("TotalPages = %d, want %d", response.TotalPages, tt.expectedPages)
|
||||
}
|
||||
if response.TotalItems != tt.expectedTotal {
|
||||
t.Errorf("TotalItems = %d, want %d", response.TotalItems, tt.expectedTotal)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -114,7 +114,7 @@ func ValidateTimezone(timezone string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func ValidateRegistration(email, password, timezone string) error {
|
||||
func ValidateRegistration(email, password string) error {
|
||||
if err := ValidateEmail(email); err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -123,9 +123,5 @@ func ValidateRegistration(email, password, timezone string) error {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := ValidateTimezone(timezone); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -118,77 +118,55 @@ func TestValidateRegistration(t *testing.T) {
|
||||
name string
|
||||
email string
|
||||
password string
|
||||
timezone string
|
||||
wantErr bool
|
||||
}{
|
||||
{
|
||||
"valid registration",
|
||||
"user@example.com",
|
||||
"Passw0rd!",
|
||||
"UTC",
|
||||
false,
|
||||
},
|
||||
{
|
||||
"valid with complex email",
|
||||
"user.name+tag@example.co.uk",
|
||||
"MyS3cur3P@ss",
|
||||
"Europe/Madrid",
|
||||
false,
|
||||
},
|
||||
{
|
||||
"invalid email",
|
||||
"invalid-email",
|
||||
"Passw0rd!",
|
||||
"UTC",
|
||||
true,
|
||||
},
|
||||
{
|
||||
"invalid password",
|
||||
"user@example.com",
|
||||
"weak",
|
||||
"UTC",
|
||||
true,
|
||||
},
|
||||
{
|
||||
"invalid timezone",
|
||||
"user@example.com",
|
||||
"Passw0rd!",
|
||||
"InvalidTZ",
|
||||
true,
|
||||
},
|
||||
{
|
||||
"all invalid",
|
||||
"not-an-email",
|
||||
"weak",
|
||||
"bad-tz",
|
||||
true,
|
||||
},
|
||||
{
|
||||
"empty email",
|
||||
"",
|
||||
"Passw0rd!",
|
||||
"UTC",
|
||||
true,
|
||||
},
|
||||
{
|
||||
"empty password",
|
||||
"user@example.com",
|
||||
"",
|
||||
"UTC",
|
||||
true,
|
||||
},
|
||||
{
|
||||
"empty timezone",
|
||||
"user@example.com",
|
||||
"Passw0rd!",
|
||||
"",
|
||||
true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
err := ValidateRegistration(tt.email, tt.password, tt.timezone)
|
||||
err := ValidateRegistration(tt.email, tt.password)
|
||||
if (err != nil) != tt.wantErr {
|
||||
t.Errorf("ValidateRegistration() error = %v, wantErr %v", err, tt.wantErr)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user