#46 的登录只有手工输验证码一条路,而会话 24 小时就过期——每天第一次 同步都得有人在场,将来也做不了定时同步。 docs/admin/08 §8 当时写死"不引入 OCR 服务",理由是"多一个必须先启动的 东西"。那条判断基于示例脚本里的 http://127.0.0.1:8000/ocr(本机服务)。 用户提供了托管地址后前提不成立,本工单推翻它——文档里改写并保留原文, 让后来人知道这个决定变过、为什么变。 OCR 优先、手工兜底:识别成功直接登录,失败或服务不可达降级到 #46 已有的 手工弹窗,并在弹窗里说明是"已尝试 N 次"还是"服务不可用"。手工路径不删, 外部服务挂了不该让整个同步功能不可用。 识别失败也是 code:200。实测拿无文字图片探测 https://ocr.ilapage.cn/ocr 返回 {"code":200,"message":"Success","data":""}——不是错误码。所以 Recognize 只负责"这次 HTTP 调用有没有问题",空 data 照常返回 (", nil), 业务校验交给调用方;空 data 和长度不对收敛到同一个 len(code) != 4, 一条规则覆盖两种情况。 不合格的验证码不拿去登录:白费一次尝试,且频繁错误登录可能触发风控。 审查时变异测试发现这条没有测试守着——原测试只断言"重新取图了"和 "最终登录成功",禁用长度校验后依然成立。已补 loginRecorder 记录每次 提交到 /am/auth/login 的 code,断言登录只被调用一次且提交的是合格的那个。 每次重试重新取图(同一张图再识别结果一样,且可能已被上次失败的登录作废); OCR 用独立 HTTP 客户端不带顺运宝 Cookie;验证码图片只在内存里传,不落盘。 OCR 不可达立即降级、不占用重试次数——对着连不上的地址重试 5 次, 操作员要等 50 秒才看到手工输入框,结果注定一样。 测试全部用 httptest,不打真实的 ocr.ilapage.cn 和 shunyunbaoerp.com。 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
224 lines
7.2 KiB
Go
224 lines
7.2 KiB
Go
package syb
|
||
|
||
import (
|
||
"context"
|
||
"encoding/json"
|
||
"net/http"
|
||
"net/http/httptest"
|
||
"testing"
|
||
"time"
|
||
)
|
||
|
||
// `[必须]` 本文件全部用 httptest 起假 OCR 服务端,绝不能打真实的
|
||
// ocr.ilapage.cn——见工单 #47 和 admin/AGENTS.md。
|
||
|
||
func ocrBody(code int, message, data string) []byte {
|
||
b, _ := json.Marshal(map[string]any{"code": code, "message": message, "data": data})
|
||
return b
|
||
}
|
||
|
||
// ── 三种约定的假响应(工单 #47「怎么验证」逐条要求) ──────────────
|
||
|
||
func TestOcrClient_Recognize_识别成功(t *testing.T) {
|
||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||
w.Write(ocrBody(200, "Success", "kycv"))
|
||
}))
|
||
defer srv.Close()
|
||
|
||
ocr, err := NewOcrClient(srv.URL, time.Second)
|
||
if err != nil {
|
||
t.Fatalf("创建 OCR 客户端失败: %v", err)
|
||
}
|
||
data, err := ocr.Recognize(context.Background(), []byte("fake-image"))
|
||
if err != nil {
|
||
t.Fatalf("识别应该成功,实际报错: %v", err)
|
||
}
|
||
if data != "kycv" {
|
||
t.Errorf("识别结果应该是 kycv,实际 %q", data)
|
||
}
|
||
}
|
||
|
||
func TestOcrClient_Recognize_code200但data为空不是错误(t *testing.T) {
|
||
// `[必须]` 实测:无文字图片探测得到 code:200 + data:"",这不是
|
||
// HTTP/JSON 错误,Recognize 应该返回 (空字符串, nil),由调用方
|
||
// (LoginWithOCR)的长度校验去判定"这次没识别出来"。
|
||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||
w.Write(ocrBody(200, "Success", ""))
|
||
}))
|
||
defer srv.Close()
|
||
|
||
ocr, err := NewOcrClient(srv.URL, time.Second)
|
||
if err != nil {
|
||
t.Fatalf("创建 OCR 客户端失败: %v", err)
|
||
}
|
||
data, err := ocr.Recognize(context.Background(), []byte("fake-image"))
|
||
if err != nil {
|
||
t.Fatalf("code:200 + data:\"\" 不应该被当成错误,实际报错: %v", err)
|
||
}
|
||
if data != "" {
|
||
t.Errorf("期望空字符串,实际 %q", data)
|
||
}
|
||
}
|
||
|
||
func TestOcrClient_Recognize_长度不对时原样返回交给调用方校验(t *testing.T) {
|
||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||
w.Write(ocrBody(200, "Success", "ab"))
|
||
}))
|
||
defer srv.Close()
|
||
|
||
ocr, err := NewOcrClient(srv.URL, time.Second)
|
||
if err != nil {
|
||
t.Fatalf("创建 OCR 客户端失败: %v", err)
|
||
}
|
||
data, err := ocr.Recognize(context.Background(), []byte("fake-image"))
|
||
if err != nil {
|
||
t.Fatalf("HTTP 调用本身没问题,不应该报错,实际: %v", err)
|
||
}
|
||
if data != "ab" {
|
||
t.Errorf("Recognize 不做长度校验,应该原样返回,实际 %q", data)
|
||
}
|
||
}
|
||
|
||
// ── 请求内容:字段名 file,multipart 上传,不落盘 ──────────────
|
||
|
||
func TestOcrClient_Recognize_用multipart字段名file上传(t *testing.T) {
|
||
var gotFieldName string
|
||
var gotBytes []byte
|
||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||
if err := r.ParseMultipartForm(1 << 20); err != nil {
|
||
t.Errorf("解析 multipart 表单失败: %v", err)
|
||
}
|
||
if r.MultipartForm != nil && len(r.MultipartForm.File) > 0 {
|
||
for name, files := range r.MultipartForm.File {
|
||
gotFieldName = name
|
||
if len(files) > 0 {
|
||
f, _ := files[0].Open()
|
||
gotBytes = make([]byte, files[0].Size)
|
||
f.Read(gotBytes)
|
||
f.Close()
|
||
}
|
||
}
|
||
}
|
||
w.Write(ocrBody(200, "Success", "abcd"))
|
||
}))
|
||
defer srv.Close()
|
||
|
||
ocr, _ := NewOcrClient(srv.URL, time.Second)
|
||
image := []byte("这是验证码图片的字节内容")
|
||
if _, err := ocr.Recognize(context.Background(), image); err != nil {
|
||
t.Fatalf("识别失败: %v", err)
|
||
}
|
||
if gotFieldName != "file" {
|
||
t.Errorf("multipart 字段名应该是 file,实际 %q", gotFieldName)
|
||
}
|
||
if string(gotBytes) != string(image) {
|
||
t.Errorf("上传的图片字节应该原样传到服务端,实际 %q", string(gotBytes))
|
||
}
|
||
}
|
||
|
||
// ── 降级路径:不可达 / 非 JSON / 非 200 ──────────────────────
|
||
|
||
func TestOcrClient_Recognize_服务不可达返回error(t *testing.T) {
|
||
// 关掉的服务器:地址存在但连不上。
|
||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {}))
|
||
srv.Close()
|
||
|
||
ocr, err := NewOcrClient(srv.URL, 500*time.Millisecond)
|
||
if err != nil {
|
||
t.Fatalf("创建 OCR 客户端失败: %v", err)
|
||
}
|
||
_, err = ocr.Recognize(context.Background(), []byte("fake-image"))
|
||
if err == nil {
|
||
t.Fatal("服务连不上应该返回 error")
|
||
}
|
||
}
|
||
|
||
func TestOcrClient_Recognize_非JSON响应返回error不panic(t *testing.T) {
|
||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||
w.Write([]byte("<html>不是 JSON</html>"))
|
||
}))
|
||
defer srv.Close()
|
||
|
||
ocr, _ := NewOcrClient(srv.URL, time.Second)
|
||
_, err := ocr.Recognize(context.Background(), []byte("fake-image"))
|
||
if err == nil {
|
||
t.Fatal("非 JSON 响应应该返回 error")
|
||
}
|
||
}
|
||
|
||
func TestOcrClient_Recognize_HTTP非200返回error(t *testing.T) {
|
||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||
w.WriteHeader(http.StatusInternalServerError)
|
||
w.Write([]byte("internal error"))
|
||
}))
|
||
defer srv.Close()
|
||
|
||
ocr, _ := NewOcrClient(srv.URL, time.Second)
|
||
_, err := ocr.Recognize(context.Background(), []byte("fake-image"))
|
||
if err == nil {
|
||
t.Fatal("HTTP 500 应该返回 error")
|
||
}
|
||
}
|
||
|
||
func TestOcrClient_Recognize_业务code非200返回error(t *testing.T) {
|
||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||
w.Write(ocrBody(500, "内部错误", ""))
|
||
}))
|
||
defer srv.Close()
|
||
|
||
ocr, _ := NewOcrClient(srv.URL, time.Second)
|
||
_, err := ocr.Recognize(context.Background(), []byte("fake-image"))
|
||
if err == nil {
|
||
t.Fatal("code != 200 应该返回 error")
|
||
}
|
||
}
|
||
|
||
func TestNewOcrClient_地址留空报错(t *testing.T) {
|
||
if _, err := NewOcrClient("", time.Second); err == nil {
|
||
t.Fatal("空地址应该报错——调用方应该在这之前就判断 ocr_url 是否留空并跳过 OCR")
|
||
}
|
||
if _, err := NewOcrClient(" ", time.Second); err == nil {
|
||
t.Fatal("空白地址同样应该报错")
|
||
}
|
||
}
|
||
|
||
// ── OCR 请求不带顺运宝 Cookie:独立 http.Client ──────────────
|
||
|
||
func TestOcrClient_不携带顺运宝的Cookie(t *testing.T) {
|
||
var gotCookie string
|
||
sybSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||
http.SetCookie(w, &http.Cookie{Name: "erp_session", Value: "should-not-leak", Path: "/"})
|
||
w.Header().Set("Content-Type", "image/jpeg")
|
||
w.Write([]byte("fake-jpeg"))
|
||
}))
|
||
defer sybSrv.Close()
|
||
|
||
ocrSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||
if ck, err := r.Cookie("erp_session"); err == nil {
|
||
gotCookie = ck.Value
|
||
}
|
||
w.Write(ocrBody(200, "Success", "abcd"))
|
||
}))
|
||
defer ocrSrv.Close()
|
||
|
||
sybClient, err := New(sybSrv.URL)
|
||
if err != nil {
|
||
t.Fatalf("创建顺运宝客户端失败: %v", err)
|
||
}
|
||
captcha, err := sybClient.FetchCaptcha(context.Background())
|
||
if err != nil {
|
||
t.Fatalf("获取验证码失败: %v", err)
|
||
}
|
||
|
||
ocrClient, err := NewOcrClient(ocrSrv.URL, time.Second)
|
||
if err != nil {
|
||
t.Fatalf("创建 OCR 客户端失败: %v", err)
|
||
}
|
||
if _, err := ocrClient.Recognize(context.Background(), captcha.Image); err != nil {
|
||
t.Fatalf("识别失败: %v", err)
|
||
}
|
||
if gotCookie != "" {
|
||
t.Fatalf("OCR 请求不应该带上顺运宝的 Cookie,实际带了 %q", gotCookie)
|
||
}
|
||
}
|