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