From aae9635987e873e7c84a39474c7a7ae1e0970379 Mon Sep 17 00:00:00 2001 From: b-l-u-e Date: Thu, 2 Apr 2026 09:51:03 +0300 Subject: [PATCH] fix(ci): isolate regtest postgres DB and harden melt error assertion --- .github/actions/regtest-run/action.yml | 168 +++++++++++++++++++++++++ .github/workflows/regtest-mint.yml | 92 +++++++------- .github/workflows/regtest-wallet.yml | 92 +++++++------- tests/conftest.py | 48 ++++++- tests/mint/test_mint_melt.py | 31 +++-- 5 files changed, 328 insertions(+), 103 deletions(-) create mode 100644 .github/actions/regtest-run/action.yml diff --git a/.github/actions/regtest-run/action.yml b/.github/actions/regtest-run/action.yml new file mode 100644 index 000000000..ffb7d4f2f --- /dev/null +++ b/.github/actions/regtest-run/action.yml @@ -0,0 +1,168 @@ +name: Regtest Run +description: Shared regtest setup, isolated DB lifecycle, and test execution + +inputs: + mint-database: + description: Mint test database DSN/path + required: true + backend-wallet-class: + description: Backend wallet class for bolt11 sat + required: true + test-target: + description: Make target to run (e.g. test-mint, test-wallet) + required: true + use-github-postgres-service: + description: > + If true, skip docker run for Postgres and use the job's services: postgres + (localhost:5432). Caller must define a matching postgres service on the job. + required: false + default: "false" + postgres-image: + description: > + Postgres image ref (prefer tag@sha256:... for immutable CI). Must match services.postgres.image when + use-github-postgres-service is true; used for docker-run fallback and log capture. + required: false + default: "postgres:16" + +runs: + using: composite + steps: + - name: Start PostgreSQL service + if: ${{ contains(inputs.mint-database, 'postgres') && inputs.use-github-postgres-service != 'true' }} + shell: bash + env: + POSTGRES_IMAGE: ${{ inputs.postgres-image }} + run: | + docker run -d --name postgres \ + -e POSTGRES_USER=cashu -e POSTGRES_PASSWORD=cashu -e POSTGRES_DB=cashu \ + -p 5432:5432 \ + "$POSTGRES_IMAGE" + until docker exec postgres pg_isready -U cashu -d postgres; do sleep 1; done + + - name: Install PostgreSQL client (GitHub service container) + if: ${{ contains(inputs.mint-database, 'postgres') && inputs.use-github-postgres-service == 'true' }} + shell: bash + run: sudo apt-get update -qq && sudo apt-get install -y --no-install-recommends postgresql-client + + - name: Setup Regtest + shell: bash + run: | + git clone https://github.com/callebtc/cashu-regtest-enviroment.git regtest + cd regtest + chmod -R 777 . + bash ./start.sh + + - name: Prepare isolated mint test database + shell: bash + env: + INPUT_DB: ${{ inputs.mint-database }} + BACKEND_WALLET_CLASS: ${{ inputs.backend-wallet-class }} + RUN_ID: ${{ github.run_id }} + RUN_ATTEMPT: ${{ github.run_attempt }} + USE_GHA_PG: ${{ inputs.use-github-postgres-service }} + PGADMINURL: postgresql://cashu:cashu@127.0.0.1:5432/postgres + run: | + if [[ "$INPUT_DB" == postgres* ]]; then + TEST_DB_URL="$(python -c "from urllib.parse import urlparse, urlunparse; import os, re; input_db=os.environ['INPUT_DB']; backend=os.environ['BACKEND_WALLET_CLASS']; run_id=os.environ['RUN_ID']; run_attempt=os.environ['RUN_ATTEMPT']; parsed=urlparse(input_db); base_name=parsed.path.lstrip('/') or 'cashu'; safe_backend=re.sub(r'[^a-zA-Z0-9_]+','_', backend).lower(); db_name=f'{base_name}_{safe_backend}_{run_id}_{run_attempt}'; print(urlunparse(parsed._replace(path='/' + db_name)))")" + DB_NAME="${TEST_DB_URL##*/}" + echo "MINT_TEST_DATABASE=$TEST_DB_URL" >> "$GITHUB_ENV" + for i in 1 2 3; do + if [[ "$USE_GHA_PG" == "true" ]]; then + if psql "$PGADMINURL" -v ON_ERROR_STOP=1 -c "CREATE DATABASE \"$DB_NAME\";"; then + break + fi + else + if docker exec postgres psql -U cashu -d postgres -c "CREATE DATABASE \"$DB_NAME\";"; then + break + fi + fi + if [[ "$i" == "3" ]]; then + echo "Failed to create isolated database: $DB_NAME" + exit 1 + fi + sleep 1 + done + else + echo "MINT_TEST_DATABASE=$INPUT_DB" >> "$GITHUB_ENV" + fi + + - name: Run Tests + shell: bash + env: + WALLET_NAME: test_wallet + MINT_HOST: localhost + MINT_PORT: 3337 + MINT_TEST_DATABASE: ${{ env.MINT_TEST_DATABASE }} + TOR: false + MINT_BACKEND_BOLT11_SAT: ${{ inputs.backend-wallet-class }} + # LNbits wallet + MINT_LNBITS_ENDPOINT: http://localhost:5001 + MINT_LNBITS_KEY: d08a3313322a4514af75d488bcc27eee + # LndRestWallet + MINT_LND_REST_ENDPOINT: https://localhost:8081/ + MINT_LND_REST_CERT: ./regtest/data/lnd-3/tls.cert + MINT_LND_REST_MACAROON: ./regtest/data/lnd-3/data/chain/bitcoin/regtest/admin.macaroon + # LndRPCWallet + MINT_LND_RPC_ENDPOINT: localhost:10009 + MINT_LND_RPC_CERT: ./regtest/data/lnd-3/tls.cert + MINT_LND_RPC_MACAROON: ./regtest/data/lnd-3/data/chain/bitcoin/regtest/admin.macaroon + # CoreLightningRestWallet + MINT_CORELIGHTNING_REST_URL: https://localhost:3001 + MINT_CORELIGHTNING_REST_MACAROON: ./regtest/data/clightning-2-rest/access.macaroon + MINT_CORELIGHTNING_REST_CERT: ./regtest/data/clightning-2-rest/certificate.pem + # CLNRestWallet + MINT_CLNREST_URL: https://localhost:3010 + MINT_CLNREST_RUNE: ./regtest/data/clightning-2/rune + MINT_CLNREST_CERT: ./regtest/data/clightning-2/regtest/ca.pem + run: | + sudo chmod -R 777 . + make ${{ inputs.test-target }} + + - name: Cleanup isolated mint test database + if: ${{ always() && contains(inputs.mint-database, 'postgres') }} + shell: bash + env: + TEST_DB_URL: ${{ env.MINT_TEST_DATABASE }} + USE_GHA_PG: ${{ inputs.use-github-postgres-service }} + PGADMINURL: postgresql://cashu:cashu@127.0.0.1:5432/postgres + run: | + DB_NAME="${TEST_DB_URL##*/}" + for i in 1 2 3; do + if [[ "$USE_GHA_PG" == "true" ]]; then + psql "$PGADMINURL" -c "SELECT pg_terminate_backend(pid) FROM pg_stat_activity WHERE datname = '$DB_NAME' AND pid <> pg_backend_pid();" || true + if psql "$PGADMINURL" -v ON_ERROR_STOP=1 -c "DROP DATABASE IF EXISTS \"$DB_NAME\";"; then + break + fi + else + docker exec postgres psql -U cashu -d postgres -c "SELECT pg_terminate_backend(pid) FROM pg_stat_activity WHERE datname = '$DB_NAME' AND pid <> pg_backend_pid();" || true + if docker exec postgres psql -U cashu -d postgres -c "DROP DATABASE IF EXISTS \"$DB_NAME\";"; then + break + fi + fi + if [[ "$i" == "3" ]]; then + echo "Warning: failed to drop isolated database: $DB_NAME" + fi + sleep 1 + done + + - name: Dump PostgreSQL logs on failure + if: ${{ failure() && contains(inputs.mint-database, 'postgres') }} + shell: bash + env: + USE_GHA_PG: ${{ inputs.use-github-postgres-service }} + POSTGRES_IMAGE: ${{ inputs.postgres-image }} + run: | + set +e + if [[ "$USE_GHA_PG" == "true" ]]; then + cid="$(docker ps -q --filter ancestor="$POSTGRES_IMAGE" | head -n1)" + if [[ -z "$cid" ]]; then + cid="$(docker ps -q --filter publish=5432 | head -n1)" + fi + if [[ -n "$cid" ]]; then + docker logs "$cid" --tail 200 + else + echo "No Postgres container found for log dump (image=$POSTGRES_IMAGE)" + fi + else + docker logs postgres --tail 200 + fi diff --git a/.github/workflows/regtest-mint.yml b/.github/workflows/regtest-mint.yml index 73fac68e7..098d2aea9 100644 --- a/.github/workflows/regtest-mint.yml +++ b/.github/workflows/regtest-mint.yml @@ -25,14 +25,22 @@ permissions: jobs: regtest-mint: runs-on: ${{ inputs.os-version }} - timeout-minutes: 10 + timeout-minutes: 120 + services: + postgres: + image: postgres:16@sha256:2586e2a95d1c9b31cb2967feb562948f7d364854453d703039b6efa45fe48417 + env: + POSTGRES_USER: cashu + POSTGRES_PASSWORD: cashu + POSTGRES_DB: cashu + ports: + - 5432:5432 + options: >- + --health-cmd "pg_isready -U cashu -d postgres" + --health-interval 2s + --health-timeout 5s + --health-retries 30 steps: - - name: Start PostgreSQL service - if: contains(inputs.mint-database, 'postgres') - run: | - docker run -d --name postgres -e POSTGRES_USER=cashu -e POSTGRES_PASSWORD=cashu -e POSTGRES_DB=cashu -p 5432:5432 postgres:latest - until docker exec postgres pg_isready; do sleep 1; done - - uses: actions/checkout@v6 - uses: ./.github/actions/prepare @@ -40,48 +48,46 @@ jobs: python-version: ${{ inputs.python-version }} poetry-version: ${{ inputs.poetry-version }} - - name: Setup Regtest - run: | - git clone https://github.com/callebtc/cashu-regtest-enviroment.git regtest - cd regtest - chmod -R 777 . - bash ./start.sh + - id: test-run + uses: ./.github/actions/regtest-run + with: + mint-database: ${{ inputs.mint-database }} + backend-wallet-class: ${{ inputs.backend-wallet-class }} + test-target: test-mint + use-github-postgres-service: "true" + postgres-image: postgres:16@sha256:2586e2a95d1c9b31cb2967feb562948f7d364854453d703039b6efa45fe48417 - - name: Run Tests - env: - WALLET_NAME: test_wallet - MINT_HOST: localhost - MINT_PORT: 3337 - MINT_TEST_DATABASE: ${{ inputs.mint-database }} - TOR: false - MINT_BACKEND_BOLT11_SAT: ${{ inputs.backend-wallet-class }} - # LNbits wallet - MINT_LNBITS_ENDPOINT: http://localhost:5001 - MINT_LNBITS_KEY: d08a3313322a4514af75d488bcc27eee - # LndRestWallet - MINT_LND_REST_ENDPOINT: https://localhost:8081/ - MINT_LND_REST_CERT: ./regtest/data/lnd-3/tls.cert - MINT_LND_REST_MACAROON: ./regtest/data/lnd-3/data/chain/bitcoin/regtest/admin.macaroon - # LndRPCWallet - MINT_LND_RPC_ENDPOINT: localhost:10009 - MINT_LND_RPC_CERT: ./regtest/data/lnd-3/tls.cert - MINT_LND_RPC_MACAROON: ./regtest/data/lnd-3/data/chain/bitcoin/regtest/admin.macaroon - # CoreLightningRestWallet - MINT_CORELIGHTNING_REST_URL: https://localhost:3001 - MINT_CORELIGHTNING_REST_MACAROON: ./regtest/data/clightning-2-rest/access.macaroon - MINT_CORELIGHTNING_REST_CERT: ./regtest/data/clightning-2-rest/certificate.pem - # CLNRestWallet - MINT_CLNREST_URL: https://localhost:3010 - MINT_CLNREST_RUNE: ./regtest/data/clightning-2/rune - MINT_CLNREST_CERT: ./regtest/data/clightning-2/regtest/ca.pem - run: | - sudo chmod -R 777 . - make test-mint + - name: Upload test logs on failure + if: failure() + uses: actions/upload-artifact@v4 + with: + name: test-logs-mint-${{ inputs.backend-wallet-class }}-${{ github.run_id }} + path: | + .pytest_cache/ + junit.xml + retention-days: 7 + if-no-files-found: ignore - name: Upload coverage to Codecov + if: always() uses: codecov/codecov-action@v5 + with: + flags: mint-${{ inputs.backend-wallet-class }} + fail_ci_if_error: false + - name: Upload test results to Codecov if: ${{ !cancelled() }} uses: codecov/test-results-action@v1 with: token: ${{ secrets.CODECOV_TOKEN }} + + - name: Generate test summary + if: always() + shell: bash + run: | + echo "## Mint Test Results" >> "$GITHUB_STEP_SUMMARY" + echo "" >> "$GITHUB_STEP_SUMMARY" + echo "- **Backend**: ${{ inputs.backend-wallet-class }}" >> "$GITHUB_STEP_SUMMARY" + echo "- **Database**: ${{ env.MINT_TEST_DATABASE }}" >> "$GITHUB_STEP_SUMMARY" + echo "- **Python**: ${{ inputs.python-version }}" >> "$GITHUB_STEP_SUMMARY" + echo "- **Status**: ${{ steps.test-run.outcome }}" >> "$GITHUB_STEP_SUMMARY" diff --git a/.github/workflows/regtest-wallet.yml b/.github/workflows/regtest-wallet.yml index 284939600..28c32e0ec 100644 --- a/.github/workflows/regtest-wallet.yml +++ b/.github/workflows/regtest-wallet.yml @@ -25,14 +25,22 @@ permissions: jobs: regtest-wallet: runs-on: ${{ inputs.os-version }} - timeout-minutes: 10 + timeout-minutes: 120 + services: + postgres: + image: postgres:16@sha256:2586e2a95d1c9b31cb2967feb562948f7d364854453d703039b6efa45fe48417 + env: + POSTGRES_USER: cashu + POSTGRES_PASSWORD: cashu + POSTGRES_DB: cashu + ports: + - 5432:5432 + options: >- + --health-cmd "pg_isready -U cashu -d postgres" + --health-interval 2s + --health-timeout 5s + --health-retries 30 steps: - - name: Start PostgreSQL service - if: contains(inputs.mint-database, 'postgres') - run: | - docker run -d --name postgres -e POSTGRES_USER=cashu -e POSTGRES_PASSWORD=cashu -e POSTGRES_DB=cashu -p 5432:5432 postgres:latest - until docker exec postgres pg_isready; do sleep 1; done - - uses: actions/checkout@v6 - uses: ./.github/actions/prepare @@ -40,48 +48,46 @@ jobs: python-version: ${{ inputs.python-version }} poetry-version: ${{ inputs.poetry-version }} - - name: Setup Regtest - run: | - git clone https://github.com/callebtc/cashu-regtest-enviroment.git regtest - cd regtest - chmod -R 777 . - bash ./start.sh + - id: test-run + uses: ./.github/actions/regtest-run + with: + mint-database: ${{ inputs.mint-database }} + backend-wallet-class: ${{ inputs.backend-wallet-class }} + test-target: test-wallet + use-github-postgres-service: "true" + postgres-image: postgres:16@sha256:2586e2a95d1c9b31cb2967feb562948f7d364854453d703039b6efa45fe48417 - - name: Run Tests - env: - WALLET_NAME: test_wallet - MINT_HOST: localhost - MINT_PORT: 3337 - MINT_TEST_DATABASE: ${{ inputs.mint-database }} - TOR: false - MINT_BACKEND_BOLT11_SAT: ${{ inputs.backend-wallet-class }} - # LNbits wallet - MINT_LNBITS_ENDPOINT: http://localhost:5001 - MINT_LNBITS_KEY: d08a3313322a4514af75d488bcc27eee - # LndRestWallet - MINT_LND_REST_ENDPOINT: https://localhost:8081/ - MINT_LND_REST_CERT: ./regtest/data/lnd-3/tls.cert - MINT_LND_REST_MACAROON: ./regtest/data/lnd-3/data/chain/bitcoin/regtest/admin.macaroon - # LndRPCWallet - MINT_LND_RPC_ENDPOINT: localhost:10009 - MINT_LND_RPC_CERT: ./regtest/data/lnd-3/tls.cert - MINT_LND_RPC_MACAROON: ./regtest/data/lnd-3/data/chain/bitcoin/regtest/admin.macaroon - # CoreLightningRestWallet - MINT_CORELIGHTNING_REST_URL: https://localhost:3001 - MINT_CORELIGHTNING_REST_MACAROON: ./regtest/data/clightning-2-rest/access.macaroon - MINT_CORELIGHTNING_REST_CERT: ./regtest/data/clightning-2-rest/certificate.pem - # CLNRestWallet - MINT_CLNREST_URL: https://localhost:3010 - MINT_CLNREST_RUNE: ./regtest/data/clightning-2/rune - MINT_CLNREST_CERT: ./regtest/data/clightning-2/regtest/ca.pem - run: | - sudo chmod -R 777 . - make test-wallet + - name: Upload test logs on failure + if: failure() + uses: actions/upload-artifact@v4 + with: + name: test-logs-wallet-${{ inputs.backend-wallet-class }}-${{ github.run_id }} + path: | + .pytest_cache/ + junit.xml + retention-days: 7 + if-no-files-found: ignore - name: Upload coverage to Codecov + if: always() uses: codecov/codecov-action@v5 + with: + flags: wallet-${{ inputs.backend-wallet-class }} + fail_ci_if_error: false + - name: Upload test results to Codecov if: ${{ !cancelled() }} uses: codecov/test-results-action@v1 with: token: ${{ secrets.CODECOV_TOKEN }} + + - name: Generate test summary + if: always() + shell: bash + run: | + echo "## Wallet Test Results" >> "$GITHUB_STEP_SUMMARY" + echo "" >> "$GITHUB_STEP_SUMMARY" + echo "- **Backend**: ${{ inputs.backend-wallet-class }}" >> "$GITHUB_STEP_SUMMARY" + echo "- **Database**: ${{ env.MINT_TEST_DATABASE }}" >> "$GITHUB_STEP_SUMMARY" + echo "- **Python**: ${{ inputs.python-version }}" >> "$GITHUB_STEP_SUMMARY" + echo "- **Status**: ${{ steps.test-run.outcome }}" >> "$GITHUB_STEP_SUMMARY" diff --git a/tests/conftest.py b/tests/conftest.py index 0873740c3..19b194e62 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,3 +1,4 @@ +import asyncio import importlib import multiprocessing import os @@ -79,6 +80,47 @@ def run(self, *args, **kwargs): self.server.run() +async def clear_postgres_database(db: Database, max_retries: int = 5) -> None: + """Clear all public tables while keeping schema objects intact. + + The ``dbversions`` table is skipped so migration bookkeeping matches the + existing schema after TRUNCATE (otherwise migrations would re-run and fail + on ADD COLUMN / duplicate objects). + """ + for attempt in range(max_retries): + try: + async with db.connect() as conn: + await conn.execute( + """ + DO $$ + DECLARE + truncate_stmt TEXT; + BEGIN + SELECT + 'TRUNCATE TABLE ' || + string_agg( + quote_ident(schemaname) || '.' || quote_ident(tablename), + ', ' + ) || + ' RESTART IDENTITY CASCADE' + INTO truncate_stmt + FROM pg_tables + WHERE schemaname = 'public' + AND tablename <> 'dbversions'; + + IF truncate_stmt IS NOT NULL THEN + EXECUTE truncate_stmt; + END IF; + END $$; + """ + ) + return + except Exception: + if attempt == max_retries - 1: + raise + await asyncio.sleep(0.5 * (attempt + 1)) + + # This fixture is used for all other tests @pytest_asyncio.fixture(scope="function") async def ledger(): @@ -95,10 +137,7 @@ async def start_mint_init(ledger: Ledger) -> Ledger: else: # clear postgres database db = Database("mint", settings.mint_database) - async with db.connect() as conn: - # drop all tables - await conn.execute("DROP SCHEMA public CASCADE;") - await conn.execute("CREATE SCHEMA public;") + await clear_postgres_database(db) await db.engine.dispose() wallets_module = importlib.import_module("cashu.lightning") @@ -124,7 +163,6 @@ async def start_mint_init(ledger: Ledger) -> Ledger: ) ledger = await start_mint_init(ledger) yield ledger - print("teardown") await ledger.shutdown_ledger() diff --git a/tests/mint/test_mint_melt.py b/tests/mint/test_mint_melt.py index 33e475d55..528077970 100644 --- a/tests/mint/test_mint_melt.py +++ b/tests/mint/test_mint_melt.py @@ -29,14 +29,20 @@ ENCRYPTED_SEED = "U2FsdGVkX1_7UU_-nVBMBWDy_9yDu4KeYb7MH8cJTYQGD4RWl82PALH8j-HKzTrI" -async def assert_err(f, msg): +async def assert_err(f, msg, contains: bool = False): """Compute f() and expect an error message 'msg'.""" try: await f except Exception as exc: - assert exc.args[0] == msg, Exception( - f"Expected error: {msg}, got: {exc.args[0]}" - ) + actual = str(exc.args[0]) if exc.args else str(exc) + if contains: + assert msg in actual, Exception( + f"Expected error containing {msg!r}, got: {actual!r}" + ) + else: + assert actual == msg, Exception( + f"Expected error: {msg!r}, got: {actual!r}" + ) def assert_amt(proofs: List[Proof], expected: int): @@ -766,7 +772,7 @@ async def test_mint_pay_with_duplicate_checking_id(wallet): ) assert response1.state == "PAID" - assert_err( + await assert_err( wallet.melt( proofs=proofs2, invoice=invoice, @@ -774,6 +780,7 @@ async def test_mint_pay_with_duplicate_checking_id(wallet): quote_id=melt_quote2.quote, ), "Melt quote already paid or pending.", + contains=True, ) @pytest.mark.asyncio @@ -832,28 +839,28 @@ async def test_melt_with_wrong_unit_proofs(ledger: Ledger, wallet: Wallet): unit="usd", ) await wallet_usd.load_mint() - + mint_quote_usd = await wallet_usd.request_mint(100) await pay_if_regtest(mint_quote_usd.request) usd_proofs = await wallet_usd.mint(100, quote_id=mint_quote_usd.quote) assert wallet_usd.unit.name == "usd" - + sat_mint_quote = await ledger.mint_quote( quote_request=PostMintQuoteRequest(amount=100, unit="sat") ) sat_invoice = sat_mint_quote.request - + sat_melt_quote = await ledger.melt_quote( PostMeltQuoteRequest(unit="sat", request=sat_invoice) ) - + assert sat_melt_quote.amount == 100 assert sat_melt_quote.unit == "sat" - + await assert_err( ledger.melt( - proofs=usd_proofs, - quote=sat_melt_quote.quote, + proofs=usd_proofs, + quote=sat_melt_quote.quote, outputs=[] ), "proof unit usd does not match quote unit sat"