feat(admin): authorize batch purchase starts
This commit is contained in:
@@ -8,6 +8,7 @@ import (
|
||||
"regexp"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"cmbuyer/admin/internal/auth"
|
||||
"cmbuyer/admin/internal/server"
|
||||
@@ -190,7 +191,7 @@ func TestTaskCreationRendersSharedFormsAndPersistsOnlyDraft(t *testing.T) {
|
||||
if fullPage.Code != http.StatusOK {
|
||||
t.Fatalf("GET full form status = %d, want 200", fullPage.Code)
|
||||
}
|
||||
for _, want := range []string{`<div class="modal-scrim"`, `<dialog open`, `aria-modal="true"`, `name="title"`, `name="product_url"`, `name="sku_color"`, `name="sku_size"`, `name="quantity"`, `name="max_total_price"`, `type="url" inputmode="url" maxlength="2048"`, `type="number" inputmode="numeric" min="1" step="1"`, `inputmode="decimal" pattern="[0-9]+(\.[0-9]{1,2})?"`, `maxlength="120"`, `maxlength="80"`, `required`, `autofocus`, `导入</button><a class="button primary"`, `type="search" disabled`, `disabled>筛选</button>`, `disabled>清除</button>`, `min-height:44px`, `overflow-x:auto`, `prefers-reduced-motion`} {
|
||||
for _, want := range []string{`<div class="modal-scrim"`, `<dialog open`, `aria-modal="true"`, `name="title"`, `name="product_url"`, `name="sku_color"`, `name="sku_size"`, `name="quantity"`, `name="max_total_price"`, `type="url" inputmode="url" maxlength="2048"`, `type="number" inputmode="numeric" min="1" step="1"`, `inputmode="decimal" pattern="[0-9]+(\.[0-9]{1,2})?"`, `maxlength="120"`, `maxlength="80"`, `required`, `autofocus`, `导入</button><a class="button primary"`, `type="search"`, `data-start-purchases`, `data-select-all`, `最高总额`, `min-height:44px`, `:focus-visible`, `overflow-x:auto`, `prefers-reduced-motion`} {
|
||||
if !strings.Contains(modal.Body.String(), want) {
|
||||
t.Fatalf("dialog form is missing %q", want)
|
||||
}
|
||||
@@ -277,13 +278,108 @@ func TestTaskCreationRendersSharedFormsAndPersistsOnlyDraft(t *testing.T) {
|
||||
t.Fatalf("task list is missing %q", want)
|
||||
}
|
||||
}
|
||||
for _, forbidden := range []string{"utm_source", "试选", "PENDING", "支付", "订单确认", "真机", "提交订单"} {
|
||||
for _, forbidden := range []string{"utm_source", "试选", "订单确认", "真机", "提交订单"} {
|
||||
if strings.Contains(body, forbidden) {
|
||||
t.Fatalf("task list exposed deferred scope %q", forbidden)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestTasksPageKeepsOriginalShellAndRendersFilteredWorkbench(t *testing.T) {
|
||||
store := &memoryStore{rows: []tasks.TaskRow{
|
||||
{ID: "b3c9f507-7473-4fa6-8d71-8786c34c6301", Title: "待开始衬衫", GoodsID: "937122477375", SKUColor: "黑色", SKUSize: "M", Quantity: 2, MaxTotalPrice: "12.80", Status: "DRAFT", Version: 3, CreatedAt: time.Date(2026, 8, 4, 1, 2, 3, 0, time.UTC)},
|
||||
{ID: "c3c9f507-7473-4fa6-8d71-8786c34c6301", Title: "等待领取衬衫", GoodsID: "958756616606", SKUColor: "白色", SKUSize: "L", Quantity: 1, MaxTotalPrice: "20.00", Status: "PENDING", Version: 4, CreatedAt: time.Date(2026, 8, 4, 2, 3, 4, 0, time.UTC)},
|
||||
}}
|
||||
router, _ := newRouterWithStore(t, store)
|
||||
cookie := authenticate(t, router)
|
||||
query := url.Values{"keyword": {"衬衫"}, "created_from": {"2026-08-04"}, "created_to": {"2026-08-04"}}
|
||||
response := serve(router, http.MethodGet, "/tasks?"+query.Encode(), nil, cookie)
|
||||
if response.Code != http.StatusOK {
|
||||
t.Fatalf("filtered tasks status = %d, want 200", response.Code)
|
||||
}
|
||||
body := response.Body.String()
|
||||
for _, want := range []string{
|
||||
`<a class="skip" href="#main">`,
|
||||
`:focus-visible`,
|
||||
`min-height:44px`,
|
||||
`@media(max-width:420px)`,
|
||||
`prefers-reduced-motion`,
|
||||
`<button class="button" type="button" disabled>导入</button><a class="button primary" href="/tasks?create=1">创建任务</a>`,
|
||||
`name="keyword" type="search" value="衬衫"`,
|
||||
`name="created_from" type="date" value="2026-08-04"`,
|
||||
`name="created_to" type="date" value="2026-08-04"`,
|
||||
`data-start-purchases`,
|
||||
`data-selection-summary aria-live="polite"`,
|
||||
`系统不会付款`,
|
||||
`开始采购(只创建待付款订单)`,
|
||||
`采购结果`,
|
||||
`创建时间(上海)`,
|
||||
`https://mobile.yangkeduo.com/goods.html?goods_id=937122477375`,
|
||||
`target="_blank" rel="noopener noreferrer"`,
|
||||
`待开始`,
|
||||
`已授权待领取`,
|
||||
`datetime="2026-08-04T09:02:03+08:00">2026-08-04 09:02`,
|
||||
`<script src="/static/tasks.js" defer></script>`,
|
||||
} {
|
||||
if !strings.Contains(body, want) {
|
||||
t.Fatalf("workbench is missing %q", want)
|
||||
}
|
||||
}
|
||||
if strings.Index(body, `name="keyword"`) > strings.Index(body, `data-start-purchases`) || strings.Index(body, `data-start-purchases`) > strings.Index(body, `<div class="table-wrap">`) {
|
||||
t.Fatal("workbench rows are not ordered as toolbar, filters, batch actions, table")
|
||||
}
|
||||
if count := strings.Count(body, `data-task-id=`); count != 1 {
|
||||
t.Fatalf("selectable row count = %d, want only the DRAFT row", count)
|
||||
}
|
||||
for _, forbidden := range []string{`<th scope="col">操作</th>`, `确认开始采购`, `确认机器选对了吗`} {
|
||||
if strings.Contains(body, forbidden) {
|
||||
t.Fatalf("workbench exposed forbidden per-row or confirmation UI %q", forbidden)
|
||||
}
|
||||
}
|
||||
if store.listTasksCalls != 1 || store.listDraftsCalls != 0 {
|
||||
t.Fatalf("GET /tasks calls = (ListTasks %d, ListDrafts %d), want (1, 0)", store.listTasksCalls, store.listDraftsCalls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTasksPageRerendersAccessibleFilterErrorsAndKeepsValues(t *testing.T) {
|
||||
store := &memoryStore{}
|
||||
router, _ := newRouterWithStore(t, store)
|
||||
cookie := authenticate(t, router)
|
||||
query := url.Values{
|
||||
"keyword": {`保留%_\`},
|
||||
"status": {"UNKNOWN"},
|
||||
"created_from": {"2026-02-30"},
|
||||
"created_to": {"not-a-date"},
|
||||
}
|
||||
response := serve(router, http.MethodGet, "/tasks?"+query.Encode(), nil, cookie)
|
||||
if response.Code != http.StatusBadRequest {
|
||||
t.Fatalf("invalid filter status = %d, want 400", response.Code)
|
||||
}
|
||||
body := response.Body.String()
|
||||
for _, want := range []string{
|
||||
`role="alert" aria-live="assertive"`,
|
||||
`href="#filter-status"`,
|
||||
`href="#filter-created-from"`,
|
||||
`href="#filter-created-to"`,
|
||||
`name="keyword" type="search" value="保留%_\"`,
|
||||
`<option value="UNKNOWN" selected>无效状态:UNKNOWN</option>`,
|
||||
`name="created_from" type="date" value="2026-02-30" aria-invalid="true" aria-describedby="filter-created-from-error"`,
|
||||
`name="created_to" type="date" value="not-a-date" aria-invalid="true" aria-describedby="filter-created-to-error"`,
|
||||
`id="filter-status-error"`,
|
||||
`id="filter-created-from-error"`,
|
||||
`id="filter-created-to-error"`,
|
||||
`筛选条件有误`,
|
||||
} {
|
||||
if !strings.Contains(body, want) {
|
||||
t.Fatalf("invalid filter page is missing %q", want)
|
||||
}
|
||||
}
|
||||
if store.listTasksCalls != 0 || store.listDraftsCalls != 0 {
|
||||
t.Fatalf("invalid filter queried stores: ListTasks=%d ListDrafts=%d", store.listTasksCalls, store.listDraftsCalls)
|
||||
}
|
||||
assertSecurityHeaders(t, response)
|
||||
}
|
||||
|
||||
func TestTaskCreationRequiresAuthenticationAndCSRF(t *testing.T) {
|
||||
router, _ := newRouter(t)
|
||||
if response := serve(router, http.MethodPost, "/tasks", url.Values{}, nil); response.Code != http.StatusForbidden {
|
||||
@@ -327,7 +423,7 @@ func assertSecurityHeaders(t *testing.T, response *httptest.ResponseRecorder) {
|
||||
"Cache-Control": "no-store",
|
||||
"X-Content-Type-Options": "nosniff",
|
||||
"Referrer-Policy": "no-referrer",
|
||||
"Content-Security-Policy": "default-src 'self'; style-src 'self' 'unsafe-inline'; script-src 'none'; object-src 'none'; base-uri 'none'; frame-ancestors 'none'; form-action 'self'",
|
||||
"Content-Security-Policy": "default-src 'self'; style-src 'self' 'unsafe-inline'; script-src 'self'; object-src 'none'; base-uri 'none'; frame-ancestors 'none'; form-action 'self'",
|
||||
}
|
||||
for name, expected := range want {
|
||||
if got := response.Header().Get(name); got != expected {
|
||||
@@ -381,6 +477,10 @@ func TestLogoutRequiresCSRFAndRevokesSession(t *testing.T) {
|
||||
}
|
||||
|
||||
func newRouter(t *testing.T) (*gin.Engine, *auth.Manager) {
|
||||
return newRouterWithStore(t, &memoryStore{})
|
||||
}
|
||||
|
||||
func newRouterWithStore(t *testing.T, store tasks.Store) (*gin.Engine, *auth.Manager) {
|
||||
t.Helper()
|
||||
gin.SetMode(gin.TestMode)
|
||||
hash, err := bcrypt.GenerateFromPassword([]byte("test-password"), bcrypt.MinCost)
|
||||
@@ -392,7 +492,7 @@ func newRouter(t *testing.T) (*gin.Engine, *auth.Manager) {
|
||||
AdminUsername: "admin",
|
||||
AdminPasswordBcrypt: string(hash),
|
||||
Sessions: manager,
|
||||
Tasks: &memoryStore{},
|
||||
Tasks: store,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("NewRouter: %v", err)
|
||||
@@ -400,7 +500,12 @@ func newRouter(t *testing.T) (*gin.Engine, *auth.Manager) {
|
||||
return router, manager
|
||||
}
|
||||
|
||||
type memoryStore struct{ drafts []tasks.Draft }
|
||||
type memoryStore struct {
|
||||
drafts []tasks.Draft
|
||||
rows []tasks.TaskRow
|
||||
listDraftsCalls int
|
||||
listTasksCalls int
|
||||
}
|
||||
|
||||
func (store *memoryStore) CreateDraft(_ context.Context, draft tasks.Draft) (tasks.Draft, error) {
|
||||
for _, existing := range store.drafts {
|
||||
@@ -415,8 +520,23 @@ func (store *memoryStore) CreateDraft(_ context.Context, draft tasks.Draft) (tas
|
||||
return draft, nil
|
||||
}
|
||||
func (store *memoryStore) ListDrafts(_ context.Context) ([]tasks.Draft, error) {
|
||||
store.listDraftsCalls++
|
||||
return append([]tasks.Draft(nil), store.drafts...), nil
|
||||
}
|
||||
func (store *memoryStore) ListTasks(_ context.Context, _ tasks.TaskFilter) ([]tasks.TaskRow, error) {
|
||||
store.listTasksCalls++
|
||||
if store.rows != nil {
|
||||
return append([]tasks.TaskRow(nil), store.rows...), nil
|
||||
}
|
||||
result := make([]tasks.TaskRow, 0, len(store.drafts))
|
||||
for _, draft := range store.drafts {
|
||||
result = append(result, tasks.TaskRow{ID: draft.ID, Title: draft.Title, GoodsID: draft.GoodsID, SKUColor: draft.SKUColor, SKUSize: draft.SKUSize, Quantity: draft.Quantity, MaxTotalPrice: draft.MaxTotalPrice, Status: "DRAFT", Version: 1, CreatedAt: draft.CreatedAt})
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
func (store *memoryStore) StartPurchases(_ context.Context, _ tasks.StartCommand, _ string) (tasks.StartResult, error) {
|
||||
return tasks.StartResult{}, tasks.ErrInvalidStart
|
||||
}
|
||||
|
||||
func serve(router http.Handler, method, target string, form url.Values, cookie *http.Cookie) *httptest.ResponseRecorder {
|
||||
var body *strings.Reader
|
||||
|
||||
Reference in New Issue
Block a user