Files
cmroubao/backend-api/internal/transport/webui/handler_test.go
T

1757 lines
48 KiB
Go
Raw Normal View History

package webui
import (
"bytes"
"context"
"errors"
"io"
"mime/multipart"
"net/http"
"net/http/httptest"
"net/url"
"regexp"
2026-07-29 11:08:23 +08:00
"strconv"
"strings"
"testing"
"time"
"cmroubao/backend-api/internal/domain"
"cmroubao/backend-api/internal/usecase"
"github.com/gin-gonic/gin"
)
const testTaskID = "00000000-0000-4000-8000-000000000001"
func TestListTasksRendersRealRowsEscapedWithSecurityHeaders(t *testing.T) {
now := time.Date(2026, 7, 26, 3, 4, 5, 0, time.UTC)
service := &fakeService{
listResult: TaskList{Items: []TaskSummary{{
ID: testTaskID,
Title: `<script>alert("private")</script>`,
SKU: "SKU-1",
Status: "PENDING",
UpdatedAt: now,
}}},
}
router := newTestRouter(t, service)
response := performRequest(
t,
router,
http.MethodGet,
"/tasks?q=%3Cquery%3E&status=PENDING",
nil,
"",
)
if response.Code != http.StatusOK {
t.Fatalf("status = %d, body = %s", response.Code, response.Body)
}
body := response.Body.String()
if strings.Contains(body, `<script>alert("private")</script>`) ||
!strings.Contains(body, "&lt;script&gt;") {
t.Fatalf("task title was not safely escaped: %s", body)
}
for _, text := range []string{
"SKU-1",
"待领取",
"/tasks/" + testTaskID,
"value=\"&lt;query&gt;\"",
} {
if !strings.Contains(body, text) {
t.Fatalf("body does not contain %q", text)
}
}
assertSecurityHeaders(t, response)
if service.listInput.Query != "<query>" ||
service.listInput.Status != "PENDING" ||
service.listInput.Limit != defaultListLimit {
t.Fatalf("list input = %+v", service.listInput)
}
}
func TestListTasksRendersHonestEmptyState(t *testing.T) {
router := newTestRouter(t, &fakeService{})
response := performRequest(
t,
router,
http.MethodGet,
"/tasks",
nil,
"",
)
if response.Code != http.StatusOK {
t.Fatalf("status = %d", response.Code)
}
body := response.Body.String()
if !strings.Contains(body, "没有符合条件的任务") ||
!strings.Contains(body, "创建第一条采购任务") {
t.Fatalf("empty state missing: %s", body)
}
for _, fake := range []string{"RB-DEMO", "演示设备", "演示任务"} {
if strings.Contains(body, fake) {
t.Fatalf("empty page contains fake data %q", fake)
}
}
}
func TestNewTaskIssuesReusableStrictCSRFCookie(t *testing.T) {
router := newTestRouter(t, &fakeService{})
response := performRequest(
t,
router,
http.MethodGet,
"/tasks/new",
nil,
"",
)
if response.Code != http.StatusOK {
t.Fatalf("status = %d, body = %s", response.Code, response.Body)
}
cookie := csrfCookie(t, response)
if !cookie.HttpOnly ||
cookie.SameSite != http.SameSiteStrictMode ||
cookie.Path != "/" {
t.Fatalf("CSRF cookie = %+v", cookie)
}
body := response.Body.String()
if !strings.Contains(
body,
`name="csrf_token" value="`+cookie.Value+`"`,
) {
t.Fatal("form CSRF token does not match the cookie")
}
for _, required := range []string{
`name="title"`,
`name="sku"`,
`name="quantity"`,
`name="max_budget"`,
`name="image"`,
`action="/logout"`,
"最高总预算",
} {
if !strings.Contains(body, required) {
t.Fatalf("new task form missing %q", required)
}
}
if strings.Contains(body, "<style") ||
strings.Contains(body, "<script>") {
t.Fatal("page contains inline style or script incompatible with CSP")
}
assertSecurityHeaders(t, response)
}
func TestNewTaskPrefersRootCSRFCookieDuringLegacyPathMigration(
t *testing.T,
) {
router := newTestRouter(t, &fakeService{})
legacy := mustToken(t)
root := mustToken(t)
for root == legacy {
root = mustToken(t)
}
request := httptest.NewRequest(http.MethodGet, "/tasks/new", nil)
request.AddCookie(&http.Cookie{
Name: csrfCookieName,
Value: legacy,
Path: "/tasks",
})
request.AddCookie(&http.Cookie{
Name: csrfCookieName,
Value: root,
Path: "/",
})
response := httptest.NewRecorder()
router.ServeHTTP(response, request)
if response.Code != http.StatusOK ||
!strings.Contains(
response.Body.String(),
`name="csrf_token" value="`+root+`"`,
) {
t.Fatalf("status/body = %d / %s", response.Code, response.Body)
}
}
func TestCreateTaskRejectsCSRFBeforeCallingService(t *testing.T) {
service := &fakeService{}
router := newTestRouter(t, service)
body, contentType := multipartBody(t, map[string]string{
"csrf_token": "invalid",
"title": "标题",
"sku": "SKU-1",
"quantity": "1",
"upload_key": mustToken(t),
"create_key": mustToken(t),
}, "image", "reference.jpg", []byte("not inspected"))
response := performRequest(
t,
router,
http.MethodPost,
"/tasks",
body,
contentType,
)
if response.Code != http.StatusForbidden {
t.Fatalf("status = %d, body = %s", response.Code, response.Body)
}
if service.uploadCalls != 0 || service.createCalls != 0 {
t.Fatalf(
"service calls = upload %d, create %d",
service.uploadCalls,
service.createCalls,
)
}
}
func TestCreateTaskValidationRetainsEscapedSafeFields(t *testing.T) {
service := &fakeService{}
router := newTestRouter(t, service)
cookie := getCSRFCookie(t, router)
body, contentType := multipartBody(t, map[string]string{
"csrf_token": cookie.Value,
"title": "",
"sku": "",
"description": `<img src=x onerror="alert(1)">`,
"quantity": "0",
"max_budget": "1.001",
"upload_key": mustToken(t),
"create_key": mustToken(t),
}, "", "", nil)
request := httptest.NewRequest(http.MethodPost, "/tasks", body)
request.Header.Set("Content-Type", contentType)
request.AddCookie(cookie)
response := httptest.NewRecorder()
router.ServeHTTP(response, request)
if response.Code != http.StatusUnprocessableEntity {
t.Fatalf("status = %d, body = %s", response.Code, response.Body)
}
rendered := response.Body.String()
for _, message := range []string{
"请输入商品标题",
"请输入 SKU",
"数量必须是大于 0 的整数",
"最多两位小数",
"&lt;img src=x onerror=&#34;alert(1)&#34;&gt;",
} {
if !strings.Contains(rendered, message) {
t.Fatalf("response missing %q", message)
}
}
if strings.Contains(rendered, `<img src=x onerror="alert(1)">`) {
t.Fatal("description was rendered as active HTML")
}
if service.uploadCalls != 0 || service.createCalls != 0 {
t.Fatal("invalid form reached the service")
}
}
func TestCreateValidationUsesContractUTF8ByteLimits(t *testing.T) {
valid := func() newTaskPageView {
token := mustToken(t)
return newTaskPageView{
CSRFToken: token,
UploadKey: mustToken(t),
CreateKey: mustToken(t),
Form: createFormView{
Title: "标题",
SKU: "SKU-1",
Quantity: "1",
QuantityValue: 1,
},
}
}
skuPage := valid()
skuPage.Form.SKU = strings.Repeat("货", maxSKUBytes/3+1)
if !validateCreateForm(&skuPage) ||
!strings.Contains(skuPage.Errors.SKU, "512") {
t.Fatalf("SKU errors = %+v", skuPage.Errors)
}
descriptionPage := valid()
descriptionPage.Form.Description = strings.Repeat(
"说",
maxDescriptionBytes/3+1,
)
if !validateCreateForm(&descriptionPage) ||
!strings.Contains(descriptionPage.Errors.Description, "8192") {
t.Fatalf("description errors = %+v", descriptionPage.Errors)
}
titlePage := valid()
titlePage.Form.Title = strings.Repeat("题", maxTitleRunes+1)
if !validateCreateForm(&titlePage) ||
!strings.Contains(titlePage.Errors.Title, "120") {
t.Fatalf("title errors = %+v", titlePage.Errors)
}
if maxRequestBytes < (20<<20)+(1<<20) {
t.Fatalf("maxRequestBytes = %d, does not cover a 20 MiB image", maxRequestBytes)
}
}
func TestCreateTaskUploadsThenRedirectsWithPRG(t *testing.T) {
service := &fakeService{
uploadResult: UploadedAsset{
ID: "00000000-0000-4000-8000-000000000099",
},
createResult: Task{ID: testTaskID},
}
router := newTestRouter(t, service)
cookie := getCSRFCookie(t, router)
body, contentType := multipartBody(t, map[string]string{
"csrf_token": cookie.Value,
"title": " 桌面收纳盒 ",
"sku": " SKU-1 ",
"description": "浅灰色",
"quantity": "2",
"max_budget": "60.00",
"upload_key": mustToken(t),
"create_key": mustToken(t),
}, "image", "reference.jpg", []byte("image bytes"))
request := httptest.NewRequest(http.MethodPost, "/tasks", body)
request.Header.Set("Content-Type", contentType)
request.AddCookie(cookie)
response := httptest.NewRecorder()
router.ServeHTTP(response, request)
if response.Code != http.StatusSeeOther ||
response.Header().Get("Location") != "/tasks/"+testTaskID {
t.Fatalf(
"status/location = %d/%q, body = %s",
response.Code,
response.Header().Get("Location"),
response.Body,
)
}
if service.uploadCalls != 1 || service.createCalls != 1 {
t.Fatalf(
"service calls = upload %d, create %d",
service.uploadCalls,
service.createCalls,
)
}
if string(service.uploadBody) != "image bytes" ||
service.createInput.Title != "桌面收纳盒" ||
service.createInput.SKU != "SKU-1" ||
service.createInput.Quantity != 2 ||
service.createInput.MaxBudget != "60.00" ||
service.createInput.ImageAssetID != service.uploadResult.ID {
t.Fatalf(
"upload/create input = %q / %+v",
service.uploadBody,
service.createInput,
)
}
}
func TestCreateTaskFailureRetainsUploadedAssetForRetry(t *testing.T) {
assetID := "00000000-0000-4000-8000-000000000099"
service := &fakeService{
uploadResult: UploadedAsset{
ID: assetID,
},
createErr: ErrUnavailable,
}
router := newTestRouter(t, service)
cookie := getCSRFCookie(t, router)
body, contentType := validCreateBody(t, cookie.Value, nil)
request := httptest.NewRequest(http.MethodPost, "/tasks", body)
request.Header.Set("Content-Type", contentType)
request.AddCookie(cookie)
response := httptest.NewRecorder()
router.ServeHTTP(response, request)
if response.Code != http.StatusServiceUnavailable {
t.Fatalf("status = %d, body = %s", response.Code, response.Body)
}
rendered := response.Body.String()
for _, value := range []string{
`name="image_asset_id" value="` + assetID + `"`,
"再次提交会复用该图片",
} {
if !strings.Contains(rendered, value) {
t.Fatalf("response missing %q", value)
}
}
if strings.Contains(rendered, "reference.jpg") {
t.Fatal("server response leaked the client file name")
}
}
func TestCreateTaskRetryReusesAssetWithoutUpload(t *testing.T) {
assetID := "00000000-0000-4000-8000-000000000099"
service := &fakeService{
createResult: Task{ID: testTaskID},
}
router := newTestRouter(t, service)
cookie := getCSRFCookie(t, router)
body, contentType := validCreateBody(t, cookie.Value, map[string]string{
"image_asset_id": assetID,
})
request := httptest.NewRequest(http.MethodPost, "/tasks", body)
request.Header.Set("Content-Type", contentType)
request.AddCookie(cookie)
response := httptest.NewRecorder()
router.ServeHTTP(response, request)
if response.Code != http.StatusSeeOther {
t.Fatalf("status = %d, body = %s", response.Code, response.Body)
}
if service.uploadCalls != 0 ||
service.createInput.ImageAssetID != assetID {
t.Fatalf(
"upload calls / asset = %d / %q",
service.uploadCalls,
service.createInput.ImageAssetID,
)
}
}
func TestTaskDetailPendingCancelUsesCSRFAndPRG(t *testing.T) {
service := &fakeService{
getResult: Task{
ID: testTaskID,
Title: "桌面收纳盒",
SKU: "SKU-1",
Description: "浅灰色",
Quantity: 2,
MaxBudget: "60.00",
Status: "PENDING",
ReferenceAssetID: "00000000-0000-4000-8000-000000000009",
CreatedAt: time.Date(2026, 7, 26, 3, 4, 5, 0, time.UTC),
UpdatedAt: time.Date(2026, 7, 26, 3, 5, 5, 0, time.UTC),
},
cancelResult: Task{Status: "CANCELED"},
}
router := newTestRouter(t, service)
detail := performRequest(
t,
router,
http.MethodGet,
"/tasks/"+testTaskID,
nil,
"",
)
if detail.Code != http.StatusOK {
t.Fatalf("detail status = %d, body = %s", detail.Code, detail.Body)
}
cookie := csrfCookie(t, detail)
cancelKey := hiddenValue(t, detail.Body.String(), "cancel_key")
for _, value := range []string{
"桌面收纳盒",
"最高总预算",
"/api/v1/assets/00000000-0000-4000-8000-000000000009/content",
"取消任务",
"确认取消任务",
} {
if !strings.Contains(detail.Body.String(), value) {
t.Fatalf("detail missing %q", value)
}
}
form := url.Values{
"csrf_token": {cookie.Value},
"cancel_key": {cancelKey},
}
request := httptest.NewRequest(
http.MethodPost,
"/tasks/"+testTaskID+"/cancel",
strings.NewReader(form.Encode()),
)
request.Header.Set("Content-Type", "application/x-www-form-urlencoded")
request.AddCookie(cookie)
response := httptest.NewRecorder()
router.ServeHTTP(response, request)
if response.Code != http.StatusSeeOther ||
response.Header().Get("Location") !=
"/tasks/"+testTaskID+"?notice=canceled" {
t.Fatalf(
"status/location = %d/%q",
response.Code,
response.Header().Get("Location"),
)
}
if service.cancelInput.TaskID != testTaskID ||
service.cancelInput.IdempotencyKey != cancelKey {
t.Fatalf("cancel input = %+v", service.cancelInput)
}
}
func TestTaskDetailDoesNotLeakForbiddenResource(t *testing.T) {
service := &fakeService{getErr: ErrForbidden}
router := newTestRouter(t, service)
response := performRequest(
t,
router,
http.MethodGet,
"/tasks/"+testTaskID,
nil,
"",
)
if response.Code != http.StatusNotFound {
t.Fatalf("status = %d", response.Code)
}
if !strings.Contains(response.Body.String(), "不存在或当前不可访问") {
t.Fatalf("safe not-found message missing: %s", response.Body)
}
}
2026-07-28 12:50:42 +08:00
func TestTaskDetailAuthorizesOneCandidateWithCSRFAndPRG(t *testing.T) {
firstKey := strings.Repeat("a", 64)
secondKey := strings.Repeat("b", 64)
service := &fakeService{
getResult: Task{
ID: testTaskID,
Title: "候选确认任务",
SKU: "BLACK-L",
Quantity: 2,
Status: "WAITING_CONFIRMATION",
Version: 7,
ReferenceAssetID: "00000000-0000-4000-8000-000000000009",
CreatedAt: time.Now().UTC(),
UpdatedAt: time.Now().UTC(),
TaskContentSHA256: strings.Repeat("c", 64),
Candidates: []AuthorizationCandidate{
{
CandidateKey: firstKey,
Ordinal: 1,
Title: "候选一",
SKUText: "BLACK-L",
PriceText: "20.00",
EvidenceURLs: []string{"/api/v1/assets/evidence-1/content"},
},
{
CandidateKey: secondKey,
Ordinal: 2,
Title: "候选二",
SKUText: "BLACK-XL",
PriceText: "22.00",
},
},
},
}
router := newTestRouter(t, service)
detail := performRequest(
t,
router,
http.MethodGet,
"/tasks/"+testTaskID,
nil,
"",
)
if detail.Code != http.StatusOK {
t.Fatalf("detail status/body = %d/%s", detail.Code, detail.Body)
}
for _, expected := range []string{
"候选确认与下单授权",
"系统只创建待付款订单",
"候选一",
"BLACK-XL",
`name="candidate_key"`,
} {
if !strings.Contains(detail.Body.String(), expected) {
t.Fatalf("authorization detail missing %q", expected)
}
}
cookie := csrfCookie(t, detail)
authorizationKey := hiddenValue(
t,
detail.Body.String(),
"authorization_key",
)
form := url.Values{
"csrf_token": {cookie.Value},
"authorization_key": {authorizationKey},
"expected_task_version": {"7"},
"candidate_key": {firstKey},
"selected_reason_code": {"SKU_MATCH"},
"rejected_reason_code": {"NOT_BEST_MATCH"},
"authorization_note": {"已核对图片、规格与价格"},
"supersedes_authorization_id": {""},
}
request := httptest.NewRequest(
http.MethodPost,
"/tasks/"+testTaskID+"/order-authorizations",
strings.NewReader(form.Encode()),
)
request.Header.Set("Content-Type", "application/x-www-form-urlencoded")
request.AddCookie(cookie)
response := httptest.NewRecorder()
router.ServeHTTP(response, request)
if response.Code != http.StatusSeeOther ||
response.Header().Get("Location") !=
"/tasks/"+testTaskID+"?notice=authorization-created" {
t.Fatalf(
"status/location = %d/%q",
response.Code,
response.Header().Get("Location"),
)
}
if service.authorizeInput.TaskID != testTaskID ||
service.authorizeInput.IdempotencyKey != authorizationKey ||
service.authorizeInput.ExpectedTaskVersion != 7 ||
service.authorizeInput.CandidateKey != firstKey ||
service.authorizeInput.SelectedReasonCode != "SKU_MATCH" ||
service.authorizeInput.RejectedReasonCode != "NOT_BEST_MATCH" {
t.Fatalf("authorize input = %+v", service.authorizeInput)
}
}
func TestTaskDetailDisablesAuthorizationAfterDelivery(t *testing.T) {
view := taskDetailViewFrom(Task{
Status: "WAITING_CONFIRMATION",
Candidates: []AuthorizationCandidate{{
CandidateKey: strings.Repeat("a", 64),
}},
OrderAuthorizations: []OrderAuthorization{{
ID: "00000000-0000-4000-8000-000000000010",
Status: "DELIVERED",
}},
})
if view.CanAuthorizeOrder {
t.Fatal("delivered authorization remains editable")
}
}
func TestTaskDetailRendersOrderSubmissionStatesWithoutPaymentActions(
t *testing.T,
) {
now := time.Date(2026, 7, 28, 9, 30, 0, 0, time.UTC)
tests := []struct {
name string
taskStatus string
submission OrderSubmission
expected []string
forbidden []string
}{
{
name: "reconciling",
taskStatus: "WAITING_CONFIRMATION",
submission: OrderSubmission{
Status: "FENCED",
StatusLabel: "正在对账",
FencedAt: now,
},
expected: []string{
"订单提交结果正在对账",
"禁止重复提交",
},
forbidden: []string{"待人工确认付款", "验证完成,未提交订单"},
},
{
name: "manual review",
taskStatus: "WAITING_CONFIRMATION",
submission: OrderSubmission{
Status: "MANUAL_REVIEW",
StatusLabel: "需要人工对账",
ManualReasonLabel: "找到多个可能订单",
FencedAt: now,
ManualReviewAt: now.Add(time.Minute),
},
expected: []string{
"需要人工对账",
"找到多个可能订单",
"禁止重新提交订单",
},
forbidden: []string{"待人工确认付款", "验证完成,未提交订单"},
},
{
name: "pending payment",
taskStatus: "SUCCEEDED",
submission: OrderSubmission{
Status: "RECONCILED",
StatusLabel: "待人工确认付款",
ExpectedTitle: "已授权灰色上衣",
ExpectedSKU: "灰色,2XL",
ExpectedQuantity: 2,
ExpectedUnitPrice: "21.50",
ExpectedTotalPrice: "43.00",
PlatformOrderNo: "12345678901234567890",
PlatformOrderedAt: now,
PlatformOrderStatus: "PENDING_PAYMENT",
EvidenceContentURL: "/api/v1/tasks/" + testTaskID +
"/evidence/00000000-0000-4000-8000-000000000078/content",
FencedAt: now.Add(-time.Minute),
ReconciledAt: now.Add(time.Minute),
},
expected: []string{
"待人工确认付款",
"请采购员打开拼多多订单列表",
"12345678901234567890",
"灰色,2XL",
"¥43.00",
"待付款订单列表对账截图",
},
forbidden: []string{
"验证完成,未提交订单",
"重试提交",
`href="pinduoduo`,
},
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
service := &fakeService{
getResult: Task{
ID: testTaskID,
Title: "订单状态任务",
SKU: "灰色,2XL",
Quantity: 2,
Status: test.taskStatus,
ReferenceAssetID: "00000000-0000-4000-8000-000000000009",
CreatedAt: now,
UpdatedAt: now,
OrderSubmissions: []OrderSubmission{test.submission},
},
}
response := performRequest(
t,
newTestRouter(t, service),
http.MethodGet,
"/tasks/"+testTaskID,
nil,
"",
)
if response.Code != http.StatusOK {
t.Fatalf(
"detail status/body = %d/%s",
response.Code,
response.Body,
)
}
body := response.Body.String()
for _, expected := range test.expected {
if !strings.Contains(body, expected) {
t.Fatalf("detail missing %q: %s", expected, body)
}
}
for _, forbidden := range test.forbidden {
if strings.Contains(body, forbidden) {
t.Fatalf("detail contains forbidden %q", forbidden)
}
}
if regexp.MustCompile(
`(?s)<(?:a|button)[^>]*>[^<]*(?:立即支付|确认支付|自动付款)`,
).MatchString(body) {
t.Fatal("detail exposes a payment action")
}
})
}
}
func TestTaskDetailKeepsHistoricalSucceededCopyWithoutSubmission(t *testing.T) {
now := time.Date(2026, 7, 28, 9, 30, 0, 0, time.UTC)
response := performRequest(
t,
newTestRouter(t, &fakeService{getResult: Task{
ID: testTaskID,
Title: "历史验证任务",
SKU: "HISTORY-SKU",
Quantity: 1,
Status: "SUCCEEDED",
ReferenceAssetID: "00000000-0000-4000-8000-000000000009",
CreatedAt: now,
UpdatedAt: now,
}}),
http.MethodGet,
"/tasks/"+testTaskID,
nil,
"",
)
if response.Code != http.StatusOK ||
!strings.Contains(
response.Body.String(),
"验证完成,未提交订单",
) {
t.Fatalf(
"historical succeeded detail = %d/%s",
response.Code,
response.Body,
)
}
}
func TestOrderSubmissionAdapterBuildsAuthenticatedEvidenceURL(t *testing.T) {
orderNo := "12345678901234567890"
status := "PENDING_PAYMENT"
evidenceID := "00000000-0000-4000-8000-000000000078"
orderedAt := time.Date(2026, 7, 28, 9, 30, 0, 0, time.UTC)
submission := orderSubmissionFrom(domain.OrderSubmission{
ID: "00000000-0000-4000-8000-000000000079",
TaskID: testTaskID,
Status: domain.OrderSubmissionReconciled,
ExpectedTitle: "已授权商品",
ExpectedSKU: "灰色,2XL",
ExpectedQuantity: 2,
ExpectedUnitPriceCents: 2150,
ExpectedTotalPriceCents: 4300,
PlatformOrderNo: &orderNo,
PlatformOrderedAt: &orderedAt,
PlatformOrderStatus: &status,
ReconciliationEvidenceAssetID: &evidenceID,
FencedAt: orderedAt.Add(-time.Minute),
ReconciledAt: &orderedAt,
})
if submission.StatusLabel != "待人工确认付款" ||
submission.PlatformOrderNo != orderNo ||
submission.ExpectedUnitPrice != "21.50" ||
submission.ExpectedTotalPrice != "43.00" ||
submission.EvidenceContentURL !=
"/api/v1/tasks/"+testTaskID+"/evidence/"+evidenceID+"/content" ||
strings.Contains(submission.EvidenceContentURL, orderNo) {
t.Fatalf("submission view = %+v", submission)
}
}
func TestTaskDetailCancelModeFollowsLifecycleStatus(t *testing.T) {
tests := []struct {
status string
canCancel bool
requiresAck bool
}{
{status: "PENDING", canCancel: true},
{status: "CLAIMED", canCancel: true},
{status: "RUNNING", canCancel: true, requiresAck: true},
{
status: "WAITING_CONFIRMATION",
canCancel: true,
requiresAck: true,
},
{status: "SUCCEEDED"},
{status: "FAILED"},
{status: "CANCELED"},
}
for _, test := range tests {
t.Run(test.status, func(t *testing.T) {
view := taskDetailViewFrom(Task{Status: test.status})
if view.CanCancel != test.canCancel ||
view.CancelRequiresAck != test.requiresAck {
t.Fatalf("task detail view = %+v", view)
}
})
}
if notice := detailNotice("cancel-requested"); !strings.Contains(
notice,
"安全停止",
) {
t.Fatalf("cancel requested notice = %q", notice)
}
}
func TestRunningTaskDetailExplainsCancelAcknowledgement(t *testing.T) {
service := &fakeService{
getResult: Task{
ID: testTaskID,
Title: "运行中任务",
SKU: "RUNNING-SKU",
Quantity: 1,
Status: "RUNNING",
ReferenceAssetID: "00000000-0000-4000-8000-000000000009",
CreatedAt: time.Now().UTC(),
UpdatedAt: time.Now().UTC(),
},
}
router := newTestRouter(t, service)
response := performRequest(
t,
router,
http.MethodGet,
"/tasks/"+testTaskID,
nil,
"",
)
if response.Code != http.StatusOK {
t.Fatalf("running task detail status = %d", response.Code)
}
body := response.Body.String()
for _, expected := range []string{
"请求安全停止任务?",
"设备确认前任务仍保持当前执行状态",
"确认请求停止",
} {
if !strings.Contains(body, expected) {
t.Fatalf("running detail missing %q", expected)
}
}
if strings.Contains(body, "采购执行员将不能再领取") {
t.Fatal("running detail uses immediate cancellation copy")
}
}
func TestStaticFilesAreEmbeddedAndProtected(t *testing.T) {
router := newTestRouter(t, &fakeService{})
for _, route := range []string{"/static/admin.css", "/static/admin.js"} {
response := performRequest(
t,
router,
http.MethodGet,
route,
nil,
"",
)
if response.Code != http.StatusOK || response.Body.Len() == 0 {
t.Fatalf("%s status/bytes = %d/%d", route, response.Code, response.Body.Len())
}
assertSecurityHeaders(t, response)
}
}
func TestRendererUsesMissingKeyErrors(t *testing.T) {
renderer, err := NewRenderer()
if err != nil {
t.Fatalf("NewRenderer() error = %v", err)
}
var output bytes.Buffer
if err := renderer.Execute(&output, "tasks", struct{}{}); err == nil {
t.Fatal("Execute() with incomplete data error = nil")
}
}
func TestFreightPagesEscapeSourceDataAndCreateSynchronousOrderSync(t *testing.T) {
now := time.Date(2026, 7, 28, 3, 4, 5, 0, time.UTC)
service := &fakeFreightService{
fakeService: &fakeService{},
orders: []FreightOrder{{
2026-07-29 15:34:51 +08:00
ID: testTaskID,
ExternalStockID: "12",
SourceCode: `<script>private</script>`,
ShopName: "测试店铺",
ItemCount: 2,
Revision: 1,
UpdatedAt: now,
PreviewTitle: "脱敏商品标题",
PreviewImageStatus: "READY",
PreviewImageURL: "/api/v1/freight-items/preview/image",
}},
createResult: FreightSync{
ID: testTaskID,
Status: "SUCCEEDED",
CreatedAt: now,
},
}
router := newTestRouter(t, service)
list := performRequest(t, router, http.MethodGet, "/freight", nil, "")
if list.Code != http.StatusOK ||
strings.Contains(list.Body.String(), `<script>private</script>`) ||
!strings.Contains(list.Body.String(), "&lt;script&gt;private") ||
2026-07-29 15:34:51 +08:00
!strings.Contains(list.Body.String(), "共 2 项") ||
!strings.Contains(list.Body.String(), `width="56" height="56"`) ||
!strings.Contains(list.Body.String(), `loading="lazy"`) ||
!strings.Contains(list.Body.String(), "脱敏商品标题") {
t.Fatalf("freight list status/body = %d / %s", list.Code, list.Body)
}
assertSecurityHeaders(t, list)
form := performRequest(
t,
router,
http.MethodGet,
"/freight/import",
nil,
"",
)
2026-07-29 00:26:05 +08:00
if !strings.Contains(form.Body.String(), "同步至现在") ||
!strings.Contains(form.Body.String(), `name="created_from"`) ||
!strings.Contains(form.Body.String(), "尚无日期同步水位") {
t.Fatalf("freight import form = %s", form.Body)
}
cookie := csrfCookie(t, form)
idempotencyKey := hiddenValue(
t,
form.Body.String(),
"idempotency_key",
)
values := url.Values{
"csrf_token": {cookie.Value},
"idempotency_key": {idempotencyKey},
"order_number": {"SOURCE-12"},
}
request := httptest.NewRequest(
http.MethodPost,
"/freight/import",
strings.NewReader(values.Encode()),
)
request.Header.Set("Content-Type", "application/x-www-form-urlencoded")
request.AddCookie(cookie)
response := httptest.NewRecorder()
router.ServeHTTP(response, request)
if response.Code != http.StatusSeeOther ||
response.Header().Get("Location") !=
"/freight?notice=import-succeeded" {
t.Fatalf(
"create sync status/location = %d / %q",
response.Code,
response.Header().Get("Location"),
)
}
if service.createInput.OrderNumber != "SOURCE-12" ||
service.createInput.IdempotencyKey != idempotencyKey {
t.Fatalf("create input = %+v", service.createInput)
}
list = performRequest(
t,
router,
http.MethodGet,
"/freight?notice=import-succeeded",
nil,
"",
)
if !strings.Contains(list.Body.String(), "ERP 货运单已同步") {
t.Fatalf("freight success notice = %s", list.Body)
}
}
2026-07-29 00:26:05 +08:00
func TestFreightDateFormSubmitsManualRange(t *testing.T) {
now := time.Date(2026, 7, 28, 3, 4, 5, 0, time.UTC)
service := &fakeFreightService{
fakeService: &fakeService{},
watermark: &FreightWatermark{
LastSuccessfulTo: now,
LastSuccessfulRunID: testTaskID,
UpdatedAt: now,
},
createResult: FreightSync{
ID: testTaskID,
Mode: "CREATED_RANGE",
Status: "PENDING",
CreatedAt: now,
},
}
router := newTestRouter(t, service)
form := performRequest(t, router, http.MethodGet, "/freight/import", nil, "")
if !strings.Contains(form.Body.String(), "增量同步水位") ||
!strings.Contains(form.Body.String(), testTaskID) {
t.Fatalf("watermark form = %s", form.Body)
}
cookie := csrfCookie(t, form)
key := hiddenValue(t, form.Body.String(), "idempotency_key")
values := url.Values{
"csrf_token": {cookie.Value},
"idempotency_key": {key},
"mode": {"CREATED_RANGE"},
"created_from": {"2026-07-22"},
"created_to": {"2026-07-28"},
}
request := httptest.NewRequest(
http.MethodPost,
"/freight/import",
strings.NewReader(values.Encode()),
)
request.Header.Set("Content-Type", "application/x-www-form-urlencoded")
request.AddCookie(cookie)
response := httptest.NewRecorder()
router.ServeHTTP(response, request)
if response.Code != http.StatusSeeOther ||
response.Header().Get("Location") !=
"/freight/import?sync="+testTaskID ||
2026-07-29 00:26:05 +08:00
service.createInput.Mode != "CREATED_RANGE" ||
service.createInput.CreatedFrom != "2026-07-22" ||
service.createInput.CreatedTo != "2026-07-28" {
t.Fatalf(
"date form response/input = %d / %+v",
response.Code,
service.createInput,
)
}
}
func TestFreightSyncToNowIgnoresPrefilledManualDates(t *testing.T) {
service := &fakeFreightService{
fakeService: &fakeService{},
createResult: FreightSync{
ID: testTaskID,
Mode: "CREATED_RANGE",
Status: "PENDING",
},
}
router := newTestRouter(t, service)
form := performRequest(t, router, http.MethodGet, "/freight/import", nil, "")
cookie := csrfCookie(t, form)
key := hiddenValue(t, form.Body.String(), "idempotency_key")
values := url.Values{
"csrf_token": {cookie.Value},
"idempotency_key": {key},
"mode": {"CREATED_RANGE"},
"created_from": {"2026-07-22"},
"created_to": {"2026-07-28"},
"sync_to_now": {"true"},
}
request := httptest.NewRequest(
http.MethodPost,
"/freight/import",
strings.NewReader(values.Encode()),
)
request.Header.Set("Content-Type", "application/x-www-form-urlencoded")
request.AddCookie(cookie)
response := httptest.NewRecorder()
router.ServeHTTP(response, request)
if response.Code != http.StatusSeeOther ||
!service.createInput.SyncToNow ||
service.createInput.CreatedFrom != "" ||
service.createInput.CreatedTo != "" {
t.Fatalf(
"sync-to-now response/input = %d / %+v",
response.Code,
service.createInput,
)
}
}
2026-07-29 10:48:06 +08:00
func TestERPConnectionRoutesAreNotRegistered(t *testing.T) {
router := newTestRouter(t, &fakeService{})
for _, target := range []string{"/erp", "/erp/captcha", "/erp/login"} {
response := performRequest(t, router, http.MethodGet, target, nil, "")
if response.Code != http.StatusNotFound {
t.Fatalf("%s status = %d", target, response.Code)
}
}
}
2026-07-29 10:58:28 +08:00
func TestFreightImportShowsStablePreflightErrorDialog(t *testing.T) {
testCases := []struct {
name string
err error
status int
code string
title string
}{
{"ocr", ErrOCRServiceInvalid, http.StatusServiceUnavailable, "OCR_SERVICE_INVALID", "OCR 服务无效"},
{"not configured", ErrERPNotConfigured, http.StatusUnprocessableEntity, "ERP_NOT_CONFIGURED", "ERP 凭证未配置"},
{"session", ErrERPSessionNeeded, http.StatusServiceUnavailable, "ERP_SESSION_REQUIRED", "ERP 会话已失效"},
{"not found", ErrERPFreightNotFound, http.StatusNotFound, "ERP_FREIGHT_NOT_FOUND", "未找到货运单"},
2026-07-29 10:58:28 +08:00
{"login rejected", ErrERPLoginRejected, http.StatusUnprocessableEntity, "ERP_LOGIN_REJECTED", "ERP 登录被拒绝"},
{"protocol", ErrERPProtocol, http.StatusBadGateway, "ERP_RESPONSE_INVALID", "ERP 响应无效"},
{"unavailable", ErrERPUnavailable, http.StatusServiceUnavailable, "ERP_UNAVAILABLE", "ERP 暂时不可用"},
{"busy", ErrFreightSyncBusy, http.StatusConflict, "FREIGHT_SYNC_BUSY", "ERP 正在同步"},
{"timeout", ErrFreightSyncTimeout, http.StatusGatewayTimeout, "FREIGHT_SYNC_TIMEOUT", "ERP 同步超时"},
2026-07-29 09:51:41 +08:00
}
2026-07-29 10:58:28 +08:00
for _, testCase := range testCases {
t.Run(testCase.name, func(t *testing.T) {
service := &fakeFreightService{fakeService: &fakeService{}, err: testCase.err}
2026-07-29 11:08:23 +08:00
var event string
router := newTestRouterWithLogger(t, service, func(value string) {
event = value
})
2026-07-29 10:58:28 +08:00
page := performRequest(t, router, http.MethodGet, "/freight/import", nil, "")
if page.Code != http.StatusOK {
t.Fatalf("import page status = %d", page.Code)
}
cookie := csrfCookie(t, page)
values := url.Values{
"csrf_token": {cookie.Value},
"idempotency_key": {mustToken(t)},
"mode": {"ORDER_NUMBER"},
"order_number": {"ORDER-123"},
}
request := httptest.NewRequest(http.MethodPost, "/freight/import", strings.NewReader(values.Encode()))
request.Header.Set("Content-Type", "application/x-www-form-urlencoded")
request.AddCookie(cookie)
response := httptest.NewRecorder()
router.ServeHTTP(response, request)
if response.Code != testCase.status ||
!strings.Contains(response.Body.String(), testCase.code) ||
!strings.Contains(response.Body.String(), testCase.title) ||
!strings.Contains(response.Body.String(), "ORDER-123") ||
strings.Contains(response.Body.String(), "private") ||
service.createInput.OrderNumber != "ORDER-123" {
t.Fatalf("dialog response/input = %d / %s / %+v", response.Code, response.Body, service.createInput)
}
wantEvent := "freight_import_failed code=" +
2026-07-29 11:08:23 +08:00
testCase.code + " status=" + strconv.Itoa(testCase.status)
if event != wantEvent || strings.Contains(event, "private") {
t.Fatalf("event = %q, want %q", event, wantEvent)
}
2026-07-29 10:58:28 +08:00
})
2026-07-29 09:51:41 +08:00
}
}
2026-07-29 11:08:23 +08:00
func TestFreightImportLogsUnknownFailureWithoutRawError(t *testing.T) {
service := &fakeFreightService{
fakeService: &fakeService{},
err: errors.New("private ERP response with cookie and password"),
}
var event string
router := newTestRouterWithLogger(t, service, func(value string) {
event = value
})
page := performRequest(t, router, http.MethodGet, "/freight/import", nil, "")
cookie := csrfCookie(t, page)
values := url.Values{
"csrf_token": {cookie.Value},
"idempotency_key": {mustToken(t)},
"mode": {"ORDER_NUMBER"},
"order_number": {"ORDER-123"},
}
request := httptest.NewRequest(http.MethodPost, "/freight/import", strings.NewReader(values.Encode()))
request.Header.Set("Content-Type", "application/x-www-form-urlencoded")
request.AddCookie(cookie)
response := httptest.NewRecorder()
router.ServeHTTP(response, request)
if response.Code != http.StatusInternalServerError ||
event != "freight_import_failed status=500" ||
strings.Contains(event, "private") {
t.Fatalf("response/event = %d / %q", response.Code, event)
}
}
func TestFreightDetailCreatesProcurementTaskWithCSRF(t *testing.T) {
const itemID = "00000000-0000-4000-8000-000000000002"
2026-07-29 15:34:51 +08:00
price := int64(12950)
service := &fakeProcurementService{
fakeFreightService: &fakeFreightService{
fakeService: &fakeService{},
orderDetail: FreightOrderDetail{
Order: FreightOrder{
ID: testTaskID,
ExternalStockID: "12",
SourceCode: "SOURCE-12",
2026-07-29 15:34:51 +08:00
ItemCount: 1,
},
Items: []FreightItemReview{{
Item: FreightOrderItem{
2026-07-29 15:34:51 +08:00
ID: itemID,
ExternalItemID: "88",
Title: "商品",
ProductSpec: "黑色,L",
SKU: "黑色,L",
Quantity: 2,
OriginalUnitPriceMinor: &price,
OriginalCurrency: "TWD",
ImageStatus: "READY",
ImageURL: "/api/v1/freight-items/" + itemID + "/image",
},
Request: &ProcurementRequest{
ID: itemID,
FreightOrderItemID: itemID,
Status: "READY",
StatusLabel: "可以生成任务",
},
}},
},
},
createTaskResult: Task{
ID: testTaskID,
Status: "PENDING",
},
}
router := newTestRouter(t, service)
detail := performRequest(
t,
router,
http.MethodGet,
"/freight/"+testTaskID,
nil,
"",
)
if detail.Code != http.StatusOK ||
!strings.Contains(detail.Body.String(), "生成采购任务") ||
2026-07-29 15:34:51 +08:00
!strings.Contains(detail.Body.String(), "可以生成任务") ||
!strings.Contains(detail.Body.String(), "TWD 129.50") ||
!strings.Contains(detail.Body.String(), `width="84" height="84"`) ||
!strings.Contains(detail.Body.String(), `loading="lazy"`) ||
strings.Contains(detail.Body.String(), "图片引用") ||
strings.Count(detail.Body.String(), "黑色,L") != 1 {
t.Fatalf("detail status/body = %d / %s", detail.Code, detail.Body)
}
cookie := csrfCookie(t, detail)
taskKey := hiddenValue(t, detail.Body.String(), "task_key")
values := url.Values{
"csrf_token": {cookie.Value},
"order_id": {testTaskID},
"task_key": {taskKey},
}
request := httptest.NewRequest(
http.MethodPost,
"/freight/procurement-requests/"+itemID+"/purchase-task",
strings.NewReader(values.Encode()),
)
request.Header.Set("Content-Type", "application/x-www-form-urlencoded")
request.AddCookie(cookie)
response := httptest.NewRecorder()
router.ServeHTTP(response, request)
if response.Code != http.StatusSeeOther ||
response.Header().Get("Location") != "/tasks/"+testTaskID {
t.Fatalf(
"create task status/location = %d / %q",
response.Code,
response.Header().Get("Location"),
)
}
if service.createTaskInput.RequestID != itemID ||
service.createTaskInput.IdempotencyKey != taskKey {
t.Fatalf("create task input = %+v", service.createTaskInput)
}
}
2026-07-29 15:34:51 +08:00
func TestFreightPresentationFormatsMoneyAndImageStates(t *testing.T) {
value := int64(12905)
if got := formatMinorCurrency(&value, "TWD"); got != "TWD 129.05" {
t.Fatalf("formatMinorCurrency() = %q", got)
}
if got := formatMinorCurrency(nil, "TWD"); got != "未提供" {
t.Fatalf("nil formatMinorCurrency() = %q", got)
}
for status, want := range map[string]string{
"": "未提供图片",
"PENDING": "图片待获取",
"MISSING": "ERP 无图片",
"FAILED": "图片获取失败",
} {
if got := freightImageStatusLabel(status); got != want {
t.Fatalf("freightImageStatusLabel(%q) = %q", status, got)
}
}
}
type fakeService struct {
2026-07-28 12:50:42 +08:00
listInput ListTasksInput
listResult TaskList
listErr error
getResult Task
getErr error
uploadResult UploadedAsset
uploadErr error
uploadInput UploadReferenceInput
uploadBody []byte
uploadCalls int
createResult Task
createErr error
createInput CreateTaskInput
createCalls int
cancelResult Task
cancelErr error
cancelInput CancelTaskInput
authorizeResult OrderAuthorization
authorizeErr error
authorizeInput AuthorizeOrderInput
}
type fakeFreightService struct {
*fakeService
orders []FreightOrder
orderDetail FreightOrderDetail
sync FreightSync
createInput CreateFreightSyncInput
createResult FreightSync
2026-07-29 00:26:05 +08:00
watermark *FreightWatermark
err error
}
2026-07-29 09:51:41 +08:00
type fakeERPService struct {
*fakeService
status ERPConnectionStatus
image ERPCaptchaImage
ticket string
captchaRequests int
login ERPLoginInput
err error
}
func (service *fakeERPService) ERPConnectionStatus(
context.Context,
) (ERPConnectionStatus, error) {
return service.status, service.err
}
func (service *fakeERPService) RequestERPCaptcha(
context.Context,
) (ERPConnectionStatus, error) {
service.captchaRequests++
service.status.CaptchaReady = true
service.status.CaptchaTicket = service.ticket
return service.status, service.err
}
func (service *fakeERPService) OpenERPCaptcha(
context.Context,
string,
) (ERPCaptchaImage, error) {
return service.image, service.err
}
func (service *fakeERPService) LoginERP(
_ context.Context,
input ERPLoginInput,
) (ERPConnectionStatus, error) {
service.login = input
if service.err == nil {
service.status.Authenticated = true
service.status.CaptchaReady = false
service.status.CaptchaTicket = ""
}
return service.status, service.err
}
type fakeProcurementService struct {
*fakeFreightService
createRequestInput CreateProcurementRequestInput
bindInput BindProcurementReferenceInput
createTaskInput CreateProcurementTaskInput
procurementResult ProcurementRequest
createTaskResult Task
procurementError error
}
func (service *fakeProcurementService) CreateProcurementRequest(
_ context.Context,
input CreateProcurementRequestInput,
) (ProcurementRequest, error) {
service.createRequestInput = input
return service.procurementResult, service.procurementError
}
func (service *fakeProcurementService) BindProcurementReference(
_ context.Context,
input BindProcurementReferenceInput,
) (ProcurementRequest, error) {
service.bindInput = input
return service.procurementResult, service.procurementError
}
func (service *fakeProcurementService) CreateProcurementTask(
_ context.Context,
input CreateProcurementTaskInput,
) (Task, error) {
service.createTaskInput = input
return service.createTaskResult, service.procurementError
}
func (service *fakeFreightService) ListFreightOrders(
context.Context,
int,
) ([]FreightOrder, error) {
return service.orders, service.err
}
func (service *fakeFreightService) GetFreightOrder(
context.Context,
string,
) (FreightOrderDetail, error) {
return service.orderDetail, service.err
}
func (service *fakeFreightService) GetFreightSync(
context.Context,
string,
) (FreightSync, error) {
return service.sync, service.err
}
2026-07-29 00:26:05 +08:00
func (service *fakeFreightService) GetFreightWatermark(
context.Context,
) (*FreightWatermark, error) {
return service.watermark, service.err
}
func (service *fakeFreightService) CreateFreightSync(
_ context.Context,
input CreateFreightSyncInput,
) (FreightSync, error) {
service.createInput = input
return service.createResult, service.err
}
func (service *fakeService) ListTasks(
_ context.Context,
input ListTasksInput,
) (TaskList, error) {
service.listInput = input
return service.listResult, service.listErr
}
func (service *fakeService) GetTask(
context.Context,
string,
) (Task, error) {
return service.getResult, service.getErr
}
func (service *fakeService) UploadReference(
_ context.Context,
input UploadReferenceInput,
) (UploadedAsset, error) {
service.uploadCalls++
service.uploadInput = input
content, err := io.ReadAll(input.Content)
if err != nil {
return UploadedAsset{}, err
}
service.uploadBody = content
return service.uploadResult, service.uploadErr
}
func (service *fakeService) CreateTask(
_ context.Context,
input CreateTaskInput,
) (Task, error) {
service.createCalls++
service.createInput = input
return service.createResult, service.createErr
}
func (service *fakeService) CancelTask(
_ context.Context,
input CancelTaskInput,
) (Task, error) {
service.cancelInput = input
return service.cancelResult, service.cancelErr
}
2026-07-28 12:50:42 +08:00
func (service *fakeService) AuthorizeOrder(
_ context.Context,
input AuthorizeOrderInput,
) (OrderAuthorization, error) {
service.authorizeInput = input
return service.authorizeResult, service.authorizeErr
}
func newTestRouter(t *testing.T, service Service) http.Handler {
2026-07-29 11:08:23 +08:00
return newTestRouterWithLogger(t, service, nil)
}
func newTestRouterWithLogger(
t *testing.T,
service Service,
logEvent EventLogger,
) http.Handler {
t.Helper()
gin.SetMode(gin.TestMode)
renderer, err := NewRenderer()
if err != nil {
t.Fatalf("NewRenderer() error = %v", err)
}
2026-07-29 11:08:23 +08:00
handler, err := NewHandler(service, renderer, logEvent)
if err != nil {
t.Fatalf("NewHandler() error = %v", err)
}
router := gin.New()
handler.Register(router)
return router
}
func performRequest(
t *testing.T,
handler http.Handler,
method string,
path string,
body io.Reader,
contentType string,
) *httptest.ResponseRecorder {
t.Helper()
request := httptest.NewRequest(method, path, body)
if contentType != "" {
request.Header.Set("Content-Type", contentType)
}
response := httptest.NewRecorder()
handler.ServeHTTP(response, request)
return response
}
func getCSRFCookie(t *testing.T, handler http.Handler) *http.Cookie {
t.Helper()
response := performRequest(
t,
handler,
http.MethodGet,
"/tasks/new",
nil,
"",
)
return csrfCookie(t, response)
}
func csrfCookie(
t *testing.T,
response *httptest.ResponseRecorder,
) *http.Cookie {
t.Helper()
for _, cookie := range response.Result().Cookies() {
if cookie.Name == csrfCookieName &&
cookie.Path == "/" &&
cookie.Value != "" {
return cookie
}
}
t.Fatal("CSRF cookie not found")
return nil
}
func multipartBody(
t *testing.T,
fields map[string]string,
fileField string,
fileName string,
content []byte,
) (*bytes.Buffer, string) {
t.Helper()
var body bytes.Buffer
writer := multipart.NewWriter(&body)
for name, value := range fields {
if err := writer.WriteField(name, value); err != nil {
t.Fatalf("WriteField(%s): %v", name, err)
}
}
if fileField != "" {
part, err := writer.CreateFormFile(fileField, fileName)
if err != nil {
t.Fatalf("CreateFormFile(): %v", err)
}
if _, err := part.Write(content); err != nil {
t.Fatalf("write file: %v", err)
}
}
if err := writer.Close(); err != nil {
t.Fatalf("close multipart: %v", err)
}
return &body, writer.FormDataContentType()
}
func validCreateBody(
t *testing.T,
csrfToken string,
overrides map[string]string,
) (*bytes.Buffer, string) {
t.Helper()
fields := map[string]string{
"csrf_token": csrfToken,
"title": "桌面收纳盒",
"sku": "SKU-1",
"description": "浅灰色",
"quantity": "2",
"max_budget": "60.00",
"upload_key": mustToken(t),
"create_key": mustToken(t),
}
for name, value := range overrides {
fields[name] = value
}
fileField := "image"
fileName := "reference.jpg"
content := []byte("image bytes")
if fields["image_asset_id"] != "" {
fileField = ""
fileName = ""
content = nil
}
return multipartBody(t, fields, fileField, fileName, content)
}
func mustToken(t *testing.T) string {
t.Helper()
token, err := newToken()
if err != nil {
t.Fatalf("newToken() error = %v", err)
}
return token
}
func hiddenValue(t *testing.T, body string, name string) string {
t.Helper()
pattern := regexp.MustCompile(
`name="` + regexp.QuoteMeta(name) + `" value="([^"]+)"`,
)
match := pattern.FindStringSubmatch(body)
if len(match) != 2 {
t.Fatalf("hidden field %q not found", name)
}
return match[1]
}
func assertSecurityHeaders(
t *testing.T,
response *httptest.ResponseRecorder,
) {
t.Helper()
required := map[string]string{
"Cache-Control": "no-store",
"Referrer-Policy": "no-referrer",
"X-Content-Type-Options": "nosniff",
"X-Frame-Options": "DENY",
}
for name, want := range required {
if got := response.Header().Get(name); got != want {
t.Fatalf("%s = %q, want %q", name, got, want)
}
}
csp := response.Header().Get("Content-Security-Policy")
for _, directive := range []string{
"default-src 'none'",
"form-action 'self'",
"frame-ancestors 'none'",
"script-src 'self'",
"style-src 'self'",
} {
if !strings.Contains(csp, directive) {
t.Fatalf("CSP missing %q: %s", directive, csp)
}
}
}
func TestUsecaseErrorMappingKeepsPublicSentinels(t *testing.T) {
tests := []struct {
kind usecase.ErrorKind
code string
want error
}{
{
kind: usecase.ErrorKindInvalid,
code: "ASSET_IMAGE_INVALID",
want: ErrInvalidFile,
},
{
kind: usecase.ErrorKindInvalid,
code: "TASK_VALIDATION_FAILED",
want: ErrValidation,
},
{
kind: usecase.ErrorKindInvalid,
code: "TASK_CANCEL_INVALID",
want: ErrNotFound,
},
{
kind: usecase.ErrorKindNotFound,
code: "TASK_NOT_FOUND",
want: ErrNotFound,
},
{
kind: usecase.ErrorKindConflict,
code: "TASK_STATE_CONFLICT",
want: ErrConflict,
},
{
kind: usecase.ErrorKindUnavailable,
code: "STORAGE_UNAVAILABLE",
want: ErrUnavailable,
},
}
for _, test := range tests {
t.Run(test.code, func(t *testing.T) {
mapped := mapUsecaseError(&usecase.Error{
Kind: test.kind,
Code: test.code,
})
if !errors.Is(mapped, test.want) {
t.Fatalf("mapped error = %v, want %v", mapped, test.want)
}
})
}
}