262 lines
9.8 KiB
Go
262 lines
9.8 KiB
Go
package main
|
||
|
||
import (
|
||
"database/sql"
|
||
"encoding/json"
|
||
"fmt"
|
||
"net/http"
|
||
"net/http/httptest"
|
||
"strings"
|
||
"testing"
|
||
"time"
|
||
|
||
"cmautobuy/admin/model"
|
||
"cmautobuy/admin/service"
|
||
)
|
||
|
||
const clientProfileJSON = `{
|
||
"client":{"name":"路由回归客户端"},
|
||
"supported_types":["collect","purchase"],
|
||
"device":{"address":"192.168.0.173:5555","platform":"android","pdd_package":"com.xunmeng.pinduoduo"},
|
||
"capabilities":{"purchase_mode":"dry_run","schema_versions":[1]}
|
||
}`
|
||
|
||
const apiResultJSON = `{
|
||
"task_version":1,"attempt_id":"http-result-1","result_type":"purchase",
|
||
"completed_at":"2026-08-09T06:00:00Z",
|
||
"pdd_data":{"schema_version":1,"goods":{"goods_id":"1"}}
|
||
}`
|
||
|
||
const apiFailureJSON = `{
|
||
"task_version":1,"attempt_id":"http-failure-1","status":"failed",
|
||
"error":{"code":"PDD_PAGE_TIMEOUT","message":"页面超时","retryable":true}
|
||
}`
|
||
|
||
func TestClient四接口不受Web认证状态影响(t *testing.T) {
|
||
states := []struct {
|
||
name string
|
||
setupUsers bool
|
||
loggedIn bool
|
||
}{
|
||
{"Admin未初始化", false, false},
|
||
{"已有管理员但Web未登录", true, false},
|
||
{"管理员Web已登录", true, true},
|
||
}
|
||
for _, state := range states {
|
||
t.Run(state.name, func(t *testing.T) {
|
||
db := newAPIRouteTestDB(t)
|
||
var webToken string
|
||
if state.setupUsers {
|
||
if err := service.SetupInitialAdmin(db, "admin", "admin-password", "admin-password", time.Now()); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if state.loggedIn {
|
||
var err error
|
||
webToken, _, _, err = service.Login(db, "admin", "admin-password", time.Now())
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
}
|
||
}
|
||
insertAPIRouteTask(t, db, "TASK-RESULT", "client-route", time.Now())
|
||
insertAPIRouteTask(t, db, "TASK-FAILURE", "client-route", time.Now().Add(time.Second))
|
||
router, err := newRouter(db)
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
|
||
registration := callClientAPI(router, http.MethodPut, "/api/v1/client/registration",
|
||
clientProfileJSON, "client-route", "", webToken)
|
||
assertClientAPIResponse(t, registration, http.StatusOK)
|
||
assertJSONField(t, registration.Body.Bytes(), "registered", true)
|
||
|
||
firstClaim := callClientAPI(router, http.MethodPost, "/api/v1/client/tasks/claim",
|
||
clientProfileJSON, "client-route", "", webToken)
|
||
assertClientAPIResponse(t, firstClaim, http.StatusOK)
|
||
firstTaskID := claimedTaskID(t, firstClaim.Body.Bytes())
|
||
|
||
secondClaim := callClientAPI(router, http.MethodPost, "/api/v1/client/tasks/claim",
|
||
clientProfileJSON, "client-route", "", webToken)
|
||
assertClientAPIResponse(t, secondClaim, http.StatusOK)
|
||
secondTaskID := claimedTaskID(t, secondClaim.Body.Bytes())
|
||
if firstTaskID == secondTaskID {
|
||
t.Fatalf("两次领取返回同一任务 %s", firstTaskID)
|
||
}
|
||
|
||
result := callClientAPI(router, http.MethodPost,
|
||
"/api/v1/client/tasks/"+firstTaskID+"/result", apiResultJSON,
|
||
"client-route", "idem-result", webToken)
|
||
assertClientAPIResponse(t, result, http.StatusOK)
|
||
assertJSONField(t, result.Body.Bytes(), "accepted", true)
|
||
|
||
failure := callClientAPI(router, http.MethodPost,
|
||
"/api/v1/client/tasks/"+secondTaskID+"/failure", apiFailureJSON,
|
||
"client-route", "idem-failure", webToken)
|
||
assertClientAPIResponse(t, failure, http.StatusOK)
|
||
assertJSONField(t, failure.Body.Bytes(), "accepted", true)
|
||
|
||
emptyClaim := callClientAPI(router, http.MethodPost, "/api/v1/client/tasks/claim",
|
||
clientProfileJSON, "client-route", "", webToken)
|
||
assertClientAPIResponse(t, emptyClaim, http.StatusNoContent)
|
||
if emptyClaim.Body.Len() != 0 {
|
||
t.Fatalf("204 响应体应为空,实际 %q", emptyClaim.Body.String())
|
||
}
|
||
})
|
||
}
|
||
}
|
||
|
||
func TestClient提交HTTP契约_取消重派与幂等不受网页登录影响(t *testing.T) {
|
||
db := newAPIRouteTestDB(t)
|
||
insertAPIRouteTask(t, db, "TASK-CANCELLED", "client-original", time.Now())
|
||
insertAPIRouteTask(t, db, "TASK-REASSIGNED", "client-original", time.Now().Add(time.Second))
|
||
insertAPIRouteTask(t, db, "TASK-NEVER-CLAIMED", "client-other", time.Now().Add(2*time.Second))
|
||
router, err := newRouter(db)
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
|
||
first := callClientAPI(router, http.MethodPost, "/api/v1/client/tasks/claim",
|
||
clientProfileJSON, "client-original", "", "")
|
||
assertClientAPIResponse(t, first, http.StatusOK)
|
||
firstID := claimedTaskID(t, first.Body.Bytes())
|
||
second := callClientAPI(router, http.MethodPost, "/api/v1/client/tasks/claim",
|
||
clientProfileJSON, "client-original", "", "")
|
||
assertClientAPIResponse(t, second, http.StatusOK)
|
||
secondID := claimedTaskID(t, second.Body.Bytes())
|
||
|
||
if _, err := db.Exec(`UPDATE tasks SET status = 'cancelled' WHERE task_id = ?`, firstID); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
cancelled := callClientAPI(router, http.MethodPost,
|
||
"/api/v1/client/tasks/"+firstID+"/result", apiResultJSON,
|
||
"client-original", "cancelled-result-key", "")
|
||
assertClientAPIResponse(t, cancelled, http.StatusOK)
|
||
assertJSONField(t, cancelled.Body.Bytes(), "accepted", true)
|
||
|
||
// 相同键和内容必须返回完全相同的业务响应;相同键不同内容返回稳定 JSON 409。
|
||
repeated := callClientAPI(router, http.MethodPost,
|
||
"/api/v1/client/tasks/"+firstID+"/result", apiResultJSON,
|
||
"client-original", "cancelled-result-key", "")
|
||
assertClientAPIResponse(t, repeated, http.StatusOK)
|
||
if repeated.Body.String() != cancelled.Body.String() {
|
||
t.Fatalf("幂等重试响应不一致:\n首次 %s\n重试 %s", cancelled.Body.String(), repeated.Body.String())
|
||
}
|
||
conflict := callClientAPI(router, http.MethodPost,
|
||
"/api/v1/client/tasks/"+firstID+"/result", strings.Replace(apiResultJSON, "http-result-1", "http-result-2", 1),
|
||
"client-original", "cancelled-result-key", "")
|
||
assertClientAPIResponse(t, conflict, http.StatusConflict)
|
||
assertAPIErrorCode(t, conflict.Body.Bytes(), "IDEMPOTENCY_CONFLICT")
|
||
|
||
if _, err := db.Exec(`UPDATE tasks SET assigned_client = 'client-new', status = 'assigned' WHERE task_id = ?`, secondID); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
reassigned := callClientAPI(router, http.MethodPost,
|
||
"/api/v1/client/tasks/"+secondID+"/failure", apiFailureJSON,
|
||
"client-original", "reassigned-failure-key", "")
|
||
assertClientAPIResponse(t, reassigned, http.StatusOK)
|
||
assertJSONField(t, reassigned.Body.Bytes(), "accepted", true)
|
||
|
||
neverClaimed := callClientAPI(router, http.MethodPost,
|
||
"/api/v1/client/tasks/TASK-NEVER-CLAIMED/result", apiResultJSON,
|
||
"client-original", "never-claimed-key", "")
|
||
assertClientAPIResponse(t, neverClaimed, http.StatusForbidden)
|
||
assertAPIErrorCode(t, neverClaimed.Body.Bytes(), "TASK_NOT_ASSIGNED")
|
||
}
|
||
|
||
func newAPIRouteTestDB(t *testing.T) *sql.DB {
|
||
t.Helper()
|
||
return newMySQLTestDB(t)
|
||
}
|
||
|
||
func insertAPIRouteTask(t *testing.T, db *sql.DB, taskID, clientID string, createdAt time.Time) {
|
||
t.Helper()
|
||
at := createdAt.UTC().Format(model.TimeLayout)
|
||
_, err := db.Exec(`
|
||
INSERT INTO tasks (task_id, task_type, status, assigned_client,
|
||
order_no, pdd_goods_url, pdd_goods_id, pdd_options,
|
||
quantity, max_price_cent, created_at, updated_at)
|
||
VALUES (?, 'purchase', 'assigned', ?, 'TEST-ORDER',
|
||
'https://mobile.yangkeduo.com/goods.html?goods_id=1', '1',
|
||
'{"color":"黑色","size":"M码"}', 2, 4200, ?, ?)`,
|
||
taskID, clientID, at, at)
|
||
if err != nil {
|
||
t.Fatalf("插入 API 路由测试任务失败: %v", err)
|
||
}
|
||
}
|
||
|
||
func callClientAPI(handler http.Handler, method, path, body, clientID, idemKey, webToken string) *httptest.ResponseRecorder {
|
||
request := httptest.NewRequest(method, path, strings.NewReader(body))
|
||
request.Header.Set("Content-Type", "application/json")
|
||
request.Header.Set("Authorization", "Bearer reserved-but-not-validated")
|
||
request.Header.Set("X-Request-Id", "route-regression-request")
|
||
if clientID != "" {
|
||
request.Header.Set("X-Client-Id", clientID)
|
||
}
|
||
if idemKey != "" {
|
||
request.Header.Set("Idempotency-Key", idemKey)
|
||
}
|
||
// 故意带一个无效的 Web CSRF Cookie;Client API 不经过 Web CSRF,不能被它影响。
|
||
request.AddCookie(&http.Cookie{Name: "cmautobuy_csrf", Value: "invalid-web-csrf"})
|
||
if webToken != "" {
|
||
request.AddCookie(&http.Cookie{Name: "cmautobuy_session", Value: webToken})
|
||
}
|
||
response := httptest.NewRecorder()
|
||
handler.ServeHTTP(response, request)
|
||
return response
|
||
}
|
||
|
||
func assertClientAPIResponse(t *testing.T, response *httptest.ResponseRecorder, wantStatus int) {
|
||
t.Helper()
|
||
if response.Code != wantStatus {
|
||
t.Fatalf("Client API 状态码 = %d,期望 %d,响应:%s", response.Code, wantStatus, response.Body.String())
|
||
}
|
||
body := response.Body.String()
|
||
for _, forbidden := range []string{"<!DOCTYPE", "登录 Admin", "CSRF token"} {
|
||
if strings.Contains(body, forbidden) {
|
||
t.Fatalf("Client API 混入 Web 响应 %q:%s", forbidden, body)
|
||
}
|
||
}
|
||
if wantStatus != http.StatusNoContent {
|
||
contentType := response.Header().Get("Content-Type")
|
||
if !strings.HasPrefix(contentType, "application/json") {
|
||
t.Fatalf("Client API Content-Type = %q,不是 JSON,响应:%s", contentType, body)
|
||
}
|
||
}
|
||
}
|
||
|
||
func claimedTaskID(t *testing.T, body []byte) string {
|
||
t.Helper()
|
||
var payload struct {
|
||
Task struct {
|
||
ID string `json:"id"`
|
||
} `json:"task"`
|
||
}
|
||
if err := json.Unmarshal(body, &payload); err != nil || payload.Task.ID == "" {
|
||
t.Fatalf("领取响应缺少任务编号: body=%s err=%v", body, err)
|
||
}
|
||
return payload.Task.ID
|
||
}
|
||
|
||
func assertJSONField(t *testing.T, body []byte, field string, want any) {
|
||
t.Helper()
|
||
var payload map[string]any
|
||
if err := json.Unmarshal(body, &payload); err != nil {
|
||
t.Fatalf("响应不是 JSON: %s err=%v", body, err)
|
||
}
|
||
if fmt.Sprint(payload[field]) != fmt.Sprint(want) {
|
||
t.Fatalf("JSON 字段 %s = %v,期望 %v;响应:%s", field, payload[field], want, body)
|
||
}
|
||
}
|
||
|
||
func assertAPIErrorCode(t *testing.T, body []byte, want string) {
|
||
t.Helper()
|
||
var payload struct {
|
||
Error struct {
|
||
Code string `json:"code"`
|
||
} `json:"error"`
|
||
}
|
||
if err := json.Unmarshal(body, &payload); err != nil || payload.Error.Code != want {
|
||
t.Fatalf("错误代码 = %q,期望 %q;响应:%s err=%v", payload.Error.Code, want, body, err)
|
||
}
|
||
}
|