12 Commits

Author SHA1 Message Date
david aa8f7af55d feat: implement offline sync endpoints with Last-Write-Wins strategy
Add comprehensive offline synchronization support for habits and entries:

## Infrastructure (Phase 1)
- Add UpdatedAt and DeletedAt timestamps to Habit and HabitEntry entities
- Implement soft delete with Delete(), Touch(), and IsDeleted() methods
- Create SQL migration with optimized composite indexes for sync queries
- Add GetChangesSince() and SoftDelete() to both repositories
- Update all Find* methods to exclude soft-deleted records
- 13 comprehensive TDD tests for sync repository methods

## HTTP Endpoints (Phase 2)
- GET /api/v1/sync/changes: retrieve all changes since timestamp
- POST /api/v1/sync/batch: apply client changes with conflict resolution
- Implement Last-Write-Wins strategy using UpdatedAt timestamps
- Add authentication and rate limiting (100 req/min)
- Validate user ownership for all sync operations
- 9 tests for sync handlers (3 queries + 6 commands)

## Technical Details
- Composite indexes: (user_id, updated_at) for optimal query performance
- No pagination: atomic sync operations for data consistency
- Upsert behavior: create resources if not found on server
- DTOs with full entity state including timestamps
- Swagger documentation updated for new endpoints

All 220+ tests passing ✓
2025-12-12 00:11:46 +01:00
david 1aedc2b69a Add integration tests for auth refresh flow, rate limiting, and statistics
- Add refresh token flow tests including token rotation and invalidation
- Add rate limiting integration tests
- Add statistics endpoint integration tests
2025-12-05 01:24:22 +01:00
david e9e7e9dbac Add automated backup system with SQLite VACUUM
- Implement backup package with SQLite VACUUM INTO for safe backups
- Add scheduler with configurable interval (default: 24h)
- Add retention policy with automatic cleanup (default: 7 days)
- Add optional gzip compression (~10x size reduction)
- Add comprehensive tests for backup creation and cleanup
- Integrate backup scheduler in main.go with graceful shutdown
- Add backup configuration variables (BACKUP_ENABLED, BACKUP_INTERVAL, BACKUP_RETENTION_DAYS, BACKUP_PATH, BACKUP_COMPRESS)
- Backups run automatically in background goroutine
- Coverage: backup package 52.7%
2025-12-05 00:17:37 +01:00
david 88b5d11113 Fix code formatting with gofmt 2025-12-04 23:26:51 +01:00
david 29f0f9b468 Add structured logging with zerolog
- Implement zerolog logger package with configurable levels
- Add contextual logging middleware (request_id, user_id, method, path, status, duration)
- Support both JSON (production) and human-readable (development) formats
- Add LOG_LEVEL and ENVIRONMENT configuration variables
- Replace standard log calls with structured logger throughout application
- Integrate logger in HTTP router and auth middleware
2025-12-04 23:22:05 +01:00
david 7575853355 Add rate limiting for password reset and improve SMTP tests
- Add RateLimitByEmail middleware (3 attempts/hour per email)
- Apply rate limiting to /api/v1/auth/forgot-password endpoint
- Expand SMTP tests with config validation, message types, and error detection
- Improve test coverage from 44.6% to 53.3%
2025-12-04 22:54:24 +01:00
david 788b6cf430 Add data export functionality with gzip compression
- Add FindByUserID method to HabitEntryRepository (JOIN with habits)
- Implement ExportUserDataHandler to export all user data
- Add GET /api/v1/export endpoint with gzip compression
- Apply strict rate limiting (1 export per hour per user)
- Export includes all habits (active + archived) and entries
- Add i18n translations for export errors (en/es)
- Update all test mocks to implement new repository method
- Export format: JSON with gzip (~10x compression ratio)
2025-12-03 22:32:22 +01:00
david 568ba3b016 Add email logging and SMTP health check
- Add structured logging for email sending (success/failure)
- Add HealthCheck method to EmailService interface
- Extend /health endpoint to include SMTP status
- Update all email service mocks to implement HealthCheck
- SMTP status shows: ok, error, or disabled
2025-12-03 20:41:40 +01:00
david 935f742ac9 Add filtering support to GET /api/v1/habits endpoint
Implemented comprehensive filtering capabilities for the habits list endpoint:
- Filter by type (BOOLEAN, COUNTER, VALUE)
- Filter by frequency (DAILY, WEEKLY, MONTHLY)
- Filter by archived status
- Text search in habit name and description
- All filters can be combined
- Filters work with pagination

