Compare commits
19 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 67fc508384 | |||
| 92fe617f73 | |||
| 94c5c30d09 | |||
| 2b059ec334 | |||
| 77cfb709d8 | |||
| aae68ba20e | |||
| 8883cdcb86 | |||
| aa8f7af55d | |||
| 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
|
||||
|
||||
@@ -4,6 +4,7 @@ data/
|
||||
*.db-shm
|
||||
*.db-wal
|
||||
apocapoc-api
|
||||
/api
|
||||
dist/
|
||||
bin/
|
||||
.internal-notes/
|
||||
|
||||
@@ -20,7 +20,7 @@ REST API for habit tracking built with Go. Self-hosted alternative for developer
|
||||
|
||||
- Multiple habit types: Boolean, Counter, Value
|
||||
- Flexible scheduling: Daily, Weekly, Monthly
|
||||
- Statistics: Streaks, completion rates, progress tracking
|
||||
- Statistics: Streaks and completions tracking
|
||||
- JWT authentication, rate limiting, optional email verification
|
||||
- Registration modes: Open or closed
|
||||
- SQLite database (single file)
|
||||
|
||||
+72
-13
@@ -1,21 +1,28 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/signal"
|
||||
"strconv"
|
||||
"strings"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"apocapoc-api/internal/application/commands"
|
||||
"apocapoc-api/internal/application/queries"
|
||||
"apocapoc-api/internal/domain/services"
|
||||
"apocapoc-api/internal/i18n"
|
||||
"apocapoc-api/internal/infrastructure/auth"
|
||||
"apocapoc-api/internal/infrastructure/backup"
|
||||
"apocapoc-api/internal/infrastructure/config"
|
||||
"apocapoc-api/internal/infrastructure/crypto"
|
||||
"apocapoc-api/internal/infrastructure/email"
|
||||
httpInfra "apocapoc-api/internal/infrastructure/http"
|
||||
"apocapoc-api/internal/infrastructure/logger"
|
||||
"apocapoc-api/internal/infrastructure/persistence/sqlite"
|
||||
)
|
||||
|
||||
@@ -43,30 +50,56 @@ func main() {
|
||||
log.Fatalf("Failed to load config: %v", err)
|
||||
}
|
||||
|
||||
logger.Init(logger.Config{
|
||||
Level: cfg.LogLevel,
|
||||
Environment: cfg.Environment,
|
||||
})
|
||||
|
||||
db, err := sqlite.NewDatabase(cfg.DBPath)
|
||||
if err != nil {
|
||||
log.Fatalf("Failed to connect to database: %v", err)
|
||||
logger.Fatal().Err(err).Msg("Failed to connect to database")
|
||||
}
|
||||
defer db.Close()
|
||||
|
||||
backupInterval, err := parseDuration(cfg.BackupInterval)
|
||||
if err != nil {
|
||||
logger.Fatal().Err(err).Msg("Invalid BACKUP_INTERVAL")
|
||||
}
|
||||
|
||||
backupRetentionDays, err := strconv.Atoi(cfg.BackupRetentionDays)
|
||||
if err != nil {
|
||||
logger.Fatal().Err(err).Msg("Invalid BACKUP_RETENTION_DAYS")
|
||||
}
|
||||
|
||||
backupScheduler := backup.NewScheduler(db.Conn(), backup.Config{
|
||||
Enabled: cfg.BackupEnabled == "true",
|
||||
Interval: backupInterval,
|
||||
RetentionDays: backupRetentionDays,
|
||||
Path: cfg.BackupPath,
|
||||
Compress: cfg.BackupCompress == "true",
|
||||
DatabasePath: cfg.DBPath,
|
||||
})
|
||||
backupScheduler.Start()
|
||||
defer backupScheduler.Stop()
|
||||
|
||||
jwtExpiryHours, err := parseJWTExpiry(cfg.JWTExpiry)
|
||||
if err != nil {
|
||||
log.Fatalf("Invalid JWT_EXPIRY: %v", err)
|
||||
logger.Fatal().Err(err).Msg("Invalid JWT_EXPIRY")
|
||||
}
|
||||
|
||||
refreshTokenExpiry, err := parseDuration(cfg.RefreshTokenExpiry)
|
||||
if err != nil {
|
||||
log.Fatalf("Invalid REFRESH_TOKEN_EXPIRY: %v", err)
|
||||
logger.Fatal().Err(err).Msg("Invalid REFRESH_TOKEN_EXPIRY")
|
||||
}
|
||||
|
||||
jwtService := auth.NewJWTService(cfg.JWTSecret, jwtExpiryHours)
|
||||
passwordHasher := crypto.NewBcryptHasher()
|
||||
|
||||
var emailService *email.SMTPService
|
||||
var emailService services.EmailService
|
||||
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 +122,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,25 +141,51 @@ 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)
|
||||
getSyncChangesHandler := queries.NewGetSyncChangesHandler(habitRepo, entryRepo)
|
||||
applySyncBatchHandler := commands.NewApplySyncBatchHandler(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)
|
||||
syncHandlers := httpInfra.NewSyncHandlers(getSyncChangesHandler, applySyncBatchHandler, translator)
|
||||
|
||||
router := httpInfra.NewRouter(cfg.AppURL, habitHandlers, authHandlers, statsHandlers, healthHandlers, userHandlers, jwtService, translator)
|
||||
router := httpInfra.NewRouter(cfg.AppURL, habitHandlers, authHandlers, statsHandlers, healthHandlers, userHandlers, exportHandlers, syncHandlers, jwtService, translator)
|
||||
|
||||
addr := fmt.Sprintf("0.0.0.0:%s", cfg.Port)
|
||||
log.Printf("Server starting on %s", addr)
|
||||
|
||||
if err := http.ListenAndServe(addr, router); err != nil {
|
||||
log.Fatalf("Server failed: %v", err)
|
||||
server := &http.Server{
|
||||
Addr: addr,
|
||||
Handler: router,
|
||||
}
|
||||
|
||||
shutdown := make(chan os.Signal, 1)
|
||||
signal.Notify(shutdown, syscall.SIGINT, syscall.SIGTERM)
|
||||
|
||||
go func() {
|
||||
logger.Info().Str("address", addr).Msg("Server starting")
|
||||
if err := server.ListenAndServe(); err != nil && err != http.ErrServerClosed {
|
||||
logger.Fatal().Err(err).Msg("Server failed")
|
||||
}
|
||||
}()
|
||||
|
||||
<-shutdown
|
||||
logger.Info().Msg("Shutting down gracefully...")
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
defer cancel()
|
||||
|
||||
if err := server.Shutdown(ctx); err != nil {
|
||||
logger.Fatal().Err(err).Msg("Server forced to shutdown")
|
||||
}
|
||||
|
||||
logger.Info().Msg("Server stopped")
|
||||
}
|
||||
|
||||
func parseJWTExpiry(expiry string) (int, error) {
|
||||
|
||||
+850
-16
@@ -24,6 +24,67 @@ const docTemplate = `{
|
||||
"host": "{{.Host}}",
|
||||
"basePath": "{{.BasePath}}",
|
||||
"paths": {
|
||||
"/auth/forgot-password": {
|
||||
"post": {
|
||||
"description": "Request a password reset email with a reset token",
|
||||
"consumes": [
|
||||
"application/json"
|
||||
],
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
"tags": [
|
||||
"auth"
|
||||
],
|
||||
"summary": "Request password reset",
|
||||
"parameters": [
|
||||
{
|
||||
"description": "User email",
|
||||
"name": "request",
|
||||
"in": "body",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"$ref": "#/definitions/http.ForgotPasswordRequest"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Reset email sent successfully",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"additionalProperties": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"description": "Invalid email",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/http.ErrorResponse"
|
||||
}
|
||||
},
|
||||
"403": {
|
||||
"description": "Email not verified",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/http.ErrorResponse"
|
||||
}
|
||||
},
|
||||
"404": {
|
||||
"description": "User not found",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/http.ErrorResponse"
|
||||
}
|
||||
},
|
||||
"500": {
|
||||
"description": "Internal server error",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/http.ErrorResponse"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/auth/login": {
|
||||
"post": {
|
||||
"description": "Authenticate user with email and password. Returns both access token and refresh token. The access token is used for API requests, the refresh token is used to obtain new access tokens when they expire.",
|
||||
@@ -67,6 +128,12 @@ const docTemplate = `{
|
||||
"$ref": "#/definitions/http.ErrorResponse"
|
||||
}
|
||||
},
|
||||
"403": {
|
||||
"description": "Email not verified",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/http.ErrorResponse"
|
||||
}
|
||||
},
|
||||
"500": {
|
||||
"description": "Internal server error",
|
||||
"schema": {
|
||||
@@ -185,7 +252,7 @@ const docTemplate = `{
|
||||
},
|
||||
"/auth/register": {
|
||||
"post": {
|
||||
"description": "Create a new user account and receive both access token and refresh token. Store both tokens securely - the refresh token is used to obtain new access tokens when they expire.",
|
||||
"description": "Create a new user account. If email verification is enabled, you will receive a verification email. Otherwise, you can login immediately.",
|
||||
"consumes": [
|
||||
"application/json"
|
||||
],
|
||||
@@ -209,13 +276,19 @@ const docTemplate = `{
|
||||
],
|
||||
"responses": {
|
||||
"201": {
|
||||
"description": "Returns access token, refresh token, and user ID",
|
||||
"description": "Returns user ID and message about next steps",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/http.AuthResponse"
|
||||
"$ref": "#/definitions/http.RegisterResponse"
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"description": "Invalid input: email format, password requirements, or timezone",
|
||||
"description": "Invalid input: email format or password requirements",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/http.ValidationErrorResponse"
|
||||
}
|
||||
},
|
||||
"403": {
|
||||
"description": "Registration is closed",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/http.ErrorResponse"
|
||||
}
|
||||
@@ -235,6 +308,220 @@ const docTemplate = `{
|
||||
}
|
||||
}
|
||||
},
|
||||
"/auth/resend-verification": {
|
||||
"post": {
|
||||
"description": "Resend the email verification link to the user's email address",
|
||||
"consumes": [
|
||||
"application/json"
|
||||
],
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
"tags": [
|
||||
"auth"
|
||||
],
|
||||
"summary": "Resend verification email",
|
||||
"parameters": [
|
||||
{
|
||||
"description": "User email",
|
||||
"name": "request",
|
||||
"in": "body",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"$ref": "#/definitions/http.ResendVerificationRequest"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Verification email sent",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"additionalProperties": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"description": "Invalid email",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/http.ErrorResponse"
|
||||
}
|
||||
},
|
||||
"404": {
|
||||
"description": "User not found",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/http.ErrorResponse"
|
||||
}
|
||||
},
|
||||
"409": {
|
||||
"description": "Email already verified",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/http.ErrorResponse"
|
||||
}
|
||||
},
|
||||
"500": {
|
||||
"description": "Internal server error",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/http.ErrorResponse"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/auth/reset-password": {
|
||||
"post": {
|
||||
"description": "Reset user password using the reset token from email",
|
||||
"consumes": [
|
||||
"application/json"
|
||||
],
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
"tags": [
|
||||
"auth"
|
||||
],
|
||||
"summary": "Reset password",
|
||||
"parameters": [
|
||||
{
|
||||
"description": "Reset token and new password",
|
||||
"name": "request",
|
||||
"in": "body",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"$ref": "#/definitions/http.ResetPasswordRequest"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Password reset successfully",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"additionalProperties": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"description": "Invalid token or password requirements not met",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/http.ErrorResponse"
|
||||
}
|
||||
},
|
||||
"404": {
|
||||
"description": "User not found",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/http.ErrorResponse"
|
||||
}
|
||||
},
|
||||
"500": {
|
||||
"description": "Internal server error",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/http.ErrorResponse"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/auth/verify-email": {
|
||||
"post": {
|
||||
"description": "Verify user email address using the token sent via email",
|
||||
"consumes": [
|
||||
"application/json"
|
||||
],
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
"tags": [
|
||||
"auth"
|
||||
],
|
||||
"summary": "Verify email address",
|
||||
"parameters": [
|
||||
{
|
||||
"description": "Verification token",
|
||||
"name": "request",
|
||||
"in": "body",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"$ref": "#/definitions/http.VerifyEmailRequest"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Email verified successfully",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"additionalProperties": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"description": "Invalid or expired token",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/http.ErrorResponse"
|
||||
}
|
||||
},
|
||||
"409": {
|
||||
"description": "Email already verified",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/http.ErrorResponse"
|
||||
}
|
||||
},
|
||||
"500": {
|
||||
"description": "Internal server error",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/http.ErrorResponse"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/export": {
|
||||
"get": {
|
||||
"security": [
|
||||
{
|
||||
"BearerAuth": []
|
||||
}
|
||||
],
|
||||
"description": "Export all user habits and entries in JSON format with gzip compression. Limited to 1 export per hour.",
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
"tags": [
|
||||
"export"
|
||||
],
|
||||
"summary": "Export user data",
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Compressed JSON export",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/queries.ExportUserDataResult"
|
||||
}
|
||||
},
|
||||
"401": {
|
||||
"description": "Unauthorized",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/http.ErrorResponse"
|
||||
}
|
||||
},
|
||||
"429": {
|
||||
"description": "Rate limit exceeded",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/http.ErrorResponse"
|
||||
}
|
||||
},
|
||||
"500": {
|
||||
"description": "Internal server error",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/http.ErrorResponse"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/habits": {
|
||||
"get": {
|
||||
"security": [
|
||||
@@ -242,7 +529,7 @@ const docTemplate = `{
|
||||
"BearerAuth": []
|
||||
}
|
||||
],
|
||||
"description": "Get all active habits for the authenticated user",
|
||||
"description": "Get all active habits for the authenticated user with optional pagination and filters",
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
@@ -250,14 +537,49 @@ const docTemplate = `{
|
||||
"habits"
|
||||
],
|
||||
"summary": "Get all user habits",
|
||||
"parameters": [
|
||||
{
|
||||
"type": "integer",
|
||||
"description": "Page number (default: 1)",
|
||||
"name": "page",
|
||||
"in": "query"
|
||||
},
|
||||
{
|
||||
"type": "integer",
|
||||
"description": "Page size (default: 50, max: 100)",
|
||||
"name": "page_size",
|
||||
"in": "query"
|
||||
},
|
||||
{
|
||||
"type": "string",
|
||||
"description": "Filter by type (BOOLEAN, COUNTER, VALUE)",
|
||||
"name": "type",
|
||||
"in": "query"
|
||||
},
|
||||
{
|
||||
"type": "string",
|
||||
"description": "Filter by frequency (DAILY, WEEKLY, MONTHLY)",
|
||||
"name": "frequency",
|
||||
"in": "query"
|
||||
},
|
||||
{
|
||||
"type": "boolean",
|
||||
"description": "Include archived habits (default: false)",
|
||||
"name": "archived",
|
||||
"in": "query"
|
||||
},
|
||||
{
|
||||
"type": "string",
|
||||
"description": "Search by name or description",
|
||||
"name": "search",
|
||||
"in": "query"
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK",
|
||||
"schema": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/definitions/http.UserHabitResponse"
|
||||
}
|
||||
"$ref": "#/definitions/http.GetUserHabitsResponse"
|
||||
}
|
||||
},
|
||||
"401": {
|
||||
@@ -340,7 +662,7 @@ const docTemplate = `{
|
||||
"BearerAuth": []
|
||||
}
|
||||
],
|
||||
"description": "Get all habits scheduled for today for the authenticated user",
|
||||
"description": "Get all habits scheduled for today for the authenticated user. Includes the entry for today if it exists. Requires timezone as query parameter (e.g., ?timezone=America/New_York).",
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
@@ -348,6 +670,15 @@ const docTemplate = `{
|
||||
"habits"
|
||||
],
|
||||
"summary": "Get today's habits",
|
||||
"parameters": [
|
||||
{
|
||||
"type": "string",
|
||||
"description": "IANA timezone (e.g., 'America/New_York', 'Europe/Madrid', 'UTC')",
|
||||
"name": "timezone",
|
||||
"in": "query",
|
||||
"required": true
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK",
|
||||
@@ -358,6 +689,12 @@ const docTemplate = `{
|
||||
}
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"description": "Invalid or missing timezone",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/http.ErrorResponse"
|
||||
}
|
||||
},
|
||||
"401": {
|
||||
"description": "Unauthorized",
|
||||
"schema": {
|
||||
@@ -835,7 +1172,7 @@ const docTemplate = `{
|
||||
"BearerAuth": []
|
||||
}
|
||||
],
|
||||
"description": "Get statistics for a specific habit including streaks and completion rates",
|
||||
"description": "Get statistics for a specific habit including streaks and completions",
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
@@ -885,6 +1222,167 @@ const docTemplate = `{
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/sync/batch": {
|
||||
"post": {
|
||||
"security": [
|
||||
{
|
||||
"BearerAuth": []
|
||||
}
|
||||
],
|
||||
"description": "Apply a batch of changes from the client for offline sync (Last-Write-Wins)",
|
||||
"consumes": [
|
||||
"application/json"
|
||||
],
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
"tags": [
|
||||
"sync"
|
||||
],
|
||||
"summary": "Apply sync batch",
|
||||
"parameters": [
|
||||
{
|
||||
"description": "Sync batch data",
|
||||
"name": "request",
|
||||
"in": "body",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"$ref": "#/definitions/http.SyncBatchRequest"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"additionalProperties": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"description": "Bad Request",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/http.ErrorResponse"
|
||||
}
|
||||
},
|
||||
"401": {
|
||||
"description": "Unauthorized",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/http.ErrorResponse"
|
||||
}
|
||||
},
|
||||
"500": {
|
||||
"description": "Internal Server Error",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/http.ErrorResponse"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/sync/changes": {
|
||||
"get": {
|
||||
"security": [
|
||||
{
|
||||
"BearerAuth": []
|
||||
}
|
||||
],
|
||||
"description": "Get all changes (habits and entries) since a given timestamp for offline sync",
|
||||
"consumes": [
|
||||
"application/json"
|
||||
],
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
"tags": [
|
||||
"sync"
|
||||
],
|
||||
"summary": "Get sync changes",
|
||||
"parameters": [
|
||||
{
|
||||
"type": "string",
|
||||
"description": "ISO 8601 timestamp (e.g., 2025-01-01T00:00:00Z)",
|
||||
"name": "since",
|
||||
"in": "query",
|
||||
"required": true
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/http.SyncChangesResponse"
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"description": "Bad Request",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/http.ErrorResponse"
|
||||
}
|
||||
},
|
||||
"401": {
|
||||
"description": "Unauthorized",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/http.ErrorResponse"
|
||||
}
|
||||
},
|
||||
"500": {
|
||||
"description": "Internal Server Error",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/http.ErrorResponse"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/users/me": {
|
||||
"delete": {
|
||||
"security": [
|
||||
{
|
||||
"BearerAuth": []
|
||||
}
|
||||
],
|
||||
"description": "Permanently delete the authenticated user's account and all associated data (habits, entries, tokens). This action cannot be undone.",
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
"tags": [
|
||||
"users"
|
||||
],
|
||||
"summary": "Delete user account",
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Account deleted successfully",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"additionalProperties": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"401": {
|
||||
"description": "Unauthorized - invalid or missing token",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/http.ErrorResponse"
|
||||
}
|
||||
},
|
||||
"404": {
|
||||
"description": "User not found",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/http.ErrorResponse"
|
||||
}
|
||||
},
|
||||
"500": {
|
||||
"description": "Internal server error",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/http.ErrorResponse"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"definitions": {
|
||||
@@ -940,6 +1438,29 @@ const docTemplate = `{
|
||||
}
|
||||
}
|
||||
},
|
||||
"http.EntryChangesDTO": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"created": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/definitions/http.SyncHabitEntryDTO"
|
||||
}
|
||||
},
|
||||
"deleted": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"updated": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/definitions/http.SyncHabitEntryDTO"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"http.ErrorResponse": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
@@ -948,6 +1469,51 @@ const docTemplate = `{
|
||||
}
|
||||
}
|
||||
},
|
||||
"http.ForgotPasswordRequest": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"email": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"http.GetUserHabitsResponse": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"data": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/definitions/http.UserHabitResponse"
|
||||
}
|
||||
},
|
||||
"pagination": {
|
||||
"$ref": "#/definitions/pagination.Response"
|
||||
}
|
||||
}
|
||||
},
|
||||
"http.HabitChangesDTO": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"created": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/definitions/http.SyncHabitDTO"
|
||||
}
|
||||
},
|
||||
"deleted": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"updated": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/definitions/http.SyncHabitDTO"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"http.HabitEntriesResponse": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
@@ -994,6 +1560,9 @@ const docTemplate = `{
|
||||
"database": {
|
||||
"type": "string"
|
||||
},
|
||||
"smtp": {
|
||||
"type": "string"
|
||||
},
|
||||
"status": {
|
||||
"type": "string"
|
||||
},
|
||||
@@ -1048,15 +1617,157 @@ const docTemplate = `{
|
||||
},
|
||||
"password": {
|
||||
"type": "string"
|
||||
},
|
||||
"timezone": {
|
||||
}
|
||||
}
|
||||
},
|
||||
"http.RegisterResponse": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"message": {
|
||||
"type": "string"
|
||||
},
|
||||
"user_id": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"http.ResendVerificationRequest": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"email": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"http.ResetPasswordRequest": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"new_password": {
|
||||
"type": "string"
|
||||
},
|
||||
"token": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"http.SyncBatchRequest": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"entries": {
|
||||
"$ref": "#/definitions/http.EntryChangesDTO"
|
||||
},
|
||||
"habits": {
|
||||
"$ref": "#/definitions/http.HabitChangesDTO"
|
||||
}
|
||||
}
|
||||
},
|
||||
"http.SyncChangesResponse": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"entries": {
|
||||
"$ref": "#/definitions/http.EntryChangesDTO"
|
||||
},
|
||||
"habits": {
|
||||
"$ref": "#/definitions/http.HabitChangesDTO"
|
||||
}
|
||||
}
|
||||
},
|
||||
"http.SyncHabitDTO": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"archived_at": {
|
||||
"type": "string"
|
||||
},
|
||||
"carry_over": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"created_at": {
|
||||
"type": "string"
|
||||
},
|
||||
"description": {
|
||||
"type": "string"
|
||||
},
|
||||
"frequency": {
|
||||
"$ref": "#/definitions/value_objects.Frequency"
|
||||
},
|
||||
"id": {
|
||||
"type": "string"
|
||||
},
|
||||
"is_negative": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"name": {
|
||||
"type": "string"
|
||||
},
|
||||
"specific_dates": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "integer"
|
||||
}
|
||||
},
|
||||
"specific_days": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "integer"
|
||||
}
|
||||
},
|
||||
"target_value": {
|
||||
"type": "number"
|
||||
},
|
||||
"type": {
|
||||
"$ref": "#/definitions/value_objects.HabitType"
|
||||
},
|
||||
"updated_at": {
|
||||
"type": "string"
|
||||
},
|
||||
"user_id": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"http.SyncHabitEntryDTO": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"completed_at": {
|
||||
"type": "string"
|
||||
},
|
||||
"habit_id": {
|
||||
"type": "string"
|
||||
},
|
||||
"id": {
|
||||
"type": "string"
|
||||
},
|
||||
"scheduled_date": {
|
||||
"type": "string"
|
||||
},
|
||||
"updated_at": {
|
||||
"type": "string"
|
||||
},
|
||||
"value": {
|
||||
"type": "number"
|
||||
}
|
||||
}
|
||||
},
|
||||
"http.TodaysHabitEntryResponse": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"completed_at": {
|
||||
"type": "string"
|
||||
},
|
||||
"id": {
|
||||
"type": "string"
|
||||
},
|
||||
"value": {
|
||||
"type": "number"
|
||||
}
|
||||
}
|
||||
},
|
||||
"http.TodaysHabitResponse": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"entry": {
|
||||
"$ref": "#/definitions/http.TodaysHabitEntryResponse"
|
||||
},
|
||||
"id": {
|
||||
"type": "string"
|
||||
},
|
||||
@@ -1089,6 +1800,9 @@ const docTemplate = `{
|
||||
"description": {
|
||||
"type": "string"
|
||||
},
|
||||
"frequency": {
|
||||
"$ref": "#/definitions/value_objects.Frequency"
|
||||
},
|
||||
"name": {
|
||||
"type": "string"
|
||||
},
|
||||
@@ -1141,12 +1855,132 @@ const docTemplate = `{
|
||||
}
|
||||
}
|
||||
},
|
||||
"http.ValidationErrorResponse": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"error": {
|
||||
"type": "string"
|
||||
},
|
||||
"field": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"http.VerifyEmailRequest": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"token": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"pagination.Response": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"page": {
|
||||
"type": "integer"
|
||||
},
|
||||
"page_size": {
|
||||
"type": "integer"
|
||||
},
|
||||
"total_items": {
|
||||
"type": "integer"
|
||||
},
|
||||
"total_pages": {
|
||||
"type": "integer"
|
||||
}
|
||||
}
|
||||
},
|
||||
"queries.ExportEntryDTO": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"completed_at": {
|
||||
"type": "string"
|
||||
},
|
||||
"habit_id": {
|
||||
"type": "string"
|
||||
},
|
||||
"id": {
|
||||
"type": "string"
|
||||
},
|
||||
"scheduled_date": {
|
||||
"type": "string"
|
||||
},
|
||||
"value": {
|
||||
"type": "number"
|
||||
}
|
||||
}
|
||||
},
|
||||
"queries.ExportHabitDTO": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"archived_at": {
|
||||
"type": "string"
|
||||
},
|
||||
"carry_over": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"created_at": {
|
||||
"type": "string"
|
||||
},
|
||||
"description": {
|
||||
"type": "string"
|
||||
},
|
||||
"frequency": {
|
||||
"$ref": "#/definitions/value_objects.Frequency"
|
||||
},
|
||||
"id": {
|
||||
"type": "string"
|
||||
},
|
||||
"is_negative": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"name": {
|
||||
"type": "string"
|
||||
},
|
||||
"specific_dates": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "integer"
|
||||
}
|
||||
},
|
||||
"specific_days": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "integer"
|
||||
}
|
||||
},
|
||||
"target_value": {
|
||||
"type": "number"
|
||||
},
|
||||
"type": {
|
||||
"$ref": "#/definitions/value_objects.HabitType"
|
||||
}
|
||||
}
|
||||
},
|
||||
"queries.ExportUserDataResult": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"entries": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/definitions/queries.ExportEntryDTO"
|
||||
}
|
||||
},
|
||||
"exported_at": {
|
||||
"type": "string"
|
||||
},
|
||||
"habits": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/definitions/queries.ExportHabitDTO"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"queries.HabitStatsDTO": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"completion_rate": {
|
||||
"type": "number"
|
||||
},
|
||||
"completions_this_month": {
|
||||
"type": "integer"
|
||||
},
|
||||
|
||||
+850
-16
@@ -16,6 +16,67 @@
|
||||
},
|
||||
"basePath": "/api/v1",
|
||||
"paths": {
|
||||
"/auth/forgot-password": {
|
||||
"post": {
|
||||
"description": "Request a password reset email with a reset token",
|
||||
"consumes": [
|
||||
"application/json"
|
||||
],
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
"tags": [
|
||||
"auth"
|
||||
],
|
||||
"summary": "Request password reset",
|
||||
"parameters": [
|
||||
{
|
||||
"description": "User email",
|
||||
"name": "request",
|
||||
"in": "body",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"$ref": "#/definitions/http.ForgotPasswordRequest"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Reset email sent successfully",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"additionalProperties": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"description": "Invalid email",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/http.ErrorResponse"
|
||||
}
|
||||
},
|
||||
"403": {
|
||||
"description": "Email not verified",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/http.ErrorResponse"
|
||||
}
|
||||
},
|
||||
"404": {
|
||||
"description": "User not found",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/http.ErrorResponse"
|
||||
}
|
||||
},
|
||||
"500": {
|
||||
"description": "Internal server error",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/http.ErrorResponse"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/auth/login": {
|
||||
"post": {
|
||||
"description": "Authenticate user with email and password. Returns both access token and refresh token. The access token is used for API requests, the refresh token is used to obtain new access tokens when they expire.",
|
||||
@@ -59,6 +120,12 @@
|
||||
"$ref": "#/definitions/http.ErrorResponse"
|
||||
}
|
||||
},
|
||||
"403": {
|
||||
"description": "Email not verified",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/http.ErrorResponse"
|
||||
}
|
||||
},
|
||||
"500": {
|
||||
"description": "Internal server error",
|
||||
"schema": {
|
||||
@@ -177,7 +244,7 @@
|
||||
},
|
||||
"/auth/register": {
|
||||
"post": {
|
||||
"description": "Create a new user account and receive both access token and refresh token. Store both tokens securely - the refresh token is used to obtain new access tokens when they expire.",
|
||||
"description": "Create a new user account. If email verification is enabled, you will receive a verification email. Otherwise, you can login immediately.",
|
||||
"consumes": [
|
||||
"application/json"
|
||||
],
|
||||
@@ -201,13 +268,19 @@
|
||||
],
|
||||
"responses": {
|
||||
"201": {
|
||||
"description": "Returns access token, refresh token, and user ID",
|
||||
"description": "Returns user ID and message about next steps",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/http.AuthResponse"
|
||||
"$ref": "#/definitions/http.RegisterResponse"
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"description": "Invalid input: email format, password requirements, or timezone",
|
||||
"description": "Invalid input: email format or password requirements",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/http.ValidationErrorResponse"
|
||||
}
|
||||
},
|
||||
"403": {
|
||||
"description": "Registration is closed",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/http.ErrorResponse"
|
||||
}
|
||||
@@ -227,6 +300,220 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"/auth/resend-verification": {
|
||||
"post": {
|
||||
"description": "Resend the email verification link to the user's email address",
|
||||
"consumes": [
|
||||
"application/json"
|
||||
],
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
"tags": [
|
||||
"auth"
|
||||
],
|
||||
"summary": "Resend verification email",
|
||||
"parameters": [
|
||||
{
|
||||
"description": "User email",
|
||||
"name": "request",
|
||||
"in": "body",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"$ref": "#/definitions/http.ResendVerificationRequest"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Verification email sent",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"additionalProperties": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"description": "Invalid email",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/http.ErrorResponse"
|
||||
}
|
||||
},
|
||||
"404": {
|
||||
"description": "User not found",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/http.ErrorResponse"
|
||||
}
|
||||
},
|
||||
"409": {
|
||||
"description": "Email already verified",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/http.ErrorResponse"
|
||||
}
|
||||
},
|
||||
"500": {
|
||||
"description": "Internal server error",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/http.ErrorResponse"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/auth/reset-password": {
|
||||
"post": {
|
||||
"description": "Reset user password using the reset token from email",
|
||||
"consumes": [
|
||||
"application/json"
|
||||
],
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
"tags": [
|
||||
"auth"
|
||||
],
|
||||
"summary": "Reset password",
|
||||
"parameters": [
|
||||
{
|
||||
"description": "Reset token and new password",
|
||||
"name": "request",
|
||||
"in": "body",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"$ref": "#/definitions/http.ResetPasswordRequest"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Password reset successfully",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"additionalProperties": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"description": "Invalid token or password requirements not met",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/http.ErrorResponse"
|
||||
}
|
||||
},
|
||||
"404": {
|
||||
"description": "User not found",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/http.ErrorResponse"
|
||||
}
|
||||
},
|
||||
"500": {
|
||||
"description": "Internal server error",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/http.ErrorResponse"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/auth/verify-email": {
|
||||
"post": {
|
||||
"description": "Verify user email address using the token sent via email",
|
||||
"consumes": [
|
||||
"application/json"
|
||||
],
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
"tags": [
|
||||
"auth"
|
||||
],
|
||||
"summary": "Verify email address",
|
||||
"parameters": [
|
||||
{
|
||||
"description": "Verification token",
|
||||
"name": "request",
|
||||
"in": "body",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"$ref": "#/definitions/http.VerifyEmailRequest"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Email verified successfully",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"additionalProperties": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"description": "Invalid or expired token",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/http.ErrorResponse"
|
||||
}
|
||||
},
|
||||
"409": {
|
||||
"description": "Email already verified",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/http.ErrorResponse"
|
||||
}
|
||||
},
|
||||
"500": {
|
||||
"description": "Internal server error",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/http.ErrorResponse"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/export": {
|
||||
"get": {
|
||||
"security": [
|
||||
{
|
||||
"BearerAuth": []
|
||||
}
|
||||
],
|
||||
"description": "Export all user habits and entries in JSON format with gzip compression. Limited to 1 export per hour.",
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
"tags": [
|
||||
"export"
|
||||
],
|
||||
"summary": "Export user data",
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Compressed JSON export",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/queries.ExportUserDataResult"
|
||||
}
|
||||
},
|
||||
"401": {
|
||||
"description": "Unauthorized",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/http.ErrorResponse"
|
||||
}
|
||||
},
|
||||
"429": {
|
||||
"description": "Rate limit exceeded",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/http.ErrorResponse"
|
||||
}
|
||||
},
|
||||
"500": {
|
||||
"description": "Internal server error",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/http.ErrorResponse"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/habits": {
|
||||
"get": {
|
||||
"security": [
|
||||
@@ -234,7 +521,7 @@
|
||||
"BearerAuth": []
|
||||
}
|
||||
],
|
||||
"description": "Get all active habits for the authenticated user",
|
||||
"description": "Get all active habits for the authenticated user with optional pagination and filters",
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
@@ -242,14 +529,49 @@
|
||||
"habits"
|
||||
],
|
||||
"summary": "Get all user habits",
|
||||
"parameters": [
|
||||
{
|
||||
"type": "integer",
|
||||
"description": "Page number (default: 1)",
|
||||
"name": "page",
|
||||
"in": "query"
|
||||
},
|
||||
{
|
||||
"type": "integer",
|
||||
"description": "Page size (default: 50, max: 100)",
|
||||
"name": "page_size",
|
||||
"in": "query"
|
||||
},
|
||||
{
|
||||
"type": "string",
|
||||
"description": "Filter by type (BOOLEAN, COUNTER, VALUE)",
|
||||
"name": "type",
|
||||
"in": "query"
|
||||
},
|
||||
{
|
||||
"type": "string",
|
||||
"description": "Filter by frequency (DAILY, WEEKLY, MONTHLY)",
|
||||
"name": "frequency",
|
||||
"in": "query"
|
||||
},
|
||||
{
|
||||
"type": "boolean",
|
||||
"description": "Include archived habits (default: false)",
|
||||
"name": "archived",
|
||||
"in": "query"
|
||||
},
|
||||
{
|
||||
"type": "string",
|
||||
"description": "Search by name or description",
|
||||
"name": "search",
|
||||
"in": "query"
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK",
|
||||
"schema": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/definitions/http.UserHabitResponse"
|
||||
}
|
||||
"$ref": "#/definitions/http.GetUserHabitsResponse"
|
||||
}
|
||||
},
|
||||
"401": {
|
||||
@@ -332,7 +654,7 @@
|
||||
"BearerAuth": []
|
||||
}
|
||||
],
|
||||
"description": "Get all habits scheduled for today for the authenticated user",
|
||||
"description": "Get all habits scheduled for today for the authenticated user. Includes the entry for today if it exists. Requires timezone as query parameter (e.g., ?timezone=America/New_York).",
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
@@ -340,6 +662,15 @@
|
||||
"habits"
|
||||
],
|
||||
"summary": "Get today's habits",
|
||||
"parameters": [
|
||||
{
|
||||
"type": "string",
|
||||
"description": "IANA timezone (e.g., 'America/New_York', 'Europe/Madrid', 'UTC')",
|
||||
"name": "timezone",
|
||||
"in": "query",
|
||||
"required": true
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK",
|
||||
@@ -350,6 +681,12 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"description": "Invalid or missing timezone",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/http.ErrorResponse"
|
||||
}
|
||||
},
|
||||
"401": {
|
||||
"description": "Unauthorized",
|
||||
"schema": {
|
||||
@@ -827,7 +1164,7 @@
|
||||
"BearerAuth": []
|
||||
}
|
||||
],
|
||||
"description": "Get statistics for a specific habit including streaks and completion rates",
|
||||
"description": "Get statistics for a specific habit including streaks and completions",
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
@@ -877,6 +1214,167 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/sync/batch": {
|
||||
"post": {
|
||||
"security": [
|
||||
{
|
||||
"BearerAuth": []
|
||||
}
|
||||
],
|
||||
"description": "Apply a batch of changes from the client for offline sync (Last-Write-Wins)",
|
||||
"consumes": [
|
||||
"application/json"
|
||||
],
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
"tags": [
|
||||
"sync"
|
||||
],
|
||||
"summary": "Apply sync batch",
|
||||
"parameters": [
|
||||
{
|
||||
"description": "Sync batch data",
|
||||
"name": "request",
|
||||
"in": "body",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"$ref": "#/definitions/http.SyncBatchRequest"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"additionalProperties": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"description": "Bad Request",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/http.ErrorResponse"
|
||||
}
|
||||
},
|
||||
"401": {
|
||||
"description": "Unauthorized",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/http.ErrorResponse"
|
||||
}
|
||||
},
|
||||
"500": {
|
||||
"description": "Internal Server Error",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/http.ErrorResponse"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/sync/changes": {
|
||||
"get": {
|
||||
"security": [
|
||||
{
|
||||
"BearerAuth": []
|
||||
}
|
||||
],
|
||||
"description": "Get all changes (habits and entries) since a given timestamp for offline sync",
|
||||
"consumes": [
|
||||
"application/json"
|
||||
],
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
"tags": [
|
||||
"sync"
|
||||
],
|
||||
"summary": "Get sync changes",
|
||||
"parameters": [
|
||||
{
|
||||
"type": "string",
|
||||
"description": "ISO 8601 timestamp (e.g., 2025-01-01T00:00:00Z)",
|
||||
"name": "since",
|
||||
"in": "query",
|
||||
"required": true
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/http.SyncChangesResponse"
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"description": "Bad Request",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/http.ErrorResponse"
|
||||
}
|
||||
},
|
||||
"401": {
|
||||
"description": "Unauthorized",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/http.ErrorResponse"
|
||||
}
|
||||
},
|
||||
"500": {
|
||||
"description": "Internal Server Error",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/http.ErrorResponse"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/users/me": {
|
||||
"delete": {
|
||||
"security": [
|
||||
{
|
||||
"BearerAuth": []
|
||||
}
|
||||
],
|
||||
"description": "Permanently delete the authenticated user's account and all associated data (habits, entries, tokens). This action cannot be undone.",
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
"tags": [
|
||||
"users"
|
||||
],
|
||||
"summary": "Delete user account",
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Account deleted successfully",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"additionalProperties": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"401": {
|
||||
"description": "Unauthorized - invalid or missing token",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/http.ErrorResponse"
|
||||
}
|
||||
},
|
||||
"404": {
|
||||
"description": "User not found",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/http.ErrorResponse"
|
||||
}
|
||||
},
|
||||
"500": {
|
||||
"description": "Internal server error",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/http.ErrorResponse"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"definitions": {
|
||||
@@ -932,6 +1430,29 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"http.EntryChangesDTO": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"created": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/definitions/http.SyncHabitEntryDTO"
|
||||
}
|
||||
},
|
||||
"deleted": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"updated": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/definitions/http.SyncHabitEntryDTO"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"http.ErrorResponse": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
@@ -940,6 +1461,51 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"http.ForgotPasswordRequest": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"email": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"http.GetUserHabitsResponse": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"data": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/definitions/http.UserHabitResponse"
|
||||
}
|
||||
},
|
||||
"pagination": {
|
||||
"$ref": "#/definitions/pagination.Response"
|
||||
}
|
||||
}
|
||||
},
|
||||
"http.HabitChangesDTO": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"created": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/definitions/http.SyncHabitDTO"
|
||||
}
|
||||
},
|
||||
"deleted": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"updated": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/definitions/http.SyncHabitDTO"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"http.HabitEntriesResponse": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
@@ -986,6 +1552,9 @@
|
||||
"database": {
|
||||
"type": "string"
|
||||
},
|
||||
"smtp": {
|
||||
"type": "string"
|
||||
},
|
||||
"status": {
|
||||
"type": "string"
|
||||
},
|
||||
@@ -1040,15 +1609,157 @@
|
||||
},
|
||||
"password": {
|
||||
"type": "string"
|
||||
},
|
||||
"timezone": {
|
||||
}
|
||||
}
|
||||
},
|
||||
"http.RegisterResponse": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"message": {
|
||||
"type": "string"
|
||||
},
|
||||
"user_id": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"http.ResendVerificationRequest": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"email": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"http.ResetPasswordRequest": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"new_password": {
|
||||
"type": "string"
|
||||
},
|
||||
"token": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"http.SyncBatchRequest": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"entries": {
|
||||
"$ref": "#/definitions/http.EntryChangesDTO"
|
||||
},
|
||||
"habits": {
|
||||
"$ref": "#/definitions/http.HabitChangesDTO"
|
||||
}
|
||||
}
|
||||
},
|
||||
"http.SyncChangesResponse": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"entries": {
|
||||
"$ref": "#/definitions/http.EntryChangesDTO"
|
||||
},
|
||||
"habits": {
|
||||
"$ref": "#/definitions/http.HabitChangesDTO"
|
||||
}
|
||||
}
|
||||
},
|
||||
"http.SyncHabitDTO": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"archived_at": {
|
||||
"type": "string"
|
||||
},
|
||||
"carry_over": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"created_at": {
|
||||
"type": "string"
|
||||
},
|
||||
"description": {
|
||||
"type": "string"
|
||||
},
|
||||
"frequency": {
|
||||
"$ref": "#/definitions/value_objects.Frequency"
|
||||
},
|
||||
"id": {
|
||||
"type": "string"
|
||||
},
|
||||
"is_negative": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"name": {
|
||||
"type": "string"
|
||||
},
|
||||
"specific_dates": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "integer"
|
||||
}
|
||||
},
|
||||
"specific_days": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "integer"
|
||||
}
|
||||
},
|
||||
"target_value": {
|
||||
"type": "number"
|
||||
},
|
||||
"type": {
|
||||
"$ref": "#/definitions/value_objects.HabitType"
|
||||
},
|
||||
"updated_at": {
|
||||
"type": "string"
|
||||
},
|
||||
"user_id": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"http.SyncHabitEntryDTO": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"completed_at": {
|
||||
"type": "string"
|
||||
},
|
||||
"habit_id": {
|
||||
"type": "string"
|
||||
},
|
||||
"id": {
|
||||
"type": "string"
|
||||
},
|
||||
"scheduled_date": {
|
||||
"type": "string"
|
||||
},
|
||||
"updated_at": {
|
||||
"type": "string"
|
||||
},
|
||||
"value": {
|
||||
"type": "number"
|
||||
}
|
||||
}
|
||||
},
|
||||
"http.TodaysHabitEntryResponse": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"completed_at": {
|
||||
"type": "string"
|
||||
},
|
||||
"id": {
|
||||
"type": "string"
|
||||
},
|
||||
"value": {
|
||||
"type": "number"
|
||||
}
|
||||
}
|
||||
},
|
||||
"http.TodaysHabitResponse": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"entry": {
|
||||
"$ref": "#/definitions/http.TodaysHabitEntryResponse"
|
||||
},
|
||||
"id": {
|
||||
"type": "string"
|
||||
},
|
||||
@@ -1081,6 +1792,9 @@
|
||||
"description": {
|
||||
"type": "string"
|
||||
},
|
||||
"frequency": {
|
||||
"$ref": "#/definitions/value_objects.Frequency"
|
||||
},
|
||||
"name": {
|
||||
"type": "string"
|
||||
},
|
||||
@@ -1133,12 +1847,132 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"http.ValidationErrorResponse": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"error": {
|
||||
"type": "string"
|
||||
},
|
||||
"field": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"http.VerifyEmailRequest": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"token": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"pagination.Response": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"page": {
|
||||
"type": "integer"
|
||||
},
|
||||
"page_size": {
|
||||
"type": "integer"
|
||||
},
|
||||
"total_items": {
|
||||
"type": "integer"
|
||||
},
|
||||
"total_pages": {
|
||||
"type": "integer"
|
||||
}
|
||||
}
|
||||
},
|
||||
"queries.ExportEntryDTO": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"completed_at": {
|
||||
"type": "string"
|
||||
},
|
||||
"habit_id": {
|
||||
"type": "string"
|
||||
},
|
||||
"id": {
|
||||
"type": "string"
|
||||
},
|
||||
"scheduled_date": {
|
||||
"type": "string"
|
||||
},
|
||||
"value": {
|
||||
"type": "number"
|
||||
}
|
||||
}
|
||||
},
|
||||
"queries.ExportHabitDTO": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"archived_at": {
|
||||
"type": "string"
|
||||
},
|
||||
"carry_over": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"created_at": {
|
||||
"type": "string"
|
||||
},
|
||||
"description": {
|
||||
"type": "string"
|
||||
},
|
||||
"frequency": {
|
||||
"$ref": "#/definitions/value_objects.Frequency"
|
||||
},
|
||||
"id": {
|
||||
"type": "string"
|
||||
},
|
||||
"is_negative": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"name": {
|
||||
"type": "string"
|
||||
},
|
||||
"specific_dates": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "integer"
|
||||
}
|
||||
},
|
||||
"specific_days": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "integer"
|
||||
}
|
||||
},
|
||||
"target_value": {
|
||||
"type": "number"
|
||||
},
|
||||
"type": {
|
||||
"$ref": "#/definitions/value_objects.HabitType"
|
||||
}
|
||||
}
|
||||
},
|
||||
"queries.ExportUserDataResult": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"entries": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/definitions/queries.ExportEntryDTO"
|
||||
}
|
||||
},
|
||||
"exported_at": {
|
||||
"type": "string"
|
||||
},
|
||||
"habits": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/definitions/queries.ExportHabitDTO"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"queries.HabitStatsDTO": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"completion_rate": {
|
||||
"type": "number"
|
||||
},
|
||||
"completions_this_month": {
|
||||
"type": "integer"
|
||||
},
|
||||
|
||||
+560
-16
@@ -34,11 +34,55 @@ definitions:
|
||||
type:
|
||||
$ref: '#/definitions/value_objects.HabitType'
|
||||
type: object
|
||||
http.EntryChangesDTO:
|
||||
properties:
|
||||
created:
|
||||
items:
|
||||
$ref: '#/definitions/http.SyncHabitEntryDTO'
|
||||
type: array
|
||||
deleted:
|
||||
items:
|
||||
type: string
|
||||
type: array
|
||||
updated:
|
||||
items:
|
||||
$ref: '#/definitions/http.SyncHabitEntryDTO'
|
||||
type: array
|
||||
type: object
|
||||
http.ErrorResponse:
|
||||
properties:
|
||||
error:
|
||||
type: string
|
||||
type: object
|
||||
http.ForgotPasswordRequest:
|
||||
properties:
|
||||
email:
|
||||
type: string
|
||||
type: object
|
||||
http.GetUserHabitsResponse:
|
||||
properties:
|
||||
data:
|
||||
items:
|
||||
$ref: '#/definitions/http.UserHabitResponse'
|
||||
type: array
|
||||
pagination:
|
||||
$ref: '#/definitions/pagination.Response'
|
||||
type: object
|
||||
http.HabitChangesDTO:
|
||||
properties:
|
||||
created:
|
||||
items:
|
||||
$ref: '#/definitions/http.SyncHabitDTO'
|
||||
type: array
|
||||
deleted:
|
||||
items:
|
||||
type: string
|
||||
type: array
|
||||
updated:
|
||||
items:
|
||||
$ref: '#/definitions/http.SyncHabitDTO'
|
||||
type: array
|
||||
type: object
|
||||
http.HabitEntriesResponse:
|
||||
properties:
|
||||
entries:
|
||||
@@ -69,6 +113,8 @@ definitions:
|
||||
properties:
|
||||
database:
|
||||
type: string
|
||||
smtp:
|
||||
type: string
|
||||
status:
|
||||
type: string
|
||||
uptime:
|
||||
@@ -104,11 +150,103 @@ definitions:
|
||||
type: string
|
||||
password:
|
||||
type: string
|
||||
timezone:
|
||||
type: object
|
||||
http.RegisterResponse:
|
||||
properties:
|
||||
message:
|
||||
type: string
|
||||
user_id:
|
||||
type: string
|
||||
type: object
|
||||
http.ResendVerificationRequest:
|
||||
properties:
|
||||
email:
|
||||
type: string
|
||||
type: object
|
||||
http.ResetPasswordRequest:
|
||||
properties:
|
||||
new_password:
|
||||
type: string
|
||||
token:
|
||||
type: string
|
||||
type: object
|
||||
http.SyncBatchRequest:
|
||||
properties:
|
||||
entries:
|
||||
$ref: '#/definitions/http.EntryChangesDTO'
|
||||
habits:
|
||||
$ref: '#/definitions/http.HabitChangesDTO'
|
||||
type: object
|
||||
http.SyncChangesResponse:
|
||||
properties:
|
||||
entries:
|
||||
$ref: '#/definitions/http.EntryChangesDTO'
|
||||
habits:
|
||||
$ref: '#/definitions/http.HabitChangesDTO'
|
||||
type: object
|
||||
http.SyncHabitDTO:
|
||||
properties:
|
||||
archived_at:
|
||||
type: string
|
||||
carry_over:
|
||||
type: boolean
|
||||
created_at:
|
||||
type: string
|
||||
description:
|
||||
type: string
|
||||
frequency:
|
||||
$ref: '#/definitions/value_objects.Frequency'
|
||||
id:
|
||||
type: string
|
||||
is_negative:
|
||||
type: boolean
|
||||
name:
|
||||
type: string
|
||||
specific_dates:
|
||||
items:
|
||||
type: integer
|
||||
type: array
|
||||
specific_days:
|
||||
items:
|
||||
type: integer
|
||||
type: array
|
||||
target_value:
|
||||
type: number
|
||||
type:
|
||||
$ref: '#/definitions/value_objects.HabitType'
|
||||
updated_at:
|
||||
type: string
|
||||
user_id:
|
||||
type: string
|
||||
type: object
|
||||
http.SyncHabitEntryDTO:
|
||||
properties:
|
||||
completed_at:
|
||||
type: string
|
||||
habit_id:
|
||||
type: string
|
||||
id:
|
||||
type: string
|
||||
scheduled_date:
|
||||
type: string
|
||||
updated_at:
|
||||
type: string
|
||||
value:
|
||||
type: number
|
||||
type: object
|
||||
http.TodaysHabitEntryResponse:
|
||||
properties:
|
||||
completed_at:
|
||||
type: string
|
||||
id:
|
||||
type: string
|
||||
value:
|
||||
type: number
|
||||
type: object
|
||||
http.TodaysHabitResponse:
|
||||
properties:
|
||||
entry:
|
||||
$ref: '#/definitions/http.TodaysHabitEntryResponse'
|
||||
id:
|
||||
type: string
|
||||
is_carried_over:
|
||||
@@ -130,6 +268,8 @@ definitions:
|
||||
type: boolean
|
||||
description:
|
||||
type: string
|
||||
frequency:
|
||||
$ref: '#/definitions/value_objects.Frequency'
|
||||
name:
|
||||
type: string
|
||||
specific_dates:
|
||||
@@ -164,10 +304,88 @@ definitions:
|
||||
type:
|
||||
$ref: '#/definitions/value_objects.HabitType'
|
||||
type: object
|
||||
http.ValidationErrorResponse:
|
||||
properties:
|
||||
error:
|
||||
type: string
|
||||
field:
|
||||
type: string
|
||||
type: object
|
||||
http.VerifyEmailRequest:
|
||||
properties:
|
||||
token:
|
||||
type: string
|
||||
type: object
|
||||
pagination.Response:
|
||||
properties:
|
||||
page:
|
||||
type: integer
|
||||
page_size:
|
||||
type: integer
|
||||
total_items:
|
||||
type: integer
|
||||
total_pages:
|
||||
type: integer
|
||||
type: object
|
||||
queries.ExportEntryDTO:
|
||||
properties:
|
||||
completed_at:
|
||||
type: string
|
||||
habit_id:
|
||||
type: string
|
||||
id:
|
||||
type: string
|
||||
scheduled_date:
|
||||
type: string
|
||||
value:
|
||||
type: number
|
||||
type: object
|
||||
queries.ExportHabitDTO:
|
||||
properties:
|
||||
archived_at:
|
||||
type: string
|
||||
carry_over:
|
||||
type: boolean
|
||||
created_at:
|
||||
type: string
|
||||
description:
|
||||
type: string
|
||||
frequency:
|
||||
$ref: '#/definitions/value_objects.Frequency'
|
||||
id:
|
||||
type: string
|
||||
is_negative:
|
||||
type: boolean
|
||||
name:
|
||||
type: string
|
||||
specific_dates:
|
||||
items:
|
||||
type: integer
|
||||
type: array
|
||||
specific_days:
|
||||
items:
|
||||
type: integer
|
||||
type: array
|
||||
target_value:
|
||||
type: number
|
||||
type:
|
||||
$ref: '#/definitions/value_objects.HabitType'
|
||||
type: object
|
||||
queries.ExportUserDataResult:
|
||||
properties:
|
||||
entries:
|
||||
items:
|
||||
$ref: '#/definitions/queries.ExportEntryDTO'
|
||||
type: array
|
||||
exported_at:
|
||||
type: string
|
||||
habits:
|
||||
items:
|
||||
$ref: '#/definitions/queries.ExportHabitDTO'
|
||||
type: array
|
||||
type: object
|
||||
queries.HabitStatsDTO:
|
||||
properties:
|
||||
completion_rate:
|
||||
type: number
|
||||
completions_this_month:
|
||||
type: integer
|
||||
completions_this_week:
|
||||
@@ -215,6 +433,46 @@ info:
|
||||
termsOfService: http://swagger.io/terms/
|
||||
title: Apocapoc API
|
||||
paths:
|
||||
/auth/forgot-password:
|
||||
post:
|
||||
consumes:
|
||||
- application/json
|
||||
description: Request a password reset email with a reset token
|
||||
parameters:
|
||||
- description: User email
|
||||
in: body
|
||||
name: request
|
||||
required: true
|
||||
schema:
|
||||
$ref: '#/definitions/http.ForgotPasswordRequest'
|
||||
produces:
|
||||
- application/json
|
||||
responses:
|
||||
"200":
|
||||
description: Reset email sent successfully
|
||||
schema:
|
||||
additionalProperties:
|
||||
type: string
|
||||
type: object
|
||||
"400":
|
||||
description: Invalid email
|
||||
schema:
|
||||
$ref: '#/definitions/http.ErrorResponse'
|
||||
"403":
|
||||
description: Email not verified
|
||||
schema:
|
||||
$ref: '#/definitions/http.ErrorResponse'
|
||||
"404":
|
||||
description: User not found
|
||||
schema:
|
||||
$ref: '#/definitions/http.ErrorResponse'
|
||||
"500":
|
||||
description: Internal server error
|
||||
schema:
|
||||
$ref: '#/definitions/http.ErrorResponse'
|
||||
summary: Request password reset
|
||||
tags:
|
||||
- auth
|
||||
/auth/login:
|
||||
post:
|
||||
consumes:
|
||||
@@ -244,6 +502,10 @@ paths:
|
||||
description: Invalid email or password
|
||||
schema:
|
||||
$ref: '#/definitions/http.ErrorResponse'
|
||||
"403":
|
||||
description: Email not verified
|
||||
schema:
|
||||
$ref: '#/definitions/http.ErrorResponse'
|
||||
"500":
|
||||
description: Internal server error
|
||||
schema:
|
||||
@@ -333,9 +595,8 @@ paths:
|
||||
post:
|
||||
consumes:
|
||||
- application/json
|
||||
description: Create a new user account and receive both access token and refresh
|
||||
token. Store both tokens securely - the refresh token is used to obtain new
|
||||
access tokens when they expire.
|
||||
description: Create a new user account. If email verification is enabled, you
|
||||
will receive a verification email. Otherwise, you can login immediately.
|
||||
parameters:
|
||||
- description: 'Registration data (password requires: min 8 chars, uppercase,
|
||||
lowercase, digit, special char)'
|
||||
@@ -348,11 +609,15 @@ paths:
|
||||
- application/json
|
||||
responses:
|
||||
"201":
|
||||
description: Returns access token, refresh token, and user ID
|
||||
description: Returns user ID and message about next steps
|
||||
schema:
|
||||
$ref: '#/definitions/http.AuthResponse'
|
||||
$ref: '#/definitions/http.RegisterResponse'
|
||||
"400":
|
||||
description: 'Invalid input: email format, password requirements, or timezone'
|
||||
description: 'Invalid input: email format or password requirements'
|
||||
schema:
|
||||
$ref: '#/definitions/http.ValidationErrorResponse'
|
||||
"403":
|
||||
description: Registration is closed
|
||||
schema:
|
||||
$ref: '#/definitions/http.ErrorResponse'
|
||||
"409":
|
||||
@@ -366,18 +631,182 @@ paths:
|
||||
summary: Register a new user
|
||||
tags:
|
||||
- auth
|
||||
/auth/resend-verification:
|
||||
post:
|
||||
consumes:
|
||||
- application/json
|
||||
description: Resend the email verification link to the user's email address
|
||||
parameters:
|
||||
- description: User email
|
||||
in: body
|
||||
name: request
|
||||
required: true
|
||||
schema:
|
||||
$ref: '#/definitions/http.ResendVerificationRequest'
|
||||
produces:
|
||||
- application/json
|
||||
responses:
|
||||
"200":
|
||||
description: Verification email sent
|
||||
schema:
|
||||
additionalProperties:
|
||||
type: string
|
||||
type: object
|
||||
"400":
|
||||
description: Invalid email
|
||||
schema:
|
||||
$ref: '#/definitions/http.ErrorResponse'
|
||||
"404":
|
||||
description: User not found
|
||||
schema:
|
||||
$ref: '#/definitions/http.ErrorResponse'
|
||||
"409":
|
||||
description: Email already verified
|
||||
schema:
|
||||
$ref: '#/definitions/http.ErrorResponse'
|
||||
"500":
|
||||
description: Internal server error
|
||||
schema:
|
||||
$ref: '#/definitions/http.ErrorResponse'
|
||||
summary: Resend verification email
|
||||
tags:
|
||||
- auth
|
||||
/auth/reset-password:
|
||||
post:
|
||||
consumes:
|
||||
- application/json
|
||||
description: Reset user password using the reset token from email
|
||||
parameters:
|
||||
- description: Reset token and new password
|
||||
in: body
|
||||
name: request
|
||||
required: true
|
||||
schema:
|
||||
$ref: '#/definitions/http.ResetPasswordRequest'
|
||||
produces:
|
||||
- application/json
|
||||
responses:
|
||||
"200":
|
||||
description: Password reset successfully
|
||||
schema:
|
||||
additionalProperties:
|
||||
type: string
|
||||
type: object
|
||||
"400":
|
||||
description: Invalid token or password requirements not met
|
||||
schema:
|
||||
$ref: '#/definitions/http.ErrorResponse'
|
||||
"404":
|
||||
description: User not found
|
||||
schema:
|
||||
$ref: '#/definitions/http.ErrorResponse'
|
||||
"500":
|
||||
description: Internal server error
|
||||
schema:
|
||||
$ref: '#/definitions/http.ErrorResponse'
|
||||
summary: Reset password
|
||||
tags:
|
||||
- auth
|
||||
/auth/verify-email:
|
||||
post:
|
||||
consumes:
|
||||
- application/json
|
||||
description: Verify user email address using the token sent via email
|
||||
parameters:
|
||||
- description: Verification token
|
||||
in: body
|
||||
name: request
|
||||
required: true
|
||||
schema:
|
||||
$ref: '#/definitions/http.VerifyEmailRequest'
|
||||
produces:
|
||||
- application/json
|
||||
responses:
|
||||
"200":
|
||||
description: Email verified successfully
|
||||
schema:
|
||||
additionalProperties:
|
||||
type: string
|
||||
type: object
|
||||
"400":
|
||||
description: Invalid or expired token
|
||||
schema:
|
||||
$ref: '#/definitions/http.ErrorResponse'
|
||||
"409":
|
||||
description: Email already verified
|
||||
schema:
|
||||
$ref: '#/definitions/http.ErrorResponse'
|
||||
"500":
|
||||
description: Internal server error
|
||||
schema:
|
||||
$ref: '#/definitions/http.ErrorResponse'
|
||||
summary: Verify email address
|
||||
tags:
|
||||
- auth
|
||||
/export:
|
||||
get:
|
||||
description: Export all user habits and entries in JSON format with gzip compression.
|
||||
Limited to 1 export per hour.
|
||||
produces:
|
||||
- application/json
|
||||
responses:
|
||||
"200":
|
||||
description: Compressed JSON export
|
||||
schema:
|
||||
$ref: '#/definitions/queries.ExportUserDataResult'
|
||||
"401":
|
||||
description: Unauthorized
|
||||
schema:
|
||||
$ref: '#/definitions/http.ErrorResponse'
|
||||
"429":
|
||||
description: Rate limit exceeded
|
||||
schema:
|
||||
$ref: '#/definitions/http.ErrorResponse'
|
||||
"500":
|
||||
description: Internal server error
|
||||
schema:
|
||||
$ref: '#/definitions/http.ErrorResponse'
|
||||
security:
|
||||
- BearerAuth: []
|
||||
summary: Export user data
|
||||
tags:
|
||||
- export
|
||||
/habits:
|
||||
get:
|
||||
description: Get all active habits for the authenticated user
|
||||
description: Get all active habits for the authenticated user with optional
|
||||
pagination and filters
|
||||
parameters:
|
||||
- description: 'Page number (default: 1)'
|
||||
in: query
|
||||
name: page
|
||||
type: integer
|
||||
- description: 'Page size (default: 50, max: 100)'
|
||||
in: query
|
||||
name: page_size
|
||||
type: integer
|
||||
- description: Filter by type (BOOLEAN, COUNTER, VALUE)
|
||||
in: query
|
||||
name: type
|
||||
type: string
|
||||
- description: Filter by frequency (DAILY, WEEKLY, MONTHLY)
|
||||
in: query
|
||||
name: frequency
|
||||
type: string
|
||||
- description: 'Include archived habits (default: false)'
|
||||
in: query
|
||||
name: archived
|
||||
type: boolean
|
||||
- description: Search by name or description
|
||||
in: query
|
||||
name: search
|
||||
type: string
|
||||
produces:
|
||||
- application/json
|
||||
responses:
|
||||
"200":
|
||||
description: OK
|
||||
schema:
|
||||
items:
|
||||
$ref: '#/definitions/http.UserHabitResponse'
|
||||
type: array
|
||||
$ref: '#/definitions/http.GetUserHabitsResponse'
|
||||
"401":
|
||||
description: Unauthorized
|
||||
schema:
|
||||
@@ -708,7 +1137,15 @@ paths:
|
||||
- habits
|
||||
/habits/today:
|
||||
get:
|
||||
description: Get all habits scheduled for today for the authenticated user
|
||||
description: Get all habits scheduled for today for the authenticated user.
|
||||
Includes the entry for today if it exists. Requires timezone as query parameter
|
||||
(e.g., ?timezone=America/New_York).
|
||||
parameters:
|
||||
- description: IANA timezone (e.g., 'America/New_York', 'Europe/Madrid', 'UTC')
|
||||
in: query
|
||||
name: timezone
|
||||
required: true
|
||||
type: string
|
||||
produces:
|
||||
- application/json
|
||||
responses:
|
||||
@@ -718,6 +1155,10 @@ paths:
|
||||
items:
|
||||
$ref: '#/definitions/http.TodaysHabitResponse'
|
||||
type: array
|
||||
"400":
|
||||
description: Invalid or missing timezone
|
||||
schema:
|
||||
$ref: '#/definitions/http.ErrorResponse'
|
||||
"401":
|
||||
description: Unauthorized
|
||||
schema:
|
||||
@@ -750,8 +1191,7 @@ paths:
|
||||
- system
|
||||
/stats/habits/{id}:
|
||||
get:
|
||||
description: Get statistics for a specific habit including streaks and completion
|
||||
rates
|
||||
description: Get statistics for a specific habit including streaks and completions
|
||||
parameters:
|
||||
- description: Habit ID
|
||||
in: path
|
||||
@@ -786,6 +1226,110 @@ paths:
|
||||
summary: Get habit statistics
|
||||
tags:
|
||||
- stats
|
||||
/sync/batch:
|
||||
post:
|
||||
consumes:
|
||||
- application/json
|
||||
description: Apply a batch of changes from the client for offline sync (Last-Write-Wins)
|
||||
parameters:
|
||||
- description: Sync batch data
|
||||
in: body
|
||||
name: request
|
||||
required: true
|
||||
schema:
|
||||
$ref: '#/definitions/http.SyncBatchRequest'
|
||||
produces:
|
||||
- application/json
|
||||
responses:
|
||||
"200":
|
||||
description: OK
|
||||
schema:
|
||||
additionalProperties:
|
||||
type: string
|
||||
type: object
|
||||
"400":
|
||||
description: Bad Request
|
||||
schema:
|
||||
$ref: '#/definitions/http.ErrorResponse'
|
||||
"401":
|
||||
description: Unauthorized
|
||||
schema:
|
||||
$ref: '#/definitions/http.ErrorResponse'
|
||||
"500":
|
||||
description: Internal Server Error
|
||||
schema:
|
||||
$ref: '#/definitions/http.ErrorResponse'
|
||||
security:
|
||||
- BearerAuth: []
|
||||
summary: Apply sync batch
|
||||
tags:
|
||||
- sync
|
||||
/sync/changes:
|
||||
get:
|
||||
consumes:
|
||||
- application/json
|
||||
description: Get all changes (habits and entries) since a given timestamp for
|
||||
offline sync
|
||||
parameters:
|
||||
- description: ISO 8601 timestamp (e.g., 2025-01-01T00:00:00Z)
|
||||
in: query
|
||||
name: since
|
||||
required: true
|
||||
type: string
|
||||
produces:
|
||||
- application/json
|
||||
responses:
|
||||
"200":
|
||||
description: OK
|
||||
schema:
|
||||
$ref: '#/definitions/http.SyncChangesResponse'
|
||||
"400":
|
||||
description: Bad Request
|
||||
schema:
|
||||
$ref: '#/definitions/http.ErrorResponse'
|
||||
"401":
|
||||
description: Unauthorized
|
||||
schema:
|
||||
$ref: '#/definitions/http.ErrorResponse'
|
||||
"500":
|
||||
description: Internal Server Error
|
||||
schema:
|
||||
$ref: '#/definitions/http.ErrorResponse'
|
||||
security:
|
||||
- BearerAuth: []
|
||||
summary: Get sync changes
|
||||
tags:
|
||||
- sync
|
||||
/users/me:
|
||||
delete:
|
||||
description: Permanently delete the authenticated user's account and all associated
|
||||
data (habits, entries, tokens). This action cannot be undone.
|
||||
produces:
|
||||
- application/json
|
||||
responses:
|
||||
"200":
|
||||
description: Account deleted successfully
|
||||
schema:
|
||||
additionalProperties:
|
||||
type: string
|
||||
type: object
|
||||
"401":
|
||||
description: Unauthorized - invalid or missing token
|
||||
schema:
|
||||
$ref: '#/definitions/http.ErrorResponse'
|
||||
"404":
|
||||
description: User not found
|
||||
schema:
|
||||
$ref: '#/definitions/http.ErrorResponse'
|
||||
"500":
|
||||
description: Internal server error
|
||||
schema:
|
||||
$ref: '#/definitions/http.ErrorResponse'
|
||||
security:
|
||||
- BearerAuth: []
|
||||
summary: Delete user account
|
||||
tags:
|
||||
- users
|
||||
securityDefinitions:
|
||||
BearerAuth:
|
||||
description: Type "Bearer" followed by a space and JWT token.
|
||||
|
||||
@@ -11,9 +11,12 @@ require (
|
||||
github.com/golang-jwt/jwt/v5 v5.3.0
|
||||
github.com/google/uuid v1.6.0
|
||||
github.com/joho/godotenv v1.5.1
|
||||
github.com/rs/zerolog v1.34.0
|
||||
github.com/swaggo/http-swagger v1.3.4
|
||||
github.com/swaggo/swag v1.16.4
|
||||
golang.org/x/crypto v0.45.0
|
||||
golang.org/x/text v0.31.0
|
||||
gopkg.in/mail.v2 v2.3.1
|
||||
modernc.org/sqlite v1.40.1
|
||||
)
|
||||
|
||||
@@ -27,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=
|
||||
|
||||
@@ -0,0 +1,140 @@
|
||||
package commands
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"apocapoc-api/internal/domain/entities"
|
||||
"apocapoc-api/internal/domain/repositories"
|
||||
"apocapoc-api/internal/shared/errors"
|
||||
)
|
||||
|
||||
type HabitBatchChanges struct {
|
||||
Created []*entities.Habit
|
||||
Updated []*entities.Habit
|
||||
Deleted []string
|
||||
}
|
||||
|
||||
type EntryBatchChanges struct {
|
||||
Created []*entities.HabitEntry
|
||||
Updated []*entities.HabitEntry
|
||||
Deleted []string
|
||||
}
|
||||
|
||||
type ApplySyncBatchCommand struct {
|
||||
UserID string
|
||||
Habits HabitBatchChanges
|
||||
Entries EntryBatchChanges
|
||||
}
|
||||
|
||||
type ApplySyncBatchHandler struct {
|
||||
habitRepo repositories.HabitRepository
|
||||
entryRepo repositories.HabitEntryRepository
|
||||
}
|
||||
|
||||
func NewApplySyncBatchHandler(
|
||||
habitRepo repositories.HabitRepository,
|
||||
entryRepo repositories.HabitEntryRepository,
|
||||
) *ApplySyncBatchHandler {
|
||||
return &ApplySyncBatchHandler{
|
||||
habitRepo: habitRepo,
|
||||
entryRepo: entryRepo,
|
||||
}
|
||||
}
|
||||
|
||||
func (h *ApplySyncBatchHandler) Handle(ctx context.Context, cmd ApplySyncBatchCommand) error {
|
||||
if cmd.UserID == "" {
|
||||
return errors.ErrInvalidInput
|
||||
}
|
||||
|
||||
for _, habit := range cmd.Habits.Created {
|
||||
if habit.UserID != cmd.UserID {
|
||||
return errors.ErrUnauthorized
|
||||
}
|
||||
if err := h.habitRepo.Create(ctx, habit); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
for _, habit := range cmd.Habits.Updated {
|
||||
if habit.UserID != cmd.UserID {
|
||||
return errors.ErrUnauthorized
|
||||
}
|
||||
|
||||
existing, err := h.habitRepo.FindByID(ctx, habit.ID)
|
||||
if err != nil {
|
||||
if err == errors.ErrNotFound {
|
||||
if err := h.habitRepo.Create(ctx, habit); err != nil {
|
||||
return err
|
||||
}
|
||||
continue
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
if existing.UserID != cmd.UserID {
|
||||
return errors.ErrUnauthorized
|
||||
}
|
||||
|
||||
if shouldApplyUpdate(existing.UpdatedAt, habit.UpdatedAt) {
|
||||
if err := h.habitRepo.Update(ctx, habit); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for _, id := range cmd.Habits.Deleted {
|
||||
existing, err := h.habitRepo.FindByID(ctx, id)
|
||||
if err != nil {
|
||||
if err == errors.ErrNotFound {
|
||||
continue
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
if existing.UserID != cmd.UserID {
|
||||
return errors.ErrUnauthorized
|
||||
}
|
||||
|
||||
if err := h.habitRepo.SoftDelete(ctx, id); err != nil && err != errors.ErrNotFound {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
for _, entry := range cmd.Entries.Created {
|
||||
if err := h.entryRepo.Create(ctx, entry); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
for _, entry := range cmd.Entries.Updated {
|
||||
existing, err := h.entryRepo.FindByID(ctx, entry.ID)
|
||||
if err != nil {
|
||||
if err == errors.ErrNotFound {
|
||||
if err := h.entryRepo.Create(ctx, entry); err != nil {
|
||||
return err
|
||||
}
|
||||
continue
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
if shouldApplyUpdate(existing.UpdatedAt, entry.UpdatedAt) {
|
||||
if err := h.entryRepo.Update(ctx, entry); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for _, id := range cmd.Entries.Deleted {
|
||||
if err := h.entryRepo.SoftDelete(ctx, id); err != nil && err != errors.ErrNotFound {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func shouldApplyUpdate(serverTime, clientTime time.Time) bool {
|
||||
return clientTime.After(serverTime)
|
||||
}
|
||||
@@ -0,0 +1,401 @@
|
||||
package commands
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"apocapoc-api/internal/domain/entities"
|
||||
"apocapoc-api/internal/domain/repositories"
|
||||
"apocapoc-api/internal/domain/value_objects"
|
||||
"apocapoc-api/internal/shared/errors"
|
||||
"apocapoc-api/internal/shared/pagination"
|
||||
)
|
||||
|
||||
type mockHabitRepoForBatch struct {
|
||||
habits map[string]*entities.Habit
|
||||
createFunc func(ctx context.Context, habit *entities.Habit) error
|
||||
updateFunc func(ctx context.Context, habit *entities.Habit) error
|
||||
softDeleteFunc func(ctx context.Context, id string) error
|
||||
}
|
||||
|
||||
func (m *mockHabitRepoForBatch) FindByID(ctx context.Context, id string) (*entities.Habit, error) {
|
||||
habit, ok := m.habits[id]
|
||||
if !ok {
|
||||
return nil, errors.ErrNotFound
|
||||
}
|
||||
return habit, nil
|
||||
}
|
||||
|
||||
func (m *mockHabitRepoForBatch) Create(ctx context.Context, habit *entities.Habit) error {
|
||||
if m.createFunc != nil {
|
||||
return m.createFunc(ctx, habit)
|
||||
}
|
||||
m.habits[habit.ID] = habit
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *mockHabitRepoForBatch) Update(ctx context.Context, habit *entities.Habit) error {
|
||||
if m.updateFunc != nil {
|
||||
return m.updateFunc(ctx, habit)
|
||||
}
|
||||
m.habits[habit.ID] = habit
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *mockHabitRepoForBatch) SoftDelete(ctx context.Context, id string) error {
|
||||
if m.softDeleteFunc != nil {
|
||||
return m.softDeleteFunc(ctx, id)
|
||||
}
|
||||
delete(m.habits, id)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *mockHabitRepoForBatch) FindByUserID(ctx context.Context, userID string) ([]*entities.Habit, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (m *mockHabitRepoForBatch) FindActiveByUserID(ctx context.Context, userID string) ([]*entities.Habit, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (m *mockHabitRepoForBatch) Delete(ctx context.Context, id string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *mockHabitRepoForBatch) FindActiveByUserIDWithPagination(ctx context.Context, userID string, params pagination.Params) ([]*entities.Habit, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (m *mockHabitRepoForBatch) FindByUserIDFiltered(ctx context.Context, userID string, filter repositories.HabitFilter, paginationParams *pagination.Params) ([]*entities.Habit, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (m *mockHabitRepoForBatch) CountActiveByUserID(ctx context.Context, userID string) (int, error) {
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
func (m *mockHabitRepoForBatch) CountByUserIDFiltered(ctx context.Context, userID string, filter repositories.HabitFilter) (int, error) {
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
func (m *mockHabitRepoForBatch) GetChangesSince(ctx context.Context, userID string, since time.Time) (*repositories.HabitChanges, error) {
|
||||
return &repositories.HabitChanges{
|
||||
Created: []*entities.Habit{},
|
||||
Updated: []*entities.Habit{},
|
||||
Deleted: []string{},
|
||||
}, nil
|
||||
}
|
||||
|
||||
type mockEntryRepoForBatch struct {
|
||||
entries map[string]*entities.HabitEntry
|
||||
createFunc func(ctx context.Context, entry *entities.HabitEntry) error
|
||||
updateFunc func(ctx context.Context, entry *entities.HabitEntry) error
|
||||
softDeleteFunc func(ctx context.Context, id string) error
|
||||
}
|
||||
|
||||
func (m *mockEntryRepoForBatch) FindByID(ctx context.Context, id string) (*entities.HabitEntry, error) {
|
||||
entry, ok := m.entries[id]
|
||||
if !ok {
|
||||
return nil, errors.ErrNotFound
|
||||
}
|
||||
return entry, nil
|
||||
}
|
||||
|
||||
func (m *mockEntryRepoForBatch) Create(ctx context.Context, entry *entities.HabitEntry) error {
|
||||
if m.createFunc != nil {
|
||||
return m.createFunc(ctx, entry)
|
||||
}
|
||||
m.entries[entry.ID] = entry
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *mockEntryRepoForBatch) Update(ctx context.Context, entry *entities.HabitEntry) error {
|
||||
if m.updateFunc != nil {
|
||||
return m.updateFunc(ctx, entry)
|
||||
}
|
||||
m.entries[entry.ID] = entry
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *mockEntryRepoForBatch) SoftDelete(ctx context.Context, id string) error {
|
||||
if m.softDeleteFunc != nil {
|
||||
return m.softDeleteFunc(ctx, id)
|
||||
}
|
||||
delete(m.entries, id)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *mockEntryRepoForBatch) FindByHabitID(ctx context.Context, habitID string) ([]*entities.HabitEntry, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (m *mockEntryRepoForBatch) FindByHabitIDAndDateRange(ctx context.Context, habitID string, from, to time.Time) ([]*entities.HabitEntry, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (m *mockEntryRepoForBatch) FindByUserID(ctx context.Context, userID string) ([]*entities.HabitEntry, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (m *mockEntryRepoForBatch) FindPendingByHabitID(ctx context.Context, habitID string, beforeDate time.Time) ([]*entities.HabitEntry, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (m *mockEntryRepoForBatch) Delete(ctx context.Context, id string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *mockEntryRepoForBatch) GetChangesSince(ctx context.Context, userID string, since time.Time) (*repositories.HabitEntryChanges, error) {
|
||||
return &repositories.HabitEntryChanges{
|
||||
Created: []*entities.HabitEntry{},
|
||||
Updated: []*entities.HabitEntry{},
|
||||
Deleted: []string{},
|
||||
}, nil
|
||||
}
|
||||
|
||||
func TestApplySyncBatchHandler_CreateNewHabits(t *testing.T) {
|
||||
habitRepo := &mockHabitRepoForBatch{
|
||||
habits: make(map[string]*entities.Habit),
|
||||
}
|
||||
entryRepo := &mockEntryRepoForBatch{
|
||||
entries: make(map[string]*entities.HabitEntry),
|
||||
}
|
||||
|
||||
handler := NewApplySyncBatchHandler(habitRepo, entryRepo)
|
||||
|
||||
newHabit := entities.NewHabit("user-123", "New Habit", value_objects.HabitTypeBoolean, value_objects.FrequencyDaily, false, false)
|
||||
newHabit.ID = "habit-new"
|
||||
|
||||
cmd := ApplySyncBatchCommand{
|
||||
UserID: "user-123",
|
||||
Habits: HabitBatchChanges{
|
||||
Created: []*entities.Habit{newHabit},
|
||||
Updated: []*entities.Habit{},
|
||||
Deleted: []string{},
|
||||
},
|
||||
Entries: EntryBatchChanges{
|
||||
Created: []*entities.HabitEntry{},
|
||||
Updated: []*entities.HabitEntry{},
|
||||
Deleted: []string{},
|
||||
},
|
||||
}
|
||||
|
||||
err := handler.Handle(context.Background(), cmd)
|
||||
|
||||
if err != nil {
|
||||
t.Fatalf("Expected no error, got %v", err)
|
||||
}
|
||||
|
||||
if len(habitRepo.habits) != 1 {
|
||||
t.Errorf("Expected 1 habit created, got %d", len(habitRepo.habits))
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplySyncBatchHandler_UpdateExistingHabits(t *testing.T) {
|
||||
oldTime := time.Now().Add(-1 * time.Hour)
|
||||
newTime := time.Now()
|
||||
|
||||
existingHabit := entities.NewHabit("user-123", "Old Name", value_objects.HabitTypeBoolean, value_objects.FrequencyDaily, false, false)
|
||||
existingHabit.ID = "habit-1"
|
||||
existingHabit.UpdatedAt = oldTime
|
||||
|
||||
habitRepo := &mockHabitRepoForBatch{
|
||||
habits: map[string]*entities.Habit{
|
||||
"habit-1": existingHabit,
|
||||
},
|
||||
}
|
||||
entryRepo := &mockEntryRepoForBatch{
|
||||
entries: make(map[string]*entities.HabitEntry),
|
||||
}
|
||||
|
||||
handler := NewApplySyncBatchHandler(habitRepo, entryRepo)
|
||||
|
||||
updatedHabit := entities.NewHabit("user-123", "New Name", value_objects.HabitTypeCounter, value_objects.FrequencyWeekly, false, false)
|
||||
updatedHabit.ID = "habit-1"
|
||||
updatedHabit.UpdatedAt = newTime
|
||||
|
||||
cmd := ApplySyncBatchCommand{
|
||||
UserID: "user-123",
|
||||
Habits: HabitBatchChanges{
|
||||
Created: []*entities.Habit{},
|
||||
Updated: []*entities.Habit{updatedHabit},
|
||||
Deleted: []string{},
|
||||
},
|
||||
Entries: EntryBatchChanges{
|
||||
Created: []*entities.HabitEntry{},
|
||||
Updated: []*entities.HabitEntry{},
|
||||
Deleted: []string{},
|
||||
},
|
||||
}
|
||||
|
||||
err := handler.Handle(context.Background(), cmd)
|
||||
|
||||
if err != nil {
|
||||
t.Fatalf("Expected no error, got %v", err)
|
||||
}
|
||||
|
||||
if habitRepo.habits["habit-1"].Name != "New Name" {
|
||||
t.Errorf("Expected habit name to be updated to 'New Name', got '%s'", habitRepo.habits["habit-1"].Name)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplySyncBatchHandler_LastWriteWins(t *testing.T) {
|
||||
serverTime := time.Now()
|
||||
clientTime := serverTime.Add(-30 * time.Minute)
|
||||
|
||||
serverHabit := entities.NewHabit("user-123", "Server Version", value_objects.HabitTypeBoolean, value_objects.FrequencyDaily, false, false)
|
||||
serverHabit.ID = "habit-1"
|
||||
serverHabit.UpdatedAt = serverTime
|
||||
|
||||
habitRepo := &mockHabitRepoForBatch{
|
||||
habits: map[string]*entities.Habit{
|
||||
"habit-1": serverHabit,
|
||||
},
|
||||
}
|
||||
entryRepo := &mockEntryRepoForBatch{
|
||||
entries: make(map[string]*entities.HabitEntry),
|
||||
}
|
||||
|
||||
handler := NewApplySyncBatchHandler(habitRepo, entryRepo)
|
||||
|
||||
clientHabit := entities.NewHabit("user-123", "Client Version", value_objects.HabitTypeCounter, value_objects.FrequencyWeekly, false, false)
|
||||
clientHabit.ID = "habit-1"
|
||||
clientHabit.UpdatedAt = clientTime
|
||||
|
||||
cmd := ApplySyncBatchCommand{
|
||||
UserID: "user-123",
|
||||
Habits: HabitBatchChanges{
|
||||
Created: []*entities.Habit{},
|
||||
Updated: []*entities.Habit{clientHabit},
|
||||
Deleted: []string{},
|
||||
},
|
||||
Entries: EntryBatchChanges{
|
||||
Created: []*entities.HabitEntry{},
|
||||
Updated: []*entities.HabitEntry{},
|
||||
Deleted: []string{},
|
||||
},
|
||||
}
|
||||
|
||||
err := handler.Handle(context.Background(), cmd)
|
||||
|
||||
if err != nil {
|
||||
t.Fatalf("Expected no error, got %v", err)
|
||||
}
|
||||
|
||||
if habitRepo.habits["habit-1"].Name != "Server Version" {
|
||||
t.Errorf("Expected server version to win (Last-Write-Wins), got '%s'", habitRepo.habits["habit-1"].Name)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplySyncBatchHandler_DeleteHabits(t *testing.T) {
|
||||
existingHabit := entities.NewHabit("user-123", "To Delete", value_objects.HabitTypeBoolean, value_objects.FrequencyDaily, false, false)
|
||||
existingHabit.ID = "habit-1"
|
||||
|
||||
habitRepo := &mockHabitRepoForBatch{
|
||||
habits: map[string]*entities.Habit{
|
||||
"habit-1": existingHabit,
|
||||
},
|
||||
}
|
||||
entryRepo := &mockEntryRepoForBatch{
|
||||
entries: make(map[string]*entities.HabitEntry),
|
||||
}
|
||||
|
||||
handler := NewApplySyncBatchHandler(habitRepo, entryRepo)
|
||||
|
||||
cmd := ApplySyncBatchCommand{
|
||||
UserID: "user-123",
|
||||
Habits: HabitBatchChanges{
|
||||
Created: []*entities.Habit{},
|
||||
Updated: []*entities.Habit{},
|
||||
Deleted: []string{"habit-1"},
|
||||
},
|
||||
Entries: EntryBatchChanges{
|
||||
Created: []*entities.HabitEntry{},
|
||||
Updated: []*entities.HabitEntry{},
|
||||
Deleted: []string{},
|
||||
},
|
||||
}
|
||||
|
||||
err := handler.Handle(context.Background(), cmd)
|
||||
|
||||
if err != nil {
|
||||
t.Fatalf("Expected no error, got %v", err)
|
||||
}
|
||||
|
||||
if len(habitRepo.habits) != 0 {
|
||||
t.Errorf("Expected habit to be deleted, but still exists")
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplySyncBatchHandler_ValidatesUserOwnership(t *testing.T) {
|
||||
habitRepo := &mockHabitRepoForBatch{
|
||||
habits: make(map[string]*entities.Habit),
|
||||
}
|
||||
entryRepo := &mockEntryRepoForBatch{
|
||||
entries: make(map[string]*entities.HabitEntry),
|
||||
}
|
||||
|
||||
handler := NewApplySyncBatchHandler(habitRepo, entryRepo)
|
||||
|
||||
habitForDifferentUser := entities.NewHabit("user-456", "Not Yours", value_objects.HabitTypeBoolean, value_objects.FrequencyDaily, false, false)
|
||||
habitForDifferentUser.ID = "habit-1"
|
||||
|
||||
cmd := ApplySyncBatchCommand{
|
||||
UserID: "user-123",
|
||||
Habits: HabitBatchChanges{
|
||||
Created: []*entities.Habit{habitForDifferentUser},
|
||||
Updated: []*entities.Habit{},
|
||||
Deleted: []string{},
|
||||
},
|
||||
Entries: EntryBatchChanges{
|
||||
Created: []*entities.HabitEntry{},
|
||||
Updated: []*entities.HabitEntry{},
|
||||
Deleted: []string{},
|
||||
},
|
||||
}
|
||||
|
||||
err := handler.Handle(context.Background(), cmd)
|
||||
|
||||
if err == nil {
|
||||
t.Error("Expected error for user mismatch, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplySyncBatchHandler_ProcessesEntries(t *testing.T) {
|
||||
habitRepo := &mockHabitRepoForBatch{
|
||||
habits: make(map[string]*entities.Habit),
|
||||
}
|
||||
entryRepo := &mockEntryRepoForBatch{
|
||||
entries: make(map[string]*entities.HabitEntry),
|
||||
}
|
||||
|
||||
handler := NewApplySyncBatchHandler(habitRepo, entryRepo)
|
||||
|
||||
newEntry := entities.NewHabitEntry("habit-1", time.Now(), nil)
|
||||
newEntry.ID = "entry-new"
|
||||
|
||||
cmd := ApplySyncBatchCommand{
|
||||
UserID: "user-123",
|
||||
Habits: HabitBatchChanges{
|
||||
Created: []*entities.Habit{},
|
||||
Updated: []*entities.Habit{},
|
||||
Deleted: []string{},
|
||||
},
|
||||
Entries: EntryBatchChanges{
|
||||
Created: []*entities.HabitEntry{newEntry},
|
||||
Updated: []*entities.HabitEntry{},
|
||||
Deleted: []string{},
|
||||
},
|
||||
}
|
||||
|
||||
err := handler.Handle(context.Background(), cmd)
|
||||
|
||||
if err != nil {
|
||||
t.Fatalf("Expected no error, got %v", err)
|
||||
}
|
||||
|
||||
if len(entryRepo.entries) != 1 {
|
||||
t.Errorf("Expected 1 entry created, got %d", len(entryRepo.entries))
|
||||
}
|
||||
}
|
||||
@@ -1,8 +1,11 @@
|
||||
package commands
|
||||
|
||||
import (
|
||||
"apocapoc-api/internal/domain/repositories"
|
||||
"apocapoc-api/internal/shared/pagination"
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"apocapoc-api/internal/domain/entities"
|
||||
"apocapoc-api/internal/shared/errors"
|
||||
@@ -36,6 +39,34 @@ func (m *mockHabitRepo) Delete(ctx context.Context, id string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *mockHabitRepo) FindActiveByUserIDWithPagination(ctx context.Context, userID string, params pagination.Params) ([]*entities.Habit, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (m *mockHabitRepo) FindByUserIDFiltered(ctx context.Context, userID string, filter repositories.HabitFilter, paginationParams *pagination.Params) ([]*entities.Habit, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (m *mockHabitRepo) CountActiveByUserID(ctx context.Context, userID string) (int, error) {
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
func (m *mockHabitRepo) CountByUserIDFiltered(ctx context.Context, userID string, filter repositories.HabitFilter) (int, error) {
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
func (m *mockHabitRepo) GetChangesSince(ctx context.Context, userID string, since time.Time) (*repositories.HabitChanges, error) {
|
||||
return &repositories.HabitChanges{
|
||||
Created: []*entities.Habit{},
|
||||
Updated: []*entities.Habit{},
|
||||
Deleted: []string{},
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (m *mockHabitRepo) SoftDelete(ctx context.Context, id string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func TestCreateHabitHandler_Success(t *testing.T) {
|
||||
mock := &mockHabitRepo{
|
||||
createFunc: func(ctx context.Context, habit *entities.Habit) error {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -53,6 +59,18 @@ func (m *mockEntryRepo) Delete(ctx context.Context, id string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *mockEntryRepo) GetChangesSince(ctx context.Context, userID string, since time.Time) (*repositories.HabitEntryChanges, error) {
|
||||
return &repositories.HabitEntryChanges{
|
||||
Created: []*entities.HabitEntry{},
|
||||
Updated: []*entities.HabitEntry{},
|
||||
Deleted: []string{},
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (m *mockEntryRepo) SoftDelete(ctx context.Context, id string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
type mockHabitRepoForMark struct {
|
||||
habit *entities.Habit
|
||||
}
|
||||
@@ -537,3 +555,47 @@ func TestMarkHabitHandler_CounterFirstMarkWithNegative(t *testing.T) {
|
||||
t.Fatalf("Expected no error, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func (m *mockEntryRepo) FindActiveByUserIDWithPagination(ctx context.Context, userID string, params pagination.Params) ([]*entities.Habit, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (m *mockEntryRepo) CountActiveByUserID(ctx context.Context, userID string) (int, error) {
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
func (m *mockHabitRepoForMark) FindActiveByUserIDWithPagination(ctx context.Context, userID string, params pagination.Params) ([]*entities.Habit, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (m *mockHabitRepoForMark) CountActiveByUserID(ctx context.Context, userID string) (int, error) {
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
func (m *mockEntryRepo) FindByUserIDFiltered(ctx context.Context, userID string, filter repositories.HabitFilter, paginationParams *pagination.Params) ([]*entities.Habit, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (m *mockEntryRepo) CountByUserIDFiltered(ctx context.Context, userID string, filter repositories.HabitFilter) (int, error) {
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
func (m *mockHabitRepoForMark) FindByUserIDFiltered(ctx context.Context, userID string, filter repositories.HabitFilter, paginationParams *pagination.Params) ([]*entities.Habit, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (m *mockHabitRepoForMark) CountByUserIDFiltered(ctx context.Context, userID string, filter repositories.HabitFilter) (int, error) {
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
func (m *mockHabitRepoForMark) GetChangesSince(ctx context.Context, userID string, since time.Time) (*repositories.HabitChanges, error) {
|
||||
return &repositories.HabitChanges{
|
||||
Created: []*entities.Habit{},
|
||||
Updated: []*entities.Habit{},
|
||||
Deleted: []string{},
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (m *mockHabitRepoForMark) SoftDelete(ctx context.Context, id string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -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,31 @@ func TestUnmarkHabitHandler_ReturnsErrorWhenEntryNotFound(t *testing.T) {
|
||||
t.Errorf("Expected ErrNotFound for missing entry, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func (m *mockEntryRepoForUnmark) FindActiveByUserIDWithPagination(ctx context.Context, userID string, params pagination.Params) ([]*entities.Habit, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (m *mockEntryRepoForUnmark) CountActiveByUserID(ctx context.Context, userID string) (int, error) {
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
func (m *mockEntryRepoForUnmark) FindByUserIDFiltered(ctx context.Context, userID string, filter repositories.HabitFilter, paginationParams *pagination.Params) ([]*entities.Habit, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (m *mockEntryRepoForUnmark) CountByUserIDFiltered(ctx context.Context, userID string, filter repositories.HabitFilter) (int, error) {
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
func (m *mockEntryRepoForUnmark) GetChangesSince(ctx context.Context, userID string, since time.Time) (*repositories.HabitEntryChanges, error) {
|
||||
return &repositories.HabitEntryChanges{
|
||||
Created: []*entities.HabitEntry{},
|
||||
Updated: []*entities.HabitEntry{},
|
||||
Deleted: []string{},
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (m *mockEntryRepoForUnmark) SoftDelete(ctx context.Context, id string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"strings"
|
||||
|
||||
"apocapoc-api/internal/domain/repositories"
|
||||
"apocapoc-api/internal/domain/value_objects"
|
||||
"apocapoc-api/internal/shared/errors"
|
||||
)
|
||||
|
||||
@@ -13,10 +14,11 @@ type UpdateHabitCommand struct {
|
||||
UserID string
|
||||
Name string
|
||||
Description string
|
||||
CarryOver bool
|
||||
TargetValue *float64
|
||||
Frequency value_objects.Frequency
|
||||
SpecificDays []int
|
||||
SpecificDates []int
|
||||
CarryOver bool
|
||||
TargetValue *float64
|
||||
}
|
||||
|
||||
type UpdateHabitHandler struct {
|
||||
@@ -34,6 +36,18 @@ func (h *UpdateHabitHandler) Handle(ctx context.Context, cmd UpdateHabitCommand)
|
||||
return errors.ErrInvalidInput
|
||||
}
|
||||
|
||||
if !cmd.Frequency.IsValid() {
|
||||
return errors.ErrInvalidInput
|
||||
}
|
||||
|
||||
if cmd.Frequency == value_objects.FrequencyWeekly && len(cmd.SpecificDays) == 0 {
|
||||
return errors.ErrInvalidInput
|
||||
}
|
||||
|
||||
if cmd.Frequency == value_objects.FrequencyMonthly && len(cmd.SpecificDates) == 0 {
|
||||
return errors.ErrInvalidInput
|
||||
}
|
||||
|
||||
habit, err := h.habitRepo.FindByID(ctx, cmd.HabitID)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -49,10 +63,11 @@ func (h *UpdateHabitHandler) Handle(ctx context.Context, cmd UpdateHabitCommand)
|
||||
|
||||
habit.Name = cmd.Name
|
||||
habit.Description = cmd.Description
|
||||
habit.CarryOver = cmd.CarryOver
|
||||
habit.TargetValue = cmd.TargetValue
|
||||
habit.Frequency = cmd.Frequency
|
||||
habit.SpecificDays = cmd.SpecificDays
|
||||
habit.SpecificDates = cmd.SpecificDates
|
||||
habit.CarryOver = cmd.CarryOver
|
||||
habit.TargetValue = cmd.TargetValue
|
||||
|
||||
return h.habitRepo.Update(ctx, habit)
|
||||
}
|
||||
|
||||
@@ -48,9 +48,10 @@ func TestUpdateHabitHandler_UpdatesSuccessfully(t *testing.T) {
|
||||
UserID: "user-123",
|
||||
Name: "Morning Exercise",
|
||||
Description: "Updated description",
|
||||
Frequency: value_objects.FrequencyWeekly,
|
||||
SpecificDays: []int{1, 3, 5},
|
||||
CarryOver: true,
|
||||
TargetValue: &newTargetValue,
|
||||
SpecificDays: []int{1, 3, 5},
|
||||
}
|
||||
|
||||
err := handler.Handle(context.Background(), cmd)
|
||||
@@ -67,6 +68,14 @@ func TestUpdateHabitHandler_UpdatesSuccessfully(t *testing.T) {
|
||||
t.Errorf("Expected description to be updated, got %s", habitRepo.updatedHabit.Description)
|
||||
}
|
||||
|
||||
if habitRepo.updatedHabit.Frequency != value_objects.FrequencyWeekly {
|
||||
t.Errorf("Expected frequency WEEKLY, got %s", habitRepo.updatedHabit.Frequency)
|
||||
}
|
||||
|
||||
if len(habitRepo.updatedHabit.SpecificDays) != 3 {
|
||||
t.Errorf("Expected 3 specific days, got %d", len(habitRepo.updatedHabit.SpecificDays))
|
||||
}
|
||||
|
||||
if !habitRepo.updatedHabit.CarryOver {
|
||||
t.Error("Expected CarryOver to be true")
|
||||
}
|
||||
@@ -74,10 +83,6 @@ func TestUpdateHabitHandler_UpdatesSuccessfully(t *testing.T) {
|
||||
if habitRepo.updatedHabit.TargetValue == nil || *habitRepo.updatedHabit.TargetValue != 5.0 {
|
||||
t.Errorf("Expected target value 5.0, got %v", habitRepo.updatedHabit.TargetValue)
|
||||
}
|
||||
|
||||
if len(habitRepo.updatedHabit.SpecificDays) != 3 {
|
||||
t.Errorf("Expected 3 specific days, got %d", len(habitRepo.updatedHabit.SpecificDays))
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateHabitHandler_ReturnsErrorWhenHabitNotFound(t *testing.T) {
|
||||
@@ -88,9 +93,10 @@ func TestUpdateHabitHandler_ReturnsErrorWhenHabitNotFound(t *testing.T) {
|
||||
handler := NewUpdateHabitHandler(habitRepo)
|
||||
|
||||
cmd := UpdateHabitCommand{
|
||||
HabitID: "non-existent",
|
||||
UserID: "user-123",
|
||||
Name: "Exercise",
|
||||
HabitID: "non-existent",
|
||||
UserID: "user-123",
|
||||
Name: "Exercise",
|
||||
Frequency: value_objects.FrequencyDaily,
|
||||
}
|
||||
|
||||
err := handler.Handle(context.Background(), cmd)
|
||||
@@ -111,9 +117,10 @@ func TestUpdateHabitHandler_ReturnsErrorWhenUserDoesNotOwnHabit(t *testing.T) {
|
||||
handler := NewUpdateHabitHandler(habitRepo)
|
||||
|
||||
cmd := UpdateHabitCommand{
|
||||
HabitID: "habit-1",
|
||||
UserID: "user-456", // Different user
|
||||
Name: "Exercise",
|
||||
HabitID: "habit-1",
|
||||
UserID: "user-456", // Different user
|
||||
Name: "Exercise",
|
||||
Frequency: value_objects.FrequencyDaily,
|
||||
}
|
||||
|
||||
err := handler.Handle(context.Background(), cmd)
|
||||
@@ -135,9 +142,10 @@ func TestUpdateHabitHandler_CannotUpdateArchivedHabit(t *testing.T) {
|
||||
handler := NewUpdateHabitHandler(habitRepo)
|
||||
|
||||
cmd := UpdateHabitCommand{
|
||||
HabitID: "habit-1",
|
||||
UserID: "user-123",
|
||||
Name: "Updated Exercise",
|
||||
HabitID: "habit-1",
|
||||
UserID: "user-123",
|
||||
Name: "Updated Exercise",
|
||||
Frequency: value_objects.FrequencyDaily,
|
||||
}
|
||||
|
||||
err := handler.Handle(context.Background(), cmd)
|
||||
@@ -158,9 +166,10 @@ func TestUpdateHabitHandler_ValidatesInput(t *testing.T) {
|
||||
handler := NewUpdateHabitHandler(habitRepo)
|
||||
|
||||
cmd := UpdateHabitCommand{
|
||||
HabitID: "habit-1",
|
||||
UserID: "user-123",
|
||||
Name: "", // Empty name
|
||||
HabitID: "habit-1",
|
||||
UserID: "user-123",
|
||||
Name: "", // Empty name
|
||||
Frequency: value_objects.FrequencyDaily,
|
||||
}
|
||||
|
||||
err := handler.Handle(context.Background(), cmd)
|
||||
@@ -169,3 +178,57 @@ func TestUpdateHabitHandler_ValidatesInput(t *testing.T) {
|
||||
t.Errorf("Expected ErrInvalidInput for empty name, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateHabitHandler_InvalidFrequency(t *testing.T) {
|
||||
habitRepo := &mockHabitRepoForUpdate{}
|
||||
handler := NewUpdateHabitHandler(habitRepo)
|
||||
|
||||
cmd := UpdateHabitCommand{
|
||||
HabitID: "habit-1",
|
||||
UserID: "user-123",
|
||||
Name: "Exercise",
|
||||
Frequency: "INVALID",
|
||||
}
|
||||
|
||||
err := handler.Handle(context.Background(), cmd)
|
||||
|
||||
if err != errors.ErrInvalidInput {
|
||||
t.Errorf("Expected ErrInvalidInput for invalid frequency, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateHabitHandler_WeeklyRequiresSpecificDays(t *testing.T) {
|
||||
habitRepo := &mockHabitRepoForUpdate{}
|
||||
handler := NewUpdateHabitHandler(habitRepo)
|
||||
|
||||
cmd := UpdateHabitCommand{
|
||||
HabitID: "habit-1",
|
||||
UserID: "user-123",
|
||||
Name: "Exercise",
|
||||
Frequency: value_objects.FrequencyWeekly,
|
||||
}
|
||||
|
||||
err := handler.Handle(context.Background(), cmd)
|
||||
|
||||
if err != errors.ErrInvalidInput {
|
||||
t.Errorf("Expected ErrInvalidInput for weekly without specific days, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateHabitHandler_MonthlyRequiresSpecificDates(t *testing.T) {
|
||||
habitRepo := &mockHabitRepoForUpdate{}
|
||||
handler := NewUpdateHabitHandler(habitRepo)
|
||||
|
||||
cmd := UpdateHabitCommand{
|
||||
HabitID: "habit-1",
|
||||
UserID: "user-123",
|
||||
Name: "Exercise",
|
||||
Frequency: value_objects.FrequencyMonthly,
|
||||
}
|
||||
|
||||
err := handler.Handle(context.Background(), cmd)
|
||||
|
||||
if err != errors.ErrInvalidInput {
|
||||
t.Errorf("Expected ErrInvalidInput for monthly without specific dates, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -6,18 +6,18 @@ import (
|
||||
|
||||
"apocapoc-api/internal/domain/entities"
|
||||
"apocapoc-api/internal/domain/repositories"
|
||||
"apocapoc-api/internal/domain/services"
|
||||
"apocapoc-api/internal/shared/errors"
|
||||
)
|
||||
|
||||
type HabitStatsDTO struct {
|
||||
HabitID string `json:"habit_id"`
|
||||
HabitName string `json:"habit_name"`
|
||||
TotalCompletions int `json:"total_completions"`
|
||||
CurrentStreak int `json:"current_streak"`
|
||||
LongestStreak int `json:"longest_streak"`
|
||||
CompletionRate float64 `json:"completion_rate"`
|
||||
CompletionsThisWeek int `json:"completions_this_week"`
|
||||
CompletionsThisMonth int `json:"completions_this_month"`
|
||||
HabitID string `json:"habit_id"`
|
||||
HabitName string `json:"habit_name"`
|
||||
TotalCompletions int `json:"total_completions"`
|
||||
CurrentStreak int `json:"current_streak"`
|
||||
LongestStreak int `json:"longest_streak"`
|
||||
CompletionsThisWeek int `json:"completions_this_week"`
|
||||
CompletionsThisMonth int `json:"completions_this_month"`
|
||||
}
|
||||
|
||||
type GetHabitStatsQuery struct {
|
||||
@@ -60,102 +60,21 @@ func (h *GetHabitStatsHandler) Handle(ctx context.Context, query GetHabitStatsQu
|
||||
HabitName: habit.Name,
|
||||
}
|
||||
|
||||
if len(entries) == 0 {
|
||||
if len(entries) == 0 && !habit.IsNegative {
|
||||
return stats, nil
|
||||
}
|
||||
|
||||
stats.TotalCompletions = len(entries)
|
||||
stats.CurrentStreak = calculateCurrentStreak(entries)
|
||||
stats.LongestStreak = calculateLongestStreak(entries)
|
||||
stats.CompletionRate = calculateCompletionRate(entries, habit.CreatedAt)
|
||||
stats.CompletionsThisWeek = countCompletionsInPeriod(entries, 7)
|
||||
stats.CompletionsThisMonth = countCompletionsInPeriod(entries, 30)
|
||||
|
||||
streaks := services.CalculateStreaks(entries, habit, time.Now().UTC())
|
||||
stats.CurrentStreak = streaks.Current
|
||||
stats.LongestStreak = streaks.Longest
|
||||
|
||||
return stats, nil
|
||||
}
|
||||
|
||||
func calculateCurrentStreak(entries []*entities.HabitEntry) int {
|
||||
if len(entries) == 0 {
|
||||
return 0
|
||||
}
|
||||
|
||||
dateMap := make(map[string]bool)
|
||||
for _, entry := range entries {
|
||||
dateStr := entry.ScheduledDate.Format("2006-01-02")
|
||||
dateMap[dateStr] = true
|
||||
}
|
||||
|
||||
streak := 0
|
||||
currentDate := time.Now().UTC()
|
||||
|
||||
for {
|
||||
dateStr := currentDate.Format("2006-01-02")
|
||||
if !dateMap[dateStr] {
|
||||
break
|
||||
}
|
||||
streak++
|
||||
currentDate = currentDate.AddDate(0, 0, -1)
|
||||
}
|
||||
|
||||
return streak
|
||||
}
|
||||
|
||||
func calculateLongestStreak(entries []*entities.HabitEntry) int {
|
||||
if len(entries) == 0 {
|
||||
return 0
|
||||
}
|
||||
|
||||
dateMap := make(map[string]bool)
|
||||
var dates []time.Time
|
||||
for _, entry := range entries {
|
||||
date := time.Date(entry.ScheduledDate.Year(), entry.ScheduledDate.Month(), entry.ScheduledDate.Day(), 0, 0, 0, 0, time.UTC)
|
||||
dateStr := date.Format("2006-01-02")
|
||||
if !dateMap[dateStr] {
|
||||
dateMap[dateStr] = true
|
||||
dates = append(dates, date)
|
||||
}
|
||||
}
|
||||
|
||||
if len(dates) == 0 {
|
||||
return 0
|
||||
}
|
||||
|
||||
longestStreak := 1
|
||||
currentStreak := 1
|
||||
|
||||
for i := 1; i < len(dates); i++ {
|
||||
diff := dates[i].Sub(dates[i-1]).Hours() / 24
|
||||
if diff == 1 {
|
||||
currentStreak++
|
||||
if currentStreak > longestStreak {
|
||||
longestStreak = currentStreak
|
||||
}
|
||||
} else {
|
||||
currentStreak = 1
|
||||
}
|
||||
}
|
||||
|
||||
return longestStreak
|
||||
}
|
||||
|
||||
func calculateCompletionRate(entries []*entities.HabitEntry, createdAt time.Time) float64 {
|
||||
if len(entries) == 0 {
|
||||
return 0
|
||||
}
|
||||
|
||||
daysSinceCreation := int(time.Since(createdAt).Hours() / 24)
|
||||
if daysSinceCreation == 0 {
|
||||
daysSinceCreation = 1
|
||||
}
|
||||
|
||||
rate := float64(len(entries)) / float64(daysSinceCreation) * 100
|
||||
if rate > 100 {
|
||||
rate = 100
|
||||
}
|
||||
|
||||
return rate
|
||||
}
|
||||
|
||||
func countCompletionsInPeriod(entries []*entities.HabitEntry, days int) int {
|
||||
cutoff := time.Now().UTC().AddDate(0, 0, -days)
|
||||
count := 0
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
package queries
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"apocapoc-api/internal/domain/entities"
|
||||
"apocapoc-api/internal/domain/repositories"
|
||||
"apocapoc-api/internal/shared/errors"
|
||||
)
|
||||
|
||||
type HabitChangesDTO struct {
|
||||
Created []*entities.Habit
|
||||
Updated []*entities.Habit
|
||||
Deleted []string
|
||||
}
|
||||
|
||||
type EntryChangesDTO struct {
|
||||
Created []*entities.HabitEntry
|
||||
Updated []*entities.HabitEntry
|
||||
Deleted []string
|
||||
}
|
||||
|
||||
type SyncChangesDTO struct {
|
||||
Habits HabitChangesDTO
|
||||
Entries EntryChangesDTO
|
||||
}
|
||||
|
||||
type GetSyncChangesQuery struct {
|
||||
UserID string
|
||||
Since time.Time
|
||||
}
|
||||
|
||||
type GetSyncChangesHandler struct {
|
||||
habitRepo repositories.HabitRepository
|
||||
entryRepo repositories.HabitEntryRepository
|
||||
}
|
||||
|
||||
func NewGetSyncChangesHandler(
|
||||
habitRepo repositories.HabitRepository,
|
||||
entryRepo repositories.HabitEntryRepository,
|
||||
) *GetSyncChangesHandler {
|
||||
return &GetSyncChangesHandler{
|
||||
habitRepo: habitRepo,
|
||||
entryRepo: entryRepo,
|
||||
}
|
||||
}
|
||||
|
||||
func (h *GetSyncChangesHandler) Handle(ctx context.Context, query GetSyncChangesQuery) (*SyncChangesDTO, error) {
|
||||
if query.UserID == "" {
|
||||
return nil, errors.ErrInvalidInput
|
||||
}
|
||||
|
||||
habitChanges, err := h.habitRepo.GetChangesSince(ctx, query.UserID, query.Since)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
entryChanges, err := h.entryRepo.GetChangesSince(ctx, query.UserID, query.Since)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &SyncChangesDTO{
|
||||
Habits: HabitChangesDTO{
|
||||
Created: habitChanges.Created,
|
||||
Updated: habitChanges.Updated,
|
||||
Deleted: habitChanges.Deleted,
|
||||
},
|
||||
Entries: EntryChangesDTO{
|
||||
Created: entryChanges.Created,
|
||||
Updated: entryChanges.Updated,
|
||||
Deleted: entryChanges.Deleted,
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
@@ -0,0 +1,255 @@
|
||||
package queries
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"apocapoc-api/internal/domain/entities"
|
||||
"apocapoc-api/internal/domain/repositories"
|
||||
"apocapoc-api/internal/domain/value_objects"
|
||||
"apocapoc-api/internal/shared/pagination"
|
||||
)
|
||||
|
||||
type mockHabitRepoForSync struct {
|
||||
changes *repositories.HabitChanges
|
||||
err error
|
||||
}
|
||||
|
||||
func (m *mockHabitRepoForSync) GetChangesSince(ctx context.Context, userID string, since time.Time) (*repositories.HabitChanges, error) {
|
||||
return m.changes, m.err
|
||||
}
|
||||
|
||||
func (m *mockHabitRepoForSync) Create(ctx context.Context, habit *entities.Habit) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *mockHabitRepoForSync) FindByID(ctx context.Context, id string) (*entities.Habit, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (m *mockHabitRepoForSync) FindByUserID(ctx context.Context, userID string) ([]*entities.Habit, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (m *mockHabitRepoForSync) FindActiveByUserID(ctx context.Context, userID string) ([]*entities.Habit, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (m *mockHabitRepoForSync) Update(ctx context.Context, habit *entities.Habit) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *mockHabitRepoForSync) Delete(ctx context.Context, id string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *mockHabitRepoForSync) FindActiveByUserIDWithPagination(ctx context.Context, userID string, params pagination.Params) ([]*entities.Habit, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (m *mockHabitRepoForSync) FindByUserIDFiltered(ctx context.Context, userID string, filter repositories.HabitFilter, paginationParams *pagination.Params) ([]*entities.Habit, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (m *mockHabitRepoForSync) CountActiveByUserID(ctx context.Context, userID string) (int, error) {
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
func (m *mockHabitRepoForSync) CountByUserIDFiltered(ctx context.Context, userID string, filter repositories.HabitFilter) (int, error) {
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
func (m *mockHabitRepoForSync) SoftDelete(ctx context.Context, id string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
type mockEntryRepoForSync struct {
|
||||
changes *repositories.HabitEntryChanges
|
||||
err error
|
||||
}
|
||||
|
||||
func (m *mockEntryRepoForSync) GetChangesSince(ctx context.Context, userID string, since time.Time) (*repositories.HabitEntryChanges, error) {
|
||||
return m.changes, m.err
|
||||
}
|
||||
|
||||
func (m *mockEntryRepoForSync) Create(ctx context.Context, entry *entities.HabitEntry) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *mockEntryRepoForSync) FindByID(ctx context.Context, id string) (*entities.HabitEntry, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (m *mockEntryRepoForSync) FindByHabitID(ctx context.Context, habitID string) ([]*entities.HabitEntry, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (m *mockEntryRepoForSync) FindByHabitIDAndDateRange(ctx context.Context, habitID string, from, to time.Time) ([]*entities.HabitEntry, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (m *mockEntryRepoForSync) FindByUserID(ctx context.Context, userID string) ([]*entities.HabitEntry, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (m *mockEntryRepoForSync) FindPendingByHabitID(ctx context.Context, habitID string, beforeDate time.Time) ([]*entities.HabitEntry, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (m *mockEntryRepoForSync) Update(ctx context.Context, entry *entities.HabitEntry) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *mockEntryRepoForSync) Delete(ctx context.Context, id string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *mockEntryRepoForSync) SoftDelete(ctx context.Context, id string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func TestGetSyncChangesHandler_Success(t *testing.T) {
|
||||
now := time.Now()
|
||||
since := now.Add(-1 * time.Hour)
|
||||
|
||||
createdHabit := entities.NewHabit("user-123", "New Habit", value_objects.HabitTypeBoolean, value_objects.FrequencyDaily, false, false)
|
||||
createdHabit.ID = "habit-1"
|
||||
createdHabit.CreatedAt = now
|
||||
createdHabit.UpdatedAt = now
|
||||
|
||||
updatedHabit := entities.NewHabit("user-123", "Updated Habit", value_objects.HabitTypeCounter, value_objects.FrequencyWeekly, false, false)
|
||||
updatedHabit.ID = "habit-2"
|
||||
updatedHabit.CreatedAt = since.Add(-1 * time.Hour)
|
||||
updatedHabit.UpdatedAt = now
|
||||
|
||||
habitRepo := &mockHabitRepoForSync{
|
||||
changes: &repositories.HabitChanges{
|
||||
Created: []*entities.Habit{createdHabit},
|
||||
Updated: []*entities.Habit{updatedHabit},
|
||||
Deleted: []string{"habit-3"},
|
||||
},
|
||||
}
|
||||
|
||||
createdEntry := entities.NewHabitEntry("habit-1", now, nil)
|
||||
createdEntry.ID = "entry-1"
|
||||
|
||||
updatedEntry := entities.NewHabitEntry("habit-2", now, nil)
|
||||
updatedEntry.ID = "entry-2"
|
||||
updatedEntry.UpdatedAt = now
|
||||
|
||||
entryRepo := &mockEntryRepoForSync{
|
||||
changes: &repositories.HabitEntryChanges{
|
||||
Created: []*entities.HabitEntry{createdEntry},
|
||||
Updated: []*entities.HabitEntry{updatedEntry},
|
||||
Deleted: []string{"entry-3"},
|
||||
},
|
||||
}
|
||||
|
||||
handler := NewGetSyncChangesHandler(habitRepo, entryRepo)
|
||||
|
||||
query := GetSyncChangesQuery{
|
||||
UserID: "user-123",
|
||||
Since: since,
|
||||
}
|
||||
|
||||
result, err := handler.Handle(context.Background(), query)
|
||||
|
||||
if err != nil {
|
||||
t.Fatalf("Expected no error, got %v", err)
|
||||
}
|
||||
|
||||
if len(result.Habits.Created) != 1 {
|
||||
t.Errorf("Expected 1 created habit, got %d", len(result.Habits.Created))
|
||||
}
|
||||
|
||||
if len(result.Habits.Updated) != 1 {
|
||||
t.Errorf("Expected 1 updated habit, got %d", len(result.Habits.Updated))
|
||||
}
|
||||
|
||||
if len(result.Habits.Deleted) != 1 {
|
||||
t.Errorf("Expected 1 deleted habit, got %d", len(result.Habits.Deleted))
|
||||
}
|
||||
|
||||
if len(result.Entries.Created) != 1 {
|
||||
t.Errorf("Expected 1 created entry, got %d", len(result.Entries.Created))
|
||||
}
|
||||
|
||||
if len(result.Entries.Updated) != 1 {
|
||||
t.Errorf("Expected 1 updated entry, got %d", len(result.Entries.Updated))
|
||||
}
|
||||
|
||||
if len(result.Entries.Deleted) != 1 {
|
||||
t.Errorf("Expected 1 deleted entry, got %d", len(result.Entries.Deleted))
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetSyncChangesHandler_EmptyChanges(t *testing.T) {
|
||||
habitRepo := &mockHabitRepoForSync{
|
||||
changes: &repositories.HabitChanges{
|
||||
Created: []*entities.Habit{},
|
||||
Updated: []*entities.Habit{},
|
||||
Deleted: []string{},
|
||||
},
|
||||
}
|
||||
|
||||
entryRepo := &mockEntryRepoForSync{
|
||||
changes: &repositories.HabitEntryChanges{
|
||||
Created: []*entities.HabitEntry{},
|
||||
Updated: []*entities.HabitEntry{},
|
||||
Deleted: []string{},
|
||||
},
|
||||
}
|
||||
|
||||
handler := NewGetSyncChangesHandler(habitRepo, entryRepo)
|
||||
|
||||
query := GetSyncChangesQuery{
|
||||
UserID: "user-123",
|
||||
Since: time.Now().Add(-1 * time.Hour),
|
||||
}
|
||||
|
||||
result, err := handler.Handle(context.Background(), query)
|
||||
|
||||
if err != nil {
|
||||
t.Fatalf("Expected no error, got %v", err)
|
||||
}
|
||||
|
||||
if len(result.Habits.Created) != 0 {
|
||||
t.Errorf("Expected 0 created habits, got %d", len(result.Habits.Created))
|
||||
}
|
||||
|
||||
if len(result.Entries.Created) != 0 {
|
||||
t.Errorf("Expected 0 created entries, got %d", len(result.Entries.Created))
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetSyncChangesHandler_InvalidUserID(t *testing.T) {
|
||||
habitRepo := &mockHabitRepoForSync{
|
||||
changes: &repositories.HabitChanges{
|
||||
Created: []*entities.Habit{},
|
||||
Updated: []*entities.Habit{},
|
||||
Deleted: []string{},
|
||||
},
|
||||
}
|
||||
|
||||
entryRepo := &mockEntryRepoForSync{
|
||||
changes: &repositories.HabitEntryChanges{
|
||||
Created: []*entities.HabitEntry{},
|
||||
Updated: []*entities.HabitEntry{},
|
||||
Deleted: []string{},
|
||||
},
|
||||
}
|
||||
|
||||
handler := NewGetSyncChangesHandler(habitRepo, entryRepo)
|
||||
|
||||
query := GetSyncChangesQuery{
|
||||
UserID: "",
|
||||
Since: time.Now(),
|
||||
}
|
||||
|
||||
_, err := handler.Handle(context.Background(), query)
|
||||
|
||||
if err == nil {
|
||||
t.Error("Expected error for empty UserID, got nil")
|
||||
}
|
||||
}
|
||||
@@ -1,12 +1,14 @@
|
||||
package queries
|
||||
|
||||
import (
|
||||
"apocapoc-api/internal/domain/repositories"
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"apocapoc-api/internal/domain/entities"
|
||||
"apocapoc-api/internal/domain/value_objects"
|
||||
"apocapoc-api/internal/shared/pagination"
|
||||
)
|
||||
|
||||
type mockHabitRepo struct {
|
||||
@@ -37,6 +39,14 @@ func (m *mockHabitRepo) Delete(ctx context.Context, id string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *mockHabitRepo) FindActiveByUserIDWithPagination(ctx context.Context, userID string, params pagination.Params) ([]*entities.Habit, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (m *mockHabitRepo) CountActiveByUserID(ctx context.Context, userID string) (int, error) {
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
type mockEntryRepo struct {
|
||||
entries []*entities.HabitEntry
|
||||
}
|
||||
@@ -63,6 +73,10 @@ func (m *mockEntryRepo) FindByHabitIDAndDateRange(ctx context.Context, habitID s
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (m *mockEntryRepo) FindByUserID(ctx context.Context, userID string) ([]*entities.HabitEntry, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (m *mockEntryRepo) FindPendingByHabitID(ctx context.Context, habitID string, beforeDate time.Time) ([]*entities.HabitEntry, error) {
|
||||
return nil, nil
|
||||
}
|
||||
@@ -75,6 +89,18 @@ func (m *mockEntryRepo) Delete(ctx context.Context, id string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *mockEntryRepo) GetChangesSince(ctx context.Context, userID string, since time.Time) (*repositories.HabitEntryChanges, error) {
|
||||
return &repositories.HabitEntryChanges{
|
||||
Created: []*entities.HabitEntry{},
|
||||
Updated: []*entities.HabitEntry{},
|
||||
Deleted: []string{},
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (m *mockEntryRepo) SoftDelete(ctx context.Context, id string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func TestGetTodaysHabitsHandler_DailyHabitNoEntries(t *testing.T) {
|
||||
habit := entities.NewHabit("user-123", "Exercise", value_objects.HabitTypeBoolean, value_objects.FrequencyDaily, false, false)
|
||||
habit.ID = "habit-1"
|
||||
@@ -315,3 +341,23 @@ func TestGetTodaysHabitsHandler_CarryOverDisabled(t *testing.T) {
|
||||
t.Fatalf("Expected 0 habits (no carry-over), got %d", len(results))
|
||||
}
|
||||
}
|
||||
|
||||
func (m *mockHabitRepo) FindByUserIDFiltered(ctx context.Context, userID string, filter repositories.HabitFilter, paginationParams *pagination.Params) ([]*entities.Habit, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (m *mockHabitRepo) CountByUserIDFiltered(ctx context.Context, userID string, filter repositories.HabitFilter) (int, error) {
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
func (m *mockHabitRepo) GetChangesSince(ctx context.Context, userID string, since time.Time) (*repositories.HabitChanges, error) {
|
||||
return &repositories.HabitChanges{
|
||||
Created: []*entities.Habit{},
|
||||
Updated: []*entities.Habit{},
|
||||
Deleted: []string{},
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (m *mockHabitRepo) SoftDelete(ctx context.Context, id string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -3,8 +3,10 @@ package queries
|
||||
import (
|
||||
"context"
|
||||
|
||||
"apocapoc-api/internal/domain/entities"
|
||||
"apocapoc-api/internal/domain/repositories"
|
||||
"apocapoc-api/internal/domain/value_objects"
|
||||
"apocapoc-api/internal/shared/pagination"
|
||||
)
|
||||
|
||||
type HabitDTO struct {
|
||||
@@ -18,8 +20,22 @@ type HabitDTO struct {
|
||||
SpecificDays []int
|
||||
}
|
||||
|
||||
type FilterParams struct {
|
||||
Type *value_objects.HabitType
|
||||
Frequency *value_objects.Frequency
|
||||
IncludeArchived bool
|
||||
Search string
|
||||
}
|
||||
|
||||
type GetUserHabitsQuery struct {
|
||||
UserID string
|
||||
UserID string
|
||||
PaginationParams *pagination.Params
|
||||
FilterParams *FilterParams
|
||||
}
|
||||
|
||||
type GetUserHabitsResult struct {
|
||||
Habits []HabitDTO
|
||||
Pagination *pagination.Response
|
||||
}
|
||||
|
||||
type GetUserHabitsHandler struct {
|
||||
@@ -32,15 +48,56 @@ func NewGetUserHabitsHandler(habitRepo repositories.HabitRepository) *GetUserHab
|
||||
}
|
||||
}
|
||||
|
||||
func (h *GetUserHabitsHandler) Handle(ctx context.Context, query GetUserHabitsQuery) ([]HabitDTO, error) {
|
||||
habits, err := h.habitRepo.FindActiveByUserID(ctx, query.UserID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
func (h *GetUserHabitsHandler) Handle(ctx context.Context, query GetUserHabitsQuery) (*GetUserHabitsResult, error) {
|
||||
var habits []*entities.Habit
|
||||
var paginationResponse *pagination.Response
|
||||
var err error
|
||||
|
||||
if query.FilterParams != nil {
|
||||
filter := repositories.HabitFilter{
|
||||
Type: query.FilterParams.Type,
|
||||
Frequency: query.FilterParams.Frequency,
|
||||
IncludeArchived: query.FilterParams.IncludeArchived,
|
||||
Search: query.FilterParams.Search,
|
||||
}
|
||||
|
||||
habits, err = h.habitRepo.FindByUserIDFiltered(ctx, query.UserID, filter, query.PaginationParams)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if query.PaginationParams != nil {
|
||||
totalItems, err := h.habitRepo.CountByUserIDFiltered(ctx, query.UserID, filter)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
response := pagination.NewResponse(*query.PaginationParams, totalItems)
|
||||
paginationResponse = &response
|
||||
}
|
||||
} else if query.PaginationParams != nil {
|
||||
habits, err = h.habitRepo.FindActiveByUserIDWithPagination(ctx, query.UserID, *query.PaginationParams)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
totalItems, err := h.habitRepo.CountActiveByUserID(ctx, query.UserID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
response := pagination.NewResponse(*query.PaginationParams, totalItems)
|
||||
paginationResponse = &response
|
||||
} else {
|
||||
habits, err = h.habitRepo.FindActiveByUserID(ctx, query.UserID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
var result []HabitDTO
|
||||
var habitDTOs []HabitDTO
|
||||
for _, habit := range habits {
|
||||
result = append(result, HabitDTO{
|
||||
habitDTOs = append(habitDTOs, HabitDTO{
|
||||
ID: habit.ID,
|
||||
Name: habit.Name,
|
||||
Type: habit.Type,
|
||||
@@ -52,5 +109,8 @@ func (h *GetUserHabitsHandler) Handle(ctx context.Context, query GetUserHabitsQu
|
||||
})
|
||||
}
|
||||
|
||||
return result, nil
|
||||
return &GetUserHabitsResult{
|
||||
Habits: habitDTOs,
|
||||
Pagination: paginationResponse,
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -1,13 +1,64 @@
|
||||
package queries
|
||||
|
||||
import (
|
||||
"apocapoc-api/internal/domain/repositories"
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"apocapoc-api/internal/domain/entities"
|
||||
"apocapoc-api/internal/domain/value_objects"
|
||||
"apocapoc-api/internal/shared/pagination"
|
||||
)
|
||||
|
||||
type mockGetUserHabitsRepo struct {
|
||||
habits []*entities.Habit
|
||||
}
|
||||
|
||||
func (m *mockGetUserHabitsRepo) Create(ctx context.Context, habit *entities.Habit) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *mockGetUserHabitsRepo) FindByID(ctx context.Context, id string) (*entities.Habit, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (m *mockGetUserHabitsRepo) FindByUserID(ctx context.Context, userID string) ([]*entities.Habit, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (m *mockGetUserHabitsRepo) FindActiveByUserID(ctx context.Context, userID string) ([]*entities.Habit, error) {
|
||||
return m.habits, nil
|
||||
}
|
||||
|
||||
func (m *mockGetUserHabitsRepo) FindActiveByUserIDWithPagination(ctx context.Context, userID string, params pagination.Params) ([]*entities.Habit, error) {
|
||||
offset := params.Offset()
|
||||
limit := params.Limit()
|
||||
|
||||
if offset >= len(m.habits) {
|
||||
return []*entities.Habit{}, nil
|
||||
}
|
||||
|
||||
end := offset + limit
|
||||
if end > len(m.habits) {
|
||||
end = len(m.habits)
|
||||
}
|
||||
|
||||
return m.habits[offset:end], nil
|
||||
}
|
||||
|
||||
func (m *mockGetUserHabitsRepo) CountActiveByUserID(ctx context.Context, userID string) (int, error) {
|
||||
return len(m.habits), nil
|
||||
}
|
||||
|
||||
func (m *mockGetUserHabitsRepo) Update(ctx context.Context, habit *entities.Habit) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *mockGetUserHabitsRepo) Delete(ctx context.Context, id string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func TestGetUserHabitsHandler_ReturnsAllActiveHabits(t *testing.T) {
|
||||
habit1 := entities.NewHabit("user-123", "Exercise", value_objects.HabitTypeBoolean, value_objects.FrequencyDaily, false, false)
|
||||
habit1.ID = "habit-1"
|
||||
@@ -15,7 +66,7 @@ func TestGetUserHabitsHandler_ReturnsAllActiveHabits(t *testing.T) {
|
||||
habit2 := entities.NewHabit("user-123", "Read", value_objects.HabitTypeBoolean, value_objects.FrequencyWeekly, false, false)
|
||||
habit2.ID = "habit-2"
|
||||
|
||||
habitRepo := &mockHabitRepo{habits: []*entities.Habit{habit1, habit2}}
|
||||
habitRepo := &mockGetUserHabitsRepo{habits: []*entities.Habit{habit1, habit2}}
|
||||
|
||||
handler := NewGetUserHabitsHandler(habitRepo)
|
||||
|
||||
@@ -23,27 +74,31 @@ func TestGetUserHabitsHandler_ReturnsAllActiveHabits(t *testing.T) {
|
||||
UserID: "user-123",
|
||||
}
|
||||
|
||||
results, err := handler.Handle(context.Background(), query)
|
||||
result, err := handler.Handle(context.Background(), query)
|
||||
|
||||
if err != nil {
|
||||
t.Fatalf("Expected no error, got %v", err)
|
||||
}
|
||||
|
||||
if len(results) != 2 {
|
||||
t.Fatalf("Expected 2 habits, got %d", len(results))
|
||||
if len(result.Habits) != 2 {
|
||||
t.Fatalf("Expected 2 habits, got %d", len(result.Habits))
|
||||
}
|
||||
|
||||
if results[0].ID != "habit-1" {
|
||||
t.Errorf("Expected first habit ID habit-1, got %s", results[0].ID)
|
||||
if result.Habits[0].ID != "habit-1" {
|
||||
t.Errorf("Expected first habit ID habit-1, got %s", result.Habits[0].ID)
|
||||
}
|
||||
|
||||
if results[1].ID != "habit-2" {
|
||||
t.Errorf("Expected second habit ID habit-2, got %s", results[1].ID)
|
||||
if result.Habits[1].ID != "habit-2" {
|
||||
t.Errorf("Expected second habit ID habit-2, got %s", result.Habits[1].ID)
|
||||
}
|
||||
|
||||
if result.Pagination != nil {
|
||||
t.Error("Expected no pagination when not requested")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetUserHabitsHandler_ReturnsEmptyListForUserWithNoHabits(t *testing.T) {
|
||||
habitRepo := &mockHabitRepo{habits: []*entities.Habit{}}
|
||||
habitRepo := &mockGetUserHabitsRepo{habits: []*entities.Habit{}}
|
||||
|
||||
handler := NewGetUserHabitsHandler(habitRepo)
|
||||
|
||||
@@ -51,14 +106,14 @@ func TestGetUserHabitsHandler_ReturnsEmptyListForUserWithNoHabits(t *testing.T)
|
||||
UserID: "user-456",
|
||||
}
|
||||
|
||||
results, err := handler.Handle(context.Background(), query)
|
||||
result, err := handler.Handle(context.Background(), query)
|
||||
|
||||
if err != nil {
|
||||
t.Fatalf("Expected no error, got %v", err)
|
||||
}
|
||||
|
||||
if len(results) != 0 {
|
||||
t.Fatalf("Expected 0 habits, got %d", len(results))
|
||||
if len(result.Habits) != 0 {
|
||||
t.Fatalf("Expected 0 habits, got %d", len(result.Habits))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -68,7 +123,7 @@ func TestGetUserHabitsHandler_IncludesAllHabitFields(t *testing.T) {
|
||||
habit.ID = "habit-1"
|
||||
habit.TargetValue = &targetValue
|
||||
|
||||
habitRepo := &mockHabitRepo{habits: []*entities.Habit{habit}}
|
||||
habitRepo := &mockGetUserHabitsRepo{habits: []*entities.Habit{habit}}
|
||||
|
||||
handler := NewGetUserHabitsHandler(habitRepo)
|
||||
|
||||
@@ -76,35 +131,302 @@ func TestGetUserHabitsHandler_IncludesAllHabitFields(t *testing.T) {
|
||||
UserID: "user-123",
|
||||
}
|
||||
|
||||
results, err := handler.Handle(context.Background(), query)
|
||||
result, err := handler.Handle(context.Background(), query)
|
||||
|
||||
if err != nil {
|
||||
t.Fatalf("Expected no error, got %v", err)
|
||||
}
|
||||
|
||||
if len(results) != 1 {
|
||||
t.Fatalf("Expected 1 habit, got %d", len(results))
|
||||
if len(result.Habits) != 1 {
|
||||
t.Fatalf("Expected 1 habit, got %d", len(result.Habits))
|
||||
}
|
||||
|
||||
result := results[0]
|
||||
habitDTO := result.Habits[0]
|
||||
|
||||
if result.Name != "Drink Water" {
|
||||
t.Errorf("Expected name 'Drink Water', got %s", result.Name)
|
||||
if habitDTO.Name != "Drink Water" {
|
||||
t.Errorf("Expected name 'Drink Water', got %s", habitDTO.Name)
|
||||
}
|
||||
|
||||
if result.Type != value_objects.HabitTypeValue {
|
||||
t.Errorf("Expected type %s, got %s", value_objects.HabitTypeValue, result.Type)
|
||||
if habitDTO.Type != value_objects.HabitTypeValue {
|
||||
t.Errorf("Expected type %s, got %s", value_objects.HabitTypeValue, habitDTO.Type)
|
||||
}
|
||||
|
||||
if result.Frequency != value_objects.FrequencyDaily {
|
||||
t.Errorf("Expected frequency %s, got %s", value_objects.FrequencyDaily, result.Frequency)
|
||||
if habitDTO.Frequency != value_objects.FrequencyDaily {
|
||||
t.Errorf("Expected frequency %s, got %s", value_objects.FrequencyDaily, habitDTO.Frequency)
|
||||
}
|
||||
|
||||
if result.TargetValue == nil || *result.TargetValue != 5.0 {
|
||||
t.Errorf("Expected target value 5.0, got %v", result.TargetValue)
|
||||
if habitDTO.TargetValue == nil || *habitDTO.TargetValue != 5.0 {
|
||||
t.Errorf("Expected target value 5.0, got %v", habitDTO.TargetValue)
|
||||
}
|
||||
|
||||
if !result.CarryOver {
|
||||
if !habitDTO.CarryOver {
|
||||
t.Error("Expected carry over to be true")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetUserHabitsHandler_WithPagination(t *testing.T) {
|
||||
var habits []*entities.Habit
|
||||
for i := 1; i <= 10; i++ {
|
||||
habit := entities.NewHabit("user-123", "Habit", value_objects.HabitTypeBoolean, value_objects.FrequencyDaily, false, false)
|
||||
habit.ID = "habit-" + string(rune(i+'0'))
|
||||
habits = append(habits, habit)
|
||||
}
|
||||
|
||||
habitRepo := &mockGetUserHabitsRepo{habits: habits}
|
||||
handler := NewGetUserHabitsHandler(habitRepo)
|
||||
|
||||
t.Run("FirstPage", func(t *testing.T) {
|
||||
params := pagination.NewParams(1, 5)
|
||||
query := GetUserHabitsQuery{
|
||||
UserID: "user-123",
|
||||
PaginationParams: ¶ms,
|
||||
}
|
||||
|
||||
result, err := handler.Handle(context.Background(), query)
|
||||
|
||||
if err != nil {
|
||||
t.Fatalf("Expected no error, got %v", err)
|
||||
}
|
||||
|
||||
if len(result.Habits) != 5 {
|
||||
t.Errorf("Expected 5 habits on first page, got %d", len(result.Habits))
|
||||
}
|
||||
|
||||
if result.Pagination == nil {
|
||||
t.Fatal("Expected pagination metadata")
|
||||
}
|
||||
|
||||
if result.Pagination.Page != 1 {
|
||||
t.Errorf("Expected page 1, got %d", result.Pagination.Page)
|
||||
}
|
||||
|
||||
if result.Pagination.PageSize != 5 {
|
||||
t.Errorf("Expected page_size 5, got %d", result.Pagination.PageSize)
|
||||
}
|
||||
|
||||
if result.Pagination.TotalItems != 10 {
|
||||
t.Errorf("Expected total_items 10, got %d", result.Pagination.TotalItems)
|
||||
}
|
||||
|
||||
if result.Pagination.TotalPages != 2 {
|
||||
t.Errorf("Expected total_pages 2, got %d", result.Pagination.TotalPages)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("SecondPage", func(t *testing.T) {
|
||||
params := pagination.NewParams(2, 5)
|
||||
query := GetUserHabitsQuery{
|
||||
UserID: "user-123",
|
||||
PaginationParams: ¶ms,
|
||||
}
|
||||
|
||||
result, err := handler.Handle(context.Background(), query)
|
||||
|
||||
if err != nil {
|
||||
t.Fatalf("Expected no error, got %v", err)
|
||||
}
|
||||
|
||||
if len(result.Habits) != 5 {
|
||||
t.Errorf("Expected 5 habits on second page, got %d", len(result.Habits))
|
||||
}
|
||||
|
||||
if result.Pagination.Page != 2 {
|
||||
t.Errorf("Expected page 2, got %d", result.Pagination.Page)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("PageBeyondTotal", func(t *testing.T) {
|
||||
params := pagination.NewParams(10, 5)
|
||||
query := GetUserHabitsQuery{
|
||||
UserID: "user-123",
|
||||
PaginationParams: ¶ms,
|
||||
}
|
||||
|
||||
result, err := handler.Handle(context.Background(), query)
|
||||
|
||||
if err != nil {
|
||||
t.Fatalf("Expected no error, got %v", err)
|
||||
}
|
||||
|
||||
if len(result.Habits) != 0 {
|
||||
t.Errorf("Expected 0 habits beyond total, got %d", len(result.Habits))
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("CustomPageSize", func(t *testing.T) {
|
||||
params := pagination.NewParams(1, 3)
|
||||
query := GetUserHabitsQuery{
|
||||
UserID: "user-123",
|
||||
PaginationParams: ¶ms,
|
||||
}
|
||||
|
||||
result, err := handler.Handle(context.Background(), query)
|
||||
|
||||
if err != nil {
|
||||
t.Fatalf("Expected no error, got %v", err)
|
||||
}
|
||||
|
||||
if len(result.Habits) != 3 {
|
||||
t.Errorf("Expected 3 habits with page_size=3, got %d", len(result.Habits))
|
||||
}
|
||||
|
||||
if result.Pagination.PageSize != 3 {
|
||||
t.Errorf("Expected page_size 3, got %d", result.Pagination.PageSize)
|
||||
}
|
||||
|
||||
if result.Pagination.TotalPages != 4 {
|
||||
t.Errorf("Expected total_pages 4 (10 items / 3 per page), got %d", result.Pagination.TotalPages)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func (m *mockGetUserHabitsRepo) FindByUserIDFiltered(ctx context.Context, userID string, filter repositories.HabitFilter, paginationParams *pagination.Params) ([]*entities.Habit, error) {
|
||||
var filtered []*entities.Habit
|
||||
|
||||
for _, habit := range m.habits {
|
||||
if filter.Type != nil && habit.Type != *filter.Type {
|
||||
continue
|
||||
}
|
||||
if filter.Frequency != nil && habit.Frequency != *filter.Frequency {
|
||||
continue
|
||||
}
|
||||
if !filter.IncludeArchived && habit.ArchivedAt != nil {
|
||||
continue
|
||||
}
|
||||
filtered = append(filtered, habit)
|
||||
}
|
||||
|
||||
if paginationParams != nil {
|
||||
offset := paginationParams.Offset()
|
||||
limit := paginationParams.Limit()
|
||||
|
||||
if offset >= len(filtered) {
|
||||
return []*entities.Habit{}, nil
|
||||
}
|
||||
|
||||
end := offset + limit
|
||||
if end > len(filtered) {
|
||||
end = len(filtered)
|
||||
}
|
||||
|
||||
return filtered[offset:end], nil
|
||||
}
|
||||
|
||||
return filtered, nil
|
||||
}
|
||||
|
||||
func (m *mockGetUserHabitsRepo) CountByUserIDFiltered(ctx context.Context, userID string, filter repositories.HabitFilter) (int, error) {
|
||||
count := 0
|
||||
|
||||
for _, habit := range m.habits {
|
||||
if filter.Type != nil && habit.Type != *filter.Type {
|
||||
continue
|
||||
}
|
||||
if filter.Frequency != nil && habit.Frequency != *filter.Frequency {
|
||||
continue
|
||||
}
|
||||
if !filter.IncludeArchived && habit.ArchivedAt != nil {
|
||||
continue
|
||||
}
|
||||
count++
|
||||
}
|
||||
|
||||
return count, nil
|
||||
}
|
||||
|
||||
func (m *mockGetUserHabitsRepo) GetChangesSince(ctx context.Context, userID string, since time.Time) (*repositories.HabitChanges, error) {
|
||||
return &repositories.HabitChanges{
|
||||
Created: []*entities.Habit{},
|
||||
Updated: []*entities.Habit{},
|
||||
Deleted: []string{},
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (m *mockGetUserHabitsRepo) SoftDelete(ctx context.Context, id string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func TestGetUserHabitsHandler_WithFilters(t *testing.T) {
|
||||
habit1 := entities.NewHabit("user-123", "Exercise", value_objects.HabitTypeBoolean, value_objects.FrequencyDaily, false, false)
|
||||
habit1.ID = "habit-1"
|
||||
|
||||
habit2 := entities.NewHabit("user-123", "Read", value_objects.HabitTypeCounter, value_objects.FrequencyWeekly, false, false)
|
||||
habit2.ID = "habit-2"
|
||||
|
||||
habit3 := entities.NewHabit("user-123", "Water", value_objects.HabitTypeValue, value_objects.FrequencyDaily, false, false)
|
||||
habit3.ID = "habit-3"
|
||||
|
||||
habitRepo := &mockGetUserHabitsRepo{habits: []*entities.Habit{habit1, habit2, habit3}}
|
||||
handler := NewGetUserHabitsHandler(habitRepo)
|
||||
|
||||
t.Run("FilterByType", func(t *testing.T) {
|
||||
habitType := value_objects.HabitTypeBoolean
|
||||
query := GetUserHabitsQuery{
|
||||
UserID: "user-123",
|
||||
FilterParams: &FilterParams{
|
||||
Type: &habitType,
|
||||
},
|
||||
}
|
||||
|
||||
result, err := handler.Handle(context.Background(), query)
|
||||
if err != nil {
|
||||
t.Fatalf("Expected no error, got %v", err)
|
||||
}
|
||||
|
||||
if len(result.Habits) != 1 {
|
||||
t.Errorf("Expected 1 BOOLEAN habit, got %d", len(result.Habits))
|
||||
}
|
||||
|
||||
if result.Habits[0].Type != value_objects.HabitTypeBoolean {
|
||||
t.Errorf("Expected BOOLEAN type, got %s", result.Habits[0].Type)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("FilterByFrequency", func(t *testing.T) {
|
||||
frequency := value_objects.FrequencyDaily
|
||||
query := GetUserHabitsQuery{
|
||||
UserID: "user-123",
|
||||
FilterParams: &FilterParams{
|
||||
Frequency: &frequency,
|
||||
},
|
||||
}
|
||||
|
||||
result, err := handler.Handle(context.Background(), query)
|
||||
if err != nil {
|
||||
t.Fatalf("Expected no error, got %v", err)
|
||||
}
|
||||
|
||||
if len(result.Habits) != 2 {
|
||||
t.Errorf("Expected 2 DAILY habits, got %d", len(result.Habits))
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("FilterWithPagination", func(t *testing.T) {
|
||||
frequency := value_objects.FrequencyDaily
|
||||
params := pagination.NewParams(1, 1)
|
||||
query := GetUserHabitsQuery{
|
||||
UserID: "user-123",
|
||||
FilterParams: &FilterParams{
|
||||
Frequency: &frequency,
|
||||
},
|
||||
PaginationParams: ¶ms,
|
||||
}
|
||||
|
||||
result, err := handler.Handle(context.Background(), query)
|
||||
if err != nil {
|
||||
t.Fatalf("Expected no error, got %v", err)
|
||||
}
|
||||
|
||||
if len(result.Habits) != 1 {
|
||||
t.Errorf("Expected 1 habit on first page, got %d", len(result.Habits))
|
||||
}
|
||||
|
||||
if result.Pagination == nil {
|
||||
t.Fatal("Expected pagination metadata")
|
||||
}
|
||||
|
||||
if result.Pagination.TotalItems != 2 {
|
||||
t.Errorf("Expected 2 total DAILY habits, got %d", result.Pagination.TotalItems)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -14,9 +14,8 @@ type LoginUserQuery struct {
|
||||
}
|
||||
|
||||
type LoginUserResult struct {
|
||||
UserID string
|
||||
Email string
|
||||
Timezone string
|
||||
UserID string
|
||||
Email string
|
||||
}
|
||||
|
||||
type LoginUserHandler struct {
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -19,7 +19,9 @@ type Habit struct {
|
||||
IsNegative bool
|
||||
TargetValue *float64
|
||||
CreatedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
ArchivedAt *time.Time
|
||||
DeletedAt *time.Time
|
||||
}
|
||||
|
||||
func NewHabit(
|
||||
@@ -30,6 +32,7 @@ func NewHabit(
|
||||
carryOver bool,
|
||||
isNegative bool,
|
||||
) *Habit {
|
||||
now := time.Now()
|
||||
return &Habit{
|
||||
UserID: userID,
|
||||
Name: name,
|
||||
@@ -37,15 +40,31 @@ func NewHabit(
|
||||
Frequency: frequency,
|
||||
CarryOver: carryOver,
|
||||
IsNegative: isNegative,
|
||||
CreatedAt: time.Now(),
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
}
|
||||
}
|
||||
|
||||
func (h *Habit) Archive() {
|
||||
now := time.Now()
|
||||
h.ArchivedAt = &now
|
||||
h.UpdatedAt = now
|
||||
}
|
||||
|
||||
func (h *Habit) IsActive() bool {
|
||||
return h.ArchivedAt == nil
|
||||
}
|
||||
|
||||
func (h *Habit) Delete() {
|
||||
now := time.Now()
|
||||
h.DeletedAt = &now
|
||||
h.UpdatedAt = now
|
||||
}
|
||||
|
||||
func (h *Habit) IsDeleted() bool {
|
||||
return h.DeletedAt != nil
|
||||
}
|
||||
|
||||
func (h *Habit) Touch() {
|
||||
h.UpdatedAt = time.Now()
|
||||
}
|
||||
|
||||
@@ -8,13 +8,27 @@ type HabitEntry struct {
|
||||
ScheduledDate time.Time
|
||||
CompletedAt time.Time
|
||||
Value *float64
|
||||
UpdatedAt time.Time
|
||||
DeletedAt *time.Time
|
||||
}
|
||||
|
||||
func NewHabitEntry(habitID string, scheduledDate time.Time, value *float64) *HabitEntry {
|
||||
now := time.Now()
|
||||
return &HabitEntry{
|
||||
HabitID: habitID,
|
||||
ScheduledDate: scheduledDate,
|
||||
CompletedAt: time.Now(),
|
||||
CompletedAt: now,
|
||||
Value: value,
|
||||
UpdatedAt: now,
|
||||
}
|
||||
}
|
||||
|
||||
func (e *HabitEntry) Delete() {
|
||||
now := time.Now()
|
||||
e.DeletedAt = &now
|
||||
e.UpdatedAt = now
|
||||
}
|
||||
|
||||
func (e *HabitEntry) IsDeleted() bool {
|
||||
return e.DeletedAt != nil
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,12 +7,23 @@ import (
|
||||
"apocapoc-api/internal/domain/entities"
|
||||
)
|
||||
|
||||
type HabitEntryChanges struct {
|
||||
Created []*entities.HabitEntry
|
||||
Updated []*entities.HabitEntry
|
||||
Deleted []string
|
||||
}
|
||||
|
||||
type HabitEntryRepository interface {
|
||||
Create(ctx context.Context, entry *entities.HabitEntry) error
|
||||
FindByID(ctx context.Context, id string) (*entities.HabitEntry, error)
|
||||
FindByHabitID(ctx context.Context, habitID string) ([]*entities.HabitEntry, error)
|
||||
FindByHabitIDAndDateRange(ctx context.Context, habitID string, from, to time.Time) ([]*entities.HabitEntry, error)
|
||||
FindByUserID(ctx context.Context, userID string) ([]*entities.HabitEntry, error)
|
||||
FindPendingByHabitID(ctx context.Context, habitID string, beforeDate time.Time) ([]*entities.HabitEntry, error)
|
||||
Update(ctx context.Context, entry *entities.HabitEntry) error
|
||||
Delete(ctx context.Context, id string) error
|
||||
|
||||
// Sync methods
|
||||
GetChangesSince(ctx context.Context, userID string, since time.Time) (*HabitEntryChanges, error)
|
||||
SoftDelete(ctx context.Context, id string) error
|
||||
}
|
||||
|
||||
@@ -2,15 +2,39 @@ package repositories
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"apocapoc-api/internal/domain/entities"
|
||||
"apocapoc-api/internal/domain/value_objects"
|
||||
"apocapoc-api/internal/shared/pagination"
|
||||
)
|
||||
|
||||
type HabitFilter struct {
|
||||
Type *value_objects.HabitType
|
||||
Frequency *value_objects.Frequency
|
||||
IncludeArchived bool
|
||||
Search string
|
||||
}
|
||||
|
||||
type HabitChanges struct {
|
||||
Created []*entities.Habit
|
||||
Updated []*entities.Habit
|
||||
Deleted []string
|
||||
}
|
||||
|
||||
type HabitRepository interface {
|
||||
Create(ctx context.Context, habit *entities.Habit) error
|
||||
FindByID(ctx context.Context, id string) (*entities.Habit, error)
|
||||
FindByUserID(ctx context.Context, userID string) ([]*entities.Habit, error)
|
||||
FindActiveByUserID(ctx context.Context, userID string) ([]*entities.Habit, error)
|
||||
FindActiveByUserIDWithPagination(ctx context.Context, userID string, params pagination.Params) ([]*entities.Habit, error)
|
||||
FindByUserIDFiltered(ctx context.Context, userID string, filter HabitFilter, paginationParams *pagination.Params) ([]*entities.Habit, error)
|
||||
CountActiveByUserID(ctx context.Context, userID string) (int, error)
|
||||
CountByUserIDFiltered(ctx context.Context, userID string, filter HabitFilter) (int, error)
|
||||
Update(ctx context.Context, habit *entities.Habit) error
|
||||
Delete(ctx context.Context, id string) error
|
||||
|
||||
// Sync methods
|
||||
GetChangesSince(ctx context.Context, userID string, since time.Time) (*HabitChanges, error)
|
||||
SoftDelete(ctx context.Context, id string) error
|
||||
}
|
||||
|
||||
@@ -9,4 +9,5 @@ type EmailMessage struct {
|
||||
|
||||
type EmailService interface {
|
||||
Send(message EmailMessage) error
|
||||
HealthCheck() error
|
||||
}
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"apocapoc-api/internal/domain/entities"
|
||||
"apocapoc-api/internal/shared/utils"
|
||||
)
|
||||
|
||||
type StreakResult struct {
|
||||
Current int
|
||||
Longest int
|
||||
}
|
||||
|
||||
func CalculateStreaks(entries []*entities.HabitEntry, habit *entities.Habit, now time.Time) StreakResult {
|
||||
entryMap := buildEntryMap(entries)
|
||||
scheduled := allScheduledDates(habit, now)
|
||||
|
||||
if len(scheduled) == 0 {
|
||||
return StreakResult{}
|
||||
}
|
||||
|
||||
today := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, time.UTC)
|
||||
longest := 0
|
||||
current := 0
|
||||
|
||||
for _, d := range scheduled {
|
||||
if IsDaySuccessful(d.Format("2006-01-02"), entryMap, habit) {
|
||||
current++
|
||||
if current > longest {
|
||||
longest = current
|
||||
}
|
||||
} else if d.Equal(today) && !habit.IsNegative {
|
||||
continue
|
||||
} else {
|
||||
current = 0
|
||||
}
|
||||
}
|
||||
|
||||
return StreakResult{Current: current, Longest: longest}
|
||||
}
|
||||
|
||||
func buildEntryMap(entries []*entities.HabitEntry) map[string]*entities.HabitEntry {
|
||||
m := make(map[string]*entities.HabitEntry)
|
||||
for _, e := range entries {
|
||||
m[e.ScheduledDate.Format("2006-01-02")] = e
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
func allScheduledDates(habit *entities.Habit, now time.Time) []time.Time {
|
||||
start := time.Date(habit.CreatedAt.Year(), habit.CreatedAt.Month(), habit.CreatedAt.Day(), 0, 0, 0, 0, time.UTC)
|
||||
today := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, time.UTC)
|
||||
freq := string(habit.Frequency)
|
||||
|
||||
var dates []time.Time
|
||||
for d := start; !d.After(today); d = d.AddDate(0, 0, 1) {
|
||||
if utils.ShouldAppearToday(freq, habit.SpecificDays, habit.SpecificDates, d) {
|
||||
dates = append(dates, d)
|
||||
}
|
||||
}
|
||||
return dates
|
||||
}
|
||||
|
||||
func IsDaySuccessful(dateStr string, entryMap map[string]*entities.HabitEntry, habit *entities.Habit) bool {
|
||||
entry, hasEntry := entryMap[dateStr]
|
||||
habitType := string(habit.Type)
|
||||
|
||||
if !habit.IsNegative {
|
||||
if !hasEntry {
|
||||
return false
|
||||
}
|
||||
if habit.TargetValue != nil && entry.Value != nil {
|
||||
return *entry.Value >= *habit.TargetValue
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
if habitType == "VALUE" && habit.TargetValue != nil {
|
||||
if !hasEntry {
|
||||
return false
|
||||
}
|
||||
if entry.Value != nil {
|
||||
return *entry.Value <= *habit.TargetValue
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
if !hasEntry {
|
||||
return true
|
||||
}
|
||||
|
||||
if habit.TargetValue != nil && entry.Value != nil {
|
||||
return *entry.Value <= *habit.TargetValue
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
@@ -0,0 +1,337 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"apocapoc-api/internal/domain/entities"
|
||||
"apocapoc-api/internal/domain/value_objects"
|
||||
)
|
||||
|
||||
func makeEntry(d time.Time, value *float64) *entities.HabitEntry {
|
||||
return &entities.HabitEntry{ScheduledDate: d, Value: value}
|
||||
}
|
||||
|
||||
func floatPtr(v float64) *float64 { return &v }
|
||||
|
||||
func dt(year, month, day int) time.Time {
|
||||
return time.Date(year, time.Month(month), day, 0, 0, 0, 0, time.UTC)
|
||||
}
|
||||
|
||||
func habit(t value_objects.HabitType, negative bool, target *float64) *entities.Habit {
|
||||
return &entities.Habit{
|
||||
Type: t,
|
||||
Frequency: value_objects.FrequencyDaily,
|
||||
IsNegative: negative,
|
||||
TargetValue: target,
|
||||
CreatedAt: dt(2026, 1, 1),
|
||||
}
|
||||
}
|
||||
|
||||
// --- IsDaySuccessful ---
|
||||
|
||||
func TestIsDaySuccessful_BooleanPositive(t *testing.T) {
|
||||
h := habit(value_objects.HabitTypeBoolean, false, nil)
|
||||
em := map[string]*entities.HabitEntry{"2026-03-01": makeEntry(dt(2026, 3, 1), nil)}
|
||||
|
||||
if !IsDaySuccessful("2026-03-01", em, h) {
|
||||
t.Error("entry should be success")
|
||||
}
|
||||
if IsDaySuccessful("2026-03-01", map[string]*entities.HabitEntry{}, h) {
|
||||
t.Error("no entry should be failure")
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsDaySuccessful_BooleanNegative(t *testing.T) {
|
||||
h := habit(value_objects.HabitTypeBoolean, true, nil)
|
||||
em := map[string]*entities.HabitEntry{"2026-03-01": makeEntry(dt(2026, 3, 1), nil)}
|
||||
|
||||
if !IsDaySuccessful("2026-03-01", map[string]*entities.HabitEntry{}, h) {
|
||||
t.Error("no entry should be success (resisted)")
|
||||
}
|
||||
if IsDaySuccessful("2026-03-01", em, h) {
|
||||
t.Error("entry should be failure")
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsDaySuccessful_CounterPositiveNoTarget(t *testing.T) {
|
||||
h := habit(value_objects.HabitTypeCounter, false, nil)
|
||||
|
||||
if !IsDaySuccessful("2026-03-01", map[string]*entities.HabitEntry{"2026-03-01": makeEntry(dt(2026, 3, 1), floatPtr(5))}, h) {
|
||||
t.Error("entry should be success")
|
||||
}
|
||||
if IsDaySuccessful("2026-03-01", map[string]*entities.HabitEntry{}, h) {
|
||||
t.Error("no entry should be failure")
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsDaySuccessful_CounterPositiveWithTarget(t *testing.T) {
|
||||
h := habit(value_objects.HabitTypeCounter, false, floatPtr(8))
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
value *float64
|
||||
has bool
|
||||
success bool
|
||||
}{
|
||||
{"value >= target", floatPtr(10), true, true},
|
||||
{"value == target", floatPtr(8), true, true},
|
||||
{"value < target", floatPtr(3), true, false},
|
||||
{"no entry", nil, false, false},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
em := map[string]*entities.HabitEntry{}
|
||||
if tc.has {
|
||||
em["2026-03-01"] = makeEntry(dt(2026, 3, 1), tc.value)
|
||||
}
|
||||
if IsDaySuccessful("2026-03-01", em, h) != tc.success {
|
||||
t.Errorf("Expected %v", tc.success)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsDaySuccessful_CounterNegativeNoTarget(t *testing.T) {
|
||||
h := habit(value_objects.HabitTypeCounter, true, nil)
|
||||
|
||||
if !IsDaySuccessful("2026-03-01", map[string]*entities.HabitEntry{}, h) {
|
||||
t.Error("no entry should be success")
|
||||
}
|
||||
if IsDaySuccessful("2026-03-01", map[string]*entities.HabitEntry{"2026-03-01": makeEntry(dt(2026, 3, 1), floatPtr(3))}, h) {
|
||||
t.Error("entry should be failure")
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsDaySuccessful_CounterNegativeWithTarget(t *testing.T) {
|
||||
h := habit(value_objects.HabitTypeCounter, true, floatPtr(2))
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
value *float64
|
||||
has bool
|
||||
success bool
|
||||
}{
|
||||
{"no entry", nil, false, true},
|
||||
{"within limit", floatPtr(1), true, true},
|
||||
{"at limit", floatPtr(2), true, true},
|
||||
{"over limit", floatPtr(5), true, false},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
em := map[string]*entities.HabitEntry{}
|
||||
if tc.has {
|
||||
em["2026-03-01"] = makeEntry(dt(2026, 3, 1), tc.value)
|
||||
}
|
||||
if IsDaySuccessful("2026-03-01", em, h) != tc.success {
|
||||
t.Errorf("Expected %v", tc.success)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsDaySuccessful_ValuePositiveNoTarget(t *testing.T) {
|
||||
h := habit(value_objects.HabitTypeValue, false, nil)
|
||||
|
||||
if !IsDaySuccessful("2026-03-01", map[string]*entities.HabitEntry{"2026-03-01": makeEntry(dt(2026, 3, 1), floatPtr(72))}, h) {
|
||||
t.Error("entry should be success")
|
||||
}
|
||||
if IsDaySuccessful("2026-03-01", map[string]*entities.HabitEntry{}, h) {
|
||||
t.Error("no entry should be failure")
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsDaySuccessful_ValuePositiveWithTarget(t *testing.T) {
|
||||
h := habit(value_objects.HabitTypeValue, false, floatPtr(7))
|
||||
|
||||
if !IsDaySuccessful("2026-03-01", map[string]*entities.HabitEntry{"2026-03-01": makeEntry(dt(2026, 3, 1), floatPtr(8))}, h) {
|
||||
t.Error("value >= target should be success")
|
||||
}
|
||||
if IsDaySuccessful("2026-03-01", map[string]*entities.HabitEntry{"2026-03-01": makeEntry(dt(2026, 3, 1), floatPtr(5))}, h) {
|
||||
t.Error("value < target should be failure")
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsDaySuccessful_ValueNegativeNoTarget(t *testing.T) {
|
||||
h := habit(value_objects.HabitTypeValue, true, nil)
|
||||
|
||||
if !IsDaySuccessful("2026-03-01", map[string]*entities.HabitEntry{}, h) {
|
||||
t.Error("no entry should be success")
|
||||
}
|
||||
if IsDaySuccessful("2026-03-01", map[string]*entities.HabitEntry{"2026-03-01": makeEntry(dt(2026, 3, 1), floatPtr(3))}, h) {
|
||||
t.Error("entry should be failure")
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsDaySuccessful_ValueNegativeWithTarget(t *testing.T) {
|
||||
h := habit(value_objects.HabitTypeValue, true, floatPtr(70))
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
value *float64
|
||||
has bool
|
||||
success bool
|
||||
}{
|
||||
{"below target", floatPtr(68), true, true},
|
||||
{"above target", floatPtr(75), true, false},
|
||||
{"no entry (didnt track)", nil, false, false},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
em := map[string]*entities.HabitEntry{}
|
||||
if tc.has {
|
||||
em["2026-03-01"] = makeEntry(dt(2026, 3, 1), tc.value)
|
||||
}
|
||||
if IsDaySuccessful("2026-03-01", em, h) != tc.success {
|
||||
t.Errorf("Expected %v", tc.success)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// --- CalculateStreaks ---
|
||||
|
||||
func TestStreaks_DailyBooleanPositive(t *testing.T) {
|
||||
h := habit(value_objects.HabitTypeBoolean, false, nil)
|
||||
|
||||
t.Run("3 consecutive days", func(t *testing.T) {
|
||||
entries := []*entities.HabitEntry{
|
||||
makeEntry(dt(2026, 3, 1), nil),
|
||||
makeEntry(dt(2026, 3, 2), nil),
|
||||
makeEntry(dt(2026, 3, 3), nil),
|
||||
}
|
||||
r := CalculateStreaks(entries, h, dt(2026, 3, 3))
|
||||
if r.Current != 3 || r.Longest != 3 {
|
||||
t.Errorf("Expected current=3 longest=3, got current=%d longest=%d", r.Current, r.Longest)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("gap finds longest and current separately", func(t *testing.T) {
|
||||
entries := []*entities.HabitEntry{
|
||||
makeEntry(dt(2026, 3, 1), nil),
|
||||
makeEntry(dt(2026, 3, 2), nil),
|
||||
makeEntry(dt(2026, 3, 3), nil),
|
||||
// gap Mar 4
|
||||
makeEntry(dt(2026, 3, 5), nil),
|
||||
}
|
||||
r := CalculateStreaks(entries, h, dt(2026, 3, 5))
|
||||
if r.Current != 1 || r.Longest != 3 {
|
||||
t.Errorf("Expected current=1 longest=3, got current=%d longest=%d", r.Current, r.Longest)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("today not completed doesnt break streak", func(t *testing.T) {
|
||||
entries := []*entities.HabitEntry{
|
||||
makeEntry(dt(2026, 3, 1), nil),
|
||||
makeEntry(dt(2026, 3, 2), nil),
|
||||
}
|
||||
r := CalculateStreaks(entries, h, dt(2026, 3, 3))
|
||||
if r.Current != 2 || r.Longest != 2 {
|
||||
t.Errorf("Expected current=2 longest=2, got current=%d longest=%d", r.Current, r.Longest)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("missed yesterday breaks streak", func(t *testing.T) {
|
||||
entries := []*entities.HabitEntry{
|
||||
makeEntry(dt(2026, 3, 1), nil),
|
||||
makeEntry(dt(2026, 3, 2), nil),
|
||||
}
|
||||
r := CalculateStreaks(entries, h, dt(2026, 3, 4))
|
||||
if r.Current != 0 || r.Longest != 2 {
|
||||
t.Errorf("Expected current=0 longest=2, got current=%d longest=%d", r.Current, r.Longest)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestStreaks_DailyBooleanNegative(t *testing.T) {
|
||||
h := habit(value_objects.HabitTypeBoolean, true, nil)
|
||||
h.CreatedAt = dt(2026, 3, 1)
|
||||
|
||||
t.Run("3 days no entries is 3 streak", func(t *testing.T) {
|
||||
r := CalculateStreaks(nil, h, dt(2026, 3, 3))
|
||||
if r.Current != 3 || r.Longest != 3 {
|
||||
t.Errorf("Expected current=3 longest=3, got current=%d longest=%d", r.Current, r.Longest)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("entry breaks streak", func(t *testing.T) {
|
||||
entries := []*entities.HabitEntry{makeEntry(dt(2026, 3, 2), nil)}
|
||||
r := CalculateStreaks(entries, h, dt(2026, 3, 3))
|
||||
if r.Current != 1 || r.Longest != 1 {
|
||||
t.Errorf("Expected current=1 longest=1, got current=%d longest=%d", r.Current, r.Longest)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestStreaks_WeeklyBooleanPositive(t *testing.T) {
|
||||
h := &entities.Habit{
|
||||
Type: value_objects.HabitTypeBoolean,
|
||||
Frequency: value_objects.FrequencyWeekly,
|
||||
SpecificDays: []int{1, 3, 5},
|
||||
CreatedAt: dt(2026, 3, 1),
|
||||
}
|
||||
|
||||
t.Run("3 consecutive scheduled days", func(t *testing.T) {
|
||||
entries := []*entities.HabitEntry{
|
||||
makeEntry(dt(2026, 3, 2), nil),
|
||||
makeEntry(dt(2026, 3, 4), nil),
|
||||
makeEntry(dt(2026, 3, 6), nil),
|
||||
}
|
||||
r := CalculateStreaks(entries, h, dt(2026, 3, 6))
|
||||
if r.Current != 3 || r.Longest != 3 {
|
||||
t.Errorf("Expected current=3 longest=3, got current=%d longest=%d", r.Current, r.Longest)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("missed Wednesday breaks streak", func(t *testing.T) {
|
||||
entries := []*entities.HabitEntry{
|
||||
makeEntry(dt(2026, 3, 2), nil),
|
||||
makeEntry(dt(2026, 3, 6), nil),
|
||||
}
|
||||
r := CalculateStreaks(entries, h, dt(2026, 3, 6))
|
||||
if r.Current != 1 || r.Longest != 1 {
|
||||
t.Errorf("Expected current=1 longest=1, got current=%d longest=%d", r.Current, r.Longest)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestStreaks_CounterWithTarget(t *testing.T) {
|
||||
h := habit(value_objects.HabitTypeCounter, false, floatPtr(8))
|
||||
|
||||
t.Run("all meet target", func(t *testing.T) {
|
||||
entries := []*entities.HabitEntry{
|
||||
makeEntry(dt(2026, 3, 1), floatPtr(8)),
|
||||
makeEntry(dt(2026, 3, 2), floatPtr(10)),
|
||||
makeEntry(dt(2026, 3, 3), floatPtr(9)),
|
||||
}
|
||||
r := CalculateStreaks(entries, h, dt(2026, 3, 3))
|
||||
if r.Current != 3 {
|
||||
t.Errorf("Expected current=3, got %d", r.Current)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestStreaks_WeeklyCounterNegativeWithTarget(t *testing.T) {
|
||||
h := &entities.Habit{
|
||||
Type: value_objects.HabitTypeCounter,
|
||||
Frequency: value_objects.FrequencyWeekly,
|
||||
SpecificDays: []int{1, 5},
|
||||
IsNegative: true,
|
||||
TargetValue: floatPtr(2),
|
||||
CreatedAt: dt(2026, 3, 1),
|
||||
}
|
||||
|
||||
t.Run("within limit and no entry both count as success", func(t *testing.T) {
|
||||
entries := []*entities.HabitEntry{
|
||||
makeEntry(dt(2026, 3, 2), floatPtr(1)),
|
||||
makeEntry(dt(2026, 3, 9), floatPtr(5)),
|
||||
}
|
||||
r := CalculateStreaks(entries, h, dt(2026, 3, 13))
|
||||
if r.Current != 1 || r.Longest != 2 {
|
||||
t.Errorf("Expected current=1 longest=2, got current=%d longest=%d", r.Current, r.Longest)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -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 {
|
||||
@@ -19,12 +20,13 @@ type CreateHabitRequest struct {
|
||||
}
|
||||
|
||||
type UpdateHabitRequest struct {
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description"`
|
||||
SpecificDays []int `json:"specific_days,omitempty"`
|
||||
SpecificDates []int `json:"specific_dates,omitempty"`
|
||||
CarryOver bool `json:"carry_over"`
|
||||
TargetValue *float64 `json:"target_value,omitempty"`
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description"`
|
||||
Frequency value_objects.Frequency `json:"frequency"`
|
||||
SpecificDays []int `json:"specific_days,omitempty"`
|
||||
SpecificDates []int `json:"specific_dates,omitempty"`
|
||||
CarryOver bool `json:"carry_over"`
|
||||
TargetValue *float64 `json:"target_value,omitempty"`
|
||||
}
|
||||
|
||||
type HabitResponse struct {
|
||||
@@ -76,6 +78,11 @@ type UserHabitResponse struct {
|
||||
IsNegative bool `json:"is_negative"`
|
||||
}
|
||||
|
||||
type GetUserHabitsResponse struct {
|
||||
Data []UserHabitResponse `json:"data"`
|
||||
Pagination *pagination.Response `json:"pagination,omitempty"`
|
||||
}
|
||||
|
||||
type HabitEntryResponse struct {
|
||||
ID string `json:"id"`
|
||||
HabitID string `json:"habit_id"`
|
||||
@@ -99,3 +106,51 @@ type ValidationErrorResponse struct {
|
||||
Error string `json:"error"`
|
||||
Field string `json:"field"`
|
||||
}
|
||||
|
||||
type SyncHabitDTO struct {
|
||||
ID string `json:"id"`
|
||||
UserID string `json:"user_id"`
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description"`
|
||||
Type value_objects.HabitType `json:"type"`
|
||||
Frequency value_objects.Frequency `json:"frequency"`
|
||||
SpecificDays []int `json:"specific_days,omitempty"`
|
||||
SpecificDates []int `json:"specific_dates,omitempty"`
|
||||
CarryOver bool `json:"carry_over"`
|
||||
IsNegative bool `json:"is_negative"`
|
||||
TargetValue *float64 `json:"target_value,omitempty"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
ArchivedAt *time.Time `json:"archived_at,omitempty"`
|
||||
}
|
||||
|
||||
type SyncHabitEntryDTO struct {
|
||||
ID string `json:"id"`
|
||||
HabitID string `json:"habit_id"`
|
||||
ScheduledDate time.Time `json:"scheduled_date"`
|
||||
CompletedAt time.Time `json:"completed_at"`
|
||||
Value *float64 `json:"value,omitempty"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
type HabitChangesDTO struct {
|
||||
Created []SyncHabitDTO `json:"created"`
|
||||
Updated []SyncHabitDTO `json:"updated"`
|
||||
Deleted []string `json:"deleted"`
|
||||
}
|
||||
|
||||
type EntryChangesDTO struct {
|
||||
Created []SyncHabitEntryDTO `json:"created"`
|
||||
Updated []SyncHabitEntryDTO `json:"updated"`
|
||||
Deleted []string `json:"deleted"`
|
||||
}
|
||||
|
||||
type SyncChangesResponse struct {
|
||||
Habits HabitChangesDTO `json:"habits"`
|
||||
Entries EntryChangesDTO `json:"entries"`
|
||||
}
|
||||
|
||||
type SyncBatchRequest struct {
|
||||
Habits HabitChangesDTO `json:"habits"`
|
||||
Entries EntryChangesDTO `json:"entries"`
|
||||
}
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
package http
|
||||
|
||||
import (
|
||||
"compress/gzip"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
|
||||
"apocapoc-api/internal/application/queries"
|
||||
"apocapoc-api/internal/i18n"
|
||||
)
|
||||
|
||||
type ExportHandlers struct {
|
||||
exportHandler *queries.ExportUserDataHandler
|
||||
translator *i18n.Translator
|
||||
}
|
||||
|
||||
func NewExportHandlers(
|
||||
exportHandler *queries.ExportUserDataHandler,
|
||||
translator *i18n.Translator,
|
||||
) *ExportHandlers {
|
||||
return &ExportHandlers{
|
||||
exportHandler: exportHandler,
|
||||
translator: translator,
|
||||
}
|
||||
}
|
||||
|
||||
// ExportData godoc
|
||||
// @Summary Export user data
|
||||
// @Description Export all user habits and entries in JSON format with gzip compression. Limited to 1 export per hour.
|
||||
// @Tags export
|
||||
// @Security BearerAuth
|
||||
// @Produce json
|
||||
// @Success 200 {object} queries.ExportUserDataResult "Compressed JSON export"
|
||||
// @Failure 401 {object} ErrorResponse "Unauthorized"
|
||||
// @Failure 429 {object} ErrorResponse "Rate limit exceeded"
|
||||
// @Failure 500 {object} ErrorResponse "Internal server error"
|
||||
// @Router /export [get]
|
||||
func (h *ExportHandlers) ExportData(w http.ResponseWriter, r *http.Request) {
|
||||
userID := r.Context().Value("user_id").(string)
|
||||
|
||||
query := queries.ExportUserDataQuery{
|
||||
UserID: userID,
|
||||
}
|
||||
|
||||
result, err := h.exportHandler.Handle(r.Context(), query)
|
||||
if err != nil {
|
||||
respondErrorI18n(w, r, h.translator, http.StatusInternalServerError, "export_failed")
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.Header().Set("Content-Encoding", "gzip")
|
||||
w.Header().Set("Content-Disposition", "attachment; filename=\"apocapoc-export.json.gz\"")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
|
||||
gzipWriter := gzip.NewWriter(w)
|
||||
defer gzipWriter.Close()
|
||||
|
||||
encoder := json.NewEncoder(gzipWriter)
|
||||
encoder.SetIndent("", " ")
|
||||
|
||||
if err := encoder.Encode(result); err != nil {
|
||||
return
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -245,10 +313,11 @@ func (h *HabitHandlers) UpdateHabit(w http.ResponseWriter, r *http.Request) {
|
||||
UserID: userID,
|
||||
Name: req.Name,
|
||||
Description: req.Description,
|
||||
CarryOver: req.CarryOver,
|
||||
TargetValue: req.TargetValue,
|
||||
Frequency: req.Frequency,
|
||||
SpecificDays: req.SpecificDays,
|
||||
SpecificDates: req.SpecificDates,
|
||||
CarryOver: req.CarryOver,
|
||||
TargetValue: req.TargetValue,
|
||||
}
|
||||
|
||||
if err := h.updateHandler.Handle(r.Context(), cmd); err != nil {
|
||||
@@ -440,11 +509,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 +526,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 +543,7 @@ func (h *HabitHandlers) GetTodaysHabits(w http.ResponseWriter, r *http.Request)
|
||||
|
||||
query := queries.GetTodaysHabitsQuery{
|
||||
UserID: userID,
|
||||
Timezone: user.Timezone,
|
||||
Timezone: timezone,
|
||||
Date: todayDate,
|
||||
}
|
||||
|
||||
|
||||
@@ -89,6 +89,7 @@ func TestHabitCRUDFlow(t *testing.T) {
|
||||
reqBody := UpdateHabitRequest{
|
||||
Name: "Morning Exercise",
|
||||
Description: "Updated description",
|
||||
Frequency: "DAILY",
|
||||
}
|
||||
|
||||
rr := makeRequest(t, *ts.Router, "PUT", "/api/v1/habits/"+habitID, reqBody, token)
|
||||
|
||||
@@ -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)
|
||||
@@ -69,13 +70,18 @@ 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)
|
||||
statsHandlers := NewStatsHandlers(getHabitStatsHandler, translator)
|
||||
healthHandlers := NewHealthHandlers(db)
|
||||
userHandlers := NewUserHandlers(deleteUserHandler, translator)
|
||||
getSyncChangesHandler := queries.NewGetSyncChangesHandler(habitRepo, entryRepo)
|
||||
applySyncBatchHandler := commands.NewApplySyncBatchHandler(habitRepo, entryRepo)
|
||||
|
||||
router := NewRouter("http://localhost:3000", habitHandlers, authHandlers, statsHandlers, healthHandlers, userHandlers, jwtService, translator)
|
||||
authHandlers := NewAuthHandlers(registerHandler, loginHandler, refreshTokenHandler, revokeTokenHandler, revokeAllTokensHandler, verifyEmailHandler, resendVerificationEmailHandler, requestPasswordResetHandler, resetPasswordHandler, jwtService, refreshTokenRepo, refreshTokenExpiry, translator)
|
||||
habitHandlers := NewHabitHandlers(createHandler, getTodaysHandler, getUserHabitsHandler, getHabitByIDHandler, getHabitEntriesHandler, updateHandler, archiveHandler, markHandler, unmarkHandler, translator)
|
||||
statsHandlers := NewStatsHandlers(getHabitStatsHandler, translator)
|
||||
healthHandlers := NewHealthHandlers(db, nil)
|
||||
userHandlers := NewUserHandlers(deleteUserHandler, translator)
|
||||
exportHandlers := NewExportHandlers(exportUserDataHandler, translator)
|
||||
syncHandlers := NewSyncHandlers(getSyncChangesHandler, applySyncBatchHandler, translator)
|
||||
|
||||
router := NewRouter("http://localhost:3000", habitHandlers, authHandlers, statsHandlers, healthHandlers, userHandlers, exportHandlers, syncHandlers, jwtService, translator)
|
||||
|
||||
handler := http.Handler(router)
|
||||
return &TestServer{
|
||||
@@ -120,7 +126,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, syncHandlers *SyncHandlers, jwtService *auth.JWTService, translator *i18n.Translator) *chi.Mux {
|
||||
r := chi.NewRouter()
|
||||
|
||||
r.Use(middleware.Logger)
|
||||
r.Use(logger.Middleware)
|
||||
r.Use(middleware.Recoverer)
|
||||
r.Use(i18n.LanguageMiddleware(translator))
|
||||
r.Use(cors.Handler(cors.Options{
|
||||
@@ -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,18 @@ 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)
|
||||
})
|
||||
|
||||
r.Route("/api/v1/sync", func(r chi.Router) {
|
||||
r.Use(AuthMiddleware(jwtService))
|
||||
r.Use(RateLimitByUser(jwtService, 100, 1*time.Minute))
|
||||
r.Get("/changes", syncHandlers.GetSyncChanges)
|
||||
r.Post("/batch", syncHandlers.ApplySyncBatch)
|
||||
})
|
||||
|
||||
return r
|
||||
}
|
||||
|
||||
@@ -27,7 +27,7 @@ func NewStatsHandlers(
|
||||
|
||||
// GetHabitStats godoc
|
||||
// @Summary Get habit statistics
|
||||
// @Description Get statistics for a specific habit including streaks and completion rates
|
||||
// @Description Get statistics for a specific habit including streaks and completions
|
||||
// @Tags stats
|
||||
// @Produce json
|
||||
// @Security BearerAuth
|
||||
|
||||
@@ -0,0 +1,160 @@
|
||||
package http
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"apocapoc-api/internal/application/queries"
|
||||
)
|
||||
|
||||
func TestHabitStatsFlow(t *testing.T) {
|
||||
ts := setupTestServer(t)
|
||||
defer ts.Close()
|
||||
|
||||
token := registerAndLogin(t, *ts.Router, "statsuser@example.com", "Password123!")
|
||||
|
||||
habitBody := CreateHabitRequest{
|
||||
Name: "Meditation",
|
||||
Type: "BOOLEAN",
|
||||
Frequency: "DAILY",
|
||||
}
|
||||
rr := makeRequest(t, *ts.Router, "POST", "/api/v1/habits", habitBody, token)
|
||||
var habitResp map[string]string
|
||||
decodeResponse(t, rr, &habitResp)
|
||||
habitID := habitResp["id"]
|
||||
|
||||
t.Run("Stats for new habit should be zero", func(t *testing.T) {
|
||||
rr := makeRequest(t, *ts.Router, "GET", "/api/v1/stats/habits/"+habitID, nil, token)
|
||||
|
||||
if rr.Code != http.StatusOK {
|
||||
t.Fatalf("Expected status 200, got %d. Body: %s", rr.Code, rr.Body.String())
|
||||
}
|
||||
|
||||
var stats queries.HabitStatsDTO
|
||||
decodeResponse(t, rr, &stats)
|
||||
|
||||
if stats.TotalCompletions != 0 {
|
||||
t.Errorf("Expected 0 total completions, got %d", stats.TotalCompletions)
|
||||
}
|
||||
if stats.CurrentStreak != 0 {
|
||||
t.Errorf("Expected 0 current streak, got %d", stats.CurrentStreak)
|
||||
}
|
||||
if stats.LongestStreak != 0 {
|
||||
t.Errorf("Expected 0 longest streak, got %d", stats.LongestStreak)
|
||||
}
|
||||
})
|
||||
|
||||
today := time.Now().UTC().Format("2006-01-02")
|
||||
|
||||
t.Run("Stats after marking habit today", func(t *testing.T) {
|
||||
markReq := MarkHabitRequest{
|
||||
ScheduledDate: today,
|
||||
}
|
||||
rr := makeRequest(t, *ts.Router, "POST", "/api/v1/habits/"+habitID+"/mark", markReq, token)
|
||||
if rr.Code != http.StatusOK {
|
||||
t.Fatalf("Failed to mark habit: %d - %s", rr.Code, rr.Body.String())
|
||||
}
|
||||
|
||||
rr = makeRequest(t, *ts.Router, "GET", "/api/v1/stats/habits/"+habitID, nil, token)
|
||||
|
||||
if rr.Code != http.StatusOK {
|
||||
t.Fatalf("Expected status 200, got %d", rr.Code)
|
||||
}
|
||||
|
||||
var stats queries.HabitStatsDTO
|
||||
decodeResponse(t, rr, &stats)
|
||||
|
||||
if stats.TotalCompletions != 1 {
|
||||
t.Errorf("Expected 1 total completion, got %d", stats.TotalCompletions)
|
||||
}
|
||||
if stats.CurrentStreak != 1 {
|
||||
t.Errorf("Expected current streak of 1 (today completed counts), got %d", stats.CurrentStreak)
|
||||
}
|
||||
if stats.LongestStreak != 1 {
|
||||
t.Errorf("Expected longest streak of 1, got %d", stats.LongestStreak)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Stats after unmarking habit", func(t *testing.T) {
|
||||
rr := makeRequest(t, *ts.Router, "DELETE", "/api/v1/habits/"+habitID+"/entries/"+today, nil, token)
|
||||
if rr.Code != http.StatusOK {
|
||||
t.Fatalf("Failed to unmark habit: %d", rr.Code)
|
||||
}
|
||||
|
||||
rr = makeRequest(t, *ts.Router, "GET", "/api/v1/stats/habits/"+habitID, nil, token)
|
||||
|
||||
var stats queries.HabitStatsDTO
|
||||
decodeResponse(t, rr, &stats)
|
||||
|
||||
if stats.TotalCompletions != 0 {
|
||||
t.Errorf("Expected 0 total completions after unmark, got %d", stats.TotalCompletions)
|
||||
}
|
||||
if stats.CurrentStreak != 0 {
|
||||
t.Errorf("Expected 0 current streak after unmark, got %d", stats.CurrentStreak)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestHabitUpdateAffectsStats(t *testing.T) {
|
||||
ts := setupTestServer(t)
|
||||
defer ts.Close()
|
||||
|
||||
token := registerAndLogin(t, *ts.Router, "updatestats@example.com", "Password123!")
|
||||
|
||||
habitBody := CreateHabitRequest{
|
||||
Name: "Running",
|
||||
Type: "BOOLEAN",
|
||||
Frequency: "DAILY",
|
||||
}
|
||||
rr := makeRequest(t, *ts.Router, "POST", "/api/v1/habits", habitBody, token)
|
||||
var habitResp map[string]string
|
||||
decodeResponse(t, rr, &habitResp)
|
||||
habitID := habitResp["id"]
|
||||
|
||||
today := time.Now().UTC().Format("2006-01-02")
|
||||
markReq := MarkHabitRequest{
|
||||
ScheduledDate: today,
|
||||
}
|
||||
makeRequest(t, *ts.Router, "POST", "/api/v1/habits/"+habitID+"/mark", markReq, token)
|
||||
|
||||
t.Run("Stats remain after updating habit name", func(t *testing.T) {
|
||||
updateReq := UpdateHabitRequest{
|
||||
Name: "Morning Running",
|
||||
Frequency: "DAILY",
|
||||
}
|
||||
rr := makeRequest(t, *ts.Router, "PUT", "/api/v1/habits/"+habitID, updateReq, token)
|
||||
if rr.Code != http.StatusOK {
|
||||
t.Fatalf("Failed to update habit: %d", rr.Code)
|
||||
}
|
||||
|
||||
rr = makeRequest(t, *ts.Router, "GET", "/api/v1/stats/habits/"+habitID, nil, token)
|
||||
|
||||
var stats queries.HabitStatsDTO
|
||||
decodeResponse(t, rr, &stats)
|
||||
|
||||
if stats.TotalCompletions != 1 {
|
||||
t.Errorf("Expected stats to persist after update, got %d completions", stats.TotalCompletions)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Stats remain available after archiving habit", func(t *testing.T) {
|
||||
rr := makeRequest(t, *ts.Router, "DELETE", "/api/v1/habits/"+habitID, nil, token)
|
||||
if rr.Code != http.StatusOK {
|
||||
t.Fatalf("Failed to archive habit: %d", rr.Code)
|
||||
}
|
||||
|
||||
rr = makeRequest(t, *ts.Router, "GET", "/api/v1/stats/habits/"+habitID, nil, token)
|
||||
|
||||
if rr.Code != http.StatusOK {
|
||||
t.Errorf("Expected stats to remain available for archived habit, got %d", rr.Code)
|
||||
}
|
||||
|
||||
var stats queries.HabitStatsDTO
|
||||
decodeResponse(t, rr, &stats)
|
||||
|
||||
if stats.TotalCompletions != 1 {
|
||||
t.Errorf("Expected stats to persist after archiving, got %d completions", stats.TotalCompletions)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,231 @@
|
||||
package http
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"apocapoc-api/internal/application/commands"
|
||||
"apocapoc-api/internal/application/queries"
|
||||
"apocapoc-api/internal/domain/entities"
|
||||
"apocapoc-api/internal/i18n"
|
||||
"apocapoc-api/internal/shared/errors"
|
||||
)
|
||||
|
||||
type SyncHandlers struct {
|
||||
getSyncChangesHandler *queries.GetSyncChangesHandler
|
||||
applySyncBatchHandler *commands.ApplySyncBatchHandler
|
||||
translator *i18n.Translator
|
||||
}
|
||||
|
||||
func NewSyncHandlers(
|
||||
getSyncChangesHandler *queries.GetSyncChangesHandler,
|
||||
applySyncBatchHandler *commands.ApplySyncBatchHandler,
|
||||
translator *i18n.Translator,
|
||||
) *SyncHandlers {
|
||||
return &SyncHandlers{
|
||||
getSyncChangesHandler: getSyncChangesHandler,
|
||||
applySyncBatchHandler: applySyncBatchHandler,
|
||||
translator: translator,
|
||||
}
|
||||
}
|
||||
|
||||
// GetSyncChanges godoc
|
||||
// @Summary Get sync changes
|
||||
// @Description Get all changes (habits and entries) since a given timestamp for offline sync
|
||||
// @Tags sync
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Security BearerAuth
|
||||
// @Param since query string true "ISO 8601 timestamp (e.g., 2025-01-01T00:00:00Z)"
|
||||
// @Success 200 {object} SyncChangesResponse
|
||||
// @Failure 400 {object} ErrorResponse
|
||||
// @Failure 401 {object} ErrorResponse
|
||||
// @Failure 500 {object} ErrorResponse
|
||||
// @Router /sync/changes [get]
|
||||
func (h *SyncHandlers) GetSyncChanges(w http.ResponseWriter, r *http.Request) {
|
||||
userID, ok := GetUserIDFromContext(r.Context())
|
||||
if !ok {
|
||||
respondErrorI18n(w, r, h.translator, http.StatusUnauthorized, "user_not_authenticated")
|
||||
return
|
||||
}
|
||||
|
||||
sinceStr := r.URL.Query().Get("since")
|
||||
if sinceStr == "" {
|
||||
respondErrorI18n(w, r, h.translator, http.StatusBadRequest, "missing_since_parameter")
|
||||
return
|
||||
}
|
||||
|
||||
since, err := time.Parse(time.RFC3339, sinceStr)
|
||||
if err != nil {
|
||||
respondErrorI18n(w, r, h.translator, http.StatusBadRequest, "invalid_since_format")
|
||||
return
|
||||
}
|
||||
|
||||
query := queries.GetSyncChangesQuery{
|
||||
UserID: userID,
|
||||
Since: since,
|
||||
}
|
||||
|
||||
result, err := h.getSyncChangesHandler.Handle(r.Context(), query)
|
||||
if err != nil {
|
||||
if err == errors.ErrInvalidInput {
|
||||
respondErrorI18n(w, r, h.translator, http.StatusBadRequest, "invalid_input")
|
||||
return
|
||||
}
|
||||
respondErrorI18n(w, r, h.translator, http.StatusInternalServerError, "internal_server_error")
|
||||
return
|
||||
}
|
||||
|
||||
response := SyncChangesResponse{
|
||||
Habits: HabitChangesDTO{
|
||||
Created: toHabitDTOs(result.Habits.Created),
|
||||
Updated: toHabitDTOs(result.Habits.Updated),
|
||||
Deleted: result.Habits.Deleted,
|
||||
},
|
||||
Entries: EntryChangesDTO{
|
||||
Created: toHabitEntryDTOs(result.Entries.Created),
|
||||
Updated: toHabitEntryDTOs(result.Entries.Updated),
|
||||
Deleted: result.Entries.Deleted,
|
||||
},
|
||||
}
|
||||
|
||||
respondJSON(w, http.StatusOK, response)
|
||||
}
|
||||
|
||||
// ApplySyncBatch godoc
|
||||
// @Summary Apply sync batch
|
||||
// @Description Apply a batch of changes from the client for offline sync (Last-Write-Wins)
|
||||
// @Tags sync
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Security BearerAuth
|
||||
// @Param request body SyncBatchRequest true "Sync batch data"
|
||||
// @Success 200 {object} map[string]string
|
||||
// @Failure 400 {object} ErrorResponse
|
||||
// @Failure 401 {object} ErrorResponse
|
||||
// @Failure 500 {object} ErrorResponse
|
||||
// @Router /sync/batch [post]
|
||||
func (h *SyncHandlers) ApplySyncBatch(w http.ResponseWriter, r *http.Request) {
|
||||
userID, ok := GetUserIDFromContext(r.Context())
|
||||
if !ok {
|
||||
respondErrorI18n(w, r, h.translator, http.StatusUnauthorized, "user_not_authenticated")
|
||||
return
|
||||
}
|
||||
|
||||
var req SyncBatchRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
respondErrorI18n(w, r, h.translator, http.StatusBadRequest, "invalid_request_body")
|
||||
return
|
||||
}
|
||||
|
||||
habitChanges := commands.HabitBatchChanges{
|
||||
Created: fromHabitDTOs(req.Habits.Created),
|
||||
Updated: fromHabitDTOs(req.Habits.Updated),
|
||||
Deleted: req.Habits.Deleted,
|
||||
}
|
||||
|
||||
entryChanges := commands.EntryBatchChanges{
|
||||
Created: fromHabitEntryDTOs(req.Entries.Created),
|
||||
Updated: fromHabitEntryDTOs(req.Entries.Updated),
|
||||
Deleted: req.Entries.Deleted,
|
||||
}
|
||||
|
||||
cmd := commands.ApplySyncBatchCommand{
|
||||
UserID: userID,
|
||||
Habits: habitChanges,
|
||||
Entries: entryChanges,
|
||||
}
|
||||
|
||||
err := h.applySyncBatchHandler.Handle(r.Context(), cmd)
|
||||
if err != nil {
|
||||
if err == errors.ErrInvalidInput {
|
||||
respondErrorI18n(w, r, h.translator, http.StatusBadRequest, "invalid_input")
|
||||
return
|
||||
}
|
||||
if err == errors.ErrUnauthorized {
|
||||
respondErrorI18n(w, r, h.translator, http.StatusForbidden, "forbidden")
|
||||
return
|
||||
}
|
||||
respondErrorI18n(w, r, h.translator, http.StatusInternalServerError, "internal_server_error")
|
||||
return
|
||||
}
|
||||
|
||||
respondJSON(w, http.StatusOK, map[string]string{"message": "sync_batch_applied"})
|
||||
}
|
||||
|
||||
func toHabitDTOs(habits []*entities.Habit) []SyncHabitDTO {
|
||||
dtos := make([]SyncHabitDTO, len(habits))
|
||||
for i, h := range habits {
|
||||
dtos[i] = SyncHabitDTO{
|
||||
ID: h.ID,
|
||||
UserID: h.UserID,
|
||||
Name: h.Name,
|
||||
Description: h.Description,
|
||||
Type: h.Type,
|
||||
Frequency: h.Frequency,
|
||||
SpecificDays: h.SpecificDays,
|
||||
SpecificDates: h.SpecificDates,
|
||||
CarryOver: h.CarryOver,
|
||||
IsNegative: h.IsNegative,
|
||||
TargetValue: h.TargetValue,
|
||||
CreatedAt: h.CreatedAt,
|
||||
UpdatedAt: h.UpdatedAt,
|
||||
ArchivedAt: h.ArchivedAt,
|
||||
}
|
||||
}
|
||||
return dtos
|
||||
}
|
||||
|
||||
func fromHabitDTOs(dtos []SyncHabitDTO) []*entities.Habit {
|
||||
habits := make([]*entities.Habit, len(dtos))
|
||||
for i, dto := range dtos {
|
||||
habits[i] = &entities.Habit{
|
||||
ID: dto.ID,
|
||||
UserID: dto.UserID,
|
||||
Name: dto.Name,
|
||||
Description: dto.Description,
|
||||
Type: dto.Type,
|
||||
Frequency: dto.Frequency,
|
||||
SpecificDays: dto.SpecificDays,
|
||||
SpecificDates: dto.SpecificDates,
|
||||
CarryOver: dto.CarryOver,
|
||||
IsNegative: dto.IsNegative,
|
||||
TargetValue: dto.TargetValue,
|
||||
CreatedAt: dto.CreatedAt,
|
||||
UpdatedAt: dto.UpdatedAt,
|
||||
ArchivedAt: dto.ArchivedAt,
|
||||
}
|
||||
}
|
||||
return habits
|
||||
}
|
||||
|
||||
func toHabitEntryDTOs(entries []*entities.HabitEntry) []SyncHabitEntryDTO {
|
||||
dtos := make([]SyncHabitEntryDTO, len(entries))
|
||||
for i, e := range entries {
|
||||
dtos[i] = SyncHabitEntryDTO{
|
||||
ID: e.ID,
|
||||
HabitID: e.HabitID,
|
||||
ScheduledDate: e.ScheduledDate,
|
||||
CompletedAt: e.CompletedAt,
|
||||
Value: e.Value,
|
||||
UpdatedAt: e.UpdatedAt,
|
||||
}
|
||||
}
|
||||
return dtos
|
||||
}
|
||||
|
||||
func fromHabitEntryDTOs(dtos []SyncHabitEntryDTO) []*entities.HabitEntry {
|
||||
entries := make([]*entities.HabitEntry, len(dtos))
|
||||
for i, dto := range dtos {
|
||||
entries[i] = &entities.HabitEntry{
|
||||
ID: dto.ID,
|
||||
HabitID: dto.HabitID,
|
||||
ScheduledDate: dto.ScheduledDate,
|
||||
CompletedAt: dto.CompletedAt,
|
||||
Value: dto.Value,
|
||||
UpdatedAt: dto.UpdatedAt,
|
||||
}
|
||||
}
|
||||
return entries
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
package logger
|
||||
|
||||
import (
|
||||
"io"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/rs/zerolog"
|
||||
"github.com/rs/zerolog/pkgerrors"
|
||||
)
|
||||
|
||||
var Log zerolog.Logger
|
||||
|
||||
type Config struct {
|
||||
Level string
|
||||
Environment string
|
||||
}
|
||||
|
||||
func Init(config Config) {
|
||||
zerolog.ErrorStackMarshaler = pkgerrors.MarshalStack
|
||||
zerolog.TimeFieldFormat = time.RFC3339
|
||||
|
||||
level := parseLogLevel(config.Level)
|
||||
zerolog.SetGlobalLevel(level)
|
||||
|
||||
var output io.Writer = os.Stdout
|
||||
|
||||
if config.Environment == "development" {
|
||||
output = zerolog.ConsoleWriter{
|
||||
Out: os.Stdout,
|
||||
TimeFormat: "15:04:05",
|
||||
NoColor: false,
|
||||
}
|
||||
}
|
||||
|
||||
Log = zerolog.New(output).
|
||||
With().
|
||||
Timestamp().
|
||||
Caller().
|
||||
Logger()
|
||||
|
||||
Log.Info().
|
||||
Str("level", level.String()).
|
||||
Str("environment", config.Environment).
|
||||
Msg("Logger initialized")
|
||||
}
|
||||
|
||||
func parseLogLevel(level string) zerolog.Level {
|
||||
switch strings.ToLower(level) {
|
||||
case "debug":
|
||||
return zerolog.DebugLevel
|
||||
case "info":
|
||||
return zerolog.InfoLevel
|
||||
case "warn", "warning":
|
||||
return zerolog.WarnLevel
|
||||
case "error":
|
||||
return zerolog.ErrorLevel
|
||||
case "fatal":
|
||||
return zerolog.FatalLevel
|
||||
case "panic":
|
||||
return zerolog.PanicLevel
|
||||
default:
|
||||
return zerolog.InfoLevel
|
||||
}
|
||||
}
|
||||
|
||||
func Debug() *zerolog.Event {
|
||||
return Log.Debug()
|
||||
}
|
||||
|
||||
func Info() *zerolog.Event {
|
||||
return Log.Info()
|
||||
}
|
||||
|
||||
func Warn() *zerolog.Event {
|
||||
return Log.Warn()
|
||||
}
|
||||
|
||||
func Error() *zerolog.Event {
|
||||
return Log.Error()
|
||||
}
|
||||
|
||||
func Fatal() *zerolog.Event {
|
||||
return Log.Fatal()
|
||||
}
|
||||
|
||||
func With() zerolog.Context {
|
||||
return Log.With()
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
package logger
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/rs/zerolog"
|
||||
)
|
||||
|
||||
type contextKey string
|
||||
|
||||
const (
|
||||
RequestIDKey contextKey = "request_id"
|
||||
UserIDKey contextKey = "user_id"
|
||||
)
|
||||
|
||||
type responseWriter struct {
|
||||
http.ResponseWriter
|
||||
status int
|
||||
size int
|
||||
}
|
||||
|
||||
func (rw *responseWriter) WriteHeader(status int) {
|
||||
rw.status = status
|
||||
rw.ResponseWriter.WriteHeader(status)
|
||||
}
|
||||
|
||||
func (rw *responseWriter) Write(b []byte) (int, error) {
|
||||
size, err := rw.ResponseWriter.Write(b)
|
||||
rw.size += size
|
||||
return size, err
|
||||
}
|
||||
|
||||
func Middleware(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
start := time.Now()
|
||||
|
||||
requestID := uuid.New().String()
|
||||
ctx := context.WithValue(r.Context(), RequestIDKey, requestID)
|
||||
|
||||
logger := Log.With().
|
||||
Str("request_id", requestID).
|
||||
Str("method", r.Method).
|
||||
Str("path", r.URL.Path).
|
||||
Str("remote_addr", r.RemoteAddr).
|
||||
Str("user_agent", r.UserAgent()).
|
||||
Logger()
|
||||
|
||||
ctx = logger.WithContext(ctx)
|
||||
r = r.WithContext(ctx)
|
||||
|
||||
rw := &responseWriter{
|
||||
ResponseWriter: w,
|
||||
status: http.StatusOK,
|
||||
}
|
||||
|
||||
next.ServeHTTP(rw, r)
|
||||
|
||||
duration := time.Since(start)
|
||||
|
||||
event := logger.Info()
|
||||
if rw.status >= 400 && rw.status < 500 {
|
||||
event = logger.Warn()
|
||||
} else if rw.status >= 500 {
|
||||
event = logger.Error()
|
||||
}
|
||||
|
||||
event.
|
||||
Int("status", rw.status).
|
||||
Int("size", rw.size).
|
||||
Dur("duration", duration).
|
||||
Msg("HTTP request")
|
||||
})
|
||||
}
|
||||
|
||||
func FromContext(ctx context.Context) *zerolog.Logger {
|
||||
logger := zerolog.Ctx(ctx)
|
||||
if logger == nil || logger.GetLevel() == zerolog.Disabled {
|
||||
return &Log
|
||||
}
|
||||
return logger
|
||||
}
|
||||
|
||||
func AddUserID(ctx context.Context, userID string) context.Context {
|
||||
logger := FromContext(ctx)
|
||||
updatedLogger := logger.With().Str("user_id", userID).Logger()
|
||||
return updatedLogger.WithContext(ctx)
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"time"
|
||||
|
||||
"apocapoc-api/internal/domain/entities"
|
||||
"apocapoc-api/internal/domain/repositories"
|
||||
"apocapoc-api/internal/shared/errors"
|
||||
|
||||
"github.com/google/uuid"
|
||||
@@ -24,8 +25,8 @@ func (r *HabitEntryRepository) Create(ctx context.Context, entry *entities.Habit
|
||||
entry.ID = uuid.New().String()
|
||||
|
||||
query := `
|
||||
INSERT INTO habit_entries (id, habit_id, scheduled_date, completed_at, value)
|
||||
VALUES (?, ?, ?, ?, ?)
|
||||
INSERT INTO habit_entries (id, habit_id, scheduled_date, completed_at, value, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?)
|
||||
`
|
||||
|
||||
_, err := r.db.ExecContext(ctx, query,
|
||||
@@ -34,6 +35,7 @@ func (r *HabitEntryRepository) Create(ctx context.Context, entry *entities.Habit
|
||||
entry.ScheduledDate.Format("2006-01-02"),
|
||||
entry.CompletedAt,
|
||||
entry.Value,
|
||||
entry.UpdatedAt,
|
||||
)
|
||||
|
||||
if err != nil {
|
||||
@@ -52,11 +54,12 @@ func (r *HabitEntryRepository) FindByHabitIDAndDateRange(
|
||||
from, to time.Time,
|
||||
) ([]*entities.HabitEntry, error) {
|
||||
query := `
|
||||
SELECT id, habit_id, scheduled_date, completed_at, value
|
||||
SELECT id, habit_id, scheduled_date, completed_at, value, updated_at, deleted_at
|
||||
FROM habit_entries
|
||||
WHERE habit_id = ?
|
||||
AND scheduled_date >= ?
|
||||
AND scheduled_date <= ?
|
||||
AND deleted_at IS NULL
|
||||
ORDER BY scheduled_date ASC
|
||||
`
|
||||
|
||||
@@ -74,13 +77,15 @@ func (r *HabitEntryRepository) FindByHabitIDAndDateRange(
|
||||
}
|
||||
|
||||
func (r *HabitEntryRepository) Update(ctx context.Context, entry *entities.HabitEntry) error {
|
||||
entry.UpdatedAt = time.Now()
|
||||
|
||||
query := `
|
||||
UPDATE habit_entries
|
||||
SET value = ?, completed_at = ?
|
||||
WHERE id = ?
|
||||
SET value = ?, completed_at = ?, updated_at = ?
|
||||
WHERE id = ? AND deleted_at IS NULL
|
||||
`
|
||||
|
||||
result, err := r.db.ExecContext(ctx, query, entry.Value, entry.CompletedAt, entry.ID)
|
||||
result, err := r.db.ExecContext(ctx, query, entry.Value, entry.CompletedAt, entry.UpdatedAt, entry.ID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to update entry: %w", err)
|
||||
}
|
||||
@@ -100,6 +105,8 @@ func (r *HabitEntryRepository) scanEntries(rows *sql.Rows) ([]*entities.HabitEnt
|
||||
var (
|
||||
entry entities.HabitEntry
|
||||
scheduledDate string
|
||||
updatedAt sql.NullTime
|
||||
deletedAt sql.NullTime
|
||||
)
|
||||
|
||||
err := rows.Scan(
|
||||
@@ -108,6 +115,8 @@ func (r *HabitEntryRepository) scanEntries(rows *sql.Rows) ([]*entities.HabitEnt
|
||||
&scheduledDate,
|
||||
&entry.CompletedAt,
|
||||
&entry.Value,
|
||||
&updatedAt,
|
||||
&deletedAt,
|
||||
)
|
||||
|
||||
if err != nil {
|
||||
@@ -123,6 +132,13 @@ func (r *HabitEntryRepository) scanEntries(rows *sql.Rows) ([]*entities.HabitEnt
|
||||
}
|
||||
entry.ScheduledDate = parsedDate
|
||||
|
||||
if updatedAt.Valid {
|
||||
entry.UpdatedAt = updatedAt.Time
|
||||
}
|
||||
if deletedAt.Valid {
|
||||
entry.DeletedAt = &deletedAt.Time
|
||||
}
|
||||
|
||||
entries = append(entries, &entry)
|
||||
}
|
||||
|
||||
@@ -131,14 +147,16 @@ func (r *HabitEntryRepository) scanEntries(rows *sql.Rows) ([]*entities.HabitEnt
|
||||
|
||||
func (r *HabitEntryRepository) FindByID(ctx context.Context, id string) (*entities.HabitEntry, error) {
|
||||
query := `
|
||||
SELECT id, habit_id, scheduled_date, completed_at, value
|
||||
SELECT id, habit_id, scheduled_date, completed_at, value, updated_at, deleted_at
|
||||
FROM habit_entries
|
||||
WHERE id = ?
|
||||
WHERE id = ? AND deleted_at IS NULL
|
||||
`
|
||||
|
||||
var (
|
||||
entry entities.HabitEntry
|
||||
scheduledDate string
|
||||
updatedAt sql.NullTime
|
||||
deletedAt sql.NullTime
|
||||
)
|
||||
|
||||
err := r.db.QueryRowContext(ctx, query, id).Scan(
|
||||
@@ -147,6 +165,8 @@ func (r *HabitEntryRepository) FindByID(ctx context.Context, id string) (*entiti
|
||||
&scheduledDate,
|
||||
&entry.CompletedAt,
|
||||
&entry.Value,
|
||||
&updatedAt,
|
||||
&deletedAt,
|
||||
)
|
||||
|
||||
if err == sql.ErrNoRows {
|
||||
@@ -165,14 +185,21 @@ func (r *HabitEntryRepository) FindByID(ctx context.Context, id string) (*entiti
|
||||
}
|
||||
entry.ScheduledDate = parsedDate
|
||||
|
||||
if updatedAt.Valid {
|
||||
entry.UpdatedAt = updatedAt.Time
|
||||
}
|
||||
if deletedAt.Valid {
|
||||
entry.DeletedAt = &deletedAt.Time
|
||||
}
|
||||
|
||||
return &entry, nil
|
||||
}
|
||||
|
||||
func (r *HabitEntryRepository) FindByHabitID(ctx context.Context, habitID string) ([]*entities.HabitEntry, error) {
|
||||
query := `
|
||||
SELECT id, habit_id, scheduled_date, completed_at, value
|
||||
SELECT id, habit_id, scheduled_date, completed_at, value, updated_at, deleted_at
|
||||
FROM habit_entries
|
||||
WHERE habit_id = ?
|
||||
WHERE habit_id = ? AND deleted_at IS NULL
|
||||
ORDER BY scheduled_date DESC
|
||||
`
|
||||
|
||||
@@ -185,12 +212,31 @@ func (r *HabitEntryRepository) FindByHabitID(ctx context.Context, habitID string
|
||||
return r.scanEntries(rows)
|
||||
}
|
||||
|
||||
func (r *HabitEntryRepository) FindByUserID(ctx context.Context, userID string) ([]*entities.HabitEntry, error) {
|
||||
query := `
|
||||
SELECT he.id, he.habit_id, he.scheduled_date, he.completed_at, he.value, he.updated_at, he.deleted_at
|
||||
FROM habit_entries he
|
||||
INNER JOIN habits h ON he.habit_id = h.id
|
||||
WHERE h.user_id = ? AND he.deleted_at IS NULL
|
||||
ORDER BY he.scheduled_date DESC
|
||||
`
|
||||
|
||||
rows, err := r.db.QueryContext(ctx, query, userID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to find entries: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
return r.scanEntries(rows)
|
||||
}
|
||||
|
||||
func (r *HabitEntryRepository) FindPendingByHabitID(ctx context.Context, habitID string, beforeDate time.Time) ([]*entities.HabitEntry, error) {
|
||||
query := `
|
||||
SELECT id, habit_id, scheduled_date, completed_at, value
|
||||
SELECT id, habit_id, scheduled_date, completed_at, value, updated_at, deleted_at
|
||||
FROM habit_entries
|
||||
WHERE habit_id = ?
|
||||
AND scheduled_date < ?
|
||||
AND deleted_at IS NULL
|
||||
ORDER BY scheduled_date DESC
|
||||
`
|
||||
|
||||
@@ -218,3 +264,128 @@ func (r *HabitEntryRepository) Delete(ctx context.Context, id string) error {
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *HabitEntryRepository) GetChangesSince(ctx context.Context, userID string, since time.Time) (*repositories.HabitEntryChanges, error) {
|
||||
changes := &repositories.HabitEntryChanges{
|
||||
Created: []*entities.HabitEntry{},
|
||||
Updated: []*entities.HabitEntry{},
|
||||
Deleted: []string{},
|
||||
}
|
||||
|
||||
query := `
|
||||
SELECT he.id, he.habit_id, he.scheduled_date, he.completed_at, he.value, he.updated_at, he.deleted_at
|
||||
FROM habit_entries he
|
||||
INNER JOIN habits h ON he.habit_id = h.id
|
||||
WHERE h.user_id = ?
|
||||
AND he.updated_at > ?
|
||||
AND he.deleted_at IS NULL
|
||||
ORDER BY he.updated_at ASC
|
||||
`
|
||||
|
||||
rows, err := r.db.QueryContext(ctx, query, userID, since)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to query habit entry changes: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
for rows.Next() {
|
||||
var (
|
||||
entry entities.HabitEntry
|
||||
scheduledDate string
|
||||
updatedAt sql.NullTime
|
||||
deletedAt sql.NullTime
|
||||
)
|
||||
|
||||
err := rows.Scan(
|
||||
&entry.ID,
|
||||
&entry.HabitID,
|
||||
&scheduledDate,
|
||||
&entry.CompletedAt,
|
||||
&entry.Value,
|
||||
&updatedAt,
|
||||
&deletedAt,
|
||||
)
|
||||
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to scan habit entry: %w", err)
|
||||
}
|
||||
|
||||
parsedDate, err := time.Parse("2006-01-02", scheduledDate)
|
||||
if err != nil {
|
||||
parsedDate, err = time.Parse(time.RFC3339, scheduledDate)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to parse scheduled_date: %w", err)
|
||||
}
|
||||
}
|
||||
entry.ScheduledDate = parsedDate
|
||||
|
||||
if updatedAt.Valid {
|
||||
entry.UpdatedAt = updatedAt.Time
|
||||
}
|
||||
if deletedAt.Valid {
|
||||
entry.DeletedAt = &deletedAt.Time
|
||||
}
|
||||
|
||||
if entry.CompletedAt.After(since) {
|
||||
changes.Created = append(changes.Created, &entry)
|
||||
} else {
|
||||
changes.Updated = append(changes.Updated, &entry)
|
||||
}
|
||||
}
|
||||
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, fmt.Errorf("error iterating habit entries: %w", err)
|
||||
}
|
||||
|
||||
queryDeleted := `
|
||||
SELECT he.id
|
||||
FROM habit_entries he
|
||||
INNER JOIN habits h ON he.habit_id = h.id
|
||||
WHERE h.user_id = ?
|
||||
AND he.deleted_at IS NOT NULL
|
||||
AND he.deleted_at > ?
|
||||
ORDER BY he.deleted_at ASC
|
||||
`
|
||||
|
||||
rowsDeleted, err := r.db.QueryContext(ctx, queryDeleted, userID, since)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to query deleted habit entries: %w", err)
|
||||
}
|
||||
defer rowsDeleted.Close()
|
||||
|
||||
for rowsDeleted.Next() {
|
||||
var id string
|
||||
if err := rowsDeleted.Scan(&id); err != nil {
|
||||
return nil, fmt.Errorf("failed to scan deleted habit entry id: %w", err)
|
||||
}
|
||||
changes.Deleted = append(changes.Deleted, id)
|
||||
}
|
||||
|
||||
if err := rowsDeleted.Err(); err != nil {
|
||||
return nil, fmt.Errorf("error iterating deleted habit entries: %w", err)
|
||||
}
|
||||
|
||||
return changes, nil
|
||||
}
|
||||
|
||||
func (r *HabitEntryRepository) SoftDelete(ctx context.Context, id string) error {
|
||||
now := time.Now()
|
||||
|
||||
query := `
|
||||
UPDATE habit_entries
|
||||
SET deleted_at = ?, updated_at = ?
|
||||
WHERE id = ? AND deleted_at IS NULL
|
||||
`
|
||||
|
||||
result, err := r.db.ExecContext(ctx, query, now, now, id)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to soft delete habit entry: %w", err)
|
||||
}
|
||||
|
||||
rows, _ := result.RowsAffected()
|
||||
if rows == 0 {
|
||||
return errors.ErrNotFound
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -5,9 +5,12 @@ import (
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"apocapoc-api/internal/domain/entities"
|
||||
"apocapoc-api/internal/domain/repositories"
|
||||
"apocapoc-api/internal/shared/errors"
|
||||
"apocapoc-api/internal/shared/pagination"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
@@ -29,8 +32,8 @@ func (r *HabitRepository) Create(ctx context.Context, habit *entities.Habit) err
|
||||
query := `
|
||||
INSERT INTO habits (
|
||||
id, user_id, name, description, type, frequency,
|
||||
specific_days, specific_dates, carry_over, is_negative, target_value, created_at
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
specific_days, specific_dates, carry_over, is_negative, target_value, created_at, updated_at
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`
|
||||
|
||||
_, err := r.db.ExecContext(ctx, query,
|
||||
@@ -46,6 +49,7 @@ func (r *HabitRepository) Create(ctx context.Context, habit *entities.Habit) err
|
||||
habit.IsNegative,
|
||||
habit.TargetValue,
|
||||
habit.CreatedAt,
|
||||
habit.UpdatedAt,
|
||||
)
|
||||
|
||||
if err != nil {
|
||||
@@ -59,16 +63,18 @@ func (r *HabitRepository) FindByID(ctx context.Context, id string) (*entities.Ha
|
||||
query := `
|
||||
SELECT id, user_id, name, description, type, frequency,
|
||||
specific_days, specific_dates, carry_over, is_negative, target_value,
|
||||
created_at, archived_at
|
||||
created_at, updated_at, archived_at, deleted_at
|
||||
FROM habits
|
||||
WHERE id = ?
|
||||
WHERE id = ? AND deleted_at IS NULL
|
||||
`
|
||||
|
||||
var (
|
||||
habit entities.Habit
|
||||
specificDays sql.NullString
|
||||
specificDates sql.NullString
|
||||
updatedAt sql.NullTime
|
||||
archivedAt sql.NullTime
|
||||
deletedAt sql.NullTime
|
||||
)
|
||||
|
||||
err := r.db.QueryRowContext(ctx, query, id).Scan(
|
||||
@@ -84,7 +90,9 @@ func (r *HabitRepository) FindByID(ctx context.Context, id string) (*entities.Ha
|
||||
&habit.IsNegative,
|
||||
&habit.TargetValue,
|
||||
&habit.CreatedAt,
|
||||
&updatedAt,
|
||||
&archivedAt,
|
||||
&deletedAt,
|
||||
)
|
||||
|
||||
if err == sql.ErrNoRows {
|
||||
@@ -94,15 +102,22 @@ func (r *HabitRepository) FindByID(ctx context.Context, id string) (*entities.Ha
|
||||
return nil, fmt.Errorf("failed to find habit: %w", err)
|
||||
}
|
||||
|
||||
if updatedAt.Valid {
|
||||
habit.UpdatedAt = updatedAt.Time
|
||||
}
|
||||
if archivedAt.Valid {
|
||||
habit.ArchivedAt = &archivedAt.Time
|
||||
}
|
||||
if deletedAt.Valid {
|
||||
habit.DeletedAt = &deletedAt.Time
|
||||
}
|
||||
|
||||
if specificDays.Valid {
|
||||
json.Unmarshal([]byte(specificDays.String), &habit.SpecificDays)
|
||||
}
|
||||
if specificDates.Valid {
|
||||
json.Unmarshal([]byte(specificDates.String), &habit.SpecificDates)
|
||||
}
|
||||
if archivedAt.Valid {
|
||||
habit.ArchivedAt = &archivedAt.Time
|
||||
}
|
||||
|
||||
return &habit, nil
|
||||
}
|
||||
@@ -111,9 +126,9 @@ func (r *HabitRepository) FindActiveByUserID(ctx context.Context, userID string)
|
||||
query := `
|
||||
SELECT id, user_id, name, description, type, frequency,
|
||||
specific_days, specific_dates, carry_over, is_negative, target_value,
|
||||
created_at, archived_at
|
||||
created_at, updated_at, archived_at, deleted_at
|
||||
FROM habits
|
||||
WHERE user_id = ? AND archived_at IS NULL
|
||||
WHERE user_id = ? AND archived_at IS NULL AND deleted_at IS NULL
|
||||
ORDER BY created_at DESC
|
||||
`
|
||||
|
||||
@@ -127,6 +142,8 @@ func (r *HabitRepository) FindActiveByUserID(ctx context.Context, userID string)
|
||||
}
|
||||
|
||||
func (r *HabitRepository) Update(ctx context.Context, habit *entities.Habit) error {
|
||||
habit.Touch()
|
||||
|
||||
specificDays, _ := json.Marshal(habit.SpecificDays)
|
||||
specificDates, _ := json.Marshal(habit.SpecificDates)
|
||||
|
||||
@@ -134,8 +151,8 @@ func (r *HabitRepository) Update(ctx context.Context, habit *entities.Habit) err
|
||||
UPDATE habits
|
||||
SET name = ?, description = ?, type = ?, frequency = ?,
|
||||
specific_days = ?, specific_dates = ?, carry_over = ?, is_negative = ?,
|
||||
target_value = ?, archived_at = ?
|
||||
WHERE id = ?
|
||||
target_value = ?, archived_at = ?, updated_at = ?
|
||||
WHERE id = ? AND deleted_at IS NULL
|
||||
`
|
||||
|
||||
result, err := r.db.ExecContext(ctx, query,
|
||||
@@ -149,6 +166,7 @@ func (r *HabitRepository) Update(ctx context.Context, habit *entities.Habit) err
|
||||
habit.IsNegative,
|
||||
habit.TargetValue,
|
||||
habit.ArchivedAt,
|
||||
habit.UpdatedAt,
|
||||
habit.ID,
|
||||
)
|
||||
|
||||
@@ -172,7 +190,9 @@ func (r *HabitRepository) scanHabits(rows *sql.Rows) ([]*entities.Habit, error)
|
||||
habit entities.Habit
|
||||
specificDays sql.NullString
|
||||
specificDates sql.NullString
|
||||
updatedAt sql.NullTime
|
||||
archivedAt sql.NullTime
|
||||
deletedAt sql.NullTime
|
||||
)
|
||||
|
||||
err := rows.Scan(
|
||||
@@ -188,7 +208,9 @@ func (r *HabitRepository) scanHabits(rows *sql.Rows) ([]*entities.Habit, error)
|
||||
&habit.IsNegative,
|
||||
&habit.TargetValue,
|
||||
&habit.CreatedAt,
|
||||
&updatedAt,
|
||||
&archivedAt,
|
||||
&deletedAt,
|
||||
)
|
||||
|
||||
if err != nil {
|
||||
@@ -201,9 +223,15 @@ func (r *HabitRepository) scanHabits(rows *sql.Rows) ([]*entities.Habit, error)
|
||||
if specificDates.Valid {
|
||||
json.Unmarshal([]byte(specificDates.String), &habit.SpecificDates)
|
||||
}
|
||||
if updatedAt.Valid {
|
||||
habit.UpdatedAt = updatedAt.Time
|
||||
}
|
||||
if archivedAt.Valid {
|
||||
habit.ArchivedAt = &archivedAt.Time
|
||||
}
|
||||
if deletedAt.Valid {
|
||||
habit.DeletedAt = &deletedAt.Time
|
||||
}
|
||||
|
||||
habits = append(habits, &habit)
|
||||
}
|
||||
@@ -215,9 +243,9 @@ func (r *HabitRepository) FindByUserID(ctx context.Context, userID string) ([]*e
|
||||
query := `
|
||||
SELECT id, user_id, name, description, type, frequency,
|
||||
specific_days, specific_dates, carry_over, is_negative, target_value,
|
||||
created_at, archived_at
|
||||
created_at, updated_at, archived_at, deleted_at
|
||||
FROM habits
|
||||
WHERE user_id = ?
|
||||
WHERE user_id = ? AND deleted_at IS NULL
|
||||
ORDER BY created_at DESC
|
||||
`
|
||||
|
||||
@@ -245,3 +273,273 @@ func (r *HabitRepository) Delete(ctx context.Context, id string) error {
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *HabitRepository) FindActiveByUserIDWithPagination(ctx context.Context, userID string, params pagination.Params) ([]*entities.Habit, error) {
|
||||
query := `
|
||||
SELECT id, user_id, name, description, type, frequency,
|
||||
specific_days, specific_dates, carry_over, is_negative, target_value,
|
||||
created_at, updated_at, archived_at, deleted_at
|
||||
FROM habits
|
||||
WHERE user_id = ? AND archived_at IS NULL AND deleted_at IS NULL
|
||||
ORDER BY created_at DESC
|
||||
LIMIT ? OFFSET ?
|
||||
`
|
||||
|
||||
rows, err := r.db.QueryContext(ctx, query, userID, params.Limit(), params.Offset())
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to find habits: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
return r.scanHabits(rows)
|
||||
}
|
||||
|
||||
func (r *HabitRepository) CountActiveByUserID(ctx context.Context, userID string) (int, error) {
|
||||
query := `
|
||||
SELECT COUNT(*)
|
||||
FROM habits
|
||||
WHERE user_id = ? AND archived_at IS NULL AND deleted_at IS NULL
|
||||
`
|
||||
|
||||
var count int
|
||||
err := r.db.QueryRowContext(ctx, query, userID).Scan(&count)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("failed to count habits: %w", err)
|
||||
}
|
||||
|
||||
return count, nil
|
||||
}
|
||||
|
||||
func (r *HabitRepository) FindByUserIDFiltered(ctx context.Context, userID string, filter repositories.HabitFilter, paginationParams *pagination.Params) ([]*entities.Habit, error) {
|
||||
baseQuery := `
|
||||
SELECT id, user_id, name, description, type, frequency,
|
||||
specific_days, specific_dates, carry_over, is_negative, target_value,
|
||||
created_at, updated_at, archived_at, deleted_at
|
||||
FROM habits
|
||||
WHERE user_id = ?`
|
||||
|
||||
args := []interface{}{userID}
|
||||
conditions := []string{}
|
||||
|
||||
// Always exclude soft deleted
|
||||
conditions = append(conditions, "deleted_at IS NULL")
|
||||
|
||||
if !filter.IncludeArchived {
|
||||
conditions = append(conditions, "archived_at IS NULL")
|
||||
}
|
||||
|
||||
if filter.Type != nil {
|
||||
conditions = append(conditions, "type = ?")
|
||||
args = append(args, string(*filter.Type))
|
||||
}
|
||||
|
||||
if filter.Frequency != nil {
|
||||
conditions = append(conditions, "frequency = ?")
|
||||
args = append(args, string(*filter.Frequency))
|
||||
}
|
||||
|
||||
if filter.Search != "" {
|
||||
conditions = append(conditions, "(name LIKE ? OR description LIKE ?)")
|
||||
searchPattern := "%" + filter.Search + "%"
|
||||
args = append(args, searchPattern, searchPattern)
|
||||
}
|
||||
|
||||
for _, condition := range conditions {
|
||||
baseQuery += " AND " + condition
|
||||
}
|
||||
|
||||
baseQuery += " ORDER BY created_at DESC"
|
||||
|
||||
if paginationParams != nil {
|
||||
baseQuery += " LIMIT ? OFFSET ?"
|
||||
args = append(args, paginationParams.Limit(), paginationParams.Offset())
|
||||
}
|
||||
|
||||
rows, err := r.db.QueryContext(ctx, baseQuery, args...)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to find habits: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
return r.scanHabits(rows)
|
||||
}
|
||||
|
||||
func (r *HabitRepository) CountByUserIDFiltered(ctx context.Context, userID string, filter repositories.HabitFilter) (int, error) {
|
||||
baseQuery := `SELECT COUNT(*) FROM habits WHERE user_id = ?`
|
||||
|
||||
args := []interface{}{userID}
|
||||
conditions := []string{}
|
||||
|
||||
// Always exclude soft deleted
|
||||
conditions = append(conditions, "deleted_at IS NULL")
|
||||
|
||||
if !filter.IncludeArchived {
|
||||
conditions = append(conditions, "archived_at IS NULL")
|
||||
}
|
||||
|
||||
if filter.Type != nil {
|
||||
conditions = append(conditions, "type = ?")
|
||||
args = append(args, string(*filter.Type))
|
||||
}
|
||||
|
||||
if filter.Frequency != nil {
|
||||
conditions = append(conditions, "frequency = ?")
|
||||
args = append(args, string(*filter.Frequency))
|
||||
}
|
||||
|
||||
if filter.Search != "" {
|
||||
conditions = append(conditions, "(name LIKE ? OR description LIKE ?)")
|
||||
searchPattern := "%" + filter.Search + "%"
|
||||
args = append(args, searchPattern, searchPattern)
|
||||
}
|
||||
|
||||
for _, condition := range conditions {
|
||||
baseQuery += " AND " + condition
|
||||
}
|
||||
|
||||
var count int
|
||||
err := r.db.QueryRowContext(ctx, baseQuery, args...).Scan(&count)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("failed to count habits: %w", err)
|
||||
}
|
||||
|
||||
return count, nil
|
||||
}
|
||||
|
||||
func (r *HabitRepository) GetChangesSince(ctx context.Context, userID string, since time.Time) (*repositories.HabitChanges, error) {
|
||||
changes := &repositories.HabitChanges{
|
||||
Created: []*entities.Habit{},
|
||||
Updated: []*entities.Habit{},
|
||||
Deleted: []string{},
|
||||
}
|
||||
|
||||
// Get created and updated habits (not deleted)
|
||||
query := `
|
||||
SELECT id, user_id, name, description, type, frequency,
|
||||
specific_days, specific_dates, carry_over, is_negative, target_value,
|
||||
created_at, updated_at, archived_at, deleted_at
|
||||
FROM habits
|
||||
WHERE user_id = ?
|
||||
AND updated_at > ?
|
||||
AND deleted_at IS NULL
|
||||
ORDER BY updated_at ASC
|
||||
`
|
||||
|
||||
rows, err := r.db.QueryContext(ctx, query, userID, since)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to query habits changes: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
for rows.Next() {
|
||||
var (
|
||||
habit entities.Habit
|
||||
specificDays sql.NullString
|
||||
specificDates sql.NullString
|
||||
updatedAt sql.NullTime
|
||||
archivedAt sql.NullTime
|
||||
deletedAt sql.NullTime
|
||||
)
|
||||
|
||||
err := rows.Scan(
|
||||
&habit.ID,
|
||||
&habit.UserID,
|
||||
&habit.Name,
|
||||
&habit.Description,
|
||||
&habit.Type,
|
||||
&habit.Frequency,
|
||||
&specificDays,
|
||||
&specificDates,
|
||||
&habit.CarryOver,
|
||||
&habit.IsNegative,
|
||||
&habit.TargetValue,
|
||||
&habit.CreatedAt,
|
||||
&updatedAt,
|
||||
&archivedAt,
|
||||
&deletedAt,
|
||||
)
|
||||
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to scan habit: %w", err)
|
||||
}
|
||||
|
||||
if specificDays.Valid {
|
||||
json.Unmarshal([]byte(specificDays.String), &habit.SpecificDays)
|
||||
}
|
||||
if specificDates.Valid {
|
||||
json.Unmarshal([]byte(specificDates.String), &habit.SpecificDates)
|
||||
}
|
||||
if updatedAt.Valid {
|
||||
habit.UpdatedAt = updatedAt.Time
|
||||
}
|
||||
if archivedAt.Valid {
|
||||
habit.ArchivedAt = &archivedAt.Time
|
||||
}
|
||||
if deletedAt.Valid {
|
||||
habit.DeletedAt = &deletedAt.Time
|
||||
}
|
||||
|
||||
// Classify as created or updated based on when it was created
|
||||
if habit.CreatedAt.After(since) {
|
||||
changes.Created = append(changes.Created, &habit)
|
||||
} else {
|
||||
changes.Updated = append(changes.Updated, &habit)
|
||||
}
|
||||
}
|
||||
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, fmt.Errorf("error iterating habits: %w", err)
|
||||
}
|
||||
|
||||
// Get deleted habits
|
||||
queryDeleted := `
|
||||
SELECT id
|
||||
FROM habits
|
||||
WHERE user_id = ?
|
||||
AND deleted_at IS NOT NULL
|
||||
AND deleted_at > ?
|
||||
ORDER BY deleted_at ASC
|
||||
`
|
||||
|
||||
rowsDeleted, err := r.db.QueryContext(ctx, queryDeleted, userID, since)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to query deleted habits: %w", err)
|
||||
}
|
||||
defer rowsDeleted.Close()
|
||||
|
||||
for rowsDeleted.Next() {
|
||||
var id string
|
||||
if err := rowsDeleted.Scan(&id); err != nil {
|
||||
return nil, fmt.Errorf("failed to scan deleted habit id: %w", err)
|
||||
}
|
||||
changes.Deleted = append(changes.Deleted, id)
|
||||
}
|
||||
|
||||
if err := rowsDeleted.Err(); err != nil {
|
||||
return nil, fmt.Errorf("error iterating deleted habits: %w", err)
|
||||
}
|
||||
|
||||
return changes, nil
|
||||
}
|
||||
|
||||
func (r *HabitRepository) SoftDelete(ctx context.Context, id string) error {
|
||||
now := time.Now()
|
||||
|
||||
query := `
|
||||
UPDATE habits
|
||||
SET deleted_at = ?, updated_at = ?
|
||||
WHERE id = ? AND deleted_at IS NULL
|
||||
`
|
||||
|
||||
result, err := r.db.ExecContext(ctx, query, now, now, id)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to soft delete habit: %w", err)
|
||||
}
|
||||
|
||||
rows, _ := result.RowsAffected()
|
||||
if rows == 0 {
|
||||
return errors.ErrNotFound
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -6,8 +6,10 @@ import (
|
||||
"time"
|
||||
|
||||
"apocapoc-api/internal/domain/entities"
|
||||
"apocapoc-api/internal/domain/repositories"
|
||||
"apocapoc-api/internal/domain/value_objects"
|
||||
"apocapoc-api/internal/shared/errors"
|
||||
"apocapoc-api/internal/shared/pagination"
|
||||
)
|
||||
|
||||
func TestHabitRepositoryCreate(t *testing.T) {
|
||||
@@ -262,3 +264,372 @@ func TestHabitRepositoryArchive(t *testing.T) {
|
||||
t.Error("Expected habit to be archived")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHabitRepositoryFindActiveByUserIDWithPagination(t *testing.T) {
|
||||
db := setupTestDB(t)
|
||||
defer db.Close()
|
||||
|
||||
repo := NewHabitRepository(db)
|
||||
ctx := context.Background()
|
||||
|
||||
userID := "user-pagination-test"
|
||||
|
||||
for i := 1; i <= 10; i++ {
|
||||
habit := entities.NewHabit(
|
||||
userID,
|
||||
"Habit "+string(rune(i+'0')),
|
||||
value_objects.HabitTypeBoolean,
|
||||
value_objects.FrequencyDaily,
|
||||
false,
|
||||
false,
|
||||
)
|
||||
err := repo.Create(ctx, habit)
|
||||
if err != nil {
|
||||
t.Fatalf("Create failed: %v", err)
|
||||
}
|
||||
time.Sleep(1 * time.Millisecond)
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
allHabits, _ := repo.FindActiveByUserID(ctx, userID)
|
||||
allHabits[0].ArchivedAt = &now
|
||||
repo.Update(ctx, allHabits[0])
|
||||
|
||||
t.Run("FirstPage", func(t *testing.T) {
|
||||
params := pagination.NewParams(1, 5)
|
||||
habits, err := repo.FindActiveByUserIDWithPagination(ctx, userID, params)
|
||||
if err != nil {
|
||||
t.Fatalf("FindActiveByUserIDWithPagination failed: %v", err)
|
||||
}
|
||||
|
||||
if len(habits) != 5 {
|
||||
t.Errorf("Expected 5 habits on first page, got %d", len(habits))
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("SecondPage", func(t *testing.T) {
|
||||
params := pagination.NewParams(2, 5)
|
||||
habits, err := repo.FindActiveByUserIDWithPagination(ctx, userID, params)
|
||||
if err != nil {
|
||||
t.Fatalf("FindActiveByUserIDWithPagination failed: %v", err)
|
||||
}
|
||||
|
||||
if len(habits) != 4 {
|
||||
t.Errorf("Expected 4 habits on second page (9 total active), got %d", len(habits))
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("PageBeyondTotal", func(t *testing.T) {
|
||||
params := pagination.NewParams(10, 5)
|
||||
habits, err := repo.FindActiveByUserIDWithPagination(ctx, userID, params)
|
||||
if err != nil {
|
||||
t.Fatalf("FindActiveByUserIDWithPagination failed: %v", err)
|
||||
}
|
||||
|
||||
if len(habits) != 0 {
|
||||
t.Errorf("Expected 0 habits beyond total pages, got %d", len(habits))
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("CustomPageSize", func(t *testing.T) {
|
||||
params := pagination.NewParams(1, 3)
|
||||
habits, err := repo.FindActiveByUserIDWithPagination(ctx, userID, params)
|
||||
if err != nil {
|
||||
t.Fatalf("FindActiveByUserIDWithPagination failed: %v", err)
|
||||
}
|
||||
|
||||
if len(habits) != 3 {
|
||||
t.Errorf("Expected 3 habits with page_size=3, got %d", len(habits))
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("ExcludesArchived", func(t *testing.T) {
|
||||
params := pagination.NewParams(1, 20)
|
||||
habits, err := repo.FindActiveByUserIDWithPagination(ctx, userID, params)
|
||||
if err != nil {
|
||||
t.Fatalf("FindActiveByUserIDWithPagination failed: %v", err)
|
||||
}
|
||||
|
||||
if len(habits) != 9 {
|
||||
t.Errorf("Expected 9 active habits (1 archived), got %d", len(habits))
|
||||
}
|
||||
|
||||
for _, habit := range habits {
|
||||
if habit.ArchivedAt != nil {
|
||||
t.Error("Expected no archived habits in results")
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestHabitRepositoryCountActiveByUserID(t *testing.T) {
|
||||
db := setupTestDB(t)
|
||||
defer db.Close()
|
||||
|
||||
repo := NewHabitRepository(db)
|
||||
ctx := context.Background()
|
||||
|
||||
userID := "user-count-test"
|
||||
|
||||
t.Run("NoHabits", func(t *testing.T) {
|
||||
count, err := repo.CountActiveByUserID(ctx, "non-existent-user")
|
||||
if err != nil {
|
||||
t.Fatalf("CountActiveByUserID failed: %v", err)
|
||||
}
|
||||
|
||||
if count != 0 {
|
||||
t.Errorf("Expected count 0 for non-existent user, got %d", count)
|
||||
}
|
||||
})
|
||||
|
||||
for i := 1; i <= 7; i++ {
|
||||
habit := entities.NewHabit(
|
||||
userID,
|
||||
"Habit "+string(rune(i+'0')),
|
||||
value_objects.HabitTypeBoolean,
|
||||
value_objects.FrequencyDaily,
|
||||
false,
|
||||
false,
|
||||
)
|
||||
repo.Create(ctx, habit)
|
||||
}
|
||||
|
||||
t.Run("AllActive", func(t *testing.T) {
|
||||
count, err := repo.CountActiveByUserID(ctx, userID)
|
||||
if err != nil {
|
||||
t.Fatalf("CountActiveByUserID failed: %v", err)
|
||||
}
|
||||
|
||||
if count != 7 {
|
||||
t.Errorf("Expected count 7, got %d", count)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("WithArchived", func(t *testing.T) {
|
||||
habits, _ := repo.FindActiveByUserID(ctx, userID)
|
||||
now := time.Now()
|
||||
habits[0].ArchivedAt = &now
|
||||
habits[1].ArchivedAt = &now
|
||||
repo.Update(ctx, habits[0])
|
||||
repo.Update(ctx, habits[1])
|
||||
|
||||
count, err := repo.CountActiveByUserID(ctx, userID)
|
||||
if err != nil {
|
||||
t.Fatalf("CountActiveByUserID failed: %v", err)
|
||||
}
|
||||
|
||||
if count != 5 {
|
||||
t.Errorf("Expected count 5 (7 total - 2 archived), got %d", count)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestHabitRepositoryFindByUserIDFiltered(t *testing.T) {
|
||||
db := setupTestDB(t)
|
||||
defer db.Close()
|
||||
|
||||
repo := NewHabitRepository(db)
|
||||
ctx := context.Background()
|
||||
|
||||
userID := "user-filter-test"
|
||||
|
||||
habit1 := entities.NewHabit(userID, "Morning Exercise", value_objects.HabitTypeBoolean, value_objects.FrequencyDaily, false, false)
|
||||
habit1.Description = "Daily morning workout"
|
||||
repo.Create(ctx, habit1)
|
||||
time.Sleep(1 * time.Millisecond)
|
||||
|
||||
habit2 := entities.NewHabit(userID, "Read Books", value_objects.HabitTypeCounter, value_objects.FrequencyWeekly, false, false)
|
||||
habit2.Description = "Read at least 3 books per week"
|
||||
repo.Create(ctx, habit2)
|
||||
time.Sleep(1 * time.Millisecond)
|
||||
|
||||
habit3 := entities.NewHabit(userID, "Drink Water", value_objects.HabitTypeValue, value_objects.FrequencyDaily, false, false)
|
||||
habit3.Description = "Drink 2 liters of water daily"
|
||||
repo.Create(ctx, habit3)
|
||||
time.Sleep(1 * time.Millisecond)
|
||||
|
||||
habit4 := entities.NewHabit(userID, "Weekly Run", value_objects.HabitTypeBoolean, value_objects.FrequencyWeekly, false, false)
|
||||
repo.Create(ctx, habit4)
|
||||
time.Sleep(1 * time.Millisecond)
|
||||
|
||||
now := time.Now()
|
||||
habit4.ArchivedAt = &now
|
||||
repo.Update(ctx, habit4)
|
||||
|
||||
t.Run("FilterByType", func(t *testing.T) {
|
||||
habitType := value_objects.HabitTypeBoolean
|
||||
filter := repositories.HabitFilter{
|
||||
Type: &habitType,
|
||||
}
|
||||
|
||||
habits, err := repo.FindByUserIDFiltered(ctx, userID, filter, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("FindByUserIDFiltered failed: %v", err)
|
||||
}
|
||||
|
||||
if len(habits) != 1 {
|
||||
t.Errorf("Expected 1 active BOOLEAN habit, got %d", len(habits))
|
||||
}
|
||||
|
||||
if habits[0].Type != value_objects.HabitTypeBoolean {
|
||||
t.Errorf("Expected BOOLEAN type, got %s", habits[0].Type)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("FilterByFrequency", func(t *testing.T) {
|
||||
frequency := value_objects.FrequencyDaily
|
||||
filter := repositories.HabitFilter{
|
||||
Frequency: &frequency,
|
||||
}
|
||||
|
||||
habits, err := repo.FindByUserIDFiltered(ctx, userID, filter, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("FindByUserIDFiltered failed: %v", err)
|
||||
}
|
||||
|
||||
if len(habits) != 2 {
|
||||
t.Errorf("Expected 2 DAILY habits, got %d", len(habits))
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("FilterIncludeArchived", func(t *testing.T) {
|
||||
filter := repositories.HabitFilter{
|
||||
IncludeArchived: true,
|
||||
}
|
||||
|
||||
habits, err := repo.FindByUserIDFiltered(ctx, userID, filter, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("FindByUserIDFiltered failed: %v", err)
|
||||
}
|
||||
|
||||
if len(habits) != 4 {
|
||||
t.Errorf("Expected 4 habits (including archived), got %d", len(habits))
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("FilterBySearch", func(t *testing.T) {
|
||||
filter := repositories.HabitFilter{
|
||||
Search: "Exercise",
|
||||
}
|
||||
|
||||
habits, err := repo.FindByUserIDFiltered(ctx, userID, filter, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("FindByUserIDFiltered failed: %v", err)
|
||||
}
|
||||
|
||||
if len(habits) != 1 {
|
||||
t.Errorf("Expected 1 habit matching 'Exercise', got %d", len(habits))
|
||||
}
|
||||
|
||||
if habits[0].Name != "Morning Exercise" {
|
||||
t.Errorf("Expected 'Morning Exercise', got %s", habits[0].Name)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("FilterBySearchInDescription", func(t *testing.T) {
|
||||
filter := repositories.HabitFilter{
|
||||
Search: "books",
|
||||
}
|
||||
|
||||
habits, err := repo.FindByUserIDFiltered(ctx, userID, filter, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("FindByUserIDFiltered failed: %v", err)
|
||||
}
|
||||
|
||||
if len(habits) != 1 {
|
||||
t.Errorf("Expected 1 habit matching 'books' in description, got %d", len(habits))
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("CombineFilters", func(t *testing.T) {
|
||||
habitType := value_objects.HabitTypeBoolean
|
||||
frequency := value_objects.FrequencyWeekly
|
||||
filter := repositories.HabitFilter{
|
||||
Type: &habitType,
|
||||
Frequency: &frequency,
|
||||
IncludeArchived: true,
|
||||
}
|
||||
|
||||
habits, err := repo.FindByUserIDFiltered(ctx, userID, filter, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("FindByUserIDFiltered failed: %v", err)
|
||||
}
|
||||
|
||||
if len(habits) != 1 {
|
||||
t.Errorf("Expected 1 BOOLEAN WEEKLY habit (archived), got %d", len(habits))
|
||||
}
|
||||
|
||||
if habits[0].Name != "Weekly Run" {
|
||||
t.Errorf("Expected 'Weekly Run', got %s", habits[0].Name)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("WithPagination", func(t *testing.T) {
|
||||
filter := repositories.HabitFilter{}
|
||||
params := pagination.NewParams(1, 2)
|
||||
|
||||
habits, err := repo.FindByUserIDFiltered(ctx, userID, filter, ¶ms)
|
||||
if err != nil {
|
||||
t.Fatalf("FindByUserIDFiltered failed: %v", err)
|
||||
}
|
||||
|
||||
if len(habits) != 2 {
|
||||
t.Errorf("Expected 2 habits on first page, got %d", len(habits))
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestHabitRepositoryCountByUserIDFiltered(t *testing.T) {
|
||||
db := setupTestDB(t)
|
||||
defer db.Close()
|
||||
|
||||
repo := NewHabitRepository(db)
|
||||
ctx := context.Background()
|
||||
|
||||
userID := "user-count-filter-test"
|
||||
|
||||
habit1 := entities.NewHabit(userID, "Test1", value_objects.HabitTypeBoolean, value_objects.FrequencyDaily, false, false)
|
||||
repo.Create(ctx, habit1)
|
||||
|
||||
habit2 := entities.NewHabit(userID, "Test2", value_objects.HabitTypeCounter, value_objects.FrequencyDaily, false, false)
|
||||
repo.Create(ctx, habit2)
|
||||
|
||||
habit3 := entities.NewHabit(userID, "Test3", value_objects.HabitTypeBoolean, value_objects.FrequencyWeekly, false, false)
|
||||
now := time.Now()
|
||||
habit3.ArchivedAt = &now
|
||||
repo.Create(ctx, habit3)
|
||||
repo.Update(ctx, habit3)
|
||||
|
||||
t.Run("CountByType", func(t *testing.T) {
|
||||
habitType := value_objects.HabitTypeBoolean
|
||||
filter := repositories.HabitFilter{
|
||||
Type: &habitType,
|
||||
}
|
||||
|
||||
count, err := repo.CountByUserIDFiltered(ctx, userID, filter)
|
||||
if err != nil {
|
||||
t.Fatalf("CountByUserIDFiltered failed: %v", err)
|
||||
}
|
||||
|
||||
if count != 1 {
|
||||
t.Errorf("Expected 1 active BOOLEAN habit, got %d", count)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("CountWithArchived", func(t *testing.T) {
|
||||
habitType := value_objects.HabitTypeBoolean
|
||||
filter := repositories.HabitFilter{
|
||||
Type: &habitType,
|
||||
IncludeArchived: true,
|
||||
}
|
||||
|
||||
count, err := repo.CountByUserIDFiltered(ctx, userID, filter)
|
||||
if err != nil {
|
||||
t.Fatalf("CountByUserIDFiltered failed: %v", err)
|
||||
}
|
||||
|
||||
if count != 2 {
|
||||
t.Errorf("Expected 2 BOOLEAN habits (including archived), got %d", count)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -0,0 +1,530 @@
|
||||
package sqlite
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"apocapoc-api/internal/domain/entities"
|
||||
"apocapoc-api/internal/domain/value_objects"
|
||||
)
|
||||
|
||||
func TestHabitRepository_GetChangesSince_EmptyWhenNoChanges(t *testing.T) {
|
||||
db := setupTestDB(t)
|
||||
defer db.Close()
|
||||
|
||||
repo := NewHabitRepository(db)
|
||||
ctx := context.Background()
|
||||
userID := "user-123"
|
||||
|
||||
// Crear hábito inicial
|
||||
habit := entities.NewHabit(
|
||||
userID,
|
||||
"Initial Habit",
|
||||
value_objects.HabitTypeBoolean,
|
||||
value_objects.FrequencyDaily,
|
||||
false,
|
||||
false,
|
||||
)
|
||||
|
||||
err := repo.Create(ctx, habit)
|
||||
if err != nil {
|
||||
t.Fatalf("Create failed: %v", err)
|
||||
}
|
||||
|
||||
// Timestamp después de la creación
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
since := time.Now()
|
||||
|
||||
// No hay cambios después de 'since'
|
||||
changes, err := repo.GetChangesSince(ctx, userID, since)
|
||||
if err != nil {
|
||||
t.Fatalf("GetChangesSince failed: %v", err)
|
||||
}
|
||||
|
||||
if len(changes.Created) != 0 {
|
||||
t.Errorf("Expected 0 created habits, got %d", len(changes.Created))
|
||||
}
|
||||
if len(changes.Updated) != 0 {
|
||||
t.Errorf("Expected 0 updated habits, got %d", len(changes.Updated))
|
||||
}
|
||||
if len(changes.Deleted) != 0 {
|
||||
t.Errorf("Expected 0 deleted habits, got %d", len(changes.Deleted))
|
||||
}
|
||||
}
|
||||
|
||||
func TestHabitRepository_GetChangesSince_ReturnsCreatedHabits(t *testing.T) {
|
||||
db := setupTestDB(t)
|
||||
defer db.Close()
|
||||
|
||||
repo := NewHabitRepository(db)
|
||||
ctx := context.Background()
|
||||
userID := "user-123"
|
||||
|
||||
// Timestamp de referencia
|
||||
since := time.Now()
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
|
||||
// Crear hábito DESPUÉS de 'since'
|
||||
habit := entities.NewHabit(
|
||||
userID,
|
||||
"New Habit",
|
||||
value_objects.HabitTypeBoolean,
|
||||
value_objects.FrequencyDaily,
|
||||
false,
|
||||
false,
|
||||
)
|
||||
|
||||
err := repo.Create(ctx, habit)
|
||||
if err != nil {
|
||||
t.Fatalf("Create failed: %v", err)
|
||||
}
|
||||
|
||||
// Obtener cambios
|
||||
changes, err := repo.GetChangesSince(ctx, userID, since)
|
||||
if err != nil {
|
||||
t.Fatalf("GetChangesSince failed: %v", err)
|
||||
}
|
||||
|
||||
if len(changes.Created) != 1 {
|
||||
t.Fatalf("Expected 1 created habit, got %d", len(changes.Created))
|
||||
}
|
||||
|
||||
if changes.Created[0].Name != "New Habit" {
|
||||
t.Errorf("Expected habit name 'New Habit', got '%s'", changes.Created[0].Name)
|
||||
}
|
||||
|
||||
if changes.Created[0].ID != habit.ID {
|
||||
t.Errorf("Expected habit ID '%s', got '%s'", habit.ID, changes.Created[0].ID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHabitRepository_GetChangesSince_ReturnsUpdatedHabits(t *testing.T) {
|
||||
db := setupTestDB(t)
|
||||
defer db.Close()
|
||||
|
||||
repo := NewHabitRepository(db)
|
||||
ctx := context.Background()
|
||||
userID := "user-123"
|
||||
|
||||
// Crear hábito inicial
|
||||
habit := entities.NewHabit(
|
||||
userID,
|
||||
"Original Name",
|
||||
value_objects.HabitTypeBoolean,
|
||||
value_objects.FrequencyDaily,
|
||||
false,
|
||||
false,
|
||||
)
|
||||
|
||||
err := repo.Create(ctx, habit)
|
||||
if err != nil {
|
||||
t.Fatalf("Create failed: %v", err)
|
||||
}
|
||||
|
||||
// Timestamp de referencia
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
since := time.Now()
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
|
||||
// Actualizar hábito DESPUÉS de 'since'
|
||||
habit.Name = "Updated Name"
|
||||
err = repo.Update(ctx, habit)
|
||||
if err != nil {
|
||||
t.Fatalf("Update failed: %v", err)
|
||||
}
|
||||
|
||||
// Obtener cambios
|
||||
changes, err := repo.GetChangesSince(ctx, userID, since)
|
||||
if err != nil {
|
||||
t.Fatalf("GetChangesSince failed: %v", err)
|
||||
}
|
||||
|
||||
if len(changes.Updated) != 1 {
|
||||
t.Fatalf("Expected 1 updated habit, got %d", len(changes.Updated))
|
||||
}
|
||||
|
||||
if changes.Updated[0].Name != "Updated Name" {
|
||||
t.Errorf("Expected updated name 'Updated Name', got '%s'", changes.Updated[0].Name)
|
||||
}
|
||||
|
||||
if len(changes.Created) != 0 {
|
||||
t.Errorf("Expected 0 created habits (should be in Updated), got %d", len(changes.Created))
|
||||
}
|
||||
}
|
||||
|
||||
func TestHabitRepository_GetChangesSince_ReturnsDeletedHabits(t *testing.T) {
|
||||
db := setupTestDB(t)
|
||||
defer db.Close()
|
||||
|
||||
repo := NewHabitRepository(db)
|
||||
ctx := context.Background()
|
||||
userID := "user-123"
|
||||
|
||||
// Crear hábito
|
||||
habit := entities.NewHabit(
|
||||
userID,
|
||||
"To Delete",
|
||||
value_objects.HabitTypeBoolean,
|
||||
value_objects.FrequencyDaily,
|
||||
false,
|
||||
false,
|
||||
)
|
||||
|
||||
err := repo.Create(ctx, habit)
|
||||
if err != nil {
|
||||
t.Fatalf("Create failed: %v", err)
|
||||
}
|
||||
|
||||
habitID := habit.ID
|
||||
|
||||
// Timestamp de referencia
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
since := time.Now()
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
|
||||
// Soft delete DESPUÉS de 'since'
|
||||
err = repo.SoftDelete(ctx, habitID)
|
||||
if err != nil {
|
||||
t.Fatalf("SoftDelete failed: %v", err)
|
||||
}
|
||||
|
||||
// Obtener cambios
|
||||
changes, err := repo.GetChangesSince(ctx, userID, since)
|
||||
if err != nil {
|
||||
t.Fatalf("GetChangesSince failed: %v", err)
|
||||
}
|
||||
|
||||
if len(changes.Deleted) != 1 {
|
||||
t.Fatalf("Expected 1 deleted habit, got %d", len(changes.Deleted))
|
||||
}
|
||||
|
||||
if changes.Deleted[0] != habitID {
|
||||
t.Errorf("Expected deleted habit ID '%s', got '%s'", habitID, changes.Deleted[0])
|
||||
}
|
||||
}
|
||||
|
||||
func TestHabitRepository_GetChangesSince_CombinedChanges(t *testing.T) {
|
||||
db := setupTestDB(t)
|
||||
defer db.Close()
|
||||
|
||||
repo := NewHabitRepository(db)
|
||||
ctx := context.Background()
|
||||
userID := "user-123"
|
||||
|
||||
// Crear hábito inicial (antes de 'since')
|
||||
habitOld := entities.NewHabit(
|
||||
userID,
|
||||
"Old Habit",
|
||||
value_objects.HabitTypeBoolean,
|
||||
value_objects.FrequencyDaily,
|
||||
false,
|
||||
false,
|
||||
)
|
||||
repo.Create(ctx, habitOld)
|
||||
|
||||
// Timestamp de referencia
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
since := time.Now()
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
|
||||
// DESPUÉS de 'since':
|
||||
// 1. Crear nuevo hábito
|
||||
habitNew := entities.NewHabit(
|
||||
userID,
|
||||
"New Habit",
|
||||
value_objects.HabitTypeCounter,
|
||||
value_objects.FrequencyWeekly,
|
||||
false,
|
||||
false,
|
||||
)
|
||||
habitNew.SpecificDays = []int{1, 3, 5}
|
||||
repo.Create(ctx, habitNew)
|
||||
|
||||
// 2. Actualizar hábito existente
|
||||
habitOld.Name = "Old Habit Updated"
|
||||
repo.Update(ctx, habitOld)
|
||||
|
||||
// 3. Crear y eliminar otro hábito
|
||||
habitToDelete := entities.NewHabit(
|
||||
userID,
|
||||
"To Delete",
|
||||
value_objects.HabitTypeBoolean,
|
||||
value_objects.FrequencyDaily,
|
||||
false,
|
||||
false,
|
||||
)
|
||||
repo.Create(ctx, habitToDelete)
|
||||
repo.SoftDelete(ctx, habitToDelete.ID)
|
||||
|
||||
// Obtener cambios
|
||||
changes, err := repo.GetChangesSince(ctx, userID, since)
|
||||
if err != nil {
|
||||
t.Fatalf("GetChangesSince failed: %v", err)
|
||||
}
|
||||
|
||||
// Verificar creados (habitNew, NO habitToDelete porque fue eliminado)
|
||||
if len(changes.Created) != 1 {
|
||||
t.Errorf("Expected 1 created habit, got %d", len(changes.Created))
|
||||
}
|
||||
if len(changes.Created) > 0 && changes.Created[0].Name != "New Habit" {
|
||||
t.Errorf("Expected created habit name 'New Habit', got '%s'", changes.Created[0].Name)
|
||||
}
|
||||
|
||||
// Verificar actualizados
|
||||
if len(changes.Updated) != 1 {
|
||||
t.Errorf("Expected 1 updated habit, got %d", len(changes.Updated))
|
||||
}
|
||||
if len(changes.Updated) > 0 && changes.Updated[0].Name != "Old Habit Updated" {
|
||||
t.Errorf("Expected updated habit name 'Old Habit Updated', got '%s'", changes.Updated[0].Name)
|
||||
}
|
||||
|
||||
// Verificar eliminados
|
||||
if len(changes.Deleted) != 1 {
|
||||
t.Errorf("Expected 1 deleted habit, got %d", len(changes.Deleted))
|
||||
}
|
||||
if len(changes.Deleted) > 0 && changes.Deleted[0] != habitToDelete.ID {
|
||||
t.Errorf("Expected deleted habit ID '%s', got '%s'", habitToDelete.ID, changes.Deleted[0])
|
||||
}
|
||||
}
|
||||
|
||||
func TestHabitRepository_GetChangesSince_OnlyReturnsUserHabits(t *testing.T) {
|
||||
db := setupTestDB(t)
|
||||
defer db.Close()
|
||||
|
||||
repo := NewHabitRepository(db)
|
||||
ctx := context.Background()
|
||||
|
||||
since := time.Now()
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
|
||||
// Crear hábitos de diferentes usuarios
|
||||
habitUser1 := entities.NewHabit(
|
||||
"user-1",
|
||||
"User 1 Habit",
|
||||
value_objects.HabitTypeBoolean,
|
||||
value_objects.FrequencyDaily,
|
||||
false,
|
||||
false,
|
||||
)
|
||||
repo.Create(ctx, habitUser1)
|
||||
|
||||
habitUser2 := entities.NewHabit(
|
||||
"user-2",
|
||||
"User 2 Habit",
|
||||
value_objects.HabitTypeBoolean,
|
||||
value_objects.FrequencyDaily,
|
||||
false,
|
||||
false,
|
||||
)
|
||||
repo.Create(ctx, habitUser2)
|
||||
|
||||
// Obtener cambios solo de user-1
|
||||
changes, err := repo.GetChangesSince(ctx, "user-1", since)
|
||||
if err != nil {
|
||||
t.Fatalf("GetChangesSince failed: %v", err)
|
||||
}
|
||||
|
||||
if len(changes.Created) != 1 {
|
||||
t.Fatalf("Expected 1 created habit for user-1, got %d", len(changes.Created))
|
||||
}
|
||||
|
||||
if changes.Created[0].UserID != "user-1" {
|
||||
t.Errorf("Expected user ID 'user-1', got '%s'", changes.Created[0].UserID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHabitRepository_SoftDelete_MarksAsDeleted(t *testing.T) {
|
||||
db := setupTestDB(t)
|
||||
defer db.Close()
|
||||
|
||||
repo := NewHabitRepository(db)
|
||||
ctx := context.Background()
|
||||
|
||||
// Crear hábito
|
||||
habit := entities.NewHabit(
|
||||
"user-123",
|
||||
"To Delete",
|
||||
value_objects.HabitTypeBoolean,
|
||||
value_objects.FrequencyDaily,
|
||||
false,
|
||||
false,
|
||||
)
|
||||
repo.Create(ctx, habit)
|
||||
|
||||
// Soft delete
|
||||
err := repo.SoftDelete(ctx, habit.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("SoftDelete failed: %v", err)
|
||||
}
|
||||
|
||||
// El hábito NO debe aparecer en FindByID (porque está eliminado)
|
||||
found, err := repo.FindByID(ctx, habit.ID)
|
||||
if err == nil {
|
||||
t.Error("Expected error when finding soft-deleted habit, got nil")
|
||||
}
|
||||
if found != nil {
|
||||
t.Error("Expected nil habit when soft-deleted, got habit")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHabitRepository_SoftDelete_NotFoundError(t *testing.T) {
|
||||
db := setupTestDB(t)
|
||||
defer db.Close()
|
||||
|
||||
repo := NewHabitRepository(db)
|
||||
ctx := context.Background()
|
||||
|
||||
// Intentar eliminar hábito inexistente
|
||||
err := repo.SoftDelete(ctx, "non-existent-id")
|
||||
if err == nil {
|
||||
t.Error("Expected error when deleting non-existent habit, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHabitRepository_SoftDelete_CannotDeleteTwice(t *testing.T) {
|
||||
db := setupTestDB(t)
|
||||
defer db.Close()
|
||||
|
||||
repo := NewHabitRepository(db)
|
||||
ctx := context.Background()
|
||||
|
||||
// Crear hábito
|
||||
habit := entities.NewHabit(
|
||||
"user-123",
|
||||
"To Delete",
|
||||
value_objects.HabitTypeBoolean,
|
||||
value_objects.FrequencyDaily,
|
||||
false,
|
||||
false,
|
||||
)
|
||||
repo.Create(ctx, habit)
|
||||
|
||||
// Primera eliminación
|
||||
err := repo.SoftDelete(ctx, habit.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("First SoftDelete failed: %v", err)
|
||||
}
|
||||
|
||||
// Segunda eliminación debe fallar
|
||||
err = repo.SoftDelete(ctx, habit.ID)
|
||||
if err == nil {
|
||||
t.Error("Expected error when deleting already deleted habit, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHabitRepository_Update_UpdatesUpdatedAt(t *testing.T) {
|
||||
db := setupTestDB(t)
|
||||
defer db.Close()
|
||||
|
||||
repo := NewHabitRepository(db)
|
||||
ctx := context.Background()
|
||||
|
||||
// Crear hábito
|
||||
habit := entities.NewHabit(
|
||||
"user-123",
|
||||
"Original",
|
||||
value_objects.HabitTypeBoolean,
|
||||
value_objects.FrequencyDaily,
|
||||
false,
|
||||
false,
|
||||
)
|
||||
repo.Create(ctx, habit)
|
||||
|
||||
originalUpdatedAt := habit.UpdatedAt
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
|
||||
// Actualizar
|
||||
habit.Name = "Updated"
|
||||
err := repo.Update(ctx, habit)
|
||||
if err != nil {
|
||||
t.Fatalf("Update failed: %v", err)
|
||||
}
|
||||
|
||||
// Verificar que UpdatedAt cambió
|
||||
if !habit.UpdatedAt.After(originalUpdatedAt) {
|
||||
t.Errorf("Expected UpdatedAt to be updated, but it wasn't. Original: %v, Current: %v",
|
||||
originalUpdatedAt, habit.UpdatedAt)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHabitRepository_Create_SetsUpdatedAt(t *testing.T) {
|
||||
db := setupTestDB(t)
|
||||
defer db.Close()
|
||||
|
||||
repo := NewHabitRepository(db)
|
||||
ctx := context.Background()
|
||||
|
||||
// Crear hábito
|
||||
habit := entities.NewHabit(
|
||||
"user-123",
|
||||
"New Habit",
|
||||
value_objects.HabitTypeBoolean,
|
||||
value_objects.FrequencyDaily,
|
||||
false,
|
||||
false,
|
||||
)
|
||||
|
||||
err := repo.Create(ctx, habit)
|
||||
if err != nil {
|
||||
t.Fatalf("Create failed: %v", err)
|
||||
}
|
||||
|
||||
// Verificar que UpdatedAt está seteado
|
||||
if habit.UpdatedAt.IsZero() {
|
||||
t.Error("Expected UpdatedAt to be set, got zero value")
|
||||
}
|
||||
|
||||
// UpdatedAt debe ser igual a CreatedAt al crear
|
||||
if !habit.UpdatedAt.Equal(habit.CreatedAt) {
|
||||
t.Errorf("Expected UpdatedAt to equal CreatedAt on creation. UpdatedAt: %v, CreatedAt: %v",
|
||||
habit.UpdatedAt, habit.CreatedAt)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHabitRepository_FindActiveByUserID_ExcludesSoftDeleted(t *testing.T) {
|
||||
db := setupTestDB(t)
|
||||
defer db.Close()
|
||||
|
||||
repo := NewHabitRepository(db)
|
||||
ctx := context.Background()
|
||||
userID := "user-123"
|
||||
|
||||
// Crear 2 hábitos
|
||||
habit1 := entities.NewHabit(
|
||||
userID,
|
||||
"Active Habit",
|
||||
value_objects.HabitTypeBoolean,
|
||||
value_objects.FrequencyDaily,
|
||||
false,
|
||||
false,
|
||||
)
|
||||
repo.Create(ctx, habit1)
|
||||
|
||||
habit2 := entities.NewHabit(
|
||||
userID,
|
||||
"Deleted Habit",
|
||||
value_objects.HabitTypeBoolean,
|
||||
value_objects.FrequencyDaily,
|
||||
false,
|
||||
false,
|
||||
)
|
||||
repo.Create(ctx, habit2)
|
||||
|
||||
// Soft delete uno
|
||||
repo.SoftDelete(ctx, habit2.ID)
|
||||
|
||||
// FindActiveByUserID debe devolver solo el activo
|
||||
activeHabits, err := repo.FindActiveByUserID(ctx, userID)
|
||||
if err != nil {
|
||||
t.Fatalf("FindActiveByUserID failed: %v", err)
|
||||
}
|
||||
|
||||
if len(activeHabits) != 1 {
|
||||
t.Fatalf("Expected 1 active habit, got %d", len(activeHabits))
|
||||
}
|
||||
|
||||
if activeHabits[0].Name != "Active Habit" {
|
||||
t.Errorf("Expected 'Active Habit', got '%s'", activeHabits[0].Name)
|
||||
}
|
||||
}
|
||||
@@ -25,6 +25,14 @@ func RunMigrations(db *sql.DB) error {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := removeTimezoneColumn(db); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := addSyncColumns(db); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -54,6 +62,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
|
||||
@@ -64,12 +89,103 @@ func columnExists(db *sql.DB, table, column string) (bool, error) {
|
||||
return count > 0, nil
|
||||
}
|
||||
|
||||
func indexExists(db *sql.DB, indexName string) (bool, error) {
|
||||
query := "SELECT COUNT(*) FROM sqlite_master WHERE type = 'index' AND name = ?"
|
||||
var count int
|
||||
err := db.QueryRow(query, indexName).Scan(&count)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
return count > 0, nil
|
||||
}
|
||||
|
||||
func addSyncColumns(db *sql.DB) error {
|
||||
// Columns to add to habits table
|
||||
habitColumns := []struct {
|
||||
name string
|
||||
definition string
|
||||
}{
|
||||
{"updated_at", "ALTER TABLE habits ADD COLUMN updated_at DATETIME"},
|
||||
{"deleted_at", "ALTER TABLE habits ADD COLUMN deleted_at DATETIME"},
|
||||
}
|
||||
|
||||
for _, col := range habitColumns {
|
||||
exists, err := columnExists(db, "habits", col.name)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to check if column %s exists: %w", col.name, err)
|
||||
}
|
||||
|
||||
if !exists {
|
||||
if _, err := db.Exec(col.definition); err != nil {
|
||||
return fmt.Errorf("failed to add column %s: %w", col.name, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize updated_at with created_at for existing records
|
||||
if _, err := db.Exec("UPDATE habits SET updated_at = created_at WHERE updated_at IS NULL"); err != nil {
|
||||
return fmt.Errorf("failed to initialize updated_at: %w", err)
|
||||
}
|
||||
|
||||
// Columns to add to habit_entries table
|
||||
entryColumns := []struct {
|
||||
name string
|
||||
definition string
|
||||
}{
|
||||
{"updated_at", "ALTER TABLE habit_entries ADD COLUMN updated_at DATETIME"},
|
||||
{"deleted_at", "ALTER TABLE habit_entries ADD COLUMN deleted_at DATETIME"},
|
||||
}
|
||||
|
||||
for _, col := range entryColumns {
|
||||
exists, err := columnExists(db, "habit_entries", col.name)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to check if column %s exists: %w", col.name, err)
|
||||
}
|
||||
|
||||
if !exists {
|
||||
if _, err := db.Exec(col.definition); err != nil {
|
||||
return fmt.Errorf("failed to add column %s: %w", col.name, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize updated_at with completed_at for existing entries
|
||||
if _, err := db.Exec("UPDATE habit_entries SET updated_at = completed_at WHERE updated_at IS NULL"); err != nil {
|
||||
return fmt.Errorf("failed to initialize updated_at for entries: %w", err)
|
||||
}
|
||||
|
||||
// Create indexes for sync queries
|
||||
indexes := []struct {
|
||||
name string
|
||||
definition string
|
||||
}{
|
||||
{"idx_habits_updated_at", "CREATE INDEX IF NOT EXISTS idx_habits_updated_at ON habits(user_id, updated_at)"},
|
||||
{"idx_habits_deleted_at", "CREATE INDEX IF NOT EXISTS idx_habits_deleted_at ON habits(deleted_at)"},
|
||||
{"idx_habit_entries_updated_at", "CREATE INDEX IF NOT EXISTS idx_habit_entries_updated_at ON habit_entries(habit_id, updated_at)"},
|
||||
{"idx_habit_entries_deleted_at", "CREATE INDEX IF NOT EXISTS idx_habit_entries_deleted_at ON habit_entries(deleted_at)"},
|
||||
}
|
||||
|
||||
for _, idx := range indexes {
|
||||
exists, err := indexExists(db, idx.name)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to check if index %s exists: %w", idx.name, err)
|
||||
}
|
||||
|
||||
if !exists {
|
||||
if _, err := db.Exec(idx.definition); err != nil {
|
||||
return fmt.Errorf("failed to create index %s: %w", idx.name, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
const createUsersTable = `
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
id TEXT PRIMARY KEY,
|
||||
email TEXT UNIQUE NOT NULL,
|
||||
password_hash TEXT NOT NULL,
|
||||
timezone TEXT DEFAULT 'UTC',
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
@@ -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