Files
cmautobuy/admin/main_test.go
T

920 lines
33 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package main
import (
"fmt"
"net/http"
"net/http/httptest"
"net/url"
"os"
"strings"
"testing"
"time"
"cmautobuy/admin/model"
"cmautobuy/admin/repository"
"cmautobuy/admin/service"
)
func TestPaginationCSS_桌面右对齐且窄窗口不遮挡内容(t *testing.T) {
content, err := os.ReadFile("static/css/app.css")
if err != nil {
t.Fatal(err)
}
css := string(content)
statusbarRightAligned := `.statusbar {
position: fixed;
left: 0; right: 0; bottom: 0;
display: flex;
align-items: center;
justify-content: flex-end;`
for _, want := range []string{
statusbarRightAligned, "flex: 0 1 auto", "text-align: right",
"margin-left: 0", "flex: 0 0 auto", "@media (max-width: 760px)",
"position: static", ".statusbar > span:first-child { flex-basis: 100%; }",
} {
if !strings.Contains(css, want) {
t.Fatalf("分页布局 CSS 缺少 %q", want)
}
}
}
func TestModuleNavigation_按账号保存稳定列表状态且安全回退(t *testing.T) {
header, err := os.ReadFile("templates/partials/header.html")
if err != nil {
t.Fatal(err)
}
js, err := os.ReadFile("static/js/app.js")
if err != nil {
t.Fatal(err)
}
headerSource := string(header)
for _, want := range []string{
"data-module-navigation",
"data-ui-state-user=\"{{if .CurrentUser}}{{.CurrentUser.UserID}}{{end}}\"",
"data-module-root=\"/shopee\"",
"data-module-root=\"/pdd\"",
"data-module-root=\"/syb\"",
"data-module-root=\"/shops\"",
"data-module-root=\"/tasks\"",
"data-module-root=\"/clients\"",
"data-module-root=\"/users\"",
} {
if !strings.Contains(headerSource, want) {
t.Errorf("公共导航缺少模块状态标识 %q", want)
}
}
jsSource := string(js)
for _, want := range []string{
"var MODULE_QUERY_KEYS = {",
"\"/shopee\": [\"q\", \"search_field\", \"status\", \"deleted\", \"unlinked\", \"page\", \"page_size\"]",
"\"/pdd\": [\"q\", \"status\", \"page\", \"page_size\"]",
"\"/syb\": [\"order_no\", \"shop\", \"stage\", \"page\", \"page_size\", \"date_from\", \"date_to\"]",
"\"/tasks\": [\"type\", \"status\", \"creator\", \"q\", \"page\", \"page_size\"]",
"\"/clients\": [\"name\", \"page\", \"page_size\"]",
"\"/inner-codes\": [\"date\", \"status\", \"q\", \"page\", \"page_size\"]",
"\"/users\": [\"q\", \"status\", \"page\", \"page_size\"]",
"var MODULE_STATE_PREFIX = \"cmautobuy:module-state:\"",
"parsed.origin !== window.location.origin || parsed.pathname !== moduleRoot",
"sessionStorage.setItem(",
"sessionStorage.removeItem(moduleStateStorageKey(userID, moduleRoot))",
"setupModuleNavigationState();",
} {
if !strings.Contains(jsSource, want) {
t.Errorf("模块状态脚本缺少安全或交互约束 %q", want)
}
}
whitelistStart := strings.Index(jsSource, "var MODULE_QUERY_KEYS")
whitelistEnd := strings.Index(jsSource, "var MODULE_STATE_PREFIX")
if whitelistStart == -1 || whitelistEnd <= whitelistStart {
t.Fatal("没有找到模块查询参数白名单")
}
whitelist := jsSource[whitelistStart:whitelistEnd]
for _, forbidden := range []string{
"\"msg\"", "\"error\"", "\"history\"", "\"login\"", "\"change_password\"",
"\"open_syb_id\"", "\"purchase_intent\"",
} {
if strings.Contains(whitelist, forbidden) {
t.Errorf("稳定状态白名单不应包含临时参数 %s", forbidden)
}
}
}
func TestPageSizeSelect_切换后自动提交且无可见应用按钮(t *testing.T) {
footer, err := os.ReadFile("templates/partials/footer.html")
if err != nil {
t.Fatal(err)
}
footerSource := string(footer)
for _, want := range []string{
`method="get" class="page-size-form" data-page-size-form`,
`name="page_size" data-page-size-select`,
`data-page-size-loading role="status" hidden>加载中…</span>`,
} {
if !strings.Contains(footerSource, want) {
t.Errorf("公共分页缺少自动刷新结构 %q", want)
}
}
if strings.Contains(footerSource, `data-page-size-submit`) || strings.Contains(footerSource, `>应用</button>`) {
t.Fatal("公共分页不应继续显示重复的应用按钮")
}
js, err := os.ReadFile("static/js/app.js")
if err != nil {
t.Fatal(err)
}
jsSource := string(js)
for _, want := range []string{
`function setupPageSizeForms()`,
`select.addEventListener("change"`,
`form.getAttribute("aria-busy") === "true"`,
`form.requestSubmit()`,
`if (beginSubmit()) form.submit()`,
`loading.hidden = false`,
`setupPageSizeForms();`,
} {
if !strings.Contains(jsSource, want) {
t.Errorf("分页容量自动提交缺少交互约束 %q", want)
}
}
}
func TestPurchaseModal_未选规格行保持隐藏且不提交(t *testing.T) {
files := map[string][]string{
"static/css/app.css": {
".purchase-confirm-row[hidden] { display: none; }",
},
"templates/syb/list.html": {
`class="purchase-confirm-row" data-purchase-row="{{.SybID}}" data-purchase-quantity="{{.Quantity}}" hidden`,
`name="mapping_option_key" value="{{.MappingOptionKey}}" disabled`,
`name="context_version" value="{{.ContextVersion}}" disabled`,
`data-purchase-quantity="{{.Quantity}}"`,
`name="unit_price_limit_yuan"`,
`value="{{.DefaultUnitPrice}}" required disabled`,
`data-total-price-output aria-live="polite"`,
},
"static/js/app.js": {
"row.hidden = !enabled;",
"input.disabled = !enabled;",
"updateTotalPrice(row);",
},
}
for path, wants := range files {
content, err := os.ReadFile(path)
if err != nil {
t.Fatal(err)
}
for _, want := range wants {
if !strings.Contains(string(content), want) {
t.Errorf("%s 缺少采购弹窗回归约束 %q", path, want)
}
}
}
}
func TestSybBulkActions_按服务端动作能力隔离勾选项(t *testing.T) {
files := map[string][]string{
"templates/syb/list.html": {
`data-need-checked="collect"`, `data-need-checked="purchase"`, `data-ai-match-open`,
`data-select-action="{{.SelectionActions}}"`,
`{{if .CanCollect}}form="syb-collect-form"{{end}}`,
`action="/syb/collect-pdd-batch"`, `action="/syb/ai-match"`, `id="syb-collect-modal"`,
},
"static/js/app.js": {
`supportsSelectAction(box, action)`,
`checkedCount(action)`,
`supportsSelectAction(box, "purchase")`,
`supportsSelectAction(box, "ai")`,
`getAttribute("data-collect-action")`,
},
"handler/web/web.go": {
`pages.POST("/syb/collect-pdd-batch", h.SybCollectPddBatch)`,
},
}
for path, wants := range files {
content, err := os.ReadFile(path)
if err != nil {
t.Fatal(err)
}
for _, want := range wants {
if !strings.Contains(string(content), want) {
t.Errorf("%s 缺少动作隔离约束 %q", path, want)
}
}
}
}
func TestPddDetailRecollect_使用独立入口并可选客户端(t *testing.T) {
content, err := os.ReadFile("templates/pdd/edit_modal.html")
if err != nil {
t.Fatal(err)
}
page := string(content)
for _, want := range []string{
`action="/pdd/recollect"`, `name="goods_id"`, `name="client_id"`,
"不指定(任意客户端可领取)", "成功后再用新结果覆盖",
} {
if !strings.Contains(page, want) {
t.Errorf("PDD 详情重新采集缺少 %q", want)
}
}
if strings.Contains(page, `id="pdd-recollect-form" method="post" action="/pdd/collect"`) {
t.Error("重新采集不能继续复用会跳过已采集商品的普通批量入口")
}
}
func TestTaskDetail_采购核单结果使用独立只读分组(t *testing.T) {
content, err := os.ReadFile("templates/task/detail_modal.html")
if err != nil {
t.Fatal(err)
}
page := string(content)
for _, want := range []string{
"PDD 核单结果", "PDD 订单编号", "下单时间", "上报时付款状态",
"{{if .IsPurchase}}", "{{if .HasPddOrder}}", "完整结果(result_data)",
} {
if !strings.Contains(page, want) {
t.Errorf("采购任务详情缺少 %q", want)
}
}
}
func TestTaskDetail_采购来源与安全重新创建入口(t *testing.T) {
files := map[string][]string{
"templates/task/detail_modal.html": {
"来源信息", "蝦皮订单号", "顺运宝明细 ID", "蝦皮商品 ID", "PDD 商品 ID",
`{{if .HasSybSource}}`, `href="{{.ViewSybURL}}"`, "查看原货运单",
`{{if .CanRecreatePurchase}}`, `href="{{.RecreatePurchaseURL}}"`, "重新创建采购任务",
},
"templates/syb/list.html": {
`data-auto-open-purchase-id="{{.OpenPurchaseSybID}}"`, `{{range .PurchaseRows}}`,
`data-auto-open-detail-id="{{.OpenSybID}}"`,
},
"static/js/app.js": {
`form.getAttribute("data-auto-open-purchase-id")`,
`modal.getAttribute("data-auto-open-detail-id")`,
`showPurchaseSelection(autoSelected)`, `loadDetail(autoDetailID)`,
},
}
for path, wants := range files {
content, err := os.ReadFile(path)
if err != nil {
t.Fatal(err)
}
for _, want := range wants {
if !strings.Contains(string(content), want) {
t.Errorf("%s 缺少采购来源恢复约束 %q", path, want)
}
}
}
}
func TestTaskPage_创建人筛选列表列和详情字段齐全(t *testing.T) {
files := map[string][]string{
"templates/task/list.html": {
`{{if .ShowCreator}}`, `name="creator"`, `{{.CreatorText}}`, `>筛选</button>`,
`<th>PDD 店铺</th>`, `title="{{.PddShopText}}"`,
`title="{{.ClientTitle}}"`, `{{if .ShowCreator}}10{{else}}9{{end}}`,
},
"templates/task/detail_modal.html": {
`<dt>创建人</dt><dd>{{.CreatorText}}</dd>`,
`<dt>客户端名称</dt><dd>{{.ClientText}}</dd>`,
`<dt>客户端 ID</dt><dd>{{.ClientIDText}}</dd>`,
`<dt>PDD 店铺</dt><dd>{{.PddShopText}}</dd>`,
},
}
for path, wants := range files {
content, err := os.ReadFile(path)
if err != nil {
t.Fatal(err)
}
for _, want := range wants {
if !strings.Contains(string(content), want) {
t.Errorf("%s 缺少任务创建人界面约束 %q", path, want)
}
}
}
}
func TestShopeePage_移除Excel入口且保留目录记录入口(t *testing.T) {
content, err := os.ReadFile("templates/shopee/list.html")
if err != nil {
t.Fatal(err)
}
page := string(content)
for _, removed := range []string{`action="/shopee/import"`, `type="file"`, "导入 Excel"} {
if strings.Contains(page, removed) {
t.Errorf("蝦皮页面仍包含已移除入口 %q", removed)
}
}
for _, want := range []string{`href="/integrations/catalog"`, "商品目录接口"} {
if !strings.Contains(page, want) {
t.Errorf("蝦皮页面缺少 %q", want)
}
}
}
func TestShopeePage_批量创建Pdd采集任务入口(t *testing.T) {
content, err := os.ReadFile("templates/shopee/list.html")
if err != nil {
t.Fatal(err)
}
page := string(content)
for _, want := range []string{
`action="/shopee/collect-batch"`, `data-modal-open="shopee-collect-modal"`,
`data-collect-form="shopee-bulk-form"`, `form="shopee-bulk-form"`,
`<label for="shopee-collect-client">执行客户端</label>`,
`不指定(任意客户端可领取)`, `.AssignableClients`, `data-collect-submit`,
} {
if !strings.Contains(page, want) {
t.Errorf("蝦皮批量采集入口缺少 %q", want)
}
}
}
func TestShopeeDetail_区分顺运宝观测与正式SKU(t *testing.T) {
content, err := os.ReadFile("templates/shopee/detail_modal.html")
if err != nil {
t.Fatal(err)
}
page := string(content)
for _, want := range []string{"顺运宝观测规格", "不是正式蝦皮 SKU", "最近货运单售价(台币)", "{{.MatchText}}"} {
if !strings.Contains(page, want) {
t.Errorf("蝦皮详情缺少 %q", want)
}
}
}
func TestSybPage_明确展示蝦皮商品Pdd关联来源(t *testing.T) {
list, err := os.ReadFile("templates/syb/list.html")
if err != nil {
t.Fatal(err)
}
detail, err := os.ReadFile("templates/syb/detail_modal.html")
if err != nil {
t.Fatal(err)
}
content := string(list) + string(detail)
for _, want := range []string{".PddAssociationText", "仅在需要更换采购商品时修改下面的关联"} {
if !strings.Contains(content, want) {
t.Errorf("顺运宝页面缺少关联复用提示 %q", want)
}
}
}
func TestSybPage_紧凑筛选和可见横向滚动(t *testing.T) {
page, err := os.ReadFile("templates/syb/list.html")
if err != nil {
t.Fatal(err)
}
css, err := os.ReadFile("static/css/app.css")
if err != nil {
t.Fatal(err)
}
header, err := os.ReadFile("templates/partials/header.html")
if err != nil {
t.Fatal(err)
}
pageText := string(page)
for _, want := range []string{
`class="syb-search-input"`,
`>清除</a>`,
`class="table-wrap syb-table-wrap"`,
`class="syb-col-shop"`,
`class="syb-col-spec"`,
`colspan="12"`,
} {
if !strings.Contains(pageText, want) {
t.Errorf("顺运宝紧凑列表缺少 %q", want)
}
}
for _, removed := range []string{
`href="/shops">店铺管理(同步`,
`<th>货运单 ID</th>`,
`<th>处理阶段</th>`,
`按顺运宝货运单创建日期(UTC+8)同步`,
`每行只显示当前最需要完成的下一步`,
} {
if strings.Contains(pageText, removed) {
t.Errorf("顺运宝列表仍包含已移除内容 %q", removed)
}
}
for _, want := range []string{
`.toolbar input[type="text"].syb-search-input`,
`flex: 0 0 140px`,
`.syb-col-shop`,
`max-width: 224px`,
`.syb-col-spec`,
`max-width: 140px`,
`.syb-table-wrap`,
`flex: 1 1 auto`,
`min-height: 0`,
`max-height: none`,
`overflow: auto`,
`.syb-table-wrap thead th`,
`position: sticky`,
`body.syb-page-body`,
`height: 100dvh`,
`body.syb-page-body > .page`,
`.visually-hidden`,
`.syb-date-separator`,
`@media (min-width: 1360px)`,
`.syb-toolbar`,
`flex-wrap: nowrap`,
`.syb-toolbar .syb-filter-form`,
`width: 116px`,
`width: 138px`,
} {
if !strings.Contains(string(css), want) {
t.Errorf("顺运宝紧凑列表样式缺少 %q", want)
}
}
if !strings.Contains(string(header), `href="/shops"`) {
t.Error("移除顺运宝工具条入口后,管理员主导航仍应保留店铺管理模块")
}
if !strings.Contains(string(header), `class="syb-page-body"`) {
t.Error("顺运宝页面缺少专用视口布局类")
}
}
func TestShops_单一名称和状态且保护删除(t *testing.T) {
templateContent, err := os.ReadFile("templates/shop/list.html")
if err != nil {
t.Fatal(err)
}
routes, err := os.ReadFile("handler/web/web.go")
if err != nil {
t.Fatal(err)
}
page := string(templateContent)
for _, want := range []string{
`name="shop_name"`, `{{if not .Enabled}}`, `action="/shops/delete"`,
`data-confirm-submit="确定删除停用店铺“{{.DisplayName}}”吗?`,
`class="danger">删除`,
} {
if !strings.Contains(page, want) {
t.Errorf("店铺管理页缺少状态或删除保护 %q", want)
}
}
if !strings.Contains(string(routes), `shops.POST("/delete", h.ShopDelete)`) {
t.Error("店铺删除没有挂到管理员路由组")
}
if strings.Contains(page, "业务店铺") || strings.Contains(page, "SYB 名称") || strings.Contains(page, "蝦皮名称") {
t.Error("店铺管理仍暴露已废弃的多名称概念")
}
}
func TestCatalogHistory_展示更新策略和分级统计(t *testing.T) {
list, err := os.ReadFile("templates/catalog/list.html")
if err != nil {
t.Fatal(err)
}
detail, err := os.ReadFile("templates/catalog/detail_modal.html")
if err != nil {
t.Fatal(err)
}
content := string(list) + string(detail)
for _, want := range []string{"更新策略", "SKUFilled", "SKUSameSourceUpdated", "SKUManualSkipped", "SKUStaleSkipped"} {
if !strings.Contains(content, want) {
t.Errorf("目录记录缺少 %q", want)
}
}
}
func TestShopeePage_展示主图店铺且保留标题宽度(t *testing.T) {
page, err := os.ReadFile("templates/shopee/list.html")
if err != nil {
t.Fatal(err)
}
detail, err := os.ReadFile("templates/shopee/detail_modal.html")
if err != nil {
t.Fatal(err)
}
css, err := os.ReadFile("static/css/app.css")
if err != nil {
t.Fatal(err)
}
js, err := os.ReadFile("static/js/app.js")
if err != nil {
t.Fatal(err)
}
content := string(page) + string(detail) + string(css) + string(js)
for _, want := range []string{"蝦皮店铺", "data-image-preview-url", "loading=\"lazy\"", "data-image-thumb-fallback", "ImageSourceText", "max-width: 120px", ".col-shop", "setupImageThumbnails"} {
if !strings.Contains(content, want) {
t.Errorf("蝦皮图片店铺界面缺少 %q", want)
}
}
}
func TestShopeeDetail_现有SKU支持渐进式人工编辑(t *testing.T) {
detail, err := os.ReadFile("templates/shopee/detail_modal.html")
if err != nil {
t.Fatal(err)
}
routes, err := os.ReadFile("handler/web/web.go")
if err != nil {
t.Fatal(err)
}
content := string(detail) + string(routes)
for _, want := range []string{
`action="/shopee/spec/save"`, `name="sku_record_id"`, `name="open_id"`,
`name="color"`, `name="size"`, `name="advice"`, `maxlength="500"`,
`规格原文和 SKU 身份不会修改`, `pages.POST("/shopee/spec/save"`,
} {
if !strings.Contains(content, want) {
t.Errorf("蝦皮规格编辑界面或路由缺少 %q", want)
}
}
}
func TestShopeePage_分类搜索回收站和状态保留(t *testing.T) {
list, err := os.ReadFile("templates/shopee/list.html")
if err != nil {
t.Fatal(err)
}
detail, err := os.ReadFile("templates/shopee/detail_modal.html")
if err != nil {
t.Fatal(err)
}
content := string(list) + string(detail)
for _, want := range []string{
`<label for="search-field">分类</label>`, `name="search_field"`,
`<label for="q">关键词</label>`, `name="q"`, `回收站`, `清除筛选条件`,
} {
if !strings.Contains(content, want) {
t.Errorf("蝦皮分类搜索界面缺少 %q", want)
}
}
}
func TestShopeeImportRoute_已移除(t *testing.T) {
router, err := newRouter(nil)
if err != nil {
t.Fatal(err)
}
req := httptest.NewRequest(http.MethodPost, "/shopee/import", nil)
resp := httptest.NewRecorder()
router.ServeHTTP(resp, req)
if resp.Code != http.StatusNotFound {
t.Fatalf("旧蝦皮导入路由应为 404,实际 %d", resp.Code)
}
}
func TestTemplatesAndRoutesCanBeBuiltWithoutDatabaseConnection(t *testing.T) {
if _, err := newRouter(nil); err != nil {
t.Fatalf("模板或路由组装失败: %v", err)
}
}
// 六个主页面都走一次真实路由和模板渲染。
// 这样模板字段写错或新增列漏接时,测试阶段就会失败,不必等人工点页面。
func TestMainPagesReturnOK(t *testing.T) {
db := newMySQLTestDB(t)
router, err := newRouter(db)
if err != nil {
t.Fatalf("组装路由失败: %v", err)
}
// 全新库的业务页必须先去初始化,不能匿名打开。
unauthenticated := httptest.NewRequest(http.MethodGet, "/shopee", nil)
unauthenticatedResponse := httptest.NewRecorder()
router.ServeHTTP(unauthenticatedResponse, unauthenticated)
if unauthenticatedResponse.Code != http.StatusSeeOther ||
unauthenticatedResponse.Header().Get("Location") != "/setup" {
t.Fatalf("全新库访问业务页应跳转 /setup,实际 %d %s",
unauthenticatedResponse.Code, unauthenticatedResponse.Header().Get("Location"))
}
if err := service.SetupInitialAdmin(db, "admin", "test-password", "test-password", time.Now()); err != nil {
t.Fatalf("准备测试管理员失败: %v", err)
}
token, adminUser, _, err := service.Login(db, "admin", "test-password", time.Now())
if err != nil {
t.Fatalf("准备测试登录 Session 失败: %v", err)
}
addAuth := func(request *http.Request) {
request.AddCookie(&http.Cookie{Name: "cmautobuy_session", Value: token})
}
for _, path := range []string{"/shopee", "/pdd", "/syb", "/tasks", "/clients", "/users", "/integrations/catalog"} {
t.Run(path, func(t *testing.T) {
request := httptest.NewRequest(http.MethodGet, path, nil)
addAuth(request)
response := httptest.NewRecorder()
router.ServeHTTP(response, request)
if response.Code != http.StatusOK {
t.Fatalf("GET %s = %d,期望 200,响应:%s",
path, response.Code, response.Body.String())
}
body := response.Body.String()
if !strings.Contains(body, `<nav class="pagination" aria-label="分页">`) ||
!strings.Contains(body, "首页") || !strings.Contains(body, "末页") {
t.Fatalf("GET %s 没有渲染公共分页组件:%s", path, body)
}
})
}
aiSettingsRequest := httptest.NewRequest(http.MethodGet, "/settings/ai", nil)
addAuth(aiSettingsRequest)
aiSettingsResponse := httptest.NewRecorder()
router.ServeHTTP(aiSettingsResponse, aiSettingsRequest)
if aiSettingsResponse.Code != http.StatusOK || !strings.Contains(aiSettingsResponse.Body.String(), "AI 模型配置") {
t.Fatalf("管理员 GET /settings/ai = %d,响应:%s", aiSettingsResponse.Code, aiSettingsResponse.Body.String())
}
pddRequest := httptest.NewRequest(http.MethodGet, "/pdd", nil)
addAuth(pddRequest)
pddResponse := httptest.NewRecorder()
router.ServeHTTP(pddResponse, pddRequest)
for _, want := range []string{
"批量导入", `action="/pdd/import"`, `enctype="multipart/form-data"`,
`id="pdd-import-file"`, `aria-describedby="pdd-import-help"`,
"拼多多链接", "最多 5000 条", `data-upload-form`,
} {
if !strings.Contains(pddResponse.Body.String(), want) {
t.Errorf("PDD 页面缺少批量导入界面 %q", want)
}
}
if err := service.CreatePurchaser(db, adminUser, "buyer", "buyer-password", "buyer-password", time.Now()); err != nil {
t.Fatalf("准备测试采购员失败: %v", err)
}
buyerToken, _, _, err := service.Login(db, "buyer", "buyer-password", time.Now())
if err != nil {
t.Fatalf("准备采购员 Session 失败: %v", err)
}
for _, path := range []string{"/shopee", "/pdd", "/syb", "/tasks", "/clients"} {
request := httptest.NewRequest(http.MethodGet, path, nil)
request.AddCookie(&http.Cookie{Name: "cmautobuy_session", Value: buyerToken})
response := httptest.NewRecorder()
router.ServeHTTP(response, request)
if response.Code != http.StatusOK {
t.Fatalf("采购员 GET %s = %d,期望 200", path, response.Code)
}
}
usersRequest := httptest.NewRequest(http.MethodGet, "/users", nil)
usersRequest.AddCookie(&http.Cookie{Name: "cmautobuy_session", Value: buyerToken})
usersResponse := httptest.NewRecorder()
router.ServeHTTP(usersResponse, usersRequest)
if usersResponse.Code != http.StatusForbidden {
t.Fatalf("采购员 GET /users = %d,期望 403", usersResponse.Code)
}
aiDeniedRequest := httptest.NewRequest(http.MethodGet, "/settings/ai", nil)
aiDeniedRequest.AddCookie(&http.Cookie{Name: "cmautobuy_session", Value: buyerToken})
aiDeniedResponse := httptest.NewRecorder()
router.ServeHTTP(aiDeniedResponse, aiDeniedRequest)
if aiDeniedResponse.Code != http.StatusForbidden {
t.Fatalf("采购员 GET /settings/ai = %d,期望 403", aiDeniedResponse.Code)
}
buyerPageRequest := httptest.NewRequest(http.MethodGet, "/shopee", nil)
buyerPageRequest.AddCookie(&http.Cookie{Name: "cmautobuy_session", Value: buyerToken})
buyerPageResponse := httptest.NewRecorder()
router.ServeHTTP(buyerPageResponse, buyerPageRequest)
buyerCSRF := findResponseCookie(t, buyerPageResponse, "cmautobuy_csrf")
for _, path := range []string{
"/settings/ai/save", "/settings/ai/secret", "/settings/ai/secret/clear",
"/settings/ai/test", "/settings/ai/enable", "/settings/ai/disable",
} {
request := postFormRequest(path, url.Values{"csrf_token": {buyerCSRF.Value}}, buyerCSRF)
request.AddCookie(&http.Cookie{Name: "cmautobuy_session", Value: buyerToken})
response := httptest.NewRecorder()
router.ServeHTTP(response, request)
if response.Code != http.StatusForbidden {
t.Fatalf("采购员 POST %s = %d,期望 403", path, response.Code)
}
}
for _, client := range []model.Client{
{ClientID: "CLIENT-LIVE", Name: "真实采购机", Capabilities: `{"purchase_mode":"live"}`},
{ClientID: "CLIENT-DRY", Name: "未就绪机", Capabilities: `{"purchase_mode":"dry_run"}`},
} {
if err := service.RegisterClient(db, client, true); err != nil {
t.Fatalf("准备采购客户端失败: %v", err)
}
}
if _, err := db.Exec(`UPDATE clients SET last_seen_at=? WHERE client_id=?`,
time.Now().UTC().Add(-time.Hour).Format(model.TimeLayout), "CLIENT-LIVE"); err != nil {
t.Fatalf("准备离线 live 客户端失败: %v", err)
}
sybRequest := httptest.NewRequest(http.MethodGet, "/syb", nil)
addAuth(sybRequest)
sybResponse := httptest.NewRecorder()
router.ServeHTTP(sybResponse, sybRequest)
for _, want := range []string{
"同步记录", "刷新同步记录", `data-syb-history-slot`,
`data-history-fetch-url=`,
`name="date_from"`, `name="date_to"`,
`name="stage"`, "处理阶段", `name="shop"`, "店铺", "下一步", `data-detail-url=`,
"创建真实采购任务", "所有当前账号可见的客户端均可指派",
} {
if !strings.Contains(sybResponse.Body.String(), want) {
t.Errorf("顺运宝页面缺少 %q", want)
}
}
for _, want := range []string{
`value="CLIENT-LIVE"`,
`value="CLIENT-DRY"`,
"未就绪机(CLIENT-DRY) — 在线",
"未就绪或离线客户端会等待其就绪后领取",
} {
if !strings.Contains(sybResponse.Body.String(), want) {
t.Errorf("采购客户端候选缺少 %q", want)
}
}
if strings.Contains(sybResponse.Body.String(), "指定日期同步") {
t.Error("顺运宝页面不应再显示旧的指定日期同步入口")
}
for _, removed := range []string{"按顺运宝货运单创建日期(UTC+8)同步", `<th>处理阶段</th>`} {
if strings.Contains(sybResponse.Body.String(), removed) {
t.Errorf("顺运宝页面仍包含已移除内容 %q", removed)
}
}
for _, removed := range []string{`name="execution_mode"`, "采购演练", "仅演练", "支持真实下单",
"真实下单安全确认", `name="live_acknowledged"`, `name="live_confirmation"`, `data-confirm-submit=`} {
if strings.Contains(sybResponse.Body.String(), removed) {
t.Errorf("固定真实采购弹窗不应显示 %q", removed)
}
}
_, err = repository.UpsertSybOrder(db, model.SybOrder{
SybID: "SYB-PDD-ID-INPUT", OrderNo: "ORDER-PDD-ID-INPUT", Title: "输入测试商品",
ProductSpec: "黑色,M", ShopeeGoodsID: "SP-PDD-ID-INPUT", Quantity: 1, SybData: `{}`,
})
if err != nil {
t.Fatalf("准备顺运宝详情测试数据失败: %v", err)
}
multiOrderRequest := httptest.NewRequest(http.MethodGet,
"/syb?order_no="+url.QueryEscape("ORDER-PDD-ID-INPUT\nNOT-FOUND"), nil)
addAuth(multiOrderRequest)
multiOrderResponse := httptest.NewRecorder()
router.ServeHTTP(multiOrderResponse, multiOrderRequest)
multiOrderBody := multiOrderResponse.Body.String()
if multiOrderResponse.Code != http.StatusOK {
t.Fatalf("多订单号搜索应返回页面,实际 %d:%s", multiOrderResponse.Code, multiOrderBody)
}
for _, want := range []string{
`>ORDER-PDD-ID-INPUT,NOT-FOUND</textarea>`,
"已输入 2 个订单号", "当前筛选命中 1 个订单、1 条商品明细", "当前筛选未命中:NOT-FOUND",
} {
if !strings.Contains(multiOrderBody, want) {
t.Errorf("多订单号搜索响应缺少 %q", want)
}
}
tooManyOrderNos := make([]string, 101)
for i := range tooManyOrderNos {
tooManyOrderNos[i] = fmt.Sprintf("ORDER-%03d", i)
}
invalidOrderRequest := httptest.NewRequest(http.MethodGet,
"/syb?order_no="+url.QueryEscape(strings.Join(tooManyOrderNos, ",")), nil)
addAuth(invalidOrderRequest)
invalidOrderResponse := httptest.NewRecorder()
router.ServeHTTP(invalidOrderResponse, invalidOrderRequest)
invalidOrderBody := invalidOrderResponse.Body.String()
if invalidOrderResponse.Code != http.StatusOK ||
!strings.Contains(invalidOrderBody, "一次最多搜索 100 个不同订单号") ||
!strings.Contains(invalidOrderBody, `aria-invalid="true"`) {
t.Fatalf("超量订单号应在页面就地提示且不返回 500,实际 %d:%s",
invalidOrderResponse.Code, invalidOrderBody)
}
detailRequest := httptest.NewRequest(http.MethodGet, "/syb/detail?id=SYB-PDD-ID-INPUT", nil)
addAuth(detailRequest)
detailResponse := httptest.NewRecorder()
router.ServeHTTP(detailResponse, detailRequest)
detailBody := detailResponse.Body.String()
if detailResponse.Code != http.StatusOK {
t.Fatalf("GET /syb/detail = %d,响应:%s", detailResponse.Code, detailBody)
}
for _, want := range []string{
"PDD 商品链接或商品 ID", `type="text" name="pdd_url"`,
"6~24 位纯数字商品 ID",
} {
if !strings.Contains(detailBody, want) {
t.Errorf("顺运宝详情输入区缺少 %q,响应:%s", want, detailBody)
}
}
if strings.Contains(detailBody, `type="url" name="pdd_url"`) {
t.Error("顺运宝详情不应继续使用会拦截纯数字的 URL 输入类型")
}
partialRequest := httptest.NewRequest(http.MethodGet,
"/syb/sync-history?history_page=1&order_no=ORDER-1&date_from=2026-08-01&date_to=2026-08-09", nil)
addAuth(partialRequest)
partialResponse := httptest.NewRecorder()
router.ServeHTTP(partialResponse, partialRequest)
partialBody := partialResponse.Body.String()
if partialResponse.Code != http.StatusOK ||
!strings.Contains(partialBody, "刷新同步记录") ||
!strings.Contains(partialBody, `data-history-fetch-url=`) {
t.Fatalf("同步记录片段响应不完整:%d %s", partialResponse.Code, partialBody)
}
if partialResponse.Header().Get("Cache-Control") != "no-store" {
t.Fatalf("同步记录刷新响应不得被浏览器缓存,实际 Cache-Control=%q",
partialResponse.Header().Get("Cache-Control"))
}
if strings.Contains(partialBody, `data-syb-sync-form`) || strings.Contains(partialBody, `modal-backdrop`) {
t.Fatalf("同步记录片段不应包含 SYB 主页面或弹窗外壳:%s", partialBody)
}
unauthorizedPartial := httptest.NewRequest(http.MethodGet, "/syb/sync-history", nil)
unauthorizedPartialResponse := httptest.NewRecorder()
router.ServeHTTP(unauthorizedPartialResponse, unauthorizedPartial)
if unauthorizedPartialResponse.Code != http.StatusSeeOther ||
!strings.HasPrefix(unauthorizedPartialResponse.Header().Get("Location"), "/login") {
t.Fatalf("匿名读取同步记录片段应跳转登录,实际 %d %s",
unauthorizedPartialResponse.Code, unauthorizedPartialResponse.Header().Get("Location"))
}
if !service.TryStartSybSync() {
t.Fatal("准备同步中页面状态失败")
}
runningRequest := httptest.NewRequest(http.MethodGet, "/syb", nil)
addAuth(runningRequest)
runningResponse := httptest.NewRecorder()
router.ServeHTTP(runningResponse, runningRequest)
service.FinishSybSync(service.SyncReport{})
if runningResponse.Code != http.StatusOK ||
!strings.Contains(runningResponse.Body.String(), "同步中…") ||
!strings.Contains(runningResponse.Body.String(), `disabled aria-busy="true"`) ||
!strings.Contains(runningResponse.Body.String(), "同步正在进行,请耐心等待") {
t.Fatalf("同步运行时页面应禁用按钮并显示等待提示,响应:%s", runningResponse.Body.String())
}
pdd, err := repository.EnsurePddProduct(
db, "737116531267",
"https://mobile.yangkeduo.com/goods.html?goods_id=737116531267")
if err != nil {
t.Fatalf("准备 PDD 商品失败: %v", err)
}
resultJSON := `{
"price_granularity":"color",
"dimensions":[{"key":"color","name":"颜色分类"},{"key":"size","name":"尺码"}],
"skus":[
{"options":{"color":"黑色","size":"M"},"price_cent":470,
"price_observed_at":{"color":"黑色","size":"M"},"available":true},
{"options":{"color":"黑色","size":"L"},"price_cent":470,
"price_observed_at":{"color":"黑色","size":"M"},"available":true}
]
}`
if err := repository.SetCollectResult(
db, pdd.GoodsID, "测试商品", "测试旗舰店", resultJSON); err != nil {
t.Fatalf("准备采集结果失败: %v", err)
}
request := httptest.NewRequest(http.MethodGet, "/pdd/detail?id=1", nil)
addAuth(request)
response := httptest.NewRecorder()
router.ServeHTTP(response, request)
if response.Code != http.StatusOK {
t.Fatalf("GET /pdd/detail = %d,响应:%s", response.Code, response.Body.String())
}
body := response.Body.String()
for _, want := range []string{
"测试旗舰店", "价格按颜色采样", "✓ 实测", "推断",
} {
if !strings.Contains(body, want) {
t.Errorf("PDD 详情缺少 %q,响应:%s", want, body)
}
}
}
// 公共弹窗不能只看 click.target 关闭:从密码框拖选到遮罩松开时,
// 浏览器会把 click 目标合成为遮罩。这个静态守卫确保 #56 的手势起点判断不被删掉;
// 真实指针行为另用 Chrome 回归验证。
func TestModalBackdropRequiresPointerStart(t *testing.T) {
js, err := staticFS.ReadFile("static/js/app.js")
if err != nil {
t.Fatalf("读取公共 JavaScript 失败: %v", err)
}
source := string(js)
for _, required := range []string{
`addEventListener("pointerdown"`,
`pointerStartedOnBackdrop = e.target === modal`,
`e.target === modal && pointerStartedOnBackdrop`,
`addEventListener("pointercancel"`,
} {
if !strings.Contains(source, required) {
t.Errorf("公共弹窗缺少拖选防误关闭守卫 %q", required)
}
}
}
func TestSybOrderSearch_回车搜索且Shift回车保留换行(t *testing.T) {
js, err := staticFS.ReadFile("static/js/app.js")
if err != nil {
t.Fatalf("读取公共 JavaScript 失败: %v", err)
}
source := string(js)
for _, want := range []string{
`document.querySelector("[data-syb-order-search]")`,
`event.key !== "Enter" || event.shiftKey || event.isComposing`,
`input.form.requestSubmit()`,
`setupSybOrderSearch();`,
} {
if !strings.Contains(source, want) {
t.Errorf("多订单号搜索缺少键盘守卫 %q", want)
}
}
}