diff --git a/.github/workflows/integration.yml b/.github/workflows/integration.yml
new file mode 100644
index 0000000..7492f2d
--- /dev/null
+++ b/.github/workflows/integration.yml
@@ -0,0 +1,353 @@
+---
+name: Integration Tests
+
+on:
+ push:
+ branches:
+ - 'feature/**'
+ - 'hotfix/**'
+ - 'bugfix/**'
+ - 'release/**'
+ - 'major/**'
+ - master
+ pull_request:
+ branches:
+ - master
+ types:
+ - opened
+ - synchronize
+ - reopened
+ workflow_dispatch:
+ inputs:
+ interx_url:
+ description: 'Interx server URL to test against'
+ required: false
+ default: 'http://3.123.154.245:11000'
+ test_address:
+ description: 'Test account address'
+ required: false
+ default: 'kira143q8vxpvuykt9pq50e6hng9s38vmy844n8k9wx'
+
+env:
+ INTERX_URL: ${{ github.event.inputs.interx_url || 'http://3.123.154.245:11000' }}
+ TEST_ADDRESS: ${{ github.event.inputs.test_address || 'kira143q8vxpvuykt9pq50e6hng9s38vmy844n8k9wx' }}
+
+jobs:
+ smoke-tests:
+ name: Smoke Tests
+ runs-on: ubuntu-latest
+ timeout-minutes: 5
+
+ steps:
+ - name: Checkout code
+ uses: actions/checkout@v4
+
+ - name: Check server availability
+ id: server-check
+ run: |
+ echo "Testing connection to $INTERX_URL"
+ if curl -sf "$INTERX_URL/api/status" > /dev/null; then
+ echo "server_available=true" >> $GITHUB_OUTPUT
+ echo "Server is available"
+ else
+ echo "server_available=false" >> $GITHUB_OUTPUT
+ echo "Warning: Server at $INTERX_URL is not available"
+ fi
+
+ - name: Build test container
+ if: steps.server-check.outputs.server_available == 'true'
+ working-directory: tests/integration
+ run: docker build -t interx-integration-tests .
+
+ - name: Run smoke tests
+ if: steps.server-check.outputs.server_available == 'true'
+ working-directory: tests/integration
+ run: |
+ docker run --rm --network host \
+ -e INTERX_URL=${{ env.INTERX_URL }} \
+ -e TEST_ADDRESS=${{ env.TEST_ADDRESS }} \
+ interx-integration-tests \
+ go test -v -count=1 -run "TestInterxStatus|TestKiraStatus" ./...
+
+ outputs:
+ server_available: ${{ steps.server-check.outputs.server_available }}
+
+ format-validation:
+ name: Miro Format Validation
+ runs-on: ubuntu-latest
+ timeout-minutes: 10
+ needs: smoke-tests
+ if: needs.smoke-tests.outputs.server_available == 'true'
+
+ steps:
+ - name: Checkout code
+ uses: actions/checkout@v4
+
+ - name: Build test container
+ working-directory: tests/integration
+ run: docker build -t interx-integration-tests .
+
+ - name: Run format validation tests
+ id: format-tests
+ working-directory: tests/integration
+ run: |
+ docker run --rm --network host \
+ -e INTERX_URL=${{ env.INTERX_URL }} \
+ -e TEST_ADDRESS=${{ env.TEST_ADDRESS }} \
+ interx-integration-tests \
+ go test -v -count=1 -run "ResponseFormat" ./... 2>&1 | tee format-test-output.log
+
+ # Count failures
+ FAILURES=$(grep -c "^--- FAIL" format-test-output.log || echo "0")
+ PASSES=$(grep -c "^--- PASS" format-test-output.log || echo "0")
+ echo "failures=$FAILURES" >> $GITHUB_OUTPUT
+ echo "passes=$PASSES" >> $GITHUB_OUTPUT
+ continue-on-error: true
+
+ - name: Format test summary
+ run: |
+ echo "## Miro Format Validation Results" >> $GITHUB_STEP_SUMMARY
+ echo "" >> $GITHUB_STEP_SUMMARY
+ echo "| Metric | Count |" >> $GITHUB_STEP_SUMMARY
+ echo "|--------|-------|" >> $GITHUB_STEP_SUMMARY
+ echo "| Passed | ${{ steps.format-tests.outputs.passes }} |" >> $GITHUB_STEP_SUMMARY
+ echo "| Failed | ${{ steps.format-tests.outputs.failures }} |" >> $GITHUB_STEP_SUMMARY
+ echo "" >> $GITHUB_STEP_SUMMARY
+
+ if [ "${{ steps.format-tests.outputs.failures }}" != "0" ]; then
+ echo "### Failed Tests (Known Issues)" >> $GITHUB_STEP_SUMMARY
+ echo "These failures track known incompatibilities with miro frontend:" >> $GITHUB_STEP_SUMMARY
+ echo "- Issue #13: snake_case vs camelCase" >> $GITHUB_STEP_SUMMARY
+ echo "- Issue #16: String vs number types" >> $GITHUB_STEP_SUMMARY
+ echo "- Issue #19: Missing expected fields" >> $GITHUB_STEP_SUMMARY
+ fi
+
+ - name: Upload format test results
+ uses: actions/upload-artifact@v4
+ if: always()
+ with:
+ name: format-validation-results
+ path: tests/integration/format-test-output.log
+ retention-days: 7
+
+ integration-tests:
+ name: Full Integration Tests
+ runs-on: ubuntu-latest
+ timeout-minutes: 15
+ needs: smoke-tests
+ if: needs.smoke-tests.outputs.server_available == 'true'
+
+ steps:
+ - name: Checkout code
+ uses: actions/checkout@v4
+
+ - name: Build test container
+ working-directory: tests/integration
+ run: docker build -t interx-integration-tests .
+
+ - name: Run integration tests
+ id: integration-tests
+ working-directory: tests/integration
+ run: |
+ docker run --rm --network host \
+ -e INTERX_URL=${{ env.INTERX_URL }} \
+ -e TEST_ADDRESS=${{ env.TEST_ADDRESS }} \
+ interx-integration-tests \
+ go test -v -count=1 -timeout=10m ./... 2>&1 | tee test-output.log
+
+ # Parse results
+ FAILURES=$(grep -c "^--- FAIL" test-output.log || echo "0")
+ PASSES=$(grep -c "^--- PASS" test-output.log || echo "0")
+ TOTAL=$((FAILURES + PASSES))
+ echo "failures=$FAILURES" >> $GITHUB_OUTPUT
+ echo "passes=$PASSES" >> $GITHUB_OUTPUT
+ echo "total=$TOTAL" >> $GITHUB_OUTPUT
+ continue-on-error: true
+
+ - name: Test summary
+ run: |
+ echo "## Integration Test Results" >> $GITHUB_STEP_SUMMARY
+ echo "" >> $GITHUB_STEP_SUMMARY
+ echo "| Metric | Count |" >> $GITHUB_STEP_SUMMARY
+ echo "|--------|-------|" >> $GITHUB_STEP_SUMMARY
+ echo "| Total | ${{ steps.integration-tests.outputs.total }} |" >> $GITHUB_STEP_SUMMARY
+ echo "| Passed | ${{ steps.integration-tests.outputs.passes }} |" >> $GITHUB_STEP_SUMMARY
+ echo "| Failed | ${{ steps.integration-tests.outputs.failures }} |" >> $GITHUB_STEP_SUMMARY
+ echo "" >> $GITHUB_STEP_SUMMARY
+
+ # List failed tests
+ if [ "${{ steps.integration-tests.outputs.failures }}" != "0" ]; then
+ echo "### Failed Tests" >> $GITHUB_STEP_SUMMARY
+ echo '```' >> $GITHUB_STEP_SUMMARY
+ grep "^--- FAIL" tests/integration/test-output.log >> $GITHUB_STEP_SUMMARY || true
+ echo '```' >> $GITHUB_STEP_SUMMARY
+ fi
+
+ - name: Upload test results
+ uses: actions/upload-artifact@v4
+ if: always()
+ with:
+ name: integration-test-results
+ path: tests/integration/test-output.log
+ retention-days: 7
+
+ category-tests:
+ name: ${{ matrix.category.name }} Tests
+ runs-on: ubuntu-latest
+ timeout-minutes: 10
+ needs: smoke-tests
+ if: needs.smoke-tests.outputs.server_available == 'true'
+ strategy:
+ fail-fast: false
+ matrix:
+ category:
+ - name: Account
+ pattern: "Test(Account|Balances|QueryAccounts)"
+ - name: Transactions
+ pattern: "Test(Transaction|Blocks)"
+ - name: Validators
+ pattern: "Test(Validator|Consensus)"
+ - name: Status
+ pattern: "Test(Status|Dashboard|Metadata|RPC|TotalSupply)"
+ - name: Governance
+ pattern: "Test(Proposal|Voters|Votes|NetworkProperties|ExecutionFee|DataKeys|Permissions|Roles)"
+ - name: Staking
+ pattern: "Test(StakingPool|Delegations|Undelegations)"
+ - name: Tokens
+ pattern: "Test(TokenAliases|TokenRates)"
+ - name: Identity
+ pattern: "Test(Identity)"
+ - name: NodeDiscovery
+ pattern: "Test(P2P|Interx|Snap)List"
+
+ steps:
+ - name: Checkout code
+ uses: actions/checkout@v4
+
+ - name: Build test container
+ working-directory: tests/integration
+ run: docker build -t interx-integration-tests .
+
+ - name: Run ${{ matrix.category.name }} tests
+ working-directory: tests/integration
+ run: |
+ docker run --rm --network host \
+ -e INTERX_URL=${{ env.INTERX_URL }} \
+ -e TEST_ADDRESS=${{ env.TEST_ADDRESS }} \
+ interx-integration-tests \
+ go test -v -count=1 -run "${{ matrix.category.pattern }}" ./...
+ continue-on-error: true
+
+ test-report:
+ name: Test Report
+ runs-on: ubuntu-latest
+ needs: [smoke-tests, format-validation, integration-tests, category-tests]
+ if: always()
+
+ steps:
+ - name: Download all artifacts
+ uses: actions/download-artifact@v4
+ with:
+ path: test-results
+ continue-on-error: true
+
+ - name: Generate report
+ run: |
+ echo "## Integration Test Report" >> $GITHUB_STEP_SUMMARY
+ echo "" >> $GITHUB_STEP_SUMMARY
+ echo "**Test Server:** \`${{ env.INTERX_URL }}\`" >> $GITHUB_STEP_SUMMARY
+ echo "" >> $GITHUB_STEP_SUMMARY
+
+ # Job status table
+ echo "### Job Status" >> $GITHUB_STEP_SUMMARY
+ echo "| Job | Status |" >> $GITHUB_STEP_SUMMARY
+ echo "|-----|--------|" >> $GITHUB_STEP_SUMMARY
+
+ # Helper function for status emoji
+ status_icon() {
+ case "$1" in
+ success) echo ":white_check_mark:" ;;
+ failure) echo ":x:" ;;
+ skipped) echo ":fast_forward:" ;;
+ cancelled) echo ":stop_sign:" ;;
+ *) echo ":grey_question:" ;;
+ esac
+ }
+
+ echo "| Smoke Tests | $(status_icon "${{ needs.smoke-tests.result }}") ${{ needs.smoke-tests.result }} |" >> $GITHUB_STEP_SUMMARY
+ echo "| Format Validation | $(status_icon "${{ needs.format-validation.result }}") ${{ needs.format-validation.result }} |" >> $GITHUB_STEP_SUMMARY
+ echo "| Integration Tests | $(status_icon "${{ needs.integration-tests.result }}") ${{ needs.integration-tests.result }} |" >> $GITHUB_STEP_SUMMARY
+ echo "| Category Tests | $(status_icon "${{ needs.category-tests.result }}") ${{ needs.category-tests.result }} |" >> $GITHUB_STEP_SUMMARY
+ echo "" >> $GITHUB_STEP_SUMMARY
+
+ # Parse integration test results if available
+ if [ -f "test-results/integration-test-results/test-output.log" ]; then
+ LOG_FILE="test-results/integration-test-results/test-output.log"
+
+ TOTAL_PASS=$(grep -c "^--- PASS" "$LOG_FILE" 2>/dev/null || echo "0")
+ TOTAL_FAIL=$(grep -c "^--- FAIL" "$LOG_FILE" 2>/dev/null || echo "0")
+ TOTAL=$((TOTAL_PASS + TOTAL_FAIL))
+
+ echo "### Integration Test Results" >> $GITHUB_STEP_SUMMARY
+ echo "" >> $GITHUB_STEP_SUMMARY
+ echo "| Metric | Count |" >> $GITHUB_STEP_SUMMARY
+ echo "|--------|-------|" >> $GITHUB_STEP_SUMMARY
+ echo "| :white_check_mark: Passed | $TOTAL_PASS |" >> $GITHUB_STEP_SUMMARY
+ echo "| :x: Failed | $TOTAL_FAIL |" >> $GITHUB_STEP_SUMMARY
+ echo "| **Total** | $TOTAL |" >> $GITHUB_STEP_SUMMARY
+ echo "" >> $GITHUB_STEP_SUMMARY
+
+ # List failed tests if any
+ if [ "$TOTAL_FAIL" != "0" ]; then
+ echo "" >> $GITHUB_STEP_SUMMARY
+ echo ":x: Failed Tests ($TOTAL_FAIL)
" >> $GITHUB_STEP_SUMMARY
+ echo "" >> $GITHUB_STEP_SUMMARY
+ echo '```' >> $GITHUB_STEP_SUMMARY
+ grep "^--- FAIL" "$LOG_FILE" | head -50 >> $GITHUB_STEP_SUMMARY
+ echo '```' >> $GITHUB_STEP_SUMMARY
+ echo " " >> $GITHUB_STEP_SUMMARY
+ echo "" >> $GITHUB_STEP_SUMMARY
+ fi
+
+ # List passed tests in collapsible section
+ if [ "$TOTAL_PASS" != "0" ]; then
+ echo "" >> $GITHUB_STEP_SUMMARY
+ echo ":white_check_mark: Passed Tests ($TOTAL_PASS)
" >> $GITHUB_STEP_SUMMARY
+ echo "" >> $GITHUB_STEP_SUMMARY
+ echo '```' >> $GITHUB_STEP_SUMMARY
+ grep "^--- PASS" "$LOG_FILE" | head -100 >> $GITHUB_STEP_SUMMARY
+ echo '```' >> $GITHUB_STEP_SUMMARY
+ echo " " >> $GITHUB_STEP_SUMMARY
+ echo "" >> $GITHUB_STEP_SUMMARY
+ fi
+ fi
+
+ # Parse format validation results if available
+ if [ -f "test-results/format-validation-results/format-test-output.log" ]; then
+ LOG_FILE="test-results/format-validation-results/format-test-output.log"
+
+ FORMAT_PASS=$(grep -c "^--- PASS" "$LOG_FILE" 2>/dev/null || echo "0")
+ FORMAT_FAIL=$(grep -c "^--- FAIL" "$LOG_FILE" 2>/dev/null || echo "0")
+
+ echo "### Format Validation Results" >> $GITHUB_STEP_SUMMARY
+ echo "" >> $GITHUB_STEP_SUMMARY
+ echo "| Metric | Count |" >> $GITHUB_STEP_SUMMARY
+ echo "|--------|-------|" >> $GITHUB_STEP_SUMMARY
+ echo "| :white_check_mark: Passed | $FORMAT_PASS |" >> $GITHUB_STEP_SUMMARY
+ echo "| :x: Failed | $FORMAT_FAIL |" >> $GITHUB_STEP_SUMMARY
+ echo "" >> $GITHUB_STEP_SUMMARY
+
+ if [ "$FORMAT_FAIL" != "0" ]; then
+ echo "" >> $GITHUB_STEP_SUMMARY
+ echo ":x: Format Validation Failures ($FORMAT_FAIL)
" >> $GITHUB_STEP_SUMMARY
+ echo "" >> $GITHUB_STEP_SUMMARY
+ echo '```' >> $GITHUB_STEP_SUMMARY
+ grep "^--- FAIL" "$LOG_FILE" | head -50 >> $GITHUB_STEP_SUMMARY
+ echo '```' >> $GITHUB_STEP_SUMMARY
+ echo " " >> $GITHUB_STEP_SUMMARY
+ echo "" >> $GITHUB_STEP_SUMMARY
+ fi
+ fi
+
+ echo "---" >> $GITHUB_STEP_SUMMARY
+ echo "*Full test logs available in workflow artifacts.*" >> $GITHUB_STEP_SUMMARY
diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml
index 8575df8..a2d43eb 100644
--- a/.github/workflows/lint.yml
+++ b/.github/workflows/lint.yml
@@ -20,9 +20,10 @@ on:
jobs:
golangci-lint:
- name: Go Lint
+ name: Lint (${{ matrix.component }})
runs-on: ubuntu-latest
strategy:
+ fail-fast: false
matrix:
component:
- manager
@@ -42,12 +43,85 @@ jobs:
go-version: '1.24.2'
- name: Run golangci-lint
- uses: golangci/golangci-lint-action@v6
+ id: lint
continue-on-error: true
+ uses: golangci/golangci-lint-action@v6
with:
version: latest
working-directory: ${{ matrix.component }}
- args: --timeout=10m
+ args: --timeout=10m --out-format=colored-line-number
+
+ - name: Save lint result
+ if: always()
+ run: |
+ COMPONENT_SAFE=$(echo "${{ matrix.component }}" | tr '/' '-')
+ mkdir -p lint-results
+ echo '{"component": "${{ matrix.component }}", "status": "${{ steps.lint.outcome }}"}' > lint-results/${COMPONENT_SAFE}.json
+
+ - name: Upload lint result
+ if: always()
+ uses: actions/upload-artifact@v4
+ with:
+ name: lint-result-${{ strategy.job-index }}
+ path: lint-results/
+ retention-days: 1
+
+ lint-report:
+ name: Lint Report
+ runs-on: ubuntu-latest
+ needs: golangci-lint
+ if: always()
+ steps:
+ - name: Download all lint results
+ uses: actions/download-artifact@v4
+ with:
+ pattern: lint-result-*
+ path: lint-results
+ merge-multiple: true
+
+ - name: Generate lint report
+ run: |
+ echo "## Lint Report" >> $GITHUB_STEP_SUMMARY
+ echo "" >> $GITHUB_STEP_SUMMARY
+ echo "| Component | Status |" >> $GITHUB_STEP_SUMMARY
+ echo "|-----------|--------|" >> $GITHUB_STEP_SUMMARY
+
+ PASSED=0
+ FAILED=0
+ TOTAL=0
+
+ for file in lint-results/*.json; do
+ if [ -f "$file" ]; then
+ COMPONENT=$(jq -r '.component' "$file")
+ STATUS=$(jq -r '.status' "$file")
+ TOTAL=$((TOTAL + 1))
+
+ if [ "$STATUS" == "success" ]; then
+ echo "| \`$COMPONENT\` | :white_check_mark: Pass |" >> $GITHUB_STEP_SUMMARY
+ PASSED=$((PASSED + 1))
+ else
+ echo "| \`$COMPONENT\` | :x: Fail |" >> $GITHUB_STEP_SUMMARY
+ FAILED=$((FAILED + 1))
+ fi
+ fi
+ done
+
+ echo "" >> $GITHUB_STEP_SUMMARY
+ echo "### Summary" >> $GITHUB_STEP_SUMMARY
+ echo "- **Total:** $TOTAL components" >> $GITHUB_STEP_SUMMARY
+ echo "- **Passed:** $PASSED" >> $GITHUB_STEP_SUMMARY
+ echo "- **Failed:** $FAILED" >> $GITHUB_STEP_SUMMARY
+ echo "" >> $GITHUB_STEP_SUMMARY
+
+ if [ $FAILED -gt 0 ]; then
+ echo "> **Note:** Lint failures are currently non-blocking. Set \`ENFORCE_LINT: true\` to make them required." >> $GITHUB_STEP_SUMMARY
+ fi
+
+ # Uncomment to enforce lint checks in the future:
+ # if [ $FAILED -gt 0 ]; then
+ # echo "::error::$FAILED component(s) failed lint checks"
+ # exit 1
+ # fi
branch-validation:
name: Validate Branch Name
diff --git a/tests/integration/Dockerfile b/tests/integration/Dockerfile
new file mode 100644
index 0000000..987fd63
--- /dev/null
+++ b/tests/integration/Dockerfile
@@ -0,0 +1,21 @@
+FROM golang:1.22-alpine
+
+WORKDIR /app
+
+# Install required packages
+RUN apk add --no-cache make curl git
+
+# Copy test files (built from tests/integration/ directory)
+COPY . .
+
+# Initialize dependencies
+RUN go mod tidy && go mod download
+
+# Set default environment variables
+ENV INTERX_URL=http://3.123.154.245:11000
+ENV TEST_ADDRESS=kira143q8vxpvuykt9pq50e6hng9s38vmy844n8k9wx
+ENV VALIDATOR_ADDRESS=kira1vvcj9avffvyav83gmptdlzrprgvsrjxzh7f9sz
+ENV DELEGATOR_ADDRESS=kira177lwmjyjds3cy7trers83r4pjn3dhv8zrqk9dl
+
+# Default command runs all tests
+CMD ["go", "test", "-v", "-count=1", "./..."]
diff --git a/tests/integration/Makefile b/tests/integration/Makefile
new file mode 100644
index 0000000..b0f6328
--- /dev/null
+++ b/tests/integration/Makefile
@@ -0,0 +1,154 @@
+.PHONY: test test-chaosnet test-local test-verbose deps clean help \
+ docker-build docker-test docker-smoke docker-account docker-clean \
+ docker-format ci-test ci-format
+
+# Default test server
+INTERX_URL ?= http://3.123.154.245:11000
+TEST_ADDRESS ?= kira143q8vxpvuykt9pq50e6hng9s38vmy844n8k9wx
+
+help:
+ @echo "Interx Integration Tests"
+ @echo ""
+ @echo "Docker Commands (Recommended):"
+ @echo " make docker-build Build the test container"
+ @echo " make docker-test Run all tests in container"
+ @echo " make docker-smoke Run smoke tests in container"
+ @echo " make docker-account Run account tests in container"
+ @echo " make docker-format Run format validation tests"
+ @echo " make docker-clean Remove test containers and images"
+ @echo ""
+ @echo "CI Commands (used by GitHub Actions):"
+ @echo " make ci-test Run full test suite with summary"
+ @echo " make ci-format Run format validation with summary"
+ @echo ""
+ @echo "Local Commands:"
+ @echo " make deps Install dependencies"
+ @echo " make test Run all integration tests"
+ @echo " make test-chaosnet Run tests against chaosnet"
+ @echo " make test-local Run tests against local instance"
+ @echo " make test-single Run a single test (use TEST=TestName)"
+ @echo " make clean Clean test cache"
+ @echo ""
+ @echo "Environment variables:"
+ @echo " INTERX_URL Override the test server URL"
+ @echo " TEST_ADDRESS Override the test account address"
+ @echo " VALIDATOR_ADDRESS Override the validator address"
+ @echo " DELEGATOR_ADDRESS Override the delegator address"
+
+# =============================================================================
+# Docker Commands
+# =============================================================================
+
+docker-build:
+ docker build -t interx-integration-tests .
+
+docker-test: docker-build
+ docker run --rm --network host \
+ -e INTERX_URL=$(INTERX_URL) \
+ -e TEST_ADDRESS=$(TEST_ADDRESS) \
+ interx-integration-tests
+
+docker-smoke: docker-build
+ docker run --rm --network host \
+ -e INTERX_URL=$(INTERX_URL) \
+ -e TEST_ADDRESS=$(TEST_ADDRESS) \
+ interx-integration-tests \
+ go test -v -count=1 -run "TestInterxStatus|TestKiraStatus" ./...
+
+docker-account: docker-build
+ docker run --rm --network host \
+ -e INTERX_URL=$(INTERX_URL) \
+ -e TEST_ADDRESS=$(TEST_ADDRESS) \
+ interx-integration-tests \
+ go test -v -count=1 -run "TestQuery(Accounts|Balances)" ./...
+
+docker-single: docker-build
+ docker run --rm --network host \
+ -e INTERX_URL=$(INTERX_URL) \
+ -e TEST_ADDRESS=$(TEST_ADDRESS) \
+ interx-integration-tests \
+ go test -v -count=1 -run "$(TEST)" ./...
+
+docker-compose-test:
+ docker-compose up --build --abort-on-container-exit integration-tests
+
+docker-compose-smoke:
+ docker-compose up --build --abort-on-container-exit smoke-tests
+
+docker-clean:
+ docker rmi -f interx-integration-tests 2>/dev/null || true
+ docker-compose down --rmi local 2>/dev/null || true
+
+docker-format: docker-build
+ docker run --rm --network host \
+ -e INTERX_URL=$(INTERX_URL) \
+ -e TEST_ADDRESS=$(TEST_ADDRESS) \
+ interx-integration-tests \
+ go test -v -count=1 -run "ResponseFormat" ./...
+
+# =============================================================================
+# CI Commands (used by GitHub Actions)
+# =============================================================================
+
+ci-test: docker-build
+ @echo "Running full integration test suite..."
+ docker run --rm --network host \
+ -e INTERX_URL=$(INTERX_URL) \
+ -e TEST_ADDRESS=$(TEST_ADDRESS) \
+ interx-integration-tests \
+ go test -v -count=1 -timeout=10m ./... 2>&1 | tee test-output.log
+ @echo ""
+ @echo "=== Test Summary ==="
+ @grep -c "^--- PASS" test-output.log | xargs -I{} echo "Passed: {}"
+ @grep -c "^--- FAIL" test-output.log | xargs -I{} echo "Failed: {}"
+
+ci-format: docker-build
+ @echo "Running miro format validation tests..."
+ docker run --rm --network host \
+ -e INTERX_URL=$(INTERX_URL) \
+ -e TEST_ADDRESS=$(TEST_ADDRESS) \
+ interx-integration-tests \
+ go test -v -count=1 -run "ResponseFormat" ./... 2>&1 | tee format-test-output.log
+ @echo ""
+ @echo "=== Format Validation Summary ==="
+ @grep -c "^--- PASS" format-test-output.log | xargs -I{} echo "Passed: {}"
+ @grep -c "^--- FAIL" format-test-output.log | xargs -I{} echo "Failed: {}"
+
+# =============================================================================
+# Local Commands
+# =============================================================================
+
+deps:
+ go mod download
+ go mod tidy
+
+test: deps
+ INTERX_URL=$(INTERX_URL) TEST_ADDRESS=$(TEST_ADDRESS) go test -v -count=1 ./...
+
+test-chaosnet: deps
+ INTERX_URL=http://3.123.154.245:11000 TEST_ADDRESS=kira143q8vxpvuykt9pq50e6hng9s38vmy844n8k9wx go test -v -count=1 ./...
+
+test-local: deps
+ TEST_ENV=local go test -v -count=1 ./...
+
+test-verbose: deps
+ INTERX_URL=$(INTERX_URL) TEST_ADDRESS=$(TEST_ADDRESS) go test -v -count=1 -race ./...
+
+test-single: deps
+ INTERX_URL=$(INTERX_URL) TEST_ADDRESS=$(TEST_ADDRESS) go test -v -count=1 -run $(TEST) ./...
+
+# Run tests by category
+test-account: deps
+ INTERX_URL=$(INTERX_URL) go test -v -count=1 -run "TestQuery(Accounts|Balances)" ./...
+
+test-transactions: deps
+ INTERX_URL=$(INTERX_URL) go test -v -count=1 -run "Test(Transaction|QueryBlocks|QueryTransactions)" ./...
+
+test-validators: deps
+ INTERX_URL=$(INTERX_URL) go test -v -count=1 -run "Test(QueryValidators|Consensus|DumpConsensus)" ./...
+
+test-status: deps
+ INTERX_URL=$(INTERX_URL) go test -v -count=1 -run "Test(KiraStatus|InterxStatus|Dashboard|Metadata)" ./...
+
+clean:
+ go clean -testcache
diff --git a/tests/integration/README.md b/tests/integration/README.md
new file mode 100644
index 0000000..1e64986
--- /dev/null
+++ b/tests/integration/README.md
@@ -0,0 +1,160 @@
+# Interx Integration Tests
+
+This directory contains Go-based integration tests for the Interx API endpoints, designed to run in containers.
+
+## Quick Start (Docker)
+
+```bash
+# Build and run all tests
+make docker-test
+
+# Run smoke tests only
+make docker-smoke
+
+# Run with custom Interx URL
+INTERX_URL=http://your-server:11000 make docker-test
+```
+
+## Test Coverage
+
+The test suite covers **60+ API endpoints** organized into the following categories:
+
+| Category | Endpoints | Test File |
+|----------|-----------|-----------|
+| Account | 2 | `account_test.go` |
+| Transactions | 9 | `transactions_test.go` |
+| Validators | 5 | `validators_test.go` |
+| Faucet | 2 | `faucet_test.go` |
+| Proposals | 5 | `proposals_test.go` |
+| Genesis | 4 | `genesis_test.go` |
+| Data Reference | 2 | `data_reference_test.go` |
+| Tokens | 2 | `tokens_test.go` |
+| Node Discovery | 4 | `node_discovery_test.go` |
+| Upgrade | 2 | `upgrade_test.go` |
+| Identity Registrar | 7 | `identity_test.go` |
+| Permissions/Roles | 3 | `permissions_roles_test.go` |
+| Spending | 4 | `spending_test.go` |
+| Staking | 3 | `staking_test.go` |
+| Execution Fees | 2 | `execution_fee_test.go` |
+| Status/Meta | 9 | `status_test.go` |
+
+## Docker Commands
+
+| Command | Description |
+|---------|-------------|
+| `make docker-build` | Build the test container |
+| `make docker-test` | Run all tests in container |
+| `make docker-smoke` | Run smoke tests (status endpoints) |
+| `make docker-account` | Run account endpoint tests |
+| `make docker-single TEST=TestName` | Run a specific test |
+| `make docker-clean` | Remove containers and images |
+
+### Using Docker Compose
+
+```bash
+# Run all integration tests
+docker-compose up --build integration-tests
+
+# Run smoke tests
+docker-compose up --build smoke-tests
+
+# Run account tests
+docker-compose up --build account-tests
+```
+
+## Configuration
+
+Tests can be configured via environment variables:
+
+| Variable | Default | Description |
+|----------|---------|-------------|
+| `INTERX_URL` | `http://3.123.154.245:11000` | Interx server URL |
+| `TEST_ADDRESS` | `kira143q8vxpvuykt9pq50e6hng9s38vmy844n8k9wx` | Test account address |
+| `VALIDATOR_ADDRESS` | `kira1vvcj9avffvyav83gmptdlzrprgvsrjxzh7f9sz` | Validator address |
+| `DELEGATOR_ADDRESS` | `kira177lwmjyjds3cy7trers83r4pjn3dhv8zrqk9dl` | Delegator address |
+
+### Examples
+
+```bash
+# Test against chaosnet (default)
+make docker-test
+
+# Test against local instance
+INTERX_URL=http://localhost:11000 make docker-test
+
+# Test against custom server
+INTERX_URL=http://your-server:11000 TEST_ADDRESS=kira1... make docker-test
+```
+
+## Local Development
+
+For local development without Docker:
+
+```bash
+# Install dependencies
+make deps
+
+# Run tests locally
+make test
+
+# Run specific test
+make test-single TEST=TestInterxStatus
+```
+
+## CI Integration
+
+Integration tests run automatically via GitHub Actions on:
+- Push to feature/*, hotfix/*, bugfix/*, release/*, major/* branches
+- Pull requests to master
+- Manual workflow dispatch (with custom URL)
+
+See `.github/workflows/integration.yml` for the workflow configuration.
+
+## Adding New Tests
+
+1. Create a new test file following the naming convention `*_test.go`
+2. Use the `GetConfig()` function to get the test configuration
+3. Use `NewClient(cfg)` to create an HTTP client
+4. Follow the existing test patterns for assertions
+
+Example:
+```go
+func TestNewEndpoint(t *testing.T) {
+ cfg := GetConfig()
+ client := NewClient(cfg)
+
+ t.Run("test case name", func(t *testing.T) {
+ resp, err := client.Get("/api/new/endpoint", nil)
+ require.NoError(t, err)
+ assert.True(t, resp.IsSuccess())
+ })
+}
+```
+
+## Project Structure
+
+```
+tests/integration/
+├── Dockerfile # Container image for tests
+├── docker-compose.yml # Multi-service test configuration
+├── Makefile # Build and run commands
+├── go.mod # Go module definition
+├── config.go # Environment configuration
+├── client.go # HTTP client utilities
+├── account_test.go # Account endpoint tests
+├── transactions_test.go # Transaction endpoint tests
+├── validators_test.go # Validator endpoint tests
+├── faucet_test.go # Faucet endpoint tests
+├── proposals_test.go # Governance proposal tests
+├── genesis_test.go # Genesis endpoint tests
+├── data_reference_test.go # Data reference tests
+├── tokens_test.go # Token endpoint tests
+├── node_discovery_test.go # Node discovery tests
+├── upgrade_test.go # Upgrade plan tests
+├── identity_test.go # Identity registrar tests
+├── permissions_roles_test.go # Permissions/roles tests
+├── spending_test.go # Spending pool tests
+├── staking_test.go # Staking endpoint tests
+├── execution_fee_test.go # Execution fee tests
+└── status_test.go # Status/metadata tests
+```
diff --git a/tests/integration/account_test.go b/tests/integration/account_test.go
new file mode 100644
index 0000000..c75b994
--- /dev/null
+++ b/tests/integration/account_test.go
@@ -0,0 +1,261 @@
+package integration
+
+import (
+ "encoding/json"
+ "strings"
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+// ============================================================================
+// Account Endpoint Tests - /api/kira/accounts/{address}
+// Expected format from miro: lib/infra/dto/api_kira/query_account/response/
+// ============================================================================
+
+// TestAccountResponseFormat validates the response matches expected miro format
+// Issues: #13 (snake_case), #16 (types)
+func TestAccountResponseFormat(t *testing.T) {
+ cfg := GetConfig()
+ client := NewClient(cfg)
+
+ resp, err := client.Get("/api/kira/accounts/"+cfg.TestAddress, nil)
+ require.NoError(t, err)
+ require.True(t, resp.IsSuccess(), "Expected success, got %d: %s", resp.StatusCode, string(resp.Body))
+
+ v, err := NewFormatValidator(t, resp.Body)
+ require.NoError(t, err, "Failed to parse response")
+
+ t.Run("@type field exists", func(t *testing.T) {
+ v.ValidateFieldExists("@type", "Format")
+ })
+
+ t.Run("address field exists", func(t *testing.T) {
+ v.ValidateFieldExists("address", "Format")
+ })
+
+ t.Run("account_number field naming (Issue #13)", func(t *testing.T) {
+ // miro expects snake_case: account_number
+ v.ValidateFieldNotExists("accountNumber", "account_number", "Issue #13")
+ if !v.HasField("account_number") {
+ t.Error("Issue #13: Missing 'account_number' field (miro expects snake_case)")
+ }
+ })
+
+ t.Run("pub_key field naming (Issue #13)", func(t *testing.T) {
+ // miro expects snake_case: pub_key
+ v.ValidateFieldNotExists("pubKey", "pub_key", "Issue #13")
+ // pub_key can be nil for new accounts, so just check naming
+ })
+
+ t.Run("sequence field type (Issue #16)", func(t *testing.T) {
+ // miro expects sequence as string
+ if v.HasField("sequence") {
+ v.ValidateFieldType("sequence", TypeString, "Issue #16")
+ }
+ })
+
+ t.Run("account_number field type", func(t *testing.T) {
+ // miro expects account_number as string
+ if v.HasField("account_number") {
+ v.ValidateFieldType("account_number", TypeString, "Issue #16")
+ }
+ })
+
+ t.Run("all top-level fields use snake_case (Issue #13)", func(t *testing.T) {
+ v.ValidateSnakeCase("Issue #13")
+ })
+
+ t.Run("pub_key nested fields use snake_case", func(t *testing.T) {
+ if v.HasField("pub_key") {
+ v.ValidateNestedSnakeCase("pub_key", "Issue #13")
+ }
+ })
+}
+
+// TestQueryAccounts tests GET /api/kira/accounts/{address}
+func TestQueryAccounts(t *testing.T) {
+ cfg := GetConfig()
+ client := NewClient(cfg)
+
+ t.Run("valid address returns account data", func(t *testing.T) {
+ resp, err := client.Get("/api/kira/accounts/"+cfg.TestAddress, nil)
+ require.NoError(t, err)
+ assert.True(t, resp.IsSuccess(), "Expected success, got status %d: %s", resp.StatusCode, string(resp.Body))
+
+ v, err := NewFormatValidator(t, resp.Body)
+ require.NoError(t, err)
+
+ // Verify required fields exist
+ assert.True(t, v.HasField("address") || v.HasField("@type"), "Response should contain address or @type field")
+ })
+
+ t.Run("invalid address returns error", func(t *testing.T) {
+ resp, err := client.Get("/api/kira/accounts/invalid_address", nil)
+ require.NoError(t, err)
+
+ var result map[string]interface{}
+ err = json.Unmarshal(resp.Body, &result)
+ require.NoError(t, err)
+
+ // Check for error response
+ if errVal, exists := result["Error"]; exists {
+ errStr, ok := errVal.(string)
+ if ok {
+ assert.True(t, strings.Contains(errStr, "bech32") || strings.Contains(errStr, "invalid") || strings.Contains(errStr, "decoding"),
+ "Error should mention bech32/invalid: %s", errStr)
+ }
+ }
+ })
+}
+
+// ============================================================================
+// Balance Endpoint Tests - /api/kira/balances/{address}
+// Expected format from miro: lib/infra/dto/api_kira/query_balance/response/
+// ============================================================================
+
+// TestBalancesResponseFormat validates the response matches expected miro format
+// Issues: #13 (snake_case), #16 (types), #19 (missing fields)
+func TestBalancesResponseFormat(t *testing.T) {
+ cfg := GetConfig()
+ client := NewClient(cfg)
+
+ resp, err := client.Get("/api/kira/balances/"+cfg.TestAddress, nil)
+ require.NoError(t, err)
+ require.True(t, resp.IsSuccess(), "Expected success, got %d: %s", resp.StatusCode, string(resp.Body))
+
+ v, err := NewFormatValidator(t, resp.Body)
+ require.NoError(t, err, "Failed to parse response")
+
+ t.Run("balances array exists", func(t *testing.T) {
+ v.ValidateFieldExists("balances", "Format")
+ v.ValidateFieldType("balances", TypeArray, "Format")
+ })
+
+ t.Run("pagination field exists (Issue #19)", func(t *testing.T) {
+ // miro REQUIRES pagination field
+ if !v.HasField("pagination") {
+ t.Error("Issue #19: Missing 'pagination' field - miro expects this")
+ }
+ })
+
+ t.Run("balance amount is string (Issue #16)", func(t *testing.T) {
+ // miro expects amount as string (for precision with big numbers)
+ v.ValidateArrayFieldType("balances", "amount", TypeString, "Issue #16")
+ })
+
+ t.Run("balance denom is string", func(t *testing.T) {
+ v.ValidateArrayFieldType("balances", "denom", TypeString, "Format")
+ })
+
+ t.Run("pagination.total is string (Issue #16)", func(t *testing.T) {
+ if nested, ok := v.GetNestedValidator("pagination"); ok {
+ nested.ValidateFieldType("total", TypeString, "Issue #16")
+ }
+ })
+}
+
+// TestQueryBalances tests GET /api/kira/balances/{address}
+func TestQueryBalances(t *testing.T) {
+ cfg := GetConfig()
+ client := NewClient(cfg)
+
+ t.Run("query all balances", func(t *testing.T) {
+ resp, err := client.Get("/api/kira/balances/"+cfg.TestAddress, nil)
+ require.NoError(t, err)
+ assert.True(t, resp.IsSuccess(), "Expected success, got status %d: %s", resp.StatusCode, string(resp.Body))
+
+ var result ExpectedBalanceResponse
+ err = json.Unmarshal(resp.Body, &result)
+ require.NoError(t, err)
+
+ t.Logf("Found %d balances for address %s", len(result.Balances), cfg.TestAddress)
+ })
+
+ t.Run("query with limit parameter", func(t *testing.T) {
+ resp, err := client.Get("/api/kira/balances/"+cfg.TestAddress, map[string]string{
+ "limit": "5",
+ })
+ require.NoError(t, err)
+ assert.True(t, resp.IsSuccess(), "Expected success, got status %d", resp.StatusCode)
+
+ var result ExpectedBalanceResponse
+ err = json.Unmarshal(resp.Body, &result)
+ require.NoError(t, err)
+
+ // Verify limit is respected
+ assert.LessOrEqual(t, len(result.Balances), 5, "Limit parameter not respected")
+ })
+
+ t.Run("query with offset parameter", func(t *testing.T) {
+ // First get total count
+ resp1, err := client.Get("/api/kira/balances/"+cfg.TestAddress, nil)
+ require.NoError(t, err)
+
+ var result1 ExpectedBalanceResponse
+ json.Unmarshal(resp1.Body, &result1)
+ totalCount := len(result1.Balances)
+
+ if totalCount > 1 {
+ // Query with offset
+ resp2, err := client.Get("/api/kira/balances/"+cfg.TestAddress, map[string]string{
+ "offset": "1",
+ })
+ require.NoError(t, err)
+ assert.True(t, resp2.IsSuccess())
+
+ var result2 ExpectedBalanceResponse
+ json.Unmarshal(resp2.Body, &result2)
+
+ // Should have fewer results due to offset
+ assert.Less(t, len(result2.Balances), totalCount, "Offset parameter not working")
+ }
+ })
+
+ t.Run("query with count_total parameter", func(t *testing.T) {
+ resp, err := client.Get("/api/kira/balances/"+cfg.TestAddress, map[string]string{
+ "count_total": "true",
+ })
+ require.NoError(t, err)
+ assert.True(t, resp.IsSuccess(), "Expected success, got status %d", resp.StatusCode)
+
+ var result ExpectedBalanceResponse
+ err = json.Unmarshal(resp.Body, &result)
+ require.NoError(t, err)
+
+ // With count_total=true, pagination should include total
+ if result.Pagination != nil && result.Pagination.Total != "" && result.Pagination.Total != "0" {
+ t.Logf("Pagination total: %s", result.Pagination.Total)
+ }
+ })
+}
+
+// TestBalancesPagination specifically tests pagination behavior
+func TestBalancesPagination(t *testing.T) {
+ cfg := GetConfig()
+ client := NewClient(cfg)
+
+ t.Run("pagination total matches actual count", func(t *testing.T) {
+ resp, err := client.Get("/api/kira/balances/"+cfg.TestAddress, map[string]string{
+ "count_total": "true",
+ })
+ require.NoError(t, err)
+ require.True(t, resp.IsSuccess())
+
+ v, err := NewFormatValidator(t, resp.Body)
+ require.NoError(t, err)
+
+ // Check pagination exists and has correct structure
+ if !v.HasField("pagination") {
+ t.Error("Issue #19: Missing pagination field")
+ return
+ }
+
+ if nested, ok := v.GetNestedValidator("pagination"); ok {
+ if nested.HasField("total") {
+ nested.ValidateFieldType("total", TypeString, "Issue #16")
+ }
+ }
+ })
+}
diff --git a/tests/integration/client.go b/tests/integration/client.go
new file mode 100644
index 0000000..1f79d65
--- /dev/null
+++ b/tests/integration/client.go
@@ -0,0 +1,118 @@
+package integration
+
+import (
+ "bytes"
+ "encoding/json"
+ "fmt"
+ "io"
+ "net/http"
+ "net/url"
+ "time"
+)
+
+// Client is an HTTP client for Interx API testing
+type Client struct {
+ baseURL string
+ httpClient *http.Client
+}
+
+// NewClient creates a new Interx API client
+func NewClient(cfg Config) *Client {
+ return &Client{
+ baseURL: cfg.BaseURL,
+ httpClient: &http.Client{
+ Timeout: cfg.Timeout,
+ },
+ }
+}
+
+// Response represents an API response
+type Response struct {
+ StatusCode int
+ Body []byte
+ Headers http.Header
+}
+
+// JSON unmarshals the response body into the given interface
+func (r *Response) JSON(v interface{}) error {
+ return json.Unmarshal(r.Body, v)
+}
+
+// IsSuccess returns true if status code is 2xx
+func (r *Response) IsSuccess() bool {
+ return r.StatusCode >= 200 && r.StatusCode < 300
+}
+
+// Get performs a GET request
+func (c *Client) Get(path string, queryParams map[string]string) (*Response, error) {
+ fullURL := c.baseURL + path
+ if len(queryParams) > 0 {
+ params := url.Values{}
+ for k, v := range queryParams {
+ params.Add(k, v)
+ }
+ fullURL += "?" + params.Encode()
+ }
+
+ req, err := http.NewRequest(http.MethodGet, fullURL, nil)
+ if err != nil {
+ return nil, fmt.Errorf("failed to create request: %w", err)
+ }
+ req.Header.Set("Content-Type", "application/json")
+
+ return c.do(req)
+}
+
+// Post performs a POST request
+func (c *Client) Post(path string, body interface{}) (*Response, error) {
+ var bodyReader io.Reader
+ if body != nil {
+ jsonBody, err := json.Marshal(body)
+ if err != nil {
+ return nil, fmt.Errorf("failed to marshal body: %w", err)
+ }
+ bodyReader = bytes.NewReader(jsonBody)
+ }
+
+ req, err := http.NewRequest(http.MethodPost, c.baseURL+path, bodyReader)
+ if err != nil {
+ return nil, fmt.Errorf("failed to create request: %w", err)
+ }
+ req.Header.Set("Content-Type", "application/json")
+
+ return c.do(req)
+}
+
+func (c *Client) do(req *http.Request) (*Response, error) {
+ start := time.Now()
+ resp, err := c.httpClient.Do(req)
+ if err != nil {
+ return nil, fmt.Errorf("request failed: %w", err)
+ }
+ defer resp.Body.Close()
+
+ body, err := io.ReadAll(resp.Body)
+ if err != nil {
+ return nil, fmt.Errorf("failed to read response body: %w", err)
+ }
+
+ _ = time.Since(start) // Can be used for performance metrics
+
+ return &Response{
+ StatusCode: resp.StatusCode,
+ Body: body,
+ Headers: resp.Header,
+ }, nil
+}
+
+// HealthCheck performs a basic health check
+func (c *Client) HealthCheck() error {
+ resp, err := c.Get("/api/status", nil)
+ if err != nil {
+ return err
+ }
+ if !resp.IsSuccess() {
+ return fmt.Errorf("health check failed with status %d", resp.StatusCode)
+ }
+ return nil
+}
diff --git a/tests/integration/config.go b/tests/integration/config.go
new file mode 100644
index 0000000..c9aa67a
--- /dev/null
+++ b/tests/integration/config.go
@@ -0,0 +1,61 @@
+package integration
+
+import (
+ "os"
+ "time"
+)
+
+// Config holds the test configuration
+type Config struct {
+ BaseURL string
+ TestAddress string
+ Timeout time.Duration
+ ValidatorAddr string
+ DelegatorAddr string
+}
+
+// Predefined environments
+var (
+ ChaosnetConfig = Config{
+ BaseURL: "http://3.123.154.245:11000",
+ TestAddress: "kira143q8vxpvuykt9pq50e6hng9s38vmy844n8k9wx",
+ Timeout: 30 * time.Second,
+ ValidatorAddr: "kira1vvcj9avffvyav83gmptdlzrprgvsrjxzh7f9sz",
+ DelegatorAddr: "kira177lwmjyjds3cy7trers83r4pjn3dhv8zrqk9dl",
+ }
+
+ LocalConfig = Config{
+ BaseURL: "http://localhost:11000",
+ TestAddress: "kira143q8vxpvuykt9pq50e6hng9s38vmy844n8k9wx",
+ Timeout: 10 * time.Second,
+ ValidatorAddr: "kira1vvcj9avffvyav83gmptdlzrprgvsrjxzh7f9sz",
+ DelegatorAddr: "kira177lwmjyjds3cy7trers83r4pjn3dhv8zrqk9dl",
+ }
+)
+
+// GetConfig returns the configuration based on environment variables
+func GetConfig() Config {
+ cfg := ChaosnetConfig // default
+
+ if url := os.Getenv("INTERX_URL"); url != "" {
+ cfg.BaseURL = url
+ }
+
+ if addr := os.Getenv("TEST_ADDRESS"); addr != "" {
+ cfg.TestAddress = addr
+ }
+
+ if valAddr := os.Getenv("VALIDATOR_ADDRESS"); valAddr != "" {
+ cfg.ValidatorAddr = valAddr
+ }
+
+ if delAddr := os.Getenv("DELEGATOR_ADDRESS"); delAddr != "" {
+ cfg.DelegatorAddr = delAddr
+ }
+
+ if env := os.Getenv("TEST_ENV"); env == "local" {
+ cfg = LocalConfig
+ }
+
+ return cfg
+}
diff --git a/tests/integration/data_reference_test.go b/tests/integration/data_reference_test.go
new file mode 100644
index 0000000..8b406fe
--- /dev/null
+++ b/tests/integration/data_reference_test.go
@@ -0,0 +1,34 @@
+package integration
+
+import (
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+// TestQueryDataReference tests GET /api/kira/gov/data/{key}
+func TestQueryDataReference(t *testing.T) {
+ cfg := GetConfig()
+ client := NewClient(cfg)
+
+ t.Run("query data reference", func(t *testing.T) {
+ resp, err := client.Get("/api/kira/gov/data/sample_png_file", nil)
+ require.NoError(t, err)
+ // Data may or may not exist
+ assert.True(t, resp.StatusCode == 200 || resp.StatusCode == 404,
+ "Expected 200 or 404, got %d", resp.StatusCode)
+ })
+}
+
+// TestQueryDataReferenceKeys tests GET /api/kira/gov/data_keys
+func TestQueryDataReferenceKeys(t *testing.T) {
+ cfg := GetConfig()
+ client := NewClient(cfg)
+
+ t.Run("query data reference keys", func(t *testing.T) {
+ resp, err := client.Get("/api/kira/gov/data_keys", nil)
+ require.NoError(t, err)
+ assert.True(t, resp.IsSuccess(), "Expected success, got status %d: %s", resp.StatusCode, string(resp.Body))
+ })
+}
diff --git a/tests/integration/docker-compose.yml b/tests/integration/docker-compose.yml
new file mode 100644
index 0000000..05bd145
--- /dev/null
+++ b/tests/integration/docker-compose.yml
@@ -0,0 +1,37 @@
+version: '3.8'
+
+services:
+ integration-tests:
+ build:
+ context: .
+ dockerfile: Dockerfile
+ container_name: interx-integration-tests
+ environment:
+ - INTERX_URL=${INTERX_URL:-http://3.123.154.245:11000}
+ - TEST_ADDRESS=${TEST_ADDRESS:-kira143q8vxpvuykt9pq50e6hng9s38vmy844n8k9wx}
+ - VALIDATOR_ADDRESS=${VALIDATOR_ADDRESS:-kira1vvcj9avffvyav83gmptdlzrprgvsrjxzh7f9sz}
+ - DELEGATOR_ADDRESS=${DELEGATOR_ADDRESS:-kira177lwmjyjds3cy7trers83r4pjn3dhv8zrqk9dl}
+ network_mode: host
+ command: ["go", "test", "-v", "-count=1", "./..."]
+
+ smoke-tests:
+ build:
+ context: .
+ dockerfile: Dockerfile
+ container_name: interx-smoke-tests
+ environment:
+ - INTERX_URL=${INTERX_URL:-http://3.123.154.245:11000}
+ - TEST_ADDRESS=${TEST_ADDRESS:-kira143q8vxpvuykt9pq50e6hng9s38vmy844n8k9wx}
+ network_mode: host
+ command: ["go", "test", "-v", "-count=1", "-run", "TestInterxStatus|TestKiraStatus", "./..."]
+
+ account-tests:
+ build:
+ context: .
+ dockerfile: Dockerfile
+ container_name: interx-account-tests
+ environment:
+ - INTERX_URL=${INTERX_URL:-http://3.123.154.245:11000}
+ - TEST_ADDRESS=${TEST_ADDRESS:-kira143q8vxpvuykt9pq50e6hng9s38vmy844n8k9wx}
+ network_mode: host
+ command: ["go", "test", "-v", "-count=1", "-run", "TestQuery(Accounts|Balances)", "./..."]
diff --git a/tests/integration/faucet_test.go b/tests/integration/faucet_test.go
new file mode 100644
index 0000000..6cba3a6
--- /dev/null
+++ b/tests/integration/faucet_test.go
@@ -0,0 +1,38 @@
+package integration
+
+import (
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+// TestFaucetInfo tests GET /api/kira/faucet (without claim)
+func TestFaucetInfo(t *testing.T) {
+ cfg := GetConfig()
+ client := NewClient(cfg)
+
+ t.Run("query faucet info", func(t *testing.T) {
+ resp, err := client.Get("/api/kira/faucet", nil)
+ require.NoError(t, err)
+ assert.True(t, resp.IsSuccess(), "Expected success, got status %d: %s", resp.StatusCode, string(resp.Body))
+ })
+}
+
+// TestFaucetClaim tests GET /api/kira/faucet?claim=...&token=...
+// Note: This actually claims tokens, so it should be used carefully in tests
+func TestFaucetClaim(t *testing.T) {
+ cfg := GetConfig()
+ client := NewClient(cfg)
+
+ t.Run("claim from faucet", func(t *testing.T) {
+ t.Skip("Skipping faucet claim test to avoid rate limiting - enable manually for full test")
+ resp, err := client.Get("/api/kira/faucet", map[string]string{
+ "claim": cfg.TestAddress,
+ "token": "ukex",
+ })
+ require.NoError(t, err)
+ // Faucet may succeed or fail due to rate limiting
+ assert.True(t, resp.StatusCode >= 200, "Expected response, got status %d", resp.StatusCode)
+ })
+}
diff --git a/tests/integration/format_validator.go b/tests/integration/format_validator.go
new file mode 100644
index 0000000..82ef447
--- /dev/null
+++ b/tests/integration/format_validator.go
@@ -0,0 +1,386 @@
+package integration
+
+import (
+ "encoding/json"
+ "fmt"
+ "strings"
+ "testing"
+)
+
+// FieldType represents expected JSON types
+type FieldType string
+
+const (
+ TypeString FieldType = "string"
+ TypeInt FieldType = "int"
+ TypeFloat FieldType = "float"
+ TypeBool FieldType = "bool"
+ TypeArray FieldType = "array"
+ TypeObject FieldType = "object"
+ TypeAny FieldType = "any"
+)
+
+// FieldSpec defines expected field properties
+type FieldSpec struct {
+ Name string
+ Type FieldType
+ Required bool
+}
+
+// ValidationResult contains test results for reporting
+type ValidationResult struct {
+ Field string
+ Issue string
+ IssueRef string // GitHub issue reference if known
+}
+
+// FormatValidator validates JSON responses against expected formats
+type FormatValidator struct {
+ t *testing.T
+ rawJSON map[string]interface{}
+ results []ValidationResult
+}
+
+// NewFormatValidator creates a validator from JSON bytes
+func NewFormatValidator(t *testing.T, body []byte) (*FormatValidator, error) {
+ var raw map[string]interface{}
+ if err := json.Unmarshal(body, &raw); err != nil {
+ return nil, fmt.Errorf("failed to parse JSON: %w", err)
+ }
+ return &FormatValidator{
+ t: t,
+ rawJSON: raw,
+ results: make([]ValidationResult, 0),
+ }, nil
+}
+
+// GetRaw returns the raw JSON map
+func (v *FormatValidator) GetRaw() map[string]interface{} {
+ return v.rawJSON
+}
+
+// HasField checks if a field exists
+func (v *FormatValidator) HasField(name string) bool {
+ _, exists := v.rawJSON[name]
+ return exists
+}
+
+// GetField returns a field value
+func (v *FormatValidator) GetField(name string) (interface{}, bool) {
+ val, exists := v.rawJSON[name]
+ return val, exists
+}
+
+// ValidateFieldExists checks field existence and reports issues
+func (v *FormatValidator) ValidateFieldExists(name string, issueRef string) bool {
+ if !v.HasField(name) {
+ v.t.Errorf("%s: Missing expected field '%s'", issueRef, name)
+ v.results = append(v.results, ValidationResult{
+ Field: name,
+ Issue: "missing field",
+ IssueRef: issueRef,
+ })
+ return false
+ }
+ return true
+}
+
+// ValidateFieldNotExists checks that a field does NOT exist (for detecting wrong naming)
+func (v *FormatValidator) ValidateFieldNotExists(wrongName, correctName, issueRef string) {
+ if v.HasField(wrongName) && !v.HasField(correctName) {
+ v.t.Errorf("%s: Using '%s' instead of expected '%s'", issueRef, wrongName, correctName)
+ v.results = append(v.results, ValidationResult{
+ Field: wrongName,
+ Issue: fmt.Sprintf("wrong field name, should be '%s'", correctName),
+ IssueRef: issueRef,
+ })
+ }
+}
+
+// ValidateFieldType checks if a field has the expected type
+func (v *FormatValidator) ValidateFieldType(name string, expectedType FieldType, issueRef string) bool {
+ val, exists := v.rawJSON[name]
+ if !exists {
+ return false
+ }
+
+ var actualType FieldType
+ switch val.(type) {
+ case string:
+ actualType = TypeString
+ case float64:
+ // JSON numbers are float64, check if it's an integer
+ f := val.(float64)
+ if f == float64(int64(f)) {
+ actualType = TypeInt
+ } else {
+ actualType = TypeFloat
+ }
+ case bool:
+ actualType = TypeBool
+ case []interface{}:
+ actualType = TypeArray
+ case map[string]interface{}:
+ actualType = TypeObject
+ case nil:
+ return true // nil is acceptable for optional fields
+ default:
+ actualType = TypeAny
+ }
+
+ // Special case: int expected but got float that's actually integer
+ if expectedType == TypeInt && actualType == TypeFloat {
+ f := val.(float64)
+ if f == float64(int64(f)) {
+ actualType = TypeInt
+ }
+ }
+
+ // Special case: expecting string but got number (common Issue #16)
+ if expectedType == TypeString && (actualType == TypeInt || actualType == TypeFloat) {
+ v.t.Errorf("%s: Field '%s' should be string, got number", issueRef, name)
+ v.results = append(v.results, ValidationResult{
+ Field: name,
+ Issue: fmt.Sprintf("expected string, got %s", actualType),
+ IssueRef: issueRef,
+ })
+ return false
+ }
+
+ // Special case: expecting int but got string (common Issue #16)
+ if expectedType == TypeInt && actualType == TypeString {
+ v.t.Errorf("%s: Field '%s' should be int, got string", issueRef, name)
+ v.results = append(v.results, ValidationResult{
+ Field: name,
+ Issue: "expected int, got string",
+ IssueRef: issueRef,
+ })
+ return false
+ }
+
+ if expectedType != TypeAny && actualType != expectedType {
+ v.t.Errorf("%s: Field '%s' expected type %s, got %s", issueRef, name, expectedType, actualType)
+ v.results = append(v.results, ValidationResult{
+ Field: name,
+ Issue: fmt.Sprintf("expected %s, got %s", expectedType, actualType),
+ IssueRef: issueRef,
+ })
+ return false
+ }
+
+ return true
+}
+
+// ValidateSnakeCase checks all top-level field names use snake_case
+func (v *FormatValidator) ValidateSnakeCase(issueRef string) {
+ for key := range v.rawJSON {
+ if key == "@type" {
+ continue // Special case for protobuf type field
+ }
+ if isCamelCase(key) {
+ v.t.Errorf("%s: Field '%s' uses camelCase, expected snake_case", issueRef, key)
+ v.results = append(v.results, ValidationResult{
+ Field: key,
+ Issue: "uses camelCase instead of snake_case",
+ IssueRef: issueRef,
+ })
+ }
+ }
+}
+
+// ValidateNestedSnakeCase checks nested object field names
+func (v *FormatValidator) ValidateNestedSnakeCase(fieldName, issueRef string) {
+ val, exists := v.rawJSON[fieldName]
+ if !exists {
+ return
+ }
+
+ nested, ok := val.(map[string]interface{})
+ if !ok {
+ return
+ }
+
+ for key := range nested {
+ if key == "@type" {
+ continue
+ }
+ if isCamelCase(key) {
+ v.t.Errorf("%s: Nested field '%s.%s' uses camelCase, expected snake_case", issueRef, fieldName, key)
+ v.results = append(v.results, ValidationResult{
+ Field: fmt.Sprintf("%s.%s", fieldName, key),
+ Issue: "uses camelCase instead of snake_case",
+ IssueRef: issueRef,
+ })
+ }
+ }
+}
+
+// ValidateArrayFieldType validates types within an array
+func (v *FormatValidator) ValidateArrayFieldType(arrayName, fieldName string, expectedType FieldType, issueRef string) {
+ val, exists := v.rawJSON[arrayName]
+ if !exists {
+ return
+ }
+
+ arr, ok := val.([]interface{})
+ if !ok || len(arr) == 0 {
+ return
+ }
+
+ for i, item := range arr {
+ obj, ok := item.(map[string]interface{})
+ if !ok {
+ continue
+ }
+
+ fieldVal, exists := obj[fieldName]
+ if !exists {
+ v.t.Errorf("%s: Array '%s[%d]' missing field '%s'", issueRef, arrayName, i, fieldName)
+ continue
+ }
+
+ var actualType FieldType
+ switch fieldVal.(type) {
+ case string:
+ actualType = TypeString
+ case float64:
+ f := fieldVal.(float64)
+ if f == float64(int64(f)) {
+ actualType = TypeInt
+ } else {
+ actualType = TypeFloat
+ }
+ case bool:
+ actualType = TypeBool
+ }
+
+ if expectedType == TypeString && (actualType == TypeInt || actualType == TypeFloat) {
+ v.t.Errorf("%s: Array '%s[%d].%s' should be string, got number", issueRef, arrayName, i, fieldName)
+ return // Only report first occurrence
+ }
+
+ if expectedType == TypeInt && actualType == TypeString {
+ v.t.Errorf("%s: Array '%s[%d].%s' should be int, got string", issueRef, arrayName, i, fieldName)
+ return
+ }
+ }
+}
+
+// GetResults returns all validation results
+func (v *FormatValidator) GetResults() []ValidationResult {
+ return v.results
+}
+
+// HasIssues returns true if any validation issues were found
+func (v *FormatValidator) HasIssues() bool {
+ return len(v.results) > 0
+}
+
+// isCamelCase checks if a string uses camelCase (lowercase start, has uppercase)
+func isCamelCase(s string) bool {
+ if len(s) == 0 {
+ return false
+ }
+ // Skip if starts with uppercase (PascalCase)
+ if s[0] >= 'A' && s[0] <= 'Z' {
+ return false
+ }
+ // Check for uppercase letters after the first character
+ for i := 1; i < len(s); i++ {
+ if s[i] >= 'A' && s[i] <= 'Z' {
+ return true
+ }
+ }
+ return false
+}
+
+// toSnakeCase converts camelCase to snake_case
+func toSnakeCase(s string) string {
+ var result strings.Builder
+ for i, r := range s {
+ if r >= 'A' && r <= 'Z' {
+ if i > 0 {
+ result.WriteRune('_')
+ }
+ result.WriteRune(r + 32) // lowercase
+ } else {
+ result.WriteRune(r)
+ }
+ }
+ return result.String()
+}
+
+// ValidateRequiredFields validates multiple required fields exist
+func (v *FormatValidator) ValidateRequiredFields(fields []string, issueRef string) {
+ for _, field := range fields {
+ v.ValidateFieldExists(field, issueRef)
+ }
+}
+
+// ValidateFieldTypes validates multiple field types
+func (v *FormatValidator) ValidateFieldTypes(specs []FieldSpec, issueRef string) {
+ for _, spec := range specs {
+ if spec.Required {
+ if !v.ValidateFieldExists(spec.Name, issueRef) {
+ continue
+ }
+ }
+ if v.HasField(spec.Name) {
+ v.ValidateFieldType(spec.Name, spec.Type, issueRef)
+ }
+ }
+}
+
+// GetNestedValidator returns a validator for a nested object
+func (v *FormatValidator) GetNestedValidator(fieldName string) (*FormatValidator, bool) {
+ val, exists := v.rawJSON[fieldName]
+ if !exists {
+ return nil, false
+ }
+
+ nested, ok := val.(map[string]interface{})
+ if !ok {
+ return nil, false
+ }
+
+ return &FormatValidator{
+ t: v.t,
+ rawJSON: nested,
+ results: v.results, // Share results
+ }, true
+}
+
+// GetArrayLength returns the length of an array field
+func (v *FormatValidator) GetArrayLength(fieldName string) int {
+ val, exists := v.rawJSON[fieldName]
+ if !exists {
+ return 0
+ }
+
+ arr, ok := val.([]interface{})
+ if !ok {
+ return 0
+ }
+
+ return len(arr)
+}
+
+// ValidateTimestampIsInt checks if a timestamp field is an integer (not string, not ISO)
+func (v *FormatValidator) ValidateTimestampIsInt(fieldName, issueRef string) bool {
+ val, exists := v.rawJSON[fieldName]
+ if !exists {
+ v.t.Errorf("%s: Missing timestamp field '%s'", issueRef, fieldName)
+ return false
+ }
+
+ switch val.(type) {
+ case float64:
+ return true // JSON numbers are float64, this is correct for int
+ case string:
+ v.t.Errorf("%s: Timestamp '%s' should be Unix int, got string (possibly ISO format)", issueRef, fieldName)
+ return false
+ default:
+ v.t.Errorf("%s: Timestamp '%s' has unexpected type %T", issueRef, fieldName, val)
+ return false
+ }
+}
diff --git a/tests/integration/genesis_test.go b/tests/integration/genesis_test.go
new file mode 100644
index 0000000..43d2711
--- /dev/null
+++ b/tests/integration/genesis_test.go
@@ -0,0 +1,59 @@
+package integration
+
+import (
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+// TestQueryGenesis tests GET /api/genesis
+func TestQueryGenesis(t *testing.T) {
+ cfg := GetConfig()
+ client := NewClient(cfg)
+
+ t.Run("query genesis", func(t *testing.T) {
+ resp, err := client.Get("/api/genesis", nil)
+ require.NoError(t, err)
+ assert.True(t, resp.IsSuccess(), "Expected success, got status %d: %s", resp.StatusCode, string(resp.Body))
+ })
+}
+
+// TestQueryGenesisChecksum tests GET /api/gensum
+func TestQueryGenesisChecksum(t *testing.T) {
+ cfg := GetConfig()
+ client := NewClient(cfg)
+
+ t.Run("query genesis checksum", func(t *testing.T) {
+ resp, err := client.Get("/api/gensum", nil)
+ require.NoError(t, err)
+ assert.True(t, resp.IsSuccess(), "Expected success, got status %d: %s", resp.StatusCode, string(resp.Body))
+ })
+}
+
+// TestQuerySnapshot tests GET /api/snapshot
+func TestQuerySnapshot(t *testing.T) {
+ cfg := GetConfig()
+ client := NewClient(cfg)
+
+ t.Run("query snapshot", func(t *testing.T) {
+ resp, err := client.Get("/api/snapshot", nil)
+ require.NoError(t, err)
+ // Snapshot may not be available on all nodes
+ assert.True(t, resp.StatusCode == 200 || resp.StatusCode == 404,
+ "Expected 200 or 404, got %d", resp.StatusCode)
+ })
+}
+
+// TestQuerySnapshotInfo tests GET /api/snapshot_info
+func TestQuerySnapshotInfo(t *testing.T) {
+ cfg := GetConfig()
+ client := NewClient(cfg)
+
+ t.Run("query snapshot info", func(t *testing.T) {
+ resp, err := client.Get("/api/snapshot_info", nil)
+ require.NoError(t, err)
+ assert.True(t, resp.StatusCode == 200 || resp.StatusCode == 404,
+ "Expected 200 or 404, got %d", resp.StatusCode)
+ })
+}
diff --git a/tests/integration/go.mod b/tests/integration/go.mod
new file mode 100644
index 0000000..78ab0ce
--- /dev/null
+++ b/tests/integration/go.mod
@@ -0,0 +1,11 @@
+module github.com/kiracore/interx/tests/integration
+
+go 1.22
+
+require github.com/stretchr/testify v1.9.0
+
+require (
+ github.com/davecgh/go-spew v1.1.1 // indirect
+ github.com/pmezard/go-difflib v1.0.0 // indirect
+ gopkg.in/yaml.v3 v3.0.1 // indirect
+)
diff --git a/tests/integration/governance_test.go b/tests/integration/governance_test.go
new file mode 100644
index 0000000..538cd16
--- /dev/null
+++ b/tests/integration/governance_test.go
@@ -0,0 +1,416 @@
+package integration
+
+import (
+ "encoding/json"
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+// ============================================================================
+// Governance Endpoint Tests
+// Expected format from miro: lib/infra/dto/api_kira/query_network_properties/
+// ============================================================================
+
+// TestNetworkPropertiesResponseFormat validates the response matches expected miro format
+func TestNetworkPropertiesResponseFormat(t *testing.T) {
+ cfg := GetConfig()
+ client := NewClient(cfg)
+
+ resp, err := client.Get("/api/kira/gov/network_properties", nil)
+ require.NoError(t, err)
+ require.True(t, resp.IsSuccess(), "Expected success, got %d: %s", resp.StatusCode, string(resp.Body))
+
+ v, err := NewFormatValidator(t, resp.Body)
+ require.NoError(t, err, "Failed to parse response")
+
+ t.Run("properties object exists", func(t *testing.T) {
+ v.ValidateFieldExists("properties", "Format")
+ v.ValidateFieldType("properties", TypeObject, "Format")
+ })
+
+ t.Run("properties fields use snake_case (Issue #13)", func(t *testing.T) {
+ if nested, ok := v.GetNestedValidator("properties"); ok {
+ nested.ValidateSnakeCase("Issue #13")
+
+ // Check key properties exist
+ expectedFields := []string{
+ "min_tx_fee",
+ "vote_quorum",
+ "min_validators",
+ "unstaking_period",
+ "max_delegators",
+ }
+
+ for _, field := range expectedFields {
+ if !nested.HasField(field) {
+ camelCase := snakeToCamel(field)
+ if nested.HasField(camelCase) {
+ t.Errorf("Issue #13: Using '%s' instead of '%s'", camelCase, field)
+ }
+ }
+ }
+ }
+ })
+
+ t.Run("boolean properties are bools (Issue #16)", func(t *testing.T) {
+ if nested, ok := v.GetNestedValidator("properties"); ok {
+ boolFields := []string{
+ "enable_foreign_fee_payments",
+ "enable_token_blacklist",
+ "enable_token_whitelist",
+ }
+
+ for _, field := range boolFields {
+ if nested.HasField(field) {
+ nested.ValidateFieldType(field, TypeBool, "Issue #16")
+ }
+ }
+ }
+ })
+}
+
+// TestQueryNetworkProperties tests GET /api/kira/gov/network_properties
+func TestQueryNetworkProperties(t *testing.T) {
+ cfg := GetConfig()
+ client := NewClient(cfg)
+
+ t.Run("query network properties", func(t *testing.T) {
+ resp, err := client.Get("/api/kira/gov/network_properties", nil)
+ require.NoError(t, err)
+ assert.True(t, resp.IsSuccess(), "Expected success, got status %d: %s", resp.StatusCode, string(resp.Body))
+
+ var result map[string]interface{}
+ json.Unmarshal(resp.Body, &result)
+
+ if props, ok := result["properties"].(map[string]interface{}); ok {
+ t.Logf("Network properties has %d fields", len(props))
+ }
+ })
+}
+
+// ============================================================================
+// Execution Fee Endpoint Tests
+// ============================================================================
+
+// TestExecutionFeeResponseFormat validates the response format
+func TestExecutionFeeResponseFormat(t *testing.T) {
+ cfg := GetConfig()
+ client := NewClient(cfg)
+
+ resp, err := client.Get("/api/kira/gov/execution_fee", map[string]string{
+ "message": "MsgSend",
+ })
+ require.NoError(t, err)
+ require.True(t, resp.IsSuccess(), "Expected success, got %d: %s", resp.StatusCode, string(resp.Body))
+
+ v, err := NewFormatValidator(t, resp.Body)
+ require.NoError(t, err, "Failed to parse response")
+
+ t.Run("fee object exists", func(t *testing.T) {
+ v.ValidateFieldExists("fee", "Format")
+ })
+
+ t.Run("fee fields use snake_case (Issue #13)", func(t *testing.T) {
+ if nested, ok := v.GetNestedValidator("fee"); ok {
+ nested.ValidateSnakeCase("Issue #13")
+
+ expectedFields := []string{
+ "default_parameters",
+ "execution_fee",
+ "failure_fee",
+ "timeout",
+ "transaction_type",
+ }
+
+ for _, field := range expectedFields {
+ if !nested.HasField(field) {
+ camelCase := snakeToCamel(field)
+ if nested.HasField(camelCase) {
+ t.Errorf("Issue #13: Using '%s' instead of '%s'", camelCase, field)
+ }
+ }
+ }
+ }
+ })
+}
+
+// TestQueryExecutionFee tests GET /api/kira/gov/execution_fee
+func TestQueryExecutionFee(t *testing.T) {
+ cfg := GetConfig()
+ client := NewClient(cfg)
+
+ t.Run("query execution fee for MsgSend", func(t *testing.T) {
+ resp, err := client.Get("/api/kira/gov/execution_fee", map[string]string{
+ "message": "MsgSend",
+ })
+ require.NoError(t, err)
+ assert.True(t, resp.IsSuccess(), "Expected success, got status %d: %s", resp.StatusCode, string(resp.Body))
+ })
+
+ t.Run("query execution fee without message param", func(t *testing.T) {
+ resp, err := client.Get("/api/kira/gov/execution_fee", nil)
+ require.NoError(t, err)
+ // May return error or default fee
+ t.Logf("Response status: %d", resp.StatusCode)
+ })
+}
+
+// TestQueryExecutionFees tests GET /api/kira/gov/execution_fees
+func TestQueryExecutionFees(t *testing.T) {
+ cfg := GetConfig()
+ client := NewClient(cfg)
+
+ t.Run("query all execution fees", func(t *testing.T) {
+ resp, err := client.Get("/api/kira/gov/execution_fees", nil)
+ require.NoError(t, err)
+ assert.True(t, resp.IsSuccess(), "Expected success, got status %d: %s", resp.StatusCode, string(resp.Body))
+ })
+}
+
+// ============================================================================
+// Proposals Endpoint Tests
+// ============================================================================
+
+// TestQueryProposals tests GET /api/kira/gov/proposals
+func TestQueryProposals(t *testing.T) {
+ cfg := GetConfig()
+ client := NewClient(cfg)
+
+ t.Run("query all proposals", func(t *testing.T) {
+ resp, err := client.Get("/api/kira/gov/proposals", map[string]string{
+ "all": "true",
+ })
+ require.NoError(t, err)
+ assert.True(t, resp.IsSuccess(), "Expected success, got status %d: %s", resp.StatusCode, string(resp.Body))
+ })
+
+ t.Run("query proposals with reverse", func(t *testing.T) {
+ resp, err := client.Get("/api/kira/gov/proposals", map[string]string{
+ "reverse": "true",
+ })
+ require.NoError(t, err)
+ assert.True(t, resp.IsSuccess(), "Expected success, got status %d", resp.StatusCode)
+ })
+}
+
+// TestQueryProposalById tests GET /api/kira/gov/proposals/{id}
+func TestQueryProposalById(t *testing.T) {
+ cfg := GetConfig()
+ client := NewClient(cfg)
+
+ t.Run("query proposal by valid id", func(t *testing.T) {
+ resp, err := client.Get("/api/kira/gov/proposals/1", nil)
+ require.NoError(t, err)
+ // May be 200 or 404 depending on if proposal exists
+ assert.True(t, resp.StatusCode == 200 || resp.StatusCode == 404,
+ "Expected 200 or 404, got %d", resp.StatusCode)
+ })
+
+ t.Run("query proposal by invalid id", func(t *testing.T) {
+ resp, err := client.Get("/api/kira/gov/proposals/invalid", nil)
+ require.NoError(t, err)
+ // Should return error
+ t.Logf("Invalid proposal id response: %d", resp.StatusCode)
+ })
+}
+
+// TestQueryVoters tests GET /api/kira/gov/voters
+func TestQueryVoters(t *testing.T) {
+ cfg := GetConfig()
+ client := NewClient(cfg)
+
+ t.Run("query voters", func(t *testing.T) {
+ resp, err := client.Get("/api/kira/gov/voters", nil)
+ require.NoError(t, err)
+ assert.True(t, resp.IsSuccess(), "Expected success, got status %d: %s", resp.StatusCode, string(resp.Body))
+ })
+}
+
+// TestQueryVotes tests GET /api/kira/gov/votes/{proposal_id}
+func TestQueryVotes(t *testing.T) {
+ cfg := GetConfig()
+ client := NewClient(cfg)
+
+ t.Run("query votes for proposal", func(t *testing.T) {
+ resp, err := client.Get("/api/kira/gov/votes/1", nil)
+ require.NoError(t, err)
+ // May be 200 or 404 depending on if proposal exists
+ t.Logf("Votes response status: %d", resp.StatusCode)
+ })
+}
+
+// ============================================================================
+// Data Keys Endpoint Tests
+// ============================================================================
+
+// TestQueryDataKeys tests GET /api/kira/gov/data_keys
+func TestQueryDataKeys(t *testing.T) {
+ cfg := GetConfig()
+ client := NewClient(cfg)
+
+ t.Run("query data keys", func(t *testing.T) {
+ resp, err := client.Get("/api/kira/gov/data_keys", nil)
+ require.NoError(t, err)
+ assert.True(t, resp.IsSuccess(), "Expected success, got status %d: %s", resp.StatusCode, string(resp.Body))
+ })
+}
+
+// TestQueryData tests GET /api/kira/gov/data/{key}
+func TestQueryData(t *testing.T) {
+ cfg := GetConfig()
+ client := NewClient(cfg)
+
+ t.Run("query data by key", func(t *testing.T) {
+ resp, err := client.Get("/api/kira/gov/data/test_key", nil)
+ require.NoError(t, err)
+ // May return data or not found
+ t.Logf("Data query response status: %d", resp.StatusCode)
+ })
+}
+
+// ============================================================================
+// Permissions and Roles Endpoint Tests
+// ============================================================================
+
+// TestQueryPermissionsByAddress tests GET /api/kira/gov/permissions_by_address/{address}
+func TestQueryPermissionsByAddress(t *testing.T) {
+ cfg := GetConfig()
+ client := NewClient(cfg)
+
+ t.Run("query permissions by address", func(t *testing.T) {
+ resp, err := client.Get("/api/kira/gov/permissions_by_address/"+cfg.TestAddress, nil)
+ require.NoError(t, err)
+ assert.True(t, resp.IsSuccess(), "Expected success, got status %d: %s", resp.StatusCode, string(resp.Body))
+ })
+}
+
+// TestQueryAllRoles tests GET /api/kira/gov/all_roles
+func TestQueryAllRoles(t *testing.T) {
+ cfg := GetConfig()
+ client := NewClient(cfg)
+
+ t.Run("query all roles", func(t *testing.T) {
+ resp, err := client.Get("/api/kira/gov/all_roles", nil)
+ require.NoError(t, err)
+ assert.True(t, resp.IsSuccess(), "Expected success, got status %d: %s", resp.StatusCode, string(resp.Body))
+ })
+}
+
+// TestQueryRolesByAddress tests GET /api/kira/gov/roles_by_address/{address}
+func TestQueryRolesByAddress(t *testing.T) {
+ cfg := GetConfig()
+ client := NewClient(cfg)
+
+ t.Run("query roles by address", func(t *testing.T) {
+ resp, err := client.Get("/api/kira/gov/roles_by_address/"+cfg.TestAddress, nil)
+ require.NoError(t, err)
+ assert.True(t, resp.IsSuccess(), "Expected success, got status %d: %s", resp.StatusCode, string(resp.Body))
+ })
+}
+
+// ============================================================================
+// Identity Endpoint Tests
+// ============================================================================
+
+// TestQueryIdentityRecords tests GET /api/kira/gov/identity_records/{address}
+func TestQueryIdentityRecords(t *testing.T) {
+ cfg := GetConfig()
+ client := NewClient(cfg)
+
+ t.Run("query identity records by address", func(t *testing.T) {
+ resp, err := client.Get("/api/kira/gov/identity_records/"+cfg.TestAddress, nil)
+ require.NoError(t, err)
+ assert.True(t, resp.IsSuccess(), "Expected success, got status %d: %s", resp.StatusCode, string(resp.Body))
+ })
+}
+
+// TestQueryIdentityRecord tests GET /api/kira/gov/identity_record/{id}
+func TestQueryIdentityRecord(t *testing.T) {
+ cfg := GetConfig()
+ client := NewClient(cfg)
+
+ t.Run("query identity record by id", func(t *testing.T) {
+ resp, err := client.Get("/api/kira/gov/identity_record/1", nil)
+ require.NoError(t, err)
+ // May return data or not found
+ t.Logf("Identity record response status: %d", resp.StatusCode)
+ })
+}
+
+// TestQueryIdentityVerifyRequestsByApprover tests the approver endpoint
+func TestQueryIdentityVerifyRequestsByApprover(t *testing.T) {
+ cfg := GetConfig()
+ client := NewClient(cfg)
+
+ t.Run("query verify requests by approver", func(t *testing.T) {
+ resp, err := client.Get("/api/kira/gov/identity_verify_requests_by_approver/"+cfg.TestAddress, nil)
+ require.NoError(t, err)
+ t.Logf("Approver verify requests response status: %d", resp.StatusCode)
+ })
+}
+
+// TestQueryIdentityVerifyRequestsByRequester tests the requester endpoint
+func TestQueryIdentityVerifyRequestsByRequester(t *testing.T) {
+ cfg := GetConfig()
+ client := NewClient(cfg)
+
+ t.Run("query verify requests by requester", func(t *testing.T) {
+ resp, err := client.Get("/api/kira/gov/identity_verify_requests_by_requester/"+cfg.TestAddress, nil)
+ require.NoError(t, err)
+ t.Logf("Requester verify requests response status: %d", resp.StatusCode)
+ })
+}
+
+// TestQueryAllIdentityRecords tests GET /api/kira/gov/all_identity_records
+func TestQueryAllIdentityRecords(t *testing.T) {
+ cfg := GetConfig()
+ client := NewClient(cfg)
+
+ t.Run("query all identity records", func(t *testing.T) {
+ resp, err := client.Get("/api/kira/gov/all_identity_records", nil)
+ require.NoError(t, err)
+ assert.True(t, resp.IsSuccess(), "Expected success, got status %d: %s", resp.StatusCode, string(resp.Body))
+ })
+}
+
+// TestQueryAllIdentityVerifyRequests tests GET /api/kira/gov/all_identity_verify_requests
+func TestQueryAllIdentityVerifyRequests(t *testing.T) {
+ cfg := GetConfig()
+ client := NewClient(cfg)
+
+ t.Run("query all identity verify requests", func(t *testing.T) {
+ resp, err := client.Get("/api/kira/gov/all_identity_verify_requests", nil)
+ require.NoError(t, err)
+ assert.True(t, resp.IsSuccess(), "Expected success, got status %d: %s", resp.StatusCode, string(resp.Body))
+ })
+}
+
+// ============================================================================
+// Upgrade Endpoint Tests
+// ============================================================================
+
+// TestQueryCurrentPlan tests GET /api/kira/upgrade/current_plan
+func TestQueryCurrentPlan(t *testing.T) {
+ cfg := GetConfig()
+ client := NewClient(cfg)
+
+ t.Run("query current upgrade plan", func(t *testing.T) {
+ resp, err := client.Get("/api/kira/upgrade/current_plan", nil)
+ require.NoError(t, err)
+ assert.True(t, resp.IsSuccess(), "Expected success, got status %d: %s", resp.StatusCode, string(resp.Body))
+ })
+}
+
+// TestQueryNextPlan tests GET /api/kira/upgrade/next_plan
+func TestQueryNextPlan(t *testing.T) {
+ cfg := GetConfig()
+ client := NewClient(cfg)
+
+ t.Run("query next upgrade plan", func(t *testing.T) {
+ resp, err := client.Get("/api/kira/upgrade/next_plan", nil)
+ require.NoError(t, err)
+ assert.True(t, resp.IsSuccess(), "Expected success, got status %d: %s", resp.StatusCode, string(resp.Body))
+ })
+}
diff --git a/tests/integration/identity_test.go b/tests/integration/identity_test.go
new file mode 100644
index 0000000..3e5fa02
--- /dev/null
+++ b/tests/integration/identity_test.go
@@ -0,0 +1,112 @@
+package integration
+
+import (
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+// TestIdentityRecordByID tests GET /api/kira/gov/identity_record/{id}
+func TestIdentityRecordByID(t *testing.T) {
+ cfg := GetConfig()
+ client := NewClient(cfg)
+
+ t.Run("query identity record by id", func(t *testing.T) {
+ resp, err := client.Get("/api/kira/gov/identity_record/9", nil)
+ require.NoError(t, err)
+ assert.True(t, resp.StatusCode == 200 || resp.StatusCode == 404,
+ "Expected 200 or 404, got %d", resp.StatusCode)
+ })
+}
+
+// TestIdentityRecordsByAddress tests GET /api/kira/gov/identity_records/{address}
+func TestIdentityRecordsByAddress(t *testing.T) {
+ cfg := GetConfig()
+ client := NewClient(cfg)
+
+ t.Run("query identity records by address", func(t *testing.T) {
+ resp, err := client.Get("/api/kira/gov/identity_records/"+cfg.TestAddress, nil)
+ require.NoError(t, err)
+ assert.True(t, resp.IsSuccess(), "Expected success, got status %d: %s", resp.StatusCode, string(resp.Body))
+ })
+
+ t.Run("query identity records with pagination", func(t *testing.T) {
+ resp, err := client.Get("/api/kira/gov/identity_records/"+cfg.TestAddress, map[string]string{
+ "limit": "5",
+ "offset": "0",
+ })
+ require.NoError(t, err)
+ assert.True(t, resp.IsSuccess(), "Expected success, got status %d", resp.StatusCode)
+ })
+}
+
+// TestAllIdentityRecords tests GET /api/kira/gov/all_identity_records
+func TestAllIdentityRecords(t *testing.T) {
+ cfg := GetConfig()
+ client := NewClient(cfg)
+
+ t.Run("query all identity records", func(t *testing.T) {
+ resp, err := client.Get("/api/kira/gov/all_identity_records", nil)
+ require.NoError(t, err)
+ assert.True(t, resp.IsSuccess(), "Expected success, got status %d: %s", resp.StatusCode, string(resp.Body))
+ })
+
+ t.Run("query all identity records with count total", func(t *testing.T) {
+ resp, err := client.Get("/api/kira/gov/all_identity_records", map[string]string{
+ "count_total": "true",
+ })
+ require.NoError(t, err)
+ assert.True(t, resp.IsSuccess(), "Expected success, got status %d", resp.StatusCode)
+ })
+}
+
+// TestIdentityVerifyRecord tests GET /api/kira/gov/identity_verify_record/{id}
+func TestIdentityVerifyRecord(t *testing.T) {
+ cfg := GetConfig()
+ client := NewClient(cfg)
+
+ t.Run("query identity verify record", func(t *testing.T) {
+ resp, err := client.Get("/api/kira/gov/identity_verify_record/1", nil)
+ require.NoError(t, err)
+ assert.True(t, resp.StatusCode == 200 || resp.StatusCode == 404,
+ "Expected 200 or 404, got %d", resp.StatusCode)
+ })
+}
+
+// TestIdentityVerifyRequestsByRequester tests GET /api/kira/gov/identity_verify_requests_by_requester/{address}
+func TestIdentityVerifyRequestsByRequester(t *testing.T) {
+ cfg := GetConfig()
+ client := NewClient(cfg)
+
+ t.Run("query identity verify requests by requester", func(t *testing.T) {
+ resp, err := client.Get("/api/kira/gov/identity_verify_requests_by_requester/"+cfg.TestAddress, nil)
+ require.NoError(t, err)
+ assert.True(t, resp.IsSuccess(), "Expected success, got status %d: %s", resp.StatusCode, string(resp.Body))
+ })
+}
+
+// TestIdentityVerifyRequestsByApprover tests GET /api/kira/gov/identity_verify_requests_by_approver/{address}
+func TestIdentityVerifyRequestsByApprover(t *testing.T) {
+ cfg := GetConfig()
+ client := NewClient(cfg)
+
+ t.Run("query identity verify requests by approver", func(t *testing.T) {
+ approverAddr := "kira177lwmjyjds3cy7trers83r4pjn3dhv8zrqk9dl"
+ resp, err := client.Get("/api/kira/gov/identity_verify_requests_by_approver/"+approverAddr, nil)
+ require.NoError(t, err)
+ assert.True(t, resp.IsSuccess(), "Expected success, got status %d: %s", resp.StatusCode, string(resp.Body))
+ })
+}
+
+// TestAllIdentityVerifyRequests tests GET /api/kira/gov/all_identity_verify_requests
+func TestAllIdentityVerifyRequests(t *testing.T) {
+ cfg := GetConfig()
+ client := NewClient(cfg)
+
+ t.Run("query all identity verify requests", func(t *testing.T) {
+ resp, err := client.Get("/api/kira/gov/all_identity_verify_requests", nil)
+ require.NoError(t, err)
+ assert.True(t, resp.IsSuccess(), "Expected success, got status %d: %s", resp.StatusCode, string(resp.Body))
+ })
+}
diff --git a/tests/integration/node_discovery_test.go b/tests/integration/node_discovery_test.go
new file mode 100644
index 0000000..62b0aa4
--- /dev/null
+++ b/tests/integration/node_discovery_test.go
@@ -0,0 +1,56 @@
+package integration
+
+import (
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+// TestPubP2PList tests GET /api/pub_p2p_list
+func TestPubP2PList(t *testing.T) {
+ cfg := GetConfig()
+ client := NewClient(cfg)
+
+ t.Run("query public p2p list", func(t *testing.T) {
+ resp, err := client.Get("/api/pub_p2p_list", nil)
+ require.NoError(t, err)
+ assert.True(t, resp.IsSuccess(), "Expected success, got status %d: %s", resp.StatusCode, string(resp.Body))
+ })
+}
+
+// TestPrivP2PList tests GET /api/priv_p2p_list
+func TestPrivP2PList(t *testing.T) {
+ cfg := GetConfig()
+ client := NewClient(cfg)
+
+ t.Run("query private p2p list", func(t *testing.T) {
+ resp, err := client.Get("/api/priv_p2p_list", nil)
+ require.NoError(t, err)
+ assert.True(t, resp.IsSuccess(), "Expected success, got status %d: %s", resp.StatusCode, string(resp.Body))
+ })
+}
+
+// TestInterxList tests GET /api/interx_list
+func TestInterxList(t *testing.T) {
+ cfg := GetConfig()
+ client := NewClient(cfg)
+
+ t.Run("query interx list", func(t *testing.T) {
+ resp, err := client.Get("/api/interx_list", nil)
+ require.NoError(t, err)
+ assert.True(t, resp.IsSuccess(), "Expected success, got status %d: %s", resp.StatusCode, string(resp.Body))
+ })
+}
+
+// TestSnapList tests GET /api/snap_list
+func TestSnapList(t *testing.T) {
+ cfg := GetConfig()
+ client := NewClient(cfg)
+
+ t.Run("query snap list", func(t *testing.T) {
+ resp, err := client.Get("/api/snap_list", nil)
+ require.NoError(t, err)
+ assert.True(t, resp.IsSuccess(), "Expected success, got status %d: %s", resp.StatusCode, string(resp.Body))
+ })
+}
diff --git a/tests/integration/spending_test.go b/tests/integration/spending_test.go
new file mode 100644
index 0000000..db4a285
--- /dev/null
+++ b/tests/integration/spending_test.go
@@ -0,0 +1,50 @@
+package integration
+
+import (
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+// TestQuerySpendingPools tests GET /api/kira/spending-pools
+func TestQuerySpendingPools(t *testing.T) {
+ cfg := GetConfig()
+ client := NewClient(cfg)
+
+ t.Run("query spending pools", func(t *testing.T) {
+ resp, err := client.Get("/api/kira/spending-pools", nil)
+ require.NoError(t, err)
+ assert.True(t, resp.IsSuccess(), "Expected success, got status %d: %s", resp.StatusCode, string(resp.Body))
+ })
+
+ t.Run("query spending pool by name", func(t *testing.T) {
+ resp, err := client.Get("/api/kira/spending-pools", map[string]string{
+ "name": "ValidatorBasicRewardsPool",
+ })
+ require.NoError(t, err)
+ assert.True(t, resp.IsSuccess(), "Expected success, got status %d", resp.StatusCode)
+ })
+
+ t.Run("query spending pools by account", func(t *testing.T) {
+ resp, err := client.Get("/api/kira/spending-pools", map[string]string{
+ "account": "kira1qveg288t6n7yar4mhhmw7kqvxzq8q08xtz56mm",
+ })
+ require.NoError(t, err)
+ assert.True(t, resp.IsSuccess(), "Expected success, got status %d", resp.StatusCode)
+ })
+}
+
+// TestQuerySpendingPoolProposals tests GET /api/kira/spending-pool-proposals
+func TestQuerySpendingPoolProposals(t *testing.T) {
+ cfg := GetConfig()
+ client := NewClient(cfg)
+
+ t.Run("query spending pool proposals by name", func(t *testing.T) {
+ resp, err := client.Get("/api/kira/spending-pool-proposals", map[string]string{
+ "name": "ValidatorBasicRewardsPool",
+ })
+ require.NoError(t, err)
+ assert.True(t, resp.IsSuccess(), "Expected success, got status %d: %s", resp.StatusCode, string(resp.Body))
+ })
+}
diff --git a/tests/integration/staking_test.go b/tests/integration/staking_test.go
new file mode 100644
index 0000000..05d4077
--- /dev/null
+++ b/tests/integration/staking_test.go
@@ -0,0 +1,240 @@
+package integration
+
+import (
+ "encoding/json"
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+// ============================================================================
+// Staking Pool Endpoint Tests - /api/kira/staking-pool
+// Expected format from miro: lib/infra/dto/api_kira/query_staking_pool/
+// ============================================================================
+
+// TestStakingPoolResponseFormat validates the response matches expected miro format
+func TestStakingPoolResponseFormat(t *testing.T) {
+ cfg := GetConfig()
+ client := NewClient(cfg)
+
+ resp, err := client.Get("/api/kira/staking-pool", map[string]string{
+ "validatorAddress": cfg.ValidatorAddr,
+ })
+ require.NoError(t, err)
+ require.True(t, resp.IsSuccess(), "Expected success, got %d: %s", resp.StatusCode, string(resp.Body))
+
+ v, err := NewFormatValidator(t, resp.Body)
+ require.NoError(t, err, "Failed to parse response")
+
+ t.Run("id field is int (Issue #16)", func(t *testing.T) {
+ if v.HasField("id") {
+ v.ValidateFieldType("id", TypeInt, "Issue #16")
+ }
+ })
+
+ t.Run("total_delegators field is int (Issue #16)", func(t *testing.T) {
+ if v.HasField("total_delegators") {
+ v.ValidateFieldType("total_delegators", TypeInt, "Issue #16")
+ }
+ // Check for camelCase version
+ v.ValidateFieldNotExists("totalDelegators", "total_delegators", "Issue #13")
+ })
+
+ t.Run("commission is string", func(t *testing.T) {
+ if v.HasField("commission") {
+ v.ValidateFieldType("commission", TypeString, "Format")
+ }
+ })
+
+ t.Run("slashed is string", func(t *testing.T) {
+ if v.HasField("slashed") {
+ v.ValidateFieldType("slashed", TypeString, "Format")
+ }
+ })
+
+ t.Run("tokens is array", func(t *testing.T) {
+ if v.HasField("tokens") {
+ v.ValidateFieldType("tokens", TypeArray, "Format")
+ }
+ })
+
+ t.Run("voting_power is array of coins", func(t *testing.T) {
+ if v.HasField("voting_power") {
+ v.ValidateFieldType("voting_power", TypeArray, "Format")
+ }
+ // Check for camelCase version
+ v.ValidateFieldNotExists("votingPower", "voting_power", "Issue #13")
+ })
+}
+
+// TestQueryStakingPool tests GET /api/kira/staking-pool
+func TestQueryStakingPool(t *testing.T) {
+ cfg := GetConfig()
+ client := NewClient(cfg)
+
+ t.Run("query staking pool by validator address", func(t *testing.T) {
+ resp, err := client.Get("/api/kira/staking-pool", map[string]string{
+ "validatorAddress": cfg.ValidatorAddr,
+ })
+ require.NoError(t, err)
+ assert.True(t, resp.IsSuccess(), "Expected success, got status %d: %s", resp.StatusCode, string(resp.Body))
+
+ var result map[string]interface{}
+ json.Unmarshal(resp.Body, &result)
+ t.Logf("Staking pool response keys: %v", getMapKeys(result))
+ })
+}
+
+// ============================================================================
+// Delegations Endpoint Tests - /api/kira/delegations
+// Expected format from miro: lib/infra/dto/api_kira/query_delegations/
+// ============================================================================
+
+// TestDelegationsResponseFormat validates the response matches expected miro format
+func TestDelegationsResponseFormat(t *testing.T) {
+ cfg := GetConfig()
+ client := NewClient(cfg)
+
+ resp, err := client.Get("/api/kira/delegations", map[string]string{
+ "delegatorAddress": cfg.DelegatorAddr,
+ })
+ require.NoError(t, err)
+ require.True(t, resp.IsSuccess(), "Expected success, got %d: %s", resp.StatusCode, string(resp.Body))
+
+ v, err := NewFormatValidator(t, resp.Body)
+ require.NoError(t, err, "Failed to parse response")
+
+ t.Run("delegations array exists", func(t *testing.T) {
+ v.ValidateFieldExists("delegations", "Format")
+ v.ValidateFieldType("delegations", TypeArray, "Format")
+ })
+
+ t.Run("pagination object exists", func(t *testing.T) {
+ if !v.HasField("pagination") {
+ t.Log("Note: 'pagination' field not present")
+ }
+ })
+
+ // Check delegation structure if we have data
+ if v.GetArrayLength("delegations") > 0 {
+ t.Run("delegation has validator_info (Issue #13)", func(t *testing.T) {
+ raw := v.GetRaw()
+ delegations := raw["delegations"].([]interface{})
+ delegation := delegations[0].(map[string]interface{})
+
+ if _, exists := delegation["validator_info"]; !exists {
+ if _, camelExists := delegation["validatorInfo"]; camelExists {
+ t.Error("Issue #13: Using 'validatorInfo' instead of 'validator_info'")
+ }
+ }
+ })
+
+ t.Run("delegation has pool_info (Issue #13)", func(t *testing.T) {
+ raw := v.GetRaw()
+ delegations := raw["delegations"].([]interface{})
+ delegation := delegations[0].(map[string]interface{})
+
+ if _, exists := delegation["pool_info"]; !exists {
+ if _, camelExists := delegation["poolInfo"]; camelExists {
+ t.Error("Issue #13: Using 'poolInfo' instead of 'pool_info'")
+ }
+ }
+ })
+
+ t.Run("pool_info.id is int (Issue #16)", func(t *testing.T) {
+ raw := v.GetRaw()
+ delegations := raw["delegations"].([]interface{})
+ delegation := delegations[0].(map[string]interface{})
+
+ if poolInfo, exists := delegation["pool_info"]; exists {
+ pool := poolInfo.(map[string]interface{})
+ if id, idExists := pool["id"]; idExists {
+ switch id.(type) {
+ case float64:
+ // OK
+ case string:
+ t.Error("Issue #16: 'pool_info.id' should be int, got string")
+ }
+ }
+ }
+ })
+ }
+}
+
+// TestQueryDelegations tests GET /api/kira/delegations
+func TestQueryDelegations(t *testing.T) {
+ cfg := GetConfig()
+ client := NewClient(cfg)
+
+ t.Run("query delegations by delegator address", func(t *testing.T) {
+ resp, err := client.Get("/api/kira/delegations", map[string]string{
+ "delegatorAddress": cfg.DelegatorAddr,
+ })
+ require.NoError(t, err)
+ assert.True(t, resp.IsSuccess(), "Expected success, got status %d: %s", resp.StatusCode, string(resp.Body))
+
+ var result map[string]interface{}
+ json.Unmarshal(resp.Body, &result)
+
+ if delegations, ok := result["delegations"].([]interface{}); ok {
+ t.Logf("Found %d delegations", len(delegations))
+ }
+ })
+
+ t.Run("query delegations with pagination", func(t *testing.T) {
+ resp, err := client.Get("/api/kira/delegations", map[string]string{
+ "delegatorAddress": cfg.DelegatorAddr,
+ "limit": "5",
+ "offset": "0",
+ })
+ require.NoError(t, err)
+ assert.True(t, resp.IsSuccess(), "Expected success, got status %d", resp.StatusCode)
+ })
+
+ t.Run("query delegations with count total", func(t *testing.T) {
+ resp, err := client.Get("/api/kira/delegations", map[string]string{
+ "delegatorAddress": cfg.DelegatorAddr,
+ "countTotal": "true",
+ })
+ require.NoError(t, err)
+ assert.True(t, resp.IsSuccess(), "Expected success, got status %d", resp.StatusCode)
+ })
+}
+
+// ============================================================================
+// Undelegations Endpoint Tests - /api/kira/undelegations
+// ============================================================================
+
+// TestQueryUndelegations tests GET /api/kira/undelegations
+func TestQueryUndelegations(t *testing.T) {
+ cfg := GetConfig()
+ client := NewClient(cfg)
+
+ t.Run("query undelegations", func(t *testing.T) {
+ resp, err := client.Get("/api/kira/undelegations", map[string]string{
+ "undelegatorAddress": cfg.TestAddress,
+ })
+ require.NoError(t, err)
+ assert.True(t, resp.IsSuccess(), "Expected success, got status %d: %s", resp.StatusCode, string(resp.Body))
+ })
+
+ t.Run("query undelegations with count total", func(t *testing.T) {
+ resp, err := client.Get("/api/kira/undelegations", map[string]string{
+ "undelegatorAddress": cfg.TestAddress,
+ "countTotal": "true",
+ })
+ require.NoError(t, err)
+ assert.True(t, resp.IsSuccess(), "Expected success, got status %d", resp.StatusCode)
+ })
+
+ t.Run("query undelegations with pagination", func(t *testing.T) {
+ resp, err := client.Get("/api/kira/undelegations", map[string]string{
+ "undelegatorAddress": cfg.TestAddress,
+ "limit": "5",
+ "offset": "0",
+ })
+ require.NoError(t, err)
+ assert.True(t, resp.IsSuccess(), "Expected success, got status %d", resp.StatusCode)
+ })
+}
diff --git a/tests/integration/status_test.go b/tests/integration/status_test.go
new file mode 100644
index 0000000..7d3adb0
--- /dev/null
+++ b/tests/integration/status_test.go
@@ -0,0 +1,384 @@
+package integration
+
+import (
+ "encoding/json"
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+// ============================================================================
+// Status Endpoint Tests - /api/status
+// Expected format from miro: lib/infra/dto/api/query_interx_status/
+// ============================================================================
+
+// TestInterxStatusResponseFormat validates the response matches expected miro format
+// Issues: #13 (snake_case), #16 (types)
+func TestInterxStatusResponseFormat(t *testing.T) {
+ cfg := GetConfig()
+ client := NewClient(cfg)
+
+ resp, err := client.Get("/api/status", nil)
+ require.NoError(t, err)
+ require.True(t, resp.IsSuccess(), "Expected success, got %d: %s", resp.StatusCode, string(resp.Body))
+
+ v, err := NewFormatValidator(t, resp.Body)
+ require.NoError(t, err, "Failed to parse response")
+
+ t.Run("id field exists", func(t *testing.T) {
+ v.ValidateFieldExists("id", "Format")
+ })
+
+ t.Run("interx_info object exists", func(t *testing.T) {
+ v.ValidateFieldExists("interx_info", "Format")
+ v.ValidateFieldType("interx_info", TypeObject, "Format")
+ })
+
+ t.Run("node_info object exists", func(t *testing.T) {
+ v.ValidateFieldExists("node_info", "Format")
+ v.ValidateFieldType("node_info", TypeObject, "Format")
+ })
+
+ t.Run("sync_info object exists", func(t *testing.T) {
+ v.ValidateFieldExists("sync_info", "Format")
+ v.ValidateFieldType("sync_info", TypeObject, "Format")
+ })
+
+ t.Run("validator_info object exists", func(t *testing.T) {
+ v.ValidateFieldExists("validator_info", "Format")
+ v.ValidateFieldType("validator_info", TypeObject, "Format")
+ })
+
+ t.Run("interx_info fields use snake_case (Issue #13)", func(t *testing.T) {
+ if nested, ok := v.GetNestedValidator("interx_info"); ok {
+ nested.ValidateSnakeCase("Issue #13")
+
+ // Validate specific expected fields
+ expectedFields := []string{
+ "catching_up",
+ "chain_id",
+ "genesis_checksum",
+ "kira_addr",
+ "kira_pub_key",
+ "latest_block_height",
+ "moniker",
+ "version",
+ }
+
+ for _, field := range expectedFields {
+ if !nested.HasField(field) {
+ // Check for camelCase version
+ camelCase := snakeToCamel(field)
+ if nested.HasField(camelCase) {
+ t.Errorf("Issue #13: Using '%s' instead of '%s'", camelCase, field)
+ }
+ }
+ }
+ }
+ })
+
+ t.Run("interx_info.catching_up is bool (Issue #16)", func(t *testing.T) {
+ if nested, ok := v.GetNestedValidator("interx_info"); ok {
+ if nested.HasField("catching_up") {
+ nested.ValidateFieldType("catching_up", TypeBool, "Issue #16")
+ }
+ }
+ })
+
+ t.Run("sync_info fields use snake_case (Issue #13)", func(t *testing.T) {
+ if nested, ok := v.GetNestedValidator("sync_info"); ok {
+ nested.ValidateSnakeCase("Issue #13")
+
+ // Expected snake_case fields
+ expectedFields := []string{
+ "earliest_app_hash",
+ "earliest_block_hash",
+ "earliest_block_height",
+ "earliest_block_time",
+ "latest_app_hash",
+ "latest_block_hash",
+ "latest_block_height",
+ "latest_block_time",
+ }
+
+ for _, field := range expectedFields {
+ if !nested.HasField(field) {
+ camelCase := snakeToCamel(field)
+ if nested.HasField(camelCase) {
+ t.Errorf("Issue #13: Using '%s' instead of '%s'", camelCase, field)
+ }
+ }
+ }
+ }
+ })
+
+ t.Run("validator_info fields use snake_case (Issue #13)", func(t *testing.T) {
+ if nested, ok := v.GetNestedValidator("validator_info"); ok {
+ nested.ValidateSnakeCase("Issue #13")
+
+ // Check for pub_key vs pubKey
+ if !nested.HasField("pub_key") && nested.HasField("pubKey") {
+ t.Error("Issue #13: Using 'pubKey' instead of 'pub_key'")
+ }
+
+ // Check for voting_power vs votingPower
+ if !nested.HasField("voting_power") && nested.HasField("votingPower") {
+ t.Error("Issue #13: Using 'votingPower' instead of 'voting_power'")
+ }
+ }
+ })
+}
+
+// TestInterxStatus tests GET /api/status
+func TestInterxStatus(t *testing.T) {
+ cfg := GetConfig()
+ client := NewClient(cfg)
+
+ t.Run("query interx status", func(t *testing.T) {
+ resp, err := client.Get("/api/status", nil)
+ require.NoError(t, err)
+ assert.True(t, resp.IsSuccess(), "Expected success, got status %d: %s", resp.StatusCode, string(resp.Body))
+
+ var result map[string]interface{}
+ err = resp.JSON(&result)
+ require.NoError(t, err)
+
+ // Verify expected fields exist
+ assert.Contains(t, result, "interx_info", "Response should contain interx_info field")
+ })
+}
+
+// ============================================================================
+// Dashboard Endpoint Tests - /api/dashboard
+// Expected format from miro: lib/infra/dto/api/dashboard/
+// ============================================================================
+
+// TestDashboardResponseFormat validates the response matches expected miro format
+func TestDashboardResponseFormat(t *testing.T) {
+ cfg := GetConfig()
+ client := NewClient(cfg)
+
+ resp, err := client.Get("/api/dashboard", nil)
+ require.NoError(t, err)
+ require.True(t, resp.IsSuccess(), "Expected success, got %d: %s", resp.StatusCode, string(resp.Body))
+
+ v, err := NewFormatValidator(t, resp.Body)
+ require.NoError(t, err, "Failed to parse response")
+
+ t.Run("consensus_health field exists", func(t *testing.T) {
+ v.ValidateFieldExists("consensus_health", "Format")
+ })
+
+ t.Run("current_block_validator object exists", func(t *testing.T) {
+ v.ValidateFieldExists("current_block_validator", "Format")
+ v.ValidateFieldType("current_block_validator", TypeObject, "Format")
+ })
+
+ t.Run("validators object exists with int counts", func(t *testing.T) {
+ v.ValidateFieldExists("validators", "Format")
+
+ if nested, ok := v.GetNestedValidator("validators"); ok {
+ // miro expects these as integers
+ intFields := []string{
+ "active_validators",
+ "paused_validators",
+ "inactive_validators",
+ "jailed_validators",
+ "total_validators",
+ "waiting_validators",
+ }
+
+ for _, field := range intFields {
+ if nested.HasField(field) {
+ nested.ValidateFieldType(field, TypeInt, "Issue #16")
+ }
+ }
+
+ nested.ValidateSnakeCase("Issue #13")
+ }
+ })
+
+ t.Run("blocks object exists with correct types", func(t *testing.T) {
+ v.ValidateFieldExists("blocks", "Format")
+
+ if nested, ok := v.GetNestedValidator("blocks"); ok {
+ // Integer fields
+ intFields := []string{"current_height", "since_genesis", "pending_transactions", "current_transactions"}
+ for _, field := range intFields {
+ if nested.HasField(field) {
+ nested.ValidateFieldType(field, TypeInt, "Issue #16")
+ }
+ }
+
+ // Float fields
+ floatFields := []string{"latest_time", "average_time"}
+ for _, field := range floatFields {
+ if nested.HasField(field) {
+ // Can be int or float
+ raw := nested.GetRaw()
+ if val, exists := raw[field]; exists {
+ switch val.(type) {
+ case float64:
+ // OK
+ case string:
+ t.Errorf("Issue #16: Field '%s' should be number, got string", field)
+ }
+ }
+ }
+ }
+
+ nested.ValidateSnakeCase("Issue #13")
+ }
+ })
+
+ t.Run("proposals object exists with int counts", func(t *testing.T) {
+ v.ValidateFieldExists("proposals", "Format")
+
+ if nested, ok := v.GetNestedValidator("proposals"); ok {
+ intFields := []string{"total", "active", "enacting", "finished", "successful"}
+ for _, field := range intFields {
+ if nested.HasField(field) {
+ nested.ValidateFieldType(field, TypeInt, "Issue #16")
+ }
+ }
+ }
+ })
+}
+
+// TestKiraDashboard tests GET /api/dashboard
+func TestKiraDashboard(t *testing.T) {
+ cfg := GetConfig()
+ client := NewClient(cfg)
+
+ t.Run("query dashboard", func(t *testing.T) {
+ resp, err := client.Get("/api/dashboard", nil)
+ require.NoError(t, err)
+ assert.True(t, resp.IsSuccess(), "Expected success, got status %d: %s", resp.StatusCode, string(resp.Body))
+
+ var result map[string]interface{}
+ json.Unmarshal(resp.Body, &result)
+ t.Logf("Dashboard response keys: %v", getMapKeys(result))
+ })
+}
+
+// ============================================================================
+// Kira Status Endpoint Tests - /api/kira/status
+// ============================================================================
+
+// TestKiraStatus tests GET /api/kira/status
+func TestKiraStatus(t *testing.T) {
+ cfg := GetConfig()
+ client := NewClient(cfg)
+
+ t.Run("query kira status", func(t *testing.T) {
+ resp, err := client.Get("/api/kira/status", nil)
+ require.NoError(t, err)
+ assert.True(t, resp.IsSuccess(), "Expected success, got status %d: %s", resp.StatusCode, string(resp.Body))
+
+ var result map[string]interface{}
+ err = resp.JSON(&result)
+ require.NoError(t, err)
+
+ t.Logf("Kira status response keys: %v", getMapKeys(result))
+ })
+}
+
+// ============================================================================
+// Other Status-Related Endpoints
+// ============================================================================
+
+// TestTotalSupply tests GET /api/kira/supply
+func TestTotalSupply(t *testing.T) {
+ cfg := GetConfig()
+ client := NewClient(cfg)
+
+ t.Run("query total supply", func(t *testing.T) {
+ resp, err := client.Get("/api/kira/supply", nil)
+ require.NoError(t, err)
+ assert.True(t, resp.IsSuccess(), "Expected success, got status %d: %s", resp.StatusCode, string(resp.Body))
+ })
+}
+
+// TestRPCMethods tests GET /api/rpc_methods
+func TestRPCMethods(t *testing.T) {
+ cfg := GetConfig()
+ client := NewClient(cfg)
+
+ t.Run("query rpc methods", func(t *testing.T) {
+ resp, err := client.Get("/api/rpc_methods", nil)
+ require.NoError(t, err)
+ assert.True(t, resp.IsSuccess(), "Expected success, got status %d: %s", resp.StatusCode, string(resp.Body))
+ })
+}
+
+// TestMetadata tests GET /api/metadata
+func TestMetadata(t *testing.T) {
+ cfg := GetConfig()
+ client := NewClient(cfg)
+
+ t.Run("query interx metadata", func(t *testing.T) {
+ resp, err := client.Get("/api/metadata", nil)
+ require.NoError(t, err)
+ assert.True(t, resp.IsSuccess(), "Expected success, got status %d: %s", resp.StatusCode, string(resp.Body))
+ })
+}
+
+// TestKiraMetadata tests GET /api/kira/metadata
+func TestKiraMetadata(t *testing.T) {
+ cfg := GetConfig()
+ client := NewClient(cfg)
+
+ t.Run("query kira metadata", func(t *testing.T) {
+ resp, err := client.Get("/api/kira/metadata", nil)
+ require.NoError(t, err)
+ assert.True(t, resp.IsSuccess(), "Expected success, got status %d: %s", resp.StatusCode, string(resp.Body))
+ })
+}
+
+// TestAddrBook tests GET /api/addrbook
+func TestAddrBook(t *testing.T) {
+ cfg := GetConfig()
+ client := NewClient(cfg)
+
+ t.Run("query address book", func(t *testing.T) {
+ resp, err := client.Get("/api/addrbook", nil)
+ require.NoError(t, err)
+ assert.True(t, resp.IsSuccess(), "Expected success, got status %d: %s", resp.StatusCode, string(resp.Body))
+ })
+}
+
+// TestNetInfo tests GET /api/net_info
+func TestNetInfo(t *testing.T) {
+ cfg := GetConfig()
+ client := NewClient(cfg)
+
+ t.Run("query net info", func(t *testing.T) {
+ resp, err := client.Get("/api/net_info", nil)
+ require.NoError(t, err)
+ assert.True(t, resp.IsSuccess(), "Expected success, got status %d: %s", resp.StatusCode, string(resp.Body))
+ })
+}
+
+// ============================================================================
+// Helper functions
+// ============================================================================
+
+// snakeToCamel converts snake_case to camelCase
+func snakeToCamel(s string) string {
+ result := ""
+ capitalizeNext := false
+ for i, r := range s {
+ if r == '_' {
+ capitalizeNext = true
+ continue
+ }
+ if capitalizeNext && i > 0 {
+ result += string(r - 32) // uppercase
+ capitalizeNext = false
+ } else {
+ result += string(r)
+ }
+ }
+ return result
+}
diff --git a/tests/integration/tokens_test.go b/tests/integration/tokens_test.go
new file mode 100644
index 0000000..a0cddf0
--- /dev/null
+++ b/tests/integration/tokens_test.go
@@ -0,0 +1,237 @@
+package integration
+
+import (
+ "encoding/json"
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+// ============================================================================
+// Token Alias Endpoint Tests - /api/kira/tokens/aliases
+// Expected format from miro: lib/infra/dto/api_kira/query_kira_tokens_aliases/
+// ============================================================================
+
+// TestTokenAliasesResponseFormat validates the response matches expected miro format
+func TestTokenAliasesResponseFormat(t *testing.T) {
+ cfg := GetConfig()
+ client := NewClient(cfg)
+
+ resp, err := client.Get("/api/kira/tokens/aliases", nil)
+ require.NoError(t, err)
+ require.True(t, resp.IsSuccess(), "Expected success, got %d: %s", resp.StatusCode, string(resp.Body))
+
+ v, err := NewFormatValidator(t, resp.Body)
+ require.NoError(t, err, "Failed to parse response")
+
+ t.Run("token_aliases_data array exists", func(t *testing.T) {
+ v.ValidateFieldExists("token_aliases_data", "Format")
+ v.ValidateFieldType("token_aliases_data", TypeArray, "Format")
+ })
+
+ t.Run("default_denom field exists", func(t *testing.T) {
+ v.ValidateFieldExists("default_denom", "Format")
+ v.ValidateFieldType("default_denom", TypeString, "Format")
+ })
+
+ t.Run("bech32_prefix field exists", func(t *testing.T) {
+ v.ValidateFieldExists("bech32_prefix", "Format")
+ v.ValidateFieldType("bech32_prefix", TypeString, "Format")
+ })
+
+ // Check token alias structure if we have data
+ if v.GetArrayLength("token_aliases_data") > 0 {
+ t.Run("token alias decimals is int (Issue #16)", func(t *testing.T) {
+ raw := v.GetRaw()
+ aliases := raw["token_aliases_data"].([]interface{})
+ alias := aliases[0].(map[string]interface{})
+
+ if decimals, exists := alias["decimals"]; exists {
+ switch decimals.(type) {
+ case float64:
+ // OK - JSON numbers are float64
+ case string:
+ t.Error("Issue #16: 'decimals' should be int, got string")
+ }
+ }
+ })
+
+ t.Run("token alias amount is string (Issue #16)", func(t *testing.T) {
+ raw := v.GetRaw()
+ aliases := raw["token_aliases_data"].([]interface{})
+ alias := aliases[0].(map[string]interface{})
+
+ if amount, exists := alias["amount"]; exists {
+ switch amount.(type) {
+ case string:
+ // OK
+ case float64:
+ t.Error("Issue #16: 'amount' should be string (for precision), got number")
+ }
+ }
+ })
+
+ t.Run("token alias denoms is array", func(t *testing.T) {
+ raw := v.GetRaw()
+ aliases := raw["token_aliases_data"].([]interface{})
+ alias := aliases[0].(map[string]interface{})
+
+ if denoms, exists := alias["denoms"]; exists {
+ if _, ok := denoms.([]interface{}); !ok {
+ t.Error("'denoms' should be an array")
+ }
+ }
+ })
+ }
+}
+
+// TestQueryTokenAliases tests GET /api/kira/tokens/aliases
+func TestQueryTokenAliases(t *testing.T) {
+ cfg := GetConfig()
+ client := NewClient(cfg)
+
+ t.Run("query token aliases", func(t *testing.T) {
+ resp, err := client.Get("/api/kira/tokens/aliases", nil)
+ require.NoError(t, err)
+ assert.True(t, resp.IsSuccess(), "Expected success, got status %d: %s", resp.StatusCode, string(resp.Body))
+
+ var result map[string]interface{}
+ json.Unmarshal(resp.Body, &result)
+
+ if aliases, ok := result["token_aliases_data"].([]interface{}); ok {
+ t.Logf("Found %d token aliases", len(aliases))
+ }
+ })
+
+ t.Run("query token aliases with pagination", func(t *testing.T) {
+ resp, err := client.Get("/api/kira/tokens/aliases", map[string]string{
+ "limit": "1",
+ "offset": "0",
+ })
+ require.NoError(t, err)
+ assert.True(t, resp.IsSuccess(), "Expected success, got status %d", resp.StatusCode)
+ })
+
+ t.Run("query token aliases with count total", func(t *testing.T) {
+ resp, err := client.Get("/api/kira/tokens/aliases", map[string]string{
+ "count_total": "true",
+ })
+ require.NoError(t, err)
+ assert.True(t, resp.IsSuccess(), "Expected success, got status %d", resp.StatusCode)
+ })
+
+ t.Run("query specific token alias", func(t *testing.T) {
+ resp, err := client.Get("/api/kira/tokens/aliases", map[string]string{
+ "tokens": "ukex",
+ })
+ require.NoError(t, err)
+ assert.True(t, resp.IsSuccess(), "Expected success, got status %d", resp.StatusCode)
+ })
+}
+
+// ============================================================================
+// Token Rates Endpoint Tests - /api/kira/tokens/rates
+// Expected format from miro: lib/infra/dto/api_kira/query_kira_tokens_rates/
+// ============================================================================
+
+// TestTokenRatesResponseFormat validates the response matches expected miro format
+func TestTokenRatesResponseFormat(t *testing.T) {
+ cfg := GetConfig()
+ client := NewClient(cfg)
+
+ resp, err := client.Get("/api/kira/tokens/rates", nil)
+ require.NoError(t, err)
+ require.True(t, resp.IsSuccess(), "Expected success, got %d: %s", resp.StatusCode, string(resp.Body))
+
+ v, err := NewFormatValidator(t, resp.Body)
+ require.NoError(t, err, "Failed to parse response")
+
+ t.Run("data array exists", func(t *testing.T) {
+ v.ValidateFieldExists("data", "Format")
+ v.ValidateFieldType("data", TypeArray, "Format")
+ })
+
+ // Check token rate structure if we have data
+ if v.GetArrayLength("data") > 0 {
+ t.Run("token rate fields use snake_case (Issue #13)", func(t *testing.T) {
+ raw := v.GetRaw()
+ rates := raw["data"].([]interface{})
+ rate := rates[0].(map[string]interface{})
+
+ expectedSnakeCaseFields := []string{
+ "fee_payments",
+ "fee_rate",
+ "stake_cap",
+ "stake_min",
+ "stake_token",
+ }
+
+ for _, field := range expectedSnakeCaseFields {
+ if _, exists := rate[field]; !exists {
+ camelCase := snakeToCamel(field)
+ if _, camelExists := rate[camelCase]; camelExists {
+ t.Errorf("Issue #13: Using '%s' instead of '%s'", camelCase, field)
+ }
+ }
+ }
+ })
+
+ t.Run("token rate boolean fields are bools (Issue #16)", func(t *testing.T) {
+ raw := v.GetRaw()
+ rates := raw["data"].([]interface{})
+ rate := rates[0].(map[string]interface{})
+
+ boolFields := []string{"fee_payments", "stake_token"}
+ for _, field := range boolFields {
+ if val, exists := rate[field]; exists {
+ switch val.(type) {
+ case bool:
+ // OK
+ case string:
+ t.Errorf("Issue #16: '%s' should be bool, got string", field)
+ case float64:
+ t.Errorf("Issue #16: '%s' should be bool, got number", field)
+ }
+ }
+ }
+ })
+
+ t.Run("token rate string fields are strings", func(t *testing.T) {
+ raw := v.GetRaw()
+ rates := raw["data"].([]interface{})
+ rate := rates[0].(map[string]interface{})
+
+ stringFields := []string{"denom", "fee_rate", "stake_cap", "stake_min"}
+ for _, field := range stringFields {
+ if val, exists := rate[field]; exists {
+ switch val.(type) {
+ case string:
+ // OK
+ case float64:
+ t.Errorf("Issue #16: '%s' should be string, got number", field)
+ }
+ }
+ }
+ })
+ }
+}
+
+// TestQueryTokenRates tests GET /api/kira/tokens/rates
+func TestQueryTokenRates(t *testing.T) {
+ cfg := GetConfig()
+ client := NewClient(cfg)
+
+ t.Run("query token rates", func(t *testing.T) {
+ resp, err := client.Get("/api/kira/tokens/rates", nil)
+ require.NoError(t, err)
+ assert.True(t, resp.IsSuccess(), "Expected success, got status %d: %s", resp.StatusCode, string(resp.Body))
+
+ var result map[string]interface{}
+ json.Unmarshal(resp.Body, &result)
+
+ if rates, ok := result["data"].([]interface{}); ok {
+ t.Logf("Found %d token rates", len(rates))
+ }
+ })
+}
diff --git a/tests/integration/transactions_test.go b/tests/integration/transactions_test.go
new file mode 100644
index 0000000..fd63ad4
--- /dev/null
+++ b/tests/integration/transactions_test.go
@@ -0,0 +1,692 @@
+package integration
+
+import (
+ "encoding/json"
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+// ============================================================================
+// Transaction Endpoint Tests - /api/transactions
+// Expected format from miro: lib/infra/dto/api/query_transactions/response/
+// ============================================================================
+
+// ActualTransactionsResponse captures what the API actually returns
+type ActualTransactionsResponse struct {
+ Transactions []ActualTransaction `json:"transactions"`
+ Total interface{} `json:"total,omitempty"`
+ TotalCount interface{} `json:"total_count,omitempty"`
+}
+
+// ActualTransaction captures the actual response structure
+type ActualTransaction struct {
+ Hash string `json:"hash"`
+ Height json.RawMessage `json:"height"`
+ Timestamp json.RawMessage `json:"timestamp"`
+ Time json.RawMessage `json:"time"`
+ Status string `json:"status"`
+ Direction string `json:"direction"`
+ Messages []interface{} `json:"messages"`
+ Txs []interface{} `json:"txs"`
+ TxResult interface{} `json:"tx_result"`
+}
+
+// TestTransactionsResponseFormat validates the response matches expected miro format
+// Issues: #13 (snake_case), #16 (types), #19 (missing fields), #40 (type filter), #41 (address/direction filter)
+func TestTransactionsResponseFormat(t *testing.T) {
+ cfg := GetConfig()
+ client := NewClient(cfg)
+
+ resp, err := client.Get("/api/transactions", map[string]string{
+ "limit": "10",
+ })
+ require.NoError(t, err)
+ require.True(t, resp.IsSuccess(), "Expected success, got %d: %s", resp.StatusCode, string(resp.Body))
+
+ v, err := NewFormatValidator(t, resp.Body)
+ require.NoError(t, err, "Failed to parse response")
+
+ t.Run("transactions array exists", func(t *testing.T) {
+ v.ValidateFieldExists("transactions", "Format")
+ v.ValidateFieldType("transactions", TypeArray, "Format")
+ })
+
+ t.Run("total_count field exists and is int (Issue #19, #16)", func(t *testing.T) {
+ // miro expects total_count as snake_case INT
+ v.ValidateFieldNotExists("totalCount", "total_count", "Issue #13")
+ if !v.ValidateFieldExists("total_count", "Issue #19") {
+ t.Error("Issue #19: Missing 'total_count' field")
+ } else {
+ v.ValidateFieldType("total_count", TypeInt, "Issue #16")
+ }
+ })
+
+ // Check transaction structure if we have transactions
+ if v.GetArrayLength("transactions") > 0 {
+ t.Run("transaction.time is Unix int (Issue #16, #19)", func(t *testing.T) {
+ // miro expects 'time' as Unix timestamp INT, not string, not ISO
+ raw := v.GetRaw()
+ txs := raw["transactions"].([]interface{})
+ tx := txs[0].(map[string]interface{})
+
+ // Check field exists
+ if _, exists := tx["time"]; !exists {
+ t.Error("Issue #19: Missing 'time' field in transaction")
+ return
+ }
+
+ // Check type
+ switch tx["time"].(type) {
+ case float64:
+ // OK - JSON numbers are float64
+ case string:
+ t.Error("Issue #16: 'time' should be Unix int, got string (possibly ISO format)")
+ default:
+ t.Errorf("Issue #16: 'time' has unexpected type %T", tx["time"])
+ }
+ })
+
+ t.Run("transaction.hash exists", func(t *testing.T) {
+ v.ValidateArrayFieldType("transactions", "hash", TypeString, "Format")
+ })
+
+ t.Run("transaction.status field exists (Issue #19)", func(t *testing.T) {
+ // miro expects 'status' field: "confirmed", "pending", "failed"
+ raw := v.GetRaw()
+ txs := raw["transactions"].([]interface{})
+ tx := txs[0].(map[string]interface{})
+
+ if _, exists := tx["status"]; !exists {
+ t.Error("Issue #19: Missing 'status' field in transaction")
+ }
+ })
+
+ t.Run("transaction.direction field exists (Issue #19)", func(t *testing.T) {
+ // miro expects 'direction' field: "inbound" or "outbound"
+ raw := v.GetRaw()
+ txs := raw["transactions"].([]interface{})
+ tx := txs[0].(map[string]interface{})
+
+ if _, exists := tx["direction"]; !exists {
+ t.Error("Issue #19: Missing 'direction' field in transaction")
+ }
+ })
+
+ t.Run("transaction.txs field not messages (Issue #19)", func(t *testing.T) {
+ // miro expects 'txs' array, NOT 'messages'
+ raw := v.GetRaw()
+ txs := raw["transactions"].([]interface{})
+ tx := txs[0].(map[string]interface{})
+
+ if _, exists := tx["messages"]; exists {
+ if _, txsExists := tx["txs"]; !txsExists {
+ t.Error("Issue #19: Using 'messages' instead of 'txs'")
+ }
+ }
+
+ if _, exists := tx["txs"]; !exists {
+ t.Error("Issue #19: Missing 'txs' field in transaction")
+ }
+ })
+
+ t.Run("transaction.fee is array of coins", func(t *testing.T) {
+ raw := v.GetRaw()
+ txs := raw["transactions"].([]interface{})
+ tx := txs[0].(map[string]interface{})
+
+ if fee, exists := tx["fee"]; exists {
+ _, ok := fee.([]interface{})
+ if !ok {
+ t.Error("'fee' should be an array of coins")
+ }
+ }
+ })
+
+ t.Run("transaction fields use snake_case (Issue #13)", func(t *testing.T) {
+ raw := v.GetRaw()
+ txs := raw["transactions"].([]interface{})
+ tx := txs[0].(map[string]interface{})
+
+ for key := range tx {
+ if isCamelCase(key) {
+ t.Errorf("Issue #13: Transaction field '%s' uses camelCase, expected snake_case", key)
+ }
+ }
+ })
+ }
+}
+
+// TestTransactionsAddressFilter tests the address query parameter (Issue #41)
+func TestTransactionsAddressFilter(t *testing.T) {
+ cfg := GetConfig()
+ client := NewClient(cfg)
+
+ t.Run("address filter returns only relevant transactions (Issue #41)", func(t *testing.T) {
+ // Get all transactions first
+ allResp, err := client.Get("/api/transactions", map[string]string{"limit": "50"})
+ require.NoError(t, err)
+ var allResult map[string]interface{}
+ json.Unmarshal(allResp.Body, &allResult)
+ allTxs, _ := allResult["transactions"].([]interface{})
+
+ // Query with address filter
+ resp, err := client.Get("/api/transactions", map[string]string{
+ "address": cfg.TestAddress,
+ "limit": "50",
+ })
+ require.NoError(t, err)
+ require.True(t, resp.IsSuccess())
+
+ var result map[string]interface{}
+ err = json.Unmarshal(resp.Body, &result)
+ require.NoError(t, err)
+
+ txs, ok := result["transactions"].([]interface{})
+ if !ok || len(txs) == 0 {
+ t.Skip("No transactions found for test address")
+ return
+ }
+
+ // BUG CHECK: If filtered count equals unfiltered count, filter may not be working
+ if len(txs) == len(allTxs) && len(allTxs) > 10 {
+ t.Errorf("Issue #41: Address filter may not be working - filtered count (%d) equals unfiltered count (%d)", len(txs), len(allTxs))
+ }
+
+ // Verify all returned transactions involve the filtered address
+ for i, tx := range txs {
+ txMap := tx.(map[string]interface{})
+ txJSON, _ := json.Marshal(txMap)
+ txStr := string(txJSON)
+
+ // The address should appear somewhere in the transaction
+ if !containsSubstr(txStr, cfg.TestAddress) {
+ t.Errorf("Issue #41: Transaction %d does not involve filtered address %s", i, cfg.TestAddress)
+ break // Only report first occurrence
+ }
+ }
+ })
+
+ t.Run("non-existent address returns no transactions (Issue #41)", func(t *testing.T) {
+ nonExistentAddr := "kira1qqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqq"
+ resp, err := client.Get("/api/transactions", map[string]string{
+ "address": nonExistentAddr,
+ "limit": "50",
+ })
+ require.NoError(t, err)
+ require.True(t, resp.IsSuccess())
+
+ var result map[string]interface{}
+ json.Unmarshal(resp.Body, &result)
+ txs, _ := result["transactions"].([]interface{})
+
+ if len(txs) > 0 {
+ t.Errorf("Issue #41: Non-existent address returned %d transactions (expected 0)", len(txs))
+ }
+ })
+}
+
+// TestTransactionsTypeFilter tests the type query parameter (Issue #40)
+func TestTransactionsTypeFilter(t *testing.T) {
+ cfg := GetConfig()
+ client := NewClient(cfg)
+
+ t.Run("type filter returns only matching transaction types (Issue #40)", func(t *testing.T) {
+ // Query with type filter for MsgSend
+ resp, err := client.Get("/api/transactions", map[string]string{
+ "type": "MsgSend",
+ "limit": "50",
+ })
+ require.NoError(t, err)
+ require.True(t, resp.IsSuccess())
+
+ var result map[string]interface{}
+ err = json.Unmarshal(resp.Body, &result)
+ require.NoError(t, err)
+
+ txs, ok := result["transactions"].([]interface{})
+ if !ok || len(txs) == 0 {
+ t.Skip("No MsgSend transactions found")
+ return
+ }
+
+ // Verify all returned transactions are MsgSend type
+ for i, tx := range txs {
+ txMap := tx.(map[string]interface{})
+ txJSON, _ := json.Marshal(txMap)
+ txStr := string(txJSON)
+
+ // Check for MsgSend in the transaction
+ if !containsSubstr(txStr, "MsgSend") && !containsSubstr(txStr, "msg_send") && !containsSubstr(txStr, "Send") {
+ t.Errorf("Issue #40: Transaction %d is not MsgSend type", i)
+ t.Logf("Transaction: %s", txStr[:minInt(500, len(txStr))])
+ break
+ }
+ }
+ })
+
+ t.Run("non-existent type returns no transactions (Issue #40)", func(t *testing.T) {
+ resp, err := client.Get("/api/transactions", map[string]string{
+ "type": "/nonexistent.type.MsgNonExistent",
+ "limit": "50",
+ })
+ require.NoError(t, err)
+ require.True(t, resp.IsSuccess())
+
+ var result map[string]interface{}
+ json.Unmarshal(resp.Body, &result)
+ txs, _ := result["transactions"].([]interface{})
+
+ if len(txs) > 0 {
+ t.Errorf("Issue #40: Non-existent type returned %d transactions (expected 0)", len(txs))
+ }
+ })
+}
+
+// TestTransactionsDirectionFilter tests the direction query parameter (Issue #41)
+func TestTransactionsDirectionFilter(t *testing.T) {
+ cfg := GetConfig()
+ client := NewClient(cfg)
+
+ t.Run("direction=inbound filter (Issue #41)", func(t *testing.T) {
+ resp, err := client.Get("/api/transactions", map[string]string{
+ "address": cfg.TestAddress,
+ "direction": "inbound",
+ "limit": "20",
+ })
+ require.NoError(t, err)
+ require.True(t, resp.IsSuccess())
+
+ var result map[string]interface{}
+ json.Unmarshal(resp.Body, &result)
+
+ txs, ok := result["transactions"].([]interface{})
+ if !ok || len(txs) == 0 {
+ t.Skip("No inbound transactions found")
+ return
+ }
+
+ // Check if direction field exists and has correct value
+ for i, tx := range txs {
+ txMap := tx.(map[string]interface{})
+ if dir, exists := txMap["direction"]; exists {
+ if dir != "inbound" {
+ t.Errorf("Issue #41: Transaction %d has direction '%v', expected 'inbound'", i, dir)
+ break
+ }
+ }
+ }
+ })
+
+ t.Run("direction=outbound filter (Issue #41)", func(t *testing.T) {
+ resp, err := client.Get("/api/transactions", map[string]string{
+ "address": cfg.TestAddress,
+ "direction": "outbound",
+ "limit": "20",
+ })
+ require.NoError(t, err)
+ require.True(t, resp.IsSuccess())
+
+ var result map[string]interface{}
+ json.Unmarshal(resp.Body, &result)
+
+ txs, ok := result["transactions"].([]interface{})
+ if !ok || len(txs) == 0 {
+ t.Skip("No outbound transactions found")
+ return
+ }
+
+ // Check if direction field exists and has correct value
+ for i, tx := range txs {
+ txMap := tx.(map[string]interface{})
+ if dir, exists := txMap["direction"]; exists {
+ if dir != "outbound" {
+ t.Errorf("Issue #41: Transaction %d has direction '%v', expected 'outbound'", i, dir)
+ break
+ }
+ }
+ }
+ })
+
+ t.Run("inbound + outbound should not equal all (Issue #41)", func(t *testing.T) {
+ // Get all transactions for address
+ allResp, err := client.Get("/api/transactions", map[string]string{
+ "address": cfg.TestAddress,
+ "limit": "100",
+ })
+ require.NoError(t, err)
+ var allResult map[string]interface{}
+ json.Unmarshal(allResp.Body, &allResult)
+ allTxs, _ := allResult["transactions"].([]interface{})
+
+ // Get inbound
+ inResp, _ := client.Get("/api/transactions", map[string]string{
+ "address": cfg.TestAddress, "direction": "inbound", "limit": "100",
+ })
+ var inResult map[string]interface{}
+ json.Unmarshal(inResp.Body, &inResult)
+ inTxs, _ := inResult["transactions"].([]interface{})
+
+ // Get outbound
+ outResp, _ := client.Get("/api/transactions", map[string]string{
+ "address": cfg.TestAddress, "direction": "outbound", "limit": "100",
+ })
+ var outResult map[string]interface{}
+ json.Unmarshal(outResp.Body, &outResult)
+ outTxs, _ := outResult["transactions"].([]interface{})
+
+ // If direction filter works, inbound + outbound should roughly equal all
+ // If both return same count as all, filter is broken
+ if len(allTxs) > 5 && len(inTxs) == len(allTxs) && len(outTxs) == len(allTxs) {
+ t.Errorf("Issue #41: Direction filter broken - all=%d, inbound=%d, outbound=%d (both should not equal all)",
+ len(allTxs), len(inTxs), len(outTxs))
+ }
+ })
+}
+
+// TestTransactionsSorting tests the sort query parameter
+func TestTransactionsSorting(t *testing.T) {
+ cfg := GetConfig()
+ client := NewClient(cfg)
+
+ t.Run("sort=dateASC returns oldest first", func(t *testing.T) {
+ resp, err := client.Get("/api/transactions", map[string]string{
+ "sort": "dateASC",
+ "limit": "10",
+ })
+ require.NoError(t, err)
+ require.True(t, resp.IsSuccess())
+
+ var result map[string]interface{}
+ json.Unmarshal(resp.Body, &result)
+
+ txs, ok := result["transactions"].([]interface{})
+ if !ok || len(txs) < 2 {
+ t.Skip("Not enough transactions to test sorting")
+ return
+ }
+
+ // Get timestamps or heights to verify order
+ var times []float64
+ for _, tx := range txs {
+ txMap := tx.(map[string]interface{})
+ if time, exists := txMap["time"]; exists {
+ if timeFloat, ok := time.(float64); ok {
+ times = append(times, timeFloat)
+ }
+ } else if height, exists := txMap["height"]; exists {
+ if heightFloat, ok := height.(float64); ok {
+ times = append(times, heightFloat)
+ }
+ }
+ }
+
+ // Verify ascending order
+ for i := 1; i < len(times); i++ {
+ if times[i] < times[i-1] {
+ t.Errorf("Sort dateASC not working: position %d (%v) < position %d (%v)", i, times[i], i-1, times[i-1])
+ break
+ }
+ }
+ })
+
+ t.Run("sort=dateDESC returns newest first", func(t *testing.T) {
+ resp, err := client.Get("/api/transactions", map[string]string{
+ "sort": "dateDESC",
+ "limit": "10",
+ })
+ require.NoError(t, err)
+ require.True(t, resp.IsSuccess())
+
+ var result map[string]interface{}
+ json.Unmarshal(resp.Body, &result)
+
+ txs, ok := result["transactions"].([]interface{})
+ if !ok || len(txs) < 2 {
+ t.Skip("Not enough transactions to test sorting")
+ return
+ }
+
+ // Get timestamps or heights to verify order
+ var times []float64
+ for _, tx := range txs {
+ txMap := tx.(map[string]interface{})
+ if time, exists := txMap["time"]; exists {
+ if timeFloat, ok := time.(float64); ok {
+ times = append(times, timeFloat)
+ }
+ } else if height, exists := txMap["height"]; exists {
+ if heightFloat, ok := height.(float64); ok {
+ times = append(times, heightFloat)
+ }
+ }
+ }
+
+ // Verify descending order
+ for i := 1; i < len(times); i++ {
+ if times[i] > times[i-1] {
+ t.Errorf("NEW BUG - Sort dateDESC not working: position %d (%v) > position %d (%v)", i, times[i], i-1, times[i-1])
+ break
+ }
+ }
+ })
+}
+
+// TestTransactionsPagination tests limit and offset parameters
+func TestTransactionsPagination(t *testing.T) {
+ cfg := GetConfig()
+ client := NewClient(cfg)
+
+ t.Run("limit parameter works", func(t *testing.T) {
+ resp, err := client.Get("/api/transactions", map[string]string{
+ "limit": "5",
+ })
+ require.NoError(t, err)
+ require.True(t, resp.IsSuccess())
+
+ var result map[string]interface{}
+ json.Unmarshal(resp.Body, &result)
+
+ txs, ok := result["transactions"].([]interface{})
+ if !ok {
+ t.Error("Missing transactions array")
+ return
+ }
+
+ assert.LessOrEqual(t, len(txs), 5, "Limit parameter not respected")
+ })
+
+ t.Run("offset parameter works", func(t *testing.T) {
+ // Get first page
+ resp1, err := client.Get("/api/transactions", map[string]string{
+ "limit": "5",
+ })
+ require.NoError(t, err)
+
+ var result1 map[string]interface{}
+ json.Unmarshal(resp1.Body, &result1)
+ txs1, _ := result1["transactions"].([]interface{})
+
+ // Get second page
+ resp2, err := client.Get("/api/transactions", map[string]string{
+ "limit": "5",
+ "offset": "5",
+ })
+ require.NoError(t, err)
+
+ var result2 map[string]interface{}
+ json.Unmarshal(resp2.Body, &result2)
+ txs2, _ := result2["transactions"].([]interface{})
+
+ if len(txs1) > 0 && len(txs2) > 0 {
+ // First transaction of page 2 should be different from page 1
+ tx1 := txs1[0].(map[string]interface{})
+ tx2 := txs2[0].(map[string]interface{})
+
+ if tx1["hash"] == tx2["hash"] {
+ t.Error("Offset parameter not working - same transactions returned")
+ }
+ }
+ })
+}
+
+// TestTransactionsStatusFilter tests the status query parameter
+func TestTransactionsStatusFilter(t *testing.T) {
+ cfg := GetConfig()
+ client := NewClient(cfg)
+
+ t.Run("status=confirmed filter", func(t *testing.T) {
+ resp, err := client.Get("/api/transactions", map[string]string{
+ "status": "confirmed",
+ "limit": "10",
+ })
+ require.NoError(t, err)
+ require.True(t, resp.IsSuccess())
+
+ var result map[string]interface{}
+ json.Unmarshal(resp.Body, &result)
+
+ txs, ok := result["transactions"].([]interface{})
+ if !ok || len(txs) == 0 {
+ t.Skip("No confirmed transactions found")
+ return
+ }
+
+ // Check if all transactions have status=confirmed
+ for i, tx := range txs {
+ txMap := tx.(map[string]interface{})
+ if status, exists := txMap["status"]; exists {
+ if status != "confirmed" {
+ t.Errorf("Status filter not working: transaction %d has status '%v'", i, status)
+ break
+ }
+ }
+ }
+ })
+}
+
+// ============================================================================
+// Block Endpoint Tests - /api/blocks
+// ============================================================================
+
+// TestBlocksEndpoint tests GET /api/blocks
+func TestBlocksEndpoint(t *testing.T) {
+ cfg := GetConfig()
+ client := NewClient(cfg)
+
+ t.Run("returns block list", func(t *testing.T) {
+ resp, err := client.Get("/api/blocks", map[string]string{
+ "limit": "10",
+ })
+ require.NoError(t, err)
+ require.True(t, resp.IsSuccess(), "Expected success, got %d: %s", resp.StatusCode, string(resp.Body))
+
+ var result map[string]interface{}
+ err = json.Unmarshal(resp.Body, &result)
+ require.NoError(t, err)
+
+ // Should have blocks array or block data
+ t.Logf("Blocks response keys: %v", getMapKeys(result))
+ })
+
+ t.Run("pagination works", func(t *testing.T) {
+ resp1, err := client.Get("/api/blocks", map[string]string{"limit": "5"})
+ require.NoError(t, err)
+
+ resp2, err := client.Get("/api/blocks", map[string]string{"limit": "5", "offset": "5"})
+ require.NoError(t, err)
+
+ assert.True(t, resp1.IsSuccess())
+ assert.True(t, resp2.IsSuccess())
+ })
+}
+
+// TestBlockByHeight tests GET /api/blocks/{height}
+func TestBlockByHeight(t *testing.T) {
+ cfg := GetConfig()
+ client := NewClient(cfg)
+
+ t.Run("valid height returns block data", func(t *testing.T) {
+ resp, err := client.Get("/api/blocks/1", nil)
+ require.NoError(t, err)
+ require.True(t, resp.IsSuccess(), "Expected success for block 1, got %d: %s", resp.StatusCode, string(resp.Body))
+
+ var result map[string]interface{}
+ err = json.Unmarshal(resp.Body, &result)
+ require.NoError(t, err)
+
+ t.Logf("Block 1 response keys: %v", getMapKeys(result))
+ })
+
+ t.Run("invalid height returns error", func(t *testing.T) {
+ resp, err := client.Get("/api/blocks/999999999999", nil)
+ require.NoError(t, err)
+ // Should either return error status or error in body
+ if resp.IsSuccess() {
+ var result map[string]interface{}
+ json.Unmarshal(resp.Body, &result)
+ if _, hasError := result["Error"]; !hasError {
+ t.Log("Note: Invalid block height did not return error")
+ }
+ }
+ })
+}
+
+// TestTransactionHash tests GET /api/kira/txs/{hash}
+func TestTransactionHash(t *testing.T) {
+ cfg := GetConfig()
+ client := NewClient(cfg)
+
+ t.Run("query transaction by hash", func(t *testing.T) {
+ txHash := "0xBF97A902D95517A4538531B21E5C2FE6FE3C5F04E392D7A1F43A802E9121232C"
+ resp, err := client.Get("/api/kira/txs/"+txHash, nil)
+ require.NoError(t, err)
+ assert.True(t, resp.StatusCode == 200 || resp.StatusCode == 404,
+ "Expected 200 or 404, got %d", resp.StatusCode)
+ })
+}
+
+// TestQueryUnconfirmedTransactions tests GET /api/unconfirmed_txs
+func TestQueryUnconfirmedTransactions(t *testing.T) {
+ cfg := GetConfig()
+ client := NewClient(cfg)
+
+ t.Run("query unconfirmed transactions", func(t *testing.T) {
+ resp, err := client.Get("/api/unconfirmed_txs", nil)
+ require.NoError(t, err)
+ assert.True(t, resp.IsSuccess(), "Expected success, got status %d: %s", resp.StatusCode, string(resp.Body))
+ })
+}
+
+// ============================================================================
+// Helper functions
+// ============================================================================
+
+func containsSubstr(s, substr string) bool {
+ return len(s) >= len(substr) && (s == substr || len(s) > 0 && containsHelper(s, substr))
+}
+
+func containsHelper(s, substr string) bool {
+ for i := 0; i <= len(s)-len(substr); i++ {
+ if s[i:i+len(substr)] == substr {
+ return true
+ }
+ }
+ return false
+}
+
+func getMapKeys(m map[string]interface{}) []string {
+ keys := make([]string, 0, len(m))
+ for k := range m {
+ keys = append(keys, k)
+ }
+ return keys
+}
+
+func minInt(a, b int) int {
+ if a < b {
+ return a
+ }
+ return b
+}
diff --git a/tests/integration/types.go b/tests/integration/types.go
new file mode 100644
index 0000000..79e7ae4
--- /dev/null
+++ b/tests/integration/types.go
@@ -0,0 +1,425 @@
+package integration
+
+// Expected Response Types based on Miro Frontend DTOs
+// Source: /home/ubuntu/Code/github.com/kiracore/miro/lib/infra/dto/
+// These define what the frontend EXPECTS from the API
+
+// ============================================================================
+// Account Endpoint Types (/api/kira/accounts/{address})
+// ============================================================================
+
+// ExpectedPubKey represents the expected pub_key structure
+type ExpectedPubKey struct {
+ Type string `json:"@type"`
+ Value string `json:"value"`
+}
+
+// ExpectedAccountResponse - miro expects snake_case fields
+// Source: lib/infra/dto/api_kira/query_account/response/
+type ExpectedAccountResponse struct {
+ Type string `json:"@type"`
+ AccountNumber string `json:"account_number"` // NOT accountNumber
+ Address string `json:"address"`
+ PubKey *ExpectedPubKey `json:"pub_key"` // NOT pubKey
+ Sequence string `json:"sequence"`
+}
+
+// ============================================================================
+// Balance Endpoint Types (/api/kira/balances/{address})
+// ============================================================================
+
+// ExpectedCoin - shared type for amount/denom pairs
+type ExpectedCoin struct {
+ Amount string `json:"amount"` // String for precision
+ Denom string `json:"denom"`
+}
+
+// ExpectedPagination - miro expects total as string
+type ExpectedPagination struct {
+ Total string `json:"total"` // String, not int
+}
+
+// ExpectedBalanceResponse
+// Source: lib/infra/dto/api_kira/query_balance/response/
+type ExpectedBalanceResponse struct {
+ Balances []ExpectedCoin `json:"balances"`
+ Pagination *ExpectedPagination `json:"pagination"` // Required by miro
+}
+
+// ============================================================================
+// Transaction Endpoint Types (/api/transactions)
+// ============================================================================
+
+// ExpectedTransaction - miro expects specific fields
+// Source: lib/infra/dto/api/query_transactions/response/transaction.dart
+type ExpectedTransaction struct {
+ Time int `json:"time"` // Unix timestamp as INT (not string, not ISO)
+ Hash string `json:"hash"`
+ Status string `json:"status"` // "confirmed", "pending", "failed"
+ Direction string `json:"direction"` // "inbound" or "outbound"
+ Memo string `json:"memo"`
+ Fee []ExpectedCoin `json:"fee"`
+ Txs []interface{} `json:"txs"` // NOT "messages"
+}
+
+// ExpectedTransactionsResponse
+// Source: lib/infra/dto/api/query_transactions/response/query_transactions_resp.dart
+type ExpectedTransactionsResponse struct {
+ Transactions []ExpectedTransaction `json:"transactions"`
+ TotalCount int `json:"total_count"` // INT, snake_case
+}
+
+// ============================================================================
+// Validator Endpoint Types (/api/valopers)
+// ============================================================================
+
+// ExpectedValidatorStatus - counts as integers
+// Source: lib/infra/dto/api/query_validators/response/status.dart
+type ExpectedValidatorStatus struct {
+ ActiveValidators int `json:"active_validators"`
+ PausedValidators int `json:"paused_validators"`
+ InactiveValidators int `json:"inactive_validators"`
+ JailedValidators int `json:"jailed_validators"`
+ TotalValidators int `json:"total_validators"`
+ WaitingValidators int `json:"waiting_validators"`
+}
+
+// ExpectedValidator - all fields as strings
+// Source: lib/infra/dto/api/query_validators/response/validator.dart
+type ExpectedValidator struct {
+ Top string `json:"top"`
+ Address string `json:"address"`
+ Valkey string `json:"valkey"`
+ Pubkey string `json:"pubkey"`
+ Proposer string `json:"proposer"`
+ Moniker string `json:"moniker"`
+ Status string `json:"status"`
+ Rank string `json:"rank"`
+ Streak string `json:"streak"`
+ Mischance string `json:"mischance"`
+ MischanceConfidence string `json:"mischance_confidence"`
+ StartHeight string `json:"start_height"`
+ InactiveUntil string `json:"inactive_until"`
+ LastPresentBlock string `json:"last_present_block"`
+ MissedBlocksCounter string `json:"missed_blocks_counter"`
+ ProducedBlocksCounter string `json:"produced_blocks_counter"`
+ StakingPoolId *string `json:"staking_pool_id,omitempty"`
+ StakingPoolStatus *string `json:"staking_pool_status,omitempty"`
+ Description *string `json:"description,omitempty"`
+ Website *string `json:"website,omitempty"`
+ Logo *string `json:"logo,omitempty"`
+ Social *string `json:"social,omitempty"`
+ Contact *string `json:"contact,omitempty"`
+ ValidatorNodeId *string `json:"validator_node_id,omitempty"`
+ SentryNodeId *string `json:"sentry_node_id,omitempty"`
+}
+
+// ExpectedValidatorsResponse
+// Source: lib/infra/dto/api/query_validators/response/query_validators_resp.dart
+type ExpectedValidatorsResponse struct {
+ Waiting []string `json:"waiting"`
+ Validators []ExpectedValidator `json:"validators"`
+ Status *ExpectedValidatorStatus `json:"status,omitempty"`
+}
+
+// ============================================================================
+// Status Endpoint Types (/api/status)
+// ============================================================================
+
+// ExpectedNode - nested in InterxInfo
+type ExpectedNode struct {
+ NodeType string `json:"node_type"`
+ SeedNodeId string `json:"seed_node_id"`
+ SentryNodeId string `json:"sentry_node_id"`
+ SnapshotNodeId string `json:"snapshot_node_id"`
+ ValidatorNodeId string `json:"validator_node_id"`
+}
+
+// ExpectedInterxInfo
+// Source: lib/infra/dto/api/query_interx_status/interx_info.dart
+type ExpectedInterxInfo struct {
+ CatchingUp bool `json:"catching_up"` // BOOL
+ ChainId string `json:"chain_id"`
+ GenesisChecksum string `json:"genesis_checksum"`
+ KiraAddr string `json:"kira_addr"`
+ KiraPubKey string `json:"kira_pub_key"`
+ LatestBlockHeight string `json:"latest_block_height"`
+ Moniker string `json:"moniker"`
+ Version string `json:"version"`
+ FaucetAddr *string `json:"faucet_addr,omitempty"`
+ Node *ExpectedNode `json:"node,omitempty"`
+ PubKey *ExpectedPubKey `json:"pub_key,omitempty"`
+}
+
+// ExpectedProtocolVersion
+type ExpectedProtocolVersion struct {
+ App string `json:"app"`
+ Block string `json:"block"`
+ P2p string `json:"p2p"`
+}
+
+// ExpectedNodeInfo
+// Source: lib/infra/dto/api/query_interx_status/node_info.dart
+type ExpectedNodeInfo struct {
+ Channels string `json:"channels"`
+ Id string `json:"id"`
+ ListenAddr string `json:"listen_addr"`
+ Moniker string `json:"moniker"`
+ Network string `json:"network"`
+ Version string `json:"version"`
+ ProtocolVersion *ExpectedProtocolVersion `json:"protocol_version,omitempty"`
+ Other map[string]interface{} `json:"other,omitempty"`
+}
+
+// ExpectedSyncInfo
+// Source: lib/infra/dto/api/query_interx_status/sync_info.dart
+type ExpectedSyncInfo struct {
+ EarliestAppHash string `json:"earliest_app_hash"`
+ EarliestBlockHash string `json:"earliest_block_hash"`
+ EarliestBlockHeight string `json:"earliest_block_height"`
+ EarliestBlockTime string `json:"earliest_block_time"`
+ LatestAppHash string `json:"latest_app_hash"`
+ LatestBlockHash string `json:"latest_block_hash"`
+ LatestBlockHeight string `json:"latest_block_height"`
+ LatestBlockTime string `json:"latest_block_time"`
+ CatchingUp bool `json:"catching_up"`
+}
+
+// ExpectedStatusValidatorInfo
+type ExpectedStatusValidatorInfo struct {
+ Address string `json:"address"`
+ PubKey *ExpectedPubKey `json:"pub_key"`
+ VotingPower string `json:"voting_power"`
+}
+
+// ExpectedStatusResponse
+// Source: lib/infra/dto/api/query_interx_status/query_interx_status_resp.dart
+type ExpectedStatusResponse struct {
+ Id string `json:"id"`
+ InterxInfo *ExpectedInterxInfo `json:"interx_info"`
+ NodeInfo *ExpectedNodeInfo `json:"node_info"`
+ SyncInfo *ExpectedSyncInfo `json:"sync_info"`
+ ValidatorInfo *ExpectedStatusValidatorInfo `json:"validator_info"`
+}
+
+// ============================================================================
+// Dashboard Endpoint Types (/api/dashboard)
+// ============================================================================
+
+// ExpectedCurrentBlockValidator
+type ExpectedCurrentBlockValidator struct {
+ Moniker string `json:"moniker"`
+ Address string `json:"address"`
+}
+
+// ExpectedDashboardValidators - counts as integers
+type ExpectedDashboardValidators struct {
+ ActiveValidators int `json:"active_validators"`
+ PausedValidators int `json:"paused_validators"`
+ InactiveValidators int `json:"inactive_validators"`
+ JailedValidators int `json:"jailed_validators"`
+ TotalValidators int `json:"total_validators"`
+ WaitingValidators int `json:"waiting_validators"`
+}
+
+// ExpectedDashboardBlocks
+// Source: lib/infra/dto/api/dashboard/blocks.dart
+type ExpectedDashboardBlocks struct {
+ CurrentHeight int `json:"current_height"`
+ SinceGenesis int `json:"since_genesis"`
+ PendingTransactions int `json:"pending_transactions"`
+ CurrentTransactions int `json:"current_transactions"`
+ LatestTime float64 `json:"latest_time"`
+ AverageTime float64 `json:"average_time"`
+}
+
+// ExpectedDashboardProposals
+// Source: lib/infra/dto/api/dashboard/proposals.dart
+type ExpectedDashboardProposals struct {
+ Total int `json:"total"`
+ Active int `json:"active"`
+ Enacting int `json:"enacting"`
+ Finished int `json:"finished"`
+ Successful int `json:"successful"`
+ Proposers string `json:"proposers"`
+ Voters string `json:"voters"`
+}
+
+// ExpectedDashboardResponse
+// Source: lib/infra/dto/api/dashboard/dashboard_resp.dart
+type ExpectedDashboardResponse struct {
+ ConsensusHealth string `json:"consensus_health"`
+ CurrentBlockValidator *ExpectedCurrentBlockValidator `json:"current_block_validator"`
+ Validators *ExpectedDashboardValidators `json:"validators"`
+ Blocks *ExpectedDashboardBlocks `json:"blocks"`
+ Proposals *ExpectedDashboardProposals `json:"proposals"`
+}
+
+// ============================================================================
+// Governance Endpoint Types
+// ============================================================================
+
+// ExpectedNetworkProperties - ~47 fields, all strings except 3 bools
+// Source: lib/infra/dto/api_kira/query_network_properties/response/properties.dart
+type ExpectedNetworkProperties struct {
+ MinTxFee string `json:"min_tx_fee"`
+ VoteQuorum string `json:"vote_quorum"`
+ MinValidators string `json:"min_validators"`
+ UnstakingPeriod string `json:"unstaking_period"`
+ MaxDelegators string `json:"max_delegators"`
+ MinDelegationPushout string `json:"min_delegation_pushout"`
+ MaxMischance string `json:"max_mischance"`
+ MischanceConfidence string `json:"mischance_confidence"`
+ MaxJailedPercentage string `json:"max_jailed_percentage"`
+ MaxSlashingPercentage string `json:"max_slashing_percentage"`
+ UnjailMaxTime string `json:"unjail_max_time"`
+ EnableForeignFeePayments bool `json:"enable_foreign_fee_payments"`
+ EnableTokenBlacklist bool `json:"enable_token_blacklist"`
+ EnableTokenWhitelist bool `json:"enable_token_whitelist"`
+ MinProposalEndBlocks string `json:"min_proposal_end_blocks"`
+ MinProposalEnactmentBlocks string `json:"min_proposal_enactment_blocks"`
+ MinimumProposalEndTime string `json:"minimum_proposal_end_time"`
+ ProposalEnactmentTime string `json:"proposal_enactment_time"`
+ InflationRate string `json:"inflation_rate"`
+ InflationPeriod string `json:"inflation_period"`
+ MaxAnnualInflation string `json:"max_annual_inflation"`
+ MaxProposalTitleSize string `json:"max_proposal_title_size"`
+ MaxProposalDescriptionSize string `json:"max_proposal_description_size"`
+ MaxProposalReferenceSize string `json:"max_proposal_reference_size"`
+ MaxProposalChecksumSize string `json:"max_proposal_checksum_size"`
+ MaxProposalPollOptionSize string `json:"max_proposal_poll_option_size"`
+ MaxProposalPollOptionCount string `json:"max_proposal_poll_option_count"`
+ ValidatorsFeeShare string `json:"validators_fee_share"`
+ ValidatorRecoveryBond string `json:"validator_recovery_bond"`
+ MaxAbstention string `json:"max_abstention"`
+ AbstentionRankDecreaseAmount string `json:"abstention_rank_decrease_amount"`
+ MischanceRankDecreaseAmount string `json:"mischance_rank_decrease_amount"`
+ InactiveRankDecreasePercent string `json:"inactive_rank_decrease_percent"`
+ PoorNetworkMaxBankSend string `json:"poor_network_max_bank_send"`
+ MinIdentityApprovalTip string `json:"min_identity_approval_tip"`
+ UniqueIdentityKeys string `json:"unique_identity_keys"`
+ UbiHardcap string `json:"ubi_hardcap"`
+}
+
+// ExpectedNetworkPropertiesResponse
+type ExpectedNetworkPropertiesResponse struct {
+ Properties *ExpectedNetworkProperties `json:"properties"`
+}
+
+// ExpectedExecutionFee
+// Source: lib/infra/dto/api_kira/query_execution_fee/response/fee.dart
+type ExpectedExecutionFee struct {
+ DefaultParameters string `json:"default_parameters"`
+ ExecutionFee string `json:"execution_fee"`
+ FailureFee string `json:"failure_fee"`
+ Timeout string `json:"timeout"`
+ TransactionType string `json:"transaction_type"`
+}
+
+// ExpectedExecutionFeeResponse
+type ExpectedExecutionFeeResponse struct {
+ Fee *ExpectedExecutionFee `json:"fee"`
+}
+
+// ============================================================================
+// Token Endpoint Types
+// ============================================================================
+
+// ExpectedTokenAlias
+// Source: lib/infra/dto/api_kira/query_kira_tokens_aliases/response/token_alias.dart
+type ExpectedTokenAlias struct {
+ Decimals int `json:"decimals"` // INT
+ Denoms []string `json:"denoms"`
+ Name string `json:"name"`
+ Symbol string `json:"symbol"`
+ Icon string `json:"icon"`
+ Amount string `json:"amount"`
+}
+
+// ExpectedTokenAliasesResponse
+// Source: lib/infra/dto/api_kira/query_kira_tokens_aliases/response/
+type ExpectedTokenAliasesResponse struct {
+ TokenAliasesData []ExpectedTokenAlias `json:"token_aliases_data"`
+ DefaultDenom string `json:"default_denom"`
+ Bech32Prefix string `json:"bech32_prefix"`
+}
+
+// ExpectedTokenRate
+// Source: lib/infra/dto/api_kira/query_kira_tokens_rates/response/token_rate.dart
+type ExpectedTokenRate struct {
+ Denom string `json:"denom"`
+ FeePayments bool `json:"fee_payments"` // BOOL
+ FeeRate string `json:"fee_rate"`
+ StakeCap string `json:"stake_cap"`
+ StakeMin string `json:"stake_min"`
+ StakeToken bool `json:"stake_token"` // BOOL
+}
+
+// ExpectedTokenRatesResponse
+type ExpectedTokenRatesResponse struct {
+ Data []ExpectedTokenRate `json:"data"`
+}
+
+// ============================================================================
+// Staking Endpoint Types
+// ============================================================================
+
+// ExpectedStakingPoolResponse
+// Source: lib/infra/dto/api_kira/query_staking_pool/response/
+type ExpectedStakingPoolResponse struct {
+ Id int `json:"id"` // INT
+ TotalDelegators int `json:"total_delegators"` // INT
+ Commission string `json:"commission"`
+ Slashed string `json:"slashed"`
+ Tokens []string `json:"tokens"`
+ VotingPower []ExpectedCoin `json:"voting_power"`
+}
+
+// ExpectedValidatorInfo - for delegations
+type ExpectedValidatorInfo struct {
+ Moniker string `json:"moniker"`
+ Address string `json:"address"`
+ Valkey string `json:"valkey"`
+ Website *string `json:"website,omitempty"`
+ Logo *string `json:"logo,omitempty"`
+}
+
+// ExpectedPoolInfo
+type ExpectedPoolInfo struct {
+ Id int `json:"id"` // INT
+ Commission string `json:"commission"`
+ Status string `json:"status"`
+ Tokens []string `json:"tokens"`
+}
+
+// ExpectedDelegation
+type ExpectedDelegation struct {
+ ValidatorInfo *ExpectedValidatorInfo `json:"validator_info"`
+ PoolInfo *ExpectedPoolInfo `json:"pool_info"`
+}
+
+// ExpectedDelegationsResponse
+type ExpectedDelegationsResponse struct {
+ Delegations []ExpectedDelegation `json:"delegations"`
+ Pagination *ExpectedPagination `json:"pagination"`
+}
+
+// ============================================================================
+// Identity Endpoint Types
+// ============================================================================
+
+// ExpectedIdentityRecord
+// Source: lib/infra/dto/api_kira/query_identity_records/response/
+type ExpectedIdentityRecord struct {
+ Address string `json:"address"`
+ Date string `json:"date"` // ISO 8601
+ Id string `json:"id"`
+ Key string `json:"key"`
+ Value string `json:"value"`
+ Verifiers []string `json:"verifiers"`
+}
+
+// ExpectedIdentityRecordsResponse
+type ExpectedIdentityRecordsResponse struct {
+ Records []ExpectedIdentityRecord `json:"records"`
+}
diff --git a/tests/integration/upgrade_test.go b/tests/integration/upgrade_test.go
new file mode 100644
index 0000000..a142f7b
--- /dev/null
+++ b/tests/integration/upgrade_test.go
@@ -0,0 +1,32 @@
+package integration
+
+import (
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+// TestCurrentUpgradePlan tests GET /api/kira/upgrade/current_plan
+func TestCurrentUpgradePlan(t *testing.T) {
+ cfg := GetConfig()
+ client := NewClient(cfg)
+
+ t.Run("query current upgrade plan", func(t *testing.T) {
+ resp, err := client.Get("/api/kira/upgrade/current_plan", nil)
+ require.NoError(t, err)
+ assert.True(t, resp.IsSuccess(), "Expected success, got status %d: %s", resp.StatusCode, string(resp.Body))
+ })
+}
+
+// TestNextUpgradePlan tests GET /api/kira/upgrade/next_plan
+func TestNextUpgradePlan(t *testing.T) {
+ cfg := GetConfig()
+ client := NewClient(cfg)
+
+ t.Run("query next upgrade plan", func(t *testing.T) {
+ resp, err := client.Get("/api/kira/upgrade/next_plan", nil)
+ require.NoError(t, err)
+ assert.True(t, resp.IsSuccess(), "Expected success, got status %d: %s", resp.StatusCode, string(resp.Body))
+ })
+}
diff --git a/tests/integration/validators_test.go b/tests/integration/validators_test.go
new file mode 100644
index 0000000..f24ba50
--- /dev/null
+++ b/tests/integration/validators_test.go
@@ -0,0 +1,301 @@
+package integration
+
+import (
+ "encoding/json"
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+// ============================================================================
+// Validator Endpoint Tests - /api/valopers
+// Expected format from miro: lib/infra/dto/api/query_validators/response/
+// ============================================================================
+
+// TestValidatorsResponseFormat validates the response matches expected miro format
+// Issues: #13 (snake_case), #16 (types)
+func TestValidatorsResponseFormat(t *testing.T) {
+ cfg := GetConfig()
+ client := NewClient(cfg)
+
+ resp, err := client.Get("/api/valopers", map[string]string{
+ "all": "true",
+ })
+ require.NoError(t, err)
+ require.True(t, resp.IsSuccess(), "Expected success, got %d: %s", resp.StatusCode, string(resp.Body))
+
+ v, err := NewFormatValidator(t, resp.Body)
+ require.NoError(t, err, "Failed to parse response")
+
+ t.Run("validators array exists", func(t *testing.T) {
+ v.ValidateFieldExists("validators", "Format")
+ v.ValidateFieldType("validators", TypeArray, "Format")
+ })
+
+ t.Run("waiting array exists", func(t *testing.T) {
+ if !v.HasField("waiting") {
+ t.Log("Note: 'waiting' field not present in response")
+ } else {
+ v.ValidateFieldType("waiting", TypeArray, "Format")
+ }
+ })
+
+ t.Run("status object exists with int counts", func(t *testing.T) {
+ if !v.HasField("status") {
+ t.Log("Note: 'status' field not present (may require status_only=false)")
+ return
+ }
+
+ nested, ok := v.GetNestedValidator("status")
+ if !ok {
+ return
+ }
+
+ // miro expects these as integers
+ intFields := []string{
+ "active_validators",
+ "paused_validators",
+ "inactive_validators",
+ "jailed_validators",
+ "total_validators",
+ "waiting_validators",
+ }
+
+ for _, field := range intFields {
+ if nested.HasField(field) {
+ nested.ValidateFieldType(field, TypeInt, "Issue #16")
+ }
+ }
+
+ // Check snake_case naming
+ nested.ValidateSnakeCase("Issue #13")
+ })
+
+ // Check validator structure if we have validators
+ if v.GetArrayLength("validators") > 0 {
+ t.Run("validator fields use snake_case (Issue #13)", func(t *testing.T) {
+ raw := v.GetRaw()
+ validators := raw["validators"].([]interface{})
+ validator := validators[0].(map[string]interface{})
+
+ expectedSnakeCaseFields := []string{
+ "mischance_confidence",
+ "start_height",
+ "inactive_until",
+ "last_present_block",
+ "missed_blocks_counter",
+ "produced_blocks_counter",
+ "staking_pool_id",
+ "staking_pool_status",
+ "validator_node_id",
+ "sentry_node_id",
+ }
+
+ // Check for camelCase versions (would be a bug)
+ camelCaseVersions := map[string]string{
+ "mischanceConfidence": "mischance_confidence",
+ "startHeight": "start_height",
+ "inactiveUntil": "inactive_until",
+ "lastPresentBlock": "last_present_block",
+ "missedBlocksCounter": "missed_blocks_counter",
+ "producedBlocksCounter": "produced_blocks_counter",
+ "stakingPoolId": "staking_pool_id",
+ "stakingPoolStatus": "staking_pool_status",
+ "validatorNodeId": "validator_node_id",
+ "sentryNodeId": "sentry_node_id",
+ }
+
+ for camelCase, snakeCase := range camelCaseVersions {
+ if _, exists := validator[camelCase]; exists {
+ if _, snakeExists := validator[snakeCase]; !snakeExists {
+ t.Errorf("Issue #13: Using '%s' instead of '%s'", camelCase, snakeCase)
+ }
+ }
+ }
+
+ // Verify snake_case fields exist
+ for _, field := range expectedSnakeCaseFields {
+ if _, exists := validator[field]; !exists {
+ // Not all fields are always present
+ t.Logf("Note: Field '%s' not present in validator", field)
+ }
+ }
+ })
+
+ t.Run("validator fields are strings", func(t *testing.T) {
+ raw := v.GetRaw()
+ validators := raw["validators"].([]interface{})
+ validator := validators[0].(map[string]interface{})
+
+ // These should all be strings according to miro
+ stringFields := []string{"top", "address", "valkey", "pubkey", "proposer", "moniker", "status", "rank", "streak", "mischance"}
+
+ for _, field := range stringFields {
+ if val, exists := validator[field]; exists {
+ if _, ok := val.(string); !ok {
+ t.Errorf("Issue #16: Field '%s' should be string, got %T", field, val)
+ }
+ }
+ }
+ })
+ }
+}
+
+// TestQueryValidators tests GET /api/valopers
+func TestQueryValidators(t *testing.T) {
+ cfg := GetConfig()
+ client := NewClient(cfg)
+
+ t.Run("query validators with status only", func(t *testing.T) {
+ resp, err := client.Get("/api/valopers", map[string]string{
+ "status_only": "true",
+ })
+ require.NoError(t, err)
+ assert.True(t, resp.IsSuccess(), "Expected success, got status %d: %s", resp.StatusCode, string(resp.Body))
+
+ var result map[string]interface{}
+ json.Unmarshal(resp.Body, &result)
+
+ // With status_only=true, should have status object
+ if _, exists := result["status"]; !exists {
+ t.Log("Note: status_only=true did not return status object")
+ }
+ })
+
+ t.Run("query all validators", func(t *testing.T) {
+ resp, err := client.Get("/api/valopers", map[string]string{
+ "all": "true",
+ })
+ require.NoError(t, err)
+ assert.True(t, resp.IsSuccess(), "Expected success, got status %d", resp.StatusCode)
+
+ var result map[string]interface{}
+ json.Unmarshal(resp.Body, &result)
+
+ validators, ok := result["validators"].([]interface{})
+ if ok {
+ t.Logf("Found %d validators", len(validators))
+ }
+ })
+
+ t.Run("query validator by address", func(t *testing.T) {
+ resp, err := client.Get("/api/valopers", map[string]string{
+ "address": cfg.ValidatorAddr,
+ })
+ require.NoError(t, err)
+ assert.True(t, resp.IsSuccess(), "Expected success, got status %d", resp.StatusCode)
+
+ var result map[string]interface{}
+ json.Unmarshal(resp.Body, &result)
+
+ validators, ok := result["validators"].([]interface{})
+ if ok && len(validators) > 0 {
+ // Should filter to the specific address
+ validator := validators[0].(map[string]interface{})
+ if addr, exists := validator["address"]; exists {
+ if addr != cfg.ValidatorAddr {
+ t.Errorf("Address filter not working: expected %s, got %s", cfg.ValidatorAddr, addr)
+ }
+ }
+ }
+ })
+
+ t.Run("query validators with count total", func(t *testing.T) {
+ resp, err := client.Get("/api/valopers", map[string]string{
+ "count_total": "true",
+ })
+ require.NoError(t, err)
+ assert.True(t, resp.IsSuccess(), "Expected success, got status %d", resp.StatusCode)
+ })
+
+ t.Run("query validators by status filter", func(t *testing.T) {
+ resp, err := client.Get("/api/valopers", map[string]string{
+ "status": "ACTIVE",
+ })
+ require.NoError(t, err)
+ assert.True(t, resp.IsSuccess(), "Expected success, got status %d", resp.StatusCode)
+
+ var result map[string]interface{}
+ json.Unmarshal(resp.Body, &result)
+
+ validators, ok := result["validators"].([]interface{})
+ if ok && len(validators) > 0 {
+ // All validators should have ACTIVE status
+ for i, v := range validators {
+ validator := v.(map[string]interface{})
+ if status, exists := validator["status"]; exists {
+ if status != "ACTIVE" {
+ t.Errorf("Status filter not working: validator %d has status '%v', expected 'ACTIVE'", i, status)
+ break
+ }
+ }
+ }
+ }
+ })
+
+ t.Run("pagination works", func(t *testing.T) {
+ resp1, err := client.Get("/api/valopers", map[string]string{"limit": "5"})
+ require.NoError(t, err)
+ assert.True(t, resp1.IsSuccess())
+
+ resp2, err := client.Get("/api/valopers", map[string]string{"limit": "5", "offset": "5"})
+ require.NoError(t, err)
+ assert.True(t, resp2.IsSuccess())
+
+ var result1, result2 map[string]interface{}
+ json.Unmarshal(resp1.Body, &result1)
+ json.Unmarshal(resp2.Body, &result2)
+
+ v1, _ := result1["validators"].([]interface{})
+ v2, _ := result2["validators"].([]interface{})
+
+ if len(v1) > 0 && len(v2) > 0 {
+ val1 := v1[0].(map[string]interface{})
+ val2 := v2[0].(map[string]interface{})
+ if val1["address"] == val2["address"] {
+ t.Error("Offset not working - same validators returned")
+ }
+ }
+ })
+}
+
+// TestQueryValidatorInfos tests GET /api/valoperinfos
+func TestQueryValidatorInfos(t *testing.T) {
+ cfg := GetConfig()
+ client := NewClient(cfg)
+
+ t.Run("query validator infos", func(t *testing.T) {
+ resp, err := client.Get("/api/valoperinfos", nil)
+ require.NoError(t, err)
+ assert.True(t, resp.IsSuccess(), "Expected success, got status %d: %s", resp.StatusCode, string(resp.Body))
+ })
+}
+
+// TestQueryConsensus tests GET /api/consensus
+func TestQueryConsensus(t *testing.T) {
+ cfg := GetConfig()
+ client := NewClient(cfg)
+
+ t.Run("query consensus", func(t *testing.T) {
+ resp, err := client.Get("/api/consensus", nil)
+ require.NoError(t, err)
+ assert.True(t, resp.IsSuccess(), "Expected success, got status %d: %s", resp.StatusCode, string(resp.Body))
+
+ var result map[string]interface{}
+ json.Unmarshal(resp.Body, &result)
+ t.Logf("Consensus response keys: %v", getMapKeys(result))
+ })
+}
+
+// TestDumpConsensusState tests GET /api/dump_consensus_state
+func TestDumpConsensusState(t *testing.T) {
+ cfg := GetConfig()
+ client := NewClient(cfg)
+
+ t.Run("dump consensus state", func(t *testing.T) {
+ resp, err := client.Get("/api/dump_consensus_state", nil)
+ require.NoError(t, err)
+ assert.True(t, resp.IsSuccess(), "Expected success, got status %d: %s", resp.StatusCode, string(resp.Body))
+ })
+}