package main import ( "net/http" "net/http/httptest" "strings" "testing" "time" "cmautobuy/admin/repository" "cmautobuy/admin/service" ) // 五个主页面都走一次真实路由和模板渲染。 // 这样模板字段写错或新增列漏接时,测试阶段就会失败,不必等人工点页面。 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) } // 全新库的业务页必须先去初始化,不能匿名打开。 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}) } for _, path := range []string{"/shopee", "/pdd", "/syb", "/tasks", "/clients"} { 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()) } }) } 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"`, "按顺运宝货运单创建日期(UTC+8)同步", } { if !strings.Contains(sybResponse.Body.String(), want) { t.Errorf("顺运宝页面缺少 %q", want) } } if strings.Contains(sybResponse.Body.String(), "指定日期同步") { t.Error("顺运宝页面不应再显示旧的指定日期同步入口") } 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) } } }