474 lines
12 KiB
Go
474 lines
12 KiB
Go
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)
|
|
}
|
|
registrar, err := NewAdminRouteRegistrar(
|
|
AdminServices{Assets: assets, Tasks: tasks},
|
|
emptyAdminWeb{},
|
|
)
|
|
if err != nil {
|
|
t.Fatalf("NewAdminRouteRegistrar() error = %v", err)
|
|
}
|
|
router, err := NewRouter(RouterDependencies{
|
|
Database: db,
|
|
RegisterPublicRoutes: discardRoutes,
|
|
RegisterAdminRoutes: registrar,
|
|
AdminSessions: 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
|
|
}
|