Compare commits
11 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 1aedc2b69a | |||
| e9e7e9dbac | |||
| 88b5d11113 | |||
| 29f0f9b468 | |||
| 7575853355 | |||
| 788b6cf430 | |||
| 568ba3b016 | |||
| 935f742ac9 | |||
| 38e640c617 | |||
| f780c69806 | |||
| 5d92820591 |
@@ -25,3 +25,14 @@ SEND_WELCOME_EMAIL=false
|
||||
|
||||
# Registration Control
|
||||
REGISTRATION_MODE=open
|
||||
|
||||
# Logging Configuration
|
||||
LOG_LEVEL=info
|
||||
ENVIRONMENT=production
|
||||
|
||||
# Backup Configuration
|
||||
BACKUP_ENABLED=false
|
||||
BACKUP_INTERVAL=24h
|
||||
BACKUP_RETENTION_DAYS=7
|
||||
BACKUP_PATH=./data/backups
|
||||
BACKUP_COMPRESS=true
|
||||
|
||||
+40
-10
@@ -12,10 +12,12 @@ import (
|
||||
"apocapoc-api/internal/application/queries"
|
||||
"apocapoc-api/internal/i18n"
|
||||
"apocapoc-api/internal/infrastructure/auth"
|
||||
"apocapoc-api/internal/infrastructure/backup"
|
||||
"apocapoc-api/internal/infrastructure/config"
|
||||
"apocapoc-api/internal/infrastructure/crypto"
|
||||
"apocapoc-api/internal/infrastructure/email"
|
||||
httpInfra "apocapoc-api/internal/infrastructure/http"
|
||||
"apocapoc-api/internal/infrastructure/logger"
|
||||
"apocapoc-api/internal/infrastructure/persistence/sqlite"
|
||||
)
|
||||
|
||||
@@ -43,20 +45,46 @@ func main() {
|
||||
log.Fatalf("Failed to load config: %v", err)
|
||||
}
|
||||
|
||||
logger.Init(logger.Config{
|
||||
Level: cfg.LogLevel,
|
||||
Environment: cfg.Environment,
|
||||
})
|
||||
|
||||
db, err := sqlite.NewDatabase(cfg.DBPath)
|
||||
if err != nil {
|
||||
log.Fatalf("Failed to connect to database: %v", err)
|
||||
logger.Fatal().Err(err).Msg("Failed to connect to database")
|
||||
}
|
||||
defer db.Close()
|
||||
|
||||
backupInterval, err := parseDuration(cfg.BackupInterval)
|
||||
if err != nil {
|
||||
logger.Fatal().Err(err).Msg("Invalid BACKUP_INTERVAL")
|
||||
}
|
||||
|
||||
backupRetentionDays, err := strconv.Atoi(cfg.BackupRetentionDays)
|
||||
if err != nil {
|
||||
logger.Fatal().Err(err).Msg("Invalid BACKUP_RETENTION_DAYS")
|
||||
}
|
||||
|
||||
backupScheduler := backup.NewScheduler(db.Conn(), backup.Config{
|
||||
Enabled: cfg.BackupEnabled == "true",
|
||||
Interval: backupInterval,
|
||||
RetentionDays: backupRetentionDays,
|
||||
Path: cfg.BackupPath,
|
||||
Compress: cfg.BackupCompress == "true",
|
||||
DatabasePath: cfg.DBPath,
|
||||
})
|
||||
backupScheduler.Start()
|
||||
defer backupScheduler.Stop()
|
||||
|
||||
jwtExpiryHours, err := parseJWTExpiry(cfg.JWTExpiry)
|
||||
if err != nil {
|
||||
log.Fatalf("Invalid JWT_EXPIRY: %v", err)
|
||||
logger.Fatal().Err(err).Msg("Invalid JWT_EXPIRY")
|
||||
}
|
||||
|
||||
refreshTokenExpiry, err := parseDuration(cfg.RefreshTokenExpiry)
|
||||
if err != nil {
|
||||
log.Fatalf("Invalid REFRESH_TOKEN_EXPIRY: %v", err)
|
||||
logger.Fatal().Err(err).Msg("Invalid REFRESH_TOKEN_EXPIRY")
|
||||
}
|
||||
|
||||
jwtService := auth.NewJWTService(cfg.JWTSecret, jwtExpiryHours)
|
||||
@@ -66,7 +94,7 @@ func main() {
|
||||
if cfg.SMTPHost != "" {
|
||||
smtpPort, err := strconv.Atoi(cfg.SMTPPort)
|
||||
if err != nil {
|
||||
log.Fatalf("Invalid SMTP_PORT: %v", err)
|
||||
logger.Fatal().Err(err).Msg("Invalid SMTP_PORT")
|
||||
}
|
||||
|
||||
emailService = email.NewSMTPService(email.SMTPConfig{
|
||||
@@ -89,7 +117,7 @@ func main() {
|
||||
|
||||
translator, err := i18n.NewTranslator()
|
||||
if err != nil {
|
||||
log.Fatalf("Failed to create translator: %v", err)
|
||||
logger.Fatal().Err(err).Msg("Failed to create translator")
|
||||
}
|
||||
|
||||
registerHandler := commands.NewRegisterUserHandler(userRepo, passwordHasher, emailService, cfg.AppURL, cfg.RegistrationMode, sendWelcomeEmail)
|
||||
@@ -108,24 +136,26 @@ func main() {
|
||||
getHabitByIDHandler := queries.NewGetHabitByIDHandler(habitRepo)
|
||||
getHabitEntriesHandler := queries.NewGetHabitEntriesHandler(habitRepo, entryRepo)
|
||||
getHabitStatsHandler := queries.NewGetHabitStatsHandler(habitRepo, entryRepo)
|
||||
exportUserDataHandler := queries.NewExportUserDataHandler(habitRepo, entryRepo)
|
||||
updateHandler := commands.NewUpdateHabitHandler(habitRepo)
|
||||
archiveHandler := commands.NewArchiveHabitHandler(habitRepo)
|
||||
markHandler := commands.NewMarkHabitHandler(entryRepo, habitRepo)
|
||||
unmarkHandler := commands.NewUnmarkHabitHandler(habitRepo, entryRepo)
|
||||
|
||||
authHandlers := httpInfra.NewAuthHandlers(registerHandler, loginHandler, refreshTokenHandler, revokeTokenHandler, revokeAllTokensHandler, verifyEmailHandler, resendVerificationEmailHandler, requestPasswordResetHandler, resetPasswordHandler, jwtService, refreshTokenRepo, refreshTokenExpiry, translator)
|
||||
habitHandlers := httpInfra.NewHabitHandlers(createHandler, getTodaysHandler, getUserHabitsHandler, getHabitByIDHandler, getHabitEntriesHandler, updateHandler, archiveHandler, markHandler, unmarkHandler, userRepo, translator)
|
||||
habitHandlers := httpInfra.NewHabitHandlers(createHandler, getTodaysHandler, getUserHabitsHandler, getHabitByIDHandler, getHabitEntriesHandler, updateHandler, archiveHandler, markHandler, unmarkHandler, translator)
|
||||
statsHandlers := httpInfra.NewStatsHandlers(getHabitStatsHandler, translator)
|
||||
healthHandlers := httpInfra.NewHealthHandlers(db.Conn())
|
||||
healthHandlers := httpInfra.NewHealthHandlers(db.Conn(), emailService)
|
||||
userHandlers := httpInfra.NewUserHandlers(deleteUserHandler, translator)
|
||||
exportHandlers := httpInfra.NewExportHandlers(exportUserDataHandler, translator)
|
||||
|
||||
router := httpInfra.NewRouter(cfg.AppURL, habitHandlers, authHandlers, statsHandlers, healthHandlers, userHandlers, jwtService, translator)
|
||||
router := httpInfra.NewRouter(cfg.AppURL, habitHandlers, authHandlers, statsHandlers, healthHandlers, userHandlers, exportHandlers, jwtService, translator)
|
||||
|
||||
addr := fmt.Sprintf("0.0.0.0:%s", cfg.Port)
|
||||
log.Printf("Server starting on %s", addr)
|
||||
logger.Info().Str("address", addr).Msg("Server starting")
|
||||
|
||||
if err := http.ListenAndServe(addr, router); err != nil {
|
||||
log.Fatalf("Server failed: %v", err)
|
||||
logger.Fatal().Err(err).Msg("Server failed")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -11,9 +11,12 @@ require (
|
||||
github.com/golang-jwt/jwt/v5 v5.3.0
|
||||
github.com/google/uuid v1.6.0
|
||||
github.com/joho/godotenv v1.5.1
|
||||
github.com/rs/zerolog v1.34.0
|
||||
github.com/swaggo/http-swagger v1.3.4
|
||||
github.com/swaggo/swag v1.16.4
|
||||
golang.org/x/crypto v0.45.0
|
||||
golang.org/x/text v0.31.0
|
||||
gopkg.in/mail.v2 v2.3.1
|
||||
modernc.org/sqlite v1.40.1
|
||||
)
|
||||
|
||||
@@ -27,17 +30,17 @@ require (
|
||||
github.com/go-openapi/swag v0.19.15 // indirect
|
||||
github.com/josharian/intern v1.0.0 // indirect
|
||||
github.com/mailru/easyjson v0.7.6 // indirect
|
||||
github.com/mattn/go-colorable v0.1.13 // indirect
|
||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||
github.com/ncruces/go-strftime v0.1.9 // indirect
|
||||
github.com/pkg/errors v0.9.1 // indirect
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
||||
github.com/swaggo/files v0.0.0-20220610200504-28940afbdbfe // indirect
|
||||
golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b // indirect
|
||||
golang.org/x/net v0.47.0 // indirect
|
||||
golang.org/x/sys v0.38.0 // indirect
|
||||
golang.org/x/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
|
||||
|
||||
@@ -2,6 +2,7 @@ github.com/KyleBanks/depth v1.2.1 h1:5h8fQADFrWtarTdtDudMmGsC7GPbOAu6RVB3ffsVFHc
|
||||
github.com/KyleBanks/depth v1.2.1/go.mod h1:jzSb9d0L43HxTQfT+oSA1EEp2q+ne2uh6XgeJcm8brE=
|
||||
github.com/cespare/xxhash/v2 v2.1.2 h1:YRXhKfTDauu4ajMg1TPgFO5jnlC2HCbmLXMcTG5cbYE=
|
||||
github.com/cespare/xxhash/v2 v2.1.2/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
||||
github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc=
|
||||
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
@@ -24,6 +25,7 @@ github.com/go-openapi/spec v0.20.6/go.mod h1:2OpW+JddWPrpXSCIX8eOx7lZ5iyuWj3RYR6
|
||||
github.com/go-openapi/swag v0.19.5/go.mod h1:POnQmlKehdgb5mhVOsnJFsivZCEZ/vjK9gh66Z9tfKk=
|
||||
github.com/go-openapi/swag v0.19.15 h1:D2NRCBzS9/pEY3gP9Nl8aDqGUcPFrwG2p+CNFrLyrCM=
|
||||
github.com/go-openapi/swag v0.19.15/go.mod h1:QYRuS/SOXUCsnplDa677K7+DxSOj6IPNl/eQntq43wQ=
|
||||
github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
|
||||
github.com/golang-jwt/jwt/v5 v5.3.0 h1:pv4AsKCKKZuqlgs5sUmn4x8UlGa0kEVt/puTpKx9vvo=
|
||||
github.com/golang-jwt/jwt/v5 v5.3.0/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
|
||||
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs=
|
||||
@@ -43,16 +45,25 @@ github.com/mailru/easyjson v0.0.0-20190614124828-94de47d64c63/go.mod h1:C1wdFJiN
|
||||
github.com/mailru/easyjson v0.0.0-20190626092158-b2ccc519800e/go.mod h1:C1wdFJiN94OJF2b5HbByQZoLdCWB1Yqtg26g4irojpc=
|
||||
github.com/mailru/easyjson v0.7.6 h1:8yTIVnZgCoiM1TgqoeTl+LfU5Jg6/xL3QhGQnimLYnA=
|
||||
github.com/mailru/easyjson v0.7.6/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc=
|
||||
github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA=
|
||||
github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
|
||||
github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
|
||||
github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||
github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4=
|
||||
github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
|
||||
github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e h1:fD57ERR4JtEqsWbfPhv4DMiApHyliiK5xCTNVSPiaAs=
|
||||
github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno=
|
||||
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
|
||||
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
||||
github.com/rs/xid v1.6.0/go.mod h1:7XoLgs4eV+QndskICGsho+ADou8ySMSjJKDIan90Nz0=
|
||||
github.com/rs/zerolog v1.34.0 h1:k43nTLIwcTVQAncfCw4KZ2VY6ukYoZaBPNOE8txlOeY=
|
||||
github.com/rs/zerolog v1.34.0/go.mod h1:bJsvje4Z08ROH4Nhs5iH600c3IkWhwp44iRc54W6wYQ=
|
||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||
github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
@@ -68,18 +79,18 @@ golang.org/x/crypto v0.45.0 h1:jMBrvKuj23MTlT0bQEOBcAE0mjg8mK9RXFhRH6nyF3Q=
|
||||
golang.org/x/crypto v0.45.0/go.mod h1:XTGrrkGJve7CYK7J8PEww4aY7gM3qMCElcJQ8n8JdX4=
|
||||
golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b h1:M2rDM6z3Fhozi9O7NWsxAkg/yqS/lQJ6PmkyIV3YP+o=
|
||||
golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b/go.mod h1:3//PLf8L/X+8b4vuAfHzxeRUl04Adcb341+IGKfnqS8=
|
||||
golang.org/x/mod v0.27.0 h1:kb+q2PyFnEADO2IEF935ehFUXlWiNjJWtRNgBLSfbxQ=
|
||||
golang.org/x/mod v0.27.0/go.mod h1:rWI627Fq0DEoudcK+MBkNkCe0EetEaDSwJJkCcjpazc=
|
||||
golang.org/x/mod v0.29.0 h1:HV8lRxZC4l2cr3Zq1LvtOsi/ThTgWnUk/y64QSs8GwA=
|
||||
golang.org/x/mod v0.29.0/go.mod h1:NyhrlYXJ2H4eJiRy/WDBO6HMqZQ6q9nk4JzS3NuCK+w=
|
||||
golang.org/x/net v0.0.0-20210805182204-aaa1db679c0d/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
|
||||
golang.org/x/net v0.47.0 h1:Mx+4dIFzqraBXUugkia1OOvlD6LemFo1ALMHjrXDOhY=
|
||||
golang.org/x/net v0.47.0/go.mod h1:/jNxtkgq5yWUGYkaZGqo27cfGZ1c5Nen03aYrrKpVRU=
|
||||
golang.org/x/sync v0.16.0 h1:ycBJEhp9p4vXvUZNszeOq0kGTPghopOL8q0fq3vstxw=
|
||||
golang.org/x/sync v0.16.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
|
||||
golang.org/x/sync v0.18.0 h1:kr88TuHDroi+UVf+0hZnirlk8o8T+4MrK6mr60WkH/I=
|
||||
golang.org/x/sync v0.18.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
|
||||
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc=
|
||||
golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||
@@ -87,8 +98,6 @@ 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=
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
package commands
|
||||
|
||||
import (
|
||||
"apocapoc-api/internal/domain/repositories"
|
||||
"apocapoc-api/internal/shared/pagination"
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
@@ -229,3 +231,19 @@ func TestCreateHabitHandler_NegativeHabit(t *testing.T) {
|
||||
t.Error("Expected habit ID to be returned")
|
||||
}
|
||||
}
|
||||
|
||||
func (m *mockHabitRepo) FindActiveByUserIDWithPagination(ctx context.Context, userID string, params pagination.Params) ([]*entities.Habit, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (m *mockHabitRepo) CountActiveByUserID(ctx context.Context, userID string) (int, error) {
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
func (m *mockHabitRepo) FindByUserIDFiltered(ctx context.Context, userID string, filter repositories.HabitFilter, paginationParams *pagination.Params) ([]*entities.Habit, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (m *mockHabitRepo) CountByUserIDFiltered(ctx context.Context, userID string, filter repositories.HabitFilter) (int, error) {
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
package commands
|
||||
|
||||
import (
|
||||
"apocapoc-api/internal/domain/repositories"
|
||||
"apocapoc-api/internal/shared/pagination"
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
@@ -48,7 +50,7 @@ func TestDeleteUserHandler_Success(t *testing.T) {
|
||||
|
||||
repo := &mockDeleteUserRepo{
|
||||
findByIDFunc: func(ctx context.Context, id string) (*entities.User, error) {
|
||||
user := entities.NewUser("test@example.com", "hashedPassword", "UTC")
|
||||
user := entities.NewUser("test@example.com", "hashedPassword")
|
||||
user.ID = id
|
||||
return user, nil
|
||||
},
|
||||
@@ -112,7 +114,7 @@ func TestDeleteUserHandler_DeleteError(t *testing.T) {
|
||||
|
||||
repo := &mockDeleteUserRepo{
|
||||
findByIDFunc: func(ctx context.Context, id string) (*entities.User, error) {
|
||||
user := entities.NewUser("test@example.com", "hashedPassword", "UTC")
|
||||
user := entities.NewUser("test@example.com", "hashedPassword")
|
||||
user.ID = id
|
||||
return user, nil
|
||||
},
|
||||
@@ -132,3 +134,19 @@ func TestDeleteUserHandler_DeleteError(t *testing.T) {
|
||||
t.Errorf("Handle() error = %v, want %v", err, customError)
|
||||
}
|
||||
}
|
||||
|
||||
func (m *mockDeleteUserRepo) FindActiveByUserIDWithPagination(ctx context.Context, userID string, params pagination.Params) ([]*entities.Habit, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (m *mockDeleteUserRepo) CountActiveByUserID(ctx context.Context, userID string) (int, error) {
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
func (m *mockDeleteUserRepo) FindByUserIDFiltered(ctx context.Context, userID string, filter repositories.HabitFilter, paginationParams *pagination.Params) ([]*entities.Habit, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (m *mockDeleteUserRepo) CountByUserIDFiltered(ctx context.Context, userID string, filter repositories.HabitFilter) (int, error) {
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
package commands
|
||||
|
||||
import (
|
||||
"apocapoc-api/internal/domain/repositories"
|
||||
"apocapoc-api/internal/shared/pagination"
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
@@ -38,6 +40,10 @@ func (m *mockEntryRepo) FindByHabitIDAndDateRange(ctx context.Context, habitID s
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (m *mockEntryRepo) FindByUserID(ctx context.Context, userID string) ([]*entities.HabitEntry, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (m *mockEntryRepo) FindPendingByHabitID(ctx context.Context, habitID string, beforeDate time.Time) ([]*entities.HabitEntry, error) {
|
||||
return nil, nil
|
||||
}
|
||||
@@ -537,3 +543,35 @@ func TestMarkHabitHandler_CounterFirstMarkWithNegative(t *testing.T) {
|
||||
t.Fatalf("Expected no error, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func (m *mockEntryRepo) FindActiveByUserIDWithPagination(ctx context.Context, userID string, params pagination.Params) ([]*entities.Habit, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (m *mockEntryRepo) CountActiveByUserID(ctx context.Context, userID string) (int, error) {
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
func (m *mockHabitRepoForMark) FindActiveByUserIDWithPagination(ctx context.Context, userID string, params pagination.Params) ([]*entities.Habit, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (m *mockHabitRepoForMark) CountActiveByUserID(ctx context.Context, userID string) (int, error) {
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
func (m *mockEntryRepo) FindByUserIDFiltered(ctx context.Context, userID string, filter repositories.HabitFilter, paginationParams *pagination.Params) ([]*entities.Habit, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (m *mockEntryRepo) CountByUserIDFiltered(ctx context.Context, userID string, filter repositories.HabitFilter) (int, error) {
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
func (m *mockHabitRepoForMark) FindByUserIDFiltered(ctx context.Context, userID string, filter repositories.HabitFilter, paginationParams *pagination.Params) ([]*entities.Habit, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (m *mockHabitRepoForMark) CountByUserIDFiltered(ctx context.Context, userID string, filter repositories.HabitFilter) (int, error) {
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
@@ -17,7 +17,6 @@ import (
|
||||
type RegisterUserCommand struct {
|
||||
Email string
|
||||
Password string
|
||||
Timezone string
|
||||
}
|
||||
|
||||
type RegisterUserResult struct {
|
||||
@@ -57,7 +56,7 @@ func (h *RegisterUserHandler) Handle(ctx context.Context, cmd RegisterUserComman
|
||||
return nil, errors.ErrRegistrationClosed
|
||||
}
|
||||
|
||||
if err := validation.ValidateRegistration(cmd.Email, cmd.Password, cmd.Timezone); err != nil {
|
||||
if err := validation.ValidateRegistration(cmd.Email, cmd.Password); err != nil {
|
||||
return nil, fmt.Errorf("%w: %v", errors.ErrInvalidInput, err)
|
||||
}
|
||||
|
||||
@@ -71,7 +70,7 @@ func (h *RegisterUserHandler) Handle(ctx context.Context, cmd RegisterUserComman
|
||||
return nil, err
|
||||
}
|
||||
|
||||
user := entities.NewUser(cmd.Email, hashedPassword, cmd.Timezone)
|
||||
user := entities.NewUser(cmd.Email, hashedPassword)
|
||||
|
||||
emailVerificationRequired := false
|
||||
if h.emailService != nil {
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
package commands
|
||||
|
||||
import (
|
||||
"apocapoc-api/internal/domain/repositories"
|
||||
"apocapoc-api/internal/shared/pagination"
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
@@ -74,7 +76,6 @@ func TestRegisterUserHandler_Success(t *testing.T) {
|
||||
cmd := RegisterUserCommand{
|
||||
Email: "test@example.com",
|
||||
Password: "Secure123!",
|
||||
Timezone: "UTC",
|
||||
}
|
||||
|
||||
result, err := handler.Handle(context.Background(), cmd)
|
||||
@@ -97,10 +98,6 @@ func TestRegisterUserHandler_Success(t *testing.T) {
|
||||
if createdUser.Email != cmd.Email {
|
||||
t.Errorf("expected email %q, got %q", cmd.Email, createdUser.Email)
|
||||
}
|
||||
|
||||
if createdUser.Timezone != cmd.Timezone {
|
||||
t.Errorf("expected timezone %q, got %q", cmd.Timezone, createdUser.Timezone)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRegisterUserHandler_InvalidEmail(t *testing.T) {
|
||||
@@ -125,7 +122,6 @@ func TestRegisterUserHandler_InvalidEmail(t *testing.T) {
|
||||
cmd := RegisterUserCommand{
|
||||
Email: tt.email,
|
||||
Password: "Secure123!",
|
||||
Timezone: "UTC",
|
||||
}
|
||||
|
||||
_, err := handler.Handle(context.Background(), cmd)
|
||||
@@ -160,38 +156,6 @@ func TestRegisterUserHandler_InvalidPassword(t *testing.T) {
|
||||
cmd := RegisterUserCommand{
|
||||
Email: "test@example.com",
|
||||
Password: tt.password,
|
||||
Timezone: "UTC",
|
||||
}
|
||||
|
||||
_, err := handler.Handle(context.Background(), cmd)
|
||||
if !errors.Is(err, appErrors.ErrInvalidInput) {
|
||||
t.Errorf("expected ErrInvalidInput, got %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestRegisterUserHandler_InvalidTimezone(t *testing.T) {
|
||||
repo := &mockUserRepo{}
|
||||
hasher := &mockPasswordHasher{}
|
||||
handler := NewRegisterUserHandler(repo, hasher, nil, "", "open", false)
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
timezone string
|
||||
}{
|
||||
{"empty timezone", ""},
|
||||
{"invalid timezone", "InvalidTimezone"},
|
||||
{"numeric format", "GMT+1"},
|
||||
{"partial timezone", "America"},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
cmd := RegisterUserCommand{
|
||||
Email: "test@example.com",
|
||||
Password: "Secure123!",
|
||||
Timezone: tt.timezone,
|
||||
}
|
||||
|
||||
_, err := handler.Handle(context.Background(), cmd)
|
||||
@@ -203,7 +167,7 @@ func TestRegisterUserHandler_InvalidTimezone(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestRegisterUserHandler_EmailAlreadyExists(t *testing.T) {
|
||||
existingUser := entities.NewUser("test@example.com", "hashed", "UTC")
|
||||
existingUser := entities.NewUser("test@example.com", "hashed")
|
||||
repo := &mockUserRepo{
|
||||
findByEmailFunc: func(ctx context.Context, email string) (*entities.User, error) {
|
||||
return existingUser, nil
|
||||
@@ -215,7 +179,6 @@ func TestRegisterUserHandler_EmailAlreadyExists(t *testing.T) {
|
||||
cmd := RegisterUserCommand{
|
||||
Email: "test@example.com",
|
||||
Password: "Secure123!",
|
||||
Timezone: "UTC",
|
||||
}
|
||||
|
||||
_, err := handler.Handle(context.Background(), cmd)
|
||||
@@ -237,7 +200,6 @@ func TestRegisterUserHandler_PasswordHashingError(t *testing.T) {
|
||||
cmd := RegisterUserCommand{
|
||||
Email: "test@example.com",
|
||||
Password: "Secure123!",
|
||||
Timezone: "UTC",
|
||||
}
|
||||
|
||||
_, err := handler.Handle(context.Background(), cmd)
|
||||
@@ -259,7 +221,6 @@ func TestRegisterUserHandler_RepositoryError(t *testing.T) {
|
||||
cmd := RegisterUserCommand{
|
||||
Email: "test@example.com",
|
||||
Password: "Secure123!",
|
||||
Timezone: "UTC",
|
||||
}
|
||||
|
||||
_, err := handler.Handle(context.Background(), cmd)
|
||||
@@ -283,7 +244,6 @@ func TestRegisterUserHandler_EdgeCases(t *testing.T) {
|
||||
RegisterUserCommand{
|
||||
Email: "user+tag@example.com",
|
||||
Password: "Secure123!",
|
||||
Timezone: "UTC",
|
||||
},
|
||||
nil,
|
||||
},
|
||||
@@ -292,16 +252,6 @@ func TestRegisterUserHandler_EdgeCases(t *testing.T) {
|
||||
RegisterUserCommand{
|
||||
Email: "user@mail.example.com",
|
||||
Password: "Secure123!",
|
||||
Timezone: "UTC",
|
||||
},
|
||||
nil,
|
||||
},
|
||||
{
|
||||
"complex timezone",
|
||||
RegisterUserCommand{
|
||||
Email: "user@example.com",
|
||||
Password: "Secure123!",
|
||||
Timezone: "America/Argentina/Buenos_Aires",
|
||||
},
|
||||
nil,
|
||||
},
|
||||
@@ -310,7 +260,6 @@ func TestRegisterUserHandler_EdgeCases(t *testing.T) {
|
||||
RegisterUserCommand{
|
||||
Email: "user@example.com",
|
||||
Password: "Sëcure123!",
|
||||
Timezone: "UTC",
|
||||
},
|
||||
nil,
|
||||
},
|
||||
@@ -319,7 +268,6 @@ func TestRegisterUserHandler_EdgeCases(t *testing.T) {
|
||||
RegisterUserCommand{
|
||||
Email: "user@example.com",
|
||||
Password: "ValidP@ss1" + string(make([]byte, 100)),
|
||||
Timezone: "UTC",
|
||||
},
|
||||
nil,
|
||||
},
|
||||
@@ -342,7 +290,6 @@ func TestRegisterUserHandler_ClosedRegistration(t *testing.T) {
|
||||
cmd := RegisterUserCommand{
|
||||
Email: "test@example.com",
|
||||
Password: "Secure123!",
|
||||
Timezone: "UTC",
|
||||
}
|
||||
|
||||
_, err := handler.Handle(context.Background(), cmd)
|
||||
@@ -350,3 +297,19 @@ func TestRegisterUserHandler_ClosedRegistration(t *testing.T) {
|
||||
t.Errorf("expected ErrRegistrationClosed, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func (m *mockUserRepo) FindActiveByUserIDWithPagination(ctx context.Context, userID string, params pagination.Params) ([]*entities.Habit, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (m *mockUserRepo) CountActiveByUserID(ctx context.Context, userID string) (int, error) {
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
func (m *mockUserRepo) FindByUserIDFiltered(ctx context.Context, userID string, filter repositories.HabitFilter, paginationParams *pagination.Params) ([]*entities.Habit, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (m *mockUserRepo) CountByUserIDFiltered(ctx context.Context, userID string, filter repositories.HabitFilter) (int, error) {
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
@@ -0,0 +1,374 @@
|
||||
package commands
|
||||
|
||||
import (
|
||||
"apocapoc-api/internal/domain/repositories"
|
||||
"apocapoc-api/internal/shared/pagination"
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"apocapoc-api/internal/domain/entities"
|
||||
"apocapoc-api/internal/domain/services"
|
||||
"apocapoc-api/internal/shared/errors"
|
||||
)
|
||||
|
||||
type mockRequestResetUserRepo struct {
|
||||
findByEmailFunc func(ctx context.Context, email string) (*entities.User, error)
|
||||
users map[string]*entities.User
|
||||
}
|
||||
|
||||
func (m *mockRequestResetUserRepo) FindByEmail(ctx context.Context, email string) (*entities.User, error) {
|
||||
if m.findByEmailFunc != nil {
|
||||
return m.findByEmailFunc(ctx, email)
|
||||
}
|
||||
if user, ok := m.users[email]; ok {
|
||||
return user, nil
|
||||
}
|
||||
return nil, errors.ErrNotFound
|
||||
}
|
||||
|
||||
func (m *mockRequestResetUserRepo) FindByID(ctx context.Context, id string) (*entities.User, error) {
|
||||
return nil, errors.ErrNotFound
|
||||
}
|
||||
|
||||
func (m *mockRequestResetUserRepo) FindByVerificationToken(ctx context.Context, token string) (*entities.User, error) {
|
||||
return nil, errors.ErrNotFound
|
||||
}
|
||||
|
||||
func (m *mockRequestResetUserRepo) Create(ctx context.Context, user *entities.User) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *mockRequestResetUserRepo) Update(ctx context.Context, user *entities.User) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *mockRequestResetUserRepo) Delete(ctx context.Context, id string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
type mockRequestResetTokenRepo struct {
|
||||
createFunc func(ctx context.Context, token *entities.PasswordResetToken) error
|
||||
tokens []*entities.PasswordResetToken
|
||||
}
|
||||
|
||||
func (m *mockRequestResetTokenRepo) Create(ctx context.Context, token *entities.PasswordResetToken) error {
|
||||
if m.createFunc != nil {
|
||||
return m.createFunc(ctx, token)
|
||||
}
|
||||
m.tokens = append(m.tokens, token)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *mockRequestResetTokenRepo) FindByToken(ctx context.Context, token string) (*entities.PasswordResetToken, error) {
|
||||
return nil, errors.ErrNotFound
|
||||
}
|
||||
|
||||
func (m *mockRequestResetTokenRepo) Update(ctx context.Context, token *entities.PasswordResetToken) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *mockRequestResetTokenRepo) DeleteExpired(ctx context.Context) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
type mockRequestResetEmailService struct {
|
||||
sendFunc func(message services.EmailMessage) error
|
||||
sentMessages []services.EmailMessage
|
||||
}
|
||||
|
||||
func (m *mockRequestResetEmailService) Send(message services.EmailMessage) error {
|
||||
if m.sendFunc != nil {
|
||||
return m.sendFunc(message)
|
||||
}
|
||||
m.sentMessages = append(m.sentMessages, message)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *mockRequestResetEmailService) HealthCheck() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func TestRequestPasswordResetHandler_Success(t *testing.T) {
|
||||
user := entities.NewUser("test@example.com", "hash")
|
||||
user.ID = "user-123"
|
||||
user.EmailVerified = true
|
||||
|
||||
userRepo := &mockRequestResetUserRepo{
|
||||
users: map[string]*entities.User{
|
||||
user.Email: user,
|
||||
},
|
||||
}
|
||||
|
||||
tokenRepo := &mockRequestResetTokenRepo{
|
||||
tokens: []*entities.PasswordResetToken{},
|
||||
}
|
||||
|
||||
emailService := &mockRequestResetEmailService{
|
||||
sentMessages: []services.EmailMessage{},
|
||||
}
|
||||
|
||||
handler := NewRequestPasswordResetHandler(userRepo, tokenRepo, emailService, "http://localhost:8080")
|
||||
|
||||
cmd := RequestPasswordResetCommand{
|
||||
Email: user.Email,
|
||||
}
|
||||
|
||||
err := handler.Handle(context.Background(), cmd)
|
||||
if err != nil {
|
||||
t.Fatalf("Handle() unexpected error = %v", err)
|
||||
}
|
||||
|
||||
if len(tokenRepo.tokens) != 1 {
|
||||
t.Fatalf("Expected 1 token created, got %d", len(tokenRepo.tokens))
|
||||
}
|
||||
|
||||
createdToken := tokenRepo.tokens[0]
|
||||
if createdToken.UserID != user.ID {
|
||||
t.Errorf("Token UserID = %v, want %v", createdToken.UserID, user.ID)
|
||||
}
|
||||
|
||||
if createdToken.Token == "" {
|
||||
t.Error("Token string is empty")
|
||||
}
|
||||
|
||||
if createdToken.ExpiresAt.Before(time.Now()) {
|
||||
t.Error("Token already expired")
|
||||
}
|
||||
|
||||
expectedExpiry := time.Now().Add(1 * time.Hour)
|
||||
diff := createdToken.ExpiresAt.Sub(expectedExpiry)
|
||||
if diff > time.Minute || diff < -time.Minute {
|
||||
t.Errorf("Token expiry = %v, expected around %v", createdToken.ExpiresAt, expectedExpiry)
|
||||
}
|
||||
|
||||
if len(emailService.sentMessages) != 1 {
|
||||
t.Fatalf("Expected 1 email sent, got %d", len(emailService.sentMessages))
|
||||
}
|
||||
|
||||
sentEmail := emailService.sentMessages[0]
|
||||
if sentEmail.To != user.Email {
|
||||
t.Errorf("Email To = %v, want %v", sentEmail.To, user.Email)
|
||||
}
|
||||
|
||||
if sentEmail.Subject != "Password Reset Request" {
|
||||
t.Errorf("Email Subject = %v, want %v", sentEmail.Subject, "Password Reset Request")
|
||||
}
|
||||
|
||||
if !sentEmail.IsHTML {
|
||||
t.Error("Email should be HTML")
|
||||
}
|
||||
|
||||
if sentEmail.Body == "" {
|
||||
t.Error("Email body is empty")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRequestPasswordResetHandler_EmptyEmail(t *testing.T) {
|
||||
handler := NewRequestPasswordResetHandler(
|
||||
&mockRequestResetUserRepo{users: make(map[string]*entities.User)},
|
||||
&mockRequestResetTokenRepo{tokens: []*entities.PasswordResetToken{}},
|
||||
&mockRequestResetEmailService{},
|
||||
"http://localhost:8080",
|
||||
)
|
||||
|
||||
cmd := RequestPasswordResetCommand{
|
||||
Email: "",
|
||||
}
|
||||
|
||||
err := handler.Handle(context.Background(), cmd)
|
||||
if err != errors.ErrInvalidInput {
|
||||
t.Errorf("Handle() error = %v, want %v", err, errors.ErrInvalidInput)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRequestPasswordResetHandler_UserNotFound(t *testing.T) {
|
||||
handler := NewRequestPasswordResetHandler(
|
||||
&mockRequestResetUserRepo{users: make(map[string]*entities.User)},
|
||||
&mockRequestResetTokenRepo{tokens: []*entities.PasswordResetToken{}},
|
||||
&mockRequestResetEmailService{},
|
||||
"http://localhost:8080",
|
||||
)
|
||||
|
||||
cmd := RequestPasswordResetCommand{
|
||||
Email: "nonexistent@example.com",
|
||||
}
|
||||
|
||||
err := handler.Handle(context.Background(), cmd)
|
||||
if err != errors.ErrNotFound {
|
||||
t.Errorf("Handle() error = %v, want %v", err, errors.ErrNotFound)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRequestPasswordResetHandler_EmailNotVerified(t *testing.T) {
|
||||
user := entities.NewUser("test@example.com", "hash")
|
||||
user.ID = "user-123"
|
||||
user.EmailVerified = false
|
||||
|
||||
userRepo := &mockRequestResetUserRepo{
|
||||
users: map[string]*entities.User{
|
||||
user.Email: user,
|
||||
},
|
||||
}
|
||||
|
||||
handler := NewRequestPasswordResetHandler(
|
||||
userRepo,
|
||||
&mockRequestResetTokenRepo{tokens: []*entities.PasswordResetToken{}},
|
||||
&mockRequestResetEmailService{},
|
||||
"http://localhost:8080",
|
||||
)
|
||||
|
||||
cmd := RequestPasswordResetCommand{
|
||||
Email: user.Email,
|
||||
}
|
||||
|
||||
err := handler.Handle(context.Background(), cmd)
|
||||
if err != errors.ErrEmailNotVerified {
|
||||
t.Errorf("Handle() error = %v, want %v", err, errors.ErrEmailNotVerified)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRequestPasswordResetHandler_TokenCreationFailure(t *testing.T) {
|
||||
user := entities.NewUser("test@example.com", "hash")
|
||||
user.ID = "user-123"
|
||||
user.EmailVerified = true
|
||||
|
||||
userRepo := &mockRequestResetUserRepo{
|
||||
users: map[string]*entities.User{
|
||||
user.Email: user,
|
||||
},
|
||||
}
|
||||
|
||||
tokenRepo := &mockRequestResetTokenRepo{
|
||||
createFunc: func(ctx context.Context, token *entities.PasswordResetToken) error {
|
||||
return errors.ErrInvalidInput
|
||||
},
|
||||
}
|
||||
|
||||
emailService := &mockRequestResetEmailService{}
|
||||
|
||||
handler := NewRequestPasswordResetHandler(userRepo, tokenRepo, emailService, "http://localhost:8080")
|
||||
|
||||
cmd := RequestPasswordResetCommand{
|
||||
Email: user.Email,
|
||||
}
|
||||
|
||||
err := handler.Handle(context.Background(), cmd)
|
||||
if err == nil {
|
||||
t.Fatal("Handle() expected error but got nil")
|
||||
}
|
||||
|
||||
if len(emailService.sentMessages) != 0 {
|
||||
t.Error("Email should not be sent if token creation fails")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRequestPasswordResetHandler_EmailSendFailure(t *testing.T) {
|
||||
user := entities.NewUser("test@example.com", "hash")
|
||||
user.ID = "user-123"
|
||||
user.EmailVerified = true
|
||||
|
||||
userRepo := &mockRequestResetUserRepo{
|
||||
users: map[string]*entities.User{
|
||||
user.Email: user,
|
||||
},
|
||||
}
|
||||
|
||||
tokenRepo := &mockRequestResetTokenRepo{
|
||||
tokens: []*entities.PasswordResetToken{},
|
||||
}
|
||||
|
||||
emailService := &mockRequestResetEmailService{
|
||||
sendFunc: func(message services.EmailMessage) error {
|
||||
return errors.ErrInvalidInput
|
||||
},
|
||||
}
|
||||
|
||||
handler := NewRequestPasswordResetHandler(userRepo, tokenRepo, emailService, "http://localhost:8080")
|
||||
|
||||
cmd := RequestPasswordResetCommand{
|
||||
Email: user.Email,
|
||||
}
|
||||
|
||||
err := handler.Handle(context.Background(), cmd)
|
||||
if err == nil {
|
||||
t.Fatal("Handle() expected error but got nil")
|
||||
}
|
||||
|
||||
if len(tokenRepo.tokens) != 1 {
|
||||
t.Error("Token should be created even if email sending fails")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRequestPasswordResetHandler_ResetLinkFormat(t *testing.T) {
|
||||
user := entities.NewUser("test@example.com", "hash")
|
||||
user.ID = "user-123"
|
||||
user.EmailVerified = true
|
||||
|
||||
userRepo := &mockRequestResetUserRepo{
|
||||
users: map[string]*entities.User{
|
||||
user.Email: user,
|
||||
},
|
||||
}
|
||||
|
||||
tokenRepo := &mockRequestResetTokenRepo{
|
||||
tokens: []*entities.PasswordResetToken{},
|
||||
}
|
||||
|
||||
emailService := &mockRequestResetEmailService{
|
||||
sentMessages: []services.EmailMessage{},
|
||||
}
|
||||
|
||||
appURL := "https://myapp.com"
|
||||
handler := NewRequestPasswordResetHandler(userRepo, tokenRepo, emailService, appURL)
|
||||
|
||||
cmd := RequestPasswordResetCommand{
|
||||
Email: user.Email,
|
||||
}
|
||||
|
||||
err := handler.Handle(context.Background(), cmd)
|
||||
if err != nil {
|
||||
t.Fatalf("Handle() unexpected error = %v", err)
|
||||
}
|
||||
|
||||
if len(emailService.sentMessages) != 1 {
|
||||
t.Fatal("Expected 1 email sent")
|
||||
}
|
||||
|
||||
sentEmail := emailService.sentMessages[0]
|
||||
if sentEmail.Body == "" {
|
||||
t.Fatal("Email body is empty")
|
||||
}
|
||||
}
|
||||
|
||||
func (m *mockRequestResetTokenRepo) FindActiveByUserIDWithPagination(ctx context.Context, userID string, params pagination.Params) ([]*entities.Habit, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (m *mockRequestResetTokenRepo) CountActiveByUserID(ctx context.Context, userID string) (int, error) {
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
func (m *mockRequestResetUserRepo) FindActiveByUserIDWithPagination(ctx context.Context, userID string, params pagination.Params) ([]*entities.Habit, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (m *mockRequestResetUserRepo) CountActiveByUserID(ctx context.Context, userID string) (int, error) {
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
func (m *mockRequestResetTokenRepo) FindByUserIDFiltered(ctx context.Context, userID string, filter repositories.HabitFilter, paginationParams *pagination.Params) ([]*entities.Habit, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (m *mockRequestResetTokenRepo) CountByUserIDFiltered(ctx context.Context, userID string, filter repositories.HabitFilter) (int, error) {
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
func (m *mockRequestResetUserRepo) FindByUserIDFiltered(ctx context.Context, userID string, filter repositories.HabitFilter, paginationParams *pagination.Params) ([]*entities.Habit, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (m *mockRequestResetUserRepo) CountByUserIDFiltered(ctx context.Context, userID string, filter repositories.HabitFilter) (int, error) {
|
||||
return 0, nil
|
||||
}
|
||||
@@ -1,6 +1,8 @@
|
||||
package commands
|
||||
|
||||
import (
|
||||
"apocapoc-api/internal/domain/repositories"
|
||||
"apocapoc-api/internal/shared/pagination"
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
@@ -98,7 +100,7 @@ func (m *mockResetPasswordHasher) Compare(hashedPassword, password string) error
|
||||
}
|
||||
|
||||
func TestResetPasswordHandler_Success(t *testing.T) {
|
||||
user := entities.NewUser("test@example.com", "old_hash", "UTC")
|
||||
user := entities.NewUser("test@example.com", "old_hash")
|
||||
user.ID = "user-123"
|
||||
|
||||
resetToken := entities.NewPasswordResetToken(
|
||||
@@ -340,7 +342,7 @@ func TestResetPasswordHandler_UserNotFound(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestResetPasswordHandler_HashingError(t *testing.T) {
|
||||
user := entities.NewUser("test@example.com", "old_hash", "UTC")
|
||||
user := entities.NewUser("test@example.com", "old_hash")
|
||||
user.ID = "user-123"
|
||||
|
||||
resetToken := entities.NewPasswordResetToken(
|
||||
@@ -379,3 +381,35 @@ func TestResetPasswordHandler_HashingError(t *testing.T) {
|
||||
t.Fatal("Handle() expected error but got nil")
|
||||
}
|
||||
}
|
||||
|
||||
func (m *mockPasswordResetTokenRepo) FindActiveByUserIDWithPagination(ctx context.Context, userID string, params pagination.Params) ([]*entities.Habit, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (m *mockPasswordResetTokenRepo) CountActiveByUserID(ctx context.Context, userID string) (int, error) {
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
func (m *mockResetPasswordUserRepo) FindActiveByUserIDWithPagination(ctx context.Context, userID string, params pagination.Params) ([]*entities.Habit, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (m *mockResetPasswordUserRepo) CountActiveByUserID(ctx context.Context, userID string) (int, error) {
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
func (m *mockPasswordResetTokenRepo) FindByUserIDFiltered(ctx context.Context, userID string, filter repositories.HabitFilter, paginationParams *pagination.Params) ([]*entities.Habit, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (m *mockPasswordResetTokenRepo) CountByUserIDFiltered(ctx context.Context, userID string, filter repositories.HabitFilter) (int, error) {
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
func (m *mockResetPasswordUserRepo) FindByUserIDFiltered(ctx context.Context, userID string, filter repositories.HabitFilter, paginationParams *pagination.Params) ([]*entities.Habit, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (m *mockResetPasswordUserRepo) CountByUserIDFiltered(ctx context.Context, userID string, filter repositories.HabitFilter) (int, error) {
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
package commands
|
||||
|
||||
import (
|
||||
"apocapoc-api/internal/domain/repositories"
|
||||
"apocapoc-api/internal/shared/pagination"
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
@@ -186,3 +188,19 @@ func TestRevokeAllTokensHandler_Handle(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func (m *mockRefreshTokenRepo) FindActiveByUserIDWithPagination(ctx context.Context, userID string, params pagination.Params) ([]*entities.Habit, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (m *mockRefreshTokenRepo) CountActiveByUserID(ctx context.Context, userID string) (int, error) {
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
func (m *mockRefreshTokenRepo) FindByUserIDFiltered(ctx context.Context, userID string, filter repositories.HabitFilter, paginationParams *pagination.Params) ([]*entities.Habit, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (m *mockRefreshTokenRepo) CountByUserIDFiltered(ctx context.Context, userID string, filter repositories.HabitFilter) (int, error) {
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
package commands
|
||||
|
||||
import (
|
||||
"apocapoc-api/internal/domain/repositories"
|
||||
"apocapoc-api/internal/shared/pagination"
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
@@ -137,3 +139,19 @@ func TestUnmarkHabitHandler_ReturnsErrorWhenEntryNotFound(t *testing.T) {
|
||||
t.Errorf("Expected ErrNotFound for missing entry, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func (m *mockEntryRepoForUnmark) FindActiveByUserIDWithPagination(ctx context.Context, userID string, params pagination.Params) ([]*entities.Habit, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (m *mockEntryRepoForUnmark) CountActiveByUserID(ctx context.Context, userID string) (int, error) {
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
func (m *mockEntryRepoForUnmark) FindByUserIDFiltered(ctx context.Context, userID string, filter repositories.HabitFilter, paginationParams *pagination.Params) ([]*entities.Habit, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (m *mockEntryRepoForUnmark) CountByUserIDFiltered(ctx context.Context, userID string, filter repositories.HabitFilter) (int, error) {
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
package commands
|
||||
|
||||
import (
|
||||
"apocapoc-api/internal/domain/repositories"
|
||||
"apocapoc-api/internal/shared/pagination"
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
@@ -63,11 +65,15 @@ func (m *mockEmailService) Send(message services.EmailMessage) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *mockEmailService) HealthCheck() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func TestVerifyEmailHandler_Success(t *testing.T) {
|
||||
token := "valid-token"
|
||||
expiry := time.Now().Add(24 * time.Hour)
|
||||
|
||||
user := entities.NewUser("test@example.com", "hashedPassword", "UTC")
|
||||
user := entities.NewUser("test@example.com", "hashedPassword")
|
||||
user.ID = "user-123"
|
||||
user.EmailVerified = false
|
||||
user.EmailVerificationToken = &token
|
||||
@@ -148,7 +154,7 @@ 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 := entities.NewUser("test@example.com", "hashedPassword")
|
||||
user.ID = "user-123"
|
||||
user.EmailVerified = true
|
||||
user.EmailVerificationToken = &token
|
||||
@@ -176,7 +182,7 @@ 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 := entities.NewUser("test@example.com", "hashedPassword")
|
||||
user.ID = "user-123"
|
||||
user.EmailVerified = false
|
||||
user.EmailVerificationToken = &token
|
||||
@@ -203,7 +209,7 @@ func TestVerifyEmailHandler_ExpiredToken(t *testing.T) {
|
||||
func TestVerifyEmailHandler_NilExpiry(t *testing.T) {
|
||||
token := "valid-token"
|
||||
|
||||
user := entities.NewUser("test@example.com", "hashedPassword", "UTC")
|
||||
user := entities.NewUser("test@example.com", "hashedPassword")
|
||||
user.ID = "user-123"
|
||||
user.EmailVerified = false
|
||||
user.EmailVerificationToken = &token
|
||||
@@ -231,7 +237,7 @@ 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 := entities.NewUser("test@example.com", "hashedPassword")
|
||||
user.ID = "user-123"
|
||||
user.EmailVerified = false
|
||||
user.EmailVerificationToken = &token
|
||||
@@ -277,7 +283,7 @@ 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 := entities.NewUser("test@example.com", "hashedPassword")
|
||||
user.ID = "user-123"
|
||||
user.EmailVerified = false
|
||||
user.EmailVerificationToken = &token
|
||||
@@ -310,7 +316,7 @@ 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 := entities.NewUser("test@example.com", "hashedPassword")
|
||||
user.ID = "user-123"
|
||||
user.EmailVerified = false
|
||||
user.EmailVerificationToken = &token
|
||||
@@ -336,3 +342,19 @@ func TestVerifyEmailHandler_UpdateError(t *testing.T) {
|
||||
t.Fatal("Handle() expected error but got nil")
|
||||
}
|
||||
}
|
||||
|
||||
func (m *mockVerifyEmailUserRepo) FindActiveByUserIDWithPagination(ctx context.Context, userID string, params pagination.Params) ([]*entities.Habit, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (m *mockVerifyEmailUserRepo) CountActiveByUserID(ctx context.Context, userID string) (int, error) {
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
func (m *mockVerifyEmailUserRepo) FindByUserIDFiltered(ctx context.Context, userID string, filter repositories.HabitFilter, paginationParams *pagination.Params) ([]*entities.Habit, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (m *mockVerifyEmailUserRepo) CountByUserIDFiltered(ctx context.Context, userID string, filter repositories.HabitFilter) (int, error) {
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
package queries
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"apocapoc-api/internal/domain/repositories"
|
||||
"apocapoc-api/internal/domain/value_objects"
|
||||
)
|
||||
|
||||
type ExportHabitDTO struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description"`
|
||||
Type value_objects.HabitType `json:"type"`
|
||||
Frequency value_objects.Frequency `json:"frequency"`
|
||||
SpecificDays []int `json:"specific_days,omitempty"`
|
||||
SpecificDates []int `json:"specific_dates,omitempty"`
|
||||
CarryOver bool `json:"carry_over"`
|
||||
IsNegative bool `json:"is_negative"`
|
||||
TargetValue *float64 `json:"target_value,omitempty"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
ArchivedAt *time.Time `json:"archived_at,omitempty"`
|
||||
}
|
||||
|
||||
type ExportEntryDTO struct {
|
||||
ID string `json:"id"`
|
||||
HabitID string `json:"habit_id"`
|
||||
ScheduledDate time.Time `json:"scheduled_date"`
|
||||
CompletedAt time.Time `json:"completed_at"`
|
||||
Value *float64 `json:"value,omitempty"`
|
||||
}
|
||||
|
||||
type ExportUserDataResult struct {
|
||||
ExportedAt time.Time `json:"exported_at"`
|
||||
Habits []ExportHabitDTO `json:"habits"`
|
||||
Entries []ExportEntryDTO `json:"entries"`
|
||||
}
|
||||
|
||||
type ExportUserDataQuery struct {
|
||||
UserID string
|
||||
}
|
||||
|
||||
type ExportUserDataHandler struct {
|
||||
habitRepo repositories.HabitRepository
|
||||
entryRepo repositories.HabitEntryRepository
|
||||
}
|
||||
|
||||
func NewExportUserDataHandler(
|
||||
habitRepo repositories.HabitRepository,
|
||||
entryRepo repositories.HabitEntryRepository,
|
||||
) *ExportUserDataHandler {
|
||||
return &ExportUserDataHandler{
|
||||
habitRepo: habitRepo,
|
||||
entryRepo: entryRepo,
|
||||
}
|
||||
}
|
||||
|
||||
func (h *ExportUserDataHandler) Handle(ctx context.Context, query ExportUserDataQuery) (*ExportUserDataResult, error) {
|
||||
habits, err := h.habitRepo.FindByUserID(ctx, query.UserID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
entries, err := h.entryRepo.FindByUserID(ctx, query.UserID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
habitDTOs := make([]ExportHabitDTO, 0, len(habits))
|
||||
for _, habit := range habits {
|
||||
habitDTOs = append(habitDTOs, ExportHabitDTO{
|
||||
ID: habit.ID,
|
||||
Name: habit.Name,
|
||||
Description: habit.Description,
|
||||
Type: habit.Type,
|
||||
Frequency: habit.Frequency,
|
||||
SpecificDays: habit.SpecificDays,
|
||||
SpecificDates: habit.SpecificDates,
|
||||
CarryOver: habit.CarryOver,
|
||||
IsNegative: habit.IsNegative,
|
||||
TargetValue: habit.TargetValue,
|
||||
CreatedAt: habit.CreatedAt,
|
||||
ArchivedAt: habit.ArchivedAt,
|
||||
})
|
||||
}
|
||||
|
||||
entryDTOs := make([]ExportEntryDTO, 0, len(entries))
|
||||
for _, entry := range entries {
|
||||
entryDTOs = append(entryDTOs, ExportEntryDTO{
|
||||
ID: entry.ID,
|
||||
HabitID: entry.HabitID,
|
||||
ScheduledDate: entry.ScheduledDate,
|
||||
CompletedAt: entry.CompletedAt,
|
||||
Value: entry.Value,
|
||||
})
|
||||
}
|
||||
|
||||
return &ExportUserDataResult{
|
||||
ExportedAt: time.Now(),
|
||||
Habits: habitDTOs,
|
||||
Entries: entryDTOs,
|
||||
}, nil
|
||||
}
|
||||
@@ -1,12 +1,14 @@
|
||||
package queries
|
||||
|
||||
import (
|
||||
"apocapoc-api/internal/domain/repositories"
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"apocapoc-api/internal/domain/entities"
|
||||
"apocapoc-api/internal/domain/value_objects"
|
||||
"apocapoc-api/internal/shared/pagination"
|
||||
)
|
||||
|
||||
type mockHabitRepo struct {
|
||||
@@ -37,6 +39,14 @@ func (m *mockHabitRepo) Delete(ctx context.Context, id string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *mockHabitRepo) FindActiveByUserIDWithPagination(ctx context.Context, userID string, params pagination.Params) ([]*entities.Habit, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (m *mockHabitRepo) CountActiveByUserID(ctx context.Context, userID string) (int, error) {
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
type mockEntryRepo struct {
|
||||
entries []*entities.HabitEntry
|
||||
}
|
||||
@@ -63,6 +73,10 @@ func (m *mockEntryRepo) FindByHabitIDAndDateRange(ctx context.Context, habitID s
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (m *mockEntryRepo) FindByUserID(ctx context.Context, userID string) ([]*entities.HabitEntry, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (m *mockEntryRepo) FindPendingByHabitID(ctx context.Context, habitID string, beforeDate time.Time) ([]*entities.HabitEntry, error) {
|
||||
return nil, nil
|
||||
}
|
||||
@@ -315,3 +329,11 @@ func TestGetTodaysHabitsHandler_CarryOverDisabled(t *testing.T) {
|
||||
t.Fatalf("Expected 0 habits (no carry-over), got %d", len(results))
|
||||
}
|
||||
}
|
||||
|
||||
func (m *mockHabitRepo) FindByUserIDFiltered(ctx context.Context, userID string, filter repositories.HabitFilter, paginationParams *pagination.Params) ([]*entities.Habit, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (m *mockHabitRepo) CountByUserIDFiltered(ctx context.Context, userID string, filter repositories.HabitFilter) (int, error) {
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
@@ -3,8 +3,10 @@ package queries
|
||||
import (
|
||||
"context"
|
||||
|
||||
"apocapoc-api/internal/domain/entities"
|
||||
"apocapoc-api/internal/domain/repositories"
|
||||
"apocapoc-api/internal/domain/value_objects"
|
||||
"apocapoc-api/internal/shared/pagination"
|
||||
)
|
||||
|
||||
type HabitDTO struct {
|
||||
@@ -18,8 +20,22 @@ type HabitDTO struct {
|
||||
SpecificDays []int
|
||||
}
|
||||
|
||||
type FilterParams struct {
|
||||
Type *value_objects.HabitType
|
||||
Frequency *value_objects.Frequency
|
||||
IncludeArchived bool
|
||||
Search string
|
||||
}
|
||||
|
||||
type GetUserHabitsQuery struct {
|
||||
UserID string
|
||||
UserID string
|
||||
PaginationParams *pagination.Params
|
||||
FilterParams *FilterParams
|
||||
}
|
||||
|
||||
type GetUserHabitsResult struct {
|
||||
Habits []HabitDTO
|
||||
Pagination *pagination.Response
|
||||
}
|
||||
|
||||
type GetUserHabitsHandler struct {
|
||||
@@ -32,15 +48,56 @@ func NewGetUserHabitsHandler(habitRepo repositories.HabitRepository) *GetUserHab
|
||||
}
|
||||
}
|
||||
|
||||
func (h *GetUserHabitsHandler) Handle(ctx context.Context, query GetUserHabitsQuery) ([]HabitDTO, error) {
|
||||
habits, err := h.habitRepo.FindActiveByUserID(ctx, query.UserID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
func (h *GetUserHabitsHandler) Handle(ctx context.Context, query GetUserHabitsQuery) (*GetUserHabitsResult, error) {
|
||||
var habits []*entities.Habit
|
||||
var paginationResponse *pagination.Response
|
||||
var err error
|
||||
|
||||
if query.FilterParams != nil {
|
||||
filter := repositories.HabitFilter{
|
||||
Type: query.FilterParams.Type,
|
||||
Frequency: query.FilterParams.Frequency,
|
||||
IncludeArchived: query.FilterParams.IncludeArchived,
|
||||
Search: query.FilterParams.Search,
|
||||
}
|
||||
|
||||
habits, err = h.habitRepo.FindByUserIDFiltered(ctx, query.UserID, filter, query.PaginationParams)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if query.PaginationParams != nil {
|
||||
totalItems, err := h.habitRepo.CountByUserIDFiltered(ctx, query.UserID, filter)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
response := pagination.NewResponse(*query.PaginationParams, totalItems)
|
||||
paginationResponse = &response
|
||||
}
|
||||
} else if query.PaginationParams != nil {
|
||||
habits, err = h.habitRepo.FindActiveByUserIDWithPagination(ctx, query.UserID, *query.PaginationParams)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
totalItems, err := h.habitRepo.CountActiveByUserID(ctx, query.UserID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
response := pagination.NewResponse(*query.PaginationParams, totalItems)
|
||||
paginationResponse = &response
|
||||
} else {
|
||||
habits, err = h.habitRepo.FindActiveByUserID(ctx, query.UserID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
var result []HabitDTO
|
||||
var habitDTOs []HabitDTO
|
||||
for _, habit := range habits {
|
||||
result = append(result, HabitDTO{
|
||||
habitDTOs = append(habitDTOs, HabitDTO{
|
||||
ID: habit.ID,
|
||||
Name: habit.Name,
|
||||
Type: habit.Type,
|
||||
@@ -52,5 +109,8 @@ func (h *GetUserHabitsHandler) Handle(ctx context.Context, query GetUserHabitsQu
|
||||
})
|
||||
}
|
||||
|
||||
return result, nil
|
||||
return &GetUserHabitsResult{
|
||||
Habits: habitDTOs,
|
||||
Pagination: paginationResponse,
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -1,13 +1,63 @@
|
||||
package queries
|
||||
|
||||
import (
|
||||
"apocapoc-api/internal/domain/repositories"
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"apocapoc-api/internal/domain/entities"
|
||||
"apocapoc-api/internal/domain/value_objects"
|
||||
"apocapoc-api/internal/shared/pagination"
|
||||
)
|
||||
|
||||
type mockGetUserHabitsRepo struct {
|
||||
habits []*entities.Habit
|
||||
}
|
||||
|
||||
func (m *mockGetUserHabitsRepo) Create(ctx context.Context, habit *entities.Habit) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *mockGetUserHabitsRepo) FindByID(ctx context.Context, id string) (*entities.Habit, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (m *mockGetUserHabitsRepo) FindByUserID(ctx context.Context, userID string) ([]*entities.Habit, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (m *mockGetUserHabitsRepo) FindActiveByUserID(ctx context.Context, userID string) ([]*entities.Habit, error) {
|
||||
return m.habits, nil
|
||||
}
|
||||
|
||||
func (m *mockGetUserHabitsRepo) FindActiveByUserIDWithPagination(ctx context.Context, userID string, params pagination.Params) ([]*entities.Habit, error) {
|
||||
offset := params.Offset()
|
||||
limit := params.Limit()
|
||||
|
||||
if offset >= len(m.habits) {
|
||||
return []*entities.Habit{}, nil
|
||||
}
|
||||
|
||||
end := offset + limit
|
||||
if end > len(m.habits) {
|
||||
end = len(m.habits)
|
||||
}
|
||||
|
||||
return m.habits[offset:end], nil
|
||||
}
|
||||
|
||||
func (m *mockGetUserHabitsRepo) CountActiveByUserID(ctx context.Context, userID string) (int, error) {
|
||||
return len(m.habits), nil
|
||||
}
|
||||
|
||||
func (m *mockGetUserHabitsRepo) Update(ctx context.Context, habit *entities.Habit) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *mockGetUserHabitsRepo) Delete(ctx context.Context, id string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func TestGetUserHabitsHandler_ReturnsAllActiveHabits(t *testing.T) {
|
||||
habit1 := entities.NewHabit("user-123", "Exercise", value_objects.HabitTypeBoolean, value_objects.FrequencyDaily, false, false)
|
||||
habit1.ID = "habit-1"
|
||||
@@ -15,7 +65,7 @@ func TestGetUserHabitsHandler_ReturnsAllActiveHabits(t *testing.T) {
|
||||
habit2 := entities.NewHabit("user-123", "Read", value_objects.HabitTypeBoolean, value_objects.FrequencyWeekly, false, false)
|
||||
habit2.ID = "habit-2"
|
||||
|
||||
habitRepo := &mockHabitRepo{habits: []*entities.Habit{habit1, habit2}}
|
||||
habitRepo := &mockGetUserHabitsRepo{habits: []*entities.Habit{habit1, habit2}}
|
||||
|
||||
handler := NewGetUserHabitsHandler(habitRepo)
|
||||
|
||||
@@ -23,27 +73,31 @@ func TestGetUserHabitsHandler_ReturnsAllActiveHabits(t *testing.T) {
|
||||
UserID: "user-123",
|
||||
}
|
||||
|
||||
results, err := handler.Handle(context.Background(), query)
|
||||
result, err := handler.Handle(context.Background(), query)
|
||||
|
||||
if err != nil {
|
||||
t.Fatalf("Expected no error, got %v", err)
|
||||
}
|
||||
|
||||
if len(results) != 2 {
|
||||
t.Fatalf("Expected 2 habits, got %d", len(results))
|
||||
if len(result.Habits) != 2 {
|
||||
t.Fatalf("Expected 2 habits, got %d", len(result.Habits))
|
||||
}
|
||||
|
||||
if results[0].ID != "habit-1" {
|
||||
t.Errorf("Expected first habit ID habit-1, got %s", results[0].ID)
|
||||
if result.Habits[0].ID != "habit-1" {
|
||||
t.Errorf("Expected first habit ID habit-1, got %s", result.Habits[0].ID)
|
||||
}
|
||||
|
||||
if results[1].ID != "habit-2" {
|
||||
t.Errorf("Expected second habit ID habit-2, got %s", results[1].ID)
|
||||
if result.Habits[1].ID != "habit-2" {
|
||||
t.Errorf("Expected second habit ID habit-2, got %s", result.Habits[1].ID)
|
||||
}
|
||||
|
||||
if result.Pagination != nil {
|
||||
t.Error("Expected no pagination when not requested")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetUserHabitsHandler_ReturnsEmptyListForUserWithNoHabits(t *testing.T) {
|
||||
habitRepo := &mockHabitRepo{habits: []*entities.Habit{}}
|
||||
habitRepo := &mockGetUserHabitsRepo{habits: []*entities.Habit{}}
|
||||
|
||||
handler := NewGetUserHabitsHandler(habitRepo)
|
||||
|
||||
@@ -51,14 +105,14 @@ func TestGetUserHabitsHandler_ReturnsEmptyListForUserWithNoHabits(t *testing.T)
|
||||
UserID: "user-456",
|
||||
}
|
||||
|
||||
results, err := handler.Handle(context.Background(), query)
|
||||
result, err := handler.Handle(context.Background(), query)
|
||||
|
||||
if err != nil {
|
||||
t.Fatalf("Expected no error, got %v", err)
|
||||
}
|
||||
|
||||
if len(results) != 0 {
|
||||
t.Fatalf("Expected 0 habits, got %d", len(results))
|
||||
if len(result.Habits) != 0 {
|
||||
t.Fatalf("Expected 0 habits, got %d", len(result.Habits))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -68,7 +122,7 @@ func TestGetUserHabitsHandler_IncludesAllHabitFields(t *testing.T) {
|
||||
habit.ID = "habit-1"
|
||||
habit.TargetValue = &targetValue
|
||||
|
||||
habitRepo := &mockHabitRepo{habits: []*entities.Habit{habit}}
|
||||
habitRepo := &mockGetUserHabitsRepo{habits: []*entities.Habit{habit}}
|
||||
|
||||
handler := NewGetUserHabitsHandler(habitRepo)
|
||||
|
||||
@@ -76,35 +130,290 @@ func TestGetUserHabitsHandler_IncludesAllHabitFields(t *testing.T) {
|
||||
UserID: "user-123",
|
||||
}
|
||||
|
||||
results, err := handler.Handle(context.Background(), query)
|
||||
result, err := handler.Handle(context.Background(), query)
|
||||
|
||||
if err != nil {
|
||||
t.Fatalf("Expected no error, got %v", err)
|
||||
}
|
||||
|
||||
if len(results) != 1 {
|
||||
t.Fatalf("Expected 1 habit, got %d", len(results))
|
||||
if len(result.Habits) != 1 {
|
||||
t.Fatalf("Expected 1 habit, got %d", len(result.Habits))
|
||||
}
|
||||
|
||||
result := results[0]
|
||||
habitDTO := result.Habits[0]
|
||||
|
||||
if result.Name != "Drink Water" {
|
||||
t.Errorf("Expected name 'Drink Water', got %s", result.Name)
|
||||
if habitDTO.Name != "Drink Water" {
|
||||
t.Errorf("Expected name 'Drink Water', got %s", habitDTO.Name)
|
||||
}
|
||||
|
||||
if result.Type != value_objects.HabitTypeValue {
|
||||
t.Errorf("Expected type %s, got %s", value_objects.HabitTypeValue, result.Type)
|
||||
if habitDTO.Type != value_objects.HabitTypeValue {
|
||||
t.Errorf("Expected type %s, got %s", value_objects.HabitTypeValue, habitDTO.Type)
|
||||
}
|
||||
|
||||
if result.Frequency != value_objects.FrequencyDaily {
|
||||
t.Errorf("Expected frequency %s, got %s", value_objects.FrequencyDaily, result.Frequency)
|
||||
if habitDTO.Frequency != value_objects.FrequencyDaily {
|
||||
t.Errorf("Expected frequency %s, got %s", value_objects.FrequencyDaily, habitDTO.Frequency)
|
||||
}
|
||||
|
||||
if result.TargetValue == nil || *result.TargetValue != 5.0 {
|
||||
t.Errorf("Expected target value 5.0, got %v", result.TargetValue)
|
||||
if habitDTO.TargetValue == nil || *habitDTO.TargetValue != 5.0 {
|
||||
t.Errorf("Expected target value 5.0, got %v", habitDTO.TargetValue)
|
||||
}
|
||||
|
||||
if !result.CarryOver {
|
||||
if !habitDTO.CarryOver {
|
||||
t.Error("Expected carry over to be true")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetUserHabitsHandler_WithPagination(t *testing.T) {
|
||||
var habits []*entities.Habit
|
||||
for i := 1; i <= 10; i++ {
|
||||
habit := entities.NewHabit("user-123", "Habit", value_objects.HabitTypeBoolean, value_objects.FrequencyDaily, false, false)
|
||||
habit.ID = "habit-" + string(rune(i+'0'))
|
||||
habits = append(habits, habit)
|
||||
}
|
||||
|
||||
habitRepo := &mockGetUserHabitsRepo{habits: habits}
|
||||
handler := NewGetUserHabitsHandler(habitRepo)
|
||||
|
||||
t.Run("FirstPage", func(t *testing.T) {
|
||||
params := pagination.NewParams(1, 5)
|
||||
query := GetUserHabitsQuery{
|
||||
UserID: "user-123",
|
||||
PaginationParams: ¶ms,
|
||||
}
|
||||
|
||||
result, err := handler.Handle(context.Background(), query)
|
||||
|
||||
if err != nil {
|
||||
t.Fatalf("Expected no error, got %v", err)
|
||||
}
|
||||
|
||||
if len(result.Habits) != 5 {
|
||||
t.Errorf("Expected 5 habits on first page, got %d", len(result.Habits))
|
||||
}
|
||||
|
||||
if result.Pagination == nil {
|
||||
t.Fatal("Expected pagination metadata")
|
||||
}
|
||||
|
||||
if result.Pagination.Page != 1 {
|
||||
t.Errorf("Expected page 1, got %d", result.Pagination.Page)
|
||||
}
|
||||
|
||||
if result.Pagination.PageSize != 5 {
|
||||
t.Errorf("Expected page_size 5, got %d", result.Pagination.PageSize)
|
||||
}
|
||||
|
||||
if result.Pagination.TotalItems != 10 {
|
||||
t.Errorf("Expected total_items 10, got %d", result.Pagination.TotalItems)
|
||||
}
|
||||
|
||||
if result.Pagination.TotalPages != 2 {
|
||||
t.Errorf("Expected total_pages 2, got %d", result.Pagination.TotalPages)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("SecondPage", func(t *testing.T) {
|
||||
params := pagination.NewParams(2, 5)
|
||||
query := GetUserHabitsQuery{
|
||||
UserID: "user-123",
|
||||
PaginationParams: ¶ms,
|
||||
}
|
||||
|
||||
result, err := handler.Handle(context.Background(), query)
|
||||
|
||||
if err != nil {
|
||||
t.Fatalf("Expected no error, got %v", err)
|
||||
}
|
||||
|
||||
if len(result.Habits) != 5 {
|
||||
t.Errorf("Expected 5 habits on second page, got %d", len(result.Habits))
|
||||
}
|
||||
|
||||
if result.Pagination.Page != 2 {
|
||||
t.Errorf("Expected page 2, got %d", result.Pagination.Page)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("PageBeyondTotal", func(t *testing.T) {
|
||||
params := pagination.NewParams(10, 5)
|
||||
query := GetUserHabitsQuery{
|
||||
UserID: "user-123",
|
||||
PaginationParams: ¶ms,
|
||||
}
|
||||
|
||||
result, err := handler.Handle(context.Background(), query)
|
||||
|
||||
if err != nil {
|
||||
t.Fatalf("Expected no error, got %v", err)
|
||||
}
|
||||
|
||||
if len(result.Habits) != 0 {
|
||||
t.Errorf("Expected 0 habits beyond total, got %d", len(result.Habits))
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("CustomPageSize", func(t *testing.T) {
|
||||
params := pagination.NewParams(1, 3)
|
||||
query := GetUserHabitsQuery{
|
||||
UserID: "user-123",
|
||||
PaginationParams: ¶ms,
|
||||
}
|
||||
|
||||
result, err := handler.Handle(context.Background(), query)
|
||||
|
||||
if err != nil {
|
||||
t.Fatalf("Expected no error, got %v", err)
|
||||
}
|
||||
|
||||
if len(result.Habits) != 3 {
|
||||
t.Errorf("Expected 3 habits with page_size=3, got %d", len(result.Habits))
|
||||
}
|
||||
|
||||
if result.Pagination.PageSize != 3 {
|
||||
t.Errorf("Expected page_size 3, got %d", result.Pagination.PageSize)
|
||||
}
|
||||
|
||||
if result.Pagination.TotalPages != 4 {
|
||||
t.Errorf("Expected total_pages 4 (10 items / 3 per page), got %d", result.Pagination.TotalPages)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func (m *mockGetUserHabitsRepo) FindByUserIDFiltered(ctx context.Context, userID string, filter repositories.HabitFilter, paginationParams *pagination.Params) ([]*entities.Habit, error) {
|
||||
var filtered []*entities.Habit
|
||||
|
||||
for _, habit := range m.habits {
|
||||
if filter.Type != nil && habit.Type != *filter.Type {
|
||||
continue
|
||||
}
|
||||
if filter.Frequency != nil && habit.Frequency != *filter.Frequency {
|
||||
continue
|
||||
}
|
||||
if !filter.IncludeArchived && habit.ArchivedAt != nil {
|
||||
continue
|
||||
}
|
||||
filtered = append(filtered, habit)
|
||||
}
|
||||
|
||||
if paginationParams != nil {
|
||||
offset := paginationParams.Offset()
|
||||
limit := paginationParams.Limit()
|
||||
|
||||
if offset >= len(filtered) {
|
||||
return []*entities.Habit{}, nil
|
||||
}
|
||||
|
||||
end := offset + limit
|
||||
if end > len(filtered) {
|
||||
end = len(filtered)
|
||||
}
|
||||
|
||||
return filtered[offset:end], nil
|
||||
}
|
||||
|
||||
return filtered, nil
|
||||
}
|
||||
|
||||
func (m *mockGetUserHabitsRepo) CountByUserIDFiltered(ctx context.Context, userID string, filter repositories.HabitFilter) (int, error) {
|
||||
count := 0
|
||||
|
||||
for _, habit := range m.habits {
|
||||
if filter.Type != nil && habit.Type != *filter.Type {
|
||||
continue
|
||||
}
|
||||
if filter.Frequency != nil && habit.Frequency != *filter.Frequency {
|
||||
continue
|
||||
}
|
||||
if !filter.IncludeArchived && habit.ArchivedAt != nil {
|
||||
continue
|
||||
}
|
||||
count++
|
||||
}
|
||||
|
||||
return count, nil
|
||||
}
|
||||
|
||||
func TestGetUserHabitsHandler_WithFilters(t *testing.T) {
|
||||
habit1 := entities.NewHabit("user-123", "Exercise", value_objects.HabitTypeBoolean, value_objects.FrequencyDaily, false, false)
|
||||
habit1.ID = "habit-1"
|
||||
|
||||
habit2 := entities.NewHabit("user-123", "Read", value_objects.HabitTypeCounter, value_objects.FrequencyWeekly, false, false)
|
||||
habit2.ID = "habit-2"
|
||||
|
||||
habit3 := entities.NewHabit("user-123", "Water", value_objects.HabitTypeValue, value_objects.FrequencyDaily, false, false)
|
||||
habit3.ID = "habit-3"
|
||||
|
||||
habitRepo := &mockGetUserHabitsRepo{habits: []*entities.Habit{habit1, habit2, habit3}}
|
||||
handler := NewGetUserHabitsHandler(habitRepo)
|
||||
|
||||
t.Run("FilterByType", func(t *testing.T) {
|
||||
habitType := value_objects.HabitTypeBoolean
|
||||
query := GetUserHabitsQuery{
|
||||
UserID: "user-123",
|
||||
FilterParams: &FilterParams{
|
||||
Type: &habitType,
|
||||
},
|
||||
}
|
||||
|
||||
result, err := handler.Handle(context.Background(), query)
|
||||
if err != nil {
|
||||
t.Fatalf("Expected no error, got %v", err)
|
||||
}
|
||||
|
||||
if len(result.Habits) != 1 {
|
||||
t.Errorf("Expected 1 BOOLEAN habit, got %d", len(result.Habits))
|
||||
}
|
||||
|
||||
if result.Habits[0].Type != value_objects.HabitTypeBoolean {
|
||||
t.Errorf("Expected BOOLEAN type, got %s", result.Habits[0].Type)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("FilterByFrequency", func(t *testing.T) {
|
||||
frequency := value_objects.FrequencyDaily
|
||||
query := GetUserHabitsQuery{
|
||||
UserID: "user-123",
|
||||
FilterParams: &FilterParams{
|
||||
Frequency: &frequency,
|
||||
},
|
||||
}
|
||||
|
||||
result, err := handler.Handle(context.Background(), query)
|
||||
if err != nil {
|
||||
t.Fatalf("Expected no error, got %v", err)
|
||||
}
|
||||
|
||||
if len(result.Habits) != 2 {
|
||||
t.Errorf("Expected 2 DAILY habits, got %d", len(result.Habits))
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("FilterWithPagination", func(t *testing.T) {
|
||||
frequency := value_objects.FrequencyDaily
|
||||
params := pagination.NewParams(1, 1)
|
||||
query := GetUserHabitsQuery{
|
||||
UserID: "user-123",
|
||||
FilterParams: &FilterParams{
|
||||
Frequency: &frequency,
|
||||
},
|
||||
PaginationParams: ¶ms,
|
||||
}
|
||||
|
||||
result, err := handler.Handle(context.Background(), query)
|
||||
if err != nil {
|
||||
t.Fatalf("Expected no error, got %v", err)
|
||||
}
|
||||
|
||||
if len(result.Habits) != 1 {
|
||||
t.Errorf("Expected 1 habit on first page, got %d", len(result.Habits))
|
||||
}
|
||||
|
||||
if result.Pagination == nil {
|
||||
t.Fatal("Expected pagination metadata")
|
||||
}
|
||||
|
||||
if result.Pagination.TotalItems != 2 {
|
||||
t.Errorf("Expected 2 total DAILY habits, got %d", result.Pagination.TotalItems)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -14,9 +14,8 @@ type LoginUserQuery struct {
|
||||
}
|
||||
|
||||
type LoginUserResult struct {
|
||||
UserID string
|
||||
Email string
|
||||
Timezone string
|
||||
UserID string
|
||||
Email string
|
||||
}
|
||||
|
||||
type LoginUserHandler struct {
|
||||
@@ -50,8 +49,7 @@ func (h *LoginUserHandler) Handle(ctx context.Context, query LoginUserQuery) (*L
|
||||
}
|
||||
|
||||
return &LoginUserResult{
|
||||
UserID: user.ID,
|
||||
Email: user.Email,
|
||||
Timezone: user.Timezone,
|
||||
UserID: user.ID,
|
||||
Email: user.Email,
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
package queries
|
||||
|
||||
import (
|
||||
"apocapoc-api/internal/domain/repositories"
|
||||
"apocapoc-api/internal/shared/pagination"
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
@@ -55,7 +57,7 @@ func (m *mockLoginPasswordHasher) Compare(hashedPassword, password string) error
|
||||
}
|
||||
|
||||
func TestLoginUserHandler_Success(t *testing.T) {
|
||||
user := entities.NewUser("test@example.com", "hashed_password", "UTC")
|
||||
user := entities.NewUser("test@example.com", "hashed_password")
|
||||
user.ID = "user-123"
|
||||
user.EmailVerified = true
|
||||
|
||||
@@ -90,10 +92,6 @@ func TestLoginUserHandler_Success(t *testing.T) {
|
||||
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) {
|
||||
@@ -150,7 +148,7 @@ func TestLoginUserHandler_UserNotFound(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestLoginUserHandler_InvalidPassword(t *testing.T) {
|
||||
user := entities.NewUser("test@example.com", "hashed_password", "UTC")
|
||||
user := entities.NewUser("test@example.com", "hashed_password")
|
||||
user.ID = "user-123"
|
||||
user.EmailVerified = true
|
||||
|
||||
@@ -180,7 +178,7 @@ func TestLoginUserHandler_InvalidPassword(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestLoginUserHandler_EmailNotVerified(t *testing.T) {
|
||||
user := entities.NewUser("test@example.com", "hashed_password", "UTC")
|
||||
user := entities.NewUser("test@example.com", "hashed_password")
|
||||
user.ID = "user-123"
|
||||
user.EmailVerified = false
|
||||
|
||||
@@ -208,3 +206,19 @@ func TestLoginUserHandler_EmailNotVerified(t *testing.T) {
|
||||
t.Errorf("Handle() error = %v, want %v", err, errors.ErrEmailNotVerified)
|
||||
}
|
||||
}
|
||||
|
||||
func (m *mockLoginUserRepo) FindActiveByUserIDWithPagination(ctx context.Context, userID string, params pagination.Params) ([]*entities.Habit, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (m *mockLoginUserRepo) CountActiveByUserID(ctx context.Context, userID string) (int, error) {
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
func (m *mockLoginUserRepo) FindByUserIDFiltered(ctx context.Context, userID string, filter repositories.HabitFilter, paginationParams *pagination.Params) ([]*entities.Habit, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (m *mockLoginUserRepo) CountByUserIDFiltered(ctx context.Context, userID string, filter repositories.HabitFilter) (int, error) {
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
@@ -17,9 +17,8 @@ type RefreshTokenQuery struct {
|
||||
}
|
||||
|
||||
type RefreshTokenResult struct {
|
||||
UserID string
|
||||
Email string
|
||||
Timezone string
|
||||
UserID string
|
||||
Email string
|
||||
}
|
||||
|
||||
type RefreshTokenHandler struct {
|
||||
@@ -57,9 +56,8 @@ func (h *RefreshTokenHandler) Handle(ctx context.Context, query RefreshTokenQuer
|
||||
}
|
||||
|
||||
return &RefreshTokenResult{
|
||||
UserID: user.ID,
|
||||
Email: user.Email,
|
||||
Timezone: user.Timezone,
|
||||
UserID: user.ID,
|
||||
Email: user.Email,
|
||||
}, nil
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
package queries
|
||||
|
||||
import (
|
||||
"apocapoc-api/internal/domain/repositories"
|
||||
"apocapoc-api/internal/shared/pagination"
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
@@ -80,7 +82,7 @@ func TestRefreshTokenHandler_Success(t *testing.T) {
|
||||
|
||||
userRepo := &mockUserRepositoryForRefresh{
|
||||
findByIDFunc: func(ctx context.Context, id string) (*entities.User, error) {
|
||||
user := entities.NewUser("test@example.com", "hash", "UTC")
|
||||
user := entities.NewUser("test@example.com", "hash")
|
||||
user.ID = id
|
||||
return user, nil
|
||||
},
|
||||
@@ -266,3 +268,35 @@ func TestCreateRefreshToken_MultipleCalls(t *testing.T) {
|
||||
t.Error("UserIDs should be different")
|
||||
}
|
||||
}
|
||||
|
||||
func (m *mockRefreshTokenRepository) FindActiveByUserIDWithPagination(ctx context.Context, userID string, params pagination.Params) ([]*entities.Habit, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (m *mockRefreshTokenRepository) CountActiveByUserID(ctx context.Context, userID string) (int, error) {
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
func (m *mockUserRepositoryForRefresh) FindActiveByUserIDWithPagination(ctx context.Context, userID string, params pagination.Params) ([]*entities.Habit, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (m *mockUserRepositoryForRefresh) CountActiveByUserID(ctx context.Context, userID string) (int, error) {
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
func (m *mockRefreshTokenRepository) FindByUserIDFiltered(ctx context.Context, userID string, filter repositories.HabitFilter, paginationParams *pagination.Params) ([]*entities.Habit, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (m *mockRefreshTokenRepository) CountByUserIDFiltered(ctx context.Context, userID string, filter repositories.HabitFilter) (int, error) {
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
func (m *mockUserRepositoryForRefresh) FindByUserIDFiltered(ctx context.Context, userID string, filter repositories.HabitFilter, paginationParams *pagination.Params) ([]*entities.Habit, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (m *mockUserRepositoryForRefresh) CountByUserIDFiltered(ctx context.Context, userID string, filter repositories.HabitFilter) (int, error) {
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
@@ -6,7 +6,6 @@ type User struct {
|
||||
ID string
|
||||
Email string
|
||||
PasswordHash string
|
||||
Timezone string
|
||||
EmailVerified bool
|
||||
EmailVerificationToken *string
|
||||
EmailVerificationExpiry *time.Time
|
||||
@@ -14,15 +13,11 @@ type User struct {
|
||||
UpdatedAt time.Time
|
||||
}
|
||||
|
||||
func NewUser(email, passwordHash, timezone string) *User {
|
||||
func NewUser(email, passwordHash string) *User {
|
||||
now := time.Now()
|
||||
if timezone == "" {
|
||||
timezone = "UTC"
|
||||
}
|
||||
return &User{
|
||||
Email: email,
|
||||
PasswordHash: passwordHash,
|
||||
Timezone: timezone,
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
}
|
||||
|
||||
@@ -8,9 +8,8 @@ import (
|
||||
func TestNewUser(t *testing.T) {
|
||||
email := "test@example.com"
|
||||
passwordHash := "hashed_password_123"
|
||||
timezone := "Europe/Madrid"
|
||||
|
||||
user := NewUser(email, passwordHash, timezone)
|
||||
user := NewUser(email, passwordHash)
|
||||
|
||||
if user.Email != email {
|
||||
t.Errorf("Expected email %s, got %s", email, user.Email)
|
||||
@@ -20,10 +19,6 @@ func TestNewUser(t *testing.T) {
|
||||
t.Errorf("Expected password hash %s, got %s", passwordHash, user.PasswordHash)
|
||||
}
|
||||
|
||||
if user.Timezone != timezone {
|
||||
t.Errorf("Expected timezone %s, got %s", timezone, user.Timezone)
|
||||
}
|
||||
|
||||
if user.CreatedAt.IsZero() {
|
||||
t.Error("CreatedAt should not be zero")
|
||||
}
|
||||
@@ -37,11 +32,3 @@ func TestNewUser(t *testing.T) {
|
||||
t.Errorf("CreatedAt and UpdatedAt should be nearly identical, diff: %v", diff)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUser_DefaultTimezone(t *testing.T) {
|
||||
user := NewUser("test@example.com", "hash", "")
|
||||
|
||||
if user.Timezone != "UTC" {
|
||||
t.Errorf("Expected default timezone UTC, got %s", user.Timezone)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ type HabitEntryRepository interface {
|
||||
FindByID(ctx context.Context, id string) (*entities.HabitEntry, error)
|
||||
FindByHabitID(ctx context.Context, habitID string) ([]*entities.HabitEntry, error)
|
||||
FindByHabitIDAndDateRange(ctx context.Context, habitID string, from, to time.Time) ([]*entities.HabitEntry, error)
|
||||
FindByUserID(ctx context.Context, userID string) ([]*entities.HabitEntry, error)
|
||||
FindPendingByHabitID(ctx context.Context, habitID string, beforeDate time.Time) ([]*entities.HabitEntry, error)
|
||||
Update(ctx context.Context, entry *entities.HabitEntry) error
|
||||
Delete(ctx context.Context, id string) error
|
||||
|
||||
@@ -4,13 +4,26 @@ import (
|
||||
"context"
|
||||
|
||||
"apocapoc-api/internal/domain/entities"
|
||||
"apocapoc-api/internal/domain/value_objects"
|
||||
"apocapoc-api/internal/shared/pagination"
|
||||
)
|
||||
|
||||
type HabitFilter struct {
|
||||
Type *value_objects.HabitType
|
||||
Frequency *value_objects.Frequency
|
||||
IncludeArchived bool
|
||||
Search string
|
||||
}
|
||||
|
||||
type HabitRepository interface {
|
||||
Create(ctx context.Context, habit *entities.Habit) error
|
||||
FindByID(ctx context.Context, id string) (*entities.Habit, error)
|
||||
FindByUserID(ctx context.Context, userID string) ([]*entities.Habit, error)
|
||||
FindActiveByUserID(ctx context.Context, userID string) ([]*entities.Habit, error)
|
||||
FindActiveByUserIDWithPagination(ctx context.Context, userID string, params pagination.Params) ([]*entities.Habit, error)
|
||||
FindByUserIDFiltered(ctx context.Context, userID string, filter HabitFilter, paginationParams *pagination.Params) ([]*entities.Habit, error)
|
||||
CountActiveByUserID(ctx context.Context, userID string) (int, error)
|
||||
CountByUserIDFiltered(ctx context.Context, userID string, filter HabitFilter) (int, error)
|
||||
Update(ctx context.Context, habit *entities.Habit) error
|
||||
Delete(ctx context.Context, id string) error
|
||||
}
|
||||
|
||||
@@ -9,4 +9,5 @@ type EmailMessage struct {
|
||||
|
||||
type EmailService interface {
|
||||
Send(message EmailMessage) error
|
||||
HealthCheck() error
|
||||
}
|
||||
|
||||
@@ -46,7 +46,10 @@
|
||||
"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"
|
||||
"failed_get_stats": "Failed to get statistics",
|
||||
"export_failed": "Failed to export data",
|
||||
"timezone_required": "Timezone is required",
|
||||
"invalid_timezone": "Invalid timezone (must be a valid IANA timezone)"
|
||||
},
|
||||
"success": {
|
||||
"registration_with_verification": "Registration successful. Please check your email to verify your account.",
|
||||
|
||||
@@ -46,7 +46,10 @@
|
||||
"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"
|
||||
"failed_get_stats": "Error al obtener estadísticas",
|
||||
"export_failed": "Error al exportar datos",
|
||||
"timezone_required": "La zona horaria es requerida",
|
||||
"invalid_timezone": "Zona horaria inválida (debe ser una zona horaria IANA válida)"
|
||||
},
|
||||
"success": {
|
||||
"registration_with_verification": "Registro exitoso. Por favor revisa tu correo electrónico para verificar tu cuenta.",
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
package backup
|
||||
|
||||
import (
|
||||
"compress/gzip"
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"time"
|
||||
|
||||
"apocapoc-api/internal/infrastructure/logger"
|
||||
)
|
||||
|
||||
type Config struct {
|
||||
Enabled bool
|
||||
Interval time.Duration
|
||||
RetentionDays int
|
||||
Path string
|
||||
Compress bool
|
||||
DatabasePath string
|
||||
}
|
||||
|
||||
func CreateBackup(db *sql.DB, config Config) error {
|
||||
if !config.Enabled {
|
||||
return nil
|
||||
}
|
||||
|
||||
if err := os.MkdirAll(config.Path, 0755); err != nil {
|
||||
return fmt.Errorf("failed to create backup directory: %w", err)
|
||||
}
|
||||
|
||||
timestamp := time.Now().Format("20060102_150405")
|
||||
filename := fmt.Sprintf("apocapoc_%s.db", timestamp)
|
||||
backupPath := filepath.Join(config.Path, filename)
|
||||
|
||||
logger.Info().
|
||||
Str("backup_path", backupPath).
|
||||
Msg("Starting database backup")
|
||||
|
||||
if err := backupDatabase(db, backupPath); err != nil {
|
||||
logger.Error().
|
||||
Err(err).
|
||||
Str("backup_path", backupPath).
|
||||
Msg("Backup failed")
|
||||
return fmt.Errorf("backup failed: %w", err)
|
||||
}
|
||||
|
||||
if config.Compress {
|
||||
compressedPath := backupPath + ".gz"
|
||||
if err := compressFile(backupPath, compressedPath); err != nil {
|
||||
logger.Warn().
|
||||
Err(err).
|
||||
Str("backup_path", backupPath).
|
||||
Msg("Compression failed, keeping uncompressed backup")
|
||||
} else {
|
||||
os.Remove(backupPath)
|
||||
backupPath = compressedPath
|
||||
}
|
||||
}
|
||||
|
||||
logger.Info().
|
||||
Str("backup_path", backupPath).
|
||||
Msg("Backup completed successfully")
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func backupDatabase(db *sql.DB, destPath string) error {
|
||||
_, err := db.Exec(fmt.Sprintf("VACUUM INTO '%s'", destPath))
|
||||
if err != nil {
|
||||
return fmt.Errorf("vacuum into failed: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func compressFile(srcPath, destPath string) error {
|
||||
srcFile, err := os.Open(srcPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer srcFile.Close()
|
||||
|
||||
destFile, err := os.Create(destPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer destFile.Close()
|
||||
|
||||
gzipWriter := gzip.NewWriter(destFile)
|
||||
defer gzipWriter.Close()
|
||||
|
||||
_, err = io.Copy(gzipWriter, srcFile)
|
||||
return err
|
||||
}
|
||||
@@ -0,0 +1,169 @@
|
||||
package backup
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
_ "modernc.org/sqlite"
|
||||
)
|
||||
|
||||
func TestCreateBackup(t *testing.T) {
|
||||
tempDir := t.TempDir()
|
||||
dbPath := filepath.Join(tempDir, "test.db")
|
||||
|
||||
db, err := sql.Open("sqlite", dbPath)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create test database: %v", err)
|
||||
}
|
||||
defer db.Close()
|
||||
|
||||
_, err = db.Exec("CREATE TABLE test (id INTEGER PRIMARY KEY, name TEXT)")
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create test table: %v", err)
|
||||
}
|
||||
|
||||
_, err = db.Exec("INSERT INTO test (name) VALUES ('test1'), ('test2')")
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to insert test data: %v", err)
|
||||
}
|
||||
|
||||
backupPath := filepath.Join(tempDir, "backups")
|
||||
config := Config{
|
||||
Enabled: true,
|
||||
Interval: 24 * time.Hour,
|
||||
RetentionDays: 7,
|
||||
Path: backupPath,
|
||||
Compress: false,
|
||||
DatabasePath: dbPath,
|
||||
}
|
||||
|
||||
err = CreateBackup(db, config)
|
||||
if err != nil {
|
||||
t.Fatalf("CreateBackup failed: %v", err)
|
||||
}
|
||||
|
||||
files, err := os.ReadDir(backupPath)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to read backup directory: %v", err)
|
||||
}
|
||||
|
||||
if len(files) != 1 {
|
||||
t.Errorf("Expected 1 backup file, got %d", len(files))
|
||||
}
|
||||
|
||||
if len(files) > 0 && filepath.Ext(files[0].Name()) != ".db" {
|
||||
t.Errorf("Expected backup file to have .db extension, got %s", files[0].Name())
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateBackupWithCompression(t *testing.T) {
|
||||
tempDir := t.TempDir()
|
||||
dbPath := filepath.Join(tempDir, "test.db")
|
||||
|
||||
db, err := sql.Open("sqlite", dbPath)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create test database: %v", err)
|
||||
}
|
||||
defer db.Close()
|
||||
|
||||
_, err = db.Exec("CREATE TABLE test (id INTEGER PRIMARY KEY, name TEXT)")
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create test table: %v", err)
|
||||
}
|
||||
|
||||
backupPath := filepath.Join(tempDir, "backups")
|
||||
config := Config{
|
||||
Enabled: true,
|
||||
Interval: 24 * time.Hour,
|
||||
RetentionDays: 7,
|
||||
Path: backupPath,
|
||||
Compress: true,
|
||||
DatabasePath: dbPath,
|
||||
}
|
||||
|
||||
err = CreateBackup(db, config)
|
||||
if err != nil {
|
||||
t.Fatalf("CreateBackup failed: %v", err)
|
||||
}
|
||||
|
||||
files, err := os.ReadDir(backupPath)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to read backup directory: %v", err)
|
||||
}
|
||||
|
||||
if len(files) != 1 {
|
||||
t.Errorf("Expected 1 backup file, got %d", len(files))
|
||||
}
|
||||
|
||||
if len(files) > 0 && filepath.Ext(files[0].Name()) != ".gz" {
|
||||
t.Errorf("Expected backup file to have .gz extension, got %s", files[0].Name())
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateBackupDisabled(t *testing.T) {
|
||||
tempDir := t.TempDir()
|
||||
dbPath := filepath.Join(tempDir, "test.db")
|
||||
|
||||
db, err := sql.Open("sqlite", dbPath)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create test database: %v", err)
|
||||
}
|
||||
defer db.Close()
|
||||
|
||||
backupPath := filepath.Join(tempDir, "backups")
|
||||
config := Config{
|
||||
Enabled: false,
|
||||
Interval: 24 * time.Hour,
|
||||
RetentionDays: 7,
|
||||
Path: backupPath,
|
||||
Compress: false,
|
||||
DatabasePath: dbPath,
|
||||
}
|
||||
|
||||
err = CreateBackup(db, config)
|
||||
if err != nil {
|
||||
t.Fatalf("CreateBackup failed: %v", err)
|
||||
}
|
||||
|
||||
_, err = os.Stat(backupPath)
|
||||
if !os.IsNotExist(err) {
|
||||
t.Error("Backup directory should not exist when backup is disabled")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCleanOldBackups(t *testing.T) {
|
||||
tempDir := t.TempDir()
|
||||
backupPath := filepath.Join(tempDir, "backups")
|
||||
os.MkdirAll(backupPath, 0755)
|
||||
|
||||
oldFile := filepath.Join(backupPath, "apocapoc_20200101_120000.db")
|
||||
recentFile := filepath.Join(backupPath, "apocapoc_"+time.Now().Format("20060102_150405")+".db")
|
||||
|
||||
os.WriteFile(oldFile, []byte("old"), 0644)
|
||||
os.WriteFile(recentFile, []byte("recent"), 0644)
|
||||
|
||||
oldTime := time.Now().AddDate(0, 0, -10)
|
||||
os.Chtimes(oldFile, oldTime, oldTime)
|
||||
|
||||
config := Config{
|
||||
Enabled: true,
|
||||
RetentionDays: 7,
|
||||
Path: backupPath,
|
||||
}
|
||||
|
||||
err := CleanOldBackups(config)
|
||||
if err != nil {
|
||||
t.Fatalf("CleanOldBackups failed: %v", err)
|
||||
}
|
||||
|
||||
if _, err := os.Stat(oldFile); !os.IsNotExist(err) {
|
||||
t.Error("Old backup file should have been deleted")
|
||||
}
|
||||
|
||||
if _, err := os.Stat(recentFile); err != nil {
|
||||
t.Error("Recent backup file should still exist")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
package backup
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"apocapoc-api/internal/infrastructure/logger"
|
||||
)
|
||||
|
||||
func CleanOldBackups(config Config) error {
|
||||
if !config.Enabled {
|
||||
return nil
|
||||
}
|
||||
|
||||
if config.RetentionDays <= 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
cutoffTime := time.Now().AddDate(0, 0, -config.RetentionDays)
|
||||
|
||||
files, err := os.ReadDir(config.Path)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
deletedCount := 0
|
||||
for _, file := range files {
|
||||
if file.IsDir() {
|
||||
continue
|
||||
}
|
||||
|
||||
if !strings.HasPrefix(file.Name(), "apocapoc_") {
|
||||
continue
|
||||
}
|
||||
|
||||
if !strings.HasSuffix(file.Name(), ".db") && !strings.HasSuffix(file.Name(), ".db.gz") {
|
||||
continue
|
||||
}
|
||||
|
||||
filePath := filepath.Join(config.Path, file.Name())
|
||||
info, err := os.Stat(filePath)
|
||||
if err != nil {
|
||||
logger.Warn().
|
||||
Err(err).
|
||||
Str("file", filePath).
|
||||
Msg("Failed to stat backup file")
|
||||
continue
|
||||
}
|
||||
|
||||
if info.ModTime().Before(cutoffTime) {
|
||||
if err := os.Remove(filePath); err != nil {
|
||||
logger.Warn().
|
||||
Err(err).
|
||||
Str("file", filePath).
|
||||
Msg("Failed to delete old backup")
|
||||
continue
|
||||
}
|
||||
|
||||
logger.Info().
|
||||
Str("file", file.Name()).
|
||||
Time("mod_time", info.ModTime()).
|
||||
Msg("Deleted old backup")
|
||||
deletedCount++
|
||||
}
|
||||
}
|
||||
|
||||
if deletedCount > 0 {
|
||||
logger.Info().
|
||||
Int("deleted_count", deletedCount).
|
||||
Int("retention_days", config.RetentionDays).
|
||||
Msg("Backup cleanup completed")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
package backup
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"time"
|
||||
|
||||
"apocapoc-api/internal/infrastructure/logger"
|
||||
)
|
||||
|
||||
type Scheduler struct {
|
||||
db *sql.DB
|
||||
config Config
|
||||
stopCh chan struct{}
|
||||
}
|
||||
|
||||
func NewScheduler(db *sql.DB, config Config) *Scheduler {
|
||||
return &Scheduler{
|
||||
db: db,
|
||||
config: config,
|
||||
stopCh: make(chan struct{}),
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Scheduler) Start() {
|
||||
if !s.config.Enabled {
|
||||
logger.Info().Msg("Backup scheduler is disabled")
|
||||
return
|
||||
}
|
||||
|
||||
logger.Info().
|
||||
Dur("interval", s.config.Interval).
|
||||
Int("retention_days", s.config.RetentionDays).
|
||||
Str("path", s.config.Path).
|
||||
Bool("compress", s.config.Compress).
|
||||
Msg("Starting backup scheduler")
|
||||
|
||||
go s.run()
|
||||
}
|
||||
|
||||
func (s *Scheduler) run() {
|
||||
if err := CreateBackup(s.db, s.config); err != nil {
|
||||
logger.Error().Err(err).Msg("Initial backup failed")
|
||||
}
|
||||
|
||||
if err := CleanOldBackups(s.config); err != nil {
|
||||
logger.Error().Err(err).Msg("Initial cleanup failed")
|
||||
}
|
||||
|
||||
ticker := time.NewTicker(s.config.Interval)
|
||||
defer ticker.Stop()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-ticker.C:
|
||||
logger.Debug().Msg("Running scheduled backup")
|
||||
|
||||
if err := CreateBackup(s.db, s.config); err != nil {
|
||||
logger.Error().Err(err).Msg("Scheduled backup failed")
|
||||
continue
|
||||
}
|
||||
|
||||
if err := CleanOldBackups(s.config); err != nil {
|
||||
logger.Error().Err(err).Msg("Backup cleanup failed")
|
||||
}
|
||||
|
||||
case <-s.stopCh:
|
||||
logger.Info().Msg("Backup scheduler stopped")
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Scheduler) Stop() {
|
||||
if s.config.Enabled {
|
||||
close(s.stopCh)
|
||||
}
|
||||
}
|
||||
@@ -8,42 +8,56 @@ import (
|
||||
)
|
||||
|
||||
type Config struct {
|
||||
DBPath string
|
||||
Port string
|
||||
AppURL string
|
||||
JWTSecret string
|
||||
JWTExpiry string
|
||||
RefreshTokenExpiry string
|
||||
DefaultTimezone string
|
||||
SMTPHost string
|
||||
SMTPPort string
|
||||
SMTPUser string
|
||||
SMTPPassword string
|
||||
SMTPFrom string
|
||||
SupportEmail string
|
||||
SendWelcomeEmail string
|
||||
RegistrationMode string
|
||||
DBPath string
|
||||
Port string
|
||||
AppURL string
|
||||
JWTSecret string
|
||||
JWTExpiry string
|
||||
RefreshTokenExpiry string
|
||||
DefaultTimezone string
|
||||
SMTPHost string
|
||||
SMTPPort string
|
||||
SMTPUser string
|
||||
SMTPPassword string
|
||||
SMTPFrom string
|
||||
SupportEmail string
|
||||
SendWelcomeEmail string
|
||||
RegistrationMode string
|
||||
LogLevel string
|
||||
Environment string
|
||||
BackupEnabled string
|
||||
BackupInterval string
|
||||
BackupRetentionDays string
|
||||
BackupPath string
|
||||
BackupCompress string
|
||||
}
|
||||
|
||||
func Load() (*Config, error) {
|
||||
godotenv.Load()
|
||||
|
||||
cfg := &Config{
|
||||
DBPath: os.Getenv("DB_PATH"),
|
||||
Port: getEnvOrDefault("PORT", "8080"),
|
||||
AppURL: getEnvOrDefault("APP_URL", "http://localhost:8080"),
|
||||
JWTSecret: os.Getenv("JWT_SECRET"),
|
||||
JWTExpiry: os.Getenv("JWT_EXPIRY"),
|
||||
RefreshTokenExpiry: os.Getenv("REFRESH_TOKEN_EXPIRY"),
|
||||
DefaultTimezone: os.Getenv("DEFAULT_TIMEZONE"),
|
||||
SMTPHost: os.Getenv("SMTP_HOST"),
|
||||
SMTPPort: getEnvOrDefault("SMTP_PORT", "587"),
|
||||
SMTPUser: os.Getenv("SMTP_USER"),
|
||||
SMTPPassword: os.Getenv("SMTP_PASSWORD"),
|
||||
SMTPFrom: os.Getenv("SMTP_FROM"),
|
||||
SupportEmail: getEnvOrDefault("SUPPORT_EMAIL", "contact@apocapoc.app"),
|
||||
SendWelcomeEmail: getEnvOrDefault("SEND_WELCOME_EMAIL", "false"),
|
||||
RegistrationMode: getEnvOrDefault("REGISTRATION_MODE", "open"),
|
||||
DBPath: os.Getenv("DB_PATH"),
|
||||
Port: getEnvOrDefault("PORT", "8080"),
|
||||
AppURL: getEnvOrDefault("APP_URL", "http://localhost:8080"),
|
||||
JWTSecret: os.Getenv("JWT_SECRET"),
|
||||
JWTExpiry: os.Getenv("JWT_EXPIRY"),
|
||||
RefreshTokenExpiry: os.Getenv("REFRESH_TOKEN_EXPIRY"),
|
||||
DefaultTimezone: os.Getenv("DEFAULT_TIMEZONE"),
|
||||
SMTPHost: os.Getenv("SMTP_HOST"),
|
||||
SMTPPort: getEnvOrDefault("SMTP_PORT", "587"),
|
||||
SMTPUser: os.Getenv("SMTP_USER"),
|
||||
SMTPPassword: os.Getenv("SMTP_PASSWORD"),
|
||||
SMTPFrom: os.Getenv("SMTP_FROM"),
|
||||
SupportEmail: getEnvOrDefault("SUPPORT_EMAIL", "contact@apocapoc.app"),
|
||||
SendWelcomeEmail: getEnvOrDefault("SEND_WELCOME_EMAIL", "false"),
|
||||
RegistrationMode: getEnvOrDefault("REGISTRATION_MODE", "open"),
|
||||
LogLevel: getEnvOrDefault("LOG_LEVEL", "info"),
|
||||
Environment: getEnvOrDefault("ENVIRONMENT", "production"),
|
||||
BackupEnabled: getEnvOrDefault("BACKUP_ENABLED", "false"),
|
||||
BackupInterval: getEnvOrDefault("BACKUP_INTERVAL", "24h"),
|
||||
BackupRetentionDays: getEnvOrDefault("BACKUP_RETENTION_DAYS", "7"),
|
||||
BackupPath: getEnvOrDefault("BACKUP_PATH", "./data/backups"),
|
||||
BackupCompress: getEnvOrDefault("BACKUP_COMPRESS", "true"),
|
||||
}
|
||||
|
||||
if cfg.DBPath == "" {
|
||||
|
||||
@@ -3,6 +3,7 @@ package email
|
||||
import (
|
||||
"crypto/tls"
|
||||
"fmt"
|
||||
"log"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -53,9 +54,11 @@ func (s *SMTPService) Send(message services.EmailMessage) error {
|
||||
}
|
||||
|
||||
if err := s.sendWithRetry(dialer, m); err != nil {
|
||||
log.Printf("[EMAIL] status=failed to=%s subject=%q error=%q", message.To, message.Subject, err.Error())
|
||||
return fmt.Errorf("failed to send email: %w", err)
|
||||
}
|
||||
|
||||
log.Printf("[EMAIL] status=sent to=%s subject=%q", message.To, message.Subject)
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -103,3 +106,28 @@ func isConfigError(err error) bool {
|
||||
func (s *SMTPService) GetConfig() SMTPConfig {
|
||||
return s.config
|
||||
}
|
||||
|
||||
func (s *SMTPService) HealthCheck() error {
|
||||
dialer := mail.NewDialer(s.config.Host, s.config.Port, s.config.Username, s.config.Password)
|
||||
dialer.TLSConfig = &tls.Config{
|
||||
ServerName: s.config.Host,
|
||||
}
|
||||
|
||||
if s.config.Port == 465 {
|
||||
dialer.SSL = true
|
||||
}
|
||||
|
||||
smtpCloser, err := dialer.Dial()
|
||||
if err != nil {
|
||||
if isAuthError(err) {
|
||||
return fmt.Errorf("SMTP authentication failed: %w", err)
|
||||
}
|
||||
if isConfigError(err) {
|
||||
return fmt.Errorf("SMTP connection failed: %w", err)
|
||||
}
|
||||
return fmt.Errorf("SMTP error: %w", err)
|
||||
}
|
||||
defer smtpCloser.Close()
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package email
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"apocapoc-api/internal/domain/services"
|
||||
@@ -25,9 +26,68 @@ func TestNewSMTPService(t *testing.T) {
|
||||
if service.GetConfig().Host != config.Host {
|
||||
t.Errorf("Expected host %s, got %s", config.Host, service.GetConfig().Host)
|
||||
}
|
||||
|
||||
if service.GetConfig().Port != config.Port {
|
||||
t.Errorf("Expected port %d, got %d", config.Port, service.GetConfig().Port)
|
||||
}
|
||||
|
||||
if service.GetConfig().From != config.From {
|
||||
t.Errorf("Expected from %s, got %s", config.From, service.GetConfig().From)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSMTPService_MessageConstruction(t *testing.T) {
|
||||
func TestSMTPService_ConfigValidation(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
config SMTPConfig
|
||||
}{
|
||||
{
|
||||
name: "Port 587 (STARTTLS)",
|
||||
config: SMTPConfig{
|
||||
Host: "smtp.example.com",
|
||||
Port: 587,
|
||||
Username: "user@example.com",
|
||||
Password: "password",
|
||||
From: "noreply@example.com",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "Port 465 (SSL)",
|
||||
config: SMTPConfig{
|
||||
Host: "smtp.example.com",
|
||||
Port: 465,
|
||||
Username: "user@example.com",
|
||||
Password: "password",
|
||||
From: "noreply@example.com",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "Port 25 (Plain)",
|
||||
config: SMTPConfig{
|
||||
Host: "smtp.example.com",
|
||||
Port: 25,
|
||||
Username: "user@example.com",
|
||||
Password: "password",
|
||||
From: "noreply@example.com",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
service := NewSMTPService(tt.config)
|
||||
if service == nil {
|
||||
t.Fatal("Expected service to be created")
|
||||
}
|
||||
|
||||
if service.GetConfig().Port != tt.config.Port {
|
||||
t.Errorf("Expected port %d, got %d", tt.config.Port, service.GetConfig().Port)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSMTPService_MessageTypes(t *testing.T) {
|
||||
config := SMTPConfig{
|
||||
Host: "smtp.example.com",
|
||||
Port: 587,
|
||||
@@ -39,26 +99,201 @@ func TestSMTPService_MessageConstruction(t *testing.T) {
|
||||
|
||||
service := NewSMTPService(config)
|
||||
|
||||
message := services.EmailMessage{
|
||||
To: "recipient@example.com",
|
||||
Subject: "Test Email",
|
||||
Body: "<h1>Test</h1>",
|
||||
IsHTML: true,
|
||||
tests := []struct {
|
||||
name string
|
||||
message services.EmailMessage
|
||||
}{
|
||||
{
|
||||
name: "HTML message",
|
||||
message: services.EmailMessage{
|
||||
To: "recipient@example.com",
|
||||
Subject: "Test Email",
|
||||
Body: "<h1>Test</h1>",
|
||||
IsHTML: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "Plain text message",
|
||||
message: services.EmailMessage{
|
||||
To: "recipient@example.com",
|
||||
Subject: "Test Email",
|
||||
Body: "Plain text body",
|
||||
IsHTML: false,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "Message with special characters",
|
||||
message: services.EmailMessage{
|
||||
To: "recipient@example.com",
|
||||
Subject: "Test Email with émojis 🎉",
|
||||
Body: "<p>Special chars: ñ, á, ü, €</p>",
|
||||
IsHTML: true,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
if message.To == "" {
|
||||
t.Error("Expected recipient to be set")
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if tt.message.To == "" {
|
||||
t.Error("Expected recipient to be set")
|
||||
}
|
||||
|
||||
if message.Subject == "" {
|
||||
t.Error("Expected subject to be set")
|
||||
}
|
||||
if tt.message.Subject == "" {
|
||||
t.Error("Expected subject to be set")
|
||||
}
|
||||
|
||||
if !message.IsHTML {
|
||||
t.Error("Expected message to be HTML")
|
||||
}
|
||||
if tt.message.Body == "" {
|
||||
t.Error("Expected body to be set")
|
||||
}
|
||||
|
||||
if service == nil {
|
||||
t.Fatal("Service should not be nil")
|
||||
if service == nil {
|
||||
t.Fatal("Service should not be nil")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsAuthError(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
errStr string
|
||||
expected bool
|
||||
}{
|
||||
{
|
||||
name: "Authentication failed error",
|
||||
errStr: "535 Authentication failed",
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "Invalid credentials error",
|
||||
errStr: "Invalid credentials provided",
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "535 error code",
|
||||
errStr: "535 5.7.8 Error",
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "Connection refused (not auth)",
|
||||
errStr: "connection refused",
|
||||
expected: false,
|
||||
},
|
||||
{
|
||||
name: "Generic error (not auth)",
|
||||
errStr: "some other error",
|
||||
expected: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
err := &mockError{msg: tt.errStr}
|
||||
result := isAuthError(err)
|
||||
if result != tt.expected {
|
||||
t.Errorf("Expected %v, got %v for error: %s", tt.expected, result, tt.errStr)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsConfigError(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
errStr string
|
||||
expected bool
|
||||
}{
|
||||
{
|
||||
name: "Connection refused",
|
||||
errStr: "connection refused",
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "No such host",
|
||||
errStr: "no such host smtp.invalid.com",
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "Network unreachable",
|
||||
errStr: "network is unreachable",
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "Authentication error (not config)",
|
||||
errStr: "authentication failed",
|
||||
expected: false,
|
||||
},
|
||||
{
|
||||
name: "Generic error (not config)",
|
||||
errStr: "some other error",
|
||||
expected: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
err := &mockError{msg: tt.errStr}
|
||||
result := isConfigError(err)
|
||||
if result != tt.expected {
|
||||
t.Errorf("Expected %v, got %v for error: %s", tt.expected, result, tt.errStr)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSMTPService_Send_InvalidConfig(t *testing.T) {
|
||||
config := SMTPConfig{
|
||||
Host: "invalid.smtp.server.that.does.not.exist",
|
||||
Port: 587,
|
||||
Username: "user@example.com",
|
||||
Password: "password",
|
||||
From: "noreply@example.com",
|
||||
}
|
||||
|
||||
service := NewSMTPService(config)
|
||||
|
||||
message := services.EmailMessage{
|
||||
To: "test@example.com",
|
||||
Subject: "Test",
|
||||
Body: "Test body",
|
||||
IsHTML: false,
|
||||
}
|
||||
|
||||
err := service.Send(message)
|
||||
if err == nil {
|
||||
t.Error("Expected error when sending to invalid SMTP server")
|
||||
}
|
||||
|
||||
if !strings.Contains(err.Error(), "failed to send email") {
|
||||
t.Errorf("Expected error message to contain 'failed to send email', got: %s", err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
func TestSMTPService_HealthCheck_InvalidConfig(t *testing.T) {
|
||||
config := SMTPConfig{
|
||||
Host: "invalid.smtp.server.that.does.not.exist",
|
||||
Port: 587,
|
||||
Username: "user@example.com",
|
||||
Password: "password",
|
||||
From: "noreply@example.com",
|
||||
}
|
||||
|
||||
service := NewSMTPService(config)
|
||||
|
||||
err := service.HealthCheck()
|
||||
if err == nil {
|
||||
t.Error("Expected error when health checking invalid SMTP server")
|
||||
}
|
||||
|
||||
if !strings.Contains(err.Error(), "SMTP") {
|
||||
t.Errorf("Expected error message to contain 'SMTP', got: %s", err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
type mockError struct {
|
||||
msg string
|
||||
}
|
||||
|
||||
func (e *mockError) Error() string {
|
||||
return e.msg
|
||||
}
|
||||
|
||||
@@ -65,7 +65,6 @@ func NewAuthHandlers(
|
||||
type RegisterRequest struct {
|
||||
Email string `json:"email"`
|
||||
Password string `json:"password"`
|
||||
Timezone string `json:"timezone"`
|
||||
}
|
||||
|
||||
type LoginRequest struct {
|
||||
@@ -100,7 +99,7 @@ type LogoutRequest struct {
|
||||
// @Produce json
|
||||
// @Param request body RegisterRequest true "Registration data (password requires: min 8 chars, uppercase, lowercase, digit, special char)"
|
||||
// @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 400 {object} ValidationErrorResponse "Invalid input: email format or password requirements"
|
||||
// @Failure 403 {object} ErrorResponse "Registration is closed"
|
||||
// @Failure 409 {object} ErrorResponse "Email already registered"
|
||||
// @Failure 500 {object} ErrorResponse "Internal server error"
|
||||
@@ -115,7 +114,6 @@ func (h *AuthHandlers) Register(w http.ResponseWriter, r *http.Request) {
|
||||
cmd := commands.RegisterUserCommand{
|
||||
Email: req.Email,
|
||||
Password: req.Password,
|
||||
Timezone: req.Timezone,
|
||||
}
|
||||
|
||||
result, err := h.registerHandler.Handle(r.Context(), cmd)
|
||||
|
||||
@@ -13,7 +13,6 @@ func TestAuthFlow(t *testing.T) {
|
||||
reqBody := RegisterRequest{
|
||||
Email: "test@example.com",
|
||||
Password: "Password123!",
|
||||
Timezone: "UTC",
|
||||
}
|
||||
|
||||
rr := makeRequest(t, *ts.Router, "POST", "/api/v1/auth/register", reqBody, "")
|
||||
@@ -37,7 +36,6 @@ func TestAuthFlow(t *testing.T) {
|
||||
reqBody := RegisterRequest{
|
||||
Email: "duplicate@example.com",
|
||||
Password: "Password123!",
|
||||
Timezone: "UTC",
|
||||
}
|
||||
|
||||
makeRequest(t, *ts.Router, "POST", "/api/v1/auth/register", reqBody, "")
|
||||
@@ -53,7 +51,6 @@ func TestAuthFlow(t *testing.T) {
|
||||
reqBody := RegisterRequest{
|
||||
Email: "invalid-email",
|
||||
Password: "Password123!",
|
||||
Timezone: "UTC",
|
||||
}
|
||||
|
||||
rr := makeRequest(t, *ts.Router, "POST", "/api/v1/auth/register", reqBody, "")
|
||||
@@ -67,7 +64,6 @@ func TestAuthFlow(t *testing.T) {
|
||||
reqBody := RegisterRequest{
|
||||
Email: "short@example.com",
|
||||
Password: "123",
|
||||
Timezone: "UTC",
|
||||
}
|
||||
|
||||
rr := makeRequest(t, *ts.Router, "POST", "/api/v1/auth/register", reqBody, "")
|
||||
@@ -81,7 +77,6 @@ func TestAuthFlow(t *testing.T) {
|
||||
registerBody := RegisterRequest{
|
||||
Email: "login@example.com",
|
||||
Password: "Password123!",
|
||||
Timezone: "UTC",
|
||||
}
|
||||
makeRequest(t, *ts.Router, "POST", "/api/v1/auth/register", registerBody, "")
|
||||
|
||||
@@ -108,7 +103,6 @@ func TestAuthFlow(t *testing.T) {
|
||||
registerBody := RegisterRequest{
|
||||
Email: "wrongpass@example.com",
|
||||
Password: "Password123!",
|
||||
Timezone: "UTC",
|
||||
}
|
||||
makeRequest(t, *ts.Router, "POST", "/api/v1/auth/register", registerBody, "")
|
||||
|
||||
@@ -137,3 +131,94 @@ func TestAuthFlow(t *testing.T) {
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestRefreshTokenFlow(t *testing.T) {
|
||||
ts := setupTestServer(t)
|
||||
defer ts.Close()
|
||||
|
||||
t.Run("Complete refresh token flow", func(t *testing.T) {
|
||||
registerBody := RegisterRequest{
|
||||
Email: "refresh@example.com",
|
||||
Password: "Password123!",
|
||||
}
|
||||
makeRequest(t, *ts.Router, "POST", "/api/v1/auth/register", registerBody, "")
|
||||
|
||||
loginBody := LoginRequest{
|
||||
Email: "refresh@example.com",
|
||||
Password: "Password123!",
|
||||
}
|
||||
rr := makeRequest(t, *ts.Router, "POST", "/api/v1/auth/login", loginBody, "")
|
||||
|
||||
var loginResp AuthResponse
|
||||
decodeResponse(t, rr, &loginResp)
|
||||
|
||||
if loginResp.RefreshToken == "" {
|
||||
t.Fatal("Expected refresh token in login response")
|
||||
}
|
||||
|
||||
refreshReq := map[string]string{
|
||||
"refresh_token": loginResp.RefreshToken,
|
||||
}
|
||||
rr = makeRequest(t, *ts.Router, "POST", "/api/v1/auth/refresh", refreshReq, "")
|
||||
|
||||
if rr.Code != http.StatusOK {
|
||||
t.Fatalf("Expected status 200, got %d. Body: %s", rr.Code, rr.Body.String())
|
||||
}
|
||||
|
||||
var refreshResp AuthResponse
|
||||
decodeResponse(t, rr, &refreshResp)
|
||||
|
||||
if refreshResp.Token == "" {
|
||||
t.Error("Expected new access token in refresh response")
|
||||
}
|
||||
if refreshResp.RefreshToken == "" {
|
||||
t.Error("Expected new refresh token in refresh response")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Refresh with invalid token", func(t *testing.T) {
|
||||
refreshReq := map[string]string{
|
||||
"refresh_token": "invalid-token",
|
||||
}
|
||||
rr := makeRequest(t, *ts.Router, "POST", "/api/v1/auth/refresh", refreshReq, "")
|
||||
|
||||
if rr.Code != http.StatusUnauthorized {
|
||||
t.Errorf("Expected status 401, got %d", rr.Code)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Logout invalidates refresh token", func(t *testing.T) {
|
||||
registerBody := RegisterRequest{
|
||||
Email: "logout@example.com",
|
||||
Password: "Password123!",
|
||||
}
|
||||
makeRequest(t, *ts.Router, "POST", "/api/v1/auth/register", registerBody, "")
|
||||
|
||||
loginBody := LoginRequest{
|
||||
Email: "logout@example.com",
|
||||
Password: "Password123!",
|
||||
}
|
||||
rr := makeRequest(t, *ts.Router, "POST", "/api/v1/auth/login", loginBody, "")
|
||||
|
||||
var loginResp AuthResponse
|
||||
decodeResponse(t, rr, &loginResp)
|
||||
|
||||
logoutReq := map[string]string{
|
||||
"refresh_token": loginResp.RefreshToken,
|
||||
}
|
||||
rr = makeRequest(t, *ts.Router, "POST", "/api/v1/auth/logout", logoutReq, loginResp.Token)
|
||||
|
||||
if rr.Code != http.StatusOK {
|
||||
t.Fatalf("Expected status 200 for logout, got %d", rr.Code)
|
||||
}
|
||||
|
||||
refreshReq := map[string]string{
|
||||
"refresh_token": loginResp.RefreshToken,
|
||||
}
|
||||
rr = makeRequest(t, *ts.Router, "POST", "/api/v1/auth/refresh", refreshReq, "")
|
||||
|
||||
if rr.Code != http.StatusUnauthorized {
|
||||
t.Errorf("Expected status 401 when using logged out token, got %d", rr.Code)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"strings"
|
||||
|
||||
"apocapoc-api/internal/infrastructure/auth"
|
||||
"apocapoc-api/internal/infrastructure/logger"
|
||||
)
|
||||
|
||||
type contextKey string
|
||||
@@ -35,6 +36,7 @@ func AuthMiddleware(jwtService *auth.JWTService) func(http.Handler) http.Handler
|
||||
}
|
||||
|
||||
ctx := context.WithValue(r.Context(), UserIDKey, claims.UserID)
|
||||
ctx = logger.AddUserID(ctx, claims.UserID)
|
||||
next.ServeHTTP(w, r.WithContext(ctx))
|
||||
})
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"time"
|
||||
|
||||
"apocapoc-api/internal/domain/value_objects"
|
||||
"apocapoc-api/internal/shared/pagination"
|
||||
)
|
||||
|
||||
type CreateHabitRequest struct {
|
||||
@@ -76,6 +77,11 @@ type UserHabitResponse struct {
|
||||
IsNegative bool `json:"is_negative"`
|
||||
}
|
||||
|
||||
type GetUserHabitsResponse struct {
|
||||
Data []UserHabitResponse `json:"data"`
|
||||
Pagination *pagination.Response `json:"pagination,omitempty"`
|
||||
}
|
||||
|
||||
type HabitEntryResponse struct {
|
||||
ID string `json:"id"`
|
||||
HabitID string `json:"habit_id"`
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
package http
|
||||
|
||||
import (
|
||||
"compress/gzip"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
|
||||
"apocapoc-api/internal/application/queries"
|
||||
"apocapoc-api/internal/i18n"
|
||||
)
|
||||
|
||||
type ExportHandlers struct {
|
||||
exportHandler *queries.ExportUserDataHandler
|
||||
translator *i18n.Translator
|
||||
}
|
||||
|
||||
func NewExportHandlers(
|
||||
exportHandler *queries.ExportUserDataHandler,
|
||||
translator *i18n.Translator,
|
||||
) *ExportHandlers {
|
||||
return &ExportHandlers{
|
||||
exportHandler: exportHandler,
|
||||
translator: translator,
|
||||
}
|
||||
}
|
||||
|
||||
// ExportData godoc
|
||||
// @Summary Export user data
|
||||
// @Description Export all user habits and entries in JSON format with gzip compression. Limited to 1 export per hour.
|
||||
// @Tags export
|
||||
// @Security BearerAuth
|
||||
// @Produce json
|
||||
// @Success 200 {object} queries.ExportUserDataResult "Compressed JSON export"
|
||||
// @Failure 401 {object} ErrorResponse "Unauthorized"
|
||||
// @Failure 429 {object} ErrorResponse "Rate limit exceeded"
|
||||
// @Failure 500 {object} ErrorResponse "Internal server error"
|
||||
// @Router /export [get]
|
||||
func (h *ExportHandlers) ExportData(w http.ResponseWriter, r *http.Request) {
|
||||
userID := r.Context().Value("user_id").(string)
|
||||
|
||||
query := queries.ExportUserDataQuery{
|
||||
UserID: userID,
|
||||
}
|
||||
|
||||
result, err := h.exportHandler.Handle(r.Context(), query)
|
||||
if err != nil {
|
||||
respondErrorI18n(w, r, h.translator, http.StatusInternalServerError, "export_failed")
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.Header().Set("Content-Encoding", "gzip")
|
||||
w.Header().Set("Content-Disposition", "attachment; filename=\"apocapoc-export.json.gz\"")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
|
||||
gzipWriter := gzip.NewWriter(w)
|
||||
defer gzipWriter.Close()
|
||||
|
||||
encoder := json.NewEncoder(gzipWriter)
|
||||
encoder.SetIndent("", " ")
|
||||
|
||||
if err := encoder.Encode(result); err != nil {
|
||||
return
|
||||
}
|
||||
}
|
||||
@@ -9,9 +9,10 @@ import (
|
||||
|
||||
"apocapoc-api/internal/application/commands"
|
||||
"apocapoc-api/internal/application/queries"
|
||||
"apocapoc-api/internal/domain/repositories"
|
||||
"apocapoc-api/internal/domain/value_objects"
|
||||
"apocapoc-api/internal/i18n"
|
||||
"apocapoc-api/internal/shared/errors"
|
||||
"apocapoc-api/internal/shared/pagination"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
)
|
||||
@@ -26,7 +27,6 @@ type HabitHandlers struct {
|
||||
archiveHandler *commands.ArchiveHabitHandler
|
||||
markHandler *commands.MarkHabitHandler
|
||||
unmarkHandler *commands.UnmarkHabitHandler
|
||||
userRepo repositories.UserRepository
|
||||
translator *i18n.Translator
|
||||
}
|
||||
|
||||
@@ -40,7 +40,6 @@ func NewHabitHandlers(
|
||||
archiveHandler *commands.ArchiveHabitHandler,
|
||||
markHandler *commands.MarkHabitHandler,
|
||||
unmarkHandler *commands.UnmarkHabitHandler,
|
||||
userRepo repositories.UserRepository,
|
||||
translator *i18n.Translator,
|
||||
) *HabitHandlers {
|
||||
return &HabitHandlers{
|
||||
@@ -53,7 +52,6 @@ func NewHabitHandlers(
|
||||
archiveHandler: archiveHandler,
|
||||
markHandler: markHandler,
|
||||
unmarkHandler: unmarkHandler,
|
||||
userRepo: userRepo,
|
||||
translator: translator,
|
||||
}
|
||||
}
|
||||
@@ -112,11 +110,17 @@ func (h *HabitHandlers) CreateHabit(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
// GetUserHabits godoc
|
||||
// @Summary Get all user habits
|
||||
// @Description Get all active habits for the authenticated user
|
||||
// @Description Get all active habits for the authenticated user with optional pagination and filters
|
||||
// @Tags habits
|
||||
// @Produce json
|
||||
// @Security BearerAuth
|
||||
// @Success 200 {array} UserHabitResponse
|
||||
// @Param page query int false "Page number (default: 1)"
|
||||
// @Param page_size query int false "Page size (default: 50, max: 100)"
|
||||
// @Param type query string false "Filter by type (BOOLEAN, COUNTER, VALUE)"
|
||||
// @Param frequency query string false "Filter by frequency (DAILY, WEEKLY, MONTHLY)"
|
||||
// @Param archived query boolean false "Include archived habits (default: false)"
|
||||
// @Param search query string false "Search by name or description"
|
||||
// @Success 200 {object} GetUserHabitsResponse
|
||||
// @Failure 401 {object} ErrorResponse
|
||||
// @Failure 500 {object} ErrorResponse
|
||||
// @Router /habits [get]
|
||||
@@ -131,15 +135,71 @@ func (h *HabitHandlers) GetUserHabits(w http.ResponseWriter, r *http.Request) {
|
||||
UserID: userID,
|
||||
}
|
||||
|
||||
habits, err := h.getUserHabitsHandler.Handle(r.Context(), query)
|
||||
pageStr := r.URL.Query().Get("page")
|
||||
pageSizeStr := r.URL.Query().Get("page_size")
|
||||
|
||||
if pageStr != "" || pageSizeStr != "" {
|
||||
page := 1
|
||||
pageSize := 50
|
||||
|
||||
if pageStr != "" {
|
||||
if p, err := strconv.Atoi(pageStr); err == nil && p > 0 {
|
||||
page = p
|
||||
}
|
||||
}
|
||||
|
||||
if pageSizeStr != "" {
|
||||
if ps, err := strconv.Atoi(pageSizeStr); err == nil && ps > 0 {
|
||||
pageSize = ps
|
||||
}
|
||||
}
|
||||
|
||||
params := pagination.NewParams(page, pageSize)
|
||||
query.PaginationParams = ¶ms
|
||||
}
|
||||
|
||||
typeStr := r.URL.Query().Get("type")
|
||||
frequencyStr := r.URL.Query().Get("frequency")
|
||||
archivedStr := r.URL.Query().Get("archived")
|
||||
searchStr := r.URL.Query().Get("search")
|
||||
|
||||
if typeStr != "" || frequencyStr != "" || archivedStr != "" || searchStr != "" {
|
||||
filterParams := &queries.FilterParams{}
|
||||
|
||||
if typeStr != "" {
|
||||
habitType := value_objects.HabitType(typeStr)
|
||||
if habitType.IsValid() {
|
||||
filterParams.Type = &habitType
|
||||
}
|
||||
}
|
||||
|
||||
if frequencyStr != "" {
|
||||
frequency := value_objects.Frequency(frequencyStr)
|
||||
if frequency.IsValid() {
|
||||
filterParams.Frequency = &frequency
|
||||
}
|
||||
}
|
||||
|
||||
if archivedStr == "true" {
|
||||
filterParams.IncludeArchived = true
|
||||
}
|
||||
|
||||
if searchStr != "" {
|
||||
filterParams.Search = searchStr
|
||||
}
|
||||
|
||||
query.FilterParams = filterParams
|
||||
}
|
||||
|
||||
result, err := h.getUserHabitsHandler.Handle(r.Context(), query)
|
||||
if err != nil {
|
||||
respondErrorI18n(w, r, h.translator, http.StatusInternalServerError, "failed_get_habits")
|
||||
return
|
||||
}
|
||||
|
||||
response := make([]UserHabitResponse, len(habits))
|
||||
for i, habit := range habits {
|
||||
response[i] = UserHabitResponse{
|
||||
habitResponses := make([]UserHabitResponse, len(result.Habits))
|
||||
for i, habit := range result.Habits {
|
||||
habitResponses[i] = UserHabitResponse{
|
||||
ID: habit.ID,
|
||||
Name: habit.Name,
|
||||
Type: habit.Type,
|
||||
@@ -151,7 +211,15 @@ func (h *HabitHandlers) GetUserHabits(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
}
|
||||
|
||||
respondJSON(w, http.StatusOK, response)
|
||||
if result.Pagination != nil {
|
||||
response := GetUserHabitsResponse{
|
||||
Data: habitResponses,
|
||||
Pagination: result.Pagination,
|
||||
}
|
||||
respondJSON(w, http.StatusOK, response)
|
||||
} else {
|
||||
respondJSON(w, http.StatusOK, habitResponses)
|
||||
}
|
||||
}
|
||||
|
||||
// GetHabitByID godoc
|
||||
@@ -440,11 +508,13 @@ 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. Includes the entry for today if it exists.
|
||||
// @Description Get all habits scheduled for today for the authenticated user. Includes the entry for today if it exists. Requires timezone as query parameter (e.g., ?timezone=America/New_York).
|
||||
// @Tags habits
|
||||
// @Produce json
|
||||
// @Security BearerAuth
|
||||
// @Param timezone query string true "IANA timezone (e.g., 'America/New_York', 'Europe/Madrid', 'UTC')"
|
||||
// @Success 200 {array} TodaysHabitResponse
|
||||
// @Failure 400 {object} ErrorResponse "Invalid or missing timezone"
|
||||
// @Failure 401 {object} ErrorResponse
|
||||
// @Failure 500 {object} ErrorResponse
|
||||
// @Router /habits/today [get]
|
||||
@@ -455,15 +525,16 @@ func (h *HabitHandlers) GetTodaysHabits(w http.ResponseWriter, r *http.Request)
|
||||
return
|
||||
}
|
||||
|
||||
user, err := h.userRepo.FindByID(r.Context(), userID)
|
||||
if err != nil {
|
||||
respondErrorI18n(w, r, h.translator, http.StatusInternalServerError, "failed_get_user")
|
||||
timezone := r.URL.Query().Get("timezone")
|
||||
if timezone == "" {
|
||||
respondErrorI18n(w, r, h.translator, http.StatusBadRequest, "timezone_required")
|
||||
return
|
||||
}
|
||||
|
||||
loc, err := time.LoadLocation(user.Timezone)
|
||||
loc, err := time.LoadLocation(timezone)
|
||||
if err != nil {
|
||||
loc = time.UTC
|
||||
respondErrorI18n(w, r, h.translator, http.StatusBadRequest, "invalid_timezone")
|
||||
return
|
||||
}
|
||||
|
||||
today := time.Now().In(loc)
|
||||
@@ -471,7 +542,7 @@ func (h *HabitHandlers) GetTodaysHabits(w http.ResponseWriter, r *http.Request)
|
||||
|
||||
query := queries.GetTodaysHabitsQuery{
|
||||
UserID: userID,
|
||||
Timezone: user.Timezone,
|
||||
Timezone: timezone,
|
||||
Date: todayDate,
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package http
|
||||
|
||||
import (
|
||||
"apocapoc-api/internal/domain/services"
|
||||
"database/sql"
|
||||
"net/http"
|
||||
"time"
|
||||
@@ -9,18 +10,21 @@ import (
|
||||
var startTime = time.Now()
|
||||
|
||||
type HealthHandlers struct {
|
||||
db *sql.DB
|
||||
db *sql.DB
|
||||
emailService services.EmailService
|
||||
}
|
||||
|
||||
func NewHealthHandlers(db *sql.DB) *HealthHandlers {
|
||||
func NewHealthHandlers(db *sql.DB, emailService services.EmailService) *HealthHandlers {
|
||||
return &HealthHandlers{
|
||||
db: db,
|
||||
db: db,
|
||||
emailService: emailService,
|
||||
}
|
||||
}
|
||||
|
||||
type HealthResponse struct {
|
||||
Status string `json:"status"`
|
||||
Database string `json:"database"`
|
||||
SMTP string `json:"smtp"`
|
||||
Uptime string `json:"uptime"`
|
||||
}
|
||||
|
||||
@@ -34,6 +38,7 @@ type HealthResponse struct {
|
||||
// @Router /health [get]
|
||||
func (h *HealthHandlers) Health(w http.ResponseWriter, r *http.Request) {
|
||||
dbStatus := "ok"
|
||||
smtpStatus := "ok"
|
||||
overallStatus := "ok"
|
||||
statusCode := http.StatusOK
|
||||
|
||||
@@ -43,12 +48,25 @@ func (h *HealthHandlers) Health(w http.ResponseWriter, r *http.Request) {
|
||||
statusCode = http.StatusServiceUnavailable
|
||||
}
|
||||
|
||||
if h.emailService != nil {
|
||||
if err := h.emailService.HealthCheck(); err != nil {
|
||||
smtpStatus = "error"
|
||||
if overallStatus != "degraded" {
|
||||
overallStatus = "degraded"
|
||||
statusCode = http.StatusServiceUnavailable
|
||||
}
|
||||
}
|
||||
} else {
|
||||
smtpStatus = "disabled"
|
||||
}
|
||||
|
||||
uptime := time.Since(startTime)
|
||||
uptimeStr := formatDuration(uptime)
|
||||
|
||||
response := HealthResponse{
|
||||
Status: overallStatus,
|
||||
Database: dbStatus,
|
||||
SMTP: smtpStatus,
|
||||
Uptime: uptimeStr,
|
||||
}
|
||||
|
||||
|
||||
@@ -58,6 +58,7 @@ func setupTestServer(t *testing.T) *TestServer {
|
||||
getHabitByIDHandler := queries.NewGetHabitByIDHandler(habitRepo)
|
||||
getHabitEntriesHandler := queries.NewGetHabitEntriesHandler(habitRepo, entryRepo)
|
||||
getHabitStatsHandler := queries.NewGetHabitStatsHandler(habitRepo, entryRepo)
|
||||
exportUserDataHandler := queries.NewExportUserDataHandler(habitRepo, entryRepo)
|
||||
updateHandler := commands.NewUpdateHabitHandler(habitRepo)
|
||||
archiveHandler := commands.NewArchiveHabitHandler(habitRepo)
|
||||
markHandler := commands.NewMarkHabitHandler(entryRepo, habitRepo)
|
||||
@@ -70,12 +71,13 @@ func setupTestServer(t *testing.T) *TestServer {
|
||||
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)
|
||||
habitHandlers := NewHabitHandlers(createHandler, getTodaysHandler, getUserHabitsHandler, getHabitByIDHandler, getHabitEntriesHandler, updateHandler, archiveHandler, markHandler, unmarkHandler, translator)
|
||||
statsHandlers := NewStatsHandlers(getHabitStatsHandler, translator)
|
||||
healthHandlers := NewHealthHandlers(db)
|
||||
healthHandlers := NewHealthHandlers(db, nil)
|
||||
userHandlers := NewUserHandlers(deleteUserHandler, translator)
|
||||
exportHandlers := NewExportHandlers(exportUserDataHandler, translator)
|
||||
|
||||
router := NewRouter("http://localhost:3000", habitHandlers, authHandlers, statsHandlers, healthHandlers, userHandlers, jwtService, translator)
|
||||
router := NewRouter("http://localhost:3000", habitHandlers, authHandlers, statsHandlers, healthHandlers, userHandlers, exportHandlers, jwtService, translator)
|
||||
|
||||
handler := http.Handler(router)
|
||||
return &TestServer{
|
||||
@@ -120,7 +122,6 @@ func registerAndLogin(t *testing.T, router http.Handler, email, password string)
|
||||
registerBody := RegisterRequest{
|
||||
Email: email,
|
||||
Password: password,
|
||||
Timezone: "UTC",
|
||||
}
|
||||
makeRequest(t, router, "POST", "/api/v1/auth/register", registerBody, "")
|
||||
|
||||
|
||||
@@ -1,8 +1,12 @@
|
||||
package http
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"apocapoc-api/internal/infrastructure/auth"
|
||||
@@ -36,3 +40,39 @@ func RateLimitByUser(jwtService *auth.JWTService, requestsPerMinute int, duratio
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func RateLimitByEmail(requests int, duration time.Duration) func(http.Handler) http.Handler {
|
||||
limiter := httprate.NewRateLimiter(
|
||||
requests,
|
||||
duration,
|
||||
httprate.WithKeyFuncs(func(r *http.Request) (string, error) {
|
||||
body, err := io.ReadAll(r.Body)
|
||||
if err != nil {
|
||||
return r.RemoteAddr, nil
|
||||
}
|
||||
r.Body = io.NopCloser(bytes.NewBuffer(body))
|
||||
|
||||
var data map[string]interface{}
|
||||
if err := json.Unmarshal(body, &data); err != nil {
|
||||
return r.RemoteAddr, nil
|
||||
}
|
||||
|
||||
if email, ok := data["email"].(string); ok && email != "" {
|
||||
return "email:" + strings.ToLower(email), nil
|
||||
}
|
||||
|
||||
return r.RemoteAddr, nil
|
||||
}),
|
||||
httprate.WithLimitHandler(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusTooManyRequests)
|
||||
w.Write([]byte(`{"error":"Too many password reset attempts. Please try again later."}`))
|
||||
}),
|
||||
)
|
||||
|
||||
return func(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
limiter.Handler(next).ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
package http
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestGlobalRateLimiting(t *testing.T) {
|
||||
ts := setupTestServer(t)
|
||||
defer ts.Close()
|
||||
|
||||
token := registerAndLogin(t, *ts.Router, "ratelimit@example.com", "Password123!")
|
||||
|
||||
t.Run("Request within rate limit succeeds", func(t *testing.T) {
|
||||
for i := 0; i < 10; i++ {
|
||||
rr := makeRequest(t, *ts.Router, "GET", "/api/v1/habits", nil, token)
|
||||
if rr.Code == http.StatusTooManyRequests {
|
||||
t.Errorf("Request %d hit rate limit unexpectedly", i+1)
|
||||
break
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestPasswordResetRateLimiting(t *testing.T) {
|
||||
ts := setupTestServer(t)
|
||||
defer ts.Close()
|
||||
|
||||
registerBody := RegisterRequest{
|
||||
Email: "resetlimit@example.com",
|
||||
Password: "Password123!",
|
||||
}
|
||||
makeRequest(t, *ts.Router, "POST", "/api/v1/auth/register", registerBody, "")
|
||||
|
||||
t.Run("Email-based rate limit for password reset", func(t *testing.T) {
|
||||
resetReq := map[string]string{
|
||||
"email": "resetlimit@example.com",
|
||||
}
|
||||
|
||||
for i := 0; i < 3; i++ {
|
||||
rr := makeRequest(t, *ts.Router, "POST", "/api/v1/auth/forgot-password", resetReq, "")
|
||||
if rr.Code == http.StatusTooManyRequests {
|
||||
t.Fatalf("Request %d hit rate limit too early (limit is 3)", i+1)
|
||||
}
|
||||
}
|
||||
|
||||
rr := makeRequest(t, *ts.Router, "POST", "/api/v1/auth/forgot-password", resetReq, "")
|
||||
if rr.Code != http.StatusTooManyRequests {
|
||||
t.Errorf("Expected status 429 after 4th request, got %d", rr.Code)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Different emails have separate rate limits", func(t *testing.T) {
|
||||
registerBody2 := RegisterRequest{
|
||||
Email: "resetlimit2@example.com",
|
||||
Password: "Password123!",
|
||||
}
|
||||
makeRequest(t, *ts.Router, "POST", "/api/v1/auth/register", registerBody2, "")
|
||||
|
||||
resetReq := map[string]string{
|
||||
"email": "resetlimit2@example.com",
|
||||
}
|
||||
|
||||
rr := makeRequest(t, *ts.Router, "POST", "/api/v1/auth/forgot-password", resetReq, "")
|
||||
if rr.Code == http.StatusTooManyRequests {
|
||||
t.Error("Different email should not be affected by previous email's rate limit")
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
|
||||
"apocapoc-api/internal/i18n"
|
||||
"apocapoc-api/internal/infrastructure/auth"
|
||||
"apocapoc-api/internal/infrastructure/logger"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/go-chi/chi/v5/middleware"
|
||||
@@ -16,10 +17,10 @@ import (
|
||||
_ "apocapoc-api/docs"
|
||||
)
|
||||
|
||||
func NewRouter(appURL string, habitHandlers *HabitHandlers, authHandlers *AuthHandlers, statsHandlers *StatsHandlers, healthHandlers *HealthHandlers, userHandlers *UserHandlers, jwtService *auth.JWTService, translator *i18n.Translator) *chi.Mux {
|
||||
func NewRouter(appURL string, habitHandlers *HabitHandlers, authHandlers *AuthHandlers, statsHandlers *StatsHandlers, healthHandlers *HealthHandlers, userHandlers *UserHandlers, exportHandlers *ExportHandlers, jwtService *auth.JWTService, translator *i18n.Translator) *chi.Mux {
|
||||
r := chi.NewRouter()
|
||||
|
||||
r.Use(middleware.Logger)
|
||||
r.Use(logger.Middleware)
|
||||
r.Use(middleware.Recoverer)
|
||||
r.Use(i18n.LanguageMiddleware(translator))
|
||||
r.Use(cors.Handler(cors.Options{
|
||||
@@ -46,7 +47,9 @@ func NewRouter(appURL string, habitHandlers *HabitHandlers, authHandlers *AuthHa
|
||||
r.Post("/logout", authHandlers.Logout)
|
||||
r.Post("/verify-email", authHandlers.VerifyEmail)
|
||||
r.Post("/resend-verification", authHandlers.ResendVerification)
|
||||
r.Post("/forgot-password", authHandlers.ForgotPassword)
|
||||
|
||||
r.With(RateLimitByEmail(3, 1*time.Hour)).Post("/forgot-password", authHandlers.ForgotPassword)
|
||||
|
||||
r.Post("/reset-password", authHandlers.ResetPassword)
|
||||
})
|
||||
|
||||
@@ -77,5 +80,11 @@ func NewRouter(appURL string, habitHandlers *HabitHandlers, authHandlers *AuthHa
|
||||
r.Delete("/me", userHandlers.DeleteAccount)
|
||||
})
|
||||
|
||||
r.Route("/api/v1/export", func(r chi.Router) {
|
||||
r.Use(AuthMiddleware(jwtService))
|
||||
r.Use(RateLimitByUser(jwtService, 1, 1*time.Hour))
|
||||
r.Get("/", exportHandlers.ExportData)
|
||||
})
|
||||
|
||||
return r
|
||||
}
|
||||
|
||||
@@ -0,0 +1,159 @@
|
||||
package http
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"apocapoc-api/internal/application/queries"
|
||||
)
|
||||
|
||||
func TestHabitStatsFlow(t *testing.T) {
|
||||
ts := setupTestServer(t)
|
||||
defer ts.Close()
|
||||
|
||||
token := registerAndLogin(t, *ts.Router, "statsuser@example.com", "Password123!")
|
||||
|
||||
habitBody := CreateHabitRequest{
|
||||
Name: "Meditation",
|
||||
Type: "BOOLEAN",
|
||||
Frequency: "DAILY",
|
||||
}
|
||||
rr := makeRequest(t, *ts.Router, "POST", "/api/v1/habits", habitBody, token)
|
||||
var habitResp map[string]string
|
||||
decodeResponse(t, rr, &habitResp)
|
||||
habitID := habitResp["id"]
|
||||
|
||||
t.Run("Stats for new habit should be zero", func(t *testing.T) {
|
||||
rr := makeRequest(t, *ts.Router, "GET", "/api/v1/stats/habits/"+habitID, nil, token)
|
||||
|
||||
if rr.Code != http.StatusOK {
|
||||
t.Fatalf("Expected status 200, got %d. Body: %s", rr.Code, rr.Body.String())
|
||||
}
|
||||
|
||||
var stats queries.HabitStatsDTO
|
||||
decodeResponse(t, rr, &stats)
|
||||
|
||||
if stats.TotalCompletions != 0 {
|
||||
t.Errorf("Expected 0 total completions, got %d", stats.TotalCompletions)
|
||||
}
|
||||
if stats.CurrentStreak != 0 {
|
||||
t.Errorf("Expected 0 current streak, got %d", stats.CurrentStreak)
|
||||
}
|
||||
if stats.LongestStreak != 0 {
|
||||
t.Errorf("Expected 0 longest streak, got %d", stats.LongestStreak)
|
||||
}
|
||||
})
|
||||
|
||||
today := time.Now().UTC().Format("2006-01-02")
|
||||
|
||||
t.Run("Stats after marking habit once", func(t *testing.T) {
|
||||
markReq := MarkHabitRequest{
|
||||
ScheduledDate: today,
|
||||
}
|
||||
rr := makeRequest(t, *ts.Router, "POST", "/api/v1/habits/"+habitID+"/mark", markReq, token)
|
||||
if rr.Code != http.StatusOK {
|
||||
t.Fatalf("Failed to mark habit: %d - %s", rr.Code, rr.Body.String())
|
||||
}
|
||||
|
||||
rr = makeRequest(t, *ts.Router, "GET", "/api/v1/stats/habits/"+habitID, nil, token)
|
||||
|
||||
if rr.Code != http.StatusOK {
|
||||
t.Fatalf("Expected status 200, got %d", rr.Code)
|
||||
}
|
||||
|
||||
var stats queries.HabitStatsDTO
|
||||
decodeResponse(t, rr, &stats)
|
||||
|
||||
if stats.TotalCompletions != 1 {
|
||||
t.Errorf("Expected 1 total completion, got %d", stats.TotalCompletions)
|
||||
}
|
||||
if stats.CurrentStreak != 1 {
|
||||
t.Errorf("Expected current streak of 1, got %d", stats.CurrentStreak)
|
||||
}
|
||||
if stats.LongestStreak != 1 {
|
||||
t.Errorf("Expected longest streak of 1, got %d", stats.LongestStreak)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Stats after unmarking habit", func(t *testing.T) {
|
||||
rr := makeRequest(t, *ts.Router, "DELETE", "/api/v1/habits/"+habitID+"/entries/"+today, nil, token)
|
||||
if rr.Code != http.StatusOK {
|
||||
t.Fatalf("Failed to unmark habit: %d", rr.Code)
|
||||
}
|
||||
|
||||
rr = makeRequest(t, *ts.Router, "GET", "/api/v1/stats/habits/"+habitID, nil, token)
|
||||
|
||||
var stats queries.HabitStatsDTO
|
||||
decodeResponse(t, rr, &stats)
|
||||
|
||||
if stats.TotalCompletions != 0 {
|
||||
t.Errorf("Expected 0 total completions after unmark, got %d", stats.TotalCompletions)
|
||||
}
|
||||
if stats.CurrentStreak != 0 {
|
||||
t.Errorf("Expected 0 current streak after unmark, got %d", stats.CurrentStreak)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestHabitUpdateAffectsStats(t *testing.T) {
|
||||
ts := setupTestServer(t)
|
||||
defer ts.Close()
|
||||
|
||||
token := registerAndLogin(t, *ts.Router, "updatestats@example.com", "Password123!")
|
||||
|
||||
habitBody := CreateHabitRequest{
|
||||
Name: "Running",
|
||||
Type: "BOOLEAN",
|
||||
Frequency: "DAILY",
|
||||
}
|
||||
rr := makeRequest(t, *ts.Router, "POST", "/api/v1/habits", habitBody, token)
|
||||
var habitResp map[string]string
|
||||
decodeResponse(t, rr, &habitResp)
|
||||
habitID := habitResp["id"]
|
||||
|
||||
today := time.Now().UTC().Format("2006-01-02")
|
||||
markReq := MarkHabitRequest{
|
||||
ScheduledDate: today,
|
||||
}
|
||||
makeRequest(t, *ts.Router, "POST", "/api/v1/habits/"+habitID+"/mark", markReq, token)
|
||||
|
||||
t.Run("Stats remain after updating habit name", func(t *testing.T) {
|
||||
updateReq := UpdateHabitRequest{
|
||||
Name: "Morning Running",
|
||||
}
|
||||
rr := makeRequest(t, *ts.Router, "PUT", "/api/v1/habits/"+habitID, updateReq, token)
|
||||
if rr.Code != http.StatusOK {
|
||||
t.Fatalf("Failed to update habit: %d", rr.Code)
|
||||
}
|
||||
|
||||
rr = makeRequest(t, *ts.Router, "GET", "/api/v1/stats/habits/"+habitID, nil, token)
|
||||
|
||||
var stats queries.HabitStatsDTO
|
||||
decodeResponse(t, rr, &stats)
|
||||
|
||||
if stats.TotalCompletions != 1 {
|
||||
t.Errorf("Expected stats to persist after update, got %d completions", stats.TotalCompletions)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Stats remain available after archiving habit", func(t *testing.T) {
|
||||
rr := makeRequest(t, *ts.Router, "DELETE", "/api/v1/habits/"+habitID, nil, token)
|
||||
if rr.Code != http.StatusOK {
|
||||
t.Fatalf("Failed to archive habit: %d", rr.Code)
|
||||
}
|
||||
|
||||
rr = makeRequest(t, *ts.Router, "GET", "/api/v1/stats/habits/"+habitID, nil, token)
|
||||
|
||||
if rr.Code != http.StatusOK {
|
||||
t.Errorf("Expected stats to remain available for archived habit, got %d", rr.Code)
|
||||
}
|
||||
|
||||
var stats queries.HabitStatsDTO
|
||||
decodeResponse(t, rr, &stats)
|
||||
|
||||
if stats.TotalCompletions != 1 {
|
||||
t.Errorf("Expected stats to persist after archiving, got %d completions", stats.TotalCompletions)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
package logger
|
||||
|
||||
import (
|
||||
"io"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/rs/zerolog"
|
||||
"github.com/rs/zerolog/pkgerrors"
|
||||
)
|
||||
|
||||
var Log zerolog.Logger
|
||||
|
||||
type Config struct {
|
||||
Level string
|
||||
Environment string
|
||||
}
|
||||
|
||||
func Init(config Config) {
|
||||
zerolog.ErrorStackMarshaler = pkgerrors.MarshalStack
|
||||
zerolog.TimeFieldFormat = time.RFC3339
|
||||
|
||||
level := parseLogLevel(config.Level)
|
||||
zerolog.SetGlobalLevel(level)
|
||||
|
||||
var output io.Writer = os.Stdout
|
||||
|
||||
if config.Environment == "development" {
|
||||
output = zerolog.ConsoleWriter{
|
||||
Out: os.Stdout,
|
||||
TimeFormat: "15:04:05",
|
||||
NoColor: false,
|
||||
}
|
||||
}
|
||||
|
||||
Log = zerolog.New(output).
|
||||
With().
|
||||
Timestamp().
|
||||
Caller().
|
||||
Logger()
|
||||
|
||||
Log.Info().
|
||||
Str("level", level.String()).
|
||||
Str("environment", config.Environment).
|
||||
Msg("Logger initialized")
|
||||
}
|
||||
|
||||
func parseLogLevel(level string) zerolog.Level {
|
||||
switch strings.ToLower(level) {
|
||||
case "debug":
|
||||
return zerolog.DebugLevel
|
||||
case "info":
|
||||
return zerolog.InfoLevel
|
||||
case "warn", "warning":
|
||||
return zerolog.WarnLevel
|
||||
case "error":
|
||||
return zerolog.ErrorLevel
|
||||
case "fatal":
|
||||
return zerolog.FatalLevel
|
||||
case "panic":
|
||||
return zerolog.PanicLevel
|
||||
default:
|
||||
return zerolog.InfoLevel
|
||||
}
|
||||
}
|
||||
|
||||
func Debug() *zerolog.Event {
|
||||
return Log.Debug()
|
||||
}
|
||||
|
||||
func Info() *zerolog.Event {
|
||||
return Log.Info()
|
||||
}
|
||||
|
||||
func Warn() *zerolog.Event {
|
||||
return Log.Warn()
|
||||
}
|
||||
|
||||
func Error() *zerolog.Event {
|
||||
return Log.Error()
|
||||
}
|
||||
|
||||
func Fatal() *zerolog.Event {
|
||||
return Log.Fatal()
|
||||
}
|
||||
|
||||
func With() zerolog.Context {
|
||||
return Log.With()
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
package logger
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/rs/zerolog"
|
||||
)
|
||||
|
||||
type contextKey string
|
||||
|
||||
const (
|
||||
RequestIDKey contextKey = "request_id"
|
||||
UserIDKey contextKey = "user_id"
|
||||
)
|
||||
|
||||
type responseWriter struct {
|
||||
http.ResponseWriter
|
||||
status int
|
||||
size int
|
||||
}
|
||||
|
||||
func (rw *responseWriter) WriteHeader(status int) {
|
||||
rw.status = status
|
||||
rw.ResponseWriter.WriteHeader(status)
|
||||
}
|
||||
|
||||
func (rw *responseWriter) Write(b []byte) (int, error) {
|
||||
size, err := rw.ResponseWriter.Write(b)
|
||||
rw.size += size
|
||||
return size, err
|
||||
}
|
||||
|
||||
func Middleware(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
start := time.Now()
|
||||
|
||||
requestID := uuid.New().String()
|
||||
ctx := context.WithValue(r.Context(), RequestIDKey, requestID)
|
||||
|
||||
logger := Log.With().
|
||||
Str("request_id", requestID).
|
||||
Str("method", r.Method).
|
||||
Str("path", r.URL.Path).
|
||||
Str("remote_addr", r.RemoteAddr).
|
||||
Str("user_agent", r.UserAgent()).
|
||||
Logger()
|
||||
|
||||
ctx = logger.WithContext(ctx)
|
||||
r = r.WithContext(ctx)
|
||||
|
||||
rw := &responseWriter{
|
||||
ResponseWriter: w,
|
||||
status: http.StatusOK,
|
||||
}
|
||||
|
||||
next.ServeHTTP(rw, r)
|
||||
|
||||
duration := time.Since(start)
|
||||
|
||||
event := logger.Info()
|
||||
if rw.status >= 400 && rw.status < 500 {
|
||||
event = logger.Warn()
|
||||
} else if rw.status >= 500 {
|
||||
event = logger.Error()
|
||||
}
|
||||
|
||||
event.
|
||||
Int("status", rw.status).
|
||||
Int("size", rw.size).
|
||||
Dur("duration", duration).
|
||||
Msg("HTTP request")
|
||||
})
|
||||
}
|
||||
|
||||
func FromContext(ctx context.Context) *zerolog.Logger {
|
||||
logger := zerolog.Ctx(ctx)
|
||||
if logger == nil || logger.GetLevel() == zerolog.Disabled {
|
||||
return &Log
|
||||
}
|
||||
return logger
|
||||
}
|
||||
|
||||
func AddUserID(ctx context.Context, userID string) context.Context {
|
||||
logger := FromContext(ctx)
|
||||
updatedLogger := logger.With().Str("user_id", userID).Logger()
|
||||
return updatedLogger.WithContext(ctx)
|
||||
}
|
||||
@@ -185,6 +185,24 @@ func (r *HabitEntryRepository) FindByHabitID(ctx context.Context, habitID string
|
||||
return r.scanEntries(rows)
|
||||
}
|
||||
|
||||
func (r *HabitEntryRepository) FindByUserID(ctx context.Context, userID string) ([]*entities.HabitEntry, error) {
|
||||
query := `
|
||||
SELECT he.id, he.habit_id, he.scheduled_date, he.completed_at, he.value
|
||||
FROM habit_entries he
|
||||
INNER JOIN habits h ON he.habit_id = h.id
|
||||
WHERE h.user_id = ?
|
||||
ORDER BY he.scheduled_date DESC
|
||||
`
|
||||
|
||||
rows, err := r.db.QueryContext(ctx, query, userID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to find entries: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
return r.scanEntries(rows)
|
||||
}
|
||||
|
||||
func (r *HabitEntryRepository) FindPendingByHabitID(ctx context.Context, habitID string, beforeDate time.Time) ([]*entities.HabitEntry, error) {
|
||||
query := `
|
||||
SELECT id, habit_id, scheduled_date, completed_at, value
|
||||
|
||||
@@ -7,7 +7,9 @@ import (
|
||||
"fmt"
|
||||
|
||||
"apocapoc-api/internal/domain/entities"
|
||||
"apocapoc-api/internal/domain/repositories"
|
||||
"apocapoc-api/internal/shared/errors"
|
||||
"apocapoc-api/internal/shared/pagination"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
@@ -245,3 +247,129 @@ func (r *HabitRepository) Delete(ctx context.Context, id string) error {
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *HabitRepository) FindActiveByUserIDWithPagination(ctx context.Context, userID string, params pagination.Params) ([]*entities.Habit, error) {
|
||||
query := `
|
||||
SELECT id, user_id, name, description, type, frequency,
|
||||
specific_days, specific_dates, carry_over, is_negative, target_value,
|
||||
created_at, archived_at
|
||||
FROM habits
|
||||
WHERE user_id = ? AND archived_at IS NULL
|
||||
ORDER BY created_at DESC
|
||||
LIMIT ? OFFSET ?
|
||||
`
|
||||
|
||||
rows, err := r.db.QueryContext(ctx, query, userID, params.Limit(), params.Offset())
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to find habits: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
return r.scanHabits(rows)
|
||||
}
|
||||
|
||||
func (r *HabitRepository) CountActiveByUserID(ctx context.Context, userID string) (int, error) {
|
||||
query := `
|
||||
SELECT COUNT(*)
|
||||
FROM habits
|
||||
WHERE user_id = ? AND archived_at IS NULL
|
||||
`
|
||||
|
||||
var count int
|
||||
err := r.db.QueryRowContext(ctx, query, userID).Scan(&count)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("failed to count habits: %w", err)
|
||||
}
|
||||
|
||||
return count, nil
|
||||
}
|
||||
|
||||
func (r *HabitRepository) FindByUserIDFiltered(ctx context.Context, userID string, filter repositories.HabitFilter, paginationParams *pagination.Params) ([]*entities.Habit, error) {
|
||||
baseQuery := `
|
||||
SELECT id, user_id, name, description, type, frequency,
|
||||
specific_days, specific_dates, carry_over, is_negative, target_value,
|
||||
created_at, archived_at
|
||||
FROM habits
|
||||
WHERE user_id = ?`
|
||||
|
||||
args := []interface{}{userID}
|
||||
conditions := []string{}
|
||||
|
||||
if !filter.IncludeArchived {
|
||||
conditions = append(conditions, "archived_at IS NULL")
|
||||
}
|
||||
|
||||
if filter.Type != nil {
|
||||
conditions = append(conditions, "type = ?")
|
||||
args = append(args, string(*filter.Type))
|
||||
}
|
||||
|
||||
if filter.Frequency != nil {
|
||||
conditions = append(conditions, "frequency = ?")
|
||||
args = append(args, string(*filter.Frequency))
|
||||
}
|
||||
|
||||
if filter.Search != "" {
|
||||
conditions = append(conditions, "(name LIKE ? OR description LIKE ?)")
|
||||
searchPattern := "%" + filter.Search + "%"
|
||||
args = append(args, searchPattern, searchPattern)
|
||||
}
|
||||
|
||||
for _, condition := range conditions {
|
||||
baseQuery += " AND " + condition
|
||||
}
|
||||
|
||||
baseQuery += " ORDER BY created_at DESC"
|
||||
|
||||
if paginationParams != nil {
|
||||
baseQuery += " LIMIT ? OFFSET ?"
|
||||
args = append(args, paginationParams.Limit(), paginationParams.Offset())
|
||||
}
|
||||
|
||||
rows, err := r.db.QueryContext(ctx, baseQuery, args...)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to find habits: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
return r.scanHabits(rows)
|
||||
}
|
||||
|
||||
func (r *HabitRepository) CountByUserIDFiltered(ctx context.Context, userID string, filter repositories.HabitFilter) (int, error) {
|
||||
baseQuery := `SELECT COUNT(*) FROM habits WHERE user_id = ?`
|
||||
|
||||
args := []interface{}{userID}
|
||||
conditions := []string{}
|
||||
|
||||
if !filter.IncludeArchived {
|
||||
conditions = append(conditions, "archived_at IS NULL")
|
||||
}
|
||||
|
||||
if filter.Type != nil {
|
||||
conditions = append(conditions, "type = ?")
|
||||
args = append(args, string(*filter.Type))
|
||||
}
|
||||
|
||||
if filter.Frequency != nil {
|
||||
conditions = append(conditions, "frequency = ?")
|
||||
args = append(args, string(*filter.Frequency))
|
||||
}
|
||||
|
||||
if filter.Search != "" {
|
||||
conditions = append(conditions, "(name LIKE ? OR description LIKE ?)")
|
||||
searchPattern := "%" + filter.Search + "%"
|
||||
args = append(args, searchPattern, searchPattern)
|
||||
}
|
||||
|
||||
for _, condition := range conditions {
|
||||
baseQuery += " AND " + condition
|
||||
}
|
||||
|
||||
var count int
|
||||
err := r.db.QueryRowContext(ctx, baseQuery, args...).Scan(&count)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("failed to count habits: %w", err)
|
||||
}
|
||||
|
||||
return count, nil
|
||||
}
|
||||
|
||||
@@ -6,8 +6,10 @@ import (
|
||||
"time"
|
||||
|
||||
"apocapoc-api/internal/domain/entities"
|
||||
"apocapoc-api/internal/domain/repositories"
|
||||
"apocapoc-api/internal/domain/value_objects"
|
||||
"apocapoc-api/internal/shared/errors"
|
||||
"apocapoc-api/internal/shared/pagination"
|
||||
)
|
||||
|
||||
func TestHabitRepositoryCreate(t *testing.T) {
|
||||
@@ -262,3 +264,372 @@ func TestHabitRepositoryArchive(t *testing.T) {
|
||||
t.Error("Expected habit to be archived")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHabitRepositoryFindActiveByUserIDWithPagination(t *testing.T) {
|
||||
db := setupTestDB(t)
|
||||
defer db.Close()
|
||||
|
||||
repo := NewHabitRepository(db)
|
||||
ctx := context.Background()
|
||||
|
||||
userID := "user-pagination-test"
|
||||
|
||||
for i := 1; i <= 10; i++ {
|
||||
habit := entities.NewHabit(
|
||||
userID,
|
||||
"Habit "+string(rune(i+'0')),
|
||||
value_objects.HabitTypeBoolean,
|
||||
value_objects.FrequencyDaily,
|
||||
false,
|
||||
false,
|
||||
)
|
||||
err := repo.Create(ctx, habit)
|
||||
if err != nil {
|
||||
t.Fatalf("Create failed: %v", err)
|
||||
}
|
||||
time.Sleep(1 * time.Millisecond)
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
allHabits, _ := repo.FindActiveByUserID(ctx, userID)
|
||||
allHabits[0].ArchivedAt = &now
|
||||
repo.Update(ctx, allHabits[0])
|
||||
|
||||
t.Run("FirstPage", func(t *testing.T) {
|
||||
params := pagination.NewParams(1, 5)
|
||||
habits, err := repo.FindActiveByUserIDWithPagination(ctx, userID, params)
|
||||
if err != nil {
|
||||
t.Fatalf("FindActiveByUserIDWithPagination failed: %v", err)
|
||||
}
|
||||
|
||||
if len(habits) != 5 {
|
||||
t.Errorf("Expected 5 habits on first page, got %d", len(habits))
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("SecondPage", func(t *testing.T) {
|
||||
params := pagination.NewParams(2, 5)
|
||||
habits, err := repo.FindActiveByUserIDWithPagination(ctx, userID, params)
|
||||
if err != nil {
|
||||
t.Fatalf("FindActiveByUserIDWithPagination failed: %v", err)
|
||||
}
|
||||
|
||||
if len(habits) != 4 {
|
||||
t.Errorf("Expected 4 habits on second page (9 total active), got %d", len(habits))
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("PageBeyondTotal", func(t *testing.T) {
|
||||
params := pagination.NewParams(10, 5)
|
||||
habits, err := repo.FindActiveByUserIDWithPagination(ctx, userID, params)
|
||||
if err != nil {
|
||||
t.Fatalf("FindActiveByUserIDWithPagination failed: %v", err)
|
||||
}
|
||||
|
||||
if len(habits) != 0 {
|
||||
t.Errorf("Expected 0 habits beyond total pages, got %d", len(habits))
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("CustomPageSize", func(t *testing.T) {
|
||||
params := pagination.NewParams(1, 3)
|
||||
habits, err := repo.FindActiveByUserIDWithPagination(ctx, userID, params)
|
||||
if err != nil {
|
||||
t.Fatalf("FindActiveByUserIDWithPagination failed: %v", err)
|
||||
}
|
||||
|
||||
if len(habits) != 3 {
|
||||
t.Errorf("Expected 3 habits with page_size=3, got %d", len(habits))
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("ExcludesArchived", func(t *testing.T) {
|
||||
params := pagination.NewParams(1, 20)
|
||||
habits, err := repo.FindActiveByUserIDWithPagination(ctx, userID, params)
|
||||
if err != nil {
|
||||
t.Fatalf("FindActiveByUserIDWithPagination failed: %v", err)
|
||||
}
|
||||
|
||||
if len(habits) != 9 {
|
||||
t.Errorf("Expected 9 active habits (1 archived), got %d", len(habits))
|
||||
}
|
||||
|
||||
for _, habit := range habits {
|
||||
if habit.ArchivedAt != nil {
|
||||
t.Error("Expected no archived habits in results")
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestHabitRepositoryCountActiveByUserID(t *testing.T) {
|
||||
db := setupTestDB(t)
|
||||
defer db.Close()
|
||||
|
||||
repo := NewHabitRepository(db)
|
||||
ctx := context.Background()
|
||||
|
||||
userID := "user-count-test"
|
||||
|
||||
t.Run("NoHabits", func(t *testing.T) {
|
||||
count, err := repo.CountActiveByUserID(ctx, "non-existent-user")
|
||||
if err != nil {
|
||||
t.Fatalf("CountActiveByUserID failed: %v", err)
|
||||
}
|
||||
|
||||
if count != 0 {
|
||||
t.Errorf("Expected count 0 for non-existent user, got %d", count)
|
||||
}
|
||||
})
|
||||
|
||||
for i := 1; i <= 7; i++ {
|
||||
habit := entities.NewHabit(
|
||||
userID,
|
||||
"Habit "+string(rune(i+'0')),
|
||||
value_objects.HabitTypeBoolean,
|
||||
value_objects.FrequencyDaily,
|
||||
false,
|
||||
false,
|
||||
)
|
||||
repo.Create(ctx, habit)
|
||||
}
|
||||
|
||||
t.Run("AllActive", func(t *testing.T) {
|
||||
count, err := repo.CountActiveByUserID(ctx, userID)
|
||||
if err != nil {
|
||||
t.Fatalf("CountActiveByUserID failed: %v", err)
|
||||
}
|
||||
|
||||
if count != 7 {
|
||||
t.Errorf("Expected count 7, got %d", count)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("WithArchived", func(t *testing.T) {
|
||||
habits, _ := repo.FindActiveByUserID(ctx, userID)
|
||||
now := time.Now()
|
||||
habits[0].ArchivedAt = &now
|
||||
habits[1].ArchivedAt = &now
|
||||
repo.Update(ctx, habits[0])
|
||||
repo.Update(ctx, habits[1])
|
||||
|
||||
count, err := repo.CountActiveByUserID(ctx, userID)
|
||||
if err != nil {
|
||||
t.Fatalf("CountActiveByUserID failed: %v", err)
|
||||
}
|
||||
|
||||
if count != 5 {
|
||||
t.Errorf("Expected count 5 (7 total - 2 archived), got %d", count)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestHabitRepositoryFindByUserIDFiltered(t *testing.T) {
|
||||
db := setupTestDB(t)
|
||||
defer db.Close()
|
||||
|
||||
repo := NewHabitRepository(db)
|
||||
ctx := context.Background()
|
||||
|
||||
userID := "user-filter-test"
|
||||
|
||||
habit1 := entities.NewHabit(userID, "Morning Exercise", value_objects.HabitTypeBoolean, value_objects.FrequencyDaily, false, false)
|
||||
habit1.Description = "Daily morning workout"
|
||||
repo.Create(ctx, habit1)
|
||||
time.Sleep(1 * time.Millisecond)
|
||||
|
||||
habit2 := entities.NewHabit(userID, "Read Books", value_objects.HabitTypeCounter, value_objects.FrequencyWeekly, false, false)
|
||||
habit2.Description = "Read at least 3 books per week"
|
||||
repo.Create(ctx, habit2)
|
||||
time.Sleep(1 * time.Millisecond)
|
||||
|
||||
habit3 := entities.NewHabit(userID, "Drink Water", value_objects.HabitTypeValue, value_objects.FrequencyDaily, false, false)
|
||||
habit3.Description = "Drink 2 liters of water daily"
|
||||
repo.Create(ctx, habit3)
|
||||
time.Sleep(1 * time.Millisecond)
|
||||
|
||||
habit4 := entities.NewHabit(userID, "Weekly Run", value_objects.HabitTypeBoolean, value_objects.FrequencyWeekly, false, false)
|
||||
repo.Create(ctx, habit4)
|
||||
time.Sleep(1 * time.Millisecond)
|
||||
|
||||
now := time.Now()
|
||||
habit4.ArchivedAt = &now
|
||||
repo.Update(ctx, habit4)
|
||||
|
||||
t.Run("FilterByType", func(t *testing.T) {
|
||||
habitType := value_objects.HabitTypeBoolean
|
||||
filter := repositories.HabitFilter{
|
||||
Type: &habitType,
|
||||
}
|
||||
|
||||
habits, err := repo.FindByUserIDFiltered(ctx, userID, filter, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("FindByUserIDFiltered failed: %v", err)
|
||||
}
|
||||
|
||||
if len(habits) != 1 {
|
||||
t.Errorf("Expected 1 active BOOLEAN habit, got %d", len(habits))
|
||||
}
|
||||
|
||||
if habits[0].Type != value_objects.HabitTypeBoolean {
|
||||
t.Errorf("Expected BOOLEAN type, got %s", habits[0].Type)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("FilterByFrequency", func(t *testing.T) {
|
||||
frequency := value_objects.FrequencyDaily
|
||||
filter := repositories.HabitFilter{
|
||||
Frequency: &frequency,
|
||||
}
|
||||
|
||||
habits, err := repo.FindByUserIDFiltered(ctx, userID, filter, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("FindByUserIDFiltered failed: %v", err)
|
||||
}
|
||||
|
||||
if len(habits) != 2 {
|
||||
t.Errorf("Expected 2 DAILY habits, got %d", len(habits))
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("FilterIncludeArchived", func(t *testing.T) {
|
||||
filter := repositories.HabitFilter{
|
||||
IncludeArchived: true,
|
||||
}
|
||||
|
||||
habits, err := repo.FindByUserIDFiltered(ctx, userID, filter, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("FindByUserIDFiltered failed: %v", err)
|
||||
}
|
||||
|
||||
if len(habits) != 4 {
|
||||
t.Errorf("Expected 4 habits (including archived), got %d", len(habits))
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("FilterBySearch", func(t *testing.T) {
|
||||
filter := repositories.HabitFilter{
|
||||
Search: "Exercise",
|
||||
}
|
||||
|
||||
habits, err := repo.FindByUserIDFiltered(ctx, userID, filter, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("FindByUserIDFiltered failed: %v", err)
|
||||
}
|
||||
|
||||
if len(habits) != 1 {
|
||||
t.Errorf("Expected 1 habit matching 'Exercise', got %d", len(habits))
|
||||
}
|
||||
|
||||
if habits[0].Name != "Morning Exercise" {
|
||||
t.Errorf("Expected 'Morning Exercise', got %s", habits[0].Name)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("FilterBySearchInDescription", func(t *testing.T) {
|
||||
filter := repositories.HabitFilter{
|
||||
Search: "books",
|
||||
}
|
||||
|
||||
habits, err := repo.FindByUserIDFiltered(ctx, userID, filter, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("FindByUserIDFiltered failed: %v", err)
|
||||
}
|
||||
|
||||
if len(habits) != 1 {
|
||||
t.Errorf("Expected 1 habit matching 'books' in description, got %d", len(habits))
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("CombineFilters", func(t *testing.T) {
|
||||
habitType := value_objects.HabitTypeBoolean
|
||||
frequency := value_objects.FrequencyWeekly
|
||||
filter := repositories.HabitFilter{
|
||||
Type: &habitType,
|
||||
Frequency: &frequency,
|
||||
IncludeArchived: true,
|
||||
}
|
||||
|
||||
habits, err := repo.FindByUserIDFiltered(ctx, userID, filter, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("FindByUserIDFiltered failed: %v", err)
|
||||
}
|
||||
|
||||
if len(habits) != 1 {
|
||||
t.Errorf("Expected 1 BOOLEAN WEEKLY habit (archived), got %d", len(habits))
|
||||
}
|
||||
|
||||
if habits[0].Name != "Weekly Run" {
|
||||
t.Errorf("Expected 'Weekly Run', got %s", habits[0].Name)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("WithPagination", func(t *testing.T) {
|
||||
filter := repositories.HabitFilter{}
|
||||
params := pagination.NewParams(1, 2)
|
||||
|
||||
habits, err := repo.FindByUserIDFiltered(ctx, userID, filter, ¶ms)
|
||||
if err != nil {
|
||||
t.Fatalf("FindByUserIDFiltered failed: %v", err)
|
||||
}
|
||||
|
||||
if len(habits) != 2 {
|
||||
t.Errorf("Expected 2 habits on first page, got %d", len(habits))
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestHabitRepositoryCountByUserIDFiltered(t *testing.T) {
|
||||
db := setupTestDB(t)
|
||||
defer db.Close()
|
||||
|
||||
repo := NewHabitRepository(db)
|
||||
ctx := context.Background()
|
||||
|
||||
userID := "user-count-filter-test"
|
||||
|
||||
habit1 := entities.NewHabit(userID, "Test1", value_objects.HabitTypeBoolean, value_objects.FrequencyDaily, false, false)
|
||||
repo.Create(ctx, habit1)
|
||||
|
||||
habit2 := entities.NewHabit(userID, "Test2", value_objects.HabitTypeCounter, value_objects.FrequencyDaily, false, false)
|
||||
repo.Create(ctx, habit2)
|
||||
|
||||
habit3 := entities.NewHabit(userID, "Test3", value_objects.HabitTypeBoolean, value_objects.FrequencyWeekly, false, false)
|
||||
now := time.Now()
|
||||
habit3.ArchivedAt = &now
|
||||
repo.Create(ctx, habit3)
|
||||
repo.Update(ctx, habit3)
|
||||
|
||||
t.Run("CountByType", func(t *testing.T) {
|
||||
habitType := value_objects.HabitTypeBoolean
|
||||
filter := repositories.HabitFilter{
|
||||
Type: &habitType,
|
||||
}
|
||||
|
||||
count, err := repo.CountByUserIDFiltered(ctx, userID, filter)
|
||||
if err != nil {
|
||||
t.Fatalf("CountByUserIDFiltered failed: %v", err)
|
||||
}
|
||||
|
||||
if count != 1 {
|
||||
t.Errorf("Expected 1 active BOOLEAN habit, got %d", count)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("CountWithArchived", func(t *testing.T) {
|
||||
habitType := value_objects.HabitTypeBoolean
|
||||
filter := repositories.HabitFilter{
|
||||
Type: &habitType,
|
||||
IncludeArchived: true,
|
||||
}
|
||||
|
||||
count, err := repo.CountByUserIDFiltered(ctx, userID, filter)
|
||||
if err != nil {
|
||||
t.Fatalf("CountByUserIDFiltered failed: %v", err)
|
||||
}
|
||||
|
||||
if count != 2 {
|
||||
t.Errorf("Expected 2 BOOLEAN habits (including archived), got %d", count)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -25,6 +25,10 @@ func RunMigrations(db *sql.DB) error {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := removeTimezoneColumn(db); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -54,6 +58,23 @@ func addEmailVerificationColumns(db *sql.DB) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func removeTimezoneColumn(db *sql.DB) error {
|
||||
exists, err := columnExists(db, "users", "timezone")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if !exists {
|
||||
return nil
|
||||
}
|
||||
|
||||
if _, err := db.Exec("ALTER TABLE users DROP COLUMN timezone"); err != nil {
|
||||
return fmt.Errorf("failed to drop timezone column: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func columnExists(db *sql.DB, table, column string) (bool, error) {
|
||||
query := fmt.Sprintf("SELECT COUNT(*) FROM pragma_table_info('%s') WHERE name = ?", table)
|
||||
var count int
|
||||
@@ -69,7 +90,6 @@ CREATE TABLE IF NOT EXISTS users (
|
||||
id TEXT PRIMARY KEY,
|
||||
email TEXT UNIQUE NOT NULL,
|
||||
password_hash TEXT NOT NULL,
|
||||
timezone TEXT DEFAULT 'UTC',
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
@@ -43,7 +43,7 @@ func TestUsersTableSchema(t *testing.T) {
|
||||
t.Fatalf("RunMigrations failed: %v", err)
|
||||
}
|
||||
|
||||
columns := []string{"id", "email", "password_hash", "timezone", "created_at", "updated_at"}
|
||||
columns := []string{"id", "email", "password_hash", "email_verified", "email_verification_token", "email_verification_expiry", "created_at", "updated_at"}
|
||||
for _, col := range columns {
|
||||
query := "SELECT " + col + " FROM users LIMIT 0"
|
||||
rows, err := db.Query(query)
|
||||
|
||||
@@ -24,15 +24,14 @@ 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, email_verified, email_verification_token, email_verification_expiry, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
INSERT INTO users (id, email, password_hash, email_verified, email_verification_token, email_verification_expiry, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`
|
||||
|
||||
_, err := r.db.ExecContext(ctx, query,
|
||||
user.ID,
|
||||
user.Email,
|
||||
user.PasswordHash,
|
||||
user.Timezone,
|
||||
user.EmailVerified,
|
||||
user.EmailVerificationToken,
|
||||
user.EmailVerificationExpiry,
|
||||
@@ -52,7 +51,7 @@ func (r *UserRepository) Create(ctx context.Context, user *entities.User) error
|
||||
|
||||
func (r *UserRepository) FindByID(ctx context.Context, id string) (*entities.User, error) {
|
||||
query := `
|
||||
SELECT id, email, password_hash, timezone, email_verified, email_verification_token, email_verification_expiry, created_at, updated_at
|
||||
SELECT id, email, password_hash, email_verified, email_verification_token, email_verification_expiry, created_at, updated_at
|
||||
FROM users
|
||||
WHERE id = ?
|
||||
`
|
||||
@@ -62,7 +61,6 @@ func (r *UserRepository) FindByID(ctx context.Context, id string) (*entities.Use
|
||||
&user.ID,
|
||||
&user.Email,
|
||||
&user.PasswordHash,
|
||||
&user.Timezone,
|
||||
&user.EmailVerified,
|
||||
&user.EmailVerificationToken,
|
||||
&user.EmailVerificationExpiry,
|
||||
@@ -82,7 +80,7 @@ func (r *UserRepository) FindByID(ctx context.Context, id string) (*entities.Use
|
||||
|
||||
func (r *UserRepository) FindByEmail(ctx context.Context, email string) (*entities.User, error) {
|
||||
query := `
|
||||
SELECT id, email, password_hash, timezone, email_verified, email_verification_token, email_verification_expiry, created_at, updated_at
|
||||
SELECT id, email, password_hash, email_verified, email_verification_token, email_verification_expiry, created_at, updated_at
|
||||
FROM users
|
||||
WHERE email = ?
|
||||
`
|
||||
@@ -92,7 +90,6 @@ func (r *UserRepository) FindByEmail(ctx context.Context, email string) (*entiti
|
||||
&user.ID,
|
||||
&user.Email,
|
||||
&user.PasswordHash,
|
||||
&user.Timezone,
|
||||
&user.EmailVerified,
|
||||
&user.EmailVerificationToken,
|
||||
&user.EmailVerificationExpiry,
|
||||
@@ -112,7 +109,7 @@ func (r *UserRepository) FindByEmail(ctx context.Context, email string) (*entiti
|
||||
|
||||
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
|
||||
SELECT id, email, password_hash, email_verified, email_verification_token, email_verification_expiry, created_at, updated_at
|
||||
FROM users
|
||||
WHERE email_verification_token = ?
|
||||
`
|
||||
@@ -122,7 +119,6 @@ func (r *UserRepository) FindByVerificationToken(ctx context.Context, token stri
|
||||
&user.ID,
|
||||
&user.Email,
|
||||
&user.PasswordHash,
|
||||
&user.Timezone,
|
||||
&user.EmailVerified,
|
||||
&user.EmailVerificationToken,
|
||||
&user.EmailVerificationExpiry,
|
||||
@@ -143,14 +139,13 @@ func (r *UserRepository) FindByVerificationToken(ctx context.Context, token stri
|
||||
func (r *UserRepository) Update(ctx context.Context, user *entities.User) error {
|
||||
query := `
|
||||
UPDATE users
|
||||
SET email = ?, password_hash = ?, timezone = ?, email_verified = ?, email_verification_token = ?, email_verification_expiry = ?, updated_at = ?
|
||||
SET email = ?, password_hash = ?, email_verified = ?, email_verification_token = ?, email_verification_expiry = ?, updated_at = ?
|
||||
WHERE id = ?
|
||||
`
|
||||
|
||||
result, err := r.db.ExecContext(ctx, query,
|
||||
user.Email,
|
||||
user.PasswordHash,
|
||||
user.Timezone,
|
||||
user.EmailVerified,
|
||||
user.EmailVerificationToken,
|
||||
user.EmailVerificationExpiry,
|
||||
|
||||
@@ -35,7 +35,6 @@ func TestUserRepositoryCreate(t *testing.T) {
|
||||
user := &entities.User{
|
||||
Email: "test@example.com",
|
||||
PasswordHash: "hashed_password",
|
||||
Timezone: "UTC",
|
||||
CreatedAt: time.Now(),
|
||||
UpdatedAt: time.Now(),
|
||||
}
|
||||
@@ -60,7 +59,6 @@ func TestUserRepositoryCreateDuplicateEmail(t *testing.T) {
|
||||
user1 := &entities.User{
|
||||
Email: "duplicate@example.com",
|
||||
PasswordHash: "hash1",
|
||||
Timezone: "UTC",
|
||||
CreatedAt: time.Now(),
|
||||
UpdatedAt: time.Now(),
|
||||
}
|
||||
@@ -73,7 +71,6 @@ func TestUserRepositoryCreateDuplicateEmail(t *testing.T) {
|
||||
user2 := &entities.User{
|
||||
Email: "duplicate@example.com",
|
||||
PasswordHash: "hash2",
|
||||
Timezone: "UTC",
|
||||
CreatedAt: time.Now(),
|
||||
UpdatedAt: time.Now(),
|
||||
}
|
||||
@@ -94,7 +91,6 @@ func TestUserRepositoryFindByID(t *testing.T) {
|
||||
user := &entities.User{
|
||||
Email: "find@example.com",
|
||||
PasswordHash: "hashed",
|
||||
Timezone: "America/New_York",
|
||||
CreatedAt: time.Now(),
|
||||
UpdatedAt: time.Now(),
|
||||
}
|
||||
@@ -115,9 +111,6 @@ func TestUserRepositoryFindByID(t *testing.T) {
|
||||
if found.Email != user.Email {
|
||||
t.Errorf("Expected email %s, got %s", user.Email, found.Email)
|
||||
}
|
||||
if found.Timezone != user.Timezone {
|
||||
t.Errorf("Expected timezone %s, got %s", user.Timezone, found.Timezone)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUserRepositoryFindByIDNotFound(t *testing.T) {
|
||||
@@ -143,7 +136,6 @@ func TestUserRepositoryFindByEmail(t *testing.T) {
|
||||
user := &entities.User{
|
||||
Email: "email@test.com",
|
||||
PasswordHash: "hashed",
|
||||
Timezone: "UTC",
|
||||
CreatedAt: time.Now(),
|
||||
UpdatedAt: time.Now(),
|
||||
}
|
||||
@@ -189,7 +181,6 @@ func TestUserRepositoryUpdate(t *testing.T) {
|
||||
user := &entities.User{
|
||||
Email: "original@example.com",
|
||||
PasswordHash: "hash1",
|
||||
Timezone: "UTC",
|
||||
CreatedAt: time.Now(),
|
||||
UpdatedAt: time.Now(),
|
||||
}
|
||||
@@ -200,7 +191,6 @@ func TestUserRepositoryUpdate(t *testing.T) {
|
||||
}
|
||||
|
||||
user.Email = "updated@example.com"
|
||||
user.Timezone = "Europe/Madrid"
|
||||
user.UpdatedAt = time.Now()
|
||||
|
||||
err = repo.Update(ctx, user)
|
||||
@@ -216,9 +206,6 @@ func TestUserRepositoryUpdate(t *testing.T) {
|
||||
if found.Email != "updated@example.com" {
|
||||
t.Errorf("Expected email updated@example.com, got %s", found.Email)
|
||||
}
|
||||
if found.Timezone != "Europe/Madrid" {
|
||||
t.Errorf("Expected timezone Europe/Madrid, got %s", found.Timezone)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUserRepositoryUpdateNotFound(t *testing.T) {
|
||||
@@ -232,7 +219,6 @@ func TestUserRepositoryUpdateNotFound(t *testing.T) {
|
||||
ID: "non-existent",
|
||||
Email: "test@example.com",
|
||||
PasswordHash: "hash",
|
||||
Timezone: "UTC",
|
||||
CreatedAt: time.Now(),
|
||||
UpdatedAt: time.Now(),
|
||||
}
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
package pagination
|
||||
|
||||
type Params struct {
|
||||
Page int
|
||||
PageSize int
|
||||
}
|
||||
|
||||
func NewParams(page, pageSize int) Params {
|
||||
if page < 1 {
|
||||
page = 1
|
||||
}
|
||||
if pageSize < 1 {
|
||||
pageSize = 50
|
||||
}
|
||||
if pageSize > 100 {
|
||||
pageSize = 100
|
||||
}
|
||||
return Params{
|
||||
Page: page,
|
||||
PageSize: pageSize,
|
||||
}
|
||||
}
|
||||
|
||||
func (p Params) Offset() int {
|
||||
return (p.Page - 1) * p.PageSize
|
||||
}
|
||||
|
||||
func (p Params) Limit() int {
|
||||
return p.PageSize
|
||||
}
|
||||
|
||||
type Response struct {
|
||||
Page int `json:"page"`
|
||||
PageSize int `json:"page_size"`
|
||||
TotalItems int `json:"total_items"`
|
||||
TotalPages int `json:"total_pages"`
|
||||
}
|
||||
|
||||
func NewResponse(params Params, totalItems int) Response {
|
||||
totalPages := totalItems / params.PageSize
|
||||
if totalItems%params.PageSize > 0 {
|
||||
totalPages++
|
||||
}
|
||||
if totalPages < 1 {
|
||||
totalPages = 1
|
||||
}
|
||||
|
||||
return Response{
|
||||
Page: params.Page,
|
||||
PageSize: params.PageSize,
|
||||
TotalItems: totalItems,
|
||||
TotalPages: totalPages,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
package pagination
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestNewParams(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
page int
|
||||
pageSize int
|
||||
expectedPage int
|
||||
expectedSize int
|
||||
}{
|
||||
{"valid params", 1, 20, 1, 20},
|
||||
{"valid params page 2", 2, 50, 2, 50},
|
||||
{"page less than 1 defaults to 1", 0, 20, 1, 20},
|
||||
{"negative page defaults to 1", -5, 20, 1, 20},
|
||||
{"pageSize less than 1 defaults to 50", 1, 0, 1, 50},
|
||||
{"negative pageSize defaults to 50", 1, -10, 1, 50},
|
||||
{"pageSize greater than 100 caps at 100", 1, 200, 1, 100},
|
||||
{"page 100", 1, 101, 1, 100},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
params := NewParams(tt.page, tt.pageSize)
|
||||
if params.Page != tt.expectedPage {
|
||||
t.Errorf("Page = %d, want %d", params.Page, tt.expectedPage)
|
||||
}
|
||||
if params.PageSize != tt.expectedSize {
|
||||
t.Errorf("PageSize = %d, want %d", params.PageSize, tt.expectedSize)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestParamsOffset(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
page int
|
||||
pageSize int
|
||||
expectedOffset int
|
||||
}{
|
||||
{"first page", 1, 20, 0},
|
||||
{"second page", 2, 20, 20},
|
||||
{"third page", 3, 20, 40},
|
||||
{"page 10 size 50", 10, 50, 450},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
params := Params{Page: tt.page, PageSize: tt.pageSize}
|
||||
offset := params.Offset()
|
||||
if offset != tt.expectedOffset {
|
||||
t.Errorf("Offset() = %d, want %d", offset, tt.expectedOffset)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestParamsLimit(t *testing.T) {
|
||||
params := Params{Page: 1, PageSize: 25}
|
||||
if params.Limit() != 25 {
|
||||
t.Errorf("Limit() = %d, want 25", params.Limit())
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewResponse(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
params Params
|
||||
totalItems int
|
||||
expectedPages int
|
||||
expectedTotal int
|
||||
}{
|
||||
{"exact division", Params{1, 20}, 100, 5, 100},
|
||||
{"with remainder", Params{1, 20}, 105, 6, 105},
|
||||
{"less than page size", Params{1, 20}, 15, 1, 15},
|
||||
{"zero items", Params{1, 20}, 0, 1, 0},
|
||||
{"one item", Params{1, 20}, 1, 1, 1},
|
||||
{"large dataset", Params{1, 50}, 1000, 20, 1000},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
response := NewResponse(tt.params, tt.totalItems)
|
||||
if response.Page != tt.params.Page {
|
||||
t.Errorf("Page = %d, want %d", response.Page, tt.params.Page)
|
||||
}
|
||||
if response.PageSize != tt.params.PageSize {
|
||||
t.Errorf("PageSize = %d, want %d", response.PageSize, tt.params.PageSize)
|
||||
}
|
||||
if response.TotalPages != tt.expectedPages {
|
||||
t.Errorf("TotalPages = %d, want %d", response.TotalPages, tt.expectedPages)
|
||||
}
|
||||
if response.TotalItems != tt.expectedTotal {
|
||||
t.Errorf("TotalItems = %d, want %d", response.TotalItems, tt.expectedTotal)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -114,7 +114,7 @@ func ValidateTimezone(timezone string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func ValidateRegistration(email, password, timezone string) error {
|
||||
func ValidateRegistration(email, password string) error {
|
||||
if err := ValidateEmail(email); err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -123,9 +123,5 @@ func ValidateRegistration(email, password, timezone string) error {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := ValidateTimezone(timezone); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -118,77 +118,55 @@ func TestValidateRegistration(t *testing.T) {
|
||||
name string
|
||||
email string
|
||||
password string
|
||||
timezone string
|
||||
wantErr bool
|
||||
}{
|
||||
{
|
||||
"valid registration",
|
||||
"user@example.com",
|
||||
"Passw0rd!",
|
||||
"UTC",
|
||||
false,
|
||||
},
|
||||
{
|
||||
"valid with complex email",
|
||||
"user.name+tag@example.co.uk",
|
||||
"MyS3cur3P@ss",
|
||||
"Europe/Madrid",
|
||||
false,
|
||||
},
|
||||
{
|
||||
"invalid email",
|
||||
"invalid-email",
|
||||
"Passw0rd!",
|
||||
"UTC",
|
||||
true,
|
||||
},
|
||||
{
|
||||
"invalid password",
|
||||
"user@example.com",
|
||||
"weak",
|
||||
"UTC",
|
||||
true,
|
||||
},
|
||||
{
|
||||
"invalid timezone",
|
||||
"user@example.com",
|
||||
"Passw0rd!",
|
||||
"InvalidTZ",
|
||||
true,
|
||||
},
|
||||
{
|
||||
"all invalid",
|
||||
"not-an-email",
|
||||
"weak",
|
||||
"bad-tz",
|
||||
true,
|
||||
},
|
||||
{
|
||||
"empty email",
|
||||
"",
|
||||
"Passw0rd!",
|
||||
"UTC",
|
||||
true,
|
||||
},
|
||||
{
|
||||
"empty password",
|
||||
"user@example.com",
|
||||
"",
|
||||
"UTC",
|
||||
true,
|
||||
},
|
||||
{
|
||||
"empty timezone",
|
||||
"user@example.com",
|
||||
"Passw0rd!",
|
||||
"",
|
||||
true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
err := ValidateRegistration(tt.email, tt.password, tt.timezone)
|
||||
err := ValidateRegistration(tt.email, tt.password)
|
||||
if (err != nil) != tt.wantErr {
|
||||
t.Errorf("ValidateRegistration() error = %v, wantErr %v", err, tt.wantErr)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user