- 보상 지급 실패 시 즉시 재시도(3회 backoff) + DB 기록 + 백그라운드 워커 재시도 - WaitForTx 폴링으로 블록체인 TX 확정 대기, SendTxAndWait 편의 메서드 - chain 트랜잭션 코드 중복 제거 (userTx/operatorTx 헬퍼, 50% 감소) - AppError 기반 에러 응답 포맷 통일 (8개 코드, 전 핸들러 마이그레이션) - TX 에러 분류 + 한국어 사용자 메시지 매핑 (11가지 패턴) - player 서비스 테스트 20개 + chain WaitForTx 테스트 10개 추가 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
334 lines
8.9 KiB
Go
334 lines
8.9 KiB
Go
package chain
|
|
|
|
import (
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"strings"
|
|
"sync/atomic"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
// rpcHandler returns an http.HandlerFunc that responds with JSON-RPC results.
|
|
// The handleFn receives the method and params, and returns the result or an error string.
|
|
func rpcHandler(handleFn func(method string, params json.RawMessage) (any, string)) http.HandlerFunc {
|
|
return func(w http.ResponseWriter, r *http.Request) {
|
|
var req struct {
|
|
ID any `json:"id"`
|
|
Method string `json:"method"`
|
|
Params json.RawMessage `json:"params"`
|
|
}
|
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
http.Error(w, "bad request", 400)
|
|
return
|
|
}
|
|
|
|
result, errMsg := handleFn(req.Method, req.Params)
|
|
|
|
w.Header().Set("Content-Type", "application/json")
|
|
if errMsg != "" {
|
|
json.NewEncoder(w).Encode(map[string]any{
|
|
"jsonrpc": "2.0",
|
|
"id": req.ID,
|
|
"error": map[string]any{"code": -32000, "message": errMsg},
|
|
})
|
|
return
|
|
}
|
|
|
|
resultJSON, _ := json.Marshal(result)
|
|
json.NewEncoder(w).Encode(map[string]any{
|
|
"jsonrpc": "2.0",
|
|
"id": req.ID,
|
|
"result": json.RawMessage(resultJSON),
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestWaitForTx_Success(t *testing.T) {
|
|
var calls atomic.Int32
|
|
srv := httptest.NewServer(rpcHandler(func(method string, params json.RawMessage) (any, string) {
|
|
if method != "getTxStatus" {
|
|
return nil, "unexpected method"
|
|
}
|
|
// First call returns null (not yet confirmed), second returns success
|
|
if calls.Add(1) == 1 {
|
|
return nil, ""
|
|
}
|
|
return &TxStatusResult{
|
|
TxID: "tx-123",
|
|
BlockHeight: 42,
|
|
Success: true,
|
|
}, ""
|
|
}))
|
|
defer srv.Close()
|
|
|
|
client := NewClient(srv.URL)
|
|
result, err := client.WaitForTx("tx-123", 5*time.Second)
|
|
if err != nil {
|
|
t.Fatalf("WaitForTx should succeed: %v", err)
|
|
}
|
|
if result.TxID != "tx-123" {
|
|
t.Errorf("TxID = %q, want %q", result.TxID, "tx-123")
|
|
}
|
|
if result.BlockHeight != 42 {
|
|
t.Errorf("BlockHeight = %d, want 42", result.BlockHeight)
|
|
}
|
|
if !result.Success {
|
|
t.Error("Success should be true")
|
|
}
|
|
if calls.Load() != 2 {
|
|
t.Errorf("expected 2 RPC calls, got %d", calls.Load())
|
|
}
|
|
}
|
|
|
|
func TestWaitForTx_Timeout(t *testing.T) {
|
|
srv := httptest.NewServer(rpcHandler(func(method string, params json.RawMessage) (any, string) {
|
|
// Always return null — transaction never confirms
|
|
return nil, ""
|
|
}))
|
|
defer srv.Close()
|
|
|
|
client := NewClient(srv.URL)
|
|
timeout := 500 * time.Millisecond
|
|
start := time.Now()
|
|
result, err := client.WaitForTx("tx-never", timeout)
|
|
elapsed := time.Since(start)
|
|
|
|
if err == nil {
|
|
t.Fatal("WaitForTx should return timeout error")
|
|
}
|
|
if result != nil {
|
|
t.Error("result should be nil on timeout")
|
|
}
|
|
if !strings.Contains(err.Error(), "not confirmed within") {
|
|
t.Errorf("error should mention timeout, got: %v", err)
|
|
}
|
|
// Should have waited at least the timeout duration
|
|
if elapsed < timeout {
|
|
t.Errorf("elapsed %v is less than timeout %v", elapsed, timeout)
|
|
}
|
|
}
|
|
|
|
func TestWaitForTx_TxFailure(t *testing.T) {
|
|
srv := httptest.NewServer(rpcHandler(func(method string, params json.RawMessage) (any, string) {
|
|
return &TxStatusResult{
|
|
TxID: "tx-fail",
|
|
BlockHeight: 10,
|
|
Success: false,
|
|
Error: "insufficient balance: have 0 need 100",
|
|
}, ""
|
|
}))
|
|
defer srv.Close()
|
|
|
|
client := NewClient(srv.URL)
|
|
result, err := client.WaitForTx("tx-fail", 5*time.Second)
|
|
|
|
if err == nil {
|
|
t.Fatal("WaitForTx should return TxError for failed transaction")
|
|
}
|
|
|
|
// Should return a TxError
|
|
var txErr *TxError
|
|
if !errors.As(err, &txErr) {
|
|
t.Fatalf("error should be *TxError, got %T: %v", err, err)
|
|
}
|
|
if txErr.TxID != "tx-fail" {
|
|
t.Errorf("TxError.TxID = %q, want %q", txErr.TxID, "tx-fail")
|
|
}
|
|
if !strings.Contains(txErr.Message, "insufficient balance") {
|
|
t.Errorf("TxError.Message should contain 'insufficient balance', got %q", txErr.Message)
|
|
}
|
|
|
|
// Result should still be returned even on TxError
|
|
if result == nil {
|
|
t.Fatal("result should be non-nil even on TxError")
|
|
}
|
|
if result.Success {
|
|
t.Error("result.Success should be false")
|
|
}
|
|
}
|
|
|
|
func TestWaitForTx_RPCError(t *testing.T) {
|
|
srv := httptest.NewServer(rpcHandler(func(method string, params json.RawMessage) (any, string) {
|
|
return nil, "internal server error"
|
|
}))
|
|
defer srv.Close()
|
|
|
|
client := NewClient(srv.URL)
|
|
result, err := client.WaitForTx("tx-rpc-err", 2*time.Second)
|
|
|
|
if err == nil {
|
|
t.Fatal("WaitForTx should return error on RPC failure")
|
|
}
|
|
if result != nil {
|
|
t.Error("result should be nil on RPC error")
|
|
}
|
|
if !strings.Contains(err.Error(), "getTxStatus") {
|
|
t.Errorf("error should wrap getTxStatus context, got: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestSendTxAndWait_Success(t *testing.T) {
|
|
srv := httptest.NewServer(rpcHandler(func(method string, params json.RawMessage) (any, string) {
|
|
switch method {
|
|
case "sendTx":
|
|
return &SendTxResult{TxID: "tx-abc"}, ""
|
|
case "getTxStatus":
|
|
return &TxStatusResult{
|
|
TxID: "tx-abc",
|
|
BlockHeight: 5,
|
|
Success: true,
|
|
}, ""
|
|
default:
|
|
return nil, fmt.Sprintf("unexpected method: %s", method)
|
|
}
|
|
}))
|
|
defer srv.Close()
|
|
|
|
client := NewClient(srv.URL)
|
|
result, err := client.SendTxAndWait(map[string]string{"dummy": "tx"}, 5*time.Second)
|
|
if err != nil {
|
|
t.Fatalf("SendTxAndWait should succeed: %v", err)
|
|
}
|
|
if result.TxID != "tx-abc" {
|
|
t.Errorf("TxID = %q, want %q", result.TxID, "tx-abc")
|
|
}
|
|
if result.BlockHeight != 5 {
|
|
t.Errorf("BlockHeight = %d, want 5", result.BlockHeight)
|
|
}
|
|
}
|
|
|
|
func TestSendTxAndWait_SendError(t *testing.T) {
|
|
srv := httptest.NewServer(rpcHandler(func(method string, params json.RawMessage) (any, string) {
|
|
return nil, "mempool full"
|
|
}))
|
|
defer srv.Close()
|
|
|
|
client := NewClient(srv.URL)
|
|
result, err := client.SendTxAndWait(map[string]string{"dummy": "tx"}, 5*time.Second)
|
|
|
|
if err == nil {
|
|
t.Fatal("SendTxAndWait should fail when sendTx fails")
|
|
}
|
|
if result != nil {
|
|
t.Error("result should be nil on send error")
|
|
}
|
|
if !strings.Contains(err.Error(), "send tx") {
|
|
t.Errorf("error should wrap send tx context, got: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestSendTxAndWait_TxFailure(t *testing.T) {
|
|
srv := httptest.NewServer(rpcHandler(func(method string, params json.RawMessage) (any, string) {
|
|
switch method {
|
|
case "sendTx":
|
|
return &SendTxResult{TxID: "tx-will-fail"}, ""
|
|
case "getTxStatus":
|
|
return &TxStatusResult{
|
|
TxID: "tx-will-fail",
|
|
BlockHeight: 7,
|
|
Success: false,
|
|
Error: "asset is not tradeable",
|
|
}, ""
|
|
default:
|
|
return nil, "unexpected"
|
|
}
|
|
}))
|
|
defer srv.Close()
|
|
|
|
client := NewClient(srv.URL)
|
|
result, err := client.SendTxAndWait(map[string]string{"dummy": "tx"}, 5*time.Second)
|
|
|
|
if err == nil {
|
|
t.Fatal("SendTxAndWait should return error for failed tx")
|
|
}
|
|
|
|
var txErr *TxError
|
|
if !errors.As(err, &txErr) {
|
|
t.Fatalf("error should be *TxError, got %T: %v", err, err)
|
|
}
|
|
if txErr.TxID != "tx-will-fail" {
|
|
t.Errorf("TxError.TxID = %q, want %q", txErr.TxID, "tx-will-fail")
|
|
}
|
|
|
|
// Result still returned with failure details
|
|
if result == nil {
|
|
t.Fatal("result should be non-nil even on TxError")
|
|
}
|
|
if result.Success {
|
|
t.Error("result.Success should be false")
|
|
}
|
|
}
|
|
|
|
func TestWaitForTx_PollingBackoff(t *testing.T) {
|
|
var calls atomic.Int32
|
|
confirmAfter := int32(5) // confirm on the 5th call
|
|
srv := httptest.NewServer(rpcHandler(func(method string, params json.RawMessage) (any, string) {
|
|
n := calls.Add(1)
|
|
if n < confirmAfter {
|
|
return nil, ""
|
|
}
|
|
return &TxStatusResult{
|
|
TxID: "tx-backoff",
|
|
BlockHeight: 99,
|
|
Success: true,
|
|
}, ""
|
|
}))
|
|
defer srv.Close()
|
|
|
|
client := NewClient(srv.URL)
|
|
result, err := client.WaitForTx("tx-backoff", 10*time.Second)
|
|
if err != nil {
|
|
t.Fatalf("WaitForTx should succeed: %v", err)
|
|
}
|
|
if result.TxID != "tx-backoff" {
|
|
t.Errorf("TxID = %q, want %q", result.TxID, "tx-backoff")
|
|
}
|
|
if calls.Load() != confirmAfter {
|
|
t.Errorf("expected %d RPC calls, got %d", confirmAfter, calls.Load())
|
|
}
|
|
}
|
|
|
|
func TestGetTxStatus_NotFound(t *testing.T) {
|
|
srv := httptest.NewServer(rpcHandler(func(method string, params json.RawMessage) (any, string) {
|
|
// Return null result — tx not yet in a block
|
|
return nil, ""
|
|
}))
|
|
defer srv.Close()
|
|
|
|
client := NewClient(srv.URL)
|
|
result, err := client.GetTxStatus("tx-pending")
|
|
if err != nil {
|
|
t.Fatalf("GetTxStatus should not error for pending tx: %v", err)
|
|
}
|
|
if result != nil {
|
|
t.Errorf("result should be nil for pending tx, got %+v", result)
|
|
}
|
|
}
|
|
|
|
func TestGetTxStatus_Found(t *testing.T) {
|
|
srv := httptest.NewServer(rpcHandler(func(method string, params json.RawMessage) (any, string) {
|
|
return &TxStatusResult{
|
|
TxID: "tx-done",
|
|
BlockHeight: 100,
|
|
Success: true,
|
|
}, ""
|
|
}))
|
|
defer srv.Close()
|
|
|
|
client := NewClient(srv.URL)
|
|
result, err := client.GetTxStatus("tx-done")
|
|
if err != nil {
|
|
t.Fatalf("GetTxStatus should succeed: %v", err)
|
|
}
|
|
if result == nil {
|
|
t.Fatal("result should not be nil for confirmed tx")
|
|
}
|
|
if result.TxID != "tx-done" || result.BlockHeight != 100 || !result.Success {
|
|
t.Errorf("unexpected result: %+v", result)
|
|
}
|
|
}
|