commit 6a64451afdbc9fce8daa340849da1ad5411cf5aa
parent 41cf1a67e72a51daf7af92ffd30218c96bf7a8e1
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:
2 files changed, 128 insertions(+), 0 deletions(-)
diff --git a/crates/studio_storage/Cargo.toml b/crates/studio_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/crates/studio_storage/tests/local_relay_e2e.rs b/crates/studio_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();
+}