package handlers import ( "encoding/json" "net/http" "testing" "github.com/gin-gonic/gin" "golang.org/x/crypto/bcrypt" "chat-switchboard/config" "chat-switchboard/database" "chat-switchboard/middleware" "chat-switchboard/store" postgres "chat-switchboard/store/postgres" sqlite "chat-switchboard/store/sqlite" ) // ── Profile Test Harness ────────────────── type profileHarness struct { *testHarness userToken string userID string } func setupProfileHarness(t *testing.T) *profileHarness { t.Helper() database.RequireTestDB(t) database.TruncateAll(t) cfg := &config.Config{ JWTSecret: testJWTSecret, BasePath: "", } var stores store.Stores if database.IsSQLite() { stores = sqlite.NewStores(database.TestDB) } else { stores = postgres.NewStores(database.TestDB) } userCache := middleware.NewUserStatusCache() r := gin.New() api := r.Group("/api/v1") protected := api.Group("") protected.Use(middleware.Auth(cfg, stores.Users, userCache)) settings := NewSettingsHandler(stores, nil) protected.GET("/profile", settings.GetProfile) protected.PUT("/profile", settings.UpdateProfile) protected.POST("/profile/password", settings.ChangePassword) protected.POST("/profile/avatar", settings.UploadAvatar) protected.DELETE("/profile/avatar", settings.DeleteAvatar) protected.GET("/settings", settings.GetSettings) protected.PUT("/settings", settings.UpdateSettings) userID := database.SeedTestUser(t, "profuser", "profuser@test.com") database.TestDB.Exec(dialectSQL("UPDATE users SET is_active = true WHERE id = $1"), userID) userToken := makeToken(userID, "profuser@test.com", "user") return &profileHarness{ testHarness: &testHarness{router: r, t: t}, userToken: userToken, userID: userID, } } // ── GET /profile ────────────────────────── func TestProfile_Get_Shape(t *testing.T) { h := setupProfileHarness(t) resp := h.request("GET", "/api/v1/profile", h.userToken, nil) if resp.Code != http.StatusOK { t.Fatalf("got %d, body: %s", resp.Code, resp.Body.String()) } var body map[string]interface{} json.Unmarshal(resp.Body.Bytes(), &body) // Required fields for _, key := range []string{"id", "username", "email", "role", "settings", "created_at"} { if _, ok := body[key]; !ok { t.Errorf("missing required field %q in profile response", key) } } if body["username"] != "profuser" { t.Errorf("username: got %v, want profuser", body["username"]) } if body["email"] != "profuser@test.com" { t.Errorf("email: got %v, want profuser@test.com", body["email"]) } if body["role"] != "user" { t.Errorf("role: got %v, want user", body["role"]) } // settings must be an object (not null) settings, ok := body["settings"].(map[string]interface{}) if !ok { t.Errorf("settings should be an object, got %T", body["settings"]) } _ = settings // avatar_url should NOT appear (handler uses "avatar" json tag) if _, exists := body["avatar_url"]; exists { t.Error("profile should return 'avatar' not 'avatar_url'") } } func TestProfile_Get_RequiresAuth(t *testing.T) { h := setupProfileHarness(t) resp := h.request("GET", "/api/v1/profile", "", nil) if resp.Code != http.StatusUnauthorized { t.Fatalf("expected 401 without token, got %d", resp.Code) } } // ── PUT /profile ────────────────────────── func TestProfile_Update_DisplayName(t *testing.T) { h := setupProfileHarness(t) name := "New Name" resp := h.request("PUT", "/api/v1/profile", h.userToken, map[string]interface{}{ "display_name": name, }) if resp.Code != http.StatusOK { t.Fatalf("got %d, body: %s", resp.Code, resp.Body.String()) } var body map[string]interface{} json.Unmarshal(resp.Body.Bytes(), &body) if body["display_name"] != name { t.Errorf("display_name: got %v, want %s", body["display_name"], name) } } func TestProfile_Update_Email(t *testing.T) { h := setupProfileHarness(t) resp := h.request("PUT", "/api/v1/profile", h.userToken, map[string]interface{}{ "email": "NEW@TEST.COM", }) if resp.Code != http.StatusOK { t.Fatalf("got %d, body: %s", resp.Code, resp.Body.String()) } var body map[string]interface{} json.Unmarshal(resp.Body.Bytes(), &body) // Email should be lowercased if body["email"] != "new@test.com" { t.Errorf("email: got %v, want new@test.com", body["email"]) } } func TestProfile_Update_DuplicateEmail_409(t *testing.T) { h := setupProfileHarness(t) // Seed another user with the target email database.SeedTestUser(t, "other", "taken@test.com") resp := h.request("PUT", "/api/v1/profile", h.userToken, map[string]interface{}{ "email": "taken@test.com", }) if resp.Code != http.StatusConflict { t.Fatalf("expected 409 for duplicate email, got %d: %s", resp.Code, resp.Body.String()) } } // ── GET /settings ───────────────────────── func TestSettings_Get_Envelope(t *testing.T) { h := setupProfileHarness(t) resp := h.request("GET", "/api/v1/settings", h.userToken, nil) if resp.Code != http.StatusOK { t.Fatalf("got %d, body: %s", resp.Code, resp.Body.String()) } var body map[string]interface{} json.Unmarshal(resp.Body.Bytes(), &body) // P0: Must have "settings" key — this was the surface test failure settings, ok := body["settings"] if !ok { t.Fatal("GET /settings must return {\"settings\": {...}}, missing \"settings\" key") } // settings value must be an object (not null) settingsMap, ok := settings.(map[string]interface{}) if !ok { t.Fatalf("settings value should be an object, got %T", settings) } // New user should have empty settings if len(settingsMap) != 0 { t.Errorf("new user settings should be empty, got %v", settingsMap) } } // ── PUT /settings ───────────────────────── func TestSettings_Update_MergeAndEnvelope(t *testing.T) { h := setupProfileHarness(t) // Set initial resp := h.request("PUT", "/api/v1/settings", h.userToken, map[string]interface{}{ "theme": "dark", "lang": "en", }) if resp.Code != http.StatusOK { t.Fatalf("initial PUT: got %d, body: %s", resp.Code, resp.Body.String()) } var body1 map[string]interface{} json.Unmarshal(resp.Body.Bytes(), &body1) settings1, _ := body1["settings"].(map[string]interface{}) if settings1["theme"] != "dark" || settings1["lang"] != "en" { t.Fatalf("initial settings: got %v", settings1) } // Merge: change theme, keep lang resp2 := h.request("PUT", "/api/v1/settings", h.userToken, map[string]interface{}{ "theme": "light", }) if resp2.Code != http.StatusOK { t.Fatalf("merge PUT: got %d, body: %s", resp2.Code, resp2.Body.String()) } var body2 map[string]interface{} json.Unmarshal(resp2.Body.Bytes(), &body2) settings2, _ := body2["settings"].(map[string]interface{}) if settings2["theme"] != "light" { t.Errorf("theme should be overwritten to 'light', got %v", settings2["theme"]) } if settings2["lang"] != "en" { t.Errorf("lang should be preserved as 'en', got %v", settings2["lang"]) } // Verify via GET resp3 := h.request("GET", "/api/v1/settings", h.userToken, nil) var body3 map[string]interface{} json.Unmarshal(resp3.Body.Bytes(), &body3) settings3, _ := body3["settings"].(map[string]interface{}) if settings3["theme"] != "light" || settings3["lang"] != "en" { t.Errorf("GET after merge: got %v", settings3) } } // ── POST /profile/password ──────────────── func TestProfile_ChangePassword(t *testing.T) { h := setupProfileHarness(t) // Seed user with a known bcrypt hash for "oldpassword" // The default SeedTestUser uses a dummy hash. We need a real one. hash, _ := hashPassword("oldpassword") database.TestDB.Exec(dialectSQL("UPDATE users SET password_hash = $1 WHERE id = $2"), hash, h.userID) // Change password resp := h.request("POST", "/api/v1/profile/password", h.userToken, map[string]interface{}{ "current_password": "oldpassword", "new_password": "newpassword123", }) if resp.Code != http.StatusOK { t.Fatalf("got %d, body: %s", resp.Code, resp.Body.String()) } var body map[string]interface{} json.Unmarshal(resp.Body.Bytes(), &body) if body["message"] != "password updated" { t.Errorf("expected success message, got %v", body) } } func TestProfile_ChangePassword_WrongCurrent_401(t *testing.T) { h := setupProfileHarness(t) hash, _ := hashPassword("correctpassword") database.TestDB.Exec(dialectSQL("UPDATE users SET password_hash = $1 WHERE id = $2"), hash, h.userID) resp := h.request("POST", "/api/v1/profile/password", h.userToken, map[string]interface{}{ "current_password": "wrongpassword", "new_password": "newpassword123", }) if resp.Code != http.StatusUnauthorized { t.Fatalf("expected 401 for wrong current password, got %d: %s", resp.Code, resp.Body.String()) } } func TestProfile_ChangePassword_TooShort_400(t *testing.T) { h := setupProfileHarness(t) hash, _ := hashPassword("oldpassword") database.TestDB.Exec(dialectSQL("UPDATE users SET password_hash = $1 WHERE id = $2"), hash, h.userID) resp := h.request("POST", "/api/v1/profile/password", h.userToken, map[string]interface{}{ "current_password": "oldpassword", "new_password": "short", }) if resp.Code != http.StatusBadRequest { t.Fatalf("expected 400 for short password, got %d: %s", resp.Code, resp.Body.String()) } } // ── DELETE /profile/avatar ──────────────── func TestProfile_DeleteAvatar(t *testing.T) { h := setupProfileHarness(t) // Set an avatar first database.TestDB.Exec(dialectSQL("UPDATE users SET avatar_url = $1 WHERE id = $2"), "data:image/png;base64,test", h.userID) resp := h.request("DELETE", "/api/v1/profile/avatar", h.userToken, nil) if resp.Code != http.StatusOK { t.Fatalf("got %d, body: %s", resp.Code, resp.Body.String()) } // Verify avatar is cleared in profile resp2 := h.request("GET", "/api/v1/profile", h.userToken, nil) var body map[string]interface{} json.Unmarshal(resp2.Body.Bytes(), &body) // avatar should be omitted (omitempty) or null if av, exists := body["avatar"]; exists && av != nil { t.Errorf("avatar should be cleared, got %v", av) } } // ── Helper ──────────────────────────────── func hashPassword(pw string) (string, error) { h, err := bcrypt.GenerateFromPassword([]byte(pw), bcryptCost) return string(h), err }