package httpapi import ( "bytes" "context" "encoding/json" "image" "image/color" "image/jpeg" "io" "mime/multipart" "net/http" "net/http/httptest" "net/textproto" "path/filepath" "strings" "testing" "time" "cmroubao/backend-api/internal/platform/assetstore" "cmroubao/backend-api/internal/platform/database" "cmroubao/backend-api/internal/platform/migration" repository "cmroubao/backend-api/internal/repository/sqlite" "cmroubao/backend-api/internal/usecase" "github.com/gin-gonic/gin" ) 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) if detailResponse.Code != http.StatusOK || requirement["sku"] != "BLACK-20L" || requirement["quantity"] != float64(2) { 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 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 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() 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) } registrar, err := NewAdminRouteRegistrar( AdminServices{Assets: assets, Tasks: tasks, Results: results}, 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 router } 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 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 }