368 lines
14 KiB
Go
368 lines
14 KiB
Go
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)
|
||
}
|
||
})
|
||
}
|
||
}
|