app

Local-first trade for farms and co-ops
git clone https://radroots.dev/git/app.git
Log | Files | Refs | README | LICENSE

commit 63d909cc1a878d28918b0699f3559fdf21d9b291
parent 9ac65e934e08d2d3ce957b3b80a80e9c52a2f95b
Author: triesap <tyson@radroots.org>
Date:   Sun,  2 Aug 2026 20:04:33 +0000

integration: prove local relay metadata refresh

- run the persistent core against an ephemeral local Nostr relay
- import and activate a deterministic local signer without public I/O
- verify signed kind-zero metadata reaches snapshots and SQLite cache
- assert observer transitions and public state remain secret-free

Diffstat:
Mcore/Cargo.lock | 4++++
Mcore/crates/storage/Cargo.toml | 4++++
Acore/crates/storage/tests/local_relay_e2e.rs | 124+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mdocs/implementation/nostr-runtime-rcld.md | 2+-
4 files changed, 133 insertions(+), 1 deletion(-)

diff --git a/core/Cargo.lock b/core/Cargo.lock @@ -1754,11 +1754,15 @@ name = "radroots-studio-storage" version = "0.1.0-alpha.0" dependencies = [ "keyring", + "nostr", + "nostr-relay-builder", + "nostr-sdk", "radroots-studio-application", "radroots-studio-domain", "refinery", "rusqlite", "tempfile", + "tokio", ] [[package]] diff --git a/core/crates/storage/Cargo.toml b/core/crates/storage/Cargo.toml @@ -14,7 +14,11 @@ refinery.workspace = true rusqlite.workspace = true [dev-dependencies] +nostr.workspace = true +nostr-relay-builder.workspace = true +nostr-sdk.workspace = true tempfile = "=3.23.0" +tokio = { workspace = true, features = ["macros", "rt-multi-thread", "time"] } [lints] workspace = true diff --git a/core/crates/storage/tests/local_relay_e2e.rs b/core/crates/storage/tests/local_relay_e2e.rs @@ -0,0 +1,124 @@ +use std::sync::{Arc, Mutex}; +use std::time::Duration; + +use nostr::{EventBuilder, Keys, Metadata}; +use nostr_relay_builder::MockRelay; +use nostr_sdk::Client; +use radroots_studio_application::{ + AppObserver, Clock, InMemorySecretStore, ProfileLoadState, ProfileRepository, + RelayConfiguration, RelayConnectionState, SdkNostrClient, SecretStore, SessionState, +}; +use radroots_studio_domain::{RelayUrl, SecretKeyInput, UnixTimestamp}; +use radroots_studio_storage::PersistentAppCore; + +const SECRET_HEX: &str = "7e7e9c42a91bfef19fa7ea99d52d8afdb67d893a8fefba1f5cb9793f2107f6d7"; + +struct FixedClock; + +impl Clock for FixedClock { + fn now(&self) -> UnixTimestamp { + UnixTimestamp::from_seconds(100).expect("fixed timestamp") + } +} + +#[derive(Default)] +struct RecordingObserver(Mutex<Vec<radroots_studio_application::AppSnapshot>>); + +impl AppObserver for RecordingObserver { + fn on_snapshot_changed(&self, snapshot: radroots_studio_application::AppSnapshot) { + self.0.lock().expect("observer lock").push(snapshot); + } +} + +#[tokio::test] +async fn local_relay_e2e_imports_activates_refreshes_and_caches_profile() { + let local_relay = MockRelay::run().await.expect("local relay"); + let relay_url = local_relay.url().await; + let keys = Keys::parse(SECRET_HEX).expect("known secret key"); + let publisher = Client::new(keys); + publisher + .add_relay(relay_url.clone()) + .await + .expect("publisher relay"); + publisher.connect().await; + publisher.wait_for_connection(Duration::from_secs(2)).await; + publisher + .send_event_builder(EventBuilder::metadata( + &Metadata::new() + .name("farmer") + .display_name("Farm Account") + .about("Local food profile"), + )) + .await + .expect("publish profile"); + + let relay = RelayUrl::parse(relay_url.as_str()).expect("relay URL"); + let adapter = PersistentAppCore::in_memory(RelayConfiguration::new(vec![relay])) + .expect("persistent adapter"); + let secrets = InMemorySecretStore::default(); + adapter.bootstrap(&secrets, &FixedClock).expect("bootstrap"); + let imported = adapter + .import_secret_key( + SecretKeyInput::parse(SECRET_HEX.to_owned()).expect("secret input"), + &secrets, + &FixedClock, + ) + .expect("import account"); + let public_key = imported.account().public_key(); + assert!(secrets.contains(public_key).expect("credential exists")); + adapter + .activate_account(public_key, &secrets, &FixedClock) + .expect("activate account"); + + let observer = Arc::new(RecordingObserver::default()); + let handle = adapter + .core() + .subscribe(observer.clone()) + .expect("observer"); + let refreshed = adapter + .core() + .refresh_active_profile( + adapter.database(), + &SdkNostrClient::new(Duration::from_secs(2)), + &FixedClock, + ) + .await + .expect("refresh profile"); + + assert_eq!(refreshed.session(), SessionState::Active); + let active = refreshed.active_account().expect("active account"); + assert_eq!(active.relay_state(), RelayConnectionState::Connected); + assert_eq!(active.profile_state(), ProfileLoadState::Fresh); + assert_eq!( + active.profile().and_then(|profile| profile.display_name()), + Some("Farm Account") + ); + let cached = adapter + .database() + .load_profile(public_key) + .expect("load cache") + .expect("cached profile"); + assert_eq!( + cached.candidate().metadata().preferred_name(), + Some("Farm Account") + ); + let observed = observer.0.lock().expect("observer snapshots"); + assert!(observed.iter().any(|snapshot| { + snapshot.active_account().is_some_and(|account| { + account.profile_state() == ProfileLoadState::Loading + && account.relay_state() == RelayConnectionState::Connecting + }) + })); + assert_eq!( + observed.last().map(|snapshot| snapshot.revision()), + Some(refreshed.revision()) + ); + drop(observed); + assert!(adapter.core().unsubscribe(handle)); + + let public_debug = format!("{refreshed:?}"); + assert!(!public_debug.contains(SECRET_HEX)); + assert!(!public_debug.contains("nsec1")); + publisher.shutdown().await; + local_relay.shutdown(); +} diff --git a/docs/implementation/nostr-runtime-rcld.md b/docs/implementation/nostr-runtime-rcld.md @@ -544,7 +544,7 @@ handoff commit sequence. ### RCLD-11 -- [ ] 58. Add full local-relay end-to-end integration test. +- [x] 58. Add full local-relay end-to-end integration test. - [ ] 59. Add restart, account isolation, and FFI callback integration tests. ### RCLD-12