24 Commits

Author SHA1 Message Date
david aae68ba20e fix: format code with gofmt 2026-03-07 04:36:52 +01:00
david 8883cdcb86 chore: add /api to gitignore 2026-03-07 03:50:47 +01:00
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
david a2aa8b2a76 Add i18n support with English and Spanish translations
- Created i18n package with translator and middleware
- Added translation files for English (en.json) and Spanish (es.json)
- Updated all HTTP handlers to use i18n for error/success messages
- Added comprehensive test coverage for i18n (87%)
- Updated CI workflow to use Go 1.24
- All tests passing with 50.8% total coverage
2025-11-29 12:27:26 +01:00
david 90d5628a42 Fix today's habits timezone calculation and include entry data
- Calculate today based on user's timezone instead of always using UTC
- Include habit entry in response if it exists for the current day
- Update GetTodaysHabitsHandler to fetch and return entry information
- Add entry field to TodaysHabitDTO and TodaysHabitResponse
- Update tests to reflect new behavior of including completed habits
- Add test for habits with value entries
2025-11-29 02:09:30 +01:00
david 9a0637aa4c Remove broken Docker badge 2025-11-28 19:01:25 +01:00
david d3111ad52c Revise README content 2025-11-28 18:56:35 +01:00
david 6fb4823183 Fix error response documentation for field attribute
Separated ErrorResponse and ValidationErrorResponse types:
- ErrorResponse: general errors (401, 403, 404, 500) - no field attribute
- ValidationErrorResponse: form validation errors (400) - includes field attribute

Updated respondValidationError to return appropriate type based on error format.
Updated Swagger documentation to use ValidationErrorResponse only for validation endpoints.

This ensures the field attribute only appears in API responses for actual form field validation errors, not in general error responses.
2025-11-28 18:05:25 +01:00
david 8d631f8fab Improve README SEO and increase test coverage to 50%
Optimized README for better discoverability with keywords: api, habits, self-hosted. Consolidated content to reduce redundancy while maintaining clarity.

Added comprehensive test coverage across multiple layers:
- Infrastructure: bcrypt hashing, JWT tokens, configuration validation
- Application commands: user deletion, password reset, token revocation, email verification
- Application queries: login, token refresh
- Domain entities: refresh tokens, password reset tokens

Coverage increased from 42.5% to 50.8% with meaningful business logic tests.

Fixed integration test handler initialization with correct parameters.
2025-11-28 17:59:39 +01:00
david 06f95afc92 Update README with email verification and new configuration
- Add email verification and welcome email features to feature list
- Update docker-compose example with all environment variables
- Document all configuration options with proper categorization
- Add note about SMTP password escaping with $$
- Remove deprecated CORS_ORIGINS variable
- Add note about auto-verification when SMTP is not configured
2025-11-28 11:57:07 +01:00
david f37c1ac19b Improve email verification flow and error handling
- Send verification email before creating user to prevent orphaned accounts
- Detect SMTP authentication errors and fail fast without retries
- Add field-level validation errors for better frontend UX
- Configure docker-compose with explicit environment variables
- Document dollar sign escaping in .env.example (use $$)
- Implement welcome email on successful verification
- Clean up unnecessary comments
2025-11-28 11:32:51 +01:00
david 00f6b51228 Add optional email verification and registration control
Implemented email service infrastructure with SMTP support and optional email verification for self-hosted deployments. Registration flow now supports open/closed modes and hardcoded Apocapoc branding.

Key features:
- Email service with SMTP and template rendering
- Optional email verification (auto-verified without SMTP config)
- Registration modes: open/closed for access control
- Hardcoded Apocapoc branding (AppName, AppURL, DefaultFrom)
- Separate registration and login flows (registration no longer returns tokens)
2025-11-28 08:21:51 +01:00
david a95a703905 Remove coverage files from repository and add to gitignore 2025-11-28 01:42:26 +01:00
106 changed files with 13217 additions and 1535 deletions
+29 -3
View File
@@ -1,12 +1,38 @@
DB_PATH=./data/apocapoc.db
PORT=8080
HOST=0.0.0.0
APP_URL=http://localhost:8080
JWT_SECRET=change-me-in-production
JWT_EXPIRY=1h
REFRESH_TOKEN_EXPIRY=7d
CORS_ORIGINS=http://localhost:3000
DEFAULT_TIMEZONE=UTC
# Email Configuration (optional - required for email features)
SMTP_HOST=
SMTP_PORT=587
SMTP_USER=
# Note: Escape $ signs with $$ (e.g., pa$word becomes pa$$word)
SMTP_PASSWORD=
SMTP_FROM=noreply@example.com
# Application Branding (optional - override support email if needed)
SUPPORT_EMAIL=contact@apocapoc.app
# Email Features
SEND_WELCOME_EMAIL=false
# Registration Control
REGISTRATION_MODE=open
# Logging Configuration
LOG_LEVEL=info
ENVIRONMENT=production
# Backup Configuration
BACKUP_ENABLED=false
BACKUP_INTERVAL=24h
BACKUP_RETENTION_DAYS=7
BACKUP_PATH=./data/backups
BACKUP_COMPRESS=true
+3 -3
View File
@@ -17,7 +17,7 @@ jobs:
- name: Set up Go
uses: actions/setup-go@v5
with:
go-version: '1.21'
go-version: '1.24'
- name: Run tests
run: go test ./... -v -race -coverprofile=coverage.txt -covermode=atomic
@@ -40,7 +40,7 @@ jobs:
- name: Set up Go
uses: actions/setup-go@v5
with:
go-version: '1.21'
go-version: '1.24'
- name: Run go vet
run: go vet ./...
@@ -69,7 +69,7 @@ jobs:
- name: Set up Go
uses: actions/setup-go@v5
with:
go-version: '1.21'
go-version: '1.24'
- name: Run GoReleaser
uses: goreleaser/goreleaser-action@v6
+3
View File
@@ -4,7 +4,10 @@ data/
*.db-shm
*.db-wal
apocapoc-api
/api
dist/
bin/
.internal-notes/
docker-compose.yml
coverage.out
coverage.txt
+88 -49
View File
@@ -1,17 +1,30 @@
# Apocapoc
# Apocapoc API - Self-Hosted Habit Tracker
Self-hosted habit tracking service with a clean, hexagonal architecture.
REST API for habit tracking built with Go. Self-hosted alternative for developers who want control over their data.
[![Go Version](https://img.shields.io/github/go-mod/go-version/davidfolch/apocapoc-api)](https://golang.org/)
[![License](https://img.shields.io/github/license/davidfolch/apocapoc-api)](LICENSE)
## Table of Contents
- [Features](#features)
- [Quick Start](#quick-start)
- [Docker Compose (Recommended)](#using-docker-compose-recommended)
- [Binary Installation](#using-the-binary)
- [Use Cases](#use-cases)
- [API Documentation](#api-documentation)
- [Development](#development)
- [Architecture](#architecture)
## Features
- **Multiple habit types**: Boolean (check), Counter, Value
- **Flexible scheduling**: Daily, Weekly, Monthly with specific days
- **Carry-over support**: Choose if incomplete habits persist or expire
- **Full history tracking**: Complete audit trail of all interactions
- **Statistics**: Track streaks, completion rates, and progress
- **Self-hosted first**: Easy deployment with SQLite
- **Security**: JWT authentication, rate limiting on auth endpoints
- **API Documentation**: Interactive Swagger UI
- Multiple habit types: Boolean, Counter, Value
- Flexible scheduling: Daily, Weekly, Monthly
- Statistics: Streaks, completion rates, progress tracking
- JWT authentication, rate limiting, optional email verification
- Registration modes: Open or closed
- SQLite database (single file)
- Swagger UI at `/api/v1/docs`
## Quick Start
@@ -27,11 +40,21 @@ services:
- "8080:8080"
environment:
- DB_PATH=/data/apocapoc.db
- PORT=8080
- APP_URL=http://localhost:8080
- JWT_SECRET=YOUR_SECRET_HERE
- JWT_EXPIRY=24h
- REFRESH_TOKEN_EXPIRY=168h
- CORS_ORIGINS=http://localhost:3000
- JWT_EXPIRY=1h
- REFRESH_TOKEN_EXPIRY=7d
- DEFAULT_TIMEZONE=UTC
- REGISTRATION_MODE=open
# Email configuration (optional)
# - SMTP_HOST=smtp.example.com
# - SMTP_PORT=587
# - SMTP_USER=your-email@example.com
# - SMTP_PASSWORD=your-password
# - SMTP_FROM=noreply@example.com
# - SUPPORT_EMAIL=contact@apocapoc.app
# - SEND_WELCOME_EMAIL=false
volumes:
- habit-data:/data
restart: unless-stopped
@@ -48,65 +71,77 @@ volumes:
docker-compose up -d
```
The API will be available at `http://localhost:8080`
API available at `http://localhost:8080`
**Available image tags:**
- `latest`: Latest stable release (recommended for production)
- `1`, `1.0`, `1.0.0`: Specific version tags
- `edge`: Latest development build from main branch (unstable)
- `sha-abc123`: Specific commit (for debugging)
**Image tags:**
- `latest`: Stable release (recommended)
- `1`, `1.0`, `1.0.0`: Specific versions
- `edge`: Development build (unstable)
**Configuration options:**
- `JWT_SECRET`: **Required**. Use a long random string
- `JWT_EXPIRY`: Token expiration (e.g., `24h`, `48h`)
- `REFRESH_TOKEN_EXPIRY`: Refresh token expiration (e.g., `168h` = 7 days)
- `CORS_ORIGINS`: Comma-separated list of allowed origins
- `DEFAULT_TIMEZONE`: Timezone for date calculations (e.g., `UTC`, `Europe/Madrid`)
**Configuration:**
*Required:*
- `JWT_SECRET`: Long random string (required)
- `DB_PATH`: Database path (default: `./data/apocapoc.db`)
*Application:*
- `PORT`: HTTP port (default: `8080`)
- `APP_URL`: Public URL for email links
- `DEFAULT_TIMEZONE`: e.g., `UTC`, `Europe/Madrid`
*Authentication:*
- `JWT_EXPIRY`: e.g., `1h`, `24h`
- `REFRESH_TOKEN_EXPIRY`: e.g., `7d`, `168h`
- `REGISTRATION_MODE`: `open` or `closed`
*Email (optional):*
- `SMTP_HOST`, `SMTP_PORT`, `SMTP_USER`, `SMTP_PASSWORD`, `SMTP_FROM`
- `SUPPORT_EMAIL`: Default `contact@apocapoc.app`
- `SEND_WELCOME_EMAIL`: `true`/`false`
Without SMTP config, users are auto-verified.
### Using the binary
1. Download the latest release from [GitHub Releases](https://github.com/davidfolch/apocapoc-api/releases)
2. Extract the archive:
```bash
tar -xzf apocapoc-api_*_linux_amd64.tar.gz
```
3. Copy `.env.example` to `.env` and configure
4. Run the binary:
```bash
./apocapoc-api
```
1. Download from [GitHub Releases](https://github.com/davidfolch/apocapoc-api/releases)
2. Extract: `tar -xzf apocapoc-api_*_linux_amd64.tar.gz`
3. Configure: `cp .env.example .env` (edit as needed)
4. Run: `./apocapoc-api`
The API will be available at `http://localhost:8080`
API available at `http://localhost:8080`
**Note:** Linux binaries only (amd64 and arm64). For other platforms, use Docker.
*Note: Linux only (amd64/arm64). Use Docker for other platforms.*
## Development
### Prerequisites
- Go 1.23+
- SQLite
- Docker (optional)
### Running locally with Docker (Recommended)
**Prerequisites:** Go 1.23+, SQLite, Docker (optional)
**With Docker:**
```bash
cp docker-compose.example.yml docker-compose.yml
docker-compose up --build
```
The API will be available at `http://localhost:8080`
### Running locally with Go
**With Go:**
```bash
cp .env.example .env
go run cmd/api/main.go
```
API runs on `http://localhost:8080`
## Use Cases
- Build your own web or mobile frontend
- Create custom CLI tools or scripts
- Keep your habit data on your own infrastructure
- Experiment with a small REST API in Go
## API Documentation
Once running, visit `http://localhost:8080/api/v1/docs` for interactive Swagger documentation.
Access the interactive Swagger UI at `http://localhost:8080/api/v1/docs`
Includes endpoint reference, schemas, authentication examples, and live testing.
## Architecture
@@ -122,6 +157,10 @@ This project follows hexagonal (ports & adapters) architecture:
- 📧 Email: contact@apocapoc.app
- 🐛 Issues: [GitHub Issues](https://github.com/davidfolch/apocapoc-api/issues)
## Keywords
`habit-tracker` `habit-tracking` `rest-api` `self-hosted` `golang` `api` `habits` `productivity` `docker` `sqlite` `hexagonal-architecture` `clean-architecture` `habit-tracker-api` `self-hosted-api` `personal-analytics` `privacy` `open-source`
## License
MIT
+78 -12
View File
@@ -10,10 +10,14 @@ import (
"apocapoc-api/internal/application/commands"
"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"
)
@@ -41,58 +45,120 @@ func main() {
log.Fatalf("Failed to load config: %v", err)
}
logger.Init(logger.Config{
Level: cfg.LogLevel,
Environment: cfg.Environment,
})
db, err := sqlite.NewDatabase(cfg.DBPath)
if err != nil {
log.Fatalf("Failed to connect to database: %v", err)
logger.Fatal().Err(err).Msg("Failed to connect to database")
}
defer db.Close()
backupInterval, err := parseDuration(cfg.BackupInterval)
if err != nil {
logger.Fatal().Err(err).Msg("Invalid BACKUP_INTERVAL")
}
backupRetentionDays, err := strconv.Atoi(cfg.BackupRetentionDays)
if err != nil {
logger.Fatal().Err(err).Msg("Invalid BACKUP_RETENTION_DAYS")
}
backupScheduler := backup.NewScheduler(db.Conn(), backup.Config{
Enabled: cfg.BackupEnabled == "true",
Interval: backupInterval,
RetentionDays: backupRetentionDays,
Path: cfg.BackupPath,
Compress: cfg.BackupCompress == "true",
DatabasePath: cfg.DBPath,
})
backupScheduler.Start()
defer backupScheduler.Stop()
jwtExpiryHours, err := parseJWTExpiry(cfg.JWTExpiry)
if err != nil {
log.Fatalf("Invalid JWT_EXPIRY: %v", err)
logger.Fatal().Err(err).Msg("Invalid JWT_EXPIRY")
}
refreshTokenExpiry, err := parseDuration(cfg.RefreshTokenExpiry)
if err != nil {
log.Fatalf("Invalid REFRESH_TOKEN_EXPIRY: %v", err)
logger.Fatal().Err(err).Msg("Invalid REFRESH_TOKEN_EXPIRY")
}
jwtService := auth.NewJWTService(cfg.JWTSecret, jwtExpiryHours)
passwordHasher := crypto.NewBcryptHasher()
var emailService *email.SMTPService
if cfg.SMTPHost != "" {
smtpPort, err := strconv.Atoi(cfg.SMTPPort)
if err != nil {
logger.Fatal().Err(err).Msg("Invalid SMTP_PORT")
}
emailService = email.NewSMTPService(email.SMTPConfig{
Host: cfg.SMTPHost,
Port: smtpPort,
Username: cfg.SMTPUser,
Password: cfg.SMTPPassword,
From: cfg.SMTPFrom,
SupportEmail: cfg.SupportEmail,
})
}
sendWelcomeEmail := cfg.SendWelcomeEmail == "true"
userRepo := sqlite.NewUserRepository(db.Conn())
habitRepo := sqlite.NewHabitRepository(db.Conn())
entryRepo := sqlite.NewHabitEntryRepository(db.Conn())
refreshTokenRepo := sqlite.NewRefreshTokenRepository(db.Conn())
passwordResetTokenRepo := sqlite.NewPasswordResetTokenRepository(db.Conn())
registerHandler := commands.NewRegisterUserHandler(userRepo, passwordHasher)
translator, err := i18n.NewTranslator()
if err != nil {
logger.Fatal().Err(err).Msg("Failed to create translator")
}
registerHandler := commands.NewRegisterUserHandler(userRepo, passwordHasher, emailService, cfg.AppURL, cfg.RegistrationMode, sendWelcomeEmail)
loginHandler := queries.NewLoginUserHandler(userRepo, passwordHasher)
refreshTokenHandler := queries.NewRefreshTokenHandler(refreshTokenRepo, userRepo)
revokeTokenHandler := commands.NewRevokeTokenHandler(refreshTokenRepo)
revokeAllTokensHandler := commands.NewRevokeAllTokensHandler(refreshTokenRepo)
verifyEmailHandler := commands.NewVerifyEmailHandler(userRepo, emailService, sendWelcomeEmail)
resendVerificationEmailHandler := commands.NewResendVerificationEmailHandler(userRepo, emailService, cfg.AppURL)
requestPasswordResetHandler := commands.NewRequestPasswordResetHandler(userRepo, passwordResetTokenRepo, emailService, cfg.AppURL)
resetPasswordHandler := commands.NewResetPasswordHandler(userRepo, passwordResetTokenRepo, passwordHasher)
deleteUserHandler := commands.NewDeleteUserHandler(userRepo)
createHandler := commands.NewCreateHabitHandler(habitRepo)
getTodaysHandler := queries.NewGetTodaysHabitsHandler(habitRepo, entryRepo)
getUserHabitsHandler := queries.NewGetUserHabitsHandler(habitRepo)
getHabitByIDHandler := queries.NewGetHabitByIDHandler(habitRepo)
getHabitEntriesHandler := queries.NewGetHabitEntriesHandler(habitRepo, entryRepo)
getHabitStatsHandler := queries.NewGetHabitStatsHandler(habitRepo, entryRepo)
exportUserDataHandler := queries.NewExportUserDataHandler(habitRepo, entryRepo)
updateHandler := commands.NewUpdateHabitHandler(habitRepo)
archiveHandler := commands.NewArchiveHabitHandler(habitRepo)
markHandler := commands.NewMarkHabitHandler(entryRepo, habitRepo)
unmarkHandler := commands.NewUnmarkHabitHandler(habitRepo, entryRepo)
getSyncChangesHandler := queries.NewGetSyncChangesHandler(habitRepo, entryRepo)
applySyncBatchHandler := commands.NewApplySyncBatchHandler(habitRepo, entryRepo)
authHandlers := httpInfra.NewAuthHandlers(registerHandler, loginHandler, refreshTokenHandler, revokeTokenHandler, revokeAllTokensHandler, jwtService, refreshTokenRepo, refreshTokenExpiry)
habitHandlers := httpInfra.NewHabitHandlers(createHandler, getTodaysHandler, getUserHabitsHandler, getHabitByIDHandler, getHabitEntriesHandler, updateHandler, archiveHandler, markHandler, unmarkHandler)
statsHandlers := httpInfra.NewStatsHandlers(getHabitStatsHandler)
healthHandlers := httpInfra.NewHealthHandlers(db.Conn())
authHandlers := httpInfra.NewAuthHandlers(registerHandler, loginHandler, refreshTokenHandler, revokeTokenHandler, revokeAllTokensHandler, verifyEmailHandler, resendVerificationEmailHandler, requestPasswordResetHandler, resetPasswordHandler, jwtService, refreshTokenRepo, refreshTokenExpiry, translator)
habitHandlers := httpInfra.NewHabitHandlers(createHandler, getTodaysHandler, getUserHabitsHandler, getHabitByIDHandler, getHabitEntriesHandler, updateHandler, archiveHandler, markHandler, unmarkHandler, translator)
statsHandlers := httpInfra.NewStatsHandlers(getHabitStatsHandler, translator)
healthHandlers := httpInfra.NewHealthHandlers(db.Conn(), emailService)
userHandlers := httpInfra.NewUserHandlers(deleteUserHandler, translator)
exportHandlers := httpInfra.NewExportHandlers(exportUserDataHandler, translator)
syncHandlers := httpInfra.NewSyncHandlers(getSyncChangesHandler, applySyncBatchHandler, translator)
router := httpInfra.NewRouter(cfg.CORSOrigins, habitHandlers, authHandlers, statsHandlers, healthHandlers, jwtService)
router := httpInfra.NewRouter(cfg.AppURL, habitHandlers, authHandlers, statsHandlers, healthHandlers, userHandlers, exportHandlers, syncHandlers, jwtService, translator)
addr := fmt.Sprintf("%s:%s", cfg.Host, cfg.Port)
log.Printf("Server starting on %s", addr)
addr := fmt.Sprintf("0.0.0.0:%s", cfg.Port)
logger.Info().Str("address", addr).Msg("Server starting")
if err := http.ListenAndServe(addr, router); err != nil {
log.Fatalf("Server failed: %v", err)
logger.Fatal().Err(err).Msg("Server failed")
}
}
-241
View File
@@ -1,241 +0,0 @@
mode: set
apocapoc-api/internal/application/commands/archive_habit.go:19.90,23.2 1 1
apocapoc-api/internal/application/commands/archive_habit.go:25.90,27.16 2 1
apocapoc-api/internal/application/commands/archive_habit.go:27.16,29.3 1 1
apocapoc-api/internal/application/commands/archive_habit.go:31.2,31.32 1 1
apocapoc-api/internal/application/commands/archive_habit.go:31.32,33.3 1 1
apocapoc-api/internal/application/commands/archive_habit.go:35.2,37.39 2 1
apocapoc-api/internal/application/commands/create_habit.go:29.88,31.2 1 1
apocapoc-api/internal/application/commands/create_habit.go:33.98,34.25 1 1
apocapoc-api/internal/application/commands/create_habit.go:34.25,36.3 1 1
apocapoc-api/internal/application/commands/create_habit.go:38.2,38.30 1 1
apocapoc-api/internal/application/commands/create_habit.go:38.30,40.3 1 1
apocapoc-api/internal/application/commands/create_habit.go:42.2,42.82 1 1
apocapoc-api/internal/application/commands/create_habit.go:42.82,44.3 1 1
apocapoc-api/internal/application/commands/create_habit.go:46.2,46.84 1 1
apocapoc-api/internal/application/commands/create_habit.go:46.84,48.3 1 1
apocapoc-api/internal/application/commands/create_habit.go:50.2,56.55 6 1
apocapoc-api/internal/application/commands/create_habit.go:56.55,58.3 1 0
apocapoc-api/internal/application/commands/create_habit.go:60.2,60.22 1 1
apocapoc-api/internal/application/commands/mark_habit.go:29.21,34.2 1 1
apocapoc-api/internal/application/commands/mark_habit.go:36.84,38.16 2 1
apocapoc-api/internal/application/commands/mark_habit.go:38.16,40.3 1 0
apocapoc-api/internal/application/commands/mark_habit.go:42.2,42.18 1 1
apocapoc-api/internal/application/commands/mark_habit.go:42.18,44.3 1 1
apocapoc-api/internal/application/commands/mark_habit.go:46.2,46.23 1 1
apocapoc-api/internal/application/commands/mark_habit.go:46.23,48.3 1 1
apocapoc-api/internal/application/commands/mark_habit.go:50.2,50.70 1 1
apocapoc-api/internal/application/commands/mark_habit.go:50.70,51.43 1 1
apocapoc-api/internal/application/commands/mark_habit.go:51.43,53.4 1 1
apocapoc-api/internal/application/commands/mark_habit.go:56.2,58.50 2 1
apocapoc-api/internal/application/commands/mark_habit.go:58.50,64.31 4 1
apocapoc-api/internal/application/commands/mark_habit.go:64.31,68.24 3 1
apocapoc-api/internal/application/commands/mark_habit.go:68.24,70.5 1 1
apocapoc-api/internal/application/commands/mark_habit.go:72.4,73.34 2 1
apocapoc-api/internal/application/commands/mark_habit.go:73.34,75.5 1 1
apocapoc-api/internal/application/commands/mark_habit.go:75.10,77.5 1 0
apocapoc-api/internal/application/commands/mark_habit.go:79.4,79.20 1 1
apocapoc-api/internal/application/commands/mark_habit.go:79.20,81.5 1 1
apocapoc-api/internal/application/commands/mark_habit.go:83.4,87.49 4 1
apocapoc-api/internal/application/commands/mark_habit.go:88.9,89.24 1 1
apocapoc-api/internal/application/commands/mark_habit.go:89.24,91.18 2 1
apocapoc-api/internal/application/commands/mark_habit.go:91.18,93.6 1 1
apocapoc-api/internal/application/commands/mark_habit.go:94.5,94.24 1 1
apocapoc-api/internal/application/commands/mark_habit.go:95.10,98.5 2 1
apocapoc-api/internal/application/commands/mark_habit.go:100.8,102.3 1 1
apocapoc-api/internal/application/commands/mark_habit.go:104.2,106.39 2 1
apocapoc-api/internal/application/commands/register_user.go:24.128,29.2 1 1
apocapoc-api/internal/application/commands/register_user.go:31.100,32.95 1 1
apocapoc-api/internal/application/commands/register_user.go:32.95,34.3 1 1
apocapoc-api/internal/application/commands/register_user.go:36.2,37.21 2 1
apocapoc-api/internal/application/commands/register_user.go:37.21,39.3 1 1
apocapoc-api/internal/application/commands/register_user.go:41.2,42.16 2 1
apocapoc-api/internal/application/commands/register_user.go:42.16,44.3 1 1
apocapoc-api/internal/application/commands/register_user.go:46.2,48.53 2 1
apocapoc-api/internal/application/commands/register_user.go:48.53,50.3 1 1
apocapoc-api/internal/application/commands/register_user.go:52.2,52.21 1 1
apocapoc-api/internal/application/commands/revoke_all_tokens.go:18.110,22.2 1 0
apocapoc-api/internal/application/commands/revoke_all_tokens.go:24.96,25.22 1 0
apocapoc-api/internal/application/commands/revoke_all_tokens.go:25.22,27.3 1 0
apocapoc-api/internal/application/commands/revoke_all_tokens.go:29.2,29.62 1 0
apocapoc-api/internal/application/commands/revoke_token.go:18.102,22.2 1 0
apocapoc-api/internal/application/commands/revoke_token.go:24.88,25.28 1 0
apocapoc-api/internal/application/commands/revoke_token.go:25.28,27.3 1 0
apocapoc-api/internal/application/commands/revoke_token.go:29.2,29.64 1 0
apocapoc-api/internal/application/commands/unmark_habit.go:25.23,30.2 1 1
apocapoc-api/internal/application/commands/unmark_habit.go:32.88,34.16 2 1
apocapoc-api/internal/application/commands/unmark_habit.go:34.16,36.3 1 1
apocapoc-api/internal/application/commands/unmark_habit.go:38.2,38.32 1 1
apocapoc-api/internal/application/commands/unmark_habit.go:38.32,40.3 1 1
apocapoc-api/internal/application/commands/unmark_habit.go:42.2,52.16 4 1
apocapoc-api/internal/application/commands/unmark_habit.go:52.16,54.3 1 0
apocapoc-api/internal/application/commands/unmark_habit.go:56.2,57.32 2 1
apocapoc-api/internal/application/commands/unmark_habit.go:57.32,58.51 1 1
apocapoc-api/internal/application/commands/unmark_habit.go:58.51,60.9 2 1
apocapoc-api/internal/application/commands/unmark_habit.go:64.2,64.25 1 1
apocapoc-api/internal/application/commands/unmark_habit.go:64.25,66.3 1 1
apocapoc-api/internal/application/commands/unmark_habit.go:68.2,68.47 1 1
apocapoc-api/internal/application/commands/update_habit.go:26.88,30.2 1 1
apocapoc-api/internal/application/commands/update_habit.go:32.88,33.39 1 1
apocapoc-api/internal/application/commands/update_habit.go:33.39,35.3 1 1
apocapoc-api/internal/application/commands/update_habit.go:37.2,38.16 2 1
apocapoc-api/internal/application/commands/update_habit.go:38.16,40.3 1 1
apocapoc-api/internal/application/commands/update_habit.go:42.2,42.32 1 1
apocapoc-api/internal/application/commands/update_habit.go:42.32,44.3 1 1
apocapoc-api/internal/application/commands/update_habit.go:46.2,46.23 1 1
apocapoc-api/internal/application/commands/update_habit.go:46.23,48.3 1 1
apocapoc-api/internal/application/commands/update_habit.go:50.2,57.39 7 1
apocapoc-api/internal/domain/entities/habit.go:32.10,42.2 1 1
apocapoc-api/internal/domain/entities/habit.go:44.27,47.2 2 1
apocapoc-api/internal/domain/entities/habit.go:49.33,51.2 1 1
apocapoc-api/internal/domain/entities/habit_entry.go:13.89,20.2 1 1
apocapoc-api/internal/domain/entities/refresh_token.go:14.79,22.2 1 0
apocapoc-api/internal/domain/entities/refresh_token.go:24.40,25.25 1 0
apocapoc-api/internal/domain/entities/refresh_token.go:25.25,27.3 1 0
apocapoc-api/internal/domain/entities/refresh_token.go:28.2,28.40 1 0
apocapoc-api/internal/domain/entities/refresh_token.go:31.34,34.2 2 0
apocapoc-api/internal/domain/entities/user.go:14.58,16.20 2 1
apocapoc-api/internal/domain/entities/user.go:16.20,18.3 1 1
apocapoc-api/internal/domain/entities/user.go:19.2,25.3 1 1
apocapoc-api/internal/application/queries/get_habit_by_id.go:19.90,23.2 1 1
apocapoc-api/internal/application/queries/get_habit_by_id.go:25.103,27.16 2 1
apocapoc-api/internal/application/queries/get_habit_by_id.go:27.16,29.3 1 1
apocapoc-api/internal/application/queries/get_habit_by_id.go:31.2,31.34 1 1
apocapoc-api/internal/application/queries/get_habit_by_id.go:31.34,33.3 1 1
apocapoc-api/internal/application/queries/get_habit_by_id.go:35.2,44.8 1 1
apocapoc-api/internal/application/queries/get_habit_entries.go:44.27,49.2 1 1
apocapoc-api/internal/application/queries/get_habit_entries.go:51.122,53.16 2 1
apocapoc-api/internal/application/queries/get_habit_entries.go:53.16,55.3 1 1
apocapoc-api/internal/application/queries/get_habit_entries.go:57.2,57.34 1 1
apocapoc-api/internal/application/queries/get_habit_entries.go:57.34,59.3 1 1
apocapoc-api/internal/application/queries/get_habit_entries.go:61.2,62.42 2 1
apocapoc-api/internal/application/queries/get_habit_entries.go:62.42,64.3 1 1
apocapoc-api/internal/application/queries/get_habit_entries.go:66.2,67.42 2 1
apocapoc-api/internal/application/queries/get_habit_entries.go:67.42,69.3 1 1
apocapoc-api/internal/application/queries/get_habit_entries.go:69.8,69.32 1 1
apocapoc-api/internal/application/queries/get_habit_entries.go:69.32,71.3 1 1
apocapoc-api/internal/application/queries/get_habit_entries.go:73.2,73.44 1 1
apocapoc-api/internal/application/queries/get_habit_entries.go:73.44,75.3 1 1
apocapoc-api/internal/application/queries/get_habit_entries.go:77.2,79.42 2 1
apocapoc-api/internal/application/queries/get_habit_entries.go:79.42,81.3 1 1
apocapoc-api/internal/application/queries/get_habit_entries.go:81.8,81.30 1 1
apocapoc-api/internal/application/queries/get_habit_entries.go:81.30,83.3 1 0
apocapoc-api/internal/application/queries/get_habit_entries.go:83.8,83.28 1 1
apocapoc-api/internal/application/queries/get_habit_entries.go:83.28,85.3 1 0
apocapoc-api/internal/application/queries/get_habit_entries.go:85.8,87.3 1 1
apocapoc-api/internal/application/queries/get_habit_entries.go:89.2,89.16 1 1
apocapoc-api/internal/application/queries/get_habit_entries.go:89.16,91.3 1 0
apocapoc-api/internal/application/queries/get_habit_entries.go:93.2,95.21 2 1
apocapoc-api/internal/application/queries/get_habit_entries.go:95.21,97.17 2 1
apocapoc-api/internal/application/queries/get_habit_entries.go:97.17,99.4 1 0
apocapoc-api/internal/application/queries/get_habit_entries.go:100.3,101.28 2 1
apocapoc-api/internal/application/queries/get_habit_entries.go:101.28,102.26 1 1
apocapoc-api/internal/application/queries/get_habit_entries.go:102.26,104.5 1 1
apocapoc-api/internal/application/queries/get_habit_entries.go:105.4,105.33 1 1
apocapoc-api/internal/application/queries/get_habit_entries.go:106.9,108.4 1 0
apocapoc-api/internal/application/queries/get_habit_entries.go:111.2,112.32 2 1
apocapoc-api/internal/application/queries/get_habit_entries.go:112.32,120.3 1 1
apocapoc-api/internal/application/queries/get_habit_entries.go:122.2,127.8 1 1
apocapoc-api/internal/application/queries/get_habit_stats.go:36.25,41.2 1 0
apocapoc-api/internal/application/queries/get_habit_stats.go:43.110,45.16 2 0
apocapoc-api/internal/application/queries/get_habit_stats.go:45.16,47.3 1 0
apocapoc-api/internal/application/queries/get_habit_stats.go:49.2,49.34 1 0
apocapoc-api/internal/application/queries/get_habit_stats.go:49.34,51.3 1 0
apocapoc-api/internal/application/queries/get_habit_stats.go:53.2,54.16 2 0
apocapoc-api/internal/application/queries/get_habit_stats.go:54.16,56.3 1 0
apocapoc-api/internal/application/queries/get_habit_stats.go:58.2,63.23 2 0
apocapoc-api/internal/application/queries/get_habit_stats.go:63.23,65.3 1 0
apocapoc-api/internal/application/queries/get_habit_stats.go:67.2,74.19 7 0
apocapoc-api/internal/application/queries/get_habit_stats.go:77.65,78.23 1 0
apocapoc-api/internal/application/queries/get_habit_stats.go:78.23,80.3 1 0
apocapoc-api/internal/application/queries/get_habit_stats.go:82.2,83.32 2 0
apocapoc-api/internal/application/queries/get_habit_stats.go:83.32,86.3 2 0
apocapoc-api/internal/application/queries/get_habit_stats.go:88.2,91.6 3 0
apocapoc-api/internal/application/queries/get_habit_stats.go:91.6,93.24 2 0
apocapoc-api/internal/application/queries/get_habit_stats.go:93.24,94.9 1 0
apocapoc-api/internal/application/queries/get_habit_stats.go:96.3,97.46 2 0
apocapoc-api/internal/application/queries/get_habit_stats.go:100.2,100.15 1 0
apocapoc-api/internal/application/queries/get_habit_stats.go:103.65,104.23 1 0
apocapoc-api/internal/application/queries/get_habit_stats.go:104.23,106.3 1 0
apocapoc-api/internal/application/queries/get_habit_stats.go:108.2,110.32 3 0
apocapoc-api/internal/application/queries/get_habit_stats.go:110.32,113.24 3 0
apocapoc-api/internal/application/queries/get_habit_stats.go:113.24,116.4 2 0
apocapoc-api/internal/application/queries/get_habit_stats.go:119.2,119.21 1 0
apocapoc-api/internal/application/queries/get_habit_stats.go:119.21,121.3 1 0
apocapoc-api/internal/application/queries/get_habit_stats.go:123.2,126.34 3 0
apocapoc-api/internal/application/queries/get_habit_stats.go:126.34,128.16 2 0
apocapoc-api/internal/application/queries/get_habit_stats.go:128.16,130.37 2 0
apocapoc-api/internal/application/queries/get_habit_stats.go:130.37,132.5 1 0
apocapoc-api/internal/application/queries/get_habit_stats.go:133.9,135.4 1 0
apocapoc-api/internal/application/queries/get_habit_stats.go:138.2,138.22 1 0
apocapoc-api/internal/application/queries/get_habit_stats.go:141.91,142.23 1 0
apocapoc-api/internal/application/queries/get_habit_stats.go:142.23,144.3 1 0
apocapoc-api/internal/application/queries/get_habit_stats.go:146.2,147.28 2 0
apocapoc-api/internal/application/queries/get_habit_stats.go:147.28,149.3 1 0
apocapoc-api/internal/application/queries/get_habit_stats.go:151.2,152.16 2 0
apocapoc-api/internal/application/queries/get_habit_stats.go:152.16,154.3 1 0
apocapoc-api/internal/application/queries/get_habit_stats.go:156.2,156.13 1 0
apocapoc-api/internal/application/queries/get_habit_stats.go:159.77,163.32 3 0
apocapoc-api/internal/application/queries/get_habit_stats.go:163.32,164.40 1 0
apocapoc-api/internal/application/queries/get_habit_stats.go:164.40,166.4 1 0
apocapoc-api/internal/application/queries/get_habit_stats.go:169.2,169.14 1 0
apocapoc-api/internal/application/queries/get_todays_habits.go:36.27,41.2 1 1
apocapoc-api/internal/application/queries/get_todays_habits.go:46.29,48.16 2 1
apocapoc-api/internal/application/queries/get_todays_habits.go:48.16,50.3 1 0
apocapoc-api/internal/application/queries/get_todays_habits.go:52.2,54.31 2 1
apocapoc-api/internal/application/queries/get_todays_habits.go:54.31,62.40 2 1
apocapoc-api/internal/application/queries/get_todays_habits.go:62.40,63.12 1 1
apocapoc-api/internal/application/queries/get_todays_habits.go:66.3,74.33 3 1
apocapoc-api/internal/application/queries/get_todays_habits.go:74.33,75.45 1 1
apocapoc-api/internal/application/queries/get_todays_habits.go:75.45,77.10 2 1
apocapoc-api/internal/application/queries/get_todays_habits.go:81.3,81.19 1 1
apocapoc-api/internal/application/queries/get_todays_habits.go:81.19,91.4 1 1
apocapoc-api/internal/application/queries/get_todays_habits.go:94.2,94.20 1 1
apocapoc-api/internal/application/queries/get_user_habits.go:29.92,33.2 1 1
apocapoc-api/internal/application/queries/get_user_habits.go:35.106,37.16 2 1
apocapoc-api/internal/application/queries/get_user_habits.go:37.16,39.3 1 0
apocapoc-api/internal/application/queries/get_user_habits.go:41.2,42.31 2 1
apocapoc-api/internal/application/queries/get_user_habits.go:42.31,53.3 1 1
apocapoc-api/internal/application/queries/get_user_habits.go:55.2,55.20 1 1
apocapoc-api/internal/application/queries/login_user.go:27.122,32.2 1 0
apocapoc-api/internal/application/queries/login_user.go:34.104,35.47 1 0
apocapoc-api/internal/application/queries/login_user.go:35.47,37.3 1 0
apocapoc-api/internal/application/queries/login_user.go:39.2,40.16 2 0
apocapoc-api/internal/application/queries/login_user.go:40.16,42.3 1 0
apocapoc-api/internal/application/queries/login_user.go:44.2,44.84 1 0
apocapoc-api/internal/application/queries/login_user.go:44.84,46.3 1 0
apocapoc-api/internal/application/queries/login_user.go:48.2,52.8 1 0
apocapoc-api/internal/application/queries/refresh_token.go:33.24,38.2 1 1
apocapoc-api/internal/application/queries/refresh_token.go:40.113,41.30 1 1
apocapoc-api/internal/application/queries/refresh_token.go:41.30,43.3 1 1
apocapoc-api/internal/application/queries/refresh_token.go:45.2,46.16 2 1
apocapoc-api/internal/application/queries/refresh_token.go:46.16,48.3 1 1
apocapoc-api/internal/application/queries/refresh_token.go:50.2,50.29 1 1
apocapoc-api/internal/application/queries/refresh_token.go:50.29,52.3 1 1
apocapoc-api/internal/application/queries/refresh_token.go:54.2,55.16 2 1
apocapoc-api/internal/application/queries/refresh_token.go:55.16,57.3 1 0
apocapoc-api/internal/application/queries/refresh_token.go:59.2,63.8 1 1
apocapoc-api/internal/application/queries/refresh_token.go:66.45,68.40 2 0
apocapoc-api/internal/application/queries/refresh_token.go:68.40,70.3 1 0
apocapoc-api/internal/application/queries/refresh_token.go:71.2,71.50 1 0
apocapoc-api/internal/application/queries/refresh_token.go:74.102,76.16 2 0
apocapoc-api/internal/application/queries/refresh_token.go:76.16,78.3 1 0
apocapoc-api/internal/application/queries/refresh_token.go:80.2,81.64 2 0
apocapoc-api/internal/domain/value_objects/frequency.go:16.35,17.11 1 1
apocapoc-api/internal/domain/value_objects/frequency.go:18.57,19.14 1 1
apocapoc-api/internal/domain/value_objects/frequency.go:21.2,21.14 1 1
apocapoc-api/internal/domain/value_objects/frequency.go:24.50,26.2 1 1
apocapoc-api/internal/domain/value_objects/frequency.go:28.54,30.49 2 1
apocapoc-api/internal/domain/value_objects/frequency.go:30.49,32.3 1 0
apocapoc-api/internal/domain/value_objects/frequency.go:34.2,35.18 2 1
apocapoc-api/internal/domain/value_objects/frequency.go:35.18,37.3 1 1
apocapoc-api/internal/domain/value_objects/frequency.go:39.2,39.12 1 1
apocapoc-api/internal/domain/value_objects/habit_type.go:16.36,17.12 1 1
apocapoc-api/internal/domain/value_objects/habit_type.go:18.58,19.14 1 1
apocapoc-api/internal/domain/value_objects/habit_type.go:21.2,21.14 1 1
apocapoc-api/internal/domain/value_objects/habit_type.go:24.51,26.2 1 1
apocapoc-api/internal/domain/value_objects/habit_type.go:28.55,30.49 2 1
apocapoc-api/internal/domain/value_objects/habit_type.go:30.49,32.3 1 0
apocapoc-api/internal/domain/value_objects/habit_type.go:34.2,35.19 2 1
apocapoc-api/internal/domain/value_objects/habit_type.go:35.19,37.3 1 1
apocapoc-api/internal/domain/value_objects/habit_type.go:39.2,39.12 1 1
-730
View File
@@ -1,730 +0,0 @@
mode: atomic
apocapoc-api/cmd/api/main.go:38.13,40.16 2 0
apocapoc-api/cmd/api/main.go:40.16,42.3 1 0
apocapoc-api/cmd/api/main.go:44.2,45.16 2 0
apocapoc-api/cmd/api/main.go:45.16,47.3 1 0
apocapoc-api/cmd/api/main.go:48.2,51.16 3 0
apocapoc-api/cmd/api/main.go:51.16,53.3 1 0
apocapoc-api/cmd/api/main.go:55.2,56.16 2 0
apocapoc-api/cmd/api/main.go:56.16,58.3 1 0
apocapoc-api/cmd/api/main.go:60.2,94.58 29 0
apocapoc-api/cmd/api/main.go:94.58,96.3 1 0
apocapoc-api/cmd/api/main.go:99.49,101.36 2 0
apocapoc-api/cmd/api/main.go:101.36,104.3 2 0
apocapoc-api/cmd/api/main.go:105.2,105.68 1 0
apocapoc-api/cmd/api/main.go:108.60,110.38 2 0
apocapoc-api/cmd/api/main.go:110.38,113.17 3 0
apocapoc-api/cmd/api/main.go:113.17,115.4 1 0
apocapoc-api/cmd/api/main.go:116.3,116.48 1 0
apocapoc-api/cmd/api/main.go:118.2,118.38 1 0
apocapoc-api/cmd/api/main.go:118.38,121.17 3 0
apocapoc-api/cmd/api/main.go:121.17,123.4 1 0
apocapoc-api/cmd/api/main.go:124.3,124.45 1 0
apocapoc-api/cmd/api/main.go:126.2,126.38 1 0
apocapoc-api/cmd/api/main.go:126.38,129.17 3 0
apocapoc-api/cmd/api/main.go:129.17,131.4 1 0
apocapoc-api/cmd/api/main.go:132.3,132.50 1 0
apocapoc-api/cmd/api/main.go:134.2,134.84 1 0
apocapoc-api/docs/docs.go:1223.13,1225.2 1 0
apocapoc-api/internal/application/queries/get_habit_by_id.go:19.90,23.2 1 3
apocapoc-api/internal/application/queries/get_habit_by_id.go:25.103,27.16 2 3
apocapoc-api/internal/application/queries/get_habit_by_id.go:27.16,29.3 1 1
apocapoc-api/internal/application/queries/get_habit_by_id.go:31.2,31.34 1 2
apocapoc-api/internal/application/queries/get_habit_by_id.go:31.34,33.3 1 1
apocapoc-api/internal/application/queries/get_habit_by_id.go:35.2,44.8 1 1
apocapoc-api/internal/application/queries/get_habit_entries.go:44.27,49.2 1 7
apocapoc-api/internal/application/queries/get_habit_entries.go:51.122,53.16 2 7
apocapoc-api/internal/application/queries/get_habit_entries.go:53.16,55.3 1 1
apocapoc-api/internal/application/queries/get_habit_entries.go:57.2,57.34 1 6
apocapoc-api/internal/application/queries/get_habit_entries.go:57.34,59.3 1 1
apocapoc-api/internal/application/queries/get_habit_entries.go:61.2,62.42 2 5
apocapoc-api/internal/application/queries/get_habit_entries.go:62.42,64.3 1 2
apocapoc-api/internal/application/queries/get_habit_entries.go:66.2,67.42 2 5
apocapoc-api/internal/application/queries/get_habit_entries.go:67.42,69.3 1 3
apocapoc-api/internal/application/queries/get_habit_entries.go:69.8,69.32 1 2
apocapoc-api/internal/application/queries/get_habit_entries.go:69.32,71.3 1 1
apocapoc-api/internal/application/queries/get_habit_entries.go:73.2,73.44 1 5
apocapoc-api/internal/application/queries/get_habit_entries.go:73.44,75.3 1 2
apocapoc-api/internal/application/queries/get_habit_entries.go:77.2,79.42 2 3
apocapoc-api/internal/application/queries/get_habit_entries.go:79.42,81.3 1 1
apocapoc-api/internal/application/queries/get_habit_entries.go:81.8,81.30 1 2
apocapoc-api/internal/application/queries/get_habit_entries.go:81.30,83.3 1 0
apocapoc-api/internal/application/queries/get_habit_entries.go:83.8,83.28 1 2
apocapoc-api/internal/application/queries/get_habit_entries.go:83.28,85.3 1 0
apocapoc-api/internal/application/queries/get_habit_entries.go:85.8,87.3 1 2
apocapoc-api/internal/application/queries/get_habit_entries.go:89.2,89.16 1 3
apocapoc-api/internal/application/queries/get_habit_entries.go:89.16,91.3 1 0
apocapoc-api/internal/application/queries/get_habit_entries.go:93.2,95.21 2 3
apocapoc-api/internal/application/queries/get_habit_entries.go:95.21,97.17 2 2
apocapoc-api/internal/application/queries/get_habit_entries.go:97.17,99.4 1 0
apocapoc-api/internal/application/queries/get_habit_entries.go:100.3,101.28 2 2
apocapoc-api/internal/application/queries/get_habit_entries.go:101.28,102.26 1 2
apocapoc-api/internal/application/queries/get_habit_entries.go:102.26,104.5 1 1
apocapoc-api/internal/application/queries/get_habit_entries.go:105.4,105.33 1 2
apocapoc-api/internal/application/queries/get_habit_entries.go:106.9,108.4 1 0
apocapoc-api/internal/application/queries/get_habit_entries.go:111.2,112.32 2 3
apocapoc-api/internal/application/queries/get_habit_entries.go:112.32,120.3 1 6
apocapoc-api/internal/application/queries/get_habit_entries.go:122.2,127.8 1 3
apocapoc-api/internal/application/queries/get_habit_stats.go:36.25,41.2 1 0
apocapoc-api/internal/application/queries/get_habit_stats.go:43.110,45.16 2 0
apocapoc-api/internal/application/queries/get_habit_stats.go:45.16,47.3 1 0
apocapoc-api/internal/application/queries/get_habit_stats.go:49.2,49.34 1 0
apocapoc-api/internal/application/queries/get_habit_stats.go:49.34,51.3 1 0
apocapoc-api/internal/application/queries/get_habit_stats.go:53.2,54.16 2 0
apocapoc-api/internal/application/queries/get_habit_stats.go:54.16,56.3 1 0
apocapoc-api/internal/application/queries/get_habit_stats.go:58.2,63.23 2 0
apocapoc-api/internal/application/queries/get_habit_stats.go:63.23,65.3 1 0
apocapoc-api/internal/application/queries/get_habit_stats.go:67.2,74.19 7 0
apocapoc-api/internal/application/queries/get_habit_stats.go:77.65,78.23 1 0
apocapoc-api/internal/application/queries/get_habit_stats.go:78.23,80.3 1 0
apocapoc-api/internal/application/queries/get_habit_stats.go:82.2,83.32 2 0
apocapoc-api/internal/application/queries/get_habit_stats.go:83.32,86.3 2 0
apocapoc-api/internal/application/queries/get_habit_stats.go:88.2,91.6 3 0
apocapoc-api/internal/application/queries/get_habit_stats.go:91.6,93.24 2 0
apocapoc-api/internal/application/queries/get_habit_stats.go:93.24,94.9 1 0
apocapoc-api/internal/application/queries/get_habit_stats.go:96.3,97.46 2 0
apocapoc-api/internal/application/queries/get_habit_stats.go:100.2,100.15 1 0
apocapoc-api/internal/application/queries/get_habit_stats.go:103.65,104.23 1 0
apocapoc-api/internal/application/queries/get_habit_stats.go:104.23,106.3 1 0
apocapoc-api/internal/application/queries/get_habit_stats.go:108.2,110.32 3 0
apocapoc-api/internal/application/queries/get_habit_stats.go:110.32,113.24 3 0
apocapoc-api/internal/application/queries/get_habit_stats.go:113.24,116.4 2 0
apocapoc-api/internal/application/queries/get_habit_stats.go:119.2,119.21 1 0
apocapoc-api/internal/application/queries/get_habit_stats.go:119.21,121.3 1 0
apocapoc-api/internal/application/queries/get_habit_stats.go:123.2,126.34 3 0
apocapoc-api/internal/application/queries/get_habit_stats.go:126.34,128.16 2 0
apocapoc-api/internal/application/queries/get_habit_stats.go:128.16,130.37 2 0
apocapoc-api/internal/application/queries/get_habit_stats.go:130.37,132.5 1 0
apocapoc-api/internal/application/queries/get_habit_stats.go:133.9,135.4 1 0
apocapoc-api/internal/application/queries/get_habit_stats.go:138.2,138.22 1 0
apocapoc-api/internal/application/queries/get_habit_stats.go:141.91,142.23 1 0
apocapoc-api/internal/application/queries/get_habit_stats.go:142.23,144.3 1 0
apocapoc-api/internal/application/queries/get_habit_stats.go:146.2,147.28 2 0
apocapoc-api/internal/application/queries/get_habit_stats.go:147.28,149.3 1 0
apocapoc-api/internal/application/queries/get_habit_stats.go:151.2,152.16 2 0
apocapoc-api/internal/application/queries/get_habit_stats.go:152.16,154.3 1 0
apocapoc-api/internal/application/queries/get_habit_stats.go:156.2,156.13 1 0
apocapoc-api/internal/application/queries/get_habit_stats.go:159.77,163.32 3 0
apocapoc-api/internal/application/queries/get_habit_stats.go:163.32,164.40 1 0
apocapoc-api/internal/application/queries/get_habit_stats.go:164.40,166.4 1 0
apocapoc-api/internal/application/queries/get_habit_stats.go:169.2,169.14 1 0
apocapoc-api/internal/application/queries/get_todays_habits.go:36.27,41.2 1 6
apocapoc-api/internal/application/queries/get_todays_habits.go:46.29,48.16 2 6
apocapoc-api/internal/application/queries/get_todays_habits.go:48.16,50.3 1 0
apocapoc-api/internal/application/queries/get_todays_habits.go:52.2,54.31 2 6
apocapoc-api/internal/application/queries/get_todays_habits.go:54.31,62.40 2 6
apocapoc-api/internal/application/queries/get_todays_habits.go:62.40,63.12 1 2
apocapoc-api/internal/application/queries/get_todays_habits.go:66.3,74.33 3 4
apocapoc-api/internal/application/queries/get_todays_habits.go:74.33,75.45 1 1
apocapoc-api/internal/application/queries/get_todays_habits.go:75.45,77.10 2 1
apocapoc-api/internal/application/queries/get_todays_habits.go:81.3,81.19 1 4
apocapoc-api/internal/application/queries/get_todays_habits.go:81.19,91.4 1 3
apocapoc-api/internal/application/queries/get_todays_habits.go:94.2,94.20 1 6
apocapoc-api/internal/application/queries/get_user_habits.go:29.92,33.2 1 3
apocapoc-api/internal/application/queries/get_user_habits.go:35.106,37.16 2 3
apocapoc-api/internal/application/queries/get_user_habits.go:37.16,39.3 1 0
apocapoc-api/internal/application/queries/get_user_habits.go:41.2,42.31 2 3
apocapoc-api/internal/application/queries/get_user_habits.go:42.31,53.3 1 3
apocapoc-api/internal/application/queries/get_user_habits.go:55.2,55.20 1 3
apocapoc-api/internal/application/queries/login_user.go:27.122,32.2 1 0
apocapoc-api/internal/application/queries/login_user.go:34.104,35.47 1 0
apocapoc-api/internal/application/queries/login_user.go:35.47,37.3 1 0
apocapoc-api/internal/application/queries/login_user.go:39.2,40.16 2 0
apocapoc-api/internal/application/queries/login_user.go:40.16,42.3 1 0
apocapoc-api/internal/application/queries/login_user.go:44.2,44.84 1 0
apocapoc-api/internal/application/queries/login_user.go:44.84,46.3 1 0
apocapoc-api/internal/application/queries/login_user.go:48.2,52.8 1 0
apocapoc-api/internal/application/queries/refresh_token.go:33.24,38.2 1 5
apocapoc-api/internal/application/queries/refresh_token.go:40.113,41.30 1 5
apocapoc-api/internal/application/queries/refresh_token.go:41.30,43.3 1 1
apocapoc-api/internal/application/queries/refresh_token.go:45.2,46.16 2 4
apocapoc-api/internal/application/queries/refresh_token.go:46.16,48.3 1 1
apocapoc-api/internal/application/queries/refresh_token.go:50.2,50.29 1 3
apocapoc-api/internal/application/queries/refresh_token.go:50.29,52.3 1 2
apocapoc-api/internal/application/queries/refresh_token.go:54.2,55.16 2 1
apocapoc-api/internal/application/queries/refresh_token.go:55.16,57.3 1 0
apocapoc-api/internal/application/queries/refresh_token.go:59.2,63.8 1 1
apocapoc-api/internal/application/queries/refresh_token.go:66.45,68.40 2 0
apocapoc-api/internal/application/queries/refresh_token.go:68.40,70.3 1 0
apocapoc-api/internal/application/queries/refresh_token.go:71.2,71.50 1 0
apocapoc-api/internal/application/queries/refresh_token.go:74.102,76.16 2 0
apocapoc-api/internal/application/queries/refresh_token.go:76.16,78.3 1 0
apocapoc-api/internal/application/queries/refresh_token.go:80.2,81.64 2 0
apocapoc-api/internal/application/commands/archive_habit.go:19.90,23.2 1 4
apocapoc-api/internal/application/commands/archive_habit.go:25.90,27.16 2 4
apocapoc-api/internal/application/commands/archive_habit.go:27.16,29.3 1 1
apocapoc-api/internal/application/commands/archive_habit.go:31.2,31.32 1 3
apocapoc-api/internal/application/commands/archive_habit.go:31.32,33.3 1 1
apocapoc-api/internal/application/commands/archive_habit.go:35.2,37.39 2 2
apocapoc-api/internal/application/commands/create_habit.go:29.88,31.2 1 8
apocapoc-api/internal/application/commands/create_habit.go:33.98,34.25 1 8
apocapoc-api/internal/application/commands/create_habit.go:34.25,36.3 1 1
apocapoc-api/internal/application/commands/create_habit.go:38.2,38.30 1 7
apocapoc-api/internal/application/commands/create_habit.go:38.30,40.3 1 1
apocapoc-api/internal/application/commands/create_habit.go:42.2,42.82 1 6
apocapoc-api/internal/application/commands/create_habit.go:42.82,44.3 1 1
apocapoc-api/internal/application/commands/create_habit.go:46.2,46.84 1 5
apocapoc-api/internal/application/commands/create_habit.go:46.84,48.3 1 1
apocapoc-api/internal/application/commands/create_habit.go:50.2,56.55 6 4
apocapoc-api/internal/application/commands/create_habit.go:56.55,58.3 1 0
apocapoc-api/internal/application/commands/create_habit.go:60.2,60.22 1 4
apocapoc-api/internal/application/commands/mark_habit.go:29.21,34.2 1 14
apocapoc-api/internal/application/commands/mark_habit.go:36.84,38.16 2 14
apocapoc-api/internal/application/commands/mark_habit.go:38.16,40.3 1 0
apocapoc-api/internal/application/commands/mark_habit.go:42.2,42.18 1 14
apocapoc-api/internal/application/commands/mark_habit.go:42.18,44.3 1 1
apocapoc-api/internal/application/commands/mark_habit.go:46.2,46.23 1 13
apocapoc-api/internal/application/commands/mark_habit.go:46.23,48.3 1 1
apocapoc-api/internal/application/commands/mark_habit.go:50.2,50.70 1 12
apocapoc-api/internal/application/commands/mark_habit.go:50.70,51.43 1 7
apocapoc-api/internal/application/commands/mark_habit.go:51.43,53.4 1 1
apocapoc-api/internal/application/commands/mark_habit.go:56.2,58.50 2 11
apocapoc-api/internal/application/commands/mark_habit.go:58.50,64.31 4 8
apocapoc-api/internal/application/commands/mark_habit.go:64.31,68.24 3 5
apocapoc-api/internal/application/commands/mark_habit.go:68.24,70.5 1 4
apocapoc-api/internal/application/commands/mark_habit.go:72.4,73.34 2 5
apocapoc-api/internal/application/commands/mark_habit.go:73.34,75.5 1 5
apocapoc-api/internal/application/commands/mark_habit.go:75.10,77.5 1 0
apocapoc-api/internal/application/commands/mark_habit.go:79.4,79.20 1 5
apocapoc-api/internal/application/commands/mark_habit.go:79.20,81.5 1 2
apocapoc-api/internal/application/commands/mark_habit.go:83.4,87.49 4 5
apocapoc-api/internal/application/commands/mark_habit.go:88.9,89.24 1 3
apocapoc-api/internal/application/commands/mark_habit.go:89.24,91.18 2 2
apocapoc-api/internal/application/commands/mark_habit.go:91.18,93.6 1 1
apocapoc-api/internal/application/commands/mark_habit.go:94.5,94.24 1 2
apocapoc-api/internal/application/commands/mark_habit.go:95.10,98.5 2 1
apocapoc-api/internal/application/commands/mark_habit.go:100.8,102.3 1 3
apocapoc-api/internal/application/commands/mark_habit.go:104.2,106.39 2 6
apocapoc-api/internal/application/commands/register_user.go:24.128,29.2 1 8
apocapoc-api/internal/application/commands/register_user.go:31.100,32.95 1 27
apocapoc-api/internal/application/commands/register_user.go:32.95,34.3 1 18
apocapoc-api/internal/application/commands/register_user.go:36.2,37.21 2 9
apocapoc-api/internal/application/commands/register_user.go:37.21,39.3 1 1
apocapoc-api/internal/application/commands/register_user.go:41.2,42.16 2 8
apocapoc-api/internal/application/commands/register_user.go:42.16,44.3 1 1
apocapoc-api/internal/application/commands/register_user.go:46.2,48.53 2 7
apocapoc-api/internal/application/commands/register_user.go:48.53,50.3 1 1
apocapoc-api/internal/application/commands/register_user.go:52.2,52.21 1 6
apocapoc-api/internal/application/commands/revoke_all_tokens.go:18.110,22.2 1 0
apocapoc-api/internal/application/commands/revoke_all_tokens.go:24.96,25.22 1 0
apocapoc-api/internal/application/commands/revoke_all_tokens.go:25.22,27.3 1 0
apocapoc-api/internal/application/commands/revoke_all_tokens.go:29.2,29.62 1 0
apocapoc-api/internal/application/commands/revoke_token.go:18.102,22.2 1 0
apocapoc-api/internal/application/commands/revoke_token.go:24.88,25.28 1 0
apocapoc-api/internal/application/commands/revoke_token.go:25.28,27.3 1 0
apocapoc-api/internal/application/commands/revoke_token.go:29.2,29.64 1 0
apocapoc-api/internal/application/commands/unmark_habit.go:25.23,30.2 1 4
apocapoc-api/internal/application/commands/unmark_habit.go:32.88,34.16 2 4
apocapoc-api/internal/application/commands/unmark_habit.go:34.16,36.3 1 1
apocapoc-api/internal/application/commands/unmark_habit.go:38.2,38.32 1 3
apocapoc-api/internal/application/commands/unmark_habit.go:38.32,40.3 1 1
apocapoc-api/internal/application/commands/unmark_habit.go:42.2,52.16 4 2
apocapoc-api/internal/application/commands/unmark_habit.go:52.16,54.3 1 0
apocapoc-api/internal/application/commands/unmark_habit.go:56.2,57.32 2 2
apocapoc-api/internal/application/commands/unmark_habit.go:57.32,58.51 1 1
apocapoc-api/internal/application/commands/unmark_habit.go:58.51,60.9 2 1
apocapoc-api/internal/application/commands/unmark_habit.go:64.2,64.25 1 2
apocapoc-api/internal/application/commands/unmark_habit.go:64.25,66.3 1 1
apocapoc-api/internal/application/commands/unmark_habit.go:68.2,68.47 1 1
apocapoc-api/internal/application/commands/update_habit.go:26.88,30.2 1 5
apocapoc-api/internal/application/commands/update_habit.go:32.88,33.39 1 5
apocapoc-api/internal/application/commands/update_habit.go:33.39,35.3 1 1
apocapoc-api/internal/application/commands/update_habit.go:37.2,38.16 2 4
apocapoc-api/internal/application/commands/update_habit.go:38.16,40.3 1 1
apocapoc-api/internal/application/commands/update_habit.go:42.2,42.32 1 3
apocapoc-api/internal/application/commands/update_habit.go:42.32,44.3 1 1
apocapoc-api/internal/application/commands/update_habit.go:46.2,46.23 1 2
apocapoc-api/internal/application/commands/update_habit.go:46.23,48.3 1 1
apocapoc-api/internal/application/commands/update_habit.go:50.2,57.39 7 1
apocapoc-api/internal/infrastructure/auth/jwt.go:21.64,26.2 1 0
apocapoc-api/internal/infrastructure/auth/jwt.go:28.74,40.2 3 0
apocapoc-api/internal/infrastructure/auth/jwt.go:42.73,43.104 1 0
apocapoc-api/internal/infrastructure/auth/jwt.go:43.104,44.58 1 0
apocapoc-api/internal/infrastructure/auth/jwt.go:44.58,46.4 1 0
apocapoc-api/internal/infrastructure/auth/jwt.go:47.3,47.23 1 0
apocapoc-api/internal/infrastructure/auth/jwt.go:50.2,50.16 1 0
apocapoc-api/internal/infrastructure/auth/jwt.go:50.16,52.3 1 0
apocapoc-api/internal/infrastructure/auth/jwt.go:54.2,54.61 1 0
apocapoc-api/internal/infrastructure/auth/jwt.go:54.61,56.3 1 0
apocapoc-api/internal/infrastructure/auth/jwt.go:58.2,58.41 1 0
apocapoc-api/internal/infrastructure/config/config.go:21.30,35.22 3 0
apocapoc-api/internal/infrastructure/config/config.go:35.22,37.3 1 0
apocapoc-api/internal/infrastructure/config/config.go:38.2,38.25 1 0
apocapoc-api/internal/infrastructure/config/config.go:38.25,40.3 1 0
apocapoc-api/internal/infrastructure/config/config.go:41.2,41.25 1 0
apocapoc-api/internal/infrastructure/config/config.go:41.25,43.3 1 0
apocapoc-api/internal/infrastructure/config/config.go:44.2,44.34 1 0
apocapoc-api/internal/infrastructure/config/config.go:44.34,46.3 1 0
apocapoc-api/internal/infrastructure/config/config.go:47.2,47.27 1 0
apocapoc-api/internal/infrastructure/config/config.go:47.27,49.3 1 0
apocapoc-api/internal/infrastructure/config/config.go:50.2,50.31 1 0
apocapoc-api/internal/infrastructure/config/config.go:50.31,52.3 1 0
apocapoc-api/internal/infrastructure/config/config.go:54.2,54.17 1 0
apocapoc-api/internal/infrastructure/config/config.go:57.55,58.42 1 0
apocapoc-api/internal/infrastructure/config/config.go:58.42,60.3 1 0
apocapoc-api/internal/infrastructure/config/config.go:61.2,61.21 1 0
apocapoc-api/internal/infrastructure/crypto/bcrypt_hasher.go:11.48,13.2 1 0
apocapoc-api/internal/infrastructure/crypto/bcrypt_hasher.go:15.62,17.16 2 0
apocapoc-api/internal/infrastructure/crypto/bcrypt_hasher.go:17.16,19.3 1 0
apocapoc-api/internal/infrastructure/crypto/bcrypt_hasher.go:20.2,20.33 1 0
apocapoc-api/internal/infrastructure/crypto/bcrypt_hasher.go:23.71,25.2 1 0
apocapoc-api/internal/domain/entities/habit.go:32.10,42.2 1 5
apocapoc-api/internal/domain/entities/habit.go:44.27,47.2 2 2
apocapoc-api/internal/domain/entities/habit.go:49.33,51.2 1 2
apocapoc-api/internal/domain/entities/habit_entry.go:13.89,20.2 1 2
apocapoc-api/internal/domain/entities/refresh_token.go:14.79,22.2 1 0
apocapoc-api/internal/domain/entities/refresh_token.go:24.40,25.25 1 0
apocapoc-api/internal/domain/entities/refresh_token.go:25.25,27.3 1 0
apocapoc-api/internal/domain/entities/refresh_token.go:28.2,28.40 1 0
apocapoc-api/internal/domain/entities/refresh_token.go:31.34,34.2 2 0
apocapoc-api/internal/domain/entities/user.go:14.58,16.20 2 2
apocapoc-api/internal/domain/entities/user.go:16.20,18.3 1 1
apocapoc-api/internal/domain/entities/user.go:19.2,25.3 1 2
apocapoc-api/internal/domain/value_objects/frequency.go:16.35,17.11 1 12
apocapoc-api/internal/domain/value_objects/frequency.go:18.57,19.14 1 7
apocapoc-api/internal/domain/value_objects/frequency.go:21.2,21.14 1 5
apocapoc-api/internal/domain/value_objects/frequency.go:24.50,26.2 1 4
apocapoc-api/internal/domain/value_objects/frequency.go:28.54,30.49 2 6
apocapoc-api/internal/domain/value_objects/frequency.go:30.49,32.3 1 0
apocapoc-api/internal/domain/value_objects/frequency.go:34.2,35.18 2 6
apocapoc-api/internal/domain/value_objects/frequency.go:35.18,37.3 1 2
apocapoc-api/internal/domain/value_objects/frequency.go:39.2,39.12 1 4
apocapoc-api/internal/domain/value_objects/habit_type.go:16.36,17.12 1 12
apocapoc-api/internal/domain/value_objects/habit_type.go:18.58,19.14 1 7
apocapoc-api/internal/domain/value_objects/habit_type.go:21.2,21.14 1 5
apocapoc-api/internal/domain/value_objects/habit_type.go:24.51,26.2 1 4
apocapoc-api/internal/domain/value_objects/habit_type.go:28.55,30.49 2 6
apocapoc-api/internal/domain/value_objects/habit_type.go:30.49,32.3 1 0
apocapoc-api/internal/domain/value_objects/habit_type.go:34.2,35.19 2 6
apocapoc-api/internal/domain/value_objects/habit_type.go:35.19,37.3 1 2
apocapoc-api/internal/domain/value_objects/habit_type.go:39.2,39.12 1 4
apocapoc-api/internal/shared/utils/date_utils.go:10.8,11.19 1 10
apocapoc-api/internal/shared/utils/date_utils.go:12.15,13.14 1 1
apocapoc-api/internal/shared/utils/date_utils.go:14.16,16.41 2 4
apocapoc-api/internal/shared/utils/date_utils.go:17.17,19.38 2 4
apocapoc-api/internal/shared/utils/date_utils.go:21.2,21.14 1 1
apocapoc-api/internal/shared/utils/date_utils.go:24.42,25.29 1 8
apocapoc-api/internal/shared/utils/date_utils.go:25.29,26.18 1 14
apocapoc-api/internal/shared/utils/date_utils.go:26.18,28.4 1 4
apocapoc-api/internal/shared/utils/date_utils.go:30.2,30.14 1 4
apocapoc-api/internal/infrastructure/persistence/sqlite/database.go:16.52,18.30 2 5
apocapoc-api/internal/infrastructure/persistence/sqlite/database.go:18.30,19.48 1 1
apocapoc-api/internal/infrastructure/persistence/sqlite/database.go:19.48,21.4 1 0
apocapoc-api/internal/infrastructure/persistence/sqlite/database.go:24.2,25.16 2 5
apocapoc-api/internal/infrastructure/persistence/sqlite/database.go:25.16,27.3 1 0
apocapoc-api/internal/infrastructure/persistence/sqlite/database.go:29.2,31.36 2 5
apocapoc-api/internal/infrastructure/persistence/sqlite/database.go:31.36,33.3 1 0
apocapoc-api/internal/infrastructure/persistence/sqlite/database.go:35.2,35.44 1 5
apocapoc-api/internal/infrastructure/persistence/sqlite/database.go:35.44,37.3 1 0
apocapoc-api/internal/infrastructure/persistence/sqlite/database.go:39.2,39.35 1 5
apocapoc-api/internal/infrastructure/persistence/sqlite/database.go:42.35,44.2 1 5
apocapoc-api/internal/infrastructure/persistence/sqlite/database.go:46.36,48.2 1 2
apocapoc-api/internal/infrastructure/persistence/sqlite/habit_entry_repository.go:19.64,21.2 1 7
apocapoc-api/internal/infrastructure/persistence/sqlite/habit_entry_repository.go:23.94,39.16 4 12
apocapoc-api/internal/infrastructure/persistence/sqlite/habit_entry_repository.go:39.16,40.35 1 1
apocapoc-api/internal/infrastructure/persistence/sqlite/habit_entry_repository.go:40.35,42.4 1 1
apocapoc-api/internal/infrastructure/persistence/sqlite/habit_entry_repository.go:43.3,43.55 1 0
apocapoc-api/internal/infrastructure/persistence/sqlite/habit_entry_repository.go:46.2,46.12 1 11
apocapoc-api/internal/infrastructure/persistence/sqlite/habit_entry_repository.go:53.35,68.16 3 3
apocapoc-api/internal/infrastructure/persistence/sqlite/habit_entry_repository.go:68.16,70.3 1 0
apocapoc-api/internal/infrastructure/persistence/sqlite/habit_entry_repository.go:71.2,73.28 2 3
apocapoc-api/internal/infrastructure/persistence/sqlite/habit_entry_repository.go:76.94,84.16 3 1
apocapoc-api/internal/infrastructure/persistence/sqlite/habit_entry_repository.go:84.16,86.3 1 0
apocapoc-api/internal/infrastructure/persistence/sqlite/habit_entry_repository.go:88.2,89.15 2 1
apocapoc-api/internal/infrastructure/persistence/sqlite/habit_entry_repository.go:89.15,91.3 1 1
apocapoc-api/internal/infrastructure/persistence/sqlite/habit_entry_repository.go:93.2,93.12 1 0
apocapoc-api/internal/infrastructure/persistence/sqlite/habit_entry_repository.go:96.92,99.18 2 3
apocapoc-api/internal/infrastructure/persistence/sqlite/habit_entry_repository.go:99.18,113.17 3 6
apocapoc-api/internal/infrastructure/persistence/sqlite/habit_entry_repository.go:113.17,115.4 1 0
apocapoc-api/internal/infrastructure/persistence/sqlite/habit_entry_repository.go:117.3,118.17 2 6
apocapoc-api/internal/infrastructure/persistence/sqlite/habit_entry_repository.go:118.17,120.18 2 6
apocapoc-api/internal/infrastructure/persistence/sqlite/habit_entry_repository.go:120.18,122.5 1 0
apocapoc-api/internal/infrastructure/persistence/sqlite/habit_entry_repository.go:124.3,126.36 2 6
apocapoc-api/internal/infrastructure/persistence/sqlite/habit_entry_repository.go:129.2,129.21 1 3
apocapoc-api/internal/infrastructure/persistence/sqlite/habit_entry_repository.go:132.103,152.26 4 1
apocapoc-api/internal/infrastructure/persistence/sqlite/habit_entry_repository.go:152.26,154.3 1 0
apocapoc-api/internal/infrastructure/persistence/sqlite/habit_entry_repository.go:155.2,155.16 1 1
apocapoc-api/internal/infrastructure/persistence/sqlite/habit_entry_repository.go:155.16,157.3 1 0
apocapoc-api/internal/infrastructure/persistence/sqlite/habit_entry_repository.go:159.2,160.16 2 1
apocapoc-api/internal/infrastructure/persistence/sqlite/habit_entry_repository.go:160.16,162.17 2 1
apocapoc-api/internal/infrastructure/persistence/sqlite/habit_entry_repository.go:162.17,164.4 1 0
apocapoc-api/internal/infrastructure/persistence/sqlite/habit_entry_repository.go:166.2,168.20 2 1
apocapoc-api/internal/infrastructure/persistence/sqlite/habit_entry_repository.go:171.115,180.16 3 0
apocapoc-api/internal/infrastructure/persistence/sqlite/habit_entry_repository.go:180.16,182.3 1 0
apocapoc-api/internal/infrastructure/persistence/sqlite/habit_entry_repository.go:183.2,185.28 2 0
apocapoc-api/internal/infrastructure/persistence/sqlite/habit_entry_repository.go:188.144,198.16 3 0
apocapoc-api/internal/infrastructure/persistence/sqlite/habit_entry_repository.go:198.16,200.3 1 0
apocapoc-api/internal/infrastructure/persistence/sqlite/habit_entry_repository.go:201.2,203.28 2 0
apocapoc-api/internal/infrastructure/persistence/sqlite/habit_entry_repository.go:206.77,210.16 3 0
apocapoc-api/internal/infrastructure/persistence/sqlite/habit_entry_repository.go:210.16,212.3 1 0
apocapoc-api/internal/infrastructure/persistence/sqlite/habit_entry_repository.go:214.2,215.15 2 0
apocapoc-api/internal/infrastructure/persistence/sqlite/habit_entry_repository.go:215.15,217.3 1 0
apocapoc-api/internal/infrastructure/persistence/sqlite/habit_entry_repository.go:219.2,219.12 1 0
apocapoc-api/internal/infrastructure/persistence/sqlite/habit_repository.go:19.54,21.2 1 8
apocapoc-api/internal/infrastructure/persistence/sqlite/habit_repository.go:23.84,51.16 6 8
apocapoc-api/internal/infrastructure/persistence/sqlite/habit_repository.go:51.16,53.3 1 0
apocapoc-api/internal/infrastructure/persistence/sqlite/habit_repository.go:55.2,55.12 1 8
apocapoc-api/internal/infrastructure/persistence/sqlite/habit_repository.go:58.93,90.26 4 5
apocapoc-api/internal/infrastructure/persistence/sqlite/habit_repository.go:90.26,92.3 1 1
apocapoc-api/internal/infrastructure/persistence/sqlite/habit_repository.go:93.2,93.16 1 4
apocapoc-api/internal/infrastructure/persistence/sqlite/habit_repository.go:93.16,95.3 1 0
apocapoc-api/internal/infrastructure/persistence/sqlite/habit_repository.go:97.2,97.24 1 4
apocapoc-api/internal/infrastructure/persistence/sqlite/habit_repository.go:97.24,99.3 1 4
apocapoc-api/internal/infrastructure/persistence/sqlite/habit_repository.go:100.2,100.25 1 4
apocapoc-api/internal/infrastructure/persistence/sqlite/habit_repository.go:100.25,102.3 1 4
apocapoc-api/internal/infrastructure/persistence/sqlite/habit_repository.go:103.2,103.22 1 4
apocapoc-api/internal/infrastructure/persistence/sqlite/habit_repository.go:103.22,105.3 1 1
apocapoc-api/internal/infrastructure/persistence/sqlite/habit_repository.go:107.2,107.20 1 4
apocapoc-api/internal/infrastructure/persistence/sqlite/habit_repository.go:110.109,121.16 3 1
apocapoc-api/internal/infrastructure/persistence/sqlite/habit_repository.go:121.16,123.3 1 0
apocapoc-api/internal/infrastructure/persistence/sqlite/habit_repository.go:124.2,126.27 2 1
apocapoc-api/internal/infrastructure/persistence/sqlite/habit_repository.go:129.84,155.16 5 4
apocapoc-api/internal/infrastructure/persistence/sqlite/habit_repository.go:155.16,157.3 1 0
apocapoc-api/internal/infrastructure/persistence/sqlite/habit_repository.go:159.2,160.15 2 4
apocapoc-api/internal/infrastructure/persistence/sqlite/habit_repository.go:160.15,162.3 1 1
apocapoc-api/internal/infrastructure/persistence/sqlite/habit_repository.go:164.2,164.12 1 3
apocapoc-api/internal/infrastructure/persistence/sqlite/habit_repository.go:167.81,170.18 2 1
apocapoc-api/internal/infrastructure/persistence/sqlite/habit_repository.go:170.18,194.17 3 2
apocapoc-api/internal/infrastructure/persistence/sqlite/habit_repository.go:194.17,196.4 1 0
apocapoc-api/internal/infrastructure/persistence/sqlite/habit_repository.go:198.3,198.25 1 2
apocapoc-api/internal/infrastructure/persistence/sqlite/habit_repository.go:198.25,200.4 1 2
apocapoc-api/internal/infrastructure/persistence/sqlite/habit_repository.go:201.3,201.26 1 2
apocapoc-api/internal/infrastructure/persistence/sqlite/habit_repository.go:201.26,203.4 1 2
apocapoc-api/internal/infrastructure/persistence/sqlite/habit_repository.go:204.3,204.23 1 2
apocapoc-api/internal/infrastructure/persistence/sqlite/habit_repository.go:204.23,206.4 1 0
apocapoc-api/internal/infrastructure/persistence/sqlite/habit_repository.go:208.3,208.34 1 2
apocapoc-api/internal/infrastructure/persistence/sqlite/habit_repository.go:211.2,211.20 1 1
apocapoc-api/internal/infrastructure/persistence/sqlite/habit_repository.go:214.103,225.16 3 0
apocapoc-api/internal/infrastructure/persistence/sqlite/habit_repository.go:225.16,227.3 1 0
apocapoc-api/internal/infrastructure/persistence/sqlite/habit_repository.go:228.2,230.27 2 0
apocapoc-api/internal/infrastructure/persistence/sqlite/habit_repository.go:233.72,237.16 3 0
apocapoc-api/internal/infrastructure/persistence/sqlite/habit_repository.go:237.16,239.3 1 0
apocapoc-api/internal/infrastructure/persistence/sqlite/habit_repository.go:241.2,242.15 2 0
apocapoc-api/internal/infrastructure/persistence/sqlite/habit_repository.go:242.15,244.3 1 0
apocapoc-api/internal/infrastructure/persistence/sqlite/habit_repository.go:246.2,246.12 1 0
apocapoc-api/internal/infrastructure/persistence/sqlite/migrations.go:7.38,16.39 2 34
apocapoc-api/internal/infrastructure/persistence/sqlite/migrations.go:16.39,17.47 1 170
apocapoc-api/internal/infrastructure/persistence/sqlite/migrations.go:17.47,19.4 1 0
apocapoc-api/internal/infrastructure/persistence/sqlite/migrations.go:21.2,21.12 1 34
apocapoc-api/internal/infrastructure/persistence/sqlite/refresh_token_repository.go:19.68,21.2 1 0
apocapoc-api/internal/infrastructure/persistence/sqlite/refresh_token_repository.go:23.98,40.16 4 0
apocapoc-api/internal/infrastructure/persistence/sqlite/refresh_token_repository.go:40.16,42.3 1 0
apocapoc-api/internal/infrastructure/persistence/sqlite/refresh_token_repository.go:44.2,44.12 1 0
apocapoc-api/internal/infrastructure/persistence/sqlite/refresh_token_repository.go:47.113,66.26 5 0
apocapoc-api/internal/infrastructure/persistence/sqlite/refresh_token_repository.go:66.26,68.3 1 0
apocapoc-api/internal/infrastructure/persistence/sqlite/refresh_token_repository.go:69.2,69.16 1 0
apocapoc-api/internal/infrastructure/persistence/sqlite/refresh_token_repository.go:69.16,71.3 1 0
apocapoc-api/internal/infrastructure/persistence/sqlite/refresh_token_repository.go:73.2,73.21 1 0
apocapoc-api/internal/infrastructure/persistence/sqlite/refresh_token_repository.go:73.21,75.3 1 0
apocapoc-api/internal/infrastructure/persistence/sqlite/refresh_token_repository.go:77.2,77.17 1 0
apocapoc-api/internal/infrastructure/persistence/sqlite/refresh_token_repository.go:80.117,89.16 3 0
apocapoc-api/internal/infrastructure/persistence/sqlite/refresh_token_repository.go:89.16,91.3 1 0
apocapoc-api/internal/infrastructure/persistence/sqlite/refresh_token_repository.go:92.2,95.18 3 0
apocapoc-api/internal/infrastructure/persistence/sqlite/refresh_token_repository.go:95.18,107.17 4 0
apocapoc-api/internal/infrastructure/persistence/sqlite/refresh_token_repository.go:107.17,109.4 1 0
apocapoc-api/internal/infrastructure/persistence/sqlite/refresh_token_repository.go:111.3,111.22 1 0
apocapoc-api/internal/infrastructure/persistence/sqlite/refresh_token_repository.go:111.22,113.4 1 0
apocapoc-api/internal/infrastructure/persistence/sqlite/refresh_token_repository.go:115.3,115.31 1 0
apocapoc-api/internal/infrastructure/persistence/sqlite/refresh_token_repository.go:118.2,118.20 1 0
apocapoc-api/internal/infrastructure/persistence/sqlite/refresh_token_repository.go:121.89,129.16 3 0
apocapoc-api/internal/infrastructure/persistence/sqlite/refresh_token_repository.go:129.16,131.3 1 0
apocapoc-api/internal/infrastructure/persistence/sqlite/refresh_token_repository.go:133.2,134.15 2 0
apocapoc-api/internal/infrastructure/persistence/sqlite/refresh_token_repository.go:134.15,136.3 1 0
apocapoc-api/internal/infrastructure/persistence/sqlite/refresh_token_repository.go:138.2,138.12 1 0
apocapoc-api/internal/infrastructure/persistence/sqlite/refresh_token_repository.go:141.94,149.16 3 0
apocapoc-api/internal/infrastructure/persistence/sqlite/refresh_token_repository.go:149.16,151.3 1 0
apocapoc-api/internal/infrastructure/persistence/sqlite/refresh_token_repository.go:153.2,153.12 1 0
apocapoc-api/internal/infrastructure/persistence/sqlite/refresh_token_repository.go:156.75,163.16 3 0
apocapoc-api/internal/infrastructure/persistence/sqlite/refresh_token_repository.go:163.16,165.3 1 0
apocapoc-api/internal/infrastructure/persistence/sqlite/refresh_token_repository.go:167.2,167.12 1 0
apocapoc-api/internal/infrastructure/persistence/sqlite/user_repository.go:19.52,21.2 1 8
apocapoc-api/internal/infrastructure/persistence/sqlite/user_repository.go:23.81,40.16 4 6
apocapoc-api/internal/infrastructure/persistence/sqlite/user_repository.go:40.16,41.35 1 1
apocapoc-api/internal/infrastructure/persistence/sqlite/user_repository.go:41.35,43.4 1 1
apocapoc-api/internal/infrastructure/persistence/sqlite/user_repository.go:44.3,44.54 1 0
apocapoc-api/internal/infrastructure/persistence/sqlite/user_repository.go:47.2,47.12 1 5
apocapoc-api/internal/infrastructure/persistence/sqlite/user_repository.go:50.91,67.26 4 3
apocapoc-api/internal/infrastructure/persistence/sqlite/user_repository.go:67.26,69.3 1 1
apocapoc-api/internal/infrastructure/persistence/sqlite/user_repository.go:70.2,70.16 1 2
apocapoc-api/internal/infrastructure/persistence/sqlite/user_repository.go:70.16,72.3 1 0
apocapoc-api/internal/infrastructure/persistence/sqlite/user_repository.go:74.2,74.19 1 2
apocapoc-api/internal/infrastructure/persistence/sqlite/user_repository.go:77.97,94.26 4 2
apocapoc-api/internal/infrastructure/persistence/sqlite/user_repository.go:94.26,96.3 1 1
apocapoc-api/internal/infrastructure/persistence/sqlite/user_repository.go:97.2,97.16 1 1
apocapoc-api/internal/infrastructure/persistence/sqlite/user_repository.go:97.16,99.3 1 0
apocapoc-api/internal/infrastructure/persistence/sqlite/user_repository.go:101.2,101.19 1 1
apocapoc-api/internal/infrastructure/persistence/sqlite/user_repository.go:104.81,119.16 3 2
apocapoc-api/internal/infrastructure/persistence/sqlite/user_repository.go:119.16,121.3 1 0
apocapoc-api/internal/infrastructure/persistence/sqlite/user_repository.go:123.2,124.15 2 2
apocapoc-api/internal/infrastructure/persistence/sqlite/user_repository.go:124.15,126.3 1 1
apocapoc-api/internal/infrastructure/persistence/sqlite/user_repository.go:128.2,128.12 1 1
apocapoc-api/internal/infrastructure/persistence/sqlite/user_repository.go:131.46,133.2 1 2
apocapoc-api/internal/shared/validation/common_passwords.go:60.45,63.2 2 10
apocapoc-api/internal/shared/validation/validator.go:19.41,21.2 1 12
apocapoc-api/internal/shared/validation/validator.go:23.40,26.17 2 26
apocapoc-api/internal/shared/validation/validator.go:26.17,28.3 1 2
apocapoc-api/internal/shared/validation/validator.go:30.2,30.22 1 24
apocapoc-api/internal/shared/validation/validator.go:30.22,32.3 1 1
apocapoc-api/internal/shared/validation/validator.go:34.2,34.36 1 23
apocapoc-api/internal/shared/validation/validator.go:34.36,36.3 1 8
apocapoc-api/internal/shared/validation/validator.go:38.2,39.24 2 15
apocapoc-api/internal/shared/validation/validator.go:39.24,41.3 1 1
apocapoc-api/internal/shared/validation/validator.go:43.2,43.12 1 14
apocapoc-api/internal/shared/validation/validator.go:46.46,47.20 1 23
apocapoc-api/internal/shared/validation/validator.go:47.20,49.3 1 2
apocapoc-api/internal/shared/validation/validator.go:51.2,51.23 1 21
apocapoc-api/internal/shared/validation/validator.go:51.23,53.3 1 3
apocapoc-api/internal/shared/validation/validator.go:55.2,55.25 1 18
apocapoc-api/internal/shared/validation/validator.go:55.25,57.3 1 1
apocapoc-api/internal/shared/validation/validator.go:59.2,66.32 2 17
apocapoc-api/internal/shared/validation/validator.go:66.32,67.10 1 292
apocapoc-api/internal/shared/validation/validator.go:68.30,69.19 1 58
apocapoc-api/internal/shared/validation/validator.go:70.30,71.19 1 130
apocapoc-api/internal/shared/validation/validator.go:72.30,73.19 1 58
apocapoc-api/internal/shared/validation/validator.go:74.56,75.21 1 45
apocapoc-api/internal/shared/validation/validator.go:79.2,79.15 1 17
apocapoc-api/internal/shared/validation/validator.go:79.15,81.3 1 2
apocapoc-api/internal/shared/validation/validator.go:83.2,83.15 1 15
apocapoc-api/internal/shared/validation/validator.go:83.15,85.3 1 1
apocapoc-api/internal/shared/validation/validator.go:87.2,87.15 1 14
apocapoc-api/internal/shared/validation/validator.go:87.15,89.3 1 2
apocapoc-api/internal/shared/validation/validator.go:91.2,91.17 1 12
apocapoc-api/internal/shared/validation/validator.go:91.17,93.3 1 2
apocapoc-api/internal/shared/validation/validator.go:95.2,95.32 1 10
apocapoc-api/internal/shared/validation/validator.go:95.32,97.3 1 0
apocapoc-api/internal/shared/validation/validator.go:99.2,99.12 1 10
apocapoc-api/internal/shared/validation/validator.go:102.46,105.20 2 18
apocapoc-api/internal/shared/validation/validator.go:105.20,107.3 1 2
apocapoc-api/internal/shared/validation/validator.go:109.2,110.16 2 16
apocapoc-api/internal/shared/validation/validator.go:110.16,112.3 1 7
apocapoc-api/internal/shared/validation/validator.go:114.2,114.12 1 9
apocapoc-api/internal/shared/validation/validator.go:117.67,118.45 1 9
apocapoc-api/internal/shared/validation/validator.go:118.45,120.3 1 3
apocapoc-api/internal/shared/validation/validator.go:122.2,122.51 1 6
apocapoc-api/internal/shared/validation/validator.go:122.51,124.3 1 2
apocapoc-api/internal/shared/validation/validator.go:126.2,126.51 1 4
apocapoc-api/internal/shared/validation/validator.go:126.51,128.3 1 2
apocapoc-api/internal/shared/validation/validator.go:130.2,130.12 1 2
apocapoc-api/internal/infrastructure/http/auth_handlers.go:35.17,46.2 1 3
apocapoc-api/internal/infrastructure/http/auth_handlers.go:85.73,87.61 2 10
apocapoc-api/internal/infrastructure/http/auth_handlers.go:87.61,90.3 2 0
apocapoc-api/internal/infrastructure/http/auth_handlers.go:92.2,99.16 3 10
apocapoc-api/internal/infrastructure/http/auth_handlers.go:99.16,100.36 1 3
apocapoc-api/internal/infrastructure/http/auth_handlers.go:100.36,103.4 2 2
apocapoc-api/internal/infrastructure/http/auth_handlers.go:104.3,104.37 1 1
apocapoc-api/internal/infrastructure/http/auth_handlers.go:104.37,107.4 2 1
apocapoc-api/internal/infrastructure/http/auth_handlers.go:108.3,109.9 2 0
apocapoc-api/internal/infrastructure/http/auth_handlers.go:112.2,113.16 2 7
apocapoc-api/internal/infrastructure/http/auth_handlers.go:113.16,116.3 2 0
apocapoc-api/internal/infrastructure/http/auth_handlers.go:118.2,119.16 2 7
apocapoc-api/internal/infrastructure/http/auth_handlers.go:119.16,122.3 2 0
apocapoc-api/internal/infrastructure/http/auth_handlers.go:124.2,124.77 1 7
apocapoc-api/internal/infrastructure/http/auth_handlers.go:124.77,127.3 2 0
apocapoc-api/internal/infrastructure/http/auth_handlers.go:129.2,133.4 1 7
apocapoc-api/internal/infrastructure/http/auth_handlers.go:148.70,150.61 2 3
apocapoc-api/internal/infrastructure/http/auth_handlers.go:150.61,153.3 2 0
apocapoc-api/internal/infrastructure/http/auth_handlers.go:155.2,161.16 3 3
apocapoc-api/internal/infrastructure/http/auth_handlers.go:161.16,162.65 1 2
apocapoc-api/internal/infrastructure/http/auth_handlers.go:162.65,165.4 2 2
apocapoc-api/internal/infrastructure/http/auth_handlers.go:166.3,167.9 2 0
apocapoc-api/internal/infrastructure/http/auth_handlers.go:170.2,171.16 2 1
apocapoc-api/internal/infrastructure/http/auth_handlers.go:171.16,174.3 2 0
apocapoc-api/internal/infrastructure/http/auth_handlers.go:176.2,177.16 2 1
apocapoc-api/internal/infrastructure/http/auth_handlers.go:177.16,180.3 2 0
apocapoc-api/internal/infrastructure/http/auth_handlers.go:182.2,182.77 1 1
apocapoc-api/internal/infrastructure/http/auth_handlers.go:182.77,185.3 2 0
apocapoc-api/internal/infrastructure/http/auth_handlers.go:187.2,191.4 1 1
apocapoc-api/internal/infrastructure/http/auth_handlers.go:206.72,208.61 2 0
apocapoc-api/internal/infrastructure/http/auth_handlers.go:208.61,211.3 2 0
apocapoc-api/internal/infrastructure/http/auth_handlers.go:213.2,218.16 3 0
apocapoc-api/internal/infrastructure/http/auth_handlers.go:218.16,219.65 1 0
apocapoc-api/internal/infrastructure/http/auth_handlers.go:219.65,222.4 2 0
apocapoc-api/internal/infrastructure/http/auth_handlers.go:223.3,224.9 2 0
apocapoc-api/internal/infrastructure/http/auth_handlers.go:227.2,228.16 2 0
apocapoc-api/internal/infrastructure/http/auth_handlers.go:228.16,231.3 2 0
apocapoc-api/internal/infrastructure/http/auth_handlers.go:233.2,234.16 2 0
apocapoc-api/internal/infrastructure/http/auth_handlers.go:234.16,237.3 2 0
apocapoc-api/internal/infrastructure/http/auth_handlers.go:239.2,239.80 1 0
apocapoc-api/internal/infrastructure/http/auth_handlers.go:239.80,242.3 2 0
apocapoc-api/internal/infrastructure/http/auth_handlers.go:244.2,244.88 1 0
apocapoc-api/internal/infrastructure/http/auth_handlers.go:244.89,245.3 0 0
apocapoc-api/internal/infrastructure/http/auth_handlers.go:247.2,251.4 1 0
apocapoc-api/internal/infrastructure/http/auth_handlers.go:266.71,268.61 2 0
apocapoc-api/internal/infrastructure/http/auth_handlers.go:268.61,271.3 2 0
apocapoc-api/internal/infrastructure/http/auth_handlers.go:273.2,278.16 3 0
apocapoc-api/internal/infrastructure/http/auth_handlers.go:278.16,279.32 1 0
apocapoc-api/internal/infrastructure/http/auth_handlers.go:279.32,282.4 2 0
apocapoc-api/internal/infrastructure/http/auth_handlers.go:283.3,283.36 1 0
apocapoc-api/internal/infrastructure/http/auth_handlers.go:283.36,286.4 2 0
apocapoc-api/internal/infrastructure/http/auth_handlers.go:287.3,288.9 2 0
apocapoc-api/internal/infrastructure/http/auth_handlers.go:291.2,293.4 1 0
apocapoc-api/internal/infrastructure/http/auth_middleware.go:15.82,16.46 1 6
apocapoc-api/internal/infrastructure/http/auth_middleware.go:16.46,17.72 1 6
apocapoc-api/internal/infrastructure/http/auth_middleware.go:17.72,19.24 2 19
apocapoc-api/internal/infrastructure/http/auth_middleware.go:19.24,22.5 2 1
apocapoc-api/internal/infrastructure/http/auth_middleware.go:24.4,25.47 2 18
apocapoc-api/internal/infrastructure/http/auth_middleware.go:25.47,28.5 2 0
apocapoc-api/internal/infrastructure/http/auth_middleware.go:30.4,32.18 3 18
apocapoc-api/internal/infrastructure/http/auth_middleware.go:32.18,35.5 2 0
apocapoc-api/internal/infrastructure/http/auth_middleware.go:37.4,38.41 2 18
apocapoc-api/internal/infrastructure/http/auth_middleware.go:43.63,46.2 2 33
apocapoc-api/internal/infrastructure/http/habit_handlers.go:38.18,50.2 1 3
apocapoc-api/internal/infrastructure/http/habit_handlers.go:65.77,67.61 2 4
apocapoc-api/internal/infrastructure/http/habit_handlers.go:67.61,70.3 2 0
apocapoc-api/internal/infrastructure/http/habit_handlers.go:72.2,73.9 2 4
apocapoc-api/internal/infrastructure/http/habit_handlers.go:73.9,76.3 2 0
apocapoc-api/internal/infrastructure/http/habit_handlers.go:78.2,92.16 3 4
apocapoc-api/internal/infrastructure/http/habit_handlers.go:92.16,93.36 1 0
apocapoc-api/internal/infrastructure/http/habit_handlers.go:93.36,96.4 2 0
apocapoc-api/internal/infrastructure/http/habit_handlers.go:97.3,98.9 2 0
apocapoc-api/internal/infrastructure/http/habit_handlers.go:101.2,101.70 1 4
apocapoc-api/internal/infrastructure/http/habit_handlers.go:114.79,116.9 2 2
apocapoc-api/internal/infrastructure/http/habit_handlers.go:116.9,119.3 2 0
apocapoc-api/internal/infrastructure/http/habit_handlers.go:121.2,126.16 3 2
apocapoc-api/internal/infrastructure/http/habit_handlers.go:126.16,129.3 2 0
apocapoc-api/internal/infrastructure/http/habit_handlers.go:131.2,132.31 2 2
apocapoc-api/internal/infrastructure/http/habit_handlers.go:132.31,143.3 1 1
apocapoc-api/internal/infrastructure/http/habit_handlers.go:145.2,145.41 1 2
apocapoc-api/internal/infrastructure/http/habit_handlers.go:161.78,165.9 3 3
apocapoc-api/internal/infrastructure/http/habit_handlers.go:165.9,168.3 2 0
apocapoc-api/internal/infrastructure/http/habit_handlers.go:170.2,176.16 3 3
apocapoc-api/internal/infrastructure/http/habit_handlers.go:176.16,177.32 1 1
apocapoc-api/internal/infrastructure/http/habit_handlers.go:177.32,180.4 2 0
apocapoc-api/internal/infrastructure/http/habit_handlers.go:181.3,181.36 1 1
apocapoc-api/internal/infrastructure/http/habit_handlers.go:181.36,184.4 2 1
apocapoc-api/internal/infrastructure/http/habit_handlers.go:185.3,186.9 2 0
apocapoc-api/internal/infrastructure/http/habit_handlers.go:189.2,200.41 2 2
apocapoc-api/internal/infrastructure/http/habit_handlers.go:219.77,223.9 3 1
apocapoc-api/internal/infrastructure/http/habit_handlers.go:223.9,226.3 2 0
apocapoc-api/internal/infrastructure/http/habit_handlers.go:228.2,229.61 2 1
apocapoc-api/internal/infrastructure/http/habit_handlers.go:229.61,232.3 2 0
apocapoc-api/internal/infrastructure/http/habit_handlers.go:234.2,245.65 2 1
apocapoc-api/internal/infrastructure/http/habit_handlers.go:245.65,246.32 1 0
apocapoc-api/internal/infrastructure/http/habit_handlers.go:246.32,249.4 2 0
apocapoc-api/internal/infrastructure/http/habit_handlers.go:250.3,250.36 1 0
apocapoc-api/internal/infrastructure/http/habit_handlers.go:250.36,253.4 2 0
apocapoc-api/internal/infrastructure/http/habit_handlers.go:254.3,254.36 1 0
apocapoc-api/internal/infrastructure/http/habit_handlers.go:254.36,257.4 2 0
apocapoc-api/internal/infrastructure/http/habit_handlers.go:258.3,259.9 2 0
apocapoc-api/internal/infrastructure/http/habit_handlers.go:262.2,262.71 1 1
apocapoc-api/internal/infrastructure/http/habit_handlers.go:278.78,282.9 3 1
apocapoc-api/internal/infrastructure/http/habit_handlers.go:282.9,285.3 2 0
apocapoc-api/internal/infrastructure/http/habit_handlers.go:287.2,292.66 2 1
apocapoc-api/internal/infrastructure/http/habit_handlers.go:292.66,293.32 1 0
apocapoc-api/internal/infrastructure/http/habit_handlers.go:293.32,296.4 2 0
apocapoc-api/internal/infrastructure/http/habit_handlers.go:297.3,297.36 1 0
apocapoc-api/internal/infrastructure/http/habit_handlers.go:297.36,300.4 2 0
apocapoc-api/internal/infrastructure/http/habit_handlers.go:301.3,302.9 2 0
apocapoc-api/internal/infrastructure/http/habit_handlers.go:305.2,305.72 1 1
apocapoc-api/internal/infrastructure/http/habit_handlers.go:326.81,330.9 3 3
apocapoc-api/internal/infrastructure/http/habit_handlers.go:330.9,333.3 2 0
apocapoc-api/internal/infrastructure/http/habit_handlers.go:335.2,340.57 2 3
apocapoc-api/internal/infrastructure/http/habit_handlers.go:340.57,342.17 2 0
apocapoc-api/internal/infrastructure/http/habit_handlers.go:342.17,345.4 2 0
apocapoc-api/internal/infrastructure/http/habit_handlers.go:346.3,346.21 1 0
apocapoc-api/internal/infrastructure/http/habit_handlers.go:349.2,349.51 1 3
apocapoc-api/internal/infrastructure/http/habit_handlers.go:349.51,351.17 2 0
apocapoc-api/internal/infrastructure/http/habit_handlers.go:351.17,354.4 2 0
apocapoc-api/internal/infrastructure/http/habit_handlers.go:355.3,355.17 1 0
apocapoc-api/internal/infrastructure/http/habit_handlers.go:358.2,359.42 2 3
apocapoc-api/internal/infrastructure/http/habit_handlers.go:359.42,361.3 1 0
apocapoc-api/internal/infrastructure/http/habit_handlers.go:363.2,364.42 2 3
apocapoc-api/internal/infrastructure/http/habit_handlers.go:364.42,366.3 1 3
apocapoc-api/internal/infrastructure/http/habit_handlers.go:366.8,366.32 1 0
apocapoc-api/internal/infrastructure/http/habit_handlers.go:366.32,368.3 1 0
apocapoc-api/internal/infrastructure/http/habit_handlers.go:370.2,370.57 1 3
apocapoc-api/internal/infrastructure/http/habit_handlers.go:370.57,372.29 2 3
apocapoc-api/internal/infrastructure/http/habit_handlers.go:372.29,375.4 2 0
apocapoc-api/internal/infrastructure/http/habit_handlers.go:376.3,376.20 1 3
apocapoc-api/internal/infrastructure/http/habit_handlers.go:377.8,377.31 1 0
apocapoc-api/internal/infrastructure/http/habit_handlers.go:377.31,379.3 1 0
apocapoc-api/internal/infrastructure/http/habit_handlers.go:381.2,381.60 1 3
apocapoc-api/internal/infrastructure/http/habit_handlers.go:381.60,383.45 2 3
apocapoc-api/internal/infrastructure/http/habit_handlers.go:383.45,386.4 2 0
apocapoc-api/internal/infrastructure/http/habit_handlers.go:387.3,387.22 1 3
apocapoc-api/internal/infrastructure/http/habit_handlers.go:388.8,388.31 1 0
apocapoc-api/internal/infrastructure/http/habit_handlers.go:388.31,390.3 1 0
apocapoc-api/internal/infrastructure/http/habit_handlers.go:392.2,392.44 1 3
apocapoc-api/internal/infrastructure/http/habit_handlers.go:392.44,395.3 2 0
apocapoc-api/internal/infrastructure/http/habit_handlers.go:397.2,398.16 2 3
apocapoc-api/internal/infrastructure/http/habit_handlers.go:398.16,399.32 1 0
apocapoc-api/internal/infrastructure/http/habit_handlers.go:399.32,402.4 2 0
apocapoc-api/internal/infrastructure/http/habit_handlers.go:403.3,403.36 1 0
apocapoc-api/internal/infrastructure/http/habit_handlers.go:403.36,406.4 2 0
apocapoc-api/internal/infrastructure/http/habit_handlers.go:407.3,408.9 2 0
apocapoc-api/internal/infrastructure/http/habit_handlers.go:411.2,412.39 2 3
apocapoc-api/internal/infrastructure/http/habit_handlers.go:412.39,420.3 1 2
apocapoc-api/internal/infrastructure/http/habit_handlers.go:422.2,429.41 2 3
apocapoc-api/internal/infrastructure/http/habit_handlers.go:442.81,444.9 2 0
apocapoc-api/internal/infrastructure/http/habit_handlers.go:444.9,447.3 2 0
apocapoc-api/internal/infrastructure/http/habit_handlers.go:449.2,458.16 4 0
apocapoc-api/internal/infrastructure/http/habit_handlers.go:458.16,461.3 2 0
apocapoc-api/internal/infrastructure/http/habit_handlers.go:463.2,464.31 2 0
apocapoc-api/internal/infrastructure/http/habit_handlers.go:464.31,474.3 1 0
apocapoc-api/internal/infrastructure/http/habit_handlers.go:476.2,476.41 1 0
apocapoc-api/internal/infrastructure/http/habit_handlers.go:494.75,498.61 3 3
apocapoc-api/internal/infrastructure/http/habit_handlers.go:498.61,501.3 2 0
apocapoc-api/internal/infrastructure/http/habit_handlers.go:503.2,504.16 2 3
apocapoc-api/internal/infrastructure/http/habit_handlers.go:504.16,507.3 2 0
apocapoc-api/internal/infrastructure/http/habit_handlers.go:509.2,515.63 2 3
apocapoc-api/internal/infrastructure/http/habit_handlers.go:515.63,516.37 1 1
apocapoc-api/internal/infrastructure/http/habit_handlers.go:516.37,519.4 2 1
apocapoc-api/internal/infrastructure/http/habit_handlers.go:520.3,520.32 1 0
apocapoc-api/internal/infrastructure/http/habit_handlers.go:520.32,523.4 2 0
apocapoc-api/internal/infrastructure/http/habit_handlers.go:524.3,525.9 2 0
apocapoc-api/internal/infrastructure/http/habit_handlers.go:528.2,528.70 1 2
apocapoc-api/internal/infrastructure/http/habit_handlers.go:546.77,551.9 4 1
apocapoc-api/internal/infrastructure/http/habit_handlers.go:551.9,554.3 2 0
apocapoc-api/internal/infrastructure/http/habit_handlers.go:556.2,557.16 2 1
apocapoc-api/internal/infrastructure/http/habit_handlers.go:557.16,560.3 2 0
apocapoc-api/internal/infrastructure/http/habit_handlers.go:562.2,568.65 2 1
apocapoc-api/internal/infrastructure/http/habit_handlers.go:568.65,569.32 1 0
apocapoc-api/internal/infrastructure/http/habit_handlers.go:569.32,572.4 2 0
apocapoc-api/internal/infrastructure/http/habit_handlers.go:573.3,573.36 1 0
apocapoc-api/internal/infrastructure/http/habit_handlers.go:573.36,576.4 2 0
apocapoc-api/internal/infrastructure/http/habit_handlers.go:577.3,578.9 2 0
apocapoc-api/internal/infrastructure/http/habit_handlers.go:581.2,581.72 1 1
apocapoc-api/internal/infrastructure/http/habit_handlers.go:584.71,588.2 3 32
apocapoc-api/internal/infrastructure/http/habit_handlers.go:590.70,592.2 1 8
apocapoc-api/internal/infrastructure/http/health_handlers.go:15.52,19.2 1 3
apocapoc-api/internal/infrastructure/http/health_handlers.go:35.73,40.36 4 0
apocapoc-api/internal/infrastructure/http/health_handlers.go:40.36,44.3 3 0
apocapoc-api/internal/infrastructure/http/health_handlers.go:46.2,55.38 4 0
apocapoc-api/internal/infrastructure/http/health_handlers.go:58.45,66.11 7 0
apocapoc-api/internal/infrastructure/http/health_handlers.go:66.11,68.3 1 0
apocapoc-api/internal/infrastructure/http/health_handlers.go:69.2,69.11 1 0
apocapoc-api/internal/infrastructure/http/health_handlers.go:69.11,71.3 1 0
apocapoc-api/internal/infrastructure/http/health_handlers.go:72.2,72.32 1 0
apocapoc-api/internal/infrastructure/http/health_handlers.go:75.48,76.16 1 0
apocapoc-api/internal/infrastructure/http/health_handlers.go:76.16,78.3 1 0
apocapoc-api/internal/infrastructure/http/health_handlers.go:79.2,79.32 1 0
apocapoc-api/internal/infrastructure/http/health_handlers.go:82.30,83.12 1 0
apocapoc-api/internal/infrastructure/http/health_handlers.go:83.12,85.3 1 0
apocapoc-api/internal/infrastructure/http/health_handlers.go:86.2,86.56 1 0
apocapoc-api/internal/infrastructure/http/rate_limit_middleware.go:13.130,17.63 1 6
apocapoc-api/internal/infrastructure/http/rate_limit_middleware.go:17.63,19.11 2 18
apocapoc-api/internal/infrastructure/http/rate_limit_middleware.go:19.11,21.5 1 0
apocapoc-api/internal/infrastructure/http/rate_limit_middleware.go:22.4,22.32 1 18
apocapoc-api/internal/infrastructure/http/rate_limit_middleware.go:24.74,28.4 3 0
apocapoc-api/internal/infrastructure/http/rate_limit_middleware.go:31.2,31.46 1 6
apocapoc-api/internal/infrastructure/http/rate_limit_middleware.go:31.46,32.72 1 6
apocapoc-api/internal/infrastructure/http/rate_limit_middleware.go:32.72,36.4 2 18
apocapoc-api/internal/infrastructure/http/router.go:18.194,30.69 5 3
apocapoc-api/internal/infrastructure/http/router.go:30.69,32.3 1 0
apocapoc-api/internal/infrastructure/http/router.go:33.2,39.45 3 3
apocapoc-api/internal/infrastructure/http/router.go:39.45,45.3 5 3
apocapoc-api/internal/infrastructure/http/router.go:47.2,47.47 1 3
apocapoc-api/internal/infrastructure/http/router.go:47.47,60.3 11 3
apocapoc-api/internal/infrastructure/http/router.go:62.2,62.46 1 3
apocapoc-api/internal/infrastructure/http/router.go:62.46,66.3 3 3
apocapoc-api/internal/infrastructure/http/router.go:68.2,68.10 1 3
apocapoc-api/internal/infrastructure/http/stats_handlers.go:18.18,22.2 1 3
apocapoc-api/internal/infrastructure/http/stats_handlers.go:37.79,41.9 3 0
apocapoc-api/internal/infrastructure/http/stats_handlers.go:41.9,44.3 2 0
apocapoc-api/internal/infrastructure/http/stats_handlers.go:46.2,52.16 3 0
apocapoc-api/internal/infrastructure/http/stats_handlers.go:52.16,53.32 1 0
apocapoc-api/internal/infrastructure/http/stats_handlers.go:53.32,56.4 2 0
apocapoc-api/internal/infrastructure/http/stats_handlers.go:57.3,57.36 1 0
apocapoc-api/internal/infrastructure/http/stats_handlers.go:57.36,60.4 2 0
apocapoc-api/internal/infrastructure/http/stats_handlers.go:61.3,62.9 2 0
apocapoc-api/internal/infrastructure/http/stats_handlers.go:65.2,65.38 1 0
+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.
+7 -1
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,14 +30,17 @@ require (
github.com/go-openapi/swag v0.19.15 // indirect
github.com/josharian/intern v1.0.0 // indirect
github.com/mailru/easyjson v0.7.6 // indirect
github.com/mattn/go-colorable v0.1.13 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/ncruces/go-strftime v0.1.9 // indirect
github.com/pkg/errors v0.9.1 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
github.com/swaggo/files v0.0.0-20220610200504-28940afbdbfe // indirect
golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b // indirect
golang.org/x/net v0.47.0 // indirect
golang.org/x/sys v0.38.0 // indirect
golang.org/x/tools v0.36.0 // indirect
golang.org/x/tools v0.38.0 // indirect
gopkg.in/alexcesaro/quotedprintable.v3 v3.0.0-20150716171945-2caba252f4dc // indirect
gopkg.in/yaml.v2 v2.4.0 // indirect
modernc.org/libc v1.66.10 // indirect
modernc.org/mathutil v1.7.1 // indirect
+25 -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,27 +79,35 @@ golang.org/x/crypto v0.45.0 h1:jMBrvKuj23MTlT0bQEOBcAE0mjg8mK9RXFhRH6nyF3Q=
golang.org/x/crypto v0.45.0/go.mod h1:XTGrrkGJve7CYK7J8PEww4aY7gM3qMCElcJQ8n8JdX4=
golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b h1:M2rDM6z3Fhozi9O7NWsxAkg/yqS/lQJ6PmkyIV3YP+o=
golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b/go.mod h1:3//PLf8L/X+8b4vuAfHzxeRUl04Adcb341+IGKfnqS8=
golang.org/x/mod v0.27.0 h1:kb+q2PyFnEADO2IEF935ehFUXlWiNjJWtRNgBLSfbxQ=
golang.org/x/mod v0.27.0/go.mod h1:rWI627Fq0DEoudcK+MBkNkCe0EetEaDSwJJkCcjpazc=
golang.org/x/mod v0.29.0 h1:HV8lRxZC4l2cr3Zq1LvtOsi/ThTgWnUk/y64QSs8GwA=
golang.org/x/mod v0.29.0/go.mod h1:NyhrlYXJ2H4eJiRy/WDBO6HMqZQ6q9nk4JzS3NuCK+w=
golang.org/x/net v0.0.0-20210805182204-aaa1db679c0d/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
golang.org/x/net v0.47.0 h1:Mx+4dIFzqraBXUugkia1OOvlD6LemFo1ALMHjrXDOhY=
golang.org/x/net v0.47.0/go.mod h1:/jNxtkgq5yWUGYkaZGqo27cfGZ1c5Nen03aYrrKpVRU=
golang.org/x/sync v0.16.0 h1:ycBJEhp9p4vXvUZNszeOq0kGTPghopOL8q0fq3vstxw=
golang.org/x/sync v0.16.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
golang.org/x/sync v0.18.0 h1:kr88TuHDroi+UVf+0hZnirlk8o8T+4MrK6mr60WkH/I=
golang.org/x/sync v0.18.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc=
golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.31.0 h1:aC8ghyu4JhP8VojJ2lEHBnochRno1sgL6nEi9WGFGMM=
golang.org/x/text v0.31.0/go.mod h1:tKRAlv61yKIjGGHX/4tP1LTbc13YSec1pxVEWXzfoeM=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.36.0 h1:kWS0uv/zsvHEle1LbV5LE8QujrxB3wfQyxHfhOk0Qkg=
golang.org/x/tools v0.36.0/go.mod h1:WBDiHKJK8YgLHlcQPYQzNCkUxUypCaa5ZegCVutKm+s=
golang.org/x/tools v0.38.0 h1:Hx2Xv8hISq8Lm16jvBZ2VQf+RLmbd7wVUsALibYI/IQ=
golang.org/x/tools v0.38.0/go.mod h1:yEsQ/d/YK8cjh0L6rZlY8tgtlKiBNTL14pGDJPJpYQs=
gopkg.in/alexcesaro/quotedprintable.v3 v3.0.0-20150716171945-2caba252f4dc h1:2gGKlE2+asNV9m7xrywl36YYNnBG5ZQ0r/BOOxqPpmk=
gopkg.in/alexcesaro/quotedprintable.v3 v3.0.0-20150716171945-2caba252f4dc/go.mod h1:m7x9LTH6d71AHyAX77c9yqWCCa3UKHcVEj9y7hAtKDk=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f h1:BLraFXnmrev5lT+xlilqcH8XK9/i0At2xKjWk4p6zsU=
gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/mail.v2 v2.3.1 h1:WYFn/oANrAGP2C0dcV6/pbkPzv8yGzqTjPmTeO7qoXk=
gopkg.in/mail.v2 v2.3.1/go.mod h1:htwXN1Qh09vZJ1NVKxQqHPBaCBbzKhp5GzuJEA4VJWw=
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
@@ -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 {
@@ -0,0 +1,39 @@
package commands
import (
"context"
"apocapoc-api/internal/domain/repositories"
"apocapoc-api/internal/shared/errors"
)
type DeleteUserCommand struct {
UserID string
}
type DeleteUserHandler struct {
userRepo repositories.UserRepository
}
func NewDeleteUserHandler(userRepo repositories.UserRepository) *DeleteUserHandler {
return &DeleteUserHandler{
userRepo: userRepo,
}
}
func (h *DeleteUserHandler) Handle(ctx context.Context, cmd DeleteUserCommand) error {
if cmd.UserID == "" {
return errors.ErrInvalidInput
}
user, err := h.userRepo.FindByID(ctx, cmd.UserID)
if err != nil {
return errors.ErrNotFound
}
if err := h.userRepo.Delete(ctx, user.ID); err != nil {
return err
}
return nil
}
@@ -0,0 +1,152 @@
package commands
import (
"apocapoc-api/internal/domain/repositories"
"apocapoc-api/internal/shared/pagination"
"context"
"testing"
"apocapoc-api/internal/domain/entities"
"apocapoc-api/internal/shared/errors"
)
type mockDeleteUserRepo struct {
findByIDFunc func(ctx context.Context, id string) (*entities.User, error)
deleteFunc func(ctx context.Context, id string) error
}
func (m *mockDeleteUserRepo) FindByID(ctx context.Context, id string) (*entities.User, error) {
if m.findByIDFunc != nil {
return m.findByIDFunc(ctx, id)
}
return nil, errors.ErrNotFound
}
func (m *mockDeleteUserRepo) FindByEmail(ctx context.Context, email string) (*entities.User, error) {
return nil, errors.ErrNotFound
}
func (m *mockDeleteUserRepo) FindByVerificationToken(ctx context.Context, token string) (*entities.User, error) {
return nil, errors.ErrNotFound
}
func (m *mockDeleteUserRepo) Create(ctx context.Context, user *entities.User) error {
return nil
}
func (m *mockDeleteUserRepo) Update(ctx context.Context, user *entities.User) error {
return nil
}
func (m *mockDeleteUserRepo) Delete(ctx context.Context, id string) error {
if m.deleteFunc != nil {
return m.deleteFunc(ctx, id)
}
return nil
}
func TestDeleteUserHandler_Success(t *testing.T) {
var deletedID string
repo := &mockDeleteUserRepo{
findByIDFunc: func(ctx context.Context, id string) (*entities.User, error) {
user := entities.NewUser("test@example.com", "hashedPassword")
user.ID = id
return user, nil
},
deleteFunc: func(ctx context.Context, id string) error {
deletedID = id
return nil
},
}
handler := NewDeleteUserHandler(repo)
cmd := DeleteUserCommand{
UserID: "user-123",
}
err := handler.Handle(context.Background(), cmd)
if err != nil {
t.Fatalf("Handle() unexpected error = %v", err)
}
if deletedID != "user-123" {
t.Errorf("deletedID = %v, want %v", deletedID, "user-123")
}
}
func TestDeleteUserHandler_EmptyUserID(t *testing.T) {
repo := &mockDeleteUserRepo{}
handler := NewDeleteUserHandler(repo)
cmd := DeleteUserCommand{
UserID: "",
}
err := handler.Handle(context.Background(), cmd)
if err != errors.ErrInvalidInput {
t.Errorf("Handle() error = %v, want %v", err, errors.ErrInvalidInput)
}
}
func TestDeleteUserHandler_UserNotFound(t *testing.T) {
repo := &mockDeleteUserRepo{
findByIDFunc: func(ctx context.Context, id string) (*entities.User, error) {
return nil, errors.ErrNotFound
},
}
handler := NewDeleteUserHandler(repo)
cmd := DeleteUserCommand{
UserID: "non-existent-user",
}
err := handler.Handle(context.Background(), cmd)
if err != errors.ErrNotFound {
t.Errorf("Handle() error = %v, want %v", err, errors.ErrNotFound)
}
}
func TestDeleteUserHandler_DeleteError(t *testing.T) {
customError := errors.ErrNotFound
repo := &mockDeleteUserRepo{
findByIDFunc: func(ctx context.Context, id string) (*entities.User, error) {
user := entities.NewUser("test@example.com", "hashedPassword")
user.ID = id
return user, nil
},
deleteFunc: func(ctx context.Context, id string) error {
return customError
},
}
handler := NewDeleteUserHandler(repo)
cmd := DeleteUserCommand{
UserID: "user-123",
}
err := handler.Handle(context.Background(), cmd)
if err != customError {
t.Errorf("Handle() error = %v, want %v", err, customError)
}
}
func (m *mockDeleteUserRepo) FindActiveByUserIDWithPagination(ctx context.Context, userID string, params pagination.Params) ([]*entities.Habit, error) {
return nil, nil
}
func (m *mockDeleteUserRepo) CountActiveByUserID(ctx context.Context, userID string) (int, error) {
return 0, nil
}
func (m *mockDeleteUserRepo) FindByUserIDFiltered(ctx context.Context, userID string, filter repositories.HabitFilter, paginationParams *pagination.Params) ([]*entities.Habit, error) {
return nil, nil
}
func (m *mockDeleteUserRepo) CountByUserIDFiltered(ctx context.Context, userID string, filter repositories.HabitFilter) (int, error) {
return 0, nil
}
@@ -1,6 +1,8 @@
package commands
import (
"apocapoc-api/internal/domain/repositories"
"apocapoc-api/internal/shared/pagination"
"context"
"testing"
"time"
@@ -38,6 +40,10 @@ func (m *mockEntryRepo) FindByHabitIDAndDateRange(ctx context.Context, habitID s
return nil, nil
}
func (m *mockEntryRepo) FindByUserID(ctx context.Context, userID string) ([]*entities.HabitEntry, error) {
return nil, nil
}
func (m *mockEntryRepo) FindPendingByHabitID(ctx context.Context, habitID string, beforeDate time.Time) ([]*entities.HabitEntry, error) {
return nil, nil
}
@@ -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
}
+96 -14
View File
@@ -2,6 +2,10 @@ package commands
import (
"context"
"crypto/rand"
"encoding/hex"
"fmt"
"time"
"apocapoc-api/internal/domain/entities"
"apocapoc-api/internal/domain/repositories"
@@ -13,41 +17,119 @@ import (
type RegisterUserCommand struct {
Email string
Password string
Timezone string
}
type RegisterUserResult struct {
UserID string
EmailVerificationRequired bool
}
type RegisterUserHandler struct {
userRepo repositories.UserRepository
passwordHasher services.PasswordHasher
userRepo repositories.UserRepository
passwordHasher services.PasswordHasher
emailService services.EmailService
appURL string
registrationMode string
sendWelcomeEmail bool
}
func NewRegisterUserHandler(userRepo repositories.UserRepository, passwordHasher services.PasswordHasher) *RegisterUserHandler {
func NewRegisterUserHandler(
userRepo repositories.UserRepository,
passwordHasher services.PasswordHasher,
emailService services.EmailService,
appURL string,
registrationMode string,
sendWelcomeEmail bool,
) *RegisterUserHandler {
return &RegisterUserHandler{
userRepo: userRepo,
passwordHasher: passwordHasher,
userRepo: userRepo,
passwordHasher: passwordHasher,
emailService: emailService,
appURL: appURL,
registrationMode: registrationMode,
sendWelcomeEmail: sendWelcomeEmail,
}
}
func (h *RegisterUserHandler) Handle(ctx context.Context, cmd RegisterUserCommand) (string, error) {
if err := validation.ValidateRegistration(cmd.Email, cmd.Password, cmd.Timezone); err != nil {
return "", errors.ErrInvalidInput
func (h *RegisterUserHandler) Handle(ctx context.Context, cmd RegisterUserCommand) (*RegisterUserResult, error) {
if h.registrationMode == "closed" {
return nil, errors.ErrRegistrationClosed
}
if err := validation.ValidateRegistration(cmd.Email, cmd.Password); err != nil {
return nil, fmt.Errorf("%w: %v", errors.ErrInvalidInput, err)
}
existing, _ := h.userRepo.FindByEmail(ctx, cmd.Email)
if existing != nil {
return "", errors.ErrAlreadyExists
return nil, errors.ErrAlreadyExists
}
hashedPassword, err := h.passwordHasher.Hash(cmd.Password)
if err != nil {
return "", err
return nil, err
}
user := entities.NewUser(cmd.Email, hashedPassword, cmd.Timezone)
user := entities.NewUser(cmd.Email, hashedPassword)
emailVerificationRequired := false
if h.emailService != nil {
token, err := h.generateVerificationToken()
if err != nil {
return nil, fmt.Errorf("failed to generate verification token: %w", err)
}
expiry := time.Now().Add(24 * time.Hour)
user.EmailVerificationToken = &token
user.EmailVerificationExpiry = &expiry
emailVerificationRequired = true
if err := h.sendVerificationEmail(user); err != nil {
return nil, fmt.Errorf("failed to send verification email: %w", err)
}
} else {
user.EmailVerified = true
}
if err := h.userRepo.Create(ctx, user); err != nil {
return "", err
return nil, err
}
return user.ID, nil
return &RegisterUserResult{
UserID: user.ID,
EmailVerificationRequired: emailVerificationRequired,
}, nil
}
func (h *RegisterUserHandler) generateVerificationToken() (string, error) {
bytes := make([]byte, 32)
if _, err := rand.Read(bytes); err != nil {
return "", err
}
return hex.EncodeToString(bytes), nil
}
func (h *RegisterUserHandler) sendVerificationEmail(user *entities.User) error {
if user.EmailVerificationToken == nil {
return nil
}
verificationLink := fmt.Sprintf("%s/verify-email?token=%s", h.appURL, *user.EmailVerificationToken)
emailBody := fmt.Sprintf(`
<h2>Welcome! Please verify your email</h2>
<p>Thank you for registering. Please click the link below to verify your email address:</p>
<p><a href="%s">Verify Email</a></p>
<p>This link will expire in 24 hours.</p>
<p>If you didn't create an account, you can safely ignore this email.</p>
`, verificationLink)
message := services.EmailMessage{
To: user.Email,
Subject: "Verify your email address",
Body: emailBody,
IsHTML: true,
}
return h.emailService.Send(message)
}
@@ -1,6 +1,8 @@
package commands
import (
"apocapoc-api/internal/domain/repositories"
"apocapoc-api/internal/shared/pagination"
"context"
"errors"
"testing"
@@ -25,6 +27,10 @@ func (m *mockUserRepo) FindByID(ctx context.Context, id string) (*entities.User,
return nil, nil
}
func (m *mockUserRepo) FindByVerificationToken(ctx context.Context, token string) (*entities.User, error) {
return nil, appErrors.ErrNotFound
}
func (m *mockUserRepo) Create(ctx context.Context, user *entities.User) error {
if m.createFunc != nil {
return m.createFunc(ctx, user)
@@ -36,6 +42,10 @@ func (m *mockUserRepo) Update(ctx context.Context, user *entities.User) error {
return nil
}
func (m *mockUserRepo) Delete(ctx context.Context, id string) error {
return nil
}
type mockPasswordHasher struct {
hashFunc func(password string) (string, error)
}
@@ -61,23 +71,26 @@ func TestRegisterUserHandler_Success(t *testing.T) {
},
}
hasher := &mockPasswordHasher{}
handler := NewRegisterUserHandler(repo, hasher)
handler := NewRegisterUserHandler(repo, hasher, nil, "", "open", false)
cmd := RegisterUserCommand{
Email: "test@example.com",
Password: "Secure123!",
Timezone: "UTC",
}
userID, err := handler.Handle(context.Background(), cmd)
result, err := handler.Handle(context.Background(), cmd)
if err != nil {
t.Fatalf("expected no error, got %v", err)
}
if userID == "" {
if result.UserID == "" {
t.Error("expected user ID, got empty string")
}
if result.EmailVerificationRequired {
t.Error("expected email verification to not be required when emailService is nil")
}
if createdUser == nil {
t.Fatal("expected user to be created")
}
@@ -85,16 +98,12 @@ func TestRegisterUserHandler_Success(t *testing.T) {
if createdUser.Email != cmd.Email {
t.Errorf("expected email %q, got %q", cmd.Email, createdUser.Email)
}
if createdUser.Timezone != cmd.Timezone {
t.Errorf("expected timezone %q, got %q", cmd.Timezone, createdUser.Timezone)
}
}
func TestRegisterUserHandler_InvalidEmail(t *testing.T) {
repo := &mockUserRepo{}
hasher := &mockPasswordHasher{}
handler := NewRegisterUserHandler(repo, hasher)
handler := NewRegisterUserHandler(repo, hasher, nil, "", "open", false)
tests := []struct {
name string
@@ -113,11 +122,10 @@ func TestRegisterUserHandler_InvalidEmail(t *testing.T) {
cmd := RegisterUserCommand{
Email: tt.email,
Password: "Secure123!",
Timezone: "UTC",
}
_, err := handler.Handle(context.Background(), cmd)
if err != appErrors.ErrInvalidInput {
if !errors.Is(err, appErrors.ErrInvalidInput) {
t.Errorf("expected ErrInvalidInput, got %v", err)
}
})
@@ -127,7 +135,7 @@ func TestRegisterUserHandler_InvalidEmail(t *testing.T) {
func TestRegisterUserHandler_InvalidPassword(t *testing.T) {
repo := &mockUserRepo{}
hasher := &mockPasswordHasher{}
handler := NewRegisterUserHandler(repo, hasher)
handler := NewRegisterUserHandler(repo, hasher, nil, "", "open", false)
tests := []struct {
name string
@@ -148,42 +156,10 @@ func TestRegisterUserHandler_InvalidPassword(t *testing.T) {
cmd := RegisterUserCommand{
Email: "test@example.com",
Password: tt.password,
Timezone: "UTC",
}
_, err := handler.Handle(context.Background(), cmd)
if err != appErrors.ErrInvalidInput {
t.Errorf("expected ErrInvalidInput, got %v", err)
}
})
}
}
func TestRegisterUserHandler_InvalidTimezone(t *testing.T) {
repo := &mockUserRepo{}
hasher := &mockPasswordHasher{}
handler := NewRegisterUserHandler(repo, hasher)
tests := []struct {
name string
timezone string
}{
{"empty timezone", ""},
{"invalid timezone", "InvalidTimezone"},
{"numeric format", "GMT+1"},
{"partial timezone", "America"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
cmd := RegisterUserCommand{
Email: "test@example.com",
Password: "Secure123!",
Timezone: tt.timezone,
}
_, err := handler.Handle(context.Background(), cmd)
if err != appErrors.ErrInvalidInput {
if !errors.Is(err, appErrors.ErrInvalidInput) {
t.Errorf("expected ErrInvalidInput, got %v", err)
}
})
@@ -191,23 +167,22 @@ func TestRegisterUserHandler_InvalidTimezone(t *testing.T) {
}
func TestRegisterUserHandler_EmailAlreadyExists(t *testing.T) {
existingUser := entities.NewUser("test@example.com", "hashed", "UTC")
existingUser := entities.NewUser("test@example.com", "hashed")
repo := &mockUserRepo{
findByEmailFunc: func(ctx context.Context, email string) (*entities.User, error) {
return existingUser, nil
},
}
hasher := &mockPasswordHasher{}
handler := NewRegisterUserHandler(repo, hasher)
handler := NewRegisterUserHandler(repo, hasher, nil, "", "open", false)
cmd := RegisterUserCommand{
Email: "test@example.com",
Password: "Secure123!",
Timezone: "UTC",
}
_, err := handler.Handle(context.Background(), cmd)
if err != appErrors.ErrAlreadyExists {
if !errors.Is(err, appErrors.ErrAlreadyExists) {
t.Errorf("expected ErrAlreadyExists, got %v", err)
}
}
@@ -220,12 +195,11 @@ func TestRegisterUserHandler_PasswordHashingError(t *testing.T) {
return "", expectedErr
},
}
handler := NewRegisterUserHandler(repo, hasher)
handler := NewRegisterUserHandler(repo, hasher, nil, "", "open", false)
cmd := RegisterUserCommand{
Email: "test@example.com",
Password: "Secure123!",
Timezone: "UTC",
}
_, err := handler.Handle(context.Background(), cmd)
@@ -242,12 +216,11 @@ func TestRegisterUserHandler_RepositoryError(t *testing.T) {
},
}
hasher := &mockPasswordHasher{}
handler := NewRegisterUserHandler(repo, hasher)
handler := NewRegisterUserHandler(repo, hasher, nil, "", "open", false)
cmd := RegisterUserCommand{
Email: "test@example.com",
Password: "Secure123!",
Timezone: "UTC",
}
_, err := handler.Handle(context.Background(), cmd)
@@ -259,7 +232,7 @@ func TestRegisterUserHandler_RepositoryError(t *testing.T) {
func TestRegisterUserHandler_EdgeCases(t *testing.T) {
repo := &mockUserRepo{}
hasher := &mockPasswordHasher{}
handler := NewRegisterUserHandler(repo, hasher)
handler := NewRegisterUserHandler(repo, hasher, nil, "", "open", false)
tests := []struct {
name string
@@ -271,7 +244,6 @@ func TestRegisterUserHandler_EdgeCases(t *testing.T) {
RegisterUserCommand{
Email: "user+tag@example.com",
Password: "Secure123!",
Timezone: "UTC",
},
nil,
},
@@ -280,16 +252,6 @@ func TestRegisterUserHandler_EdgeCases(t *testing.T) {
RegisterUserCommand{
Email: "user@mail.example.com",
Password: "Secure123!",
Timezone: "UTC",
},
nil,
},
{
"complex timezone",
RegisterUserCommand{
Email: "user@example.com",
Password: "Secure123!",
Timezone: "America/Argentina/Buenos_Aires",
},
nil,
},
@@ -298,7 +260,6 @@ func TestRegisterUserHandler_EdgeCases(t *testing.T) {
RegisterUserCommand{
Email: "user@example.com",
Password: "Sëcure123!",
Timezone: "UTC",
},
nil,
},
@@ -307,7 +268,6 @@ func TestRegisterUserHandler_EdgeCases(t *testing.T) {
RegisterUserCommand{
Email: "user@example.com",
Password: "ValidP@ss1" + string(make([]byte, 100)),
Timezone: "UTC",
},
nil,
},
@@ -322,3 +282,34 @@ func TestRegisterUserHandler_EdgeCases(t *testing.T) {
})
}
}
func TestRegisterUserHandler_ClosedRegistration(t *testing.T) {
repo := &mockUserRepo{}
hasher := &mockPasswordHasher{}
handler := NewRegisterUserHandler(repo, hasher, nil, "", "closed", false)
cmd := RegisterUserCommand{
Email: "test@example.com",
Password: "Secure123!",
}
_, err := handler.Handle(context.Background(), cmd)
if !errors.Is(err, appErrors.ErrRegistrationClosed) {
t.Errorf("expected ErrRegistrationClosed, got %v", err)
}
}
func (m *mockUserRepo) FindActiveByUserIDWithPagination(ctx context.Context, userID string, params pagination.Params) ([]*entities.Habit, error) {
return nil, nil
}
func (m *mockUserRepo) CountActiveByUserID(ctx context.Context, userID string) (int, error) {
return 0, nil
}
func (m *mockUserRepo) FindByUserIDFiltered(ctx context.Context, userID string, filter repositories.HabitFilter, paginationParams *pagination.Params) ([]*entities.Habit, error) {
return nil, nil
}
func (m *mockUserRepo) CountByUserIDFiltered(ctx context.Context, userID string, filter repositories.HabitFilter) (int, error) {
return 0, nil
}
@@ -0,0 +1,97 @@
package commands
import (
"context"
"crypto/rand"
"encoding/hex"
"fmt"
"time"
"apocapoc-api/internal/domain/entities"
"apocapoc-api/internal/domain/repositories"
"apocapoc-api/internal/domain/services"
"apocapoc-api/internal/shared/errors"
)
type RequestPasswordResetCommand struct {
Email string
}
type RequestPasswordResetHandler struct {
userRepo repositories.UserRepository
passwordResetTokenRepo repositories.PasswordResetTokenRepository
emailService services.EmailService
appURL string
}
func NewRequestPasswordResetHandler(
userRepo repositories.UserRepository,
passwordResetTokenRepo repositories.PasswordResetTokenRepository,
emailService services.EmailService,
appURL string,
) *RequestPasswordResetHandler {
return &RequestPasswordResetHandler{
userRepo: userRepo,
passwordResetTokenRepo: passwordResetTokenRepo,
emailService: emailService,
appURL: appURL,
}
}
func (h *RequestPasswordResetHandler) Handle(ctx context.Context, cmd RequestPasswordResetCommand) error {
if cmd.Email == "" {
return errors.ErrInvalidInput
}
user, err := h.userRepo.FindByEmail(ctx, cmd.Email)
if err != nil {
return errors.ErrNotFound
}
if !user.EmailVerified {
return errors.ErrEmailNotVerified
}
tokenStr, err := generatePasswordResetToken()
if err != nil {
return fmt.Errorf("failed to generate reset token: %w", err)
}
expiresAt := time.Now().Add(1 * time.Hour)
resetToken := entities.NewPasswordResetToken(user.ID, tokenStr, expiresAt)
if err := h.passwordResetTokenRepo.Create(ctx, resetToken); err != nil {
return fmt.Errorf("failed to save reset token: %w", err)
}
resetLink := fmt.Sprintf("%s/reset-password?token=%s", h.appURL, tokenStr)
emailBody := fmt.Sprintf(`
<h2>Password Reset Request</h2>
<p>You requested to reset your password. Click the link below to reset it:</p>
<p><a href="%s">Reset Password</a></p>
<p>This link will expire in 1 hour.</p>
<p>If you didn't request this, you can safely ignore this email.</p>
`, resetLink)
message := services.EmailMessage{
To: user.Email,
Subject: "Password Reset Request",
Body: emailBody,
IsHTML: true,
}
if err := h.emailService.Send(message); err != nil {
return fmt.Errorf("failed to send reset email: %w", err)
}
return nil
}
func generatePasswordResetToken() (string, error) {
bytes := make([]byte, 32)
if _, err := rand.Read(bytes); err != nil {
return "", err
}
return hex.EncodeToString(bytes), nil
}
@@ -0,0 +1,374 @@
package commands
import (
"apocapoc-api/internal/domain/repositories"
"apocapoc-api/internal/shared/pagination"
"context"
"testing"
"time"
"apocapoc-api/internal/domain/entities"
"apocapoc-api/internal/domain/services"
"apocapoc-api/internal/shared/errors"
)
type mockRequestResetUserRepo struct {
findByEmailFunc func(ctx context.Context, email string) (*entities.User, error)
users map[string]*entities.User
}
func (m *mockRequestResetUserRepo) FindByEmail(ctx context.Context, email string) (*entities.User, error) {
if m.findByEmailFunc != nil {
return m.findByEmailFunc(ctx, email)
}
if user, ok := m.users[email]; ok {
return user, nil
}
return nil, errors.ErrNotFound
}
func (m *mockRequestResetUserRepo) FindByID(ctx context.Context, id string) (*entities.User, error) {
return nil, errors.ErrNotFound
}
func (m *mockRequestResetUserRepo) FindByVerificationToken(ctx context.Context, token string) (*entities.User, error) {
return nil, errors.ErrNotFound
}
func (m *mockRequestResetUserRepo) Create(ctx context.Context, user *entities.User) error {
return nil
}
func (m *mockRequestResetUserRepo) Update(ctx context.Context, user *entities.User) error {
return nil
}
func (m *mockRequestResetUserRepo) Delete(ctx context.Context, id string) error {
return nil
}
type mockRequestResetTokenRepo struct {
createFunc func(ctx context.Context, token *entities.PasswordResetToken) error
tokens []*entities.PasswordResetToken
}
func (m *mockRequestResetTokenRepo) Create(ctx context.Context, token *entities.PasswordResetToken) error {
if m.createFunc != nil {
return m.createFunc(ctx, token)
}
m.tokens = append(m.tokens, token)
return nil
}
func (m *mockRequestResetTokenRepo) FindByToken(ctx context.Context, token string) (*entities.PasswordResetToken, error) {
return nil, errors.ErrNotFound
}
func (m *mockRequestResetTokenRepo) Update(ctx context.Context, token *entities.PasswordResetToken) error {
return nil
}
func (m *mockRequestResetTokenRepo) DeleteExpired(ctx context.Context) error {
return nil
}
type mockRequestResetEmailService struct {
sendFunc func(message services.EmailMessage) error
sentMessages []services.EmailMessage
}
func (m *mockRequestResetEmailService) Send(message services.EmailMessage) error {
if m.sendFunc != nil {
return m.sendFunc(message)
}
m.sentMessages = append(m.sentMessages, message)
return nil
}
func (m *mockRequestResetEmailService) HealthCheck() error {
return nil
}
func TestRequestPasswordResetHandler_Success(t *testing.T) {
user := entities.NewUser("test@example.com", "hash")
user.ID = "user-123"
user.EmailVerified = true
userRepo := &mockRequestResetUserRepo{
users: map[string]*entities.User{
user.Email: user,
},
}
tokenRepo := &mockRequestResetTokenRepo{
tokens: []*entities.PasswordResetToken{},
}
emailService := &mockRequestResetEmailService{
sentMessages: []services.EmailMessage{},
}
handler := NewRequestPasswordResetHandler(userRepo, tokenRepo, emailService, "http://localhost:8080")
cmd := RequestPasswordResetCommand{
Email: user.Email,
}
err := handler.Handle(context.Background(), cmd)
if err != nil {
t.Fatalf("Handle() unexpected error = %v", err)
}
if len(tokenRepo.tokens) != 1 {
t.Fatalf("Expected 1 token created, got %d", len(tokenRepo.tokens))
}
createdToken := tokenRepo.tokens[0]
if createdToken.UserID != user.ID {
t.Errorf("Token UserID = %v, want %v", createdToken.UserID, user.ID)
}
if createdToken.Token == "" {
t.Error("Token string is empty")
}
if createdToken.ExpiresAt.Before(time.Now()) {
t.Error("Token already expired")
}
expectedExpiry := time.Now().Add(1 * time.Hour)
diff := createdToken.ExpiresAt.Sub(expectedExpiry)
if diff > time.Minute || diff < -time.Minute {
t.Errorf("Token expiry = %v, expected around %v", createdToken.ExpiresAt, expectedExpiry)
}
if len(emailService.sentMessages) != 1 {
t.Fatalf("Expected 1 email sent, got %d", len(emailService.sentMessages))
}
sentEmail := emailService.sentMessages[0]
if sentEmail.To != user.Email {
t.Errorf("Email To = %v, want %v", sentEmail.To, user.Email)
}
if sentEmail.Subject != "Password Reset Request" {
t.Errorf("Email Subject = %v, want %v", sentEmail.Subject, "Password Reset Request")
}
if !sentEmail.IsHTML {
t.Error("Email should be HTML")
}
if sentEmail.Body == "" {
t.Error("Email body is empty")
}
}
func TestRequestPasswordResetHandler_EmptyEmail(t *testing.T) {
handler := NewRequestPasswordResetHandler(
&mockRequestResetUserRepo{users: make(map[string]*entities.User)},
&mockRequestResetTokenRepo{tokens: []*entities.PasswordResetToken{}},
&mockRequestResetEmailService{},
"http://localhost:8080",
)
cmd := RequestPasswordResetCommand{
Email: "",
}
err := handler.Handle(context.Background(), cmd)
if err != errors.ErrInvalidInput {
t.Errorf("Handle() error = %v, want %v", err, errors.ErrInvalidInput)
}
}
func TestRequestPasswordResetHandler_UserNotFound(t *testing.T) {
handler := NewRequestPasswordResetHandler(
&mockRequestResetUserRepo{users: make(map[string]*entities.User)},
&mockRequestResetTokenRepo{tokens: []*entities.PasswordResetToken{}},
&mockRequestResetEmailService{},
"http://localhost:8080",
)
cmd := RequestPasswordResetCommand{
Email: "nonexistent@example.com",
}
err := handler.Handle(context.Background(), cmd)
if err != errors.ErrNotFound {
t.Errorf("Handle() error = %v, want %v", err, errors.ErrNotFound)
}
}
func TestRequestPasswordResetHandler_EmailNotVerified(t *testing.T) {
user := entities.NewUser("test@example.com", "hash")
user.ID = "user-123"
user.EmailVerified = false
userRepo := &mockRequestResetUserRepo{
users: map[string]*entities.User{
user.Email: user,
},
}
handler := NewRequestPasswordResetHandler(
userRepo,
&mockRequestResetTokenRepo{tokens: []*entities.PasswordResetToken{}},
&mockRequestResetEmailService{},
"http://localhost:8080",
)
cmd := RequestPasswordResetCommand{
Email: user.Email,
}
err := handler.Handle(context.Background(), cmd)
if err != errors.ErrEmailNotVerified {
t.Errorf("Handle() error = %v, want %v", err, errors.ErrEmailNotVerified)
}
}
func TestRequestPasswordResetHandler_TokenCreationFailure(t *testing.T) {
user := entities.NewUser("test@example.com", "hash")
user.ID = "user-123"
user.EmailVerified = true
userRepo := &mockRequestResetUserRepo{
users: map[string]*entities.User{
user.Email: user,
},
}
tokenRepo := &mockRequestResetTokenRepo{
createFunc: func(ctx context.Context, token *entities.PasswordResetToken) error {
return errors.ErrInvalidInput
},
}
emailService := &mockRequestResetEmailService{}
handler := NewRequestPasswordResetHandler(userRepo, tokenRepo, emailService, "http://localhost:8080")
cmd := RequestPasswordResetCommand{
Email: user.Email,
}
err := handler.Handle(context.Background(), cmd)
if err == nil {
t.Fatal("Handle() expected error but got nil")
}
if len(emailService.sentMessages) != 0 {
t.Error("Email should not be sent if token creation fails")
}
}
func TestRequestPasswordResetHandler_EmailSendFailure(t *testing.T) {
user := entities.NewUser("test@example.com", "hash")
user.ID = "user-123"
user.EmailVerified = true
userRepo := &mockRequestResetUserRepo{
users: map[string]*entities.User{
user.Email: user,
},
}
tokenRepo := &mockRequestResetTokenRepo{
tokens: []*entities.PasswordResetToken{},
}
emailService := &mockRequestResetEmailService{
sendFunc: func(message services.EmailMessage) error {
return errors.ErrInvalidInput
},
}
handler := NewRequestPasswordResetHandler(userRepo, tokenRepo, emailService, "http://localhost:8080")
cmd := RequestPasswordResetCommand{
Email: user.Email,
}
err := handler.Handle(context.Background(), cmd)
if err == nil {
t.Fatal("Handle() expected error but got nil")
}
if len(tokenRepo.tokens) != 1 {
t.Error("Token should be created even if email sending fails")
}
}
func TestRequestPasswordResetHandler_ResetLinkFormat(t *testing.T) {
user := entities.NewUser("test@example.com", "hash")
user.ID = "user-123"
user.EmailVerified = true
userRepo := &mockRequestResetUserRepo{
users: map[string]*entities.User{
user.Email: user,
},
}
tokenRepo := &mockRequestResetTokenRepo{
tokens: []*entities.PasswordResetToken{},
}
emailService := &mockRequestResetEmailService{
sentMessages: []services.EmailMessage{},
}
appURL := "https://myapp.com"
handler := NewRequestPasswordResetHandler(userRepo, tokenRepo, emailService, appURL)
cmd := RequestPasswordResetCommand{
Email: user.Email,
}
err := handler.Handle(context.Background(), cmd)
if err != nil {
t.Fatalf("Handle() unexpected error = %v", err)
}
if len(emailService.sentMessages) != 1 {
t.Fatal("Expected 1 email sent")
}
sentEmail := emailService.sentMessages[0]
if sentEmail.Body == "" {
t.Fatal("Email body is empty")
}
}
func (m *mockRequestResetTokenRepo) FindActiveByUserIDWithPagination(ctx context.Context, userID string, params pagination.Params) ([]*entities.Habit, error) {
return nil, nil
}
func (m *mockRequestResetTokenRepo) CountActiveByUserID(ctx context.Context, userID string) (int, error) {
return 0, nil
}
func (m *mockRequestResetUserRepo) FindActiveByUserIDWithPagination(ctx context.Context, userID string, params pagination.Params) ([]*entities.Habit, error) {
return nil, nil
}
func (m *mockRequestResetUserRepo) CountActiveByUserID(ctx context.Context, userID string) (int, error) {
return 0, nil
}
func (m *mockRequestResetTokenRepo) FindByUserIDFiltered(ctx context.Context, userID string, filter repositories.HabitFilter, paginationParams *pagination.Params) ([]*entities.Habit, error) {
return nil, nil
}
func (m *mockRequestResetTokenRepo) CountByUserIDFiltered(ctx context.Context, userID string, filter repositories.HabitFilter) (int, error) {
return 0, nil
}
func (m *mockRequestResetUserRepo) FindByUserIDFiltered(ctx context.Context, userID string, filter repositories.HabitFilter, paginationParams *pagination.Params) ([]*entities.Habit, error) {
return nil, nil
}
func (m *mockRequestResetUserRepo) CountByUserIDFiltered(ctx context.Context, userID string, filter repositories.HabitFilter) (int, error) {
return 0, nil
}
@@ -0,0 +1,95 @@
package commands
import (
"context"
"crypto/rand"
"encoding/hex"
"fmt"
"time"
"apocapoc-api/internal/domain/repositories"
"apocapoc-api/internal/domain/services"
"apocapoc-api/internal/shared/errors"
)
type ResendVerificationEmailCommand struct {
Email string
}
type ResendVerificationEmailHandler struct {
userRepo repositories.UserRepository
emailService services.EmailService
appURL string
}
func NewResendVerificationEmailHandler(
userRepo repositories.UserRepository,
emailService services.EmailService,
appURL string,
) *ResendVerificationEmailHandler {
return &ResendVerificationEmailHandler{
userRepo: userRepo,
emailService: emailService,
appURL: appURL,
}
}
func (h *ResendVerificationEmailHandler) Handle(ctx context.Context, cmd ResendVerificationEmailCommand) error {
if cmd.Email == "" {
return errors.ErrInvalidInput
}
user, err := h.userRepo.FindByEmail(ctx, cmd.Email)
if err != nil {
return errors.ErrNotFound
}
if user.EmailVerified {
return errors.ErrAlreadyExists
}
token, err := generateVerificationToken()
if err != nil {
return fmt.Errorf("failed to generate verification token: %w", err)
}
expiry := time.Now().Add(24 * time.Hour)
user.EmailVerificationToken = &token
user.EmailVerificationExpiry = &expiry
user.UpdatedAt = time.Now()
if err := h.userRepo.Update(ctx, user); err != nil {
return fmt.Errorf("failed to update user: %w", err)
}
verificationLink := fmt.Sprintf("%s/verify-email?token=%s", h.appURL, token)
emailBody := fmt.Sprintf(`
<h2>Verify your email address</h2>
<p>Please click the link below to verify your email address:</p>
<p><a href="%s">Verify Email</a></p>
<p>This link will expire in 24 hours.</p>
<p>If you didn't create an account, you can safely ignore this email.</p>
`, verificationLink)
message := services.EmailMessage{
To: user.Email,
Subject: "Verify your email address",
Body: emailBody,
IsHTML: true,
}
if err := h.emailService.Send(message); err != nil {
return fmt.Errorf("failed to send verification email: %w", err)
}
return nil
}
func generateVerificationToken() (string, error) {
bytes := make([]byte, 32)
if _, err := rand.Read(bytes); err != nil {
return "", err
}
return hex.EncodeToString(bytes), nil
}
@@ -0,0 +1,80 @@
package commands
import (
"context"
"fmt"
"apocapoc-api/internal/domain/repositories"
"apocapoc-api/internal/domain/services"
"apocapoc-api/internal/shared/errors"
"apocapoc-api/internal/shared/validation"
)
type ResetPasswordCommand struct {
Token string
NewPassword string
}
type ResetPasswordHandler struct {
userRepo repositories.UserRepository
passwordResetTokenRepo repositories.PasswordResetTokenRepository
passwordHasher services.PasswordHasher
}
func NewResetPasswordHandler(
userRepo repositories.UserRepository,
passwordResetTokenRepo repositories.PasswordResetTokenRepository,
passwordHasher services.PasswordHasher,
) *ResetPasswordHandler {
return &ResetPasswordHandler{
userRepo: userRepo,
passwordResetTokenRepo: passwordResetTokenRepo,
passwordHasher: passwordHasher,
}
}
func (h *ResetPasswordHandler) Handle(ctx context.Context, cmd ResetPasswordCommand) error {
if cmd.Token == "" || cmd.NewPassword == "" {
return errors.ErrInvalidInput
}
if err := validation.ValidatePassword(cmd.NewPassword); err != nil {
return errors.ErrInvalidInput
}
resetToken, err := h.passwordResetTokenRepo.FindByToken(ctx, cmd.Token)
if err != nil {
return errors.ErrInvalidInput
}
if resetToken.IsExpired() {
return errors.ErrInvalidInput
}
if resetToken.IsUsed() {
return errors.ErrInvalidInput
}
user, err := h.userRepo.FindByID(ctx, resetToken.UserID)
if err != nil {
return errors.ErrNotFound
}
hashedPassword, err := h.passwordHasher.Hash(cmd.NewPassword)
if err != nil {
return fmt.Errorf("failed to hash password: %w", err)
}
user.PasswordHash = hashedPassword
if err := h.userRepo.Update(ctx, user); err != nil {
return fmt.Errorf("failed to update password: %w", err)
}
resetToken.MarkAsUsed()
if err := h.passwordResetTokenRepo.Update(ctx, resetToken); err != nil {
return fmt.Errorf("failed to mark token as used: %w", err)
}
return nil
}
@@ -0,0 +1,415 @@
package commands
import (
"apocapoc-api/internal/domain/repositories"
"apocapoc-api/internal/shared/pagination"
"context"
"testing"
"time"
"apocapoc-api/internal/domain/entities"
"apocapoc-api/internal/shared/errors"
)
type mockResetPasswordUserRepo struct {
findByIDFunc func(ctx context.Context, id string) (*entities.User, error)
updateFunc func(ctx context.Context, user *entities.User) error
users map[string]*entities.User
}
func (m *mockResetPasswordUserRepo) FindByID(ctx context.Context, id string) (*entities.User, error) {
if m.findByIDFunc != nil {
return m.findByIDFunc(ctx, id)
}
if user, ok := m.users[id]; ok {
return user, nil
}
return nil, errors.ErrNotFound
}
func (m *mockResetPasswordUserRepo) Update(ctx context.Context, user *entities.User) error {
if m.updateFunc != nil {
return m.updateFunc(ctx, user)
}
m.users[user.ID] = user
return nil
}
func (m *mockResetPasswordUserRepo) FindByEmail(ctx context.Context, email string) (*entities.User, error) {
return nil, errors.ErrNotFound
}
func (m *mockResetPasswordUserRepo) FindByVerificationToken(ctx context.Context, token string) (*entities.User, error) {
return nil, errors.ErrNotFound
}
func (m *mockResetPasswordUserRepo) Create(ctx context.Context, user *entities.User) error {
return nil
}
func (m *mockResetPasswordUserRepo) Delete(ctx context.Context, id string) error {
return nil
}
type mockPasswordResetTokenRepo struct {
findByTokenFunc func(ctx context.Context, token string) (*entities.PasswordResetToken, error)
updateFunc func(ctx context.Context, token *entities.PasswordResetToken) error
tokens map[string]*entities.PasswordResetToken
}
func (m *mockPasswordResetTokenRepo) Create(ctx context.Context, token *entities.PasswordResetToken) error {
m.tokens[token.Token] = token
return nil
}
func (m *mockPasswordResetTokenRepo) FindByToken(ctx context.Context, token string) (*entities.PasswordResetToken, error) {
if m.findByTokenFunc != nil {
return m.findByTokenFunc(ctx, token)
}
if t, ok := m.tokens[token]; ok {
return t, nil
}
return nil, errors.ErrNotFound
}
func (m *mockPasswordResetTokenRepo) Update(ctx context.Context, token *entities.PasswordResetToken) error {
if m.updateFunc != nil {
return m.updateFunc(ctx, token)
}
m.tokens[token.Token] = token
return nil
}
func (m *mockPasswordResetTokenRepo) DeleteExpired(ctx context.Context) error {
return nil
}
type mockResetPasswordHasher struct {
hashFunc func(password string) (string, error)
}
func (m *mockResetPasswordHasher) Hash(password string) (string, error) {
if m.hashFunc != nil {
return m.hashFunc(password)
}
return "hashed_" + password, nil
}
func (m *mockResetPasswordHasher) Compare(hashedPassword, password string) error {
return nil
}
func TestResetPasswordHandler_Success(t *testing.T) {
user := entities.NewUser("test@example.com", "old_hash")
user.ID = "user-123"
resetToken := entities.NewPasswordResetToken(
user.ID,
"reset-token",
time.Now().Add(1*time.Hour),
)
var updatedUser *entities.User
var updatedToken *entities.PasswordResetToken
userRepo := &mockResetPasswordUserRepo{
users: map[string]*entities.User{
user.ID: user,
},
updateFunc: func(ctx context.Context, u *entities.User) error {
updatedUser = u
return nil
},
}
tokenRepo := &mockPasswordResetTokenRepo{
tokens: map[string]*entities.PasswordResetToken{
resetToken.Token: resetToken,
},
updateFunc: func(ctx context.Context, t *entities.PasswordResetToken) error {
updatedToken = t
return nil
},
}
hasher := &mockResetPasswordHasher{}
handler := NewResetPasswordHandler(userRepo, tokenRepo, hasher)
cmd := ResetPasswordCommand{
Token: "reset-token",
NewPassword: "NewP@ssw0rd123",
}
err := handler.Handle(context.Background(), cmd)
if err != nil {
t.Fatalf("Handle() unexpected error = %v", err)
}
if updatedUser == nil {
t.Fatal("User was not updated")
}
if updatedUser.PasswordHash != "hashed_NewP@ssw0rd123" {
t.Errorf("PasswordHash = %v, want %v", updatedUser.PasswordHash, "hashed_NewP@ssw0rd123")
}
if updatedToken == nil {
t.Fatal("Token was not updated")
}
if !updatedToken.IsUsed() {
t.Error("Token should be marked as used")
}
}
func TestResetPasswordHandler_EmptyToken(t *testing.T) {
handler := NewResetPasswordHandler(
&mockResetPasswordUserRepo{users: make(map[string]*entities.User)},
&mockPasswordResetTokenRepo{tokens: make(map[string]*entities.PasswordResetToken)},
&mockResetPasswordHasher{},
)
cmd := ResetPasswordCommand{
Token: "",
NewPassword: "NewP@ssw0rd123",
}
err := handler.Handle(context.Background(), cmd)
if err != errors.ErrInvalidInput {
t.Errorf("Handle() error = %v, want %v", err, errors.ErrInvalidInput)
}
}
func TestResetPasswordHandler_EmptyPassword(t *testing.T) {
handler := NewResetPasswordHandler(
&mockResetPasswordUserRepo{users: make(map[string]*entities.User)},
&mockPasswordResetTokenRepo{tokens: make(map[string]*entities.PasswordResetToken)},
&mockResetPasswordHasher{},
)
cmd := ResetPasswordCommand{
Token: "reset-token",
NewPassword: "",
}
err := handler.Handle(context.Background(), cmd)
if err != errors.ErrInvalidInput {
t.Errorf("Handle() error = %v, want %v", err, errors.ErrInvalidInput)
}
}
func TestResetPasswordHandler_InvalidPassword(t *testing.T) {
handler := NewResetPasswordHandler(
&mockResetPasswordUserRepo{users: make(map[string]*entities.User)},
&mockPasswordResetTokenRepo{tokens: make(map[string]*entities.PasswordResetToken)},
&mockResetPasswordHasher{},
)
tests := []struct {
name string
password string
}{
{"too short", "Short1!"},
{"no uppercase", "password123!"},
{"no lowercase", "PASSWORD123!"},
{"no digit", "Password!"},
{"no special char", "Password123"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
cmd := ResetPasswordCommand{
Token: "reset-token",
NewPassword: tt.password,
}
err := handler.Handle(context.Background(), cmd)
if err != errors.ErrInvalidInput {
t.Errorf("Handle() error = %v, want %v", err, errors.ErrInvalidInput)
}
})
}
}
func TestResetPasswordHandler_TokenNotFound(t *testing.T) {
handler := NewResetPasswordHandler(
&mockResetPasswordUserRepo{users: make(map[string]*entities.User)},
&mockPasswordResetTokenRepo{tokens: make(map[string]*entities.PasswordResetToken)},
&mockResetPasswordHasher{},
)
cmd := ResetPasswordCommand{
Token: "non-existent-token",
NewPassword: "NewP@ssw0rd123",
}
err := handler.Handle(context.Background(), cmd)
if err != errors.ErrInvalidInput {
t.Errorf("Handle() error = %v, want %v", err, errors.ErrInvalidInput)
}
}
func TestResetPasswordHandler_ExpiredToken(t *testing.T) {
resetToken := entities.NewPasswordResetToken(
"user-123",
"expired-token",
time.Now().Add(-1*time.Hour),
)
tokenRepo := &mockPasswordResetTokenRepo{
tokens: map[string]*entities.PasswordResetToken{
resetToken.Token: resetToken,
},
}
handler := NewResetPasswordHandler(
&mockResetPasswordUserRepo{users: make(map[string]*entities.User)},
tokenRepo,
&mockResetPasswordHasher{},
)
cmd := ResetPasswordCommand{
Token: "expired-token",
NewPassword: "NewP@ssw0rd123",
}
err := handler.Handle(context.Background(), cmd)
if err != errors.ErrInvalidInput {
t.Errorf("Handle() error = %v, want %v", err, errors.ErrInvalidInput)
}
}
func TestResetPasswordHandler_UsedToken(t *testing.T) {
resetToken := entities.NewPasswordResetToken(
"user-123",
"used-token",
time.Now().Add(1*time.Hour),
)
resetToken.MarkAsUsed()
tokenRepo := &mockPasswordResetTokenRepo{
tokens: map[string]*entities.PasswordResetToken{
resetToken.Token: resetToken,
},
}
handler := NewResetPasswordHandler(
&mockResetPasswordUserRepo{users: make(map[string]*entities.User)},
tokenRepo,
&mockResetPasswordHasher{},
)
cmd := ResetPasswordCommand{
Token: "used-token",
NewPassword: "NewP@ssw0rd123",
}
err := handler.Handle(context.Background(), cmd)
if err != errors.ErrInvalidInput {
t.Errorf("Handle() error = %v, want %v", err, errors.ErrInvalidInput)
}
}
func TestResetPasswordHandler_UserNotFound(t *testing.T) {
resetToken := entities.NewPasswordResetToken(
"non-existent-user",
"reset-token",
time.Now().Add(1*time.Hour),
)
tokenRepo := &mockPasswordResetTokenRepo{
tokens: map[string]*entities.PasswordResetToken{
resetToken.Token: resetToken,
},
}
userRepo := &mockResetPasswordUserRepo{
users: make(map[string]*entities.User),
}
handler := NewResetPasswordHandler(userRepo, tokenRepo, &mockResetPasswordHasher{})
cmd := ResetPasswordCommand{
Token: "reset-token",
NewPassword: "NewP@ssw0rd123",
}
err := handler.Handle(context.Background(), cmd)
if err != errors.ErrNotFound {
t.Errorf("Handle() error = %v, want %v", err, errors.ErrNotFound)
}
}
func TestResetPasswordHandler_HashingError(t *testing.T) {
user := entities.NewUser("test@example.com", "old_hash")
user.ID = "user-123"
resetToken := entities.NewPasswordResetToken(
user.ID,
"reset-token",
time.Now().Add(1*time.Hour),
)
userRepo := &mockResetPasswordUserRepo{
users: map[string]*entities.User{
user.ID: user,
},
}
tokenRepo := &mockPasswordResetTokenRepo{
tokens: map[string]*entities.PasswordResetToken{
resetToken.Token: resetToken,
},
}
hasher := &mockResetPasswordHasher{
hashFunc: func(password string) (string, error) {
return "", errors.ErrInvalidInput
},
}
handler := NewResetPasswordHandler(userRepo, tokenRepo, hasher)
cmd := ResetPasswordCommand{
Token: "reset-token",
NewPassword: "NewP@ssw0rd123",
}
err := handler.Handle(context.Background(), cmd)
if err == nil {
t.Fatal("Handle() expected error but got nil")
}
}
func (m *mockPasswordResetTokenRepo) FindActiveByUserIDWithPagination(ctx context.Context, userID string, params pagination.Params) ([]*entities.Habit, error) {
return nil, nil
}
func (m *mockPasswordResetTokenRepo) CountActiveByUserID(ctx context.Context, userID string) (int, error) {
return 0, nil
}
func (m *mockResetPasswordUserRepo) FindActiveByUserIDWithPagination(ctx context.Context, userID string, params pagination.Params) ([]*entities.Habit, error) {
return nil, nil
}
func (m *mockResetPasswordUserRepo) CountActiveByUserID(ctx context.Context, userID string) (int, error) {
return 0, nil
}
func (m *mockPasswordResetTokenRepo) FindByUserIDFiltered(ctx context.Context, userID string, filter repositories.HabitFilter, paginationParams *pagination.Params) ([]*entities.Habit, error) {
return nil, nil
}
func (m *mockPasswordResetTokenRepo) CountByUserIDFiltered(ctx context.Context, userID string, filter repositories.HabitFilter) (int, error) {
return 0, nil
}
func (m *mockResetPasswordUserRepo) FindByUserIDFiltered(ctx context.Context, userID string, filter repositories.HabitFilter, paginationParams *pagination.Params) ([]*entities.Habit, error) {
return nil, nil
}
func (m *mockResetPasswordUserRepo) CountByUserIDFiltered(ctx context.Context, userID string, filter repositories.HabitFilter) (int, error) {
return 0, nil
}
@@ -0,0 +1,206 @@
package commands
import (
"apocapoc-api/internal/domain/repositories"
"apocapoc-api/internal/shared/pagination"
"context"
"testing"
"time"
"apocapoc-api/internal/domain/entities"
"apocapoc-api/internal/shared/errors"
)
type mockRefreshTokenRepo struct {
tokens map[string]*entities.RefreshToken
}
func (m *mockRefreshTokenRepo) Create(ctx context.Context, token *entities.RefreshToken) error {
m.tokens[token.Token] = token
return nil
}
func (m *mockRefreshTokenRepo) FindByToken(ctx context.Context, token string) (*entities.RefreshToken, error) {
if t, ok := m.tokens[token]; ok {
return t, nil
}
return nil, errors.ErrNotFound
}
func (m *mockRefreshTokenRepo) FindByUserID(ctx context.Context, userID string) ([]*entities.RefreshToken, error) {
var tokens []*entities.RefreshToken
for _, t := range m.tokens {
if t.UserID == userID {
tokens = append(tokens, t)
}
}
return tokens, nil
}
func (m *mockRefreshTokenRepo) RevokeByToken(ctx context.Context, token string) error {
if t, ok := m.tokens[token]; ok {
t.Revoke()
return nil
}
return errors.ErrNotFound
}
func (m *mockRefreshTokenRepo) RevokeAllByUserID(ctx context.Context, userID string) error {
for _, t := range m.tokens {
if t.UserID == userID {
t.Revoke()
}
}
return nil
}
func (m *mockRefreshTokenRepo) DeleteExpired(ctx context.Context) error {
return nil
}
func TestRevokeTokenHandler_Handle(t *testing.T) {
repo := &mockRefreshTokenRepo{
tokens: make(map[string]*entities.RefreshToken),
}
handler := NewRevokeTokenHandler(repo)
token := entities.NewRefreshToken("user-123", "valid-token", time.Now().Add(24*time.Hour))
repo.Create(context.Background(), token)
tests := []struct {
name string
cmd RevokeTokenCommand
expectError bool
expectedErr error
}{
{
name: "revoke valid token",
cmd: RevokeTokenCommand{
RefreshToken: "valid-token",
},
expectError: false,
},
{
name: "revoke empty token",
cmd: RevokeTokenCommand{
RefreshToken: "",
},
expectError: true,
expectedErr: errors.ErrInvalidInput,
},
{
name: "revoke non-existent token",
cmd: RevokeTokenCommand{
RefreshToken: "non-existent-token",
},
expectError: true,
expectedErr: errors.ErrNotFound,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := handler.Handle(context.Background(), tt.cmd)
if tt.expectError {
if err == nil {
t.Fatal("Handle() expected error but got nil")
}
if tt.expectedErr != nil && err != tt.expectedErr {
t.Errorf("Handle() error = %v, want %v", err, tt.expectedErr)
}
} else {
if err != nil {
t.Fatalf("Handle() unexpected error = %v", err)
}
}
})
}
}
func TestRevokeAllTokensHandler_Handle(t *testing.T) {
repo := &mockRefreshTokenRepo{
tokens: make(map[string]*entities.RefreshToken),
}
handler := NewRevokeAllTokensHandler(repo)
token1 := entities.NewRefreshToken("user-123", "token-1", time.Now().Add(24*time.Hour))
token2 := entities.NewRefreshToken("user-123", "token-2", time.Now().Add(24*time.Hour))
token3 := entities.NewRefreshToken("user-456", "token-3", time.Now().Add(24*time.Hour))
repo.Create(context.Background(), token1)
repo.Create(context.Background(), token2)
repo.Create(context.Background(), token3)
tests := []struct {
name string
cmd RevokeAllTokensCommand
expectError bool
expectedErr error
}{
{
name: "revoke all tokens for user",
cmd: RevokeAllTokensCommand{
UserID: "user-123",
},
expectError: false,
},
{
name: "revoke with empty user ID",
cmd: RevokeAllTokensCommand{
UserID: "",
},
expectError: true,
expectedErr: errors.ErrInvalidInput,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := handler.Handle(context.Background(), tt.cmd)
if tt.expectError {
if err == nil {
t.Fatal("Handle() expected error but got nil")
}
if tt.expectedErr != nil && err != tt.expectedErr {
t.Errorf("Handle() error = %v, want %v", err, tt.expectedErr)
}
} else {
if err != nil {
t.Fatalf("Handle() unexpected error = %v", err)
}
if !tt.expectError && tt.cmd.UserID == "user-123" {
if repo.tokens["token-1"].RevokedAt == nil {
t.Error("token-1 should be revoked")
}
if repo.tokens["token-2"].RevokedAt == nil {
t.Error("token-2 should be revoked")
}
if repo.tokens["token-3"].RevokedAt != nil {
t.Error("token-3 should not be revoked")
}
}
}
})
}
}
func (m *mockRefreshTokenRepo) FindActiveByUserIDWithPagination(ctx context.Context, userID string, params pagination.Params) ([]*entities.Habit, error) {
return nil, nil
}
func (m *mockRefreshTokenRepo) CountActiveByUserID(ctx context.Context, userID string) (int, error) {
return 0, nil
}
func (m *mockRefreshTokenRepo) FindByUserIDFiltered(ctx context.Context, userID string, filter repositories.HabitFilter, paginationParams *pagination.Params) ([]*entities.Habit, error) {
return nil, nil
}
func (m *mockRefreshTokenRepo) CountByUserIDFiltered(ctx context.Context, userID string, filter repositories.HabitFilter) (int, error) {
return 0, nil
}
@@ -1,6 +1,8 @@
package commands
import (
"apocapoc-api/internal/domain/repositories"
"apocapoc-api/internal/shared/pagination"
"context"
"testing"
"time"
@@ -137,3 +139,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
}
@@ -0,0 +1,86 @@
package commands
import (
"context"
"fmt"
"time"
"apocapoc-api/internal/domain/entities"
"apocapoc-api/internal/domain/repositories"
"apocapoc-api/internal/domain/services"
"apocapoc-api/internal/shared/errors"
)
type VerifyEmailCommand struct {
Token string
}
type VerifyEmailHandler struct {
userRepo repositories.UserRepository
emailService services.EmailService
sendWelcomeEmail bool
}
func NewVerifyEmailHandler(
userRepo repositories.UserRepository,
emailService services.EmailService,
sendWelcomeEmail bool,
) *VerifyEmailHandler {
return &VerifyEmailHandler{
userRepo: userRepo,
emailService: emailService,
sendWelcomeEmail: sendWelcomeEmail,
}
}
func (h *VerifyEmailHandler) Handle(ctx context.Context, cmd VerifyEmailCommand) error {
if cmd.Token == "" {
return errors.ErrInvalidInput
}
user, err := h.userRepo.FindByVerificationToken(ctx, cmd.Token)
if err != nil {
return errors.ErrInvalidInput
}
if user.EmailVerified {
return errors.ErrAlreadyExists
}
if user.EmailVerificationExpiry == nil || user.EmailVerificationExpiry.Before(time.Now()) {
return errors.ErrInvalidInput
}
user.EmailVerified = true
user.EmailVerificationToken = nil
user.EmailVerificationExpiry = nil
user.UpdatedAt = time.Now()
if err := h.userRepo.Update(ctx, user); err != nil {
return fmt.Errorf("failed to verify email: %w", err)
}
if h.sendWelcomeEmail && h.emailService != nil {
h.sendWelcomeEmailToUser(user)
}
return nil
}
func (h *VerifyEmailHandler) sendWelcomeEmailToUser(user *entities.User) error {
emailBody := fmt.Sprintf(`
<h2>Welcome to Apocapoc!</h2>
<p>Your email has been successfully verified.</p>
<p>You can now start tracking your habits and building better routines.</p>
<p>If you have any questions or need help, please don't hesitate to contact us.</p>
`)
message := services.EmailMessage{
To: user.Email,
Subject: "Welcome to Apocapoc!",
Body: emailBody,
IsHTML: true,
}
return h.emailService.Send(message)
}
@@ -0,0 +1,360 @@
package commands
import (
"apocapoc-api/internal/domain/repositories"
"apocapoc-api/internal/shared/pagination"
"context"
"testing"
"time"
"apocapoc-api/internal/domain/entities"
"apocapoc-api/internal/domain/services"
"apocapoc-api/internal/shared/errors"
)
type mockVerifyEmailUserRepo struct {
findByVerificationTokenFunc func(ctx context.Context, token string) (*entities.User, error)
updateFunc func(ctx context.Context, user *entities.User) error
users map[string]*entities.User
}
func (m *mockVerifyEmailUserRepo) FindByVerificationToken(ctx context.Context, token string) (*entities.User, error) {
if m.findByVerificationTokenFunc != nil {
return m.findByVerificationTokenFunc(ctx, token)
}
if user, ok := m.users[token]; ok {
return user, nil
}
return nil, errors.ErrNotFound
}
func (m *mockVerifyEmailUserRepo) Update(ctx context.Context, user *entities.User) error {
if m.updateFunc != nil {
return m.updateFunc(ctx, user)
}
m.users[user.Email] = user
return nil
}
func (m *mockVerifyEmailUserRepo) FindByID(ctx context.Context, id string) (*entities.User, error) {
return nil, errors.ErrNotFound
}
func (m *mockVerifyEmailUserRepo) FindByEmail(ctx context.Context, email string) (*entities.User, error) {
return nil, errors.ErrNotFound
}
func (m *mockVerifyEmailUserRepo) Create(ctx context.Context, user *entities.User) error {
return nil
}
func (m *mockVerifyEmailUserRepo) Delete(ctx context.Context, id string) error {
return nil
}
type mockEmailService struct {
sendFunc func(message services.EmailMessage) error
sentMessages []services.EmailMessage
}
func (m *mockEmailService) Send(message services.EmailMessage) error {
m.sentMessages = append(m.sentMessages, message)
if m.sendFunc != nil {
return m.sendFunc(message)
}
return nil
}
func (m *mockEmailService) HealthCheck() error {
return nil
}
func TestVerifyEmailHandler_Success(t *testing.T) {
token := "valid-token"
expiry := time.Now().Add(24 * time.Hour)
user := entities.NewUser("test@example.com", "hashedPassword")
user.ID = "user-123"
user.EmailVerified = false
user.EmailVerificationToken = &token
user.EmailVerificationExpiry = &expiry
var updatedUser *entities.User
repo := &mockVerifyEmailUserRepo{
users: map[string]*entities.User{
token: user,
},
updateFunc: func(ctx context.Context, u *entities.User) error {
updatedUser = u
return nil
},
}
handler := NewVerifyEmailHandler(repo, nil, false)
cmd := VerifyEmailCommand{
Token: token,
}
err := handler.Handle(context.Background(), cmd)
if err != nil {
t.Fatalf("Handle() unexpected error = %v", err)
}
if updatedUser == nil {
t.Fatal("User was not updated")
}
if !updatedUser.EmailVerified {
t.Error("EmailVerified should be true")
}
if updatedUser.EmailVerificationToken != nil {
t.Error("EmailVerificationToken should be nil after verification")
}
if updatedUser.EmailVerificationExpiry != nil {
t.Error("EmailVerificationExpiry should be nil after verification")
}
}
func TestVerifyEmailHandler_EmptyToken(t *testing.T) {
repo := &mockVerifyEmailUserRepo{
users: make(map[string]*entities.User),
}
handler := NewVerifyEmailHandler(repo, nil, false)
cmd := VerifyEmailCommand{
Token: "",
}
err := handler.Handle(context.Background(), cmd)
if err != errors.ErrInvalidInput {
t.Errorf("Handle() error = %v, want %v", err, errors.ErrInvalidInput)
}
}
func TestVerifyEmailHandler_TokenNotFound(t *testing.T) {
repo := &mockVerifyEmailUserRepo{
users: make(map[string]*entities.User),
}
handler := NewVerifyEmailHandler(repo, nil, false)
cmd := VerifyEmailCommand{
Token: "non-existent-token",
}
err := handler.Handle(context.Background(), cmd)
if err != errors.ErrInvalidInput {
t.Errorf("Handle() error = %v, want %v", err, errors.ErrInvalidInput)
}
}
func TestVerifyEmailHandler_AlreadyVerified(t *testing.T) {
token := "valid-token"
expiry := time.Now().Add(24 * time.Hour)
user := entities.NewUser("test@example.com", "hashedPassword")
user.ID = "user-123"
user.EmailVerified = true
user.EmailVerificationToken = &token
user.EmailVerificationExpiry = &expiry
repo := &mockVerifyEmailUserRepo{
users: map[string]*entities.User{
token: user,
},
}
handler := NewVerifyEmailHandler(repo, nil, false)
cmd := VerifyEmailCommand{
Token: token,
}
err := handler.Handle(context.Background(), cmd)
if err != errors.ErrAlreadyExists {
t.Errorf("Handle() error = %v, want %v", err, errors.ErrAlreadyExists)
}
}
func TestVerifyEmailHandler_ExpiredToken(t *testing.T) {
token := "expired-token"
expiry := time.Now().Add(-1 * time.Hour)
user := entities.NewUser("test@example.com", "hashedPassword")
user.ID = "user-123"
user.EmailVerified = false
user.EmailVerificationToken = &token
user.EmailVerificationExpiry = &expiry
repo := &mockVerifyEmailUserRepo{
users: map[string]*entities.User{
token: user,
},
}
handler := NewVerifyEmailHandler(repo, nil, false)
cmd := VerifyEmailCommand{
Token: token,
}
err := handler.Handle(context.Background(), cmd)
if err != errors.ErrInvalidInput {
t.Errorf("Handle() error = %v, want %v", err, errors.ErrInvalidInput)
}
}
func TestVerifyEmailHandler_NilExpiry(t *testing.T) {
token := "valid-token"
user := entities.NewUser("test@example.com", "hashedPassword")
user.ID = "user-123"
user.EmailVerified = false
user.EmailVerificationToken = &token
user.EmailVerificationExpiry = nil
repo := &mockVerifyEmailUserRepo{
users: map[string]*entities.User{
token: user,
},
}
handler := NewVerifyEmailHandler(repo, nil, false)
cmd := VerifyEmailCommand{
Token: token,
}
err := handler.Handle(context.Background(), cmd)
if err != errors.ErrInvalidInput {
t.Errorf("Handle() error = %v, want %v", err, errors.ErrInvalidInput)
}
}
func TestVerifyEmailHandler_WithWelcomeEmail(t *testing.T) {
token := "valid-token"
expiry := time.Now().Add(24 * time.Hour)
user := entities.NewUser("test@example.com", "hashedPassword")
user.ID = "user-123"
user.EmailVerified = false
user.EmailVerificationToken = &token
user.EmailVerificationExpiry = &expiry
repo := &mockVerifyEmailUserRepo{
users: map[string]*entities.User{
token: user,
},
}
emailService := &mockEmailService{}
handler := NewVerifyEmailHandler(repo, emailService, true)
cmd := VerifyEmailCommand{
Token: token,
}
err := handler.Handle(context.Background(), cmd)
if err != nil {
t.Fatalf("Handle() unexpected error = %v", err)
}
if len(emailService.sentMessages) != 1 {
t.Fatalf("Expected 1 email sent, got %d", len(emailService.sentMessages))
}
sentEmail := emailService.sentMessages[0]
if sentEmail.To != "test@example.com" {
t.Errorf("Email To = %v, want %v", sentEmail.To, "test@example.com")
}
if sentEmail.Subject != "Welcome to Apocapoc!" {
t.Errorf("Email Subject = %v, want %v", sentEmail.Subject, "Welcome to Apocapoc!")
}
if !sentEmail.IsHTML {
t.Error("Email should be HTML")
}
}
func TestVerifyEmailHandler_WithoutWelcomeEmail(t *testing.T) {
token := "valid-token"
expiry := time.Now().Add(24 * time.Hour)
user := entities.NewUser("test@example.com", "hashedPassword")
user.ID = "user-123"
user.EmailVerified = false
user.EmailVerificationToken = &token
user.EmailVerificationExpiry = &expiry
repo := &mockVerifyEmailUserRepo{
users: map[string]*entities.User{
token: user,
},
}
emailService := &mockEmailService{}
handler := NewVerifyEmailHandler(repo, emailService, false)
cmd := VerifyEmailCommand{
Token: token,
}
err := handler.Handle(context.Background(), cmd)
if err != nil {
t.Fatalf("Handle() unexpected error = %v", err)
}
if len(emailService.sentMessages) != 0 {
t.Errorf("Expected 0 emails sent, got %d", len(emailService.sentMessages))
}
}
func TestVerifyEmailHandler_UpdateError(t *testing.T) {
token := "valid-token"
expiry := time.Now().Add(24 * time.Hour)
user := entities.NewUser("test@example.com", "hashedPassword")
user.ID = "user-123"
user.EmailVerified = false
user.EmailVerificationToken = &token
user.EmailVerificationExpiry = &expiry
repo := &mockVerifyEmailUserRepo{
users: map[string]*entities.User{
token: user,
},
updateFunc: func(ctx context.Context, u *entities.User) error {
return errors.ErrNotFound
},
}
handler := NewVerifyEmailHandler(repo, nil, false)
cmd := VerifyEmailCommand{
Token: token,
}
err := handler.Handle(context.Background(), cmd)
if err == nil {
t.Fatal("Handle() expected error but got nil")
}
}
func (m *mockVerifyEmailUserRepo) FindActiveByUserIDWithPagination(ctx context.Context, userID string, params pagination.Params) ([]*entities.Habit, error) {
return nil, nil
}
func (m *mockVerifyEmailUserRepo) CountActiveByUserID(ctx context.Context, userID string) (int, error) {
return 0, nil
}
func (m *mockVerifyEmailUserRepo) FindByUserIDFiltered(ctx context.Context, userID string, filter repositories.HabitFilter, paginationParams *pagination.Params) ([]*entities.Habit, error) {
return nil, nil
}
func (m *mockVerifyEmailUserRepo) CountByUserIDFiltered(ctx context.Context, userID string, filter repositories.HabitFilter) (int, error) {
return 0, nil
}
@@ -0,0 +1,104 @@
package queries
import (
"context"
"time"
"apocapoc-api/internal/domain/repositories"
"apocapoc-api/internal/domain/value_objects"
)
type ExportHabitDTO struct {
ID string `json:"id"`
Name string `json:"name"`
Description string `json:"description"`
Type value_objects.HabitType `json:"type"`
Frequency value_objects.Frequency `json:"frequency"`
SpecificDays []int `json:"specific_days,omitempty"`
SpecificDates []int `json:"specific_dates,omitempty"`
CarryOver bool `json:"carry_over"`
IsNegative bool `json:"is_negative"`
TargetValue *float64 `json:"target_value,omitempty"`
CreatedAt time.Time `json:"created_at"`
ArchivedAt *time.Time `json:"archived_at,omitempty"`
}
type ExportEntryDTO struct {
ID string `json:"id"`
HabitID string `json:"habit_id"`
ScheduledDate time.Time `json:"scheduled_date"`
CompletedAt time.Time `json:"completed_at"`
Value *float64 `json:"value,omitempty"`
}
type ExportUserDataResult struct {
ExportedAt time.Time `json:"exported_at"`
Habits []ExportHabitDTO `json:"habits"`
Entries []ExportEntryDTO `json:"entries"`
}
type ExportUserDataQuery struct {
UserID string
}
type ExportUserDataHandler struct {
habitRepo repositories.HabitRepository
entryRepo repositories.HabitEntryRepository
}
func NewExportUserDataHandler(
habitRepo repositories.HabitRepository,
entryRepo repositories.HabitEntryRepository,
) *ExportUserDataHandler {
return &ExportUserDataHandler{
habitRepo: habitRepo,
entryRepo: entryRepo,
}
}
func (h *ExportUserDataHandler) Handle(ctx context.Context, query ExportUserDataQuery) (*ExportUserDataResult, error) {
habits, err := h.habitRepo.FindByUserID(ctx, query.UserID)
if err != nil {
return nil, err
}
entries, err := h.entryRepo.FindByUserID(ctx, query.UserID)
if err != nil {
return nil, err
}
habitDTOs := make([]ExportHabitDTO, 0, len(habits))
for _, habit := range habits {
habitDTOs = append(habitDTOs, ExportHabitDTO{
ID: habit.ID,
Name: habit.Name,
Description: habit.Description,
Type: habit.Type,
Frequency: habit.Frequency,
SpecificDays: habit.SpecificDays,
SpecificDates: habit.SpecificDates,
CarryOver: habit.CarryOver,
IsNegative: habit.IsNegative,
TargetValue: habit.TargetValue,
CreatedAt: habit.CreatedAt,
ArchivedAt: habit.ArchivedAt,
})
}
entryDTOs := make([]ExportEntryDTO, 0, len(entries))
for _, entry := range entries {
entryDTOs = append(entryDTOs, ExportEntryDTO{
ID: entry.ID,
HabitID: entry.HabitID,
ScheduledDate: entry.ScheduledDate,
CompletedAt: entry.CompletedAt,
Value: entry.Value,
})
}
return &ExportUserDataResult{
ExportedAt: time.Now(),
Habits: habitDTOs,
Entries: entryDTOs,
}, nil
}
@@ -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")
}
}
@@ -9,6 +9,12 @@ import (
"apocapoc-api/internal/shared/utils"
)
type TodaysHabitEntryDTO struct {
ID string
Value *float64
CompletedAt time.Time
}
type TodaysHabitDTO struct {
ID string
Name string
@@ -17,6 +23,7 @@ type TodaysHabitDTO struct {
IsNegative bool
ScheduledDate time.Time
IsCarriedOver bool
Entry *TodaysHabitEntryDTO
}
type GetTodaysHabitsQuery struct {
@@ -66,29 +73,29 @@ func (h *GetTodaysHabitsHandler) Handle(
entries, _ := h.entryRepo.FindByHabitIDAndDateRange(
ctx,
habit.ID,
query.Date.AddDate(0, 0, -30),
query.Date,
query.Date,
)
isCompleted := false
for _, entry := range entries {
if entry.ScheduledDate.Equal(query.Date) {
isCompleted = true
break
var entryDTO *TodaysHabitEntryDTO
if len(entries) > 0 && entries[0].ScheduledDate.Format("2006-01-02") == query.Date.Format("2006-01-02") {
entryDTO = &TodaysHabitEntryDTO{
ID: entries[0].ID,
Value: entries[0].Value,
CompletedAt: entries[0].CompletedAt,
}
}
if !isCompleted {
result = append(result, TodaysHabitDTO{
ID: habit.ID,
Name: habit.Name,
Type: habit.Type,
TargetValue: habit.TargetValue,
IsNegative: habit.IsNegative,
ScheduledDate: query.Date,
IsCarriedOver: !shouldAppear && habit.CarryOver,
})
}
result = append(result, TodaysHabitDTO{
ID: habit.ID,
Name: habit.Name,
Type: habit.Type,
TargetValue: habit.TargetValue,
IsNegative: habit.IsNegative,
ScheduledDate: query.Date,
IsCarriedOver: !shouldAppear && habit.CarryOver,
Entry: entryDTO,
})
}
return result, nil
@@ -1,12 +1,14 @@
package queries
import (
"apocapoc-api/internal/domain/repositories"
"context"
"testing"
"time"
"apocapoc-api/internal/domain/entities"
"apocapoc-api/internal/domain/value_objects"
"apocapoc-api/internal/shared/pagination"
)
type mockHabitRepo struct {
@@ -37,6 +39,14 @@ func (m *mockHabitRepo) Delete(ctx context.Context, id string) error {
return nil
}
func (m *mockHabitRepo) FindActiveByUserIDWithPagination(ctx context.Context, userID string, params pagination.Params) ([]*entities.Habit, error) {
return nil, nil
}
func (m *mockHabitRepo) CountActiveByUserID(ctx context.Context, userID string) (int, error) {
return 0, nil
}
type mockEntryRepo struct {
entries []*entities.HabitEntry
}
@@ -63,6 +73,10 @@ func (m *mockEntryRepo) FindByHabitIDAndDateRange(ctx context.Context, habitID s
return result, nil
}
func (m *mockEntryRepo) FindByUserID(ctx context.Context, userID string) ([]*entities.HabitEntry, error) {
return nil, nil
}
func (m *mockEntryRepo) FindPendingByHabitID(ctx context.Context, habitID string, beforeDate time.Time) ([]*entities.HabitEntry, error) {
return nil, nil
}
@@ -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"
@@ -107,6 +133,10 @@ func TestGetTodaysHabitsHandler_DailyHabitNoEntries(t *testing.T) {
if results[0].IsCarriedOver {
t.Error("Expected IsCarriedOver to be false")
}
if results[0].Entry != nil {
t.Error("Expected entry to be nil when no entry exists")
}
}
func TestGetTodaysHabitsHandler_DailyHabitAlreadyCompleted(t *testing.T) {
@@ -115,6 +145,7 @@ func TestGetTodaysHabitsHandler_DailyHabitAlreadyCompleted(t *testing.T) {
targetDate := time.Date(2025, 1, 15, 0, 0, 0, 0, time.UTC)
entry := entities.NewHabitEntry("habit-1", targetDate, nil)
entry.ID = "entry-1"
habitRepo := &mockHabitRepo{habits: []*entities.Habit{habit}}
entryRepo := &mockEntryRepo{entries: []*entities.HabitEntry{entry}}
@@ -133,8 +164,61 @@ func TestGetTodaysHabitsHandler_DailyHabitAlreadyCompleted(t *testing.T) {
t.Fatalf("Expected no error, got %v", err)
}
if len(results) != 0 {
t.Fatalf("Expected 0 habits (already completed), got %d", len(results))
if len(results) != 1 {
t.Fatalf("Expected 1 habit (with entry), got %d", len(results))
}
if results[0].Entry == nil {
t.Fatal("Expected entry to be present")
}
if results[0].Entry.ID != "entry-1" {
t.Errorf("Expected entry ID entry-1, got %s", results[0].Entry.ID)
}
}
func TestGetTodaysHabitsHandler_HabitWithValueEntry(t *testing.T) {
habit := entities.NewHabit("user-123", "Water", value_objects.HabitTypeValue, value_objects.FrequencyDaily, false, false)
habit.ID = "habit-1"
targetValue := 2000.0
habit.TargetValue = &targetValue
targetDate := time.Date(2025, 1, 15, 0, 0, 0, 0, time.UTC)
value := 1500.0
entry := entities.NewHabitEntry("habit-1", targetDate, &value)
entry.ID = "entry-1"
habitRepo := &mockHabitRepo{habits: []*entities.Habit{habit}}
entryRepo := &mockEntryRepo{entries: []*entities.HabitEntry{entry}}
handler := NewGetTodaysHabitsHandler(habitRepo, entryRepo)
query := GetTodaysHabitsQuery{
UserID: "user-123",
Timezone: "UTC",
Date: targetDate,
}
results, err := handler.Handle(context.Background(), query)
if err != nil {
t.Fatalf("Expected no error, got %v", err)
}
if len(results) != 1 {
t.Fatalf("Expected 1 habit, got %d", len(results))
}
if results[0].Entry == nil {
t.Fatal("Expected entry to be present")
}
if results[0].Entry.Value == nil {
t.Fatal("Expected entry value to be present")
}
if *results[0].Entry.Value != 1500.0 {
t.Errorf("Expected entry value 1500.0, got %f", *results[0].Entry.Value)
}
}
@@ -257,3 +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)
}
})
}
+8 -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 {
@@ -45,9 +44,12 @@ func (h *LoginUserHandler) Handle(ctx context.Context, query LoginUserQuery) (*L
return nil, errors.ErrNotFound
}
if !user.EmailVerified {
return nil, errors.ErrEmailNotVerified
}
return &LoginUserResult{
UserID: user.ID,
Email: user.Email,
Timezone: user.Timezone,
UserID: user.ID,
Email: user.Email,
}, nil
}
@@ -0,0 +1,224 @@
package queries
import (
"apocapoc-api/internal/domain/repositories"
"apocapoc-api/internal/shared/pagination"
"context"
"testing"
"apocapoc-api/internal/domain/entities"
"apocapoc-api/internal/shared/errors"
)
type mockLoginUserRepo struct {
findByEmailFunc func(ctx context.Context, email string) (*entities.User, error)
}
func (m *mockLoginUserRepo) FindByEmail(ctx context.Context, email string) (*entities.User, error) {
if m.findByEmailFunc != nil {
return m.findByEmailFunc(ctx, email)
}
return nil, errors.ErrNotFound
}
func (m *mockLoginUserRepo) FindByID(ctx context.Context, id string) (*entities.User, error) {
return nil, errors.ErrNotFound
}
func (m *mockLoginUserRepo) FindByVerificationToken(ctx context.Context, token string) (*entities.User, error) {
return nil, errors.ErrNotFound
}
func (m *mockLoginUserRepo) Create(ctx context.Context, user *entities.User) error {
return nil
}
func (m *mockLoginUserRepo) Update(ctx context.Context, user *entities.User) error {
return nil
}
func (m *mockLoginUserRepo) Delete(ctx context.Context, id string) error {
return nil
}
type mockLoginPasswordHasher struct {
compareFunc func(hashedPassword, password string) error
}
func (m *mockLoginPasswordHasher) Hash(password string) (string, error) {
return "hashed_" + password, nil
}
func (m *mockLoginPasswordHasher) Compare(hashedPassword, password string) error {
if m.compareFunc != nil {
return m.compareFunc(hashedPassword, password)
}
return nil
}
func TestLoginUserHandler_Success(t *testing.T) {
user := entities.NewUser("test@example.com", "hashed_password")
user.ID = "user-123"
user.EmailVerified = true
repo := &mockLoginUserRepo{
findByEmailFunc: func(ctx context.Context, email string) (*entities.User, error) {
return user, nil
},
}
hasher := &mockLoginPasswordHasher{
compareFunc: func(hashedPassword, password string) error {
return nil
},
}
handler := NewLoginUserHandler(repo, hasher)
query := LoginUserQuery{
Email: "test@example.com",
Password: "password123",
}
result, err := handler.Handle(context.Background(), query)
if err != nil {
t.Fatalf("Handle() unexpected error = %v", err)
}
if result.UserID != "user-123" {
t.Errorf("UserID = %v, want %v", result.UserID, "user-123")
}
if result.Email != "test@example.com" {
t.Errorf("Email = %v, want %v", result.Email, "test@example.com")
}
}
func TestLoginUserHandler_EmptyEmail(t *testing.T) {
repo := &mockLoginUserRepo{}
hasher := &mockLoginPasswordHasher{}
handler := NewLoginUserHandler(repo, hasher)
query := LoginUserQuery{
Email: "",
Password: "password123",
}
_, err := handler.Handle(context.Background(), query)
if err != errors.ErrInvalidInput {
t.Errorf("Handle() error = %v, want %v", err, errors.ErrInvalidInput)
}
}
func TestLoginUserHandler_EmptyPassword(t *testing.T) {
repo := &mockLoginUserRepo{}
hasher := &mockLoginPasswordHasher{}
handler := NewLoginUserHandler(repo, hasher)
query := LoginUserQuery{
Email: "test@example.com",
Password: "",
}
_, err := handler.Handle(context.Background(), query)
if err != errors.ErrInvalidInput {
t.Errorf("Handle() error = %v, want %v", err, errors.ErrInvalidInput)
}
}
func TestLoginUserHandler_UserNotFound(t *testing.T) {
repo := &mockLoginUserRepo{
findByEmailFunc: func(ctx context.Context, email string) (*entities.User, error) {
return nil, errors.ErrNotFound
},
}
hasher := &mockLoginPasswordHasher{}
handler := NewLoginUserHandler(repo, hasher)
query := LoginUserQuery{
Email: "nonexistent@example.com",
Password: "password123",
}
_, err := handler.Handle(context.Background(), query)
if err != errors.ErrNotFound {
t.Errorf("Handle() error = %v, want %v", err, errors.ErrNotFound)
}
}
func TestLoginUserHandler_InvalidPassword(t *testing.T) {
user := entities.NewUser("test@example.com", "hashed_password")
user.ID = "user-123"
user.EmailVerified = true
repo := &mockLoginUserRepo{
findByEmailFunc: func(ctx context.Context, email string) (*entities.User, error) {
return user, nil
},
}
hasher := &mockLoginPasswordHasher{
compareFunc: func(hashedPassword, password string) error {
return errors.ErrInvalidInput
},
}
handler := NewLoginUserHandler(repo, hasher)
query := LoginUserQuery{
Email: "test@example.com",
Password: "wrongpassword",
}
_, err := handler.Handle(context.Background(), query)
if err != errors.ErrNotFound {
t.Errorf("Handle() error = %v, want %v", err, errors.ErrNotFound)
}
}
func TestLoginUserHandler_EmailNotVerified(t *testing.T) {
user := entities.NewUser("test@example.com", "hashed_password")
user.ID = "user-123"
user.EmailVerified = false
repo := &mockLoginUserRepo{
findByEmailFunc: func(ctx context.Context, email string) (*entities.User, error) {
return user, nil
},
}
hasher := &mockLoginPasswordHasher{
compareFunc: func(hashedPassword, password string) error {
return nil
},
}
handler := NewLoginUserHandler(repo, hasher)
query := LoginUserQuery{
Email: "test@example.com",
Password: "password123",
}
_, err := handler.Handle(context.Background(), query)
if err != errors.ErrEmailNotVerified {
t.Errorf("Handle() error = %v, want %v", err, errors.ErrEmailNotVerified)
}
}
func (m *mockLoginUserRepo) FindActiveByUserIDWithPagination(ctx context.Context, userID string, params pagination.Params) ([]*entities.Habit, error) {
return nil, nil
}
func (m *mockLoginUserRepo) CountActiveByUserID(ctx context.Context, userID string) (int, error) {
return 0, nil
}
func (m *mockLoginUserRepo) FindByUserIDFiltered(ctx context.Context, userID string, filter repositories.HabitFilter, paginationParams *pagination.Params) ([]*entities.Habit, error) {
return nil, nil
}
func (m *mockLoginUserRepo) CountByUserIDFiltered(ctx context.Context, userID string, filter repositories.HabitFilter) (int, error) {
return 0, nil
}
@@ -17,9 +17,8 @@ type RefreshTokenQuery struct {
}
type RefreshTokenResult struct {
UserID string
Email string
Timezone string
UserID string
Email string
}
type RefreshTokenHandler struct {
@@ -57,9 +56,8 @@ func (h *RefreshTokenHandler) Handle(ctx context.Context, query RefreshTokenQuer
}
return &RefreshTokenResult{
UserID: user.ID,
Email: user.Email,
Timezone: user.Timezone,
UserID: user.ID,
Email: user.Email,
}, nil
}
@@ -1,6 +1,8 @@
package queries
import (
"apocapoc-api/internal/domain/repositories"
"apocapoc-api/internal/shared/pagination"
"context"
"testing"
"time"
@@ -59,10 +61,18 @@ func (m *mockUserRepositoryForRefresh) FindByEmail(ctx context.Context, email st
return nil, nil
}
func (m *mockUserRepositoryForRefresh) FindByVerificationToken(ctx context.Context, token string) (*entities.User, error) {
return nil, errors.ErrNotFound
}
func (m *mockUserRepositoryForRefresh) Update(ctx context.Context, user *entities.User) error {
return nil
}
func (m *mockUserRepositoryForRefresh) Delete(ctx context.Context, id string) error {
return nil
}
func TestRefreshTokenHandler_Success(t *testing.T) {
refreshTokenRepo := &mockRefreshTokenRepository{
findByTokenFunc: func(ctx context.Context, token string) (*entities.RefreshToken, error) {
@@ -72,7 +82,7 @@ func TestRefreshTokenHandler_Success(t *testing.T) {
userRepo := &mockUserRepositoryForRefresh{
findByIDFunc: func(ctx context.Context, id string) (*entities.User, error) {
user := entities.NewUser("test@example.com", "hash", "UTC")
user := entities.NewUser("test@example.com", "hash")
user.ID = id
return user, nil
},
@@ -174,3 +184,119 @@ func TestRefreshTokenHandler_EmptyToken(t *testing.T) {
t.Errorf("Expected ErrInvalidInput, got %v", err)
}
}
func TestGenerateRefreshToken(t *testing.T) {
token1, err := GenerateRefreshToken()
if err != nil {
t.Fatalf("GenerateRefreshToken() unexpected error = %v", err)
}
if token1 == "" {
t.Fatal("GenerateRefreshToken() returned empty token")
}
token2, err := GenerateRefreshToken()
if err != nil {
t.Fatalf("GenerateRefreshToken() unexpected error = %v", err)
}
if token1 == token2 {
t.Error("GenerateRefreshToken() generated identical tokens")
}
if len(token1) < 20 {
t.Errorf("GenerateRefreshToken() token too short: %d characters", len(token1))
}
}
func TestCreateRefreshToken(t *testing.T) {
userID := "user-123"
expiryDuration := 7 * 24 * time.Hour
token, err := CreateRefreshToken(userID, expiryDuration)
if err != nil {
t.Fatalf("CreateRefreshToken() unexpected error = %v", err)
}
if token == nil {
t.Fatal("CreateRefreshToken() returned nil")
}
if token.UserID != userID {
t.Errorf("UserID = %v, want %v", token.UserID, userID)
}
if token.Token == "" {
t.Error("Token is empty")
}
if token.ExpiresAt.IsZero() {
t.Error("ExpiresAt is zero")
}
if token.CreatedAt.IsZero() {
t.Error("CreatedAt is zero")
}
expectedExpiry := time.Now().Add(expiryDuration)
diff := token.ExpiresAt.Sub(expectedExpiry)
if diff > time.Second || diff < -time.Second {
t.Errorf("ExpiresAt difference too large: %v", diff)
}
if !token.IsValid() {
t.Error("Token should be valid")
}
}
func TestCreateRefreshToken_MultipleCalls(t *testing.T) {
token1, err := CreateRefreshToken("user-1", 24*time.Hour)
if err != nil {
t.Fatalf("CreateRefreshToken(1) unexpected error = %v", err)
}
token2, err := CreateRefreshToken("user-2", 24*time.Hour)
if err != nil {
t.Fatalf("CreateRefreshToken(2) unexpected error = %v", err)
}
if token1.Token == token2.Token {
t.Error("CreateRefreshToken() generated identical tokens for different users")
}
if token1.UserID == token2.UserID {
t.Error("UserIDs should be different")
}
}
func (m *mockRefreshTokenRepository) FindActiveByUserIDWithPagination(ctx context.Context, userID string, params pagination.Params) ([]*entities.Habit, error) {
return nil, nil
}
func (m *mockRefreshTokenRepository) CountActiveByUserID(ctx context.Context, userID string) (int, error) {
return 0, nil
}
func (m *mockUserRepositoryForRefresh) FindActiveByUserIDWithPagination(ctx context.Context, userID string, params pagination.Params) ([]*entities.Habit, error) {
return nil, nil
}
func (m *mockUserRepositoryForRefresh) CountActiveByUserID(ctx context.Context, userID string) (int, error) {
return 0, nil
}
func (m *mockRefreshTokenRepository) FindByUserIDFiltered(ctx context.Context, userID string, filter repositories.HabitFilter, paginationParams *pagination.Params) ([]*entities.Habit, error) {
return nil, nil
}
func (m *mockRefreshTokenRepository) CountByUserIDFiltered(ctx context.Context, userID string, filter repositories.HabitFilter) (int, error) {
return 0, nil
}
func (m *mockUserRepositoryForRefresh) FindByUserIDFiltered(ctx context.Context, userID string, filter repositories.HabitFilter, paginationParams *pagination.Params) ([]*entities.Habit, error) {
return nil, nil
}
func (m *mockUserRepositoryForRefresh) CountByUserIDFiltered(ctx context.Context, userID string, filter repositories.HabitFilter) (int, error) {
return 0, nil
}
+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
}
@@ -0,0 +1,40 @@
package entities
import (
"time"
"github.com/google/uuid"
)
type PasswordResetToken struct {
ID string
UserID string
Token string
ExpiresAt time.Time
UsedAt *time.Time
CreatedAt time.Time
}
func NewPasswordResetToken(userID, token string, expiresAt time.Time) *PasswordResetToken {
return &PasswordResetToken{
ID: uuid.NewString(),
UserID: userID,
Token: token,
ExpiresAt: expiresAt,
UsedAt: nil,
CreatedAt: time.Now(),
}
}
func (t *PasswordResetToken) IsExpired() bool {
return time.Now().After(t.ExpiresAt)
}
func (t *PasswordResetToken) IsUsed() bool {
return t.UsedAt != nil
}
func (t *PasswordResetToken) MarkAsUsed() {
now := time.Now()
t.UsedAt = &now
}
@@ -0,0 +1,135 @@
package entities
import (
"testing"
"time"
)
func TestNewPasswordResetToken(t *testing.T) {
userID := "user-123"
token := "reset-token-abc"
expiresAt := time.Now().Add(1 * time.Hour)
prt := NewPasswordResetToken(userID, token, expiresAt)
if prt == nil {
t.Fatal("NewPasswordResetToken() returned nil")
}
if prt.ID == "" {
t.Error("ID is empty")
}
if prt.UserID != userID {
t.Errorf("UserID = %v, want %v", prt.UserID, userID)
}
if prt.Token != token {
t.Errorf("Token = %v, want %v", prt.Token, token)
}
if !prt.ExpiresAt.Equal(expiresAt) {
t.Errorf("ExpiresAt = %v, want %v", prt.ExpiresAt, expiresAt)
}
if prt.UsedAt != nil {
t.Error("UsedAt should be nil for new token")
}
if prt.CreatedAt.IsZero() {
t.Error("CreatedAt is zero")
}
}
func TestPasswordResetToken_IsExpired(t *testing.T) {
tests := []struct {
name string
expiresAt time.Time
want bool
}{
{
name: "future expiry",
expiresAt: time.Now().Add(1 * time.Hour),
want: false,
},
{
name: "past expiry",
expiresAt: time.Now().Add(-1 * time.Hour),
want: true,
},
{
name: "expires in 1 second",
expiresAt: time.Now().Add(1 * time.Second),
want: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
prt := NewPasswordResetToken("user-123", "token", tt.expiresAt)
got := prt.IsExpired()
if got != tt.want {
t.Errorf("IsExpired() = %v, want %v", got, tt.want)
}
})
}
}
func TestPasswordResetToken_IsUsed(t *testing.T) {
prt := NewPasswordResetToken("user-123", "token", time.Now().Add(1*time.Hour))
if prt.IsUsed() {
t.Error("IsUsed() should return false for new token")
}
prt.MarkAsUsed()
if !prt.IsUsed() {
t.Error("IsUsed() should return true after MarkAsUsed()")
}
}
func TestPasswordResetToken_MarkAsUsed(t *testing.T) {
prt := NewPasswordResetToken("user-123", "token", time.Now().Add(1*time.Hour))
if prt.UsedAt != nil {
t.Error("UsedAt should be nil before MarkAsUsed()")
}
prt.MarkAsUsed()
if prt.UsedAt == nil {
t.Fatal("UsedAt should not be nil after MarkAsUsed()")
}
now := time.Now()
diff := now.Sub(*prt.UsedAt)
if diff > time.Second || diff < 0 {
t.Errorf("UsedAt difference too large: %v", diff)
}
firstUsedAt := prt.UsedAt
time.Sleep(10 * time.Millisecond)
prt.MarkAsUsed()
if prt.UsedAt == firstUsedAt {
t.Error("MarkAsUsed() should update UsedAt on subsequent calls")
}
}
func TestPasswordResetToken_MultipleTokens(t *testing.T) {
token1 := NewPasswordResetToken("user-1", "token-1", time.Now().Add(1*time.Hour))
token2 := NewPasswordResetToken("user-2", "token-2", time.Now().Add(1*time.Hour))
if token1.ID == token2.ID {
t.Error("NewPasswordResetToken() generated identical IDs")
}
if token1.UserID == token2.UserID {
t.Error("UserIDs should be different")
}
if token1.Token == token2.Token {
t.Error("Tokens should be different")
}
}
@@ -0,0 +1,160 @@
package entities
import (
"testing"
"time"
)
func TestNewRefreshToken(t *testing.T) {
userID := "user-123"
token := "refresh-token-abc"
expiresAt := time.Now().Add(7 * 24 * time.Hour)
rt := NewRefreshToken(userID, token, expiresAt)
if rt == nil {
t.Fatal("NewRefreshToken() returned nil")
}
if rt.UserID != userID {
t.Errorf("UserID = %v, want %v", rt.UserID, userID)
}
if rt.Token != token {
t.Errorf("Token = %v, want %v", rt.Token, token)
}
if !rt.ExpiresAt.Equal(expiresAt) {
t.Errorf("ExpiresAt = %v, want %v", rt.ExpiresAt, expiresAt)
}
if rt.RevokedAt != nil {
t.Error("RevokedAt should be nil for new token")
}
if rt.CreatedAt.IsZero() {
t.Error("CreatedAt is zero")
}
}
func TestRefreshToken_IsValid(t *testing.T) {
tests := []struct {
name string
expiresAt time.Time
revoked bool
want bool
}{
{
name: "valid token",
expiresAt: time.Now().Add(24 * time.Hour),
revoked: false,
want: true,
},
{
name: "expired token",
expiresAt: time.Now().Add(-1 * time.Hour),
revoked: false,
want: false,
},
{
name: "revoked token",
expiresAt: time.Now().Add(24 * time.Hour),
revoked: true,
want: false,
},
{
name: "expired and revoked",
expiresAt: time.Now().Add(-1 * time.Hour),
revoked: true,
want: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
rt := NewRefreshToken("user-123", "token", tt.expiresAt)
if tt.revoked {
rt.Revoke()
}
got := rt.IsValid()
if got != tt.want {
t.Errorf("IsValid() = %v, want %v", got, tt.want)
}
})
}
}
func TestRefreshToken_Revoke(t *testing.T) {
rt := NewRefreshToken("user-123", "token", time.Now().Add(24*time.Hour))
if rt.RevokedAt != nil {
t.Error("RevokedAt should be nil before Revoke()")
}
if !rt.IsValid() {
t.Error("Token should be valid before Revoke()")
}
rt.Revoke()
if rt.RevokedAt == nil {
t.Fatal("RevokedAt should not be nil after Revoke()")
}
if rt.IsValid() {
t.Error("Token should not be valid after Revoke()")
}
now := time.Now()
diff := now.Sub(*rt.RevokedAt)
if diff > time.Second || diff < 0 {
t.Errorf("RevokedAt difference too large: %v", diff)
}
}
func TestRefreshToken_RevokeMultipleTimes(t *testing.T) {
rt := NewRefreshToken("user-123", "token", time.Now().Add(24*time.Hour))
rt.Revoke()
firstRevokedAt := rt.RevokedAt
time.Sleep(10 * time.Millisecond)
rt.Revoke()
if rt.RevokedAt == firstRevokedAt {
t.Error("Revoke() should update RevokedAt on subsequent calls")
}
}
func TestRefreshToken_ExpirationCheck(t *testing.T) {
expiresIn := 100 * time.Millisecond
rt := NewRefreshToken("user-123", "token", time.Now().Add(expiresIn))
if !rt.IsValid() {
t.Error("Token should be valid initially")
}
time.Sleep(expiresIn + 10*time.Millisecond)
if rt.IsValid() {
t.Error("Token should be invalid after expiration")
}
}
func TestRefreshToken_MultipleTokens(t *testing.T) {
token1 := NewRefreshToken("user-1", "token-1", time.Now().Add(24*time.Hour))
token2 := NewRefreshToken("user-2", "token-2", time.Now().Add(24*time.Hour))
if token1.UserID == token2.UserID {
t.Error("UserIDs should be different")
}
if token1.Token == token2.Token {
t.Error("Tokens should be different")
}
if token1.CreatedAt.Equal(token2.CreatedAt) {
t.Log("Warning: CreatedAt timestamps are identical (possible race condition in test)")
}
}
+9 -11
View File
@@ -3,23 +3,21 @@ package entities
import "time"
type User struct {
ID string
Email string
PasswordHash string
Timezone string
CreatedAt time.Time
UpdatedAt time.Time
ID string
Email string
PasswordHash string
EmailVerified bool
EmailVerificationToken *string
EmailVerificationExpiry *time.Time
CreatedAt time.Time
UpdatedAt time.Time
}
func NewUser(email, passwordHash, timezone string) *User {
func NewUser(email, passwordHash string) *User {
now := time.Now()
if timezone == "" {
timezone = "UTC"
}
return &User{
Email: email,
PasswordHash: passwordHash,
Timezone: timezone,
CreatedAt: now,
UpdatedAt: now,
}
+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
}
@@ -0,0 +1,14 @@
package repositories
import (
"context"
"apocapoc-api/internal/domain/entities"
)
type PasswordResetTokenRepository interface {
Create(ctx context.Context, token *entities.PasswordResetToken) error
FindByToken(ctx context.Context, token string) (*entities.PasswordResetToken, error)
Update(ctx context.Context, token *entities.PasswordResetToken) error
DeleteExpired(ctx context.Context) error
}
@@ -10,5 +10,7 @@ type UserRepository interface {
Create(ctx context.Context, user *entities.User) error
FindByID(ctx context.Context, id string) (*entities.User, error)
FindByEmail(ctx context.Context, email string) (*entities.User, error)
FindByVerificationToken(ctx context.Context, token string) (*entities.User, error)
Update(ctx context.Context, user *entities.User) error
Delete(ctx context.Context, id string) error
}
+13
View File
@@ -0,0 +1,13 @@
package services
type EmailMessage struct {
To string
Subject string
Body string
IsHTML bool
}
type EmailService interface {
Send(message EmailMessage) error
HealthCheck() error
}
+122
View File
@@ -0,0 +1,122 @@
package i18n
import (
_ "embed"
"encoding/json"
"fmt"
"strings"
"golang.org/x/text/language"
)
//go:embed locales/en.json
var enTranslations []byte
//go:embed locales/es.json
var esTranslations []byte
type Translations struct {
Errors map[string]string `json:"errors"`
Success map[string]string `json:"success"`
Validation map[string]string `json:"validation"`
Emails map[string]string `json:"emails"`
}
type Translator struct {
translations map[language.Tag]Translations
matcher language.Matcher
}
func NewTranslator() (*Translator, error) {
var enTrans, esTrans Translations
if err := json.Unmarshal(enTranslations, &enTrans); err != nil {
return nil, fmt.Errorf("failed to load English translations: %w", err)
}
if err := json.Unmarshal(esTranslations, &esTrans); err != nil {
return nil, fmt.Errorf("failed to load Spanish translations: %w", err)
}
translations := map[language.Tag]Translations{
language.English: enTrans,
language.Spanish: esTrans,
}
matcher := language.NewMatcher([]language.Tag{
language.English,
language.Spanish,
})
return &Translator{
translations: translations,
matcher: matcher,
}, nil
}
func (t *Translator) GetLanguage(acceptLanguage string) language.Tag {
if acceptLanguage == "" {
return language.English
}
tags, _, err := language.ParseAcceptLanguage(acceptLanguage)
if err != nil || len(tags) == 0 {
return language.English
}
_, index, _ := t.matcher.Match(tags...)
supportedTags := []language.Tag{language.English, language.Spanish}
if index < len(supportedTags) {
return supportedTags[index]
}
return language.English
}
func (t *Translator) Translate(lang language.Tag, category, key string) string {
trans, ok := t.translations[lang]
if !ok {
trans = t.translations[language.English]
}
var categoryMap map[string]string
switch category {
case "errors":
categoryMap = trans.Errors
case "success":
categoryMap = trans.Success
case "validation":
categoryMap = trans.Validation
case "emails":
categoryMap = trans.Emails
default:
return key
}
if value, ok := categoryMap[key]; ok {
return value
}
return key
}
func (t *Translator) Error(lang language.Tag, key string) string {
return t.Translate(lang, "errors", key)
}
func (t *Translator) Success(lang language.Tag, key string) string {
return t.Translate(lang, "success", key)
}
func (t *Translator) Validation(lang language.Tag, key string) string {
return t.Translate(lang, "validation", key)
}
func (t *Translator) Email(lang language.Tag, key string) string {
return t.Translate(lang, "emails", key)
}
func (t *Translator) TranslateValidationError(lang language.Tag, field, validationKey string) string {
message := t.Validation(lang, validationKey)
return strings.ReplaceAll(message, field, field)
}
+252
View File
@@ -0,0 +1,252 @@
package i18n
import (
"testing"
"golang.org/x/text/language"
)
func TestNewTranslator(t *testing.T) {
translator, err := NewTranslator()
if err != nil {
t.Fatalf("Failed to create translator: %v", err)
}
if translator == nil {
t.Fatal("Expected translator to be non-nil")
}
if translator.translations == nil {
t.Fatal("Expected translations map to be initialized")
}
if len(translator.translations) != 2 {
t.Errorf("Expected 2 languages, got %d", len(translator.translations))
}
}
func TestGetLanguage(t *testing.T) {
translator, _ := NewTranslator()
tests := []struct {
name string
acceptLanguage string
expected language.Tag
}{
{
name: "English",
acceptLanguage: "en-US",
expected: language.English,
},
{
name: "Spanish",
acceptLanguage: "es-ES",
expected: language.Spanish,
},
{
name: "Empty defaults to English",
acceptLanguage: "",
expected: language.English,
},
{
name: "Unknown language defaults to English",
acceptLanguage: "fr-FR",
expected: language.English,
},
{
name: "Spanish with quality",
acceptLanguage: "es-ES,es;q=0.9",
expected: language.Spanish,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := translator.GetLanguage(tt.acceptLanguage)
if result != tt.expected {
t.Errorf("Expected %v, got %v", tt.expected, result)
}
})
}
}
func TestError(t *testing.T) {
translator, _ := NewTranslator()
tests := []struct {
name string
lang language.Tag
key string
expected string
}{
{
name: "English error message",
lang: language.English,
key: "invalid_request_body",
expected: "Invalid request body",
},
{
name: "Spanish error message",
lang: language.Spanish,
key: "invalid_request_body",
expected: "Cuerpo de solicitud inválido",
},
{
name: "Missing key returns key",
lang: language.English,
key: "non_existent_key",
expected: "non_existent_key",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := translator.Error(tt.lang, tt.key)
if result != tt.expected {
t.Errorf("Expected '%s', got '%s'", tt.expected, result)
}
})
}
}
func TestSuccess(t *testing.T) {
translator, _ := NewTranslator()
tests := []struct {
name string
lang language.Tag
key string
expected string
}{
{
name: "English success message",
lang: language.English,
key: "logged_out",
expected: "Successfully logged out",
},
{
name: "Spanish success message",
lang: language.Spanish,
key: "logged_out",
expected: "Sesión cerrada exitosamente",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := translator.Success(tt.lang, tt.key)
if result != tt.expected {
t.Errorf("Expected '%s', got '%s'", tt.expected, result)
}
})
}
}
func TestValidation(t *testing.T) {
translator, _ := NewTranslator()
tests := []struct {
name string
lang language.Tag
key string
expected string
}{
{
name: "English validation message",
lang: language.English,
key: "email_required",
expected: "email is required",
},
{
name: "Spanish validation message",
lang: language.Spanish,
key: "email_required",
expected: "el email es requerido",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := translator.Validation(tt.lang, tt.key)
if result != tt.expected {
t.Errorf("Expected '%s', got '%s'", tt.expected, result)
}
})
}
}
func TestEmail(t *testing.T) {
translator, _ := NewTranslator()
tests := []struct {
name string
lang language.Tag
key string
expected string
}{
{
name: "English email message",
lang: language.English,
key: "welcome_subject",
expected: "Welcome to Apocapoc!",
},
{
name: "Spanish email message",
lang: language.Spanish,
key: "welcome_subject",
expected: "¡Bienvenido a Apocapoc!",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := translator.Email(tt.lang, tt.key)
if result != tt.expected {
t.Errorf("Expected '%s', got '%s'", tt.expected, result)
}
})
}
}
func TestTranslate(t *testing.T) {
translator, _ := NewTranslator()
tests := []struct {
name string
lang language.Tag
category string
key string
expected string
}{
{
name: "Valid category and key",
lang: language.English,
category: "errors",
key: "user_not_found",
expected: "User not found",
},
{
name: "Invalid category returns key",
lang: language.English,
category: "invalid_category",
key: "some_key",
expected: "some_key",
},
{
name: "Unsupported language fallback to English",
lang: language.French,
category: "errors",
key: "user_not_found",
expected: "User not found",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := translator.Translate(tt.lang, tt.category, tt.key)
if result != tt.expected {
t.Errorf("Expected '%s', got '%s'", tt.expected, result)
}
})
}
}
+112
View File
@@ -0,0 +1,112 @@
{
"errors": {
"invalid_request_body": "Invalid request body",
"email_already_registered": "Email already registered",
"registration_closed": "Registration is currently closed",
"failed_register_user": "Failed to register user",
"invalid_credentials": "Invalid email or password",
"email_not_verified": "Please verify your email before logging in",
"failed_login": "Failed to login",
"failed_generate_token": "Failed to generate token",
"failed_create_refresh_token": "Failed to create refresh token",
"failed_save_refresh_token": "Failed to save refresh token",
"invalid_expired_refresh_token": "Invalid or expired refresh token",
"failed_refresh_token": "Failed to refresh token",
"refresh_token_not_found": "Refresh token not found",
"invalid_refresh_token": "Invalid refresh token",
"user_not_authenticated": "User not authenticated",
"failed_get_user": "Failed to get user",
"failed_get_habits": "Failed to get habits",
"failed_create_habit": "Failed to create habit",
"habit_not_found": "Habit not found",
"access_denied": "Access denied",
"failed_get_habit": "Failed to get habit",
"invalid_input": "Invalid input",
"failed_update_habit": "Failed to update habit",
"failed_archive_habit": "Failed to archive habit",
"failed_get_habit_entries": "Failed to get habit entries",
"invalid_date_format": "Invalid date format (use YYYY-MM-DD)",
"invalid_page_parameter": "Invalid 'page' parameter",
"invalid_limit_parameter": "Invalid 'limit' parameter (must be 1-100)",
"pagination_required": "Pagination required: provide 'limit' parameter or use date range ≤ 1 year",
"invalid_from_date_format": "Invalid 'from' date format (use YYYY-MM-DD)",
"invalid_to_date_format": "Invalid 'to' date format (use YYYY-MM-DD)",
"habit_already_marked": "Habit already marked for this date",
"failed_mark_habit": "Failed to mark habit",
"habit_entry_not_found": "Habit entry not found",
"failed_unmark_habit": "Failed to unmark habit",
"invalid_expired_verification_token": "Invalid or expired verification token",
"email_already_verified": "Email already verified",
"failed_verify_email": "Failed to verify email",
"invalid_email": "Invalid email",
"user_not_found": "User not found",
"failed_send_verification_email": "Failed to send verification email",
"email_not_verified_reset": "Please verify your email before resetting password",
"failed_send_reset_email": "Failed to send reset email",
"invalid_token_or_password": "Invalid or expired token, or password requirements not met",
"failed_reset_password": "Failed to reset password",
"failed_delete_user": "Failed to delete user",
"failed_get_stats": "Failed to get statistics",
"export_failed": "Failed to export data",
"timezone_required": "Timezone is required",
"invalid_timezone": "Invalid timezone (must be a valid IANA timezone)"
},
"success": {
"registration_with_verification": "Registration successful. Please check your email to verify your account.",
"registration_without_verification": "Registration successful. You can now login.",
"logged_out": "Successfully logged out",
"email_verified": "Email verified successfully",
"verification_email_sent": "Verification email sent successfully",
"password_reset_email_sent": "Password reset email sent successfully",
"password_reset": "Password reset successfully",
"user_deleted": "User and all associated data deleted successfully"
},
"validation": {
"email_required": "email is required",
"email_invalid_format": "email must be a valid email address",
"email_too_long": "email must not exceed 254 characters",
"password_required": "password is required",
"password_min_length": "password must be at least 8 characters long",
"password_max_length": "password must not exceed 128 characters",
"password_uppercase": "password must contain at least one uppercase letter",
"password_lowercase": "password must contain at least one lowercase letter",
"password_digit": "password must contain at least one digit",
"password_special_char": "password must contain at least one special character (!@#$%^&*)",
"timezone_required": "timezone is required",
"timezone_invalid": "timezone is not a valid IANA timezone",
"name_required": "name is required",
"name_too_long": "name must not exceed 255 characters",
"type_invalid": "type must be one of: BOOLEAN, COUNTER, VALUE",
"frequency_invalid": "frequency must be one of: DAILY, WEEKLY, MONTHLY",
"specific_days_required": "specific_days is required for WEEKLY frequency",
"specific_days_invalid": "specific_days must contain values between 0-6 (0=Sunday, 6=Saturday)",
"specific_dates_required": "specific_dates is required for MONTHLY frequency",
"specific_dates_invalid": "specific_dates must contain values between 1-31",
"target_value_required": "target_value is required for VALUE type",
"target_value_positive": "target_value must be positive"
},
"emails": {
"verify_email_subject": "Verify your email address",
"verify_email_title": "Welcome! Please verify your email",
"verify_email_body": "Thank you for registering. Please click the link below to verify your email address:",
"verify_email_link": "Verify Email",
"verify_email_expiry": "This link will expire in 24 hours.",
"verify_email_ignore": "If you didn't create an account, you can safely ignore this email.",
"resend_verification_subject": "Verify your email address",
"resend_verification_title": "Verify your email address",
"resend_verification_body": "Please click the link below to verify your email address:",
"resend_verification_link": "Verify Email",
"resend_verification_expiry": "This link will expire in 24 hours.",
"resend_verification_ignore": "If you didn't request this, you can safely ignore this email.",
"password_reset_subject": "Password Reset Request",
"password_reset_title": "Password Reset Request",
"password_reset_body": "You have requested to reset your password. Please click the link below:",
"password_reset_link": "Reset Password",
"password_reset_expiry": "This link will expire in 1 hour.",
"password_reset_ignore": "If you didn't request this, you can safely ignore this email.",
"welcome_subject": "Welcome to Apocapoc!",
"welcome_title": "Welcome to Apocapoc!",
"welcome_body": "Your email has been verified successfully. You can now start using all features of Apocapoc.",
"welcome_enjoy": "Enjoy building better habits!"
}
}
+112
View File
@@ -0,0 +1,112 @@
{
"errors": {
"invalid_request_body": "Cuerpo de solicitud inválido",
"email_already_registered": "El correo electrónico ya está registrado",
"registration_closed": "El registro está actualmente cerrado",
"failed_register_user": "Error al registrar usuario",
"invalid_credentials": "Correo electrónico o contraseña inválidos",
"email_not_verified": "Por favor verifica tu correo electrónico antes de iniciar sesión",
"failed_login": "Error al iniciar sesión",
"failed_generate_token": "Error al generar token",
"failed_create_refresh_token": "Error al crear token de actualización",
"failed_save_refresh_token": "Error al guardar token de actualización",
"invalid_expired_refresh_token": "Token de actualización inválido o expirado",
"failed_refresh_token": "Error al actualizar token",
"refresh_token_not_found": "Token de actualización no encontrado",
"invalid_refresh_token": "Token de actualización inválido",
"user_not_authenticated": "Usuario no autenticado",
"failed_get_user": "Error al obtener usuario",
"failed_get_habits": "Error al obtener hábitos",
"failed_create_habit": "Error al crear hábito",
"habit_not_found": "Hábito no encontrado",
"access_denied": "Acceso denegado",
"failed_get_habit": "Error al obtener hábito",
"invalid_input": "Entrada inválida",
"failed_update_habit": "Error al actualizar hábito",
"failed_archive_habit": "Error al archivar hábito",
"failed_get_habit_entries": "Error al obtener entradas de hábito",
"invalid_date_format": "Formato de fecha inválido (usa AAAA-MM-DD)",
"invalid_page_parameter": "Parámetro 'page' inválido",
"invalid_limit_parameter": "Parámetro 'limit' inválido (debe ser 1-100)",
"pagination_required": "Se requiere paginación: proporciona el parámetro 'limit' o usa un rango de fechas ≤ 1 año",
"invalid_from_date_format": "Formato de fecha 'from' inválido (usa AAAA-MM-DD)",
"invalid_to_date_format": "Formato de fecha 'to' inválido (usa AAAA-MM-DD)",
"habit_already_marked": "El hábito ya está marcado para esta fecha",
"failed_mark_habit": "Error al marcar hábito",
"habit_entry_not_found": "Entrada de hábito no encontrada",
"failed_unmark_habit": "Error al desmarcar hábito",
"invalid_expired_verification_token": "Token de verificación inválido o expirado",
"email_already_verified": "El correo electrónico ya está verificado",
"failed_verify_email": "Error al verificar correo electrónico",
"invalid_email": "Correo electrónico inválido",
"user_not_found": "Usuario no encontrado",
"failed_send_verification_email": "Error al enviar correo de verificación",
"email_not_verified_reset": "Por favor verifica tu correo electrónico antes de restablecer la contraseña",
"failed_send_reset_email": "Error al enviar correo de restablecimiento",
"invalid_token_or_password": "Token inválido o expirado, o no se cumplen los requisitos de contraseña",
"failed_reset_password": "Error al restablecer contraseña",
"failed_delete_user": "Error al eliminar usuario",
"failed_get_stats": "Error al obtener estadísticas",
"export_failed": "Error al exportar datos",
"timezone_required": "La zona horaria es requerida",
"invalid_timezone": "Zona horaria inválida (debe ser una zona horaria IANA válida)"
},
"success": {
"registration_with_verification": "Registro exitoso. Por favor revisa tu correo electrónico para verificar tu cuenta.",
"registration_without_verification": "Registro exitoso. Ya puedes iniciar sesión.",
"logged_out": "Sesión cerrada exitosamente",
"email_verified": "Correo electrónico verificado exitosamente",
"verification_email_sent": "Correo de verificación enviado exitosamente",
"password_reset_email_sent": "Correo de restablecimiento de contraseña enviado exitosamente",
"password_reset": "Contraseña restablecida exitosamente",
"user_deleted": "Usuario y todos los datos asociados eliminados exitosamente"
},
"validation": {
"email_required": "el email es requerido",
"email_invalid_format": "el email debe ser una dirección de correo válida",
"email_too_long": "el email no debe exceder 254 caracteres",
"password_required": "la password es requerida",
"password_min_length": "la password debe tener al menos 8 caracteres",
"password_max_length": "la password no debe exceder 128 caracteres",
"password_uppercase": "la password debe contener al menos una letra mayúscula",
"password_lowercase": "la password debe contener al menos una letra minúscula",
"password_digit": "la password debe contener al menos un dígito",
"password_special_char": "la password debe contener al menos un carácter especial (!@#$%^&*)",
"timezone_required": "la timezone es requerida",
"timezone_invalid": "la timezone no es una zona horaria IANA válida",
"name_required": "el name es requerido",
"name_too_long": "el name no debe exceder 255 caracteres",
"type_invalid": "el type debe ser uno de: BOOLEAN, COUNTER, VALUE",
"frequency_invalid": "la frequency debe ser una de: DAILY, WEEKLY, MONTHLY",
"specific_days_required": "specific_days es requerido para frecuencia WEEKLY",
"specific_days_invalid": "specific_days debe contener valores entre 0-6 (0=Domingo, 6=Sábado)",
"specific_dates_required": "specific_dates es requerido para frecuencia MONTHLY",
"specific_dates_invalid": "specific_dates debe contener valores entre 1-31",
"target_value_required": "target_value es requerido para tipo VALUE",
"target_value_positive": "target_value debe ser positivo"
},
"emails": {
"verify_email_subject": "Verifica tu dirección de correo electrónico",
"verify_email_title": "¡Bienvenido! Por favor verifica tu correo electrónico",
"verify_email_body": "Gracias por registrarte. Por favor haz clic en el enlace a continuación para verificar tu dirección de correo electrónico:",
"verify_email_link": "Verificar correo electrónico",
"verify_email_expiry": "Este enlace expirará en 24 horas.",
"verify_email_ignore": "Si no creaste una cuenta, puedes ignorar este correo de forma segura.",
"resend_verification_subject": "Verifica tu dirección de correo electrónico",
"resend_verification_title": "Verifica tu dirección de correo electrónico",
"resend_verification_body": "Por favor haz clic en el enlace a continuación para verificar tu dirección de correo electrónico:",
"resend_verification_link": "Verificar correo electrónico",
"resend_verification_expiry": "Este enlace expirará en 24 horas.",
"resend_verification_ignore": "Si no solicitaste esto, puedes ignorar este correo de forma segura.",
"password_reset_subject": "Solicitud de restablecimiento de contraseña",
"password_reset_title": "Solicitud de restablecimiento de contraseña",
"password_reset_body": "Has solicitado restablecer tu contraseña. Por favor haz clic en el enlace a continuación:",
"password_reset_link": "Restablecer contraseña",
"password_reset_expiry": "Este enlace expirará en 1 hora.",
"password_reset_ignore": "Si no solicitaste esto, puedes ignorar este correo de forma segura.",
"welcome_subject": "¡Bienvenido a Apocapoc!",
"welcome_title": "¡Bienvenido a Apocapoc!",
"welcome_body": "Tu correo electrónico ha sido verificado exitosamente. Ya puedes comenzar a usar todas las funcionalidades de Apocapoc.",
"welcome_enjoy": "¡Disfruta construyendo mejores hábitos!"
}
}
+30
View File
@@ -0,0 +1,30 @@
package i18n
import (
"context"
"net/http"
"golang.org/x/text/language"
)
type contextKey string
const languageKey contextKey = "language"
func LanguageMiddleware(translator *Translator) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
acceptLanguage := r.Header.Get("Accept-Language")
lang := translator.GetLanguage(acceptLanguage)
ctx := context.WithValue(r.Context(), languageKey, lang)
next.ServeHTTP(w, r.WithContext(ctx))
})
}
}
func GetLanguageFromContext(ctx context.Context) language.Tag {
if lang, ok := ctx.Value(languageKey).(language.Tag); ok {
return lang
}
return language.English
}
+99
View File
@@ -0,0 +1,99 @@
package i18n
import (
"context"
"net/http"
"net/http/httptest"
"testing"
"golang.org/x/text/language"
)
func TestLanguageMiddleware(t *testing.T) {
translator, _ := NewTranslator()
middleware := LanguageMiddleware(translator)
tests := []struct {
name string
acceptLanguage string
expectedLang language.Tag
}{
{
name: "English header",
acceptLanguage: "en-US",
expectedLang: language.English,
},
{
name: "Spanish header",
acceptLanguage: "es-ES",
expectedLang: language.Spanish,
},
{
name: "No header defaults to English",
acceptLanguage: "",
expectedLang: language.English,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
var capturedLang language.Tag
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
capturedLang = GetLanguageFromContext(r.Context())
w.WriteHeader(http.StatusOK)
})
wrappedHandler := middleware(handler)
req := httptest.NewRequest("GET", "/test", nil)
if tt.acceptLanguage != "" {
req.Header.Set("Accept-Language", tt.acceptLanguage)
}
w := httptest.NewRecorder()
wrappedHandler.ServeHTTP(w, req)
if capturedLang != tt.expectedLang {
t.Errorf("Expected language %v, got %v", tt.expectedLang, capturedLang)
}
})
}
}
func TestGetLanguageFromContext(t *testing.T) {
tests := []struct {
name string
ctx context.Context
expected language.Tag
}{
{
name: "Context with English",
ctx: context.WithValue(context.Background(), languageKey, language.English),
expected: language.English,
},
{
name: "Context with Spanish",
ctx: context.WithValue(context.Background(), languageKey, language.Spanish),
expected: language.Spanish,
},
{
name: "Context without language defaults to English",
ctx: context.Background(),
expected: language.English,
},
{
name: "Context with wrong value type defaults to English",
ctx: context.WithValue(context.Background(), languageKey, "invalid"),
expected: language.English,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := GetLanguageFromContext(tt.ctx)
if result != tt.expected {
t.Errorf("Expected %v, got %v", tt.expected, result)
}
})
}
}
+357
View File
@@ -0,0 +1,357 @@
package auth
import (
"testing"
"time"
"github.com/golang-jwt/jwt/v5"
)
func TestNewJWTService(t *testing.T) {
tests := []struct {
name string
secret string
expiryHours int
}{
{
name: "standard configuration",
secret: "my-secret-key",
expiryHours: 24,
},
{
name: "short expiry",
secret: "test-secret",
expiryHours: 1,
},
{
name: "long expiry",
secret: "test-secret",
expiryHours: 168,
},
{
name: "empty secret",
secret: "",
expiryHours: 24,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
service := NewJWTService(tt.secret, tt.expiryHours)
if service == nil {
t.Fatal("NewJWTService() returned nil")
}
if string(service.secret) != tt.secret {
t.Errorf("secret = %v, want %v", string(service.secret), tt.secret)
}
expectedExpiry := time.Duration(tt.expiryHours) * time.Hour
if service.expiry != expectedExpiry {
t.Errorf("expiry = %v, want %v", service.expiry, expectedExpiry)
}
})
}
}
func TestJWTService_GenerateToken(t *testing.T) {
service := NewJWTService("test-secret-key", 24)
tests := []struct {
name string
userID string
email string
}{
{
name: "standard user",
userID: "user-123",
email: "user@example.com",
},
{
name: "empty user ID",
userID: "",
email: "user@example.com",
},
{
name: "empty email",
userID: "user-123",
email: "",
},
{
name: "both empty",
userID: "",
email: "",
},
{
name: "special characters in email",
userID: "user-456",
email: "user+test@example.com",
},
{
name: "uuid as user ID",
userID: "550e8400-e29b-41d4-a716-446655440000",
email: "uuid@example.com",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
token, err := service.GenerateToken(tt.userID, tt.email)
if err != nil {
t.Fatalf("GenerateToken() error = %v", err)
}
if token == "" {
t.Fatal("GenerateToken() returned empty token")
}
claims, err := service.ValidateToken(token)
if err != nil {
t.Fatalf("ValidateToken() error = %v", err)
}
if claims.UserID != tt.userID {
t.Errorf("UserID = %v, want %v", claims.UserID, tt.userID)
}
if claims.Email != tt.email {
t.Errorf("Email = %v, want %v", claims.Email, tt.email)
}
})
}
}
func TestJWTService_ValidateToken(t *testing.T) {
secret := "test-secret-key"
service := NewJWTService(secret, 24)
tests := []struct {
name string
setupToken func() string
expectError bool
errorMsg string
}{
{
name: "valid token",
setupToken: func() string {
token, _ := service.GenerateToken("user-123", "user@example.com")
return token
},
expectError: false,
},
{
name: "invalid token format",
setupToken: func() string {
return "not.a.valid.token"
},
expectError: true,
},
{
name: "empty token",
setupToken: func() string {
return ""
},
expectError: true,
},
{
name: "token with wrong secret",
setupToken: func() string {
wrongService := NewJWTService("wrong-secret", 24)
token, _ := wrongService.GenerateToken("user-123", "user@example.com")
return token
},
expectError: true,
},
{
name: "expired token",
setupToken: func() string {
expiredService := NewJWTService(secret, -1)
token, _ := expiredService.GenerateToken("user-123", "user@example.com")
return token
},
expectError: true,
},
{
name: "malformed token",
setupToken: func() string {
return "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.malformed"
},
expectError: true,
},
{
name: "token with invalid signature",
setupToken: func() string {
token, _ := service.GenerateToken("user-123", "user@example.com")
return token[:len(token)-5] + "xxxxx"
},
expectError: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
token := tt.setupToken()
claims, err := service.ValidateToken(token)
if tt.expectError {
if err == nil {
t.Fatal("ValidateToken() expected error but got nil")
}
} else {
if err != nil {
t.Fatalf("ValidateToken() unexpected error = %v", err)
}
if claims == nil {
t.Fatal("ValidateToken() returned nil claims")
}
}
})
}
}
func TestJWTService_ValidateTokenClaims(t *testing.T) {
service := NewJWTService("test-secret-key", 24)
userID := "user-123"
email := "user@example.com"
token, err := service.GenerateToken(userID, email)
if err != nil {
t.Fatalf("GenerateToken() error = %v", err)
}
claims, err := service.ValidateToken(token)
if err != nil {
t.Fatalf("ValidateToken() error = %v", err)
}
if claims.UserID != userID {
t.Errorf("UserID = %v, want %v", claims.UserID, userID)
}
if claims.Email != email {
t.Errorf("Email = %v, want %v", claims.Email, email)
}
if claims.ExpiresAt == nil {
t.Fatal("ExpiresAt is nil")
}
if claims.IssuedAt == nil {
t.Fatal("IssuedAt is nil")
}
if claims.ExpiresAt.Before(claims.IssuedAt.Time) {
t.Error("ExpiresAt is before IssuedAt")
}
expectedExpiry := claims.IssuedAt.Add(24 * time.Hour)
if !claims.ExpiresAt.Time.Equal(expectedExpiry) {
diff := claims.ExpiresAt.Time.Sub(expectedExpiry)
if diff > time.Second || diff < -time.Second {
t.Errorf("ExpiresAt = %v, want approximately %v (diff: %v)", claims.ExpiresAt.Time, expectedExpiry, diff)
}
}
}
func TestJWTService_TokenExpiry(t *testing.T) {
tests := []struct {
name string
expiryHours int
}{
{
name: "1 hour expiry",
expiryHours: 1,
},
{
name: "24 hours expiry",
expiryHours: 24,
},
{
name: "168 hours (1 week) expiry",
expiryHours: 168,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
service := NewJWTService("test-secret", tt.expiryHours)
token, err := service.GenerateToken("user-123", "user@example.com")
if err != nil {
t.Fatalf("GenerateToken() error = %v", err)
}
claims, err := service.ValidateToken(token)
if err != nil {
t.Fatalf("ValidateToken() error = %v", err)
}
expectedExpiry := time.Now().Add(time.Duration(tt.expiryHours) * time.Hour)
diff := claims.ExpiresAt.Time.Sub(expectedExpiry)
if diff > time.Second || diff < -time.Second {
t.Errorf("ExpiresAt difference too large: %v", diff)
}
})
}
}
func TestJWTService_ValidateTokenWithWrongSigningMethod(t *testing.T) {
service := NewJWTService("test-secret", 24)
claims := &Claims{
UserID: "user-123",
Email: "user@example.com",
RegisteredClaims: jwt.RegisteredClaims{
ExpiresAt: jwt.NewNumericDate(time.Now().Add(24 * time.Hour)),
IssuedAt: jwt.NewNumericDate(time.Now()),
},
}
token := jwt.NewWithClaims(jwt.SigningMethodNone, claims)
tokenString, err := token.SignedString(jwt.UnsafeAllowNoneSignatureType)
if err != nil {
t.Fatalf("SignedString() error = %v", err)
}
_, err = service.ValidateToken(tokenString)
if err == nil {
t.Fatal("ValidateToken() expected error for none signing method but got nil")
}
}
func TestJWTService_MultipleTokens(t *testing.T) {
service := NewJWTService("test-secret", 24)
token1, err := service.GenerateToken("user-1", "user1@example.com")
if err != nil {
t.Fatalf("GenerateToken(1) error = %v", err)
}
token2, err := service.GenerateToken("user-2", "user2@example.com")
if err != nil {
t.Fatalf("GenerateToken(2) error = %v", err)
}
if token1 == token2 {
t.Error("Generated identical tokens for different users")
}
claims1, err := service.ValidateToken(token1)
if err != nil {
t.Fatalf("ValidateToken(1) error = %v", err)
}
claims2, err := service.ValidateToken(token2)
if err != nil {
t.Fatalf("ValidateToken(2) error = %v", err)
}
if claims1.UserID == claims2.UserID {
t.Error("Claims have same UserID")
}
if claims1.Email == claims2.Email {
t.Error("Claims have same Email")
}
}
+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)
}
}
+47 -19
View File
@@ -8,33 +8,64 @@ import (
)
type Config struct {
DBPath string
Port string
Host string
JWTSecret string
JWTExpiry string
RefreshTokenExpiry string
CORSOrigins string
DefaultTimezone 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"),
Host: getEnvOrDefault("HOST", "0.0.0.0"),
JWTSecret: os.Getenv("JWT_SECRET"),
JWTExpiry: os.Getenv("JWT_EXPIRY"),
RefreshTokenExpiry: os.Getenv("REFRESH_TOKEN_EXPIRY"),
CORSOrigins: os.Getenv("CORS_ORIGINS"),
DefaultTimezone: os.Getenv("DEFAULT_TIMEZONE"),
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 == "" {
return nil, fmt.Errorf("DB_PATH is required")
}
if cfg.AppURL == "" {
return nil, fmt.Errorf("APP_URL is required")
}
if cfg.JWTSecret == "" {
return nil, fmt.Errorf("JWT_SECRET is required")
}
@@ -44,9 +75,6 @@ func Load() (*Config, error) {
if cfg.RefreshTokenExpiry == "" {
return nil, fmt.Errorf("REFRESH_TOKEN_EXPIRY is required")
}
if cfg.CORSOrigins == "" {
return nil, fmt.Errorf("CORS_ORIGINS is required")
}
if cfg.DefaultTimezone == "" {
return nil, fmt.Errorf("DEFAULT_TIMEZONE is required")
}
@@ -0,0 +1,315 @@
package config
import (
"os"
"testing"
)
func TestLoad_Success(t *testing.T) {
os.Setenv("DB_PATH", "/test/db.sqlite")
os.Setenv("APP_URL", "http://test.com")
os.Setenv("JWT_SECRET", "test-secret")
os.Setenv("JWT_EXPIRY", "1h")
os.Setenv("REFRESH_TOKEN_EXPIRY", "7d")
os.Setenv("DEFAULT_TIMEZONE", "UTC")
defer clearEnv()
cfg, err := Load()
if err != nil {
t.Fatalf("Load() error = %v", err)
}
if cfg.DBPath != "/test/db.sqlite" {
t.Errorf("DBPath = %v, want %v", cfg.DBPath, "/test/db.sqlite")
}
if cfg.AppURL != "http://test.com" {
t.Errorf("AppURL = %v, want %v", cfg.AppURL, "http://test.com")
}
if cfg.JWTSecret != "test-secret" {
t.Errorf("JWTSecret = %v, want %v", cfg.JWTSecret, "test-secret")
}
if cfg.JWTExpiry != "1h" {
t.Errorf("JWTExpiry = %v, want %v", cfg.JWTExpiry, "1h")
}
if cfg.RefreshTokenExpiry != "7d" {
t.Errorf("RefreshTokenExpiry = %v, want %v", cfg.RefreshTokenExpiry, "7d")
}
if cfg.DefaultTimezone != "UTC" {
t.Errorf("DefaultTimezone = %v, want %v", cfg.DefaultTimezone, "UTC")
}
}
func TestLoad_WithDefaults(t *testing.T) {
os.Setenv("DB_PATH", "/test/db.sqlite")
os.Setenv("APP_URL", "http://test.com")
os.Setenv("JWT_SECRET", "test-secret")
os.Setenv("JWT_EXPIRY", "1h")
os.Setenv("REFRESH_TOKEN_EXPIRY", "7d")
os.Setenv("DEFAULT_TIMEZONE", "UTC")
defer clearEnv()
cfg, err := Load()
if err != nil {
t.Fatalf("Load() error = %v", err)
}
if cfg.Port != "8080" {
t.Errorf("Port = %v, want default %v", cfg.Port, "8080")
}
if cfg.SMTPPort != "587" {
t.Errorf("SMTPPort = %v, want default %v", cfg.SMTPPort, "587")
}
if cfg.SupportEmail != "contact@apocapoc.app" {
t.Errorf("SupportEmail = %v, want default %v", cfg.SupportEmail, "contact@apocapoc.app")
}
if cfg.SendWelcomeEmail != "false" {
t.Errorf("SendWelcomeEmail = %v, want default %v", cfg.SendWelcomeEmail, "false")
}
if cfg.RegistrationMode != "open" {
t.Errorf("RegistrationMode = %v, want default %v", cfg.RegistrationMode, "open")
}
}
func TestLoad_WithCustomDefaults(t *testing.T) {
os.Setenv("DB_PATH", "/test/db.sqlite")
os.Setenv("APP_URL", "http://test.com")
os.Setenv("JWT_SECRET", "test-secret")
os.Setenv("JWT_EXPIRY", "1h")
os.Setenv("REFRESH_TOKEN_EXPIRY", "7d")
os.Setenv("DEFAULT_TIMEZONE", "UTC")
os.Setenv("PORT", "3000")
os.Setenv("SMTP_PORT", "465")
os.Setenv("SUPPORT_EMAIL", "support@test.com")
os.Setenv("SEND_WELCOME_EMAIL", "true")
os.Setenv("REGISTRATION_MODE", "closed")
defer clearEnv()
cfg, err := Load()
if err != nil {
t.Fatalf("Load() error = %v", err)
}
if cfg.Port != "3000" {
t.Errorf("Port = %v, want %v", cfg.Port, "3000")
}
if cfg.SMTPPort != "465" {
t.Errorf("SMTPPort = %v, want %v", cfg.SMTPPort, "465")
}
if cfg.SupportEmail != "support@test.com" {
t.Errorf("SupportEmail = %v, want %v", cfg.SupportEmail, "support@test.com")
}
if cfg.SendWelcomeEmail != "true" {
t.Errorf("SendWelcomeEmail = %v, want %v", cfg.SendWelcomeEmail, "true")
}
if cfg.RegistrationMode != "closed" {
t.Errorf("RegistrationMode = %v, want %v", cfg.RegistrationMode, "closed")
}
}
func TestLoad_MissingDBPath(t *testing.T) {
clearEnv()
os.Setenv("APP_URL", "http://test.com")
os.Setenv("JWT_SECRET", "test-secret")
os.Setenv("JWT_EXPIRY", "1h")
os.Setenv("REFRESH_TOKEN_EXPIRY", "7d")
os.Setenv("DEFAULT_TIMEZONE", "UTC")
defer clearEnv()
_, err := Load()
if err == nil {
t.Fatal("Load() expected error for missing DB_PATH but got nil")
}
expectedMsg := "DB_PATH is required"
if err.Error() != expectedMsg {
t.Errorf("Load() error = %v, want %v", err.Error(), expectedMsg)
}
}
func TestLoad_MissingJWTSecret(t *testing.T) {
clearEnv()
os.Setenv("DB_PATH", "/test/db.sqlite")
os.Setenv("APP_URL", "http://test.com")
os.Setenv("JWT_EXPIRY", "1h")
os.Setenv("REFRESH_TOKEN_EXPIRY", "7d")
os.Setenv("DEFAULT_TIMEZONE", "UTC")
defer clearEnv()
_, err := Load()
if err == nil {
t.Fatal("Load() expected error for missing JWT_SECRET but got nil")
}
expectedMsg := "JWT_SECRET is required"
if err.Error() != expectedMsg {
t.Errorf("Load() error = %v, want %v", err.Error(), expectedMsg)
}
}
func TestLoad_MissingJWTExpiry(t *testing.T) {
clearEnv()
os.Setenv("DB_PATH", "/test/db.sqlite")
os.Setenv("APP_URL", "http://test.com")
os.Setenv("JWT_SECRET", "test-secret")
os.Setenv("REFRESH_TOKEN_EXPIRY", "7d")
os.Setenv("DEFAULT_TIMEZONE", "UTC")
defer clearEnv()
_, err := Load()
if err == nil {
t.Fatal("Load() expected error for missing JWT_EXPIRY but got nil")
}
expectedMsg := "JWT_EXPIRY is required"
if err.Error() != expectedMsg {
t.Errorf("Load() error = %v, want %v", err.Error(), expectedMsg)
}
}
func TestLoad_MissingRefreshTokenExpiry(t *testing.T) {
clearEnv()
os.Setenv("DB_PATH", "/test/db.sqlite")
os.Setenv("APP_URL", "http://test.com")
os.Setenv("JWT_SECRET", "test-secret")
os.Setenv("JWT_EXPIRY", "1h")
os.Setenv("DEFAULT_TIMEZONE", "UTC")
defer clearEnv()
_, err := Load()
if err == nil {
t.Fatal("Load() expected error for missing REFRESH_TOKEN_EXPIRY but got nil")
}
expectedMsg := "REFRESH_TOKEN_EXPIRY is required"
if err.Error() != expectedMsg {
t.Errorf("Load() error = %v, want %v", err.Error(), expectedMsg)
}
}
func TestLoad_MissingDefaultTimezone(t *testing.T) {
clearEnv()
os.Setenv("DB_PATH", "/test/db.sqlite")
os.Setenv("APP_URL", "http://test.com")
os.Setenv("JWT_SECRET", "test-secret")
os.Setenv("JWT_EXPIRY", "1h")
os.Setenv("REFRESH_TOKEN_EXPIRY", "7d")
defer clearEnv()
_, err := Load()
if err == nil {
t.Fatal("Load() expected error for missing DEFAULT_TIMEZONE but got nil")
}
expectedMsg := "DEFAULT_TIMEZONE is required"
if err.Error() != expectedMsg {
t.Errorf("Load() error = %v, want %v", err.Error(), expectedMsg)
}
}
func TestLoad_WithSMTPConfig(t *testing.T) {
os.Setenv("DB_PATH", "/test/db.sqlite")
os.Setenv("APP_URL", "http://test.com")
os.Setenv("JWT_SECRET", "test-secret")
os.Setenv("JWT_EXPIRY", "1h")
os.Setenv("REFRESH_TOKEN_EXPIRY", "7d")
os.Setenv("DEFAULT_TIMEZONE", "UTC")
os.Setenv("SMTP_HOST", "smtp.test.com")
os.Setenv("SMTP_PORT", "587")
os.Setenv("SMTP_USER", "user@test.com")
os.Setenv("SMTP_PASSWORD", "test-password")
os.Setenv("SMTP_FROM", "noreply@test.com")
defer clearEnv()
cfg, err := Load()
if err != nil {
t.Fatalf("Load() error = %v", err)
}
if cfg.SMTPHost != "smtp.test.com" {
t.Errorf("SMTPHost = %v, want %v", cfg.SMTPHost, "smtp.test.com")
}
if cfg.SMTPPort != "587" {
t.Errorf("SMTPPort = %v, want %v", cfg.SMTPPort, "587")
}
if cfg.SMTPUser != "user@test.com" {
t.Errorf("SMTPUser = %v, want %v", cfg.SMTPUser, "user@test.com")
}
if cfg.SMTPPassword != "test-password" {
t.Errorf("SMTPPassword = %v, want %v", cfg.SMTPPassword, "test-password")
}
if cfg.SMTPFrom != "noreply@test.com" {
t.Errorf("SMTPFrom = %v, want %v", cfg.SMTPFrom, "noreply@test.com")
}
}
func TestGetEnvOrDefault(t *testing.T) {
tests := []struct {
name string
key string
defaultValue string
envValue string
want string
}{
{
name: "env value exists",
key: "TEST_KEY",
defaultValue: "default",
envValue: "custom",
want: "custom",
},
{
name: "env value empty uses default",
key: "TEST_KEY_EMPTY",
defaultValue: "default",
envValue: "",
want: "default",
},
{
name: "env value not set uses default",
key: "TEST_KEY_NOT_SET",
defaultValue: "default",
envValue: "",
want: "default",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if tt.envValue != "" {
os.Setenv(tt.key, tt.envValue)
defer os.Unsetenv(tt.key)
}
got := getEnvOrDefault(tt.key, tt.defaultValue)
if got != tt.want {
t.Errorf("getEnvOrDefault() = %v, want %v", got, tt.want)
}
})
}
}
func clearEnv() {
os.Unsetenv("DB_PATH")
os.Unsetenv("PORT")
os.Unsetenv("APP_URL")
os.Unsetenv("JWT_SECRET")
os.Unsetenv("JWT_EXPIRY")
os.Unsetenv("REFRESH_TOKEN_EXPIRY")
os.Unsetenv("DEFAULT_TIMEZONE")
os.Unsetenv("SMTP_HOST")
os.Unsetenv("SMTP_PORT")
os.Unsetenv("SMTP_USER")
os.Unsetenv("SMTP_PASSWORD")
os.Unsetenv("SMTP_FROM")
os.Unsetenv("SUPPORT_EMAIL")
os.Unsetenv("SEND_WELCOME_EMAIL")
os.Unsetenv("REGISTRATION_MODE")
}
@@ -0,0 +1,217 @@
package crypto
import (
"testing"
"golang.org/x/crypto/bcrypt"
)
func TestNewBcryptHasher(t *testing.T) {
hasher := NewBcryptHasher()
if hasher == nil {
t.Fatal("NewBcryptHasher() returned nil")
}
_, ok := hasher.(*BcryptHasher)
if !ok {
t.Fatal("NewBcryptHasher() did not return *BcryptHasher")
}
}
func TestBcryptHasher_Hash(t *testing.T) {
hasher := NewBcryptHasher()
tests := []struct {
name string
password string
}{
{
name: "simple password",
password: "password123",
},
{
name: "complex password",
password: "P@ssw0rd!123$%^&*()",
},
{
name: "empty password",
password: "",
},
{
name: "unicode password",
password: "pässwörd123",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
hashed, err := hasher.Hash(tt.password)
if err != nil {
t.Fatalf("Hash() error = %v", err)
}
if hashed == "" {
t.Fatal("Hash() returned empty string")
}
if hashed == tt.password {
t.Fatal("Hash() returned the same as input password")
}
err = bcrypt.CompareHashAndPassword([]byte(hashed), []byte(tt.password))
if err != nil {
t.Fatalf("Generated hash does not match original password: %v", err)
}
})
}
}
func TestBcryptHasher_Compare(t *testing.T) {
hasher := NewBcryptHasher()
tests := []struct {
name string
password string
compareWith string
expectError bool
errorAssertion func(error) bool
}{
{
name: "matching passwords",
password: "password123",
compareWith: "password123",
expectError: false,
},
{
name: "non-matching passwords",
password: "password123",
compareWith: "wrongpassword",
expectError: true,
errorAssertion: func(err error) bool {
return err == bcrypt.ErrMismatchedHashAndPassword
},
},
{
name: "empty password comparison",
password: "",
compareWith: "",
expectError: false,
},
{
name: "unicode password match",
password: "pässwörd123",
compareWith: "pässwörd123",
expectError: false,
},
{
name: "unicode password mismatch",
password: "pässwörd123",
compareWith: "password123",
expectError: true,
errorAssertion: func(err error) bool {
return err == bcrypt.ErrMismatchedHashAndPassword
},
},
{
name: "case sensitive",
password: "Password123",
compareWith: "password123",
expectError: true,
errorAssertion: func(err error) bool {
return err == bcrypt.ErrMismatchedHashAndPassword
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
hashed, err := hasher.Hash(tt.password)
if err != nil {
t.Fatalf("Hash() error = %v", err)
}
err = hasher.Compare(hashed, tt.compareWith)
if tt.expectError {
if err == nil {
t.Fatal("Compare() expected error but got nil")
}
if tt.errorAssertion != nil && !tt.errorAssertion(err) {
t.Fatalf("Compare() error = %v, but assertion failed", err)
}
} else {
if err != nil {
t.Fatalf("Compare() unexpected error = %v", err)
}
}
})
}
}
func TestBcryptHasher_CompareWithInvalidHash(t *testing.T) {
hasher := NewBcryptHasher()
tests := []struct {
name string
invalidHash string
password string
expectError bool
}{
{
name: "invalid hash format",
invalidHash: "not-a-valid-hash",
password: "password123",
expectError: true,
},
{
name: "empty hash",
invalidHash: "",
password: "password123",
expectError: true,
},
{
name: "corrupted hash",
invalidHash: "$2a$10$invalidhashdata",
password: "password123",
expectError: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := hasher.Compare(tt.invalidHash, tt.password)
if !tt.expectError && err != nil {
t.Fatalf("Compare() unexpected error = %v", err)
}
if tt.expectError && err == nil {
t.Fatal("Compare() expected error but got nil")
}
})
}
}
func TestBcryptHasher_HashGeneratesDifferentHashes(t *testing.T) {
hasher := NewBcryptHasher()
password := "samePassword123"
hash1, err := hasher.Hash(password)
if err != nil {
t.Fatalf("Hash() error = %v", err)
}
hash2, err := hasher.Hash(password)
if err != nil {
t.Fatalf("Hash() error = %v", err)
}
if hash1 == hash2 {
t.Fatal("Hash() generated identical hashes for same password (should use salt)")
}
if err := hasher.Compare(hash1, password); err != nil {
t.Fatalf("First hash doesn't match password: %v", err)
}
if err := hasher.Compare(hash2, password); err != nil {
t.Fatalf("Second hash doesn't match password: %v", err)
}
}
@@ -0,0 +1,133 @@
package email
import (
"crypto/tls"
"fmt"
"log"
"strings"
"time"
"apocapoc-api/internal/domain/services"
"gopkg.in/mail.v2"
)
type SMTPConfig struct {
Host string
Port int
Username string
Password string
From string
SupportEmail string
}
type SMTPService struct {
config SMTPConfig
}
func NewSMTPService(config SMTPConfig) *SMTPService {
return &SMTPService{
config: config,
}
}
func (s *SMTPService) Send(message services.EmailMessage) error {
m := mail.NewMessage()
m.SetHeader("From", s.config.From)
m.SetHeader("To", message.To)
m.SetHeader("Subject", message.Subject)
if message.IsHTML {
m.SetBody("text/html", message.Body)
} else {
m.SetBody("text/plain", message.Body)
}
dialer := mail.NewDialer(s.config.Host, s.config.Port, s.config.Username, s.config.Password)
dialer.TLSConfig = &tls.Config{
ServerName: s.config.Host,
}
// Use SSL from start for port 465, STARTTLS for other ports
if s.config.Port == 465 {
dialer.SSL = true
}
if err := s.sendWithRetry(dialer, m); err != nil {
log.Printf("[EMAIL] status=failed to=%s subject=%q error=%q", message.To, message.Subject, err.Error())
return fmt.Errorf("failed to send email: %w", err)
}
log.Printf("[EMAIL] status=sent to=%s subject=%q", message.To, message.Subject)
return nil
}
func (s *SMTPService) sendWithRetry(dialer *mail.Dialer, message *mail.Message) error {
maxRetries := 3
var lastErr error
for i := 0; i < maxRetries; i++ {
if err := dialer.DialAndSend(message); err == nil {
return nil
} else {
lastErr = err
if isAuthError(err) {
return fmt.Errorf("SMTP authentication failed. Please check your SMTP credentials (username, password, and from address)")
}
if isConfigError(err) {
return fmt.Errorf("SMTP configuration error: %w", err)
}
if i < maxRetries-1 {
time.Sleep(time.Second * time.Duration(i+1))
}
}
}
return fmt.Errorf("failed to send email after %d attempts: %w", maxRetries, lastErr)
}
func isAuthError(err error) bool {
errStr := strings.ToLower(err.Error())
return strings.Contains(errStr, "authentication failed") ||
strings.Contains(errStr, "535") ||
strings.Contains(errStr, "invalid credentials")
}
func isConfigError(err error) bool {
errStr := strings.ToLower(err.Error())
return strings.Contains(errStr, "connection refused") ||
strings.Contains(errStr, "no such host") ||
strings.Contains(errStr, "network is unreachable")
}
func (s *SMTPService) GetConfig() SMTPConfig {
return s.config
}
func (s *SMTPService) HealthCheck() error {
dialer := mail.NewDialer(s.config.Host, s.config.Port, s.config.Username, s.config.Password)
dialer.TLSConfig = &tls.Config{
ServerName: s.config.Host,
}
if s.config.Port == 465 {
dialer.SSL = true
}
smtpCloser, err := dialer.Dial()
if err != nil {
if isAuthError(err) {
return fmt.Errorf("SMTP authentication failed: %w", err)
}
if isConfigError(err) {
return fmt.Errorf("SMTP connection failed: %w", err)
}
return fmt.Errorf("SMTP error: %w", err)
}
defer smtpCloser.Close()
return nil
}
@@ -0,0 +1,299 @@
package email
import (
"strings"
"testing"
"apocapoc-api/internal/domain/services"
)
func TestNewSMTPService(t *testing.T) {
config := SMTPConfig{
Host: "smtp.example.com",
Port: 587,
Username: "user@example.com",
Password: "password",
From: "noreply@example.com",
SupportEmail: "support@example.com",
}
service := NewSMTPService(config)
if service == nil {
t.Fatal("Expected service to be created")
}
if service.GetConfig().Host != config.Host {
t.Errorf("Expected host %s, got %s", config.Host, service.GetConfig().Host)
}
if service.GetConfig().Port != config.Port {
t.Errorf("Expected port %d, got %d", config.Port, service.GetConfig().Port)
}
if service.GetConfig().From != config.From {
t.Errorf("Expected from %s, got %s", config.From, service.GetConfig().From)
}
}
func TestSMTPService_ConfigValidation(t *testing.T) {
tests := []struct {
name string
config SMTPConfig
}{
{
name: "Port 587 (STARTTLS)",
config: SMTPConfig{
Host: "smtp.example.com",
Port: 587,
Username: "user@example.com",
Password: "password",
From: "noreply@example.com",
},
},
{
name: "Port 465 (SSL)",
config: SMTPConfig{
Host: "smtp.example.com",
Port: 465,
Username: "user@example.com",
Password: "password",
From: "noreply@example.com",
},
},
{
name: "Port 25 (Plain)",
config: SMTPConfig{
Host: "smtp.example.com",
Port: 25,
Username: "user@example.com",
Password: "password",
From: "noreply@example.com",
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
service := NewSMTPService(tt.config)
if service == nil {
t.Fatal("Expected service to be created")
}
if service.GetConfig().Port != tt.config.Port {
t.Errorf("Expected port %d, got %d", tt.config.Port, service.GetConfig().Port)
}
})
}
}
func TestSMTPService_MessageTypes(t *testing.T) {
config := SMTPConfig{
Host: "smtp.example.com",
Port: 587,
Username: "user@example.com",
Password: "password",
From: "noreply@example.com",
SupportEmail: "support@example.com",
}
service := NewSMTPService(config)
tests := []struct {
name string
message services.EmailMessage
}{
{
name: "HTML message",
message: services.EmailMessage{
To: "recipient@example.com",
Subject: "Test Email",
Body: "<h1>Test</h1>",
IsHTML: true,
},
},
{
name: "Plain text message",
message: services.EmailMessage{
To: "recipient@example.com",
Subject: "Test Email",
Body: "Plain text body",
IsHTML: false,
},
},
{
name: "Message with special characters",
message: services.EmailMessage{
To: "recipient@example.com",
Subject: "Test Email with émojis 🎉",
Body: "<p>Special chars: ñ, á, ü, €</p>",
IsHTML: true,
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if tt.message.To == "" {
t.Error("Expected recipient to be set")
}
if tt.message.Subject == "" {
t.Error("Expected subject to be set")
}
if tt.message.Body == "" {
t.Error("Expected body to be set")
}
if service == nil {
t.Fatal("Service should not be nil")
}
})
}
}
func TestIsAuthError(t *testing.T) {
tests := []struct {
name string
errStr string
expected bool
}{
{
name: "Authentication failed error",
errStr: "535 Authentication failed",
expected: true,
},
{
name: "Invalid credentials error",
errStr: "Invalid credentials provided",
expected: true,
},
{
name: "535 error code",
errStr: "535 5.7.8 Error",
expected: true,
},
{
name: "Connection refused (not auth)",
errStr: "connection refused",
expected: false,
},
{
name: "Generic error (not auth)",
errStr: "some other error",
expected: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := &mockError{msg: tt.errStr}
result := isAuthError(err)
if result != tt.expected {
t.Errorf("Expected %v, got %v for error: %s", tt.expected, result, tt.errStr)
}
})
}
}
func TestIsConfigError(t *testing.T) {
tests := []struct {
name string
errStr string
expected bool
}{
{
name: "Connection refused",
errStr: "connection refused",
expected: true,
},
{
name: "No such host",
errStr: "no such host smtp.invalid.com",
expected: true,
},
{
name: "Network unreachable",
errStr: "network is unreachable",
expected: true,
},
{
name: "Authentication error (not config)",
errStr: "authentication failed",
expected: false,
},
{
name: "Generic error (not config)",
errStr: "some other error",
expected: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := &mockError{msg: tt.errStr}
result := isConfigError(err)
if result != tt.expected {
t.Errorf("Expected %v, got %v for error: %s", tt.expected, result, tt.errStr)
}
})
}
}
func TestSMTPService_Send_InvalidConfig(t *testing.T) {
config := SMTPConfig{
Host: "invalid.smtp.server.that.does.not.exist",
Port: 587,
Username: "user@example.com",
Password: "password",
From: "noreply@example.com",
}
service := NewSMTPService(config)
message := services.EmailMessage{
To: "test@example.com",
Subject: "Test",
Body: "Test body",
IsHTML: false,
}
err := service.Send(message)
if err == nil {
t.Error("Expected error when sending to invalid SMTP server")
}
if !strings.Contains(err.Error(), "failed to send email") {
t.Errorf("Expected error message to contain 'failed to send email', got: %s", err.Error())
}
}
func TestSMTPService_HealthCheck_InvalidConfig(t *testing.T) {
config := SMTPConfig{
Host: "invalid.smtp.server.that.does.not.exist",
Port: 587,
Username: "user@example.com",
Password: "password",
From: "noreply@example.com",
}
service := NewSMTPService(config)
err := service.HealthCheck()
if err == nil {
t.Error("Expected error when health checking invalid SMTP server")
}
if !strings.Contains(err.Error(), "SMTP") {
t.Errorf("Expected error message to contain 'SMTP', got: %s", err.Error())
}
}
type mockError struct {
msg string
}
func (e *mockError) Error() string {
return e.msg
}
@@ -0,0 +1,49 @@
package email
import (
"bytes"
"fmt"
"html/template"
)
type TemplateData struct {
AppName string
AppURL string
SupportEmail string
Data map[string]interface{}
}
type TemplateRenderer struct {
appName string
appURL string
supportEmail string
}
func NewTemplateRenderer(appName, appURL, supportEmail string) *TemplateRenderer {
return &TemplateRenderer{
appName: appName,
appURL: appURL,
supportEmail: supportEmail,
}
}
func (r *TemplateRenderer) Render(templateContent string, data map[string]interface{}) (string, error) {
tmpl, err := template.New("email").Parse(templateContent)
if err != nil {
return "", fmt.Errorf("failed to parse template: %w", err)
}
templateData := TemplateData{
AppName: r.appName,
AppURL: r.appURL,
SupportEmail: r.supportEmail,
Data: data,
}
var buf bytes.Buffer
if err := tmpl.Execute(&buf, templateData); err != nil {
return "", fmt.Errorf("failed to execute template: %w", err)
}
return buf.String(), nil
}
@@ -0,0 +1,97 @@
package email
import (
"strings"
"testing"
)
func TestNewTemplateRenderer(t *testing.T) {
renderer := NewTemplateRenderer("Test App", "https://example.com", "support@example.com")
if renderer == nil {
t.Fatal("Expected renderer to be created")
}
if renderer.appName != "Test App" {
t.Errorf("Expected app name 'Test App', got '%s'", renderer.appName)
}
}
func TestTemplateRenderer_Render(t *testing.T) {
renderer := NewTemplateRenderer("Test App", "https://example.com", "support@example.com")
template := `Hello {{.Data.Name}}, welcome to {{.AppName}}!`
data := map[string]interface{}{
"Name": "John",
}
result, err := renderer.Render(template, data)
if err != nil {
t.Fatalf("Failed to render template: %v", err)
}
expected := "Hello John, welcome to Test App!"
if result != expected {
t.Errorf("Expected '%s', got '%s'", expected, result)
}
}
func TestTemplateRenderer_RenderWithAllVariables(t *testing.T) {
renderer := NewTemplateRenderer("My App", "https://myapp.com", "help@myapp.com")
template := `
App: {{.AppName}}
URL: {{.AppURL}}
Support: {{.SupportEmail}}
User: {{.Data.User}}
`
data := map[string]interface{}{
"User": "Alice",
}
result, err := renderer.Render(template, data)
if err != nil {
t.Fatalf("Failed to render template: %v", err)
}
if !strings.Contains(result, "My App") {
t.Error("Expected result to contain app name")
}
if !strings.Contains(result, "https://myapp.com") {
t.Error("Expected result to contain app URL")
}
if !strings.Contains(result, "help@myapp.com") {
t.Error("Expected result to contain support email")
}
if !strings.Contains(result, "Alice") {
t.Error("Expected result to contain user name")
}
}
func TestTemplateRenderer_RenderInvalidTemplate(t *testing.T) {
renderer := NewTemplateRenderer("Test App", "https://example.com", "support@example.com")
template := `{{.Data.Invalid}}`
data := map[string]interface{}{}
result, err := renderer.Render(template, data)
if err != nil {
t.Fatalf("Template should render even with missing data: %v", err)
}
if result != "<no value>" {
t.Logf("Got result: %s", result)
}
}
func TestTemplateRenderer_RenderSyntaxError(t *testing.T) {
renderer := NewTemplateRenderer("Test App", "https://example.com", "support@example.com")
template := `{{.Data.Name`
data := map[string]interface{}{}
_, err := renderer.Render(template, data)
if err == nil {
t.Error("Expected error for invalid template syntax")
}
}
@@ -0,0 +1,77 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<style>
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif;
line-height: 1.6;
color: #333;
max-width: 600px;
margin: 0 auto;
padding: 20px;
background-color: #f4f4f4;
}
.container {
background-color: #ffffff;
border-radius: 8px;
padding: 30px;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
}
.header {
text-align: center;
margin-bottom: 30px;
padding-bottom: 20px;
border-bottom: 2px solid #f0f0f0;
}
.header h1 {
margin: 0;
color: #2c3e50;
font-size: 24px;
}
.content {
margin-bottom: 30px;
}
.button {
display: inline-block;
padding: 12px 24px;
background-color: #3498db;
color: #ffffff !important;
text-decoration: none;
border-radius: 4px;
margin: 20px 0;
font-weight: 600;
}
.button:hover {
background-color: #2980b9;
}
.footer {
text-align: center;
margin-top: 30px;
padding-top: 20px;
border-top: 2px solid #f0f0f0;
font-size: 12px;
color: #7f8c8d;
}
.footer a {
color: #3498db;
text-decoration: none;
}
</style>
</head>
<body>
<div class="container">
<div class="header">
<h1>{{.AppName}}</h1>
</div>
<div class="content">
{{.Content}}
</div>
<div class="footer">
<p>Need help? Contact us at <a href="mailto:{{.SupportEmail}}">{{.SupportEmail}}</a></p>
<p>&copy; {{.AppName}}. All rights reserved.</p>
</div>
</div>
</body>
</html>
+290 -69
View File
@@ -2,25 +2,32 @@ package http
import (
"encoding/json"
"errors"
"net/http"
"time"
"apocapoc-api/internal/application/commands"
"apocapoc-api/internal/application/queries"
"apocapoc-api/internal/domain/repositories"
"apocapoc-api/internal/i18n"
"apocapoc-api/internal/infrastructure/auth"
"apocapoc-api/internal/shared/errors"
appErrors "apocapoc-api/internal/shared/errors"
)
type AuthHandlers struct {
registerHandler *commands.RegisterUserHandler
loginHandler *queries.LoginUserHandler
refreshTokenHandler *queries.RefreshTokenHandler
revokeTokenHandler *commands.RevokeTokenHandler
revokeAllTokensHandler *commands.RevokeAllTokensHandler
jwtService *auth.JWTService
refreshTokenRepo repositories.RefreshTokenRepository
refreshTokenExpiry time.Duration
registerHandler *commands.RegisterUserHandler
loginHandler *queries.LoginUserHandler
refreshTokenHandler *queries.RefreshTokenHandler
revokeTokenHandler *commands.RevokeTokenHandler
revokeAllTokensHandler *commands.RevokeAllTokensHandler
verifyEmailHandler *commands.VerifyEmailHandler
resendVerificationEmailHandler *commands.ResendVerificationEmailHandler
requestPasswordResetHandler *commands.RequestPasswordResetHandler
resetPasswordHandler *commands.ResetPasswordHandler
jwtService *auth.JWTService
refreshTokenRepo repositories.RefreshTokenRepository
refreshTokenExpiry time.Duration
translator *i18n.Translator
}
func NewAuthHandlers(
@@ -29,26 +36,35 @@ func NewAuthHandlers(
refreshTokenHandler *queries.RefreshTokenHandler,
revokeTokenHandler *commands.RevokeTokenHandler,
revokeAllTokensHandler *commands.RevokeAllTokensHandler,
verifyEmailHandler *commands.VerifyEmailHandler,
resendVerificationEmailHandler *commands.ResendVerificationEmailHandler,
requestPasswordResetHandler *commands.RequestPasswordResetHandler,
resetPasswordHandler *commands.ResetPasswordHandler,
jwtService *auth.JWTService,
refreshTokenRepo repositories.RefreshTokenRepository,
refreshTokenExpiry time.Duration,
translator *i18n.Translator,
) *AuthHandlers {
return &AuthHandlers{
registerHandler: registerHandler,
loginHandler: loginHandler,
refreshTokenHandler: refreshTokenHandler,
revokeTokenHandler: revokeTokenHandler,
revokeAllTokensHandler: revokeAllTokensHandler,
jwtService: jwtService,
refreshTokenRepo: refreshTokenRepo,
refreshTokenExpiry: refreshTokenExpiry,
registerHandler: registerHandler,
loginHandler: loginHandler,
refreshTokenHandler: refreshTokenHandler,
revokeTokenHandler: revokeTokenHandler,
revokeAllTokensHandler: revokeAllTokensHandler,
verifyEmailHandler: verifyEmailHandler,
resendVerificationEmailHandler: resendVerificationEmailHandler,
requestPasswordResetHandler: requestPasswordResetHandler,
resetPasswordHandler: resetPasswordHandler,
jwtService: jwtService,
refreshTokenRepo: refreshTokenRepo,
refreshTokenExpiry: refreshTokenExpiry,
translator: translator,
}
}
type RegisterRequest struct {
Email string `json:"email"`
Password string `json:"password"`
Timezone string `json:"timezone"`
}
type LoginRequest struct {
@@ -62,6 +78,11 @@ type AuthResponse struct {
UserID string `json:"user_id"`
}
type RegisterResponse struct {
UserID string `json:"user_id"`
Message string `json:"message"`
}
type RefreshRequest struct {
RefreshToken string `json:"refresh_token"`
}
@@ -72,64 +93,58 @@ type LogoutRequest struct {
// Register godoc
// @Summary Register a new user
// @Description Create a new user account and receive both access token and refresh token. Store both tokens securely - the refresh token is used to obtain new access tokens when they expire.
// @Description Create a new user account. If email verification is enabled, you will receive a verification email. Otherwise, you can login immediately.
// @Tags auth
// @Accept json
// @Produce json
// @Param request body RegisterRequest true "Registration data (password requires: min 8 chars, uppercase, lowercase, digit, special char)"
// @Success 201 {object} AuthResponse "Returns access token, refresh token, and user ID"
// @Failure 400 {object} ErrorResponse "Invalid input: email format, password requirements, or timezone"
// @Success 201 {object} RegisterResponse "Returns user ID and message about next steps"
// @Failure 400 {object} ValidationErrorResponse "Invalid input: email format or password requirements"
// @Failure 403 {object} ErrorResponse "Registration is closed"
// @Failure 409 {object} ErrorResponse "Email already registered"
// @Failure 500 {object} ErrorResponse "Internal server error"
// @Router /auth/register [post]
func (h *AuthHandlers) Register(w http.ResponseWriter, r *http.Request) {
var req RegisterRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
respondError(w, http.StatusBadRequest, "Invalid request body")
respondErrorI18n(w, r, h.translator, http.StatusBadRequest, "invalid_request_body")
return
}
cmd := commands.RegisterUserCommand{
Email: req.Email,
Password: req.Password,
Timezone: req.Timezone,
}
userID, err := h.registerHandler.Handle(r.Context(), cmd)
result, err := h.registerHandler.Handle(r.Context(), cmd)
if err != nil {
if err == errors.ErrInvalidInput {
respondError(w, http.StatusBadRequest, "Invalid email or password (min 8 characters)")
if errors.Is(err, appErrors.ErrInvalidInput) {
respondValidationErrorI18n(w, r, h.translator, err)
return
}
if err == errors.ErrAlreadyExists {
respondError(w, http.StatusConflict, "Email already registered")
if err == appErrors.ErrAlreadyExists {
respondErrorI18n(w, r, h.translator, http.StatusConflict, "email_already_registered")
return
}
respondError(w, http.StatusInternalServerError, "Failed to register user")
if err == appErrors.ErrRegistrationClosed {
respondErrorI18n(w, r, h.translator, http.StatusForbidden, "registration_closed")
return
}
respondErrorI18n(w, r, h.translator, http.StatusInternalServerError, "failed_register_user")
return
}
token, err := h.jwtService.GenerateToken(userID, req.Email)
if err != nil {
respondError(w, http.StatusInternalServerError, "Failed to generate token")
return
lang := i18n.GetLanguageFromContext(r.Context())
var message string
if result.EmailVerificationRequired {
message = h.translator.Success(lang, "registration_with_verification")
} else {
message = h.translator.Success(lang, "registration_without_verification")
}
refreshToken, err := queries.CreateRefreshToken(userID, h.refreshTokenExpiry)
if err != nil {
respondError(w, http.StatusInternalServerError, "Failed to create refresh token")
return
}
if err := h.refreshTokenRepo.Create(r.Context(), refreshToken); err != nil {
respondError(w, http.StatusInternalServerError, "Failed to save refresh token")
return
}
respondJSON(w, http.StatusCreated, AuthResponse{
Token: token,
RefreshToken: refreshToken.Token,
UserID: userID,
respondJSON(w, http.StatusCreated, RegisterResponse{
UserID: result.UserID,
Message: message,
})
}
@@ -143,12 +158,13 @@ func (h *AuthHandlers) Register(w http.ResponseWriter, r *http.Request) {
// @Success 200 {object} AuthResponse "Returns access token, refresh token, and user ID"
// @Failure 400 {object} ErrorResponse "Invalid request body"
// @Failure 401 {object} ErrorResponse "Invalid email or password"
// @Failure 403 {object} ErrorResponse "Email not verified"
// @Failure 500 {object} ErrorResponse "Internal server error"
// @Router /auth/login [post]
func (h *AuthHandlers) Login(w http.ResponseWriter, r *http.Request) {
var req LoginRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
respondError(w, http.StatusBadRequest, "Invalid request body")
respondErrorI18n(w, r, h.translator, http.StatusBadRequest, "invalid_request_body")
return
}
@@ -159,28 +175,32 @@ func (h *AuthHandlers) Login(w http.ResponseWriter, r *http.Request) {
result, err := h.loginHandler.Handle(r.Context(), query)
if err != nil {
if err == errors.ErrNotFound || err == errors.ErrInvalidInput {
respondError(w, http.StatusUnauthorized, "Invalid email or password")
if err == appErrors.ErrNotFound || err == appErrors.ErrInvalidInput {
respondErrorI18n(w, r, h.translator, http.StatusUnauthorized, "invalid_credentials")
return
}
respondError(w, http.StatusInternalServerError, "Failed to login")
if err == appErrors.ErrEmailNotVerified {
respondErrorI18n(w, r, h.translator, http.StatusForbidden, "email_not_verified")
return
}
respondErrorI18n(w, r, h.translator, http.StatusInternalServerError, "failed_login")
return
}
token, err := h.jwtService.GenerateToken(result.UserID, result.Email)
if err != nil {
respondError(w, http.StatusInternalServerError, "Failed to generate token")
respondErrorI18n(w, r, h.translator, http.StatusInternalServerError, "failed_generate_token")
return
}
refreshToken, err := queries.CreateRefreshToken(result.UserID, h.refreshTokenExpiry)
if err != nil {
respondError(w, http.StatusInternalServerError, "Failed to create refresh token")
respondErrorI18n(w, r, h.translator, http.StatusInternalServerError, "failed_create_refresh_token")
return
}
if err := h.refreshTokenRepo.Create(r.Context(), refreshToken); err != nil {
respondError(w, http.StatusInternalServerError, "Failed to save refresh token")
respondErrorI18n(w, r, h.translator, http.StatusInternalServerError, "failed_save_refresh_token")
return
}
@@ -206,7 +226,7 @@ func (h *AuthHandlers) Login(w http.ResponseWriter, r *http.Request) {
func (h *AuthHandlers) Refresh(w http.ResponseWriter, r *http.Request) {
var req RefreshRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
respondError(w, http.StatusBadRequest, "Invalid request body")
respondErrorI18n(w, r, h.translator, http.StatusBadRequest, "invalid_request_body")
return
}
@@ -216,28 +236,28 @@ func (h *AuthHandlers) Refresh(w http.ResponseWriter, r *http.Request) {
result, err := h.refreshTokenHandler.Handle(r.Context(), query)
if err != nil {
if err == errors.ErrNotFound || err == errors.ErrInvalidInput {
respondError(w, http.StatusUnauthorized, "Invalid or expired refresh token")
if err == appErrors.ErrNotFound || err == appErrors.ErrInvalidInput {
respondErrorI18n(w, r, h.translator, http.StatusUnauthorized, "invalid_expired_refresh_token")
return
}
respondError(w, http.StatusInternalServerError, "Failed to refresh token")
respondErrorI18n(w, r, h.translator, http.StatusInternalServerError, "failed_refresh_token")
return
}
token, err := h.jwtService.GenerateToken(result.UserID, result.Email)
if err != nil {
respondError(w, http.StatusInternalServerError, "Failed to generate token")
respondErrorI18n(w, r, h.translator, http.StatusInternalServerError, "failed_generate_token")
return
}
newRefreshToken, err := queries.CreateRefreshToken(result.UserID, h.refreshTokenExpiry)
if err != nil {
respondError(w, http.StatusInternalServerError, "Failed to create refresh token")
respondErrorI18n(w, r, h.translator, http.StatusInternalServerError, "failed_create_refresh_token")
return
}
if err := h.refreshTokenRepo.Create(r.Context(), newRefreshToken); err != nil {
respondError(w, http.StatusInternalServerError, "Failed to save refresh token")
respondErrorI18n(w, r, h.translator, http.StatusInternalServerError, "failed_save_refresh_token")
return
}
@@ -266,7 +286,7 @@ func (h *AuthHandlers) Refresh(w http.ResponseWriter, r *http.Request) {
func (h *AuthHandlers) Logout(w http.ResponseWriter, r *http.Request) {
var req LogoutRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
respondError(w, http.StatusBadRequest, "Invalid request body")
respondErrorI18n(w, r, h.translator, http.StatusBadRequest, "invalid_request_body")
return
}
@@ -276,19 +296,220 @@ func (h *AuthHandlers) Logout(w http.ResponseWriter, r *http.Request) {
err := h.revokeTokenHandler.Handle(r.Context(), cmd)
if err != nil {
if err == errors.ErrNotFound {
respondError(w, http.StatusNotFound, "Refresh token not found")
if err == appErrors.ErrNotFound {
respondErrorI18n(w, r, h.translator, http.StatusNotFound, "refresh_token_not_found")
return
}
if err == errors.ErrInvalidInput {
respondError(w, http.StatusBadRequest, "Invalid refresh token")
if err == appErrors.ErrInvalidInput {
respondErrorI18n(w, r, h.translator, http.StatusBadRequest, "invalid_refresh_token")
return
}
respondError(w, http.StatusInternalServerError, "Failed to revoke token")
respondErrorI18n(w, r, h.translator, http.StatusInternalServerError, "failed_refresh_token")
return
}
lang := i18n.GetLanguageFromContext(r.Context())
respondJSON(w, http.StatusOK, map[string]string{
"message": "Successfully logged out",
"message": h.translator.Success(lang, "logged_out"),
})
}
type VerifyEmailRequest struct {
Token string `json:"token"`
}
type ResendVerificationRequest struct {
Email string `json:"email"`
}
// VerifyEmail godoc
// @Summary Verify email address
// @Description Verify user email address using the token sent via email
// @Tags auth
// @Accept json
// @Produce json
// @Param request body VerifyEmailRequest true "Verification token"
// @Success 200 {object} map[string]string "Email verified successfully"
// @Failure 400 {object} ErrorResponse "Invalid or expired token"
// @Failure 409 {object} ErrorResponse "Email already verified"
// @Failure 500 {object} ErrorResponse "Internal server error"
// @Router /auth/verify-email [post]
func (h *AuthHandlers) VerifyEmail(w http.ResponseWriter, r *http.Request) {
var req VerifyEmailRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
respondErrorI18n(w, r, h.translator, http.StatusBadRequest, "invalid_request_body")
return
}
cmd := commands.VerifyEmailCommand{
Token: req.Token,
}
err := h.verifyEmailHandler.Handle(r.Context(), cmd)
if err != nil {
if err == appErrors.ErrInvalidInput {
respondErrorI18n(w, r, h.translator, http.StatusBadRequest, "invalid_expired_verification_token")
return
}
if err == appErrors.ErrAlreadyExists {
respondErrorI18n(w, r, h.translator, http.StatusConflict, "email_already_verified")
return
}
respondErrorI18n(w, r, h.translator, http.StatusInternalServerError, "failed_verify_email")
return
}
lang := i18n.GetLanguageFromContext(r.Context())
respondJSON(w, http.StatusOK, map[string]string{
"message": h.translator.Success(lang, "email_verified"),
})
}
// ResendVerification godoc
// @Summary Resend verification email
// @Description Resend the email verification link to the user's email address
// @Tags auth
// @Accept json
// @Produce json
// @Param request body ResendVerificationRequest true "User email"
// @Success 200 {object} map[string]string "Verification email sent"
// @Failure 400 {object} ErrorResponse "Invalid email"
// @Failure 404 {object} ErrorResponse "User not found"
// @Failure 409 {object} ErrorResponse "Email already verified"
// @Failure 500 {object} ErrorResponse "Internal server error"
// @Router /auth/resend-verification [post]
func (h *AuthHandlers) ResendVerification(w http.ResponseWriter, r *http.Request) {
var req ResendVerificationRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
respondErrorI18n(w, r, h.translator, http.StatusBadRequest, "invalid_request_body")
return
}
cmd := commands.ResendVerificationEmailCommand{
Email: req.Email,
}
err := h.resendVerificationEmailHandler.Handle(r.Context(), cmd)
if err != nil {
if err == appErrors.ErrInvalidInput {
respondErrorI18n(w, r, h.translator, http.StatusBadRequest, "invalid_email")
return
}
if err == appErrors.ErrNotFound {
respondErrorI18n(w, r, h.translator, http.StatusNotFound, "user_not_found")
return
}
if err == appErrors.ErrAlreadyExists {
respondErrorI18n(w, r, h.translator, http.StatusConflict, "email_already_verified")
return
}
respondErrorI18n(w, r, h.translator, http.StatusInternalServerError, "failed_send_verification_email")
return
}
lang := i18n.GetLanguageFromContext(r.Context())
respondJSON(w, http.StatusOK, map[string]string{
"message": h.translator.Success(lang, "verification_email_sent"),
})
}
type ForgotPasswordRequest struct {
Email string `json:"email"`
}
type ResetPasswordRequest struct {
Token string `json:"token"`
NewPassword string `json:"new_password"`
}
// ForgotPassword godoc
// @Summary Request password reset
// @Description Request a password reset email with a reset token
// @Tags auth
// @Accept json
// @Produce json
// @Param request body ForgotPasswordRequest true "User email"
// @Success 200 {object} map[string]string "Reset email sent successfully"
// @Failure 400 {object} ErrorResponse "Invalid email"
// @Failure 403 {object} ErrorResponse "Email not verified"
// @Failure 404 {object} ErrorResponse "User not found"
// @Failure 500 {object} ErrorResponse "Internal server error"
// @Router /auth/forgot-password [post]
func (h *AuthHandlers) ForgotPassword(w http.ResponseWriter, r *http.Request) {
var req ForgotPasswordRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
respondErrorI18n(w, r, h.translator, http.StatusBadRequest, "invalid_request_body")
return
}
cmd := commands.RequestPasswordResetCommand{
Email: req.Email,
}
err := h.requestPasswordResetHandler.Handle(r.Context(), cmd)
if err != nil {
if err == appErrors.ErrInvalidInput {
respondErrorI18n(w, r, h.translator, http.StatusBadRequest, "invalid_email")
return
}
if err == appErrors.ErrNotFound {
respondErrorI18n(w, r, h.translator, http.StatusNotFound, "user_not_found")
return
}
if err == appErrors.ErrEmailNotVerified {
respondErrorI18n(w, r, h.translator, http.StatusForbidden, "email_not_verified_reset")
return
}
respondErrorI18n(w, r, h.translator, http.StatusInternalServerError, "failed_send_reset_email")
return
}
lang := i18n.GetLanguageFromContext(r.Context())
respondJSON(w, http.StatusOK, map[string]string{
"message": h.translator.Success(lang, "password_reset_email_sent"),
})
}
// ResetPassword godoc
// @Summary Reset password
// @Description Reset user password using the reset token from email
// @Tags auth
// @Accept json
// @Produce json
// @Param request body ResetPasswordRequest true "Reset token and new password"
// @Success 200 {object} map[string]string "Password reset successfully"
// @Failure 400 {object} ErrorResponse "Invalid token or password requirements not met"
// @Failure 404 {object} ErrorResponse "User not found"
// @Failure 500 {object} ErrorResponse "Internal server error"
// @Router /auth/reset-password [post]
func (h *AuthHandlers) ResetPassword(w http.ResponseWriter, r *http.Request) {
var req ResetPasswordRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
respondErrorI18n(w, r, h.translator, http.StatusBadRequest, "invalid_request_body")
return
}
cmd := commands.ResetPasswordCommand{
Token: req.Token,
NewPassword: req.NewPassword,
}
err := h.resetPasswordHandler.Handle(r.Context(), cmd)
if err != nil {
if err == appErrors.ErrInvalidInput {
respondErrorI18n(w, r, h.translator, http.StatusBadRequest, "invalid_token_or_password")
return
}
if err == appErrors.ErrNotFound {
respondErrorI18n(w, r, h.translator, http.StatusNotFound, "user_not_found")
return
}
respondErrorI18n(w, r, h.translator, http.StatusInternalServerError, "failed_reset_password")
return
}
lang := i18n.GetLanguageFromContext(r.Context())
respondJSON(w, http.StatusOK, map[string]string{
"message": h.translator.Success(lang, "password_reset"),
})
}
@@ -13,7 +13,6 @@ func TestAuthFlow(t *testing.T) {
reqBody := RegisterRequest{
Email: "test@example.com",
Password: "Password123!",
Timezone: "UTC",
}
rr := makeRequest(t, *ts.Router, "POST", "/api/v1/auth/register", reqBody, "")
@@ -22,22 +21,21 @@ func TestAuthFlow(t *testing.T) {
t.Errorf("Expected status 201, got %d. Body: %s", rr.Code, rr.Body.String())
}
var resp AuthResponse
var resp RegisterResponse
decodeResponse(t, rr, &resp)
if resp.Token == "" {
t.Error("Expected token in response")
}
if resp.UserID == "" {
t.Error("Expected user ID in response")
}
if resp.Message == "" {
t.Error("Expected message in response")
}
})
t.Run("Register duplicate email", func(t *testing.T) {
reqBody := RegisterRequest{
Email: "duplicate@example.com",
Password: "Password123!",
Timezone: "UTC",
}
makeRequest(t, *ts.Router, "POST", "/api/v1/auth/register", reqBody, "")
@@ -53,7 +51,6 @@ func TestAuthFlow(t *testing.T) {
reqBody := RegisterRequest{
Email: "invalid-email",
Password: "Password123!",
Timezone: "UTC",
}
rr := makeRequest(t, *ts.Router, "POST", "/api/v1/auth/register", reqBody, "")
@@ -67,7 +64,6 @@ func TestAuthFlow(t *testing.T) {
reqBody := RegisterRequest{
Email: "short@example.com",
Password: "123",
Timezone: "UTC",
}
rr := makeRequest(t, *ts.Router, "POST", "/api/v1/auth/register", reqBody, "")
@@ -81,7 +77,6 @@ func TestAuthFlow(t *testing.T) {
registerBody := RegisterRequest{
Email: "login@example.com",
Password: "Password123!",
Timezone: "UTC",
}
makeRequest(t, *ts.Router, "POST", "/api/v1/auth/register", registerBody, "")
@@ -108,7 +103,6 @@ func TestAuthFlow(t *testing.T) {
registerBody := RegisterRequest{
Email: "wrongpass@example.com",
Password: "Password123!",
Timezone: "UTC",
}
makeRequest(t, *ts.Router, "POST", "/api/v1/auth/register", registerBody, "")
@@ -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))
})
}
+73 -7
View File
@@ -4,6 +4,7 @@ import (
"time"
"apocapoc-api/internal/domain/value_objects"
"apocapoc-api/internal/shared/pagination"
)
type CreateHabitRequest struct {
@@ -48,14 +49,21 @@ type MarkHabitRequest struct {
Value *float64 `json:"value,omitempty"`
}
type TodaysHabitEntryResponse struct {
ID string `json:"id"`
Value *float64 `json:"value,omitempty"`
CompletedAt time.Time `json:"completed_at"`
}
type TodaysHabitResponse struct {
ID string `json:"id"`
Name string `json:"name"`
Type value_objects.HabitType `json:"type"`
TargetValue *float64 `json:"target_value,omitempty"`
IsNegative bool `json:"is_negative"`
ScheduledDate time.Time `json:"scheduled_date"`
IsCarriedOver bool `json:"is_carried_over"`
ID string `json:"id"`
Name string `json:"name"`
Type value_objects.HabitType `json:"type"`
TargetValue *float64 `json:"target_value,omitempty"`
IsNegative bool `json:"is_negative"`
ScheduledDate time.Time `json:"scheduled_date"`
IsCarriedOver bool `json:"is_carried_over"`
Entry *TodaysHabitEntryResponse `json:"entry,omitempty"`
}
type UserHabitResponse struct {
@@ -69,6 +77,11 @@ type UserHabitResponse struct {
IsNegative bool `json:"is_negative"`
}
type GetUserHabitsResponse struct {
Data []UserHabitResponse `json:"data"`
Pagination *pagination.Response `json:"pagination,omitempty"`
}
type HabitEntryResponse struct {
ID string `json:"id"`
HabitID string `json:"habit_id"`
@@ -87,3 +100,56 @@ type HabitEntriesResponse struct {
type ErrorResponse struct {
Error string `json:"error"`
}
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
}
}
@@ -10,22 +10,14 @@ func TestHabitEntriesFlow(t *testing.T) {
ts := setupTestServer(t)
defer ts.Close()
registerBody := RegisterRequest{
Email: "entryuser@example.com",
Password: "Password123!",
Timezone: "UTC",
}
rr := makeRequest(t, *ts.Router, "POST", "/api/v1/auth/register", registerBody, "")
var authResp AuthResponse
decodeResponse(t, rr, &authResp)
token := authResp.Token
token := registerAndLogin(t, *ts.Router, "entryuser@example.com", "Password123!")
habitBody := CreateHabitRequest{
Name: "Reading",
Type: "BOOLEAN",
Frequency: "DAILY",
}
rr = makeRequest(t, *ts.Router, "POST", "/api/v1/habits", habitBody, token)
rr := makeRequest(t, *ts.Router, "POST", "/api/v1/habits", habitBody, token)
var habitResp map[string]string
decodeResponse(t, rr, &habitResp)
habitID := habitResp["id"]
+173 -49
View File
@@ -4,11 +4,15 @@ import (
"encoding/json"
"net/http"
"strconv"
"strings"
"time"
"apocapoc-api/internal/application/commands"
"apocapoc-api/internal/application/queries"
"apocapoc-api/internal/domain/value_objects"
"apocapoc-api/internal/i18n"
"apocapoc-api/internal/shared/errors"
"apocapoc-api/internal/shared/pagination"
"github.com/go-chi/chi/v5"
)
@@ -23,6 +27,7 @@ type HabitHandlers struct {
archiveHandler *commands.ArchiveHabitHandler
markHandler *commands.MarkHabitHandler
unmarkHandler *commands.UnmarkHabitHandler
translator *i18n.Translator
}
func NewHabitHandlers(
@@ -35,6 +40,7 @@ func NewHabitHandlers(
archiveHandler *commands.ArchiveHabitHandler,
markHandler *commands.MarkHabitHandler,
unmarkHandler *commands.UnmarkHabitHandler,
translator *i18n.Translator,
) *HabitHandlers {
return &HabitHandlers{
createHandler: createHandler,
@@ -46,6 +52,7 @@ func NewHabitHandlers(
archiveHandler: archiveHandler,
markHandler: markHandler,
unmarkHandler: unmarkHandler,
translator: translator,
}
}
@@ -65,13 +72,13 @@ func NewHabitHandlers(
func (h *HabitHandlers) CreateHabit(w http.ResponseWriter, r *http.Request) {
var req CreateHabitRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
respondError(w, http.StatusBadRequest, "Invalid request body")
respondErrorI18n(w, r, h.translator, http.StatusBadRequest, "invalid_request_body")
return
}
userID, ok := GetUserIDFromContext(r.Context())
if !ok {
respondError(w, http.StatusUnauthorized, "User not authenticated")
respondErrorI18n(w, r, h.translator, http.StatusUnauthorized, "user_not_authenticated")
return
}
@@ -94,7 +101,7 @@ func (h *HabitHandlers) CreateHabit(w http.ResponseWriter, r *http.Request) {
respondError(w, http.StatusBadRequest, err.Error())
return
}
respondError(w, http.StatusInternalServerError, "Failed to create habit")
respondErrorI18n(w, r, h.translator, http.StatusInternalServerError, "failed_create_habit")
return
}
@@ -103,18 +110,24 @@ func (h *HabitHandlers) CreateHabit(w http.ResponseWriter, r *http.Request) {
// GetUserHabits godoc
// @Summary Get all user habits
// @Description Get all active habits for the authenticated user
// @Description Get all active habits for the authenticated user with optional pagination and filters
// @Tags habits
// @Produce json
// @Security BearerAuth
// @Success 200 {array} UserHabitResponse
// @Param page query int false "Page number (default: 1)"
// @Param page_size query int false "Page size (default: 50, max: 100)"
// @Param type query string false "Filter by type (BOOLEAN, COUNTER, VALUE)"
// @Param frequency query string false "Filter by frequency (DAILY, WEEKLY, MONTHLY)"
// @Param archived query boolean false "Include archived habits (default: false)"
// @Param search query string false "Search by name or description"
// @Success 200 {object} GetUserHabitsResponse
// @Failure 401 {object} ErrorResponse
// @Failure 500 {object} ErrorResponse
// @Router /habits [get]
func (h *HabitHandlers) GetUserHabits(w http.ResponseWriter, r *http.Request) {
userID, ok := GetUserIDFromContext(r.Context())
if !ok {
respondError(w, http.StatusUnauthorized, "User not authenticated")
respondErrorI18n(w, r, h.translator, http.StatusUnauthorized, "user_not_authenticated")
return
}
@@ -122,15 +135,71 @@ func (h *HabitHandlers) GetUserHabits(w http.ResponseWriter, r *http.Request) {
UserID: userID,
}
habits, err := h.getUserHabitsHandler.Handle(r.Context(), query)
pageStr := r.URL.Query().Get("page")
pageSizeStr := r.URL.Query().Get("page_size")
if pageStr != "" || pageSizeStr != "" {
page := 1
pageSize := 50
if pageStr != "" {
if p, err := strconv.Atoi(pageStr); err == nil && p > 0 {
page = p
}
}
if pageSizeStr != "" {
if ps, err := strconv.Atoi(pageSizeStr); err == nil && ps > 0 {
pageSize = ps
}
}
params := pagination.NewParams(page, pageSize)
query.PaginationParams = &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 {
respondError(w, http.StatusInternalServerError, "Failed to get habits")
respondErrorI18n(w, r, h.translator, http.StatusInternalServerError, "failed_get_habits")
return
}
response := make([]UserHabitResponse, len(habits))
for i, habit := range habits {
response[i] = UserHabitResponse{
habitResponses := make([]UserHabitResponse, len(result.Habits))
for i, habit := range result.Habits {
habitResponses[i] = UserHabitResponse{
ID: habit.ID,
Name: habit.Name,
Type: habit.Type,
@@ -142,7 +211,15 @@ func (h *HabitHandlers) GetUserHabits(w http.ResponseWriter, r *http.Request) {
}
}
respondJSON(w, http.StatusOK, response)
if result.Pagination != nil {
response := GetUserHabitsResponse{
Data: habitResponses,
Pagination: result.Pagination,
}
respondJSON(w, http.StatusOK, response)
} else {
respondJSON(w, http.StatusOK, habitResponses)
}
}
// GetHabitByID godoc
@@ -163,7 +240,7 @@ func (h *HabitHandlers) GetHabitByID(w http.ResponseWriter, r *http.Request) {
userID, ok := GetUserIDFromContext(r.Context())
if !ok {
respondError(w, http.StatusUnauthorized, "User not authenticated")
respondErrorI18n(w, r, h.translator, http.StatusUnauthorized, "user_not_authenticated")
return
}
@@ -175,14 +252,14 @@ func (h *HabitHandlers) GetHabitByID(w http.ResponseWriter, r *http.Request) {
habit, err := h.getHabitByIDHandler.Handle(r.Context(), query)
if err != nil {
if err == errors.ErrNotFound {
respondError(w, http.StatusNotFound, "Habit not found")
respondErrorI18n(w, r, h.translator, http.StatusNotFound, "habit_not_found")
return
}
if err == errors.ErrUnauthorized {
respondError(w, http.StatusForbidden, "Access denied")
respondErrorI18n(w, r, h.translator, http.StatusForbidden, "access_denied")
return
}
respondError(w, http.StatusInternalServerError, "Failed to get habit")
respondErrorI18n(w, r, h.translator, http.StatusInternalServerError, "failed_get_habit")
return
}
@@ -221,13 +298,13 @@ func (h *HabitHandlers) UpdateHabit(w http.ResponseWriter, r *http.Request) {
userID, ok := GetUserIDFromContext(r.Context())
if !ok {
respondError(w, http.StatusUnauthorized, "User not authenticated")
respondErrorI18n(w, r, h.translator, http.StatusUnauthorized, "user_not_authenticated")
return
}
var req UpdateHabitRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
respondError(w, http.StatusBadRequest, "Invalid request body")
respondErrorI18n(w, r, h.translator, http.StatusBadRequest, "invalid_request_body")
return
}
@@ -244,18 +321,18 @@ func (h *HabitHandlers) UpdateHabit(w http.ResponseWriter, r *http.Request) {
if err := h.updateHandler.Handle(r.Context(), cmd); err != nil {
if err == errors.ErrNotFound {
respondError(w, http.StatusNotFound, "Habit not found")
respondErrorI18n(w, r, h.translator, http.StatusNotFound, "habit_not_found")
return
}
if err == errors.ErrUnauthorized {
respondError(w, http.StatusForbidden, "Access denied")
respondErrorI18n(w, r, h.translator, http.StatusForbidden, "access_denied")
return
}
if err == errors.ErrInvalidInput {
respondError(w, http.StatusBadRequest, "Invalid input")
respondErrorI18n(w, r, h.translator, http.StatusBadRequest, "invalid_input")
return
}
respondError(w, http.StatusInternalServerError, "Failed to update habit")
respondErrorI18n(w, r, h.translator, http.StatusInternalServerError, "failed_update_habit")
return
}
@@ -280,7 +357,7 @@ func (h *HabitHandlers) ArchiveHabit(w http.ResponseWriter, r *http.Request) {
userID, ok := GetUserIDFromContext(r.Context())
if !ok {
respondError(w, http.StatusUnauthorized, "User not authenticated")
respondErrorI18n(w, r, h.translator, http.StatusUnauthorized, "user_not_authenticated")
return
}
@@ -291,14 +368,14 @@ func (h *HabitHandlers) ArchiveHabit(w http.ResponseWriter, r *http.Request) {
if err := h.archiveHandler.Handle(r.Context(), cmd); err != nil {
if err == errors.ErrNotFound {
respondError(w, http.StatusNotFound, "Habit not found")
respondErrorI18n(w, r, h.translator, http.StatusNotFound, "habit_not_found")
return
}
if err == errors.ErrUnauthorized {
respondError(w, http.StatusForbidden, "Access denied")
respondErrorI18n(w, r, h.translator, http.StatusForbidden, "access_denied")
return
}
respondError(w, http.StatusInternalServerError, "Failed to archive habit")
respondErrorI18n(w, r, h.translator, http.StatusInternalServerError, "failed_archive_habit")
return
}
@@ -328,7 +405,7 @@ func (h *HabitHandlers) GetHabitEntries(w http.ResponseWriter, r *http.Request)
userID, ok := GetUserIDFromContext(r.Context())
if !ok {
respondError(w, http.StatusUnauthorized, "User not authenticated")
respondErrorI18n(w, r, h.translator, http.StatusUnauthorized, "user_not_authenticated")
return
}
@@ -340,7 +417,7 @@ func (h *HabitHandlers) GetHabitEntries(w http.ResponseWriter, r *http.Request)
if fromStr := r.URL.Query().Get("from"); fromStr != "" {
from, err := time.Parse("2006-01-02", fromStr)
if err != nil {
respondError(w, http.StatusBadRequest, "Invalid 'from' date format (use YYYY-MM-DD)")
respondErrorI18n(w, r, h.translator, http.StatusBadRequest, "invalid_from_date_format")
return
}
query.From = &from
@@ -349,7 +426,7 @@ func (h *HabitHandlers) GetHabitEntries(w http.ResponseWriter, r *http.Request)
if toStr := r.URL.Query().Get("to"); toStr != "" {
to, err := time.Parse("2006-01-02", toStr)
if err != nil {
respondError(w, http.StatusBadRequest, "Invalid 'to' date format (use YYYY-MM-DD)")
respondErrorI18n(w, r, h.translator, http.StatusBadRequest, "invalid_to_date_format")
return
}
query.To = &to
@@ -370,7 +447,7 @@ func (h *HabitHandlers) GetHabitEntries(w http.ResponseWriter, r *http.Request)
if pageStr := r.URL.Query().Get("page"); pageStr != "" {
page, err := strconv.Atoi(pageStr)
if err != nil || page < 1 {
respondError(w, http.StatusBadRequest, "Invalid 'page' parameter")
respondErrorI18n(w, r, h.translator, http.StatusBadRequest, "invalid_page_parameter")
return
}
query.Page = page
@@ -381,7 +458,7 @@ func (h *HabitHandlers) GetHabitEntries(w http.ResponseWriter, r *http.Request)
if limitStr := r.URL.Query().Get("limit"); limitStr != "" {
limit, err := strconv.Atoi(limitStr)
if err != nil || limit < 1 || limit > 100 {
respondError(w, http.StatusBadRequest, "Invalid 'limit' parameter (must be 1-100)")
respondErrorI18n(w, r, h.translator, http.StatusBadRequest, "invalid_limit_parameter")
return
}
query.Limit = limit
@@ -397,14 +474,14 @@ func (h *HabitHandlers) GetHabitEntries(w http.ResponseWriter, r *http.Request)
result, err := h.getHabitEntriesHandler.Handle(r.Context(), query)
if err != nil {
if err == errors.ErrNotFound {
respondError(w, http.StatusNotFound, "Habit not found")
respondErrorI18n(w, r, h.translator, http.StatusNotFound, "habit_not_found")
return
}
if err == errors.ErrUnauthorized {
respondError(w, http.StatusForbidden, "Access denied")
respondErrorI18n(w, r, h.translator, http.StatusForbidden, "access_denied")
return
}
respondError(w, http.StatusInternalServerError, "Failed to get habit entries")
respondErrorI18n(w, r, h.translator, http.StatusInternalServerError, "failed_get_habit_entries")
return
}
@@ -431,37 +508,61 @@ func (h *HabitHandlers) GetHabitEntries(w http.ResponseWriter, r *http.Request)
// GetTodaysHabits godoc
// @Summary Get today's habits
// @Description Get all habits scheduled for today for the authenticated user
// @Description Get all habits scheduled for today for the authenticated user. Includes the entry for today if it exists. Requires timezone as query parameter (e.g., ?timezone=America/New_York).
// @Tags habits
// @Produce json
// @Security BearerAuth
// @Param timezone query string true "IANA timezone (e.g., 'America/New_York', 'Europe/Madrid', 'UTC')"
// @Success 200 {array} TodaysHabitResponse
// @Failure 400 {object} ErrorResponse "Invalid or missing timezone"
// @Failure 401 {object} ErrorResponse
// @Failure 500 {object} ErrorResponse
// @Router /habits/today [get]
func (h *HabitHandlers) GetTodaysHabits(w http.ResponseWriter, r *http.Request) {
userID, ok := GetUserIDFromContext(r.Context())
if !ok {
respondError(w, http.StatusUnauthorized, "User not authenticated")
respondErrorI18n(w, r, h.translator, http.StatusUnauthorized, "user_not_authenticated")
return
}
timezone := "UTC"
timezone := r.URL.Query().Get("timezone")
if timezone == "" {
respondErrorI18n(w, r, h.translator, http.StatusBadRequest, "timezone_required")
return
}
loc, err := time.LoadLocation(timezone)
if err != nil {
respondErrorI18n(w, r, h.translator, http.StatusBadRequest, "invalid_timezone")
return
}
today := time.Now().In(loc)
todayDate := time.Date(today.Year(), today.Month(), today.Day(), 0, 0, 0, 0, time.UTC)
query := queries.GetTodaysHabitsQuery{
UserID: userID,
Timezone: timezone,
Date: time.Now().UTC(),
Date: todayDate,
}
habits, err := h.getTodaysHandler.Handle(r.Context(), query)
if err != nil {
respondError(w, http.StatusInternalServerError, "Failed to get habits")
respondErrorI18n(w, r, h.translator, http.StatusInternalServerError, "failed_get_habits")
return
}
response := make([]TodaysHabitResponse, len(habits))
for i, habit := range habits {
var entryResponse *TodaysHabitEntryResponse
if habit.Entry != nil {
entryResponse = &TodaysHabitEntryResponse{
ID: habit.Entry.ID,
Value: habit.Entry.Value,
CompletedAt: habit.Entry.CompletedAt,
}
}
response[i] = TodaysHabitResponse{
ID: habit.ID,
Name: habit.Name,
@@ -470,6 +571,7 @@ func (h *HabitHandlers) GetTodaysHabits(w http.ResponseWriter, r *http.Request)
IsNegative: habit.IsNegative,
ScheduledDate: habit.ScheduledDate,
IsCarriedOver: habit.IsCarriedOver,
Entry: entryResponse,
}
}
@@ -496,13 +598,13 @@ func (h *HabitHandlers) MarkHabit(w http.ResponseWriter, r *http.Request) {
var req MarkHabitRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
respondError(w, http.StatusBadRequest, "Invalid request body")
respondErrorI18n(w, r, h.translator, http.StatusBadRequest, "invalid_request_body")
return
}
scheduledDate, err := time.Parse("2006-01-02", req.ScheduledDate)
if err != nil {
respondError(w, http.StatusBadRequest, "Invalid date format (use YYYY-MM-DD)")
respondErrorI18n(w, r, h.translator, http.StatusBadRequest, "invalid_date_format")
return
}
@@ -514,14 +616,14 @@ func (h *HabitHandlers) MarkHabit(w http.ResponseWriter, r *http.Request) {
if err := h.markHandler.Handle(r.Context(), cmd); err != nil {
if err == errors.ErrAlreadyExists {
respondError(w, http.StatusConflict, "Habit already marked for this date")
respondErrorI18n(w, r, h.translator, http.StatusConflict, "habit_already_marked")
return
}
if err == errors.ErrNotFound {
respondError(w, http.StatusNotFound, "Habit not found")
respondErrorI18n(w, r, h.translator, http.StatusNotFound, "habit_not_found")
return
}
respondError(w, http.StatusInternalServerError, "Failed to mark habit")
respondErrorI18n(w, r, h.translator, http.StatusInternalServerError, "failed_mark_habit")
return
}
@@ -549,13 +651,13 @@ func (h *HabitHandlers) UnmarkHabit(w http.ResponseWriter, r *http.Request) {
userID, ok := GetUserIDFromContext(r.Context())
if !ok {
respondError(w, http.StatusUnauthorized, "User not authenticated")
respondErrorI18n(w, r, h.translator, http.StatusUnauthorized, "user_not_authenticated")
return
}
scheduledDate, err := time.Parse("2006-01-02", dateStr)
if err != nil {
respondError(w, http.StatusBadRequest, "Invalid date format (use YYYY-MM-DD)")
respondErrorI18n(w, r, h.translator, http.StatusBadRequest, "invalid_date_format")
return
}
@@ -567,14 +669,14 @@ func (h *HabitHandlers) UnmarkHabit(w http.ResponseWriter, r *http.Request) {
if err := h.unmarkHandler.Handle(r.Context(), cmd); err != nil {
if err == errors.ErrNotFound {
respondError(w, http.StatusNotFound, "Habit entry not found")
respondErrorI18n(w, r, h.translator, http.StatusNotFound, "habit_entry_not_found")
return
}
if err == errors.ErrUnauthorized {
respondError(w, http.StatusForbidden, "Access denied")
respondErrorI18n(w, r, h.translator, http.StatusForbidden, "access_denied")
return
}
respondError(w, http.StatusInternalServerError, "Failed to unmark habit")
respondErrorI18n(w, r, h.translator, http.StatusInternalServerError, "failed_unmark_habit")
return
}
@@ -590,3 +692,25 @@ func respondJSON(w http.ResponseWriter, status int, data interface{}) {
func respondError(w http.ResponseWriter, status int, message string) {
respondJSON(w, status, ErrorResponse{Error: message})
}
func respondValidationError(w http.ResponseWriter, err error) {
errMsg := err.Error()
var field string
if strings.Contains(errMsg, ": ") {
parts := strings.SplitN(errMsg, ": ", 3)
if len(parts) >= 3 {
field = parts[1]
errMsg = parts[2]
respondJSON(w, http.StatusBadRequest, ValidationErrorResponse{
Error: errMsg,
Field: field,
})
return
}
}
respondJSON(w, http.StatusBadRequest, ErrorResponse{
Error: errMsg,
})
}
@@ -9,15 +9,7 @@ func TestHabitCRUDFlow(t *testing.T) {
ts := setupTestServer(t)
defer ts.Close()
registerBody := RegisterRequest{
Email: "habituser@example.com",
Password: "Password123!",
Timezone: "UTC",
}
rr := makeRequest(t, *ts.Router, "POST", "/api/v1/auth/register", registerBody, "")
var authResp AuthResponse
decodeResponse(t, rr, &authResp)
token := authResp.Token
token := registerAndLogin(t, *ts.Router, "habituser@example.com", "Password123!")
var habitID string
@@ -131,30 +123,22 @@ func TestHabitCRUDFlow(t *testing.T) {
})
t.Run("Access other user's habit", func(t *testing.T) {
registerBody := RegisterRequest{
Email: "otheruser@example.com",
Password: "Password123!",
Timezone: "UTC",
}
rr := makeRequest(t, *ts.Router, "POST", "/api/v1/auth/register", registerBody, "")
var authResp AuthResponse
decodeResponse(t, rr, &authResp)
otherToken := authResp.Token
otherToken := registerAndLogin(t, *ts.Router, "otheruser@example.com", "Password123!")
reqBody := CreateHabitRequest{
Name: "Other User Habit",
Type: "BOOLEAN",
Frequency: "DAILY",
}
rr = makeRequest(t, *ts.Router, "POST", "/api/v1/habits", reqBody, otherToken)
rr := makeRequest(t, *ts.Router, "POST", "/api/v1/habits", reqBody, otherToken)
var createResp map[string]string
decodeResponse(t, rr, &createResp)
otherHabitID := createResp["id"]
rr = makeRequest(t, *ts.Router, "GET", "/api/v1/habits/"+otherHabitID, nil, token)
rr2 := makeRequest(t, *ts.Router, "GET", "/api/v1/habits/"+otherHabitID, nil, token)
if rr.Code != http.StatusForbidden {
t.Errorf("Expected status 403, got %d", rr.Code)
if rr2.Code != http.StatusForbidden {
t.Errorf("Expected status 403, got %d", rr2.Code)
}
})
}
@@ -1,6 +1,7 @@
package http
import (
"apocapoc-api/internal/domain/services"
"database/sql"
"net/http"
"time"
@@ -9,18 +10,21 @@ import (
var startTime = time.Now()
type HealthHandlers struct {
db *sql.DB
db *sql.DB
emailService services.EmailService
}
func NewHealthHandlers(db *sql.DB) *HealthHandlers {
func NewHealthHandlers(db *sql.DB, emailService services.EmailService) *HealthHandlers {
return &HealthHandlers{
db: db,
db: db,
emailService: emailService,
}
}
type HealthResponse struct {
Status string `json:"status"`
Database string `json:"database"`
SMTP string `json:"smtp"`
Uptime string `json:"uptime"`
}
@@ -34,6 +38,7 @@ type HealthResponse struct {
// @Router /health [get]
func (h *HealthHandlers) Health(w http.ResponseWriter, r *http.Request) {
dbStatus := "ok"
smtpStatus := "ok"
overallStatus := "ok"
statusCode := http.StatusOK
@@ -43,12 +48,25 @@ func (h *HealthHandlers) Health(w http.ResponseWriter, r *http.Request) {
statusCode = http.StatusServiceUnavailable
}
if h.emailService != nil {
if err := h.emailService.HealthCheck(); err != nil {
smtpStatus = "error"
if overallStatus != "degraded" {
overallStatus = "degraded"
statusCode = http.StatusServiceUnavailable
}
}
} else {
smtpStatus = "disabled"
}
uptime := time.Since(startTime)
uptimeStr := formatDuration(uptime)
response := HealthResponse{
Status: overallStatus,
Database: dbStatus,
SMTP: smtpStatus,
Uptime: uptimeStr,
}
@@ -0,0 +1,52 @@
package http
import (
"net/http"
"strings"
"apocapoc-api/internal/i18n"
"golang.org/x/text/language"
)
func respondErrorI18n(w http.ResponseWriter, r *http.Request, translator *i18n.Translator, status int, key string) {
lang := i18n.GetLanguageFromContext(r.Context())
message := translator.Error(lang, key)
respondJSON(w, status, ErrorResponse{Error: message})
}
func respondSuccessI18n(w http.ResponseWriter, r *http.Request, translator *i18n.Translator, key string) {
lang := i18n.GetLanguageFromContext(r.Context())
message := translator.Success(lang, key)
respondJSON(w, http.StatusOK, map[string]string{"message": message})
}
func respondValidationErrorI18n(w http.ResponseWriter, r *http.Request, translator *i18n.Translator, err error) {
lang := i18n.GetLanguageFromContext(r.Context())
errMsg := err.Error()
var field string
var translatedMsg string
if strings.Contains(errMsg, ": ") {
parts := strings.SplitN(errMsg, ": ", 3)
if len(parts) >= 3 {
field = parts[1]
validationKey := parts[2]
translatedMsg = translator.Validation(lang, validationKey)
respondJSON(w, http.StatusBadRequest, ValidationErrorResponse{
Error: translatedMsg,
Field: field,
})
return
}
}
respondJSON(w, http.StatusBadRequest, ErrorResponse{
Error: errMsg,
})
}
func getLanguageFromRequest(r *http.Request, translator *i18n.Translator) language.Tag {
lang := i18n.GetLanguageFromContext(r.Context())
return lang
}
@@ -11,6 +11,7 @@ import (
"apocapoc-api/internal/application/commands"
"apocapoc-api/internal/application/queries"
"apocapoc-api/internal/i18n"
"apocapoc-api/internal/infrastructure/auth"
"apocapoc-api/internal/infrastructure/crypto"
"apocapoc-api/internal/infrastructure/persistence/sqlite"
@@ -40,18 +41,24 @@ func setupTestServer(t *testing.T) *TestServer {
habitRepo := sqlite.NewHabitRepository(db)
entryRepo := sqlite.NewHabitEntryRepository(db)
refreshTokenRepo := sqlite.NewRefreshTokenRepository(db)
passwordResetTokenRepo := sqlite.NewPasswordResetTokenRepository(db)
registerHandler := commands.NewRegisterUserHandler(userRepo, passwordHasher)
registerHandler := commands.NewRegisterUserHandler(userRepo, passwordHasher, nil, "", "open", false)
loginHandler := queries.NewLoginUserHandler(userRepo, passwordHasher)
refreshTokenHandler := queries.NewRefreshTokenHandler(refreshTokenRepo, userRepo)
revokeTokenHandler := commands.NewRevokeTokenHandler(refreshTokenRepo)
revokeAllTokensHandler := commands.NewRevokeAllTokensHandler(refreshTokenRepo)
verifyEmailHandler := commands.NewVerifyEmailHandler(userRepo, nil, false)
resendVerificationEmailHandler := commands.NewResendVerificationEmailHandler(userRepo, nil, "")
requestPasswordResetHandler := commands.NewRequestPasswordResetHandler(userRepo, passwordResetTokenRepo, nil, "")
resetPasswordHandler := commands.NewResetPasswordHandler(userRepo, passwordResetTokenRepo, passwordHasher)
createHandler := commands.NewCreateHabitHandler(habitRepo)
getTodaysHandler := queries.NewGetTodaysHabitsHandler(habitRepo, entryRepo)
getUserHabitsHandler := queries.NewGetUserHabitsHandler(habitRepo)
getHabitByIDHandler := queries.NewGetHabitByIDHandler(habitRepo)
getHabitEntriesHandler := queries.NewGetHabitEntriesHandler(habitRepo, entryRepo)
getHabitStatsHandler := queries.NewGetHabitStatsHandler(habitRepo, entryRepo)
exportUserDataHandler := queries.NewExportUserDataHandler(habitRepo, entryRepo)
updateHandler := commands.NewUpdateHabitHandler(habitRepo)
archiveHandler := commands.NewArchiveHabitHandler(habitRepo)
markHandler := commands.NewMarkHabitHandler(entryRepo, habitRepo)
@@ -59,12 +66,22 @@ func setupTestServer(t *testing.T) *TestServer {
refreshTokenExpiry := 7 * 24 * time.Hour
authHandlers := NewAuthHandlers(registerHandler, loginHandler, refreshTokenHandler, revokeTokenHandler, revokeAllTokensHandler, jwtService, refreshTokenRepo, refreshTokenExpiry)
habitHandlers := NewHabitHandlers(createHandler, getTodaysHandler, getUserHabitsHandler, getHabitByIDHandler, getHabitEntriesHandler, updateHandler, archiveHandler, markHandler, unmarkHandler)
statsHandlers := NewStatsHandlers(getHabitStatsHandler)
healthHandlers := NewHealthHandlers(db)
deleteUserHandler := commands.NewDeleteUserHandler(userRepo)
router := NewRouter("*", habitHandlers, authHandlers, statsHandlers, healthHandlers, jwtService)
translator, _ := i18n.NewTranslator()
getSyncChangesHandler := queries.NewGetSyncChangesHandler(habitRepo, entryRepo)
applySyncBatchHandler := commands.NewApplySyncBatchHandler(habitRepo, entryRepo)
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{
@@ -104,3 +121,21 @@ func decodeResponse(t *testing.T, rr *httptest.ResponseRecorder, target interfac
t.Fatalf("Failed to decode response: %v", err)
}
}
func registerAndLogin(t *testing.T, router http.Handler, email, password string) string {
registerBody := RegisterRequest{
Email: email,
Password: password,
}
makeRequest(t, router, "POST", "/api/v1/auth/register", registerBody, "")
loginBody := LoginRequest{
Email: email,
Password: password,
}
rr := makeRequest(t, router, "POST", "/api/v1/auth/login", loginBody, "")
var authResp AuthResponse
decodeResponse(t, rr, &authResp)
return authResp.Token
}
@@ -1,8 +1,12 @@
package http
import (
"bytes"
"encoding/json"
"io"
"net/http"
"strconv"
"strings"
"time"
"apocapoc-api/internal/infrastructure/auth"
@@ -36,3 +40,39 @@ func RateLimitByUser(jwtService *auth.JWTService, requestsPerMinute int, duratio
})
}
}
func RateLimitByEmail(requests int, duration time.Duration) func(http.Handler) http.Handler {
limiter := httprate.NewRateLimiter(
requests,
duration,
httprate.WithKeyFuncs(func(r *http.Request) (string, error) {
body, err := io.ReadAll(r.Body)
if err != nil {
return r.RemoteAddr, nil
}
r.Body = io.NopCloser(bytes.NewBuffer(body))
var data map[string]interface{}
if err := json.Unmarshal(body, &data); err != nil {
return r.RemoteAddr, nil
}
if email, ok := data["email"].(string); ok && email != "" {
return "email:" + strings.ToLower(email), nil
}
return r.RemoteAddr, nil
}),
httprate.WithLimitHandler(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusTooManyRequests)
w.Write([]byte(`{"error":"Too many password reset attempts. Please try again later."}`))
}),
)
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
limiter.Handler(next).ServeHTTP(w, r)
})
}
}
@@ -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")
}
})
}
+32 -4
View File
@@ -4,7 +4,9 @@ import (
"net/http"
"time"
"apocapoc-api/internal/i18n"
"apocapoc-api/internal/infrastructure/auth"
"apocapoc-api/internal/infrastructure/logger"
"github.com/go-chi/chi/v5"
"github.com/go-chi/chi/v5/middleware"
@@ -15,15 +17,16 @@ import (
_ "apocapoc-api/docs"
)
func NewRouter(corsOrigins string, habitHandlers *HabitHandlers, authHandlers *AuthHandlers, statsHandlers *StatsHandlers, healthHandlers *HealthHandlers, jwtService *auth.JWTService) *chi.Mux {
func NewRouter(appURL string, habitHandlers *HabitHandlers, authHandlers *AuthHandlers, statsHandlers *StatsHandlers, healthHandlers *HealthHandlers, userHandlers *UserHandlers, exportHandlers *ExportHandlers, 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{
AllowedOrigins: []string{corsOrigins},
AllowedOrigins: []string{appURL},
AllowedMethods: []string{"GET", "POST", "PUT", "DELETE", "OPTIONS"},
AllowedHeaders: []string{"Accept", "Authorization", "Content-Type"},
AllowedHeaders: []string{"Accept", "Authorization", "Content-Type", "Accept-Language"},
AllowCredentials: true,
}))
@@ -42,6 +45,12 @@ func NewRouter(corsOrigins string, habitHandlers *HabitHandlers, authHandlers *A
r.Post("/login", authHandlers.Login)
r.Post("/refresh", authHandlers.Refresh)
r.Post("/logout", authHandlers.Logout)
r.Post("/verify-email", authHandlers.VerifyEmail)
r.Post("/resend-verification", authHandlers.ResendVerification)
r.With(RateLimitByEmail(3, 1*time.Hour)).Post("/forgot-password", authHandlers.ForgotPassword)
r.Post("/reset-password", authHandlers.ResetPassword)
})
r.Route("/api/v1/habits", func(r chi.Router) {
@@ -65,5 +74,24 @@ func NewRouter(corsOrigins string, habitHandlers *HabitHandlers, authHandlers *A
r.Get("/habits/{id}", statsHandlers.GetHabitStats)
})
r.Route("/api/v1/users", func(r chi.Router) {
r.Use(AuthMiddleware(jwtService))
r.Use(RateLimitByUser(jwtService, 100, 1*time.Minute))
r.Delete("/me", userHandlers.DeleteAccount)
})
r.Route("/api/v1/export", func(r chi.Router) {
r.Use(AuthMiddleware(jwtService))
r.Use(RateLimitByUser(jwtService, 1, 1*time.Hour))
r.Get("/", exportHandlers.ExportData)
})
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
}
@@ -6,18 +6,22 @@ import (
"apocapoc-api/internal/application/queries"
"apocapoc-api/internal/shared/errors"
"apocapoc-api/internal/i18n"
"github.com/go-chi/chi/v5"
)
type StatsHandlers struct {
getHabitStatsHandler *queries.GetHabitStatsHandler
translator *i18n.Translator
}
func NewStatsHandlers(
getHabitStatsHandler *queries.GetHabitStatsHandler,
translator *i18n.Translator,
) *StatsHandlers {
return &StatsHandlers{
getHabitStatsHandler: getHabitStatsHandler,
translator: translator,
}
}
@@ -39,7 +43,7 @@ func (h *StatsHandlers) GetHabitStats(w http.ResponseWriter, r *http.Request) {
userID, ok := GetUserIDFromContext(r.Context())
if !ok {
respondError(w, http.StatusUnauthorized, "User not authenticated")
respondErrorI18n(w, r, h.translator, http.StatusUnauthorized, "user_not_authenticated")
return
}
@@ -51,14 +55,14 @@ func (h *StatsHandlers) GetHabitStats(w http.ResponseWriter, r *http.Request) {
stats, err := h.getHabitStatsHandler.Handle(r.Context(), query)
if err != nil {
if err == errors.ErrNotFound {
respondError(w, http.StatusNotFound, "Habit not found")
respondErrorI18n(w, r, h.translator, http.StatusNotFound, "habit_not_found")
return
}
if err == errors.ErrUnauthorized {
respondError(w, http.StatusForbidden, "Access denied")
respondErrorI18n(w, r, h.translator, http.StatusForbidden, "access_denied")
return
}
respondError(w, http.StatusInternalServerError, "Failed to get habit stats")
respondErrorI18n(w, r, h.translator, http.StatusInternalServerError, "failed_get_stats")
return
}
@@ -0,0 +1,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
}
@@ -0,0 +1,55 @@
package http
import (
"net/http"
"apocapoc-api/internal/application/commands"
"apocapoc-api/internal/i18n"
"apocapoc-api/internal/shared/errors"
)
type UserHandlers struct {
deleteUserHandler *commands.DeleteUserHandler
translator *i18n.Translator
}
func NewUserHandlers(deleteUserHandler *commands.DeleteUserHandler, translator *i18n.Translator) *UserHandlers {
return &UserHandlers{
deleteUserHandler: deleteUserHandler,
translator: translator,
}
}
// DeleteAccount godoc
// @Summary Delete user account
// @Description Permanently delete the authenticated user's account and all associated data (habits, entries, tokens). This action cannot be undone.
// @Tags users
// @Security BearerAuth
// @Produce json
// @Success 200 {object} map[string]string "Account deleted successfully"
// @Failure 401 {object} ErrorResponse "Unauthorized - invalid or missing token"
// @Failure 404 {object} ErrorResponse "User not found"
// @Failure 500 {object} ErrorResponse "Internal server error"
// @Router /users/me [delete]
func (h *UserHandlers) DeleteAccount(w http.ResponseWriter, r *http.Request) {
userID := r.Context().Value("user_id").(string)
cmd := commands.DeleteUserCommand{
UserID: userID,
}
err := h.deleteUserHandler.Handle(r.Context(), cmd)
if err != nil {
if err == errors.ErrNotFound {
respondErrorI18n(w, r, h.translator, http.StatusNotFound, "user_not_found")
return
}
respondErrorI18n(w, r, h.translator, http.StatusInternalServerError, "failed_delete_user")
return
}
lang := i18n.GetLanguageFromContext(r.Context())
respondJSON(w, http.StatusOK, map[string]string{
"message": h.translator.Success(lang, "user_deleted"),
})
}
+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)
}
}
@@ -2,6 +2,7 @@ package sqlite
import (
"database/sql"
"fmt"
)
func RunMigrations(db *sql.DB) error {
@@ -10,6 +11,7 @@ func RunMigrations(db *sql.DB) error {
createHabitsTable,
createHabitEntriesTable,
createRefreshTokensTable,
createPasswordResetTokensTable,
createIndexes,
}
@@ -18,6 +20,164 @@ func RunMigrations(db *sql.DB) error {
return err
}
}
if err := addEmailVerificationColumns(db); err != nil {
return err
}
if err := removeTimezoneColumn(db); err != nil {
return err
}
if err := addSyncColumns(db); err != nil {
return err
}
return nil
}
func addEmailVerificationColumns(db *sql.DB) error {
columns := []struct {
name string
definition string
}{
{"email_verified", "ALTER TABLE users ADD COLUMN email_verified BOOLEAN DEFAULT 0"},
{"email_verification_token", "ALTER TABLE users ADD COLUMN email_verification_token TEXT"},
{"email_verification_expiry", "ALTER TABLE users ADD COLUMN email_verification_expiry DATETIME"},
}
for _, col := range columns {
exists, err := columnExists(db, "users", col.name)
if err != nil {
return err
}
if !exists {
if _, err := db.Exec(col.definition); err != nil {
return err
}
}
}
return nil
}
func removeTimezoneColumn(db *sql.DB) error {
exists, err := columnExists(db, "users", "timezone")
if err != nil {
return err
}
if !exists {
return nil
}
if _, err := db.Exec("ALTER TABLE users DROP COLUMN timezone"); err != nil {
return fmt.Errorf("failed to drop timezone column: %w", err)
}
return nil
}
func columnExists(db *sql.DB, table, column string) (bool, error) {
query := fmt.Sprintf("SELECT COUNT(*) FROM pragma_table_info('%s') WHERE name = ?", table)
var count int
err := db.QueryRow(query, column).Scan(&count)
if err != nil {
return false, err
}
return count > 0, nil
}
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
}
@@ -26,7 +186,6 @@ CREATE TABLE IF NOT EXISTS users (
id TEXT PRIMARY KEY,
email TEXT UNIQUE NOT NULL,
password_hash TEXT NOT NULL,
timezone TEXT DEFAULT 'UTC',
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
@@ -75,6 +234,18 @@ CREATE TABLE IF NOT EXISTS refresh_tokens (
);
`
const createPasswordResetTokensTable = `
CREATE TABLE IF NOT EXISTS password_reset_tokens (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL,
token TEXT UNIQUE NOT NULL,
expires_at DATETIME NOT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
used_at DATETIME,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
);
`
const createIndexes = `
CREATE INDEX IF NOT EXISTS idx_habits_user ON habits(user_id);
CREATE INDEX IF NOT EXISTS idx_habits_active ON habits(user_id, archived_at);
@@ -82,4 +253,6 @@ CREATE INDEX IF NOT EXISTS idx_entries_habit ON habit_entries(habit_id);
CREATE INDEX IF NOT EXISTS idx_entries_scheduled ON habit_entries(scheduled_date);
CREATE INDEX IF NOT EXISTS idx_refresh_tokens_user ON refresh_tokens(user_id);
CREATE INDEX IF NOT EXISTS idx_refresh_tokens_token ON refresh_tokens(token);
CREATE INDEX IF NOT EXISTS idx_password_reset_tokens_user ON password_reset_tokens(user_id);
CREATE INDEX IF NOT EXISTS idx_password_reset_tokens_token ON password_reset_tokens(token);
`
@@ -43,7 +43,7 @@ func TestUsersTableSchema(t *testing.T) {
t.Fatalf("RunMigrations failed: %v", err)
}
columns := []string{"id", "email", "password_hash", "timezone", "created_at", "updated_at"}
columns := []string{"id", "email", "password_hash", "email_verified", "email_verification_token", "email_verification_expiry", "created_at", "updated_at"}
for _, col := range columns {
query := "SELECT " + col + " FROM users LIMIT 0"
rows, err := db.Query(query)
@@ -0,0 +1,98 @@
package sqlite
import (
"context"
"database/sql"
"time"
"apocapoc-api/internal/domain/entities"
"apocapoc-api/internal/shared/errors"
)
type PasswordResetTokenRepository struct {
db *sql.DB
}
func NewPasswordResetTokenRepository(db *sql.DB) *PasswordResetTokenRepository {
return &PasswordResetTokenRepository{db: db}
}
func (r *PasswordResetTokenRepository) Create(ctx context.Context, token *entities.PasswordResetToken) error {
query := `
INSERT INTO password_reset_tokens (id, user_id, token, expires_at, created_at, used_at)
VALUES (?, ?, ?, ?, ?, ?)
`
_, err := r.db.ExecContext(ctx, query,
token.ID,
token.UserID,
token.Token,
token.ExpiresAt,
token.CreatedAt,
token.UsedAt,
)
return err
}
func (r *PasswordResetTokenRepository) FindByToken(ctx context.Context, tokenStr string) (*entities.PasswordResetToken, error) {
query := `
SELECT id, user_id, token, expires_at, created_at, used_at
FROM password_reset_tokens
WHERE token = ?
`
token := &entities.PasswordResetToken{}
err := r.db.QueryRowContext(ctx, query, tokenStr).Scan(
&token.ID,
&token.UserID,
&token.Token,
&token.ExpiresAt,
&token.CreatedAt,
&token.UsedAt,
)
if err == sql.ErrNoRows {
return nil, errors.ErrNotFound
}
if err != nil {
return nil, err
}
return token, nil
}
func (r *PasswordResetTokenRepository) Update(ctx context.Context, token *entities.PasswordResetToken) error {
query := `
UPDATE password_reset_tokens
SET used_at = ?
WHERE id = ?
`
result, err := r.db.ExecContext(ctx, query, token.UsedAt, token.ID)
if err != nil {
return err
}
rows, err := result.RowsAffected()
if err != nil {
return err
}
if rows == 0 {
return errors.ErrNotFound
}
return nil
}
func (r *PasswordResetTokenRepository) DeleteExpired(ctx context.Context) error {
query := `
DELETE FROM password_reset_tokens
WHERE expires_at < ?
`
_, err := r.db.ExecContext(ctx, query, time.Now())
return err
}
@@ -24,15 +24,17 @@ func (r *UserRepository) Create(ctx context.Context, user *entities.User) error
user.ID = uuid.New().String()
query := `
INSERT INTO users (id, email, password_hash, timezone, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?)
INSERT INTO users (id, email, password_hash, email_verified, email_verification_token, email_verification_expiry, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
`
_, err := r.db.ExecContext(ctx, query,
user.ID,
user.Email,
user.PasswordHash,
user.Timezone,
user.EmailVerified,
user.EmailVerificationToken,
user.EmailVerificationExpiry,
user.CreatedAt,
user.UpdatedAt,
)
@@ -49,7 +51,7 @@ func (r *UserRepository) Create(ctx context.Context, user *entities.User) error
func (r *UserRepository) FindByID(ctx context.Context, id string) (*entities.User, error) {
query := `
SELECT id, email, password_hash, timezone, created_at, updated_at
SELECT id, email, password_hash, email_verified, email_verification_token, email_verification_expiry, created_at, updated_at
FROM users
WHERE id = ?
`
@@ -59,7 +61,9 @@ func (r *UserRepository) FindByID(ctx context.Context, id string) (*entities.Use
&user.ID,
&user.Email,
&user.PasswordHash,
&user.Timezone,
&user.EmailVerified,
&user.EmailVerificationToken,
&user.EmailVerificationExpiry,
&user.CreatedAt,
&user.UpdatedAt,
)
@@ -76,7 +80,7 @@ func (r *UserRepository) FindByID(ctx context.Context, id string) (*entities.Use
func (r *UserRepository) FindByEmail(ctx context.Context, email string) (*entities.User, error) {
query := `
SELECT id, email, password_hash, timezone, created_at, updated_at
SELECT id, email, password_hash, email_verified, email_verification_token, email_verification_expiry, created_at, updated_at
FROM users
WHERE email = ?
`
@@ -86,7 +90,9 @@ func (r *UserRepository) FindByEmail(ctx context.Context, email string) (*entiti
&user.ID,
&user.Email,
&user.PasswordHash,
&user.Timezone,
&user.EmailVerified,
&user.EmailVerificationToken,
&user.EmailVerificationExpiry,
&user.CreatedAt,
&user.UpdatedAt,
)
@@ -101,17 +107,48 @@ func (r *UserRepository) FindByEmail(ctx context.Context, email string) (*entiti
return &user, nil
}
func (r *UserRepository) FindByVerificationToken(ctx context.Context, token string) (*entities.User, error) {
query := `
SELECT id, email, password_hash, email_verified, email_verification_token, email_verification_expiry, created_at, updated_at
FROM users
WHERE email_verification_token = ?
`
var user entities.User
err := r.db.QueryRowContext(ctx, query, token).Scan(
&user.ID,
&user.Email,
&user.PasswordHash,
&user.EmailVerified,
&user.EmailVerificationToken,
&user.EmailVerificationExpiry,
&user.CreatedAt,
&user.UpdatedAt,
)
if err == sql.ErrNoRows {
return nil, errors.ErrNotFound
}
if err != nil {
return nil, fmt.Errorf("failed to find user by verification token: %w", err)
}
return &user, nil
}
func (r *UserRepository) Update(ctx context.Context, user *entities.User) error {
query := `
UPDATE users
SET email = ?, password_hash = ?, timezone = ?, updated_at = ?
SET email = ?, password_hash = ?, email_verified = ?, email_verification_token = ?, email_verification_expiry = ?, updated_at = ?
WHERE id = ?
`
result, err := r.db.ExecContext(ctx, query,
user.Email,
user.PasswordHash,
user.Timezone,
user.EmailVerified,
user.EmailVerificationToken,
user.EmailVerificationExpiry,
user.UpdatedAt,
user.ID,
)
@@ -128,6 +165,22 @@ func (r *UserRepository) Update(ctx context.Context, user *entities.User) error
return nil
}
func (r *UserRepository) Delete(ctx context.Context, id string) error {
query := `DELETE FROM users WHERE id = ?`
result, err := r.db.ExecContext(ctx, query, id)
if err != nil {
return fmt.Errorf("failed to delete user: %w", err)
}
rows, _ := result.RowsAffected()
if rows == 0 {
return errors.ErrNotFound
}
return nil
}
func isUniqueConstraintError(err error) bool {
return err != nil && strings.Contains(err.Error(), "UNIQUE constraint failed")
}
@@ -35,7 +35,6 @@ func TestUserRepositoryCreate(t *testing.T) {
user := &entities.User{
Email: "test@example.com",
PasswordHash: "hashed_password",
Timezone: "UTC",
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
}
@@ -60,7 +59,6 @@ func TestUserRepositoryCreateDuplicateEmail(t *testing.T) {
user1 := &entities.User{
Email: "duplicate@example.com",
PasswordHash: "hash1",
Timezone: "UTC",
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
}
@@ -73,7 +71,6 @@ func TestUserRepositoryCreateDuplicateEmail(t *testing.T) {
user2 := &entities.User{
Email: "duplicate@example.com",
PasswordHash: "hash2",
Timezone: "UTC",
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
}
@@ -94,7 +91,6 @@ func TestUserRepositoryFindByID(t *testing.T) {
user := &entities.User{
Email: "find@example.com",
PasswordHash: "hashed",
Timezone: "America/New_York",
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
}
@@ -115,9 +111,6 @@ func TestUserRepositoryFindByID(t *testing.T) {
if found.Email != user.Email {
t.Errorf("Expected email %s, got %s", user.Email, found.Email)
}
if found.Timezone != user.Timezone {
t.Errorf("Expected timezone %s, got %s", user.Timezone, found.Timezone)
}
}
func TestUserRepositoryFindByIDNotFound(t *testing.T) {
@@ -143,7 +136,6 @@ func TestUserRepositoryFindByEmail(t *testing.T) {
user := &entities.User{
Email: "email@test.com",
PasswordHash: "hashed",
Timezone: "UTC",
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
}
@@ -189,7 +181,6 @@ func TestUserRepositoryUpdate(t *testing.T) {
user := &entities.User{
Email: "original@example.com",
PasswordHash: "hash1",
Timezone: "UTC",
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
}
@@ -200,7 +191,6 @@ func TestUserRepositoryUpdate(t *testing.T) {
}
user.Email = "updated@example.com"
user.Timezone = "Europe/Madrid"
user.UpdatedAt = time.Now()
err = repo.Update(ctx, user)
@@ -216,9 +206,6 @@ func TestUserRepositoryUpdate(t *testing.T) {
if found.Email != "updated@example.com" {
t.Errorf("Expected email updated@example.com, got %s", found.Email)
}
if found.Timezone != "Europe/Madrid" {
t.Errorf("Expected timezone Europe/Madrid, got %s", found.Timezone)
}
}
func TestUserRepositoryUpdateNotFound(t *testing.T) {
@@ -232,7 +219,6 @@ func TestUserRepositoryUpdateNotFound(t *testing.T) {
ID: "non-existent",
Email: "test@example.com",
PasswordHash: "hash",
Timezone: "UTC",
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
}

Some files were not shown because too many files have changed in this diff Show More