344 lines
10 KiB
Go
344 lines
10 KiB
Go
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
|
|
}
|