Technical changes:
- Added FilterParams to GetUserHabitsQuery
- Created HabitFilter struct in repository interface
- Implemented dynamic SQL query building in SQLite repository
- Updated HTTP handler to parse filter query parameters
- Added comprehensive tests for repository and handler filtering
- Updated all test mocks with new filter methods
2025-12-02 01:02:39 +01:00
david 38e640c617 Add pagination support to GET /api/v1/habits endpoint
- Create pagination package with Params and Response structs
- Add FindActiveByUserIDWithPagination and CountActiveByUserID methods to HabitRepository interface
- Implement pagination in SQLite repository using LIMIT and OFFSET
- Update GetUserHabitsHandler to support optional pagination parameters
- Modify HTTP endpoint to parse 'page' and 'page_size' query params (default: page=1, page_size=50, max=100)
- Add GetUserHabitsResponse DTO with pagination metadata
- Maintain backward compatibility - endpoint works with and without pagination params
- Add comprehensive tests for pagination logic in repository, handler, and pagination package
- Update all mock repositories to implement new pagination methods
2025-12-01 23:53:00 +01:00
david f780c69806 Add tests for RequestPasswordResetHandler and fix test compilation errors
- Add comprehensive tests for RequestPasswordResetHandler covering success and error cases
- Fix timezone-related test failures after removal of User.Timezone field:
  - Remove Timezone assertions from login_user_test.go
  - Remove Timezone field from RegisterRequest in integration tests
  - Update ValidateRegistration test cases (no longer validates timezone)
  - Update migrations_test to check current user table schema
- Fix syntax errors in user_repository_test.go (duplicate closing braces on lines 114 and 210)
2025-12-01 23:04:40 +01:00
david 5d92820591 Remove timezone from User model and pass as request parameter
Timezone is now sent from the client on each request that needs it,
instead of storing it in the database. This simplifies the model and
allows timezone to be dynamic (useful for traveling users).

