package httpapi import ( "bytes" "context" "database/sql" "encoding/json" "fmt" "image" "image/color" "image/jpeg" "io" "mime/multipart" "net/http" "net/http/httptest" "net/textproto" "path/filepath" "strings" "testing" "time" "cmroubao/backend-api/internal/domain" "cmroubao/backend-api/internal/platform/assetstore" "cmroubao/backend-api/internal/platform/database" "cmroubao/backend-api/internal/platform/migration" "cmroubao/backend-api/internal/platform/shunyunbao" repository "cmroubao/backend-api/internal/repository/sqlite" "cmroubao/backend-api/internal/usecase" "github.com/gin-gonic/gin" ) func TestCandidateDecisionDatasetResponseIncludesPersistentIdentity(t *testing.T) { createdAt := time.Date(2026, 7, 28, 12, 0, 0, 0, time.UTC) response := candidateDecisionDatasetResponse(&domain.CandidateDecisionDataset{ Observations: []domain.CandidateObservation{ { Ordinal: 1, Identity: &domain.CandidateObservationIdentity{ CandidateKey: strings.Repeat("a", 64), CardSignature: strings.Repeat("b", 64), DetailSignature: strings.Repeat("c", 64), DetailEvidenceSHA256: strings.Repeat("d", 64), SpecificationEvidenceSHA256: strings.Repeat("e", 64), IdentityVersion: 1, CreatedAt: createdAt, }, }, }, }) observations, ok := response["observations"].([]gin.H) if !ok || len(observations) != 1 { t.Fatalf("observations = %#v", response["observations"]) } identity, ok := observations[0]["identity"].(gin.H) if !ok || identity["candidate_key"] != strings.Repeat("a", 64) || identity["identity_version"] != 1 { t.Fatalf("identity = %#v", observations[0]["identity"]) } } func TestERPAdminAPIUsesCaptchaTicketWithoutExposingCredentials(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func( writer http.ResponseWriter, request *http.Request, ) { switch request.URL.Path { case shunyunbao.CaptchaPath: http.SetCookie(writer, &http.Cookie{Name: "erp", Value: "captcha", Path: "/"}) writer.Header().Set("Content-Type", "image/png") _, _ = writer.Write([]byte("captcha-image")) case shunyunbao.LoginPath: if _, err := request.Cookie("erp"); err != nil { t.Fatalf("login did not retain captcha cookie: %v", err) } http.SetCookie(writer, &http.Cookie{Name: "erp", Value: "login", Path: "/"}) _, _ = writer.Write([]byte(`{"status":true,"data":{"user":{"id":12,"username":"private-user"},"token":"private-token"}}`)) case shunyunbao.UserPath: if request.URL.Query().Get("id") != "12" { t.Fatalf("user query = %q", request.URL.RawQuery) } if cookie, err := request.Cookie("erp"); err != nil || cookie.Value != "login" { t.Fatalf("user session cookie = %v / %v", cookie, err) } _, _ = writer.Write([]byte(`{"status":true,"data":{"id":12,"username":"private-user"}}`)) default: writer.WriteHeader(http.StatusNotFound) } })) defer server.Close() manager, err := shunyunbao.NewSessionManager(shunyunbao.SessionConfig{ BaseURL: server.URL, Username: "private-user", Password: "private-password", Timeout: time.Second, AllowInsecureHTTP: true, }) if err != nil { t.Fatalf("NewSessionManager() error = %v", err) } gin.SetMode(gin.TestMode) router := gin.New() registerERPAdminAPI(router, &adminHandlers{services: AdminServices{ERP: manager}}) status := performERPRequest(t, router, http.MethodGet, "/api/v1/erp-session", nil, "") if status.Code != http.StatusOK || strings.Contains(status.Body.String(), "private-user") || strings.Contains(status.Body.String(), "private-password") { t.Fatalf("status response = %d / %s", status.Code, status.Body) } captcha := performERPRequest( t, router, http.MethodPost, "/api/v1/erp-session/captcha", nil, "", ) if captcha.Code != http.StatusOK || strings.Contains(captcha.Body.String(), "private-password") { t.Fatalf("captcha response = %d / %s", captcha.Code, captcha.Body) } var captchaBody map[string]any decodeResponse(t, captcha, &captchaBody) ticket, _ := captchaBody["captcha_ticket"].(string) if len(ticket) != 43 || responseContainsKey(captchaBody["session"], "captcha_ticket") { t.Fatalf("captcha response body = %#v", captchaBody) } image := performERPRequest( t, router, http.MethodGet, "/api/v1/erp-session/captcha/"+ticket, nil, "", ) if image.Code != http.StatusOK || image.Header().Get("Cache-Control") != "no-store" || image.Body.String() != "captcha-image" { t.Fatalf("captcha image = %d / %q / %s", image.Code, image.Header(), image.Body) } login := performERPRequest( t, router, http.MethodPost, "/api/v1/erp-session/login", strings.NewReader(`{"captcha_ticket":"`+ticket+`","captcha_code":"1234"}`), "application/json", ) if login.Code != http.StatusOK || strings.Contains(login.Body.String(), "private-token") || strings.Contains(login.Body.String(), "private-password") { t.Fatalf("login response = %d / %s", login.Code, login.Body) } if !strings.Contains(login.Body.String(), `"authenticated":true`) { t.Fatalf("login does not report authenticated state: %s", login.Body) } } func TestAdminAPIAssetAndTaskLifecycle(t *testing.T) { router := newAdminIntegrationRouter(t) imageBody, imageContentType := referenceUpload(t, "asset-key-1") assetResponse := performAdminRequest( t, router, http.MethodPost, "/api/v1/assets", imageContentType, imageBody, "asset-key-1", ) if assetResponse.Code != http.StatusCreated { t.Fatalf( "asset upload status = %d, body = %s", assetResponse.Code, assetResponse.Body.String(), ) } var asset map[string]any decodeResponse(t, assetResponse, &asset) assetID, _ := asset["id"].(string) if assetID == "" || asset["media_type"] != "image/jpeg" { t.Fatalf("asset response = %#v", asset) } if responseContainsKey(asset, "storage_key") || strings.Contains(strings.ToLower(assetResponse.Body.String()), "temp") { t.Fatalf("asset response exposes storage details: %#v", asset) } replayBody, replayContentType := referenceUpload(t, "asset-key-1") replayResponse := performAdminRequest( t, router, http.MethodPost, "/api/v1/assets", replayContentType, replayBody, "asset-key-1", ) var replayedAsset map[string]any decodeResponse(t, replayResponse, &replayedAsset) if replayResponse.Code != http.StatusCreated || replayedAsset["id"] != assetID { t.Fatalf( "asset replay status/body = %d / %#v", replayResponse.Code, replayedAsset, ) } taskJSON := `{ "source_ref":"external-10001", "title":"黑色双肩包", "sku":"BLACK-20L", "description":"容量约20L", "image_asset_id":"` + assetID + `", "quantity":2, "max_budget":"200.00" }` taskResponse := performAdminRequest( t, router, http.MethodPost, "/api/v1/tasks", "application/json", strings.NewReader(taskJSON), "task-key-1", ) if taskResponse.Code != http.StatusCreated { t.Fatalf( "task create status = %d, body = %s", taskResponse.Code, taskResponse.Body.String(), ) } var task map[string]any decodeResponse(t, taskResponse, &task) taskID, _ := task["id"].(string) if taskID == "" || task["status"] != "PENDING" || task["sku"] != "BLACK-20L" || task["max_budget"] != "200.00" { t.Fatalf("task response = %#v", task) } taskReplay := performAdminRequest( t, router, http.MethodPost, "/api/v1/tasks", "application/json", strings.NewReader(taskJSON), "task-key-1", ) var replayedTask map[string]any decodeResponse(t, taskReplay, &replayedTask) if taskReplay.Code != http.StatusCreated || replayedTask["id"] != taskID { t.Fatalf( "task replay status/body = %d / %#v", taskReplay.Code, replayedTask, ) } listResponse := performAdminRequest( t, router, http.MethodGet, "/api/v1/tasks?q=BLACK-20L&limit=20", "", nil, "", ) var list map[string]any decodeResponse(t, listResponse, &list) items, _ := list["items"].([]any) if listResponse.Code != http.StatusOK || len(items) != 1 { t.Fatalf( "task list status/body = %d / %#v", listResponse.Code, list, ) } detailResponse := performAdminRequest( t, router, http.MethodGet, "/api/v1/tasks/"+taskID, "", nil, "", ) var detail map[string]any decodeResponse(t, detailResponse, &detail) requirement, _ := detail["original_requirement"].(map[string]any) orderSubmissions, ok := detail["order_submissions"].([]any) if detailResponse.Code != http.StatusOK || requirement["sku"] != "BLACK-20L" || requirement["quantity"] != float64(2) || !ok || len(orderSubmissions) != 0 || detailResponse.Header().Get("Cache-Control") != "no-store" { t.Fatalf( "task detail status/body = %d / %#v", detailResponse.Code, detail, ) } contentResponse := performAdminRequest( t, router, http.MethodGet, "/api/v1/assets/"+assetID+"/content", "", nil, "", ) if contentResponse.Code != http.StatusOK || contentResponse.Header().Get("Content-Type") != "image/jpeg" || !bytes.HasPrefix(contentResponse.Body.Bytes(), []byte{0xff, 0xd8}) { t.Fatalf( "asset content status/headers = %d / %#v", contentResponse.Code, contentResponse.Header(), ) } cancelResponse := performAdminRequest( t, router, http.MethodPost, "/api/v1/tasks/"+taskID+"/cancel", "application/json", strings.NewReader(`{"reason":"需求已撤销"}`), "", ) var canceled map[string]any decodeResponse(t, cancelResponse, &canceled) if cancelResponse.Code != http.StatusOK || canceled["status"] != "CANCELED" { t.Fatalf( "task cancel status/body = %d / %#v", cancelResponse.Code, canceled, ) } canceledDetailResponse := performAdminRequest( t, router, http.MethodGet, "/api/v1/tasks/"+taskID, "", nil, "", ) var canceledDetail map[string]any decodeResponse(t, canceledDetailResponse, &canceledDetail) events, _ := canceledDetail["events"].([]any) if canceledDetailResponse.Code != http.StatusOK || len(events) != 2 { t.Fatalf( "canceled detail status/body = %d / %#v", canceledDetailResponse.Code, canceledDetail, ) } for _, value := range events { event, _ := value.(map[string]any) if event["actor_user_id"] != "00000000-0000-4000-8000-000000000099" { t.Fatalf("event actor = %#v", event) } } secondCancel := performAdminRequest( t, router, http.MethodPost, "/api/v1/tasks/"+taskID+"/cancel", "application/json", strings.NewReader(`{"reason":"再次取消"}`), "", ) if secondCancel.Code != http.StatusConflict { t.Fatalf( "second cancel status = %d, body = %s", secondCancel.Code, secondCancel.Body.String(), ) } } func TestAdminFreightAPIImportsAllItemsWithoutPII(t *testing.T) { router := newAdminIntegrationRouter(t) create := performAdminRequest( t, router, http.MethodPost, "/api/v1/freight-syncs", "application/json", strings.NewReader( `{"mode":"ORDER_NUMBER","order_number":"SOURCE-12"}`, ), "freight-sync-1", ) if create.Code != http.StatusCreated || create.Header().Get("Location") != "/api/v1/freight-orders" || !strings.Contains(create.Body.String(), `"status":"SUCCEEDED"`) { t.Fatalf("create status/body = %d / %s", create.Code, create.Body) } var createBody struct { Sync struct { ID string `json:"id"` } `json:"sync"` } decodeResponse(t, create, &createBody) if createBody.Sync.ID == "" || strings.Contains(create.Body.String(), "SOURCE-12") { t.Fatalf("create response exposes query or lacks ID: %s", create.Body) } var sync *httptest.ResponseRecorder for attempt := 0; attempt < 50; attempt++ { sync = performAdminRequest( t, router, http.MethodGet, "/api/v1/freight-syncs/"+createBody.Sync.ID, "", nil, "", ) if strings.Contains(sync.Body.String(), `"status":"SUCCEEDED"`) { break } time.Sleep(10 * time.Millisecond) } if sync == nil || sync.Code != http.StatusOK || !strings.Contains(sync.Body.String(), `"item_count":2`) { t.Fatalf("sync status/body = %d / %s", sync.Code, sync.Body) } list := performAdminRequest( t, router, http.MethodGet, "/api/v1/freight-orders", "", nil, "", ) if list.Code != http.StatusOK || !strings.Contains(list.Body.String(), `"item_count":2`) || responseContainsKey(mustDecodeAny(t, list), "receiver") || responseContainsKey(mustDecodeAny(t, list), "receiverTel") || responseContainsKey(mustDecodeAny(t, list), "receiverAddr") { t.Fatalf("freight list status/body = %d / %s", list.Code, list.Body) } var listBody struct { Items []struct { ID string `json:"id"` } `json:"items"` } decodeResponse(t, list, &listBody) detail := performAdminRequest( t, router, http.MethodGet, "/api/v1/freight-orders/"+listBody.Items[0].ID, "", nil, "", ) if detail.Code != http.StatusOK || !strings.Contains(detail.Body.String(), `"sku":"黑色,L"`) || !strings.Contains(detail.Body.String(), `"sku":"白色,M"`) || !strings.Contains(detail.Body.String(), `"original_unit_price_minor":12950`) || !strings.Contains(detail.Body.String(), `"original_currency":"TWD"`) || detail.Header().Get("Cache-Control") != "no-store" { t.Fatalf("freight detail status/body = %d / %s", detail.Code, detail.Body) } var detailBody struct { Items []struct { ID string `json:"id"` ImageStatus string `json:"image_status"` ImageURL string `json:"image_url"` } `json:"items"` } decodeResponse(t, detail, &detailBody) if len(detailBody.Items) != 2 || detailBody.Items[0].ImageStatus != "READY" || detailBody.Items[0].ImageURL == "" { t.Fatalf("freight detail images = %+v", detailBody.Items) } imageResponse := performAdminRequest( t, router, http.MethodGet, detailBody.Items[0].ImageURL, "", nil, "", ) if imageResponse.Code != http.StatusOK || imageResponse.Header().Get("Content-Type") != "image/jpeg" || imageResponse.Header().Get("Cache-Control") != "private, no-store" || imageResponse.Header().Get("X-Content-Type-Options") != "nosniff" || imageResponse.Header().Get("ETag") == "" || !bytes.HasPrefix(imageResponse.Body.Bytes(), []byte{0xff, 0xd8}) { t.Fatalf( "freight image status/headers/body = %d / %#v / %x", imageResponse.Code, imageResponse.Header(), imageResponse.Body.Bytes(), ) } replay := performAdminRequest( t, router, http.MethodPost, "/api/v1/freight-syncs", "application/json", strings.NewReader( `{"mode":"ORDER_NUMBER","order_number":"SOURCE-12"}`, ), "freight-sync-1", ) if replay.Code != http.StatusOK || !strings.Contains(replay.Body.String(), `"replayed":true`) || !strings.Contains(replay.Body.String(), `"status":"SUCCEEDED"`) || !strings.Contains(replay.Body.String(), createBody.Sync.ID) { t.Fatalf("replay status/body = %d / %s", replay.Code, replay.Body) } } func TestAdminFreightDateSyncAdvancesInspectableWatermark(t *testing.T) { fixture := newAdminIntegrationFixture(t) location, err := time.LoadLocation("Asia/Shanghai") if err != nil { t.Fatalf("LoadLocation() error = %v", err) } today := time.Now().In(location).Format(time.DateOnly) body := fmt.Sprintf( `{"mode":"CREATED_RANGE","created_from":%q,"created_to":%q}`, today, today, ) create := performAdminRequest( t, fixture.router, http.MethodPost, "/api/v1/freight-syncs", "application/json", strings.NewReader(body), "freight-date-sync-1", ) requireAdminStatus(t, create, http.StatusAccepted) if !strings.Contains(create.Body.String(), `"status":"PENDING"`) { t.Fatalf("date sync create response = %s", create.Body) } var created struct { Sync struct { ID string `json:"id"` } `json:"sync"` } decodeResponse(t, create, &created) var status *httptest.ResponseRecorder for attempt := 0; attempt < 50; attempt++ { status = performAdminRequest( t, fixture.router, http.MethodGet, "/api/v1/freight-syncs/"+created.Sync.ID, "", nil, "", ) if strings.Contains(status.Body.String(), `"status":"SUCCEEDED"`) { break } time.Sleep(10 * time.Millisecond) } requireAdminStatus(t, status, http.StatusOK) if !strings.Contains(status.Body.String(), `"mode":"CREATED_RANGE"`) || !strings.Contains(status.Body.String(), `"created_from":"`+today+`"`) || !strings.Contains(status.Body.String(), `"order_count":0`) { t.Fatalf("date sync response = %s", status.Body) } watermark := performAdminRequest( t, fixture.router, http.MethodGet, "/api/v1/freight-sync-watermark", "", nil, "", ) requireAdminStatus(t, watermark, http.StatusOK) if !strings.Contains( watermark.Body.String(), `"last_successful_run_id":"`+created.Sync.ID+`"`, ) || strings.Contains(watermark.Body.String(), "receiver") { t.Fatalf("watermark response = %s", watermark.Body) } mixed := performAdminRequest( t, fixture.router, http.MethodPost, "/api/v1/freight-syncs", "application/json", strings.NewReader( `{"mode":"CREATED_RANGE","order_number":"must-not-be-ignored",`+ `"created_from":"`+today+`","created_to":"`+today+`"}`, ), "freight-date-mixed", ) requireAdminStatus(t, mixed, http.StatusUnprocessableEntity) } func TestAdminProcurementAPIProducesImmutablePendingTask(t *testing.T) { fixture := newAdminIntegrationFixture(t) createSync := performAdminRequest( t, fixture.router, http.MethodPost, "/api/v1/freight-syncs", "application/json", strings.NewReader( `{"mode":"ORDER_NUMBER","order_number":"SOURCE-12"}`, ), "procurement-freight-sync", ) requireAdminStatus(t, createSync, http.StatusCreated) if !strings.Contains(createSync.Body.String(), `"status":"SUCCEEDED"`) { t.Fatalf("freight sync response = %s", createSync.Body) } orders := performAdminRequest( t, fixture.router, http.MethodGet, "/api/v1/freight-orders", "", nil, "", ) var orderBody struct { Items []struct { ID string `json:"id"` } `json:"items"` } decodeResponse(t, orders, &orderBody) detail := performAdminRequest( t, fixture.router, http.MethodGet, "/api/v1/freight-orders/"+orderBody.Items[0].ID, "", nil, "", ) var detailBody struct { Items []struct { ID string `json:"id"` } `json:"items"` } decodeResponse(t, detail, &detailBody) createRequest := performAdminRequest( t, fixture.router, http.MethodPost, "/api/v1/freight-items/"+detailBody.Items[0].ID+ "/procurement-request", "application/json", strings.NewReader(`{"confirm_procurement_needed":true}`), "", ) if createRequest.Code != http.StatusCreated || !strings.Contains(createRequest.Body.String(), `"status":"NEEDS_IMAGE"`) { t.Fatalf( "create request status/body = %d / %s", createRequest.Code, createRequest.Body, ) } var requestBody struct { Request struct { ID string `json:"id"` } `json:"procurement_request"` } decodeResponse(t, createRequest, &requestBody) imageBody, imageContentType := referenceUpload(t, "procurement-image") upload := performAdminRequest( t, fixture.router, http.MethodPost, "/api/v1/assets", imageContentType, imageBody, "procurement-image", ) var assetBody struct { ID string `json:"id"` } decodeResponse(t, upload, &assetBody) bind := performAdminRequest( t, fixture.router, http.MethodPut, "/api/v1/procurement-requests/"+requestBody.Request.ID+ "/reference-asset", "application/json", strings.NewReader( fmt.Sprintf(`{"image_asset_id":%q}`, assetBody.ID), ), "", ) if bind.Code != http.StatusOK || !strings.Contains(bind.Body.String(), `"status":"READY"`) { t.Fatalf("bind status/body = %d / %s", bind.Code, bind.Body) } createTask := performAdminRequest( t, fixture.router, http.MethodPost, "/api/v1/procurement-requests/"+requestBody.Request.ID+ "/purchase-task", "application/json", strings.NewReader(`{}`), "procurement-task-create", ) if createTask.Code != http.StatusCreated || !strings.Contains(createTask.Body.String(), `"status":"PENDING"`) { t.Fatalf( "create task status/body = %d / %s", createTask.Code, createTask.Body, ) } var taskBody struct { Task struct { ID string `json:"id"` } `json:"task"` } decodeResponse(t, createTask, &taskBody) taskDetail := performAdminRequest( t, fixture.router, http.MethodGet, "/api/v1/tasks/"+taskBody.Task.ID, "", nil, "", ) for _, required := range []string{ `"title":"商品一"`, `"sku":"黑色,L"`, `"quantity":1`, `"image_asset_id":"` + assetBody.ID + `"`, `"description":"ERP 规格:黑色,L"`, } { if !strings.Contains(taskDetail.Body.String(), required) { t.Fatalf("task detail missing %q: %s", required, taskDetail.Body) } } replay := performAdminRequest( t, fixture.router, http.MethodPost, "/api/v1/procurement-requests/"+requestBody.Request.ID+ "/purchase-task", "application/json", strings.NewReader(`{}`), "procurement-task-replay", ) if replay.Code != http.StatusCreated || !strings.Contains(replay.Body.String(), `"replayed":true`) || !strings.Contains(replay.Body.String(), taskBody.Task.ID) { t.Fatalf("task replay status/body = %d / %s", replay.Code, replay.Body) } var sourceCount int if err := fixture.db.QueryRow( `SELECT count(*) FROM purchase_task_sources WHERE task_id = ? AND procurement_request_id = ?`, taskBody.Task.ID, requestBody.Request.ID, ).Scan(&sourceCount); err != nil || sourceCount != 1 { t.Fatalf("task source count = %d, error = %v", sourceCount, err) } } func TestAdminOrderAuthorizationIsIdempotentAndRevisioned(t *testing.T) { fixture := newAdminIntegrationFixture(t) taskID, executionID, taskHash, firstKey, secondKey := seedAdminAuthorizationTask(t, fixture) payload := fmt.Sprintf( `{"execution_id":%q,"task_content_sha256":%q,"expected_task_version":2,"candidate_key":%q,"reason_schema_version":1,"primary_reason_code":"SELECTED_BEST_MATCH","note":"","supersedes_authorization_id":null,"items":[{"candidate_key":%q,"label":"ACCEPT","primary_reason_code":"SKU_MATCH","reason_codes":["SKU_MATCH"],"note":""},{"candidate_key":%q,"label":"REJECT","primary_reason_code":"NOT_BEST_MATCH","reason_codes":["NOT_BEST_MATCH"],"note":""}]}`, executionID, taskHash, firstKey, firstKey, secondKey, ) created := performAdminRequest( t, fixture.router, http.MethodPost, "/api/v1/tasks/"+taskID+"/order-authorizations", "application/json", strings.NewReader(payload), "authorization-1", ) if created.Code != http.StatusCreated { t.Fatalf( "authorization status/body = %d / %s", created.Code, created.Body.String(), ) } var createdBody struct { Authorization struct { ID string `json:"id"` Status string `json:"status"` Version int `json:"authorization_version"` } `json:"authorization"` Replayed bool `json:"replayed"` } decodeResponse(t, created, &createdBody) if createdBody.Authorization.ID == "" || createdBody.Authorization.Status != "PENDING_DELIVERY" || createdBody.Authorization.Version != 1 || createdBody.Replayed { t.Fatalf("authorization response = %+v", createdBody) } replayed := performAdminRequest( t, fixture.router, http.MethodPost, "/api/v1/tasks/"+taskID+"/order-authorizations", "application/json", strings.NewReader(payload), "authorization-1", ) requireAdminStatus(t, replayed, http.StatusCreated) if !strings.Contains(replayed.Body.String(), `"replayed":true`) || !strings.Contains( replayed.Body.String(), createdBody.Authorization.ID, ) { t.Fatalf("authorization replay = %s", replayed.Body.String()) } stale := performAdminRequest( t, fixture.router, http.MethodPost, "/api/v1/tasks/"+taskID+"/order-authorizations", "application/json", strings.NewReader(payload), "authorization-stale", ) requireAdminStatus(t, stale, http.StatusConflict) revisedPayload := fmt.Sprintf( `{"execution_id":%q,"task_content_sha256":%q,"expected_task_version":3,"candidate_key":%q,"reason_schema_version":1,"primary_reason_code":"SELECTED_BEST_MATCH","note":"","supersedes_authorization_id":%q,"items":[{"candidate_key":%q,"label":"REJECT","primary_reason_code":"NOT_BEST_MATCH","reason_codes":["NOT_BEST_MATCH"],"note":""},{"candidate_key":%q,"label":"ACCEPT","primary_reason_code":"IMAGE_MATCH","reason_codes":["IMAGE_MATCH"],"note":""}]}`, executionID, taskHash, secondKey, createdBody.Authorization.ID, firstKey, secondKey, ) revised := performAdminRequest( t, fixture.router, http.MethodPost, "/api/v1/tasks/"+taskID+"/order-authorizations", "application/json", strings.NewReader(revisedPayload), "authorization-2", ) requireAdminStatus(t, revised, http.StatusCreated) if !strings.Contains(revised.Body.String(), `"authorization_version":2`) || !strings.Contains(revised.Body.String(), `"candidate_key":"`+secondKey+`"`) { t.Fatalf("revised authorization = %s", revised.Body.String()) } detail := performAdminRequest( t, fixture.router, http.MethodGet, "/api/v1/tasks/"+taskID, "", nil, "", ) requireAdminStatus(t, detail, http.StatusOK) var detailBody map[string]any decodeResponse(t, detail, &detailBody) authorizations, _ := detailBody["order_authorizations"].([]any) if detailBody["version"] != float64(4) || len(authorizations) != 2 || !strings.Contains(detail.Body.String(), `"status":"SUPERSEDED"`) || !strings.Contains(detail.Body.String(), `"review_version":2`) { t.Fatalf("authorization detail = %#v", detailBody) } runner, err := migration.New(fixture.db) if err != nil { t.Fatalf("migration.New() error = %v", err) } if err := runner.Down(context.Background()); err != nil { t.Fatalf("image migration down: %v", err) } if err := runner.Down(context.Background()); err != nil { t.Fatalf("metadata migration down: %v", err) } if err := runner.Down(context.Background()); err != nil { t.Fatalf("date sync migration down: %v", err) } if err := runner.Down(context.Background()); err != nil { t.Fatalf("procurement migration down: %v", err) } if err := runner.Down(context.Background()); err != nil { t.Fatalf("freight migration down: %v", err) } if err := runner.Down(context.Background()); err != nil { t.Fatalf("order submission migration down: %v", err) } if err := runner.Down(context.Background()); err != nil { t.Fatalf("order dry-run migration down: %v", err) } if err := runner.Down(context.Background()); err != nil { t.Fatalf("device command migration down: %v", err) } if err := runner.Down(context.Background()); err == nil { t.Fatal("order authorization migration down succeeded with retained data") } } func TestAdminRoutesRejectRequestsWithoutAdminSession(t *testing.T) { router := newAdminIntegrationRouter(t) request := httptest.NewRequest(http.MethodGet, "/api/v1/tasks", nil) request.RemoteAddr = "192.0.2.10:3210" response := httptest.NewRecorder() router.ServeHTTP(response, request) if response.Code != http.StatusUnauthorized { t.Fatalf("status = %d, body = %s", response.Code, response.Body.String()) } var body map[string]any decodeResponse(t, response, &body) publicError, _ := body["error"].(map[string]any) if publicError["code"] != "ADMIN_SESSION_REQUIRED" { t.Fatalf("error response = %#v", body) } } func seedAdminAuthorizationTask( t *testing.T, fixture *adminIntegrationFixture, ) (taskID string, executionID string, taskHash string, firstKey string, secondKey string) { t.Helper() imageBody, imageContentType := referenceUpload(t, "authorization-asset") assetResponse := performAdminRequest( t, fixture.router, http.MethodPost, "/api/v1/assets", imageContentType, imageBody, "authorization-asset", ) requireAdminStatus(t, assetResponse, http.StatusCreated) var asset struct { ID string `json:"id"` } decodeResponse(t, assetResponse, &asset) taskResponse := performAdminRequest( t, fixture.router, http.MethodPost, "/api/v1/tasks", "application/json", strings.NewReader( `{"title":"后台授权测试商品","sku":"BLACK-L","description":"","image_asset_id":"`+ asset.ID+`","quantity":2,"max_budget":"100.00"}`, ), "authorization-task", ) requireAdminStatus(t, taskResponse, http.StatusCreated) var task struct { ID string `json:"id"` } decodeResponse(t, taskResponse, &task) const ( buyerID = "00000000-0000-4000-8000-000000000901" deviceID = "00000000-0000-4000-8000-000000000902" ) executionID = "00000000-0000-4000-8000-000000000903" now := time.Now().UTC() nowText := now.Format(time.RFC3339Nano) expiryText := now.Add(time.Hour).Format(time.RFC3339Nano) if _, err := fixture.db.Exec( `INSERT INTO users ( id, username, password_hash, role, is_active, created_at, updated_at ) VALUES (?, 'buyer-auth-test', 'test-only-hash', 'BUYER', 1, ?, ?)`, buyerID, nowText, nowText, ); err != nil { t.Fatalf("seed authorization buyer: %v", err) } if _, err := fixture.db.Exec( `INSERT INTO devices ( id, name, token_hash, bound_user_id, app_version, android_version, pdd_version, last_seen_at, is_enabled, created_at, updated_at, accessibility_enabled, pdd_installed, readiness_reported_at ) VALUES (?, 'auth-device', ?, ?, 'test', '16', '8.17.0', ?, 1, ?, ?, 1, 1, ?)`, deviceID, strings.Repeat("9", 64), buyerID, nowText, nowText, nowText, nowText, ); err != nil { t.Fatalf("seed authorization device: %v", err) } if _, err := fixture.db.Exec( `UPDATE purchase_tasks SET status = 'WAITING_CONFIRMATION', version = 2, claimed_by_user_id = ?, claimed_by_device_id = ?, claim_generation = 1, claim_token_hash = ?, claim_issued_at = ?, claim_expires_at = ?, updated_at = ? WHERE id = ?`, buyerID, deviceID, strings.Repeat("8", 64), nowText, expiryText, nowText, task.ID, ); err != nil { t.Fatalf("seed authorization task: %v", err) } if _, err := fixture.db.Exec( `INSERT INTO task_executions ( id, task_id, attempt_no, claim_generation, user_id, device_id, current_step, last_heartbeat_at, order_submitted, started_at ) VALUES (?, ?, 1, 1, ?, ?, 'WAITING_ADMIN_CONFIRMATION', ?, 0, ?)`, executionID, task.ID, buyerID, deviceID, nowText, nowText, ); err != nil { t.Fatalf("seed authorization execution: %v", err) } store, err := repository.New(fixture.db) if err != nil { t.Fatalf("repository.New() error = %v", err) } detail, err := store.GetTaskDetail( context.Background(), localAdminSubject, task.ID, ) if err != nil { t.Fatalf("GetTaskDetail() error = %v", err) } taskHash = usecase.TaskContentSHA256(detail.Task) firstKey = strings.Repeat("a", 64) secondKey = strings.Repeat("b", 64) if _, err := fixture.db.Exec( `INSERT INTO candidate_search_runs ( execution_id, task_id, task_content_sha256, execution_mode, search_query, started_at, received_at, observation_count, collection_complete, received_after_execution_expiry ) VALUES (?, ?, ?, 'MANUAL_FIRST', 'PDD_IMAGE_SEARCH', ?, ?, 2, 1, 0)`, executionID, task.ID, taskHash, nowText, nowText, ); err != nil { t.Fatalf("seed authorization search run: %v", err) } if _, err := fixture.db.Exec( `INSERT INTO candidate_observations ( execution_id, task_id, ordinal, title, sku_text, price_text, product_url, image_url, evidence_asset_ids_json, collection_status, observed_at ) VALUES (?, ?, 1, '候选一', 'BLACK-L', '20.00', '', '', '[]', 'COMPLETE', ?), (?, ?, 2, '候选二', 'BLACK-L', '22.00', '', '', '[]', 'COMPLETE', ?)`, executionID, task.ID, nowText, executionID, task.ID, nowText, ); err != nil { t.Fatalf("seed authorization observations: %v", err) } if _, err := fixture.db.Exec( `INSERT INTO candidate_observation_identities ( candidate_key, execution_id, candidate_ordinal, card_signature, detail_signature, detail_evidence_sha256, specification_evidence_sha256, identity_version, created_at ) VALUES (?, ?, 1, ?, ?, ?, ?, 1, ?), (?, ?, 2, ?, ?, ?, ?, 1, ?)`, firstKey, executionID, strings.Repeat("c", 64), strings.Repeat("d", 64), strings.Repeat("e", 64), strings.Repeat("f", 64), nowText, secondKey, executionID, strings.Repeat("1", 64), strings.Repeat("2", 64), strings.Repeat("3", 64), strings.Repeat("4", 64), nowText, ); err != nil { t.Fatalf("seed authorization identities: %v", err) } return task.ID, executionID, taskHash, firstKey, secondKey } func requireAdminStatus( t *testing.T, response *httptest.ResponseRecorder, want int, ) { t.Helper() if response.Code != want { t.Fatalf( "status/body = %d / %s, want %d", response.Code, response.Body.String(), want, ) } } func TestAdminAssetUploadRequiresIdempotencyKey(t *testing.T) { router := newAdminIntegrationRouter(t) imageBody, imageContentType := referenceUpload(t, "missing-key") response := performAdminRequest( t, router, http.MethodPost, "/api/v1/assets", imageContentType, imageBody, "", ) if response.Code != http.StatusBadRequest { t.Fatalf("status = %d, body = %s", response.Code, response.Body.String()) } var body map[string]any decodeResponse(t, response, &body) publicError, _ := body["error"].(map[string]any) if publicError["code"] != "IDEMPOTENCY_KEY_REQUIRED" { t.Fatalf("error response = %#v", body) } } type emptyAdminWeb struct{} func (emptyAdminWeb) RegisterProtected(gin.IRoutes) {} func newAdminIntegrationRouter(t *testing.T) http.Handler { t.Helper() return newAdminIntegrationFixture(t).router } type adminIntegrationFixture struct { router http.Handler db *sql.DB } func newAdminIntegrationFixture(t *testing.T) *adminIntegrationFixture { t.Helper() ctx := context.Background() db, err := database.Open(ctx, filepath.Join(t.TempDir(), "admin.db")) if err != nil { t.Fatalf("database.Open() error = %v", err) } t.Cleanup(func() { _ = db.Close() }) runner, err := migration.New(db) if err != nil { t.Fatalf("migration.New() error = %v", err) } if _, err := runner.Up(ctx); err != nil { t.Fatalf("migration.Up() error = %v", err) } now := time.Now().UTC().Format(time.RFC3339Nano) if _, err := db.ExecContext( ctx, `INSERT INTO users ( id, username, password_hash, role, is_active, created_at, updated_at ) VALUES (?, 'admin', 'test-only-hash', 'ADMIN', 1, ?, ?)`, "00000000-0000-4000-8000-000000000099", now, now, ); err != nil { t.Fatalf("seed admin user: %v", err) } repositories, err := repository.New(db) if err != nil { t.Fatalf("repository.New() error = %v", err) } files, err := assetstore.New(filepath.Join(t.TempDir(), "assets")) if err != nil { t.Fatalf("assetstore.New() error = %v", err) } clock := usecase.SystemClock{} ids := usecase.UUIDGenerator{} assets, err := usecase.NewAssetService(repositories, files, clock, ids) if err != nil { t.Fatalf("usecase.NewAssetService() error = %v", err) } tasks, err := usecase.NewTaskService(repositories, clock, ids) if err != nil { t.Fatalf("usecase.NewTaskService() error = %v", err) } results, err := usecase.NewExecutionResultService(repositories, files, clock, ids) if err != nil { t.Fatalf("usecase.NewExecutionResultService() error = %v", err) } authorizations, err := usecase.NewOrderAuthorizationService( repositories, clock, ids, ) if err != nil { t.Fatalf("usecase.NewOrderAuthorizationService() error = %v", err) } freightImages, err := usecase.NewFreightImageService( repositories, staticFreightSource{}, files, clock, ) if err != nil { t.Fatalf("usecase.NewFreightImageService() error = %v", err) } freight, err := usecase.NewFreightService( repositories, staticFreightSource{}, clock, ids, time.Second, usecase.WithFreightImageCache(freightImages), ) if err != nil { t.Fatalf("usecase.NewFreightService() error = %v", err) } procurement, err := usecase.NewProcurementService( repositories, clock, ids, ) if err != nil { t.Fatalf("usecase.NewProcurementService() error = %v", err) } registrar, err := NewAdminRouteRegistrar( AdminServices{ Assets: assets, Tasks: tasks, Results: results, Authorizations: authorizations, Freight: freight, FreightImages: freightImages, Procurement: procurement, }, emptyAdminWeb{}, ) if err != nil { t.Fatalf("NewAdminRouteRegistrar() error = %v", err) } router, err := NewRouter(RouterDependencies{ Database: db, RegisterPublicRoutes: discardRoutes, RegisterAdminRoutes: registrar, RegisterDeviceRoutes: discardRoutes, AdminSessions: allowAdminAuthenticator{}, DeviceAccess: allowAdminAuthenticator{}, LogEvent: discardEvent, }) if err != nil { t.Fatalf("NewRouter() error = %v", err) } return &adminIntegrationFixture{router: router, db: db} } type staticFreightSource struct{} func (staticFreightSource) QueryOrder( context.Context, string, ) (domain.FreightSourceBatch, error) { shop := "测试店铺" created := "2026-07-28 08:00" quantityOne := 1 quantityTwo := 2 priceOne := int64(12950) priceTwo := int64(8800) thumbOne := "190" thumbTwo := "191" return domain.FreightSourceBatch{ SchemaVersion: 1, Query: domain.FreightSourceQuery{ Mode: domain.FreightSyncOrderNumber, }, Orders: []domain.FreightSourceOrder{{ ExternalStockID: "12", SourceCode: "SOURCE-12", ShopName: &shop, SourceCreatedAt: &created, Items: []domain.FreightSourceItem{ { ExternalItemID: "88", Title: "商品一", ProductSpec: "黑色,L", SKU: "黑色,L", Quantity: &quantityOne, OriginalUnitPriceMinor: &priceOne, OriginalCurrency: domain.FreightCurrencyTWD, ProductThumbRef: &thumbOne, }, { ExternalItemID: "89", Title: "商品二", ProductSpec: "白色,M", SKU: "白色,M", Quantity: &quantityTwo, OriginalUnitPriceMinor: &priceTwo, OriginalCurrency: domain.FreightCurrencyTWD, ProductThumbRef: &thumbTwo, }, }, }}, }, nil } func (staticFreightSource) FetchProductImage( context.Context, string, ) (usecase.FreightSourceImage, error) { var imageBytes bytes.Buffer source := image.NewRGBA(image.Rect(0, 0, 8, 6)) for y := 0; y < 6; y++ { for x := 0; x < 8; x++ { source.Set( x, y, color.RGBA{R: uint8(x * 20), G: 80, B: 160, A: 255}, ) } } if err := jpeg.Encode( &imageBytes, source, &jpeg.Options{Quality: 85}, ); err != nil { return usecase.FreightSourceImage{}, err } return usecase.FreightSourceImage{ Content: io.NopCloser(bytes.NewReader(imageBytes.Bytes())), MediaType: "image/jpeg", }, nil } func (staticFreightSource) QueryCreatedRange( _ context.Context, createdFrom, createdTo string, ) (domain.FreightSourceBatch, error) { return domain.FreightSourceBatch{ SchemaVersion: 1, Query: domain.FreightSourceQuery{ Mode: domain.FreightSyncCreatedRange, CreatedFrom: &createdFrom, CreatedTo: &createdTo, }, Orders: []domain.FreightSourceOrder{}, }, nil } func mustDecodeAny( t *testing.T, response *httptest.ResponseRecorder, ) any { t.Helper() var decoded any decodeResponse(t, response, &decoded) return decoded } func referenceUpload(t *testing.T, key string) (io.Reader, string) { t.Helper() var imageBytes bytes.Buffer source := image.NewRGBA(image.Rect(0, 0, 8, 6)) for y := 0; y < 6; y++ { for x := 0; x < 8; x++ { source.Set(x, y, color.RGBA{R: uint8(x * 20), G: 80, B: 160, A: 255}) } } if err := jpeg.Encode(&imageBytes, source, &jpeg.Options{Quality: 85}); err != nil { t.Fatalf("jpeg.Encode() error = %v", err) } var body bytes.Buffer writer := multipart.NewWriter(&body) if err := writer.WriteField("purpose", "TASK_REFERENCE"); err != nil { t.Fatalf("WriteField(purpose) error = %v", err) } if err := writer.WriteField("task_id", ""); err != nil { t.Fatalf("WriteField(task_id) error = %v", err) } header := make(textproto.MIMEHeader) header.Set("Content-Disposition", `form-data; name="file"; filename="`+key+`.jpg"`) header.Set("Content-Type", "image/jpeg") part, err := writer.CreatePart(header) if err != nil { t.Fatalf("CreatePart() error = %v", err) } if _, err := part.Write(imageBytes.Bytes()); err != nil { t.Fatalf("part.Write() error = %v", err) } if err := writer.Close(); err != nil { t.Fatalf("multipart.Close() error = %v", err) } return bytes.NewReader(body.Bytes()), writer.FormDataContentType() } func performAdminRequest( t *testing.T, router http.Handler, method string, target string, contentType string, body io.Reader, idempotencyKey string, ) *httptest.ResponseRecorder { t.Helper() request := httptest.NewRequest(method, target, body) request.AddCookie(&http.Cookie{ Name: "cmroubao_admin_session", Value: "test-session", }) if method != http.MethodGet && method != http.MethodHead { const csrfToken = "YWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWE" request.AddCookie(&http.Cookie{ Name: "cmroubao_admin_csrf", Value: csrfToken, }) request.Header.Set("X-CSRF-Token", csrfToken) } if contentType != "" { request.Header.Set("Content-Type", contentType) } if idempotencyKey != "" { request.Header.Set("Idempotency-Key", idempotencyKey) } response := httptest.NewRecorder() router.ServeHTTP(response, request) return response } func performERPRequest( t *testing.T, router http.Handler, method, target string, body io.Reader, contentType string, ) *httptest.ResponseRecorder { t.Helper() request := httptest.NewRequest(method, target, body) if contentType != "" { request.Header.Set("Content-Type", contentType) } response := httptest.NewRecorder() router.ServeHTTP(response, request) return response } func decodeResponse( t *testing.T, response *httptest.ResponseRecorder, target any, ) { t.Helper() if err := json.Unmarshal(response.Body.Bytes(), target); err != nil { t.Fatalf( "json.Unmarshal() error = %v, body = %s", err, response.Body.String(), ) } } func responseContainsKey(value any, key string) bool { switch typed := value.(type) { case map[string]any: for candidate, child := range typed { if candidate == key || responseContainsKey(child, key) { return true } } case []any: for _, child := range typed { if responseContainsKey(child, key) { return true } } } return false }