diff --git a/.config/nextest.toml b/.config/nextest.toml new file mode 100644 index 00000000000..4481ebc6ba3 --- /dev/null +++ b/.config/nextest.toml @@ -0,0 +1,41 @@ +nextest-version = "0.9.136" +# The PostgreSQL lane uses a run-scoped desired-state template database and a +# per-test wrapper, both of which require nextest's script support. +experimental = ["setup-scripts", "wrapper-scripts"] + +[scripts.setup.postgres-template] +# Bootstrap the desired-state source database once per nextest invocation. +command = { command-line = "scripts/postgres-test-setup.sh", relative-to = "workspace-root" } +slow-timeout = "60s" + +[scripts.wrapper.postgres-isolation] +# Clone or create a unique database for each test process, then drop it on exit. +command = { command-line = "scripts/postgres-test-wrapper.sh", relative-to = "workspace-root" } + +[profile.postgres-ci] +# This structural convention keeps new PostgreSQL-backed tests discoverable +# without maintaining an exact list of test names. +default-filter = """ +(test(/postgres_tests::/) or binary(/^postgres_/)) +and not test(/(^|::)external_infra[^:]*::/) +""" +fail-fast = false +# Eight workers was the fastest stable setting in the Blox benchmark while the +# wrapper retained one database per concurrently running test process. +test-threads = 8 + +[test-groups.postgres-cluster-global] +# These tests inspect cluster-wide activity or create least-privilege sessions, +# so database-per-test isolation alone cannot make them independent. +max-threads = 1 + +[[profile.postgres-ci.overrides]] +filter = "test(/cluster_global_/)" +test-group = "postgres-cluster-global" + +[[profile.postgres-ci.scripts]] +# Script filters are separate from default-filter: they attach the setup and +# isolation wrapper to the same automatically discovered test set. +filter = "(test(/postgres_tests::/) or binary(/^postgres_/)) and not test(/(^|::)external_infra[^:]*::/)" +setup = "postgres-template" +run-wrapper = "postgres-isolation" diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 25a59c32432..e5f7e177619 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -44,6 +44,14 @@ jobs: - 'Cargo.toml' - 'Cargo.lock' - 'rust-toolchain.toml' + - '.config/nextest.toml' + - 'scripts/postgres-test-*.sh' + - 'scripts/reconcile-schema-after-pgschema.sql' + - 'bin/pgschema' + - 'bin/.pgschema-*.pkg' + - 'scripts/check-postgres-test-discovery.py' + - 'scripts/test-postgres-test-discovery.sh' + - 'scripts/test-postgres-test-wrapper.sh' - 'deny.toml' - '.github/workflows/ci.yml' - 'scripts/run-tests.sh' @@ -73,6 +81,11 @@ jobs: - 'scripts/test-mobile-worktree-overrides.sh' - '.github/workflows/mobile-release-candidate.yml' - '.github/workflows/ci.yml' + - name: Validate PostgreSQL test discovery + if: github.event_name == 'push' || steps.filter.outputs.rust == 'true' + run: | + scripts/test-postgres-test-discovery.sh + scripts/test-postgres-test-wrapper.sh - name: Release workflow source contract run: scripts/test-release-ref-contract.sh - name: Relay image eligibility contract @@ -346,7 +359,8 @@ jobs: target/ci/buzz-relay target/ci/git-credential-nostr target/ci/backend-integration-tests.tar.zst - key: relay-artifacts-${{ runner.os }}-${{ hashFiles('crates/**', 'migrations/**', 'Dockerfile', 'Cargo.toml', 'Cargo.lock', 'rust-toolchain.toml', '.cargo/config.toml', '.github/workflows/ci.yml') }} + target/ci/postgres-tests.tar.zst + key: relay-artifacts-${{ runner.os }}-${{ hashFiles('crates/**', 'migrations/**', 'Dockerfile', 'Cargo.toml', 'Cargo.lock', 'rust-toolchain.toml', '.cargo/config.toml', '.config/nextest.toml', 'scripts/postgres-test-*.sh', 'scripts/check-postgres-test-discovery.py', '.github/workflows/ci.yml') }} - uses: rui314/setup-mold@7e4f20ad28a2e8ca6fd0892ccf72e2abb706b9c3 # v1 if: steps.relay-artifacts-cache.outputs.cache-hit != 'true' - uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2.9.1 @@ -382,6 +396,20 @@ jobs: --lib \ --test e2e_event_reminder \ --archive-file target/ci/backend-integration-tests.tar.zst + postgres_package_args=() + while IFS= read -r package; do + postgres_package_args+=(-p "$package") + done < <(scripts/postgres-test-packages.sh) + if [[ "${#postgres_package_args[@]}" -eq 0 ]]; then + echo "no PostgreSQL test packages were discovered" >&2 + exit 1 + fi + cargo nextest archive \ + --cargo-profile ci \ + "${postgres_package_args[@]}" \ + --lib \ + --tests \ + --archive-file target/ci/postgres-tests.tar.zst - name: Save relay artifacts cache # PR-scoped exact-source entries cannot warm main or other PRs and churn # the shared cache pool. sccache provides read-only PR reuse instead. @@ -392,7 +420,8 @@ jobs: target/ci/buzz-relay target/ci/git-credential-nostr target/ci/backend-integration-tests.tar.zst - key: relay-artifacts-${{ runner.os }}-${{ hashFiles('crates/**', 'migrations/**', 'Dockerfile', 'Cargo.toml', 'Cargo.lock', 'rust-toolchain.toml', '.cargo/config.toml', '.github/workflows/ci.yml') }} + target/ci/postgres-tests.tar.zst + key: relay-artifacts-${{ runner.os }}-${{ hashFiles('crates/**', 'migrations/**', 'Dockerfile', 'Cargo.toml', 'Cargo.lock', 'rust-toolchain.toml', '.cargo/config.toml', '.config/nextest.toml', 'scripts/postgres-test-*.sh', 'scripts/check-postgres-test-discovery.py', '.github/workflows/ci.yml') }} - name: Upload relay artifacts uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 with: @@ -401,9 +430,71 @@ jobs: target/ci/buzz-relay target/ci/git-credential-nostr target/ci/backend-integration-tests.tar.zst + target/ci/postgres-tests.tar.zst if-no-files-found: error retention-days: 1 + postgres-tests: + name: PostgreSQL Tests + runs-on: ubuntu-latest + timeout-minutes: 5 + needs: [changes, desktop-e2e-relay] + if: github.event_name == 'push' || needs.changes.outputs.rust == 'true' + permissions: + contents: read + services: + postgres: + image: postgres:16 + env: + POSTGRES_USER: buzz + POSTGRES_PASSWORD: ${{ env.BUZZ_TEST_POSTGRES_PASSWORD }} + POSTGRES_DB: postgres + ports: + - 5432:5432 + options: >- + --health-cmd "pg_isready -U buzz -d postgres" + --health-interval 5s + --health-timeout 5s + --health-retries 12 + redis: + image: redis:7 + ports: + - 6379:6379 + options: >- + --health-cmd "redis-cli ping" + --health-interval 5s + --health-timeout 5s + --health-retries 12 + env: + PGHOST: localhost + PGPORT: "5432" + PGUSER: buzz + PG_BIN_DIR: /usr/bin + REDIS_URL: redis://localhost:6379 + PGSCHEMA_PLAN_HOST: localhost + PGSCHEMA_PLAN_PORT: "5432" + PGSCHEMA_PLAN_USER: buzz + PGSCHEMA_PLAN_PASSWORD: buzz_dev + steps: + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + - uses: cashapp/activate-hermit@cea9af7913204a965fd488637a8d1811bba2e616 # v1 + - name: Install cargo-nextest + uses: taiki-e/install-action@0fd46367812ee04360509b4169d9f659d6892bb2 # v2.79.15 + with: + tool: cargo-nextest@0.9.136 + - name: Download backend test archive + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: desktop-e2e-relay + path: target/ci + - name: PostgreSQL-backed tests + env: + BUZZ_POSTGRES_ADMIN_URL: postgres://buzz:${{ env.BUZZ_TEST_POSTGRES_PASSWORD }}@localhost:5432/postgres + PGPASSWORD: ${{ env.BUZZ_TEST_POSTGRES_PASSWORD }} + run: | + scripts/postgres-test-run.sh \ + --archive-file target/ci/postgres-tests.tar.zst + desktop-e2e-integration-shard: name: Desktop E2E Integration (${{ matrix.shard }}/2) runs-on: ubuntu-latest @@ -684,31 +775,6 @@ jobs: VALUES ('00000000-0000-4000-8000-00000000c0de', 'localhost:3000') ON CONFLICT (lower(host)) DO NOTHING ;" - - name: Replaceable persistence PostgreSQL tests - # Transaction, concurrency, and mention-index coverage for the - # replaceable-event store seam. These tests require real Postgres and - # are ignored by the infrastructure-free unit-test job. - run: | - filter='package(buzz-db) and test(/tests::(parameterized_|concurrent_parameterized_)/)' - cargo nextest run \ - --archive-file target/ci/backend-integration-tests.tar.zst \ - -E "${filter}" \ - --run-ignored ignored-only - env: - DATABASE_URL: postgres://buzz:${{ env.BUZZ_TEST_POSTGRES_PASSWORD }}@localhost:5432/buzz - TEST_DATABASE_URL: postgres://buzz:${{ env.BUZZ_TEST_POSTGRES_PASSWORD }}@localhost:5432/buzz - - name: Database pressure observability PostgreSQL tests - # Explicit pool acquisition and advisory-lock metrics require real - # Postgres and are ignored by the infrastructure-free unit-test job. - run: | - filter='package(buzz-db) and test(/observability::tests::(pool_acquire_records_success_timeout_and_error_with_wait_time|advisory_lock_records_success_contention_timeout_and_error)/)' - cargo nextest run \ - --archive-file target/ci/backend-integration-tests.tar.zst \ - -E "${filter}" \ - --run-ignored ignored-only - env: - DATABASE_URL: postgres://buzz:${{ env.BUZZ_TEST_POSTGRES_PASSWORD }}@localhost:5432/buzz - TEST_DATABASE_URL: postgres://buzz:${{ env.BUZZ_TEST_POSTGRES_PASSWORD }}@localhost:5432/buzz - name: Start relay run: | chmod +x ./target/ci/buzz-relay @@ -737,26 +803,6 @@ jobs: done cat /tmp/buzz-relay.log exit 1 - - name: Invite security tests - run: | - cargo nextest run \ - --archive-file target/ci/backend-integration-tests.tar.zst \ - -E '(package(buzz-db) and test(/relay_invite::tests/)) or (package(buzz-relay) and test(/api::invites::tests/))' \ - --run-ignored ignored-only - env: - DATABASE_URL: postgres://buzz:${{ env.BUZZ_TEST_POSTGRES_PASSWORD }}@localhost:5432/buzz - - name: Workspace profile (kind:9033) gate tests - # Call-site integration for the 9033 authorization gate: open relay - # rosterless/steward transitions and the closed-relay admin/owner rule, - # against real Postgres. #[ignore]d in the default suite, selected - # explicitly here — see handlers::relay_admin::tests. - run: | - cargo nextest run \ - --archive-file target/ci/backend-integration-tests.tar.zst \ - -E 'package(buzz-relay) and test(/handlers::relay_admin::tests/)' \ - --run-ignored ignored-only - env: - DATABASE_URL: postgres://buzz:${{ env.BUZZ_TEST_POSTGRES_PASSWORD }}@localhost:5432/buzz - name: NIP-ER reminder e2e # Feature e2e for NIP-ER (Event Reminders, kind:30300): write-path # validation, author-only read filtering, and scheduler delivery against @@ -769,88 +815,6 @@ jobs: --run-ignored ignored-only env: RELAY_URL: ws://localhost:3000 - - name: NIP-MP coordinate deletion guard - # Verifies the never-delete-newer invariant of soft_delete_by_coordinate: - # a stale tombstone (created_at earlier than the live head) spares that - # head, and an equal-timestamp tombstone deletes it. - run: | - cargo nextest run \ - --archive-file target/ci/backend-integration-tests.tar.zst \ - -E 'package(buzz-db) and test(coordinate_delete_spares_head_newer_than_the_deletion)' \ - --run-ignored ignored-only - env: - DATABASE_URL: postgres://buzz:${{ env.BUZZ_TEST_POSTGRES_PASSWORD }}@localhost:5432/buzz - - name: Admin API nip98 read-write attribution test - # The only real HTTP → nip98 operator principal → mutation → cross-table - # attribution coverage: an authenticated operator's dismiss attributes - # to the operator's own key with relay_operator authority. Staffing - # PUT/DELETE attribution is covered by - # nip98_staffing_put_and_delete_write_attributed_audit_rows in the - # roster-audit lane below. #[ignore]d in the default suite — see - # api::admin::tests::nip98_operator_dismiss_succeeds_attributed_to_operator. - run: | - cargo nextest run \ - --archive-file target/ci/backend-integration-tests.tar.zst \ - -E 'package(buzz-relay) and test(=api::admin::tests::nip98_operator_dismiss_succeeds_attributed_to_operator)' \ - --run-ignored ignored-only - env: - DATABASE_URL: postgres://buzz:${{ env.BUZZ_TEST_POSTGRES_PASSWORD }}@localhost:5432/buzz - - name: Admin API unrostered-signer replay invariant - # The only causal proof that a validly-signing but unrostered key cannot - # consume NIP-98 replay slots: it asserts principal resolution fails - # BEFORE the replay ID is claimed (tracking.claim_count() == 0). This - # test is non-ignored, so it runs neither in Backend Integration's - # ignored-only selectors nor in the infra-free unit job — the unit job's - # api::admin selector excludes it because DB-free it only passes by - # waiting out the ~30s sqlx acquire timeout on a read-route fallthrough. - # It lives here so a reachable Postgres resolves (and fails) the lookup - # fast instead of timing out. - run: | - cargo nextest run \ - --archive-file target/ci/backend-integration-tests.tar.zst \ - -E 'package(buzz-relay) and test(=api::admin::tests::nip98_mode_unrostered_signer_does_not_consume_a_replay_slot)' - env: - DATABASE_URL: postgres://buzz:${{ env.BUZZ_TEST_POSTGRES_PASSWORD }}@localhost:5432/buzz - - name: Admin API roster-audit / timeout / canonicalization security tests - # Security-review fixes for the roster admin API, all #[ignore]d in the - # default suite (they need Postgres) and selected by no other job: - # - buzz-db relay_operators::tests: audit pre-image trail, per-target - # lock serialization, insertion-time audit ordering, and - # audit-failure rollback coupling. - # - buzz-db relay_operators::tests last-operator invariant: sole DB - # operator cannot self-demote or self-delete to zero, config presence - # lifts the guard, and concurrent cross-target deletes racing to zero - # leave exactly one operator (roster-wide advisory lock). - # - buzz-relay api::admin: NIP-98 staffing writes attributed audit rows, - # adversarial expirationSecs rejected at the resolve route, mixed-case - # staffing normalizes to one canonical row. - # - # --test-threads=1: the last-operator invariant counts the roster - # globally, and the sole-operator tests clear the roster then assert - # their operator is the only one. They must not race each other on the - # shared test roster, so this lane runs serially. - run: | - cargo nextest run \ - --archive-file target/ci/backend-integration-tests.tar.zst \ - --test-threads=1 \ - -E '(package(buzz-db) and test(=relay_operators::tests::roster_mutations_write_pre_image_audit_rows)) or (package(buzz-db) and test(=relay_operators::tests::concurrent_upserts_serialize_and_record_true_pre_image)) or (package(buzz-db) and test(=relay_operators::tests::audit_order_follows_seq_under_backward_clock)) or (package(buzz-db) and test(=relay_operators::tests::audit_insert_failure_rolls_back_roster_mutation)) or (package(buzz-db) and test(=relay_operators::tests::demoting_sole_db_operator_without_config_is_rejected)) or (package(buzz-db) and test(=relay_operators::tests::deleting_sole_db_operator_without_config_is_rejected)) or (package(buzz-db) and test(=relay_operators::tests::config_present_allows_deleting_last_db_operator)) or (package(buzz-db) and test(=relay_operators::tests::concurrent_deletes_racing_to_zero_leave_one_operator)) or (package(buzz-relay) and test(=api::admin::tests::nip98_staffing_put_and_delete_write_attributed_audit_rows)) or (package(buzz-relay) and test(=api::admin::tests::resolve_route_rejects_adversarial_expiration_and_leaves_report_open)) or (package(buzz-relay) and test(=api::admin::tests::mixed_case_non_config_staffing_normalizes_to_one_row))' \ - --run-ignored ignored-only - env: - DATABASE_URL: postgres://buzz:${{ env.BUZZ_TEST_POSTGRES_PASSWORD }}@localhost:5432/buzz - - name: Admin API escalation-scoping tests - # Escalation scoping for the moderation queue, all #[ignore]d (they need - # Postgres) and selected by no other job: - # - GET /reports defaults to the escalated-only backstop, scope=all - # restores full visibility, explicit status= overrides the default. - # - member reports with category 'illegal' auto-escalate at ingestion - # while every other category still lands 'open'. - run: | - cargo nextest run \ - --archive-file target/ci/backend-integration-tests.tar.zst \ - -E '(package(buzz-relay) and test(=api::admin::tests::reports_default_lists_escalated_only)) or (package(buzz-relay) and test(=api::admin::tests::reports_scope_all_lists_every_status)) or (package(buzz-relay) and test(=api::admin::tests::reports_explicit_status_filter_overrides_default)) or (package(buzz-db) and test(=moderation::tests::illegal_report_auto_escalates_at_ingest)) or (package(buzz-db) and test(=moderation::tests::non_illegal_report_lands_open_at_ingest)) or (package(buzz-db) and test(=relay_admin_actions::tests::auto_escalated_report_reopens_like_an_admin_escalated_one))' \ - --run-ignored ignored-only - env: - DATABASE_URL: postgres://buzz:${{ env.BUZZ_TEST_POSTGRES_PASSWORD }}@localhost:5432/buzz - name: Upload relay log if: failure() uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 0247b570a23..dbe4ba5dd2b 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -215,6 +215,64 @@ connections, NIP-42 auth, event ingestion, search indexing, and workflow execution. `just test` starts Docker services automatically if they're not already running. +### PostgreSQL-backed tests + +PostgreSQL-backed tests run in a dedicated nextest lane. Mark them ignored with +a PostgreSQL reason and place them in a module whose name ends in +`postgres_tests`. Standalone integration-test targets use a `postgres_` +filename prefix instead. Tests that also require infrastructure beyond +PostgreSQL and Redis live under an `external_infra*_tests` module and are +excluded without changing their descriptive function names. + +See the [buzz-db testing guide](crates/buzz-db/TESTING.md) for the crate-level +checklist. + +`scripts/test-postgres-test-discovery.sh` enforces the convention across every +Rust source file. It fails CI when an ignored PostgreSQL test would be omitted, +or when a Redis-only or hybrid test is accidentally included, so module or file +renames cannot silently change lane membership. The archive and runner derive +their Cargo package set from the same markers, so a database test in a new crate +does not require a separate package-list update. + +The `postgres-ci` nextest profile creates one database per test process, so +destructive and concurrent tests must use the database URL supplied through +`BUZZ_TEST_DATABASE_URL`, `TEST_DATABASE_URL`, or `DATABASE_URL`; do not +hard-code the shared development database. Ordinary tests receive the committed +desired-state schema from `schema/schema.sql`. Tests under +`migration::postgres_tests` receive an empty database and own the embedded +migration lifecycle. A test outside that module whose behavior intentionally +depends on migration-created triggers or seed rows uses a +`migration_schema_` function-name prefix and also receives an empty database +with `BUZZ_TEST_SCHEMA_MODE=migration`. Test helpers that normally call the +migrator honor `BUZZ_TEST_SCHEMA_MODE=desired` so the desired-state contract is +not re-migrated. + +Tests that inspect cluster-wide PostgreSQL state or open least-privilege +sessions use a `cluster_global_` function-name segment; migration-backed cases +use `migration_schema_cluster_global_`. Nextest serializes this small group +while the database-isolated remainder stays parallel. + +The setup process requires a PostgreSQL role that can create and drop databases +and owns the databases it creates; the harness itself does not require +superuser access. The complete inventory includes privilege-boundary tests that +create temporary roles and inspect all sessions, so grant that role +`CREATEROLE` and membership in `pg_read_all_stats` (or use an ephemeral +superuser, as CI does). +Set `BUZZ_POSTGRES_ADMIN_URL` to that role's maintenance database, and set +`PGHOST`, `PGPORT`, `PGUSER`, and `PGPASSWORD` for the desired-state +schema bootstrap. PostgreSQL client tools are resolved from `PATH` unless +`PG_BIN_DIR` is set. Tests that use Redis read `REDIS_URL`. + +With native PostgreSQL and Redis running, the complete lane is below. The +runner bounds compilation to the packages discovered from the current source +tree and removes the run-scoped desired-state source database on exit. +Per-test and source-database cleanup retries transient PostgreSQL disconnect +races and emits a warning if all five attempts fail. + +```bash +./scripts/postgres-test-run.sh +``` + ### End-to-End Tests End-to-end tests live in `crates/buzz-test-client/tests/`: diff --git a/crates/buzz-audit/src/service.rs b/crates/buzz-audit/src/service.rs index 9ae1d168590..6819fe23ca3 100644 --- a/crates/buzz-audit/src/service.rs +++ b/crates/buzz-audit/src/service.rs @@ -269,7 +269,7 @@ fn row_to_audit_entry(row: &sqlx::postgres::PgRow) -> Result } #[cfg(test)] -mod tests { +mod postgres_tests { use super::*; use std::{ collections::BTreeSet, diff --git a/crates/buzz-db/src/runtime/mod.rs b/crates/buzz-db/src/runtime/mod.rs index 29eef884024..1c7a2f975c7 100644 --- a/crates/buzz-db/src/runtime/mod.rs +++ b/crates/buzz-db/src/runtime/mod.rs @@ -1041,4 +1041,5 @@ impl Db { } #[cfg(test)] -mod tests; +#[path = "tests.rs"] +mod postgres_tests; diff --git a/crates/buzz-db/src/runtime/observability.rs b/crates/buzz-db/src/runtime/observability.rs index afe1d20b305..cb39358b2e0 100644 --- a/crates/buzz-db/src/runtime/observability.rs +++ b/crates/buzz-db/src/runtime/observability.rs @@ -426,8 +426,6 @@ mod tests { } } - #[tokio::test(flavor = "current_thread")] - #[ignore = "requires Postgres"] async fn pool_acquire_records_success_timeout_and_error_with_wait_time() { let database_url = std::env::var("TEST_DATABASE_URL") .unwrap_or_else(|_| "postgres://buzz:buzz_dev@localhost:5432/buzz".to_owned()); // sadscan:disable np.postgres.1 -- local test-only credentials @@ -490,8 +488,6 @@ mod tests { ); } - #[tokio::test(flavor = "current_thread")] - #[ignore = "requires Postgres"] async fn advisory_lock_records_success_contention_timeout_and_error() { let database_url = std::env::var("TEST_DATABASE_URL") .unwrap_or_else(|_| "postgres://buzz:buzz_dev@localhost:5432/buzz".to_owned()); // sadscan:disable np.postgres.1 -- local test-only credentials @@ -633,4 +629,18 @@ mod tests { "lock timer must include the holder wait: {contention:?}" ); } + + mod postgres_tests { + #[tokio::test(flavor = "current_thread")] + #[ignore = "requires Postgres"] + async fn pool_acquire_records_success_timeout_and_error_with_wait_time() { + super::pool_acquire_records_success_timeout_and_error_with_wait_time().await; + } + + #[tokio::test(flavor = "current_thread")] + #[ignore = "requires Postgres"] + async fn advisory_lock_records_success_contention_timeout_and_error() { + super::advisory_lock_records_success_contention_timeout_and_error().await; + } + } } diff --git a/crates/buzz-db/src/runtime/replica_fence.rs b/crates/buzz-db/src/runtime/replica_fence.rs index cf9b46ddd8b..2194c8bb30a 100644 --- a/crates/buzz-db/src/runtime/replica_fence.rs +++ b/crates/buzz-db/src/runtime/replica_fence.rs @@ -789,20 +789,14 @@ pub async fn run_probe(writer: PgPool, fence: Arc) { } #[cfg(test)] -mod tests { +mod postgres_tests { use super::*; - const TEST_DB_URL: &str = "postgres://buzz:buzz_dev@localhost:5432/buzz"; // sadscan:disable np.postgres.1 - - fn test_db_url() -> String { - std::env::var("TEST_DATABASE_URL").unwrap_or_else(|_| TEST_DB_URL.into()) - } - /// A private scratch database with migrations applied: the probe tests /// mutate the singleton heartbeat row (rewind/rotate), which must never /// race the shared dev database or each other. async fn scratch_db() -> (PgPool, PgPool, String) { - let admin = PgPool::connect(&test_db_url()) + let admin = PgPool::connect(&crate::test_support::database_url()) .await .expect("connect admin"); let name = format!("fence_probe_{}", uuid::Uuid::new_v4().simple()); @@ -810,7 +804,7 @@ mod tests { .execute(&admin) .await .expect("create scratch db"); - let base = test_db_url(); + let base = crate::test_support::database_url(); let idx = base.rfind('/').expect("db url has a path segment"); let pool = PgPool::connect(&format!("{}/{}", &base[..idx], name)) .await @@ -973,17 +967,27 @@ mod tests { /// sessions, per the agreed classification. #[tokio::test] #[ignore = "requires Postgres"] - async fn sample_writer_sees_open_transactions_and_ignores_idle() { - let pool = PgPool::connect(&test_db_url()).await.expect("connect"); + async fn migration_schema_cluster_global_sample_writer_sees_open_transactions_and_ignores_idle() + { + let pool = PgPool::connect(&crate::test_support::database_url()) + .await + .expect("connect"); + crate::migration::run_migrations(&pool) + .await + .expect("apply migration schema"); // A plain idle session: pinned connection, no transaction. - let idle_pool = PgPool::connect(&test_db_url()).await.expect("connect idle"); + let idle_pool = PgPool::connect(&crate::test_support::database_url()) + .await + .expect("connect idle"); let _idle_conn = idle_pool.acquire().await.expect("idle conn"); let before = sample_writer(&pool).await.expect("sample without tx"); // Now hold a transaction open on a second connection. - let tx_pool = PgPool::connect(&test_db_url()).await.expect("connect tx"); + let tx_pool = PgPool::connect(&crate::test_support::database_url()) + .await + .expect("connect tx"); let mut tx = tx_pool.begin().await.expect("begin"); sqlx::query("SELECT 1") .execute(&mut *tx) @@ -1016,12 +1020,16 @@ mod tests { /// never silently `MIN()` the hidden row away. #[tokio::test] #[ignore = "requires Postgres"] - async fn sample_writer_fails_closed_when_activity_is_masked() { - let admin = PgPool::connect(&test_db_url()).await.expect("connect"); + async fn cluster_global_sample_writer_fails_closed_when_activity_is_masked() { + let admin = PgPool::connect(&crate::test_support::database_url()) + .await + .expect("connect"); // Hold a transaction open as the privileged user: this is the row // the unprivileged probe must notice it cannot classify. - let tx_pool = PgPool::connect(&test_db_url()).await.expect("connect tx"); + let tx_pool = PgPool::connect(&crate::test_support::database_url()) + .await + .expect("connect tx"); let mut tx = tx_pool.begin().await.expect("begin"); sqlx::query("SELECT 1") .execute(&mut *tx) @@ -1038,7 +1046,7 @@ mod tests { .await .expect("create unprivileged role"); - let base = test_db_url(); + let base = crate::test_support::database_url(); let unpriv_url = { let rest = base.strip_prefix("postgres://").expect("pg url"); let at = rest.rfind('@').expect("credentials in url"); @@ -1079,7 +1087,9 @@ mod tests { #[tokio::test] #[ignore = "requires Postgres"] async fn aurora_identity_probe_reports_false_on_plain_postgres() { - let pool = PgPool::connect(&test_db_url()).await.expect("connect"); + let pool = PgPool::connect(&crate::test_support::database_url()) + .await + .expect("connect"); let mut conn = pool.acquire().await.expect("conn"); assert!( !reader_supports_aurora_identity(&mut conn) @@ -1100,7 +1110,7 @@ mod tests { /// same database observes a token/epoch that resolves that entry. #[tokio::test] #[ignore = "requires Postgres"] - async fn probe_commits_tokens_and_sessions_prove_coverage() { + async fn cluster_global_probe_commits_tokens_and_sessions_prove_coverage() { let (admin, pool, name) = scratch_db().await; let fence = ReplicaFence::new(); @@ -1155,7 +1165,7 @@ mod tests { /// epoch — fails the epoch check instead of proving stale coverage. #[tokio::test] #[ignore = "requires Postgres"] - async fn probe_rotates_epoch_on_same_epoch_token_regression() { + async fn cluster_global_probe_rotates_epoch_on_same_epoch_token_regression() { let (admin, pool, name) = scratch_db().await; let fence = ReplicaFence::new(); diff --git a/crates/buzz-db/src/runtime/tests.rs b/crates/buzz-db/src/runtime/tests.rs index ecdc983a4ac..8c68be01504 100644 --- a/crates/buzz-db/src/runtime/tests.rs +++ b/crates/buzz-db/src/runtime/tests.rs @@ -11,6 +11,11 @@ async fn setup_db() -> Db { let pool = PgPool::connect(&database_url) .await .expect("connect to test DB"); + if std::env::var("BUZZ_TEST_SCHEMA_MODE").as_deref() == Ok("migration") { + crate::migration::run_migrations(&pool) + .await + .expect("apply migration schema"); + } Db::from_pool(pool) } @@ -28,7 +33,7 @@ async fn make_community(pool: &PgPool) -> Uuid { #[tokio::test] #[ignore = "requires Postgres"] -async fn database_guard_covers_legacy_writer_and_nip09_deletion() { +async fn migration_schema_database_guard_covers_legacy_writer_and_nip09_deletion() { use nostr::{EventBuilder, Keys, Kind, Tag, Timestamp}; let db = setup_db().await; diff --git a/crates/buzz-db/src/store/admin_moderation.rs b/crates/buzz-db/src/store/admin_moderation.rs index f38231787bf..c5a7ea542f5 100644 --- a/crates/buzz-db/src/store/admin_moderation.rs +++ b/crates/buzz-db/src/store/admin_moderation.rs @@ -455,7 +455,7 @@ impl Db { } #[cfg(test)] -mod tests { +mod postgres_tests { use super::*; const TEST_DB_URL: &str = "postgres://buzz:buzz_dev@localhost:5432/buzz"; // sadscan:disable np.postgres.1 diff --git a/crates/buzz-db/src/store/allowlist.rs b/crates/buzz-db/src/store/allowlist.rs index 6b213d5cce8..d2780a498c0 100644 --- a/crates/buzz-db/src/store/allowlist.rs +++ b/crates/buzz-db/src/store/allowlist.rs @@ -113,7 +113,7 @@ impl Db { } #[cfg(test)] -mod tests { +mod postgres_tests { use super::*; use sqlx::PgPool; use uuid::Uuid; diff --git a/crates/buzz-db/src/store/api_token.rs b/crates/buzz-db/src/store/api_token.rs index ec380d9e5e7..41d4dcbad29 100644 --- a/crates/buzz-db/src/store/api_token.rs +++ b/crates/buzz-db/src/store/api_token.rs @@ -606,7 +606,7 @@ impl Db { } #[cfg(test)] -mod tests { +mod postgres_tests { //! Row-44 conformance: API token lookups MUST be keyed on //! `(community_id, token_hash)`, not on `token_hash` alone. The storage //! UNIQUE index is a *storage* guarantee; the WHERE clause here is the @@ -625,10 +625,8 @@ mod tests { use crate::{ApiTokenRecord, Db}; use sqlx::PgPool; - const TEST_DB_URL: &str = "postgres://buzz:buzz_dev@localhost:5432/buzz"; // sadscan:disable np.postgres.1 -- local test-only credentials - async fn setup_db() -> Db { - let pool = PgPool::connect(TEST_DB_URL) + let pool = PgPool::connect(&crate::test_support::database_url()) .await .expect("connect to test DB"); Db::from_pool(pool) diff --git a/crates/buzz-db/src/store/archived_identities.rs b/crates/buzz-db/src/store/archived_identities.rs index 810c8c0aa1f..102ca44f96f 100644 --- a/crates/buzz-db/src/store/archived_identities.rs +++ b/crates/buzz-db/src/store/archived_identities.rs @@ -176,13 +176,11 @@ impl Db { } #[cfg(test)] -mod tests { +mod postgres_tests { use super::*; - const TEST_DB_URL: &str = "postgres://buzz:buzz_dev@localhost:5432/buzz"; // sadscan:disable np.postgres.1 - async fn setup_pool() -> PgPool { - PgPool::connect(TEST_DB_URL) + PgPool::connect(&crate::test_support::database_url()) .await .expect("connect to test DB") } diff --git a/crates/buzz-db/src/store/channel.rs b/crates/buzz-db/src/store/channel.rs index a93ffb36c6a..c581f529f72 100644 --- a/crates/buzz-db/src/store/channel.rs +++ b/crates/buzz-db/src/store/channel.rs @@ -910,15 +910,13 @@ impl Db { } #[cfg(test)] -mod tests { +mod postgres_tests { use super::*; use crate::user::ensure_user; use nostr::Keys; - const TEST_DB_URL: &str = "postgres://buzz:buzz_dev@localhost:5432/buzz"; // sadscan:disable np.postgres.1 -- local test-only credentials - async fn setup_pool() -> PgPool { - PgPool::connect(TEST_DB_URL) + PgPool::connect(&crate::test_support::database_url()) .await .expect("connect to test DB") } diff --git a/crates/buzz-db/src/store/channel_members.rs b/crates/buzz-db/src/store/channel_members.rs index f0fd3332acd..dd526a0a46c 100644 --- a/crates/buzz-db/src/store/channel_members.rs +++ b/crates/buzz-db/src/store/channel_members.rs @@ -1379,7 +1379,7 @@ impl Db { } #[cfg(test)] -mod tests { +mod postgres_tests { use super::*; use crate::channel::{ChannelType, ChannelVisibility}; use crate::migration; @@ -1387,10 +1387,8 @@ mod tests { use nostr::Keys; use sqlx::postgres::PgPoolOptions; - const TEST_DB_URL: &str = "postgres://buzz:buzz_dev@localhost:5432/buzz"; // sadscan:disable np.postgres.1 -- local test-only credentials - async fn setup_pool() -> PgPool { - PgPool::connect(TEST_DB_URL) + PgPool::connect(&crate::test_support::database_url()) .await .expect("connect to test DB") } @@ -1573,8 +1571,7 @@ mod tests { #[tokio::test] #[ignore = "requires Postgres"] async fn accessible_channel_ids_are_not_truncated_at_one_thousand() { - let database_url = - std::env::var("BUZZ_TEST_DATABASE_URL").unwrap_or_else(|_| TEST_DB_URL.to_string()); + let database_url = crate::test_support::database_url(); let pool = PgPool::connect(&database_url) .await .expect("connect to test DB"); @@ -1612,8 +1609,7 @@ mod tests { #[tokio::test] #[ignore = "requires Postgres"] async fn get_members_returns_full_roster_beyond_1000() { - let database_url = - std::env::var("BUZZ_TEST_DATABASE_URL").unwrap_or_else(|_| TEST_DB_URL.to_string()); + let database_url = crate::test_support::database_url(); let pool = PgPool::connect(&database_url) .await .expect("connect to test DB"); @@ -1720,11 +1716,15 @@ mod tests { .await .expect("insert large roster"); + // Migration 0032's roster guard requires canonical four-field p tags + // whose roles exactly match channel_members, including the creator's + // owner row created by create_test_channel. + let creator_hex = hex::encode(&creator); let stale_tags: Vec = std::iter::once(serde_json::json!(["d", channel.id.to_string()])) .chain(std::iter::once(serde_json::json!([ "p", - hex::encode(&creator), + creator_hex, "", "owner" ]))) @@ -1736,7 +1736,7 @@ mod tests { std::iter::once(serde_json::json!(["d", channel.id.to_string()])) .chain(std::iter::once(serde_json::json!([ "p", - hex::encode(&creator), + creator_hex, "", "owner" ]))) @@ -1747,8 +1747,14 @@ mod tests { .collect(); let other_complete_tags: Vec = std::iter::once(serde_json::json!(["d", channel.id.to_string()])) + .chain(std::iter::once(serde_json::json!([ + "p", + hex::encode(&creator), + "", + "owner" + ]))) .chain( - (0..=1_500) + (1..=extra_members) .map(|n| serde_json::json!(["p", format!("{n:064x}"), "", "member"])), ) .collect(); @@ -1793,6 +1799,10 @@ mod tests { // The same channel UUID in another tenant is deliberately valid. A // complete snapshot there must not mask this tenant's stale head. let other_community_id = make_test_community(&pool).await; + // Insert directly because create_test_channel generates a fresh UUID, + // while this test needs the same channel ID in both tenants. Direct + // insertion skips the helper's creator membership, so add the owner + // row explicitly below. sqlx::query( r#" INSERT INTO channels @@ -1806,16 +1816,29 @@ mod tests { .execute(&pool) .await .expect("insert same channel id in other tenant"); + sqlx::query( + r#" + INSERT INTO channel_members (community_id, channel_id, pubkey, role, joined_at) + VALUES ($1, $2, $3, 'owner', NOW()) + "#, + ) + .bind(other_community_id) + .bind(channel.id) + .bind(&creator) + .execute(&pool) + .await + .expect("insert other-tenant owner"); sqlx::query( r#" INSERT INTO channel_members (community_id, channel_id, pubkey, role, joined_at) SELECT $1, $2, decode(lpad(to_hex(n), 64, '0'), 'hex'), 'member', NOW() + (n || ' seconds')::interval - FROM generate_series(0, 1500) n + FROM generate_series(1, $3) n "#, ) .bind(other_community_id) .bind(channel.id) + .bind(extra_members) .execute(&pool) .await .expect("insert complete other-tenant roster"); @@ -2398,7 +2421,7 @@ mod tests { let snapshot_pool = PgPoolOptions::new() .max_connections(1) .acquire_timeout(std::time::Duration::from_secs(1)) - .connect(TEST_DB_URL) + .connect(&crate::test_support::database_url()) .await .expect("connect one-connection pool"); let relay_keys = Keys::generate(); @@ -2470,7 +2493,7 @@ mod tests { /// until it is released. Verified by mutation — dropping the lock from either /// function makes that call return immediately and fails this test. #[tokio::test] - #[ignore] + #[ignore = "requires PostgreSQL"] async fn membership_writes_serialize_on_the_shared_channel_lock() { let pool = setup_pool().await; let (community, channel_id, owner_a, owner_b) = @@ -2541,7 +2564,7 @@ mod tests { /// holder then demotes the remover and commits. Once the key is released the /// remover must re-read its (now unprivileged) role and be rejected. #[tokio::test] - #[ignore] + #[ignore = "requires PostgreSQL"] async fn remove_member_rejects_an_actor_demoted_while_it_waited() { let pool = setup_pool().await; let (community, channel_id, owner_a, owner_b) = @@ -2627,7 +2650,7 @@ mod tests { /// Two owners on purpose, so the last-owner guard can never be what /// decides the outcome — only role resolution can. #[tokio::test] - #[ignore] + #[ignore = "requires PostgreSQL"] async fn kicked_owner_rejoins_as_member_not_owner() { let pool = setup_pool().await; let (community, channel_id, owner_a, owner_b) = @@ -2679,7 +2702,7 @@ mod tests { /// The other side of the same boundary: reactivation may reach an elevated /// role, but only because a *currently* elevated granter asked for it. #[tokio::test] - #[ignore] + #[ignore = "requires PostgreSQL"] async fn removed_owner_is_restored_only_by_a_current_owner() { let pool = setup_pool().await; let (community, channel_id, owner_a, owner_b) = @@ -2736,7 +2759,7 @@ mod tests { } async fn admin_url() -> String { - std::env::var("TEST_DATABASE_URL").unwrap_or_else(|_| TEST_DB_URL.into()) + crate::test_support::database_url() } /// Create a fresh scratch database on the same server and optionally run migrations. diff --git a/crates/buzz-db/src/store/community.rs b/crates/buzz-db/src/store/community.rs index 5e8462345bb..dd8a2e58bc8 100644 --- a/crates/buzz-db/src/store/community.rs +++ b/crates/buzz-db/src/store/community.rs @@ -546,7 +546,7 @@ impl Db { } #[cfg(test)] -mod tests { +mod postgres_tests { //! Pin the load-bearing contract for `Db::communities_of_channels`: //! a channel id that does NOT exist MUST be absent from the result //! map, never mapped to a default. The relay-side read-row emitter @@ -557,11 +557,8 @@ mod tests { use super::*; use sqlx::PgPool; - const TEST_DB_URL: &str = "postgres://buzz:buzz_dev@localhost:5432/buzz"; // sadscan:disable np.postgres.1 - async fn setup_db() -> Db { - let database_url = - std::env::var("TEST_DATABASE_URL").unwrap_or_else(|_| TEST_DB_URL.into()); + let database_url = crate::test_support::database_url(); let pool = PgPool::connect(&database_url) .await .expect("connect to test DB"); @@ -847,8 +844,8 @@ mod tests { let db = setup_db().await; let owner = format!("{:064x}", Uuid::new_v4().as_u128()); - // Create 3 communities for this owner (the max). - for i in 0..3 { + // Fill the configured default ownership limit. + for i in 0..crate::relay_members::MAX_COMMUNITIES_PER_OWNER { let host = format!("limit-test-{}-{}.example", i, Uuid::new_v4().simple()); assert!(matches!( db.create_community_with_owner(&host, &owner) @@ -858,7 +855,7 @@ mod tests { )); } - let host = format!("limit-test-3-{}.example", Uuid::new_v4().simple()); + let host = format!("limit-test-overflow-{}.example", Uuid::new_v4().simple()); assert_eq!( db.create_community_with_owner(&host, &owner) .await diff --git a/crates/buzz-db/src/store/deletion.rs b/crates/buzz-db/src/store/deletion.rs index c7fcdc09f66..d34b39f14b1 100644 --- a/crates/buzz-db/src/store/deletion.rs +++ b/crates/buzz-db/src/store/deletion.rs @@ -3355,7 +3355,9 @@ mod postgres_tests { }) .await .expect("connect deletion test DB"); - db.migrate().await.expect("migrate deletion test DB"); + if std::env::var("BUZZ_TEST_SCHEMA_MODE").as_deref() != Ok("desired") { + db.migrate().await.expect("migrate deletion test DB"); + } let store = db.deletion_store(); (db, store) } diff --git a/crates/buzz-db/src/store/event.rs b/crates/buzz-db/src/store/event.rs index 60e6b05ef9b..d685e44485e 100644 --- a/crates/buzz-db/src/store/event.rs +++ b/crates/buzz-db/src/store/event.rs @@ -1740,7 +1740,7 @@ impl Db { } #[cfg(test)] -mod tests { +mod postgres_tests { use super::*; use nostr::{EventBuilder, Keys, Kind, Tag}; diff --git a/crates/buzz-db/src/store/feed.rs b/crates/buzz-db/src/store/feed.rs index 01e4fef32be..7047bbdd9b7 100644 --- a/crates/buzz-db/src/store/feed.rs +++ b/crates/buzz-db/src/store/feed.rs @@ -536,7 +536,7 @@ impl Db { // -- Tests -------------------------------------------------------------------- #[cfg(test)] -mod tests { +mod postgres_tests { use super::*; use nostr::{EventBuilder, Keys, Kind, Tag}; use uuid::Uuid; @@ -1120,14 +1120,11 @@ mod tests { /// `insert_mentions` must index every p-tag even past Postgres's /// bind-parameter statement cap. /// - /// Relay-signed kind 39002 member snapshots carry one p-tag per channel - /// member, and a multi-row INSERT binds 6 parameters per row — a single - /// statement tops out at ~10.9k rows against the 65,535-parameter limit. - /// Clients discover their channels via `{kinds:[39002], "#p":[me]}`, so a - /// failed insert silently breaks discovery for the whole channel. + /// A multi-row INSERT binds 6 parameters per p-tag, so a single statement + /// tops out at ~10.9k rows against the 65,535-parameter limit. #[tokio::test] #[ignore = "requires Postgres"] - async fn insert_mentions_indexes_rosters_past_bind_parameter_cap() { + async fn insert_mentions_indexes_p_tags_past_bind_parameter_cap() { let pool = setup_pool().await; let community = CommunityId::from_uuid(make_test_community(&pool).await); let channel = insert_test_channel(&pool, community).await; @@ -1148,7 +1145,15 @@ mod tests { let tags: Vec = (1..=mention_count) .map(|n| Tag::parse(["p", &format!("{n:064x}"), "", "member"]).expect("p tag")) .collect(); - let event = store_feed_event(&pool, community, 39002, "", Some(channel), tags).await; + let event = store_feed_event( + &pool, + community, + KIND_STREAM_MESSAGE, + "", + Some(channel), + tags, + ) + .await; let indexed: i64 = sqlx::query_scalar( "SELECT COUNT(*) FROM event_mentions WHERE community_id = $1 AND event_id = $2", @@ -1160,7 +1165,7 @@ mod tests { .expect("count indexed mentions"); assert_eq!( indexed as usize, mention_count, - "every roster p-tag must land in event_mentions" + "every p-tag must land in event_mentions" ); } } diff --git a/crates/buzz-db/src/store/git_repo.rs b/crates/buzz-db/src/store/git_repo.rs index 5afea1e4fda..bc4e70e151e 100644 --- a/crates/buzz-db/src/store/git_repo.rs +++ b/crates/buzz-db/src/store/git_repo.rs @@ -231,7 +231,7 @@ impl Db { } #[cfg(test)] -mod tests { +mod postgres_tests { use super::*; use uuid::Uuid; diff --git a/crates/buzz-db/src/store/moderation.rs b/crates/buzz-db/src/store/moderation.rs index 5ac7c93af9a..94e550185d5 100644 --- a/crates/buzz-db/src/store/moderation.rs +++ b/crates/buzz-db/src/store/moderation.rs @@ -814,7 +814,7 @@ impl Db { } #[cfg(test)] -mod tests { +mod postgres_tests { use super::*; use chrono::Duration; use uuid::Uuid; diff --git a/crates/buzz-db/src/store/product_feedback.rs b/crates/buzz-db/src/store/product_feedback.rs index 8a0ef36bea5..e732c44d4b9 100644 --- a/crates/buzz-db/src/store/product_feedback.rs +++ b/crates/buzz-db/src/store/product_feedback.rs @@ -137,7 +137,7 @@ impl Db { } #[cfg(test)] -mod tests { +mod postgres_tests { use super::*; #[tokio::test] diff --git a/crates/buzz-db/src/store/push.rs b/crates/buzz-db/src/store/push.rs index 9133b82e716..710ffb931c6 100644 --- a/crates/buzz-db/src/store/push.rs +++ b/crates/buzz-db/src/store/push.rs @@ -1463,7 +1463,7 @@ impl Db { } #[cfg(test)] -mod tests { +mod postgres_tests { use super::*; use crate::migration; use std::sync::Arc; @@ -1476,9 +1476,11 @@ mod tests { let pool = PgPool::connect(&database_url) .await .expect("connect to test DB"); - migration::run_migrations(&pool) - .await - .expect("run migrations"); + if std::env::var("BUZZ_TEST_SCHEMA_MODE").as_deref() != Ok("desired") { + migration::run_migrations(&pool) + .await + .expect("run migrations"); + } pool } diff --git a/crates/buzz-db/src/store/reaction.rs b/crates/buzz-db/src/store/reaction.rs index 1f14adf176d..6494d856639 100644 --- a/crates/buzz-db/src/store/reaction.rs +++ b/crates/buzz-db/src/store/reaction.rs @@ -687,7 +687,7 @@ impl Db { } #[cfg(test)] -mod tests { +mod postgres_tests { use super::*; use crate::{ error::DbError, diff --git a/crates/buzz-db/src/store/relay_admin_actions.rs b/crates/buzz-db/src/store/relay_admin_actions.rs index 438543da583..7662077911d 100644 --- a/crates/buzz-db/src/store/relay_admin_actions.rs +++ b/crates/buzz-db/src/store/relay_admin_actions.rs @@ -2043,7 +2043,7 @@ impl crate::Db { } #[cfg(test)] -mod tests { +mod postgres_tests { use super::*; use sqlx::PgPool; diff --git a/crates/buzz-db/src/store/relay_invite.rs b/crates/buzz-db/src/store/relay_invite.rs index 1424829933f..6c90c31b944 100644 --- a/crates/buzz-db/src/store/relay_invite.rs +++ b/crates/buzz-db/src/store/relay_invite.rs @@ -429,29 +429,21 @@ impl Db { } #[cfg(test)] -mod tests { +mod postgres_tests { use super::*; use crate::relay_members::is_relay_member; use sha2::Digest; use sqlx::PgPool; use uuid::Uuid; - const TEST_DB_URL: &str = "postgres://buzz:buzz_dev@localhost:5432/buzz"; // sadscan:disable np.postgres.1 - async fn setup_pool() -> PgPool { - PgPool::connect(&test_database_url()) + PgPool::connect(&crate::test_support::database_url()) .await .expect("connect to test DB") } - fn test_database_url() -> String { - std::env::var("BUZZ_TEST_DATABASE_URL") - .or_else(|_| std::env::var("DATABASE_URL")) - .unwrap_or_else(|_| TEST_DB_URL.to_owned()) - } - async fn create_scratch_database(prefix: &str) -> (PgPool, String, String) { - let admin_url = test_database_url(); + let admin_url = crate::test_support::database_url(); let admin = PgPool::connect(&admin_url) .await .expect("connect to test database server"); diff --git a/crates/buzz-db/src/store/relay_members.rs b/crates/buzz-db/src/store/relay_members.rs index 0a20b011ebd..9a5b6f91a24 100644 --- a/crates/buzz-db/src/store/relay_members.rs +++ b/crates/buzz-db/src/store/relay_members.rs @@ -968,7 +968,7 @@ impl Db { } #[cfg(test)] -mod tests { +mod postgres_tests { #[test] fn owner_limit_defaults_when_unset_or_invalid() { assert_eq!( @@ -1359,8 +1359,8 @@ mod tests { let owner = test_pubkey(); let transferee = test_pubkey(); - // Give the transferee 3 communities (the max). - for _ in 0..3 { + // Fill the configured default ownership limit. + for _ in 0..MAX_COMMUNITIES_PER_OWNER { let c = make_test_community(&pool).await; bootstrap_owner(&pool, c, &transferee) .await diff --git a/crates/buzz-db/src/store/relay_operators.rs b/crates/buzz-db/src/store/relay_operators.rs index 3670a2f142b..204e88fd95a 100644 --- a/crates/buzz-db/src/store/relay_operators.rs +++ b/crates/buzz-db/src/store/relay_operators.rs @@ -326,7 +326,7 @@ impl crate::Db { } #[cfg(test)] -mod tests { +mod postgres_tests { use super::*; use sqlx::PgPool; diff --git a/crates/buzz-db/src/store/reminder.rs b/crates/buzz-db/src/store/reminder.rs index 20f503f4008..2d2dde18c11 100644 --- a/crates/buzz-db/src/store/reminder.rs +++ b/crates/buzz-db/src/store/reminder.rs @@ -239,7 +239,7 @@ impl Db { } #[cfg(test)] -mod tests { +mod postgres_tests { use super::*; use crate::event::insert_event; use nostr::{EventBuilder, Keys, Kind, Tag}; diff --git a/crates/buzz-db/src/store/replaceable.rs b/crates/buzz-db/src/store/replaceable.rs index 9b575b6ea18..19f0d2d008e 100644 --- a/crates/buzz-db/src/store/replaceable.rs +++ b/crates/buzz-db/src/store/replaceable.rs @@ -586,7 +586,7 @@ impl Db { } #[cfg(test)] -mod tests { +mod postgres_tests { use super::*; use crate::{event, migration, replaceable}; use sqlx::postgres::PgPoolOptions; @@ -601,6 +601,11 @@ mod tests { let pool = PgPool::connect(&database_url) .await .expect("connect to test DB"); + if std::env::var("BUZZ_TEST_SCHEMA_MODE").as_deref() == Ok("migration") { + migration::run_migrations(&pool) + .await + .expect("apply migration schema"); + } Db::from_pool(pool) } @@ -994,7 +999,7 @@ mod tests { #[tokio::test] #[ignore = "requires Postgres"] - async fn nip_rs_transaction_operation_restores_hard_delete_opt_in() { + async fn migration_schema_nip_rs_transaction_operation_restores_hard_delete_opt_in() { use nostr::{EventBuilder, Keys, Kind, Tag, Timestamp}; let db = setup_db().await; @@ -1444,7 +1449,7 @@ mod tests { #[tokio::test] #[ignore = "requires Postgres"] - async fn mesh_status_replacement_keeps_one_physical_row() { + async fn migration_schema_mesh_status_replacement_keeps_one_physical_row() { use nostr::{EventBuilder, Keys, Kind, Tag, Timestamp}; let db = setup_db().await; @@ -1607,7 +1612,8 @@ mod tests { #[tokio::test] #[ignore = "requires Postgres"] - async fn nip_rs_hard_delete_fence_fails_closed_and_scopes_opt_in_to_transaction() { + async fn migration_schema_nip_rs_hard_delete_fence_fails_closed_and_scopes_opt_in_to_transaction( + ) { use nostr::{EventBuilder, Keys, Kind, Tag, Timestamp}; let db = setup_db().await; diff --git a/crates/buzz-db/src/store/thread.rs b/crates/buzz-db/src/store/thread.rs index d7a2d239eff..0cf4e91f342 100644 --- a/crates/buzz-db/src/store/thread.rs +++ b/crates/buzz-db/src/store/thread.rs @@ -1152,7 +1152,7 @@ impl Db { } #[cfg(test)] -mod tests { +mod postgres_tests { use super::*; use crate::{ channel::{ChannelType, ChannelVisibility}, diff --git a/crates/buzz-db/src/store/usage.rs b/crates/buzz-db/src/store/usage.rs index 97235f0b26e..ce581561bd5 100644 --- a/crates/buzz-db/src/store/usage.rs +++ b/crates/buzz-db/src/store/usage.rs @@ -476,17 +476,15 @@ impl Db { } #[cfg(test)] -mod tests { +mod postgres_tests { use super::*; use buzz_core::CommunityId; use nostr::Keys; use sqlx::postgres::PgPoolOptions; use sqlx::PgPool; - const TEST_DB_URL: &str = "postgres://buzz:buzz_dev@localhost:5432/buzz"; // sadscan:disable np.postgres.1 - async fn get_pool() -> PgPool { - PgPool::connect(TEST_DB_URL) + PgPool::connect(&crate::test_support::database_url()) .await .expect("connect to test DB") } @@ -497,7 +495,7 @@ mod tests { .execute(admin) .await .expect("create scratch db"); - let base = std::env::var("TEST_DATABASE_URL").unwrap_or_else(|_| TEST_DB_URL.into()); + let base = crate::test_support::database_url(); let idx = base.rfind('/').expect("db url has a path segment"); let scratch_url = format!("{}/{}", &base[..idx], name); let pool = PgPool::connect(&scratch_url) @@ -525,7 +523,7 @@ mod tests { // Postgres advisory locks are per-database; hardcoding the production // USAGE_METRICS_LOCK_KEY (0x4255_5A5A_4D45_5452) on the shared test DB // races any live buzz-relay on the same database (see #3619). - let admin_url = std::env::var("TEST_DATABASE_URL").unwrap_or_else(|_| TEST_DB_URL.into()); + let admin_url = crate::test_support::database_url(); let admin = PgPoolOptions::new() .max_connections(1) .connect(&admin_url) diff --git a/crates/buzz-db/src/store/user.rs b/crates/buzz-db/src/store/user.rs index 140a722a21b..67f9cb341dc 100644 --- a/crates/buzz-db/src/store/user.rs +++ b/crates/buzz-db/src/store/user.rs @@ -512,15 +512,13 @@ impl Db { } #[cfg(test)] -mod tests { +mod postgres_tests { use super::*; use crate::Db; use nostr::Keys; - const TEST_DB_URL: &str = "postgres://buzz:buzz_dev@localhost:5432/buzz"; // sadscan:disable np.postgres.1 -- local test-only credentials - async fn setup_db() -> Db { - let pool = PgPool::connect(TEST_DB_URL) + let pool = PgPool::connect(&crate::test_support::database_url()) .await .expect("connect to test DB"); Db::from_pool(pool) diff --git a/crates/buzz-db/src/store/workflow.rs b/crates/buzz-db/src/store/workflow.rs index 0ae1b623764..3ceed9ea32e 100644 --- a/crates/buzz-db/src/store/workflow.rs +++ b/crates/buzz-db/src/store/workflow.rs @@ -1686,7 +1686,7 @@ impl Db { // -- Tests -------------------------------------------------------------------- #[cfg(test)] -mod tests { +mod postgres_tests { use super::*; use chrono::TimeZone; diff --git a/crates/buzz-db/src/test_support.rs b/crates/buzz-db/src/test_support.rs new file mode 100644 index 00000000000..7699313d636 --- /dev/null +++ b/crates/buzz-db/src/test_support.rs @@ -0,0 +1,9 @@ +const DEFAULT_DATABASE_URL: &str = "postgres://buzz:buzz_dev@localhost:5432/buzz"; // sadscan:disable np.postgres.1 -- local test-only credentials + +/// Resolve the database URL shared by PostgreSQL-backed unit tests. +pub(crate) fn database_url() -> String { + std::env::var("BUZZ_TEST_DATABASE_URL") + .or_else(|_| std::env::var("TEST_DATABASE_URL")) + .or_else(|_| std::env::var("DATABASE_URL")) + .unwrap_or_else(|_| DEFAULT_DATABASE_URL.to_owned()) +} diff --git a/crates/buzz-deletion/src/lib.rs b/crates/buzz-deletion/src/lib.rs index f13b7d507ac..ec48e927d05 100644 --- a/crates/buzz-deletion/src/lib.rs +++ b/crates/buzz-deletion/src/lib.rs @@ -1599,7 +1599,7 @@ fn print_json(value: &impl Serialize) -> Result<()> { } #[cfg(test)] -mod tests { +mod postgres_tests { use super::*; #[test] @@ -1657,7 +1657,9 @@ mod tests { .await .expect("connect deletion engine test DB"); let db = Db::from_pool(pool); - db.migrate().await.expect("migrate deletion engine test DB"); + if std::env::var("BUZZ_TEST_SCHEMA_MODE").as_deref() != Ok("desired") { + db.migrate().await.expect("migrate deletion engine test DB"); + } let store = db.deletion_store(); let host = format!("{prefix}-{}.example", Uuid::new_v4().simple()); let community = db @@ -1814,8 +1816,6 @@ mod tests { ) } - #[tokio::test] - #[ignore = "requires Postgres"] async fn approved_stage_allows_post_inventory_row_churn_before_fencing() { let (db, services, claim) = claimed_test_deletion("deletion-row-churn").await; let frozen: FrozenInventory = serde_json::from_value( @@ -1877,8 +1877,6 @@ mod tests { /// then the worker died before the chunk stamp. Resume must re-delete the /// chunk (missing keys report as deleted — idempotent), stamp it, and /// finish the stage. - #[tokio::test] - #[ignore = "requires Postgres and S3-compatible storage"] async fn drained_stage_resumes_chunk_deleted_before_stamp() { let (_, mut services, claim) = claimed_test_deletion("deletion-chunk-resume").await; services.media = deletion_test_media_storage(); @@ -2133,8 +2131,6 @@ mod tests { assert!(scan_proves_absence(&[(9, Vec::new()), (0, Vec::new())])); } - #[tokio::test] - #[ignore = "requires Postgres and S3-compatible storage"] async fn final_storage_verification_rejects_late_target_binding() { let (_, mut services, claim) = claimed_test_deletion("deletion-late-binding").await; services.media = deletion_test_media_storage(); @@ -2159,6 +2155,26 @@ mod tests { .expect("empty tenant prefixes verify clean"); } + mod external_infra_s3_tests { + #[tokio::test] + #[ignore = "requires Postgres and S3-compatible storage"] + async fn approved_stage_allows_post_inventory_row_churn_before_fencing() { + super::approved_stage_allows_post_inventory_row_churn_before_fencing().await; + } + + #[tokio::test] + #[ignore = "requires Postgres and S3-compatible storage"] + async fn drained_stage_resumes_chunk_deleted_before_stamp() { + super::drained_stage_resumes_chunk_deleted_before_stamp().await; + } + + #[tokio::test] + #[ignore = "requires Postgres and S3-compatible storage"] + async fn final_storage_verification_rejects_late_target_binding() { + super::final_storage_verification_rejects_late_target_binding().await; + } + } + #[tokio::test] #[ignore = "requires Postgres"] async fn stale_lease_during_failure_recording_is_lost_ownership() { @@ -2256,7 +2272,9 @@ mod tests { .await .expect("connect serving guard test DB"); let db = Db::from_pool(pool.clone()); - db.migrate().await.expect("migrate serving guard test DB"); + if std::env::var("BUZZ_TEST_SCHEMA_MODE").as_deref() != Ok("desired") { + db.migrate().await.expect("migrate serving guard test DB"); + } let community = db .ensure_configured_community(&format!( "serving-guard-{}.example", diff --git a/crates/buzz-push-gateway/src/postgres.rs b/crates/buzz-push-gateway/src/postgres.rs index 6cbfb45893d..17fff2a2429 100644 --- a/crates/buzz-push-gateway/src/postgres.rs +++ b/crates/buzz-push-gateway/src/postgres.rs @@ -510,15 +510,15 @@ impl AuthorityStore for PostgresAuthorityStore { } #[cfg(test)] -mod tests { +mod postgres_tests { use super::*; use sqlx::{postgres::PgPoolOptions, AssertSqlSafe}; - const TEST_DB_URL: &str = "postgres://buzz:buzz_dev@localhost:5432/buzz"; // sadscan:disable np.postgres.1 -- local test-only credentials + const TEST_DB_URL: &str = "postgres://buzz:buzz_dev@localhost:5432/buzz"; // sadscan:disable np.postgres.1 -- fixed localhost-only test credential #[tokio::test] #[ignore = "requires PostgreSQL with CREATEDB/CREATEROLE"] - async fn readiness_requires_migrated_schema_dml_and_no_ddl() { + async fn cluster_global_readiness_requires_migrated_schema_dml_and_no_ddl() { let admin_url = std::env::var("BUZZ_TEST_DATABASE_URL") .or_else(|_| std::env::var("DATABASE_URL")) .unwrap_or_else(|_| TEST_DB_URL.to_owned()); diff --git a/crates/buzz-relay/src/api/admin/mod.rs b/crates/buzz-relay/src/api/admin/mod.rs index 2f0d128fc87..19f2153b95b 100644 --- a/crates/buzz-relay/src/api/admin/mod.rs +++ b/crates/buzz-relay/src/api/admin/mod.rs @@ -1254,7 +1254,7 @@ fn summarize_body(body: &str, tags: &serde_json::Value) -> String { } #[cfg(test)] -mod tests { +mod postgres_tests { use super::*; use auth::ADMIN_API_PREFIX; use axum::{ @@ -1265,6 +1265,12 @@ mod tests { use tower::ServiceExt; use uuid::Uuid; + fn database_url() -> String { + std::env::var("BUZZ_TEST_DATABASE_URL").unwrap_or_else(|_| { + "postgres://buzz:buzz_dev@localhost:5432/buzz".to_string() // sadscan:disable np.postgres.1 -- local test-only credentials + }) + } + /// Deterministic operator keypair for the default authorized test state. /// Rostered as a config operator in `test_state()` so `authorized()` can /// mint NIP-98 credentials that resolve to an Operator principal without a @@ -1992,6 +1998,7 @@ mod tests { } #[tokio::test] + #[ignore = "requires PostgreSQL"] async fn nip98_mode_unrostered_signer_does_not_consume_a_replay_slot() { // Regression: the replay ID must be claimed only AFTER principal // resolution succeeds. A validly-signing but unrostered key (any @@ -2366,12 +2373,9 @@ mod tests { auth: crate::config::AdminAuth::Nip98, web_dir: None, }); - let pool = sqlx::PgPool::connect( - &std::env::var("BUZZ_TEST_DATABASE_URL") - .unwrap_or_else(|_| "postgres://buzz:buzz_dev@localhost:5432/buzz".to_string()), - ) - .await - .expect("connect to test DB"); + let pool = sqlx::PgPool::connect(&database_url()) + .await + .expect("connect to test DB"); let db = buzz_db::Db::from_pool(pool.clone()); let redis_pool = deadpool_redis::Config::from_url(&config.redis_url) .create_pool(Some(deadpool_redis::Runtime::Tokio1)) @@ -3270,12 +3274,9 @@ mod tests { // At the DB level: claim_report with two concurrent UUIDs on the same report_id. // FOR UPDATE row lock ensures serial execution; first commit wins, second // returns NotOpen. moderation_actions must have exactly 1 row. - let pool = sqlx::PgPool::connect( - &std::env::var("BUZZ_TEST_DATABASE_URL") - .unwrap_or_else(|_| "postgres://buzz:buzz_dev@localhost:5432/buzz".to_string()), - ) - .await - .expect("connect to test DB"); + let pool = sqlx::PgPool::connect(&database_url()) + .await + .expect("connect to test DB"); let community_id = { let id = uuid::Uuid::new_v4(); @@ -3372,12 +3373,9 @@ mod tests { async fn same_request_id_retry_returns_existing_action() { // Two POST /reports/{id}/resolve calls with the same requestId UUID. // Both should return 200 with the same actionId. - let pool = sqlx::PgPool::connect( - &std::env::var("BUZZ_TEST_DATABASE_URL") - .unwrap_or_else(|_| "postgres://buzz:buzz_dev@localhost:5432/buzz".to_string()), - ) - .await - .expect("connect to test DB"); + let pool = sqlx::PgPool::connect(&database_url()) + .await + .expect("connect to test DB"); let community_id = { let id = uuid::Uuid::new_v4(); @@ -3478,12 +3476,9 @@ mod tests { // // resolve_report_decision_atomic CASes on status='open'; if the report is // already 'processing', the transaction rolls back with no audit row. - let pool = sqlx::PgPool::connect( - &std::env::var("BUZZ_TEST_DATABASE_URL") - .unwrap_or_else(|_| "postgres://buzz:buzz_dev@localhost:5432/buzz".to_string()), - ) - .await - .expect("connect to test DB"); + let pool = sqlx::PgPool::connect(&database_url()) + .await + .expect("connect to test DB"); let community_id = { let id = uuid::Uuid::new_v4(); @@ -3578,12 +3573,9 @@ mod tests { // After an enforcement action reaches mutation_committed step_marker, // attempting to cancel the action record must fail (cancel is only // legal pre-mutation). - let pool = sqlx::PgPool::connect( - &std::env::var("BUZZ_TEST_DATABASE_URL") - .unwrap_or_else(|_| "postgres://buzz:buzz_dev@localhost:5432/buzz".to_string()), - ) - .await - .expect("connect to test DB"); + let pool = sqlx::PgPool::connect(&database_url()) + .await + .expect("connect to test DB"); let community_id = { let id = uuid::Uuid::new_v4(); @@ -3776,12 +3768,9 @@ mod tests { async fn reports_default_lists_escalated_only() { let keys = nostr::Keys::generate(); let state = nip98_state(vec![keys.public_key().to_hex()]).await; - let pool = sqlx::PgPool::connect( - &std::env::var("BUZZ_TEST_DATABASE_URL") - .unwrap_or_else(|_| "postgres://buzz:buzz_dev@localhost:5432/buzz".to_string()), - ) - .await - .expect("connect to test DB"); + let pool = sqlx::PgPool::connect(&database_url()) + .await + .expect("connect to test DB"); let escalated = seed_admin_host_report(&pool, "escalated").await; let open = seed_admin_host_report(&pool, "open").await; @@ -3803,12 +3792,9 @@ mod tests { async fn reports_scope_all_lists_every_status() { let keys = nostr::Keys::generate(); let state = nip98_state(vec![keys.public_key().to_hex()]).await; - let pool = sqlx::PgPool::connect( - &std::env::var("BUZZ_TEST_DATABASE_URL") - .unwrap_or_else(|_| "postgres://buzz:buzz_dev@localhost:5432/buzz".to_string()), - ) - .await - .expect("connect to test DB"); + let pool = sqlx::PgPool::connect(&database_url()) + .await + .expect("connect to test DB"); let escalated = seed_admin_host_report(&pool, "escalated").await; let open = seed_admin_host_report(&pool, "open").await; @@ -3827,12 +3813,9 @@ mod tests { async fn reports_explicit_status_filter_overrides_default() { let keys = nostr::Keys::generate(); let state = nip98_state(vec![keys.public_key().to_hex()]).await; - let pool = sqlx::PgPool::connect( - &std::env::var("BUZZ_TEST_DATABASE_URL") - .unwrap_or_else(|_| "postgres://buzz:buzz_dev@localhost:5432/buzz".to_string()), - ) - .await - .expect("connect to test DB"); + let pool = sqlx::PgPool::connect(&database_url()) + .await + .expect("connect to test DB"); let escalated = seed_admin_host_report(&pool, "escalated").await; let open = seed_admin_host_report(&pool, "open").await; @@ -3852,12 +3835,9 @@ mod tests { async fn reopen_route_returns_report_to_open_and_writes_audit_row() { let keys = nostr::Keys::generate(); let state = nip98_state(vec![keys.public_key().to_hex()]).await; - let pool = sqlx::PgPool::connect( - &std::env::var("BUZZ_TEST_DATABASE_URL") - .unwrap_or_else(|_| "postgres://buzz:buzz_dev@localhost:5432/buzz".to_string()), - ) - .await - .expect("connect to test DB"); + let pool = sqlx::PgPool::connect(&database_url()) + .await + .expect("connect to test DB"); let report_id = seed_admin_host_report(&pool, "resolved").await; let request_id = Uuid::new_v4(); @@ -3917,12 +3897,9 @@ mod tests { let operator_keys = nostr::Keys::generate(); let operator_bytes = operator_keys.public_key().to_bytes(); let state = nip98_state(vec![operator_keys.public_key().to_hex()]).await; - let pool = sqlx::PgPool::connect( - &std::env::var("BUZZ_TEST_DATABASE_URL") - .unwrap_or_else(|_| "postgres://buzz:buzz_dev@localhost:5432/buzz".to_string()), - ) - .await - .expect("connect to test DB"); + let pool = sqlx::PgPool::connect(&database_url()) + .await + .expect("connect to test DB"); let report_id = seed_admin_host_report(&pool, "open").await; // Unique per-invocation correlation: `reason` flows to the audit row's @@ -4014,12 +3991,9 @@ mod tests { // Only the operator is config-backed (Operator role); the target is a // fresh, mutable, non-config key. let state = nip98_state(vec![operator_keys.public_key().to_hex()]).await; - let pool = sqlx::PgPool::connect( - &std::env::var("BUZZ_TEST_DATABASE_URL") - .unwrap_or_else(|_| "postgres://buzz:buzz_dev@localhost:5432/buzz".to_string()), - ) - .await - .expect("connect to test DB"); + let pool = sqlx::PgPool::connect(&database_url()) + .await + .expect("connect to test DB"); let target_keys = nostr::Keys::generate(); let target_hex = target_keys.public_key().to_hex(); @@ -4118,12 +4092,9 @@ mod tests { async fn resolve_route_rejects_adversarial_expiration_and_leaves_report_open() { let keys = nostr::Keys::generate(); let state = nip98_state(vec![keys.public_key().to_hex()]).await; - let pool = sqlx::PgPool::connect( - &std::env::var("BUZZ_TEST_DATABASE_URL") - .unwrap_or_else(|_| "postgres://buzz:buzz_dev@localhost:5432/buzz".to_string()), - ) - .await - .expect("connect to test DB"); + let pool = sqlx::PgPool::connect(&database_url()) + .await + .expect("connect to test DB"); // 0, over-cap, i64::MAX magnitude, and a value that casts to a negative // i64 (wrapped-past-expiry) — all must reject before any state change. @@ -4187,12 +4158,9 @@ mod tests { async fn mixed_case_non_config_staffing_normalizes_to_one_row() { let operator_keys = nostr::Keys::generate(); let state = nip98_state(vec![operator_keys.public_key().to_hex()]).await; - let pool = sqlx::PgPool::connect( - &std::env::var("BUZZ_TEST_DATABASE_URL") - .unwrap_or_else(|_| "postgres://buzz:buzz_dev@localhost:5432/buzz".to_string()), - ) - .await - .expect("connect to test DB"); + let pool = sqlx::PgPool::connect(&database_url()) + .await + .expect("connect to test DB"); let target_keys = nostr::Keys::generate(); let lower_hex = target_keys.public_key().to_hex(); @@ -4280,12 +4248,9 @@ mod tests { async fn reopen_route_rejects_non_terminal_report_with_409() { let keys = nostr::Keys::generate(); let state = nip98_state(vec![keys.public_key().to_hex()]).await; - let pool = sqlx::PgPool::connect( - &std::env::var("BUZZ_TEST_DATABASE_URL") - .unwrap_or_else(|_| "postgres://buzz:buzz_dev@localhost:5432/buzz".to_string()), - ) - .await - .expect("connect to test DB"); + let pool = sqlx::PgPool::connect(&database_url()) + .await + .expect("connect to test DB"); let report_id = seed_admin_host_report(&pool, "open").await; let body = serde_json::json!({ "requestId": Uuid::new_v4() }).to_string(); @@ -4313,12 +4278,9 @@ mod tests { async fn cancel_route_returns_open_and_embeds_the_cancelled_action_dto() { let keys = nostr::Keys::generate(); let state = nip98_state(vec![keys.public_key().to_hex()]).await; - let pool = sqlx::PgPool::connect( - &std::env::var("BUZZ_TEST_DATABASE_URL") - .unwrap_or_else(|_| "postgres://buzz:buzz_dev@localhost:5432/buzz".to_string()), - ) - .await - .expect("connect to test DB"); + let pool = sqlx::PgPool::connect(&database_url()) + .await + .expect("connect to test DB"); let report_id = seed_admin_host_report(&pool, "open").await; let community_id: Uuid = sqlx::query_scalar("SELECT community_id FROM moderation_reports WHERE id = $1") @@ -4435,12 +4397,9 @@ mod tests { // community fence — can block this: it is the sharper negative case. let keys = nostr::Keys::generate(); let state = nip98_state(vec![keys.public_key().to_hex()]).await; - let pool = sqlx::PgPool::connect( - &std::env::var("BUZZ_TEST_DATABASE_URL") - .unwrap_or_else(|_| "postgres://buzz:buzz_dev@localhost:5432/buzz".to_string()), - ) - .await - .expect("connect to test DB"); + let pool = sqlx::PgPool::connect(&database_url()) + .await + .expect("connect to test DB"); // Two reports on the same admin.example community, each driven to // `processing` with its own distinct pre-mutation `failed` action. @@ -4582,12 +4541,9 @@ mod tests { // Simulate a crash after mutation_committed but before finalization. // Re-drive from persisted step state must produce exactly one // enforcement, one report transition, one audit chain, one reporter notice. - let pool = sqlx::PgPool::connect( - &std::env::var("BUZZ_TEST_DATABASE_URL") - .unwrap_or_else(|_| "postgres://buzz:buzz_dev@localhost:5432/buzz".to_string()), - ) - .await - .expect("connect to test DB"); + let pool = sqlx::PgPool::connect(&database_url()) + .await + .expect("connect to test DB"); let community_id = { let id = uuid::Uuid::new_v4(); @@ -4797,8 +4753,7 @@ mod tests { } async fn e2e_pool() -> sqlx::PgPool { - let url = std::env::var("BUZZ_TEST_DATABASE_URL") - .unwrap_or_else(|_| "postgres://buzz:buzz_dev@localhost:5432/buzz".to_string()); + let url = database_url(); sqlx::PgPool::connect(&url) .await .expect("connect to test DB") @@ -7345,8 +7300,7 @@ mod tests { // Our outbox row's created_at is ~10 s ago → trigger fires on insert_event. // This pool is fully isolated: no other pool or test is affected, and there // is no cleanup dependence (dropping the pool closes all its connections). - let db_url = std::env::var("BUZZ_TEST_DATABASE_URL") - .unwrap_or_else(|_| "postgres://buzz:buzz_dev@localhost:5432/buzz".to_string()); + let db_url = database_url(); let floor_pool = sqlx::postgres::PgPoolOptions::new() .max_connections(4) .after_connect(|conn, _meta| { diff --git a/crates/buzz-relay/src/api/bridge.rs b/crates/buzz-relay/src/api/bridge.rs index 5dbb2aaf50c..f64426900df 100644 --- a/crates/buzz-relay/src/api/bridge.rs +++ b/crates/buzz-relay/src/api/bridge.rs @@ -2462,7 +2462,7 @@ fn ban_json(b: &buzz_db::moderation::BanRecord) -> Value { } #[cfg(test)] -mod tests { +mod postgres_tests { use super::*; use nostr::{Alphabet, EventBuilder, Keys, Kind, SingleLetterTag, Tag}; use std::sync::Mutex; @@ -2697,8 +2697,6 @@ mod tests { /// replay of the same event id in the same community is rejected. The same /// id in a different community still succeeds, proving the key is scoped by /// server-resolved tenant rather than global process memory. - #[tokio::test] - #[ignore = "requires Redis"] async fn nip98_replay_guard_rejects_cross_pod_replay_on_bridge_path() { let pool = redis_pool(); let pod_a = buzz_pubsub::RedisNip98ReplayGuard::new(pool.clone()); @@ -2726,8 +2724,6 @@ mod tests { /// rejection. A single guard instance, called twice with the same /// `TenantContext` and the same event id, MUST reject the second call. /// Bites if `try_mark`'s admit/reject mapping is reversed or no-op'd. - #[tokio::test] - #[ignore = "requires Redis"] async fn nip98_replay_guard_rejects_same_pod_same_community_replay() { let pool = redis_pool(); let pod = buzz_pubsub::RedisNip98ReplayGuard::new(pool); @@ -2744,6 +2740,20 @@ mod tests { assert_eq!(status, StatusCode::UNAUTHORIZED); } + mod external_infra_redis_tests { + #[tokio::test] + #[ignore = "requires Redis"] + async fn nip98_replay_guard_rejects_cross_pod_replay_on_bridge_path() { + super::nip98_replay_guard_rejects_cross_pod_replay_on_bridge_path().await; + } + + #[tokio::test] + #[ignore = "requires Redis"] + async fn nip98_replay_guard_rejects_same_pod_same_community_replay() { + super::nip98_replay_guard_rejects_same_pod_same_community_replay().await; + } + } + /// Attack 3 fail-closed guard: a stateless worker that loses Redis MUST /// reject the request, never admit it. The shared seen-set is the /// freshness fence; degrading to "best effort, allow on error" forfeits @@ -3744,8 +3754,6 @@ mod tests { } } - const TEST_DB_URL: &str = "postgres://buzz:buzz_dev@localhost:5432/buzz"; // sadscan:disable np.postgres.1 - /// Build an AppState suitable for handler-level bridge tests. /// /// - `require_auth_token = false` → X-Pubkey dev-mode fallback active. @@ -3758,7 +3766,7 @@ mod tests { /// Returns `None` when local Postgres is not reachable. async fn bridge_handler_test_state() -> Option> { let mut config = crate::config::Config::from_env().ok()?; - config.database_url = TEST_DB_URL.to_string(); + config.database_url = crate::test_support::database_url(); // Use the real local Redis so enforce_http_admission can pass. config.redis_url = std::env::var("REDIS_URL").unwrap_or_else(|_| "redis://127.0.0.1:6379".to_string()); @@ -3766,7 +3774,9 @@ mod tests { config.require_auth_token = false; config.require_relay_membership = false; - let pool = sqlx::PgPool::connect(TEST_DB_URL).await.ok()?; + let pool = sqlx::PgPool::connect(&crate::test_support::database_url()) + .await + .ok()?; let db = buzz_db::Db::from_pool(pool.clone()); let redis_pool = deadpool_redis::Config::from_url(&config.redis_url) .create_pool(Some(deadpool_redis::Runtime::Tokio1)) diff --git a/crates/buzz-relay/src/api/git/policy.rs b/crates/buzz-relay/src/api/git/policy.rs index 32d63f46008..40d4eea0352 100644 --- a/crates/buzz-relay/src/api/git/policy.rs +++ b/crates/buzz-relay/src/api/git/policy.rs @@ -462,7 +462,7 @@ pub fn generate_hook_hmac( } #[cfg(test)] -mod tests { +mod postgres_tests { use super::*; fn make_request() -> HookCallbackRequest { diff --git a/crates/buzz-relay/src/api/git/transport.rs b/crates/buzz-relay/src/api/git/transport.rs index ec7af3aac65..ffeb3ee46ea 100644 --- a/crates/buzz-relay/src/api/git/transport.rs +++ b/crates/buzz-relay/src/api/git/transport.rs @@ -2424,8 +2424,6 @@ mod track_c_tests { } } - #[tokio::test] - #[ignore = "requires Postgres and MinIO"] async fn repo_announcement_holds_serving_lease_until_pointer_is_seeded() { let (state, pool) = finalize_test_state().await; let host = format!( @@ -2522,8 +2520,6 @@ mod track_c_tests { pool.close().await; } - #[tokio::test] - #[ignore = "requires Postgres and MinIO"] async fn finalize_push_holds_serving_lease_through_post_cas_publication() { let (state, pool) = finalize_test_state().await; let host = format!("git-finalize-{}.example", uuid::Uuid::new_v4().simple()); @@ -2615,8 +2611,6 @@ mod track_c_tests { pool.close().await; } - #[tokio::test] - #[ignore = "requires Postgres and MinIO"] async fn finalize_push_db_failure_after_cas_is_not_success_and_releases_lease() { let (state, pool) = finalize_test_state().await; let host = format!( @@ -2657,6 +2651,26 @@ mod track_c_tests { pool.close().await; } + mod external_infra_minio_tests { + #[tokio::test] + #[ignore = "requires Postgres and MinIO"] + async fn repo_announcement_holds_serving_lease_until_pointer_is_seeded() { + super::repo_announcement_holds_serving_lease_until_pointer_is_seeded().await; + } + + #[tokio::test] + #[ignore = "requires Postgres and MinIO"] + async fn finalize_push_holds_serving_lease_through_post_cas_publication() { + super::finalize_push_holds_serving_lease_through_post_cas_publication().await; + } + + #[tokio::test] + #[ignore = "requires Postgres and MinIO"] + async fn finalize_push_db_failure_after_cas_is_not_success_and_releases_lease() { + super::finalize_push_db_failure_after_cas_is_not_success_and_releases_lease().await; + } + } + /// A gzip-encoded request body is transparently inflated before it /// reaches the git subprocess. Git's smart-HTTP client gzips the /// upload-pack/receive-pack request body past a size threshold (fires @@ -3169,7 +3183,7 @@ mod track_c_tests { } #[cfg(test)] -mod sec005_read_gate_tests { +mod sec005_postgres_tests { use super::*; use nostr::{EventBuilder, Keys, Kind, Tag}; diff --git a/crates/buzz-relay/src/api/invites.rs b/crates/buzz-relay/src/api/invites.rs index d09c7fc6119..2c0a44d244d 100644 --- a/crates/buzz-relay/src/api/invites.rs +++ b/crates/buzz-relay/src/api/invites.rs @@ -537,7 +537,7 @@ fn claim_key_rate_limited( } #[cfg(test)] -mod tests { +mod postgres_tests { use std::sync::Arc; use std::time::Duration; diff --git a/crates/buzz-relay/src/api/operator.rs b/crates/buzz-relay/src/api/operator.rs index f19ac17d4c1..012a129a97b 100644 --- a/crates/buzz-relay/src/api/operator.rs +++ b/crates/buzz-relay/src/api/operator.rs @@ -498,7 +498,7 @@ pub async fn community_availability( } #[cfg(test)] -mod tests { +mod postgres_tests { use std::sync::Arc; use axum::{ @@ -532,8 +532,6 @@ mod tests { Box::pin(async { Ok(true) }) } } - - const TEST_DB_URL: &str = "postgres://buzz:buzz_dev@localhost:5432/buzz"; // sadscan:disable np.postgres.1 const INGRESS_HOST: &str = "operator-ingress.example"; fn nip98_auth_header(keys: &Keys, url: &str, method: &str, body: Option<&[u8]>) -> String { @@ -571,7 +569,7 @@ mod tests { async fn operator_test_state(operator_keys: &[Keys]) -> Option> { let mut config = crate::config::Config::from_env().ok()?; - config.database_url = TEST_DB_URL.to_string(); + config.database_url = crate::test_support::database_url(); config.redis_url = "redis://127.0.0.1:1".to_string(); config.relay_url = "wss://tenant.example".to_string(); config.relay_operator_api_origin = Some(format!("http://{INGRESS_HOST}")); @@ -581,7 +579,9 @@ mod tests { .collect(); config.require_relay_membership = true; - let pool = sqlx::PgPool::connect(TEST_DB_URL).await.ok()?; + let pool = sqlx::PgPool::connect(&crate::test_support::database_url()) + .await + .ok()?; let db = buzz_db::Db::from_pool(pool.clone()); let redis_pool = deadpool_redis::Config::from_url(&config.redis_url) diff --git a/crates/buzz-relay/src/handlers/command_executor.rs b/crates/buzz-relay/src/handlers/command_executor.rs index ae7adc98143..41d7900b7d1 100644 --- a/crates/buzz-relay/src/handlers/command_executor.rs +++ b/crates/buzz-relay/src/handlers/command_executor.rs @@ -1367,21 +1367,23 @@ async fn resume_workflow_after_approval( } #[cfg(test)] -mod tests { +mod postgres_tests { use super::*; use nostr::{EventBuilder, Keys, Kind, Tag, Timestamp}; async fn persistence_test_context() -> (buzz_db::Db, TenantContext) { let url = std::env::var("BUZZ_TEST_DATABASE_URL") .or_else(|_| std::env::var("DATABASE_URL")) - .unwrap_or_else(|_| "postgres://buzz:buzz_dev@localhost:5432/buzz".to_string()); + .unwrap_or_else(|_| "postgres://buzz:buzz_dev@localhost:5432/buzz".to_string()); // sadscan:disable np.postgres.1 -- local test-only credentials let pool = sqlx::PgPool::connect(&url) .await .expect("connect workflow persistence test database"); let db = buzz_db::Db::from_pool(pool); - db.migrate() - .await - .expect("migrate workflow persistence test database"); + if std::env::var("BUZZ_TEST_SCHEMA_MODE").as_deref() != Ok("desired") { + db.migrate() + .await + .expect("migrate workflow persistence test database"); + } let host = format!("workflow-cas-{}.example", Uuid::new_v4().simple()); let community = db .ensure_configured_community(&host) @@ -1509,7 +1511,9 @@ mod tests { )); let create_revision = create.id.to_hex(); - let mut updates = (0..64).map(|index| { + // Event IDs are hashes, so keep sampling instead of imposing a finite + // cutoff that makes this same-second ordering check probabilistic. + let mut updates = (0_u64..).map(|index| { workflow_event( &keys, workflow_id, @@ -1521,7 +1525,7 @@ mod tests { let update = updates .find(|candidate| candidate.id.as_bytes() < create.id.as_bytes()) .expect("find same-second update that wins NIP-33 ordering"); - let dominated_update = (64..256) + let dominated_update = (64_u64..) .map(|index| { workflow_event( &keys, diff --git a/crates/buzz-relay/src/handlers/ingest.rs b/crates/buzz-relay/src/handlers/ingest.rs index dd2fa6e93e0..b4a3e24f8f4 100644 --- a/crates/buzz-relay/src/handlers/ingest.rs +++ b/crates/buzz-relay/src/handlers/ingest.rs @@ -3275,7 +3275,7 @@ async fn ingest_event_inner( } #[cfg(test)] -mod tests { +mod postgres_tests { use std::sync::Mutex; use super::*; @@ -3528,7 +3528,9 @@ mod tests { .unwrap_or_else(|_| "postgres://buzz:buzz_dev@localhost:5432/buzz".to_string()); // sadscan:disable np.postgres.1 let pool = sqlx::PgPool::connect(&url).await.expect("connect test DB"); let db = buzz_db::Db::from_pool(pool); - db.migrate().await.expect("migrate test DB"); + if std::env::var("BUZZ_TEST_SCHEMA_MODE").as_deref() != Ok("desired") { + db.migrate().await.expect("migrate test DB"); + } let store = buzz_deletion::store(&db); let host = format!("lane3-fence-{}.example", Uuid::new_v4().simple()); diff --git a/crates/buzz-relay/src/handlers/relay_admin.rs b/crates/buzz-relay/src/handlers/relay_admin.rs index 3782f2c516d..56f0e78d3c1 100644 --- a/crates/buzz-relay/src/handlers/relay_admin.rs +++ b/crates/buzz-relay/src/handlers/relay_admin.rs @@ -484,7 +484,7 @@ async fn execute_relay_admin_command( } #[cfg(test)] -mod tests { +mod postgres_tests { use super::*; use nostr::{EventBuilder, Keys, Kind, Tag}; diff --git a/crates/buzz-relay/src/lib.rs b/crates/buzz-relay/src/lib.rs index 800433a8498..005e3c384ed 100644 --- a/crates/buzz-relay/src/lib.rs +++ b/crates/buzz-relay/src/lib.rs @@ -45,6 +45,8 @@ pub mod subscription; pub mod telemetry; /// Row-zero host binding: resolve the request community from the connection host. pub mod tenant; +#[cfg(test)] +mod test_support; /// Relay-side tunnel session directory and routing. pub mod tunnel; /// Webhook secret generation and constant-time comparison. diff --git a/crates/buzz-relay/src/test_support.rs b/crates/buzz-relay/src/test_support.rs new file mode 100644 index 00000000000..6936a60ae4e --- /dev/null +++ b/crates/buzz-relay/src/test_support.rs @@ -0,0 +1,9 @@ +const DEFAULT_DATABASE_URL: &str = "postgres://buzz:buzz_dev@localhost:5432/buzz"; // sadscan:disable np.postgres.1 -- local test-only credentials + +/// Resolve the database URL shared by PostgreSQL-backed relay tests. +pub(crate) fn database_url() -> String { + std::env::var("BUZZ_TEST_DATABASE_URL") + .or_else(|_| std::env::var("TEST_DATABASE_URL")) + .or_else(|_| std::env::var("DATABASE_URL")) + .unwrap_or_else(|_| DEFAULT_DATABASE_URL.to_owned()) +} diff --git a/crates/buzz-relay/src/workflow_sink.rs b/crates/buzz-relay/src/workflow_sink.rs index 8ce23a2e8ea..b996c5b0acd 100644 --- a/crates/buzz-relay/src/workflow_sink.rs +++ b/crates/buzz-relay/src/workflow_sink.rs @@ -626,7 +626,7 @@ mod tests { } #[cfg(test)] -mod integration_tests { +mod postgres_tests { //! Regression test for `e3661764` / `7899c1a8`: a workflow `send_message` //! that mentions a channel member by name (`@Name`) must emit a `p` tag for //! that member so ACP agent wake (`event_mentions_agent`, p-tag gated) fires. diff --git a/crates/buzz-search/tests/fts_integration.rs b/crates/buzz-search/tests/postgres_fts_integration.rs similarity index 100% rename from crates/buzz-search/tests/fts_integration.rs rename to crates/buzz-search/tests/postgres_fts_integration.rs diff --git a/crates/buzz-workflow/src/lib.rs b/crates/buzz-workflow/src/lib.rs index bceb6d8bd8d..ee1c7467762 100644 --- a/crates/buzz-workflow/src/lib.rs +++ b/crates/buzz-workflow/src/lib.rs @@ -1047,7 +1047,7 @@ fn trigger_matches_event(trigger: &TriggerDef, kind_u32: u32) -> bool { } #[cfg(test)] -mod tests { +mod postgres_tests { use super::*; #[test] diff --git a/scripts/check-postgres-test-discovery.py b/scripts/check-postgres-test-discovery.py new file mode 100755 index 00000000000..4e621b3dd84 --- /dev/null +++ b/scripts/check-postgres-test-discovery.py @@ -0,0 +1,378 @@ +#!/usr/bin/env python3 +"""Validate structural discovery for ignored PostgreSQL-backed Rust tests.""" + +from __future__ import annotations + +import re +import sys +import tomllib +from pathlib import Path + +IGNORE_ATTRIBUTE = re.compile(r"#\s*\[\s*ignore\s*=") +BARE_IGNORE_ATTRIBUTE = re.compile(r"#\s*\[\s*ignore\s*\]") +FUNCTION = re.compile(r"\b(?:async\s+)?fn\s+(?P[A-Za-z_][A-Za-z0-9_]*)") +MODULE = re.compile(r"\bmod\s+(?P[A-Za-z_][A-Za-z0-9_]*)\s*\{") +OUT_OF_LINE_MODULE = re.compile(r"\bmod\s+(?P[A-Za-z_][A-Za-z0-9_]*)\s*;") +PATH_ATTRIBUTE = re.compile(r"#\s*\[\s*path\s*=\s*") +EXTERNAL_INFRA = re.compile(r"\b(?:s3|minio|storage|docker|network)\b", re.IGNORECASE) +RAW_STRING = re.compile(r'(?:b?r)(?P#{0,255})"') +CHAR_LITERAL = re.compile(r"(?:b)?'(?:\\(?:u\{[0-9A-Fa-f_]+\}|x[0-9A-Fa-f]{2}|.)|[^\\'\n])'") + + +def sanitize_rust(source: str) -> str: + """Blank comments and literals while preserving byte offsets and braces.""" + chars = list(source) + index = 0 + length = len(source) + while index < length: + if source.startswith("//", index): + end = source.find("\n", index + 2) + end = length if end == -1 else end + for offset in range(index, end): + chars[offset] = " " + index = end + continue + if source.startswith("/*", index): + start = index + depth = 1 + index += 2 + while index < length and depth: + if source.startswith("/*", index): + depth += 1 + index += 2 + elif source.startswith("*/", index): + depth -= 1 + index += 2 + else: + index += 1 + for offset in range(start, index): + if chars[offset] != "\n": + chars[offset] = " " + continue + + character = CHAR_LITERAL.match(source, index) + if character: + start = index + index = character.end() + for offset in range(start, index): + chars[offset] = " " + continue + + raw = RAW_STRING.match(source, index) + if raw: + start = index + terminator = '"' + raw.group("hashes") + index = raw.end() + end = source.find(terminator, index) + index = length if end == -1 else end + len(terminator) + for offset in range(start, index): + if chars[offset] != "\n": + chars[offset] = " " + continue + + quote_start = index + if source.startswith('b"', index): + index += 1 + if source[index] == '"': + index += 1 + while index < length: + if source[index] == "\\": + index += 2 + elif source[index] == '"': + index += 1 + break + else: + index += 1 + for offset in range(quote_start, min(index, length)): + if chars[offset] != "\n": + chars[offset] = " " + continue + index += 1 + return "".join(chars) + + +def parse_rust_string_literal(source: str, start: int) -> tuple[str, int] | None: + """Parse an ordinary or raw Rust string literal at or after start.""" + index = start + while index < len(source) and source[index].isspace(): + index += 1 + + raw = RAW_STRING.match(source, index) + if raw: + content_start = raw.end() + terminator = '"' + raw.group("hashes") + content_end = source.find(terminator, content_start) + if content_end == -1: + return None + return source[content_start:content_end], content_end + len(terminator) + + if index >= len(source) or source[index] != '"': + return None + index += 1 + content = [] + while index < len(source): + if source[index] == "\\": + if index + 1 >= len(source): + return None + content.append(source[index + 1]) + index += 2 + elif source[index] == '"': + return "".join(content), index + 1 + else: + content.append(source[index]) + index += 1 + return None + + +def ignore_attributes(source: str, sanitized: str) -> list[tuple[int, int, str]]: + """Return real ignore attributes and reasons, excluding comments.""" + attributes = [] + for match in IGNORE_ATTRIBUTE.finditer(sanitized): + parsed = parse_rust_string_literal(source, match.end()) + if parsed is None: + continue + reason, literal_end = parsed + attribute_end = literal_end + while attribute_end < len(source) and source[attribute_end].isspace(): + attribute_end += 1 + if attribute_end >= len(source) or source[attribute_end] != "]": + continue + attributes.append((match.start(), attribute_end + 1, reason)) + return attributes + + +def module_ranges(source: str) -> list[tuple[int, int, str]]: + sanitized = sanitize_rust(source) + brace_pairs: dict[int, int] = {} + stack: list[int] = [] + for index, char in enumerate(sanitized): + if char == "{": + stack.append(index) + elif char == "}" and stack: + brace_pairs[stack.pop()] = index + + ranges = [] + for match in MODULE.finditer(sanitized): + open_brace = sanitized.find("{", match.start(), match.end()) + close_brace = brace_pairs.get(open_brace) + if close_brace is not None: + ranges.append((open_brace, close_brace, match.group("name"))) + return ranges + + +def crate_root(path: Path) -> Path | None: + for candidate in path.parents: + if (candidate / "Cargo.toml").is_file(): + return candidate + return None + + +def integration_binary_is_postgres(path: Path) -> bool: + root = crate_root(path) + return ( + root is not None + and path.parent == root / "tests" + and path.name.startswith("postgres_") + ) + + +def out_of_line_module_index(files: list[Path]) -> dict[Path, list[str]]: + """Index explicit-path module names by their resolved source file.""" + names: dict[Path, list[str]] = {} + context_files = set(files) + for directory in {path.parent for path in files}: + context_files.update(directory.glob("*.rs")) + for parent_source in sorted(context_files): + source = parent_source.read_text(encoding="utf-8") + if "path" not in source: + continue + sanitized = sanitize_rust(source) + for attribute in PATH_ATTRIBUTE.finditer(sanitized): + equals = source.find("=", attribute.start(), attribute.end()) + parsed = parse_rust_string_literal(source, equals + 1) + if parsed is None: + continue + module_path, literal_end = parsed + module = OUT_OF_LINE_MODULE.search(sanitized, literal_end) + if module is not None: + resolved_path = (parent_source.parent / module_path).resolve() + names.setdefault(resolved_path, []).append(module.group("name")) + return names + + +def file_has_postgres_lane_test( + path: Path, out_of_line_modules: dict[Path, list[str]] +) -> bool: + source = path.read_text(encoding="utf-8") + sanitized = sanitize_rust(source) + ranges = module_ranges(source) + external_modules = out_of_line_modules.get(path.resolve(), []) + + for attribute_start, _attribute_end, reason in ignore_attributes(source, sanitized): + reason_lower = reason.lower() + modules = [name for start, end, name in ranges if start < attribute_start < end] + if ( + ("postgres" in reason_lower or "postgresql" in reason_lower) + and not EXTERNAL_INFRA.search(reason) + and not any(name.startswith("external_infra") for name in modules) + and ( + any(name.endswith("postgres_tests") for name in modules + external_modules) + or integration_binary_is_postgres(path) + ) + ): + return True + return False + + +def postgres_packages( + files: list[Path], out_of_line_modules: dict[Path, list[str]] +) -> list[str]: + roots = { + root + for path in files + if file_has_postgres_lane_test(path, out_of_line_modules) + if (root := crate_root(path)) is not None + } + packages = [] + for root in roots: + with (root / "Cargo.toml").open("rb") as manifest: + package = tomllib.load(manifest).get("package", {}) + name = package.get("name") + if not isinstance(name, str) or not name: + raise ValueError(f"discoverable PostgreSQL tests lack a package name: {root}") + packages.append(name) + return sorted(packages) + + +def validate_file( + path: Path, out_of_line_modules: dict[Path, list[str]] +) -> list[str]: + source = path.read_text(encoding="utf-8") + sanitized = sanitize_rust(source) + ranges = module_ranges(source) + external_modules = out_of_line_modules.get(path.resolve(), []) + errors = [] + + for match in BARE_IGNORE_ATTRIBUTE.finditer(sanitized): + function = FUNCTION.search(sanitized, match.end()) + if function is None: + errors.append(f"{path}: ignored infrastructure test has no following function") + continue + modules = [name for start, end, name in ranges if start < match.start() < end] + in_postgres_structure = ( + any(name.endswith("postgres_tests") for name in modules + external_modules) + or integration_binary_is_postgres(path) + ) + if in_postgres_structure: + errors.append( + f"{path}:{source.count(chr(10), 0, function.start()) + 1}: " + f"{function.group('name')} uses bare #[ignore] in PostgreSQL discovery; " + 'use #[ignore = "requires PostgreSQL"] or an explicit external-infra reason' + ) + + for attribute_start, attribute_end, reason in ignore_attributes(source, sanitized): + reason_lower = reason.lower() + mentions_postgres = "postgres" in reason_lower or "postgresql" in reason_lower + mentions_redis = "redis" in reason_lower + if not mentions_postgres and not mentions_redis: + continue + + function = FUNCTION.search(sanitized, attribute_end) + if function is None: + errors.append(f"{path}: ignored infrastructure test has no following function") + continue + function_name = function.group("name") + modules = [ + name for start, end, name in ranges if start < attribute_start < end + ] + in_postgres_lane = ( + any(name.endswith("postgres_tests") for name in modules + external_modules) + or integration_binary_is_postgres(path) + ) + in_external_module = any(name.startswith("external_infra") for name in modules) + needs_external_infra = bool(EXTERNAL_INFRA.search(reason)) + + if mentions_redis and not mentions_postgres and in_postgres_lane and not in_external_module: + errors.append( + f"{path}:{source.count(chr(10), 0, function.start()) + 1}: " + f"{function_name} is Redis-only but sits inside PostgreSQL discovery; " + "move it under an external_infra* module" + ) + elif needs_external_infra and not in_external_module: + errors.append( + f"{path}:{source.count(chr(10), 0, function.start()) + 1}: " + f"{function_name} requires infrastructure beyond PostgreSQL/Redis; " + "move it under an external_infra* module" + ) + elif mentions_postgres and in_external_module and not needs_external_infra: + errors.append( + f"{path}:{source.count(chr(10), 0, function.start()) + 1}: " + f"{function_name} requires only PostgreSQL/Redis but is excluded by an " + "external_infra* module; move it into PostgreSQL discovery" + ) + elif mentions_postgres and not needs_external_infra and not in_postgres_lane: + errors.append( + f"{path}:{source.count(chr(10), 0, function.start()) + 1}: " + f"{function_name} requires PostgreSQL but is not discoverable; " + "place it under postgres_tests or in a postgres_* integration binary" + ) + + return errors + + +def rust_files(arguments: list[str]) -> list[Path]: + files = [] + for argument in arguments: + path = Path(argument) + if path.is_dir(): + files.extend(candidate for candidate in path.rglob("*.rs") if "target" not in candidate.parts) + elif path.suffix == ".rs": + files.append(path) + else: + raise ValueError(f"not a Rust source file or directory: {path}") + return sorted(set(files)) + + +def main() -> int: + arguments = sys.argv[1:] + print_packages = bool(arguments and arguments[0] == "--print-packages") + if print_packages: + arguments = arguments[1:] + if not arguments: + print( + f"usage: {Path(sys.argv[0]).name} [--print-packages] " + " [...]", + file=sys.stderr, + ) + return 2 + try: + files = rust_files(arguments) + except ValueError as error: + print(error, file=sys.stderr) + return 2 + out_of_line_modules = out_of_line_module_index(files) + errors = [ + error + for path in files + for error in validate_file(path, out_of_line_modules) + ] + if errors: + print("PostgreSQL test discovery validation failed:", file=sys.stderr) + for error in errors: + print(f" - {error}", file=sys.stderr) + return 1 + if print_packages: + try: + packages = postgres_packages(files, out_of_line_modules) + except (OSError, tomllib.TOMLDecodeError, ValueError) as error: + print(error, file=sys.stderr) + return 1 + for package in packages: + print(package) + return 0 + print(f"validated PostgreSQL test discovery across {len(files)} Rust source files") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/postgres-test-packages.sh b/scripts/postgres-test-packages.sh new file mode 100755 index 00000000000..3a0b3673c9b --- /dev/null +++ b/scripts/postgres-test-packages.sh @@ -0,0 +1,8 @@ +#!/usr/bin/env bash +set -euo pipefail + +repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +source_root="${1:-$repo_root/crates}" + +exec python3 "$repo_root/scripts/check-postgres-test-discovery.py" \ + --print-packages "$source_root" diff --git a/scripts/postgres-test-run.sh b/scripts/postgres-test-run.sh new file mode 100755 index 00000000000..4eab9dd4274 --- /dev/null +++ b/scripts/postgres-test-run.sh @@ -0,0 +1,82 @@ +#!/usr/bin/env bash +# Run the discoverable PostgreSQL lane and remove its desired-state source +# database even when nextest fails or is interrupted. +set -euo pipefail + +: "${BUZZ_POSTGRES_ADMIN_URL:?set BUZZ_POSTGRES_ADMIN_URL to an administrator database URL}" + +repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" + +resolve_pg_command() { + local name="$1" + local candidate + if [[ -n "${PG_BIN_DIR:-}" ]]; then + candidate="${PG_BIN_DIR}/${name}" + else + candidate="$(command -v "$name" || true)" + fi + if [[ -z "$candidate" || ! -x "$candidate" ]]; then + echo "required PostgreSQL client is not executable: ${candidate:-$name}" >&2 + exit 1 + fi + printf '%s\n' "$candidate" +} + +sha256_hex() { + if command -v sha256sum >/dev/null 2>&1; then + sha256sum | awk '{print $1}' + elif command -v shasum >/dev/null 2>&1; then + shasum -a 256 | awk '{print $1}' + elif command -v openssl >/dev/null 2>&1; then + openssl dgst -sha256 | awk '{print $NF}' + else + echo "a SHA-256 utility (sha256sum, shasum, or openssl) is required" >&2 + return 1 + fi +} + +dropdb="$(resolve_pg_command dropdb)" +run_identity="${BUZZ_TEST_RUN_ID:-${USER:-buzz}:$$:$(date -u +%s):${RANDOM:-0}}" +run_hash="$(printf '%s' "$run_identity" | sha256_hex)" +run_hash="${run_hash:0:20}" +template_database="buzz_nt_${run_hash}_desired" +export BUZZ_TEST_RUN_ID="$run_identity" +export BUZZ_POSTGRES_DESIRED_TEMPLATE="$template_database" + +cleanup() { + local attempt + for attempt in 1 2 3 4 5; do + if "$dropdb" --if-exists --force \ + --maintenance-db="$BUZZ_POSTGRES_ADMIN_URL" \ + "$template_database" >/dev/null 2>&1; then + return 0 + fi + if [[ "$attempt" -lt 5 ]]; then + sleep 1 + fi + done + echo "warning: failed to remove PostgreSQL source database after 5 attempts: $template_database" >&2 + return 0 +} +trap cleanup EXIT +trap 'exit 129' HUP +trap 'exit 130' INT +trap 'exit 143' TERM + +nextest_args=("$@") +if [[ "$#" -eq 0 ]]; then + package_args=() + while IFS= read -r package; do + package_args+=(-p "$package") + done < <("$repo_root/scripts/postgres-test-packages.sh") + if [[ "${#package_args[@]}" -eq 0 ]]; then + echo "no PostgreSQL test packages were discovered" >&2 + exit 1 + fi + nextest_args=("${package_args[@]}" --lib --tests) +fi + +cargo nextest run \ + --profile postgres-ci \ + --run-ignored ignored-only \ + "${nextest_args[@]}" diff --git a/scripts/postgres-test-setup.sh b/scripts/postgres-test-setup.sh new file mode 100755 index 00000000000..0a9f916a9a5 --- /dev/null +++ b/scripts/postgres-test-setup.sh @@ -0,0 +1,73 @@ +#!/usr/bin/env bash +# Build one desired-state source database for a nextest PostgreSQL run. Each +# selected test clones it (or template0 for migration-owned tests). The outer +# postgres-test-run.sh process owns and removes the source database. +set -euo pipefail + +: "${NEXTEST_ENV:?nextest must provide NEXTEST_ENV}" +: "${BUZZ_POSTGRES_ADMIN_URL:?set BUZZ_POSTGRES_ADMIN_URL to an administrator database URL}" +: "${BUZZ_POSTGRES_DESIRED_TEMPLATE:?postgres-test-run.sh must provide the desired-state database name}" +: "${PGHOST:?set PGHOST for pgschema}" +: "${PGPORT:?set PGPORT for pgschema}" +: "${PGUSER:?set PGUSER for pgschema}" +: "${PGPASSWORD:?set PGPASSWORD for pgschema}" + +resolve_pg_command() { + local name="$1" + local candidate + if [[ -n "${PG_BIN_DIR:-}" ]]; then + candidate="${PG_BIN_DIR}/${name}" + else + candidate="$(command -v "$name" || true)" + fi + if [[ -z "$candidate" || ! -x "$candidate" ]]; then + echo "required PostgreSQL client is not executable: ${candidate:-$name}" >&2 + exit 1 + fi + printf '%s\n' "$candidate" +} + +psql="$(resolve_pg_command psql)" +createdb="$(resolve_pg_command createdb)" +dropdb="$(resolve_pg_command dropdb)" + +workspace_root="${NEXTEST_WORKSPACE_ROOT:-$(cd "$(dirname "$0")/.." && pwd)}" +template_database="$BUZZ_POSTGRES_DESIRED_TEMPLATE" +if [[ ! "$template_database" =~ ^buzz_nt_[0-9a-f]{20}_desired$ ]]; then + echo "refusing unsafe desired-state database name: $template_database" >&2 + exit 1 +fi +run_hash="${template_database#buzz_nt_}" +run_hash="${run_hash%_desired}" + +"$dropdb" --if-exists --force \ + --maintenance-db="$BUZZ_POSTGRES_ADMIN_URL" \ + "$template_database" >/dev/null 2>&1 +"$createdb" \ + --maintenance-db="$BUZZ_POSTGRES_ADMIN_URL" \ + --template=template0 \ + "$template_database" + +export PGDATABASE="$template_database" +export PGSCHEMA_PLAN_HOST="${PGSCHEMA_PLAN_HOST:-$PGHOST}" +export PGSCHEMA_PLAN_PORT="${PGSCHEMA_PLAN_PORT:-$PGPORT}" +export PGSCHEMA_PLAN_DB="$template_database" +export PGSCHEMA_PLAN_USER="${PGSCHEMA_PLAN_USER:-$PGUSER}" +export PGSCHEMA_PLAN_PASSWORD="${PGSCHEMA_PLAN_PASSWORD:-$PGPASSWORD}" + +schema_log="${TMPDIR:-/tmp}/buzz-pgschema-${run_hash}.log" +if ! "$workspace_root/bin/pgschema" apply \ + --file "$workspace_root/schema/schema.sql" \ + --auto-approve >"$schema_log" 2>&1; then + cat "$schema_log" >&2 + exit 1 +fi +if ! "$psql" --dbname="$template_database" --set=ON_ERROR_STOP=1 \ + --file="$workspace_root/scripts/reconcile-schema-after-pgschema.sql" \ + >>"$schema_log" 2>&1; then + cat "$schema_log" >&2 + exit 1 +fi +rm -f "$schema_log" + +printf 'BUZZ_POSTGRES_DESIRED_TEMPLATE=%s\n' "$template_database" >>"$NEXTEST_ENV" diff --git a/scripts/postgres-test-wrapper.sh b/scripts/postgres-test-wrapper.sh new file mode 100755 index 00000000000..52f923e47ed --- /dev/null +++ b/scripts/postgres-test-wrapper.sh @@ -0,0 +1,90 @@ +#!/usr/bin/env bash +# Give each nextest process its own PostgreSQL database. The test binary and +# arguments supplied by nextest must always be executed by this wrapper. +set -euo pipefail + +: "${NEXTEST_RUN_ID:?nextest must provide NEXTEST_RUN_ID}" +: "${NEXTEST_BINARY_ID:?nextest must provide NEXTEST_BINARY_ID}" +: "${NEXTEST_TEST_NAME:?nextest must provide NEXTEST_TEST_NAME}" +: "${NEXTEST_ATTEMPT_ID:?nextest must provide NEXTEST_ATTEMPT_ID}" +: "${BUZZ_POSTGRES_ADMIN_URL:?setup must provide BUZZ_POSTGRES_ADMIN_URL}" +: "${BUZZ_POSTGRES_DESIRED_TEMPLATE:?setup must provide BUZZ_POSTGRES_DESIRED_TEMPLATE}" + +resolve_pg_command() { + local name="$1" + local candidate + if [[ -n "${PG_BIN_DIR:-}" ]]; then + candidate="${PG_BIN_DIR}/${name}" + else + candidate="$(command -v "$name" || true)" + fi + if [[ -z "$candidate" || ! -x "$candidate" ]]; then + echo "required PostgreSQL client is not executable: ${candidate:-$name}" >&2 + exit 1 + fi + printf '%s\n' "$candidate" +} + +sha256_hex() { + if command -v sha256sum >/dev/null 2>&1; then + sha256sum | awk '{print $1}' + elif command -v shasum >/dev/null 2>&1; then + shasum -a 256 | awk '{print $1}' + elif command -v openssl >/dev/null 2>&1; then + openssl dgst -sha256 | awk '{print $NF}' + else + echo "a SHA-256 utility (sha256sum, shasum, or openssl) is required" >&2 + return 1 + fi +} + +createdb="$(resolve_pg_command createdb)" +dropdb="$(resolve_pg_command dropdb)" + +identity="${NEXTEST_RUN_ID}:${NEXTEST_BINARY_ID}:${NEXTEST_TEST_NAME}:${NEXTEST_ATTEMPT_ID}" +database_hash="$(printf '%s' "$identity" | sha256_hex)" +database_hash="${database_hash:0:24}" +database="buzz_nt_${database_hash}" +schema_mode="desired" +source_database="$BUZZ_POSTGRES_DESIRED_TEMPLATE" + +# These tests own the migration lifecycle and intentionally begin empty. +case "$NEXTEST_TEST_NAME" in + migration::postgres_tests::* | migration_schema_* | *::migration_schema_*) + schema_mode="migration" + source_database="template0" + ;; +esac + +cleanup() { + local attempt + for attempt in 1 2 3 4 5; do + if "$dropdb" --if-exists --force \ + --maintenance-db="$BUZZ_POSTGRES_ADMIN_URL" \ + "$database" >/dev/null 2>&1; then + return 0 + fi + if [[ "$attempt" -lt 5 ]]; then + sleep 1 + fi + done + echo "warning: failed to remove isolated PostgreSQL test database after 5 attempts: $database" >&2 + return 0 +} +trap cleanup EXIT +trap 'exit 129' HUP +trap 'exit 130' INT +trap 'exit 143' TERM + +"$createdb" \ + --maintenance-db="$BUZZ_POSTGRES_ADMIN_URL" \ + --template="$source_database" \ + "$database" + +database_url="${BUZZ_POSTGRES_ADMIN_URL%/*}/$database" +export DATABASE_URL="$database_url" +export TEST_DATABASE_URL="$database_url" +export BUZZ_TEST_DATABASE_URL="$database_url" +export BUZZ_TEST_SCHEMA_MODE="$schema_mode" + +"$@" diff --git a/scripts/test-postgres-test-discovery.sh b/scripts/test-postgres-test-discovery.sh new file mode 100755 index 00000000000..dff2c9d5c34 --- /dev/null +++ b/scripts/test-postgres-test-discovery.sh @@ -0,0 +1,203 @@ +#!/usr/bin/env bash +set -euo pipefail + +repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +checker="$repo_root/scripts/check-postgres-test-discovery.py" +fixture_root="$(mktemp -d "${TMPDIR:-/tmp}/buzz-postgres-discovery.XXXXXX")" +trap 'rm -rf "$fixture_root"' EXIT + +mkdir -p "$fixture_root/src/tests" "$fixture_root/tests/common" + +cat >"$fixture_root/Cargo.toml" <<'TOML' +[package] +name = "postgres-discovery-fixture" +version = "0.0.0" +edition = "2021" +TOML + +cat >"$fixture_root/src/good.rs" <<'RS' +#[cfg(test)] +mod postgres_tests { + #[test] + #[ignore = "requires Postgres"] + fn ordinary_database_test() {} + + mod external_infra_tests { + #[tokio::test] + #[ignore = "requires Postgres and S3-compatible storage"] + async fn hybrid_database_test() {} + } +} +RS + +cat >"$fixture_root/tests/postgres_search.rs" <<'RS' +#[test] +#[ignore = "requires PostgreSQL"] +fn integration_database_test() {} +RS + +cat >"$fixture_root/src/lib.rs" <<'RS' +#[cfg(test)] +#[path = "out_of_line.rs"] +mod postgres_tests; +RS + +cat >"$fixture_root/src/out_of_line.rs" <<'RS' +#[test] +#[ignore = "requires PostgreSQL"] +fn out_of_line_database_test() {} +RS + +python3 "$checker" "$fixture_root" +python3 "$checker" "$fixture_root/src/out_of_line.rs" + +packages="$("$repo_root/scripts/postgres-test-packages.sh" "$fixture_root")" +if [[ "$packages" != "postgres-discovery-fixture" ]]; then + echo "expected fixture package discovery, got: $packages" >&2 + exit 1 +fi + +cat >"$fixture_root/src/tests/postgres_nested.rs" <<'RS' +#[test] +#[ignore = "requires PostgreSQL"] +fn nested_source_module_is_not_an_integration_binary() {} +RS + +cat >"$fixture_root/tests/common/postgres_helper.rs" <<'RS' +#[test] +#[ignore = "requires PostgreSQL"] +fn nested_integration_helper_is_not_an_integration_binary() {} +RS + +if python3 "$checker" "$fixture_root" >"$fixture_root/nested.out" 2>&1; then + echo "expected nested postgres_* modules to fail discovery validation" >&2 + exit 1 +fi +grep -q "nested_source_module_is_not_an_integration_binary" "$fixture_root/nested.out" +grep -q "nested_integration_helper_is_not_an_integration_binary" "$fixture_root/nested.out" +rm "$fixture_root/src/tests/postgres_nested.rs" +rm "$fixture_root/tests/common/postgres_helper.rs" + +cat >"$fixture_root/src/external_postgres_only.rs" <<'RS' +#[cfg(test)] +mod postgres_tests { + mod external_infra_tests { + #[test] + #[ignore = "requires PostgreSQL"] + fn postgres_only_test_cannot_hide_under_external_infra() {} + } +} +RS + +if python3 "$checker" "$fixture_root" >"$fixture_root/external-postgres.out" 2>&1; then + echo "expected a PostgreSQL-only external-infra test to fail validation" >&2 + exit 1 +fi +grep -q "postgres_only_test_cannot_hide_under_external_infra" \ + "$fixture_root/external-postgres.out" +rm "$fixture_root/src/external_postgres_only.rs" + +cat >"$fixture_root/src/bare_ignore.rs" <<'RS' +#[cfg(test)] +mod postgres_tests { + #[test] + #[ignore] + fn bare_ignore_in_postgres_module_has_no_classification() {} +} +RS + +cat >"$fixture_root/tests/postgres_bare.rs" <<'RS' +#[test] +#[ignore] +fn bare_ignore_in_postgres_binary_has_no_classification() {} +RS + +if python3 "$checker" "$fixture_root" >"$fixture_root/bare-ignore.out" 2>&1; then + echo "expected bare ignores in PostgreSQL structures to fail validation" >&2 + exit 1 +fi +grep -q "bare_ignore_in_postgres_module_has_no_classification" \ + "$fixture_root/bare-ignore.out" +grep -q "bare_ignore_in_postgres_binary_has_no_classification" \ + "$fixture_root/bare-ignore.out" +rm "$fixture_root/src/bare_ignore.rs" +rm "$fixture_root/tests/postgres_bare.rs" + +cat >"$fixture_root/src/missed.rs" <<'RS' +#[cfg(test)] +mod tests { + #[test] + #[ignore = "requires Postgres"] + fn silently_missed_database_test() {} +} +RS + +if python3 "$checker" "$fixture_root" >"$fixture_root/missed.out" 2>&1; then + echo "expected an unclassified PostgreSQL test to fail discovery validation" >&2 + exit 1 +fi +grep -q "silently_missed_database_test" "$fixture_root/missed.out" +rm "$fixture_root/src/missed.rs" + +cat >"$fixture_root/src/raw_missed.rs" <<'RS' +#[cfg(test)] +mod tests { + #[test] + #[ignore = r#"requires PostgreSQL"#] + fn raw_string_database_test() {} +} +RS + +if python3 "$checker" "$fixture_root" >"$fixture_root/raw-missed.out" 2>&1; then + echo "expected a raw-string PostgreSQL reason to fail discovery validation" >&2 + exit 1 +fi +grep -q "raw_string_database_test" "$fixture_root/raw-missed.out" +rm "$fixture_root/src/raw_missed.rs" + +cat >"$fixture_root/src/commented.rs" <<'RS' +#[cfg(test)] +mod tests { + // #[ignore = "requires Postgres"] + fn ordinary_helper() {} +} +RS + +python3 "$checker" "$fixture_root" +rm "$fixture_root/src/commented.rs" + +cat >"$fixture_root/src/hybrid.rs" <<'RS' +#[cfg(test)] +mod postgres_tests { + #[test] + #[ignore = "requires Postgres and MinIO"] + fn hybrid_without_external_module() {} +} +RS + +if python3 "$checker" "$fixture_root" >"$fixture_root/hybrid.out" 2>&1; then + echo "expected a hybrid test without an external-infra module to fail validation" >&2 + exit 1 +fi +grep -q "hybrid_without_external_module" "$fixture_root/hybrid.out" +rm "$fixture_root/src/hybrid.rs" + +cat >"$fixture_root/src/name_is_not_classification.rs" <<'RS' +#[cfg(test)] +mod postgres_tests { + #[test] + #[ignore = "requires Postgres and MinIO"] + fn external_infra_prefix_is_not_enough() {} +} +RS + +if python3 "$checker" "$fixture_root" >"$fixture_root/name.out" 2>&1; then + echo "expected function-name infrastructure classification to fail validation" >&2 + exit 1 +fi +grep -q "external_infra_prefix_is_not_enough" "$fixture_root/name.out" +rm "$fixture_root/src/name_is_not_classification.rs" + +python3 "$checker" "$repo_root/crates" + +echo "PostgreSQL test discovery convention checks passed" diff --git a/scripts/test-postgres-test-wrapper.sh b/scripts/test-postgres-test-wrapper.sh new file mode 100755 index 00000000000..ff0f2c962a2 --- /dev/null +++ b/scripts/test-postgres-test-wrapper.sh @@ -0,0 +1,59 @@ +#!/usr/bin/env bash +set -euo pipefail + +repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +wrapper="$repo_root/scripts/postgres-test-wrapper.sh" +fixture_root="$(mktemp -d "${TMPDIR:-/tmp}/buzz-postgres-wrapper.XXXXXX")" +trap 'rm -rf "$fixture_root"' EXIT + +mkdir -p "$fixture_root/bin" + +cat >"$fixture_root/bin/createdb" <<'SH' +#!/usr/bin/env bash +printf '%s\n' "$*" >"$BUZZ_CREATEDB_LOG" +SH + +cat >"$fixture_root/bin/dropdb" <<'SH' +#!/usr/bin/env bash +exit 0 +SH + +cat >"$fixture_root/bin/capture-schema-mode" <<'SH' +#!/usr/bin/env bash +printf '%s\n' "$BUZZ_TEST_SCHEMA_MODE" >"$BUZZ_SCHEMA_MODE_LOG" +SH + +chmod +x "$fixture_root/bin/createdb" \ + "$fixture_root/bin/dropdb" \ + "$fixture_root/bin/capture-schema-mode" + +run_case() { + local test_name="$1" + local expected_mode="$2" + local expected_template="$3" + local case_id="${test_name//[^A-Za-z0-9]/_}" + local createdb_log="$fixture_root/${case_id}.createdb" + local mode_log="$fixture_root/${case_id}.mode" + + env \ + NEXTEST_RUN_ID=wrapper-test \ + NEXTEST_BINARY_ID=postgres_fixture \ + NEXTEST_TEST_NAME="$test_name" \ + NEXTEST_ATTEMPT_ID=1 \ + BUZZ_POSTGRES_ADMIN_URL=postgres://buzz@localhost/postgres \ + BUZZ_POSTGRES_DESIRED_TEMPLATE=desired_template \ + BUZZ_CREATEDB_LOG="$createdb_log" \ + BUZZ_SCHEMA_MODE_LOG="$mode_log" \ + PG_BIN_DIR="$fixture_root/bin" \ + "$wrapper" "$fixture_root/bin/capture-schema-mode" + + grep -Fxq "$expected_mode" "$mode_log" + grep -Fq -- "--template=$expected_template" "$createdb_log" +} + +run_case migration_schema_root_level migration template0 +run_case module::migration_schema_nested migration template0 +run_case migration::postgres_tests::legacy migration template0 +run_case ordinary_database_test desired desired_template + +echo "PostgreSQL wrapper schema-mode checks passed"