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\", \"apply_batch_id\"]", "\"/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>加载中…`, } { if !strings.Contains(footerSource, want) { t.Errorf("公共分页缺少自动刷新结构 %q", want) } } if strings.Contains(footerSource, `data-page-size-submit`) || strings.Contains(footerSource, `>应用`) { 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}}`, `>筛选`, `PDD 店铺`, `title="{{.PddShopText}}"`, `title="{{.ClientTitle}}"`, `{{if .ShowCreator}}10{{else}}9{{end}}`, }, "templates/task/detail_modal.html": { `
创建人
{{.CreatorText}}
`, `
客户端名称
{{.ClientText}}
`, `
客户端 ID
{{.ClientIDText}}
`, `
PDD 店铺
{{.PddShopText}}
`, }, } 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"`, ``, `不指定(任意客户端可领取)`, `.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"`, `>清除`, `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">店铺管理(同步`, `货运单 ID`, `处理阶段`, `按顺运宝货运单创建日期(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{ ``, `name="search_field"`, ``, `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, `