Skip to content

Fix critical bugs (host-app shadowing, FK destroy, PG race), extract shared concerns, reach 100% line coverage#4

Open
rameerez wants to merge 2 commits into
mainfrom
fix/critical-bugs-dry-full-coverage
Open

Fix critical bugs (host-app shadowing, FK destroy, PG race), extract shared concerns, reach 100% line coverage#4
rameerez wants to merge 2 commits into
mainfrom
fix/critical-bugs-dry-full-coverage

Conversation

@rameerez

Copy link
Copy Markdown
Owner

Summary

A full-codebase hardening pass: four serious bugs fixed (each verified with a reproduction before fixing), the embeddability/metadata plumbing consolidated into shared concerns, and the test suite doubled to 100% line / 99.39% branch coverage.

Note: this branch also carries the previously-unpushed local commit 449da81 (README embeddability contract docs), which this PR's Wallets::Embeddable concern formalizes.

Bugs fixed

1. Installing the gem broke host apps with a Transaction/Wallet/Transfer/Allocation model

The engine added the gem's lib dirs to the host app's autoload paths, so Zeitwerk claimed those top-level constants and shadowed the app's own models (NameError: uninitialized constant Transaction — reproduced in the dummy app). All gem code is eagerly required, so the paths were pure liability. Removed, with a regression test asserting no gem dir is ever autoloaded.

2. Owners with transfer history couldn't be destroyed

Destroying a wallet destroyed its transfers while the counterparty's ledger row still referenced them → ActiveRecord::InvalidForeignKey (reproduced). Transfer#transactions is now dependent: :nullify: the link object goes away, both sides' ledger rows and balances survive, and transaction metadata still carries transfer_id + counterparty details for audit.

3. create_for_owner! race recovery failed on PostgreSQL

The duplicate-insert rescue ran inside the same transaction as the failed INSERT; on PG a unique-index violation aborts that transaction, so the recovery SELECT raised PG::InFailedSqlTransaction — precisely in the concurrent-creation scenario the rescue exists for. The create now writes through a savepoint (requires_new: true) and rescues outside it, keeping the caller's surrounding transaction (e.g. after_create auto-creation) usable after a lost race. Pinned by a test that performs a real duplicate insert inside a caller's transaction, exercising true PG savepoint semantics in CI.

4. All belongs_to associations were silently optional

The gem's models load before Rails applies belongs_to_required_by_default, so orphan Transaction/Allocation/Transfer records passed validation and died later on DB NOT NULL constraints. Every required association now declares optional: false explicitly. (Deliberately not centralized via belongs_to_required_by_default on the abstract base: embedder subclasses redeclare associations and must keep following their own app's config.)

Smaller fixes

  • has_wallets options now inherited by STI subclasses and resolved lazily against Wallets.configuration
  • Callbacks.dispatch ignores unknown events instead of raising NoMethodError mid-transaction
  • transfer_to on an unpersisted source raises friendly InvalidTransfer
  • Float::INFINITY/NaN/Symbol amounts raise ArgumentError (previously FloatDomainError/NoMethodError leaked); has_enough_balance? returns false instead of crashing
  • String expires_at values parse ("2030-01-01" works; garbage raises a clear error)
  • Unsaved-owner errors are Wallets::Error (one rescuable hierarchy)
  • expired/not_expired scopes partition cleanly at the exact boundary instant
  • history ordered with id tiebreaker; transfers use self.class.transaction (multi-DB embedders); Transfer validates category presence
  • Removed redundant no-op Wallets::Railtie; fixed initializer template's category docs; synced Appraisals with gemfiles/ + CI (7.2 / 8.1)

DRY / SSOT

  • Wallets::Embeddable — table-name/config plumbing extracted from 4 identical copies; embedded subclasses without embedded_table_name derive "#{prefix}#{table_suffix}"
  • Wallets::HasMetadata — indifferent-access metadata behavior extracted from 3 identical copies (incl. MySQL NULL-column healing)
  • Wallets.normalize_asset_code — single source of truth (was 5 inline copies)
  • Wallet/Transfer reuse Transaction scopes (credits/debits/not_expired) instead of hand-written SQL predicates
  • The embeddability contract (embedded_table_name, config_provider, callbacks_module, *_class_name, callback_event_map) is fully preserved — embeddability suite passes unchanged

