diff --git a/internal/infrastructure/http/auth_integration_test.go b/internal/infrastructure/http/auth_integration_test.go new file mode 100644 index 0000000..e703ddf --- /dev/null +++ b/internal/infrastructure/http/auth_integration_test.go @@ -0,0 +1,139 @@ +package http + +import ( + "net/http" + "testing" +) + +func TestAuthFlow(t *testing.T) { + ts := setupTestServer(t) + defer ts.Close() + + t.Run("Register new user", func(t *testing.T) { + reqBody := RegisterRequest{ + Email: "test@example.com", + Password: "password123", + Timezone: "UTC", + } + + rr := makeRequest(t, *ts.Router, "POST", "/api/v1/auth/register", reqBody, "") + + if rr.Code != http.StatusCreated { + t.Errorf("Expected status 201, got %d. Body: %s", rr.Code, rr.Body.String()) + } + + var resp AuthResponse + decodeResponse(t, rr, &resp) + + if resp.Token == "" { + t.Error("Expected token in response") + } + if resp.UserID == "" { + t.Error("Expected user ID in response") + } + }) + + t.Run("Register duplicate email", func(t *testing.T) { + reqBody := RegisterRequest{ + Email: "duplicate@example.com", + Password: "password123", + Timezone: "UTC", + } + + makeRequest(t, *ts.Router, "POST", "/api/v1/auth/register", reqBody, "") + + rr := makeRequest(t, *ts.Router, "POST", "/api/v1/auth/register", reqBody, "") + + if rr.Code != http.StatusConflict { + t.Errorf("Expected status 409, got %d", rr.Code) + } + }) + + t.Run("Register with invalid email", func(t *testing.T) { + reqBody := RegisterRequest{ + Email: "invalid-email", + Password: "password123", + Timezone: "UTC", + } + + rr := makeRequest(t, *ts.Router, "POST", "/api/v1/auth/register", reqBody, "") + + if rr.Code != http.StatusBadRequest { + t.Errorf("Expected status 400, got %d", rr.Code) + } + }) + + t.Run("Register with short password", func(t *testing.T) { + reqBody := RegisterRequest{ + Email: "short@example.com", + Password: "123", + Timezone: "UTC", + } + + rr := makeRequest(t, *ts.Router, "POST", "/api/v1/auth/register", reqBody, "") + + if rr.Code != http.StatusBadRequest { + t.Errorf("Expected status 400, got %d", rr.Code) + } + }) + + t.Run("Login with valid credentials", func(t *testing.T) { + registerBody := RegisterRequest{ + Email: "login@example.com", + Password: "password123", + Timezone: "UTC", + } + makeRequest(t, *ts.Router, "POST", "/api/v1/auth/register", registerBody, "") + + loginBody := LoginRequest{ + Email: "login@example.com", + Password: "password123", + } + + rr := makeRequest(t, *ts.Router, "POST", "/api/v1/auth/login", loginBody, "") + + if rr.Code != http.StatusOK { + t.Errorf("Expected status 200, got %d", rr.Code) + } + + var resp AuthResponse + decodeResponse(t, rr, &resp) + + if resp.Token == "" { + t.Error("Expected token in response") + } + }) + + t.Run("Login with invalid password", func(t *testing.T) { + registerBody := RegisterRequest{ + Email: "wrongpass@example.com", + Password: "password123", + Timezone: "UTC", + } + makeRequest(t, *ts.Router, "POST", "/api/v1/auth/register", registerBody, "") + + loginBody := LoginRequest{ + Email: "wrongpass@example.com", + Password: "wrongpassword", + } + + rr := makeRequest(t, *ts.Router, "POST", "/api/v1/auth/login", loginBody, "") + + if rr.Code != http.StatusUnauthorized { + t.Errorf("Expected status 401, got %d", rr.Code) + } + }) + + t.Run("Login with non-existent user", func(t *testing.T) { + loginBody := LoginRequest{ + Email: "nonexistent@example.com", + Password: "password123", + } + + rr := makeRequest(t, *ts.Router, "POST", "/api/v1/auth/login", loginBody, "") + + if rr.Code != http.StatusUnauthorized { + t.Errorf("Expected status 401, got %d", rr.Code) + } + }) +} diff --git a/internal/infrastructure/http/habit_entries_integration_test.go b/internal/infrastructure/http/habit_entries_integration_test.go new file mode 100644 index 0000000..97a868b --- /dev/null +++ b/internal/infrastructure/http/habit_entries_integration_test.go @@ -0,0 +1,127 @@ +package http + +import ( + "net/http" + "testing" + "time" +) + +func TestHabitEntriesFlow(t *testing.T) { + ts := setupTestServer(t) + defer ts.Close() + + registerBody := RegisterRequest{ + Email: "entryuser@example.com", + Password: "password123", + Timezone: "UTC", + } + rr := makeRequest(t, *ts.Router, "POST", "/api/v1/auth/register", registerBody, "") + var authResp AuthResponse + decodeResponse(t, rr, &authResp) + token := authResp.Token + + habitBody := CreateHabitRequest{ + Name: "Reading", + 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") + + t.Run("Mark habit as complete", func(t *testing.T) { + reqBody := MarkHabitRequest{ + ScheduledDate: today, + } + + rr := makeRequest(t, *ts.Router, "POST", "/api/v1/habits/"+habitID+"/mark", reqBody, token) + + if rr.Code != http.StatusOK { + t.Fatalf("Expected status 200, got %d. Body: %s", rr.Code, rr.Body.String()) + } + }) + + t.Run("Mark habit twice returns conflict", func(t *testing.T) { + reqBody := MarkHabitRequest{ + ScheduledDate: today, + } + + rr := makeRequest(t, *ts.Router, "POST", "/api/v1/habits/"+habitID+"/mark", reqBody, token) + + if rr.Code != http.StatusConflict { + t.Errorf("Expected status 409, got %d", rr.Code) + } + }) + + t.Run("Get habit entries", func(t *testing.T) { + rr := makeRequest(t, *ts.Router, "GET", "/api/v1/habits/"+habitID+"/entries?page=1&limit=10", nil, token) + + if rr.Code != http.StatusOK { + t.Fatalf("Expected status 200, got %d", rr.Code) + } + + var resp HabitEntriesResponse + decodeResponse(t, rr, &resp) + + if resp.Total != 1 { + t.Errorf("Expected 1 entry, got %d", resp.Total) + } + + if len(resp.Entries) != 1 { + t.Errorf("Expected 1 entry in array, got %d", len(resp.Entries)) + } + }) + + t.Run("Unmark 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("Expected status 200, got %d. Body: %s", rr.Code, rr.Body.String()) + } + + rr = makeRequest(t, *ts.Router, "GET", "/api/v1/habits/"+habitID+"/entries?page=1&limit=10", nil, token) + var resp HabitEntriesResponse + decodeResponse(t, rr, &resp) + + if resp.Total != 0 { + t.Errorf("Expected 0 entries after unmark, got %d", resp.Total) + } + }) + + t.Run("Mark with value for counter habit", func(t *testing.T) { + counterHabitBody := CreateHabitRequest{ + Name: "Steps", + Type: "COUNTER", + Frequency: "DAILY", + } + rr := makeRequest(t, *ts.Router, "POST", "/api/v1/habits", counterHabitBody, token) + var counterResp map[string]string + decodeResponse(t, rr, &counterResp) + counterHabitID := counterResp["id"] + + value := 10000.0 + reqBody := MarkHabitRequest{ + ScheduledDate: today, + Value: &value, + } + + rr = makeRequest(t, *ts.Router, "POST", "/api/v1/habits/"+counterHabitID+"/mark", reqBody, token) + + if rr.Code != http.StatusOK { + t.Fatalf("Expected status 200, got %d", rr.Code) + } + + rr = makeRequest(t, *ts.Router, "GET", "/api/v1/habits/"+counterHabitID+"/entries?page=1&limit=10", nil, token) + var resp HabitEntriesResponse + decodeResponse(t, rr, &resp) + + if resp.Entries[0].Value == nil { + t.Error("Expected value in entry") + } else if *resp.Entries[0].Value != 10000.0 { + t.Errorf("Expected value 10000, got %f", *resp.Entries[0].Value) + } + }) +} diff --git a/internal/infrastructure/http/habit_integration_test.go b/internal/infrastructure/http/habit_integration_test.go new file mode 100644 index 0000000..2356686 --- /dev/null +++ b/internal/infrastructure/http/habit_integration_test.go @@ -0,0 +1,160 @@ +package http + +import ( + "net/http" + "testing" +) + +func TestHabitCRUDFlow(t *testing.T) { + ts := setupTestServer(t) + defer ts.Close() + + registerBody := RegisterRequest{ + Email: "habituser@example.com", + Password: "password123", + Timezone: "UTC", + } + rr := makeRequest(t, *ts.Router, "POST", "/api/v1/auth/register", registerBody, "") + var authResp AuthResponse + decodeResponse(t, rr, &authResp) + token := authResp.Token + + var habitID string + + t.Run("Create habit", func(t *testing.T) { + reqBody := CreateHabitRequest{ + Name: "Exercise", + Description: "Daily workout", + Type: "BOOLEAN", + Frequency: "DAILY", + CarryOver: false, + } + + rr := makeRequest(t, *ts.Router, "POST", "/api/v1/habits", reqBody, token) + + if rr.Code != http.StatusCreated { + t.Fatalf("Expected status 201, got %d. Body: %s", rr.Code, rr.Body.String()) + } + + var resp map[string]string + decodeResponse(t, rr, &resp) + + habitID = resp["id"] + if habitID == "" { + t.Fatal("Expected habit ID in response") + } + }) + + t.Run("Create habit without auth", func(t *testing.T) { + reqBody := CreateHabitRequest{ + Name: "No Auth", + Type: "BOOLEAN", + Frequency: "DAILY", + } + + rr := makeRequest(t, *ts.Router, "POST", "/api/v1/habits", reqBody, "") + + if rr.Code != http.StatusUnauthorized { + t.Errorf("Expected status 401, got %d", rr.Code) + } + }) + + t.Run("Get all user habits", func(t *testing.T) { + rr := makeRequest(t, *ts.Router, "GET", "/api/v1/habits", nil, token) + + if rr.Code != http.StatusOK { + t.Fatalf("Expected status 200, got %d", rr.Code) + } + + var habits []UserHabitResponse + decodeResponse(t, rr, &habits) + + if len(habits) != 1 { + t.Errorf("Expected 1 habit, got %d", len(habits)) + } + + if habits[0].Name != "Exercise" { + t.Errorf("Expected habit name 'Exercise', got '%s'", habits[0].Name) + } + }) + + t.Run("Get habit by ID", func(t *testing.T) { + rr := makeRequest(t, *ts.Router, "GET", "/api/v1/habits/"+habitID, nil, token) + + if rr.Code != http.StatusOK { + t.Fatalf("Expected status 200, got %d", rr.Code) + } + + var habit UserHabitResponse + decodeResponse(t, rr, &habit) + + if habit.Name != "Exercise" { + t.Errorf("Expected habit name 'Exercise', got '%s'", habit.Name) + } + }) + + t.Run("Update habit", func(t *testing.T) { + reqBody := UpdateHabitRequest{ + Name: "Morning Exercise", + Description: "Updated description", + } + + rr := makeRequest(t, *ts.Router, "PUT", "/api/v1/habits/"+habitID, reqBody, token) + + if rr.Code != http.StatusOK { + t.Fatalf("Expected status 200, got %d. Body: %s", rr.Code, rr.Body.String()) + } + + rr = makeRequest(t, *ts.Router, "GET", "/api/v1/habits/"+habitID, nil, token) + var habit UserHabitResponse + decodeResponse(t, rr, &habit) + + if habit.Name != "Morning Exercise" { + t.Errorf("Expected updated name 'Morning Exercise', got '%s'", habit.Name) + } + }) + + t.Run("Archive habit", func(t *testing.T) { + rr := makeRequest(t, *ts.Router, "DELETE", "/api/v1/habits/"+habitID, nil, token) + + if rr.Code != http.StatusOK { + t.Fatalf("Expected status 200, got %d", rr.Code) + } + + rr = makeRequest(t, *ts.Router, "GET", "/api/v1/habits", nil, token) + var habits []UserHabitResponse + decodeResponse(t, rr, &habits) + + if len(habits) != 0 { + t.Errorf("Expected 0 active habits after archive, got %d", len(habits)) + } + }) + + t.Run("Access other user's habit", func(t *testing.T) { + registerBody := RegisterRequest{ + Email: "otheruser@example.com", + Password: "password123", + Timezone: "UTC", + } + rr := makeRequest(t, *ts.Router, "POST", "/api/v1/auth/register", registerBody, "") + var authResp AuthResponse + decodeResponse(t, rr, &authResp) + otherToken := authResp.Token + + reqBody := CreateHabitRequest{ + Name: "Other User Habit", + Type: "BOOLEAN", + Frequency: "DAILY", + } + rr = makeRequest(t, *ts.Router, "POST", "/api/v1/habits", reqBody, otherToken) + var createResp map[string]string + decodeResponse(t, rr, &createResp) + otherHabitID := createResp["id"] + + rr = makeRequest(t, *ts.Router, "GET", "/api/v1/habits/"+otherHabitID, nil, token) + + if rr.Code != http.StatusForbidden { + t.Errorf("Expected status 403, got %d", rr.Code) + } + }) +} diff --git a/internal/infrastructure/http/integration_test.go b/internal/infrastructure/http/integration_test.go new file mode 100644 index 0000000..ca41d9b --- /dev/null +++ b/internal/infrastructure/http/integration_test.go @@ -0,0 +1,94 @@ +package http + +import ( + "bytes" + "database/sql" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "apocapoc-api/internal/application/commands" + "apocapoc-api/internal/application/queries" + "apocapoc-api/internal/infrastructure/auth" + "apocapoc-api/internal/infrastructure/persistence/sqlite" + + _ "github.com/mattn/go-sqlite3" +) + +type TestServer struct { + Router *http.Handler + DB *sql.DB +} + +func setupTestServer(t *testing.T) *TestServer { + db, err := sql.Open("sqlite3", ":memory:") + if err != nil { + t.Fatalf("Failed to open test database: %v", err) + } + + if err := sqlite.RunMigrations(db); err != nil { + t.Fatalf("Failed to run migrations: %v", err) + } + + jwtService := auth.NewJWTService("test-secret", 24) + + userRepo := sqlite.NewUserRepository(db) + habitRepo := sqlite.NewHabitRepository(db) + entryRepo := sqlite.NewHabitEntryRepository(db) + + registerHandler := commands.NewRegisterUserHandler(userRepo) + loginHandler := queries.NewLoginUserHandler(userRepo) + createHandler := commands.NewCreateHabitHandler(habitRepo) + getTodaysHandler := queries.NewGetTodaysHabitsHandler(habitRepo, entryRepo) + getUserHabitsHandler := queries.NewGetUserHabitsHandler(habitRepo) + getHabitByIDHandler := queries.NewGetHabitByIDHandler(habitRepo) + getHabitEntriesHandler := queries.NewGetHabitEntriesHandler(habitRepo, entryRepo) + updateHandler := commands.NewUpdateHabitHandler(habitRepo) + archiveHandler := commands.NewArchiveHabitHandler(habitRepo) + markHandler := commands.NewMarkHabitHandler(entryRepo, habitRepo) + unmarkHandler := commands.NewUnmarkHabitHandler(habitRepo, entryRepo) + + authHandlers := NewAuthHandlers(registerHandler, loginHandler, jwtService) + habitHandlers := NewHabitHandlers(createHandler, getTodaysHandler, getUserHabitsHandler, getHabitByIDHandler, getHabitEntriesHandler, updateHandler, archiveHandler, markHandler, unmarkHandler) + + router := NewRouter("*", habitHandlers, authHandlers, jwtService) + + handler := http.Handler(router) + return &TestServer{ + Router: &handler, + DB: db, + } +} + +func (ts *TestServer) Close() { + ts.DB.Close() +} + +func makeRequest(t *testing.T, handler http.Handler, method, path string, body interface{}, authToken string) *httptest.ResponseRecorder { + var bodyBytes []byte + if body != nil { + var err error + bodyBytes, err = json.Marshal(body) + if err != nil { + t.Fatalf("Failed to marshal request body: %v", err) + } + } + + req := httptest.NewRequest(method, path, bytes.NewReader(bodyBytes)) + req.Header.Set("Content-Type", "application/json") + if authToken != "" { + req.Header.Set("Authorization", "Bearer "+authToken) + } + + rr := httptest.NewRecorder() + handler.ServeHTTP(rr, req) + + return rr +} + +func decodeResponse(t *testing.T, rr *httptest.ResponseRecorder, target interface{}) { + if err := json.NewDecoder(rr.Body).Decode(target); err != nil { + t.Fatalf("Failed to decode response: %v", err) + } +}