feat(routing): GET/POST /v1/me/routing 端点 + openapi
This commit is contained in:
@@ -217,6 +217,83 @@ components:
|
|||||||
minutes_used:
|
minutes_used:
|
||||||
type: integer
|
type: integer
|
||||||
|
|
||||||
|
RoutingRule:
|
||||||
|
type: object
|
||||||
|
required: [type, value, action, enabled]
|
||||||
|
properties:
|
||||||
|
type:
|
||||||
|
type: string
|
||||||
|
enum: [domain, domain_suffix, domain_keyword, ip_cidr, geoip, geosite]
|
||||||
|
value:
|
||||||
|
type: string
|
||||||
|
description: geoip/geosite 目前仅白名单 cn(自托管规则集);ip_cidr 需合法 CIDR
|
||||||
|
example: x.com
|
||||||
|
action:
|
||||||
|
type: string
|
||||||
|
enum: [direct, proxy, reject]
|
||||||
|
note:
|
||||||
|
type: string
|
||||||
|
enabled:
|
||||||
|
type: boolean
|
||||||
|
|
||||||
|
RoutingBuiltin:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
china_direct:
|
||||||
|
type: boolean
|
||||||
|
description: 国内域名/IP 直连
|
||||||
|
lan_direct:
|
||||||
|
type: boolean
|
||||||
|
description: 局域网直连
|
||||||
|
private_via_tunnel:
|
||||||
|
type: boolean
|
||||||
|
description: 私有服务域名强制走隧道(家庭内网穿透场景)
|
||||||
|
|
||||||
|
RoutingProfile:
|
||||||
|
type: object
|
||||||
|
required: [mode, builtin, rules, final]
|
||||||
|
properties:
|
||||||
|
mode:
|
||||||
|
type: string
|
||||||
|
enum: [rule, global, direct]
|
||||||
|
builtin:
|
||||||
|
$ref: '#/components/schemas/RoutingBuiltin'
|
||||||
|
rules:
|
||||||
|
type: array
|
||||||
|
maxItems: 200
|
||||||
|
items:
|
||||||
|
$ref: '#/components/schemas/RoutingRule'
|
||||||
|
final:
|
||||||
|
type: string
|
||||||
|
enum: [proxy, direct]
|
||||||
|
|
||||||
|
RoutingValidationError:
|
||||||
|
type: object
|
||||||
|
required: [code, message_zh, message_en, errors]
|
||||||
|
properties:
|
||||||
|
code:
|
||||||
|
type: string
|
||||||
|
example: routing_invalid
|
||||||
|
message_zh:
|
||||||
|
type: string
|
||||||
|
example: 规则校验未通过
|
||||||
|
message_en:
|
||||||
|
type: string
|
||||||
|
example: Rule validation failed
|
||||||
|
errors:
|
||||||
|
type: array
|
||||||
|
items:
|
||||||
|
type: object
|
||||||
|
required: [index, field, reason]
|
||||||
|
properties:
|
||||||
|
index:
|
||||||
|
type: integer
|
||||||
|
description: -1 表示档案级字段(mode/final/rules 数量),否则为 rules 下标
|
||||||
|
field:
|
||||||
|
type: string
|
||||||
|
reason:
|
||||||
|
type: string
|
||||||
|
|
||||||
paths:
|
paths:
|
||||||
# ── Auth ──────────────────────────────────────────────────────────────────
|
# ── Auth ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -418,6 +495,50 @@ paths:
|
|||||||
schema:
|
schema:
|
||||||
$ref: '#/components/schemas/Error'
|
$ref: '#/components/schemas/Error'
|
||||||
|
|
||||||
|
/me/routing:
|
||||||
|
get:
|
||||||
|
summary: 获取当前用户的分流配置(可配置分流;未自定义时返回默认档案)
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: 分流档案
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/RoutingProfile'
|
||||||
|
'401':
|
||||||
|
description: 未认证
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/Error'
|
||||||
|
post:
|
||||||
|
summary: 保存当前用户的分流配置(整份覆盖;校验失败不部分保存)
|
||||||
|
requestBody:
|
||||||
|
required: true
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/RoutingProfile'
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: 保存成功,返回规整化后的档案
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/RoutingProfile'
|
||||||
|
'400':
|
||||||
|
description: 校验失败(逐条错误,不部分保存)
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/RoutingValidationError'
|
||||||
|
'401':
|
||||||
|
description: 未认证
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/Error'
|
||||||
|
|
||||||
# ── Plans & Subscriptions ─────────────────────────────────────────────────
|
# ── Plans & Subscriptions ─────────────────────────────────────────────────
|
||||||
|
|
||||||
/plans:
|
/plans:
|
||||||
|
|||||||
@@ -249,6 +249,55 @@ paths:
|
|||||||
"500":
|
"500":
|
||||||
$ref: "#/components/responses/Internal"
|
$ref: "#/components/responses/Internal"
|
||||||
|
|
||||||
|
/me/routing:
|
||||||
|
get:
|
||||||
|
operationId: getRoutingProfile
|
||||||
|
summary: 获取当前用户的分流配置
|
||||||
|
description: |
|
||||||
|
可配置分流(#5 之上的用户自定义规则)。用户未自定义时返回默认档案
|
||||||
|
(等价于原硬编码行为:智能分流 + 国内直连 + 无自定义规则)。
|
||||||
|
tags: [Account]
|
||||||
|
responses:
|
||||||
|
"200":
|
||||||
|
description: 分流档案
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: "#/components/schemas/RoutingProfile"
|
||||||
|
"401":
|
||||||
|
$ref: "#/components/responses/Unauthorized"
|
||||||
|
"500":
|
||||||
|
$ref: "#/components/responses/Internal"
|
||||||
|
post:
|
||||||
|
operationId: saveRoutingProfile
|
||||||
|
summary: 保存当前用户的分流配置
|
||||||
|
description: |
|
||||||
|
整份覆盖式保存(非增量 patch)。校验失败时不部分保存,一次性返回全部违规字段。
|
||||||
|
tags: [Account]
|
||||||
|
requestBody:
|
||||||
|
required: true
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: "#/components/schemas/RoutingProfile"
|
||||||
|
responses:
|
||||||
|
"200":
|
||||||
|
description: 保存成功,返回规整化(去重/裁剪)后的档案
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: "#/components/schemas/RoutingProfile"
|
||||||
|
"400":
|
||||||
|
description: 校验失败(逐条错误,不部分保存)
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: "#/components/schemas/RoutingValidationError"
|
||||||
|
"401":
|
||||||
|
$ref: "#/components/responses/Unauthorized"
|
||||||
|
"500":
|
||||||
|
$ref: "#/components/responses/Internal"
|
||||||
|
|
||||||
# ── 商业闭环 ─────────────────────────────────────────────
|
# ── 商业闭环 ─────────────────────────────────────────────
|
||||||
|
|
||||||
/redeem:
|
/redeem:
|
||||||
@@ -1131,6 +1180,85 @@ components:
|
|||||||
format: date-time
|
format: date-time
|
||||||
description: 最后活跃时间(UTC ISO-8601)
|
description: 最后活跃时间(UTC ISO-8601)
|
||||||
|
|
||||||
|
# ── 可配置分流 ───────────────────────────────────────────
|
||||||
|
|
||||||
|
RoutingRule:
|
||||||
|
type: object
|
||||||
|
required: [type, value, action, enabled]
|
||||||
|
properties:
|
||||||
|
type:
|
||||||
|
type: string
|
||||||
|
enum: [domain, domain_suffix, domain_keyword, ip_cidr, geoip, geosite]
|
||||||
|
value:
|
||||||
|
type: string
|
||||||
|
description: geoip/geosite 目前仅白名单 cn(自托管规则集);ip_cidr 需合法 CIDR
|
||||||
|
example: x.com
|
||||||
|
action:
|
||||||
|
type: string
|
||||||
|
enum: [direct, proxy, reject]
|
||||||
|
note:
|
||||||
|
type: string
|
||||||
|
enabled:
|
||||||
|
type: boolean
|
||||||
|
|
||||||
|
RoutingBuiltin:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
china_direct:
|
||||||
|
type: boolean
|
||||||
|
description: 国内域名/IP 直连
|
||||||
|
lan_direct:
|
||||||
|
type: boolean
|
||||||
|
description: 局域网直连
|
||||||
|
private_via_tunnel:
|
||||||
|
type: boolean
|
||||||
|
description: 私有服务域名强制走隧道(家庭内网穿透场景)
|
||||||
|
|
||||||
|
RoutingProfile:
|
||||||
|
type: object
|
||||||
|
required: [mode, builtin, rules, final]
|
||||||
|
properties:
|
||||||
|
mode:
|
||||||
|
type: string
|
||||||
|
enum: [rule, global, direct]
|
||||||
|
builtin:
|
||||||
|
$ref: "#/components/schemas/RoutingBuiltin"
|
||||||
|
rules:
|
||||||
|
type: array
|
||||||
|
maxItems: 200
|
||||||
|
items:
|
||||||
|
$ref: "#/components/schemas/RoutingRule"
|
||||||
|
final:
|
||||||
|
type: string
|
||||||
|
enum: [proxy, direct]
|
||||||
|
|
||||||
|
RoutingValidationError:
|
||||||
|
type: object
|
||||||
|
required: [code, message_zh, message_en, errors]
|
||||||
|
properties:
|
||||||
|
code:
|
||||||
|
type: string
|
||||||
|
example: routing_invalid
|
||||||
|
message_zh:
|
||||||
|
type: string
|
||||||
|
example: 规则校验未通过
|
||||||
|
message_en:
|
||||||
|
type: string
|
||||||
|
example: Rule validation failed
|
||||||
|
errors:
|
||||||
|
type: array
|
||||||
|
items:
|
||||||
|
type: object
|
||||||
|
required: [index, field, reason]
|
||||||
|
properties:
|
||||||
|
index:
|
||||||
|
type: integer
|
||||||
|
description: -1 表示档案级字段(mode/final/rules 数量),否则为 rules 下标
|
||||||
|
field:
|
||||||
|
type: string
|
||||||
|
reason:
|
||||||
|
type: string
|
||||||
|
|
||||||
# ── 商业 ───────────────────────────────────────────────
|
# ── 商业 ───────────────────────────────────────────────
|
||||||
|
|
||||||
Plan:
|
Plan:
|
||||||
|
|||||||
@@ -40,6 +40,7 @@ import (
|
|||||||
"github.com/wangjia/pangolin/server/internal/provision/providers"
|
"github.com/wangjia/pangolin/server/internal/provision/providers"
|
||||||
"github.com/wangjia/pangolin/server/internal/redisutil"
|
"github.com/wangjia/pangolin/server/internal/redisutil"
|
||||||
"github.com/wangjia/pangolin/server/internal/reward"
|
"github.com/wangjia/pangolin/server/internal/reward"
|
||||||
|
"github.com/wangjia/pangolin/server/internal/routing"
|
||||||
"github.com/wangjia/pangolin/server/internal/scheduler"
|
"github.com/wangjia/pangolin/server/internal/scheduler"
|
||||||
"github.com/wangjia/pangolin/server/internal/scheduler/probe"
|
"github.com/wangjia/pangolin/server/internal/scheduler/probe"
|
||||||
"github.com/wangjia/pangolin/server/internal/sessions"
|
"github.com/wangjia/pangolin/server/internal/sessions"
|
||||||
@@ -406,6 +407,9 @@ func mountV1(r chi.Router, sqlDB *sql.DB, rdb *redis.Client, nodeSvc *nodes.Serv
|
|||||||
// ── Account / Plans / Notices ─────────────────────────────────────────────
|
// ── Account / Plans / Notices ─────────────────────────────────────────────
|
||||||
accountAPI := httpapi.NewAccountAPI(sqlDB)
|
accountAPI := httpapi.NewAccountAPI(sqlDB)
|
||||||
|
|
||||||
|
// ── Routing profile (可配置分流) ───────────────────────────────────────────
|
||||||
|
routingAPI := httpapi.NewRoutingAPI(routing.NewStore(sqlDB))
|
||||||
|
|
||||||
// ── Nodes + Connect ───────────────────────────────────────────────────────
|
// ── Nodes + Connect ───────────────────────────────────────────────────────
|
||||||
var nodeAPI *httpapi.NodeAPI
|
var nodeAPI *httpapi.NodeAPI
|
||||||
var nodeStore nodes.NodeStore
|
var nodeStore nodes.NodeStore
|
||||||
@@ -486,6 +490,8 @@ func mountV1(r chi.Router, sqlDB *sql.DB, rdb *redis.Client, nodeSvc *nodes.Serv
|
|||||||
me.Post("/redeem", redeemHandler.ServeHTTP)
|
me.Post("/redeem", redeemHandler.ServeHTTP)
|
||||||
me.Get("/subscription", subAPI.GetSubscription)
|
me.Get("/subscription", subAPI.GetSubscription)
|
||||||
me.Post("/subscription/reset", subAPI.ResetSubscription)
|
me.Post("/subscription/reset", subAPI.ResetSubscription)
|
||||||
|
me.Get("/routing", routingAPI.GetProfile)
|
||||||
|
me.Post("/routing", routingAPI.SaveProfile)
|
||||||
if totpHandler != nil {
|
if totpHandler != nil {
|
||||||
me.Post("/totp/setup", totpHandler.Setup)
|
me.Post("/totp/setup", totpHandler.Setup)
|
||||||
me.Post("/totp/verify", totpHandler.Verify)
|
me.Post("/totp/verify", totpHandler.Verify)
|
||||||
|
|||||||
@@ -0,0 +1,70 @@
|
|||||||
|
package httpapi
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"github.com/wangjia/pangolin/server/internal/apierr"
|
||||||
|
"github.com/wangjia/pangolin/server/internal/auth"
|
||||||
|
"github.com/wangjia/pangolin/server/internal/routing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// RoutingAPI serves /v1/me/routing: the user's configurable routing profile
|
||||||
|
// (可配置分流). GET returns the stored profile or routing.Default() when the
|
||||||
|
// user hasn't customized one yet; POST validates and upserts.
|
||||||
|
type RoutingAPI struct {
|
||||||
|
store *routing.Store
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewRoutingAPI creates a RoutingAPI backed by the given routing.Store.
|
||||||
|
func NewRoutingAPI(store *routing.Store) *RoutingAPI { return &RoutingAPI{store: store} }
|
||||||
|
|
||||||
|
// GetProfile handles GET /v1/me/routing.
|
||||||
|
func (a *RoutingAPI) GetProfile(w http.ResponseWriter, r *http.Request) {
|
||||||
|
uid, ok := auth.UserIDFromContext(r.Context())
|
||||||
|
if !ok {
|
||||||
|
apierr.WriteJSON(w, http.StatusUnauthorized, apierr.ErrUnauthorized)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
p, err := a.store.Get(r.Context(), uid)
|
||||||
|
if err != nil {
|
||||||
|
apierr.WriteJSON(w, http.StatusInternalServerError, apierr.ErrInternal)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if p == nil {
|
||||||
|
p = routing.Default()
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, p)
|
||||||
|
}
|
||||||
|
|
||||||
|
// SaveProfile handles POST /v1/me/routing. On validation failure it returns
|
||||||
|
// 400 with every offending field reported at once (no partial save).
|
||||||
|
func (a *RoutingAPI) SaveProfile(w http.ResponseWriter, r *http.Request) {
|
||||||
|
uid, ok := auth.UserIDFromContext(r.Context())
|
||||||
|
if !ok {
|
||||||
|
apierr.WriteJSON(w, http.StatusUnauthorized, apierr.ErrUnauthorized)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var p routing.Profile
|
||||||
|
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 64*1024)).Decode(&p); err != nil {
|
||||||
|
apierr.WriteJSON(w, http.StatusBadRequest, apierr.ErrBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
p.Normalize()
|
||||||
|
if errs := p.Validate(); len(errs) > 0 {
|
||||||
|
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||||
|
w.WriteHeader(http.StatusBadRequest)
|
||||||
|
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||||
|
"code": "routing_invalid",
|
||||||
|
"message_zh": "规则校验未通过",
|
||||||
|
"message_en": "Rule validation failed",
|
||||||
|
"errors": errs,
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := a.store.Upsert(r.Context(), uid, &p); err != nil {
|
||||||
|
apierr.WriteJSON(w, http.StatusInternalServerError, apierr.ErrInternal)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, &p)
|
||||||
|
}
|
||||||
@@ -0,0 +1,130 @@
|
|||||||
|
package httpapi
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"database/sql"
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/wangjia/pangolin/server/internal/codes"
|
||||||
|
"github.com/wangjia/pangolin/server/internal/config"
|
||||||
|
"github.com/wangjia/pangolin/server/internal/routing"
|
||||||
|
"github.com/wangjia/pangolin/server/internal/store"
|
||||||
|
)
|
||||||
|
|
||||||
|
// openRoutingTestDB opens an in-memory SQLite DB with migrations applied,
|
||||||
|
// mirroring internal/routing/store_sqlite_test.go's helper (no shared helper
|
||||||
|
// exists in this package yet).
|
||||||
|
func openRoutingTestDB(t *testing.T) *sql.DB {
|
||||||
|
t.Helper()
|
||||||
|
db, err := store.Open(&config.Config{Driver: "sqlite", DSN: ":memory:"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
t.Cleanup(func() { _ = db.Close() })
|
||||||
|
if err := store.MigrateUp(db, "sqlite"); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := store.ApplyCodesLibMigrations(context.Background(), db, "sqlite"); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return db
|
||||||
|
}
|
||||||
|
|
||||||
|
func seedRoutingUser(t *testing.T, db *sql.DB, id int64) {
|
||||||
|
t.Helper()
|
||||||
|
uuid := "u-routing"
|
||||||
|
if _, err := db.Exec(`INSERT INTO users (id,uuid,email,pw_hash,dp_uuid,status,created_at)
|
||||||
|
VALUES (?,?,?, 'x','dp-'||?, 'active', ?)`, id, uuid, uuid+"@x", uuid, time.Now().UTC()); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// doAuthReq builds an httptest request with an authenticated context (numeric
|
||||||
|
// user id injected under codes.CtxKeyUserID, matching auth.RequireAuth) and
|
||||||
|
// invokes the handler directly (no router needed for a single route).
|
||||||
|
func doAuthReq(t *testing.T, method, target string, body *strings.Reader, uid int64, h http.HandlerFunc) *httptest.ResponseRecorder {
|
||||||
|
t.Helper()
|
||||||
|
var req *http.Request
|
||||||
|
if body == nil {
|
||||||
|
req = httptest.NewRequest(method, target, nil)
|
||||||
|
} else {
|
||||||
|
req = httptest.NewRequest(method, target, body)
|
||||||
|
}
|
||||||
|
ctx := context.WithValue(req.Context(), codes.CtxKeyUserID, uid)
|
||||||
|
req = req.WithContext(ctx)
|
||||||
|
rr := httptest.NewRecorder()
|
||||||
|
h(rr, req)
|
||||||
|
return rr
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRoutingGetDefaultThenSave(t *testing.T) {
|
||||||
|
db := openRoutingTestDB(t)
|
||||||
|
seedRoutingUser(t, db, 7)
|
||||||
|
api := NewRoutingAPI(routing.NewStore(db))
|
||||||
|
|
||||||
|
// GET 无档案 → 200 + Default
|
||||||
|
rr := doAuthReq(t, http.MethodGet, "/v1/me/routing", nil, 7, api.GetProfile)
|
||||||
|
if rr.Code != 200 {
|
||||||
|
t.Fatalf("GET code %d", rr.Code)
|
||||||
|
}
|
||||||
|
var p routing.Profile
|
||||||
|
if err := json.Unmarshal(rr.Body.Bytes(), &p); err != nil {
|
||||||
|
t.Fatalf("unmarshal: %v", err)
|
||||||
|
}
|
||||||
|
if p.Mode != "rule" {
|
||||||
|
t.Fatalf("default mode %s", p.Mode)
|
||||||
|
}
|
||||||
|
|
||||||
|
// POST 合法 → 200
|
||||||
|
body := `{"mode":"rule","builtin":{"china_direct":true,"lan_direct":true,"private_via_tunnel":true},"rules":[{"type":"domain_suffix","value":"x.com","action":"direct","enabled":true}],"final":"proxy"}`
|
||||||
|
rr = doAuthReq(t, http.MethodPost, "/v1/me/routing", strings.NewReader(body), 7, api.SaveProfile)
|
||||||
|
if rr.Code != 200 {
|
||||||
|
t.Fatalf("POST code %d body %s", rr.Code, rr.Body)
|
||||||
|
}
|
||||||
|
|
||||||
|
// GET 后应能取回刚保存的档案
|
||||||
|
rr = doAuthReq(t, http.MethodGet, "/v1/me/routing", nil, 7, api.GetProfile)
|
||||||
|
if rr.Code != 200 {
|
||||||
|
t.Fatalf("GET-after-save code %d", rr.Code)
|
||||||
|
}
|
||||||
|
var p2 routing.Profile
|
||||||
|
if err := json.Unmarshal(rr.Body.Bytes(), &p2); err != nil {
|
||||||
|
t.Fatalf("unmarshal2: %v", err)
|
||||||
|
}
|
||||||
|
if len(p2.Rules) != 1 || p2.Rules[0].Value != "x.com" {
|
||||||
|
t.Fatalf("saved profile not persisted: %+v", p2)
|
||||||
|
}
|
||||||
|
|
||||||
|
// POST 非法 → 400 + errors
|
||||||
|
rr = doAuthReq(t, http.MethodPost, "/v1/me/routing", strings.NewReader(`{"mode":"x","final":"y","rules":[]}`), 7, api.SaveProfile)
|
||||||
|
if rr.Code != 400 {
|
||||||
|
t.Fatalf("bad POST code %d", rr.Code)
|
||||||
|
}
|
||||||
|
var errBody map[string]any
|
||||||
|
if err := json.Unmarshal(rr.Body.Bytes(), &errBody); err != nil {
|
||||||
|
t.Fatalf("unmarshal err body: %v", err)
|
||||||
|
}
|
||||||
|
if errBody["code"] != "routing_invalid" {
|
||||||
|
t.Fatalf("bad POST body code = %v", errBody["code"])
|
||||||
|
}
|
||||||
|
errs, ok := errBody["errors"].([]any)
|
||||||
|
if !ok || len(errs) == 0 {
|
||||||
|
t.Fatalf("expected non-empty errors, got %v", errBody["errors"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRoutingGetUnauthorized(t *testing.T) {
|
||||||
|
db := openRoutingTestDB(t)
|
||||||
|
api := NewRoutingAPI(routing.NewStore(db))
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/v1/me/routing", nil)
|
||||||
|
rr := httptest.NewRecorder()
|
||||||
|
api.GetProfile(rr, req)
|
||||||
|
if rr.Code != http.StatusUnauthorized {
|
||||||
|
t.Fatalf("expected 401, got %d", rr.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user