Files
cmautobuy/admin/main_test.go
T

336 lines
13 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 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}}" hidden`,
`name="mapping_option_key" value="{{.MappingOptionKey}}" disabled`,
`name="context_version" value="{{.ContextVersion}}" disabled`,
`name="max_price_yuan" min="0.01" step="0.01" value="{{.DefaultMaxPrice}}" required disabled`,
},
"static/js/app.js": {
"row.hidden = !enabled;",
"input.disabled = !enabled;",
},
}
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 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"} {
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"`, "处理阶段", "下一步", `data-detail-url=`,
"按顺运宝货运单创建日期(UTC+8)同步",
"创建真实采购任务", "未启用真实采购",
} {
if !strings.Contains(sybResponse.Body.String(), want) {
t.Errorf("顺运宝页面缺少 %q", want)
}
}
for _, want := range []string{
`value="CLIENT-LIVE" selected`,
`value="CLIENT-DRY" disabled`,
"未授权机(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)
}
}
}