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() } }