876 lines
31 KiB
Go
876 lines
31 KiB
Go
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\"]",
|
||
"\"/syb\": [\"order_no\", \"shop\", \"stage\", \"page\", \"date_from\", \"date_to\"]",
|
||
"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 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)
|
||
}
|
||
}
|
||
}
|