fix(admin): закрыть обещания панели, которые продукт не выполнял
Девятый проход, по итогам приёмки v1.0.0-rc1 на живом Debian 13. Общая тема:
интерфейс обещал оператору то, что продукт умел, но до чего не доходило
управление.
Секрет пира. Подпись под полем предлагала оставить его пустым, сервер умел его
сгенерировать, и генерация была недостижима: в go-playground/validator тег
omitempty НЕ пропускает правило, если поле объявлено указателем и указатель не
nil — hasValue считает указатель на пустую строку «значением». Правило min=6
применялось к пустой строке и отказывало. Ловушка закрыта общим шагом
нормализации DTO, а не тегом на одном поле: та же ловушка ломала фильтр списка
пиров, где очищенный крестиком el-input отправляет `?name=`. Граница проходит по
каждому полю отдельно — у remark пустая строка означает «убрать пометку», у
disabled ноль означает «включён».
Отказы. Любая ошибка любого поля превращалась в слово `invalid`, а слой vo
определял код ответа СРАВНЕНИЕМ текста сообщения — тот же антипаттерн, который
запрещён панели, только на сервере. Ответ несёт errors[{code, field, message,
params}]; панель выбирает фразу по коду и подставляет причины под поля.
Сессия. Ветка «войдите заново» была недостижима дважды: сервер отвечает HTTP 200
на любой отказ, поэтому обработчик ошибок axios не вызывался, а условие в нём
проверяло code === "A0230" и поле msg, которых в этом API никогда не было.
Истёкший токен вдобавок уезжал с кодом системной ошибки.
Иконки. Контракт currentColor был объявлен в двух местах и не действовал: восемь
ассетов несли литеральный fill="#000000" на <path>, а атрибут представления
перебивает унаследованное CSS-свойство. Под это попадали все семь иконок
бокового меню на фоне #181818.
Имя пира. Два правила на одном поле противоречили друг другу (min=1 против
6-32), а копия набора символов в слое контроллеров несла неэкранированный дефис
и впускала `, - . / : ; <` — через панель проходило имя peer/name, которое
импорт того же пира отклонял. Набор символов ЛОГИНА сознательно не сужен и
закреплён тестом: он приходит из HY2XS_ADMIN_USER и оркестратором не
ограничивается.
Добавлены подпись «Разработано во Flamy» с адресом, принадлежащим приложению, и
контрактные тесты панели как обязательный шаг сборки. Их исполняет Bun, а не
vitest: jsdom не вычисляет currentColor и визуальной корректности не доказал бы,
зато vitest привёл бы в граф pnpm audit сотню транзитивных зависимостей.
docs/ разложена по слоям, 11-testing-and-acceptance.md (117 КБ) разбит на пять
частей, добавлен docs/acceptance/ с отчётом о прогоне rc1 и перечнем дефектов.
Обход документации в приёмке стал рекурсивным: плоский docs/*.md после
разнесения по каталогам совпадал бы ровно с одним файлом.
This commit is contained in:
@@ -0,0 +1,505 @@
|
||||
package controller
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"hy2xs-admin/dao"
|
||||
"hy2xs-admin/model/constant"
|
||||
"hy2xs-admin/model/entity"
|
||||
"hy2xs-admin/service"
|
||||
)
|
||||
|
||||
// Контракт формы пира: необязательный секрет и внятный отказ.
|
||||
//
|
||||
// Проверяется весь путь запроса — разбор тела, нормализация DTO, правила
|
||||
// валидатора, сервис, база, — потому что дефект жил ровно на стыке этих
|
||||
// слоёв и ни один из них по отдельности его не показывал: панель обещала
|
||||
// автогенерацию, сервис умел её выполнить, а правило `omitempty,min=6` на
|
||||
// поле-указателе отказывало раньше, чем управление доходило до сервиса.
|
||||
|
||||
func newPeerControllerDB(t *testing.T) {
|
||||
t.Helper()
|
||||
dbPath := filepath.Join(t.TempDir(), "hy2xs-admin-test.db")
|
||||
if err := dao.InitSqliteDBAt(dbPath); err != nil {
|
||||
t.Fatalf("не удалось открыть тестовую базу: %v", err)
|
||||
}
|
||||
if err := dao.RunMigrations(); err != nil {
|
||||
t.Fatalf("не удалось применить миграции: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = dao.CloseSqliteDB() })
|
||||
}
|
||||
|
||||
// peerPayload — тело создания пира со всеми обязательными полями.
|
||||
// Тесты меняют в нём ровно то, что проверяют.
|
||||
func peerPayload(name string) map[string]any {
|
||||
return map[string]any{
|
||||
"name": name,
|
||||
"quotaBytes": -1,
|
||||
"expiresAt": 0,
|
||||
"maxDevices": 3,
|
||||
"disabled": 0,
|
||||
"remark": "",
|
||||
}
|
||||
}
|
||||
|
||||
func createPeer(t *testing.T, body map[string]any) apiResult {
|
||||
t.Helper()
|
||||
return postJSON(t, SavePeer, "/peers", body)
|
||||
}
|
||||
|
||||
// errorFor возвращает причину отказа по имени поля.
|
||||
func errorFor(t *testing.T, result apiResult, field string) (string, bool) {
|
||||
t.Helper()
|
||||
for _, item := range result.Errors {
|
||||
if item.Field == field {
|
||||
return item.Code, true
|
||||
}
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
|
||||
func storedPeer(t *testing.T, name string) entity.Peer {
|
||||
t.Helper()
|
||||
peer, err := dao.GetPeer("name = ?", name)
|
||||
if err != nil {
|
||||
t.Fatalf("пир %q не найден в базе: %v", name, err)
|
||||
}
|
||||
return peer
|
||||
}
|
||||
|
||||
// Регрессия UX-02. Панель писала под полем «оставьте пустым — сгенерируем
|
||||
// автоматически» и отправляла `secret: ""`. Правило `omitempty,min=6` на
|
||||
// поле-указателе НЕ пропускалось (см. hasValue в baked_in.go валидатора),
|
||||
// применялось к пустой строке и отказывало. Оператор видел «Invalid», а
|
||||
// генерация в CreatePeer была недостижима.
|
||||
func TestCreatePeerGeneratesSecretWhenNotProvided(t *testing.T) {
|
||||
cases := map[string]func(map[string]any){
|
||||
"поле отсутствует": func(body map[string]any) {},
|
||||
"пустая строка": func(body map[string]any) { body["secret"] = "" },
|
||||
"только пробелы": func(body map[string]any) { body["secret"] = " " },
|
||||
"перевод строки": func(body map[string]any) { body["secret"] = "\n" },
|
||||
"табуляция и пробел": func(body map[string]any) { body["secret"] = "\t " },
|
||||
}
|
||||
|
||||
for label, mutate := range cases {
|
||||
t.Run(label, func(t *testing.T) {
|
||||
newPeerControllerDB(t)
|
||||
|
||||
body := peerPayload("client-01")
|
||||
mutate(body)
|
||||
|
||||
result := createPeer(t, body)
|
||||
if result.Type != "ok" {
|
||||
t.Fatalf("создание пира отклонено: code=%d message=%q errors=%+v",
|
||||
result.Code, result.Message, result.Errors)
|
||||
}
|
||||
|
||||
peer := storedPeer(t, "client-01")
|
||||
if peer.SecretEncrypted == nil || *peer.SecretEncrypted == "" {
|
||||
t.Fatal("секрет не сохранён")
|
||||
}
|
||||
|
||||
secret, err := service.DecryptPeerSecret(*peer.SecretEncrypted)
|
||||
if err != nil {
|
||||
t.Fatalf("сохранённый секрет не расшифровывается: %v", err)
|
||||
}
|
||||
if len(secret) < 6 {
|
||||
t.Fatalf("сгенерирован слишком короткий секрет: %q", secret)
|
||||
}
|
||||
|
||||
// Сгенерированный секрет обязан РАБОТАТЬ немедленно: то, что он
|
||||
// записан, ничего не значит, пока по нему не проходит проверка
|
||||
// доступа. Это же связывает digest и шифртекст между собой.
|
||||
id, authID, authErr := service.Hysteria2Auth(secret)
|
||||
if authErr != nil {
|
||||
t.Fatalf("пир не аутентифицируется своим секретом: %v", authErr)
|
||||
}
|
||||
if id != *peer.Id || authID != *peer.AuthId {
|
||||
t.Fatalf("аутентифицировался другой пир: id=%d authId=%q", id, authID)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Два одинаковых запроса не должны давать одинаковый секрет: генератор
|
||||
// обязан быть случайным, а не производной от имени.
|
||||
func TestGeneratedPeerSecretsDiffer(t *testing.T) {
|
||||
newPeerControllerDB(t)
|
||||
|
||||
secrets := make(map[string]struct{}, 5)
|
||||
for _, name := range []string{"client-01", "client-02", "client-03", "client-04", "client-05"} {
|
||||
if result := createPeer(t, peerPayload(name)); result.Type != "ok" {
|
||||
t.Fatalf("создание %q отклонено: %+v", name, result)
|
||||
}
|
||||
peer := storedPeer(t, name)
|
||||
secret, err := service.DecryptPeerSecret(*peer.SecretEncrypted)
|
||||
if err != nil {
|
||||
t.Fatalf("секрет %q не расшифровывается: %v", name, err)
|
||||
}
|
||||
if _, seen := secrets[secret]; seen {
|
||||
t.Fatalf("сгенерированный секрет повторился: %q", secret)
|
||||
}
|
||||
secrets[secret] = struct{}{}
|
||||
}
|
||||
}
|
||||
|
||||
// Границы ручного секрета — ровно те, что обещает подсказка под полем.
|
||||
func TestCreatePeerSecretLengthBoundaries(t *testing.T) {
|
||||
cases := []struct {
|
||||
label string
|
||||
secret string
|
||||
accepted bool
|
||||
expectCode string
|
||||
}{
|
||||
{"5 символов", strings.Repeat("a", 5), false, constant.ErrCodeMinLength},
|
||||
{"6 символов", strings.Repeat("a", 6), true, ""},
|
||||
{"128 символов", strings.Repeat("a", 128), true, ""},
|
||||
{"129 символов", strings.Repeat("a", 129), false, constant.ErrCodeMaxLength},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.label, func(t *testing.T) {
|
||||
newPeerControllerDB(t)
|
||||
|
||||
body := peerPayload("client-01")
|
||||
body["secret"] = tc.secret
|
||||
result := createPeer(t, body)
|
||||
|
||||
if tc.accepted {
|
||||
if result.Type != "ok" {
|
||||
t.Fatalf("секрет длиной %d отклонён: %+v", len(tc.secret), result)
|
||||
}
|
||||
peer := storedPeer(t, "client-01")
|
||||
stored, err := service.DecryptPeerSecret(*peer.SecretEncrypted)
|
||||
if err != nil {
|
||||
t.Fatalf("секрет не расшифровывается: %v", err)
|
||||
}
|
||||
if stored != tc.secret {
|
||||
t.Fatalf("сохранён не тот секрет, который передали")
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if result.Type != "no" {
|
||||
t.Fatalf("секрет длиной %d принят", len(tc.secret))
|
||||
}
|
||||
code, ok := errorFor(t, result, "secret")
|
||||
if !ok {
|
||||
t.Fatalf("отказ не назвал поле secret: %+v", result.Errors)
|
||||
}
|
||||
if code != tc.expectCode {
|
||||
t.Fatalf("код отказа %q, ожидался %q", code, tc.expectCode)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Регрессия UX-03. Любая ошибка любого поля превращалась в одно слово
|
||||
// `invalid`: панель не могла ни подсветить поле, ни объяснить причину, и
|
||||
// вынуждена была бы разбирать текст, чтобы попытаться.
|
||||
func TestCreatePeerNamesTheFieldAndTheRule(t *testing.T) {
|
||||
cases := []struct {
|
||||
label string
|
||||
body func() map[string]any
|
||||
field string
|
||||
code string
|
||||
}{
|
||||
{
|
||||
label: "имя не передано",
|
||||
body: func() map[string]any {
|
||||
body := peerPayload("client-01")
|
||||
delete(body, "name")
|
||||
return body
|
||||
},
|
||||
field: "name",
|
||||
code: constant.ErrCodeRequired,
|
||||
},
|
||||
{
|
||||
label: "имя короче допустимого",
|
||||
body: func() map[string]any { return peerPayload("pc1") },
|
||||
field: "name",
|
||||
code: constant.ErrCodePeerName,
|
||||
},
|
||||
{
|
||||
label: "имя длиннее допустимого",
|
||||
body: func() map[string]any { return peerPayload(strings.Repeat("a", 33)) },
|
||||
field: "name",
|
||||
code: constant.ErrCodePeerName,
|
||||
},
|
||||
{
|
||||
label: "лимит устройств меньше единицы",
|
||||
body: func() map[string]any {
|
||||
body := peerPayload("client-01")
|
||||
body["maxDevices"] = 0
|
||||
return body
|
||||
},
|
||||
field: "maxDevices",
|
||||
code: constant.ErrCodeMin,
|
||||
},
|
||||
{
|
||||
label: "disabled вне множества значений",
|
||||
body: func() map[string]any {
|
||||
body := peerPayload("client-01")
|
||||
body["disabled"] = 7
|
||||
return body
|
||||
},
|
||||
field: "disabled",
|
||||
code: constant.ErrCodeOneOf,
|
||||
},
|
||||
{
|
||||
label: "квота меньше минимума",
|
||||
body: func() map[string]any {
|
||||
body := peerPayload("client-01")
|
||||
body["quotaBytes"] = -2
|
||||
return body
|
||||
},
|
||||
field: "quotaBytes",
|
||||
code: constant.ErrCodeMin,
|
||||
},
|
||||
{
|
||||
label: "комментарий длиннее допустимого",
|
||||
body: func() map[string]any {
|
||||
body := peerPayload("client-01")
|
||||
body["remark"] = strings.Repeat("я", 65)
|
||||
return body
|
||||
},
|
||||
field: "remark",
|
||||
code: constant.ErrCodeMaxLength,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.label, func(t *testing.T) {
|
||||
newPeerControllerDB(t)
|
||||
|
||||
result := createPeer(t, tc.body())
|
||||
if result.Type != "no" {
|
||||
t.Fatalf("некорректный ввод принят: %+v", result)
|
||||
}
|
||||
if result.Code != constant.CodeInvalidError {
|
||||
t.Fatalf("код ответа %d, ожидался %d", result.Code, constant.CodeInvalidError)
|
||||
}
|
||||
code, ok := errorFor(t, result, tc.field)
|
||||
if !ok {
|
||||
t.Fatalf("отказ не назвал поле %q: %+v", tc.field, result.Errors)
|
||||
}
|
||||
if code != tc.code {
|
||||
t.Fatalf("код отказа %q, ожидался %q", code, tc.code)
|
||||
}
|
||||
// Сообщение остаётся человекочитаемым для клиента без панели, но
|
||||
// панель им не пользуется: у неё есть код.
|
||||
if strings.TrimSpace(result.Message) == "" {
|
||||
t.Fatal("отказ без человекочитаемого сообщения")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Регрессия: слой контроллеров нёс собственную копию правила имени, в которой
|
||||
// неэкранированный дефис превращал `+-=` в диапазон и впускал `, - . / : ; <`.
|
||||
// Имя `peer/name` создавалось через панель и отклонялось импортом того же
|
||||
// пира, хотя имя уезжает во fragment клиентской ссылки и в секрет.
|
||||
func TestCreatePeerRejectsNamesOutsideTheCharset(t *testing.T) {
|
||||
for _, name := range []string{
|
||||
"peer/name",
|
||||
"peer:name",
|
||||
"peer;name",
|
||||
"peer,name",
|
||||
"peer.name",
|
||||
"peer<name",
|
||||
"peer name",
|
||||
"пир-01",
|
||||
} {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
newPeerControllerDB(t)
|
||||
|
||||
result := createPeer(t, peerPayload(name))
|
||||
if result.Type != "no" {
|
||||
t.Fatalf("имя %q принято", name)
|
||||
}
|
||||
if code, _ := errorFor(t, result, "name"); code != constant.ErrCodePeerName {
|
||||
t.Fatalf("код отказа %q, ожидался %q", code, constant.ErrCodePeerName)
|
||||
}
|
||||
// Обе двери в таблицу пиров обязаны требовать одного и того же.
|
||||
if service.IsValidPeerName(name) {
|
||||
t.Fatalf("импорт принимает имя %q, которое отклоняет панель", name)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreatePeerReportsTakenName(t *testing.T) {
|
||||
newPeerControllerDB(t)
|
||||
|
||||
if result := createPeer(t, peerPayload("client-01")); result.Type != "ok" {
|
||||
t.Fatalf("первое создание отклонено: %+v", result)
|
||||
}
|
||||
|
||||
result := createPeer(t, peerPayload("client-01"))
|
||||
if result.Type != "no" {
|
||||
t.Fatal("повторное имя принято")
|
||||
}
|
||||
if code, _ := errorFor(t, result, "name"); code != constant.ErrCodePeerNameTaken {
|
||||
t.Fatalf("код отказа %q, ожидался %q", code, constant.ErrCodePeerNameTaken)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreatePeerReportsReservedName(t *testing.T) {
|
||||
newPeerControllerDB(t)
|
||||
|
||||
result := createPeer(t, peerPayload(service.ReservedBootstrapPeerName))
|
||||
if result.Type != "no" {
|
||||
t.Fatal("зарезервированное имя принято")
|
||||
}
|
||||
if code, _ := errorFor(t, result, "name"); code != constant.ErrCodePeerNameReserved {
|
||||
t.Fatalf("код отказа %q, ожидался %q", code, constant.ErrCodePeerNameReserved)
|
||||
}
|
||||
}
|
||||
|
||||
// Тело, которое вообще не разобралось, — это не нарушение правила поля.
|
||||
// Панели важно различать: в первом случае подсвечивать нечего.
|
||||
func TestCreatePeerReportsUnparsableBody(t *testing.T) {
|
||||
newPeerControllerDB(t)
|
||||
|
||||
gin.SetMode(gin.TestMode)
|
||||
engine := gin.New()
|
||||
engine.POST("/peers", SavePeer)
|
||||
|
||||
request := httptest.NewRequest(http.MethodPost, "/peers", strings.NewReader("{не json"))
|
||||
request.Header.Set("Content-Type", "application/json")
|
||||
recorder := httptest.NewRecorder()
|
||||
engine.ServeHTTP(recorder, request)
|
||||
|
||||
var result apiResult
|
||||
if err := json.Unmarshal(recorder.Body.Bytes(), &result); err != nil {
|
||||
t.Fatalf("ответ не разбирается как JSON: %s", recorder.Body.String())
|
||||
}
|
||||
if result.Type != "no" {
|
||||
t.Fatal("неразбираемое тело принято")
|
||||
}
|
||||
if len(result.Errors) != 1 || result.Errors[0].Code != constant.ErrCodeBodyInvalid {
|
||||
t.Fatalf("неожиданное описание отказа: %+v", result.Errors)
|
||||
}
|
||||
if result.Errors[0].Field != "" {
|
||||
t.Fatalf("отказ разбора привязан к полю %q", result.Errors[0].Field)
|
||||
}
|
||||
}
|
||||
|
||||
// patchPeer выполняет PATCH /peers/:id так же, как это делает панель.
|
||||
func patchPeer(t *testing.T, id int64, body map[string]any) apiResult {
|
||||
t.Helper()
|
||||
gin.SetMode(gin.TestMode)
|
||||
|
||||
payload, err := json.Marshal(body)
|
||||
if err != nil {
|
||||
t.Fatalf("не удалось собрать тело запроса: %v", err)
|
||||
}
|
||||
|
||||
engine := gin.New()
|
||||
engine.PATCH("/peers/:id", UpdatePeer)
|
||||
|
||||
target := "/peers/" + strconv.FormatInt(id, 10)
|
||||
request := httptest.NewRequest(http.MethodPatch, target, strings.NewReader(string(payload)))
|
||||
request.Header.Set("Content-Type", "application/json")
|
||||
recorder := httptest.NewRecorder()
|
||||
engine.ServeHTTP(recorder, request)
|
||||
|
||||
var result apiResult
|
||||
if err := json.Unmarshal(recorder.Body.Bytes(), &result); err != nil {
|
||||
t.Fatalf("ответ не разбирается как JSON: %s", recorder.Body.String())
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// При изменении пустой секрет означает «не менять», и это то же самое
|
||||
// состояние, что и отсутствие поля. Панель отправляет `secret: ""` всякий раз,
|
||||
// когда оператор открыл форму и не трогал поле секрета.
|
||||
func TestUpdatePeerKeepsSecretWhenFieldIsBlank(t *testing.T) {
|
||||
newPeerControllerDB(t)
|
||||
|
||||
if result := createPeer(t, peerPayload("client-01")); result.Type != "ok" {
|
||||
t.Fatalf("создание пира отклонено: %+v", result)
|
||||
}
|
||||
before := storedPeer(t, "client-01")
|
||||
|
||||
for _, blank := range []string{"", " "} {
|
||||
result := patchPeer(t, *before.Id, map[string]any{
|
||||
"name": "client-01",
|
||||
"secret": blank,
|
||||
"remark": "рабочее устройство",
|
||||
})
|
||||
if result.Type != "ok" {
|
||||
t.Fatalf("изменение с пустым секретом %q отклонено: %+v", blank, result)
|
||||
}
|
||||
|
||||
after := storedPeer(t, "client-01")
|
||||
if *after.SecretDigest != *before.SecretDigest {
|
||||
t.Fatal("секрет пира изменился, хотя поле оставили пустым")
|
||||
}
|
||||
if after.Remark == nil || *after.Remark != "рабочее устройство" {
|
||||
t.Fatal("остальные поля формы не применились")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Пустой комментарий обязан ОЧИЩАТЬ комментарий, а не означать «не менять»:
|
||||
// иначе оператор не может убрать однажды сделанную пометку. Это граница, по
|
||||
// которой нормализация проходит для каждого поля отдельно.
|
||||
func TestUpdatePeerClearsRemarkWhenFieldIsBlank(t *testing.T) {
|
||||
newPeerControllerDB(t)
|
||||
|
||||
body := peerPayload("client-01")
|
||||
body["remark"] = "временная пометка"
|
||||
if result := createPeer(t, body); result.Type != "ok" {
|
||||
t.Fatalf("создание пира отклонено: %+v", result)
|
||||
}
|
||||
peer := storedPeer(t, "client-01")
|
||||
|
||||
if result := patchPeer(t, *peer.Id, map[string]any{"remark": ""}); result.Type != "ok" {
|
||||
t.Fatalf("очистка комментария отклонена: %+v", result)
|
||||
}
|
||||
|
||||
after := storedPeer(t, "client-01")
|
||||
if after.Remark != nil && *after.Remark != "" {
|
||||
t.Fatalf("комментарий не очищен: %q", *after.Remark)
|
||||
}
|
||||
}
|
||||
|
||||
// Регрессия, найденная вместе с UX-02 и в отчёте не значившаяся: `el-input`
|
||||
// с крестиком очистки ставит пустую строку, axios сериализует её как `?name=`,
|
||||
// и та же ловушка `omitempty` на указателе отказывала поиску пиров с
|
||||
// «invalid» — то есть список пиров ломался в один клик по крестику.
|
||||
func TestPagePeerAcceptsClearedFilters(t *testing.T) {
|
||||
newPeerControllerDB(t)
|
||||
|
||||
gin.SetMode(gin.TestMode)
|
||||
engine := gin.New()
|
||||
engine.GET("/peers", PagePeer)
|
||||
|
||||
query := url.Values{}
|
||||
query.Set("pageNum", "1")
|
||||
query.Set("pageSize", "10")
|
||||
query.Set("name", "")
|
||||
query.Set("remark", "")
|
||||
|
||||
request := httptest.NewRequest(http.MethodGet, "/peers?"+query.Encode(), nil)
|
||||
recorder := httptest.NewRecorder()
|
||||
engine.ServeHTTP(recorder, request)
|
||||
|
||||
var result apiResult
|
||||
if err := json.Unmarshal(recorder.Body.Bytes(), &result); err != nil {
|
||||
t.Fatalf("ответ не разбирается как JSON: %s", recorder.Body.String())
|
||||
}
|
||||
if result.Type != "ok" {
|
||||
t.Fatalf("очищенный фильтр отклонён: code=%d message=%q errors=%+v",
|
||||
result.Code, result.Message, result.Errors)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user