Tests

before after
Runs / assertions 92 / 338 186 / 667
Line coverage 93.45% 100%
Branch coverage 65.93% 99.39%
SimpleCov gate 80 / 50 98 / 85

New coverage includes: regression tests for every fix above, a real duplicate-insert race inside a caller's transaction, destroy cascades with transfer history, FIFO expiring-first allocation order, expiration boundary partitioning, amount/expiration edge cases, callback logging fallbacks, STI wallet-option inheritance, opposing-transfer deadlock test, and the install generator now runs in tests with its generated migration executed up and down against the real adapter.

A max-effort multi-angle review (5 correctness + 3 cleanup + 1 altitude finder, adversarial verification, gap sweep) ran over this diff; zero correctness findings survived, and the two confirmed cleanup findings are applied.

Verified: SQLite (multiple seeds), PostgreSQL (full suite locally), Rails 7.2 + 8.1 appraisals — all 186 green everywhere.

🤖 Generated with Claude Code

rameerez and others added 2 commits June 11, 2026 15:11
usage_credits 1.0 builds on these hooks; writing them down makes the
contract explicit so future wallets releases treat changes to it as
breaking. Also note the association re-declaration requirement and add
dynamic association class resolution to the TODO list.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Bugs fixed (each verified with a reproduction before fixing):

- Engine no longer adds gem lib dirs to host-app autoload paths, which
  made Zeitwerk claim top-level ::Wallet/::Transaction/::Transfer/
  ::Allocation and shadow (break) host apps defining models with those
  names.
- Transfer#transactions is now dependent: :nullify - destroying a
  wallet/owner with transfer history no longer raises
  ActiveRecord::InvalidForeignKey; the counterparty's ledger and balance
  survive, and metadata keeps transfer_id for audit.
- create_for_owner! race recovery works on PostgreSQL: the duplicate
  insert now goes through a savepoint (requires_new: true) with the
  rescue outside the transaction, so a lost race no longer poisons the
  caller's transaction (PG::InFailedSqlTransaction).
- All required belongs_to declare optional: false explicitly - the gem
  loads before Rails applies belongs_to_required_by_default, so orphan
  records were passing validation and dying on DB NOT NULL constraints.
- has_wallets options are resolved lazily and inherited by subclasses
  (STI); config default_asset changes are honored regardless of load
  order.
- Callbacks.dispatch ignores unknown events instead of raising
  NoMethodError mid-transaction.
- transfer_to raises friendly InvalidTransfer for unpersisted source
  wallets; amount validation no longer leaks FloatDomainError/
  NoMethodError; String expires_at parses instead of crashing; unsaved
  owner errors are Wallets::Error; expired/not_expired scopes partition
  cleanly at the boundary; history gets an id tiebreaker; transfers use
  self.class.transaction; Transfer validates category presence.
- Removed the redundant no-op Wallets::Railtie; fixed the initializer
  template's category docs; synced Appraisals with gemfiles/ and CI.

DRY/SSOT:

- New Wallets::Embeddable concern (table-name/config plumbing, was
  4 copies) and Wallets::HasMetadata concern (indifferent-access
  metadata, was 3 copies); Wallets.normalize_asset_code (was 5 inline
  copies); Wallet/Transfer reuse Transaction scopes instead of
  hand-written SQL predicates. Embeddability contract unchanged.

Tests: 92 runs / 338 assertions -> 186 runs / 667 assertions.
Coverage: 93.45% -> 100% line, 65.93% -> 99.39% branch; SimpleCov gate
raised to 98/85. The install generator now runs in tests and its
generated migration executes up and down against the real adapter.
Verified on SQLite, PostgreSQL, and Rails 7.2/8.1 appraisals.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant