13 Commits

Author SHA1 Message Date
david ed50d2427e fix: return field-level validation errors on POST /habits
Previously all validation failures in CreateHabitHandler returned a
generic {"error":"invalid input"}, making it impossible to tell
whether type, frequency, specific_days or specific_dates was the
problem. Errors are now wrapped with field + i18n key and the HTTP
layer replies via respondValidationErrorI18n, matching the pattern
already used by auth endpoints.
2026-04-17 19:04:23 +02:00
david c20720f120 Add live docs URL to README and remove broken /docs shortcut 2026-03-27 01:09:14 +01:00
david a07d033e93 Add NoOp email service, /docs shortcut and fix streak timezone bug
Replace nil email service pattern with NoOpEmailService (Null Object)
to eliminate nil pointer panics across all handlers.

Add /docs route as a shortcut to Swagger UI.

Fix streak calculation returning 0 when server timezone differs from
UTC — CreatedAt was not converted to UTC before date extraction.
2026-03-27 00:32:10 +01:00
david 67fc508384 Fix import order 2026-03-27 00:04:15 +01:00
david 92fe617f73 Fix typed nil panic when SMTP is not configured 2026-03-27 00:00:02 +01:00
david 94c5c30d09 feat: allow editing frequency and target_value on habits
- Add frequency to UpdateHabitRequest (was immutable, now editable)
- Validate frequency + specific_days/dates coherence on update
- Keep type and is_negative immutable (they change entry semantics)
- Remove completion_rate references from swagger and README
2026-03-08 00:14:25 +01:00
david 2b059ec334 refactor: extract streak calculation to domain service
Move streak logic from application query to a dedicated domain service,
making it reusable and properly tested for all habit configurations.

- Support streaks for all habit types (boolean, counter, value) combined
  with positive/negative and optional target values
