Commit Graph
7 Commits
Author SHA1 Message Date
dd6b624cae Quality pass on the 1.7.0 batch: fix confirmed bugs, bump to 1.7.1 (#1418)
* Quality pass on the 1.7.0 batch: fix confirmed bugs, bump to 1.7.1

Post-merge review of PRs #1400–#1417 (push-to-talk, mesh bridging, DM
store-and-forward, empty-mesh liveliness, geo-notes). Fixes the confirmed,
well-scoped findings; deeper architectural/security items are tracked
separately.

- PTT hot-mic leak: releasing the mic during VoiceCaptureSession.start()'s
  150ms retry pause left the mic live and streaming for up to 120s, because
  cancel() no-op'd once `completed` was set. Bail after the sleep if the hold
  was released, and make cancel() always tear down a late-started capture.
- Bridge courier depositDrop reported success and burned the dedup slot before
  the drop was actually published (evicted/compose-fail = lying 📦 "carried"
  with no retry). Only consume publishedDropKeys on durable accept; add
  BoundedIDSet.remove() to release evicted/failed slots (uses the dead dedupKey).
- Blocked senders resurfaced via archived "heard here earlier" echoes, the one
  path that bypassed the live block filter — filter at seed time.
- A late optimistic .sent clobbered the router's .carried state; extend
  ConversationStore.shouldSkipStatusUpdate to a full precedence guard
  (sending < sent < carried < delivered < read).
- Read receipts were permanently burned when the router dropped them (marked
  sent then dropped). sendReadReceipt/routeReadReceipt now return Bool; only
  record as sent on a successful route, else retry on the next read scan.
- MessageRouter.cleanupExpiredMessages() had no production caller, so DMs to a
  peer that never reconnects sat on .sending until relaunch — run it in the
  120s bridge sweep.
- Sightings tally now rolls over at midnight while idle; wave notification
  action localized across all 29 locales; bridged anon#tag uses suffix(4) like
  everything else; makeThrowawayIdentity delegates to NostrIdentity.generate();
  .swiftlint.yml excludes .claude worktrees.
- Add regression tests: carried→sent no-downgrade, carried→delivered upgrade,
  evicted pending drop stays retryable.
- Bump MARKETING_VERSION to 1.7.1.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* Fix CI: adjust delivery-status benchmark and drop now-dead addSystemMessage

The stricter no-downgrade guard (delivered/carried never regress to sent) broke
two things the earlier commit didn't catch locally (perf tests are skipped in
the default run, and Periphery runs only in CI):

- PerformanceBaselineTests delivery benchmarks alternated sent <-> delivered
  assuming both directions apply; the delivered -> sent half is now correctly
  skipped, so the pass measured 0 updates. Alternate two delivered timestamps
  instead — every update is real, no downgrade.
- Routing the geoDM "not in a location channel" error into the thread removed
  the only caller of ChatPrivateConversationContext.addSystemMessage, leaving
  it (and its mock) dead per Periphery. Drop the protocol requirement, the mock
  impl, and the now-vacuous systemMessages.isEmpty assertions (the invariant is
  compile-time enforced: the context can no longer emit a public system line).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* Address review findings: read-receipt dedup, carried-vs-sending, block-time echo purge

- Read receipts: claim the receipt in sentReadReceipts synchronously
  before spawning the routing task (chat open runs two read scans in one
  MainActor stretch, so the async insert let every unread message route
  twice), and release the claim when the route fails so the
  retry-on-failed-route behavior is preserved.

- Delivery status: extend the no-downgrade guard so the `.sending`
  stamp a pre-handshake resend emits can no longer clobber
  carried/delivered/read (the 📦 indicator survived `.sent` but not
  `.sending`).

- Archived echoes: blocking a peer now purges their carried public
  messages from the gossip archive at block time (UnifiedPeerService
  and /block), while the fingerprint-to-peerID mapping is still known —
  the seed-time filter can't resolve offline non-favorite strangers and
  stays only as defense-in-depth. New Transport hook (default no-op) +
  GossipSyncManager.removePublicMessages with immediate persist.

