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:
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