- Handle weekly habits with specific days (streak counts scheduled days)
- Today completed counts toward streak; not yet completed doesn't break it
- Calculate current and longest streak in a single forward pass
- Remove completion_rate from stats (not a useful metric for habits)
- Add comprehensive unit tests covering all 10 type combinations
2026-03-07 22:49:31 +01:00
david 77cfb709d8 feat: add graceful shutdown on SIGINT/SIGTERM 2026-03-07 14:33:30 +01:00
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
51 changed files with 6102 additions and 292 deletions
+7
View File
@@ -29,3 +29,10 @@ 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
+1
View File
@@ -4,6 +4,7 @@ data/
*.db-shm
*.db-wal
apocapoc-api
/api
dist/
bin/
.internal-notes/
+4 -2
View File
@@ -20,7 +20,7 @@ REST API for habit tracking built with Go. Self-hosted alternative for developer
- Multiple habit types: Boolean, Counter, Value
- Flexible scheduling: Daily, Weekly, Monthly
- Statistics: Streaks, completion rates, progress tracking
- Statistics: Streaks and completions tracking
- JWT authentication, rate limiting, optional email verification
- Registration modes: Open or closed
- SQLite database (single file)
@@ -139,7 +139,9 @@ API runs on `http://localhost:8080`
## API Documentation
Access the interactive Swagger UI at `http://localhost:8080/api/v1/docs`
**Live:** [apocapoc.app/api/v1/docs](https://apocapoc.app/api/v1/docs)
**Local:** `http://localhost:8080/api/v1/docs`
Includes endpoint reference, schemas, authentication examples, and live testing.
+55 -4
View File
@@ -1,17 +1,23 @@
package main
import (
"context"
"fmt"
"log"
"net/http"
"os"
"os/signal"
"strconv"
"strings"
"syscall"
"time"
"apocapoc-api/internal/application/commands"
"apocapoc-api/internal/application/queries"
"apocapoc-api/internal/domain/services"
"apocapoc-api/internal/i18n"
"apocapoc-api/internal/infrastructure/auth"
"apocapoc-api/internal/infrastructure/backup"
"apocapoc-api/internal/infrastructure/config"
"apocapoc-api/internal/infrastructure/crypto"
"apocapoc-api/internal/infrastructure/email"
@@ -55,6 +61,27 @@ func main() {
}
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 {
logger.Fatal().Err(err).Msg("Invalid JWT_EXPIRY")
@@ -68,7 +95,7 @@ func main() {
jwtService := auth.NewJWTService(cfg.JWTSecret, jwtExpiryHours)
passwordHasher := crypto.NewBcryptHasher()
var emailService *email.SMTPService
var emailService services.EmailService = &services.NoOpEmailService{}
if cfg.SMTPHost != "" {
smtpPort, err := strconv.Atoi(cfg.SMTPPort)
if err != nil {
@@ -119,6 +146,8 @@ func main() {
archiveHandler := commands.NewArchiveHabitHandler(habitRepo)
markHandler := commands.NewMarkHabitHandler(entryRepo, habitRepo)
unmarkHandler := commands.NewUnmarkHabitHandler(habitRepo, entryRepo)
getSyncChangesHandler := queries.NewGetSyncChangesHandler(habitRepo, entryRepo)
applySyncBatchHandler := commands.NewApplySyncBatchHandler(habitRepo, entryRepo)
authHandlers := httpInfra.NewAuthHandlers(registerHandler, loginHandler, refreshTokenHandler, revokeTokenHandler, revokeAllTokensHandler, verifyEmailHandler, resendVerificationEmailHandler, requestPasswordResetHandler, resetPasswordHandler, jwtService, refreshTokenRepo, refreshTokenExpiry, translator)
habitHandlers := httpInfra.NewHabitHandlers(createHandler, getTodaysHandler, getUserHabitsHandler, getHabitByIDHandler, getHabitEntriesHandler, updateHandler, archiveHandler, markHandler, unmarkHandler, translator)
@@ -126,15 +155,37 @@ func main() {
healthHandlers := httpInfra.NewHealthHandlers(db.Conn(), emailService)
userHandlers := httpInfra.NewUserHandlers(deleteUserHandler, translator)
exportHandlers := httpInfra.NewExportHandlers(exportUserDataHandler, translator)
syncHandlers := httpInfra.NewSyncHandlers(getSyncChangesHandler, applySyncBatchHandler, translator)
router := httpInfra.NewRouter(cfg.AppURL, habitHandlers, authHandlers, statsHandlers, healthHandlers, userHandlers, exportHandlers, jwtService, translator)
router := httpInfra.NewRouter(cfg.AppURL, habitHandlers, authHandlers, statsHandlers, healthHandlers, userHandlers, exportHandlers, syncHandlers, jwtService, translator)
addr := fmt.Sprintf("0.0.0.0:%s", cfg.Port)
logger.Info().Str("address", addr).Msg("Server starting")
server := &http.Server{
Addr: addr,
Handler: router,
}
if err := http.ListenAndServe(addr, router); err != nil {
shutdown := make(chan os.Signal, 1)
signal.Notify(shutdown, syscall.SIGINT, syscall.SIGTERM)
go func() {
logger.Info().Str("address", addr).Msg("Server starting")
if err := server.ListenAndServe(); err != nil && err != http.ErrServerClosed {
logger.Fatal().Err(err).Msg("Server failed")
}
}()
<-shutdown
logger.Info().Msg("Shutting down gracefully...")
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
if err := server.Shutdown(ctx); err != nil {
logger.Fatal().Err(err).Msg("Server forced to shutdown")
}
logger.Info().Msg("Server stopped")
}
func parseJWTExpiry(expiry string) (int, error) {
+849 -15
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": {
@@ -835,7 +1172,7 @@ const docTemplate = `{
"BearerAuth": []
}
],
"description": "Get statistics for a specific habit including streaks and completion rates",
"description": "Get statistics for a specific habit including streaks and completions",
"produces": [
"application/json"
],
@@ -885,6 +1222,167 @@ const docTemplate = `{
}
}
}
},
"/sync/batch": {
"post": {
"security": [
{
"BearerAuth": []
}
],
"description": "Apply a batch of changes from the client for offline sync (Last-Write-Wins)",
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
"tags": [
"sync"
],
"summary": "Apply sync batch",
"parameters": [
{
"description": "Sync batch data",
"name": "request",
"in": "body",
"required": true,
"schema": {
"$ref": "#/definitions/http.SyncBatchRequest"
}
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"type": "object",
"additionalProperties": {
"type": "string"
}
}
},
"400": {
"description": "Bad Request",
"schema": {
"$ref": "#/definitions/http.ErrorResponse"
}
},
"401": {
"description": "Unauthorized",
"schema": {
"$ref": "#/definitions/http.ErrorResponse"
}
},
"500": {
"description": "Internal Server Error",
"schema": {
"$ref": "#/definitions/http.ErrorResponse"
}
}
}
}
},
"/sync/changes": {
"get": {
"security": [
{
"BearerAuth": []
}
],
"description": "Get all changes (habits and entries) since a given timestamp for offline sync",
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
"tags": [
"sync"
],
"summary": "Get sync changes",
"parameters": [
{
"type": "string",
"description": "ISO 8601 timestamp (e.g., 2025-01-01T00:00:00Z)",
"name": "since",
"in": "query",
"required": true
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"$ref": "#/definitions/http.SyncChangesResponse"
}
},
"400": {
"description": "Bad Request",
"schema": {
"$ref": "#/definitions/http.ErrorResponse"
}
},
"401": {
"description": "Unauthorized",
"schema": {
"$ref": "#/definitions/http.ErrorResponse"
}
},
"500": {
"description": "Internal Server Error",
"schema": {
"$ref": "#/definitions/http.ErrorResponse"
}
}
}
}
},
"/users/me": {
"delete": {
"security": [
{
"BearerAuth": []
}
],
"description": "Permanently delete the authenticated user's account and all associated data (habits, entries, tokens). This action cannot be undone.",
"produces": [
"application/json"
],
"tags": [
"users"
],
"summary": "Delete user account",
"responses": {
"200": {
"description": "Account deleted successfully",
"schema": {
"type": "object",
"additionalProperties": {
"type": "string"
}
}
},
"401": {
"description": "Unauthorized - invalid or missing token",
"schema": {
"$ref": "#/definitions/http.ErrorResponse"
}
},
"404": {
"description": "User not found",
"schema": {
"$ref": "#/definitions/http.ErrorResponse"
}
},
"500": {
"description": "Internal server error",
"schema": {
"$ref": "#/definitions/http.ErrorResponse"
}
}
}
}
}
},
"definitions": {
@@ -940,6 +1438,29 @@ const docTemplate = `{
}
}
},
"http.EntryChangesDTO": {
"type": "object",
"properties": {
"created": {
"type": "array",
"items": {
"$ref": "#/definitions/http.SyncHabitEntryDTO"
}
},
"deleted": {
"type": "array",
"items": {
"type": "string"
}
},
"updated": {
"type": "array",
"items": {
"$ref": "#/definitions/http.SyncHabitEntryDTO"
}
}
}
},
"http.ErrorResponse": {
"type": "object",
"properties": {
@@ -948,6 +1469,51 @@ const docTemplate = `{
}
}
},
"http.ForgotPasswordRequest": {
"type": "object",
"properties": {
"email": {
"type": "string"
}
}
},
"http.GetUserHabitsResponse": {
"type": "object",
"properties": {
"data": {
"type": "array",
"items": {
"$ref": "#/definitions/http.UserHabitResponse"
}
},
"pagination": {
"$ref": "#/definitions/pagination.Response"
}
}
},
"http.HabitChangesDTO": {
"type": "object",
"properties": {
"created": {
"type": "array",
"items": {
"$ref": "#/definitions/http.SyncHabitDTO"
}
},
"deleted": {
"type": "array",
"items": {
"type": "string"
}
},
"updated": {
"type": "array",
"items": {
"$ref": "#/definitions/http.SyncHabitDTO"
}
}
}
},
"http.HabitEntriesResponse": {
"type": "object",
"properties": {
@@ -994,6 +1560,9 @@ const docTemplate = `{
"database": {
"type": "string"
},
"smtp": {
"type": "string"
},
"status": {
"type": "string"
},
@@ -1048,15 +1617,157 @@ const docTemplate = `{
},
"password": {
"type": "string"
}
}
},
"timezone": {
"http.RegisterResponse": {
"type": "object",
"properties": {
"message": {
"type": "string"
},
"user_id": {
"type": "string"
}
}
},
"http.ResendVerificationRequest": {
"type": "object",
"properties": {
"email": {
"type": "string"
}
}
},
"http.ResetPasswordRequest": {
"type": "object",
"properties": {
"new_password": {
"type": "string"
},
"token": {
"type": "string"
}
}
},
"http.SyncBatchRequest": {
"type": "object",
"properties": {
"entries": {
"$ref": "#/definitions/http.EntryChangesDTO"
},
"habits": {
"$ref": "#/definitions/http.HabitChangesDTO"
}
}
},
"http.SyncChangesResponse": {
"type": "object",
"properties": {
"entries": {
"$ref": "#/definitions/http.EntryChangesDTO"
},
"habits": {
"$ref": "#/definitions/http.HabitChangesDTO"
}
}
},
"http.SyncHabitDTO": {
"type": "object",
"properties": {
"archived_at": {
"type": "string"
},
"carry_over": {
"type": "boolean"
},
"created_at": {
"type": "string"
},
"description": {
"type": "string"
},
"frequency": {
"$ref": "#/definitions/value_objects.Frequency"
},
"id": {
"type": "string"
},
"is_negative": {
"type": "boolean"
},
"name": {
"type": "string"
},
"specific_dates": {
"type": "array",
"items": {
"type": "integer"
}
},
"specific_days": {
"type": "array",
"items": {
"type": "integer"
}
},
"target_value": {
"type": "number"
},
"type": {
"$ref": "#/definitions/value_objects.HabitType"
},
"updated_at": {
"type": "string"
},
"user_id": {
"type": "string"
}
}
},
"http.SyncHabitEntryDTO": {
"type": "object",
"properties": {
"completed_at": {
"type": "string"
},
"habit_id": {
"type": "string"
},
"id": {
"type": "string"
},
"scheduled_date": {
"type": "string"
},
"updated_at": {
"type": "string"
},
"value": {
"type": "number"
}
}
},
"http.TodaysHabitEntryResponse": {
"type": "object",
"properties": {
"completed_at": {
"type": "string"
},
"id": {
"type": "string"
},
"value": {
"type": "number"
}
}
},
"http.TodaysHabitResponse": {
"type": "object",
"properties": {
"entry": {
"$ref": "#/definitions/http.TodaysHabitEntryResponse"
},
"id": {
"type": "string"
},
@@ -1089,6 +1800,9 @@ const docTemplate = `{
"description": {
"type": "string"
},
"frequency": {
"$ref": "#/definitions/value_objects.Frequency"
},
"name": {
"type": "string"
},
@@ -1141,12 +1855,132 @@ const docTemplate = `{
}
}
},
"http.ValidationErrorResponse": {
"type": "object",
"properties": {
"error": {
"type": "string"
},
"field": {
"type": "string"
}
}
},
"http.VerifyEmailRequest": {
"type": "object",
"properties": {
"token": {
"type": "string"
}
}
},
"pagination.Response": {
"type": "object",
"properties": {
"page": {
"type": "integer"
},
"page_size": {
"type": "integer"
},
"total_items": {
"type": "integer"
},
"total_pages": {
"type": "integer"
}
}
},
"queries.ExportEntryDTO": {
"type": "object",
"properties": {
"completed_at": {
"type": "string"
},
"habit_id": {
"type": "string"
},
"id": {
"type": "string"
},
"scheduled_date": {
"type": "string"
},
"value": {
"type": "number"
}
}
},
"queries.ExportHabitDTO": {
"type": "object",
"properties": {
"archived_at": {
"type": "string"
},
"carry_over": {
"type": "boolean"
},
"created_at": {
"type": "string"
},
"description": {
"type": "string"
},
"frequency": {
"$ref": "#/definitions/value_objects.Frequency"
},
"id": {
"type": "string"
},
"is_negative": {
"type": "boolean"
},
"name": {
"type": "string"
},
"specific_dates": {
"type": "array",
"items": {
"type": "integer"
}
},
"specific_days": {
"type": "array",
"items": {
"type": "integer"
}
},
"target_value": {
"type": "number"
},
"type": {
"$ref": "#/definitions/value_objects.HabitType"
}
}
},
"queries.ExportUserDataResult": {
"type": "object",
"properties": {
"entries": {
"type": "array",
"items": {
"$ref": "#/definitions/queries.ExportEntryDTO"
}
},
"exported_at": {
"type": "string"
},
"habits": {
"type": "array",
"items": {
"$ref": "#/definitions/queries.ExportHabitDTO"
}
}
}
},
"queries.HabitStatsDTO": {
"type": "object",
"properties": {
"completion_rate": {
"type": "number"
},
"completions_this_month": {
"type": "integer"
},
+849 -15
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": {
@@ -827,7 +1164,7 @@
"BearerAuth": []
}
],
"description": "Get statistics for a specific habit including streaks and completion rates",
"description": "Get statistics for a specific habit including streaks and completions",
"produces": [
"application/json"
],
@@ -877,6 +1214,167 @@
}
}
}
},
"/sync/batch": {
"post": {
"security": [
{
"BearerAuth": []
}
],
"description": "Apply a batch of changes from the client for offline sync (Last-Write-Wins)",
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
"tags": [
"sync"
],
"summary": "Apply sync batch",
"parameters": [
{
"description": "Sync batch data",
"name": "request",
"in": "body",
"required": true,
"schema": {
"$ref": "#/definitions/http.SyncBatchRequest"
}
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"type": "object",
"additionalProperties": {
"type": "string"
}
}
},
"400": {
"description": "Bad Request",
"schema": {
"$ref": "#/definitions/http.ErrorResponse"
}
},
"401": {
"description": "Unauthorized",
"schema": {
"$ref": "#/definitions/http.ErrorResponse"
}
},
"500": {
"description": "Internal Server Error",
"schema": {
"$ref": "#/definitions/http.ErrorResponse"
}
}
}
}
},
"/sync/changes": {
"get": {
"security": [
{
"BearerAuth": []
}
],
"description": "Get all changes (habits and entries) since a given timestamp for offline sync",
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
"tags": [
"sync"
],
"summary": "Get sync changes",
"parameters": [
{
"type": "string",
"description": "ISO 8601 timestamp (e.g., 2025-01-01T00:00:00Z)",
"name": "since",
"in": "query",
"required": true
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"$ref": "#/definitions/http.SyncChangesResponse"
}
},
"400": {
"description": "Bad Request",
"schema": {
"$ref": "#/definitions/http.ErrorResponse"
}
},
"401": {
"description": "Unauthorized",
"schema": {
"$ref": "#/definitions/http.ErrorResponse"
}
},
"500": {
"description": "Internal Server Error",
"schema": {
"$ref": "#/definitions/http.ErrorResponse"
}
}
}
}
},
"/users/me": {
"delete": {
"security": [
{
"BearerAuth": []
}
],
"description": "Permanently delete the authenticated user's account and all associated data (habits, entries, tokens). This action cannot be undone.",
"produces": [
"application/json"
],
"tags": [
"users"
],
"summary": "Delete user account",
"responses": {
"200": {
"description": "Account deleted successfully",
"schema": {
"type": "object",
"additionalProperties": {
"type": "string"
}
}
},
"401": {
"description": "Unauthorized - invalid or missing token",
"schema": {
"$ref": "#/definitions/http.ErrorResponse"
}
},
"404": {
"description": "User not found",
"schema": {
"$ref": "#/definitions/http.ErrorResponse"
}
},
"500": {
"description": "Internal server error",
"schema": {
"$ref": "#/definitions/http.ErrorResponse"
}
}
}
}
}
},
"definitions": {
@@ -932,6 +1430,29 @@
}
}
},
"http.EntryChangesDTO": {
"type": "object",
"properties": {
"created": {
"type": "array",
"items": {
"$ref": "#/definitions/http.SyncHabitEntryDTO"
}
},
"deleted": {
"type": "array",
"items": {
"type": "string"
}
},
"updated": {
"type": "array",
"items": {
"$ref": "#/definitions/http.SyncHabitEntryDTO"
}
}
}
},
"http.ErrorResponse": {
"type": "object",
"properties": {
@@ -940,6 +1461,51 @@
}
}
},
"http.ForgotPasswordRequest": {
"type": "object",
"properties": {
"email": {
"type": "string"
}
}
},
"http.GetUserHabitsResponse": {
"type": "object",
"properties": {
"data": {
"type": "array",
"items": {
"$ref": "#/definitions/http.UserHabitResponse"
}
},
"pagination": {
"$ref": "#/definitions/pagination.Response"
}
}
},
"http.HabitChangesDTO": {
"type": "object",
"properties": {
"created": {
"type": "array",
"items": {
"$ref": "#/definitions/http.SyncHabitDTO"
}
},
"deleted": {
"type": "array",
"items": {
"type": "string"
}
},
"updated": {
"type": "array",
"items": {
"$ref": "#/definitions/http.SyncHabitDTO"
}
}
}
},
"http.HabitEntriesResponse": {
"type": "object",
"properties": {
@@ -986,6 +1552,9 @@
"database": {
"type": "string"
},
"smtp": {
"type": "string"
},
"status": {
"type": "string"
},
@@ -1040,15 +1609,157 @@
},
"password": {
"type": "string"
}
}
},
"timezone": {
"http.RegisterResponse": {
"type": "object",
"properties": {
"message": {
"type": "string"
},
"user_id": {
"type": "string"
}
}
},
"http.ResendVerificationRequest": {
"type": "object",
"properties": {
"email": {
"type": "string"
}
}
},
"http.ResetPasswordRequest": {
"type": "object",
"properties": {
"new_password": {
"type": "string"
},
"token": {
"type": "string"
}
}
},
"http.SyncBatchRequest": {
"type": "object",
"properties": {
"entries": {
"$ref": "#/definitions/http.EntryChangesDTO"
},
"habits": {
"$ref": "#/definitions/http.HabitChangesDTO"
}
}
},
"http.SyncChangesResponse": {
"type": "object",
"properties": {
"entries": {
"$ref": "#/definitions/http.EntryChangesDTO"
},
"habits": {
"$ref": "#/definitions/http.HabitChangesDTO"
}
}
},
"http.SyncHabitDTO": {
"type": "object",
"properties": {
"archived_at": {
"type": "string"
},
"carry_over": {
"type": "boolean"
},
"created_at": {
"type": "string"
},
"description": {
"type": "string"
},
"frequency": {
"$ref": "#/definitions/value_objects.Frequency"
},
"id": {
"type": "string"
},
"is_negative": {
"type": "boolean"
},
"name": {
"type": "string"
},
"specific_dates": {
"type": "array",
"items": {
"type": "integer"
}
},
"specific_days": {
"type": "array",
"items": {
"type": "integer"
}
},
"target_value": {
"type": "number"
},
"type": {
"$ref": "#/definitions/value_objects.HabitType"
},
"updated_at": {
"type": "string"
},
"user_id": {
"type": "string"
}
}
},
"http.SyncHabitEntryDTO": {
"type": "object",
"properties": {
"completed_at": {
"type": "string"
},
"habit_id": {
"type": "string"
},
"id": {
"type": "string"
},
"scheduled_date": {
"type": "string"
},
"updated_at": {
"type": "string"
},
"value": {
"type": "number"
}
}
},
"http.TodaysHabitEntryResponse": {
"type": "object",
"properties": {
"completed_at": {
"type": "string"
},
"id": {
"type": "string"
},
"value": {
"type": "number"
}
}
},
"http.TodaysHabitResponse": {
"type": "object",
"properties": {
"entry": {
"$ref": "#/definitions/http.TodaysHabitEntryResponse"
},
"id": {
"type": "string"
},
@@ -1081,6 +1792,9 @@
"description": {
"type": "string"
},
"frequency": {
"$ref": "#/definitions/value_objects.Frequency"
},
"name": {
"type": "string"
},
@@ -1133,12 +1847,132 @@
}
}
},
"http.ValidationErrorResponse": {
"type": "object",
"properties": {
"error": {
"type": "string"
},
"field": {
"type": "string"
}
}
},
"http.VerifyEmailRequest": {
"type": "object",
"properties": {
"token": {
"type": "string"
}
}
},
"pagination.Response": {
"type": "object",
"properties": {
"page": {
"type": "integer"
},
"page_size": {
"type": "integer"
},
"total_items": {
"type": "integer"
},
"total_pages": {
"type": "integer"
}
}
},
"queries.ExportEntryDTO": {
"type": "object",
"properties": {
"completed_at": {
"type": "string"
},
"habit_id": {
"type": "string"
},
"id": {
"type": "string"
},
"scheduled_date": {
"type": "string"
},
"value": {
"type": "number"
}
}
},
"queries.ExportHabitDTO": {
"type": "object",
"properties": {
"archived_at": {
"type": "string"
},
"carry_over": {
"type": "boolean"
},
"created_at": {
"type": "string"
},
"description": {
"type": "string"
},
"frequency": {
"$ref": "#/definitions/value_objects.Frequency"
},
"id": {
"type": "string"
},
"is_negative": {
"type": "boolean"
},
"name": {
"type": "string"
},
"specific_dates": {
"type": "array",
"items": {
"type": "integer"
}
},
"specific_days": {
"type": "array",
"items": {
"type": "integer"
}
},
"target_value": {
"type": "number"
},
"type": {
"$ref": "#/definitions/value_objects.HabitType"
}
}
},
"queries.ExportUserDataResult": {
"type": "object",
"properties": {
"entries": {
"type": "array",
"items": {
"$ref": "#/definitions/queries.ExportEntryDTO"
}
},
"exported_at": {
"type": "string"
},
"habits": {
"type": "array",
"items": {
"$ref": "#/definitions/queries.ExportHabitDTO"
}
}
}
},
"queries.HabitStatsDTO": {
"type": "object",
"properties": {
"completion_rate": {
"type": "number"
},
"completions_this_month": {
"type": "integer"
},
+560 -16
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:
@@ -130,6 +268,8 @@ definitions:
type: boolean
description:
type: string
frequency:
$ref: '#/definitions/value_objects.Frequency'
name:
type: string
specific_dates:
@@ -164,10 +304,88 @@ definitions:
type:
$ref: '#/definitions/value_objects.HabitType'
type: object
http.ValidationErrorResponse:
properties:
error:
type: string
field:
type: string
type: object
http.VerifyEmailRequest:
properties:
token:
type: string
type: object
pagination.Response:
properties:
page:
type: integer
page_size:
type: integer
total_items:
type: integer
total_pages:
type: integer
type: object
queries.ExportEntryDTO:
properties:
completed_at:
type: string
habit_id:
type: string
id:
type: string
scheduled_date:
type: string
value:
type: number
type: object
queries.ExportHabitDTO:
properties:
archived_at:
type: string
carry_over:
type: boolean
created_at:
type: string
description:
type: string
frequency:
$ref: '#/definitions/value_objects.Frequency'
id:
type: string
is_negative:
type: boolean
name:
type: string
specific_dates:
items:
type: integer
type: array
specific_days:
items:
type: integer
type: array
target_value:
type: number
type:
$ref: '#/definitions/value_objects.HabitType'
type: object
queries.ExportUserDataResult:
properties:
entries:
items:
$ref: '#/definitions/queries.ExportEntryDTO'
type: array
exported_at:
type: string
habits:
items:
$ref: '#/definitions/queries.ExportHabitDTO'
type: array
type: object
queries.HabitStatsDTO:
properties:
completion_rate:
type: number
completions_this_month:
type: integer
completions_this_week:
@@ -215,6 +433,46 @@ info:
termsOfService: http://swagger.io/terms/
title: Apocapoc API
paths:
/auth/forgot-password:
post:
consumes:
- application/json
description: Request a password reset email with a reset token
parameters:
- description: User email
in: body
name: request
required: true
schema:
$ref: '#/definitions/http.ForgotPasswordRequest'
produces:
- application/json
responses:
"200":
description: Reset email sent successfully
schema:
additionalProperties:
type: string
type: object
"400":
description: Invalid email
schema:
$ref: '#/definitions/http.ErrorResponse'
"403":
description: Email not verified
schema:
$ref: '#/definitions/http.ErrorResponse'
"404":
description: User not found
schema:
$ref: '#/definitions/http.ErrorResponse'
"500":
description: Internal server error
schema:
$ref: '#/definitions/http.ErrorResponse'
summary: Request password reset
tags:
- auth
/auth/login:
post:
consumes:
@@ -244,6 +502,10 @@ paths:
description: Invalid email or password
schema:
$ref: '#/definitions/http.ErrorResponse'
"403":
description: Email not verified
schema:
$ref: '#/definitions/http.ErrorResponse'
"500":
description: Internal server error
schema:
@@ -333,9 +595,8 @@ paths:
post:
consumes:
- application/json
description: Create a new user account and receive both access token and refresh
token. Store both tokens securely - the refresh token is used to obtain new
access tokens when they expire.
description: Create a new user account. If email verification is enabled, you
will receive a verification email. Otherwise, you can login immediately.
parameters:
- description: 'Registration data (password requires: min 8 chars, uppercase,
lowercase, digit, special char)'
@@ -348,11 +609,15 @@ paths:
- application/json
responses:
"201":
description: Returns access token, refresh token, and user ID
description: Returns user ID and message about next steps
schema:
$ref: '#/definitions/http.AuthResponse'
$ref: '#/definitions/http.RegisterResponse'
"400":
description: 'Invalid input: email format, password requirements, or timezone'
description: 'Invalid input: email format or password requirements'
schema:
$ref: '#/definitions/http.ValidationErrorResponse'
"403":
description: Registration is closed
schema:
$ref: '#/definitions/http.ErrorResponse'
"409":
@@ -366,18 +631,182 @@ paths:
summary: Register a new user
tags:
- auth
/auth/resend-verification:
post:
consumes:
- application/json
description: Resend the email verification link to the user's email address
parameters:
- description: User email
in: body
name: request
required: true
schema:
$ref: '#/definitions/http.ResendVerificationRequest'
produces:
- application/json
responses:
"200":
description: Verification email sent
schema:
additionalProperties:
type: string
type: object
"400":
description: Invalid email
schema:
$ref: '#/definitions/http.ErrorResponse'
"404":
description: User not found
schema:
$ref: '#/definitions/http.ErrorResponse'
"409":
description: Email already verified
schema:
$ref: '#/definitions/http.ErrorResponse'
"500":
description: Internal server error
schema:
$ref: '#/definitions/http.ErrorResponse'
summary: Resend verification email
tags:
- auth
/auth/reset-password:
post:
consumes:
- application/json
description: Reset user password using the reset token from email
parameters:
- description: Reset token and new password
in: body
name: request
required: true
schema:
$ref: '#/definitions/http.ResetPasswordRequest'
produces:
- application/json
responses:
"200":
description: Password reset successfully
schema:
additionalProperties:
type: string
type: object
"400":
description: Invalid token or password requirements not met
schema:
$ref: '#/definitions/http.ErrorResponse'
"404":
description: User not found
schema:
$ref: '#/definitions/http.ErrorResponse'
"500":
description: Internal server error
schema:
$ref: '#/definitions/http.ErrorResponse'
summary: Reset password
tags:
- auth
/auth/verify-email:
post:
consumes:
- application/json
description: Verify user email address using the token sent via email
parameters:
- description: Verification token
in: body
name: request
required: true
schema:
$ref: '#/definitions/http.VerifyEmailRequest'
produces:
- application/json
responses:
"200":
description: Email verified successfully
schema:
additionalProperties:
type: string
type: object
"400":
description: Invalid or expired token
schema:
$ref: '#/definitions/http.ErrorResponse'
"409":
description: Email already verified
schema:
$ref: '#/definitions/http.ErrorResponse'
"500":
description: Internal server error
schema:
$ref: '#/definitions/http.ErrorResponse'
summary: Verify email address
tags:
- auth
/export:
get:
description: Export all user habits and entries in JSON format with gzip compression.
Limited to 1 export per hour.
produces:
- application/json
responses:
"200":
description: Compressed JSON export
schema:
$ref: '#/definitions/queries.ExportUserDataResult'
"401":
description: Unauthorized
schema:
$ref: '#/definitions/http.ErrorResponse'
"429":
description: Rate limit exceeded
schema:
$ref: '#/definitions/http.ErrorResponse'
"500":
description: Internal server error
schema:
$ref: '#/definitions/http.ErrorResponse'
security:
- BearerAuth: []
summary: Export user data
tags:
- export
/habits:
get:
description: Get all active habits for the authenticated user
description: Get all active habits for the authenticated user with optional
pagination and filters
parameters:
- description: 'Page number (default: 1)'
in: query
name: page
type: integer
- description: 'Page size (default: 50, max: 100)'
in: query
name: page_size
type: integer
- description: Filter by type (BOOLEAN, COUNTER, VALUE)
in: query
name: type
type: string
- description: Filter by frequency (DAILY, WEEKLY, MONTHLY)
in: query
name: frequency
type: string
- description: 'Include archived habits (default: false)'
in: query
name: archived
type: boolean
- description: Search by name or description
in: query
name: search
type: string
produces:
- application/json
responses:
"200":
description: OK
schema:
items:
$ref: '#/definitions/http.UserHabitResponse'
type: array
$ref: '#/definitions/http.GetUserHabitsResponse'
"401":
description: Unauthorized
schema:
@@ -708,7 +1137,15 @@ paths:
- habits
/habits/today:
get:
description: Get all habits scheduled for today for the authenticated user
description: Get all habits scheduled for today for the authenticated user.
Includes the entry for today if it exists. Requires timezone as query parameter
(e.g., ?timezone=America/New_York).
parameters:
- description: IANA timezone (e.g., 'America/New_York', 'Europe/Madrid', 'UTC')
in: query
name: timezone
required: true
type: string
produces:
- application/json
responses:
@@ -718,6 +1155,10 @@ paths:
items:
$ref: '#/definitions/http.TodaysHabitResponse'
type: array
"400":
description: Invalid or missing timezone
schema:
$ref: '#/definitions/http.ErrorResponse'
"401":
description: Unauthorized
schema:
@@ -750,8 +1191,7 @@ paths:
- system
/stats/habits/{id}:
get:
description: Get statistics for a specific habit including streaks and completion
rates
description: Get statistics for a specific habit including streaks and completions
parameters:
- description: Habit ID
in: path
@@ -786,6 +1226,110 @@ paths:
summary: Get habit statistics
tags:
- stats
/sync/batch:
post:
consumes:
- application/json
description: Apply a batch of changes from the client for offline sync (Last-Write-Wins)
parameters:
- description: Sync batch data
in: body
name: request
required: true
schema:
$ref: '#/definitions/http.SyncBatchRequest'
produces:
- application/json
responses:
"200":
description: OK
schema:
additionalProperties:
type: string
type: object
"400":
description: Bad Request
schema:
$ref: '#/definitions/http.ErrorResponse'
"401":
description: Unauthorized
schema:
$ref: '#/definitions/http.ErrorResponse'
"500":
description: Internal Server Error
schema:
$ref: '#/definitions/http.ErrorResponse'
security:
- BearerAuth: []
summary: Apply sync batch
tags:
- sync
/sync/changes:
get:
consumes:
- application/json
description: Get all changes (habits and entries) since a given timestamp for
offline sync
parameters:
- description: ISO 8601 timestamp (e.g., 2025-01-01T00:00:00Z)
in: query
name: since
required: true
type: string
produces:
- application/json
responses:
"200":
description: OK
schema:
$ref: '#/definitions/http.SyncChangesResponse'
"400":
description: Bad Request
schema:
$ref: '#/definitions/http.ErrorResponse'
"401":
description: Unauthorized
schema:
$ref: '#/definitions/http.ErrorResponse'
"500":
description: Internal Server Error
schema:
$ref: '#/definitions/http.ErrorResponse'
security:
- BearerAuth: []
summary: Get sync changes
tags:
- sync
/users/me:
delete:
description: Permanently delete the authenticated user's account and all associated
data (habits, entries, tokens). This action cannot be undone.
produces:
- application/json
responses:
"200":
description: Account deleted successfully
schema:
additionalProperties:
type: string
type: object
"401":
description: Unauthorized - invalid or missing token
schema:
$ref: '#/definitions/http.ErrorResponse'
"404":
description: User not found
schema:
$ref: '#/definitions/http.ErrorResponse'
"500":
description: Internal server error
schema:
$ref: '#/definitions/http.ErrorResponse'
security:
- BearerAuth: []
summary: Delete user account
tags:
- users
securityDefinitions:
BearerAuth:
description: Type "Bearer" followed by a space and JWT token.
@@ -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))
}
}
@@ -2,6 +2,7 @@ package commands
import (
"context"
"fmt"
"apocapoc-api/internal/domain/entities"
"apocapoc-api/internal/domain/repositories"
@@ -32,19 +33,19 @@ func NewCreateHabitHandler(habitRepo repositories.HabitRepository) *CreateHabitH
func (h *CreateHabitHandler) Handle(ctx context.Context, cmd CreateHabitCommand) (string, error) {
if !cmd.Type.IsValid() {
return "", errors.ErrInvalidInput
return "", fmt.Errorf("%w: type: type_invalid", errors.ErrInvalidInput)
}
if !cmd.Frequency.IsValid() {
return "", errors.ErrInvalidInput
return "", fmt.Errorf("%w: frequency: frequency_invalid", errors.ErrInvalidInput)
}
if cmd.Frequency == value_objects.FrequencyWeekly && len(cmd.SpecificDays) == 0 {
return "", errors.ErrInvalidInput
return "", fmt.Errorf("%w: specific_days: specific_days_required", errors.ErrInvalidInput)
}
if cmd.Frequency == value_objects.FrequencyMonthly && len(cmd.SpecificDates) == 0 {
return "", errors.ErrInvalidInput
return "", fmt.Errorf("%w: specific_dates: specific_dates_required", errors.ErrInvalidInput)
}
habit := entities.NewHabit(cmd.UserID, cmd.Name, cmd.Type, cmd.Frequency, cmd.CarryOver, cmd.IsNegative)
@@ -4,7 +4,10 @@ import (
"apocapoc-api/internal/domain/repositories"
"apocapoc-api/internal/shared/pagination"
"context"
stderrors "errors"
"strings"
"testing"
"time"
"apocapoc-api/internal/domain/entities"
"apocapoc-api/internal/shared/errors"
@@ -38,6 +41,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 {
@@ -81,8 +112,31 @@ func TestCreateHabitHandler_InvalidType(t *testing.T) {
_, err := handler.Handle(context.Background(), cmd)
if err != errors.ErrInvalidInput {
t.Errorf("Expected ErrInvalidInput, got %v", err)
if !stderrors.Is(err, errors.ErrInvalidInput) {
t.Fatalf("Expected ErrInvalidInput wrapper, got %v", err)
}
if !strings.Contains(err.Error(), "type: type_invalid") {
t.Errorf("Expected 'type: type_invalid' in error, got %q", err.Error())
}
}
func TestCreateHabitHandler_EmptyType(t *testing.T) {
mock := &mockHabitRepo{}
handler := NewCreateHabitHandler(mock)
cmd := CreateHabitCommand{
UserID: "user-123",
Name: "Exercise",
Frequency: "DAILY",
}
_, err := handler.Handle(context.Background(), cmd)
if !stderrors.Is(err, errors.ErrInvalidInput) {
t.Fatalf("Expected ErrInvalidInput wrapper, got %v", err)
}
if !strings.Contains(err.Error(), "type: type_invalid") {
t.Errorf("Expected 'type: type_invalid' in error, got %q", err.Error())
}
}
@@ -99,8 +153,11 @@ func TestCreateHabitHandler_InvalidFrequency(t *testing.T) {
_, err := handler.Handle(context.Background(), cmd)
if err != errors.ErrInvalidInput {
t.Errorf("Expected ErrInvalidInput, got %v", err)
if !stderrors.Is(err, errors.ErrInvalidInput) {
t.Fatalf("Expected ErrInvalidInput wrapper, got %v", err)
}
if !strings.Contains(err.Error(), "frequency: frequency_invalid") {
t.Errorf("Expected 'frequency: frequency_invalid' in error, got %q", err.Error())
}
}
@@ -118,8 +175,11 @@ func TestCreateHabitHandler_WeeklyWithoutSpecificDays(t *testing.T) {
_, err := handler.Handle(context.Background(), cmd)
if err != errors.ErrInvalidInput {
t.Errorf("Expected ErrInvalidInput, got %v", err)
if !stderrors.Is(err, errors.ErrInvalidInput) {
t.Fatalf("Expected ErrInvalidInput wrapper, got %v", err)
}
if !strings.Contains(err.Error(), "specific_days: specific_days_required") {
t.Errorf("Expected 'specific_days: specific_days_required' in error, got %q", err.Error())
}
}
@@ -137,8 +197,11 @@ func TestCreateHabitHandler_MonthlyWithoutSpecificDates(t *testing.T) {
_, err := handler.Handle(context.Background(), cmd)
if err != errors.ErrInvalidInput {
t.Errorf("Expected ErrInvalidInput, got %v", err)
if !stderrors.Is(err, errors.ErrInvalidInput) {
t.Fatalf("Expected ErrInvalidInput wrapper, got %v", err)
}
if !strings.Contains(err.Error(), "specific_dates: specific_dates_required") {
t.Errorf("Expected 'specific_dates: specific_dates_required' in error, got %q", err.Error())
}
}
@@ -231,19 +294,3 @@ func TestCreateHabitHandler_NegativeHabit(t *testing.T) {
t.Error("Expected habit ID to be returned")
}
}
func (m *mockHabitRepo) FindActiveByUserIDWithPagination(ctx context.Context, userID string, params pagination.Params) ([]*entities.Habit, error) {
return nil, nil
}
func (m *mockHabitRepo) CountActiveByUserID(ctx context.Context, userID string) (int, error) {
return 0, nil
}
func (m *mockHabitRepo) FindByUserIDFiltered(ctx context.Context, userID string, filter repositories.HabitFilter, paginationParams *pagination.Params) ([]*entities.Habit, error) {
return nil, nil
}
func (m *mockHabitRepo) CountByUserIDFiltered(ctx context.Context, userID string, filter repositories.HabitFilter) (int, error) {
return 0, nil
}
@@ -59,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
}
@@ -575,3 +587,15 @@ func (m *mockHabitRepoForMark) FindByUserIDFiltered(ctx context.Context, userID
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
}
@@ -73,7 +73,7 @@ func (h *RegisterUserHandler) Handle(ctx context.Context, cmd RegisterUserComman
user := entities.NewUser(cmd.Email, hashedPassword)
emailVerificationRequired := false
if h.emailService != nil {
if h.emailService.IsEnabled() {
token, err := h.generateVerificationToken()
if err != nil {
return nil, fmt.Errorf("failed to generate verification token: %w", err)
@@ -2,6 +2,7 @@ package commands
import (
"apocapoc-api/internal/domain/repositories"
"apocapoc-api/internal/domain/services"
"apocapoc-api/internal/shared/pagination"
"context"
"errors"
@@ -71,7 +72,7 @@ func TestRegisterUserHandler_Success(t *testing.T) {
},
}
hasher := &mockPasswordHasher{}
handler := NewRegisterUserHandler(repo, hasher, nil, "", "open", false)
handler := NewRegisterUserHandler(repo, hasher, &services.NoOpEmailService{}, "", "open", false)
cmd := RegisterUserCommand{
Email: "test@example.com",
@@ -88,7 +89,7 @@ func TestRegisterUserHandler_Success(t *testing.T) {
}
if result.EmailVerificationRequired {
t.Error("expected email verification to not be required when emailService is nil")
t.Error("expected email verification to not be required when email is disabled")
}
if createdUser == nil {
@@ -103,7 +104,7 @@ func TestRegisterUserHandler_Success(t *testing.T) {
func TestRegisterUserHandler_InvalidEmail(t *testing.T) {
repo := &mockUserRepo{}
hasher := &mockPasswordHasher{}
handler := NewRegisterUserHandler(repo, hasher, nil, "", "open", false)
handler := NewRegisterUserHandler(repo, hasher, &services.NoOpEmailService{}, "", "open", false)
tests := []struct {
name string
@@ -135,7 +136,7 @@ func TestRegisterUserHandler_InvalidEmail(t *testing.T) {
func TestRegisterUserHandler_InvalidPassword(t *testing.T) {
repo := &mockUserRepo{}
hasher := &mockPasswordHasher{}
handler := NewRegisterUserHandler(repo, hasher, nil, "", "open", false)
handler := NewRegisterUserHandler(repo, hasher, &services.NoOpEmailService{}, "", "open", false)
tests := []struct {
name string
@@ -174,7 +175,7 @@ func TestRegisterUserHandler_EmailAlreadyExists(t *testing.T) {
},
}
hasher := &mockPasswordHasher{}
handler := NewRegisterUserHandler(repo, hasher, nil, "", "open", false)
handler := NewRegisterUserHandler(repo, hasher, &services.NoOpEmailService{}, "", "open", false)
cmd := RegisterUserCommand{
Email: "test@example.com",
@@ -195,7 +196,7 @@ func TestRegisterUserHandler_PasswordHashingError(t *testing.T) {
return "", expectedErr
},
}
handler := NewRegisterUserHandler(repo, hasher, nil, "", "open", false)
handler := NewRegisterUserHandler(repo, hasher, &services.NoOpEmailService{}, "", "open", false)
cmd := RegisterUserCommand{
Email: "test@example.com",
@@ -216,7 +217,7 @@ func TestRegisterUserHandler_RepositoryError(t *testing.T) {
},
}
hasher := &mockPasswordHasher{}
handler := NewRegisterUserHandler(repo, hasher, nil, "", "open", false)
handler := NewRegisterUserHandler(repo, hasher, &services.NoOpEmailService{}, "", "open", false)
cmd := RegisterUserCommand{
Email: "test@example.com",
@@ -232,7 +233,7 @@ func TestRegisterUserHandler_RepositoryError(t *testing.T) {
func TestRegisterUserHandler_EdgeCases(t *testing.T) {
repo := &mockUserRepo{}
hasher := &mockPasswordHasher{}
handler := NewRegisterUserHandler(repo, hasher, nil, "", "open", false)
handler := NewRegisterUserHandler(repo, hasher, &services.NoOpEmailService{}, "", "open", false)
tests := []struct {
name string
@@ -285,7 +286,7 @@ func TestRegisterUserHandler_EdgeCases(t *testing.T) {
func TestRegisterUserHandler_ClosedRegistration(t *testing.T) {
repo := &mockUserRepo{}
hasher := &mockPasswordHasher{}
handler := NewRegisterUserHandler(repo, hasher, nil, "", "closed", false)
handler := NewRegisterUserHandler(repo, hasher, &services.NoOpEmailService{}, "", "closed", false)
cmd := RegisterUserCommand{
Email: "test@example.com",
@@ -89,6 +89,10 @@ func (m *mockRequestResetEmailService) HealthCheck() error {
return nil
}
func (m *mockRequestResetEmailService) IsEnabled() bool {
return true
}
func TestRequestPasswordResetHandler_Success(t *testing.T) {
user := entities.NewUser("test@example.com", "hash")
user.ID = "user-123"
@@ -155,3 +155,15 @@ func (m *mockEntryRepoForUnmark) FindByUserIDFiltered(ctx context.Context, userI
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
}
+19 -4
View File
@@ -5,6 +5,7 @@ import (
"strings"
"apocapoc-api/internal/domain/repositories"
"apocapoc-api/internal/domain/value_objects"
"apocapoc-api/internal/shared/errors"
)
@@ -13,10 +14,11 @@ type UpdateHabitCommand struct {
UserID string
Name string
Description string
CarryOver bool
TargetValue *float64
Frequency value_objects.Frequency
SpecificDays []int
SpecificDates []int
CarryOver bool
TargetValue *float64
}
type UpdateHabitHandler struct {
@@ -34,6 +36,18 @@ func (h *UpdateHabitHandler) Handle(ctx context.Context, cmd UpdateHabitCommand)
return errors.ErrInvalidInput
}
if !cmd.Frequency.IsValid() {
return errors.ErrInvalidInput
}
if cmd.Frequency == value_objects.FrequencyWeekly && len(cmd.SpecificDays) == 0 {
return errors.ErrInvalidInput
}
if cmd.Frequency == value_objects.FrequencyMonthly && len(cmd.SpecificDates) == 0 {
return errors.ErrInvalidInput
}
habit, err := h.habitRepo.FindByID(ctx, cmd.HabitID)
if err != nil {
return err
@@ -49,10 +63,11 @@ func (h *UpdateHabitHandler) Handle(ctx context.Context, cmd UpdateHabitCommand)
habit.Name = cmd.Name
habit.Description = cmd.Description
habit.CarryOver = cmd.CarryOver
habit.TargetValue = cmd.TargetValue
habit.Frequency = cmd.Frequency
habit.SpecificDays = cmd.SpecificDays
habit.SpecificDates = cmd.SpecificDates
habit.CarryOver = cmd.CarryOver
habit.TargetValue = cmd.TargetValue
return h.habitRepo.Update(ctx, habit)
}
@@ -48,9 +48,10 @@ func TestUpdateHabitHandler_UpdatesSuccessfully(t *testing.T) {
UserID: "user-123",
Name: "Morning Exercise",
Description: "Updated description",
Frequency: value_objects.FrequencyWeekly,
SpecificDays: []int{1, 3, 5},
CarryOver: true,
TargetValue: &newTargetValue,
SpecificDays: []int{1, 3, 5},
}
err := handler.Handle(context.Background(), cmd)
@@ -67,6 +68,14 @@ func TestUpdateHabitHandler_UpdatesSuccessfully(t *testing.T) {
t.Errorf("Expected description to be updated, got %s", habitRepo.updatedHabit.Description)
}
if habitRepo.updatedHabit.Frequency != value_objects.FrequencyWeekly {
t.Errorf("Expected frequency WEEKLY, got %s", habitRepo.updatedHabit.Frequency)
}
if len(habitRepo.updatedHabit.SpecificDays) != 3 {
t.Errorf("Expected 3 specific days, got %d", len(habitRepo.updatedHabit.SpecificDays))
}
if !habitRepo.updatedHabit.CarryOver {
t.Error("Expected CarryOver to be true")
}
@@ -74,10 +83,6 @@ func TestUpdateHabitHandler_UpdatesSuccessfully(t *testing.T) {
if habitRepo.updatedHabit.TargetValue == nil || *habitRepo.updatedHabit.TargetValue != 5.0 {
t.Errorf("Expected target value 5.0, got %v", habitRepo.updatedHabit.TargetValue)
}
if len(habitRepo.updatedHabit.SpecificDays) != 3 {
t.Errorf("Expected 3 specific days, got %d", len(habitRepo.updatedHabit.SpecificDays))
}
}
func TestUpdateHabitHandler_ReturnsErrorWhenHabitNotFound(t *testing.T) {
@@ -91,6 +96,7 @@ func TestUpdateHabitHandler_ReturnsErrorWhenHabitNotFound(t *testing.T) {
HabitID: "non-existent",
UserID: "user-123",
Name: "Exercise",
Frequency: value_objects.FrequencyDaily,
}
err := handler.Handle(context.Background(), cmd)
@@ -114,6 +120,7 @@ func TestUpdateHabitHandler_ReturnsErrorWhenUserDoesNotOwnHabit(t *testing.T) {
HabitID: "habit-1",
UserID: "user-456", // Different user
Name: "Exercise",
Frequency: value_objects.FrequencyDaily,
}
err := handler.Handle(context.Background(), cmd)
@@ -138,6 +145,7 @@ func TestUpdateHabitHandler_CannotUpdateArchivedHabit(t *testing.T) {
HabitID: "habit-1",
UserID: "user-123",
Name: "Updated Exercise",
Frequency: value_objects.FrequencyDaily,
}
err := handler.Handle(context.Background(), cmd)
@@ -161,6 +169,7 @@ func TestUpdateHabitHandler_ValidatesInput(t *testing.T) {
HabitID: "habit-1",
UserID: "user-123",
Name: "", // Empty name
Frequency: value_objects.FrequencyDaily,
}
err := handler.Handle(context.Background(), cmd)
@@ -169,3 +178,57 @@ func TestUpdateHabitHandler_ValidatesInput(t *testing.T) {
t.Errorf("Expected ErrInvalidInput for empty name, got %v", err)
}
}
func TestUpdateHabitHandler_InvalidFrequency(t *testing.T) {
habitRepo := &mockHabitRepoForUpdate{}
handler := NewUpdateHabitHandler(habitRepo)
cmd := UpdateHabitCommand{
HabitID: "habit-1",
UserID: "user-123",
Name: "Exercise",
Frequency: "INVALID",
}
err := handler.Handle(context.Background(), cmd)
if err != errors.ErrInvalidInput {
t.Errorf("Expected ErrInvalidInput for invalid frequency, got %v", err)
}
}
func TestUpdateHabitHandler_WeeklyRequiresSpecificDays(t *testing.T) {
habitRepo := &mockHabitRepoForUpdate{}
handler := NewUpdateHabitHandler(habitRepo)
cmd := UpdateHabitCommand{
HabitID: "habit-1",
UserID: "user-123",
Name: "Exercise",
Frequency: value_objects.FrequencyWeekly,
}
err := handler.Handle(context.Background(), cmd)
if err != errors.ErrInvalidInput {
t.Errorf("Expected ErrInvalidInput for weekly without specific days, got %v", err)
}
}
func TestUpdateHabitHandler_MonthlyRequiresSpecificDates(t *testing.T) {
habitRepo := &mockHabitRepoForUpdate{}
handler := NewUpdateHabitHandler(habitRepo)
cmd := UpdateHabitCommand{
HabitID: "habit-1",
UserID: "user-123",
Name: "Exercise",
Frequency: value_objects.FrequencyMonthly,
}
err := handler.Handle(context.Background(), cmd)
if err != errors.ErrInvalidInput {
t.Errorf("Expected ErrInvalidInput for monthly without specific dates, got %v", err)
}
}
@@ -69,6 +69,10 @@ func (m *mockEmailService) HealthCheck() error {
return nil
}
func (m *mockEmailService) IsEnabled() bool {
return true
}
func TestVerifyEmailHandler_Success(t *testing.T) {
token := "valid-token"
expiry := time.Now().Add(24 * time.Hour)
@@ -6,6 +6,7 @@ import (
"apocapoc-api/internal/domain/entities"
"apocapoc-api/internal/domain/repositories"
"apocapoc-api/internal/domain/services"
"apocapoc-api/internal/shared/errors"
)
@@ -15,7 +16,6 @@ type HabitStatsDTO struct {
TotalCompletions int `json:"total_completions"`
CurrentStreak int `json:"current_streak"`
LongestStreak int `json:"longest_streak"`
CompletionRate float64 `json:"completion_rate"`
CompletionsThisWeek int `json:"completions_this_week"`
CompletionsThisMonth int `json:"completions_this_month"`
}
@@ -60,102 +60,21 @@ func (h *GetHabitStatsHandler) Handle(ctx context.Context, query GetHabitStatsQu
HabitName: habit.Name,
}
if len(entries) == 0 {
if len(entries) == 0 && !habit.IsNegative {
return stats, nil
}
stats.TotalCompletions = len(entries)
stats.CurrentStreak = calculateCurrentStreak(entries)
stats.LongestStreak = calculateLongestStreak(entries)
stats.CompletionRate = calculateCompletionRate(entries, habit.CreatedAt)
stats.CompletionsThisWeek = countCompletionsInPeriod(entries, 7)
stats.CompletionsThisMonth = countCompletionsInPeriod(entries, 30)
streaks := services.CalculateStreaks(entries, habit, time.Now().UTC())
stats.CurrentStreak = streaks.Current
stats.LongestStreak = streaks.Longest
return stats, nil
}
func calculateCurrentStreak(entries []*entities.HabitEntry) int {
if len(entries) == 0 {
return 0
}
dateMap := make(map[string]bool)
for _, entry := range entries {
dateStr := entry.ScheduledDate.Format("2006-01-02")
dateMap[dateStr] = true
}
streak := 0
currentDate := time.Now().UTC()
for {
dateStr := currentDate.Format("2006-01-02")
if !dateMap[dateStr] {
break
}
streak++
currentDate = currentDate.AddDate(0, 0, -1)
}
return streak
}
func calculateLongestStreak(entries []*entities.HabitEntry) int {
if len(entries) == 0 {
return 0
}
dateMap := make(map[string]bool)
var dates []time.Time
for _, entry := range entries {
date := time.Date(entry.ScheduledDate.Year(), entry.ScheduledDate.Month(), entry.ScheduledDate.Day(), 0, 0, 0, 0, time.UTC)
dateStr := date.Format("2006-01-02")
if !dateMap[dateStr] {
dateMap[dateStr] = true
dates = append(dates, date)
}
}
if len(dates) == 0 {
return 0
}
longestStreak := 1
currentStreak := 1
for i := 1; i < len(dates); i++ {
diff := dates[i].Sub(dates[i-1]).Hours() / 24
if diff == 1 {
currentStreak++
if currentStreak > longestStreak {
longestStreak = currentStreak
}
} else {
currentStreak = 1
}
}
return longestStreak
}
func calculateCompletionRate(entries []*entities.HabitEntry, createdAt time.Time) float64 {
if len(entries) == 0 {
return 0
}
daysSinceCreation := int(time.Since(createdAt).Hours() / 24)
if daysSinceCreation == 0 {
daysSinceCreation = 1
}
rate := float64(len(entries)) / float64(daysSinceCreation) * 100
if rate > 100 {
rate = 100
}
return rate
}
func countCompletionsInPeriod(entries []*entities.HabitEntry, days int) int {
cutoff := time.Now().UTC().AddDate(0, 0, -days)
count := 0
@@ -0,0 +1,76 @@
package queries
import (
"context"
"time"
"apocapoc-api/internal/domain/entities"
"apocapoc-api/internal/domain/repositories"
"apocapoc-api/internal/shared/errors"
)
type HabitChangesDTO struct {
Created []*entities.Habit
Updated []*entities.Habit
Deleted []string
}
type EntryChangesDTO struct {
Created []*entities.HabitEntry
Updated []*entities.HabitEntry
Deleted []string
}
type SyncChangesDTO struct {
Habits HabitChangesDTO
Entries EntryChangesDTO
}
type GetSyncChangesQuery struct {
UserID string
Since time.Time
}
type GetSyncChangesHandler struct {
habitRepo repositories.HabitRepository
entryRepo repositories.HabitEntryRepository
}
func NewGetSyncChangesHandler(
habitRepo repositories.HabitRepository,
entryRepo repositories.HabitEntryRepository,
) *GetSyncChangesHandler {
return &GetSyncChangesHandler{
habitRepo: habitRepo,
entryRepo: entryRepo,
}
}
func (h *GetSyncChangesHandler) Handle(ctx context.Context, query GetSyncChangesQuery) (*SyncChangesDTO, error) {
if query.UserID == "" {
return nil, errors.ErrInvalidInput
}
habitChanges, err := h.habitRepo.GetChangesSince(ctx, query.UserID, query.Since)
if err != nil {
return nil, err
}
entryChanges, err := h.entryRepo.GetChangesSince(ctx, query.UserID, query.Since)
if err != nil {
return nil, err
}
return &SyncChangesDTO{
Habits: HabitChangesDTO{
Created: habitChanges.Created,
Updated: habitChanges.Updated,
Deleted: habitChanges.Deleted,
},
Entries: EntryChangesDTO{
Created: entryChanges.Created,
Updated: entryChanges.Updated,
Deleted: entryChanges.Deleted,
},
}, nil
}
@@ -0,0 +1,255 @@
package queries
import (
"context"
"testing"
"time"
"apocapoc-api/internal/domain/entities"
"apocapoc-api/internal/domain/repositories"
"apocapoc-api/internal/domain/value_objects"
"apocapoc-api/internal/shared/pagination"
)
type mockHabitRepoForSync struct {
changes *repositories.HabitChanges
err error
}
func (m *mockHabitRepoForSync) GetChangesSince(ctx context.Context, userID string, since time.Time) (*repositories.HabitChanges, error) {
return m.changes, m.err
}
func (m *mockHabitRepoForSync) Create(ctx context.Context, habit *entities.Habit) error {
return nil
}
func (m *mockHabitRepoForSync) FindByID(ctx context.Context, id string) (*entities.Habit, error) {
return nil, nil
}
func (m *mockHabitRepoForSync) FindByUserID(ctx context.Context, userID string) ([]*entities.Habit, error) {
return nil, nil
}
func (m *mockHabitRepoForSync) FindActiveByUserID(ctx context.Context, userID string) ([]*entities.Habit, error) {
return nil, nil
}
func (m *mockHabitRepoForSync) Update(ctx context.Context, habit *entities.Habit) error {
return nil
}
func (m *mockHabitRepoForSync) Delete(ctx context.Context, id string) error {
return nil
}
func (m *mockHabitRepoForSync) FindActiveByUserIDWithPagination(ctx context.Context, userID string, params pagination.Params) ([]*entities.Habit, error) {
return nil, nil
}
func (m *mockHabitRepoForSync) FindByUserIDFiltered(ctx context.Context, userID string, filter repositories.HabitFilter, paginationParams *pagination.Params) ([]*entities.Habit, error) {
return nil, nil
}
func (m *mockHabitRepoForSync) CountActiveByUserID(ctx context.Context, userID string) (int, error) {
return 0, nil
}
func (m *mockHabitRepoForSync) CountByUserIDFiltered(ctx context.Context, userID string, filter repositories.HabitFilter) (int, error) {
return 0, nil
}
func (m *mockHabitRepoForSync) SoftDelete(ctx context.Context, id string) error {
return nil
}
type mockEntryRepoForSync struct {
changes *repositories.HabitEntryChanges
err error
}
func (m *mockEntryRepoForSync) GetChangesSince(ctx context.Context, userID string, since time.Time) (*repositories.HabitEntryChanges, error) {
return m.changes, m.err
}
func (m *mockEntryRepoForSync) Create(ctx context.Context, entry *entities.HabitEntry) error {
return nil
}
func (m *mockEntryRepoForSync) FindByID(ctx context.Context, id string) (*entities.HabitEntry, error) {
return nil, nil
}
func (m *mockEntryRepoForSync) FindByHabitID(ctx context.Context, habitID string) ([]*entities.HabitEntry, error) {
return nil, nil
}
func (m *mockEntryRepoForSync) FindByHabitIDAndDateRange(ctx context.Context, habitID string, from, to time.Time) ([]*entities.HabitEntry, error) {
return nil, nil
}
func (m *mockEntryRepoForSync) FindByUserID(ctx context.Context, userID string) ([]*entities.HabitEntry, error) {
return nil, nil
}
func (m *mockEntryRepoForSync) FindPendingByHabitID(ctx context.Context, habitID string, beforeDate time.Time) ([]*entities.HabitEntry, error) {
return nil, nil
}
func (m *mockEntryRepoForSync) Update(ctx context.Context, entry *entities.HabitEntry) error {
return nil
}
func (m *mockEntryRepoForSync) Delete(ctx context.Context, id string) error {
return nil
}
func (m *mockEntryRepoForSync) SoftDelete(ctx context.Context, id string) error {
return nil
}
func TestGetSyncChangesHandler_Success(t *testing.T) {
now := time.Now()
since := now.Add(-1 * time.Hour)
createdHabit := entities.NewHabit("user-123", "New Habit", value_objects.HabitTypeBoolean, value_objects.FrequencyDaily, false, false)
createdHabit.ID = "habit-1"
createdHabit.CreatedAt = now
createdHabit.UpdatedAt = now
updatedHabit := entities.NewHabit("user-123", "Updated Habit", value_objects.HabitTypeCounter, value_objects.FrequencyWeekly, false, false)
updatedHabit.ID = "habit-2"
updatedHabit.CreatedAt = since.Add(-1 * time.Hour)
updatedHabit.UpdatedAt = now
habitRepo := &mockHabitRepoForSync{
changes: &repositories.HabitChanges{
Created: []*entities.Habit{createdHabit},
Updated: []*entities.Habit{updatedHabit},
Deleted: []string{"habit-3"},
},
}
createdEntry := entities.NewHabitEntry("habit-1", now, nil)
createdEntry.ID = "entry-1"
updatedEntry := entities.NewHabitEntry("habit-2", now, nil)
updatedEntry.ID = "entry-2"
updatedEntry.UpdatedAt = now
entryRepo := &mockEntryRepoForSync{
changes: &repositories.HabitEntryChanges{
Created: []*entities.HabitEntry{createdEntry},
Updated: []*entities.HabitEntry{updatedEntry},
Deleted: []string{"entry-3"},
},
}
handler := NewGetSyncChangesHandler(habitRepo, entryRepo)
query := GetSyncChangesQuery{
UserID: "user-123",
Since: since,
}
result, err := handler.Handle(context.Background(), query)
if err != nil {
t.Fatalf("Expected no error, got %v", err)
}
if len(result.Habits.Created) != 1 {
t.Errorf("Expected 1 created habit, got %d", len(result.Habits.Created))
}
if len(result.Habits.Updated) != 1 {
t.Errorf("Expected 1 updated habit, got %d", len(result.Habits.Updated))
}
if len(result.Habits.Deleted) != 1 {
t.Errorf("Expected 1 deleted habit, got %d", len(result.Habits.Deleted))
}
if len(result.Entries.Created) != 1 {
t.Errorf("Expected 1 created entry, got %d", len(result.Entries.Created))
}
if len(result.Entries.Updated) != 1 {
t.Errorf("Expected 1 updated entry, got %d", len(result.Entries.Updated))
}
if len(result.Entries.Deleted) != 1 {
t.Errorf("Expected 1 deleted entry, got %d", len(result.Entries.Deleted))
}
}
func TestGetSyncChangesHandler_EmptyChanges(t *testing.T) {
habitRepo := &mockHabitRepoForSync{
changes: &repositories.HabitChanges{
Created: []*entities.Habit{},
Updated: []*entities.Habit{},
Deleted: []string{},
},
}
entryRepo := &mockEntryRepoForSync{
changes: &repositories.HabitEntryChanges{
Created: []*entities.HabitEntry{},
Updated: []*entities.HabitEntry{},
Deleted: []string{},
},
}
handler := NewGetSyncChangesHandler(habitRepo, entryRepo)
query := GetSyncChangesQuery{
UserID: "user-123",
Since: time.Now().Add(-1 * time.Hour),
}
result, err := handler.Handle(context.Background(), query)
if err != nil {
t.Fatalf("Expected no error, got %v", err)
}
if len(result.Habits.Created) != 0 {
t.Errorf("Expected 0 created habits, got %d", len(result.Habits.Created))
}
if len(result.Entries.Created) != 0 {
t.Errorf("Expected 0 created entries, got %d", len(result.Entries.Created))
}
}
func TestGetSyncChangesHandler_InvalidUserID(t *testing.T) {
habitRepo := &mockHabitRepoForSync{
changes: &repositories.HabitChanges{
Created: []*entities.Habit{},
Updated: []*entities.Habit{},
Deleted: []string{},
},
}
entryRepo := &mockEntryRepoForSync{
changes: &repositories.HabitEntryChanges{
Created: []*entities.HabitEntry{},
Updated: []*entities.HabitEntry{},
Deleted: []string{},
},
}
handler := NewGetSyncChangesHandler(habitRepo, entryRepo)
query := GetSyncChangesQuery{
UserID: "",
Since: time.Now(),
}
_, err := handler.Handle(context.Background(), query)
if err == nil {
t.Error("Expected error for empty UserID, got nil")
}
}
@@ -89,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"
@@ -337,3 +349,15 @@ func (m *mockHabitRepo) FindByUserIDFiltered(ctx context.Context, userID string,
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
}
@@ -4,6 +4,7 @@ import (
"apocapoc-api/internal/domain/repositories"
"context"
"testing"
"time"
"apocapoc-api/internal/domain/entities"
"apocapoc-api/internal/domain/value_objects"
@@ -333,6 +334,18 @@ func (m *mockGetUserHabitsRepo) CountByUserIDFiltered(ctx context.Context, userI
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"
+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
}
@@ -7,6 +7,12 @@ 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)
@@ -16,4 +22,8 @@ type HabitEntryRepository interface {
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,6 +2,7 @@ package repositories
import (
"context"
"time"
"apocapoc-api/internal/domain/entities"
"apocapoc-api/internal/domain/value_objects"
@@ -15,6 +16,12 @@ type HabitFilter struct {
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)
@@ -26,4 +33,8 @@ type HabitRepository interface {
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
}
@@ -10,4 +10,11 @@ type EmailMessage struct {
type EmailService interface {
Send(message EmailMessage) error
HealthCheck() error
IsEnabled() bool
}
type NoOpEmailService struct{}
func (n *NoOpEmailService) Send(_ EmailMessage) error { return nil }
func (n *NoOpEmailService) HealthCheck() error { return nil }
func (n *NoOpEmailService) IsEnabled() bool { return false }
+99
View File
@@ -0,0 +1,99 @@
package services
import (
"time"
"apocapoc-api/internal/domain/entities"
"apocapoc-api/internal/shared/utils"
)
type StreakResult struct {
Current int
Longest int
}
func CalculateStreaks(entries []*entities.HabitEntry, habit *entities.Habit, now time.Time) StreakResult {
entryMap := buildEntryMap(entries)
scheduled := allScheduledDates(habit, now)
if len(scheduled) == 0 {
return StreakResult{}
}
today := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, time.UTC)
longest := 0
current := 0
for _, d := range scheduled {
if IsDaySuccessful(d.Format("2006-01-02"), entryMap, habit) {
current++
if current > longest {
longest = current
}
} else if d.Equal(today) && !habit.IsNegative {
continue
} else {
current = 0
}
}
return StreakResult{Current: current, Longest: longest}
}
func buildEntryMap(entries []*entities.HabitEntry) map[string]*entities.HabitEntry {
m := make(map[string]*entities.HabitEntry)
for _, e := range entries {
m[e.ScheduledDate.Format("2006-01-02")] = e
}
return m
}
func allScheduledDates(habit *entities.Habit, now time.Time) []time.Time {
createdUTC := habit.CreatedAt.UTC()
start := time.Date(createdUTC.Year(), createdUTC.Month(), createdUTC.Day(), 0, 0, 0, 0, time.UTC)
today := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, time.UTC)
freq := string(habit.Frequency)
var dates []time.Time
for d := start; !d.After(today); d = d.AddDate(0, 0, 1) {
if utils.ShouldAppearToday(freq, habit.SpecificDays, habit.SpecificDates, d) {
dates = append(dates, d)
}
}
return dates
}
func IsDaySuccessful(dateStr string, entryMap map[string]*entities.HabitEntry, habit *entities.Habit) bool {
entry, hasEntry := entryMap[dateStr]
habitType := string(habit.Type)
if !habit.IsNegative {
if !hasEntry {
return false
}
if habit.TargetValue != nil && entry.Value != nil {
return *entry.Value >= *habit.TargetValue
}
return true
}
if habitType == "VALUE" && habit.TargetValue != nil {
if !hasEntry {
return false
}
if entry.Value != nil {
return *entry.Value <= *habit.TargetValue
}
return false
}
if !hasEntry {
return true
}
if habit.TargetValue != nil && entry.Value != nil {
return *entry.Value <= *habit.TargetValue
}
return false
}
+337
View File
@@ -0,0 +1,337 @@
package services
import (
"testing"
"time"
"apocapoc-api/internal/domain/entities"
"apocapoc-api/internal/domain/value_objects"
)
func makeEntry(d time.Time, value *float64) *entities.HabitEntry {
return &entities.HabitEntry{ScheduledDate: d, Value: value}
}
func floatPtr(v float64) *float64 { return &v }
func dt(year, month, day int) time.Time {
return time.Date(year, time.Month(month), day, 0, 0, 0, 0, time.UTC)
}
func habit(t value_objects.HabitType, negative bool, target *float64) *entities.Habit {
return &entities.Habit{
Type: t,
Frequency: value_objects.FrequencyDaily,
IsNegative: negative,
TargetValue: target,
CreatedAt: dt(2026, 1, 1),
}
}
// --- IsDaySuccessful ---
func TestIsDaySuccessful_BooleanPositive(t *testing.T) {
h := habit(value_objects.HabitTypeBoolean, false, nil)
em := map[string]*entities.HabitEntry{"2026-03-01": makeEntry(dt(2026, 3, 1), nil)}
if !IsDaySuccessful("2026-03-01", em, h) {
t.Error("entry should be success")
}
if IsDaySuccessful("2026-03-01", map[string]*entities.HabitEntry{}, h) {
t.Error("no entry should be failure")
}
}
func TestIsDaySuccessful_BooleanNegative(t *testing.T) {
h := habit(value_objects.HabitTypeBoolean, true, nil)
em := map[string]*entities.HabitEntry{"2026-03-01": makeEntry(dt(2026, 3, 1), nil)}
if !IsDaySuccessful("2026-03-01", map[string]*entities.HabitEntry{}, h) {
t.Error("no entry should be success (resisted)")
}
if IsDaySuccessful("2026-03-01", em, h) {
t.Error("entry should be failure")
}
}
func TestIsDaySuccessful_CounterPositiveNoTarget(t *testing.T) {
h := habit(value_objects.HabitTypeCounter, false, nil)
if !IsDaySuccessful("2026-03-01", map[string]*entities.HabitEntry{"2026-03-01": makeEntry(dt(2026, 3, 1), floatPtr(5))}, h) {
t.Error("entry should be success")
}
if IsDaySuccessful("2026-03-01", map[string]*entities.HabitEntry{}, h) {
t.Error("no entry should be failure")
}
}
func TestIsDaySuccessful_CounterPositiveWithTarget(t *testing.T) {
h := habit(value_objects.HabitTypeCounter, false, floatPtr(8))
cases := []struct {
name string
value *float64
has bool
success bool
}{
{"value >= target", floatPtr(10), true, true},
{"value == target", floatPtr(8), true, true},
{"value < target", floatPtr(3), true, false},
{"no entry", nil, false, false},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
em := map[string]*entities.HabitEntry{}
if tc.has {
em["2026-03-01"] = makeEntry(dt(2026, 3, 1), tc.value)
}
if IsDaySuccessful("2026-03-01", em, h) != tc.success {
t.Errorf("Expected %v", tc.success)
}
})
}
}
func TestIsDaySuccessful_CounterNegativeNoTarget(t *testing.T) {
h := habit(value_objects.HabitTypeCounter, true, nil)
if !IsDaySuccessful("2026-03-01", map[string]*entities.HabitEntry{}, h) {
t.Error("no entry should be success")
}
if IsDaySuccessful("2026-03-01", map[string]*entities.HabitEntry{"2026-03-01": makeEntry(dt(2026, 3, 1), floatPtr(3))}, h) {
t.Error("entry should be failure")
}
}
func TestIsDaySuccessful_CounterNegativeWithTarget(t *testing.T) {
h := habit(value_objects.HabitTypeCounter, true, floatPtr(2))
cases := []struct {
name string
value *float64
has bool
success bool
}{
{"no entry", nil, false, true},
{"within limit", floatPtr(1), true, true},
{"at limit", floatPtr(2), true, true},
{"over limit", floatPtr(5), true, false},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
em := map[string]*entities.HabitEntry{}
if tc.has {
em["2026-03-01"] = makeEntry(dt(2026, 3, 1), tc.value)
}
if IsDaySuccessful("2026-03-01", em, h) != tc.success {
t.Errorf("Expected %v", tc.success)
}
})
}
}
func TestIsDaySuccessful_ValuePositiveNoTarget(t *testing.T) {
h := habit(value_objects.HabitTypeValue, false, nil)
if !IsDaySuccessful("2026-03-01", map[string]*entities.HabitEntry{"2026-03-01": makeEntry(dt(2026, 3, 1), floatPtr(72))}, h) {
t.Error("entry should be success")
}
if IsDaySuccessful("2026-03-01", map[string]*entities.HabitEntry{}, h) {
t.Error("no entry should be failure")
}
}
func TestIsDaySuccessful_ValuePositiveWithTarget(t *testing.T) {
h := habit(value_objects.HabitTypeValue, false, floatPtr(7))
if !IsDaySuccessful("2026-03-01", map[string]*entities.HabitEntry{"2026-03-01": makeEntry(dt(2026, 3, 1), floatPtr(8))}, h) {
t.Error("value >= target should be success")
}
if IsDaySuccessful("2026-03-01", map[string]*entities.HabitEntry{"2026-03-01": makeEntry(dt(2026, 3, 1), floatPtr(5))}, h) {
t.Error("value < target should be failure")
}
}
func TestIsDaySuccessful_ValueNegativeNoTarget(t *testing.T) {
h := habit(value_objects.HabitTypeValue, true, nil)
if !IsDaySuccessful("2026-03-01", map[string]*entities.HabitEntry{}, h) {
t.Error("no entry should be success")
}
if IsDaySuccessful("2026-03-01", map[string]*entities.HabitEntry{"2026-03-01": makeEntry(dt(2026, 3, 1), floatPtr(3))}, h) {
t.Error("entry should be failure")
}
}
func TestIsDaySuccessful_ValueNegativeWithTarget(t *testing.T) {
h := habit(value_objects.HabitTypeValue, true, floatPtr(70))
cases := []struct {
name string
value *float64
has bool
success bool
}{
{"below target", floatPtr(68), true, true},
{"above target", floatPtr(75), true, false},
{"no entry (didnt track)", nil, false, false},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
em := map[string]*entities.HabitEntry{}
if tc.has {
em["2026-03-01"] = makeEntry(dt(2026, 3, 1), tc.value)
}
if IsDaySuccessful("2026-03-01", em, h) != tc.success {
t.Errorf("Expected %v", tc.success)
}
})
}
}
// --- CalculateStreaks ---
func TestStreaks_DailyBooleanPositive(t *testing.T) {
h := habit(value_objects.HabitTypeBoolean, false, nil)
t.Run("3 consecutive days", func(t *testing.T) {
entries := []*entities.HabitEntry{
makeEntry(dt(2026, 3, 1), nil),
makeEntry(dt(2026, 3, 2), nil),
makeEntry(dt(2026, 3, 3), nil),
}
r := CalculateStreaks(entries, h, dt(2026, 3, 3))
if r.Current != 3 || r.Longest != 3 {
t.Errorf("Expected current=3 longest=3, got current=%d longest=%d", r.Current, r.Longest)
}
})
t.Run("gap finds longest and current separately", func(t *testing.T) {
entries := []*entities.HabitEntry{
makeEntry(dt(2026, 3, 1), nil),
makeEntry(dt(2026, 3, 2), nil),
makeEntry(dt(2026, 3, 3), nil),
// gap Mar 4
makeEntry(dt(2026, 3, 5), nil),
}
r := CalculateStreaks(entries, h, dt(2026, 3, 5))
if r.Current != 1 || r.Longest != 3 {
t.Errorf("Expected current=1 longest=3, got current=%d longest=%d", r.Current, r.Longest)
}
})
t.Run("today not completed doesnt break streak", func(t *testing.T) {
entries := []*entities.HabitEntry{
makeEntry(dt(2026, 3, 1), nil),
makeEntry(dt(2026, 3, 2), nil),
}
r := CalculateStreaks(entries, h, dt(2026, 3, 3))
if r.Current != 2 || r.Longest != 2 {
t.Errorf("Expected current=2 longest=2, got current=%d longest=%d", r.Current, r.Longest)
}
})
t.Run("missed yesterday breaks streak", func(t *testing.T) {
entries := []*entities.HabitEntry{
makeEntry(dt(2026, 3, 1), nil),
makeEntry(dt(2026, 3, 2), nil),
}
r := CalculateStreaks(entries, h, dt(2026, 3, 4))
if r.Current != 0 || r.Longest != 2 {
t.Errorf("Expected current=0 longest=2, got current=%d longest=%d", r.Current, r.Longest)
}
})
}
func TestStreaks_DailyBooleanNegative(t *testing.T) {
h := habit(value_objects.HabitTypeBoolean, true, nil)
h.CreatedAt = dt(2026, 3, 1)
t.Run("3 days no entries is 3 streak", func(t *testing.T) {
r := CalculateStreaks(nil, h, dt(2026, 3, 3))
if r.Current != 3 || r.Longest != 3 {
t.Errorf("Expected current=3 longest=3, got current=%d longest=%d", r.Current, r.Longest)
}
})
t.Run("entry breaks streak", func(t *testing.T) {
entries := []*entities.HabitEntry{makeEntry(dt(2026, 3, 2), nil)}
r := CalculateStreaks(entries, h, dt(2026, 3, 3))
if r.Current != 1 || r.Longest != 1 {
t.Errorf("Expected current=1 longest=1, got current=%d longest=%d", r.Current, r.Longest)
}
})
}
func TestStreaks_WeeklyBooleanPositive(t *testing.T) {
h := &entities.Habit{
Type: value_objects.HabitTypeBoolean,
Frequency: value_objects.FrequencyWeekly,
SpecificDays: []int{1, 3, 5},
CreatedAt: dt(2026, 3, 1),
}
t.Run("3 consecutive scheduled days", func(t *testing.T) {
entries := []*entities.HabitEntry{
makeEntry(dt(2026, 3, 2), nil),
makeEntry(dt(2026, 3, 4), nil),
makeEntry(dt(2026, 3, 6), nil),
}
r := CalculateStreaks(entries, h, dt(2026, 3, 6))
if r.Current != 3 || r.Longest != 3 {
t.Errorf("Expected current=3 longest=3, got current=%d longest=%d", r.Current, r.Longest)
}
})
t.Run("missed Wednesday breaks streak", func(t *testing.T) {
entries := []*entities.HabitEntry{
makeEntry(dt(2026, 3, 2), nil),
makeEntry(dt(2026, 3, 6), nil),
}
r := CalculateStreaks(entries, h, dt(2026, 3, 6))
if r.Current != 1 || r.Longest != 1 {
t.Errorf("Expected current=1 longest=1, got current=%d longest=%d", r.Current, r.Longest)
}
})
}
func TestStreaks_CounterWithTarget(t *testing.T) {
h := habit(value_objects.HabitTypeCounter, false, floatPtr(8))
t.Run("all meet target", func(t *testing.T) {
entries := []*entities.HabitEntry{
makeEntry(dt(2026, 3, 1), floatPtr(8)),
makeEntry(dt(2026, 3, 2), floatPtr(10)),
makeEntry(dt(2026, 3, 3), floatPtr(9)),
}
r := CalculateStreaks(entries, h, dt(2026, 3, 3))
if r.Current != 3 {
t.Errorf("Expected current=3, got %d", r.Current)
}
})
}
func TestStreaks_WeeklyCounterNegativeWithTarget(t *testing.T) {
h := &entities.Habit{
Type: value_objects.HabitTypeCounter,
Frequency: value_objects.FrequencyWeekly,
SpecificDays: []int{1, 5},
IsNegative: true,
TargetValue: floatPtr(2),
CreatedAt: dt(2026, 3, 1),
}
t.Run("within limit and no entry both count as success", func(t *testing.T) {
entries := []*entities.HabitEntry{
makeEntry(dt(2026, 3, 2), floatPtr(1)),
makeEntry(dt(2026, 3, 9), floatPtr(5)),
}
r := CalculateStreaks(entries, h, dt(2026, 3, 13))
if r.Current != 1 || r.Longest != 2 {
t.Errorf("Expected current=1 longest=2, got current=%d longest=%d", r.Current, r.Longest)
}
})
}
+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)
}
}
+10
View File
@@ -25,6 +25,11 @@ type Config struct {
RegistrationMode string
LogLevel string
Environment string
BackupEnabled string
BackupInterval string
BackupRetentionDays string
BackupPath string
BackupCompress string
}
func Load() (*Config, error) {
@@ -48,6 +53,11 @@ func Load() (*Config, error) {
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 == "" {
@@ -107,6 +107,10 @@ func (s *SMTPService) GetConfig() SMTPConfig {
return s.config
}
func (s *SMTPService) IsEnabled() bool {
return true
}
func (s *SMTPService) HealthCheck() error {
dialer := mail.NewDialer(s.config.Host, s.config.Port, s.config.Username, s.config.Password)
dialer.TLSConfig = &tls.Config{
@@ -131,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)
}
})
}
+49
View File
@@ -22,6 +22,7 @@ type CreateHabitRequest struct {
type UpdateHabitRequest struct {
Name string `json:"name"`
Description string `json:"description"`
Frequency value_objects.Frequency `json:"frequency"`
SpecificDays []int `json:"specific_days,omitempty"`
SpecificDates []int `json:"specific_dates,omitempty"`
CarryOver bool `json:"carry_over"`
@@ -105,3 +106,51 @@ type ValidationErrorResponse struct {
Error string `json:"error"`
Field string `json:"field"`
}
type SyncHabitDTO struct {
ID string `json:"id"`
UserID string `json:"user_id"`
Name string `json:"name"`
Description string `json:"description"`
Type value_objects.HabitType `json:"type"`
Frequency value_objects.Frequency `json:"frequency"`
SpecificDays []int `json:"specific_days,omitempty"`
SpecificDates []int `json:"specific_dates,omitempty"`
CarryOver bool `json:"carry_over"`
IsNegative bool `json:"is_negative"`
TargetValue *float64 `json:"target_value,omitempty"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
ArchivedAt *time.Time `json:"archived_at,omitempty"`
}
type SyncHabitEntryDTO struct {
ID string `json:"id"`
HabitID string `json:"habit_id"`
ScheduledDate time.Time `json:"scheduled_date"`
CompletedAt time.Time `json:"completed_at"`
Value *float64 `json:"value,omitempty"`
UpdatedAt time.Time `json:"updated_at"`
}
type HabitChangesDTO struct {
Created []SyncHabitDTO `json:"created"`
Updated []SyncHabitDTO `json:"updated"`
Deleted []string `json:"deleted"`
}
type EntryChangesDTO struct {
Created []SyncHabitEntryDTO `json:"created"`
Updated []SyncHabitEntryDTO `json:"updated"`
Deleted []string `json:"deleted"`
}
type SyncChangesResponse struct {
Habits HabitChangesDTO `json:"habits"`
Entries EntryChangesDTO `json:"entries"`
}
type SyncBatchRequest struct {
Habits HabitChangesDTO `json:"habits"`
Entries EntryChangesDTO `json:"entries"`
}
@@ -2,6 +2,7 @@ package http
import (
"encoding/json"
stderrors "errors"
"net/http"
"strconv"
"strings"
@@ -97,8 +98,8 @@ func (h *HabitHandlers) CreateHabit(w http.ResponseWriter, r *http.Request) {
habitID, err := h.createHandler.Handle(r.Context(), cmd)
if err != nil {
if err == errors.ErrInvalidInput {
respondError(w, http.StatusBadRequest, err.Error())
if stderrors.Is(err, errors.ErrInvalidInput) {
respondValidationErrorI18n(w, r, h.translator, err)
return
}
respondErrorI18n(w, r, h.translator, http.StatusInternalServerError, "failed_create_habit")
@@ -313,10 +314,11 @@ func (h *HabitHandlers) UpdateHabit(w http.ResponseWriter, r *http.Request) {
UserID: userID,
Name: req.Name,
Description: req.Description,
CarryOver: req.CarryOver,
TargetValue: req.TargetValue,
Frequency: req.Frequency,
SpecificDays: req.SpecificDays,
SpecificDates: req.SpecificDates,
CarryOver: req.CarryOver,
TargetValue: req.TargetValue,
}
if err := h.updateHandler.Handle(r.Context(), cmd); err != nil {
@@ -89,6 +89,7 @@ func TestHabitCRUDFlow(t *testing.T) {
reqBody := UpdateHabitRequest{
Name: "Morning Exercise",
Description: "Updated description",
Frequency: "DAILY",
}
rr := makeRequest(t, *ts.Router, "PUT", "/api/v1/habits/"+habitID, reqBody, token)
@@ -122,6 +123,47 @@ func TestHabitCRUDFlow(t *testing.T) {
}
})
t.Run("Create habit with missing type returns field-level error", func(t *testing.T) {
token := registerAndLogin(t, *ts.Router, "validationuser@example.com", "Password123!")
reqBody := map[string]any{"name": "No Type"}
rr := makeRequest(t, *ts.Router, "POST", "/api/v1/habits", reqBody, token)
if rr.Code != http.StatusBadRequest {
t.Fatalf("Expected 400, got %d. Body: %s", rr.Code, rr.Body.String())
}
var resp ValidationErrorResponse
decodeResponse(t, rr, &resp)
if resp.Field != "type" {
t.Errorf("Expected field 'type', got %q. Body: %s", resp.Field, rr.Body.String())
}
if resp.Error == "" {
t.Errorf("Expected non-empty translated error message. Body: %s", rr.Body.String())
}
})
t.Run("Create weekly habit without specific_days returns field-level error", func(t *testing.T) {
token := registerAndLogin(t, *ts.Router, "weeklyuser@example.com", "Password123!")
reqBody := CreateHabitRequest{
Name: "Cut nails",
Type: "BOOLEAN",
Frequency: "WEEKLY",
}
rr := makeRequest(t, *ts.Router, "POST", "/api/v1/habits", reqBody, token)
if rr.Code != http.StatusBadRequest {
t.Fatalf("Expected 400, got %d. Body: %s", rr.Code, rr.Body.String())
}
var resp ValidationErrorResponse
decodeResponse(t, rr, &resp)
if resp.Field != "specific_days" {
t.Errorf("Expected field 'specific_days', got %q. Body: %s", resp.Field, rr.Body.String())
}
})
t.Run("Access other user's habit", func(t *testing.T) {
otherToken := registerAndLogin(t, *ts.Router, "otheruser@example.com", "Password123!")
@@ -11,6 +11,7 @@ import (
"apocapoc-api/internal/application/commands"
"apocapoc-api/internal/application/queries"
"apocapoc-api/internal/domain/services"
"apocapoc-api/internal/i18n"
"apocapoc-api/internal/infrastructure/auth"
"apocapoc-api/internal/infrastructure/crypto"
@@ -43,14 +44,15 @@ func setupTestServer(t *testing.T) *TestServer {
refreshTokenRepo := sqlite.NewRefreshTokenRepository(db)
passwordResetTokenRepo := sqlite.NewPasswordResetTokenRepository(db)
registerHandler := commands.NewRegisterUserHandler(userRepo, passwordHasher, nil, "", "open", false)
noOpEmail := &services.NoOpEmailService{}
registerHandler := commands.NewRegisterUserHandler(userRepo, passwordHasher, noOpEmail, "", "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, "")
verifyEmailHandler := commands.NewVerifyEmailHandler(userRepo, noOpEmail, false)
resendVerificationEmailHandler := commands.NewResendVerificationEmailHandler(userRepo, noOpEmail, "")
requestPasswordResetHandler := commands.NewRequestPasswordResetHandler(userRepo, passwordResetTokenRepo, noOpEmail, "")
resetPasswordHandler := commands.NewResetPasswordHandler(userRepo, passwordResetTokenRepo, passwordHasher)
createHandler := commands.NewCreateHabitHandler(habitRepo)
getTodaysHandler := queries.NewGetTodaysHabitsHandler(habitRepo, entryRepo)
@@ -70,14 +72,18 @@ func setupTestServer(t *testing.T) *TestServer {
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, jwtService, translator)
router := NewRouter("http://localhost:3000", habitHandlers, authHandlers, statsHandlers, healthHandlers, userHandlers, exportHandlers, syncHandlers, jwtService, translator)
handler := http.Handler(router)
return &TestServer{
@@ -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")
}
})
}
+8 -1
View File
@@ -17,7 +17,7 @@ import (
_ "apocapoc-api/docs"
)
func NewRouter(appURL string, habitHandlers *HabitHandlers, authHandlers *AuthHandlers, statsHandlers *StatsHandlers, healthHandlers *HealthHandlers, userHandlers *UserHandlers, exportHandlers *ExportHandlers, jwtService *auth.JWTService, translator *i18n.Translator) *chi.Mux {
func NewRouter(appURL string, habitHandlers *HabitHandlers, authHandlers *AuthHandlers, statsHandlers *StatsHandlers, healthHandlers *HealthHandlers, userHandlers *UserHandlers, exportHandlers *ExportHandlers, syncHandlers *SyncHandlers, jwtService *auth.JWTService, translator *i18n.Translator) *chi.Mux {
r := chi.NewRouter()
r.Use(logger.Middleware)
@@ -86,5 +86,12 @@ func NewRouter(appURL string, habitHandlers *HabitHandlers, authHandlers *AuthHa
r.Get("/", exportHandlers.ExportData)
})
r.Route("/api/v1/sync", func(r chi.Router) {
r.Use(AuthMiddleware(jwtService))
r.Use(RateLimitByUser(jwtService, 100, 1*time.Minute))
r.Get("/changes", syncHandlers.GetSyncChanges)
r.Post("/batch", syncHandlers.ApplySyncBatch)
})
return r
}
@@ -27,7 +27,7 @@ func NewStatsHandlers(
// GetHabitStats godoc
// @Summary Get habit statistics
// @Description Get statistics for a specific habit including streaks and completion rates
// @Description Get statistics for a specific habit including streaks and completions
// @Tags stats
// @Produce json
// @Security BearerAuth
@@ -0,0 +1,160 @@
package http
import (
"net/http"
"testing"
"time"
"apocapoc-api/internal/application/queries"
)
func TestHabitStatsFlow(t *testing.T) {
ts := setupTestServer(t)
defer ts.Close()
token := registerAndLogin(t, *ts.Router, "statsuser@example.com", "Password123!")
habitBody := CreateHabitRequest{
Name: "Meditation",
Type: "BOOLEAN",
Frequency: "DAILY",
}
rr := makeRequest(t, *ts.Router, "POST", "/api/v1/habits", habitBody, token)
var habitResp map[string]string
decodeResponse(t, rr, &habitResp)
habitID := habitResp["id"]
t.Run("Stats for new habit should be zero", func(t *testing.T) {
rr := makeRequest(t, *ts.Router, "GET", "/api/v1/stats/habits/"+habitID, nil, token)
if rr.Code != http.StatusOK {
t.Fatalf("Expected status 200, got %d. Body: %s", rr.Code, rr.Body.String())
}
var stats queries.HabitStatsDTO
decodeResponse(t, rr, &stats)
if stats.TotalCompletions != 0 {
t.Errorf("Expected 0 total completions, got %d", stats.TotalCompletions)
}
if stats.CurrentStreak != 0 {
t.Errorf("Expected 0 current streak, got %d", stats.CurrentStreak)
}
if stats.LongestStreak != 0 {
t.Errorf("Expected 0 longest streak, got %d", stats.LongestStreak)
}
})
today := time.Now().UTC().Format("2006-01-02")
t.Run("Stats after marking habit today", func(t *testing.T) {
markReq := MarkHabitRequest{
ScheduledDate: today,
}
rr := makeRequest(t, *ts.Router, "POST", "/api/v1/habits/"+habitID+"/mark", markReq, token)
if rr.Code != http.StatusOK {
t.Fatalf("Failed to mark habit: %d - %s", rr.Code, rr.Body.String())
}
rr = makeRequest(t, *ts.Router, "GET", "/api/v1/stats/habits/"+habitID, nil, token)
if rr.Code != http.StatusOK {
t.Fatalf("Expected status 200, got %d", rr.Code)
}
var stats queries.HabitStatsDTO
decodeResponse(t, rr, &stats)
if stats.TotalCompletions != 1 {
t.Errorf("Expected 1 total completion, got %d", stats.TotalCompletions)
}
if stats.CurrentStreak != 1 {
t.Errorf("Expected current streak of 1 (today completed counts), got %d", stats.CurrentStreak)
}
if stats.LongestStreak != 1 {
t.Errorf("Expected longest streak of 1, got %d", stats.LongestStreak)
}
})
t.Run("Stats after unmarking habit", func(t *testing.T) {
rr := makeRequest(t, *ts.Router, "DELETE", "/api/v1/habits/"+habitID+"/entries/"+today, nil, token)
if rr.Code != http.StatusOK {
t.Fatalf("Failed to unmark habit: %d", rr.Code)
}
rr = makeRequest(t, *ts.Router, "GET", "/api/v1/stats/habits/"+habitID, nil, token)
var stats queries.HabitStatsDTO
decodeResponse(t, rr, &stats)
if stats.TotalCompletions != 0 {
t.Errorf("Expected 0 total completions after unmark, got %d", stats.TotalCompletions)
}
if stats.CurrentStreak != 0 {
t.Errorf("Expected 0 current streak after unmark, got %d", stats.CurrentStreak)
}
})
}
func TestHabitUpdateAffectsStats(t *testing.T) {
ts := setupTestServer(t)
defer ts.Close()
token := registerAndLogin(t, *ts.Router, "updatestats@example.com", "Password123!")
habitBody := CreateHabitRequest{
Name: "Running",
Type: "BOOLEAN",
Frequency: "DAILY",
}
rr := makeRequest(t, *ts.Router, "POST", "/api/v1/habits", habitBody, token)
var habitResp map[string]string
decodeResponse(t, rr, &habitResp)
habitID := habitResp["id"]
today := time.Now().UTC().Format("2006-01-02")
markReq := MarkHabitRequest{
ScheduledDate: today,
}
makeRequest(t, *ts.Router, "POST", "/api/v1/habits/"+habitID+"/mark", markReq, token)
t.Run("Stats remain after updating habit name", func(t *testing.T) {
updateReq := UpdateHabitRequest{
Name: "Morning Running",
Frequency: "DAILY",
}
rr := makeRequest(t, *ts.Router, "PUT", "/api/v1/habits/"+habitID, updateReq, token)
if rr.Code != http.StatusOK {
t.Fatalf("Failed to update habit: %d", rr.Code)
}
rr = makeRequest(t, *ts.Router, "GET", "/api/v1/stats/habits/"+habitID, nil, token)
var stats queries.HabitStatsDTO
decodeResponse(t, rr, &stats)
if stats.TotalCompletions != 1 {
t.Errorf("Expected stats to persist after update, got %d completions", stats.TotalCompletions)
}
})
t.Run("Stats remain available after archiving habit", func(t *testing.T) {
rr := makeRequest(t, *ts.Router, "DELETE", "/api/v1/habits/"+habitID, nil, token)
if rr.Code != http.StatusOK {
t.Fatalf("Failed to archive habit: %d", rr.Code)
}
rr = makeRequest(t, *ts.Router, "GET", "/api/v1/stats/habits/"+habitID, nil, token)
if rr.Code != http.StatusOK {
t.Errorf("Expected stats to remain available for archived habit, got %d", rr.Code)
}
var stats queries.HabitStatsDTO
decodeResponse(t, rr, &stats)
if stats.TotalCompletions != 1 {
t.Errorf("Expected stats to persist after archiving, got %d completions", stats.TotalCompletions)
}
})
}
@@ -0,0 +1,231 @@
package http
import (
"encoding/json"
"net/http"
"time"
"apocapoc-api/internal/application/commands"
"apocapoc-api/internal/application/queries"
"apocapoc-api/internal/domain/entities"
"apocapoc-api/internal/i18n"
"apocapoc-api/internal/shared/errors"
)
type SyncHandlers struct {
getSyncChangesHandler *queries.GetSyncChangesHandler
applySyncBatchHandler *commands.ApplySyncBatchHandler
translator *i18n.Translator
}
func NewSyncHandlers(
getSyncChangesHandler *queries.GetSyncChangesHandler,
applySyncBatchHandler *commands.ApplySyncBatchHandler,
translator *i18n.Translator,
) *SyncHandlers {
return &SyncHandlers{
getSyncChangesHandler: getSyncChangesHandler,
applySyncBatchHandler: applySyncBatchHandler,
translator: translator,
}
}
// GetSyncChanges godoc
// @Summary Get sync changes
// @Description Get all changes (habits and entries) since a given timestamp for offline sync
// @Tags sync
// @Accept json
// @Produce json
// @Security BearerAuth
// @Param since query string true "ISO 8601 timestamp (e.g., 2025-01-01T00:00:00Z)"
// @Success 200 {object} SyncChangesResponse
// @Failure 400 {object} ErrorResponse
// @Failure 401 {object} ErrorResponse
// @Failure 500 {object} ErrorResponse
// @Router /sync/changes [get]
func (h *SyncHandlers) GetSyncChanges(w http.ResponseWriter, r *http.Request) {
userID, ok := GetUserIDFromContext(r.Context())
if !ok {
respondErrorI18n(w, r, h.translator, http.StatusUnauthorized, "user_not_authenticated")
return
}
sinceStr := r.URL.Query().Get("since")
if sinceStr == "" {
respondErrorI18n(w, r, h.translator, http.StatusBadRequest, "missing_since_parameter")
return
}
since, err := time.Parse(time.RFC3339, sinceStr)
if err != nil {
respondErrorI18n(w, r, h.translator, http.StatusBadRequest, "invalid_since_format")
return
}
query := queries.GetSyncChangesQuery{
UserID: userID,
Since: since,
}
result, err := h.getSyncChangesHandler.Handle(r.Context(), query)
if err != nil {
if err == errors.ErrInvalidInput {
respondErrorI18n(w, r, h.translator, http.StatusBadRequest, "invalid_input")
return
}
respondErrorI18n(w, r, h.translator, http.StatusInternalServerError, "internal_server_error")
return
}
response := SyncChangesResponse{
Habits: HabitChangesDTO{
Created: toHabitDTOs(result.Habits.Created),
Updated: toHabitDTOs(result.Habits.Updated),
Deleted: result.Habits.Deleted,
},
Entries: EntryChangesDTO{
Created: toHabitEntryDTOs(result.Entries.Created),
Updated: toHabitEntryDTOs(result.Entries.Updated),
Deleted: result.Entries.Deleted,
},
}
respondJSON(w, http.StatusOK, response)
}
// ApplySyncBatch godoc
// @Summary Apply sync batch
// @Description Apply a batch of changes from the client for offline sync (Last-Write-Wins)
// @Tags sync
// @Accept json
// @Produce json
// @Security BearerAuth
// @Param request body SyncBatchRequest true "Sync batch data"
// @Success 200 {object} map[string]string
// @Failure 400 {object} ErrorResponse
// @Failure 401 {object} ErrorResponse
// @Failure 500 {object} ErrorResponse
// @Router /sync/batch [post]
func (h *SyncHandlers) ApplySyncBatch(w http.ResponseWriter, r *http.Request) {
userID, ok := GetUserIDFromContext(r.Context())
if !ok {
respondErrorI18n(w, r, h.translator, http.StatusUnauthorized, "user_not_authenticated")
return
}
var req SyncBatchRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
respondErrorI18n(w, r, h.translator, http.StatusBadRequest, "invalid_request_body")
return
}
habitChanges := commands.HabitBatchChanges{
Created: fromHabitDTOs(req.Habits.Created),
Updated: fromHabitDTOs(req.Habits.Updated),
Deleted: req.Habits.Deleted,
}
entryChanges := commands.EntryBatchChanges{
Created: fromHabitEntryDTOs(req.Entries.Created),
Updated: fromHabitEntryDTOs(req.Entries.Updated),
Deleted: req.Entries.Deleted,
}
cmd := commands.ApplySyncBatchCommand{
UserID: userID,
Habits: habitChanges,
Entries: entryChanges,
}
err := h.applySyncBatchHandler.Handle(r.Context(), cmd)
if err != nil {
if err == errors.ErrInvalidInput {
respondErrorI18n(w, r, h.translator, http.StatusBadRequest, "invalid_input")
return
}
if err == errors.ErrUnauthorized {
respondErrorI18n(w, r, h.translator, http.StatusForbidden, "forbidden")
return
}
respondErrorI18n(w, r, h.translator, http.StatusInternalServerError, "internal_server_error")
return
}
respondJSON(w, http.StatusOK, map[string]string{"message": "sync_batch_applied"})
}
func toHabitDTOs(habits []*entities.Habit) []SyncHabitDTO {
dtos := make([]SyncHabitDTO, len(habits))
for i, h := range habits {
dtos[i] = SyncHabitDTO{
ID: h.ID,
UserID: h.UserID,
Name: h.Name,
Description: h.Description,
Type: h.Type,
Frequency: h.Frequency,
SpecificDays: h.SpecificDays,
SpecificDates: h.SpecificDates,
CarryOver: h.CarryOver,
IsNegative: h.IsNegative,
TargetValue: h.TargetValue,
CreatedAt: h.CreatedAt,
UpdatedAt: h.UpdatedAt,
ArchivedAt: h.ArchivedAt,
}
}
return dtos
}
func fromHabitDTOs(dtos []SyncHabitDTO) []*entities.Habit {
habits := make([]*entities.Habit, len(dtos))
for i, dto := range dtos {
habits[i] = &entities.Habit{
ID: dto.ID,
UserID: dto.UserID,
Name: dto.Name,
Description: dto.Description,
Type: dto.Type,
Frequency: dto.Frequency,
SpecificDays: dto.SpecificDays,
SpecificDates: dto.SpecificDates,
CarryOver: dto.CarryOver,
IsNegative: dto.IsNegative,
TargetValue: dto.TargetValue,
CreatedAt: dto.CreatedAt,
UpdatedAt: dto.UpdatedAt,
ArchivedAt: dto.ArchivedAt,
}
}
return habits
}
func toHabitEntryDTOs(entries []*entities.HabitEntry) []SyncHabitEntryDTO {
dtos := make([]SyncHabitEntryDTO, len(entries))
for i, e := range entries {
dtos[i] = SyncHabitEntryDTO{
ID: e.ID,
HabitID: e.HabitID,
ScheduledDate: e.ScheduledDate,
CompletedAt: e.CompletedAt,
Value: e.Value,
UpdatedAt: e.UpdatedAt,
}
}
return dtos
}
func fromHabitEntryDTOs(dtos []SyncHabitEntryDTO) []*entities.HabitEntry {
entries := make([]*entities.HabitEntry, len(dtos))
for i, dto := range dtos {
entries[i] = &entities.HabitEntry{
ID: dto.ID,
HabitID: dto.HabitID,
ScheduledDate: dto.ScheduledDate,
CompletedAt: dto.CompletedAt,
Value: dto.Value,
UpdatedAt: dto.UpdatedAt,
}
}
return entries
}
@@ -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
`
@@ -187,10 +214,10 @@ func (r *HabitEntryRepository) FindByHabitID(ctx context.Context, habitID string
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
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 = ?
WHERE h.user_id = ? AND he.deleted_at IS NULL
ORDER BY he.scheduled_date DESC
`
@@ -205,10 +232,11 @@ func (r *HabitEntryRepository) FindByUserID(ctx context.Context, userID string)
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
`
@@ -236,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,6 +5,7 @@ import (
"database/sql"
"encoding/json"
"fmt"
"time"
"apocapoc-api/internal/domain/entities"
"apocapoc-api/internal/domain/repositories"
@@ -31,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,
@@ -48,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 {
@@ -61,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(
@@ -86,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 {
@@ -96,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
}
@@ -113,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
`
@@ -129,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)
@@ -136,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,
@@ -151,6 +166,7 @@ func (r *HabitRepository) Update(ctx context.Context, habit *entities.Habit) err
habit.IsNegative,
habit.TargetValue,
habit.ArchivedAt,
habit.UpdatedAt,
habit.ID,
)
@@ -174,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(
@@ -190,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 {
@@ -203,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)
}
@@ -217,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
`
@@ -252,9 +278,9 @@ func (r *HabitRepository) FindActiveByUserIDWithPagination(ctx context.Context,
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
LIMIT ? OFFSET ?
`
@@ -272,7 +298,7 @@ func (r *HabitRepository) CountActiveByUserID(ctx context.Context, userID string
query := `
SELECT COUNT(*)
FROM habits
WHERE user_id = ? AND archived_at IS NULL
WHERE user_id = ? AND archived_at IS NULL AND deleted_at IS NULL
`
var count int
@@ -288,13 +314,16 @@ func (r *HabitRepository) FindByUserIDFiltered(ctx context.Context, userID strin
baseQuery := `
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 = ?`
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")
}
@@ -341,6 +370,9 @@ func (r *HabitRepository) CountByUserIDFiltered(ctx context.Context, userID stri
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")
}
@@ -373,3 +405,141 @@ func (r *HabitRepository) CountByUserIDFiltered(ctx context.Context, userID stri
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
}
@@ -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)
}
}
@@ -29,6 +29,10 @@ func RunMigrations(db *sql.DB) error {
return err
}
if err := addSyncColumns(db); err != nil {
return err
}
return nil
}
@@ -85,6 +89,98 @@ func columnExists(db *sql.DB, table, column string) (bool, error) {
return count > 0, nil
}
func indexExists(db *sql.DB, indexName string) (bool, error) {
query := "SELECT COUNT(*) FROM sqlite_master WHERE type = 'index' AND name = ?"
var count int
err := db.QueryRow(query, indexName).Scan(&count)
if err != nil {
return false, err
}
return count > 0, nil
}
func addSyncColumns(db *sql.DB) error {
// Columns to add to habits table
habitColumns := []struct {
name string
definition string
}{
{"updated_at", "ALTER TABLE habits ADD COLUMN updated_at DATETIME"},
{"deleted_at", "ALTER TABLE habits ADD COLUMN deleted_at DATETIME"},
}
for _, col := range habitColumns {
exists, err := columnExists(db, "habits", col.name)
if err != nil {
return fmt.Errorf("failed to check if column %s exists: %w", col.name, err)
}
if !exists {
if _, err := db.Exec(col.definition); err != nil {
return fmt.Errorf("failed to add column %s: %w", col.name, err)
}
}
}
// Initialize updated_at with created_at for existing records
if _, err := db.Exec("UPDATE habits SET updated_at = created_at WHERE updated_at IS NULL"); err != nil {
return fmt.Errorf("failed to initialize updated_at: %w", err)
}
// Columns to add to habit_entries table
entryColumns := []struct {
name string
definition string
}{
{"updated_at", "ALTER TABLE habit_entries ADD COLUMN updated_at DATETIME"},
{"deleted_at", "ALTER TABLE habit_entries ADD COLUMN deleted_at DATETIME"},
}
for _, col := range entryColumns {
exists, err := columnExists(db, "habit_entries", col.name)
if err != nil {
return fmt.Errorf("failed to check if column %s exists: %w", col.name, err)
}
if !exists {
if _, err := db.Exec(col.definition); err != nil {
return fmt.Errorf("failed to add column %s: %w", col.name, err)
}
}
}
// Initialize updated_at with completed_at for existing entries
if _, err := db.Exec("UPDATE habit_entries SET updated_at = completed_at WHERE updated_at IS NULL"); err != nil {
return fmt.Errorf("failed to initialize updated_at for entries: %w", err)
}
// Create indexes for sync queries
indexes := []struct {
name string
definition string
}{
{"idx_habits_updated_at", "CREATE INDEX IF NOT EXISTS idx_habits_updated_at ON habits(user_id, updated_at)"},
{"idx_habits_deleted_at", "CREATE INDEX IF NOT EXISTS idx_habits_deleted_at ON habits(deleted_at)"},
{"idx_habit_entries_updated_at", "CREATE INDEX IF NOT EXISTS idx_habit_entries_updated_at ON habit_entries(habit_id, updated_at)"},
{"idx_habit_entries_deleted_at", "CREATE INDEX IF NOT EXISTS idx_habit_entries_deleted_at ON habit_entries(deleted_at)"},
}
for _, idx := range indexes {
exists, err := indexExists(db, idx.name)
if err != nil {
return fmt.Errorf("failed to check if index %s exists: %w", idx.name, err)
}
if !exists {
if _, err := db.Exec(idx.definition); err != nil {
return fmt.Errorf("failed to create index %s: %w", idx.name, err)
}
}
}
return nil
}
const createUsersTable = `
CREATE TABLE IF NOT EXISTS users (
id TEXT PRIMARY KEY,