package main import ( "database/sql" "encoding/json" "fmt" "net/http" "net/http/httptest" "strings" "testing" "time" "cmautobuy/admin/model" "cmautobuy/admin/repository" "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() db, err := repository.Open(t.TempDir()) if err != nil { t.Fatal(err) } t.Cleanup(func() { db.Close() }) if err := repository.Migrate(db); err != nil { t.Fatal(err) } return db } 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{"