Changes:
- Remove timezone field from User entity
- Remove timezone from user registration
- GET /habits/today now requires ?timezone= query param
- Add migration to drop timezone column from database
- Update related tests
2025-11-29 16:18:04 +01:00
72 changed files with 7979 additions and 368 deletions
+11
View File
@@ -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
+43 -10
View File
@@ -12,10 +12,12 @@ import (
"apocapoc-api/internal/application/queries"
"apocapoc-api/internal/i18n"
"apocapoc-api/internal/infrastructure/auth"
"apocapoc-api/internal/infrastructure/backup"
"apocapoc-api/internal/infrastructure/config"
"apocapoc-api/internal/infrastructure/crypto"
"apocapoc-api/internal/infrastructure/email"
httpInfra "apocapoc-api/internal/infrastructure/http"
"apocapoc-api/internal/infrastructure/logger"
"apocapoc-api/internal/infrastructure/persistence/sqlite"
)
@@ -43,20 +45,46 @@ func main() {
log.Fatalf("Failed to load config: %v", err)
}
logger.Init(logger.Config{
Level: cfg.LogLevel,
Environment: cfg.Environment,
})
db, err := sqlite.NewDatabase(cfg.DBPath)
if err != nil {
log.Fatalf("Failed to connect to database: %v", err)
logger.Fatal().Err(err).Msg("Failed to connect to database")
}
defer db.Close()
backupInterval, err := parseDuration(cfg.BackupInterval)
if err != nil {
logger.Fatal().Err(err).Msg("Invalid BACKUP_INTERVAL")
}
backupRetentionDays, err := strconv.Atoi(cfg.BackupRetentionDays)
if err != nil {
logger.Fatal().Err(err).Msg("Invalid BACKUP_RETENTION_DAYS")
}
backupScheduler := backup.NewScheduler(db.Conn(), backup.Config{
Enabled: cfg.BackupEnabled == "true",
Interval: backupInterval,
RetentionDays: backupRetentionDays,
Path: cfg.BackupPath,
Compress: cfg.BackupCompress == "true",
DatabasePath: cfg.DBPath,
})
backupScheduler.Start()
defer backupScheduler.Stop()
jwtExpiryHours, err := parseJWTExpiry(cfg.JWTExpiry)
if err != nil {
log.Fatalf("Invalid JWT_EXPIRY: %v", err)
logger.Fatal().Err(err).Msg("Invalid JWT_EXPIRY")
}
refreshTokenExpiry, err := parseDuration(cfg.RefreshTokenExpiry)
if err != nil {
log.Fatalf("Invalid REFRESH_TOKEN_EXPIRY: %v", err)
logger.Fatal().Err(err).Msg("Invalid REFRESH_TOKEN_EXPIRY")
}
jwtService := auth.NewJWTService(cfg.JWTSecret, jwtExpiryHours)
@@ -66,7 +94,7 @@ func main() {
if cfg.SMTPHost != "" {
smtpPort, err := strconv.Atoi(cfg.SMTPPort)
if err != nil {
log.Fatalf("Invalid SMTP_PORT: %v", err)
logger.Fatal().Err(err).Msg("Invalid SMTP_PORT")
}
emailService = email.NewSMTPService(email.SMTPConfig{
@@ -89,7 +117,7 @@ func main() {
translator, err := i18n.NewTranslator()
if err != nil {
log.Fatalf("Failed to create translator: %v", err)
logger.Fatal().Err(err).Msg("Failed to create translator")
}
registerHandler := commands.NewRegisterUserHandler(userRepo, passwordHasher, emailService, cfg.AppURL, cfg.RegistrationMode, sendWelcomeEmail)
@@ -108,24 +136,29 @@ 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)
logger.Info().Str("address", addr).Msg("Server starting")
if err := http.ListenAndServe(addr, router); err != nil {
log.Fatalf("Server failed: %v", err)
logger.Fatal().Err(err).Msg("Server failed")
}
}
+846 -12
View File
@@ -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": {
@@ -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"
},
@@ -1141,6 +1852,129 @@ 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": {
+846 -12
View File
@@ -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": {
@@ -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"
},
@@ -1133,6 +1844,129 @@
}
}
},
"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": {
+557 -12
View File
@@ -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:
@@ -164,6 +302,86 @@ 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:
@@ -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:
@@ -786,6 +1227,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.
+5 -2
View File
@@ -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
+15 -6
View File
@@ -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
}
@@ -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
}
@@ -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: &params,
}
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: &params,
}
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: &params,
}
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: &params,
}
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: &params,
}
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)
}
})
}
+4 -6
View File
@@ -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
}
+20 -1
View File
@@ -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()
}
+15 -1
View File
@@ -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
}
+1 -6
View File
@@ -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,
}
+1 -14
View File
@@ -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
}
+4 -1
View File
@@ -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.",
+4 -1
View File
@@ -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.",
+95
View File
@@ -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)
}
}
+44 -30
View File
@@ -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))
})
}
+54
View File
@@ -4,6 +4,7 @@ import (
"time"
"apocapoc-api/internal/domain/value_objects"
"apocapoc-api/internal/shared/pagination"
)
type CreateHabitRequest struct {
@@ -76,6 +77,11 @@ type UserHabitResponse struct {
IsNegative bool `json:"is_negative"`
}
type GetUserHabitsResponse struct {
Data []UserHabitResponse `json:"data"`
Pagination *pagination.Response `json:"pagination,omitempty"`
}
type HabitEntryResponse struct {
ID string `json:"id"`
HabitID string `json:"habit_id"`
@@ -99,3 +105,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
}
}
+89 -18
View File
@@ -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 = &params
}
typeStr := r.URL.Query().Get("type")
frequencyStr := r.URL.Query().Get("frequency")
archivedStr := r.URL.Query().Get("archived")
searchStr := r.URL.Query().Get("search")
if typeStr != "" || frequencyStr != "" || archivedStr != "" || searchStr != "" {
filterParams := &queries.FilterParams{}
if typeStr != "" {
habitType := value_objects.HabitType(typeStr)
if habitType.IsValid() {
filterParams.Type = &habitType
}
}
if frequencyStr != "" {
frequency := value_objects.Frequency(frequencyStr)
if frequency.IsValid() {
filterParams.Frequency = &frequency
}
}
if archivedStr == "true" {
filterParams.IncludeArchived = true
}
if searchStr != "" {
filterParams.Search = searchStr
}
query.FilterParams = filterParams
}
result, err := h.getUserHabitsHandler.Handle(r.Context(), query)
if err != nil {
respondErrorI18n(w, r, h.translator, http.StatusInternalServerError, "failed_get_habits")
return
}
response := make([]UserHabitResponse, len(habits))
for i, habit := range habits {
response[i] = UserHabitResponse{
habitResponses := make([]UserHabitResponse, len(result.Habits))
for i, habit := range result.Habits {
habitResponses[i] = UserHabitResponse{
ID: habit.ID,
Name: habit.Name,
Type: habit.Type,
@@ -151,7 +211,15 @@ func (h *HabitHandlers) GetUserHabits(w http.ResponseWriter, r *http.Request) {
}
}
respondJSON(w, http.StatusOK, response)
if result.Pagination != nil {
response := GetUserHabitsResponse{
Data: habitResponses,
Pagination: result.Pagination,
}
respondJSON(w, http.StatusOK, response)
} else {
respondJSON(w, http.StatusOK, habitResponses)
}
}
// GetHabitByID godoc
@@ -440,11 +508,13 @@ func (h *HabitHandlers) GetHabitEntries(w http.ResponseWriter, r *http.Request)
// GetTodaysHabits godoc
// @Summary Get today's habits
// @Description Get all habits scheduled for today for the authenticated user. Includes the entry for today if it exists.
// @Description Get all habits scheduled for today for the authenticated user. Includes the entry for today if it exists. Requires timezone as query parameter (e.g., ?timezone=America/New_York).
// @Tags habits
// @Produce json
// @Security BearerAuth
// @Param timezone query string true "IANA timezone (e.g., 'America/New_York', 'Europe/Madrid', 'UTC')"
// @Success 200 {array} TodaysHabitResponse
// @Failure 400 {object} ErrorResponse "Invalid or missing timezone"
// @Failure 401 {object} ErrorResponse
// @Failure 500 {object} ErrorResponse
// @Router /habits/today [get]
@@ -455,15 +525,16 @@ func (h *HabitHandlers) GetTodaysHabits(w http.ResponseWriter, r *http.Request)
return
}
user, err := h.userRepo.FindByID(r.Context(), userID)
if err != nil {
respondErrorI18n(w, r, h.translator, http.StatusInternalServerError, "failed_get_user")
timezone := r.URL.Query().Get("timezone")
if timezone == "" {
respondErrorI18n(w, r, h.translator, http.StatusBadRequest, "timezone_required")
return
}
loc, err := time.LoadLocation(user.Timezone)
loc, err := time.LoadLocation(timezone)
if err != nil {
loc = time.UTC
respondErrorI18n(w, r, h.translator, http.StatusBadRequest, "invalid_timezone")
return
}
today := time.Now().In(loc)
@@ -471,7 +542,7 @@ func (h *HabitHandlers) GetTodaysHabits(w http.ResponseWriter, r *http.Request)
query := queries.GetTodaysHabitsQuery{
UserID: userID,
Timezone: user.Timezone,
Timezone: timezone,
Date: todayDate,
}
@@ -1,6 +1,7 @@
package http
import (
"apocapoc-api/internal/domain/services"
"database/sql"
"net/http"
"time"
@@ -9,18 +10,21 @@ import (
var startTime = time.Now()
type HealthHandlers struct {
db *sql.DB
db *sql.DB
emailService services.EmailService
}
func NewHealthHandlers(db *sql.DB) *HealthHandlers {
func NewHealthHandlers(db *sql.DB, emailService services.EmailService) *HealthHandlers {
return &HealthHandlers{
db: db,
db: db,
emailService: emailService,
}
}
type HealthResponse struct {
Status string `json:"status"`
Database string `json:"database"`
SMTP string `json:"smtp"`
Uptime string `json:"uptime"`
}
@@ -34,6 +38,7 @@ type HealthResponse struct {
// @Router /health [get]
func (h *HealthHandlers) Health(w http.ResponseWriter, r *http.Request) {
dbStatus := "ok"
smtpStatus := "ok"
overallStatus := "ok"
statusCode := http.StatusOK
@@ -43,12 +48,25 @@ func (h *HealthHandlers) Health(w http.ResponseWriter, r *http.Request) {
statusCode = http.StatusServiceUnavailable
}
if h.emailService != nil {
if err := h.emailService.HealthCheck(); err != nil {
smtpStatus = "error"
if overallStatus != "degraded" {
overallStatus = "degraded"
statusCode = http.StatusServiceUnavailable
}
}
} else {
smtpStatus = "disabled"
}
uptime := time.Since(startTime)
uptimeStr := formatDuration(uptime)
response := HealthResponse{
Status: overallStatus,
Database: dbStatus,
SMTP: smtpStatus,
Uptime: uptimeStr,
}
@@ -58,6 +58,7 @@ func setupTestServer(t *testing.T) *TestServer {
getHabitByIDHandler := queries.NewGetHabitByIDHandler(habitRepo)
getHabitEntriesHandler := queries.NewGetHabitEntriesHandler(habitRepo, entryRepo)
getHabitStatsHandler := queries.NewGetHabitStatsHandler(habitRepo, entryRepo)
exportUserDataHandler := queries.NewExportUserDataHandler(habitRepo, entryRepo)
updateHandler := commands.NewUpdateHabitHandler(habitRepo)
archiveHandler := commands.NewArchiveHabitHandler(habitRepo)
markHandler := commands.NewMarkHabitHandler(entryRepo, habitRepo)
@@ -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")
}
})
}
+19 -3
View File
@@ -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
}
@@ -0,0 +1,159 @@
package http
import (
"net/http"
"testing"
"time"
"apocapoc-api/internal/application/queries"
)
func TestHabitStatsFlow(t *testing.T) {
ts := setupTestServer(t)
defer ts.Close()
token := registerAndLogin(t, *ts.Router, "statsuser@example.com", "Password123!")
habitBody := CreateHabitRequest{
Name: "Meditation",
Type: "BOOLEAN",
Frequency: "DAILY",
}
rr := makeRequest(t, *ts.Router, "POST", "/api/v1/habits", habitBody, token)
var habitResp map[string]string
decodeResponse(t, rr, &habitResp)
habitID := habitResp["id"]
t.Run("Stats for new habit should be zero", func(t *testing.T) {
rr := makeRequest(t, *ts.Router, "GET", "/api/v1/stats/habits/"+habitID, nil, token)
if rr.Code != http.StatusOK {
t.Fatalf("Expected status 200, got %d. Body: %s", rr.Code, rr.Body.String())
}
var stats queries.HabitStatsDTO
decodeResponse(t, rr, &stats)
if stats.TotalCompletions != 0 {
t.Errorf("Expected 0 total completions, got %d", stats.TotalCompletions)
}
if stats.CurrentStreak != 0 {
t.Errorf("Expected 0 current streak, got %d", stats.CurrentStreak)
}
if stats.LongestStreak != 0 {
t.Errorf("Expected 0 longest streak, got %d", stats.LongestStreak)
}
})
today := time.Now().UTC().Format("2006-01-02")
t.Run("Stats after marking habit once", func(t *testing.T) {
markReq := MarkHabitRequest{
ScheduledDate: today,
}
rr := makeRequest(t, *ts.Router, "POST", "/api/v1/habits/"+habitID+"/mark", markReq, token)
if rr.Code != http.StatusOK {
t.Fatalf("Failed to mark habit: %d - %s", rr.Code, rr.Body.String())
}
rr = makeRequest(t, *ts.Router, "GET", "/api/v1/stats/habits/"+habitID, nil, token)
if rr.Code != http.StatusOK {
t.Fatalf("Expected status 200, got %d", rr.Code)
}
var stats queries.HabitStatsDTO
decodeResponse(t, rr, &stats)
if stats.TotalCompletions != 1 {
t.Errorf("Expected 1 total completion, got %d", stats.TotalCompletions)
}
if stats.CurrentStreak != 1 {
t.Errorf("Expected current streak of 1, got %d", stats.CurrentStreak)
}
if stats.LongestStreak != 1 {
t.Errorf("Expected longest streak of 1, got %d", stats.LongestStreak)
}
})
t.Run("Stats after unmarking habit", func(t *testing.T) {
rr := makeRequest(t, *ts.Router, "DELETE", "/api/v1/habits/"+habitID+"/entries/"+today, nil, token)
if rr.Code != http.StatusOK {
t.Fatalf("Failed to unmark habit: %d", rr.Code)
}
rr = makeRequest(t, *ts.Router, "GET", "/api/v1/stats/habits/"+habitID, nil, token)
var stats queries.HabitStatsDTO
decodeResponse(t, rr, &stats)
if stats.TotalCompletions != 0 {
t.Errorf("Expected 0 total completions after unmark, got %d", stats.TotalCompletions)
}
if stats.CurrentStreak != 0 {
t.Errorf("Expected 0 current streak after unmark, got %d", stats.CurrentStreak)
}
})
}
func TestHabitUpdateAffectsStats(t *testing.T) {
ts := setupTestServer(t)
defer ts.Close()
token := registerAndLogin(t, *ts.Router, "updatestats@example.com", "Password123!")
habitBody := CreateHabitRequest{
Name: "Running",
Type: "BOOLEAN",
Frequency: "DAILY",
}
rr := makeRequest(t, *ts.Router, "POST", "/api/v1/habits", habitBody, token)
var habitResp map[string]string
decodeResponse(t, rr, &habitResp)
habitID := habitResp["id"]
today := time.Now().UTC().Format("2006-01-02")
markReq := MarkHabitRequest{
ScheduledDate: today,
}
makeRequest(t, *ts.Router, "POST", "/api/v1/habits/"+habitID+"/mark", markReq, token)
t.Run("Stats remain after updating habit name", func(t *testing.T) {
updateReq := UpdateHabitRequest{
Name: "Morning Running",
}
rr := makeRequest(t, *ts.Router, "PUT", "/api/v1/habits/"+habitID, updateReq, token)
if rr.Code != http.StatusOK {
t.Fatalf("Failed to update habit: %d", rr.Code)
}
rr = makeRequest(t, *ts.Router, "GET", "/api/v1/stats/habits/"+habitID, nil, token)
var stats queries.HabitStatsDTO
decodeResponse(t, rr, &stats)
if stats.TotalCompletions != 1 {
t.Errorf("Expected stats to persist after update, got %d completions", stats.TotalCompletions)
}
})
t.Run("Stats remain available after archiving habit", func(t *testing.T) {
rr := makeRequest(t, *ts.Router, "DELETE", "/api/v1/habits/"+habitID, nil, token)
if rr.Code != http.StatusOK {
t.Fatalf("Failed to archive habit: %d", rr.Code)
}
rr = makeRequest(t, *ts.Router, "GET", "/api/v1/stats/habits/"+habitID, nil, token)
if rr.Code != http.StatusOK {
t.Errorf("Expected stats to remain available for archived habit, got %d", rr.Code)
}
var stats queries.HabitStatsDTO
decodeResponse(t, rr, &stats)
if stats.TotalCompletions != 1 {
t.Errorf("Expected stats to persist after archiving, got %d completions", stats.TotalCompletions)
}
})
}
@@ -0,0 +1,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
}
+90
View File
@@ -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, &params)
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(),
}
+54
View File
@@ -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)
}
})
}
}
+1 -5
View File
@@ -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
}
+1 -23
View File
@@ -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)
}