- Bridge courier: an envelope that can't encode within the drop size
  caps fails identically on every attempt; consume the dedup slot so
  the 120s retry sweep stops re-running Noise sealing on it.

- MeshSightingsTracker: cache the day-key DateFormatter instead of
  building one per call.

Tests: double-markAsRead dedup + failed-route retry, carried→sending
no-downgrade matrix, block-time purge (manager + service wiring),
oversize-drop slot consumption.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Also skip the sent→sending downgrade in the delivery-status guard

Codex review follow-up: sendPrivateMessage without an established Noise
session emits `.sending` asynchronously, so it can land after the
message already reached `.sent` and visibly walk "Sent" back to
"Sending...". Treat `.sending` as weaker than `.sent` too — the status
was already truthful. `.failed` → `.sending` stays allowed so a retry
after a real failure remains visible.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Retain Codable properties in Periphery scan (same fix as #1421)

The noiseKey assign-only false positive fired persistently on this branch
(twice, including a rerun) despite the baselined USR. Byte-identical to the
fix on fix/announce-replay-link-steal so the branches merge cleanly in
either order.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: jack <jackjackbits@users.noreply.github.com>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-09 16:44:02 +02:00
ef848857b7 Remove dead code found by full Periphery audit; add scan config + advisory CI (#1410)
Periphery 3.7.4 audit of both schemes (macOS + iOS, intersected so
platform-specific code is never touched), with test targets indexed and
the share extension built. 277 dead declarations removed or demoted:
dead forwarding wrappers (ChatViewModel+Nostr/+PrivateChat), removed-
feature remnants (autocomplete command suggestions, back-swipe tuning,
MediaSendError, GeohashParticipantTracker), unused Tor dormancy
bindings, assign-only properties, unused parameters (renamed to _), and
redundant public accessibility. 13 orphaned localization keys deleted
across all 29 locales (old pre-#1392 location-notes UI, app_info
warnings).

Two real tests were flagged as unused because they never ran: Swift
Testing methods missing @Test (NostrProtocolTests.
testAckRoundTripNIP44V2_Delivered, NotificationStreamAssemblerTests.
testAssemblesCompressedLargeFrame). Re-armed both; they pass.

Deliberately kept, now recorded in .periphery.baseline.json: iOS-only
code invisible to the CI macOS scan, C FFI signatures, keep-alive
NWPathMonitor reference, InboundEventKey.eventID (dedup semantics),
wifiBulk capability bit (reserved for Wi-Fi bulk work, used by
BitFoundation package tests), and the String secureClear cluster
(exercised by package tests).

New: .periphery.yml config and an advisory Dead Code CI job (mirrors
the SwiftLint precedent from #1361) that fails on findings not in the
committed baseline.

Verified: full macOS app suite, BitFoundation (119) and BitLogger (13)
package tests green; periphery scan --strict exits clean.

Co-authored-by: jack <jackjackbits@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-08 11:24:19 +02:00
2360140760 Transitive verification: vouch for verified peers over Noise (#1380)
* Add capability bits to announce TLV

Announces now carry an optional capabilities TLV (0x05): a little-endian
bitfield with named bits for upcoming features (prekeys, wifiBulk,
gateway, groups, board, vouch, meshDiagnostics). Old clients skip the
unknown TLV; peers without it decode as nil so features can distinguish
"legacy peer" from "advertises nothing".

PeerCapabilities lives in BitFoundation with a minimal-length encoding
that preserves unknown bits for forward compatibility. Peer capabilities
are stored in the BLE peer registry on verified announce and exposed via
BLEService.peerCapabilities(_:). The local advertisement set is empty
until each feature ships its bit.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Transitive verification: vouch for verified peers over Noise

When a Noise session establishes with a peer I verified and that peer
advertises the .vouch capability, send signed attestations (up to 16,
most recently verified first, at most once per peer per 24h) for the
OTHER fingerprints I verified. Receivers accept vouches only from
senders they verified themselves, verify the Ed25519 signature against
the sender's announce-bound signing key, and surface the result as a
new derived trust tier: vouched (unfilled seal) between casual and
trusted.

Protocol:
- NoisePayloadType.vouch = 0x12 carries a batch of TLV attestations:
  voucheeFingerprint (32B), voucheeSigningKey (32B), timestamp
  (uint64 ms BE), Ed25519 signature over
  "bitchat-vouch-v1" | fingerprint | signingKey | timestamp.
  The voucher is implicit in the authenticated session.
- PeerCapabilities.localSupported now advertises .vouch.

Storage (SecureIdentityStateManager / IdentityCache):
- vouches keyed by vouchee, capped at 8 vouchers each; validity is
  recomputed on read (voucher still verified-by-me, < 30 days old), so
  unverifying a voucher retires their vouches without cascade deletes.
- New IdentityCache fields are Optional so pre-existing encrypted
  caches decode cleanly; TrustLevel.vouched is inserted mid-ladder but
  raw values are strings, so persisted values are unaffected (and
  vouched itself is never persisted).
- Panic wipe clears vouch state with the rest of the identity cache.

UI: unfilled checkmark.seal badge in the mesh peer list (filled seal
stays exclusive to verified) and a "vouched for by N people you
verified" section with voucher names in FingerprintView; VoiceOver
labels and xcstrings entries included.

Tests: attestation encode/decode + signature (forged/tampered/expired),
accept-policy gates, batch cap, trust-level derivation incl. voucher
invalidation, persistence compat, and coordinator exchange/accept
policies. Full macOS suite: 1088 tests passing.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Fix CI deadlock in vouch tests and live-refresh the fingerprint sheet on vouch acceptance

Two fixes for PR #1380 review findings:

1. CI "Run Swift Tests (app)" hang (exit 137): the new
   SecureIdentityStateManagerVouchTests suite was nonisolated, so Swift
   Testing ran its tests in parallel on the Swift Concurrency cooperative
   pool. Each test enqueues a queue.async(.barrier) write (setVerified)
   and immediately blocks in queue.sync / queue.sync(.barrier)
   (recordVouch / effectiveTrustLevel). On CI's few-core runners every
   cooperative-pool thread ended up parked behind a pending barrier that
   never got a dispatch worker, deadlocking the whole test process until
   the watchdog SIGKILLed it. The suite is now @MainActor, matching the
   production isolation of the vouch API (ChatVouchCoordinator is
   @MainActor) and keeping blocking syncs off the cooperative pool.

2. Codex P2: an open fingerprint sheet did not refresh its vouched badge
   when a vouch batch was accepted - VerificationModel.bind() never
   observed the trust-change signal. It now subscribes to the
   "peerStatusUpdated" notification that
   ChatVouchCoordinator.notifyPeerTrustChanged() posts (same source
   PeerListModel uses) and forwards it to objectWillChange. Added a
   regression test that pins VerificationModel's own subscription
   (verified to fail without the fix).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Skip media-wipe detached tasks under tests (shared-filesystem race)

panicClearAllData and clearCurrentPublicTimeline delete the real
~/Library/Application Support/files tree in detached utility-priority
tasks. The SPM test process shares that tree and ChatViewModelTests
invoke both methods, so under parallel scheduling the wipe lands at a
nondeterministic time — deleting media a concurrently running test just
wrote (and the developer's real app data with it). Guard both with the
existing TestEnvironment.isRunningTests pattern, mirroring the same fix
on feat/mesh-diagnostics (#1377).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Port vouch capability-race fix to feat/vouching (ports b8adcbe9)

Ports the on-device-confirmed fix from the integration test branch
(commit b8adcbe9) onto feat/vouching so PR #1380 is actually correct.
On-device testing confirmed the transitive vouch propagated once the
send was triggered on verify / announce arrival rather than auth alone.

Vouch attestations only ever sent from peerAuthenticated, gated on the
peer's .vouch capability. That capability arrives via the peer's announce,
processed independently of the Noise handshake, so at auth time the set was
usually empty -> gate failed -> vouch silently skipped and never retried.

- Refactor the send path into a reusable attemptVouch(to:fingerprint:now:).
- Trigger on peer-list updates (peersUpdated): fired after every verified
  announce, so the batch goes out once the .vouch bit actually arrives.
- Trigger on local verification (vouchToConnectedVerifiedPeers): verifying a
  peer runs a vouch pass over connected verified peers, covering the
  verify-while-connected case and propagating the new identity onward.
- Relax the capability gate: treat an empty/unknown set as eligible (the
  Noise 0x12 payload is ignored by non-supporting peers); only skip when a
  non-empty set explicitly lacks .vouch.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: jack <jackjackbits@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-07 14:48:37 +02:00
75da63c9d7 Fix favorites end-to-end: peer-list duplicates, Nostr sync, /fav key corruption (v1.5.4) (#1367)
* Friend-courier store-and-forward: mutual favorites carry sealed messages to offline peers

When a private message has no reachable transport, the router now seals it
to the recipient's Noise static key (new one-way Noise X pattern) and hands
the envelope to up to three connected mutual favorites. Couriers store the
opaque ciphertext under strict quotas (20 total, 5 per depositor, 16 KiB,
24 h) and hand it over when the recipient's announce matches a rotating
HMAC recipient tag; the recipient opens it and the message flows through
the normal private-message pipeline, so dedup and delivery acks just work.

- CourierEnvelope TLV + courierEnvelope (0x04) message type in BitFoundation
- Noise X one-way pattern reusing the existing handshake machinery,
  domain-separated by a courier prologue; sender identity authenticated
  via the ss DH (no forward secrecy - documented tradeoff)
- CourierStore with eviction, file persistence, and panic-wipe integration
- Rotating recipient tags (HMAC over epoch day) so carried envelopes don't
  correlate for observers who don't already know the recipient's key
- New "carried" delivery status with figure.walk glyph; header indicator
  while carrying mail for others
- Three-node end-to-end test ferrying packets through real BLEService
  instances, plus codec/crypto/store/router suites (986 tests green)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Fix courier handoff verification and directed sends

* Authenticate courier deposits by ingress peer

* Gate courier handover on direct announces and isolate store test

Envelopes are removed from the courier store optimistically, so releasing
them on a relayed (multi-hop) announce risks losing carried mail to a
speculative flood that never reaches the recipient. Handover now also
requires the announce to have arrived directly (full TTL), i.e. an actual
encounter with a live link; regression test builds a relayed copy of a
genuinely signed announce (TTL is excluded from announce signatures).

Also make CourierStore's on-disk location injectable so the persistence
test round-trips through a temp directory instead of wiping the real
Application Support store, and reattach BLEAnnounceHandler's doc comment
to the class it describes.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Use Xcode-bundled Swift in CI instead of a standalone toolchain

The unpinned setup-swift action installs Swift 6.1, which refuses the
SDK on runner images that have rolled to Xcode 26.5 ("this SDK is not
supported by the compiler"). Jobs passed or failed depending on which
image they landed on. The Xcode-bundled toolchain always matches the
image's SDK, and matches local development. Cache keys now include the
toolchain version so artifacts from one compiler are never restored
into builds with another.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Drop couriered mail from blocked senders at envelope open

The UI-layer block check (isPeerBlocked in the transport event
coordinator) resolves a fingerprint from the live session or peer list,
but a couriered message arrives precisely when its sender is absent —
no session, no registry entry — so the check failed open and a blocked
identity's mail was delivered anyway. Gate in openCourierEnvelope,
where the sealed sender's full static key is in hand.

End-to-end test ferries a full deposit→carry→handover round and
verifies the envelope from a blocked sender never reaches the delegate
(confirmed failing without the gate).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Fix favorites end-to-end: peer-list dedup, Nostr sync, /fav key corruption

- UnifiedPeerService: dedup offline favorites against mesh peers by noise
  key. Phase 2 compared a 64-hex noise-key PeerID against 16-hex mesh IDs
  (never equal), leaving only a nickname+isConnected heuristic — a mutual
  favorite that was reachable-but-not-connected or renamed rendered twice,
  and a same-nick stranger could suppress a favorite entirely.
- Nostr inbound: intercept [FAVORITED]/[UNFAVORITED] markers in the live
  PM handler so they update theyFavoritedUs instead of rendering as chat
  text; mutual favorites can now form over Nostr. Delete the dead
  favorite-aware PM variant and ChatNostrCoordinator.handleFavoriteNotification
  (unwired, parsed a stale FAVORITE:TRUE|… format no sender emits).
- NostrTransport.isPeerReachable: match short form regardless of incoming
  ID width — toggling an offline favorite (addressed by 64-hex noise key)
  was silently dropped with no reachable transport.
- BLEService.sendPrivateMessage: normalize recipient to the short ID like
  sendFilePrivate, so a 64-hex target hits the existing Noise session
  instead of initiating a handshake with a 32-byte wire recipient ID.
- /fav, /unfav: stop writing Data(hexString: peerID.id) — the 8-byte
  routing ID for mesh peers — into the favorites store as a "noise key",
  and stop double-sending the favorite notification; delegate to
  toggleFavorite with a proper state check.
- FavoritesPersistenceService.updatePeerFavoritedUs: keep the stored
  nickname when the caller passes the "Unknown" placeholder.
- Bump marketing version to 1.5.4.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Route DMs to mutual favorites via Nostr when a mesh-keyed peer goes offline

Field-tested on device: with a DM window opened while the peer was on
mesh (conversation keyed by the short 16-hex ID), walking out of range
and sending failed instantly with "peer not reachable" even though the
header showed the peer as Nostr-reachable (mutual favorite, npub known).

sendPrivateMessage derived the favorites key as Data(hexString:
peerID.id) — for a short mesh ID that is the 8-byte routing ID, never
the noise key — so the mutual-favorite/Nostr-key checks always came up
empty and the send failed before reaching MessageRouter. Conversations
keyed by the full 64-hex noise-key ID (opened from the offline favorite
row) were unaffected, which is why later tests appeared to work.

Resolve the noise key properly (peerID.noiseKey, then the unified peer
row, then the favorites store by derived short ID) and add a regression
test for the mesh-keyed-peer-goes-offline case.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Label Nostr DMs from favorites with their stored nickname

Field-tested: a DM delivered over the Nostr fallback rendered as
"anon#678e" instead of the sender's name. The inbound handler named the
sender via displayNameForNostrPubkey, which only knows geohash-scoped
names — even though the pipeline had already resolved the sender's
noise key (the conversation is keyed by it).

When the conversation key carries a noise key, prefer the favorite's
stored nickname; geohash DMs (nostr_ keys) keep the anon geo name. This
also stops an inbound Nostr [FAVORITED] from overwriting the stored
nickname with the anon fallback, since the same name feeds
updatePeerFavoritedUs.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Fix courier path for offline favorites addressed by noise-key IDs

Two Codex review findings, both the same ID-width confusion this PR
targets, in the courier flow:

- CourierDirectory.favoritesBacked resolved recipients only via
  getFavoriteStatus(forPeerID:), which requires a short 16-hex ID —
  offline favorites are addressed by the full 64-hex noise-key ID, so
  attemptCourierDeposit silently bailed for exactly the peers couriers
  exist to serve. The 64-hex ID now yields its own key directly.
- openCourierEnvelope emitted the derived short mesh ID even when the
  sender has no live mesh identity, landing couriered mail in an
  unresolvable short-ID thread labeled "Unknown". Absent senders now
  emit the full noise-key ID so the message joins the stable favorite
  conversation; present senders keep the live short-ID thread.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: jack <jackjackbits@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-06 17:17:21 +02:00
c74e212ea3 Make peer lists accessible and actionable; block mesh peers by stable identity (#1360)
* Make peer lists accessible and actionable; block by stable identity

Who you can reach — the app's most important fact — was encoded in
unlabeled 10pt icons with macOS-only tooltips, and the mesh list had no
actions (block/favorite/verify were slash-command-only).

- Both peer lists become real accessibility citizens: each row is one
  element announcing name, reachability, and favorite/unread/blocked
  state, with a button trait and custom actions for the gesture-only
  interactions. Neither file previously had a single accessibility
  modifier. Reachability icons gain tooltips reusing existing strings;
  teleported vs in-area pins are explained.
- Mesh rows gain the context menu the geohash list already had: direct
  message, favorite, show fingerprint, block/unblock. The fingerprint
  double-tap, previously shadowed by the single tap, is reordered so it
  fires.
- The DM header's offline state (previously EmptyView — absence of a
  glyph as the only signal) becomes a dimmed "offline" tag, and a
  geohash DM — always Nostr-routed — no longer mislabels itself
  "offline".
- App Info gains a SYMBOLS legend defining every glyph the lists and
  headers use; nothing defined them before.
- Mesh block/unblock now resolve by the peer's stable Noise identity
  instead of a `/block <displayName>` string, so the exact tapped row is
  affected and offline peers can be unblocked (with covering tests).

New strings are added source-language (en) only.

* Surface block/unblock feedback in the conversation where it was triggered

setMeshPeerBlocked silently returned when the peer's identity could not
be resolved (e.g. long-press-blocking an old public message from a
sender who left and was never a favorite), where the /block command
printed "cannot block X: not found or unable to verify identity" — post
that same message from the guard branch.

Both the failure and confirmation messages now route through
addCommandOutput instead of addSystemMessage, so blocking from inside a
private chat prints into that chat rather than invisibly into the
public timeline (same routing #1363 applied to command output).

The confirmation also reuses the /block wording ("blocked X. you will
no longer receive messages from them") for parity with the command.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Remove dead accessibility label and unreachable /unblock fallback

The favorite button's .accessibilityLabel in MeshPeerList is
unreachable: the row-level .accessibilityElement(children: .ignore)
swallows child elements, and the row's custom accessibility action
already covers favoriting.

ConversationUIModel.unblock is only called from the mesh peer list with
a non-optional mesh peerID, so the "/unblock <name>" fallback branch
could never run — take PeerID directly and drop the branch.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: jack <212554440+jackjackbits@users.noreply.github.com>
Co-authored-by: jack <jackjackbits@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-06 10:12:53 +02:00
IslamandGitHub 4cfcefcda6 BitFoundation module to centralize shared components (#1089)
* Run local packages’ tests as well on CI

* BitFoundation module to centralize shared components
2026-04-14 14:10:03 -05:00
806c420313 Add comprehensive test coverage for ChatViewModel and BLEService (#962)
* Add comprehensive test coverage for ChatViewModel and BLEService

This commit adds 14 new tests to improve the safety net for future
refactoring of ChatViewModel and BLEService:

New test files:
- ChatViewModelTorTests: Tor lifecycle notification handlers (8 tests)
- ChatViewModelDeliveryStatusTests: Delivery status state machine (6 tests)
- ChatViewModelRefactoringTests: Command routing and message handling (4 tests)
- BLEServiceCoreTests: Packet deduplication and stale broadcast filtering (2 tests)
- PublicMessagePipelineTests: Message ordering and deduplication (4 tests)
- MessageRouterTests: Transport selection and outbox behavior (4 tests)
- PrivateChatManagerTests: Chat selection and read receipts (2 tests)
- UnifiedPeerServiceTests: Fingerprint resolution and blocking (2 tests)
- RelayControllerTests: TTL, handshake, and fragment relay logic (4 tests)

Modified files:
- MockTransport: Added peer snapshot publishing on connect/disconnect
- TestHelpers: Added waitUntil polling helper for async tests
- MockIdentityManager: Extended for new test scenarios

Total: 454 tests across 64 suites (was 440)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* Fix flaky test by using waitUntil instead of fixed sleep

Replace fixed 200ms sleep with waitUntil helper for more reliable
async assertion in routing tests. This prevents timing issues on CI.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

---------

Co-authored-by: jack <jackjackbits@users.noreply.github.com>
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-16 16:02:13 -10:00