2026-08-07 17:13:25 +08:00
|
|
|
package main
|
|
|
|
|
|
|
|
|
|
import (
|
|
|
|
|
"net/http"
|
|
|
|
|
"net/http/httptest"
|
2026-08-09 21:51:20 +08:00
|
|
|
"os"
|
2026-08-07 17:13:25 +08:00
|
|
|
"strings"
|
|
|
|
|
"testing"
|
2026-08-09 13:36:41 +08:00
|
|
|
"time"
|
2026-08-07 17:13:25 +08:00
|
|
|
|
|
|
|
|
"cmautobuy/admin/repository"
|
2026-08-09 13:36:41 +08:00
|
|
|
"cmautobuy/admin/service"
|
2026-08-07 17:13:25 +08:00
|
|
|
)
|
|
|
|
|
|
2026-08-09 21:51:20 +08:00
|
|
|
func TestPaginationCSS_桌面右对齐且窄窗口不遮挡内容(t *testing.T) {
|
|
|
|
|
content, err := os.ReadFile("static/css/app.css")
|
|
|
|
|
if err != nil {
|
|
|
|
|
t.Fatal(err)
|
|
|
|
|
}
|
|
|
|
|
css := string(content)
|
2026-08-09 22:05:11 +08:00
|
|
|
statusbarRightAligned := `.statusbar {
|
|
|
|
|
position: fixed;
|
|
|
|
|
left: 0; right: 0; bottom: 0;
|
|
|
|
|
display: flex;
|
|
|
|
|
align-items: center;
|
|
|
|
|
justify-content: flex-end;`
|
2026-08-09 21:51:20 +08:00
|
|
|
for _, want := range []string{
|
2026-08-09 22:05:11 +08:00
|
|
|
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%; }",
|
2026-08-09 21:51:20 +08:00
|
|
|
} {
|
|
|
|
|
if !strings.Contains(css, want) {
|
|
|
|
|
t.Fatalf("分页布局 CSS 缺少 %q", want)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 六个主页面都走一次真实路由和模板渲染。
|
2026-08-07 17:13:25 +08:00
|
|
|
// 这样模板字段写错或新增列漏接时,测试阶段就会失败,不必等人工点页面。
|
|
|
|
|
func TestMainPagesReturnOK(t *testing.T) {
|
|
|
|
|
db, err := repository.Open(t.TempDir())
|
|
|
|
|
if err != nil {
|
|
|
|
|
t.Fatalf("打开测试数据库失败: %v", err)
|
|
|
|
|
}
|
|
|
|
|
defer db.Close()
|
|
|
|
|
if err := repository.Migrate(db); err != nil {
|
|
|
|
|
t.Fatalf("迁移测试数据库失败: %v", err)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
router, err := newRouter(db)
|
|
|
|
|
if err != nil {
|
|
|
|
|
t.Fatalf("组装路由失败: %v", err)
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-09 13:36:41 +08:00
|
|
|
// 全新库的业务页必须先去初始化,不能匿名打开。
|
|
|
|
|
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, _, _, 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})
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-09 21:51:20 +08:00
|
|
|
for _, path := range []string{"/shopee", "/pdd", "/syb", "/tasks", "/clients", "/users"} {
|
2026-08-07 17:13:25 +08:00
|
|
|
t.Run(path, func(t *testing.T) {
|
|
|
|
|
request := httptest.NewRequest(http.MethodGet, path, nil)
|
2026-08-09 13:36:41 +08:00
|
|
|
addAuth(request)
|
2026-08-07 17:13:25 +08:00
|
|
|
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())
|
|
|
|
|
}
|
2026-08-09 21:51:20 +08:00
|
|
|
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)
|
|
|
|
|
}
|
2026-08-07 17:13:25 +08:00
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-09 13:24:02 +08:00
|
|
|
sybRequest := httptest.NewRequest(http.MethodGet, "/syb", nil)
|
2026-08-09 13:36:41 +08:00
|
|
|
addAuth(sybRequest)
|
2026-08-09 13:24:02 +08:00
|
|
|
sybResponse := httptest.NewRecorder()
|
|
|
|
|
router.ServeHTTP(sybResponse, sybRequest)
|
|
|
|
|
for _, want := range []string{
|
2026-08-09 21:02:28 +08:00
|
|
|
"同步记录", "刷新同步记录", `data-syb-history-slot`,
|
|
|
|
|
`data-history-fetch-url=`,
|
2026-08-09 20:41:24 +08:00
|
|
|
`name="date_from"`, `name="date_to"`,
|
2026-08-09 22:32:19 +08:00
|
|
|
`name="stage"`, "处理阶段", "下一步", `data-detail-url=`,
|
2026-08-09 13:24:02 +08:00
|
|
|
"按顺运宝货运单创建日期(UTC+8)同步",
|
|
|
|
|
} {
|
|
|
|
|
if !strings.Contains(sybResponse.Body.String(), want) {
|
|
|
|
|
t.Errorf("顺运宝页面缺少 %q", want)
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-08-09 18:22:58 +08:00
|
|
|
if strings.Contains(sybResponse.Body.String(), "指定日期同步") {
|
|
|
|
|
t.Error("顺运宝页面不应再显示旧的指定日期同步入口")
|
|
|
|
|
}
|
2026-08-09 13:24:02 +08:00
|
|
|
|
2026-08-09 21:02:28 +08:00
|
|
|
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"))
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-09 20:41:24 +08:00
|
|
|
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())
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-07 17:13:25 +08:00
|
|
|
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)
|
2026-08-09 13:36:41 +08:00
|
|
|
addAuth(request)
|
2026-08-07 17:13:25 +08:00
|
|
|
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)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-08-09 16:31:41 +08:00
|
|
|
|
|
|
|
|
// 公共弹窗不能只看 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)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|