Files
cmautobuy/admin/syb/ocr_test.go
T
chengmaandClaude Opus 5 2e686b17a4 feat: 顺运宝登录接入验证码自动识别 (#47)
#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>
2026-08-09 12:35:02 +08:00

224 lines
7.2 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 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)
}
}