Files
cmautobuy/admin/main_test.go
T

594 lines
21 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 (
"net/http"
"net/http/httptest"
"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=\"/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\": [\"goods_id\", \"shop_name\", \"status\", \"shop\", \"image\", \"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\""} {
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-select-action="{{if .CanCollect}}collect{{else if .CanPurchase}}purchase{{end}}"`,
`{{if .CanCollect}}form="syb-collect-form"{{end}}`,
`action="/syb/collect-pdd-batch"`, `id="syb-collect-modal"`,
},
"static/js/app.js": {
`supportsSelectAction(box, action)`,
`checkedCount(action)`,
`supportsSelectAction(box, "purchase")`,
`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 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 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 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: 18%", ".col-shop", "setupImageThumbnails"} {
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="shop">店铺</label>`, `name="shop"`, `.ShopOptions`, `.ShopFilter`,
`<label for="image">图片</label>`, `name="image"`, `.ImageOptions`, `.ImageFilter`,
`清除筛选条件`,
} {
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)
}
})
}
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)
}
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=`,
"按顺运宝货运单创建日期(UTC+8)同步",
"创建真实采购任务", "所有当前账号可见的客户端均可指派",
} {
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{`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)
}
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)
}
}
}