1440 lines
39 KiB
Go
1440 lines
39 KiB
Go
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":"BLACK-L"`) ||
|
|
!strings.Contains(detail.Body.String(), `"sku":"WHITE-M"`) ||
|
|
detail.Header().Get("Cache-Control") != "no-store" {
|
|
t.Fatalf("freight detail status/body = %d / %s", detail.Code, detail.Body)
|
|
}
|
|
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":"BLACK-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("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)
|
|
}
|
|
freight, err := usecase.NewFreightService(
|
|
repositories,
|
|
staticFreightSource{},
|
|
clock,
|
|
ids,
|
|
time.Second,
|
|
)
|
|
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,
|
|
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
|
|
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: "BLACK-L",
|
|
Quantity: &quantityOne,
|
|
},
|
|
{
|
|
ExternalItemID: "89",
|
|
Title: "商品二",
|
|
ProductSpec: "白色,M",
|
|
SKU: "WHITE-M",
|
|
Quantity: &quantityTwo,
|
|
},
|
|
},
|
|
}},
|
|
}, 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
|
|
}
|