本地 Mock 服务 /mock + 限流 20QPS/60QPM + 服务条款
- Go Mock 引擎(与前端同语法 @cname/@integer/... 每次请求随机生成,JSON 叶子保持类型) - mock_endpoints 表 + 发布 API(PUT /api/workspaces/:id/mock,全量替换,单空间 200 个) - 路由 /mock/:wsid/*path:状态码/延迟(≤5s)/?_method 覆盖;未发布 404 - 限流(IP+空间 滑动窗口):20 QPS / 60 QPM → 429 + Retry-After + 条款提示 - 官网 /terms 服务条款(七节:含禁止滥用——未经授权或滥用有权采取措施、技术手段规避限流属违规) - UI:HTTP 编辑器「Mock 链接」弹窗(发布/状态码/延迟/模板预览/复制/试一试); 发布自动烘焙智能 Mock 表达式;集合端点去重 + INSERT OR REPLACE - docs:14-mock 增本地 Mock 服务与限流章节;页脚/429 响应均指向 /terms - 单测:mocksrv 引擎/限流/路径 5 用例;curl E2E(随机×2、201+延迟、404、连发 30 次触发 429);浏览器发布+试一试通过
This commit is contained in:
1 parent
522e5d6300
commit
d9ccccd280
17 files changed
+1070
-6
No files matched your search
@@ -0,0 +1,137 @@
|
||||
package mocksrv
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"regexp"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestGenerateFormats(t *testing.T) {
|
||||
cases := []struct {
|
||||
expr string
|
||||
re string
|
||||
}{
|
||||
{"@integer(3, 7), extra", ""},
|
||||
}
|
||||
_ = cases
|
||||
checks := map[string]*regexp.Regexp{
|
||||
"@integer(3, 7)": regexp.MustCompile(`^[3-7]$`),
|
||||
"@float(1, 9, 2)": regexp.MustCompile(`^\d\.\d{2}$`),
|
||||
"@email": regexp.MustCompile(`^[\w.]+@[\w.]+\.\w+$`),
|
||||
"@phone": regexp.MustCompile(`^1[3-9]\d{9}$`),
|
||||
"@guid": regexp.MustCompile(`^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$`),
|
||||
"@id": regexp.MustCompile(`^\d{17}[\dX]$`),
|
||||
"@datetime(YYYY-MM-DD HH:mm:ss)": regexp.MustCompile(`^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$`),
|
||||
"@timestamp": regexp.MustCompile(`^\d{12,}$`),
|
||||
"@boolean": regexp.MustCompile(`^(true|false)$`),
|
||||
"@cname": regexp.MustCompile(`^\p{Han}{2,3}$`),
|
||||
"@county(true)": regexp.MustCompile(`\p{Han}+(市)?\p{Han}+区$`),
|
||||
"@pick(\"男\", \"女\")": regexp.MustCompile(`^(男|女)$`),
|
||||
"@notExistThing": regexp.MustCompile(`^@notExistThing$`), // 未知表达式原样
|
||||
"普通文本": regexp.MustCompile(`^普通文本$`),
|
||||
}
|
||||
for expr, re := range checks {
|
||||
for i := 0; i < 30; i++ {
|
||||
out := Generate(expr)
|
||||
if !re.MatchString(out) {
|
||||
t.Fatalf("%s → %q 不匹配 %v", expr, out, re)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerateJSONLeaves(t *testing.T) {
|
||||
tpl := `{"username":"@cname","age":"@integer(18,65)","vip":"@boolean","keep":"固定值"}`
|
||||
var v map[string]interface{}
|
||||
if err := json.Unmarshal([]byte(GenerateJSON(tpl)), &v); err != nil {
|
||||
t.Fatalf("生成结果非 JSON: %v", err)
|
||||
}
|
||||
age, ok := v["age"].(float64)
|
||||
if !ok || age < 18 || age > 65 {
|
||||
t.Fatalf("age 应为 18-65 的数字,得到 %v", v["age"])
|
||||
}
|
||||
if _, ok := v["vip"].(bool); !ok {
|
||||
t.Fatalf("vip 应为布尔,得到 %T", v["vip"])
|
||||
}
|
||||
if v["keep"] != "固定值" {
|
||||
t.Fatalf("非表达式叶子应原样保留")
|
||||
}
|
||||
if s, _ := v["username"].(string); len(s) < 2 {
|
||||
t.Fatalf("username 生成异常: %v", v["username"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerateJSONTopLevelAndInvalid(t *testing.T) {
|
||||
if out := GenerateJSON("@cname"); len([]rune(out)) < 2 {
|
||||
t.Fatalf("顶层表达式应生成: %q", out)
|
||||
}
|
||||
if out := GenerateJSON("not-json"); out != "not-json" {
|
||||
t.Fatalf("非法 JSON 原样返回: %q", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLimiter(t *testing.T) {
|
||||
l := newLimiter()
|
||||
now := time.Now()
|
||||
// 同一秒内第 21 次拒绝(QPS)
|
||||
pass := 0
|
||||
for i := 0; i < 25; i++ {
|
||||
if l.allow("k", now) {
|
||||
pass++
|
||||
}
|
||||
}
|
||||
if pass != LimitQPS {
|
||||
t.Fatalf("QPS 应放行 %d,实际 %d", LimitQPS, pass)
|
||||
}
|
||||
|
||||
// 后续秒窗重置但 60 秒窗累计:S2 放 20、S3 放 20(累计 60),S4 拒绝(QPM)
|
||||
for _, off := range []time.Duration{2 * time.Second, 3 * time.Second} {
|
||||
pass2 := 0
|
||||
for i := 0; i < 25; i++ {
|
||||
if l.allow("k", now.Add(off)) {
|
||||
pass2++
|
||||
}
|
||||
}
|
||||
if pass2 != 20 {
|
||||
t.Fatalf("+%v 应放行 20(QPS),实际 %d", off, pass2)
|
||||
}
|
||||
}
|
||||
if l.allow("k", now.Add(4*time.Second)) {
|
||||
t.Fatalf("60 秒窗口内累计达 60 应拒绝")
|
||||
}
|
||||
|
||||
// 不同 key 相互独立
|
||||
if !l.allow("other", now.Add(3*time.Second)) {
|
||||
t.Fatal("不同 key 不应被牵连")
|
||||
}
|
||||
|
||||
// 滑动窗口过期后恢复
|
||||
l2 := newLimiter()
|
||||
t0 := time.Now()
|
||||
for i := 0; i < LimitQPM; i++ {
|
||||
if !l2.allow("k2", t0.Add(time.Duration(i)*time.Second)) {
|
||||
t.Fatalf("分散 60 秒内应全部放行(第 %d 次被拒)", i+1)
|
||||
}
|
||||
}
|
||||
if l2.allow("k2", t0.Add(59*time.Second)) {
|
||||
t.Fatal("60 次命中仍在窗口内应被拒")
|
||||
}
|
||||
if !l2.allow("k2", t0.Add(130*time.Second)) {
|
||||
t.Fatal("窗口滑过后应恢复放行")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizePath(t *testing.T) {
|
||||
if normalizePath("api/users/") != "/api/users" {
|
||||
t.Fatalf("got %q", normalizePath("api/users/"))
|
||||
}
|
||||
if normalizePath("") != "/" {
|
||||
t.Fatalf("空路径 → /")
|
||||
}
|
||||
if normalizePath("/x/") != "/x" {
|
||||
t.Fatalf("去尾斜杠")
|
||||
}
|
||||
_ = strings.TrimSpace
|
||||
}
|
||||
Reference in new issue
Block a user