package handlers import ( "net/http" "net/http/httptest" "strings" "testing" "github.com/gin-gonic/gin" ) // Наборы разбора ?q= в GET /users: короткий запрос отклоняется до базы. // Хендлер создаётся с nil-репозиторием намеренно — если валидация // перестанет работать, тест упадёт с паникой, а не молча уйдёт в SQL. func listUsersQ(t *testing.T, rawQuery, role string) (int, string) { t.Helper() c, w := newQueryContext(t, rawQuery) c.Set("role", role) c.Set("user_id", "00000000-0000-0000-0000-000000000001") h := &UserHandler{} h.List(c) return w.Code, errorBody(t, w) } func TestListUsersRejectsShortQuery(t *testing.T) { cases := []struct { name string query string role string }{ {"один символ", "role=worker&q=x", "client"}, {"символ после пробелов", "role=worker&q=%20x", "client"}, {"админ с коротким запросом", "role=worker&q=п", "admin"}, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { code, errBody := listUsersQ(t, tc.query, tc.role) if code != http.StatusBadRequest || errBody != "invalid q" { t.Errorf("status=%d error=%q, want 400/invalid q", code, errBody) } }) } } // Пустой q — обычный список, а не ошибка: валидация его пропускает, // и хендлер доходит до репозитория. Репозиторий в тесте nil — паника // от него и есть доказательство, что ответа 400 на пустой q не было. func TestListUsersEmptyQueryPassesValidation(t *testing.T) { c, w := newQueryContext(t, "role=worker&q=") c.Set("role", "client") c.Set("user_id", "00000000-0000-0000-0000-000000000001") defer func() { if recover() == nil { t.Error("ожидалась паника от nil-репозитория — значит, до репозитория не дошли") } if w.Code == http.StatusBadRequest { t.Errorf("пустой q не должен быть 400, body %q", w.Body.String()) } }() h := &UserHandler{} h.List(c) } // --- POST /users: роль, граница полномочий и пароль — до базы --- // newPostContext — контекст POST с JSON-телом (для Create). func newPostContext(t *testing.T, body string) (*gin.Context, *httptest.ResponseRecorder) { t.Helper() gin.SetMode(gin.TestMode) w := httptest.NewRecorder() c, _ := gin.CreateTestContext(w) c.Request = httptest.NewRequest(http.MethodPost, "/users", strings.NewReader(body)) c.Request.Header.Set("Content-Type", "application/json") return c, w } // TestCreateUserRoleRules — «клиент создать клиента/админа» — 403, // неизвестная роль — 400, слабый пароль — 400. Все проверки идут до // HashPassword/SQL, поэтому репозиторий здесь nil: сдвинутая проверка // упадёт паникой, а не молча создаст пользователя. Успешный путь // админа (role=client → 201) проверяется интеграционно в API-смоуке. func TestCreateUserRoleRules(t *testing.T) { cases := []struct { name string actor string body string wantCode int wantSub string // подстрока в error, "" — не проверяем }{ { "неизвестная роль", "admin", `{"email":"a@b.co","password":"password1","name":"X","role":"manager"}`, http.StatusBadRequest, "must be one of: worker admin client", }, { "клиент создаёт клиента", "client", `{"email":"a@b.co","password":"password1","name":"X","role":"client"}`, http.StatusForbidden, "forbidden", }, { "клиент создаёт админа", "client", `{"email":"a@b.co","password":"password1","name":"X","role":"admin"}`, http.StatusForbidden, "forbidden", }, { // Политика пароля (мин. 10, буквы и цифры) — после границы // полномочий и до базы: репозиторий nil. "слабый пароль", "admin", `{"email":"a@b.co","password":"short123","name":"X","role":"worker"}`, http.StatusBadRequest, "password must be at least 10 characters", }, { // Граница не изменилась: клиент по-прежнему создаёт только мастеров. "клиент создаёт мастера — доходит до базы", "client", `{"email":"a@b.co","password":"password123","name":"X","role":"worker"}`, 0, "паника от nil-репозитория", }, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { c, w := newPostContext(t, tc.body) c.Set("role", tc.actor) c.Set("user_id", "00000000-0000-0000-0000-000000000001") h := &UserHandler{} if tc.wantCode == 0 { // Успешный путь: останавливает только паника nil-репо. defer func() { if recover() == nil { t.Error("ожидалась паника от nil-репозитория — значит, до репозитория не дошли") } }() h.Create(c) return } h.Create(c) if w.Code != tc.wantCode { t.Errorf("status = %d, want %d (body %q)", w.Code, tc.wantCode, w.Body.String()) } if body := errorBody(t, w); !strings.Contains(body, tc.wantSub) { t.Errorf("error = %q, want substring %q", body, tc.wantSub) } }) } } // --- PATCH /users/:id и DELETE /users/:id: проверки до базы --- // newIDContext — контекст с path-параметром :id; body пустой — без JSON. func newIDContext(t *testing.T, method, id, body string) (*gin.Context, *httptest.ResponseRecorder) { t.Helper() gin.SetMode(gin.TestMode) w := httptest.NewRecorder() c, _ := gin.CreateTestContext(w) c.Request = httptest.NewRequest(method, "/users/"+id, strings.NewReader(body)) if body != "" { c.Request.Header.Set("Content-Type", "application/json") } c.Params = gin.Params{{Key: "id", Value: id}} return c, w } const ownID = "00000000-0000-0000-0000-000000000001" const otherID = "00000000-0000-0000-0000-000000000002" // TestUpdateUserValidation — до базы: кривой id и смена своего статуса. // Роль-ветку (клиент идёт в SetActiveWorkerForClient) проверяем отдельно: // nil-репозиторий даёт панику — доказательство, что выбор SQL-правила // действительно происходит по роли. func TestUpdateUserValidation(t *testing.T) { cases := []struct { name string actor string id string body string wantCode int wantSub string }{ {"кривой id", "admin", "not-a-uuid", `{"is_active":false}`, http.StatusBadRequest, "invalid id"}, {"пустое тело", "admin", otherID, ``, http.StatusBadRequest, ""}, {"свой статус менять нельзя", "admin", ownID, `{"is_active":false}`, http.StatusBadRequest, "cannot change your own status"}, {"клиент меняет свой статус", "client", ownID, `{"is_active":false}`, http.StatusBadRequest, "cannot change your own status"}, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { c, w := newIDContext(t, http.MethodPatch, tc.id, tc.body) c.Set("role", tc.actor) c.Set("user_id", ownID) h := &UserHandler{} h.Update(c) if w.Code != tc.wantCode { t.Errorf("status = %d, want %d (body %q)", w.Code, tc.wantCode, w.Body.String()) } if tc.wantSub != "" { if body := errorBody(t, w); !strings.Contains(body, tc.wantSub) { t.Errorf("error = %q, want substring %q", body, tc.wantSub) } } }) } } // TestUpdateUserRoleBranchReachesRepo — и админ, и клиент после // проверок уходят в репозиторий: клиент — в SQL-правило «только мой // мастер», админ — в обычное SetActive. Паника nil-репо — и есть // доказательство; если ветка по роли сломается, упадёт тест. func TestUpdateUserRoleBranchReachesRepo(t *testing.T) { for _, actor := range []string{"admin", "client"} { t.Run(actor, func(t *testing.T) { c, _ := newIDContext(t, http.MethodPatch, otherID, `{"is_active":false}`) c.Set("role", actor) c.Set("user_id", ownID) defer func() { if recover() == nil { t.Error("ожидалась паника от nil-репозитория — значит, роль-ветка не дошла до SQL") } }() h := &UserHandler{} h.Update(c) }) } } // TestDeleteUserValidation — до базы: кривой id и удаление самого себя. func TestDeleteUserValidation(t *testing.T) { cases := []struct { name string id string wantCode int wantSub string }{ {"кривой id", "not-a-uuid", http.StatusBadRequest, "invalid id"}, {"себя удалить нельзя", ownID, http.StatusBadRequest, "cannot delete yourself"}, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { c, w := newIDContext(t, http.MethodDelete, tc.id, "") c.Set("role", "admin") c.Set("user_id", ownID) h := &UserHandler{} h.Delete(c) if w.Code != tc.wantCode { t.Errorf("status = %d, want %d (body %q)", w.Code, tc.wantCode, w.Body.String()) } if body := errorBody(t, w); !strings.Contains(body, tc.wantSub) { t.Errorf("error = %q, want substring %q", body, tc.wantSub) } }) } } // TestDeleteUserReachesRepo — валидный id после проверок уходит в БД, // независимо от роли: у админа ветка DeleteProfile, у клиента — // DeleteWorkerForClient (граница и транзакция — в SQL). Паника // nil-репозитория — доказательство; успешный путь и 409 «есть история» // проверяются интеграционно в API-смоуке и репозиториных тестах. func TestDeleteUserReachesRepo(t *testing.T) { for _, r := range []string{"admin", "client"} { t.Run(r, func(t *testing.T) { c, _ := newIDContext(t, http.MethodDelete, otherID, "") c.Set("role", r) c.Set("user_id", ownID) defer func() { if recover() == nil { t.Error("ожидалась паника от nil-репозитория — значит, до репозитория не дошли") } }() h := &UserHandler{} h.Delete(c) }) } } // --- POST /users/:id/confirm: проверки до базы --- // TestConfirmValidation — до базы: кривой id отбрасывается сразу, валидный // доходит до репозитория (паника nil-репозитория — доказательство). // Матрица прав подтверждения — TestUserConfirmationRules в репозитории, // письмо — в mailer-тестах: здесь только разбор параметра. func TestConfirmValidation(t *testing.T) { t.Run("кривой id", func(t *testing.T) { c, w := newIDContext(t, http.MethodPost, "not-a-uuid", "") c.Set("role", "admin") c.Set("user_id", ownID) h := &UserHandler{} h.Confirm(c) if w.Code != http.StatusBadRequest { t.Errorf("status = %d, want 400 (body %q)", w.Code, w.Body.String()) } if body := errorBody(t, w); !strings.Contains(body, "invalid id") { t.Errorf("error = %q, want \"invalid id\"", body) } }) t.Run("валидный id доходит в репозиторий", func(t *testing.T) { c, _ := newIDContext(t, http.MethodPost, otherID, "") c.Set("role", "admin") c.Set("user_id", ownID) defer func() { if recover() == nil { t.Error("ожидалась паника от nil-репозитория — значит, до репозитория не дошли") } }() h := &UserHandler{} h.Confirm(c) }) } // --- POST /users/:id/password-set: проверки до базы --- // TestSetPasswordValidation — до базы: кривой id, пустое тело и слабый // пароль отклоняются до SQL. Валидный запрос доходит до репозитория // (паника nil-репозитория — доказательство); успешный путь админа // (200, вход новым паролем, старые сессии отозваны) проверяется // интеграционно в API-смоуке. func TestSetPasswordValidation(t *testing.T) { cases := []struct { name string id string body string wantCode int wantSub string }{ {"кривой id", "not-a-uuid", `{"password":"password123"}`, http.StatusBadRequest, "invalid id"}, {"пустое тело", otherID, ``, http.StatusBadRequest, "invalid request body"}, {"нет пароля", otherID, `{}`, http.StatusBadRequest, "is required"}, {"слабый пароль", otherID, `{"password":"short123"}`, http.StatusBadRequest, "password must be at least 10 characters"}, {"валидный запрос идёт в базу", otherID, `{"password":"password123"}`, 0, ""}, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { c, w := newIDContext(t, http.MethodPost, tc.id, tc.body) c.Set("role", "admin") c.Set("user_id", ownID) h := &UserHandler{} if tc.wantCode == 0 { defer func() { if recover() == nil { t.Error("ожидалась паника от nil-репозитория — значит, до репозитория не дошли") } }() h.SetPassword(c) return } h.SetPassword(c) if w.Code != tc.wantCode { t.Errorf("status = %d, want %d (body %q)", w.Code, tc.wantCode, w.Body.String()) } if body := errorBody(t, w); !strings.Contains(body, tc.wantSub) { t.Errorf("error = %q, want substring %q", body, tc.wantSub) } }) } }