Files
a301_server/internal/chain/client_test.go
tolelom f4d862b47f feat: 보상 재시도 + TX 확정 대기 + 에러 포맷 통일 + 품질 고도화
- 보상 지급 실패 시 즉시 재시도(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>
2026-03-18 16:42:03 +09:00

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