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