Files
hi-server/internal/logic/notify/ePayNotifyLogic_test.go
T
架构师 750b7be424
Build docker and publish / build (20.15.1) (push) Failing after 8m55s
Build docker and publish / build (20.15.1) (pull_request) Failing after 22m22s
修复(#136): EPay notify 静默失败硬化 + 回写 trade_no
P01 签名校验失败由 return nil 改为返回 xerr.SignatureInvalid,handler 回 400,EPay 网关重试;Debug 旁路保留
P02 订单不存在维持 error 返回,仅 status=5 Finished 保留 nil 作幂等短路
P03 支付完成路径写入 order.trade_no = req.TradeNo,再 UpdateOrderStatus 刷缓存
附加:TradeStatus != TRADE_SUCCESS 降为 INFO 日志 + metric epay_notify_trade_not_success
抽离纯决策函数 evaluateEPayNotify,新增单测覆盖 4 个核心分支 + 签名优先级回归 + SQL 写路径 + URL 解析(9/9 PASS,-race 干净)

仅 internal/logic/notify/ePayNotifyLogic.go 与对应单测,无其他文件变更。

Co-authored-by: multica-agent <github@multica.ai>
2026-06-01 03:08:31 -07:00

183 lines
6.5 KiB
Go

package notify
import (
"context"
"fmt"
"strings"
"testing"
"github.com/DATA-DOG/go-sqlmock"
"github.com/perfect-panel/server/internal/model/order"
"gorm.io/driver/mysql"
"gorm.io/gorm"
)
// TestEvaluateEPayNotify_RejectsInvalidSign covers HIF-136 P01: sign invalid +
// debug bypass off must return rejectSign so the handler responds non-200 and
// EPay retries (was previously a silent `return nil` → 200 → no retry).
func TestEvaluateEPayNotify_RejectsInvalidSign(t *testing.T) {
got := evaluateEPayNotify(false, false, "TRADE_SUCCESS", 1)
if got != epayNotifyDecisionRejectSign {
t.Fatalf("evaluateEPayNotify(invalid sign): got %v want %v", got, epayNotifyDecisionRejectSign)
}
}
// TestEvaluateEPayNotify_DebugBypassAllowsInvalidSign documents the debug-mode
// escape hatch the issue calls out: production runs with Debug=false so this
// branch is never taken in prod, but local/dev should still flow through.
func TestEvaluateEPayNotify_DebugBypassAllowsInvalidSign(t *testing.T) {
got := evaluateEPayNotify(false, true, "TRADE_SUCCESS", 1)
if got != epayNotifyDecisionProcess {
t.Fatalf("evaluateEPayNotify(invalid sign + debug): got %v want %v", got, epayNotifyDecisionProcess)
}
}
// TestEvaluateEPayNotify_TradeNotSuccess covers the user-cancelled / gateway-failure
// branch: behaviour unchanged (return nil → 200), but caller must now emit the
// `epay_notify_trade_not_success` metric-named log instead of an Error-level one.
func TestEvaluateEPayNotify_TradeNotSuccess(t *testing.T) {
tests := []string{"TRADE_FAILED", "WAIT_BUYER_PAY", ""}
for _, ts := range tests {
t.Run(ts, func(t *testing.T) {
got := evaluateEPayNotify(true, false, ts, 1)
if got != epayNotifyDecisionAckTradeNotSuccess {
t.Fatalf("evaluateEPayNotify(trade_status=%q): got %v want %v",
ts, got, epayNotifyDecisionAckTradeNotSuccess)
}
})
}
}
// TestEvaluateEPayNotify_IdempotentForFinishedOrder covers the only legitimate
// `return nil` short-circuit retained by HIF-136: duplicate callback for an
// already Finished (status=5) order is acknowledged with 200 instead of being
// re-processed.
func TestEvaluateEPayNotify_IdempotentForFinishedOrder(t *testing.T) {
got := evaluateEPayNotify(true, false, "TRADE_SUCCESS", 5)
if got != epayNotifyDecisionAckIdempotent {
t.Fatalf("evaluateEPayNotify(status=5): got %v want %v", got, epayNotifyDecisionAckIdempotent)
}
}
// TestEvaluateEPayNotify_HappyPath covers the normal success branch: valid sign,
// trade_status=TRADE_SUCCESS, order not yet Finished → proceed to write trade_no
// + flip status + enqueue activation.
func TestEvaluateEPayNotify_HappyPath(t *testing.T) {
statuses := []uint8{1, 2, 3, 4} // anything but Finished(5)
for _, s := range statuses {
got := evaluateEPayNotify(true, false, "TRADE_SUCCESS", s)
if got != epayNotifyDecisionProcess {
t.Fatalf("evaluateEPayNotify(status=%d): got %v want %v", s, got, epayNotifyDecisionProcess)
}
}
}
// TestEvaluateEPayNotify_SignTakesPrecedenceOverIdempotency guards against a
// regression where a duplicate callback with a forged signature gets quietly
// acked because status==5 was checked before sign.
func TestEvaluateEPayNotify_SignTakesPrecedenceOverIdempotency(t *testing.T) {
got := evaluateEPayNotify(false, false, "TRADE_SUCCESS", 5)
if got != epayNotifyDecisionRejectSign {
t.Fatalf("evaluateEPayNotify(invalid sign + status=5): got %v want %v",
got, epayNotifyDecisionRejectSign)
}
}
// TestUrlParamsToMap verifies the helper used to feed VerifySign — collapses
// each query parameter to its first value and treats absent params as missing.
func TestUrlParamsToMap(t *testing.T) {
got := urlParamsToMap("pid=1001&out_trade_no=ORD-1&trade_no=EPAY-9&sign=abc&trade_status=TRADE_SUCCESS")
want := map[string]string{
"pid": "1001",
"out_trade_no": "ORD-1",
"trade_no": "EPAY-9",
"sign": "abc",
"trade_status": "TRADE_SUCCESS",
}
if len(got) != len(want) {
t.Fatalf("urlParamsToMap len = %d want %d (got=%v)", len(got), len(want), got)
}
for k, v := range want {
if got[k] != v {
t.Fatalf("urlParamsToMap[%q] = %q want %q", k, got[k], v)
}
}
}
// TestEPayNotify_TradeNoRawWrite verifies HIF-136 P03 at the SQL boundary: the
// raw gorm write hits the `order` table with the exact `trade_no` from the
// EPay request, scoped by `order_no`, and surfaces driver errors back to the
// caller (so they propagate as non-200 and EPay retries).
func TestEPayNotify_TradeNoRawWrite(t *testing.T) {
db, mock, cleanup := newEPayNotifyTestDB(t)
defer cleanup()
const (
orderNo = "202606010001"
tradeNo = "EPAY-202606010001"
)
mock.ExpectBegin()
mock.ExpectExec("UPDATE `order` SET `trade_no`").
WithArgs(tradeNo, sqlmock.AnyArg(), orderNo).
WillReturnResult(sqlmock.NewResult(0, 1))
mock.ExpectCommit()
err := db.WithContext(context.Background()).
Model(&order.Order{}).
Where("order_no = ?", orderNo).
Update("trade_no", tradeNo).Error
if err != nil {
t.Fatalf("raw trade_no update: %v", err)
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Fatalf("unmet sql expectations: %v", err)
}
}
// TestEPayNotify_TradeNoRawWritePropagatesError ensures a DB-side failure during
// the trade_no backfill is not swallowed — caller returns the error so EPay
// retries instead of silently flipping status without trade_no being written.
func TestEPayNotify_TradeNoRawWritePropagatesError(t *testing.T) {
db, mock, cleanup := newEPayNotifyTestDB(t)
defer cleanup()
mock.ExpectBegin()
mock.ExpectExec("UPDATE `order` SET `trade_no`").
WillReturnError(fmt.Errorf("deadlock detected"))
mock.ExpectRollback()
err := db.WithContext(context.Background()).
Model(&order.Order{}).
Where("order_no = ?", "ORD-2").
Update("trade_no", "EPAY-2").Error
if err == nil {
t.Fatalf("expected error from raw trade_no update, got nil")
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Fatalf("unmet sql expectations: %v", err)
}
}
func newEPayNotifyTestDB(t *testing.T) (*gorm.DB, sqlmock.Sqlmock, func()) {
t.Helper()
sqlDB, mock, err := sqlmock.New(sqlmock.QueryMatcherOption(sqlmock.QueryMatcherFunc(func(expectedSQL, actualSQL string) error {
if strings.Contains(actualSQL, expectedSQL) {
return nil
}
return fmt.Errorf("actual sql %q does not contain %q", actualSQL, expectedSQL)
})))
if err != nil {
t.Fatalf("create sqlmock: %v", err)
}
db, err := gorm.Open(mysql.New(mysql.Config{Conn: sqlDB, SkipInitializeWithVersion: true}), &gorm.Config{})
if err != nil {
_ = sqlDB.Close()
t.Fatalf("open gorm db: %v", err)
}
return db, mock, func() {
_ = sqlDB.Close()
}
}