Compare commits
10 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| a2aa8b2a76 | |||
| 90d5628a42 | |||
| 9a0637aa4c | |||
| d3111ad52c | |||
| 6fb4823183 | |||
| 8d631f8fab | |||
| 06f95afc92 | |||
| f37c1ac19b | |||
| 00f6b51228 | |||
| a95a703905 |
+18
-3
@@ -1,12 +1,27 @@
|
||||
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
|
||||
|
||||
@@ -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
|
||||
|
||||
+39
-6
@@ -10,9 +10,11 @@ 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/persistence/sqlite"
|
||||
)
|
||||
@@ -60,16 +62,46 @@ func main() {
|
||||
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 {
|
||||
log.Fatalf("Invalid SMTP_PORT: %v", err)
|
||||
}
|
||||
|
||||
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 {
|
||||
log.Fatalf("Failed to create translator: %v", err)
|
||||
}
|
||||
|
||||
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)
|
||||
@@ -81,14 +113,15 @@ func main() {
|
||||
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)
|
||||
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, userRepo, translator)
|
||||
statsHandlers := httpInfra.NewStatsHandlers(getHabitStatsHandler, translator)
|
||||
healthHandlers := httpInfra.NewHealthHandlers(db.Conn())
|
||||
userHandlers := httpInfra.NewUserHandlers(deleteUserHandler, translator)
|
||||
|
||||
router := httpInfra.NewRouter(cfg.CORSOrigins, habitHandlers, authHandlers, statsHandlers, healthHandlers, jwtService)
|
||||
router := httpInfra.NewRouter(cfg.AppURL, habitHandlers, authHandlers, statsHandlers, healthHandlers, userHandlers, jwtService, translator)
|
||||
|
||||
addr := fmt.Sprintf("%s:%s", cfg.Host, cfg.Port)
|
||||
addr := fmt.Sprintf("0.0.0.0:%s", cfg.Port)
|
||||
log.Printf("Server starting on %s", addr)
|
||||
|
||||
if err := http.ListenAndServe(addr, router); err != nil {
|
||||
|
||||
-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
|
||||
@@ -34,7 +34,10 @@ require (
|
||||
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/text v0.31.0 // indirect
|
||||
golang.org/x/tools v0.38.0 // indirect
|
||||
gopkg.in/alexcesaro/quotedprintable.v3 v3.0.0-20150716171945-2caba252f4dc // indirect
|
||||
gopkg.in/mail.v2 v2.3.1 // indirect
|
||||
gopkg.in/yaml.v2 v2.4.0 // indirect
|
||||
modernc.org/libc v1.66.10 // indirect
|
||||
modernc.org/mathutil v1.7.1 // indirect
|
||||
|
||||
@@ -70,11 +70,13 @@ golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b h1:M2rDM6z3Fhozi9O7NWsxAkg/y
|
||||
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/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/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.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
@@ -82,13 +84,21 @@ golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc=
|
||||
golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
golang.org/x/text v0.31.0 h1:aC8ghyu4JhP8VojJ2lEHBnochRno1sgL6nEi9WGFGMM=
|
||||
golang.org/x/text v0.31.0/go.mod h1:tKRAlv61yKIjGGHX/4tP1LTbc13YSec1pxVEWXzfoeM=
|
||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||
golang.org/x/tools v0.36.0 h1:kWS0uv/zsvHEle1LbV5LE8QujrxB3wfQyxHfhOk0Qkg=
|
||||
golang.org/x/tools v0.36.0/go.mod h1:WBDiHKJK8YgLHlcQPYQzNCkUxUypCaa5ZegCVutKm+s=
|
||||
golang.org/x/tools v0.38.0 h1:Hx2Xv8hISq8Lm16jvBZ2VQf+RLmbd7wVUsALibYI/IQ=
|
||||
golang.org/x/tools v0.38.0/go.mod h1:yEsQ/d/YK8cjh0L6rZlY8tgtlKiBNTL14pGDJPJpYQs=
|
||||
gopkg.in/alexcesaro/quotedprintable.v3 v3.0.0-20150716171945-2caba252f4dc h1:2gGKlE2+asNV9m7xrywl36YYNnBG5ZQ0r/BOOxqPpmk=
|
||||
gopkg.in/alexcesaro/quotedprintable.v3 v3.0.0-20150716171945-2caba252f4dc/go.mod h1:m7x9LTH6d71AHyAX77c9yqWCCa3UKHcVEj9y7hAtKDk=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f h1:BLraFXnmrev5lT+xlilqcH8XK9/i0At2xKjWk4p6zsU=
|
||||
gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/mail.v2 v2.3.1 h1:WYFn/oANrAGP2C0dcV6/pbkPzv8yGzqTjPmTeO7qoXk=
|
||||
gopkg.in/mail.v2 v2.3.1/go.mod h1:htwXN1Qh09vZJ1NVKxQqHPBaCBbzKhp5GzuJEA4VJWw=
|
||||
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||
gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
|
||||
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
|
||||
|
||||
@@ -0,0 +1,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,134 @@
|
||||
package commands
|
||||
|
||||
import (
|
||||
"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", "UTC")
|
||||
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", "UTC")
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,10 @@ package commands
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"apocapoc-api/internal/domain/entities"
|
||||
"apocapoc-api/internal/domain/repositories"
|
||||
@@ -16,38 +20,117 @@ type RegisterUserCommand struct {
|
||||
Timezone string
|
||||
}
|
||||
|
||||
type RegisterUserHandler struct {
|
||||
userRepo repositories.UserRepository
|
||||
passwordHasher services.PasswordHasher
|
||||
type RegisterUserResult struct {
|
||||
UserID string
|
||||
EmailVerificationRequired bool
|
||||
}
|
||||
|
||||
func NewRegisterUserHandler(userRepo repositories.UserRepository, passwordHasher services.PasswordHasher) *RegisterUserHandler {
|
||||
type RegisterUserHandler struct {
|
||||
userRepo repositories.UserRepository
|
||||
passwordHasher services.PasswordHasher
|
||||
emailService services.EmailService
|
||||
appURL string
|
||||
registrationMode string
|
||||
sendWelcomeEmail bool
|
||||
}
|
||||
|
||||
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) {
|
||||
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, cmd.Timezone); err != nil {
|
||||
return "", errors.ErrInvalidInput
|
||||
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)
|
||||
|
||||
if err := h.userRepo.Create(ctx, user); err != nil {
|
||||
return "", err
|
||||
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
|
||||
}
|
||||
|
||||
return user.ID, nil
|
||||
if err := h.userRepo.Create(ctx, user); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -25,6 +25,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 +40,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,7 +69,7 @@ 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",
|
||||
@@ -69,15 +77,19 @@ func TestRegisterUserHandler_Success(t *testing.T) {
|
||||
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")
|
||||
}
|
||||
@@ -94,7 +106,7 @@ func TestRegisterUserHandler_Success(t *testing.T) {
|
||||
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
|
||||
@@ -117,7 +129,7 @@ func TestRegisterUserHandler_InvalidEmail(t *testing.T) {
|
||||
}
|
||||
|
||||
_, 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 +139,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
|
||||
@@ -152,7 +164,7 @@ func TestRegisterUserHandler_InvalidPassword(t *testing.T) {
|
||||
}
|
||||
|
||||
_, err := handler.Handle(context.Background(), cmd)
|
||||
if err != appErrors.ErrInvalidInput {
|
||||
if !errors.Is(err, appErrors.ErrInvalidInput) {
|
||||
t.Errorf("expected ErrInvalidInput, got %v", err)
|
||||
}
|
||||
})
|
||||
@@ -162,7 +174,7 @@ func TestRegisterUserHandler_InvalidPassword(t *testing.T) {
|
||||
func TestRegisterUserHandler_InvalidTimezone(t *testing.T) {
|
||||
repo := &mockUserRepo{}
|
||||
hasher := &mockPasswordHasher{}
|
||||
handler := NewRegisterUserHandler(repo, hasher)
|
||||
handler := NewRegisterUserHandler(repo, hasher, nil, "", "open", false)
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
@@ -183,7 +195,7 @@ func TestRegisterUserHandler_InvalidTimezone(t *testing.T) {
|
||||
}
|
||||
|
||||
_, err := handler.Handle(context.Background(), cmd)
|
||||
if err != appErrors.ErrInvalidInput {
|
||||
if !errors.Is(err, appErrors.ErrInvalidInput) {
|
||||
t.Errorf("expected ErrInvalidInput, got %v", err)
|
||||
}
|
||||
})
|
||||
@@ -198,7 +210,7 @@ func TestRegisterUserHandler_EmailAlreadyExists(t *testing.T) {
|
||||
},
|
||||
}
|
||||
hasher := &mockPasswordHasher{}
|
||||
handler := NewRegisterUserHandler(repo, hasher)
|
||||
handler := NewRegisterUserHandler(repo, hasher, nil, "", "open", false)
|
||||
|
||||
cmd := RegisterUserCommand{
|
||||
Email: "test@example.com",
|
||||
@@ -207,7 +219,7 @@ func TestRegisterUserHandler_EmailAlreadyExists(t *testing.T) {
|
||||
}
|
||||
|
||||
_, err := handler.Handle(context.Background(), cmd)
|
||||
if err != appErrors.ErrAlreadyExists {
|
||||
if !errors.Is(err, appErrors.ErrAlreadyExists) {
|
||||
t.Errorf("expected ErrAlreadyExists, got %v", err)
|
||||
}
|
||||
}
|
||||
@@ -220,7 +232,7 @@ 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",
|
||||
@@ -242,7 +254,7 @@ 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",
|
||||
@@ -259,7 +271,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
|
||||
@@ -322,3 +334,19 @@ 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!",
|
||||
Timezone: "UTC",
|
||||
}
|
||||
|
||||
_, err := handler.Handle(context.Background(), cmd)
|
||||
if !errors.Is(err, appErrors.ErrRegistrationClosed) {
|
||||
t.Errorf("expected ErrRegistrationClosed, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,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,381 @@
|
||||
package commands
|
||||
|
||||
import (
|
||||
"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", "UTC")
|
||||
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", "UTC")
|
||||
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")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,188 @@
|
||||
package commands
|
||||
|
||||
import (
|
||||
"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")
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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,338 @@
|
||||
package commands
|
||||
|
||||
import (
|
||||
"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 TestVerifyEmailHandler_Success(t *testing.T) {
|
||||
token := "valid-token"
|
||||
expiry := time.Now().Add(24 * time.Hour)
|
||||
|
||||
user := entities.NewUser("test@example.com", "hashedPassword", "UTC")
|
||||
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", "UTC")
|
||||
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", "UTC")
|
||||
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", "UTC")
|
||||
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", "UTC")
|
||||
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", "UTC")
|
||||
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", "UTC")
|
||||
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")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -107,6 +107,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 +119,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 +138,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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -45,6 +45,10 @@ 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,
|
||||
|
||||
@@ -0,0 +1,210 @@
|
||||
package queries
|
||||
|
||||
import (
|
||||
"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", "UTC")
|
||||
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")
|
||||
}
|
||||
|
||||
if result.Timezone != "UTC" {
|
||||
t.Errorf("Timezone = %v, want %v", result.Timezone, "UTC")
|
||||
}
|
||||
}
|
||||
|
||||
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", "UTC")
|
||||
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", "UTC")
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -59,10 +59,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) {
|
||||
@@ -174,3 +182,87 @@ 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")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,12 +3,15 @@ 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
|
||||
Timezone string
|
||||
EmailVerified bool
|
||||
EmailVerificationToken *string
|
||||
EmailVerificationExpiry *time.Time
|
||||
CreatedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
}
|
||||
|
||||
func NewUser(email, passwordHash, timezone string) *User {
|
||||
|
||||
@@ -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,12 @@
|
||||
package services
|
||||
|
||||
type EmailMessage struct {
|
||||
To string
|
||||
Subject string
|
||||
Body string
|
||||
IsHTML bool
|
||||
}
|
||||
|
||||
type EmailService interface {
|
||||
Send(message EmailMessage) 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,109 @@
|
||||
{
|
||||
"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"
|
||||
},
|
||||
"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,109 @@
|
||||
{
|
||||
"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"
|
||||
},
|
||||
"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,19 @@ 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
|
||||
}
|
||||
|
||||
func Load() (*Config, error) {
|
||||
@@ -24,17 +31,27 @@ 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"),
|
||||
}
|
||||
|
||||
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 +61,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,105 @@
|
||||
package email
|
||||
|
||||
import (
|
||||
"crypto/tls"
|
||||
"fmt"
|
||||
"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 {
|
||||
return fmt.Errorf("failed to send email: %w", err)
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
package email
|
||||
|
||||
import (
|
||||
"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)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSMTPService_MessageConstruction(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)
|
||||
|
||||
message := services.EmailMessage{
|
||||
To: "recipient@example.com",
|
||||
Subject: "Test Email",
|
||||
Body: "<h1>Test</h1>",
|
||||
IsHTML: true,
|
||||
}
|
||||
|
||||
if message.To == "" {
|
||||
t.Error("Expected recipient to be set")
|
||||
}
|
||||
|
||||
if message.Subject == "" {
|
||||
t.Error("Expected subject to be set")
|
||||
}
|
||||
|
||||
if !message.IsHTML {
|
||||
t.Error("Expected message to be HTML")
|
||||
}
|
||||
|
||||
if service == nil {
|
||||
t.Fatal("Service should not be nil")
|
||||
}
|
||||
}
|
||||
@@ -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,19 +36,29 @@ 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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -62,6 +79,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,20 +94,21 @@ 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, password requirements, or timezone"
|
||||
// @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
|
||||
}
|
||||
|
||||
@@ -95,41 +118,35 @@ func (h *AuthHandlers) Register(w http.ResponseWriter, r *http.Request) {
|
||||
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 +160,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 +177,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 +228,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 +238,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 +288,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 +298,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"),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -22,15 +22,15 @@ 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) {
|
||||
|
||||
@@ -48,14 +48,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 {
|
||||
@@ -87,3 +94,8 @@ type HabitEntriesResponse struct {
|
||||
type ErrorResponse struct {
|
||||
Error string `json:"error"`
|
||||
}
|
||||
|
||||
type ValidationErrorResponse struct {
|
||||
Error string `json:"error"`
|
||||
Field string `json:"field"`
|
||||
}
|
||||
|
||||
@@ -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,10 +4,13 @@ import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"apocapoc-api/internal/application/commands"
|
||||
"apocapoc-api/internal/application/queries"
|
||||
"apocapoc-api/internal/domain/repositories"
|
||||
"apocapoc-api/internal/i18n"
|
||||
"apocapoc-api/internal/shared/errors"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
@@ -23,6 +26,8 @@ type HabitHandlers struct {
|
||||
archiveHandler *commands.ArchiveHabitHandler
|
||||
markHandler *commands.MarkHabitHandler
|
||||
unmarkHandler *commands.UnmarkHabitHandler
|
||||
userRepo repositories.UserRepository
|
||||
translator *i18n.Translator
|
||||
}
|
||||
|
||||
func NewHabitHandlers(
|
||||
@@ -35,6 +40,8 @@ func NewHabitHandlers(
|
||||
archiveHandler *commands.ArchiveHabitHandler,
|
||||
markHandler *commands.MarkHabitHandler,
|
||||
unmarkHandler *commands.UnmarkHabitHandler,
|
||||
userRepo repositories.UserRepository,
|
||||
translator *i18n.Translator,
|
||||
) *HabitHandlers {
|
||||
return &HabitHandlers{
|
||||
createHandler: createHandler,
|
||||
@@ -46,6 +53,8 @@ func NewHabitHandlers(
|
||||
archiveHandler: archiveHandler,
|
||||
markHandler: markHandler,
|
||||
unmarkHandler: unmarkHandler,
|
||||
userRepo: userRepo,
|
||||
translator: translator,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -65,13 +74,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 +103,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
|
||||
}
|
||||
|
||||
@@ -114,7 +123,7 @@ func (h *HabitHandlers) CreateHabit(w http.ResponseWriter, r *http.Request) {
|
||||
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
|
||||
}
|
||||
|
||||
@@ -124,7 +133,7 @@ func (h *HabitHandlers) GetUserHabits(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
habits, 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
|
||||
}
|
||||
|
||||
@@ -163,7 +172,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 +184,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 +230,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 +253,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 +289,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 +300,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 +337,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 +349,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 +358,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 +379,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 +390,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 +406,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,7 +440,7 @@ 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.
|
||||
// @Tags habits
|
||||
// @Produce json
|
||||
// @Security BearerAuth
|
||||
@@ -442,26 +451,47 @@ func (h *HabitHandlers) GetHabitEntries(w http.ResponseWriter, r *http.Request)
|
||||
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"
|
||||
user, err := h.userRepo.FindByID(r.Context(), userID)
|
||||
if err != nil {
|
||||
respondErrorI18n(w, r, h.translator, http.StatusInternalServerError, "failed_get_user")
|
||||
return
|
||||
}
|
||||
|
||||
loc, err := time.LoadLocation(user.Timezone)
|
||||
if err != nil {
|
||||
loc = time.UTC
|
||||
}
|
||||
|
||||
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(),
|
||||
Timezone: user.Timezone,
|
||||
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 +500,7 @@ func (h *HabitHandlers) GetTodaysHabits(w http.ResponseWriter, r *http.Request)
|
||||
IsNegative: habit.IsNegative,
|
||||
ScheduledDate: habit.ScheduledDate,
|
||||
IsCarriedOver: habit.IsCarriedOver,
|
||||
Entry: entryResponse,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -496,13 +527,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 +545,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 +580,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 +598,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 +621,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)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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,12 +41,17 @@ 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)
|
||||
@@ -59,12 +65,17 @@ 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, userRepo, translator)
|
||||
statsHandlers := NewStatsHandlers(getHabitStatsHandler, translator)
|
||||
healthHandlers := NewHealthHandlers(db)
|
||||
userHandlers := NewUserHandlers(deleteUserHandler, translator)
|
||||
|
||||
router := NewRouter("http://localhost:3000", habitHandlers, authHandlers, statsHandlers, healthHandlers, userHandlers, jwtService, translator)
|
||||
|
||||
handler := http.Handler(router)
|
||||
return &TestServer{
|
||||
@@ -104,3 +115,22 @@ 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,
|
||||
Timezone: "UTC",
|
||||
}
|
||||
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
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"apocapoc-api/internal/i18n"
|
||||
"apocapoc-api/internal/infrastructure/auth"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
@@ -15,15 +16,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, jwtService *auth.JWTService, translator *i18n.Translator) *chi.Mux {
|
||||
r := chi.NewRouter()
|
||||
|
||||
r.Use(middleware.Logger)
|
||||
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 +44,10 @@ 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.Post("/forgot-password", authHandlers.ForgotPassword)
|
||||
r.Post("/reset-password", authHandlers.ResetPassword)
|
||||
})
|
||||
|
||||
r.Route("/api/v1/habits", func(r chi.Router) {
|
||||
@@ -65,5 +71,11 @@ 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)
|
||||
})
|
||||
|
||||
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"),
|
||||
})
|
||||
}
|
||||
@@ -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,9 +20,50 @@ func RunMigrations(db *sql.DB) error {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
if err := addEmailVerificationColumns(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 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,
|
||||
@@ -75,6 +118,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 +137,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);
|
||||
`
|
||||
|
||||
@@ -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,8 +24,8 @@ 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, timezone, email_verified, email_verification_token, email_verification_expiry, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`
|
||||
|
||||
_, err := r.db.ExecContext(ctx, query,
|
||||
@@ -33,6 +33,9 @@ func (r *UserRepository) Create(ctx context.Context, user *entities.User) error
|
||||
user.Email,
|
||||
user.PasswordHash,
|
||||
user.Timezone,
|
||||
user.EmailVerified,
|
||||
user.EmailVerificationToken,
|
||||
user.EmailVerificationExpiry,
|
||||
user.CreatedAt,
|
||||
user.UpdatedAt,
|
||||
)
|
||||
@@ -49,7 +52,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, timezone, email_verified, email_verification_token, email_verification_expiry, created_at, updated_at
|
||||
FROM users
|
||||
WHERE id = ?
|
||||
`
|
||||
@@ -60,6 +63,9 @@ func (r *UserRepository) FindByID(ctx context.Context, id string) (*entities.Use
|
||||
&user.Email,
|
||||
&user.PasswordHash,
|
||||
&user.Timezone,
|
||||
&user.EmailVerified,
|
||||
&user.EmailVerificationToken,
|
||||
&user.EmailVerificationExpiry,
|
||||
&user.CreatedAt,
|
||||
&user.UpdatedAt,
|
||||
)
|
||||
@@ -76,7 +82,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, timezone, email_verified, email_verification_token, email_verification_expiry, created_at, updated_at
|
||||
FROM users
|
||||
WHERE email = ?
|
||||
`
|
||||
@@ -87,6 +93,9 @@ func (r *UserRepository) FindByEmail(ctx context.Context, email string) (*entiti
|
||||
&user.Email,
|
||||
&user.PasswordHash,
|
||||
&user.Timezone,
|
||||
&user.EmailVerified,
|
||||
&user.EmailVerificationToken,
|
||||
&user.EmailVerificationExpiry,
|
||||
&user.CreatedAt,
|
||||
&user.UpdatedAt,
|
||||
)
|
||||
@@ -101,10 +110,40 @@ 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, timezone, 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.Timezone,
|
||||
&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 = ?, timezone = ?, email_verified = ?, email_verification_token = ?, email_verification_expiry = ?, updated_at = ?
|
||||
WHERE id = ?
|
||||
`
|
||||
|
||||
@@ -112,6 +151,9 @@ func (r *UserRepository) Update(ctx context.Context, user *entities.User) error
|
||||
user.Email,
|
||||
user.PasswordHash,
|
||||
user.Timezone,
|
||||
user.EmailVerified,
|
||||
user.EmailVerificationToken,
|
||||
user.EmailVerificationExpiry,
|
||||
user.UpdatedAt,
|
||||
user.ID,
|
||||
)
|
||||
@@ -128,6 +170,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")
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user