runtime.rs (813570B)
1 use std::collections::{BTreeMap, BTreeSet}; 2 use std::fmt; 3 use std::fs; 4 use std::path::PathBuf; 5 use std::sync::{Arc, Mutex, MutexGuard, PoisonError}; 6 use std::time::{Duration as StdDuration, SystemTime, UNIX_EPOCH}; 7 8 use chrono::{DateTime, Duration, Utc}; 9 use radroots_app_core::{ 10 AppBuildIdentity, AppDesktopRuntimePaths, AppRuntimeCapture, AppRuntimeMode, 11 AppRuntimePathsError, AppRuntimeSnapshot, AppSdkConfig, AppSdkDiagnostics, 12 AppSdkFarmPublishRequest, AppSdkLifecycleState, AppSdkListingPublishRequest, 13 AppSdkOrderCancellationRequest, AppSdkOrderDecisionRequest, AppSdkOrderRevisionDecisionRequest, 14 AppSdkOrderRevisionProposalRequest, AppSdkOrderSubmitRequest, AppSdkProjectionLifecycleState, 15 AppSdkRelayUrlPolicy, AppSdkRuntime, AppSdkRuntimeError, AppSdkRuntimeIssue, 16 AppSdkRuntimeStatus, AppSdkStoragePaths, AppSdkWorkflowReceipt, AppSharedAccountsPaths, 17 PackDayExportWriteError, prepare_pack_day_export_bundle_at_data_root, 18 shared_local_events_database_path_from_shared_accounts, write_prepared_pack_day_export_bundle, 19 }; 20 use radroots_app_remote_signer::{ 21 RadrootsAppRemoteSignerApprovedSession, RadrootsAppRemoteSignerPendingSession, 22 }; 23 use radroots_app_sqlite::{ 24 APP_ACTIVITY_CONTEXT_LIMIT, AppLocalInteropImportReport, AppSdkMigrationReceiptInput, 25 AppSdkMigrationReceiptSourceKind, AppSdkMigrationState, AppSqliteError, AppSqliteStore, 26 BuyerOrderLocalEventExport, BuyerOrderLocalEventLine, BuyerRepeatDemandApplyOutcome, 27 DatabaseTarget, SelectedBuyerOrderScope, SellerOrderDecisionExport, StoredPendingSyncOperation, 28 StoredRelayIngestCursor, StoredSyncConflict, derive_farm_rules_readiness, 29 projected_order_id_from_trade_request, 30 }; 31 use radroots_app_state::{ 32 APP_STATE_FILE_NAME, AppShellProjection, AppStateCommand, AppStatePersistenceRepository, 33 AppStateStore, AppStateStoreError, BuyerBrowseScreenProjection, BuyerCartScreenProjection, 34 BuyerOrdersScreenProjection, BuyerSearchScreenProjection, BuyerSearchScreenQueryState, 35 FarmSetupFlowStage, FarmWorkspaceReadinessProjection, HomeRoute, OrdersScreenProjection, 36 PackDayBatchPrintRequest, PackDayExportRequest, PackDayHostHandoffRequest, PackDayPrintRequest, 37 PackDayScreenProjection, PersistedAppState, PersonalWorkspaceProjection, 38 ProductsScreenProjection, ProductsScreenQueryState, derive_product_publish_blockers, 39 derive_sync_projection, 40 }; 41 use radroots_app_sync::{ 42 AppFarmProfilePublishPayload, AppListingPublishPayload, AppOrderCancellationPublishPayload, 43 AppOrderDecisionInventoryCommitment, AppOrderDecisionPayload, AppOrderDecisionPublishPayload, 44 AppOrderRequestItemPayload, AppOrderRequestPublishPayload, 45 AppOrderRevisionDecisionPublishPayload, AppOrderRevisionProposalPublishPayload, 46 AppPublishContext, AppPublishPayload, AppPublishedOperationReceipt, 47 AppRelayIngestScopeFreshness, AppSyncProjection, AppSyncRequest, AppSyncResult, 48 AppSyncRunStatus, AppSyncTransport, AppSyncTransportError, SyncCheckpointStatus, 49 SyncConflictSeverity, SyncTrigger, 50 }; 51 #[cfg(test)] 52 use radroots_app_sync::{PendingSyncOperation, SyncAggregateRef, SyncOperationKind}; 53 use radroots_app_view::{ 54 ActiveSurface, AppActivityContext, AppActivityKind, AppIdentityProjection, AppStartupGate, 55 BuyerCartLineProjection, BuyerCartProjection, BuyerCartReplaceConfirmationProjection, 56 BuyerContext, BuyerOrderDetailProjection, BuyerOrderReviewDraft, BuyerOrderStatus, 57 BuyerProductDetailProjection, FarmId, FarmOrderMethod, FarmProfileRecord, FarmReadiness, 58 FarmRulesProjection, FarmSetupDraft, FarmSetupProjection, FarmSummary, FarmerSection, 59 FulfillmentWindowId, LoggedOutStartupProjection, OrderDetailProjection, OrderId, OrderStatus, 60 OrdersFilter, OrdersListProjection, OrdersScreenQueryState, PackDayBatchPrintStatus, 61 PackDayExportBundle, PackDayExportInstanceId, PackDayExportStatus, PackDayHostHandoffKind, 62 PackDayHostHandoffStatus, PackDayPrintKind, PackDayPrintStatus, PackDayProjection, 63 PackDayScreenQueryState, PersonalSection, PickupLocationRecord, ProductEditorDraft, ProductId, 64 ProductStatus, ProductsFilter, ProductsListProjection, ProductsSort, 65 ReminderDeadlineProjection, ReminderDeliveryState, ReminderFeedProjection, ReminderId, 66 ReminderKind, ReminderLogEntryProjection, ReminderLogProjection, ReminderSurface, 67 ReminderUrgency, SettingsAccountProjection, SettingsPreference, SettingsSection, ShellSection, 68 TodayAgendaProjection, 69 }; 70 use radroots_core::{ 71 RadrootsCoreCurrency, RadrootsCoreDecimal, RadrootsCoreMoney, RadrootsCoreQuantity, 72 RadrootsCoreQuantityPrice, RadrootsCoreUnit, 73 }; 74 use radroots_events::{ 75 ids::{ 76 RadrootsDTag, RadrootsEventId, RadrootsInventoryBinId, RadrootsListingAddress, 77 RadrootsOrderId, RadrootsOrderRevisionId, RadrootsPublicKey, 78 }, 79 kinds::{ 80 KIND_FARM, KIND_LISTING, KIND_LISTING_DRAFT, KIND_ORDER_CANCELLATION, KIND_ORDER_DECISION, 81 KIND_ORDER_REQUEST, KIND_ORDER_REVISION_DECISION, KIND_ORDER_REVISION_PROPOSAL, 82 KIND_PROFILE, 83 }, 84 }; 85 use radroots_events_codec::order::order_event_context_from_tags; 86 use radroots_identity::{RadrootsIdentity, RadrootsIdentityId}; 87 use radroots_local_events::{ 88 BUYER_ORDER_REQUEST_ACTOR_SOURCE_RESOLVED_ACCOUNT, 89 BUYER_ORDER_REQUEST_ACTOR_SOURCE_UNRESOLVED_APP, BUYER_ORDER_REQUEST_DOCUMENT_KIND, 90 BUYER_ORDER_REQUEST_LOCAL_WORK_RECORD_KIND, LocalEventRecord, LocalEventRecordInput, 91 LocalEventRecordUpdate, LocalEventsStore, LocalRecordFamily, LocalRecordStatus, 92 PublishOutboxStatus, RelayDeliveryEvidence, RelayDeliveryFailure, SourceRuntime, 93 buyer_order_request_local_work_record_id, validate_buyer_order_request_local_work_payload, 94 }; 95 use radroots_nostr::prelude::{ 96 RadrootsNostrClient, RadrootsNostrEvent, RadrootsNostrFilter, RadrootsNostrOutput, 97 RadrootsNostrTimestamp, radroots_nostr_kind, radroots_nostr_parse_pubkey, 98 }; 99 use radroots_nostr_accounts::prelude::RadrootsNostrAccountsManager; 100 use radroots_sdk::protocol::events::{ 101 RadrootsNostrEvent as SdkRadrootsNostrEvent, RadrootsNostrEventPtr, 102 }; 103 use radroots_sdk::protocol::farm::{RadrootsFarm, RadrootsFarmRef}; 104 use radroots_sdk::protocol::listing::{ 105 RadrootsListing, RadrootsListingAvailability, RadrootsListingBin, 106 RadrootsListingDeliveryMethod, RadrootsListingLocation, RadrootsListingProduct, 107 RadrootsListingStatus, 108 }; 109 use radroots_sdk::protocol::order::{ 110 RadrootsOrderCancellation, RadrootsOrderDecision, RadrootsOrderDecisionOutcome, 111 RadrootsOrderEconomics, RadrootsOrderInventoryCommitment, RadrootsOrderItem, 112 RadrootsOrderRequest, RadrootsOrderRevisionDecision, RadrootsOrderRevisionOutcome, 113 RadrootsOrderRevisionProposal, 114 }; 115 use radroots_sdk::{ 116 FARM_PUBLISH_OPERATION_KIND, LISTING_PUBLISH_OPERATION_KIND, ORDER_CANCELLATION_OPERATION_KIND, 117 ORDER_DECISION_OPERATION_KIND, ORDER_REVISION_DECISION_OPERATION_KIND, 118 ORDER_REVISION_PROPOSAL_OPERATION_KIND, ORDER_SUBMIT_OPERATION_KIND, 119 }; 120 use radroots_sql_core::SqliteExecutor; 121 use radroots_trade::listing::parse_public_listing_address; 122 use radroots_trade::order::{ 123 RadrootsOrderCancellationRecord, RadrootsOrderDecisionRecord, RadrootsOrderReductionInputs, 124 RadrootsOrderRequestRecord, RadrootsOrderRevisionDecisionRecord, 125 RadrootsOrderRevisionProposalRecord, RadrootsOrderStatus, reduce_order_events, 126 }; 127 use serde_json::json; 128 use thiserror::Error; 129 use tokio::runtime::Builder as TokioRuntimeBuilder; 130 use tracing::error; 131 use uuid::Uuid; 132 133 use crate::accounts::{ 134 DesktopAccountsBootstrapError, DesktopAccountsCommandError, DesktopAccountsProjectionError, 135 DesktopLocalIdentityImportRequest, bootstrap_desktop_accounts, generate_local_account, 136 identity_projection_from_manager, import_local_account, remove_selected_local_key, 137 reset_local_device_state, select_active_surface, select_local_account, 138 }; 139 use crate::pack_day_host_handoff::{ 140 PackDayHostHandoffCommandPlan, PackDayHostHandoffError, plan_pack_day_host_handoff, 141 }; 142 use crate::pack_day_print::{ 143 PackDayBatchPrintCommandPlan, PackDayBatchPrintError, PackDayPrintCommandPlan, 144 PackDayPrintError, cleanup_prepared_customer_label_asset_root, 145 cleanup_prepared_customer_label_assets_for_export_instance, plan_pack_day_batch_print, 146 plan_pack_day_print, 147 }; 148 use crate::remote_signer::{ 149 DesktopRemoteSignerError, DesktopRemoteSignerPaths, activate_pending_session, 150 apply_remote_signer_custody, clear_pending_session, load_pending_session, purge_all_state, 151 reconcile_startup, store_pending_session, 152 }; 153 154 const APP_DATABASE_FILE_NAME: &str = "app.sqlite3"; 155 const SYNC_TRANSPORT_UNAVAILABLE_MESSAGE: &str = "remote sync transport is not configured"; 156 const APP_SYNC_PUBLISH_USES_SDK_RUNTIME_MESSAGE: &str = "app sync publish work uses AppSdkRuntime"; 157 const APP_DIRECT_RELAY_SYNC_TIMEOUT_MS: u64 = 2_000; 158 const APP_DIRECT_RELAY_CONNECT_TIMEOUT: StdDuration = StdDuration::from_secs(10); 159 const APP_DIRECT_RELAY_INGEST_LIMIT: usize = 1_000; 160 const APP_DIRECT_RELAY_INGEST_MAX_PAGES: usize = 5; 161 const APP_DIRECT_RELAY_INGEST_SCOPE_KEY: &str = "direct_relay_ingest"; 162 const APP_DIRECT_RELAY_INGEST_STALE_AFTER_SECONDS: i64 = 900; 163 const APP_SELLER_ORDER_DECISION_EVIDENCE_PAGE_SIZE: u32 = 250; 164 const APP_DIRECT_RELAY_INGEST_KINDS: &[u16] = &[ 165 KIND_PROFILE as u16, 166 KIND_FARM as u16, 167 KIND_LISTING as u16, 168 KIND_LISTING_DRAFT as u16, 169 KIND_ORDER_REQUEST as u16, 170 KIND_ORDER_DECISION as u16, 171 KIND_ORDER_REVISION_PROPOSAL as u16, 172 KIND_ORDER_REVISION_DECISION as u16, 173 KIND_ORDER_CANCELLATION as u16, 174 ]; 175 176 #[derive(Debug, Default)] 177 struct UnavailableAppSyncTransport; 178 179 impl AppSyncTransport for UnavailableAppSyncTransport { 180 fn sync(&mut self, _request: AppSyncRequest) -> Result<AppSyncResult, AppSyncTransportError> { 181 Err(AppSyncTransportError::unavailable( 182 SYNC_TRANSPORT_UNAVAILABLE_MESSAGE, 183 )) 184 } 185 186 fn supports_empty_sync_request(&self) -> bool { 187 false 188 } 189 } 190 191 fn default_sync_transport() -> Box<dyn AppSyncTransport + Send> { 192 Box::new(UnavailableAppSyncTransport) 193 } 194 195 #[derive(Debug, Clone)] 196 struct AppDirectRelayFetchReceipt { 197 target_relays: Vec<String>, 198 connected_relays: Vec<String>, 199 failed_relays: Vec<RelayDeliveryFailure>, 200 fetched_relays: Vec<AppDirectRelayFetchedRelay>, 201 event_observed_relays: BTreeMap<String, Vec<String>>, 202 events: Vec<RadrootsNostrEvent>, 203 } 204 205 #[derive(Debug, Clone)] 206 struct AppDirectRelayFetchedRelay { 207 relay_url: String, 208 last_event_created_at_unix_seconds: Option<i64>, 209 } 210 211 #[derive(Clone, Debug, Eq, PartialEq)] 212 pub enum AppSellerOrderDecisionCommand { 213 Accept, 214 Decline { reason: String }, 215 } 216 217 #[derive(Clone, Debug, Eq, PartialEq)] 218 struct ResolvedAppSellerOrderRequest { 219 request_event: SdkRadrootsNostrEvent, 220 request_event_id: String, 221 request_author_pubkey: String, 222 listing_event_id: Option<String>, 223 payload: RadrootsOrderRequest, 224 } 225 226 #[derive(Clone, Debug, Eq, PartialEq)] 227 struct ResolvedAppOrderDecisionEvidence { 228 event_id: String, 229 payload: RadrootsOrderDecision, 230 } 231 232 #[derive(Clone, Debug, Eq, PartialEq)] 233 struct ResolvedAppOrderRevisionProposalEvidence { 234 event_id: String, 235 payload: RadrootsOrderRevisionProposal, 236 } 237 238 #[derive(Clone, Debug, Eq, PartialEq)] 239 struct ResolvedAppOrderRevisionDecisionEvidence { 240 event_id: String, 241 payload: RadrootsOrderRevisionDecision, 242 } 243 244 #[derive(Clone, Debug, Eq, PartialEq)] 245 struct ResolvedAppOrderLifecycleEvidence { 246 evidence_events: Vec<SdkRadrootsNostrEvent>, 247 request_event_id: String, 248 status: RadrootsOrderStatus, 249 agreement_event_id: Option<String>, 250 last_event_id: Option<String>, 251 decision: Option<ResolvedAppOrderDecisionEvidence>, 252 revision_proposals: Vec<ResolvedAppOrderRevisionProposalEvidence>, 253 revision_decisions: Vec<ResolvedAppOrderRevisionDecisionEvidence>, 254 cancellation_event_id: Option<String>, 255 } 256 257 #[derive(Clone, Debug, Default, Eq, PartialEq)] 258 struct AppActiveOrderEvidenceBuckets { 259 requests: Vec<RadrootsOrderRequestRecord>, 260 decisions: Vec<RadrootsOrderDecisionRecord>, 261 revision_proposals: Vec<RadrootsOrderRevisionProposalRecord>, 262 revision_decisions: Vec<RadrootsOrderRevisionDecisionRecord>, 263 cancellations: Vec<RadrootsOrderCancellationRecord>, 264 } 265 266 #[derive(Debug, Default)] 267 struct AppDirectRelayIngestReport { 268 local_import: AppLocalInteropImportReport, 269 freshness_changed: bool, 270 } 271 272 #[derive(Debug, Error)] 273 enum AppDirectRelayIngestError { 274 #[error(transparent)] 275 Sqlite(#[from] AppSqliteError), 276 #[error(transparent)] 277 Transport(#[from] AppSyncTransportError), 278 } 279 280 #[derive(Clone)] 281 struct ConfiguredRelayAppSyncTransport { 282 relay_urls: Vec<String>, 283 } 284 285 impl ConfiguredRelayAppSyncTransport { 286 fn new(_accounts_manager: RadrootsNostrAccountsManager, nostr_relay_urls: Vec<String>) -> Self { 287 Self { 288 relay_urls: nostr_relay_urls, 289 } 290 } 291 292 #[cfg(test)] 293 fn with_relay_urls( 294 _accounts_manager: RadrootsNostrAccountsManager, 295 relay_urls: Vec<String>, 296 ) -> Self { 297 Self { relay_urls } 298 } 299 300 fn sync_with_sdk( 301 &self, 302 request: AppSyncRequest, 303 ) -> Result<AppSyncResult, AppSyncTransportError> { 304 let run_started_at = current_utc_timestamp(); 305 let _relay_urls = normalized_app_sync_relay_urls(&self.relay_urls)?; 306 307 if !request.pending_operations.is_empty() { 308 return Err(AppSyncTransportError::failed( 309 APP_SYNC_PUBLISH_USES_SDK_RUNTIME_MESSAGE, 310 )); 311 } 312 313 Ok(AppSyncResult { 314 run_status: radroots_app_sync::AppSyncRunStatus::Succeeded, 315 checkpoint: SyncCheckpointStatus::current( 316 Some(run_started_at), 317 current_utc_timestamp(), 318 request.checkpoint.last_remote_cursor.clone(), 319 ), 320 pushed_operation_count: 0, 321 pulled_record_count: 0, 322 conflicts: request.known_conflicts, 323 published_receipts: Vec::new(), 324 }) 325 } 326 } 327 328 fn signing_identity_for_publish_payload( 329 accounts_manager: &RadrootsNostrAccountsManager, 330 publish_payload: &AppPublishPayload, 331 ) -> Result<RadrootsIdentity, AppSyncTransportError> { 332 let context = publish_payload_context(publish_payload); 333 let account_id = RadrootsIdentityId::parse(context.account_id.trim()).map_err(|error| { 334 AppSyncTransportError::failed(format!( 335 "pending app publish work has invalid account context: {error}" 336 )) 337 })?; 338 let record = accounts_manager 339 .list_accounts() 340 .map_err(|error| AppSyncTransportError::failed(error.to_string()))? 341 .into_iter() 342 .find(|record| record.account_id == account_id) 343 .ok_or_else(|| { 344 AppSyncTransportError::unavailable(format!( 345 "publish account is not configured locally: {account_id}" 346 )) 347 })?; 348 let identity = accounts_manager 349 .get_signing_identity(&account_id) 350 .map_err(|error| AppSyncTransportError::failed(error.to_string()))? 351 .ok_or_else(|| { 352 AppSyncTransportError::unavailable(format!( 353 "publish account is not backed by a local signing key: {account_id}" 354 )) 355 })?; 356 if identity.public_key_hex() != record.public_identity.public_key_hex { 357 return Err(AppSyncTransportError::failed( 358 "publish account signing key does not match account context", 359 )); 360 } 361 Ok(identity) 362 } 363 364 fn publish_payload_context(publish_payload: &AppPublishPayload) -> &AppPublishContext { 365 match publish_payload { 366 AppPublishPayload::FarmProfile(payload) => &payload.context, 367 AppPublishPayload::Listing(payload) => &payload.context, 368 AppPublishPayload::OrderRequest(payload) => &payload.context, 369 AppPublishPayload::OrderDecision(payload) => &payload.context, 370 AppPublishPayload::OrderRevisionProposal(payload) => &payload.context, 371 AppPublishPayload::OrderRevisionDecision(payload) => &payload.context, 372 AppPublishPayload::OrderCancellation(payload) => &payload.context, 373 } 374 } 375 376 impl AppSyncTransport for ConfiguredRelayAppSyncTransport { 377 fn sync(&mut self, request: AppSyncRequest) -> Result<AppSyncResult, AppSyncTransportError> { 378 self.sync_with_sdk(request) 379 } 380 } 381 382 #[derive(Clone, Debug)] 383 pub struct DesktopAppRuntime { 384 state: Arc<Mutex<DesktopAppRuntimeState>>, 385 sdk_runtime: Arc<Mutex<Option<AppSdkRuntime>>>, 386 } 387 388 impl DesktopAppRuntime { 389 pub fn bootstrap(nostr_relay_urls: Vec<String>, runtime_snapshot: AppRuntimeSnapshot) -> Self { 390 let paths = match AppDesktopRuntimePaths::current_desktop() { 391 Ok(paths) => paths, 392 Err(error) => { 393 return Self::from_state(DesktopAppRuntimeState::degraded_with_snapshot( 394 error.into(), 395 runtime_snapshot, 396 )); 397 } 398 }; 399 400 Self::bootstrap_from_paths_with_snapshot(paths, nostr_relay_urls, runtime_snapshot) 401 } 402 403 pub fn bootstrap_with_paths( 404 paths: AppDesktopRuntimePaths, 405 nostr_relay_urls: Vec<String>, 406 ) -> Self { 407 let runtime_snapshot = default_runtime_snapshot(); 408 Self::bootstrap_from_paths_with_snapshot(paths, nostr_relay_urls, runtime_snapshot) 409 } 410 411 fn bootstrap_from_paths_with_snapshot( 412 paths: AppDesktopRuntimePaths, 413 nostr_relay_urls: Vec<String>, 414 runtime_snapshot: AppRuntimeSnapshot, 415 ) -> Self { 416 let state = match DesktopAppRuntimeState::bootstrap_from_paths( 417 paths.clone(), 418 nostr_relay_urls.clone(), 419 runtime_snapshot.clone(), 420 ) { 421 Ok(state) => state, 422 Err(error) => { 423 return Self::from_state(DesktopAppRuntimeState::degraded_with_snapshot( 424 error, 425 runtime_snapshot, 426 )); 427 } 428 }; 429 430 match start_desktop_sdk_runtime(&paths, nostr_relay_urls) { 431 Ok(sdk_runtime) => { 432 let runtime = Self::from_state_with_sdk_runtime(state, sdk_runtime); 433 let _ = runtime.wait_for_sdk_startup(StdDuration::from_secs(5)); 434 if let Err(error) = runtime.retry_pending_personal_order_coordination() { 435 error!( 436 target: "buyer_order", 437 event = "buyer_order.coordination_bootstrap_retry_failed", 438 error = %error, 439 "failed to retry pending buyer order coordination during bootstrap" 440 ); 441 } 442 runtime 443 } 444 Err(error) => { 445 let mut state = state; 446 state.startup_issue = Some(error.to_string()); 447 Self::from_state(state) 448 } 449 } 450 } 451 452 pub fn summary(&self) -> DesktopAppRuntimeSummary { 453 let sdk_status = self.sdk_status_summary(); 454 let state = self.lock_state(); 455 let sync_status = DesktopAppSyncStatusSummary { 456 account_id: state 457 .state_store 458 .identity_projection() 459 .selected_account 460 .as_ref() 461 .map(|account| account.account.account_id.clone()), 462 projection: state.state_store.sync_projection().clone(), 463 pending_write_count: state.selected_account_pending_sync_write_count, 464 conflicts: state.selected_account_sync_conflicts.clone(), 465 }; 466 467 DesktopAppRuntimeSummary { 468 shell_projection: state.state_store.shell_projection().clone(), 469 settings_account_projection: state.state_store.settings_account_projection(), 470 startup_gate: state.state_store.startup_gate(), 471 logged_out_startup: state.state_store.logged_out_startup_projection().clone(), 472 home_route: state.state_store.home_route(), 473 personal_projection: state.state_store.personal_projection().clone(), 474 farm_setup_projection: state.state_store.farm_setup_projection().clone(), 475 farm_rules_projection: state.state_store.farm_rules_projection().clone(), 476 farm_readiness_projection: state.state_store.farm_readiness_projection().clone(), 477 today_projection: state.state_store.today_projection().clone(), 478 products_projection: state.state_store.products_projection().clone(), 479 orders_projection: state.state_store.orders_projection().clone(), 480 pack_day_projection: state.state_store.pack_day_projection().clone(), 481 reminder_log: state.state_store.reminder_log_projection().clone(), 482 runtime_metadata: state.runtime_metadata.clone(), 483 sync_status, 484 startup_issue: state.startup_issue.clone(), 485 sdk_status, 486 } 487 } 488 489 pub fn nostr_relay_urls(&self) -> Vec<String> { 490 self.lock_state().nostr_relay_urls.clone() 491 } 492 493 pub fn sdk_status(&self) -> Option<AppSdkRuntimeStatus> { 494 self.sdk_runtime 495 .lock() 496 .unwrap_or_else(|poisoned| poisoned.into_inner()) 497 .as_ref() 498 .map(AppSdkRuntime::status) 499 } 500 501 pub fn sdk_status_summary(&self) -> Option<DesktopAppSdkStatusSummary> { 502 self.sdk_status() 503 .as_ref() 504 .map(DesktopAppSdkStatusSummary::from_status) 505 } 506 507 pub fn wait_for_sdk_startup(&self, timeout: StdDuration) -> Option<AppSdkRuntimeStatus> { 508 self.sdk_runtime 509 .lock() 510 .unwrap_or_else(|poisoned| poisoned.into_inner()) 511 .as_ref() 512 .map(|runtime| runtime.wait_for_startup(timeout)) 513 } 514 515 pub fn shutdown_sdk_runtime(&self) -> Result<bool, AppSdkRuntimeError> { 516 let mut sdk_runtime = self 517 .sdk_runtime 518 .lock() 519 .unwrap_or_else(|poisoned| poisoned.into_inner()); 520 let Some(runtime) = sdk_runtime.take() else { 521 return Ok(false); 522 }; 523 runtime.shutdown()?; 524 Ok(true) 525 } 526 527 pub fn sdk_diagnostics(&self) -> Result<Option<AppSdkDiagnostics>, AppSdkRuntimeError> { 528 let sdk_runtime = self 529 .sdk_runtime 530 .lock() 531 .unwrap_or_else(|poisoned| poisoned.into_inner()); 532 let Some(runtime) = sdk_runtime.as_ref() else { 533 return Ok(None); 534 }; 535 runtime.diagnostics().map(Some) 536 } 537 538 pub fn sdk_diagnostics_summary(&self) -> Option<DesktopAppSdkDiagnosticsSummary> { 539 let sdk_runtime = self 540 .sdk_runtime 541 .lock() 542 .unwrap_or_else(|poisoned| poisoned.into_inner()); 543 let runtime = sdk_runtime.as_ref()?; 544 let status = runtime.status(); 545 match runtime.diagnostics() { 546 Ok(diagnostics) => Some(DesktopAppSdkDiagnosticsSummary { 547 status: DesktopAppSdkStatusSummary::from_status(&diagnostics.runtime), 548 state: DesktopAppSdkDiagnosticsState::Ready( 549 DesktopAppSdkReadyDiagnosticsSummary::from_diagnostics(&diagnostics), 550 ), 551 }), 552 Err(error) => { 553 let issue = desktop_app_sdk_issue_from_runtime_error(&error); 554 let mut status = DesktopAppSdkStatusSummary::from_status(&status); 555 status.last_issue = Some(issue.clone()); 556 Some(DesktopAppSdkDiagnosticsSummary { 557 status, 558 state: DesktopAppSdkDiagnosticsState::Blocked(issue), 559 }) 560 } 561 } 562 } 563 564 pub fn selected_settings_section(&self) -> SettingsSection { 565 self.lock_state() 566 .state_store 567 .shell_projection() 568 .settings 569 .selected_section 570 } 571 572 pub fn sync_settings_section(&self, section: SettingsSection) -> bool { 573 self.lock_state_mut() 574 .state_store 575 .apply_in_memory(AppStateCommand::select_settings_section(section)) 576 } 577 578 pub fn show_startup_identity_choice(&self) -> bool { 579 self.lock_state_mut() 580 .state_store 581 .apply_in_memory(AppStateCommand::show_startup_identity_choice()) 582 } 583 584 pub fn begin_generate_key_startup(&self) -> bool { 585 self.lock_state_mut() 586 .state_store 587 .apply_in_memory(AppStateCommand::begin_generate_key_startup()) 588 } 589 590 pub fn show_startup_signer_entry(&self) -> bool { 591 self.lock_state_mut() 592 .state_store 593 .apply_in_memory(AppStateCommand::show_startup_signer_entry()) 594 } 595 596 pub fn set_startup_signer_source_input(&self, source_input: &str) -> bool { 597 self.lock_state_mut().state_store.apply_in_memory( 598 AppStateCommand::set_startup_signer_source_input(source_input), 599 ) 600 } 601 602 pub fn load_startup_pending_remote_signer_session( 603 &self, 604 ) -> Result<Option<RadrootsAppRemoteSignerPendingSession>, DesktopAppRuntimeCommandError> { 605 self.lock_state() 606 .load_startup_pending_remote_signer_session() 607 } 608 609 pub fn store_startup_pending_remote_signer_session( 610 &self, 611 pending: &RadrootsAppRemoteSignerPendingSession, 612 ) -> Result<bool, DesktopAppRuntimeCommandError> { 613 self.lock_state_mut() 614 .store_startup_pending_remote_signer_session(pending) 615 } 616 617 pub fn clear_startup_pending_remote_signer_session( 618 &self, 619 ) -> Result<bool, DesktopAppRuntimeCommandError> { 620 self.lock_state_mut() 621 .clear_startup_pending_remote_signer_session() 622 } 623 624 pub fn activate_startup_approved_remote_signer_session( 625 &self, 626 pending: &RadrootsAppRemoteSignerPendingSession, 627 approved: &RadrootsAppRemoteSignerApprovedSession, 628 ) -> Result<bool, DesktopAppRuntimeCommandError> { 629 self.lock_state_mut() 630 .activate_startup_approved_remote_signer_session(pending, approved) 631 } 632 633 pub fn select_settings_section(&self, section: SettingsSection) -> bool { 634 let changed = self.sync_settings_section(section); 635 636 if changed { 637 let _ = self.record_activity(AppActivityKind::SettingsSectionSelected { section }); 638 } 639 640 changed 641 } 642 643 pub fn select_home(&self) -> bool { 644 let mut state = self.lock_state_mut(); 645 let selected_section = match state.state_store.startup_gate() { 646 AppStartupGate::Farmer => ShellSection::Farmer(FarmerSection::Today), 647 AppStartupGate::Blocked | AppStartupGate::SetupRequired => ShellSection::Home, 648 AppStartupGate::Personal => ShellSection::Personal(PersonalSection::Browse), 649 }; 650 651 let section_changed = state 652 .state_store 653 .apply_in_memory(AppStateCommand::SelectSection(selected_section)); 654 let editor_changed = state.close_product_editor(); 655 656 section_changed || editor_changed 657 } 658 659 pub fn select_account(&self) -> bool { 660 let mut state = self.lock_state_mut(); 661 let section_changed = state 662 .state_store 663 .apply_in_memory(AppStateCommand::SelectSection(ShellSection::Account)); 664 let editor_changed = state.close_product_editor(); 665 666 section_changed || editor_changed 667 } 668 669 pub fn select_personal_section( 670 &self, 671 section: PersonalSection, 672 ) -> Result<bool, AppSqliteError> { 673 self.lock_state_mut().select_personal_section(section) 674 } 675 676 pub fn select_farmer_section(&self, section: FarmerSection) -> bool { 677 self.lock_state_mut().select_farmer_section(section) 678 } 679 680 pub fn open_personal_product_detail( 681 &self, 682 section: PersonalSection, 683 product_id: ProductId, 684 ) -> Result<bool, AppSqliteError> { 685 self.lock_state_mut() 686 .open_personal_product_detail(section, product_id) 687 } 688 689 pub fn close_personal_product_detail(&self, section: PersonalSection) -> bool { 690 self.lock_state_mut().close_personal_product_detail(section) 691 } 692 693 pub fn increase_personal_product_quantity(&self, section: PersonalSection) -> bool { 694 self.lock_state_mut() 695 .adjust_personal_product_quantity(section, 1) 696 } 697 698 pub fn decrease_personal_product_quantity(&self, section: PersonalSection) -> bool { 699 self.lock_state_mut() 700 .adjust_personal_product_quantity(section, -1) 701 } 702 703 pub fn add_personal_product_to_cart( 704 &self, 705 section: PersonalSection, 706 replace_existing: bool, 707 ) -> Result<bool, AppSqliteError> { 708 self.lock_state_mut() 709 .add_personal_product_to_cart(section, replace_existing) 710 } 711 712 pub fn clear_personal_cart_replace_confirmation(&self) -> bool { 713 self.lock_state_mut() 714 .clear_personal_cart_replace_confirmation() 715 } 716 717 pub fn remove_personal_cart_line(&self, product_id: ProductId) -> Result<bool, AppSqliteError> { 718 self.lock_state_mut().remove_personal_cart_line(product_id) 719 } 720 721 pub fn save_personal_order_review_draft( 722 &self, 723 draft: BuyerOrderReviewDraft, 724 ) -> Result<bool, AppSqliteError> { 725 self.lock_state_mut() 726 .save_personal_order_review_draft(draft) 727 } 728 729 pub fn place_personal_order(&self) -> Result<bool, AppSqliteError> { 730 self.lock_state_mut().place_personal_order() 731 } 732 733 pub fn retry_pending_personal_order_coordination(&self) -> Result<bool, AppSqliteError> { 734 self.lock_state_mut() 735 .retry_pending_personal_order_coordination() 736 } 737 738 pub fn open_personal_order_detail(&self, order_id: OrderId) -> Result<bool, AppSqliteError> { 739 self.lock_state_mut().open_personal_order_detail(order_id) 740 } 741 742 pub fn repeat_personal_order( 743 &self, 744 order_id: OrderId, 745 replace_existing: bool, 746 ) -> Result<bool, AppSqliteError> { 747 self.lock_state_mut() 748 .repeat_personal_order(order_id, replace_existing) 749 } 750 751 pub fn set_personal_search_query(&self, search_query: &str) -> Result<bool, AppSqliteError> { 752 self.lock_state_mut() 753 .set_personal_search_query(search_query) 754 } 755 756 pub fn set_personal_search_fulfillment_method( 757 &self, 758 method: FarmOrderMethod, 759 enabled: bool, 760 ) -> Result<bool, AppSqliteError> { 761 self.lock_state_mut() 762 .set_personal_search_fulfillment_method(method, enabled) 763 } 764 765 pub fn set_products_search_query(&self, search_query: &str) -> Result<bool, AppSqliteError> { 766 self.lock_state_mut() 767 .set_products_search_query(search_query) 768 } 769 770 pub fn select_products_filter(&self, filter: ProductsFilter) -> Result<bool, AppSqliteError> { 771 self.lock_state_mut().select_products_filter(filter) 772 } 773 774 pub fn select_products_sort(&self, sort: ProductsSort) -> Result<bool, AppSqliteError> { 775 self.lock_state_mut().select_products_sort(sort) 776 } 777 778 pub fn open_products_filter(&self, filter: ProductsFilter) -> Result<bool, AppSqliteError> { 779 self.lock_state_mut().open_products_filter(filter) 780 } 781 782 pub fn select_orders_filter(&self, filter: OrdersFilter) -> Result<bool, AppSqliteError> { 783 self.lock_state_mut().select_orders_filter(filter) 784 } 785 786 pub fn open_orders(&self) -> Result<bool, AppSqliteError> { 787 self.lock_state_mut().open_orders() 788 } 789 790 pub fn open_orders_fulfillment_window( 791 &self, 792 fulfillment_window_id: FulfillmentWindowId, 793 ) -> Result<bool, AppSqliteError> { 794 self.lock_state_mut() 795 .open_orders_fulfillment_window(fulfillment_window_id) 796 } 797 798 pub fn open_order_detail(&self, order_id: OrderId) -> Result<bool, AppSqliteError> { 799 self.lock_state_mut().open_order_detail(order_id) 800 } 801 802 pub fn prepare_order_accept( 803 &self, 804 order_id: OrderId, 805 ) -> Result<AppOrderDecisionPublishPayload, AppSqliteError> { 806 self.lock_state_mut() 807 .prepare_seller_order_decision(order_id, AppSellerOrderDecisionCommand::Accept) 808 } 809 810 pub fn prepare_order_decline( 811 &self, 812 order_id: OrderId, 813 reason: &str, 814 ) -> Result<AppOrderDecisionPublishPayload, AppSqliteError> { 815 self.lock_state_mut().prepare_seller_order_decision( 816 order_id, 817 AppSellerOrderDecisionCommand::Decline { 818 reason: reason.to_owned(), 819 }, 820 ) 821 } 822 823 pub fn publish_order_accept(&self, order_id: OrderId) -> Result<bool, AppSqliteError> { 824 self.lock_state_mut() 825 .publish_seller_order_decision(order_id, AppSellerOrderDecisionCommand::Accept) 826 } 827 828 pub fn publish_order_decline( 829 &self, 830 order_id: OrderId, 831 reason: &str, 832 ) -> Result<bool, AppSqliteError> { 833 self.lock_state_mut().publish_seller_order_decision( 834 order_id, 835 AppSellerOrderDecisionCommand::Decline { 836 reason: reason.to_owned(), 837 }, 838 ) 839 } 840 841 pub fn publish_order_revision_proposal( 842 &self, 843 order_id: OrderId, 844 items: Vec<RadrootsOrderItem>, 845 economics: RadrootsOrderEconomics, 846 reason: &str, 847 ) -> Result<bool, AppSqliteError> { 848 self.lock_state_mut() 849 .publish_seller_order_revision_proposal(order_id, items, economics, reason) 850 } 851 852 pub fn publish_buyer_order_cancel(&self, order_id: OrderId) -> Result<bool, AppSqliteError> { 853 self.lock_state_mut() 854 .publish_buyer_order_cancellation(order_id) 855 } 856 857 pub fn publish_buyer_order_revision_accept( 858 &self, 859 order_id: OrderId, 860 ) -> Result<bool, AppSqliteError> { 861 self.lock_state_mut() 862 .publish_buyer_order_revision_accept(order_id) 863 } 864 865 pub fn publish_buyer_order_revision_decline( 866 &self, 867 order_id: OrderId, 868 ) -> Result<bool, AppSqliteError> { 869 self.lock_state_mut() 870 .publish_buyer_order_revision_decline(order_id) 871 } 872 873 pub fn open_pack_day( 874 &self, 875 fulfillment_window_id: Option<FulfillmentWindowId>, 876 ) -> Result<bool, AppSqliteError> { 877 self.lock_state_mut().open_pack_day(fulfillment_window_id) 878 } 879 880 pub fn export_pack_day(&self) -> Result<bool, DesktopAppRuntimeCommandError> { 881 self.lock_state_mut().export_pack_day() 882 } 883 884 pub fn prepare_pack_day_host_handoff( 885 &self, 886 kind: PackDayHostHandoffKind, 887 ) -> Result< 888 Option<(PackDayHostHandoffRequest, PackDayHostHandoffCommandPlan)>, 889 DesktopAppRuntimeCommandError, 890 > { 891 self.lock_state_mut().prepare_pack_day_host_handoff(kind) 892 } 893 894 pub fn finish_pack_day_host_handoff( 895 &self, 896 request: PackDayHostHandoffRequest, 897 result: Result<(), PackDayHostHandoffError>, 898 ) -> Result<bool, DesktopAppRuntimeCommandError> { 899 self.lock_state_mut() 900 .finish_pack_day_host_handoff(request, result) 901 } 902 903 pub fn prepare_pack_day_print( 904 &self, 905 kind: PackDayPrintKind, 906 ) -> Result<Option<(PackDayPrintRequest, PackDayPrintCommandPlan)>, DesktopAppRuntimeCommandError> 907 { 908 self.lock_state_mut().prepare_pack_day_print(kind) 909 } 910 911 pub fn finish_pack_day_print( 912 &self, 913 request: PackDayPrintRequest, 914 result: Result<(), PackDayPrintError>, 915 ) -> Result<bool, DesktopAppRuntimeCommandError> { 916 self.lock_state_mut().finish_pack_day_print(request, result) 917 } 918 919 pub fn prepare_pack_day_batch_print( 920 &self, 921 ) -> Result< 922 Option<(PackDayBatchPrintRequest, PackDayBatchPrintCommandPlan)>, 923 DesktopAppRuntimeCommandError, 924 > { 925 self.lock_state_mut().prepare_pack_day_batch_print() 926 } 927 928 pub fn finish_pack_day_batch_print( 929 &self, 930 request: PackDayBatchPrintRequest, 931 result: Result<(), PackDayBatchPrintError>, 932 ) -> Result<bool, DesktopAppRuntimeCommandError> { 933 self.lock_state_mut() 934 .finish_pack_day_batch_print(request, result) 935 } 936 937 pub fn update_product_stock( 938 &self, 939 product_id: ProductId, 940 stock_quantity: u32, 941 ) -> Result<bool, DesktopAppRuntimeProductStockUpdateError> { 942 self.lock_state_mut() 943 .update_product_stock(product_id, stock_quantity) 944 } 945 946 pub fn open_new_product_editor(&self) -> Result<bool, AppSqliteError> { 947 self.lock_state_mut().open_new_product_editor() 948 } 949 950 pub fn open_existing_product_editor( 951 &self, 952 product_id: ProductId, 953 ) -> Result<bool, AppSqliteError> { 954 self.lock_state_mut() 955 .open_existing_product_editor(product_id) 956 } 957 958 pub fn save_product_editor_draft( 959 &self, 960 draft: ProductEditorDraft, 961 ) -> Result<bool, DesktopAppRuntimeProductEditorSaveError> { 962 self.lock_state_mut().save_product_editor_draft(draft) 963 } 964 965 pub fn close_product_editor(&self) -> bool { 966 self.lock_state_mut().close_product_editor() 967 } 968 969 pub fn set_settings_preference(&self, preference: SettingsPreference, enabled: bool) -> bool { 970 let changed = self.lock_state_mut().state_store.apply_in_memory( 971 AppStateCommand::SetSettingsPreference { 972 preference, 973 enabled, 974 }, 975 ); 976 977 if changed { 978 let _ = self.record_activity(AppActivityKind::SettingsPreferenceUpdated { 979 preference, 980 enabled, 981 }); 982 } 983 984 changed 985 } 986 987 pub fn generate_local_account( 988 &self, 989 label: Option<String>, 990 ) -> Result<bool, DesktopAppRuntimeCommandError> { 991 self.lock_state_mut().generate_local_account(label) 992 } 993 994 pub fn import_local_account( 995 &self, 996 request: DesktopLocalIdentityImportRequest, 997 ) -> Result<bool, DesktopAppRuntimeCommandError> { 998 self.lock_state_mut().import_local_account(&request) 999 } 1000 1001 pub fn select_local_account( 1002 &self, 1003 account_id: &str, 1004 ) -> Result<bool, DesktopAppRuntimeCommandError> { 1005 self.lock_state_mut().select_local_account(account_id) 1006 } 1007 1008 pub fn select_active_surface( 1009 &self, 1010 active_surface: ActiveSurface, 1011 ) -> Result<bool, DesktopAppRuntimeCommandError> { 1012 self.lock_state_mut().select_active_surface(active_surface) 1013 } 1014 1015 pub fn remove_selected_local_key(&self) -> Result<bool, DesktopAppRuntimeCommandError> { 1016 self.lock_state_mut().remove_selected_local_key() 1017 } 1018 1019 pub fn reset_local_device_state(&self) -> Result<bool, DesktopAppRuntimeCommandError> { 1020 self.lock_state_mut().reset_local_device_state() 1021 } 1022 1023 #[allow(dead_code)] 1024 pub fn replace_today_agenda(&self, projection: TodayAgendaProjection) -> bool { 1025 self.lock_state_mut() 1026 .state_store 1027 .apply_in_memory(AppStateCommand::replace_today_agenda(projection)) 1028 } 1029 1030 pub fn select_farm_setup_flow_stage(&self, stage: FarmSetupFlowStage) -> bool { 1031 self.lock_state_mut() 1032 .state_store 1033 .apply_in_memory(AppStateCommand::select_farm_setup_flow_stage(stage)) 1034 } 1035 1036 pub fn save_farm_setup_draft( 1037 &self, 1038 draft: FarmSetupDraft, 1039 ) -> Result<FarmSetupProjection, DesktopAppRuntimeFarmSetupError> { 1040 self.lock_state_mut().save_farm_setup_draft(draft) 1041 } 1042 1043 pub fn finish_farm_setup( 1044 &self, 1045 ) -> Result<FarmSetupProjection, DesktopAppRuntimeFarmSetupError> { 1046 self.lock_state_mut().finish_farm_setup() 1047 } 1048 1049 pub fn load_farm_rules_projection( 1050 &self, 1051 ) -> Result<FarmRulesProjection, DesktopAppRuntimeFarmRulesError> { 1052 self.lock_state().load_farm_rules_projection() 1053 } 1054 1055 pub fn save_farm_rules_projection( 1056 &self, 1057 projection: FarmRulesProjection, 1058 ) -> Result<FarmRulesProjection, DesktopAppRuntimeFarmRulesError> { 1059 self.lock_state_mut().save_farm_rules_projection(projection) 1060 } 1061 1062 pub fn sync_on_app_launch(&self) -> Result<bool, AppSqliteError> { 1063 self.lock_state_mut().attempt_sync(SyncTrigger::AppLaunch) 1064 } 1065 1066 pub fn sync_on_foreground_resume(&self) -> Result<bool, AppSqliteError> { 1067 self.lock_state_mut().sync_on_foreground_resume() 1068 } 1069 1070 pub fn sync_on_manual_refresh(&self) -> Result<bool, AppSqliteError> { 1071 self.lock_state_mut() 1072 .attempt_sync(SyncTrigger::ManualRefresh) 1073 } 1074 1075 pub fn refresh_shared_local_events( 1076 &self, 1077 ) -> Result<AppLocalInteropImportReport, AppSqliteError> { 1078 let mut state = self.lock_state_mut(); 1079 let report = state.import_shared_local_events()?; 1080 let _ = state.refresh_selected_account_context_after_local_events()?; 1081 Ok(report) 1082 } 1083 1084 pub fn resolve_sync_conflict( 1085 &self, 1086 conflict_id: &str, 1087 resolution: radroots_app_sync::SyncConflictResolutionStatus, 1088 ) -> Result<bool, AppSqliteError> { 1089 self.lock_state_mut() 1090 .resolve_sync_conflict(conflict_id, resolution) 1091 } 1092 1093 pub fn acknowledge_reminder(&self, reminder_id: ReminderId) -> Result<bool, AppSqliteError> { 1094 self.lock_state_mut().acknowledge_reminder(reminder_id) 1095 } 1096 1097 pub fn record_home_opened(&self) -> bool { 1098 self.record_activity(AppActivityKind::HomeOpened) 1099 } 1100 1101 pub fn record_settings_opened(&self, section: SettingsSection) -> bool { 1102 self.record_activity(AppActivityKind::SettingsOpened { section }) 1103 } 1104 1105 pub fn activity_context( 1106 &self, 1107 limit: Option<usize>, 1108 ) -> Result<AppActivityContext, DesktopAppRuntimeActivityContextError> { 1109 let state = self.lock_state(); 1110 let store = state 1111 .sqlite_store 1112 .as_ref() 1113 .ok_or(DesktopAppRuntimeActivityContextError::RuntimeUnavailable)?; 1114 1115 store 1116 .load_activity_context(limit.unwrap_or(APP_ACTIVITY_CONTEXT_LIMIT)) 1117 .map_err(DesktopAppRuntimeActivityContextError::from) 1118 } 1119 1120 fn from_state(state: DesktopAppRuntimeState) -> Self { 1121 let sdk_runtime = Arc::new(Mutex::new(None)); 1122 Self { 1123 state: Arc::new(Mutex::new(state)), 1124 sdk_runtime, 1125 } 1126 } 1127 1128 fn from_state_with_sdk_runtime( 1129 mut state: DesktopAppRuntimeState, 1130 sdk_runtime: AppSdkRuntime, 1131 ) -> Self { 1132 let sdk_runtime = Arc::new(Mutex::new(Some(sdk_runtime))); 1133 state.sdk_runtime = Some(Arc::clone(&sdk_runtime)); 1134 Self { 1135 state: Arc::new(Mutex::new(state)), 1136 sdk_runtime, 1137 } 1138 } 1139 1140 fn lock_state(&self) -> MutexGuard<'_, DesktopAppRuntimeState> { 1141 self.state.lock().unwrap_or_else(PoisonError::into_inner) 1142 } 1143 1144 fn lock_state_mut(&self) -> MutexGuard<'_, DesktopAppRuntimeState> { 1145 self.state.lock().unwrap_or_else(PoisonError::into_inner) 1146 } 1147 1148 fn record_activity(&self, kind: AppActivityKind) -> bool { 1149 let result = self.lock_state().record_activity(kind.clone()); 1150 if let Err(error) = result { 1151 error!( 1152 target: "activity", 1153 event = "activity.record_failed", 1154 activity_kind = kind.storage_key(), 1155 error = %error, 1156 "failed to record activity event" 1157 ); 1158 return false; 1159 } 1160 1161 true 1162 } 1163 } 1164 1165 fn default_runtime_snapshot() -> AppRuntimeSnapshot { 1166 AppRuntimeSnapshot::from_capture( 1167 AppBuildIdentity { 1168 package_name: env!("CARGO_PKG_NAME").to_owned(), 1169 package_version: env!("CARGO_PKG_VERSION").to_owned(), 1170 build_profile: option_env!("PROFILE").unwrap_or("debug").to_owned(), 1171 target_triple: option_env!("TARGET").unwrap_or("unknown-target").to_owned(), 1172 projection_source: "rust".to_owned(), 1173 git_commit: None, 1174 }, 1175 AppRuntimeMode::Development, 1176 AppRuntimeCapture { 1177 host_locale: "en_US.UTF-8".to_owned(), 1178 operating_system: "macos".to_owned(), 1179 run_id: "runtime-summary-test-run".to_owned(), 1180 }, 1181 ) 1182 } 1183 1184 fn start_desktop_sdk_runtime( 1185 paths: &AppDesktopRuntimePaths, 1186 nostr_relay_urls: Vec<String>, 1187 ) -> Result<AppSdkRuntime, AppSdkRuntimeError> { 1188 AppSdkRuntime::start(AppSdkConfig::from_desktop_paths(paths, nostr_relay_urls)) 1189 } 1190 1191 fn sdk_storage_path_pair(paths: Option<&AppSdkStoragePaths>) -> (Option<PathBuf>, Option<PathBuf>) { 1192 paths 1193 .map(|paths| { 1194 ( 1195 Some(paths.event_store_path.clone()), 1196 Some(paths.outbox_path.clone()), 1197 ) 1198 }) 1199 .unwrap_or((None, None)) 1200 } 1201 1202 fn desktop_app_sdk_issue_from_runtime_error( 1203 error: &AppSdkRuntimeError, 1204 ) -> DesktopAppSdkIssueSummary { 1205 match error { 1206 AppSdkRuntimeError::CommandFailed(issue) => DesktopAppSdkIssueSummary::from_issue(issue), 1207 AppSdkRuntimeError::CommandQueueCapacityZero => DesktopAppSdkIssueSummary::runtime( 1208 "sdk_command_queue_capacity_zero", 1209 false, 1210 ["review_runtime_configuration"], 1211 ), 1212 AppSdkRuntimeError::WorkerSpawn(_) => { 1213 DesktopAppSdkIssueSummary::runtime("sdk_worker_spawn_failed", true, ["retry_startup"]) 1214 } 1215 AppSdkRuntimeError::CommandQueueFull => DesktopAppSdkIssueSummary::runtime( 1216 "sdk_command_queue_full", 1217 true, 1218 ["retry_status_refresh"], 1219 ), 1220 AppSdkRuntimeError::CommandQueueClosed => { 1221 DesktopAppSdkIssueSummary::runtime("sdk_command_queue_closed", true, ["retry_startup"]) 1222 } 1223 AppSdkRuntimeError::CommandResponseClosed => DesktopAppSdkIssueSummary::runtime( 1224 "sdk_command_response_closed", 1225 true, 1226 ["retry_status_refresh"], 1227 ), 1228 AppSdkRuntimeError::ShutdownAck => { 1229 DesktopAppSdkIssueSummary::runtime("sdk_shutdown_ack_failed", true, ["retry_startup"]) 1230 } 1231 AppSdkRuntimeError::WorkerJoin => { 1232 DesktopAppSdkIssueSummary::runtime("sdk_worker_join_failed", true, ["retry_startup"]) 1233 } 1234 } 1235 } 1236 1237 #[derive(Clone, Debug, Default, Eq, PartialEq)] 1238 pub struct DesktopAppSyncStatusSummary { 1239 pub account_id: Option<String>, 1240 pub projection: AppSyncProjection, 1241 pub pending_write_count: usize, 1242 pub conflicts: Vec<DesktopAppSyncConflictSummary>, 1243 } 1244 1245 impl DesktopAppSyncStatusSummary { 1246 pub const fn is_enabled(&self) -> bool { 1247 self.account_id.is_some() 1248 } 1249 } 1250 1251 #[derive(Clone, Debug, Eq, PartialEq)] 1252 pub struct DesktopAppSyncConflictSummary { 1253 pub conflict_id: String, 1254 pub conflict: radroots_app_sync::SyncConflict, 1255 } 1256 1257 #[derive(Clone, Debug, Eq, PartialEq)] 1258 pub struct DesktopAppSdkStatusSummary { 1259 pub lifecycle_state: AppSdkLifecycleState, 1260 pub projection_lifecycle_state: AppSdkProjectionLifecycleState, 1261 pub projection_lifecycle_reason: Option<String>, 1262 pub storage_root: PathBuf, 1263 pub event_store_path: Option<PathBuf>, 1264 pub outbox_path: Option<PathBuf>, 1265 pub relay_target_count: usize, 1266 pub relay_url_policy: AppSdkRelayUrlPolicy, 1267 pub last_issue: Option<DesktopAppSdkIssueSummary>, 1268 } 1269 1270 impl DesktopAppSdkStatusSummary { 1271 fn from_status(status: &AppSdkRuntimeStatus) -> Self { 1272 let (event_store_path, outbox_path) = sdk_storage_path_pair(status.storage_paths.as_ref()); 1273 Self { 1274 lifecycle_state: status.state, 1275 projection_lifecycle_state: status.projection_lifecycle.state, 1276 projection_lifecycle_reason: status.projection_lifecycle.reason.clone(), 1277 storage_root: status.storage_root.clone(), 1278 event_store_path, 1279 outbox_path, 1280 relay_target_count: status.relay_urls.len(), 1281 relay_url_policy: status.relay_url_policy, 1282 last_issue: status 1283 .last_issue 1284 .as_ref() 1285 .map(DesktopAppSdkIssueSummary::from_issue), 1286 } 1287 } 1288 } 1289 1290 #[derive(Clone, Debug, Eq, PartialEq)] 1291 pub struct DesktopAppSdkDiagnosticsSummary { 1292 pub status: DesktopAppSdkStatusSummary, 1293 pub state: DesktopAppSdkDiagnosticsState, 1294 } 1295 1296 #[derive(Clone, Debug, Eq, PartialEq)] 1297 pub enum DesktopAppSdkDiagnosticsState { 1298 Ready(DesktopAppSdkReadyDiagnosticsSummary), 1299 Blocked(DesktopAppSdkIssueSummary), 1300 } 1301 1302 #[derive(Clone, Debug, Eq, PartialEq)] 1303 pub struct DesktopAppSdkReadyDiagnosticsSummary { 1304 pub storage_kind: String, 1305 pub event_store_total_events: i64, 1306 pub outbox_total_events: i64, 1307 pub outbox_pending_events: i64, 1308 pub outbox_failed_terminal_events: i64, 1309 pub integrity_event_store_ok: bool, 1310 pub integrity_outbox_ok: bool, 1311 pub sync_source: String, 1312 pub sync_observed_at_ms: i64, 1313 pub sync_relay_target_count: usize, 1314 } 1315 1316 impl DesktopAppSdkReadyDiagnosticsSummary { 1317 fn from_diagnostics(diagnostics: &AppSdkDiagnostics) -> Self { 1318 Self { 1319 storage_kind: diagnostics.storage.storage_kind.clone(), 1320 event_store_total_events: diagnostics.storage.event_store.total_events, 1321 outbox_total_events: diagnostics.storage.outbox.total_events, 1322 outbox_pending_events: diagnostics.storage.outbox.pending_events, 1323 outbox_failed_terminal_events: diagnostics.storage.outbox.failed_terminal_events, 1324 integrity_event_store_ok: diagnostics.integrity.event_store_ok, 1325 integrity_outbox_ok: diagnostics.integrity.outbox_ok, 1326 sync_source: diagnostics.sync.source.clone(), 1327 sync_observed_at_ms: diagnostics.sync.observed_at_ms, 1328 sync_relay_target_count: diagnostics.sync.relay_targets.configured_count, 1329 } 1330 } 1331 1332 pub const fn integrity_ok(&self) -> bool { 1333 self.integrity_event_store_ok && self.integrity_outbox_ok 1334 } 1335 } 1336 1337 #[derive(Clone, Debug, Eq, PartialEq)] 1338 pub struct DesktopAppSdkIssueSummary { 1339 pub code: String, 1340 pub class: String, 1341 pub retryable: bool, 1342 pub recovery_actions: Vec<String>, 1343 } 1344 1345 impl DesktopAppSdkIssueSummary { 1346 fn from_issue(issue: &AppSdkRuntimeIssue) -> Self { 1347 Self { 1348 code: issue.code.clone(), 1349 class: issue.class.clone(), 1350 retryable: issue.retryable, 1351 recovery_actions: issue.recovery_actions.clone(), 1352 } 1353 } 1354 1355 fn runtime( 1356 code: impl Into<String>, 1357 retryable: bool, 1358 recovery_actions: impl IntoIterator<Item = &'static str>, 1359 ) -> Self { 1360 Self { 1361 code: code.into(), 1362 class: "runtime".to_owned(), 1363 retryable, 1364 recovery_actions: recovery_actions.into_iter().map(str::to_owned).collect(), 1365 } 1366 } 1367 } 1368 1369 #[derive(Clone, Debug, Eq, PartialEq)] 1370 pub struct DesktopAppRuntimeMetadataSummary { 1371 pub snapshot: AppRuntimeSnapshot, 1372 pub data_root: Option<PathBuf>, 1373 pub logs_root: Option<PathBuf>, 1374 pub database_path: Option<PathBuf>, 1375 pub database_schema_version: Option<u32>, 1376 } 1377 1378 impl DesktopAppRuntimeMetadataSummary { 1379 fn available( 1380 snapshot: AppRuntimeSnapshot, 1381 paths: &AppDesktopRuntimePaths, 1382 database_path: PathBuf, 1383 database_schema_version: u32, 1384 ) -> Self { 1385 Self { 1386 snapshot, 1387 data_root: Some(paths.app.data.clone()), 1388 logs_root: Some(paths.app.logs.clone()), 1389 database_path: Some(database_path), 1390 database_schema_version: Some(database_schema_version), 1391 } 1392 } 1393 1394 fn unavailable(snapshot: AppRuntimeSnapshot) -> Self { 1395 Self { 1396 snapshot, 1397 data_root: None, 1398 logs_root: None, 1399 database_path: None, 1400 database_schema_version: None, 1401 } 1402 } 1403 } 1404 1405 impl Default for DesktopAppRuntimeMetadataSummary { 1406 fn default() -> Self { 1407 Self::unavailable(default_runtime_snapshot()) 1408 } 1409 } 1410 1411 #[derive(Clone, Debug)] 1412 pub struct DesktopAppRuntimeSummary { 1413 pub shell_projection: AppShellProjection, 1414 pub settings_account_projection: SettingsAccountProjection, 1415 pub startup_gate: AppStartupGate, 1416 pub logged_out_startup: LoggedOutStartupProjection, 1417 pub home_route: HomeRoute, 1418 pub personal_projection: PersonalWorkspaceProjection, 1419 pub farm_setup_projection: FarmSetupProjection, 1420 pub farm_rules_projection: FarmRulesProjection, 1421 pub farm_readiness_projection: FarmWorkspaceReadinessProjection, 1422 pub today_projection: TodayAgendaProjection, 1423 pub products_projection: ProductsScreenProjection, 1424 pub orders_projection: OrdersScreenProjection, 1425 pub pack_day_projection: PackDayScreenProjection, 1426 pub reminder_log: ReminderLogProjection, 1427 pub runtime_metadata: DesktopAppRuntimeMetadataSummary, 1428 pub sync_status: DesktopAppSyncStatusSummary, 1429 pub startup_issue: Option<String>, 1430 pub sdk_status: Option<DesktopAppSdkStatusSummary>, 1431 } 1432 1433 #[derive(Debug, Error)] 1434 pub enum DesktopAppRuntimeActivityContextError { 1435 #[error("desktop runtime activity context is unavailable while the runtime is degraded")] 1436 RuntimeUnavailable, 1437 #[error(transparent)] 1438 Sqlite(#[from] AppSqliteError), 1439 } 1440 1441 #[derive(Clone, Debug, Default)] 1442 struct DesktopSelectedAccountContext { 1443 personal_projection: PersonalWorkspaceProjection, 1444 farm_setup_projection: FarmSetupProjection, 1445 farm_rules_projection: FarmRulesProjection, 1446 today_projection: TodayAgendaProjection, 1447 products_query: ProductsScreenQueryState, 1448 products_list: ProductsListProjection, 1449 orders_query: OrdersScreenQueryState, 1450 orders_list: OrdersListProjection, 1451 orders_reminders: ReminderFeedProjection, 1452 order_detail: Option<OrderDetailProjection>, 1453 pack_day_query: PackDayScreenQueryState, 1454 pack_day_projection: PackDayProjection, 1455 product_editor_draft: Option<(ProductId, ProductEditorDraft)>, 1456 reminder_log: ReminderLogProjection, 1457 } 1458 1459 #[derive(Clone, Debug, Default)] 1460 struct DesktopSellerReminderContext { 1461 today_feed: ReminderFeedProjection, 1462 orders_feed: ReminderFeedProjection, 1463 pack_day_feed: ReminderFeedProjection, 1464 due_soon_count: u32, 1465 reminder_log: ReminderLogProjection, 1466 } 1467 1468 #[derive(Clone, Debug, Default)] 1469 struct DesktopReminderSyncTruth { 1470 checkpoint: SyncCheckpointStatus, 1471 pending_write_count: usize, 1472 unresolved_conflict_count: usize, 1473 blocking_conflict_count: usize, 1474 } 1475 1476 #[derive(Clone, Debug, Default, Eq, PartialEq)] 1477 struct DesktopSelectedAccountSyncContext { 1478 projection: AppSyncProjection, 1479 relay_ingest: AppRelayIngestScopeFreshness, 1480 pending_write_count: usize, 1481 conflicts: Vec<DesktopAppSyncConflictSummary>, 1482 } 1483 1484 #[derive(Clone, Debug)] 1485 struct DesktopPreparedSyncRequest { 1486 account_id: String, 1487 checkpoint: SyncCheckpointStatus, 1488 conflicts: Vec<StoredSyncConflict>, 1489 pending_operations: Vec<StoredPendingSyncOperation>, 1490 } 1491 1492 struct DesktopAppRuntimeState { 1493 state_store: AppStateStore<AppStatePersistenceRepository>, 1494 nostr_relay_urls: Vec<String>, 1495 shared_accounts_paths: Option<AppSharedAccountsPaths>, 1496 remote_signer_paths: Option<DesktopRemoteSignerPaths>, 1497 accounts_manager: Option<RadrootsNostrAccountsManager>, 1498 sqlite_store: Option<AppSqliteStore>, 1499 sdk_runtime: Option<Arc<Mutex<Option<AppSdkRuntime>>>>, 1500 sync_transport: Box<dyn AppSyncTransport + Send>, 1501 runtime_metadata: DesktopAppRuntimeMetadataSummary, 1502 selected_account_pending_sync_write_count: usize, 1503 selected_account_relay_ingest_freshness: AppRelayIngestScopeFreshness, 1504 selected_account_sync_conflicts: Vec<DesktopAppSyncConflictSummary>, 1505 startup_issue: Option<String>, 1506 } 1507 1508 impl fmt::Debug for DesktopAppRuntimeState { 1509 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { 1510 formatter 1511 .debug_struct("DesktopAppRuntimeState") 1512 .field("state_store", &self.state_store) 1513 .field( 1514 "shared_accounts_paths", 1515 &self.shared_accounts_paths.as_ref().map(|_| "available"), 1516 ) 1517 .field( 1518 "remote_signer_paths", 1519 &self.remote_signer_paths.as_ref().map(|_| "available"), 1520 ) 1521 .field( 1522 "accounts_manager", 1523 &self.accounts_manager.as_ref().map(|_| "available"), 1524 ) 1525 .field( 1526 "sqlite_store", 1527 &self.sqlite_store.as_ref().map(|_| "available"), 1528 ) 1529 .field( 1530 "sdk_runtime", 1531 &self.sdk_runtime.as_ref().map(|_| "available"), 1532 ) 1533 .field("sync_transport", &"configured") 1534 .field("runtime_metadata", &self.runtime_metadata) 1535 .field( 1536 "selected_account_pending_sync_write_count", 1537 &self.selected_account_pending_sync_write_count, 1538 ) 1539 .field( 1540 "selected_account_relay_ingest_freshness", 1541 &self.selected_account_relay_ingest_freshness, 1542 ) 1543 .field( 1544 "selected_account_sync_conflicts", 1545 &self.selected_account_sync_conflicts, 1546 ) 1547 .field("startup_issue", &self.startup_issue) 1548 .finish() 1549 } 1550 } 1551 1552 impl DesktopAppRuntimeState { 1553 fn bootstrap_from_paths( 1554 paths: AppDesktopRuntimePaths, 1555 nostr_relay_urls: Vec<String>, 1556 runtime_snapshot: AppRuntimeSnapshot, 1557 ) -> Result<Self, DesktopAppRuntimeBootstrapError> { 1558 if let Err(error) = cleanup_prepared_customer_label_asset_root() { 1559 error!( 1560 target: "pack_day", 1561 event = "pack_day.print_prepared_asset_bootstrap_sweep_failed", 1562 error = %error, 1563 "failed to sweep prepared pack day print assets during bootstrap" 1564 ); 1565 } 1566 let database_path = paths.app.data.join(APP_DATABASE_FILE_NAME); 1567 let sqlite_store = AppSqliteStore::open(DatabaseTarget::Path(database_path.clone()))?; 1568 let shared_local_events_database_path = paths.shared_local_events_database_path()?; 1569 let _ = sqlite_store 1570 .import_shared_local_events_from_path(shared_local_events_database_path.as_path())?; 1571 let database_schema_version = sqlite_store.schema_version()?; 1572 let mut state_store = AppStateStore::load(AppStatePersistenceRepository::file_backed( 1573 paths.app.data.join(APP_STATE_FILE_NAME), 1574 ))?; 1575 let continuity_state = state_store.persisted_state().clone(); 1576 let remote_signer_paths = DesktopRemoteSignerPaths::from_runtime_paths(&paths); 1577 let accounts_bootstrap = bootstrap_desktop_accounts(&paths.shared_accounts, &sqlite_store)?; 1578 if let Some(accounts_manager) = accounts_bootstrap.accounts_manager.as_ref() { 1579 reconcile_startup(accounts_manager, &remote_signer_paths)?; 1580 } 1581 let identity_projection = apply_remote_signer_custody( 1582 identity_projection_from_manager( 1583 accounts_bootstrap 1584 .accounts_manager 1585 .as_ref() 1586 .expect("desktop bootstrap always returns an accounts manager"), 1587 &sqlite_store, 1588 )?, 1589 &remote_signer_paths, 1590 )?; 1591 let selected_account_context = 1592 load_selected_account_context(&sqlite_store, &identity_projection, &continuity_state)?; 1593 let selected_account_sync_context = load_selected_account_sync_context( 1594 &sqlite_store, 1595 &identity_projection, 1596 &nostr_relay_urls, 1597 )?; 1598 let _ = state_store.apply_in_memory(AppStateCommand::replace_identity_projection( 1599 identity_projection.clone(), 1600 )); 1601 if identity_projection.startup_gate() == AppStartupGate::SetupRequired 1602 && load_pending_session(&remote_signer_paths)?.is_some() 1603 { 1604 let _ = state_store.apply_in_memory(AppStateCommand::show_startup_signer_entry()); 1605 } 1606 let pending_sync_write_count = selected_account_sync_context.pending_write_count; 1607 let selected_account_relay_ingest_freshness = 1608 selected_account_sync_context.relay_ingest.clone(); 1609 let selected_account_sync_conflicts = selected_account_sync_context.conflicts; 1610 let _ = state_store.apply_in_memory(AppStateCommand::replace_sync_projection( 1611 selected_account_sync_context.projection, 1612 )); 1613 let sync_transport: Box<dyn AppSyncTransport + Send> = 1614 match accounts_bootstrap.accounts_manager.as_ref() { 1615 Some(accounts_manager) => Box::new(ConfiguredRelayAppSyncTransport::new( 1616 accounts_manager.clone(), 1617 nostr_relay_urls.clone(), 1618 )), 1619 None => default_sync_transport(), 1620 }; 1621 let mut state = Self { 1622 state_store, 1623 nostr_relay_urls, 1624 shared_accounts_paths: Some(paths.shared_accounts.clone()), 1625 remote_signer_paths: Some(remote_signer_paths), 1626 accounts_manager: accounts_bootstrap.accounts_manager, 1627 sqlite_store: Some(sqlite_store), 1628 sdk_runtime: None, 1629 sync_transport, 1630 runtime_metadata: DesktopAppRuntimeMetadataSummary::available( 1631 runtime_snapshot, 1632 &paths, 1633 database_path, 1634 database_schema_version, 1635 ), 1636 selected_account_pending_sync_write_count: pending_sync_write_count, 1637 selected_account_relay_ingest_freshness, 1638 selected_account_sync_conflicts, 1639 startup_issue: None, 1640 }; 1641 let _ = state.apply_selected_account_context(&selected_account_context); 1642 1643 Ok(state) 1644 } 1645 1646 #[cfg(test)] 1647 fn degraded(error: DesktopAppRuntimeBootstrapError) -> Self { 1648 Self::degraded_with_snapshot(error, default_runtime_snapshot()) 1649 } 1650 1651 fn degraded_with_snapshot( 1652 error: DesktopAppRuntimeBootstrapError, 1653 runtime_snapshot: AppRuntimeSnapshot, 1654 ) -> Self { 1655 Self { 1656 state_store: AppStateStore::load(AppStatePersistenceRepository::in_memory()) 1657 .expect("in-memory state store should load"), 1658 nostr_relay_urls: Vec::new(), 1659 shared_accounts_paths: None, 1660 remote_signer_paths: None, 1661 accounts_manager: None, 1662 sqlite_store: None, 1663 sdk_runtime: None, 1664 sync_transport: default_sync_transport(), 1665 runtime_metadata: DesktopAppRuntimeMetadataSummary::unavailable(runtime_snapshot), 1666 selected_account_pending_sync_write_count: 0, 1667 selected_account_relay_ingest_freshness: AppRelayIngestScopeFreshness::default(), 1668 selected_account_sync_conflicts: Vec::new(), 1669 startup_issue: Some(error.to_string()), 1670 } 1671 } 1672 1673 fn generate_local_account( 1674 &mut self, 1675 label: Option<String>, 1676 ) -> Result<bool, DesktopAppRuntimeCommandError> { 1677 let projection = { 1678 let accounts_manager = self.accounts_manager()?; 1679 let sqlite_store = self.sqlite_store()?; 1680 generate_local_account(accounts_manager, sqlite_store, label)? 1681 }; 1682 1683 self.replace_identity_projection(projection) 1684 } 1685 1686 fn import_local_account( 1687 &mut self, 1688 request: &DesktopLocalIdentityImportRequest, 1689 ) -> Result<bool, DesktopAppRuntimeCommandError> { 1690 let projection = { 1691 let accounts_manager = self.accounts_manager()?; 1692 let sqlite_store = self.sqlite_store()?; 1693 import_local_account(accounts_manager, sqlite_store, request)? 1694 }; 1695 1696 self.replace_identity_projection(projection) 1697 } 1698 1699 fn select_local_account( 1700 &mut self, 1701 account_id: &str, 1702 ) -> Result<bool, DesktopAppRuntimeCommandError> { 1703 let projection = { 1704 let accounts_manager = self.accounts_manager()?; 1705 let sqlite_store = self.sqlite_store()?; 1706 select_local_account(accounts_manager, sqlite_store, account_id)? 1707 }; 1708 1709 self.replace_identity_projection(projection) 1710 } 1711 1712 fn select_active_surface( 1713 &mut self, 1714 active_surface: ActiveSurface, 1715 ) -> Result<bool, DesktopAppRuntimeCommandError> { 1716 let projection = { 1717 let accounts_manager = self.accounts_manager()?; 1718 let sqlite_store = self.sqlite_store()?; 1719 select_active_surface(accounts_manager, sqlite_store, active_surface)? 1720 }; 1721 1722 let identity_changed = self.replace_identity_projection(projection)?; 1723 let shell_changed = self 1724 .state_store 1725 .apply_in_memory(AppStateCommand::select_active_surface(active_surface)); 1726 1727 Ok(identity_changed || shell_changed) 1728 } 1729 1730 fn remove_selected_local_key(&mut self) -> Result<bool, DesktopAppRuntimeCommandError> { 1731 let projection = { 1732 let accounts_manager = self.accounts_manager()?; 1733 let sqlite_store = self.sqlite_store()?; 1734 remove_selected_local_key(accounts_manager, sqlite_store)? 1735 }; 1736 1737 self.replace_identity_projection(projection) 1738 } 1739 1740 fn reset_local_device_state(&mut self) -> Result<bool, DesktopAppRuntimeCommandError> { 1741 if let Some(paths) = self.remote_signer_paths.as_ref() { 1742 purge_all_state(paths)?; 1743 } 1744 let projection = { 1745 let accounts_manager = self.accounts_manager()?; 1746 let sqlite_store = self.sqlite_store()?; 1747 let shared_accounts_paths = self.shared_accounts_paths()?; 1748 reset_local_device_state(accounts_manager, sqlite_store, shared_accounts_paths)? 1749 }; 1750 1751 self.replace_identity_projection(projection) 1752 } 1753 1754 fn record_activity(&self, kind: AppActivityKind) -> Result<(), AppSqliteError> { 1755 match self.sqlite_store.as_ref() { 1756 Some(store) => store.record_activity_event(&kind), 1757 None => Ok(()), 1758 } 1759 } 1760 1761 fn select_farmer_section(&mut self, section: FarmerSection) -> bool { 1762 match section { 1763 FarmerSection::Today => { 1764 let section_changed = 1765 self.state_store 1766 .apply_in_memory(AppStateCommand::SelectSection(ShellSection::Farmer( 1767 FarmerSection::Today, 1768 ))); 1769 let editor_changed = self.close_product_editor(); 1770 1771 section_changed || editor_changed 1772 } 1773 FarmerSection::Products if self.has_saved_farm() => { 1774 self.state_store 1775 .apply_in_memory(AppStateCommand::SelectSection(ShellSection::Farmer( 1776 FarmerSection::Products, 1777 ))) 1778 } 1779 FarmerSection::Orders if self.has_saved_farm() => { 1780 let section_changed = 1781 self.state_store 1782 .apply_in_memory(AppStateCommand::SelectSection(ShellSection::Farmer( 1783 FarmerSection::Orders, 1784 ))); 1785 let detail_changed = self 1786 .state_store 1787 .apply_in_memory(AppStateCommand::replace_order_detail(None)); 1788 let editor_changed = self.close_product_editor(); 1789 1790 section_changed || detail_changed || editor_changed 1791 } 1792 FarmerSection::PackDay if self.has_saved_farm() && self.has_pack_day_context() => { 1793 let section_changed = 1794 self.state_store 1795 .apply_in_memory(AppStateCommand::SelectSection(ShellSection::Farmer( 1796 FarmerSection::PackDay, 1797 ))); 1798 let editor_changed = self.close_product_editor(); 1799 1800 section_changed || editor_changed 1801 } 1802 FarmerSection::Products 1803 | FarmerSection::Orders 1804 | FarmerSection::PackDay 1805 | FarmerSection::Farm => false, 1806 } 1807 } 1808 1809 fn select_personal_section( 1810 &mut self, 1811 section: PersonalSection, 1812 ) -> Result<bool, AppSqliteError> { 1813 let freshness_changed = if section == PersonalSection::Browse { 1814 self.refresh_personal_browse_navigation()? 1815 } else { 1816 false 1817 }; 1818 let section_changed = self.apply_personal_section_selection(section); 1819 1820 Ok(freshness_changed || section_changed) 1821 } 1822 1823 fn apply_personal_section_selection(&mut self, section: PersonalSection) -> bool { 1824 let section_changed = self 1825 .state_store 1826 .apply_in_memory(AppStateCommand::SelectSection(ShellSection::Personal( 1827 section, 1828 ))); 1829 let editor_changed = self.close_product_editor(); 1830 1831 section_changed || editor_changed 1832 } 1833 1834 fn refresh_personal_browse_navigation(&mut self) -> Result<bool, AppSqliteError> { 1835 let report = self.import_shared_local_events()?; 1836 let local_changed = report.imported_records > 0 || report.skipped_records > 0; 1837 let context_changed = self.refresh_selected_account_context_after_local_events()?; 1838 1839 Ok(local_changed || context_changed) 1840 } 1841 1842 fn open_personal_product_detail( 1843 &mut self, 1844 section: PersonalSection, 1845 product_id: ProductId, 1846 ) -> Result<bool, AppSqliteError> { 1847 let should_refresh_before_lookup = 1848 matches!(section, PersonalSection::Browse | PersonalSection::Search); 1849 let freshness_changed = if should_refresh_before_lookup { 1850 self.refresh_personal_browse_navigation()? 1851 } else { 1852 false 1853 }; 1854 let section_changed = if should_refresh_before_lookup { 1855 self.apply_personal_section_selection(section) 1856 } else { 1857 false 1858 }; 1859 let Some(sqlite_store) = self.sqlite_store.as_ref() else { 1860 return Ok(freshness_changed || section_changed); 1861 }; 1862 let Some(detail) = sqlite_store.load_buyer_product_detail(product_id)? else { 1863 return Ok(freshness_changed || section_changed); 1864 }; 1865 1866 let detail_changed = self.set_personal_product_detail(section, Some(detail)); 1867 1868 Ok(freshness_changed || section_changed || detail_changed) 1869 } 1870 1871 fn close_personal_product_detail(&mut self, section: PersonalSection) -> bool { 1872 self.set_personal_product_detail(section, None) 1873 } 1874 1875 fn adjust_personal_product_quantity(&mut self, section: PersonalSection, delta: i32) -> bool { 1876 self.mutate_personal_projection(|projection| { 1877 let Some(detail) = personal_detail_mut(projection, section) else { 1878 return false; 1879 }; 1880 let next_quantity = if delta.is_negative() { 1881 detail 1882 .selected_quantity 1883 .saturating_sub(delta.unsigned_abs()) 1884 } else { 1885 detail.selected_quantity.saturating_add(delta as u32) 1886 }; 1887 1888 if next_quantity == 0 || next_quantity == detail.selected_quantity { 1889 return false; 1890 } 1891 1892 detail.selected_quantity = next_quantity; 1893 true 1894 }) 1895 } 1896 1897 fn add_personal_product_to_cart( 1898 &mut self, 1899 section: PersonalSection, 1900 replace_existing: bool, 1901 ) -> Result<bool, AppSqliteError> { 1902 let Some(sqlite_store) = self.sqlite_store.as_ref() else { 1903 return Ok(false); 1904 }; 1905 let Some(detail) = 1906 personal_detail(self.state_store.personal_projection(), section).cloned() 1907 else { 1908 return Ok(false); 1909 }; 1910 let buyer_context = self.state_store.identity_projection().buyer_context(); 1911 let current_cart = sqlite_store.load_buyer_cart(&buyer_context)?; 1912 1913 if !replace_existing 1914 && !current_cart.is_empty() 1915 && current_cart.farm_id != Some(detail.listing.farm_id) 1916 { 1917 let current_farm_display_name = current_cart 1918 .farm_display_name 1919 .clone() 1920 .or_else(|| { 1921 current_cart 1922 .lines 1923 .first() 1924 .map(|line| line.farm_display_name.clone()) 1925 }) 1926 .ok_or(AppSqliteError::InvalidProjection { 1927 reason: "buyer cart farm display name is missing", 1928 })?; 1929 let replace_confirmation = BuyerCartReplaceConfirmationProjection { 1930 current_farm_display_name, 1931 incoming_farm_display_name: detail.listing.farm_display_name.clone(), 1932 }; 1933 1934 return Ok(self.mutate_personal_projection(|projection| { 1935 let cart = &mut projection.cart.cart; 1936 if cart.replace_confirmation.as_ref() == Some(&replace_confirmation) { 1937 return false; 1938 } 1939 1940 cart.replace_confirmation = Some(replace_confirmation); 1941 true 1942 })); 1943 } 1944 1945 let next_cart = next_buyer_cart_for_detail(current_cart, &detail, replace_existing)?; 1946 sqlite_store.replace_buyer_cart(&buyer_context, &next_cart)?; 1947 let refreshed_cart = sqlite_store.load_buyer_cart(&buyer_context)?; 1948 let refreshed_order_review = sqlite_store.load_buyer_order_review(&buyer_context)?; 1949 let cart_changed = self.mutate_personal_projection(|projection| { 1950 let mut changed = false; 1951 if projection.cart.cart != refreshed_cart { 1952 projection.cart.cart = refreshed_cart.clone(); 1953 changed = true; 1954 } 1955 if projection.cart.order_review != refreshed_order_review { 1956 projection.cart.order_review = refreshed_order_review.clone(); 1957 changed = true; 1958 } 1959 changed 1960 }); 1961 let section_changed = self.select_personal_section(PersonalSection::Cart)?; 1962 1963 Ok(cart_changed || section_changed) 1964 } 1965 1966 fn clear_personal_cart_replace_confirmation(&mut self) -> bool { 1967 self.mutate_personal_projection(|projection| { 1968 if projection.cart.cart.replace_confirmation.is_none() { 1969 return false; 1970 } 1971 1972 projection.cart.cart.replace_confirmation = None; 1973 true 1974 }) 1975 } 1976 1977 fn remove_personal_cart_line(&mut self, product_id: ProductId) -> Result<bool, AppSqliteError> { 1978 let Some(sqlite_store) = self.sqlite_store.as_ref() else { 1979 return Ok(false); 1980 }; 1981 let buyer_context = self.state_store.identity_projection().buyer_context(); 1982 let current_cart = sqlite_store.load_buyer_cart(&buyer_context)?; 1983 let Some(next_cart) = next_buyer_cart_after_removing_line(current_cart, product_id)? else { 1984 return Ok(false); 1985 }; 1986 1987 if next_cart.lines.is_empty() { 1988 sqlite_store.clear_buyer_cart(&buyer_context)?; 1989 } else { 1990 sqlite_store.replace_buyer_cart(&buyer_context, &next_cart)?; 1991 } 1992 1993 let refreshed_cart = sqlite_store.load_buyer_cart(&buyer_context)?; 1994 let refreshed_order_review = sqlite_store.load_buyer_order_review(&buyer_context)?; 1995 1996 Ok(self.refresh_personal_cart_and_order_review(refreshed_cart, refreshed_order_review)) 1997 } 1998 1999 fn save_personal_order_review_draft( 2000 &mut self, 2001 draft: BuyerOrderReviewDraft, 2002 ) -> Result<bool, AppSqliteError> { 2003 let Some(sqlite_store) = self.sqlite_store.as_ref() else { 2004 return Ok(false); 2005 }; 2006 let buyer_context = self.state_store.identity_projection().buyer_context(); 2007 sqlite_store.save_buyer_order_review_draft(&buyer_context, &draft)?; 2008 let refreshed_order_review = sqlite_store.load_buyer_order_review(&buyer_context)?; 2009 2010 Ok(self.mutate_personal_projection(|projection| { 2011 if projection.cart.order_review == refreshed_order_review { 2012 return false; 2013 } 2014 2015 projection.cart.order_review = refreshed_order_review; 2016 true 2017 })) 2018 } 2019 2020 fn place_personal_order(&mut self) -> Result<bool, AppSqliteError> { 2021 let buyer_context = self.state_store.identity_projection().buyer_context(); 2022 if matches!(buyer_context, BuyerContext::Guest) { 2023 return Err(AppSqliteError::InvalidProjection { 2024 reason: "buyer order review requires a selected account", 2025 }); 2026 } 2027 let (refreshed_cart, refreshed_order_review, refreshed_orders, order_detail, order_export) = { 2028 let Some(sqlite_store) = self.sqlite_store.as_ref() else { 2029 return Ok(false); 2030 }; 2031 let order_id = sqlite_store.place_buyer_order(&buyer_context)?; 2032 let refreshed_cart = sqlite_store.load_buyer_cart(&buyer_context)?; 2033 let refreshed_order_review = sqlite_store.load_buyer_order_review(&buyer_context)?; 2034 let refreshed_orders = sqlite_store.load_buyer_orders(&buyer_context)?; 2035 if !refreshed_orders 2036 .rows 2037 .iter() 2038 .any(|row| row.order_id == order_id) 2039 { 2040 return Err(AppSqliteError::InvalidProjection { 2041 reason: "buyer order write did not surface in buyer order history", 2042 }); 2043 } 2044 let Some(order_detail) = 2045 sqlite_store.load_buyer_order_detail(&buyer_context, order_id)? 2046 else { 2047 return Err(AppSqliteError::InvalidProjection { 2048 reason: "buyer order write did not surface in buyer order detail", 2049 }); 2050 }; 2051 let Some(order_export) = 2052 sqlite_store.load_buyer_order_local_event_export(&buyer_context, order_id)? 2053 else { 2054 return Err(AppSqliteError::InvalidProjection { 2055 reason: "buyer order write did not surface in buyer order local event export", 2056 }); 2057 }; 2058 ( 2059 refreshed_cart, 2060 refreshed_order_review, 2061 refreshed_orders, 2062 order_detail, 2063 order_export, 2064 ) 2065 }; 2066 let personal_changed = self.mutate_personal_projection(|projection| { 2067 let mut changed = false; 2068 if projection.cart.cart != refreshed_cart { 2069 projection.cart.cart = refreshed_cart.clone(); 2070 changed = true; 2071 } 2072 if projection.cart.order_review != refreshed_order_review { 2073 projection.cart.order_review = refreshed_order_review.clone(); 2074 changed = true; 2075 } 2076 if projection.orders.list != refreshed_orders { 2077 projection.orders.list = refreshed_orders.clone(); 2078 changed = true; 2079 } 2080 if projection.orders.detail.as_ref() != Some(&order_detail) { 2081 projection.orders.detail = Some(order_detail.clone()); 2082 changed = true; 2083 } 2084 if !projection.orders.has_recoverable_coordination { 2085 projection.orders.has_recoverable_coordination = true; 2086 changed = true; 2087 } 2088 2089 changed 2090 }); 2091 let section_changed = self.select_personal_section(PersonalSection::Orders)?; 2092 let order_local_work = { 2093 let sqlite_store = 2094 self.sqlite_store 2095 .as_ref() 2096 .ok_or_else(|| AppSqliteError::InvalidProjection { 2097 reason: "sqlite store became unavailable during buyer order placement", 2098 })?; 2099 self.append_app_buyer_order_request_local_work_record( 2100 sqlite_store, 2101 &buyer_context, 2102 &order_export, 2103 )? 2104 }; 2105 let pending_changed = if matches!(buyer_context, BuyerContext::Account(_)) { 2106 self.enqueue_selected_account_order_sync_operation( 2107 &buyer_context, 2108 &order_export, 2109 order_local_work.as_ref(), 2110 )? 2111 } else { 2112 false 2113 }; 2114 let coordination_changed = 2115 self.refresh_personal_orders_coordination_retry_state(&buyer_context)?; 2116 2117 Ok(personal_changed || section_changed || pending_changed || coordination_changed) 2118 } 2119 2120 fn retry_pending_personal_order_coordination(&mut self) -> Result<bool, AppSqliteError> { 2121 let buyer_context = self.state_store.identity_projection().buyer_context(); 2122 let records = { 2123 let Some(sqlite_store) = self.sqlite_store.as_ref() else { 2124 return Ok(false); 2125 }; 2126 sqlite_store.load_recoverable_buyer_order_coordination_records(&buyer_context)? 2127 }; 2128 if records.is_empty() { 2129 return self.refresh_personal_orders_coordination_retry_state(&buyer_context); 2130 } 2131 let mut changed = false; 2132 let mut refreshed_order_id = None; 2133 2134 for record in records { 2135 let order_export = { 2136 let Some(sqlite_store) = self.sqlite_store.as_ref() else { 2137 return Ok(changed); 2138 }; 2139 let Some(order_export) = sqlite_store 2140 .load_buyer_order_local_event_export(&buyer_context, record.order_id)? 2141 else { 2142 changed |= sqlite_store.mark_buyer_order_coordination_failed( 2143 &buyer_context, 2144 record.order_id, 2145 "buyer order local event export is unavailable", 2146 )?; 2147 continue; 2148 }; 2149 order_export 2150 }; 2151 2152 let order_local_work = { 2153 let Some(sqlite_store) = self.sqlite_store.as_ref() else { 2154 return Ok(changed); 2155 }; 2156 self.append_app_buyer_order_request_local_work_record( 2157 sqlite_store, 2158 &buyer_context, 2159 &order_export, 2160 )? 2161 }; 2162 if let Some(order_local_work) = order_local_work.as_ref() 2163 && matches!(buyer_context, BuyerContext::Account(_)) 2164 { 2165 let _ = self.enqueue_selected_account_order_sync_operation( 2166 &buyer_context, 2167 &order_export, 2168 Some(order_local_work), 2169 )?; 2170 } 2171 if order_local_work.is_some() { 2172 refreshed_order_id.get_or_insert(record.order_id); 2173 changed = true; 2174 } 2175 } 2176 2177 if changed { 2178 changed |= 2179 self.refresh_personal_orders_projection(&buyer_context, refreshed_order_id)?; 2180 } 2181 2182 Ok(changed) 2183 } 2184 2185 fn refresh_personal_orders_coordination_retry_state( 2186 &mut self, 2187 buyer_context: &BuyerContext, 2188 ) -> Result<bool, AppSqliteError> { 2189 let Some(sqlite_store) = self.sqlite_store.as_ref() else { 2190 return Ok(false); 2191 }; 2192 let has_recoverable_coordination = !sqlite_store 2193 .load_recoverable_buyer_order_coordination_records(buyer_context)? 2194 .is_empty(); 2195 Ok(self.mutate_personal_projection(|projection| { 2196 if projection.orders.has_recoverable_coordination == has_recoverable_coordination { 2197 false 2198 } else { 2199 projection.orders.has_recoverable_coordination = has_recoverable_coordination; 2200 true 2201 } 2202 })) 2203 } 2204 2205 fn refresh_personal_orders_projection( 2206 &mut self, 2207 buyer_context: &BuyerContext, 2208 preferred_order_id: Option<OrderId>, 2209 ) -> Result<bool, AppSqliteError> { 2210 let current_detail_order_id = self 2211 .state_store 2212 .personal_projection() 2213 .orders 2214 .detail 2215 .as_ref() 2216 .map(|detail| detail.order_id); 2217 let buyer_order_scope = selected_buyer_order_scope(self.state_store.identity_projection()); 2218 let ( 2219 refreshed_cart, 2220 refreshed_order_review, 2221 refreshed_orders, 2222 refreshed_order_detail, 2223 has_recoverable_coordination, 2224 ) = { 2225 let Some(sqlite_store) = self.sqlite_store.as_ref() else { 2226 return Ok(false); 2227 }; 2228 let refreshed_cart = sqlite_store.load_buyer_cart(buyer_context)?; 2229 let refreshed_order_review = sqlite_store.load_buyer_order_review(buyer_context)?; 2230 let refreshed_orders = sqlite_store.load_buyer_orders_for_scope(&buyer_order_scope)?; 2231 let has_recoverable_coordination = !sqlite_store 2232 .load_recoverable_buyer_order_coordination_records(buyer_context)? 2233 .is_empty(); 2234 let detail_order_id = current_detail_order_id 2235 .filter(|order_id| { 2236 refreshed_orders 2237 .rows 2238 .iter() 2239 .any(|row| row.order_id == *order_id) 2240 }) 2241 .or_else(|| { 2242 preferred_order_id.filter(|order_id| { 2243 refreshed_orders 2244 .rows 2245 .iter() 2246 .any(|row| row.order_id == *order_id) 2247 }) 2248 }); 2249 let refreshed_order_detail = match detail_order_id { 2250 Some(order_id) => { 2251 sqlite_store.load_buyer_order_detail_for_scope(&buyer_order_scope, order_id)? 2252 } 2253 None => None, 2254 }; 2255 ( 2256 refreshed_cart, 2257 refreshed_order_review, 2258 refreshed_orders, 2259 refreshed_order_detail, 2260 has_recoverable_coordination, 2261 ) 2262 }; 2263 2264 Ok(self.mutate_personal_projection(|projection| { 2265 let mut changed = false; 2266 if projection.cart.cart != refreshed_cart { 2267 projection.cart.cart = refreshed_cart.clone(); 2268 changed = true; 2269 } 2270 if projection.cart.order_review != refreshed_order_review { 2271 projection.cart.order_review = refreshed_order_review.clone(); 2272 changed = true; 2273 } 2274 if projection.orders.list != refreshed_orders { 2275 projection.orders.list = refreshed_orders.clone(); 2276 changed = true; 2277 } 2278 if projection.orders.detail != refreshed_order_detail { 2279 projection.orders.detail = refreshed_order_detail.clone(); 2280 changed = true; 2281 } 2282 if projection.orders.has_recoverable_coordination != has_recoverable_coordination { 2283 projection.orders.has_recoverable_coordination = has_recoverable_coordination; 2284 changed = true; 2285 } 2286 2287 changed 2288 })) 2289 } 2290 2291 fn open_personal_order_detail(&mut self, order_id: OrderId) -> Result<bool, AppSqliteError> { 2292 let Some(sqlite_store) = self.sqlite_store.as_ref() else { 2293 return Ok(false); 2294 }; 2295 let buyer_order_scope = selected_buyer_order_scope(self.state_store.identity_projection()); 2296 let Some(order_detail) = 2297 sqlite_store.load_buyer_order_detail_for_scope(&buyer_order_scope, order_id)? 2298 else { 2299 return Ok(false); 2300 }; 2301 2302 let detail_changed = self.set_personal_order_detail(Some(order_detail)); 2303 let section_changed = self.select_personal_section(PersonalSection::Orders)?; 2304 2305 Ok(detail_changed || section_changed) 2306 } 2307 2308 fn repeat_personal_order( 2309 &mut self, 2310 order_id: OrderId, 2311 replace_existing: bool, 2312 ) -> Result<bool, AppSqliteError> { 2313 let Some(sqlite_store) = self.sqlite_store.as_ref() else { 2314 return Ok(false); 2315 }; 2316 let buyer_context = self.state_store.identity_projection().buyer_context(); 2317 let buyer_order_scope = selected_buyer_order_scope(self.state_store.identity_projection()); 2318 2319 match sqlite_store.apply_buyer_repeat_demand_from_scope_to_cart( 2320 &buyer_order_scope, 2321 &buyer_context, 2322 order_id, 2323 replace_existing, 2324 )? { 2325 BuyerRepeatDemandApplyOutcome::Applied => { 2326 let refreshed_cart = sqlite_store.load_buyer_cart(&buyer_context)?; 2327 let refreshed_order_review = 2328 sqlite_store.load_buyer_order_review(&buyer_context)?; 2329 let refreshed_orders = 2330 sqlite_store.load_buyer_orders_for_scope(&buyer_order_scope)?; 2331 let refreshed_detail = 2332 sqlite_store.load_buyer_order_detail_for_scope(&buyer_order_scope, order_id)?; 2333 let personal_changed = self.mutate_personal_projection(|projection| { 2334 let mut changed = false; 2335 if projection.cart.cart != refreshed_cart { 2336 projection.cart.cart = refreshed_cart.clone(); 2337 changed = true; 2338 } 2339 if projection.cart.order_review != refreshed_order_review { 2340 projection.cart.order_review = refreshed_order_review.clone(); 2341 changed = true; 2342 } 2343 if projection.orders.list != refreshed_orders { 2344 projection.orders.list = refreshed_orders.clone(); 2345 changed = true; 2346 } 2347 if projection.orders.detail != refreshed_detail { 2348 projection.orders.detail = refreshed_detail.clone(); 2349 changed = true; 2350 } 2351 2352 changed 2353 }); 2354 let section_changed = self.select_personal_section(PersonalSection::Cart)?; 2355 2356 Ok(personal_changed || section_changed) 2357 } 2358 BuyerRepeatDemandApplyOutcome::ConfirmationRequired(replace_confirmation) => Ok(self 2359 .mutate_personal_projection(|projection| { 2360 let cart = &mut projection.cart.cart; 2361 if cart.replace_confirmation.as_ref() == Some(&replace_confirmation) { 2362 return false; 2363 } 2364 2365 cart.replace_confirmation = Some(replace_confirmation); 2366 true 2367 })), 2368 BuyerRepeatDemandApplyOutcome::Unavailable => Ok(false), 2369 } 2370 } 2371 2372 fn set_personal_search_query(&mut self, search_query: &str) -> Result<bool, AppSqliteError> { 2373 let query = self.state_store.personal_projection().search.query.clone(); 2374 if query.search_query == search_query { 2375 return self.replace_personal_search_query(query); 2376 } 2377 2378 self.replace_personal_search_query(BuyerSearchScreenQueryState::new( 2379 search_query, 2380 query.fulfillment_methods, 2381 )) 2382 } 2383 2384 fn set_personal_search_fulfillment_method( 2385 &mut self, 2386 method: FarmOrderMethod, 2387 enabled: bool, 2388 ) -> Result<bool, AppSqliteError> { 2389 let mut query = self.state_store.personal_projection().search.query.clone(); 2390 let changed = if enabled { 2391 query.fulfillment_methods.insert(method) 2392 } else { 2393 query.fulfillment_methods.remove(&method) 2394 }; 2395 2396 if !changed { 2397 return Ok(false); 2398 } 2399 2400 self.replace_personal_search_query(query) 2401 } 2402 2403 fn set_products_search_query(&mut self, search_query: &str) -> Result<bool, AppSqliteError> { 2404 let query = self.state_store.products_projection().query.clone(); 2405 if query.search_query == search_query { 2406 return Ok(false); 2407 } 2408 2409 self.replace_products_query(ProductsScreenQueryState::new( 2410 search_query, 2411 query.filter, 2412 query.sort, 2413 )) 2414 } 2415 2416 fn select_products_filter(&mut self, filter: ProductsFilter) -> Result<bool, AppSqliteError> { 2417 let query = self.state_store.products_projection().query.clone(); 2418 if query.filter == filter { 2419 return Ok(false); 2420 } 2421 2422 self.replace_products_query(ProductsScreenQueryState::new( 2423 query.search_query, 2424 filter, 2425 query.sort, 2426 )) 2427 } 2428 2429 fn select_products_sort(&mut self, sort: ProductsSort) -> Result<bool, AppSqliteError> { 2430 let query = self.state_store.products_projection().query.clone(); 2431 if query.sort == sort { 2432 return Ok(false); 2433 } 2434 2435 self.replace_products_query(ProductsScreenQueryState::new( 2436 query.search_query, 2437 query.filter, 2438 sort, 2439 )) 2440 } 2441 2442 fn open_products_filter(&mut self, filter: ProductsFilter) -> Result<bool, AppSqliteError> { 2443 if !self.state_store.farm_setup_projection().has_saved_farm() { 2444 return Ok(false); 2445 } 2446 2447 let filter_changed = self.select_products_filter(filter)?; 2448 let section_changed = self.select_farmer_section(FarmerSection::Products); 2449 2450 Ok(filter_changed || section_changed) 2451 } 2452 2453 fn select_orders_filter(&mut self, filter: OrdersFilter) -> Result<bool, AppSqliteError> { 2454 if !self.has_saved_farm() { 2455 return Ok(false); 2456 } 2457 2458 let query = self.state_store.orders_projection().query.clone(); 2459 if query.filter == filter { 2460 return Ok(false); 2461 } 2462 2463 self.replace_orders_query(OrdersScreenQueryState { 2464 filter, 2465 fulfillment_window_id: query.fulfillment_window_id, 2466 }) 2467 } 2468 2469 fn open_orders(&mut self) -> Result<bool, AppSqliteError> { 2470 if !self.has_saved_farm() { 2471 return Ok(false); 2472 } 2473 2474 self.open_orders_query(OrdersScreenQueryState::default()) 2475 } 2476 2477 fn open_orders_fulfillment_window( 2478 &mut self, 2479 fulfillment_window_id: FulfillmentWindowId, 2480 ) -> Result<bool, AppSqliteError> { 2481 if !self.has_saved_farm() { 2482 return Ok(false); 2483 } 2484 2485 self.open_orders_query(OrdersScreenQueryState { 2486 filter: OrdersFilter::All, 2487 fulfillment_window_id: Some(fulfillment_window_id), 2488 }) 2489 } 2490 2491 fn open_orders_query(&mut self, query: OrdersScreenQueryState) -> Result<bool, AppSqliteError> { 2492 let query_changed = self.replace_orders_query(query)?; 2493 let section_changed = self 2494 .state_store 2495 .apply_in_memory(AppStateCommand::SelectSection(ShellSection::Farmer( 2496 FarmerSection::Orders, 2497 ))); 2498 let editor_changed = self.close_product_editor(); 2499 2500 Ok(query_changed || section_changed || editor_changed) 2501 } 2502 2503 fn open_order_detail(&mut self, order_id: OrderId) -> Result<bool, AppSqliteError> { 2504 let Some(sqlite_store) = self.sqlite_store.as_ref() else { 2505 return Ok(false); 2506 }; 2507 let Some(farm_id) = self.selected_farm_id() else { 2508 return Ok(false); 2509 }; 2510 let Some(_) = sqlite_store.load_order_detail(farm_id, order_id)? else { 2511 return Ok(false); 2512 }; 2513 let continuity_state = self.continuity_state_with_order_detail(Some(order_id)); 2514 let selected_account_context = load_selected_account_context( 2515 sqlite_store, 2516 self.state_store.identity_projection(), 2517 &continuity_state, 2518 )?; 2519 let detail_changed = self.apply_selected_account_context(&selected_account_context); 2520 let section_changed = self 2521 .state_store 2522 .apply_in_memory(AppStateCommand::SelectSection(ShellSection::Farmer( 2523 FarmerSection::Orders, 2524 ))); 2525 let editor_changed = self.close_product_editor(); 2526 2527 Ok(detail_changed || section_changed || editor_changed) 2528 } 2529 2530 fn prepare_seller_order_decision( 2531 &mut self, 2532 order_id: OrderId, 2533 command: AppSellerOrderDecisionCommand, 2534 ) -> Result<AppOrderDecisionPublishPayload, AppSqliteError> { 2535 let _ = self.import_shared_local_events()?; 2536 let relay_urls = normalized_app_sync_relay_urls(&self.nostr_relay_urls).map_err(|_| { 2537 AppSqliteError::InvalidProjection { 2538 reason: "seller order decision requires valid configured relays", 2539 } 2540 })?; 2541 if relay_urls.is_empty() { 2542 return Err(AppSqliteError::InvalidProjection { 2543 reason: "seller order decision requires configured relays", 2544 }); 2545 } 2546 self.refresh_configured_relay_state_before_order_lifecycle()?; 2547 let Some(sqlite_store) = self.sqlite_store.as_ref() else { 2548 return Err(AppSqliteError::InvalidProjection { 2549 reason: "seller order decision requires local state", 2550 }); 2551 }; 2552 let Some(farm_id) = self.selected_farm_id() else { 2553 return Err(AppSqliteError::InvalidProjection { 2554 reason: "seller order decision requires a selected farm", 2555 }); 2556 }; 2557 let Some(selected_account) = self 2558 .state_store 2559 .identity_projection() 2560 .selected_account 2561 .as_ref() 2562 else { 2563 return Err(AppSqliteError::InvalidProjection { 2564 reason: "seller order decision requires a selected seller account", 2565 }); 2566 }; 2567 let account_id = selected_account.account.account_id.clone(); 2568 let seller_pubkey = self.local_events_owner_pubkey(selected_account).ok_or( 2569 AppSqliteError::InvalidProjection { 2570 reason: "seller order decision requires a selected seller public key", 2571 }, 2572 )?; 2573 let request = self.resolve_seller_order_request_evidence(order_id)?; 2574 if request.payload.seller_pubkey.trim() != seller_pubkey.as_str() { 2575 return Err(AppSqliteError::InvalidProjection { 2576 reason: "seller order decision seller account does not match order seller", 2577 }); 2578 } 2579 let listing_address = parse_public_listing_address(request.payload.listing_addr.as_str()) 2580 .map_err(|_| AppSqliteError::InvalidProjection { 2581 reason: "seller order decision listing address is invalid", 2582 })?; 2583 if listing_address.seller_pubkey.as_str() != seller_pubkey.as_str() { 2584 return Err(AppSqliteError::InvalidProjection { 2585 reason: "seller order decision listing address is outside seller authority", 2586 }); 2587 } 2588 let Some(order_export) = 2589 sqlite_store.load_seller_order_decision_export(farm_id, order_id)? 2590 else { 2591 return Err(AppSqliteError::InvalidProjection { 2592 reason: "seller order decision requires a visible seller order", 2593 }); 2594 }; 2595 if order_export.status != OrderStatus::NeedsAction { 2596 return Err(AppSqliteError::InvalidProjection { 2597 reason: "seller order decision requires an undecided order", 2598 }); 2599 } 2600 2601 let decision = match command { 2602 AppSellerOrderDecisionCommand::Accept => AppOrderDecisionPayload::Accepted { 2603 inventory_commitments: seller_order_inventory_commitments(&order_export)?, 2604 }, 2605 AppSellerOrderDecisionCommand::Decline { reason } => { 2606 let reason = reason.trim(); 2607 if reason.is_empty() { 2608 return Err(AppSqliteError::InvalidProjection { 2609 reason: "seller order decline requires a non-empty reason", 2610 }); 2611 } 2612 AppOrderDecisionPayload::Declined { 2613 reason: reason.to_owned(), 2614 } 2615 } 2616 }; 2617 let payload = AppOrderDecisionPublishPayload { 2618 context: AppPublishContext::new(account_id, "seller_order_decision"), 2619 app_order_id: order_id, 2620 farm_id, 2621 trade_order_id: request.payload.order_id.to_string(), 2622 request_event_id: request.request_event_id, 2623 listing_event_id: request.listing_event_id, 2624 listing_addr: request.payload.listing_addr.to_string(), 2625 buyer_pubkey: request.payload.buyer_pubkey.to_string(), 2626 seller_pubkey: request.payload.seller_pubkey.to_string(), 2627 decision, 2628 }; 2629 AppPublishPayload::OrderDecision(payload.clone()) 2630 .validate() 2631 .map_err(|_| AppSqliteError::InvalidProjection { 2632 reason: "seller order decision publish payload is invalid", 2633 })?; 2634 2635 Ok(payload) 2636 } 2637 2638 fn refresh_configured_relay_state_before_order_lifecycle( 2639 &mut self, 2640 ) -> Result<(), AppSqliteError> { 2641 match self.ingest_configured_relay_events() { 2642 Ok(report) => { 2643 if report.freshness_changed 2644 || report.local_import.imported_records > 0 2645 || report.local_import.skipped_records > 0 2646 { 2647 let _ = self.refresh_selected_account_context_after_local_events()?; 2648 } 2649 Ok(()) 2650 } 2651 Err(AppDirectRelayIngestError::Sqlite(error)) => Err(error), 2652 Err(AppDirectRelayIngestError::Transport(_)) => { 2653 Err(AppSqliteError::InvalidProjection { 2654 reason: "order lifecycle publish requires fresh configured relay state", 2655 }) 2656 } 2657 } 2658 } 2659 2660 fn publish_seller_order_decision( 2661 &mut self, 2662 order_id: OrderId, 2663 command: AppSellerOrderDecisionCommand, 2664 ) -> Result<bool, AppSqliteError> { 2665 let payload = self.prepare_seller_order_decision(order_id, command)?; 2666 let source_record_id = order_decision_sdk_source_record_id(&payload); 2667 self.enqueue_order_decision_payload_via_sdk( 2668 &payload, 2669 AppSdkMigrationReceiptSourceKind::LocalOutbox, 2670 source_record_id.as_str(), 2671 )?; 2672 let _ = self.refresh_selected_account_sync()?; 2673 Ok(true) 2674 } 2675 2676 fn prepare_seller_order_revision_proposal( 2677 &mut self, 2678 order_id: OrderId, 2679 items: Vec<RadrootsOrderItem>, 2680 economics: RadrootsOrderEconomics, 2681 reason: &str, 2682 ) -> Result<AppOrderRevisionProposalPublishPayload, AppSqliteError> { 2683 let _ = self.import_shared_local_events()?; 2684 let relay_urls = normalized_app_sync_relay_urls(&self.nostr_relay_urls).map_err(|_| { 2685 AppSqliteError::InvalidProjection { 2686 reason: "seller order revision requires valid configured relays", 2687 } 2688 })?; 2689 if relay_urls.is_empty() { 2690 return Err(AppSqliteError::InvalidProjection { 2691 reason: "seller order revision requires configured relays", 2692 }); 2693 } 2694 self.refresh_configured_relay_state_before_order_lifecycle()?; 2695 let Some(sqlite_store) = self.sqlite_store.as_ref() else { 2696 return Err(AppSqliteError::InvalidProjection { 2697 reason: "seller order revision requires local state", 2698 }); 2699 }; 2700 let Some(farm_id) = self.selected_farm_id() else { 2701 return Err(AppSqliteError::InvalidProjection { 2702 reason: "seller order revision requires a selected farm", 2703 }); 2704 }; 2705 let Some(selected_account) = self 2706 .state_store 2707 .identity_projection() 2708 .selected_account 2709 .as_ref() 2710 else { 2711 return Err(AppSqliteError::InvalidProjection { 2712 reason: "seller order revision requires a selected seller account", 2713 }); 2714 }; 2715 let account_id = selected_account.account.account_id.clone(); 2716 let seller_pubkey = self.local_events_owner_pubkey(selected_account).ok_or( 2717 AppSqliteError::InvalidProjection { 2718 reason: "seller order revision requires a selected seller public key", 2719 }, 2720 )?; 2721 let request = self.resolve_seller_order_request_evidence(order_id)?; 2722 if request.payload.seller_pubkey.trim() != seller_pubkey.as_str() { 2723 return Err(AppSqliteError::InvalidProjection { 2724 reason: "seller order revision seller account does not match order seller", 2725 }); 2726 } 2727 let listing_address = parse_public_listing_address(request.payload.listing_addr.as_str()) 2728 .map_err(|_| AppSqliteError::InvalidProjection { 2729 reason: "seller order revision listing address is invalid", 2730 })?; 2731 if listing_address.seller_pubkey.as_str() != seller_pubkey.as_str() { 2732 return Err(AppSqliteError::InvalidProjection { 2733 reason: "seller order revision listing address is outside seller authority", 2734 }); 2735 } 2736 let lifecycle = self.resolve_order_lifecycle_evidence(&request)?; 2737 if lifecycle.decision.is_some() || lifecycle.status != RadrootsOrderStatus::Requested { 2738 return Err(AppSqliteError::InvalidProjection { 2739 reason: "seller order revision requires an undecided order", 2740 }); 2741 } 2742 if lifecycle.cancellation_event_id.is_some() { 2743 return Err(AppSqliteError::InvalidProjection { 2744 reason: "seller order revision requires an undecided order", 2745 }); 2746 } 2747 let Some(order_detail) = sqlite_store.load_order_detail(farm_id, order_id)? else { 2748 return Err(AppSqliteError::InvalidProjection { 2749 reason: "seller order revision requires a visible seller order", 2750 }); 2751 }; 2752 if order_detail.status != OrderStatus::NeedsAction { 2753 return Err(AppSqliteError::InvalidProjection { 2754 reason: "seller order revision requires an undecided order", 2755 }); 2756 } 2757 if active_order_pending_revision_proposal(&lifecycle).is_some() { 2758 return Err(AppSqliteError::InvalidProjection { 2759 reason: "seller order revision requires no pending revision proposal", 2760 }); 2761 } 2762 let reason = reason.trim(); 2763 if reason.is_empty() { 2764 return Err(AppSqliteError::InvalidProjection { 2765 reason: "seller order revision requires a non-empty reason", 2766 }); 2767 } 2768 let payload = AppOrderRevisionProposalPublishPayload { 2769 context: AppPublishContext::new(account_id, "seller_order_revision_proposal"), 2770 app_order_id: order_id, 2771 farm_id, 2772 trade_order_id: request.payload.order_id.to_string(), 2773 request_event_id: request.request_event_id, 2774 prev_event_id: lifecycle.request_event_id, 2775 revision_id: format!("app-revision-{}", d_tag_from_uuid(Uuid::now_v7())), 2776 listing_addr: request.payload.listing_addr.to_string(), 2777 buyer_pubkey: request.payload.buyer_pubkey.to_string(), 2778 seller_pubkey: request.payload.seller_pubkey.to_string(), 2779 items, 2780 economics, 2781 reason: reason.to_owned(), 2782 }; 2783 AppPublishPayload::OrderRevisionProposal(payload.clone()) 2784 .validate() 2785 .map_err(|_| AppSqliteError::InvalidProjection { 2786 reason: "seller order revision publish payload is invalid", 2787 })?; 2788 Ok(payload) 2789 } 2790 2791 fn publish_seller_order_revision_proposal( 2792 &mut self, 2793 order_id: OrderId, 2794 items: Vec<RadrootsOrderItem>, 2795 economics: RadrootsOrderEconomics, 2796 reason: &str, 2797 ) -> Result<bool, AppSqliteError> { 2798 let payload = 2799 self.prepare_seller_order_revision_proposal(order_id, items, economics, reason)?; 2800 let source_record_id = order_revision_proposal_sdk_source_record_id(&payload); 2801 self.enqueue_order_revision_proposal_payload_via_sdk( 2802 &payload, 2803 AppSdkMigrationReceiptSourceKind::LocalOutbox, 2804 source_record_id.as_str(), 2805 )?; 2806 let _ = self.refresh_selected_account_sync()?; 2807 Ok(true) 2808 } 2809 2810 fn prepare_buyer_order_revision_decision( 2811 &mut self, 2812 order_id: OrderId, 2813 decision: RadrootsOrderRevisionOutcome, 2814 ) -> Result<AppOrderRevisionDecisionPublishPayload, AppSqliteError> { 2815 let _ = self.import_shared_local_events()?; 2816 let relay_urls = normalized_app_sync_relay_urls(&self.nostr_relay_urls).map_err(|_| { 2817 AppSqliteError::InvalidProjection { 2818 reason: "buyer order revision requires valid configured relays", 2819 } 2820 })?; 2821 if relay_urls.is_empty() { 2822 return Err(AppSqliteError::InvalidProjection { 2823 reason: "buyer order revision requires configured relays", 2824 }); 2825 } 2826 self.refresh_configured_relay_state_before_order_lifecycle()?; 2827 let buyer_context = self.state_store.identity_projection().buyer_context(); 2828 let BuyerContext::Account(account_id) = &buyer_context else { 2829 return Err(AppSqliteError::InvalidProjection { 2830 reason: "buyer order revision requires a selected buyer account", 2831 }); 2832 }; 2833 let Some(selected_account) = self.selected_buyer_account(&buyer_context) else { 2834 return Err(AppSqliteError::InvalidProjection { 2835 reason: "buyer order revision requires a selected buyer account", 2836 }); 2837 }; 2838 let buyer_pubkey = self.local_events_owner_pubkey(selected_account).ok_or( 2839 AppSqliteError::InvalidProjection { 2840 reason: "buyer order revision requires a selected buyer public key", 2841 }, 2842 )?; 2843 let Some(sqlite_store) = self.sqlite_store.as_ref() else { 2844 return Err(AppSqliteError::InvalidProjection { 2845 reason: "buyer order revision requires local state", 2846 }); 2847 }; 2848 let buyer_order_scope = selected_buyer_order_scope(self.state_store.identity_projection()); 2849 let Some(detail) = 2850 sqlite_store.load_buyer_order_detail_for_scope(&buyer_order_scope, order_id)? 2851 else { 2852 return Err(AppSqliteError::InvalidProjection { 2853 reason: "buyer order revision requires a visible buyer order", 2854 }); 2855 }; 2856 if matches!( 2857 detail.status, 2858 BuyerOrderStatus::Ready | BuyerOrderStatus::Completed | BuyerOrderStatus::Declined 2859 ) { 2860 return Err(AppSqliteError::InvalidProjection { 2861 reason: "buyer order revision requires an active negotiated order", 2862 }); 2863 } 2864 let request = self.resolve_seller_order_request_evidence(order_id)?; 2865 if request.payload.buyer_pubkey.trim() != buyer_pubkey.as_str() { 2866 return Err(AppSqliteError::InvalidProjection { 2867 reason: "buyer order revision buyer account does not match order buyer", 2868 }); 2869 } 2870 let lifecycle = self.resolve_order_lifecycle_evidence(&request)?; 2871 if lifecycle.decision.is_some() || lifecycle.status != RadrootsOrderStatus::Requested { 2872 return Err(AppSqliteError::InvalidProjection { 2873 reason: "buyer order revision requires active pre-agreement negotiation", 2874 }); 2875 } 2876 if lifecycle.cancellation_event_id.is_some() { 2877 return Err(AppSqliteError::InvalidProjection { 2878 reason: "buyer order revision requires active pre-agreement negotiation", 2879 }); 2880 } 2881 let Some(proposal) = active_order_pending_revision_proposal(&lifecycle) else { 2882 return Err(AppSqliteError::InvalidProjection { 2883 reason: "buyer order revision requires a pending seller proposal", 2884 }); 2885 }; 2886 let payload = AppOrderRevisionDecisionPublishPayload { 2887 context: AppPublishContext::new(account_id.clone(), "buyer_order_revision_decision"), 2888 app_order_id: order_id, 2889 farm_id: detail.farm_id, 2890 trade_order_id: request.payload.order_id.to_string(), 2891 request_event_id: request.request_event_id, 2892 prev_event_id: proposal.event_id.clone(), 2893 revision_id: proposal.payload.revision_id.to_string(), 2894 listing_addr: request.payload.listing_addr.to_string(), 2895 buyer_pubkey: request.payload.buyer_pubkey.to_string(), 2896 seller_pubkey: request.payload.seller_pubkey.to_string(), 2897 decision, 2898 }; 2899 AppPublishPayload::OrderRevisionDecision(payload.clone()) 2900 .validate() 2901 .map_err(|_| AppSqliteError::InvalidProjection { 2902 reason: "buyer order revision publish payload is invalid", 2903 })?; 2904 Ok(payload) 2905 } 2906 2907 fn publish_buyer_order_revision_accept( 2908 &mut self, 2909 order_id: OrderId, 2910 ) -> Result<bool, AppSqliteError> { 2911 self.publish_buyer_order_revision_decision(order_id, RadrootsOrderRevisionOutcome::Accepted) 2912 } 2913 2914 fn publish_buyer_order_revision_decline( 2915 &mut self, 2916 order_id: OrderId, 2917 ) -> Result<bool, AppSqliteError> { 2918 self.publish_buyer_order_revision_decision( 2919 order_id, 2920 RadrootsOrderRevisionOutcome::Declined { 2921 reason: "buyer kept order as placed".to_owned(), 2922 }, 2923 ) 2924 } 2925 2926 fn publish_buyer_order_revision_decision( 2927 &mut self, 2928 order_id: OrderId, 2929 decision: RadrootsOrderRevisionOutcome, 2930 ) -> Result<bool, AppSqliteError> { 2931 let payload = self.prepare_buyer_order_revision_decision(order_id, decision)?; 2932 let source_record_id = order_revision_decision_sdk_source_record_id(&payload); 2933 self.enqueue_order_revision_decision_payload_via_sdk( 2934 &payload, 2935 AppSdkMigrationReceiptSourceKind::LocalOutbox, 2936 source_record_id.as_str(), 2937 )?; 2938 let _ = self.refresh_selected_account_sync()?; 2939 Ok(true) 2940 } 2941 2942 fn prepare_buyer_order_cancellation( 2943 &mut self, 2944 order_id: OrderId, 2945 ) -> Result<AppOrderCancellationPublishPayload, AppSqliteError> { 2946 let _ = self.import_shared_local_events()?; 2947 let relay_urls = normalized_app_sync_relay_urls(&self.nostr_relay_urls).map_err(|_| { 2948 AppSqliteError::InvalidProjection { 2949 reason: "buyer order cancellation requires valid configured relays", 2950 } 2951 })?; 2952 if relay_urls.is_empty() { 2953 return Err(AppSqliteError::InvalidProjection { 2954 reason: "buyer order cancellation requires configured relays", 2955 }); 2956 } 2957 self.refresh_configured_relay_state_before_order_lifecycle()?; 2958 let buyer_context = self.state_store.identity_projection().buyer_context(); 2959 let BuyerContext::Account(account_id) = &buyer_context else { 2960 return Err(AppSqliteError::InvalidProjection { 2961 reason: "buyer order cancellation requires a selected buyer account", 2962 }); 2963 }; 2964 let Some(selected_account) = self.selected_buyer_account(&buyer_context) else { 2965 return Err(AppSqliteError::InvalidProjection { 2966 reason: "buyer order cancellation requires a selected buyer account", 2967 }); 2968 }; 2969 let buyer_pubkey = self.local_events_owner_pubkey(selected_account).ok_or( 2970 AppSqliteError::InvalidProjection { 2971 reason: "buyer order cancellation requires a selected buyer public key", 2972 }, 2973 )?; 2974 let Some(sqlite_store) = self.sqlite_store.as_ref() else { 2975 return Err(AppSqliteError::InvalidProjection { 2976 reason: "buyer order cancellation requires local state", 2977 }); 2978 }; 2979 let buyer_order_scope = selected_buyer_order_scope(self.state_store.identity_projection()); 2980 let Some(detail) = 2981 sqlite_store.load_buyer_order_detail_for_scope(&buyer_order_scope, order_id)? 2982 else { 2983 return Err(AppSqliteError::InvalidProjection { 2984 reason: "buyer order cancellation requires a visible buyer order", 2985 }); 2986 }; 2987 if !matches!(detail.status, BuyerOrderStatus::Placed) { 2988 return Err(AppSqliteError::InvalidProjection { 2989 reason: "buyer order cancellation requires an open pre-agreement order", 2990 }); 2991 } 2992 let request = self.resolve_seller_order_request_evidence(order_id)?; 2993 if request.payload.buyer_pubkey.trim() != buyer_pubkey.as_str() { 2994 return Err(AppSqliteError::InvalidProjection { 2995 reason: "buyer order cancellation buyer account does not match order buyer", 2996 }); 2997 } 2998 let lifecycle = self.resolve_order_lifecycle_evidence(&request)?; 2999 if lifecycle.cancellation_event_id.is_some() { 3000 return Err(AppSqliteError::InvalidProjection { 3001 reason: "buyer order cancellation requires an open pre-agreement order", 3002 }); 3003 } 3004 let prev_event_id = match lifecycle.status { 3005 RadrootsOrderStatus::Requested => { 3006 if active_order_pending_revision_proposal(&lifecycle).is_some() { 3007 return Err(AppSqliteError::InvalidProjection { 3008 reason: "buyer order cancellation requires no pending seller proposal", 3009 }); 3010 } 3011 request.request_event_id.clone() 3012 } 3013 RadrootsOrderStatus::Accepted => { 3014 return Err(AppSqliteError::InvalidProjection { 3015 reason: "buyer order cancellation requires an open pre-agreement order", 3016 }); 3017 } 3018 RadrootsOrderStatus::Missing 3019 | RadrootsOrderStatus::Declined 3020 | RadrootsOrderStatus::Cancelled 3021 | RadrootsOrderStatus::Invalid => { 3022 return Err(AppSqliteError::InvalidProjection { 3023 reason: "buyer order cancellation requires an open pre-agreement order", 3024 }); 3025 } 3026 }; 3027 let payload = AppOrderCancellationPublishPayload { 3028 context: AppPublishContext::new(account_id.clone(), "buyer_order_cancellation"), 3029 app_order_id: order_id, 3030 farm_id: detail.farm_id, 3031 trade_order_id: request.payload.order_id.to_string(), 3032 request_event_id: request.request_event_id, 3033 prev_event_id, 3034 listing_addr: request.payload.listing_addr.to_string(), 3035 buyer_pubkey: request.payload.buyer_pubkey.to_string(), 3036 seller_pubkey: request.payload.seller_pubkey.to_string(), 3037 reason: "buyer cancelled order".to_owned(), 3038 }; 3039 AppPublishPayload::OrderCancellation(payload.clone()) 3040 .validate() 3041 .map_err(|_| AppSqliteError::InvalidProjection { 3042 reason: "buyer order cancellation publish payload is invalid", 3043 })?; 3044 Ok(payload) 3045 } 3046 3047 fn publish_buyer_order_cancellation( 3048 &mut self, 3049 order_id: OrderId, 3050 ) -> Result<bool, AppSqliteError> { 3051 let payload = self.prepare_buyer_order_cancellation(order_id)?; 3052 let source_record_id = order_cancellation_sdk_source_record_id(&payload); 3053 self.enqueue_order_cancellation_payload_via_sdk( 3054 &payload, 3055 AppSdkMigrationReceiptSourceKind::LocalOutbox, 3056 source_record_id.as_str(), 3057 )?; 3058 let _ = self.refresh_selected_account_sync()?; 3059 Ok(true) 3060 } 3061 3062 fn open_pack_day( 3063 &mut self, 3064 fulfillment_window_id: Option<FulfillmentWindowId>, 3065 ) -> Result<bool, AppSqliteError> { 3066 if !self.has_saved_farm() { 3067 return Ok(false); 3068 } 3069 3070 let query = PackDayScreenQueryState { 3071 fulfillment_window_id, 3072 }; 3073 let query_changed = self.replace_pack_day_query(query)?; 3074 if !self.has_pack_day_context() { 3075 return Ok(false); 3076 } 3077 let section_changed = self 3078 .state_store 3079 .apply_in_memory(AppStateCommand::SelectSection(ShellSection::Farmer( 3080 FarmerSection::PackDay, 3081 ))); 3082 let editor_changed = self.close_product_editor(); 3083 3084 Ok(query_changed || section_changed || editor_changed) 3085 } 3086 3087 fn export_pack_day(&mut self) -> Result<bool, DesktopAppRuntimeCommandError> { 3088 let Some(farm_id) = self.selected_farm_id() else { 3089 return Ok(false); 3090 }; 3091 let previous_export_instance_id = self.current_pack_day_export_instance_id(); 3092 let Some(fulfillment_window_id) = self 3093 .state_store 3094 .pack_day_projection() 3095 .projection 3096 .fulfillment_window 3097 .as_ref() 3098 .map(|window| window.fulfillment_window_id) 3099 else { 3100 return Ok(false); 3101 }; 3102 let Some(data_root) = self.runtime_metadata.data_root.clone() else { 3103 return Err(self.command_unavailable_error()); 3104 }; 3105 3106 let source = { 3107 let sqlite_store = self.sqlite_store()?; 3108 sqlite_store.load_pack_day_output_source(farm_id, fulfillment_window_id)? 3109 }; 3110 let Some(source) = source else { 3111 return Ok(false); 3112 }; 3113 if source.is_empty() { 3114 return Ok(false); 3115 } 3116 3117 let request = PackDayExportRequest::for_fulfillment_window( 3118 source.fulfillment_window.fulfillment_window_id, 3119 ); 3120 let _ = self 3121 .state_store 3122 .apply_in_memory(AppStateCommand::begin_pack_day_export(request.clone())); 3123 self.cleanup_prepared_pack_day_print_assets_if_export_changed( 3124 previous_export_instance_id, 3125 "export_reset", 3126 ); 3127 let prepared = 3128 prepare_pack_day_export_bundle_at_data_root(data_root.as_path(), &source, Utc::now()); 3129 3130 match write_prepared_pack_day_export_bundle(&prepared) { 3131 Ok(()) => { 3132 let _ = self 3133 .state_store 3134 .apply_in_memory(AppStateCommand::succeed_pack_day_export( 3135 request, 3136 prepared.bundle, 3137 )); 3138 Ok(true) 3139 } 3140 Err(error) => { 3141 let _ = self 3142 .state_store 3143 .apply_in_memory(AppStateCommand::fail_pack_day_export( 3144 request, 3145 error.to_string(), 3146 )); 3147 Err(error.into()) 3148 } 3149 } 3150 } 3151 3152 fn prepare_pack_day_host_handoff( 3153 &mut self, 3154 kind: PackDayHostHandoffKind, 3155 ) -> Result< 3156 Option<(PackDayHostHandoffRequest, PackDayHostHandoffCommandPlan)>, 3157 DesktopAppRuntimeCommandError, 3158 > { 3159 if self.state_store.pack_day_projection().host_handoff.status 3160 == PackDayHostHandoffStatus::Running 3161 || self.state_store.pack_day_projection().print.status == PackDayPrintStatus::Running 3162 || self.state_store.pack_day_projection().batch_print.status 3163 == PackDayBatchPrintStatus::Running 3164 { 3165 return Ok(None); 3166 } 3167 3168 let Some(bundle) = self.current_pack_day_export_bundle() else { 3169 return Ok(None); 3170 }; 3171 let request = PackDayHostHandoffRequest::for_bundle(kind, &bundle); 3172 let _ = self 3173 .state_store 3174 .apply_in_memory(AppStateCommand::begin_pack_day_host_handoff( 3175 request.clone(), 3176 )); 3177 3178 match plan_pack_day_host_handoff(&bundle, kind) { 3179 Ok(plan) => Ok(Some((request, plan))), 3180 Err(error) => { 3181 let _ = 3182 self.state_store 3183 .apply_in_memory(AppStateCommand::fail_pack_day_host_handoff( 3184 request, 3185 error.to_string(), 3186 )); 3187 Err(error.into()) 3188 } 3189 } 3190 } 3191 3192 fn prepare_pack_day_print( 3193 &mut self, 3194 kind: PackDayPrintKind, 3195 ) -> Result<Option<(PackDayPrintRequest, PackDayPrintCommandPlan)>, DesktopAppRuntimeCommandError> 3196 { 3197 if self.state_store.pack_day_projection().print.status == PackDayPrintStatus::Running 3198 || self.state_store.pack_day_projection().host_handoff.status 3199 == PackDayHostHandoffStatus::Running 3200 || self.state_store.pack_day_projection().batch_print.status 3201 == PackDayBatchPrintStatus::Running 3202 { 3203 return Ok(None); 3204 } 3205 3206 let Some(bundle) = self.current_pack_day_export_bundle() else { 3207 return Ok(None); 3208 }; 3209 let request = PackDayPrintRequest::for_bundle(kind, &bundle); 3210 let _ = self 3211 .state_store 3212 .apply_in_memory(AppStateCommand::begin_pack_day_print(request.clone())); 3213 3214 match plan_pack_day_print(&bundle, kind) { 3215 Ok(plan) => Ok(Some((request, plan))), 3216 Err(error) => { 3217 let failure_command = match error.failure_kind() { 3218 Some(failure) => { 3219 AppStateCommand::fail_pack_day_print_with_kind(request, failure) 3220 } 3221 None => AppStateCommand::fail_pack_day_print(request), 3222 }; 3223 let _ = self.state_store.apply_in_memory(failure_command); 3224 Err(error.into()) 3225 } 3226 } 3227 } 3228 3229 fn prepare_pack_day_batch_print( 3230 &mut self, 3231 ) -> Result< 3232 Option<(PackDayBatchPrintRequest, PackDayBatchPrintCommandPlan)>, 3233 DesktopAppRuntimeCommandError, 3234 > { 3235 if self.state_store.pack_day_projection().batch_print.status 3236 == PackDayBatchPrintStatus::Running 3237 || self.state_store.pack_day_projection().print.status == PackDayPrintStatus::Running 3238 || self.state_store.pack_day_projection().host_handoff.status 3239 == PackDayHostHandoffStatus::Running 3240 { 3241 return Ok(None); 3242 } 3243 3244 let Some(bundle) = self.current_pack_day_export_bundle() else { 3245 return Ok(None); 3246 }; 3247 let request = PackDayBatchPrintRequest::for_bundle(&bundle); 3248 let _ = self 3249 .state_store 3250 .apply_in_memory(AppStateCommand::begin_pack_day_batch_print(request.clone())); 3251 3252 match plan_pack_day_batch_print(&bundle, &request) { 3253 Ok(plan) => Ok(Some((request, plan))), 3254 Err(error) => { 3255 let _ = 3256 self.state_store 3257 .apply_in_memory(AppStateCommand::fail_pack_day_batch_print( 3258 request, 3259 error.failed_artifact(), 3260 error.failure_kind(), 3261 )); 3262 Err(error.into()) 3263 } 3264 } 3265 } 3266 3267 fn finish_pack_day_batch_print( 3268 &mut self, 3269 request: PackDayBatchPrintRequest, 3270 result: Result<(), PackDayBatchPrintError>, 3271 ) -> Result<bool, DesktopAppRuntimeCommandError> { 3272 if !self.current_pack_day_batch_print_request_matches(&request) { 3273 return Ok(false); 3274 } 3275 3276 let cleanup_export_instance_id = request.export_instance_id; 3277 3278 match result { 3279 Ok(()) => { 3280 let changed = self 3281 .state_store 3282 .apply_in_memory(AppStateCommand::succeed_pack_day_batch_print(request)); 3283 self.cleanup_prepared_pack_day_print_assets_for_export_instance( 3284 cleanup_export_instance_id, 3285 "batch_print_completion", 3286 ); 3287 Ok(changed) 3288 } 3289 Err(error) => { 3290 let _ = 3291 self.state_store 3292 .apply_in_memory(AppStateCommand::fail_pack_day_batch_print( 3293 request, 3294 error.failed_artifact(), 3295 error.failure_kind(), 3296 )); 3297 self.cleanup_prepared_pack_day_print_assets_for_export_instance( 3298 cleanup_export_instance_id, 3299 "batch_print_completion", 3300 ); 3301 Err(error.into()) 3302 } 3303 } 3304 } 3305 3306 fn finish_pack_day_print( 3307 &mut self, 3308 request: PackDayPrintRequest, 3309 result: Result<(), PackDayPrintError>, 3310 ) -> Result<bool, DesktopAppRuntimeCommandError> { 3311 if !self.current_pack_day_print_request_matches(&request) { 3312 return Ok(false); 3313 } 3314 3315 let cleanup_export_instance_id = (request.kind == PackDayPrintKind::PrintCustomerLabels) 3316 .then_some(request.export_instance_id); 3317 3318 match result { 3319 Ok(()) => { 3320 let changed = self 3321 .state_store 3322 .apply_in_memory(AppStateCommand::succeed_pack_day_print(request)); 3323 if let Some(export_instance_id) = cleanup_export_instance_id { 3324 self.cleanup_prepared_pack_day_print_assets_for_export_instance( 3325 export_instance_id, 3326 "print_completion", 3327 ); 3328 } 3329 Ok(changed) 3330 } 3331 Err(error) => { 3332 let failure_command = match error.failure_kind() { 3333 Some(failure) => { 3334 AppStateCommand::fail_pack_day_print_with_kind(request, failure) 3335 } 3336 None => AppStateCommand::fail_pack_day_print(request), 3337 }; 3338 let _ = self.state_store.apply_in_memory(failure_command); 3339 if let Some(export_instance_id) = cleanup_export_instance_id { 3340 self.cleanup_prepared_pack_day_print_assets_for_export_instance( 3341 export_instance_id, 3342 "print_completion", 3343 ); 3344 } 3345 Err(error.into()) 3346 } 3347 } 3348 } 3349 3350 fn finish_pack_day_host_handoff( 3351 &mut self, 3352 request: PackDayHostHandoffRequest, 3353 result: Result<(), PackDayHostHandoffError>, 3354 ) -> Result<bool, DesktopAppRuntimeCommandError> { 3355 if !self.current_pack_day_host_handoff_request_matches(&request) { 3356 return Ok(false); 3357 } 3358 3359 match result { 3360 Ok(()) => Ok(self 3361 .state_store 3362 .apply_in_memory(AppStateCommand::succeed_pack_day_host_handoff(request))), 3363 Err(error) => { 3364 let _ = 3365 self.state_store 3366 .apply_in_memory(AppStateCommand::fail_pack_day_host_handoff( 3367 request, 3368 error.to_string(), 3369 )); 3370 Err(error.into()) 3371 } 3372 } 3373 } 3374 3375 fn update_product_stock( 3376 &mut self, 3377 product_id: ProductId, 3378 stock_quantity: u32, 3379 ) -> Result<bool, DesktopAppRuntimeProductStockUpdateError> { 3380 let Some(sqlite_store) = self.sqlite_store.as_ref() else { 3381 return Ok(false); 3382 }; 3383 let Some(_) = self.selected_farm_id() else { 3384 return Ok(false); 3385 }; 3386 if self 3387 .state_store 3388 .identity_projection() 3389 .selected_account 3390 .is_none() 3391 { 3392 return Ok(false); 3393 } 3394 3395 let updated = sqlite_store.update_product_stock(product_id, stock_quantity)?; 3396 3397 let continuity_state = 3398 self.continuity_state_with_order_detail(self.selected_order_detail_id()); 3399 let selected_account_context = load_selected_account_context( 3400 sqlite_store, 3401 self.state_store.identity_projection(), 3402 &continuity_state, 3403 )?; 3404 let context_changed = self.apply_selected_account_context(&selected_account_context); 3405 let publish_changed = self.enqueue_selected_account_product_publish_operation( 3406 product_id, 3407 "update_product_stock", 3408 None, 3409 )?; 3410 3411 Ok(updated || context_changed || publish_changed) 3412 } 3413 3414 fn open_new_product_editor(&mut self) -> Result<bool, AppSqliteError> { 3415 let Some(sqlite_store) = self.sqlite_store.as_ref() else { 3416 return Ok(false); 3417 }; 3418 let Some(farm_id) = self.selected_farm_id() else { 3419 return Ok(false); 3420 }; 3421 3422 let product_id = sqlite_store.create_product_draft(farm_id)?; 3423 let Some(draft) = sqlite_store.load_product_editor_draft(product_id)? else { 3424 return Ok(false); 3425 }; 3426 let continuity_state = self.continuity_state(); 3427 let selected_account_context = load_selected_account_context( 3428 sqlite_store, 3429 self.state_store.identity_projection(), 3430 &continuity_state, 3431 )?; 3432 let context_changed = self.apply_selected_account_context(&selected_account_context); 3433 let section_changed = self.select_farmer_section(FarmerSection::Products); 3434 let editor_changed = 3435 self.state_store 3436 .apply_in_memory(AppStateCommand::open_existing_product_editor( 3437 product_id, draft, 3438 )); 3439 3440 Ok(context_changed || section_changed || editor_changed) 3441 } 3442 3443 fn open_existing_product_editor( 3444 &mut self, 3445 product_id: ProductId, 3446 ) -> Result<bool, AppSqliteError> { 3447 let Some(sqlite_store) = self.sqlite_store.as_ref() else { 3448 return Ok(false); 3449 }; 3450 let Some(draft) = sqlite_store.load_product_editor_draft(product_id)? else { 3451 return Ok(false); 3452 }; 3453 let section_changed = self.select_farmer_section(FarmerSection::Products); 3454 let editor_changed = 3455 self.state_store 3456 .apply_in_memory(AppStateCommand::open_existing_product_editor( 3457 product_id, draft, 3458 )); 3459 3460 Ok(section_changed || editor_changed) 3461 } 3462 3463 fn save_product_editor_draft( 3464 &mut self, 3465 draft: ProductEditorDraft, 3466 ) -> Result<bool, DesktopAppRuntimeProductEditorSaveError> { 3467 let Some(product_id) = self.selected_product_editor_id() else { 3468 return Ok(false); 3469 }; 3470 3471 let saved = { 3472 let Some(sqlite_store) = self.sqlite_store.as_ref() else { 3473 return Ok(false); 3474 }; 3475 sqlite_store.save_product_editor_draft(product_id, &draft)? 3476 }; 3477 if !saved { 3478 return Ok(false); 3479 } 3480 3481 let selected_account_context = { 3482 let Some(sqlite_store) = self.sqlite_store.as_ref() else { 3483 return Ok(false); 3484 }; 3485 let continuity_state = self.continuity_state(); 3486 load_selected_account_context( 3487 sqlite_store, 3488 self.state_store.identity_projection(), 3489 &continuity_state, 3490 )? 3491 }; 3492 let reloaded_draft = { 3493 let Some(sqlite_store) = self.sqlite_store.as_ref() else { 3494 return Ok(false); 3495 }; 3496 sqlite_store 3497 .load_product_editor_draft(product_id)? 3498 .unwrap_or(draft) 3499 }; 3500 let context_changed = self.apply_selected_account_context(&selected_account_context); 3501 let draft_payload = reloaded_draft.clone(); 3502 let editor_changed = 3503 self.state_store 3504 .apply_in_memory(AppStateCommand::replace_product_editor_draft( 3505 reloaded_draft, 3506 )); 3507 let source_local_event_id = 3508 self.append_app_listing_local_work_record(product_id, &draft_payload)?; 3509 let pending_changed = self.enqueue_selected_account_product_publish_operation( 3510 product_id, 3511 "save_product_editor_draft", 3512 source_local_event_id.as_deref(), 3513 )?; 3514 3515 Ok(saved 3516 || context_changed 3517 || editor_changed 3518 || source_local_event_id.is_some() 3519 || pending_changed) 3520 } 3521 3522 fn close_product_editor(&mut self) -> bool { 3523 self.state_store 3524 .apply_in_memory(AppStateCommand::close_product_editor()) 3525 } 3526 3527 fn save_farm_setup_draft( 3528 &mut self, 3529 draft: FarmSetupDraft, 3530 ) -> Result<FarmSetupProjection, DesktopAppRuntimeFarmSetupError> { 3531 let account_id = self.selected_account_id()?; 3532 let sqlite_store = self.sqlite_store_for_farm_setup()?; 3533 let projection = FarmSetupProjection::from_draft(draft); 3534 sqlite_store.save_farm_setup(account_id.as_str(), &projection)?; 3535 3536 let selected_account_context = self.refresh_selected_account_context()?; 3537 self.apply_selected_account_context(&selected_account_context); 3538 3539 Ok(selected_account_context.farm_setup_projection) 3540 } 3541 3542 fn finish_farm_setup( 3543 &mut self, 3544 ) -> Result<FarmSetupProjection, DesktopAppRuntimeFarmSetupError> { 3545 let account = self.selected_account_for_farm_setup()?.clone(); 3546 let sqlite_store = self.sqlite_store_for_farm_setup()?; 3547 let draft = self.state_store.farm_setup_projection().draft.clone(); 3548 3549 if !draft.blockers().is_empty() { 3550 return Err(DesktopAppRuntimeFarmSetupError::IncompleteDraft); 3551 } 3552 3553 let saved_farm = FarmSummary { 3554 farm_id: account 3555 .farmer_activation 3556 .farm_id 3557 .unwrap_or_else(FarmId::new), 3558 display_name: draft.farm_name.trim().to_owned(), 3559 readiness: FarmReadiness::Incomplete, 3560 }; 3561 let projection = FarmSetupProjection::new(draft, Some(saved_farm.clone())); 3562 3563 sqlite_store.save_farm_summary(&saved_farm)?; 3564 sqlite_store.save_farm_setup(account.account.account_id.as_str(), &projection)?; 3565 let source_local_event_id = 3566 self.append_app_farm_local_work_record(&account, &projection, &saved_farm)?; 3567 3568 let selected_account_context = self.refresh_selected_account_context()?; 3569 self.apply_selected_account_context(&selected_account_context); 3570 let _ = self.enqueue_selected_account_farm_publish_operation( 3571 saved_farm.farm_id, 3572 saved_farm.display_name.as_str(), 3573 Some(saved_farm.readiness), 3574 "finish_farm_setup", 3575 source_local_event_id.as_deref(), 3576 )?; 3577 3578 Ok(selected_account_context.farm_setup_projection) 3579 } 3580 3581 fn load_farm_rules_projection( 3582 &self, 3583 ) -> Result<FarmRulesProjection, DesktopAppRuntimeFarmRulesError> { 3584 let farm_id = self 3585 .selected_farm_id() 3586 .ok_or(DesktopAppRuntimeFarmRulesError::FarmRequired)?; 3587 let fallback_profile = self.fallback_farm_profile(farm_id); 3588 let projection = self 3589 .sqlite_store_for_farm_rules()? 3590 .load_farm_rules(farm_id) 3591 .map(|projection| { 3592 prepare_loaded_farm_rules_projection(projection, &fallback_profile) 3593 })?; 3594 3595 Ok(projection) 3596 } 3597 3598 fn save_farm_rules_projection( 3599 &mut self, 3600 projection: FarmRulesProjection, 3601 ) -> Result<FarmRulesProjection, DesktopAppRuntimeFarmRulesError> { 3602 let account_id = self.selected_account_id_for_farm_rules()?; 3603 let farm_id = self 3604 .selected_farm_id() 3605 .ok_or(DesktopAppRuntimeFarmRulesError::FarmRequired)?; 3606 let fallback_profile = self.fallback_farm_profile(farm_id); 3607 let normalized = normalize_farm_rules_projection(projection, &fallback_profile); 3608 let saved_projection = { 3609 let sqlite_store = self.sqlite_store_for_farm_rules()?; 3610 sqlite_store.save_farm_rules(&normalized)?; 3611 3612 let mut refreshed = sqlite_store.load_farm_rules(farm_id)?; 3613 refreshed = prepare_loaded_farm_rules_projection(refreshed, &fallback_profile); 3614 3615 let saved_farm = FarmSummary { 3616 farm_id, 3617 display_name: refreshed 3618 .farm_profile 3619 .as_ref() 3620 .map(|profile| profile.display_name.clone()) 3621 .unwrap_or_default(), 3622 readiness: if refreshed.is_ready() { 3623 FarmReadiness::Ready 3624 } else { 3625 FarmReadiness::Incomplete 3626 }, 3627 }; 3628 let mut farm_setup_projection = self.state_store.farm_setup_projection().clone(); 3629 farm_setup_projection.draft.farm_name = saved_farm.display_name.clone(); 3630 farm_setup_projection.saved_farm = Some(saved_farm.clone()); 3631 3632 sqlite_store.save_farm_summary(&saved_farm)?; 3633 sqlite_store.save_farm_setup(account_id.as_str(), &farm_setup_projection)?; 3634 3635 refreshed 3636 }; 3637 3638 let selected_account_context = { 3639 let sqlite_store = self.sqlite_store_for_farm_rules()?; 3640 let continuity_state = self.continuity_state(); 3641 load_selected_account_context( 3642 sqlite_store, 3643 self.state_store.identity_projection(), 3644 &continuity_state, 3645 )? 3646 }; 3647 self.apply_selected_account_context(&selected_account_context); 3648 let display_name = saved_projection 3649 .farm_profile 3650 .as_ref() 3651 .map(|profile| profile.display_name.as_str()) 3652 .unwrap_or_default(); 3653 let readiness = if saved_projection.is_ready() { 3654 FarmReadiness::Ready 3655 } else { 3656 FarmReadiness::Incomplete 3657 }; 3658 let _ = self.enqueue_selected_account_farm_publish_operation( 3659 farm_id, 3660 display_name, 3661 Some(readiness), 3662 "save_farm_rules_projection", 3663 None, 3664 )?; 3665 3666 Ok(saved_projection) 3667 } 3668 3669 fn replace_identity_projection( 3670 &mut self, 3671 projection: AppIdentityProjection, 3672 ) -> Result<bool, DesktopAppRuntimeCommandError> { 3673 let projection = self.decorate_identity_projection(projection)?; 3674 let _ = self.import_shared_local_events()?; 3675 let continuity_state = self.continuity_state(); 3676 let selected_account_context = 3677 load_selected_account_context(self.sqlite_store()?, &projection, &continuity_state)?; 3678 let selected_account_sync_context = load_selected_account_sync_context( 3679 self.sqlite_store()?, 3680 &projection, 3681 &self.nostr_relay_urls, 3682 )?; 3683 let identity_changed = self 3684 .state_store 3685 .apply_in_memory(AppStateCommand::replace_identity_projection(projection)); 3686 let context_changed = self.apply_selected_account_context(&selected_account_context); 3687 let sync_changed = self.apply_selected_account_sync_context(&selected_account_sync_context); 3688 let editor_changed = self.close_product_editor(); 3689 3690 Ok(identity_changed || context_changed || sync_changed || editor_changed) 3691 } 3692 3693 fn refresh_selected_account_context( 3694 &self, 3695 ) -> Result<DesktopSelectedAccountContext, DesktopAppRuntimeFarmSetupError> { 3696 let _ = self.import_shared_local_events()?; 3697 let continuity_state = self.continuity_state(); 3698 Ok(load_selected_account_context( 3699 self.sqlite_store_for_farm_setup()?, 3700 self.state_store.identity_projection(), 3701 &continuity_state, 3702 )?) 3703 } 3704 3705 fn apply_selected_account_context(&mut self, context: &DesktopSelectedAccountContext) -> bool { 3706 self.apply_selected_account_context_with_options(context, true) 3707 } 3708 3709 fn apply_selected_account_seller_context( 3710 &mut self, 3711 context: &DesktopSelectedAccountContext, 3712 ) -> bool { 3713 self.apply_selected_account_context_with_options(context, false) 3714 } 3715 3716 fn apply_selected_account_context_with_options( 3717 &mut self, 3718 context: &DesktopSelectedAccountContext, 3719 include_personal: bool, 3720 ) -> bool { 3721 let previous_export_instance_id = self.current_pack_day_export_instance_id(); 3722 let personal_changed = if include_personal { 3723 self.state_store 3724 .apply_in_memory(AppStateCommand::replace_personal_projection( 3725 context.personal_projection.clone(), 3726 )) 3727 } else { 3728 false 3729 }; 3730 let farm_setup_changed = 3731 self.state_store 3732 .apply_in_memory(AppStateCommand::replace_farm_setup_projection( 3733 context.farm_setup_projection.clone(), 3734 )); 3735 let farm_rules_changed = 3736 self.state_store 3737 .apply_in_memory(AppStateCommand::replace_farm_rules_projection( 3738 context.farm_rules_projection.clone(), 3739 )); 3740 let today_changed = 3741 self.state_store 3742 .apply_in_memory(AppStateCommand::replace_today_agenda( 3743 context.today_projection.clone(), 3744 )); 3745 let products_query_changed = 3746 self.state_store 3747 .apply_in_memory(AppStateCommand::set_products_search_query( 3748 context.products_query.search_query.clone(), 3749 )) 3750 || self 3751 .state_store 3752 .apply_in_memory(AppStateCommand::select_products_filter( 3753 context.products_query.filter, 3754 )) 3755 || self 3756 .state_store 3757 .apply_in_memory(AppStateCommand::select_products_sort( 3758 context.products_query.sort, 3759 )); 3760 let products_changed = 3761 self.state_store 3762 .apply_in_memory(AppStateCommand::replace_products_list( 3763 context.products_list.clone(), 3764 )); 3765 let orders_query_changed = 3766 self.state_store 3767 .apply_in_memory(AppStateCommand::select_orders_filter( 3768 context.orders_query.filter, 3769 )) 3770 || self.state_store.apply_in_memory( 3771 AppStateCommand::select_orders_fulfillment_window( 3772 context.orders_query.fulfillment_window_id, 3773 ), 3774 ); 3775 let orders_changed = 3776 self.state_store 3777 .apply_in_memory(AppStateCommand::replace_orders_list( 3778 context.orders_list.clone(), 3779 )); 3780 let orders_reminders_changed = 3781 self.state_store 3782 .apply_in_memory(AppStateCommand::replace_orders_reminders( 3783 context.orders_reminders.clone(), 3784 )); 3785 let reminder_log_changed = 3786 self.state_store 3787 .apply_in_memory(AppStateCommand::replace_reminder_log( 3788 context.reminder_log.clone(), 3789 )); 3790 let order_detail_changed = 3791 self.state_store 3792 .apply_in_memory(AppStateCommand::replace_order_detail( 3793 context.order_detail.clone(), 3794 )); 3795 let pack_day_changed = 3796 self.state_store 3797 .apply_in_memory(AppStateCommand::replace_pack_day_projection( 3798 context.pack_day_projection.clone(), 3799 )); 3800 let pack_day_query_changed = 3801 self.state_store 3802 .apply_in_memory(AppStateCommand::set_pack_day_fulfillment_window( 3803 context.pack_day_query.fulfillment_window_id, 3804 )); 3805 let editor_changed = 3806 if let Some((product_id, draft)) = context.product_editor_draft.as_ref() { 3807 self.state_store 3808 .apply_in_memory(AppStateCommand::open_existing_product_editor( 3809 *product_id, 3810 draft.clone(), 3811 )) 3812 } else { 3813 self.close_product_editor() 3814 }; 3815 let shell_changed = self.sync_truthful_farmer_section(); 3816 self.cleanup_prepared_pack_day_print_assets_if_export_changed( 3817 previous_export_instance_id, 3818 "context_refresh", 3819 ); 3820 3821 personal_changed 3822 || farm_setup_changed 3823 || farm_rules_changed 3824 || today_changed 3825 || products_query_changed 3826 || products_changed 3827 || orders_query_changed 3828 || orders_changed 3829 || orders_reminders_changed 3830 || reminder_log_changed 3831 || order_detail_changed 3832 || pack_day_query_changed 3833 || pack_day_changed 3834 || editor_changed 3835 || shell_changed 3836 } 3837 3838 fn refresh_selected_account_sync_context( 3839 &self, 3840 ) -> Result<DesktopSelectedAccountSyncContext, AppSqliteError> { 3841 let Some(sqlite_store) = self.sqlite_store.as_ref() else { 3842 return Ok(DesktopSelectedAccountSyncContext::default()); 3843 }; 3844 3845 load_selected_account_sync_context( 3846 sqlite_store, 3847 self.state_store.identity_projection(), 3848 &self.nostr_relay_urls, 3849 ) 3850 } 3851 3852 fn apply_selected_account_sync_context( 3853 &mut self, 3854 context: &DesktopSelectedAccountSyncContext, 3855 ) -> bool { 3856 let projection_changed = 3857 self.state_store 3858 .apply_in_memory(AppStateCommand::replace_sync_projection( 3859 context.projection.clone(), 3860 )); 3861 let pending_changed = 3862 self.selected_account_pending_sync_write_count != context.pending_write_count; 3863 let relay_ingest_changed = 3864 self.selected_account_relay_ingest_freshness != context.relay_ingest; 3865 let conflicts_changed = self.selected_account_sync_conflicts != context.conflicts; 3866 3867 self.selected_account_pending_sync_write_count = context.pending_write_count; 3868 self.selected_account_relay_ingest_freshness = context.relay_ingest.clone(); 3869 self.selected_account_sync_conflicts = context.conflicts.clone(); 3870 3871 projection_changed || pending_changed || relay_ingest_changed || conflicts_changed 3872 } 3873 3874 fn refresh_selected_account_sync(&mut self) -> Result<bool, AppSqliteError> { 3875 let context = self.refresh_selected_account_sync_context()?; 3876 let sync_changed = self.apply_selected_account_sync_context(&context); 3877 let selected_account_changed = match self.sqlite_store.as_ref() { 3878 Some(sqlite_store) => { 3879 let continuity_state = self.continuity_state(); 3880 let selected_account_context = load_selected_account_context( 3881 sqlite_store, 3882 self.state_store.identity_projection(), 3883 &continuity_state, 3884 )?; 3885 self.apply_selected_account_seller_context(&selected_account_context) 3886 } 3887 None => false, 3888 }; 3889 3890 Ok(sync_changed || selected_account_changed) 3891 } 3892 3893 fn resolve_sync_conflict( 3894 &mut self, 3895 conflict_id: &str, 3896 resolution: radroots_app_sync::SyncConflictResolutionStatus, 3897 ) -> Result<bool, AppSqliteError> { 3898 let Some(sqlite_store) = self.sqlite_store.as_ref() else { 3899 return Ok(false); 3900 }; 3901 let Some(selected_account) = self 3902 .state_store 3903 .identity_projection() 3904 .selected_account 3905 .as_ref() 3906 else { 3907 return Ok(false); 3908 }; 3909 let account_id = selected_account.account.account_id.as_str(); 3910 let stored_conflicts = sqlite_store.load_sync_conflicts(account_id)?; 3911 let Some(stored_conflict) = stored_conflicts 3912 .iter() 3913 .find(|stored| stored.conflict_id == conflict_id) 3914 else { 3915 return Ok(false); 3916 }; 3917 if !stored_conflict.conflict.is_unresolved() { 3918 return Ok(false); 3919 } 3920 if matches!( 3921 (stored_conflict.conflict.severity, resolution,), 3922 ( 3923 SyncConflictSeverity::Blocking, 3924 radroots_app_sync::SyncConflictResolutionStatus::Dismissed, 3925 ) 3926 ) { 3927 return Ok(false); 3928 } 3929 3930 if !sqlite_store.resolve_sync_conflict( 3931 account_id, 3932 conflict_id, 3933 resolution, 3934 current_utc_timestamp().as_str(), 3935 )? { 3936 return Ok(false); 3937 } 3938 3939 self.refresh_selected_account_sync() 3940 } 3941 3942 fn acknowledge_reminder(&mut self, reminder_id: ReminderId) -> Result<bool, AppSqliteError> { 3943 let Some(sqlite_store) = self.sqlite_store.as_ref() else { 3944 return Ok(false); 3945 }; 3946 let Some(selected_account) = self 3947 .state_store 3948 .identity_projection() 3949 .selected_account 3950 .as_ref() 3951 else { 3952 return Ok(false); 3953 }; 3954 let Some(farm_id) = self.selected_farm_id() else { 3955 return Ok(false); 3956 }; 3957 let account_id = selected_account.account.account_id.clone(); 3958 let mut schedule = sqlite_store.load_reminder_schedule(account_id.as_str(), farm_id)?; 3959 let Some(reminder) = schedule 3960 .items 3961 .iter_mut() 3962 .find(|item| item.reminder_id == reminder_id) 3963 else { 3964 return Ok(false); 3965 }; 3966 if matches!( 3967 reminder.delivery_state, 3968 ReminderDeliveryState::Acknowledged | ReminderDeliveryState::Resolved 3969 ) { 3970 return Ok(false); 3971 } 3972 3973 reminder.delivery_state = ReminderDeliveryState::Acknowledged; 3974 let reminder_log_entry = 3975 build_reminder_log_entry(reminder, ReminderDeliveryState::Acknowledged); 3976 sqlite_store.apply_reminder_schedule_update( 3977 account_id.as_str(), 3978 farm_id, 3979 &schedule, 3980 &[reminder_log_entry], 3981 )?; 3982 3983 let continuity_state = self.continuity_state(); 3984 let selected_account_context = load_selected_account_context_with_options( 3985 sqlite_store, 3986 self.state_store.identity_projection(), 3987 &continuity_state, 3988 false, 3989 )?; 3990 3991 let _ = self.apply_selected_account_context(&selected_account_context); 3992 3993 Ok(true) 3994 } 3995 3996 fn attempt_sync(&mut self, trigger: SyncTrigger) -> Result<bool, AppSqliteError> { 3997 let Some(prepared) = self.prepare_sync_request(trigger)? else { 3998 return Ok(false); 3999 }; 4000 4001 let started_at = current_utc_timestamp(); 4002 let syncing_checkpoint = SyncCheckpointStatus::syncing( 4003 started_at.clone(), 4004 prepared.checkpoint.last_remote_cursor.clone(), 4005 ); 4006 { 4007 let Some(sqlite_store) = self.sqlite_store.as_ref() else { 4008 return Ok(false); 4009 }; 4010 sqlite_store.save_sync_checkpoint(prepared.account_id.as_str(), &syncing_checkpoint)?; 4011 } 4012 4013 let mut changed = self.refresh_selected_account_sync()?; 4014 let request = AppSyncRequest { 4015 trigger, 4016 checkpoint: prepared.checkpoint.clone(), 4017 pending_operations: prepared 4018 .pending_operations 4019 .iter() 4020 .map(|stored| stored.operation.clone()) 4021 .collect(), 4022 known_conflicts: prepared 4023 .conflicts 4024 .iter() 4025 .map(|stored| stored.conflict.clone()) 4026 .collect(), 4027 }; 4028 4029 match self.run_sync_transport_or_relay_only(request, started_at.as_str()) { 4030 Ok(mut result) => { 4031 let relay_context_changed = 4032 self.ingest_configured_relay_events_for_sync(Some(&mut result), &started_at)?; 4033 changed |= self.apply_sync_result( 4034 prepared.account_id.as_str(), 4035 &prepared.pending_operations, 4036 &result, 4037 )?; 4038 if relay_context_changed { 4039 changed |= self.refresh_selected_account_context_after_local_events()?; 4040 } 4041 } 4042 Err(error) => { 4043 changed |= self.apply_sync_transport_error( 4044 prepared.account_id.as_str(), 4045 &prepared.checkpoint, 4046 &prepared.pending_operations, 4047 started_at.as_str(), 4048 error, 4049 )?; 4050 let relay_context_changed = 4051 self.ingest_configured_relay_events_for_sync(None, &started_at)?; 4052 if relay_context_changed { 4053 changed |= self.refresh_selected_account_sync()?; 4054 changed |= self.refresh_selected_account_context_after_local_events()?; 4055 } 4056 } 4057 } 4058 4059 Ok(changed) 4060 } 4061 4062 fn run_sync_transport_or_relay_only( 4063 &mut self, 4064 request: AppSyncRequest, 4065 started_at: &str, 4066 ) -> Result<AppSyncResult, AppSyncTransportError> { 4067 if request.pending_operations.is_empty() 4068 && self.has_configured_relay_ingest() 4069 && !self.sync_transport.supports_empty_sync_request() 4070 { 4071 return Ok(AppSyncResult { 4072 run_status: AppSyncRunStatus::Succeeded, 4073 checkpoint: SyncCheckpointStatus::current( 4074 Some(started_at.to_owned()), 4075 current_utc_timestamp(), 4076 request.checkpoint.last_remote_cursor.clone(), 4077 ), 4078 pushed_operation_count: 0, 4079 pulled_record_count: 0, 4080 conflicts: request.known_conflicts, 4081 published_receipts: Vec::new(), 4082 }); 4083 } 4084 4085 self.sync_transport.sync(request) 4086 } 4087 4088 fn has_configured_relay_ingest(&self) -> bool { 4089 self.nostr_relay_urls 4090 .iter() 4091 .any(|relay_url| !relay_url.trim().is_empty()) 4092 } 4093 4094 fn ingest_configured_relay_events( 4095 &self, 4096 ) -> Result<AppDirectRelayIngestReport, AppDirectRelayIngestError> { 4097 let Some(sqlite_store) = self.sqlite_store.as_ref() else { 4098 return Ok(AppDirectRelayIngestReport::default()); 4099 }; 4100 let relay_urls = normalized_app_relay_ingest_urls(&self.nostr_relay_urls)?; 4101 if relay_urls.is_empty() { 4102 return Ok(AppDirectRelayIngestReport::default()); 4103 } 4104 let started_at = current_utc_timestamp(); 4105 let started_unix_seconds = current_runtime_time_seconds()?; 4106 let cursors = sqlite_store 4107 .load_relay_ingest_cursors(APP_DIRECT_RELAY_INGEST_SCOPE_KEY, &relay_urls)?; 4108 let receipt = fetch_app_events_from_relays_windowed(cursors.as_slice())?; 4109 let completed_at = current_utc_timestamp(); 4110 let completed_unix_seconds = current_runtime_time_seconds()?; 4111 self.record_relay_ingest_freshness( 4112 &receipt, 4113 started_at.as_str(), 4114 started_unix_seconds, 4115 completed_at.as_str(), 4116 completed_unix_seconds, 4117 )?; 4118 if receipt.connected_relays.is_empty() { 4119 return Err(AppSyncTransportError::unavailable(format!( 4120 "direct relay app ingest connection failed: {}", 4121 summarize_app_relay_failures(&receipt.failed_relays) 4122 )) 4123 .into()); 4124 } 4125 if receipt.events.is_empty() { 4126 return Ok(AppDirectRelayIngestReport { 4127 local_import: AppLocalInteropImportReport::default(), 4128 freshness_changed: true, 4129 }); 4130 } 4131 let records = direct_relay_event_records(&receipt, current_runtime_time_ms()?)?; 4132 let local_import = sqlite_store 4133 .import_local_event_records(records.as_slice()) 4134 .map_err(AppDirectRelayIngestError::from)?; 4135 Ok(AppDirectRelayIngestReport { 4136 local_import, 4137 freshness_changed: true, 4138 }) 4139 } 4140 4141 fn record_relay_ingest_freshness( 4142 &self, 4143 receipt: &AppDirectRelayFetchReceipt, 4144 started_at: &str, 4145 started_unix_seconds: i64, 4146 completed_at: &str, 4147 completed_unix_seconds: i64, 4148 ) -> Result<(), AppSqliteError> { 4149 let Some(sqlite_store) = self.sqlite_store.as_ref() else { 4150 return Ok(()); 4151 }; 4152 for relay in &receipt.fetched_relays { 4153 let cursor_since_unix_seconds = relay 4154 .last_event_created_at_unix_seconds 4155 .map_or(started_unix_seconds, |last_event_created_at| { 4156 started_unix_seconds.max(last_event_created_at) 4157 }); 4158 sqlite_store.record_relay_ingest_success( 4159 APP_DIRECT_RELAY_INGEST_SCOPE_KEY, 4160 relay.relay_url.as_str(), 4161 cursor_since_unix_seconds, 4162 relay.last_event_created_at_unix_seconds, 4163 started_at, 4164 started_unix_seconds, 4165 completed_at, 4166 completed_unix_seconds, 4167 )?; 4168 } 4169 for failure in &receipt.failed_relays { 4170 sqlite_store.record_relay_ingest_failure( 4171 APP_DIRECT_RELAY_INGEST_SCOPE_KEY, 4172 failure.relay_url.as_str(), 4173 started_at, 4174 started_unix_seconds, 4175 completed_at, 4176 completed_unix_seconds, 4177 failure.error.as_str(), 4178 )?; 4179 } 4180 4181 Ok(()) 4182 } 4183 4184 fn ingest_configured_relay_events_for_sync( 4185 &mut self, 4186 mut result: Option<&mut AppSyncResult>, 4187 started_at: &str, 4188 ) -> Result<bool, AppSqliteError> { 4189 if !self.has_configured_relay_ingest() { 4190 return Ok(false); 4191 } 4192 match self.ingest_configured_relay_events() { 4193 Ok(report) => { 4194 if let Some(result) = result.as_mut() { 4195 result.pulled_record_count = result 4196 .pulled_record_count 4197 .saturating_add(report.local_import.scanned_records as usize); 4198 } 4199 Ok(report.freshness_changed 4200 || report.local_import.imported_records > 0 4201 || report.local_import.skipped_records > 0) 4202 } 4203 Err(AppDirectRelayIngestError::Sqlite(error)) => Err(error), 4204 Err(AppDirectRelayIngestError::Transport(error)) => { 4205 if let Some(result) = result.as_mut() { 4206 result.run_status = AppSyncRunStatus::Failed; 4207 result.checkpoint = SyncCheckpointStatus::failed( 4208 Some(started_at.to_owned()), 4209 Some(current_utc_timestamp()), 4210 result.checkpoint.last_remote_cursor.clone(), 4211 error.to_string(), 4212 ); 4213 } 4214 Ok(true) 4215 } 4216 } 4217 } 4218 4219 fn prepare_sync_request( 4220 &self, 4221 trigger: SyncTrigger, 4222 ) -> Result<Option<DesktopPreparedSyncRequest>, AppSqliteError> { 4223 let Some(sqlite_store) = self.sqlite_store.as_ref() else { 4224 return Ok(None); 4225 }; 4226 let Some(selected_account) = self 4227 .state_store 4228 .identity_projection() 4229 .selected_account 4230 .as_ref() 4231 else { 4232 return Ok(None); 4233 }; 4234 4235 let account_id = selected_account.account.account_id.clone(); 4236 let checkpoint = sqlite_store.load_sync_checkpoint(account_id.as_str())?; 4237 let conflicts = sqlite_store.load_sync_conflicts(account_id.as_str())?; 4238 let pending_operations = sqlite_store.load_pending_sync_operations(account_id.as_str())?; 4239 4240 if conflicts.iter().any(|stored| { 4241 stored.conflict.is_unresolved() 4242 && matches!(stored.conflict.severity, SyncConflictSeverity::Blocking) 4243 }) { 4244 return Ok(None); 4245 } 4246 4247 if !matches!(trigger, SyncTrigger::ManualRefresh) 4248 && !self.has_configured_relay_ingest() 4249 && !self.has_sync_eligible_runtime_state(&checkpoint, &conflicts, &pending_operations) 4250 { 4251 return Ok(None); 4252 } 4253 4254 Ok(Some(DesktopPreparedSyncRequest { 4255 account_id, 4256 checkpoint, 4257 conflicts, 4258 pending_operations, 4259 })) 4260 } 4261 4262 fn has_sync_eligible_runtime_state( 4263 &self, 4264 checkpoint: &SyncCheckpointStatus, 4265 conflicts: &[StoredSyncConflict], 4266 pending_operations: &[StoredPendingSyncOperation], 4267 ) -> bool { 4268 !pending_operations.is_empty() 4269 || !conflicts.is_empty() 4270 || *checkpoint != SyncCheckpointStatus::never_synced() 4271 || self.selected_farm_id().is_some() 4272 || !self 4273 .state_store 4274 .personal_projection() 4275 .orders 4276 .list 4277 .rows 4278 .is_empty() 4279 || !self.state_store.orders_projection().list.rows.is_empty() 4280 || !self.state_store.products_projection().list.rows.is_empty() 4281 } 4282 4283 fn apply_sync_result( 4284 &mut self, 4285 account_id: &str, 4286 pending_operations: &[StoredPendingSyncOperation], 4287 result: &AppSyncResult, 4288 ) -> Result<bool, AppSqliteError> { 4289 self.record_published_sync_receipts(result.published_receipts.as_slice())?; 4290 let receipt_import_changed = if result.published_receipts.is_empty() { 4291 false 4292 } else { 4293 let report = self.import_shared_local_events()?; 4294 report.imported_records > 0 || report.skipped_records > 0 4295 }; 4296 { 4297 let Some(sqlite_store) = self.sqlite_store.as_ref() else { 4298 return Ok(false); 4299 }; 4300 sqlite_store.save_sync_checkpoint(account_id, &result.checkpoint)?; 4301 sqlite_store.replace_sync_conflicts(account_id, &result.conflicts)?; 4302 4303 for pending in pending_operations 4304 .iter() 4305 .take(result.pushed_operation_count) 4306 { 4307 let _ = sqlite_store 4308 .dequeue_pending_sync_operation(account_id, pending.operation_id.as_str())?; 4309 } 4310 if result.run_status == AppSyncRunStatus::Failed { 4311 let retry_available_at = result 4312 .checkpoint 4313 .last_sync_completed_at 4314 .clone() 4315 .unwrap_or_else(current_utc_timestamp); 4316 let last_error_message = result.checkpoint.last_error_message.as_deref(); 4317 for pending in pending_operations 4318 .iter() 4319 .skip(result.pushed_operation_count) 4320 { 4321 let _ = sqlite_store.update_pending_sync_operation_retry( 4322 account_id, 4323 pending.operation_id.as_str(), 4324 retry_available_at.as_str(), 4325 pending.operation.attempt_count.saturating_add(1), 4326 last_error_message, 4327 )?; 4328 } 4329 } 4330 } 4331 4332 let sync_changed = self.refresh_selected_account_sync()?; 4333 Ok(receipt_import_changed || sync_changed) 4334 } 4335 4336 fn apply_sync_transport_error( 4337 &mut self, 4338 account_id: &str, 4339 previous_checkpoint: &SyncCheckpointStatus, 4340 pending_operations: &[StoredPendingSyncOperation], 4341 started_at: &str, 4342 error: AppSyncTransportError, 4343 ) -> Result<bool, AppSqliteError> { 4344 let error_message = error.to_string(); 4345 let failed_checkpoint = SyncCheckpointStatus::failed( 4346 Some(started_at.to_owned()), 4347 previous_checkpoint.last_sync_completed_at.clone(), 4348 previous_checkpoint.last_remote_cursor.clone(), 4349 error_message.clone(), 4350 ); 4351 { 4352 let Some(sqlite_store) = self.sqlite_store.as_ref() else { 4353 return Ok(false); 4354 }; 4355 sqlite_store.save_sync_checkpoint(account_id, &failed_checkpoint)?; 4356 4357 for pending in pending_operations { 4358 let _ = sqlite_store.update_pending_sync_operation_retry( 4359 account_id, 4360 pending.operation_id.as_str(), 4361 started_at, 4362 pending.operation.attempt_count.saturating_add(1), 4363 Some(error_message.as_str()), 4364 )?; 4365 } 4366 } 4367 4368 self.refresh_selected_account_sync() 4369 } 4370 4371 #[cfg(test)] 4372 fn enqueue_selected_account_sync_operations( 4373 &mut self, 4374 operations: Vec<PendingSyncOperation>, 4375 ) -> Result<bool, AppSqliteError> { 4376 if operations.is_empty() { 4377 return Ok(false); 4378 } 4379 4380 let Some(selected_account) = self 4381 .state_store 4382 .identity_projection() 4383 .selected_account 4384 .as_ref() 4385 else { 4386 return Ok(false); 4387 }; 4388 { 4389 let Some(sqlite_store) = self.sqlite_store.as_ref() else { 4390 return Ok(false); 4391 }; 4392 for operation in &operations { 4393 let _ = sqlite_store.enqueue_pending_sync_operation( 4394 selected_account.account.account_id.as_str(), 4395 operation, 4396 )?; 4397 } 4398 } 4399 4400 self.refresh_selected_account_sync() 4401 } 4402 4403 fn enqueue_selected_account_order_sync_operation( 4404 &mut self, 4405 buyer_context: &BuyerContext, 4406 order: &BuyerOrderLocalEventExport, 4407 local_work: Option<&AppOrderLocalWorkPublishSource>, 4408 ) -> Result<bool, AppSqliteError> { 4409 let Some(payload) = self.order_request_publish_payload(buyer_context, order, local_work)? 4410 else { 4411 return self.refresh_selected_account_sync(); 4412 }; 4413 4414 let source_record_id = payload 4415 .context 4416 .source_local_event_id 4417 .clone() 4418 .unwrap_or_else(|| format!("app:order_request:{}", payload.order_id)); 4419 self.enqueue_order_request_payload_via_sdk( 4420 &payload, 4421 AppSdkMigrationReceiptSourceKind::SharedLocalEvent, 4422 source_record_id.as_str(), 4423 )?; 4424 self.refresh_selected_account_sync() 4425 } 4426 4427 fn enqueue_selected_account_farm_publish_operation( 4428 &mut self, 4429 farm_id: FarmId, 4430 display_name: &str, 4431 readiness: Option<FarmReadiness>, 4432 source: &str, 4433 source_local_event_id: Option<&str>, 4434 ) -> Result<bool, AppSqliteError> { 4435 let Some(payload) = self.farm_profile_publish_payload( 4436 farm_id, 4437 display_name, 4438 readiness, 4439 source, 4440 source_local_event_id, 4441 )? 4442 else { 4443 return self.refresh_selected_account_sync(); 4444 }; 4445 4446 let (source_kind, source_record_id) = farm_publish_source_record( 4447 farm_id, 4448 source, 4449 payload.context.source_local_event_id.as_deref(), 4450 ); 4451 self.enqueue_farm_profile_payload_via_sdk( 4452 &payload, 4453 source_kind, 4454 source_record_id.as_str(), 4455 )?; 4456 self.refresh_selected_account_sync() 4457 } 4458 4459 fn enqueue_selected_account_product_publish_operation( 4460 &mut self, 4461 product_id: ProductId, 4462 source: &str, 4463 source_local_event_id: Option<&str>, 4464 ) -> Result<bool, DesktopAppRuntimeProductPublishError> { 4465 let Some(payload) = 4466 self.product_publish_payload(product_id, source, source_local_event_id)? 4467 else { 4468 return self 4469 .refresh_selected_account_sync() 4470 .map_err(DesktopAppRuntimeProductPublishError::from); 4471 }; 4472 4473 let (source_kind, source_record_id) = listing_publish_source_record( 4474 product_id, 4475 source, 4476 payload.context.source_local_event_id.as_deref(), 4477 ); 4478 self.enqueue_listing_payload_via_sdk(&payload, source_kind, source_record_id.as_str())?; 4479 let _ = self.refresh_selected_account_sync()?; 4480 Ok(true) 4481 } 4482 4483 fn farm_profile_publish_payload( 4484 &self, 4485 farm_id: FarmId, 4486 display_name: &str, 4487 readiness: Option<FarmReadiness>, 4488 source: &str, 4489 source_local_event_id: Option<&str>, 4490 ) -> Result<Option<AppFarmProfilePublishPayload>, AppSqliteError> { 4491 let Some(selected_account) = self 4492 .state_store 4493 .identity_projection() 4494 .selected_account 4495 .as_ref() 4496 else { 4497 return Ok(None); 4498 }; 4499 let mut context = AppPublishContext::new( 4500 selected_account.account.account_id.clone(), 4501 source.to_owned(), 4502 ); 4503 if let Some(source_local_event_id) = source_local_event_id { 4504 context = context.with_source_local_event_id(source_local_event_id.to_owned()); 4505 } 4506 let payload = AppFarmProfilePublishPayload { 4507 context, 4508 farm_id, 4509 display_name: display_name.trim().to_owned(), 4510 readiness, 4511 }; 4512 if AppPublishPayload::FarmProfile(payload.clone()) 4513 .validate() 4514 .is_err() 4515 { 4516 return Ok(None); 4517 } 4518 4519 Ok(Some(payload)) 4520 } 4521 4522 fn product_publish_payload( 4523 &self, 4524 product_id: ProductId, 4525 source: &str, 4526 source_local_event_id: Option<&str>, 4527 ) -> Result<Option<AppListingPublishPayload>, AppSqliteError> { 4528 let Some(sqlite_store) = self.sqlite_store.as_ref() else { 4529 return Ok(None); 4530 }; 4531 let Some(selected_account) = self 4532 .state_store 4533 .identity_projection() 4534 .selected_account 4535 .as_ref() 4536 else { 4537 return Ok(None); 4538 }; 4539 let Some(draft) = sqlite_store.load_product_editor_draft(product_id)? else { 4540 return Ok(None); 4541 }; 4542 if !product_status_needs_relay_publish(draft.status) { 4543 return Ok(None); 4544 } 4545 let farm_rules = self.state_store.farm_rules_projection(); 4546 if !derive_product_publish_blockers( 4547 &draft, 4548 self.state_store.farm_readiness_projection(), 4549 farm_rules, 4550 ) 4551 .is_empty() 4552 { 4553 return Ok(None); 4554 } 4555 let Some(farm_id) = self.selected_farm_id() else { 4556 return Ok(None); 4557 }; 4558 let Some(farm_pubkey) = self.local_events_owner_pubkey(selected_account) else { 4559 return Ok(None); 4560 }; 4561 let farm_setup = self.state_store.farm_setup_projection(); 4562 let (availability_starts_at, availability_ends_at) = 4563 listing_availability_window_times(&draft, farm_rules); 4564 let listing_d_tag = d_tag_from_uuid(product_id.as_uuid()); 4565 let mut context = AppPublishContext::new( 4566 selected_account.account.account_id.clone(), 4567 source.to_owned(), 4568 ); 4569 if let Some(source_local_event_id) = source_local_event_id { 4570 context = context.with_source_local_event_id(source_local_event_id.to_owned()); 4571 } 4572 let payload = AppListingPublishPayload { 4573 context, 4574 product_id, 4575 listing_d_tag: Some(listing_d_tag), 4576 farm_id: Some(farm_id), 4577 farm_pubkey: Some(farm_pubkey), 4578 farm_d_tag: Some(d_tag_from_uuid(farm_id.as_uuid())), 4579 title: draft.title.trim().to_owned(), 4580 subtitle: non_empty_string(draft.subtitle.as_str()), 4581 category: non_empty_string(draft.category.as_str()), 4582 unit_label: draft.unit_label.trim().to_owned(), 4583 price_minor_units: draft.price_minor_units, 4584 price_currency: draft.price_currency.trim().to_uppercase(), 4585 stock_quantity: draft.stock_quantity, 4586 availability_window_id: draft.availability_window_id, 4587 availability_starts_at, 4588 availability_ends_at, 4589 fulfillment_method: listing_fulfillment_method(&draft, farm_setup, farm_rules), 4590 fulfillment_location: listing_fulfillment_location(&draft, farm_setup, farm_rules), 4591 status: draft.status, 4592 }; 4593 if AppPublishPayload::Listing(payload.clone()) 4594 .validate() 4595 .is_err() 4596 { 4597 return Ok(None); 4598 } 4599 4600 Ok(Some(payload)) 4601 } 4602 4603 fn order_request_publish_payload( 4604 &self, 4605 buyer_context: &BuyerContext, 4606 order: &BuyerOrderLocalEventExport, 4607 local_work: Option<&AppOrderLocalWorkPublishSource>, 4608 ) -> Result<Option<AppOrderRequestPublishPayload>, AppSqliteError> { 4609 let Some(local_work) = local_work else { 4610 return Ok(None); 4611 }; 4612 let Some(buyer_account) = self.selected_buyer_account(buyer_context) else { 4613 return Ok(None); 4614 }; 4615 let buyer_pubkey = self.local_events_owner_pubkey(buyer_account); 4616 let export = AppBuyerOrderRequestExport::from_order(order, buyer_pubkey.as_deref())?; 4617 if !export.is_supported() { 4618 return Ok(None); 4619 } 4620 let Some((currency_code, total_minor_units)) = order_currency_and_total(order)? else { 4621 return Ok(None); 4622 }; 4623 let context = AppPublishContext::new( 4624 buyer_account.account.account_id.clone(), 4625 "place_personal_order", 4626 ) 4627 .with_source_local_event_id(local_work.record_id.clone()); 4628 let payload = AppOrderRequestPublishPayload { 4629 context, 4630 order_id: order.order_id, 4631 farm_id: order.farm_id, 4632 status: Some(order.status.clone()), 4633 order_document_json: Some(local_work.payload.clone()), 4634 listing_addr: export.listing_addr, 4635 listing_event_id: export.listing_event_id, 4636 listing_relays: export.listing_relays, 4637 buyer_pubkey: export.buyer_pubkey, 4638 seller_pubkey: export.seller_pubkey, 4639 items: order 4640 .lines 4641 .iter() 4642 .map(|line| AppOrderRequestItemPayload { 4643 product_id: line.product_id, 4644 quantity: line.quantity, 4645 }) 4646 .collect(), 4647 currency_code: Some(currency_code), 4648 total_minor_units: Some(total_minor_units), 4649 note: non_empty_string(order.buyer_order_note.as_str()), 4650 }; 4651 if AppPublishPayload::OrderRequest(payload.clone()) 4652 .validate() 4653 .is_err() 4654 { 4655 return Ok(None); 4656 } 4657 4658 Ok(Some(payload)) 4659 } 4660 4661 fn enqueue_farm_profile_payload_via_sdk( 4662 &self, 4663 payload: &AppFarmProfilePublishPayload, 4664 source_kind: AppSdkMigrationReceiptSourceKind, 4665 source_record_id: &str, 4666 ) -> Result<(), AppSqliteError> { 4667 let operation_kind = FARM_PUBLISH_OPERATION_KIND; 4668 let actor_pubkey = self 4669 .local_signing_identity_for_publish_payload(&AppPublishPayload::FarmProfile( 4670 payload.clone(), 4671 )) 4672 .and_then(|identity| { 4673 let actor_pubkey = identity.public_key_hex(); 4674 let request = AppSdkFarmPublishRequest { 4675 actor_account_id: payload.context.account_id.clone(), 4676 actor_pubkey: actor_pubkey.clone(), 4677 signer_keys: identity.into_keys(), 4678 farm: farm_profile_publish_payload_to_sdk_farm(payload), 4679 target_relays: normalized_app_sync_relay_urls(&self.nostr_relay_urls)?, 4680 relay_url_policy: sdk_relay_url_policy_for_targets(&self.nostr_relay_urls), 4681 idempotency_key: Some(sdk_idempotency_key(source_record_id)), 4682 }; 4683 self.enqueue_app_sdk_farm_publish(request) 4684 .map(|receipt| (actor_pubkey, receipt)) 4685 .map_err(sync_transport_error_from_sdk_runtime_error) 4686 }); 4687 match actor_pubkey { 4688 Ok((actor_pubkey, receipt)) => self.record_app_sdk_migration_success( 4689 source_kind, 4690 source_record_id, 4691 operation_kind, 4692 actor_pubkey.as_str(), 4693 &receipt, 4694 ), 4695 Err(error) => self.record_app_sdk_migration_failure( 4696 source_kind, 4697 source_record_id, 4698 operation_kind, 4699 None, 4700 sync_transport_error_detail_json(&error), 4701 ), 4702 } 4703 } 4704 4705 fn enqueue_listing_payload_via_sdk( 4706 &self, 4707 payload: &AppListingPublishPayload, 4708 source_kind: AppSdkMigrationReceiptSourceKind, 4709 source_record_id: &str, 4710 ) -> Result<(), DesktopAppRuntimeProductPublishError> { 4711 let operation_kind = LISTING_PUBLISH_OPERATION_KIND; 4712 let actor_pubkey = self 4713 .local_signing_identity_for_publish_payload(&AppPublishPayload::Listing( 4714 payload.clone(), 4715 )) 4716 .and_then(|identity| { 4717 let actor_pubkey = identity.public_key_hex(); 4718 let request = AppSdkListingPublishRequest { 4719 actor_account_id: payload.context.account_id.clone(), 4720 actor_pubkey: actor_pubkey.clone(), 4721 signer_keys: identity.into_keys(), 4722 listing: listing_publish_payload_to_sdk_listing(payload)?, 4723 target_relays: normalized_app_sync_relay_urls(&self.nostr_relay_urls)?, 4724 relay_url_policy: sdk_relay_url_policy_for_targets(&self.nostr_relay_urls), 4725 idempotency_key: Some(sdk_idempotency_key(source_record_id)), 4726 }; 4727 self.enqueue_app_sdk_listing_publish(request) 4728 .map(|receipt| (actor_pubkey, receipt)) 4729 .map_err(sync_transport_error_from_sdk_runtime_error) 4730 }); 4731 match actor_pubkey { 4732 Ok((actor_pubkey, receipt)) => self 4733 .record_app_sdk_migration_success( 4734 source_kind, 4735 source_record_id, 4736 operation_kind, 4737 actor_pubkey.as_str(), 4738 &receipt, 4739 ) 4740 .map_err(DesktopAppRuntimeProductPublishError::from), 4741 Err(error) => { 4742 self.record_app_sdk_migration_failure( 4743 source_kind, 4744 source_record_id, 4745 operation_kind, 4746 None, 4747 sync_transport_error_detail_json(&error), 4748 )?; 4749 Err(DesktopAppRuntimeProductPublishError::ListingPublishSdkEnqueueFailed) 4750 } 4751 } 4752 } 4753 4754 fn enqueue_order_request_payload_via_sdk( 4755 &self, 4756 payload: &AppOrderRequestPublishPayload, 4757 source_kind: AppSdkMigrationReceiptSourceKind, 4758 source_record_id: &str, 4759 ) -> Result<(), AppSqliteError> { 4760 let operation_kind = ORDER_SUBMIT_OPERATION_KIND; 4761 let actor_pubkey = self 4762 .local_signing_identity_for_publish_payload(&AppPublishPayload::OrderRequest( 4763 payload.clone(), 4764 )) 4765 .and_then(|identity| { 4766 let actor_pubkey = identity.public_key_hex(); 4767 let target_relays = 4768 order_request_sdk_target_relays(payload, self.nostr_relay_urls.as_slice())?; 4769 let request = AppSdkOrderSubmitRequest { 4770 actor_account_id: payload.context.account_id.clone(), 4771 actor_pubkey: actor_pubkey.clone(), 4772 signer_keys: identity.into_keys(), 4773 listing_event: order_request_sdk_listing_event_ptr(payload)?, 4774 order: order_request_publish_payload_to_sdk_order(payload)?, 4775 relay_url_policy: sdk_relay_url_policy_for_targets(target_relays.as_slice()), 4776 target_relays, 4777 idempotency_key: Some(sdk_idempotency_key(source_record_id)), 4778 }; 4779 self.enqueue_app_sdk_order_submit(request) 4780 .map(|receipt| (actor_pubkey, receipt)) 4781 .map_err(sync_transport_error_from_sdk_runtime_error) 4782 }); 4783 match actor_pubkey { 4784 Ok((actor_pubkey, receipt)) => self.record_app_sdk_migration_success( 4785 source_kind, 4786 source_record_id, 4787 operation_kind, 4788 actor_pubkey.as_str(), 4789 &receipt, 4790 ), 4791 Err(error) => self.record_app_sdk_migration_failure( 4792 source_kind, 4793 source_record_id, 4794 operation_kind, 4795 None, 4796 sync_transport_error_detail_json(&error), 4797 ), 4798 } 4799 } 4800 4801 fn enqueue_order_decision_payload_via_sdk( 4802 &self, 4803 payload: &AppOrderDecisionPublishPayload, 4804 source_kind: AppSdkMigrationReceiptSourceKind, 4805 source_record_id: &str, 4806 ) -> Result<(), AppSqliteError> { 4807 let operation_kind = ORDER_DECISION_OPERATION_KIND; 4808 let request_evidence = self.resolve_seller_order_request_evidence(payload.app_order_id)?; 4809 let actor_pubkey = self 4810 .local_signing_identity_for_publish_payload(&AppPublishPayload::OrderDecision( 4811 payload.clone(), 4812 )) 4813 .and_then(|identity| { 4814 let actor_pubkey = identity.public_key_hex(); 4815 let target_relays = normalized_app_sync_relay_urls(&self.nostr_relay_urls)?; 4816 let request = AppSdkOrderDecisionRequest { 4817 actor_account_id: payload.context.account_id.clone(), 4818 actor_pubkey: actor_pubkey.clone(), 4819 signer_keys: identity.into_keys(), 4820 request_event: request_evidence.request_event, 4821 request_event_ptr: order_decision_sdk_request_event_ptr( 4822 payload, 4823 target_relays.as_slice(), 4824 )?, 4825 decision: order_decision_publish_payload_to_sdk_decision(payload)?, 4826 relay_url_policy: sdk_relay_url_policy_for_targets(target_relays.as_slice()), 4827 target_relays, 4828 idempotency_key: Some(sdk_idempotency_key(source_record_id)), 4829 }; 4830 self.enqueue_app_sdk_order_decision(request) 4831 .map(|receipt| (actor_pubkey, receipt)) 4832 .map_err(sync_transport_error_from_sdk_runtime_error) 4833 }); 4834 match actor_pubkey { 4835 Ok((actor_pubkey, receipt)) => self.record_app_sdk_migration_success( 4836 source_kind, 4837 source_record_id, 4838 operation_kind, 4839 actor_pubkey.as_str(), 4840 &receipt, 4841 ), 4842 Err(error) => self.record_app_sdk_migration_failure( 4843 source_kind, 4844 source_record_id, 4845 operation_kind, 4846 None, 4847 sync_transport_error_detail_json(&error), 4848 ), 4849 } 4850 } 4851 4852 fn enqueue_order_revision_proposal_payload_via_sdk( 4853 &self, 4854 payload: &AppOrderRevisionProposalPublishPayload, 4855 source_kind: AppSdkMigrationReceiptSourceKind, 4856 source_record_id: &str, 4857 ) -> Result<(), AppSqliteError> { 4858 let operation_kind = ORDER_REVISION_PROPOSAL_OPERATION_KIND; 4859 let request_evidence = self.resolve_seller_order_request_evidence(payload.app_order_id)?; 4860 let lifecycle = self.resolve_order_lifecycle_evidence(&request_evidence)?; 4861 let actor_pubkey = self 4862 .local_signing_identity_for_publish_payload(&AppPublishPayload::OrderRevisionProposal( 4863 payload.clone(), 4864 )) 4865 .and_then(|identity| { 4866 let actor_pubkey = identity.public_key_hex(); 4867 let target_relays = normalized_app_sync_relay_urls(&self.nostr_relay_urls)?; 4868 let request = AppSdkOrderRevisionProposalRequest { 4869 actor_account_id: payload.context.account_id.clone(), 4870 actor_pubkey: actor_pubkey.clone(), 4871 signer_keys: identity.into_keys(), 4872 evidence_events: lifecycle.evidence_events, 4873 root_event: order_lifecycle_sdk_event_ptr( 4874 payload.request_event_id.as_str(), 4875 target_relays.as_slice(), 4876 "order revision proposal requires request event id", 4877 )?, 4878 previous_event: order_lifecycle_sdk_event_ptr( 4879 payload.prev_event_id.as_str(), 4880 target_relays.as_slice(), 4881 "order revision proposal requires previous event id", 4882 )?, 4883 proposal: order_revision_proposal_publish_payload_to_sdk_revision(payload)?, 4884 relay_url_policy: sdk_relay_url_policy_for_targets(target_relays.as_slice()), 4885 target_relays, 4886 idempotency_key: Some(sdk_idempotency_key(source_record_id)), 4887 }; 4888 self.enqueue_app_sdk_order_revision_proposal(request) 4889 .map(|receipt| (actor_pubkey, receipt)) 4890 .map_err(sync_transport_error_from_sdk_runtime_error) 4891 }); 4892 match actor_pubkey { 4893 Ok((actor_pubkey, receipt)) => self.record_app_sdk_migration_success( 4894 source_kind, 4895 source_record_id, 4896 operation_kind, 4897 actor_pubkey.as_str(), 4898 &receipt, 4899 ), 4900 Err(error) => self.record_app_sdk_migration_failure( 4901 source_kind, 4902 source_record_id, 4903 operation_kind, 4904 None, 4905 sync_transport_error_detail_json(&error), 4906 ), 4907 } 4908 } 4909 4910 fn enqueue_order_revision_decision_payload_via_sdk( 4911 &self, 4912 payload: &AppOrderRevisionDecisionPublishPayload, 4913 source_kind: AppSdkMigrationReceiptSourceKind, 4914 source_record_id: &str, 4915 ) -> Result<(), AppSqliteError> { 4916 let operation_kind = ORDER_REVISION_DECISION_OPERATION_KIND; 4917 let request_evidence = self.resolve_seller_order_request_evidence(payload.app_order_id)?; 4918 let lifecycle = self.resolve_order_lifecycle_evidence(&request_evidence)?; 4919 let actor_pubkey = self 4920 .local_signing_identity_for_publish_payload(&AppPublishPayload::OrderRevisionDecision( 4921 payload.clone(), 4922 )) 4923 .and_then(|identity| { 4924 let actor_pubkey = identity.public_key_hex(); 4925 let target_relays = normalized_app_sync_relay_urls(&self.nostr_relay_urls)?; 4926 let request = AppSdkOrderRevisionDecisionRequest { 4927 actor_account_id: payload.context.account_id.clone(), 4928 actor_pubkey: actor_pubkey.clone(), 4929 signer_keys: identity.into_keys(), 4930 evidence_events: lifecycle.evidence_events, 4931 root_event: order_lifecycle_sdk_event_ptr( 4932 payload.request_event_id.as_str(), 4933 target_relays.as_slice(), 4934 "order revision decision requires request event id", 4935 )?, 4936 previous_event: order_lifecycle_sdk_event_ptr( 4937 payload.prev_event_id.as_str(), 4938 target_relays.as_slice(), 4939 "order revision decision requires previous event id", 4940 )?, 4941 decision: order_revision_decision_publish_payload_to_sdk_revision_decision( 4942 payload, 4943 )?, 4944 relay_url_policy: sdk_relay_url_policy_for_targets(target_relays.as_slice()), 4945 target_relays, 4946 idempotency_key: Some(sdk_idempotency_key(source_record_id)), 4947 }; 4948 self.enqueue_app_sdk_order_revision_decision(request) 4949 .map(|receipt| (actor_pubkey, receipt)) 4950 .map_err(sync_transport_error_from_sdk_runtime_error) 4951 }); 4952 match actor_pubkey { 4953 Ok((actor_pubkey, receipt)) => self.record_app_sdk_migration_success( 4954 source_kind, 4955 source_record_id, 4956 operation_kind, 4957 actor_pubkey.as_str(), 4958 &receipt, 4959 ), 4960 Err(error) => self.record_app_sdk_migration_failure( 4961 source_kind, 4962 source_record_id, 4963 operation_kind, 4964 None, 4965 sync_transport_error_detail_json(&error), 4966 ), 4967 } 4968 } 4969 4970 fn enqueue_order_cancellation_payload_via_sdk( 4971 &self, 4972 payload: &AppOrderCancellationPublishPayload, 4973 source_kind: AppSdkMigrationReceiptSourceKind, 4974 source_record_id: &str, 4975 ) -> Result<(), AppSqliteError> { 4976 let operation_kind = ORDER_CANCELLATION_OPERATION_KIND; 4977 let request_evidence = self.resolve_seller_order_request_evidence(payload.app_order_id)?; 4978 let lifecycle = self.resolve_order_lifecycle_evidence(&request_evidence)?; 4979 let actor_pubkey = self 4980 .local_signing_identity_for_publish_payload(&AppPublishPayload::OrderCancellation( 4981 payload.clone(), 4982 )) 4983 .and_then(|identity| { 4984 let actor_pubkey = identity.public_key_hex(); 4985 let target_relays = normalized_app_sync_relay_urls(&self.nostr_relay_urls)?; 4986 let request = AppSdkOrderCancellationRequest { 4987 actor_account_id: payload.context.account_id.clone(), 4988 actor_pubkey: actor_pubkey.clone(), 4989 signer_keys: identity.into_keys(), 4990 evidence_events: lifecycle.evidence_events, 4991 root_event: order_lifecycle_sdk_event_ptr( 4992 payload.request_event_id.as_str(), 4993 target_relays.as_slice(), 4994 "order cancellation requires request event id", 4995 )?, 4996 previous_event: order_lifecycle_sdk_event_ptr( 4997 payload.prev_event_id.as_str(), 4998 target_relays.as_slice(), 4999 "order cancellation requires previous event id", 5000 )?, 5001 cancellation: order_cancellation_publish_payload_to_sdk_cancellation(payload)?, 5002 relay_url_policy: sdk_relay_url_policy_for_targets(target_relays.as_slice()), 5003 target_relays, 5004 idempotency_key: Some(sdk_idempotency_key(source_record_id)), 5005 }; 5006 self.enqueue_app_sdk_order_cancellation(request) 5007 .map(|receipt| (actor_pubkey, receipt)) 5008 .map_err(sync_transport_error_from_sdk_runtime_error) 5009 }); 5010 match actor_pubkey { 5011 Ok((actor_pubkey, receipt)) => self.record_app_sdk_migration_success( 5012 source_kind, 5013 source_record_id, 5014 operation_kind, 5015 actor_pubkey.as_str(), 5016 &receipt, 5017 ), 5018 Err(error) => self.record_app_sdk_migration_failure( 5019 source_kind, 5020 source_record_id, 5021 operation_kind, 5022 None, 5023 sync_transport_error_detail_json(&error), 5024 ), 5025 } 5026 } 5027 5028 fn local_signing_identity_for_publish_payload( 5029 &self, 5030 payload: &AppPublishPayload, 5031 ) -> Result<RadrootsIdentity, AppSyncTransportError> { 5032 let accounts_manager = self.accounts_manager.as_ref().ok_or_else(|| { 5033 AppSyncTransportError::unavailable("app account manager is not configured") 5034 })?; 5035 signing_identity_for_publish_payload(accounts_manager, payload) 5036 } 5037 5038 fn enqueue_app_sdk_farm_publish( 5039 &self, 5040 request: AppSdkFarmPublishRequest, 5041 ) -> Result<AppSdkWorkflowReceipt, AppSdkRuntimeError> { 5042 self.with_app_sdk_runtime(|runtime| runtime.enqueue_farm_publish(request)) 5043 } 5044 5045 fn enqueue_app_sdk_listing_publish( 5046 &self, 5047 request: AppSdkListingPublishRequest, 5048 ) -> Result<AppSdkWorkflowReceipt, AppSdkRuntimeError> { 5049 self.with_app_sdk_runtime(|runtime| runtime.enqueue_listing_publish(request)) 5050 } 5051 5052 fn enqueue_app_sdk_order_submit( 5053 &self, 5054 request: AppSdkOrderSubmitRequest, 5055 ) -> Result<AppSdkWorkflowReceipt, AppSdkRuntimeError> { 5056 self.with_app_sdk_runtime(|runtime| runtime.enqueue_order_submit(request)) 5057 } 5058 5059 fn enqueue_app_sdk_order_decision( 5060 &self, 5061 request: AppSdkOrderDecisionRequest, 5062 ) -> Result<AppSdkWorkflowReceipt, AppSdkRuntimeError> { 5063 self.with_app_sdk_runtime(|runtime| runtime.enqueue_order_decision(request)) 5064 } 5065 5066 fn enqueue_app_sdk_order_revision_proposal( 5067 &self, 5068 request: AppSdkOrderRevisionProposalRequest, 5069 ) -> Result<AppSdkWorkflowReceipt, AppSdkRuntimeError> { 5070 self.with_app_sdk_runtime(|runtime| runtime.enqueue_order_revision_proposal(request)) 5071 } 5072 5073 fn enqueue_app_sdk_order_revision_decision( 5074 &self, 5075 request: AppSdkOrderRevisionDecisionRequest, 5076 ) -> Result<AppSdkWorkflowReceipt, AppSdkRuntimeError> { 5077 self.with_app_sdk_runtime(|runtime| runtime.enqueue_order_revision_decision(request)) 5078 } 5079 5080 fn enqueue_app_sdk_order_cancellation( 5081 &self, 5082 request: AppSdkOrderCancellationRequest, 5083 ) -> Result<AppSdkWorkflowReceipt, AppSdkRuntimeError> { 5084 self.with_app_sdk_runtime(|runtime| runtime.enqueue_order_cancellation(request)) 5085 } 5086 5087 fn with_app_sdk_runtime<T>( 5088 &self, 5089 command: impl FnOnce(&AppSdkRuntime) -> Result<T, AppSdkRuntimeError>, 5090 ) -> Result<T, AppSdkRuntimeError> { 5091 let Some(handle) = self.sdk_runtime.as_ref() else { 5092 return Err(sdk_runtime_unavailable_error()); 5093 }; 5094 let sdk_runtime = handle.lock().unwrap_or_else(PoisonError::into_inner); 5095 let Some(runtime) = sdk_runtime.as_ref() else { 5096 return Err(sdk_runtime_unavailable_error()); 5097 }; 5098 command(runtime) 5099 } 5100 5101 fn record_app_sdk_migration_success( 5102 &self, 5103 source_kind: AppSdkMigrationReceiptSourceKind, 5104 source_record_id: &str, 5105 operation_kind: &str, 5106 actor_pubkey: &str, 5107 receipt: &AppSdkWorkflowReceipt, 5108 ) -> Result<(), AppSqliteError> { 5109 let detail_json = json!({ 5110 "operation_kind": receipt.operation_kind, 5111 "expected_event_id": receipt.expected_event_id, 5112 "signed_event_id": receipt.signed_event_id, 5113 "outbox_operation_id": receipt.outbox_operation_id, 5114 "outbox_event_id": receipt.outbox_event_id, 5115 "state": receipt.state, 5116 }); 5117 self.record_app_sdk_migration_receipt(AppSdkMigrationReceiptInput { 5118 source_kind, 5119 source_record_id: source_record_id.to_owned(), 5120 sdk_operation_kind: operation_kind.to_owned(), 5121 sdk_outbox_event_ids: vec![receipt.outbox_event_id.to_string()], 5122 expected_event_id: Some(receipt.expected_event_id.clone()), 5123 actor_pubkey: Some(actor_pubkey.to_owned()), 5124 idempotency_digest_prefix: receipt.idempotency_digest_prefix.clone(), 5125 migration_state: AppSdkMigrationState::Enqueued, 5126 recorded_at: current_utc_timestamp(), 5127 detail_json, 5128 }) 5129 } 5130 5131 fn record_app_sdk_migration_failure( 5132 &self, 5133 source_kind: AppSdkMigrationReceiptSourceKind, 5134 source_record_id: &str, 5135 operation_kind: &str, 5136 actor_pubkey: Option<&str>, 5137 detail_json: serde_json::Value, 5138 ) -> Result<(), AppSqliteError> { 5139 self.record_app_sdk_migration_receipt(AppSdkMigrationReceiptInput { 5140 source_kind, 5141 source_record_id: source_record_id.to_owned(), 5142 sdk_operation_kind: operation_kind.to_owned(), 5143 sdk_outbox_event_ids: Vec::new(), 5144 expected_event_id: None, 5145 actor_pubkey: actor_pubkey.map(str::to_owned), 5146 idempotency_digest_prefix: None, 5147 migration_state: AppSdkMigrationState::Failed, 5148 recorded_at: current_utc_timestamp(), 5149 detail_json, 5150 }) 5151 } 5152 5153 fn record_app_sdk_migration_receipt( 5154 &self, 5155 input: AppSdkMigrationReceiptInput, 5156 ) -> Result<(), AppSqliteError> { 5157 let Some(sqlite_store) = self.sqlite_store.as_ref() else { 5158 return Ok(()); 5159 }; 5160 let _ = sqlite_store 5161 .sdk_migration_receipt_repository() 5162 .record_receipt(&input)?; 5163 Ok(()) 5164 } 5165 5166 fn selected_account_id(&self) -> Result<String, DesktopAppRuntimeFarmSetupError> { 5167 self.selected_account_for_farm_setup() 5168 .map(|account| account.account.account_id.clone()) 5169 } 5170 5171 fn selected_account_id_for_farm_rules( 5172 &self, 5173 ) -> Result<String, DesktopAppRuntimeFarmRulesError> { 5174 self.selected_account_for_farm_rules() 5175 .map(|account| account.account.account_id.clone()) 5176 } 5177 5178 fn selected_account_for_farm_setup( 5179 &self, 5180 ) -> Result<&radroots_app_view::SelectedAccountProjection, DesktopAppRuntimeFarmSetupError> 5181 { 5182 self.state_store 5183 .identity_projection() 5184 .selected_account 5185 .as_ref() 5186 .ok_or(DesktopAppRuntimeFarmSetupError::AccountRequired) 5187 } 5188 5189 fn selected_account_for_farm_rules( 5190 &self, 5191 ) -> Result<&radroots_app_view::SelectedAccountProjection, DesktopAppRuntimeFarmRulesError> 5192 { 5193 self.state_store 5194 .identity_projection() 5195 .selected_account 5196 .as_ref() 5197 .ok_or(DesktopAppRuntimeFarmRulesError::AccountRequired) 5198 } 5199 5200 fn accounts_manager( 5201 &self, 5202 ) -> Result<&RadrootsNostrAccountsManager, DesktopAppRuntimeCommandError> { 5203 self.accounts_manager 5204 .as_ref() 5205 .ok_or_else(|| self.command_unavailable_error()) 5206 } 5207 5208 fn sqlite_store(&self) -> Result<&AppSqliteStore, DesktopAppRuntimeCommandError> { 5209 self.sqlite_store 5210 .as_ref() 5211 .ok_or(DesktopAppRuntimeCommandError::RuntimeUnavailable) 5212 } 5213 5214 fn sqlite_store_for_farm_setup( 5215 &self, 5216 ) -> Result<&AppSqliteStore, DesktopAppRuntimeFarmSetupError> { 5217 self.sqlite_store 5218 .as_ref() 5219 .ok_or(DesktopAppRuntimeFarmSetupError::RuntimeUnavailable) 5220 } 5221 5222 fn sqlite_store_for_farm_rules( 5223 &self, 5224 ) -> Result<&AppSqliteStore, DesktopAppRuntimeFarmRulesError> { 5225 self.sqlite_store 5226 .as_ref() 5227 .ok_or(DesktopAppRuntimeFarmRulesError::RuntimeUnavailable) 5228 } 5229 5230 fn mutate_personal_projection( 5231 &mut self, 5232 mutator: impl FnOnce(&mut PersonalWorkspaceProjection) -> bool, 5233 ) -> bool { 5234 let mut projection = self.state_store.personal_projection().clone(); 5235 if !mutator(&mut projection) { 5236 return false; 5237 } 5238 5239 self.state_store 5240 .apply_in_memory(AppStateCommand::replace_personal_projection(projection)) 5241 } 5242 5243 fn set_personal_product_detail( 5244 &mut self, 5245 section: PersonalSection, 5246 detail: Option<BuyerProductDetailProjection>, 5247 ) -> bool { 5248 self.mutate_personal_projection(|projection| { 5249 let current_detail = match section { 5250 PersonalSection::Browse => &mut projection.browse.detail, 5251 PersonalSection::Search => &mut projection.search.detail, 5252 PersonalSection::Cart | PersonalSection::Orders => return false, 5253 }; 5254 if *current_detail == detail { 5255 return false; 5256 } 5257 5258 *current_detail = detail; 5259 true 5260 }) 5261 } 5262 5263 fn set_personal_order_detail(&mut self, detail: Option<BuyerOrderDetailProjection>) -> bool { 5264 self.mutate_personal_projection(|projection| { 5265 if projection.orders.detail == detail { 5266 return false; 5267 } 5268 5269 projection.orders.detail = detail; 5270 true 5271 }) 5272 } 5273 5274 fn replace_personal_search_query( 5275 &mut self, 5276 query: BuyerSearchScreenQueryState, 5277 ) -> Result<bool, AppSqliteError> { 5278 let search_listings = self.load_personal_listings_for_query(&query)?; 5279 let mut personal_projection = self.state_store.personal_projection().clone(); 5280 5281 if personal_projection.search.query == query 5282 && personal_projection.search.listings == search_listings 5283 { 5284 return Ok(false); 5285 } 5286 5287 personal_projection.search.query = query; 5288 personal_projection.search.listings = search_listings; 5289 5290 Ok(self 5291 .state_store 5292 .apply_in_memory(AppStateCommand::replace_personal_projection( 5293 personal_projection, 5294 ))) 5295 } 5296 5297 fn load_personal_listings_for_query( 5298 &self, 5299 query: &BuyerSearchScreenQueryState, 5300 ) -> Result<radroots_app_view::BuyerListingsProjection, AppSqliteError> { 5301 let _ = self.import_shared_local_events()?; 5302 let Some(sqlite_store) = self.sqlite_store.as_ref() else { 5303 return Ok(Default::default()); 5304 }; 5305 5306 sqlite_store.load_buyer_listings(&query.search_query, &query.fulfillment_methods) 5307 } 5308 5309 fn refresh_personal_cart_and_order_review( 5310 &mut self, 5311 refreshed_cart: BuyerCartProjection, 5312 refreshed_order_review: radroots_app_view::BuyerOrderReviewProjection, 5313 ) -> bool { 5314 self.mutate_personal_projection(|projection| { 5315 let mut changed = false; 5316 if projection.cart.cart != refreshed_cart { 5317 projection.cart.cart = refreshed_cart.clone(); 5318 changed = true; 5319 } 5320 if projection.cart.order_review != refreshed_order_review { 5321 projection.cart.order_review = refreshed_order_review.clone(); 5322 changed = true; 5323 } 5324 5325 changed 5326 }) 5327 } 5328 5329 fn replace_products_query( 5330 &mut self, 5331 query: ProductsScreenQueryState, 5332 ) -> Result<bool, AppSqliteError> { 5333 let products_list = self.load_products_list_for_query(&query)?; 5334 let query_changed = 5335 self.state_store 5336 .apply_in_memory(AppStateCommand::set_products_search_query( 5337 query.search_query.clone(), 5338 )); 5339 let filter_changed = self 5340 .state_store 5341 .apply_in_memory(AppStateCommand::select_products_filter(query.filter)); 5342 let sort_changed = self 5343 .state_store 5344 .apply_in_memory(AppStateCommand::select_products_sort(query.sort)); 5345 let list_changed = self 5346 .state_store 5347 .apply_in_memory(AppStateCommand::replace_products_list(products_list)); 5348 5349 Ok(query_changed || filter_changed || sort_changed || list_changed) 5350 } 5351 5352 fn selected_farm_id(&self) -> Option<FarmId> { 5353 selected_farm_id_from_context( 5354 self.state_store.identity_projection(), 5355 self.state_store.farm_setup_projection(), 5356 ) 5357 } 5358 5359 fn has_saved_farm(&self) -> bool { 5360 self.state_store.farm_setup_projection().has_saved_farm() 5361 } 5362 5363 fn has_pack_day_context(&self) -> bool { 5364 self.state_store 5365 .pack_day_projection() 5366 .projection 5367 .fulfillment_window 5368 .is_some() 5369 } 5370 5371 fn selected_product_editor_id(&self) -> Option<ProductId> { 5372 match &self.state_store.products_projection().editor { 5373 radroots_app_state::ProductEditorState::Open(session) => session.selected_product_id, 5374 radroots_app_state::ProductEditorState::Closed => None, 5375 } 5376 } 5377 5378 fn selected_order_detail_id(&self) -> Option<OrderId> { 5379 self.state_store 5380 .orders_projection() 5381 .detail 5382 .as_ref() 5383 .map(|detail| detail.order_id) 5384 } 5385 5386 fn continuity_state(&self) -> PersistedAppState { 5387 self.state_store.persisted_state().clone() 5388 } 5389 5390 fn continuity_state_with_order_detail(&self, order_id: Option<OrderId>) -> PersistedAppState { 5391 let mut state = self.continuity_state(); 5392 state.seller.order_detail_order_id = order_id; 5393 state 5394 } 5395 5396 fn continuity_state_with_orders_query( 5397 &self, 5398 query: OrdersScreenQueryState, 5399 order_id: Option<OrderId>, 5400 ) -> PersistedAppState { 5401 let mut state = self.continuity_state(); 5402 state.seller.orders_query = query; 5403 state.seller.order_detail_order_id = order_id; 5404 state 5405 } 5406 5407 fn continuity_state_with_pack_day_query( 5408 &self, 5409 query: PackDayScreenQueryState, 5410 ) -> PersistedAppState { 5411 let mut state = self.continuity_state(); 5412 state.seller.pack_day_query = query; 5413 state 5414 } 5415 5416 fn fallback_farm_profile(&self, farm_id: FarmId) -> FarmProfileRecord { 5417 fallback_farm_profile_for_projection(farm_id, self.state_store.farm_setup_projection()) 5418 } 5419 5420 fn load_products_list_for_query( 5421 &self, 5422 query: &ProductsScreenQueryState, 5423 ) -> Result<ProductsListProjection, AppSqliteError> { 5424 let _ = self.import_shared_local_events()?; 5425 let Some(sqlite_store) = self.sqlite_store.as_ref() else { 5426 return Ok(ProductsListProjection::default()); 5427 }; 5428 let Some(farm_id) = self.selected_farm_id() else { 5429 return Ok(ProductsListProjection::default()); 5430 }; 5431 5432 sqlite_store.load_products(farm_id, &query.search_query, query.filter, query.sort) 5433 } 5434 5435 fn import_shared_local_events(&self) -> Result<AppLocalInteropImportReport, AppSqliteError> { 5436 let Some(sqlite_store) = self.sqlite_store.as_ref() else { 5437 return Ok(AppLocalInteropImportReport::default()); 5438 }; 5439 let Some(shared_accounts_paths) = self.shared_accounts_paths.as_ref() else { 5440 return Ok(AppLocalInteropImportReport::default()); 5441 }; 5442 let Some(database_path) = 5443 shared_local_events_database_path_from_shared_accounts(shared_accounts_paths) 5444 else { 5445 return Ok(AppLocalInteropImportReport::default()); 5446 }; 5447 sqlite_store.import_shared_local_events_from_path(database_path.as_path()) 5448 } 5449 5450 fn resolve_seller_order_request_evidence( 5451 &self, 5452 order_id: OrderId, 5453 ) -> Result<ResolvedAppSellerOrderRequest, AppSqliteError> { 5454 let mut matched_requests = BTreeMap::new(); 5455 self.collect_seller_order_request_evidence_from_shared_events( 5456 &order_id, 5457 &mut matched_requests, 5458 )?; 5459 self.collect_seller_order_request_evidence_from_local_interop( 5460 &order_id, 5461 &mut matched_requests, 5462 )?; 5463 5464 if matched_requests.len() > 1 { 5465 return Err(AppSqliteError::InvalidProjection { 5466 reason: "seller order decision found multiple signed order requests", 5467 }); 5468 } 5469 5470 matched_requests 5471 .into_values() 5472 .next() 5473 .ok_or(AppSqliteError::InvalidProjection { 5474 reason: "seller order decision requires signed order request evidence", 5475 }) 5476 } 5477 5478 fn collect_seller_order_request_evidence_from_shared_events( 5479 &self, 5480 order_id: &OrderId, 5481 matched_requests: &mut BTreeMap<String, ResolvedAppSellerOrderRequest>, 5482 ) -> Result<(), AppSqliteError> { 5483 let store = self.open_shared_local_events_store()?; 5484 let Some(store) = store else { 5485 return Ok(()); 5486 }; 5487 let mut before = None; 5488 5489 loop { 5490 let records = match before { 5491 Some((before_change_seq, before_seq)) => store 5492 .list_records_changed_before( 5493 before_change_seq, 5494 before_seq, 5495 APP_SELLER_ORDER_DECISION_EVIDENCE_PAGE_SIZE, 5496 ) 5497 .map_err(|source| AppSqliteError::LocalEvents { 5498 operation: "load shared order request evidence", 5499 source, 5500 })?, 5501 None => store 5502 .list_records_changed_latest(APP_SELLER_ORDER_DECISION_EVIDENCE_PAGE_SIZE) 5503 .map_err(|source| AppSqliteError::LocalEvents { 5504 operation: "load shared order request evidence", 5505 source, 5506 })?, 5507 }; 5508 if records.is_empty() { 5509 break; 5510 } 5511 let is_last_page = 5512 records.len() < APP_SELLER_ORDER_DECISION_EVIDENCE_PAGE_SIZE as usize; 5513 before = records.last().map(|record| (record.change_seq, record.seq)); 5514 5515 for record in records { 5516 if record.family != LocalRecordFamily::SignedEvent 5517 || record.event_kind 5518 != Some(i64::from( 5519 radroots_sdk::protocol::order::RadrootsOrderEventType::OrderRequested 5520 .kind(), 5521 )) 5522 || !signed_order_request_evidence_record_is_usable(&record) 5523 { 5524 continue; 5525 } 5526 let Some(event) = signed_event_from_local_record(&record)? else { 5527 continue; 5528 }; 5529 let Ok(envelope) = radroots_sdk::protocol::order::parse_order_request(&event) 5530 else { 5531 continue; 5532 }; 5533 insert_seller_order_request_evidence( 5534 order_id, 5535 &event, 5536 envelope.payload, 5537 matched_requests, 5538 ); 5539 } 5540 5541 if before.is_none() || is_last_page { 5542 break; 5543 } 5544 } 5545 5546 Ok(()) 5547 } 5548 5549 fn collect_seller_order_request_evidence_from_local_interop( 5550 &self, 5551 order_id: &OrderId, 5552 matched_requests: &mut BTreeMap<String, ResolvedAppSellerOrderRequest>, 5553 ) -> Result<(), AppSqliteError> { 5554 let Some(sqlite_store) = self.sqlite_store.as_ref() else { 5555 return Ok(()); 5556 }; 5557 let events = sqlite_store.load_local_interop_signed_events_by_kind(i64::from( 5558 radroots_sdk::protocol::order::RadrootsOrderEventType::OrderRequested.kind(), 5559 ))?; 5560 5561 for event in events { 5562 let Ok(envelope) = radroots_sdk::protocol::order::parse_order_request(&event) else { 5563 continue; 5564 }; 5565 insert_seller_order_request_evidence( 5566 order_id, 5567 &event, 5568 envelope.payload, 5569 matched_requests, 5570 ); 5571 } 5572 5573 Ok(()) 5574 } 5575 5576 fn resolve_order_lifecycle_evidence( 5577 &self, 5578 request: &ResolvedAppSellerOrderRequest, 5579 ) -> Result<ResolvedAppOrderLifecycleEvidence, AppSqliteError> { 5580 let mut events = self.collect_order_lifecycle_signed_events()?; 5581 events.sort_by(|left, right| { 5582 left.created_at 5583 .cmp(&right.created_at) 5584 .then_with(|| left.id.cmp(&right.id)) 5585 }); 5586 5587 let mut evidence_events = vec![request.request_event.clone()]; 5588 let mut buckets = AppActiveOrderEvidenceBuckets::default(); 5589 let request_event_id = 5590 active_order_event_id(request.request_event_id.as_str(), "request_event_id")?; 5591 let request_author_pubkey = active_order_pubkey( 5592 request.request_author_pubkey.as_str(), 5593 "request_author_pubkey", 5594 )?; 5595 buckets.requests.push(RadrootsOrderRequestRecord { 5596 event_id: request_event_id.clone(), 5597 author_pubkey: request_author_pubkey, 5598 payload: request.payload.clone(), 5599 }); 5600 for event in events { 5601 if trade_chain_tag_value(&event, "e_root").as_deref() 5602 != Some(request.request_event_id.as_str()) 5603 { 5604 continue; 5605 } 5606 evidence_events.push(event.clone()); 5607 let event_id = active_order_event_id(event.id.as_str(), "event_id")?; 5608 let author_pubkey = active_order_pubkey(event.author.as_str(), "author_pubkey")?; 5609 match event.kind { 5610 KIND_ORDER_DECISION => { 5611 let envelope = radroots_sdk::protocol::order::parse_order_decision(&event) 5612 .map_err(|_| AppSqliteError::InvalidProjection { 5613 reason: "order lifecycle evidence is invalid", 5614 })?; 5615 let context = active_order_event_record_context(&event, envelope.message_type)?; 5616 buckets.decisions.push(RadrootsOrderDecisionRecord { 5617 event_id, 5618 author_pubkey, 5619 counterparty_pubkey: context.0, 5620 root_event_id: context.1, 5621 prev_event_id: context.2, 5622 payload: envelope.payload, 5623 }); 5624 } 5625 KIND_ORDER_REVISION_PROPOSAL => { 5626 let Ok(envelope) = 5627 radroots_sdk::protocol::order::parse_order_revision_proposal(&event) 5628 else { 5629 return Err(AppSqliteError::InvalidProjection { 5630 reason: "order lifecycle evidence is invalid", 5631 }); 5632 }; 5633 let context = active_order_event_record_context(&event, envelope.message_type)?; 5634 buckets 5635 .revision_proposals 5636 .push(RadrootsOrderRevisionProposalRecord { 5637 event_id, 5638 author_pubkey, 5639 counterparty_pubkey: context.0, 5640 root_event_id: context.1, 5641 prev_event_id: context.2, 5642 payload: envelope.payload, 5643 }); 5644 } 5645 KIND_ORDER_REVISION_DECISION => { 5646 let Ok(envelope) = 5647 radroots_sdk::protocol::order::parse_order_revision_decision(&event) 5648 else { 5649 return Err(AppSqliteError::InvalidProjection { 5650 reason: "order lifecycle evidence is invalid", 5651 }); 5652 }; 5653 let context = active_order_event_record_context(&event, envelope.message_type)?; 5654 buckets 5655 .revision_decisions 5656 .push(RadrootsOrderRevisionDecisionRecord { 5657 event_id, 5658 author_pubkey, 5659 counterparty_pubkey: context.0, 5660 root_event_id: context.1, 5661 prev_event_id: context.2, 5662 payload: envelope.payload, 5663 }); 5664 } 5665 KIND_ORDER_CANCELLATION => { 5666 let Ok(envelope) = 5667 radroots_sdk::protocol::order::parse_order_cancellation(&event) 5668 else { 5669 return Err(AppSqliteError::InvalidProjection { 5670 reason: "order lifecycle evidence is invalid", 5671 }); 5672 }; 5673 let context = active_order_event_record_context(&event, envelope.message_type)?; 5674 buckets.cancellations.push(RadrootsOrderCancellationRecord { 5675 event_id, 5676 author_pubkey, 5677 counterparty_pubkey: context.0, 5678 root_event_id: context.1, 5679 prev_event_id: context.2, 5680 payload: envelope.payload, 5681 }); 5682 } 5683 _ => {} 5684 } 5685 } 5686 5687 let projection = reduce_order_events( 5688 &request.payload.order_id, 5689 RadrootsOrderReductionInputs { 5690 requests: buckets.requests.clone(), 5691 decisions: buckets.decisions.clone(), 5692 revision_proposals: buckets.revision_proposals.clone(), 5693 revision_decisions: buckets.revision_decisions.clone(), 5694 cancellations: buckets.cancellations.clone(), 5695 }, 5696 ); 5697 if !projection.issues.is_empty() || projection.status == RadrootsOrderStatus::Invalid { 5698 return Err(AppSqliteError::InvalidProjection { 5699 reason: "order lifecycle evidence is invalid", 5700 }); 5701 } 5702 if projection.request_event_id.as_ref() != Some(&request_event_id) { 5703 return Err(AppSqliteError::InvalidProjection { 5704 reason: "order lifecycle evidence is invalid", 5705 }); 5706 } 5707 5708 let decision = projection 5709 .decision_event_id 5710 .as_ref() 5711 .map(|event_id| { 5712 buckets 5713 .decisions 5714 .iter() 5715 .find(|decision| decision.event_id == *event_id) 5716 .map(|decision| ResolvedAppOrderDecisionEvidence { 5717 event_id: decision.event_id.to_string(), 5718 payload: decision.payload.clone(), 5719 }) 5720 .ok_or(AppSqliteError::InvalidProjection { 5721 reason: "order lifecycle evidence is invalid", 5722 }) 5723 }) 5724 .transpose()?; 5725 Ok(ResolvedAppOrderLifecycleEvidence { 5726 evidence_events, 5727 request_event_id: request.request_event_id.clone(), 5728 status: projection.status, 5729 agreement_event_id: projection 5730 .agreement_event_id 5731 .map(|event_id| event_id.to_string()), 5732 last_event_id: projection 5733 .last_event_id 5734 .map(|event_id| event_id.to_string()), 5735 decision, 5736 revision_proposals: buckets 5737 .revision_proposals 5738 .into_iter() 5739 .map(|proposal| ResolvedAppOrderRevisionProposalEvidence { 5740 event_id: proposal.event_id.to_string(), 5741 payload: proposal.payload, 5742 }) 5743 .collect(), 5744 revision_decisions: buckets 5745 .revision_decisions 5746 .into_iter() 5747 .map(|decision| ResolvedAppOrderRevisionDecisionEvidence { 5748 event_id: decision.event_id.to_string(), 5749 payload: decision.payload, 5750 }) 5751 .collect(), 5752 cancellation_event_id: projection 5753 .cancellation_event_id 5754 .map(|event_id| event_id.to_string()), 5755 }) 5756 } 5757 5758 fn collect_order_lifecycle_signed_events( 5759 &self, 5760 ) -> Result<Vec<SdkRadrootsNostrEvent>, AppSqliteError> { 5761 let mut events = Vec::new(); 5762 let mut seen_event_ids = BTreeSet::new(); 5763 let kinds = [ 5764 KIND_ORDER_DECISION, 5765 KIND_ORDER_REVISION_PROPOSAL, 5766 KIND_ORDER_REVISION_DECISION, 5767 KIND_ORDER_CANCELLATION, 5768 ]; 5769 5770 if let Some(sqlite_store) = self.sqlite_store.as_ref() { 5771 for kind in kinds { 5772 for event in 5773 sqlite_store.load_local_interop_signed_events_by_kind(i64::from(kind))? 5774 { 5775 if seen_event_ids.insert(event.id.clone()) { 5776 events.push(event); 5777 } 5778 } 5779 } 5780 } 5781 5782 let Some(store) = self.open_shared_local_events_store()? else { 5783 return Ok(events); 5784 }; 5785 let mut before = None; 5786 loop { 5787 let records = match before { 5788 Some((before_change_seq, before_seq)) => store 5789 .list_records_changed_before( 5790 before_change_seq, 5791 before_seq, 5792 APP_SELLER_ORDER_DECISION_EVIDENCE_PAGE_SIZE, 5793 ) 5794 .map_err(|source| AppSqliteError::LocalEvents { 5795 operation: "load shared order lifecycle evidence", 5796 source, 5797 })?, 5798 None => store 5799 .list_records_changed_latest(APP_SELLER_ORDER_DECISION_EVIDENCE_PAGE_SIZE) 5800 .map_err(|source| AppSqliteError::LocalEvents { 5801 operation: "load shared order lifecycle evidence", 5802 source, 5803 })?, 5804 }; 5805 if records.is_empty() { 5806 break; 5807 } 5808 let is_last_page = 5809 records.len() < APP_SELLER_ORDER_DECISION_EVIDENCE_PAGE_SIZE as usize; 5810 before = records.last().map(|record| (record.change_seq, record.seq)); 5811 5812 for record in records { 5813 let Some(kind) = record.event_kind else { 5814 continue; 5815 }; 5816 if record.family != LocalRecordFamily::SignedEvent 5817 || !kinds.contains(&u32::try_from(kind).unwrap_or_default()) 5818 || !signed_order_request_evidence_record_is_usable(&record) 5819 { 5820 continue; 5821 } 5822 let Some(event) = signed_event_from_local_record(&record)? else { 5823 continue; 5824 }; 5825 if seen_event_ids.insert(event.id.clone()) { 5826 events.push(event); 5827 } 5828 } 5829 5830 if before.is_none() || is_last_page { 5831 break; 5832 } 5833 } 5834 5835 Ok(events) 5836 } 5837 5838 fn open_shared_local_events_store( 5839 &self, 5840 ) -> Result<Option<LocalEventsStore<SqliteExecutor>>, AppSqliteError> { 5841 let Some(shared_accounts_paths) = self.shared_accounts_paths.as_ref() else { 5842 return Ok(None); 5843 }; 5844 let Some(database_path) = 5845 shared_local_events_database_path_from_shared_accounts(shared_accounts_paths) 5846 else { 5847 return Ok(None); 5848 }; 5849 if let Some(parent) = database_path.parent() { 5850 fs::create_dir_all(parent).map_err(|source| AppSqliteError::CreateParentDirectory { 5851 path: parent.to_path_buf(), 5852 source, 5853 })?; 5854 } 5855 let executor = SqliteExecutor::open(database_path.as_path()).map_err(|source| { 5856 AppSqliteError::LocalEventsSql { 5857 operation: "open shared local events database", 5858 source, 5859 } 5860 })?; 5861 let store = LocalEventsStore::new(executor); 5862 store 5863 .migrate_up() 5864 .map_err(|source| AppSqliteError::LocalEventsSql { 5865 operation: "migrate shared local events database", 5866 source, 5867 })?; 5868 5869 Ok(Some(store)) 5870 } 5871 5872 fn append_app_farm_local_work_record( 5873 &self, 5874 account: &radroots_app_view::SelectedAccountProjection, 5875 projection: &FarmSetupProjection, 5876 saved_farm: &FarmSummary, 5877 ) -> Result<Option<String>, AppSqliteError> { 5878 let Some(shared_accounts_paths) = self.shared_accounts_paths.as_ref() else { 5879 return Ok(None); 5880 }; 5881 let timestamp = current_runtime_time_ms()?; 5882 let farm_d_tag = d_tag_from_uuid(saved_farm.farm_id.as_uuid()); 5883 let owner_pubkey = self.local_events_owner_pubkey(account); 5884 let exportability = local_work_exportability(owner_pubkey.as_deref()); 5885 let delivery_method = projection 5886 .draft 5887 .order_methods 5888 .iter() 5889 .next() 5890 .map(|method| method.storage_key()); 5891 let payload = json!({ 5892 "record_kind": "farm_config_v1", 5893 "scope": "app", 5894 "exportability": exportability, 5895 "document": { 5896 "version": 1, 5897 "selection": { 5898 "scope": "app", 5899 "account": account.account.account_id, 5900 "farm_d_tag": farm_d_tag, 5901 }, 5902 "profile": { 5903 "name": saved_farm.display_name, 5904 "display_name": saved_farm.display_name, 5905 }, 5906 "farm": { 5907 "d_tag": farm_d_tag, 5908 "name": saved_farm.display_name, 5909 "location": { 5910 "primary": projection.draft.location_or_service_area, 5911 }, 5912 }, 5913 "listing_defaults": { 5914 "delivery_method": delivery_method, 5915 "location": { 5916 "primary": projection.draft.location_or_service_area, 5917 }, 5918 }, 5919 }, 5920 }); 5921 let record_id = format!("app:local_work:farm:{farm_d_tag}:{}", Uuid::now_v7()); 5922 let input = LocalEventRecordInput { 5923 record_id: record_id.clone(), 5924 family: LocalRecordFamily::LocalWork, 5925 status: LocalRecordStatus::LocalSaved, 5926 source_runtime: SourceRuntime::App, 5927 created_at_ms: timestamp, 5928 inserted_at_ms: timestamp, 5929 owner_account_id: Some(account.account.account_id.clone()), 5930 owner_pubkey, 5931 farm_id: Some(farm_d_tag), 5932 listing_addr: None, 5933 local_work_json: Some(payload.clone()), 5934 event_id: None, 5935 event_kind: None, 5936 event_pubkey: None, 5937 event_created_at: None, 5938 event_tags_json: None, 5939 event_content: None, 5940 event_sig: None, 5941 raw_event_json: None, 5942 outbox_status: PublishOutboxStatus::None, 5943 relay_set_fingerprint: None, 5944 relay_delivery_json: None, 5945 }; 5946 5947 self.append_app_local_work_record(shared_accounts_paths, &input)?; 5948 Ok(Some(record_id)) 5949 } 5950 5951 fn append_app_listing_local_work_record( 5952 &self, 5953 product_id: ProductId, 5954 draft: &ProductEditorDraft, 5955 ) -> Result<Option<String>, AppSqliteError> { 5956 let Some(shared_accounts_paths) = self.shared_accounts_paths.as_ref() else { 5957 return Ok(None); 5958 }; 5959 let Some(account) = self 5960 .state_store 5961 .identity_projection() 5962 .selected_account 5963 .as_ref() 5964 else { 5965 return Ok(None); 5966 }; 5967 let Some(farm_id) = self.selected_farm_id() else { 5968 return Ok(None); 5969 }; 5970 let timestamp = current_runtime_time_ms()?; 5971 let farm_d_tag = d_tag_from_uuid(farm_id.as_uuid()); 5972 let listing_d_tag = d_tag_from_uuid(product_id.as_uuid()); 5973 let primary_bin_id = listing_primary_bin_id(listing_d_tag.as_str()); 5974 let owner_pubkey = self.local_events_owner_pubkey(account); 5975 let listing_addr = owner_pubkey 5976 .as_ref() 5977 .map(|pubkey| format!("{KIND_LISTING}:{pubkey}:{listing_d_tag}")); 5978 let exportability = local_work_exportability(owner_pubkey.as_deref()); 5979 let farm_setup = self.state_store.farm_setup_projection(); 5980 let farm_rules = self.state_store.farm_rules_projection(); 5981 let delivery_method = listing_fulfillment_method(draft, farm_setup, farm_rules); 5982 let location_primary = listing_fulfillment_location(draft, farm_setup, farm_rules); 5983 let category = non_empty_string(draft.category.as_str()); 5984 let unit_label = non_empty_string(draft.unit_label.as_str()); 5985 let price_amount = draft.price_minor_units.map(decimal_from_minor_units); 5986 let available = draft.stock_quantity.map(|value| value.to_string()); 5987 let publish_blockers = derive_product_publish_blockers( 5988 draft, 5989 self.state_store.farm_readiness_projection(), 5990 farm_rules, 5991 ) 5992 .into_iter() 5993 .map(|blocker| blocker.storage_key()) 5994 .collect::<Vec<_>>(); 5995 let payload = json!({ 5996 "record_kind": "listing_draft_v1", 5997 "exportability": exportability, 5998 "publishability": { 5999 "state": if publish_blockers.is_empty() { "publishable" } else { "blocked" }, 6000 "blockers": publish_blockers, 6001 }, 6002 "document": { 6003 "version": 1, 6004 "kind": "listing_draft_v1", 6005 "listing": { 6006 "d_tag": listing_d_tag, 6007 "farm_d_tag": farm_d_tag, 6008 }, 6009 "seller_actor": { 6010 "account_id": account.account.account_id, 6011 "pubkey": owner_pubkey.as_deref(), 6012 "source": "farm_config", 6013 }, 6014 "product": { 6015 "key": listing_d_tag, 6016 "title": draft.title, 6017 "category": category, 6018 "summary": draft.subtitle, 6019 }, 6020 "primary_bin": { 6021 "bin_id": primary_bin_id, 6022 "quantity_amount": "1", 6023 "quantity_unit": unit_label, 6024 "price_amount": price_amount, 6025 "price_currency": draft.price_currency, 6026 "price_per_amount": "1", 6027 "price_per_unit": unit_label, 6028 }, 6029 "inventory": { 6030 "available": available, 6031 }, 6032 "availability": { 6033 "kind": "local", 6034 "status": draft.status.storage_key(), 6035 }, 6036 "delivery": { 6037 "method": delivery_method, 6038 }, 6039 "location": { 6040 "primary": location_primary, 6041 }, 6042 }, 6043 }); 6044 let record_id = format!("app:local_work:listing:{listing_d_tag}:{}", Uuid::now_v7()); 6045 let input = LocalEventRecordInput { 6046 record_id: record_id.clone(), 6047 family: LocalRecordFamily::LocalWork, 6048 status: LocalRecordStatus::LocalSaved, 6049 source_runtime: SourceRuntime::App, 6050 created_at_ms: timestamp, 6051 inserted_at_ms: timestamp, 6052 owner_account_id: Some(account.account.account_id.clone()), 6053 owner_pubkey, 6054 farm_id: Some(farm_d_tag), 6055 listing_addr, 6056 local_work_json: Some(payload.clone()), 6057 event_id: None, 6058 event_kind: None, 6059 event_pubkey: None, 6060 event_created_at: None, 6061 event_tags_json: None, 6062 event_content: None, 6063 event_sig: None, 6064 raw_event_json: None, 6065 outbox_status: PublishOutboxStatus::None, 6066 relay_set_fingerprint: None, 6067 relay_delivery_json: None, 6068 }; 6069 6070 self.append_app_local_work_record(shared_accounts_paths, &input)?; 6071 Ok(Some(record_id)) 6072 } 6073 6074 fn append_app_buyer_order_request_local_work_record( 6075 &self, 6076 sqlite_store: &AppSqliteStore, 6077 buyer_context: &BuyerContext, 6078 order: &BuyerOrderLocalEventExport, 6079 ) -> Result<Option<AppOrderLocalWorkPublishSource>, AppSqliteError> { 6080 let Some(shared_accounts_paths) = self.shared_accounts_paths.as_ref() else { 6081 return Ok(None); 6082 }; 6083 let timestamp = current_runtime_time_ms()?; 6084 let record_id = buyer_order_request_local_work_record_id( 6085 order.order_id.to_string().as_str(), 6086 ) 6087 .map_err(|source| AppSqliteError::LocalEvents { 6088 operation: "build app buyer order request record id", 6089 source, 6090 })?; 6091 let buyer_account = self.selected_buyer_account(buyer_context); 6092 let owner_account_id = buyer_account.map(|account| account.account.account_id.clone()); 6093 let buyer_pubkey = 6094 buyer_account.and_then(|account| self.local_events_owner_pubkey(account)); 6095 let export = AppBuyerOrderRequestExport::from_order(order, buyer_pubkey.as_deref())?; 6096 let payload = buyer_order_request_local_work_payload( 6097 order, 6098 buyer_context, 6099 &record_id, 6100 &export, 6101 timestamp, 6102 ); 6103 if sqlite_store.buyer_order_coordination_is_synced(buyer_context, order.order_id)? { 6104 return Ok(Some(AppOrderLocalWorkPublishSource { record_id, payload })); 6105 } 6106 validate_buyer_order_request_local_work_payload(&payload).map_err(|source| { 6107 AppSqliteError::LocalEvents { 6108 operation: "validate app buyer order request local work payload", 6109 source, 6110 } 6111 })?; 6112 let payload_json = 6113 serde_json::to_string(&payload).map_err(|_| AppSqliteError::InvalidProjection { 6114 reason: "buyer order request local work payload must encode", 6115 })?; 6116 sqlite_store.prepare_buyer_order_coordination_attempt( 6117 buyer_context, 6118 order.order_id, 6119 record_id.as_str(), 6120 payload_json.as_str(), 6121 )?; 6122 let input = LocalEventRecordInput { 6123 record_id: record_id.clone(), 6124 family: LocalRecordFamily::LocalWork, 6125 status: LocalRecordStatus::LocalSaved, 6126 source_runtime: SourceRuntime::App, 6127 created_at_ms: timestamp, 6128 inserted_at_ms: timestamp, 6129 owner_account_id, 6130 owner_pubkey: buyer_pubkey, 6131 farm_id: export.farm_key.clone(), 6132 listing_addr: export.listing_addr.clone(), 6133 local_work_json: Some(payload.clone()), 6134 event_id: None, 6135 event_kind: None, 6136 event_pubkey: None, 6137 event_created_at: None, 6138 event_tags_json: None, 6139 event_content: None, 6140 event_sig: None, 6141 raw_event_json: None, 6142 outbox_status: PublishOutboxStatus::None, 6143 relay_set_fingerprint: None, 6144 relay_delivery_json: None, 6145 }; 6146 6147 if let Err(error) = self.append_app_local_work_record(shared_accounts_paths, &input) { 6148 let failure_message = error.to_string(); 6149 let _ = sqlite_store.mark_buyer_order_coordination_failed( 6150 buyer_context, 6151 order.order_id, 6152 failure_message.as_str(), 6153 ); 6154 return Err(error); 6155 } 6156 sqlite_store.mark_buyer_order_coordination_synced(buyer_context, order.order_id)?; 6157 Ok(Some(AppOrderLocalWorkPublishSource { record_id, payload })) 6158 } 6159 6160 fn append_app_local_work_record( 6161 &self, 6162 shared_accounts_paths: &AppSharedAccountsPaths, 6163 input: &LocalEventRecordInput, 6164 ) -> Result<(), AppSqliteError> { 6165 let Some(database_path) = 6166 shared_local_events_database_path_from_shared_accounts(shared_accounts_paths) 6167 else { 6168 return Ok(()); 6169 }; 6170 if let Some(parent) = database_path.parent() { 6171 fs::create_dir_all(parent).map_err(|source| AppSqliteError::CreateParentDirectory { 6172 path: parent.to_path_buf(), 6173 source, 6174 })?; 6175 } 6176 let executor = SqliteExecutor::open(database_path.as_path()).map_err(|source| { 6177 AppSqliteError::LocalEventsSql { 6178 operation: "open shared local events database", 6179 source, 6180 } 6181 })?; 6182 let store = LocalEventsStore::new(executor); 6183 store 6184 .migrate_up() 6185 .map_err(|source| AppSqliteError::LocalEventsSql { 6186 operation: "migrate shared local events database", 6187 source, 6188 })?; 6189 store 6190 .append_record(input) 6191 .map_err(|source| AppSqliteError::LocalEvents { 6192 operation: "append app local work record", 6193 source, 6194 })?; 6195 Ok(()) 6196 } 6197 6198 fn record_published_sync_receipts( 6199 &self, 6200 receipts: &[AppPublishedOperationReceipt], 6201 ) -> Result<(), AppSqliteError> { 6202 if receipts.is_empty() { 6203 return Ok(()); 6204 } 6205 let Some(shared_accounts_paths) = self.shared_accounts_paths.as_ref() else { 6206 return Ok(()); 6207 }; 6208 let Some(database_path) = 6209 shared_local_events_database_path_from_shared_accounts(shared_accounts_paths) 6210 else { 6211 return Ok(()); 6212 }; 6213 if let Some(parent) = database_path.parent() { 6214 fs::create_dir_all(parent).map_err(|source| AppSqliteError::CreateParentDirectory { 6215 path: parent.to_path_buf(), 6216 source, 6217 })?; 6218 } 6219 let executor = SqliteExecutor::open(database_path.as_path()).map_err(|source| { 6220 AppSqliteError::LocalEventsSql { 6221 operation: "open shared local events database", 6222 source, 6223 } 6224 })?; 6225 let store = LocalEventsStore::new(executor); 6226 store 6227 .migrate_up() 6228 .map_err(|source| AppSqliteError::LocalEventsSql { 6229 operation: "migrate shared local events database", 6230 source, 6231 })?; 6232 let timestamp = current_runtime_time_ms()?; 6233 6234 for receipt in receipts { 6235 let source_record = receipt 6236 .source_local_event_id 6237 .as_deref() 6238 .map(|source_record_id| { 6239 store.get_record(source_record_id).map_err(|source| { 6240 AppSqliteError::LocalEvents { 6241 operation: "load app publish source record", 6242 source, 6243 } 6244 }) 6245 }) 6246 .transpose()? 6247 .flatten(); 6248 if source_record 6249 .as_ref() 6250 .and_then(|record| record.owner_account_id.as_deref()) 6251 .is_some_and(|owner_account_id| owner_account_id != receipt.source_account_id) 6252 { 6253 return Err(AppSqliteError::InvalidProjection { 6254 reason: "published operation source account does not match local event owner", 6255 }); 6256 } 6257 let farm_id = source_record 6258 .as_ref() 6259 .and_then(|record| record.farm_id.clone()) 6260 .or_else(|| signed_event_farm_id(receipt)); 6261 let listing_addr = source_record 6262 .as_ref() 6263 .and_then(|record| record.listing_addr.clone()) 6264 .or_else(|| receipt.listing_addr.clone()) 6265 .or_else(|| signed_event_listing_addr(receipt)); 6266 let event_record = LocalEventRecordInput { 6267 record_id: format!("app:signed_event:{}", receipt.event_id), 6268 family: LocalRecordFamily::SignedEvent, 6269 status: LocalRecordStatus::Published, 6270 source_runtime: SourceRuntime::App, 6271 created_at_ms: i64::from(receipt.event_created_at) * 1_000, 6272 inserted_at_ms: timestamp, 6273 owner_account_id: Some(receipt.source_account_id.clone()), 6274 owner_pubkey: Some(receipt.event_pubkey.clone()), 6275 farm_id, 6276 listing_addr, 6277 local_work_json: None, 6278 event_id: Some(receipt.event_id.clone()), 6279 event_kind: Some(i64::from(receipt.event_kind)), 6280 event_pubkey: Some(receipt.event_pubkey.clone()), 6281 event_created_at: Some(i64::from(receipt.event_created_at)), 6282 event_tags_json: Some(receipt.event_tags_json.clone()), 6283 event_content: Some(receipt.event_content.clone()), 6284 event_sig: Some(receipt.event_sig.clone()), 6285 raw_event_json: Some(receipt.raw_event_json.clone()), 6286 outbox_status: PublishOutboxStatus::Acknowledged, 6287 relay_set_fingerprint: Some(receipt.relay_set_fingerprint.clone()), 6288 relay_delivery_json: Some(receipt.relay_delivery_json.clone()), 6289 }; 6290 store 6291 .append_record(&event_record) 6292 .map_err(|source| AppSqliteError::LocalEvents { 6293 operation: "append app published event record", 6294 source, 6295 })?; 6296 6297 if let Some(source_record_id) = receipt.source_local_event_id.as_deref() { 6298 let Some(source_record) = source_record.as_ref() else { 6299 continue; 6300 }; 6301 if source_record.family == LocalRecordFamily::LocalWork { 6302 continue; 6303 } 6304 store 6305 .update_outbox(&LocalEventRecordUpdate { 6306 record_id: source_record_id.to_owned(), 6307 status: LocalRecordStatus::Published, 6308 outbox_status: PublishOutboxStatus::Acknowledged, 6309 relay_set_fingerprint: Some(receipt.relay_set_fingerprint.clone()), 6310 relay_delivery_json: Some(receipt.relay_delivery_json.clone()), 6311 updated_at_ms: timestamp, 6312 }) 6313 .map_err(|source| AppSqliteError::LocalEvents { 6314 operation: "update app publish source evidence", 6315 source, 6316 })?; 6317 } 6318 } 6319 6320 Ok(()) 6321 } 6322 6323 fn local_events_owner_pubkey( 6324 &self, 6325 account: &radroots_app_view::SelectedAccountProjection, 6326 ) -> Option<String> { 6327 if is_hex_64(account.account.account_id.as_str()) { 6328 return Some(account.account.account_id.clone()); 6329 } 6330 self.accounts_manager 6331 .as_ref() 6332 .and_then(|manager| { 6333 manager 6334 .resolve_account_selector(account.account.account_id.as_str()) 6335 .ok() 6336 }) 6337 .map(|record| record.public_identity.public_key_hex) 6338 .filter(|pubkey| is_hex_64(pubkey)) 6339 } 6340 6341 fn selected_buyer_account( 6342 &self, 6343 buyer_context: &BuyerContext, 6344 ) -> Option<&radroots_app_view::SelectedAccountProjection> { 6345 let BuyerContext::Account(account_id) = buyer_context else { 6346 return None; 6347 }; 6348 self.state_store 6349 .identity_projection() 6350 .selected_account 6351 .as_ref() 6352 .filter(|account| account.account.account_id == *account_id) 6353 } 6354 6355 fn refresh_selected_account_context_after_local_events( 6356 &mut self, 6357 ) -> Result<bool, AppSqliteError> { 6358 let Some(sqlite_store) = self.sqlite_store.as_ref() else { 6359 return Ok(false); 6360 }; 6361 let continuity_state = self.continuity_state(); 6362 let identity_projection = self.state_store.identity_projection().clone(); 6363 let selected_account_context = 6364 load_selected_account_context(sqlite_store, &identity_projection, &continuity_state)?; 6365 6366 Ok(self.apply_selected_account_context(&selected_account_context)) 6367 } 6368 6369 fn sync_on_foreground_resume(&mut self) -> Result<bool, AppSqliteError> { 6370 let report = self.import_shared_local_events()?; 6371 let local_changed = report.imported_records > 0 || report.skipped_records > 0; 6372 let context_changed = self.refresh_selected_account_context_after_local_events()?; 6373 let coordination_changed = self.retry_pending_personal_order_coordination()?; 6374 let sync_changed = self.attempt_sync(SyncTrigger::ForegroundResume)?; 6375 6376 Ok(local_changed || context_changed || coordination_changed || sync_changed) 6377 } 6378 6379 fn replace_orders_query( 6380 &mut self, 6381 query: OrdersScreenQueryState, 6382 ) -> Result<bool, AppSqliteError> { 6383 let filter_changed = self 6384 .state_store 6385 .apply_in_memory(AppStateCommand::select_orders_filter(query.filter)); 6386 let fulfillment_window_changed = 6387 self.state_store 6388 .apply_in_memory(AppStateCommand::select_orders_fulfillment_window( 6389 query.fulfillment_window_id, 6390 )); 6391 let Some(sqlite_store) = self.sqlite_store.as_ref() else { 6392 return Ok(filter_changed || fulfillment_window_changed); 6393 }; 6394 let continuity_state = self.continuity_state_with_orders_query(query, None); 6395 let selected_account_context = load_selected_account_context( 6396 sqlite_store, 6397 self.state_store.identity_projection(), 6398 &continuity_state, 6399 )?; 6400 let context_changed = self.apply_selected_account_context(&selected_account_context); 6401 6402 Ok(filter_changed || fulfillment_window_changed || context_changed) 6403 } 6404 6405 fn replace_pack_day_query( 6406 &mut self, 6407 query: PackDayScreenQueryState, 6408 ) -> Result<bool, AppSqliteError> { 6409 let previous_export_instance_id = self.current_pack_day_export_instance_id(); 6410 let fulfillment_window_changed = 6411 self.state_store 6412 .apply_in_memory(AppStateCommand::set_pack_day_fulfillment_window( 6413 query.fulfillment_window_id, 6414 )); 6415 let Some(sqlite_store) = self.sqlite_store.as_ref() else { 6416 return Ok(fulfillment_window_changed); 6417 }; 6418 let continuity_state = self.continuity_state_with_pack_day_query(query); 6419 let selected_account_context = load_selected_account_context( 6420 sqlite_store, 6421 self.state_store.identity_projection(), 6422 &continuity_state, 6423 )?; 6424 let context_changed = self.apply_selected_account_context(&selected_account_context); 6425 self.cleanup_prepared_pack_day_print_assets_if_export_changed( 6426 previous_export_instance_id, 6427 "query_reset", 6428 ); 6429 6430 Ok(fulfillment_window_changed || context_changed) 6431 } 6432 fn sync_truthful_farmer_section(&mut self) -> bool { 6433 let selected_section = self.state_store.shell_projection().selected_section; 6434 let should_reset_to_today = match selected_section { 6435 ShellSection::Farmer(FarmerSection::Today) => false, 6436 ShellSection::Farmer(FarmerSection::Products | FarmerSection::Orders) => { 6437 !self.has_saved_farm() 6438 } 6439 ShellSection::Farmer(FarmerSection::PackDay) => { 6440 !self.has_saved_farm() || !self.has_pack_day_context() 6441 } 6442 ShellSection::Farmer(FarmerSection::Farm) => true, 6443 ShellSection::Home 6444 | ShellSection::Account 6445 | ShellSection::Personal(_) 6446 | ShellSection::Settings(_) => false, 6447 }; 6448 6449 should_reset_to_today 6450 && self 6451 .state_store 6452 .apply_in_memory(AppStateCommand::SelectSection(ShellSection::Farmer( 6453 FarmerSection::Today, 6454 ))) 6455 } 6456 6457 fn shared_accounts_paths( 6458 &self, 6459 ) -> Result<&AppSharedAccountsPaths, DesktopAppRuntimeCommandError> { 6460 self.shared_accounts_paths 6461 .as_ref() 6462 .ok_or(DesktopAppRuntimeCommandError::RuntimeUnavailable) 6463 } 6464 6465 fn remote_signer_paths(&self) -> Option<&DesktopRemoteSignerPaths> { 6466 self.remote_signer_paths.as_ref() 6467 } 6468 6469 fn load_startup_pending_remote_signer_session( 6470 &self, 6471 ) -> Result<Option<RadrootsAppRemoteSignerPendingSession>, DesktopAppRuntimeCommandError> { 6472 let Some(paths) = self.remote_signer_paths() else { 6473 return Ok(None); 6474 }; 6475 Ok(load_pending_session(paths)?) 6476 } 6477 6478 fn store_startup_pending_remote_signer_session( 6479 &mut self, 6480 pending: &RadrootsAppRemoteSignerPendingSession, 6481 ) -> Result<bool, DesktopAppRuntimeCommandError> { 6482 let Some(paths) = self.remote_signer_paths() else { 6483 return Err(DesktopAppRuntimeCommandError::RuntimeUnavailable); 6484 }; 6485 store_pending_session(paths, pending)?; 6486 Ok(true) 6487 } 6488 6489 fn clear_startup_pending_remote_signer_session( 6490 &mut self, 6491 ) -> Result<bool, DesktopAppRuntimeCommandError> { 6492 let Some(paths) = self.remote_signer_paths() else { 6493 return Ok(false); 6494 }; 6495 clear_pending_session(paths)?; 6496 Ok(true) 6497 } 6498 6499 fn activate_startup_approved_remote_signer_session( 6500 &mut self, 6501 pending: &RadrootsAppRemoteSignerPendingSession, 6502 approved: &RadrootsAppRemoteSignerApprovedSession, 6503 ) -> Result<bool, DesktopAppRuntimeCommandError> { 6504 let Some(paths) = self.remote_signer_paths() else { 6505 return Err(DesktopAppRuntimeCommandError::RuntimeUnavailable); 6506 }; 6507 { 6508 let accounts_manager = self.accounts_manager()?; 6509 activate_pending_session( 6510 accounts_manager, 6511 paths, 6512 pending.record.client_account_id(), 6513 approved, 6514 )?; 6515 } 6516 let projection = { 6517 let accounts_manager = self.accounts_manager()?; 6518 let sqlite_store = self.sqlite_store()?; 6519 identity_projection_from_manager(accounts_manager, sqlite_store)? 6520 }; 6521 self.replace_identity_projection(projection) 6522 } 6523 6524 fn decorate_identity_projection( 6525 &self, 6526 projection: AppIdentityProjection, 6527 ) -> Result<AppIdentityProjection, DesktopAppRuntimeCommandError> { 6528 let Some(paths) = self.remote_signer_paths() else { 6529 return Ok(projection); 6530 }; 6531 Ok(apply_remote_signer_custody(projection, paths)?) 6532 } 6533 6534 fn command_unavailable_error(&self) -> DesktopAppRuntimeCommandError { 6535 let _ = self; 6536 DesktopAppRuntimeCommandError::RuntimeUnavailable 6537 } 6538 6539 fn current_pack_day_export_bundle(&self) -> Option<PackDayExportBundle> { 6540 let pack_day = self.state_store.pack_day_projection(); 6541 if pack_day.export.status != PackDayExportStatus::Succeeded { 6542 return None; 6543 } 6544 6545 let bundle = pack_day.export.bundle.clone()?; 6546 let fulfillment_window = pack_day.projection.fulfillment_window.as_ref()?; 6547 (fulfillment_window.fulfillment_window_id == bundle.fulfillment_window_id).then_some(bundle) 6548 } 6549 6550 fn current_pack_day_export_instance_id(&self) -> Option<PackDayExportInstanceId> { 6551 self.current_pack_day_export_bundle() 6552 .map(|bundle| bundle.export_instance_id) 6553 } 6554 6555 fn cleanup_prepared_pack_day_print_assets_for_export_instance( 6556 &self, 6557 export_instance_id: PackDayExportInstanceId, 6558 trigger: &'static str, 6559 ) { 6560 if let Err(error) = 6561 cleanup_prepared_customer_label_assets_for_export_instance(export_instance_id) 6562 { 6563 error!( 6564 target: "pack_day", 6565 event = "pack_day.print_prepared_asset_cleanup_failed", 6566 trigger, 6567 export_instance_id = %export_instance_id, 6568 error = %error, 6569 "failed to clean prepared pack day print assets" 6570 ); 6571 } 6572 } 6573 6574 fn cleanup_prepared_pack_day_print_assets_if_export_changed( 6575 &self, 6576 previous_export_instance_id: Option<PackDayExportInstanceId>, 6577 trigger: &'static str, 6578 ) { 6579 let current_export_instance_id = self.current_pack_day_export_instance_id(); 6580 if let Some(export_instance_id) = previous_export_instance_id 6581 .filter(|export_instance_id| Some(*export_instance_id) != current_export_instance_id) 6582 { 6583 self.cleanup_prepared_pack_day_print_assets_for_export_instance( 6584 export_instance_id, 6585 trigger, 6586 ); 6587 } 6588 } 6589 6590 fn current_pack_day_host_handoff_request_matches( 6591 &self, 6592 request: &PackDayHostHandoffRequest, 6593 ) -> bool { 6594 let pack_day = self.state_store.pack_day_projection(); 6595 pack_day.host_handoff.status == PackDayHostHandoffStatus::Running 6596 && pack_day.host_handoff.request.as_ref() == Some(request) 6597 } 6598 6599 fn current_pack_day_print_request_matches(&self, request: &PackDayPrintRequest) -> bool { 6600 let pack_day = self.state_store.pack_day_projection(); 6601 pack_day.print.status == PackDayPrintStatus::Running 6602 && pack_day.print.request.as_ref() == Some(request) 6603 } 6604 6605 fn current_pack_day_batch_print_request_matches( 6606 &self, 6607 request: &PackDayBatchPrintRequest, 6608 ) -> bool { 6609 let pack_day = self.state_store.pack_day_projection(); 6610 pack_day.batch_print.status == PackDayBatchPrintStatus::Running 6611 && pack_day.batch_print.request.as_ref() == Some(request) 6612 } 6613 } 6614 6615 #[derive(Debug, Error)] 6616 pub enum DesktopAppRuntimeCommandError { 6617 #[error("desktop runtime commands are unavailable while the runtime is degraded")] 6618 RuntimeUnavailable, 6619 #[error(transparent)] 6620 Accounts(#[from] DesktopAccountsCommandError), 6621 #[error(transparent)] 6622 Projection(#[from] DesktopAccountsProjectionError), 6623 #[error("remote signer command failed: {0}")] 6624 RemoteSigner(String), 6625 #[error(transparent)] 6626 Sqlite(#[from] AppSqliteError), 6627 #[error(transparent)] 6628 PackDayExportWrite(#[from] PackDayExportWriteError), 6629 #[error(transparent)] 6630 PackDayHostHandoff(#[from] PackDayHostHandoffError), 6631 #[error(transparent)] 6632 PackDayPrint(#[from] PackDayPrintError), 6633 #[error(transparent)] 6634 PackDayBatchPrint(#[from] PackDayBatchPrintError), 6635 } 6636 6637 #[derive(Debug, Error)] 6638 enum DesktopAppRuntimeProductPublishError { 6639 #[error(transparent)] 6640 Sqlite(#[from] AppSqliteError), 6641 #[error("listing publish could not be queued through the SDK runtime")] 6642 ListingPublishSdkEnqueueFailed, 6643 } 6644 6645 #[derive(Debug, Error)] 6646 pub enum DesktopAppRuntimeProductEditorSaveError { 6647 #[error(transparent)] 6648 Sqlite(AppSqliteError), 6649 #[error( 6650 "product details were saved, but listing publish could not be queued through the SDK runtime" 6651 )] 6652 ListingPublishSdkEnqueueFailed, 6653 } 6654 6655 impl From<AppSqliteError> for DesktopAppRuntimeProductEditorSaveError { 6656 fn from(error: AppSqliteError) -> Self { 6657 Self::Sqlite(error) 6658 } 6659 } 6660 6661 impl From<DesktopAppRuntimeProductPublishError> for DesktopAppRuntimeProductEditorSaveError { 6662 fn from(error: DesktopAppRuntimeProductPublishError) -> Self { 6663 match error { 6664 DesktopAppRuntimeProductPublishError::Sqlite(error) => Self::Sqlite(error), 6665 DesktopAppRuntimeProductPublishError::ListingPublishSdkEnqueueFailed => { 6666 Self::ListingPublishSdkEnqueueFailed 6667 } 6668 } 6669 } 6670 } 6671 6672 impl DesktopAppRuntimeProductEditorSaveError { 6673 pub fn is_listing_publish_sdk_enqueue_failed(&self) -> bool { 6674 matches!(self, Self::ListingPublishSdkEnqueueFailed) 6675 } 6676 } 6677 6678 #[derive(Debug, Error)] 6679 pub enum DesktopAppRuntimeProductStockUpdateError { 6680 #[error(transparent)] 6681 Sqlite(AppSqliteError), 6682 #[error("stock was saved, but listing publish could not be queued through the SDK runtime")] 6683 ListingPublishSdkEnqueueFailed, 6684 } 6685 6686 impl From<AppSqliteError> for DesktopAppRuntimeProductStockUpdateError { 6687 fn from(error: AppSqliteError) -> Self { 6688 Self::Sqlite(error) 6689 } 6690 } 6691 6692 impl From<DesktopAppRuntimeProductPublishError> for DesktopAppRuntimeProductStockUpdateError { 6693 fn from(error: DesktopAppRuntimeProductPublishError) -> Self { 6694 match error { 6695 DesktopAppRuntimeProductPublishError::Sqlite(error) => Self::Sqlite(error), 6696 DesktopAppRuntimeProductPublishError::ListingPublishSdkEnqueueFailed => { 6697 Self::ListingPublishSdkEnqueueFailed 6698 } 6699 } 6700 } 6701 } 6702 6703 impl DesktopAppRuntimeProductStockUpdateError { 6704 pub fn is_listing_publish_sdk_enqueue_failed(&self) -> bool { 6705 matches!(self, Self::ListingPublishSdkEnqueueFailed) 6706 } 6707 } 6708 6709 impl From<DesktopRemoteSignerError> for DesktopAppRuntimeCommandError { 6710 fn from(error: DesktopRemoteSignerError) -> Self { 6711 Self::RemoteSigner(error.to_string()) 6712 } 6713 } 6714 6715 #[derive(Debug, Error)] 6716 pub enum DesktopAppRuntimeFarmSetupError { 6717 #[error("desktop runtime commands are unavailable while the runtime is degraded")] 6718 RuntimeUnavailable, 6719 #[error("farm setup requires a selected account")] 6720 AccountRequired, 6721 #[error("farm setup is incomplete")] 6722 IncompleteDraft, 6723 #[error(transparent)] 6724 Sqlite(#[from] AppSqliteError), 6725 } 6726 6727 #[derive(Debug, Error)] 6728 pub enum DesktopAppRuntimeFarmRulesError { 6729 #[error("desktop runtime commands are unavailable while the runtime is degraded")] 6730 RuntimeUnavailable, 6731 #[error("farm settings require a selected account")] 6732 AccountRequired, 6733 #[error("farm settings require a configured farm")] 6734 FarmRequired, 6735 #[error(transparent)] 6736 Sqlite(#[from] AppSqliteError), 6737 } 6738 6739 #[derive(Debug, Error)] 6740 enum DesktopAppRuntimeBootstrapError { 6741 #[error(transparent)] 6742 RuntimePaths(#[from] AppRuntimePathsError), 6743 #[error(transparent)] 6744 Accounts(#[from] DesktopAccountsBootstrapError), 6745 #[error(transparent)] 6746 Projection(#[from] DesktopAccountsProjectionError), 6747 #[error(transparent)] 6748 RemoteSigner(#[from] DesktopRemoteSignerError), 6749 #[error(transparent)] 6750 Sqlite(#[from] AppSqliteError), 6751 #[error(transparent)] 6752 State(#[from] AppStateStoreError), 6753 } 6754 6755 fn current_runtime_time_ms() -> Result<i64, AppSqliteError> { 6756 let duration = SystemTime::now().duration_since(UNIX_EPOCH).map_err(|_| { 6757 AppSqliteError::InvalidProjection { 6758 reason: "current runtime timestamp must be after unix epoch", 6759 } 6760 })?; 6761 i64::try_from(duration.as_millis()).map_err(|_| AppSqliteError::InvalidProjection { 6762 reason: "current runtime timestamp must fit i64 milliseconds", 6763 }) 6764 } 6765 6766 fn current_runtime_time_seconds() -> Result<i64, AppSqliteError> { 6767 let duration = SystemTime::now().duration_since(UNIX_EPOCH).map_err(|_| { 6768 AppSqliteError::InvalidProjection { 6769 reason: "current runtime timestamp must be after unix epoch", 6770 } 6771 })?; 6772 i64::try_from(duration.as_secs()).map_err(|_| AppSqliteError::InvalidProjection { 6773 reason: "current runtime timestamp must fit i64 seconds", 6774 }) 6775 } 6776 6777 fn normalized_app_sync_relay_urls( 6778 relay_urls: &[String], 6779 ) -> Result<Vec<String>, AppSyncTransportError> { 6780 let normalized = radroots_local_events::normalize_relay_urls(relay_urls).map_err(|error| { 6781 AppSyncTransportError::failed(format!("invalid direct relay app sync relay url: {error}")) 6782 })?; 6783 if normalized.is_empty() { 6784 return Err(AppSyncTransportError::unavailable( 6785 "direct relay app sync requires at least one configured relay", 6786 )); 6787 } 6788 Ok(normalized) 6789 } 6790 6791 fn normalized_app_relay_ingest_urls(relay_urls: &[String]) -> Result<Vec<String>, AppSqliteError> { 6792 let normalized = radroots_local_events::normalize_relay_urls(relay_urls).map_err(|_| { 6793 AppSqliteError::InvalidProjection { 6794 reason: "app relay ingest requires valid relay urls", 6795 } 6796 })?; 6797 Ok(normalized) 6798 } 6799 6800 fn fetch_app_events_from_relays_windowed( 6801 cursors: &[StoredRelayIngestCursor], 6802 ) -> Result<AppDirectRelayFetchReceipt, AppSyncTransportError> { 6803 let target_relays = cursors 6804 .iter() 6805 .map(|cursor| cursor.relay_url.clone()) 6806 .collect::<Vec<_>>(); 6807 let mut merged: Option<AppDirectRelayFetchReceipt> = None; 6808 6809 for cursor in cursors { 6810 match fetch_app_events_from_single_relay_windowed(cursor) { 6811 Ok(receipt) => merge_app_direct_relay_fetch_receipt(&mut merged, receipt), 6812 Err(error) => merge_app_direct_relay_fetch_receipt( 6813 &mut merged, 6814 AppDirectRelayFetchReceipt { 6815 target_relays: vec![cursor.relay_url.clone()], 6816 connected_relays: Vec::new(), 6817 failed_relays: vec![ 6818 RelayDeliveryFailure::new(cursor.relay_url.clone(), error.to_string()) 6819 .map_err(|source| AppSyncTransportError::failed(source.to_string()))?, 6820 ], 6821 fetched_relays: Vec::new(), 6822 event_observed_relays: BTreeMap::new(), 6823 events: Vec::new(), 6824 }, 6825 ), 6826 } 6827 } 6828 6829 Ok(merged.unwrap_or_else(|| AppDirectRelayFetchReceipt { 6830 target_relays, 6831 connected_relays: Vec::new(), 6832 failed_relays: Vec::new(), 6833 fetched_relays: Vec::new(), 6834 event_observed_relays: BTreeMap::new(), 6835 events: Vec::new(), 6836 })) 6837 } 6838 6839 fn fetch_app_events_from_single_relay_windowed( 6840 cursor: &StoredRelayIngestCursor, 6841 ) -> Result<AppDirectRelayFetchReceipt, AppSyncTransportError> { 6842 let base_filter = direct_relay_ingest_filter_since(cursor.cursor_since_unix_seconds)?; 6843 let mut next_filter = base_filter.clone(); 6844 let mut merged: Option<AppDirectRelayFetchReceipt> = None; 6845 6846 for _ in 0..APP_DIRECT_RELAY_INGEST_MAX_PAGES { 6847 let receipt = fetch_app_events_from_single_relay(cursor.relay_url.as_str(), next_filter)?; 6848 let page_len = receipt.events.len(); 6849 let oldest_created_at = receipt 6850 .events 6851 .iter() 6852 .map(|event| event.created_at.as_secs()) 6853 .min(); 6854 merge_app_direct_relay_fetch_receipt(&mut merged, receipt); 6855 if page_len < APP_DIRECT_RELAY_INGEST_LIMIT { 6856 break; 6857 } 6858 let Some(oldest_created_at) = oldest_created_at else { 6859 break; 6860 }; 6861 if cursor.cursor_since_unix_seconds.is_some_and(|since| { 6862 i64::try_from(oldest_created_at).is_ok_and(|oldest| oldest <= since) 6863 }) || oldest_created_at == 0 6864 { 6865 break; 6866 } 6867 next_filter = base_filter 6868 .clone() 6869 .until(RadrootsNostrTimestamp::from(oldest_created_at - 1)) 6870 .limit(APP_DIRECT_RELAY_INGEST_LIMIT); 6871 } 6872 6873 Ok(merged.unwrap_or_else(|| AppDirectRelayFetchReceipt { 6874 target_relays: vec![cursor.relay_url.clone()], 6875 connected_relays: Vec::new(), 6876 failed_relays: Vec::new(), 6877 fetched_relays: Vec::new(), 6878 event_observed_relays: BTreeMap::new(), 6879 events: Vec::new(), 6880 })) 6881 } 6882 6883 fn fetch_app_events_from_single_relay( 6884 relay_url: &str, 6885 filter: RadrootsNostrFilter, 6886 ) -> Result<AppDirectRelayFetchReceipt, AppSyncTransportError> { 6887 let runtime = TokioRuntimeBuilder::new_current_thread() 6888 .enable_all() 6889 .build() 6890 .map_err(|error| AppSyncTransportError::failed(error.to_string()))?; 6891 runtime.block_on(fetch_app_events_from_single_relay_async(relay_url, filter)) 6892 } 6893 6894 async fn fetch_app_events_from_single_relay_async( 6895 relay_url: &str, 6896 filter: RadrootsNostrFilter, 6897 ) -> Result<AppDirectRelayFetchReceipt, AppSyncTransportError> { 6898 let client = RadrootsNostrClient::new_signerless(); 6899 6900 client 6901 .add_read_relay(relay_url) 6902 .await 6903 .map_err(|source| AppSyncTransportError::failed(source.to_string()))?; 6904 6905 let connection_output = client.try_connect(APP_DIRECT_RELAY_CONNECT_TIMEOUT).await; 6906 let failed_relays = direct_relay_failures_from_output(&connection_output)?; 6907 if connection_output.success.is_empty() { 6908 return Err(AppSyncTransportError::unavailable(format!( 6909 "direct relay app ingest connection failed: {}", 6910 summarize_app_relay_failures(&failed_relays) 6911 ))); 6912 } 6913 6914 let events = client 6915 .fetch_events( 6916 filter, 6917 StdDuration::from_millis(APP_DIRECT_RELAY_SYNC_TIMEOUT_MS), 6918 ) 6919 .await 6920 .map_err(|source| AppSyncTransportError::failed(source.to_string()))?; 6921 let last_event_created_at_unix_seconds = events 6922 .iter() 6923 .map(|event| relay_event_created_at_unix_seconds_for_fetch(event)) 6924 .collect::<Result<Vec<_>, _>>()? 6925 .into_iter() 6926 .max(); 6927 let mut event_observed_relays = BTreeMap::new(); 6928 for event in &events { 6929 event_observed_relays.insert(event.id.to_hex(), vec![relay_url.to_owned()]); 6930 } 6931 6932 Ok(AppDirectRelayFetchReceipt { 6933 target_relays: vec![relay_url.to_owned()], 6934 connected_relays: connection_output 6935 .success 6936 .iter() 6937 .map(ToString::to_string) 6938 .collect(), 6939 failed_relays, 6940 fetched_relays: vec![AppDirectRelayFetchedRelay { 6941 relay_url: relay_url.to_owned(), 6942 last_event_created_at_unix_seconds, 6943 }], 6944 event_observed_relays, 6945 events, 6946 }) 6947 } 6948 6949 fn direct_relay_ingest_filter() -> RadrootsNostrFilter { 6950 RadrootsNostrFilter::new() 6951 .kinds( 6952 APP_DIRECT_RELAY_INGEST_KINDS 6953 .iter() 6954 .copied() 6955 .map(radroots_nostr_kind), 6956 ) 6957 .limit(APP_DIRECT_RELAY_INGEST_LIMIT) 6958 } 6959 6960 fn direct_relay_ingest_filter_since( 6961 since_unix_seconds: Option<i64>, 6962 ) -> Result<RadrootsNostrFilter, AppSyncTransportError> { 6963 let mut filter = direct_relay_ingest_filter(); 6964 if let Some(since_unix_seconds) = since_unix_seconds { 6965 let since = u64::try_from(since_unix_seconds).map_err(|_| { 6966 AppSyncTransportError::failed("relay ingest cursor must be non-negative") 6967 })?; 6968 filter = filter.since(RadrootsNostrTimestamp::from(since)); 6969 } 6970 Ok(filter) 6971 } 6972 6973 fn direct_relay_failures_from_output<T: fmt::Debug>( 6974 output: &RadrootsNostrOutput<T>, 6975 ) -> Result<Vec<RelayDeliveryFailure>, AppSyncTransportError> { 6976 output 6977 .failed 6978 .iter() 6979 .map(|(relay, reason)| { 6980 RelayDeliveryFailure::new(relay.to_string(), reason.to_string()) 6981 .map_err(|source| AppSyncTransportError::failed(source.to_string())) 6982 }) 6983 .collect() 6984 } 6985 6986 fn summarize_app_relay_failures(failed_relays: &[RelayDeliveryFailure]) -> String { 6987 if failed_relays.is_empty() { 6988 return "no relay acknowledged the operation".to_owned(); 6989 } 6990 6991 failed_relays 6992 .iter() 6993 .map(|failure| format!("{}: {}", failure.relay_url, failure.error)) 6994 .collect::<Vec<_>>() 6995 .join("; ") 6996 } 6997 6998 fn merge_app_direct_relay_fetch_receipt( 6999 merged: &mut Option<AppDirectRelayFetchReceipt>, 7000 receipt: AppDirectRelayFetchReceipt, 7001 ) { 7002 let Some(existing) = merged.as_mut() else { 7003 *merged = Some(receipt); 7004 return; 7005 }; 7006 7007 append_unique_relays(&mut existing.target_relays, receipt.target_relays); 7008 append_unique_relays(&mut existing.connected_relays, receipt.connected_relays); 7009 for fetched_relay in receipt.fetched_relays { 7010 if !existing 7011 .fetched_relays 7012 .iter() 7013 .any(|known| known.relay_url == fetched_relay.relay_url) 7014 { 7015 existing.fetched_relays.push(fetched_relay); 7016 } 7017 } 7018 for failure in receipt.failed_relays { 7019 if !existing 7020 .failed_relays 7021 .iter() 7022 .any(|known| known.relay_url == failure.relay_url && known.error == failure.error) 7023 { 7024 existing.failed_relays.push(failure); 7025 } 7026 } 7027 for (event_id, relays) in receipt.event_observed_relays { 7028 let observed = existing 7029 .event_observed_relays 7030 .entry(event_id) 7031 .or_insert_with(Vec::new); 7032 append_unique_relays(observed, relays); 7033 } 7034 let mut seen_event_ids = existing 7035 .events 7036 .iter() 7037 .map(|event| event.id.to_hex()) 7038 .collect::<BTreeSet<_>>(); 7039 for event in receipt.events { 7040 if seen_event_ids.insert(event.id.to_hex()) { 7041 existing.events.push(event); 7042 } 7043 } 7044 } 7045 7046 fn append_unique_relays(target: &mut Vec<String>, relays: Vec<String>) { 7047 for relay in relays { 7048 if !target.iter().any(|known| known == &relay) { 7049 target.push(relay); 7050 } 7051 } 7052 } 7053 7054 fn direct_relay_event_records( 7055 receipt: &AppDirectRelayFetchReceipt, 7056 inserted_at_ms: i64, 7057 ) -> Result<Vec<LocalEventRecord>, AppDirectRelayIngestError> { 7058 let mut records = Vec::with_capacity(receipt.events.len()); 7059 7060 for (index, event) in receipt.events.iter().enumerate() { 7061 let event_id = event.id.to_hex(); 7062 let observed_relays = receipt 7063 .event_observed_relays 7064 .get(event_id.as_str()) 7065 .cloned() 7066 .unwrap_or_default(); 7067 let delivery_evidence = RelayDeliveryEvidence::observed( 7068 &receipt.target_relays, 7069 &receipt.connected_relays, 7070 observed_relays, 7071 receipt.failed_relays.clone(), 7072 ) 7073 .map_err(|source| AppSyncTransportError::failed(source.to_string()))?; 7074 let relay_set_fingerprint = delivery_evidence.relay_set_fingerprint().ok_or_else(|| { 7075 AppSyncTransportError::failed("app relay ingest requires a non-empty relay set") 7076 })?; 7077 let relay_delivery_json = delivery_evidence 7078 .to_json_value() 7079 .map_err(|source| AppSyncTransportError::failed(source.to_string()))?; 7080 let tags = relay_event_tags(event); 7081 let kind = relay_event_kind(event); 7082 let event_pubkey = event.pubkey.to_string(); 7083 let listing_d_tag = relay_event_tag_value(&tags, "d", 1); 7084 let farm_id = direct_relay_event_farm_id(kind, &tags); 7085 let listing_addr = 7086 direct_relay_event_listing_addr(kind, &event_pubkey, listing_d_tag.as_deref()); 7087 let created_at_ms = relay_event_created_at_ms(event)?; 7088 let local_seq = created_at_ms.saturating_add(i64::try_from(index).map_err(|_| { 7089 AppSqliteError::InvalidProjection { 7090 reason: "app relay ingest sequence must fit i64", 7091 } 7092 })?); 7093 records.push(LocalEventRecord { 7094 seq: local_seq, 7095 change_seq: local_seq, 7096 record_id: format!("app:relay_event:{event_id}"), 7097 family: LocalRecordFamily::SignedEvent, 7098 status: LocalRecordStatus::Published, 7099 source_runtime: direct_relay_event_source_runtime(kind, listing_d_tag.as_deref()), 7100 created_at_ms, 7101 inserted_at_ms, 7102 updated_at_ms: inserted_at_ms, 7103 owner_account_id: None, 7104 owner_pubkey: Some(event_pubkey.clone()), 7105 farm_id, 7106 listing_addr, 7107 local_work_json: None, 7108 event_id: Some(event_id), 7109 event_kind: Some(i64::from(kind)), 7110 event_pubkey: Some(event_pubkey), 7111 event_created_at: Some(relay_event_created_at_i64(event)?), 7112 event_tags_json: Some(json!(tags)), 7113 event_content: Some(event.content.clone()), 7114 event_sig: Some(event.sig.to_string()), 7115 raw_event_json: Some(relay_raw_event_json(event)?), 7116 outbox_status: PublishOutboxStatus::None, 7117 relay_set_fingerprint: Some(relay_set_fingerprint.clone()), 7118 relay_delivery_json: Some(relay_delivery_json.clone()), 7119 }); 7120 } 7121 7122 Ok(records) 7123 } 7124 7125 fn direct_relay_event_farm_id(kind: u16, tags: &[Vec<String>]) -> Option<String> { 7126 match kind { 7127 kind if kind == KIND_FARM as u16 => relay_event_tag_value(tags, "d", 1), 7128 kind if kind == KIND_LISTING as u16 || kind == KIND_LISTING_DRAFT as u16 => { 7129 relay_event_tag_value(tags, "a", 1).and_then(|address| relay_address_d_tag(&address)) 7130 } 7131 _ => None, 7132 } 7133 } 7134 7135 fn direct_relay_event_listing_addr( 7136 kind: u16, 7137 event_pubkey: &str, 7138 listing_d_tag: Option<&str>, 7139 ) -> Option<String> { 7140 match kind { 7141 kind if kind == KIND_LISTING as u16 || kind == KIND_LISTING_DRAFT as u16 => { 7142 listing_d_tag.map(|d_tag| format!("{kind}:{event_pubkey}:{d_tag}")) 7143 } 7144 _ => None, 7145 } 7146 } 7147 7148 fn direct_relay_event_source_runtime(_kind: u16, _d_tag: Option<&str>) -> SourceRuntime { 7149 SourceRuntime::Network 7150 } 7151 7152 fn relay_event_kind(event: &RadrootsNostrEvent) -> u16 { 7153 event.kind.as_u16() 7154 } 7155 7156 fn relay_event_created_at_i64(event: &RadrootsNostrEvent) -> Result<i64, AppSqliteError> { 7157 i64::try_from(event.created_at.as_secs()).map_err(|_| AppSqliteError::InvalidProjection { 7158 reason: "app relay ingest event timestamp must fit i64", 7159 }) 7160 } 7161 7162 fn relay_event_created_at_unix_seconds_for_fetch( 7163 event: &RadrootsNostrEvent, 7164 ) -> Result<i64, AppSyncTransportError> { 7165 i64::try_from(event.created_at.as_secs()) 7166 .map_err(|_| AppSyncTransportError::failed("app relay ingest event timestamp must fit i64")) 7167 } 7168 7169 fn relay_event_created_at_ms(event: &RadrootsNostrEvent) -> Result<i64, AppSqliteError> { 7170 relay_event_created_at_i64(event)? 7171 .checked_mul(1_000) 7172 .ok_or(AppSqliteError::InvalidProjection { 7173 reason: "app relay ingest event timestamp milliseconds must fit i64", 7174 }) 7175 } 7176 7177 fn relay_event_tags(event: &RadrootsNostrEvent) -> Vec<Vec<String>> { 7178 event 7179 .tags 7180 .iter() 7181 .map(|tag| tag.as_slice().to_vec()) 7182 .collect() 7183 } 7184 7185 fn relay_event_tag_value(tags: &[Vec<String>], tag_name: &str, index: usize) -> Option<String> { 7186 tags.iter().find_map(|tag| { 7187 (tag.first().map(String::as_str) == Some(tag_name)) 7188 .then(|| tag.get(index)) 7189 .flatten() 7190 .map(String::as_str) 7191 .map(str::trim) 7192 .filter(|value| !value.is_empty()) 7193 .map(str::to_owned) 7194 }) 7195 } 7196 7197 fn relay_raw_event_json(event: &RadrootsNostrEvent) -> Result<serde_json::Value, AppSqliteError> { 7198 Ok(json!({ 7199 "id": event.id.to_hex(), 7200 "pubkey": event.pubkey.to_string(), 7201 "created_at": relay_event_created_at_i64(event)?, 7202 "kind": u32::from(event.kind.as_u16()), 7203 "tags": relay_event_tags(event), 7204 "content": event.content.clone(), 7205 "sig": event.sig.to_string(), 7206 })) 7207 } 7208 7209 fn relay_address_d_tag(address: &str) -> Option<String> { 7210 address 7211 .rsplit(':') 7212 .next() 7213 .map(str::trim) 7214 .filter(|value| !value.is_empty()) 7215 .map(str::to_owned) 7216 } 7217 7218 fn non_empty_string(value: &str) -> Option<String> { 7219 let trimmed = value.trim(); 7220 (!trimmed.is_empty()).then(|| trimmed.to_owned()) 7221 } 7222 7223 fn product_status_needs_relay_publish(status: ProductStatus) -> bool { 7224 !matches!(status, ProductStatus::Draft) 7225 } 7226 7227 fn listing_primary_bin_id(listing_d_tag: &str) -> String { 7228 format!("{listing_d_tag}:primary") 7229 } 7230 7231 fn listing_availability_window_times( 7232 draft: &ProductEditorDraft, 7233 farm_rules: &FarmRulesProjection, 7234 ) -> (Option<String>, Option<String>) { 7235 draft 7236 .availability_window_id 7237 .and_then(|window_id| { 7238 farm_rules 7239 .fulfillment_windows 7240 .iter() 7241 .find(|window| window.fulfillment_window_id == window_id) 7242 }) 7243 .map(|window| (Some(window.starts_at.clone()), Some(window.ends_at.clone()))) 7244 .unwrap_or((None, None)) 7245 } 7246 7247 fn listing_fulfillment_method( 7248 draft: &ProductEditorDraft, 7249 farm_setup: &FarmSetupProjection, 7250 farm_rules: &FarmRulesProjection, 7251 ) -> Option<String> { 7252 if draft.availability_window_id.is_some_and(|window_id| { 7253 farm_rules 7254 .fulfillment_windows 7255 .iter() 7256 .any(|window| window.fulfillment_window_id == window_id) 7257 }) { 7258 return Some(FarmOrderMethod::Pickup.storage_key().to_owned()); 7259 } 7260 7261 farm_setup 7262 .draft 7263 .order_methods 7264 .iter() 7265 .next() 7266 .map(|method| method.storage_key().to_owned()) 7267 } 7268 7269 fn listing_fulfillment_location( 7270 draft: &ProductEditorDraft, 7271 farm_setup: &FarmSetupProjection, 7272 farm_rules: &FarmRulesProjection, 7273 ) -> Option<String> { 7274 draft 7275 .availability_window_id 7276 .and_then(|window_id| { 7277 farm_rules 7278 .fulfillment_windows 7279 .iter() 7280 .find(|window| window.fulfillment_window_id == window_id) 7281 }) 7282 .and_then(|window| { 7283 farm_rules 7284 .pickup_locations 7285 .iter() 7286 .find(|location| location.pickup_location_id == window.pickup_location_id) 7287 }) 7288 .and_then(|location| { 7289 non_empty_string(location.address_line.as_str()) 7290 .or_else(|| non_empty_string(location.label.as_str())) 7291 }) 7292 .or_else(|| non_empty_string(farm_setup.draft.location_or_service_area.as_str())) 7293 } 7294 7295 fn farm_profile_publish_payload_to_sdk_farm( 7296 payload: &AppFarmProfilePublishPayload, 7297 ) -> RadrootsFarm { 7298 RadrootsFarm { 7299 d_tag: d_tag_from_uuid(payload.farm_id.as_uuid()), 7300 name: payload.display_name.trim().to_owned(), 7301 about: None, 7302 website: None, 7303 picture: None, 7304 banner: None, 7305 location: None, 7306 tags: payload.readiness.map(|readiness| match readiness { 7307 FarmReadiness::Incomplete => vec!["radroots:readiness:incomplete".to_owned()], 7308 FarmReadiness::Ready => vec!["radroots:readiness:ready".to_owned()], 7309 }), 7310 } 7311 } 7312 7313 fn farm_publish_source_record( 7314 farm_id: FarmId, 7315 source: &str, 7316 source_local_event_id: Option<&str>, 7317 ) -> (AppSdkMigrationReceiptSourceKind, String) { 7318 source_local_event_id 7319 .map(|record_id| { 7320 ( 7321 AppSdkMigrationReceiptSourceKind::SharedLocalEvent, 7322 record_id.to_owned(), 7323 ) 7324 }) 7325 .unwrap_or_else(|| { 7326 ( 7327 AppSdkMigrationReceiptSourceKind::LocalOutbox, 7328 format!("app:farm_publish:{farm_id}:{source}"), 7329 ) 7330 }) 7331 } 7332 7333 fn listing_publish_source_record( 7334 product_id: ProductId, 7335 source: &str, 7336 source_local_event_id: Option<&str>, 7337 ) -> (AppSdkMigrationReceiptSourceKind, String) { 7338 source_local_event_id 7339 .map(|record_id| { 7340 ( 7341 AppSdkMigrationReceiptSourceKind::SharedLocalEvent, 7342 record_id.to_owned(), 7343 ) 7344 }) 7345 .unwrap_or_else(|| { 7346 ( 7347 AppSdkMigrationReceiptSourceKind::LocalOutbox, 7348 format!("app:listing_publish:{product_id}:{source}"), 7349 ) 7350 }) 7351 } 7352 7353 fn order_decision_sdk_source_record_id(payload: &AppOrderDecisionPublishPayload) -> String { 7354 format!("app:order_decision:{}", payload.app_order_id) 7355 } 7356 7357 fn order_revision_proposal_sdk_source_record_id( 7358 payload: &AppOrderRevisionProposalPublishPayload, 7359 ) -> String { 7360 format!( 7361 "app:order_revision_proposal:{}:{}", 7362 payload.app_order_id, payload.revision_id 7363 ) 7364 } 7365 7366 fn order_revision_decision_sdk_source_record_id( 7367 payload: &AppOrderRevisionDecisionPublishPayload, 7368 ) -> String { 7369 format!( 7370 "app:order_revision_decision:{}:{}", 7371 payload.app_order_id, payload.revision_id 7372 ) 7373 } 7374 7375 fn order_cancellation_sdk_source_record_id(payload: &AppOrderCancellationPublishPayload) -> String { 7376 format!("app:order_cancellation:{}", payload.app_order_id) 7377 } 7378 7379 fn sdk_relay_url_policy_for_targets(target_relays: &[String]) -> AppSdkRelayUrlPolicy { 7380 if target_relays 7381 .iter() 7382 .any(|relay_url| relay_url.trim().starts_with("ws://")) 7383 { 7384 AppSdkRelayUrlPolicy::Localhost 7385 } else { 7386 AppSdkRelayUrlPolicy::Public 7387 } 7388 } 7389 7390 fn sdk_idempotency_key(source_record_id: &str) -> String { 7391 format!( 7392 "app-{}", 7393 Uuid::new_v5(&Uuid::NAMESPACE_URL, source_record_id.as_bytes()) 7394 ) 7395 } 7396 7397 fn sdk_runtime_unavailable_error() -> AppSdkRuntimeError { 7398 AppSdkRuntimeError::CommandFailed(AppSdkRuntimeIssue { 7399 code: "sdk_runtime_not_available".to_owned(), 7400 class: "runtime".to_owned(), 7401 retryable: true, 7402 message: "app SDK runtime is not available".to_owned(), 7403 recovery_actions: vec!["retry_startup".to_owned()], 7404 detail_json: json!({ 7405 "code": "sdk_runtime_not_available", 7406 "class": "runtime", 7407 "retryable": true, 7408 "recovery_actions": ["retry_startup"], 7409 }), 7410 }) 7411 } 7412 7413 fn sync_transport_error_from_sdk_runtime_error(error: AppSdkRuntimeError) -> AppSyncTransportError { 7414 AppSyncTransportError::failed(sdk_runtime_error_detail_json(&error).to_string()) 7415 } 7416 7417 fn sdk_runtime_error_detail_json(error: &AppSdkRuntimeError) -> serde_json::Value { 7418 match error { 7419 AppSdkRuntimeError::CommandFailed(issue) => issue.detail_json.clone(), 7420 AppSdkRuntimeError::CommandQueueCapacityZero => json!({ 7421 "code": "sdk_command_queue_capacity_zero", 7422 "class": "runtime", 7423 "retryable": false, 7424 "message": error.to_string(), 7425 "recovery_actions": ["review_runtime_configuration"], 7426 }), 7427 AppSdkRuntimeError::WorkerSpawn(_) => json!({ 7428 "code": "sdk_worker_spawn_failed", 7429 "class": "runtime", 7430 "retryable": true, 7431 "message": error.to_string(), 7432 "recovery_actions": ["retry_startup"], 7433 }), 7434 AppSdkRuntimeError::CommandQueueFull => json!({ 7435 "code": "sdk_command_queue_full", 7436 "class": "runtime", 7437 "retryable": true, 7438 "message": error.to_string(), 7439 "recovery_actions": ["retry_command"], 7440 }), 7441 AppSdkRuntimeError::CommandQueueClosed => json!({ 7442 "code": "sdk_command_queue_closed", 7443 "class": "runtime", 7444 "retryable": true, 7445 "message": error.to_string(), 7446 "recovery_actions": ["restart_runtime"], 7447 }), 7448 AppSdkRuntimeError::CommandResponseClosed => json!({ 7449 "code": "sdk_command_response_closed", 7450 "class": "runtime", 7451 "retryable": true, 7452 "message": error.to_string(), 7453 "recovery_actions": ["restart_runtime"], 7454 }), 7455 AppSdkRuntimeError::ShutdownAck => json!({ 7456 "code": "sdk_shutdown_ack_failed", 7457 "class": "runtime", 7458 "retryable": true, 7459 "message": error.to_string(), 7460 "recovery_actions": ["restart_runtime"], 7461 }), 7462 AppSdkRuntimeError::WorkerJoin => json!({ 7463 "code": "sdk_worker_join_failed", 7464 "class": "runtime", 7465 "retryable": true, 7466 "message": error.to_string(), 7467 "recovery_actions": ["restart_runtime"], 7468 }), 7469 } 7470 } 7471 7472 fn sync_transport_error_detail_json(error: &AppSyncTransportError) -> serde_json::Value { 7473 match error { 7474 AppSyncTransportError::Unavailable { message } => json!({ 7475 "code": "app_sync_transport_unavailable", 7476 "class": "runtime", 7477 "retryable": true, 7478 "message": message, 7479 "recovery_actions": ["retry_after_runtime_ready"], 7480 }), 7481 AppSyncTransportError::Failed { message } => serde_json::from_str(message.as_str()) 7482 .unwrap_or_else(|_| { 7483 json!({ 7484 "code": "app_sync_transport_failed", 7485 "class": "operation", 7486 "retryable": true, 7487 "message": message, 7488 "recovery_actions": ["retry_publish"], 7489 }) 7490 }), 7491 } 7492 } 7493 7494 fn listing_publish_payload_to_sdk_listing( 7495 payload: &AppListingPublishPayload, 7496 ) -> Result<RadrootsListing, AppSyncTransportError> { 7497 let currency = payload 7498 .price_currency 7499 .parse::<RadrootsCoreCurrency>() 7500 .map_err(|error| AppSyncTransportError::failed(error.to_string()))?; 7501 let unit = parse_app_listing_unit(payload.unit_label.as_str())?; 7502 let price_minor_units = payload.price_minor_units.ok_or_else(|| { 7503 AppSyncTransportError::failed("publishable listing requires price minor units") 7504 })?; 7505 let farm_id = payload 7506 .farm_id 7507 .ok_or_else(|| AppSyncTransportError::failed("publishable listing requires farm id"))?; 7508 let farm_pubkey = payload 7509 .farm_pubkey 7510 .as_deref() 7511 .ok_or_else(|| AppSyncTransportError::failed("publishable listing requires farm pubkey"))? 7512 .trim() 7513 .to_owned(); 7514 let d_tag = payload 7515 .listing_d_tag 7516 .as_deref() 7517 .filter(|value| !value.trim().is_empty()) 7518 .map(str::to_owned) 7519 .unwrap_or_else(|| d_tag_from_uuid(payload.product_id.as_uuid())); 7520 let d_tag = RadrootsDTag::parse(d_tag.as_str()) 7521 .map_err(|error| AppSyncTransportError::failed(error.to_string()))?; 7522 let bin_id = RadrootsInventoryBinId::parse(listing_primary_bin_id(d_tag.as_str())) 7523 .map_err(|error| AppSyncTransportError::failed(error.to_string()))?; 7524 7525 Ok(RadrootsListing { 7526 d_tag, 7527 farm: RadrootsFarmRef { 7528 pubkey: farm_pubkey, 7529 d_tag: payload 7530 .farm_d_tag 7531 .as_deref() 7532 .filter(|value| !value.trim().is_empty()) 7533 .map(str::to_owned) 7534 .unwrap_or_else(|| d_tag_from_uuid(farm_id.as_uuid())), 7535 }, 7536 product: RadrootsListingProduct { 7537 key: payload.product_id.to_string(), 7538 title: payload.title.trim().to_owned(), 7539 category: payload 7540 .category 7541 .as_deref() 7542 .unwrap_or_default() 7543 .trim() 7544 .to_owned(), 7545 summary: payload 7546 .subtitle 7547 .as_deref() 7548 .filter(|value| !value.trim().is_empty()) 7549 .map(str::to_owned), 7550 process: None, 7551 lot: None, 7552 location: None, 7553 profile: None, 7554 year: None, 7555 }, 7556 primary_bin_id: bin_id.clone(), 7557 bins: vec![RadrootsListingBin { 7558 bin_id, 7559 quantity: RadrootsCoreQuantity::new(RadrootsCoreDecimal::from(1u32), unit), 7560 price_per_canonical_unit: RadrootsCoreQuantityPrice::new( 7561 RadrootsCoreMoney::from_minor_units_u32(price_minor_units, currency), 7562 RadrootsCoreQuantity::new(RadrootsCoreDecimal::from(1u32), unit), 7563 ), 7564 display_amount: Some(RadrootsCoreDecimal::from(1u32)), 7565 display_unit: Some(unit), 7566 display_label: Some(payload.unit_label.trim().to_owned()), 7567 display_price: Some(RadrootsCoreMoney::from_minor_units_u32( 7568 price_minor_units, 7569 currency, 7570 )), 7571 display_price_unit: Some(unit), 7572 }], 7573 resource_area: None, 7574 plot: None, 7575 discounts: None, 7576 inventory_available: payload.stock_quantity.map(RadrootsCoreDecimal::from), 7577 availability: listing_publish_payload_availability(payload)?, 7578 delivery_method: Some(parse_app_listing_delivery_method( 7579 payload.fulfillment_method.as_deref().unwrap_or_default(), 7580 )?), 7581 location: payload 7582 .fulfillment_location 7583 .as_deref() 7584 .filter(|value| !value.trim().is_empty()) 7585 .map(|primary| RadrootsListingLocation { 7586 primary: primary.trim().to_owned(), 7587 city: None, 7588 region: None, 7589 country: None, 7590 lat: None, 7591 lng: None, 7592 geohash: None, 7593 }), 7594 published_at: None, 7595 images: None, 7596 }) 7597 } 7598 7599 fn listing_publish_payload_availability( 7600 payload: &AppListingPublishPayload, 7601 ) -> Result<Option<RadrootsListingAvailability>, AppSyncTransportError> { 7602 if payload.status == ProductStatus::Published { 7603 let start = parse_listing_availability_timestamp( 7604 payload.availability_starts_at.as_deref(), 7605 "publishable listing requires availability start", 7606 )?; 7607 let end = parse_listing_availability_timestamp( 7608 payload.availability_ends_at.as_deref(), 7609 "publishable listing requires availability end", 7610 )?; 7611 if end <= start { 7612 return Err(AppSyncTransportError::failed( 7613 "publishable listing availability end must be after start", 7614 )); 7615 } 7616 return Ok(Some(RadrootsListingAvailability::Window { 7617 start: Some(start), 7618 end: Some(end), 7619 })); 7620 } 7621 7622 Ok(Some(RadrootsListingAvailability::Status { 7623 status: match payload.status { 7624 ProductStatus::Archived => RadrootsListingStatus::Sold, 7625 other => RadrootsListingStatus::Other { 7626 value: other.storage_key().to_owned(), 7627 }, 7628 }, 7629 })) 7630 } 7631 7632 fn parse_listing_availability_timestamp( 7633 value: Option<&str>, 7634 missing_message: &'static str, 7635 ) -> Result<u64, AppSyncTransportError> { 7636 let value = value 7637 .map(str::trim) 7638 .filter(|value| !value.is_empty()) 7639 .ok_or_else(|| AppSyncTransportError::failed(missing_message))?; 7640 let timestamp = DateTime::parse_from_rfc3339(value) 7641 .map_err(|error| AppSyncTransportError::failed(error.to_string()))? 7642 .timestamp(); 7643 u64::try_from(timestamp) 7644 .map_err(|_| AppSyncTransportError::failed("listing availability timestamp is negative")) 7645 } 7646 7647 fn parse_app_listing_unit(value: &str) -> Result<RadrootsCoreUnit, AppSyncTransportError> { 7648 match value.trim().to_ascii_lowercase().as_str() { 7649 "each" | "ea" | "unit" | "units" => Ok(RadrootsCoreUnit::Each), 7650 "kg" | "kilogram" | "kilograms" => Ok(RadrootsCoreUnit::MassKg), 7651 "g" | "gram" | "grams" => Ok(RadrootsCoreUnit::MassG), 7652 "oz" | "ounce" | "ounces" => Ok(RadrootsCoreUnit::MassOz), 7653 "lb" | "pound" | "pounds" => Ok(RadrootsCoreUnit::MassLb), 7654 "l" | "liter" | "liters" => Ok(RadrootsCoreUnit::VolumeL), 7655 "ml" | "milliliter" | "milliliters" => Ok(RadrootsCoreUnit::VolumeMl), 7656 other => Err(AppSyncTransportError::failed(format!( 7657 "unsupported listing unit `{other}`" 7658 ))), 7659 } 7660 } 7661 7662 fn parse_app_listing_delivery_method( 7663 value: &str, 7664 ) -> Result<RadrootsListingDeliveryMethod, AppSyncTransportError> { 7665 match value.trim().to_ascii_lowercase().as_str() { 7666 "pickup" | "local_pickup" => Ok(RadrootsListingDeliveryMethod::Pickup), 7667 "delivery" | "local_delivery" => Ok(RadrootsListingDeliveryMethod::LocalDelivery), 7668 "shipping" | "ship" => Ok(RadrootsListingDeliveryMethod::Shipping), 7669 "" => Err(AppSyncTransportError::failed( 7670 "publishable listing requires fulfillment method", 7671 )), 7672 other => Ok(RadrootsListingDeliveryMethod::Other { 7673 method: other.to_owned(), 7674 }), 7675 } 7676 } 7677 7678 fn order_request_sdk_listing_event_ptr( 7679 payload: &AppOrderRequestPublishPayload, 7680 ) -> Result<RadrootsNostrEventPtr, AppSyncTransportError> { 7681 let listing_event_id = payload 7682 .listing_event_id 7683 .as_deref() 7684 .ok_or_else(|| { 7685 AppSyncTransportError::failed("order request publish requires listing event id") 7686 })? 7687 .trim() 7688 .to_owned(); 7689 let listing_relay = normalized_listing_relays(payload.listing_relays.as_slice())? 7690 .into_iter() 7691 .next(); 7692 7693 Ok(RadrootsNostrEventPtr { 7694 id: listing_event_id, 7695 relays: listing_relay, 7696 }) 7697 } 7698 7699 fn order_request_sdk_target_relays( 7700 payload: &AppOrderRequestPublishPayload, 7701 configured_relay_urls: &[String], 7702 ) -> Result<Vec<String>, AppSyncTransportError> { 7703 let known_relays = normalized_listing_relays(payload.listing_relays.as_slice())?; 7704 let configured_relays = configured_relay_urls 7705 .iter() 7706 .map(|relay| relay.trim()) 7707 .filter(|relay| !relay.is_empty()) 7708 .map(str::to_owned) 7709 .collect::<BTreeSet<_>>(); 7710 if configured_relays.is_empty() { 7711 return Ok(known_relays); 7712 } 7713 let selected_relays = known_relays 7714 .iter() 7715 .filter(|relay| configured_relays.contains(*relay)) 7716 .cloned() 7717 .collect::<Vec<_>>(); 7718 if selected_relays.is_empty() { 7719 return Ok(known_relays); 7720 } 7721 Ok(selected_relays) 7722 } 7723 7724 fn order_decision_sdk_request_event_ptr( 7725 payload: &AppOrderDecisionPublishPayload, 7726 target_relays: &[String], 7727 ) -> Result<RadrootsNostrEventPtr, AppSyncTransportError> { 7728 let request_event_id = payload.request_event_id.trim(); 7729 if request_event_id.is_empty() { 7730 return Err(AppSyncTransportError::failed( 7731 "order decision publish requires request event id", 7732 )); 7733 } 7734 Ok(RadrootsNostrEventPtr { 7735 id: request_event_id.to_owned(), 7736 relays: target_relays.first().cloned(), 7737 }) 7738 } 7739 7740 fn order_lifecycle_sdk_event_ptr( 7741 event_id: &str, 7742 target_relays: &[String], 7743 missing_message: &'static str, 7744 ) -> Result<RadrootsNostrEventPtr, AppSyncTransportError> { 7745 let event_id = event_id.trim(); 7746 if event_id.is_empty() { 7747 return Err(AppSyncTransportError::failed(missing_message)); 7748 } 7749 Ok(RadrootsNostrEventPtr { 7750 id: event_id.to_owned(), 7751 relays: target_relays.first().cloned(), 7752 }) 7753 } 7754 7755 #[cfg(test)] 7756 fn selected_listing_relay( 7757 listing_relays: &[String], 7758 configured_relay_urls: &[String], 7759 ) -> Result<String, AppSyncTransportError> { 7760 let known_relays = normalized_listing_relays(listing_relays)?; 7761 for configured_relay in configured_relay_urls { 7762 let configured_relay = configured_relay.trim(); 7763 if !configured_relay.is_empty() 7764 && known_relays.iter().any(|relay| relay == configured_relay) 7765 { 7766 return Ok(configured_relay.to_owned()); 7767 } 7768 } 7769 Err(missing_listing_provenance_relay_error(&known_relays)) 7770 } 7771 7772 fn normalized_listing_relays( 7773 listing_relays: &[String], 7774 ) -> Result<Vec<String>, AppSyncTransportError> { 7775 let mut seen = BTreeSet::new(); 7776 let mut known_relays = Vec::new(); 7777 for relay in listing_relays { 7778 let relay = relay.trim(); 7779 if !relay.is_empty() && seen.insert(relay.to_owned()) { 7780 known_relays.push(relay.to_owned()); 7781 } 7782 } 7783 if known_relays.is_empty() { 7784 return Err(AppSyncTransportError::failed( 7785 "order request publish requires listing relay", 7786 )); 7787 } 7788 Ok(known_relays) 7789 } 7790 7791 #[cfg(test)] 7792 fn missing_listing_provenance_relay_error(known_relays: &[String]) -> AppSyncTransportError { 7793 AppSyncTransportError::failed( 7794 json!({ 7795 "code": "missing_listing_provenance_relay", 7796 "missing_provenance_relays": known_relays, 7797 }) 7798 .to_string(), 7799 ) 7800 } 7801 7802 fn order_request_publish_payload_to_sdk_order( 7803 payload: &AppOrderRequestPublishPayload, 7804 ) -> Result<RadrootsOrderRequest, AppSyncTransportError> { 7805 let Some(document_json) = payload.order_document_json.as_ref() else { 7806 return Err(AppSyncTransportError::failed( 7807 "order request publish requires order document", 7808 )); 7809 }; 7810 let order_json = document_json 7811 .pointer("/document/order") 7812 .or_else(|| document_json.get("order")) 7813 .unwrap_or(document_json); 7814 serde_json::from_value::<RadrootsOrderRequest>(order_json.clone()) 7815 .map_err(|error| AppSyncTransportError::failed(error.to_string())) 7816 } 7817 7818 fn d_tag_from_uuid(uuid: Uuid) -> String { 7819 base64_url_no_pad(uuid.as_bytes()) 7820 } 7821 7822 fn signed_event_farm_id(receipt: &AppPublishedOperationReceipt) -> Option<String> { 7823 match receipt.event_kind { 7824 KIND_FARM => signed_event_tag_value(&receipt.event_tags_json, "d", 1), 7825 KIND_LISTING => signed_event_tag_value(&receipt.event_tags_json, "a", 1) 7826 .and_then(|address| signed_event_address_d_tag(address.as_str())), 7827 _ => None, 7828 } 7829 } 7830 7831 fn signed_event_listing_addr(receipt: &AppPublishedOperationReceipt) -> Option<String> { 7832 if receipt.event_kind != KIND_LISTING { 7833 return None; 7834 } 7835 let pubkey = receipt.event_pubkey.trim(); 7836 if pubkey.is_empty() { 7837 return None; 7838 } 7839 signed_event_tag_value(&receipt.event_tags_json, "d", 1) 7840 .map(|d_tag| format!("{KIND_LISTING}:{pubkey}:{d_tag}")) 7841 } 7842 7843 fn signed_event_tag_value( 7844 tags: &serde_json::Value, 7845 tag_name: &str, 7846 index: usize, 7847 ) -> Option<String> { 7848 tags.as_array()?.iter().find_map(|tag| { 7849 let values = tag.as_array()?; 7850 (values.first()?.as_str()? == tag_name) 7851 .then(|| values.get(index).and_then(serde_json::Value::as_str)) 7852 .flatten() 7853 .map(str::trim) 7854 .filter(|value| !value.is_empty()) 7855 .map(str::to_owned) 7856 }) 7857 } 7858 7859 fn signed_event_address_d_tag(address: &str) -> Option<String> { 7860 address 7861 .rsplit(':') 7862 .next() 7863 .map(str::trim) 7864 .filter(|value| !value.is_empty()) 7865 .map(str::to_owned) 7866 } 7867 7868 fn base64_url_no_pad(bytes: &[u8]) -> String { 7869 const ALPHABET: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_"; 7870 let mut output = String::with_capacity((bytes.len() * 4).div_ceil(3)); 7871 let mut chunks = bytes.chunks_exact(3); 7872 for chunk in &mut chunks { 7873 output.push(ALPHABET[(chunk[0] >> 2) as usize] as char); 7874 output.push(ALPHABET[(((chunk[0] & 0b0000_0011) << 4) | (chunk[1] >> 4)) as usize] as char); 7875 output.push(ALPHABET[(((chunk[1] & 0b0000_1111) << 2) | (chunk[2] >> 6)) as usize] as char); 7876 output.push(ALPHABET[(chunk[2] & 0b0011_1111) as usize] as char); 7877 } 7878 match chunks.remainder() { 7879 [one] => { 7880 output.push(ALPHABET[(one >> 2) as usize] as char); 7881 output.push(ALPHABET[((one & 0b0000_0011) << 4) as usize] as char); 7882 } 7883 [one, two] => { 7884 output.push(ALPHABET[(one >> 2) as usize] as char); 7885 output.push(ALPHABET[(((one & 0b0000_0011) << 4) | (two >> 4)) as usize] as char); 7886 output.push(ALPHABET[((two & 0b0000_1111) << 2) as usize] as char); 7887 } 7888 [] => {} 7889 _ => {} 7890 } 7891 output 7892 } 7893 7894 fn decimal_from_minor_units(value: u32) -> String { 7895 format!("{}.{:02}", value / 100, value % 100) 7896 } 7897 7898 fn normalize_currency_code(value: &str) -> String { 7899 let trimmed = value.trim(); 7900 if trimmed.is_empty() { 7901 "USD".to_owned() 7902 } else { 7903 trimmed.to_ascii_uppercase() 7904 } 7905 } 7906 7907 fn is_hex_64(value: &str) -> bool { 7908 value.len() == 64 && value.bytes().all(|byte| byte.is_ascii_hexdigit()) 7909 } 7910 7911 fn local_work_exportability(owner_pubkey: Option<&str>) -> serde_json::Value { 7912 match owner_pubkey { 7913 Some(_) => json!({ 7914 "state": "exportable" 7915 }), 7916 None => json!({ 7917 "state": "identity_unresolved", 7918 "reason": "canonical_hex_pubkey_required" 7919 }), 7920 } 7921 } 7922 7923 #[derive(Clone, Debug, Eq, PartialEq)] 7924 struct AppBuyerOrderRequestExport { 7925 buyer_pubkey: Option<String>, 7926 seller_pubkey: Option<String>, 7927 listing_addr: Option<String>, 7928 listing_event_id: Option<String>, 7929 listing_relays: Vec<String>, 7930 farm_key: Option<String>, 7931 order_items: Vec<serde_json::Value>, 7932 line_refs: Vec<serde_json::Value>, 7933 economics: Option<serde_json::Value>, 7934 support_issues: Vec<&'static str>, 7935 } 7936 7937 #[derive(Clone, Debug)] 7938 struct AppOrderLocalWorkPublishSource { 7939 record_id: String, 7940 payload: serde_json::Value, 7941 } 7942 7943 impl AppBuyerOrderRequestExport { 7944 fn from_order( 7945 order: &BuyerOrderLocalEventExport, 7946 buyer_pubkey: Option<&str>, 7947 ) -> Result<Self, AppSqliteError> { 7948 let mut support_issues = Vec::new(); 7949 if buyer_pubkey.is_none() { 7950 support_issues.push("buyer_pubkey_required"); 7951 } 7952 if order.lines.is_empty() { 7953 support_issues.push("order_lines_required"); 7954 } 7955 let listing_addr = 7956 shared_optional_line_value(&order.lines, |line| line.listing_addr.as_deref()); 7957 let listing_event_id = 7958 shared_optional_line_value(&order.lines, |line| line.listing_event_id.as_deref()); 7959 let listing_relays = shared_listing_relays(&order.lines); 7960 let seller_pubkey = 7961 shared_optional_line_value(&order.lines, |line| line.seller_pubkey.as_deref()); 7962 let farm_key = shared_optional_line_value(&order.lines, |line| line.farm_key.as_deref()) 7963 .or_else(|| Some(d_tag_from_uuid(order.farm_id.as_uuid()))); 7964 7965 if listing_addr.is_none() { 7966 support_issues.push("single_listing_addr_required"); 7967 } 7968 if listing_event_id.is_none() { 7969 support_issues.push("listing_event_id_required"); 7970 } 7971 if listing_relays.is_empty() { 7972 support_issues.push("listing_relays_required"); 7973 } 7974 if seller_pubkey.is_none() { 7975 support_issues.push("seller_pubkey_required"); 7976 } 7977 7978 let mut order_items = Vec::with_capacity(order.lines.len()); 7979 let mut line_refs = Vec::with_capacity(order.lines.len()); 7980 for line in &order.lines { 7981 let line_bin_id = line 7982 .listing_bin_id 7983 .as_deref() 7984 .map(str::trim) 7985 .filter(|value| !value.is_empty()); 7986 if line_bin_id.is_none() && !support_issues.contains(&"listing_bin_id_required") { 7987 support_issues.push("listing_bin_id_required"); 7988 } 7989 order_items.push(json!({ 7990 "bin_id": line_bin_id.unwrap_or_default(), 7991 "bin_count": line.quantity, 7992 })); 7993 line_refs.push(json!({ 7994 "product_id": line.product_id.to_string(), 7995 "title": line.title, 7996 "quantity": { 7997 "count": line.quantity, 7998 "display": line.quantity_display, 7999 "unit_label": line.quantity_unit_label, 8000 }, 8001 "listing_addr": line.listing_addr, 8002 "listing_event_id": line.listing_event_id, 8003 "listing_relays": line.listing_relays, 8004 "listing_bin_id": line.listing_bin_id, 8005 "seller_pubkey": line.seller_pubkey, 8006 "farm_key": line.farm_key, 8007 })); 8008 } 8009 8010 let economics = order_economics_json(order, &mut support_issues)?; 8011 8012 Ok(Self { 8013 buyer_pubkey: buyer_pubkey.map(str::to_owned), 8014 seller_pubkey, 8015 listing_addr, 8016 listing_event_id, 8017 listing_relays, 8018 farm_key, 8019 order_items, 8020 line_refs, 8021 economics, 8022 support_issues, 8023 }) 8024 } 8025 8026 fn is_supported(&self) -> bool { 8027 self.support_issues.is_empty() 8028 } 8029 } 8030 8031 fn buyer_order_request_local_work_payload( 8032 order: &BuyerOrderLocalEventExport, 8033 buyer_context: &BuyerContext, 8034 record_id: &str, 8035 export: &AppBuyerOrderRequestExport, 8036 timestamp: i64, 8037 ) -> serde_json::Value { 8038 let buyer_account_id = match buyer_context { 8039 BuyerContext::Account(account_id) => account_id.as_str(), 8040 BuyerContext::Guest => "", 8041 }; 8042 let buyer_actor_source = if export.buyer_pubkey.is_some() { 8043 BUYER_ORDER_REQUEST_ACTOR_SOURCE_RESOLVED_ACCOUNT 8044 } else { 8045 BUYER_ORDER_REQUEST_ACTOR_SOURCE_UNRESOLVED_APP 8046 }; 8047 8048 json!({ 8049 "record_kind": BUYER_ORDER_REQUEST_LOCAL_WORK_RECORD_KIND, 8050 "scope": "app", 8051 "exportability": local_work_exportability(export.buyer_pubkey.as_deref()), 8052 "support_status": { 8053 "state": if export.is_supported() { "supported" } else { "unsupported" }, 8054 "issues": export.support_issues.clone(), 8055 }, 8056 "currentness": { 8057 "current": true, 8058 "source": "app_sqlite_order", 8059 "record_id": record_id, 8060 "order_id": order.order_id.to_string(), 8061 "order_updated_at": order.updated_at, 8062 "created_at_ms": timestamp, 8063 }, 8064 "document": { 8065 "version": 1, 8066 "kind": BUYER_ORDER_REQUEST_DOCUMENT_KIND, 8067 "order": { 8068 "order_id": order.order_id.to_string(), 8069 "listing_addr": export.listing_addr.as_deref().unwrap_or_default(), 8070 "listing_event_id": export.listing_event_id.as_deref().unwrap_or_default(), 8071 "listing_relays": export.listing_relays.clone(), 8072 "buyer_pubkey": export.buyer_pubkey.as_deref().unwrap_or_default(), 8073 "seller_pubkey": export.seller_pubkey.as_deref().unwrap_or_default(), 8074 "items": export.order_items.clone(), 8075 "economics": export.economics.clone(), 8076 }, 8077 "buyer_actor": { 8078 "account_id": buyer_account_id, 8079 "pubkey": export.buyer_pubkey.as_deref().unwrap_or_default(), 8080 "source": buyer_actor_source, 8081 }, 8082 "listing_lookup": export.listing_addr.clone(), 8083 }, 8084 "app_order": { 8085 "order_id": order.order_id.to_string(), 8086 "order_number": order.order_number, 8087 "farm_id": order.farm_id.to_string(), 8088 "farm_display_name": order.farm_display_name, 8089 "farm_key": export.farm_key.clone(), 8090 "status": order.status, 8091 "buyer_context_key": order.buyer_context_key, 8092 "buyer_name": order.buyer_name, 8093 "buyer_email": order.buyer_email, 8094 "buyer_phone": order.buyer_phone, 8095 "buyer_order_note": order.buyer_order_note, 8096 "fulfillment": { 8097 "window_id": order.fulfillment_window_id.map(|id| id.to_string()), 8098 "label": order.fulfillment_window_label, 8099 "starts_at": order.fulfillment_starts_at, 8100 "ends_at": order.fulfillment_ends_at, 8101 }, 8102 "lines": export.line_refs.clone(), 8103 }, 8104 }) 8105 } 8106 8107 fn order_economics_json( 8108 order: &BuyerOrderLocalEventExport, 8109 support_issues: &mut Vec<&'static str>, 8110 ) -> Result<Option<serde_json::Value>, AppSqliteError> { 8111 let mut economics_items = Vec::with_capacity(order.lines.len()); 8112 let mut subtotal_minor_units = 0_u32; 8113 let mut currency = None::<String>; 8114 8115 for line in &order.lines { 8116 let line_bin_id = line 8117 .listing_bin_id 8118 .as_deref() 8119 .map(str::trim) 8120 .filter(|value| !value.is_empty()); 8121 if line_bin_id.is_none() && !support_issues.contains(&"listing_bin_id_required") { 8122 support_issues.push("listing_bin_id_required"); 8123 continue; 8124 } 8125 let Some(quantity_unit) = canonical_quantity_unit(line.quantity_unit_label.as_str()) else { 8126 support_issues.push("canonical_quantity_unit_required"); 8127 continue; 8128 }; 8129 let Some(unit_price_minor_units) = line.unit_price_minor_units else { 8130 support_issues.push("unit_price_required"); 8131 continue; 8132 }; 8133 if unit_price_minor_units == 0 { 8134 support_issues.push("positive_unit_price_required"); 8135 continue; 8136 } 8137 let line_currency = normalize_currency_code(line.price_currency.as_str()); 8138 if line_currency.len() != 3 || !line_currency.bytes().all(|byte| byte.is_ascii_uppercase()) 8139 { 8140 support_issues.push("canonical_currency_required"); 8141 continue; 8142 } 8143 if let Some(existing_currency) = currency.as_deref() { 8144 if existing_currency != line_currency { 8145 support_issues.push("single_currency_required"); 8146 continue; 8147 } 8148 } else { 8149 currency = Some(line_currency.clone()); 8150 } 8151 let line_subtotal_minor_units = unit_price_minor_units.checked_mul(line.quantity).ok_or( 8152 AppSqliteError::InvalidProjection { 8153 reason: "buyer order local event line subtotal overflowed", 8154 }, 8155 )?; 8156 subtotal_minor_units = subtotal_minor_units 8157 .checked_add(line_subtotal_minor_units) 8158 .ok_or(AppSqliteError::InvalidProjection { 8159 reason: "buyer order local event subtotal overflowed", 8160 })?; 8161 economics_items.push(json!({ 8162 "bin_id": line_bin_id.unwrap_or_default(), 8163 "bin_count": line.quantity, 8164 "quantity_amount": "1", 8165 "quantity_unit": quantity_unit, 8166 "unit_price_amount": decimal_from_minor_units(unit_price_minor_units), 8167 "unit_price_currency": line_currency, 8168 "line_subtotal": { 8169 "amount": decimal_from_minor_units(line_subtotal_minor_units), 8170 "currency": line_currency, 8171 }, 8172 })); 8173 } 8174 8175 if economics_items.len() != order.lines.len() || economics_items.is_empty() { 8176 return Ok(None); 8177 } 8178 8179 let currency = currency.unwrap_or_else(|| "USD".to_owned()); 8180 let subtotal = json!({ 8181 "amount": decimal_from_minor_units(subtotal_minor_units), 8182 "currency": currency, 8183 }); 8184 Ok(Some(json!({ 8185 "quote_id": format!("app-order:{}", order.order_id), 8186 "quote_version": 1, 8187 "pricing_basis": "listing_event", 8188 "currency": currency, 8189 "items": economics_items, 8190 "discounts": [], 8191 "adjustments": [], 8192 "subtotal": subtotal, 8193 "discount_total": { 8194 "amount": "0", 8195 "currency": currency, 8196 }, 8197 "adjustment_total": { 8198 "amount": "0", 8199 "currency": currency, 8200 }, 8201 "total": subtotal, 8202 }))) 8203 } 8204 8205 fn order_currency_and_total( 8206 order: &BuyerOrderLocalEventExport, 8207 ) -> Result<Option<(String, u32)>, AppSqliteError> { 8208 let mut currency = None::<String>; 8209 let mut total_minor_units = 0_u32; 8210 8211 for line in &order.lines { 8212 let Some(unit_price_minor_units) = line.unit_price_minor_units else { 8213 return Ok(None); 8214 }; 8215 let line_currency = normalize_currency_code(line.price_currency.as_str()); 8216 if line_currency.len() != 3 || !line_currency.bytes().all(|byte| byte.is_ascii_uppercase()) 8217 { 8218 return Ok(None); 8219 } 8220 if let Some(existing_currency) = currency.as_deref() { 8221 if existing_currency != line_currency { 8222 return Ok(None); 8223 } 8224 } else { 8225 currency = Some(line_currency.clone()); 8226 } 8227 let line_total = unit_price_minor_units.checked_mul(line.quantity).ok_or( 8228 AppSqliteError::InvalidProjection { 8229 reason: "buyer order publish line total overflowed", 8230 }, 8231 )?; 8232 total_minor_units = 8233 total_minor_units 8234 .checked_add(line_total) 8235 .ok_or(AppSqliteError::InvalidProjection { 8236 reason: "buyer order publish total overflowed", 8237 })?; 8238 } 8239 8240 Ok(currency.map(|currency| (currency, total_minor_units))) 8241 } 8242 8243 fn shared_optional_line_value( 8244 lines: &[BuyerOrderLocalEventLine], 8245 value: impl Fn(&BuyerOrderLocalEventLine) -> Option<&str>, 8246 ) -> Option<String> { 8247 let mut resolved = None::<String>; 8248 for line in lines { 8249 let Some(next) = value(line).map(str::trim).filter(|next| !next.is_empty()) else { 8250 return None; 8251 }; 8252 if let Some(existing) = resolved.as_deref() { 8253 if existing != next { 8254 return None; 8255 } 8256 } else { 8257 resolved = Some(next.to_owned()); 8258 } 8259 } 8260 resolved 8261 } 8262 8263 fn shared_listing_relays(lines: &[BuyerOrderLocalEventLine]) -> Vec<String> { 8264 let mut seen = BTreeSet::new(); 8265 let mut relays = Vec::new(); 8266 for line in lines { 8267 for relay in &line.listing_relays { 8268 let relay = relay.trim(); 8269 if !relay.is_empty() && seen.insert(relay.to_owned()) { 8270 relays.push(relay.to_owned()); 8271 } 8272 } 8273 } 8274 relays 8275 } 8276 8277 fn canonical_quantity_unit(unit_label: &str) -> Option<&'static str> { 8278 match unit_label.trim().to_ascii_lowercase().as_str() { 8279 "each" | "ea" | "count" => Some("each"), 8280 "kg" | "kilogram" | "kilograms" => Some("kg"), 8281 "g" | "gram" | "grams" => Some("g"), 8282 "oz" | "ounce" | "ounces" => Some("oz"), 8283 "lb" | "pound" | "pounds" => Some("lb"), 8284 "l" | "liter" | "litre" | "liters" | "litres" => Some("l"), 8285 "ml" | "milliliter" | "millilitre" | "milliliters" | "millilitres" => Some("ml"), 8286 _ => None, 8287 } 8288 } 8289 8290 fn load_selected_account_context( 8291 sqlite_store: &AppSqliteStore, 8292 identity_projection: &AppIdentityProjection, 8293 continuity_state: &PersistedAppState, 8294 ) -> Result<DesktopSelectedAccountContext, AppSqliteError> { 8295 load_selected_account_context_with_options( 8296 sqlite_store, 8297 identity_projection, 8298 continuity_state, 8299 true, 8300 ) 8301 } 8302 8303 fn selected_buyer_order_scope( 8304 identity_projection: &AppIdentityProjection, 8305 ) -> SelectedBuyerOrderScope { 8306 let buyer_context = identity_projection.buyer_context(); 8307 match &buyer_context { 8308 BuyerContext::Account(account_id) => SelectedBuyerOrderScope::for_selected_account( 8309 account_id, 8310 identity_projection 8311 .selected_account 8312 .as_ref() 8313 .and_then(selected_account_public_key_hex) 8314 .as_deref(), 8315 ), 8316 BuyerContext::Guest => SelectedBuyerOrderScope::from_buyer_context(&buyer_context), 8317 } 8318 } 8319 8320 fn selected_account_public_key_hex( 8321 selected_account: &radroots_app_view::SelectedAccountProjection, 8322 ) -> Option<String> { 8323 let npub = selected_account.account.npub.trim(); 8324 radroots_nostr_parse_pubkey(npub) 8325 .ok() 8326 .map(|public_key| public_key.to_hex()) 8327 .filter(|public_key| is_hex_64(public_key)) 8328 .or_else(|| { 8329 let account_id = selected_account.account.account_id.trim(); 8330 is_hex_64(account_id).then(|| account_id.to_owned()) 8331 }) 8332 } 8333 8334 fn load_selected_account_context_with_options( 8335 sqlite_store: &AppSqliteStore, 8336 identity_projection: &AppIdentityProjection, 8337 continuity_state: &PersistedAppState, 8338 allow_auto_present: bool, 8339 ) -> Result<DesktopSelectedAccountContext, AppSqliteError> { 8340 let buyer_context = identity_projection.buyer_context(); 8341 let buyer_order_scope = selected_buyer_order_scope(identity_projection); 8342 let browse_fulfillment_methods = BTreeSet::new(); 8343 let browse_listings = sqlite_store.load_buyer_listings("", &browse_fulfillment_methods)?; 8344 let search_query = continuity_state.buyer.search_query.clone(); 8345 let search_listings = sqlite_store.load_buyer_listings( 8346 &search_query.search_query, 8347 &search_query.fulfillment_methods, 8348 )?; 8349 let browse_detail = match continuity_state.buyer.browse_detail_product_id { 8350 Some(product_id) => sqlite_store.load_buyer_product_detail(product_id)?, 8351 None => None, 8352 }; 8353 let search_detail = match continuity_state.buyer.search_detail_product_id { 8354 Some(product_id) => sqlite_store.load_buyer_product_detail(product_id)?, 8355 None => None, 8356 }; 8357 let buyer_cart = sqlite_store.load_buyer_cart(&buyer_context)?; 8358 let buyer_order_review = sqlite_store.load_buyer_order_review(&buyer_context)?; 8359 let buyer_orders = sqlite_store.load_buyer_orders_for_scope(&buyer_order_scope)?; 8360 let has_recoverable_coordination = !sqlite_store 8361 .load_recoverable_buyer_order_coordination_records(&buyer_context)? 8362 .is_empty(); 8363 let buyer_order_detail = match continuity_state.buyer.orders_detail_order_id { 8364 Some(order_id) => { 8365 sqlite_store.load_buyer_order_detail_for_scope(&buyer_order_scope, order_id)? 8366 } 8367 None => None, 8368 }; 8369 let personal_projection = PersonalWorkspaceProjection { 8370 browse: BuyerBrowseScreenProjection { 8371 listings: browse_listings, 8372 detail: browse_detail, 8373 }, 8374 search: BuyerSearchScreenProjection { 8375 query: search_query, 8376 listings: search_listings, 8377 detail: search_detail, 8378 }, 8379 cart: BuyerCartScreenProjection { 8380 cart: buyer_cart, 8381 order_review: buyer_order_review, 8382 }, 8383 orders: BuyerOrdersScreenProjection { 8384 list: buyer_orders, 8385 detail: buyer_order_detail, 8386 has_recoverable_coordination, 8387 }, 8388 ..PersonalWorkspaceProjection::default() 8389 }; 8390 let Some(selected_account) = identity_projection.selected_account.as_ref() else { 8391 return Ok(DesktopSelectedAccountContext { 8392 personal_projection, 8393 products_query: ProductsScreenQueryState::default(), 8394 orders_list: OrdersListProjection::default(), 8395 orders_query: OrdersScreenQueryState::default(), 8396 orders_reminders: ReminderFeedProjection::default(), 8397 pack_day_query: PackDayScreenQueryState::default(), 8398 product_editor_draft: None, 8399 reminder_log: ReminderLogProjection::default(), 8400 ..DesktopSelectedAccountContext::default() 8401 }); 8402 }; 8403 let farm_setup_projection = 8404 sqlite_store.load_farm_setup(&selected_account.account.account_id)?; 8405 let today_farm_id = selected_farm_id_from_context(identity_projection, &farm_setup_projection); 8406 let ( 8407 farm_rules_projection, 8408 mut today_projection, 8409 products_query, 8410 products_list, 8411 orders_query, 8412 orders_list, 8413 canonical_orders_list, 8414 order_detail, 8415 pack_day_query, 8416 mut pack_day_projection, 8417 product_editor_draft, 8418 ) = match today_farm_id { 8419 Some(farm_id) => { 8420 let fallback_profile = 8421 fallback_farm_profile_for_projection(farm_id, &farm_setup_projection); 8422 let farm_rules_projection = 8423 sqlite_store.load_farm_rules(farm_id).map(|projection| { 8424 prepare_loaded_farm_rules_projection(projection, &fallback_profile) 8425 })?; 8426 let today_projection = sqlite_store.load_today_agenda(Some(farm_id))?; 8427 let products_query = continuity_state.seller.products_query.clone(); 8428 let products_list = sqlite_store.load_products( 8429 farm_id, 8430 &products_query.search_query, 8431 products_query.filter, 8432 products_query.sort, 8433 )?; 8434 let orders_query = sanitize_orders_query( 8435 sqlite_store, 8436 farm_id, 8437 continuity_state.seller.orders_query.clone(), 8438 )?; 8439 let orders_list = sqlite_store.load_orders_list(farm_id, &orders_query)?; 8440 let canonical_orders_list = 8441 sqlite_store.load_orders_list(farm_id, &OrdersScreenQueryState::default())?; 8442 let order_detail = match continuity_state.seller.order_detail_order_id { 8443 Some(order_id) => sqlite_store.load_order_detail(farm_id, order_id)?, 8444 None => None, 8445 }; 8446 let (pack_day_query, pack_day_projection) = sanitize_pack_day_query( 8447 sqlite_store, 8448 farm_id, 8449 continuity_state.seller.pack_day_query.clone(), 8450 )?; 8451 let product_editor_draft = if matches!( 8452 continuity_state.shell.selected_section, 8453 ShellSection::Farmer(FarmerSection::Products) 8454 ) { 8455 match continuity_state.seller.product_editor_product_id { 8456 Some(product_id) => sqlite_store 8457 .load_product_editor_draft(product_id)? 8458 .map(|draft| (product_id, draft)), 8459 None => None, 8460 } 8461 } else { 8462 None 8463 }; 8464 8465 ( 8466 farm_rules_projection, 8467 today_projection, 8468 products_query, 8469 products_list, 8470 orders_query, 8471 orders_list, 8472 canonical_orders_list, 8473 order_detail, 8474 pack_day_query, 8475 pack_day_projection, 8476 product_editor_draft, 8477 ) 8478 } 8479 None => ( 8480 FarmRulesProjection::default(), 8481 TodayAgendaProjection::default(), 8482 ProductsScreenQueryState::default(), 8483 ProductsListProjection::default(), 8484 OrdersScreenQueryState::default(), 8485 OrdersListProjection::default(), 8486 OrdersListProjection::default(), 8487 None, 8488 PackDayScreenQueryState::default(), 8489 PackDayProjection::default(), 8490 None, 8491 ), 8492 }; 8493 let (orders_reminders, reminder_log) = match today_farm_id { 8494 Some(farm_id) => { 8495 let reminder_context = load_selected_account_reminder_context_with_options( 8496 sqlite_store, 8497 selected_account.account.account_id.as_str(), 8498 farm_id, 8499 &today_projection, 8500 &canonical_orders_list, 8501 &pack_day_projection, 8502 order_detail.as_ref(), 8503 allow_auto_present, 8504 )?; 8505 today_projection.reminders = reminder_context.today_feed; 8506 if let Some(summary) = today_projection.summary.as_mut() { 8507 summary.reminders_due_soon = reminder_context.due_soon_count; 8508 } 8509 pack_day_projection.reminders = reminder_context.pack_day_feed; 8510 8511 (reminder_context.orders_feed, reminder_context.reminder_log) 8512 } 8513 None => ( 8514 ReminderFeedProjection::default(), 8515 ReminderLogProjection::default(), 8516 ), 8517 }; 8518 8519 Ok(DesktopSelectedAccountContext { 8520 personal_projection, 8521 farm_setup_projection, 8522 farm_rules_projection, 8523 today_projection, 8524 products_query, 8525 products_list, 8526 orders_query, 8527 orders_list, 8528 orders_reminders, 8529 reminder_log, 8530 order_detail, 8531 pack_day_query, 8532 pack_day_projection, 8533 product_editor_draft, 8534 }) 8535 } 8536 8537 fn sanitize_orders_query( 8538 sqlite_store: &AppSqliteStore, 8539 farm_id: FarmId, 8540 query: OrdersScreenQueryState, 8541 ) -> Result<OrdersScreenQueryState, AppSqliteError> { 8542 let Some(fulfillment_window_id) = query.fulfillment_window_id else { 8543 return Ok(query); 8544 }; 8545 let pack_day = sqlite_store.load_pack_day( 8546 farm_id, 8547 &PackDayScreenQueryState { 8548 fulfillment_window_id: Some(fulfillment_window_id), 8549 }, 8550 )?; 8551 if pack_day 8552 .fulfillment_window 8553 .as_ref() 8554 .map(|window| window.fulfillment_window_id) 8555 == Some(fulfillment_window_id) 8556 { 8557 Ok(query) 8558 } else { 8559 Ok(OrdersScreenQueryState { 8560 filter: query.filter, 8561 fulfillment_window_id: None, 8562 }) 8563 } 8564 } 8565 8566 fn sanitize_pack_day_query( 8567 sqlite_store: &AppSqliteStore, 8568 farm_id: FarmId, 8569 query: PackDayScreenQueryState, 8570 ) -> Result<(PackDayScreenQueryState, PackDayProjection), AppSqliteError> { 8571 let projection = sqlite_store.load_pack_day(farm_id, &query)?; 8572 if query.fulfillment_window_id.is_none() 8573 || projection 8574 .fulfillment_window 8575 .as_ref() 8576 .map(|window| window.fulfillment_window_id) 8577 == query.fulfillment_window_id 8578 { 8579 return Ok((query, projection)); 8580 } 8581 8582 let default_query = PackDayScreenQueryState::default(); 8583 let default_projection = sqlite_store.load_pack_day(farm_id, &default_query)?; 8584 8585 Ok((default_query, default_projection)) 8586 } 8587 8588 fn load_selected_account_reminder_context_with_options( 8589 sqlite_store: &AppSqliteStore, 8590 account_id: &str, 8591 farm_id: FarmId, 8592 today_projection: &TodayAgendaProjection, 8593 canonical_orders_list: &OrdersListProjection, 8594 pack_day_projection: &PackDayProjection, 8595 _selected_order_detail: Option<&OrderDetailProjection>, 8596 allow_auto_present: bool, 8597 ) -> Result<DesktopSellerReminderContext, AppSqliteError> { 8598 let existing_schedule = sqlite_store.load_reminder_schedule(account_id, farm_id)?; 8599 let sync_truth = load_selected_account_reminder_sync_truth(sqlite_store, account_id)?; 8600 let mut schedule = derive_selected_account_reminder_schedule( 8601 farm_id, 8602 today_projection, 8603 canonical_orders_list, 8604 pack_day_projection, 8605 &sync_truth, 8606 &existing_schedule, 8607 ); 8608 let mut reminder_log_entries = 8609 reconcile_resolved_reminder_log_entries(&existing_schedule, &schedule); 8610 promote_desktop_reminder_presentation( 8611 &mut schedule, 8612 &mut reminder_log_entries, 8613 allow_auto_present, 8614 ); 8615 if schedule != existing_schedule || !reminder_log_entries.is_empty() { 8616 sqlite_store.apply_reminder_schedule_update( 8617 account_id, 8618 farm_id, 8619 &schedule, 8620 &reminder_log_entries, 8621 )?; 8622 } 8623 let reminder_log = sqlite_store.load_reminder_log(account_id, farm_id, 8)?; 8624 8625 let due_soon_count = schedule 8626 .items 8627 .iter() 8628 .filter(|item| { 8629 matches!( 8630 item.urgency, 8631 ReminderUrgency::DueSoon | ReminderUrgency::Overdue | ReminderUrgency::Blocking 8632 ) 8633 }) 8634 .count() as u32; 8635 8636 Ok(DesktopSellerReminderContext { 8637 today_feed: filter_reminder_surface(&schedule, ReminderSurface::Today), 8638 orders_feed: filter_reminder_surface(&schedule, ReminderSurface::Orders), 8639 pack_day_feed: filter_reminder_surface(&schedule, ReminderSurface::PackDay), 8640 due_soon_count, 8641 reminder_log, 8642 }) 8643 } 8644 8645 fn load_selected_account_reminder_sync_truth( 8646 sqlite_store: &AppSqliteStore, 8647 account_id: &str, 8648 ) -> Result<DesktopReminderSyncTruth, AppSqliteError> { 8649 let checkpoint = sqlite_store.load_sync_checkpoint(account_id)?; 8650 let conflicts = sqlite_store.load_sync_conflicts(account_id)?; 8651 let pending_write_count = sqlite_store.load_pending_sync_operations(account_id)?.len(); 8652 let unresolved_conflict_count = conflicts 8653 .iter() 8654 .filter(|stored| stored.conflict.is_unresolved()) 8655 .count(); 8656 let blocking_conflict_count = conflicts 8657 .iter() 8658 .filter(|stored| { 8659 stored.conflict.is_unresolved() 8660 && matches!(stored.conflict.severity, SyncConflictSeverity::Blocking) 8661 }) 8662 .count(); 8663 8664 Ok(DesktopReminderSyncTruth { 8665 checkpoint, 8666 pending_write_count, 8667 unresolved_conflict_count, 8668 blocking_conflict_count, 8669 }) 8670 } 8671 8672 fn derive_selected_account_reminder_schedule( 8673 farm_id: FarmId, 8674 today_projection: &TodayAgendaProjection, 8675 canonical_orders_list: &OrdersListProjection, 8676 pack_day_projection: &PackDayProjection, 8677 sync_truth: &DesktopReminderSyncTruth, 8678 existing_schedule: &ReminderFeedProjection, 8679 ) -> ReminderFeedProjection { 8680 let mut items = Vec::new(); 8681 8682 if let Some(window) = today_projection.next_fulfillment_window.as_ref() { 8683 items.push(build_reminder_projection( 8684 farm_id, 8685 format!( 8686 "reminder:today:fulfillment_window:{}", 8687 window.fulfillment_window_id 8688 ), 8689 None, 8690 Some(window.fulfillment_window_id), 8691 ReminderKind::FulfillmentWindow, 8692 ReminderSurface::Today, 8693 "Prepare the next fulfillment window".to_owned(), 8694 format!( 8695 "The next fulfillment window starts at {}.", 8696 window.starts_at 8697 ), 8698 window.starts_at.clone(), 8699 Some("Open pack day".to_owned()), 8700 None, 8701 existing_schedule, 8702 )); 8703 } 8704 8705 if canonical_orders_list.summary.needs_action_orders > 0 { 8706 let deadline_at = today_projection 8707 .next_fulfillment_window 8708 .as_ref() 8709 .map(|window| window.starts_at.clone()) 8710 .unwrap_or_else(current_utc_timestamp); 8711 let detail = canonical_orders_list 8712 .rows 8713 .first() 8714 .and_then(|row| row.fulfillment_window_label.as_ref()) 8715 .map(|label| { 8716 format!( 8717 "{} order(s) still need review before {}.", 8718 canonical_orders_list.summary.needs_action_orders, label 8719 ) 8720 }) 8721 .unwrap_or_else(|| { 8722 format!( 8723 "{} order(s) still need review.", 8724 canonical_orders_list.summary.needs_action_orders 8725 ) 8726 }); 8727 items.push(build_reminder_projection( 8728 farm_id, 8729 "reminder:orders:needs_action".to_owned(), 8730 canonical_orders_list.rows.first().map(|row| row.order_id), 8731 canonical_orders_list 8732 .rows 8733 .first() 8734 .and_then(|row| row.fulfillment_window_id), 8735 ReminderKind::OrderAction, 8736 ReminderSurface::Orders, 8737 "Review open orders".to_owned(), 8738 detail, 8739 deadline_at, 8740 Some("Review".to_owned()), 8741 None, 8742 existing_schedule, 8743 )); 8744 } 8745 8746 if let Some(window) = pack_day_projection.fulfillment_window.as_ref() { 8747 items.push(build_reminder_projection( 8748 farm_id, 8749 format!( 8750 "reminder:pack_day:fulfillment_window:{}", 8751 window.fulfillment_window_id 8752 ), 8753 None, 8754 Some(window.fulfillment_window_id), 8755 ReminderKind::FulfillmentWindow, 8756 ReminderSurface::PackDay, 8757 "Pack for this fulfillment window".to_owned(), 8758 format!("Packing needs to be ready before {}.", window.starts_at), 8759 window.starts_at.clone(), 8760 Some("Review pack day".to_owned()), 8761 None, 8762 existing_schedule, 8763 )); 8764 } 8765 8766 if let Some(sync_reminder) = 8767 build_sync_reminder_projection(farm_id, sync_truth, existing_schedule) 8768 { 8769 items.push(sync_reminder); 8770 } 8771 8772 items.sort_by(|left, right| { 8773 left.deadline_at.cmp(&right.deadline_at).then_with(|| { 8774 left.reminder_id 8775 .to_string() 8776 .cmp(&right.reminder_id.to_string()) 8777 }) 8778 }); 8779 8780 ReminderFeedProjection { items } 8781 } 8782 8783 fn build_sync_reminder_projection( 8784 farm_id: FarmId, 8785 sync_truth: &DesktopReminderSyncTruth, 8786 existing_schedule: &ReminderFeedProjection, 8787 ) -> Option<ReminderDeadlineProjection> { 8788 if sync_truth.blocking_conflict_count > 0 { 8789 return Some(build_reminder_projection( 8790 farm_id, 8791 "reminder:orders:sync:blocking_conflicts".to_owned(), 8792 None, 8793 None, 8794 ReminderKind::SyncImpact, 8795 ReminderSurface::Orders, 8796 "Resolve blocking sync conflicts".to_owned(), 8797 format!( 8798 "{} blocking sync conflict(s) need review before the next sync run.", 8799 sync_truth.blocking_conflict_count 8800 ), 8801 current_utc_timestamp(), 8802 Some("Review".to_owned()), 8803 Some(ReminderUrgency::Blocking), 8804 existing_schedule, 8805 )); 8806 } 8807 8808 if sync_truth.unresolved_conflict_count > 0 { 8809 return Some(build_reminder_projection( 8810 farm_id, 8811 "reminder:orders:sync:conflicts".to_owned(), 8812 None, 8813 None, 8814 ReminderKind::SyncImpact, 8815 ReminderSurface::Orders, 8816 "Review sync conflicts".to_owned(), 8817 format!( 8818 "{} sync conflict(s) are still unresolved.", 8819 sync_truth.unresolved_conflict_count 8820 ), 8821 current_utc_timestamp(), 8822 Some("Review".to_owned()), 8823 Some(ReminderUrgency::DueSoon), 8824 existing_schedule, 8825 )); 8826 } 8827 8828 if sync_truth.checkpoint.is_failed() { 8829 return Some(build_reminder_projection( 8830 farm_id, 8831 "reminder:orders:sync:failed".to_owned(), 8832 None, 8833 None, 8834 ReminderKind::SyncImpact, 8835 ReminderSurface::Orders, 8836 "Retry sync".to_owned(), 8837 sync_truth 8838 .checkpoint 8839 .last_error_message 8840 .clone() 8841 .unwrap_or_else(|| "The last sync attempt failed.".to_owned()), 8842 current_utc_timestamp(), 8843 Some("Review".to_owned()), 8844 Some(ReminderUrgency::Blocking), 8845 existing_schedule, 8846 )); 8847 } 8848 8849 if sync_truth.pending_write_count > 0 { 8850 return Some(build_reminder_projection( 8851 farm_id, 8852 "reminder:orders:sync:pending".to_owned(), 8853 None, 8854 None, 8855 ReminderKind::SyncImpact, 8856 ReminderSurface::Orders, 8857 "Pending local changes".to_owned(), 8858 format!( 8859 "{} local change(s) are waiting to sync.", 8860 sync_truth.pending_write_count 8861 ), 8862 current_utc_timestamp(), 8863 Some("Review".to_owned()), 8864 Some(ReminderUrgency::Upcoming), 8865 existing_schedule, 8866 )); 8867 } 8868 8869 None 8870 } 8871 8872 fn build_reminder_projection( 8873 farm_id: FarmId, 8874 identity_key: String, 8875 order_id: Option<OrderId>, 8876 fulfillment_window_id: Option<FulfillmentWindowId>, 8877 kind: ReminderKind, 8878 surface: ReminderSurface, 8879 title: String, 8880 detail: String, 8881 deadline_at: String, 8882 action_label: Option<String>, 8883 urgency_override: Option<ReminderUrgency>, 8884 existing_schedule: &ReminderFeedProjection, 8885 ) -> ReminderDeadlineProjection { 8886 let reminder_id = stable_reminder_id(identity_key.as_str()); 8887 let urgency = urgency_override.unwrap_or_else(|| reminder_urgency(deadline_at.as_str())); 8888 let delivery_state = existing_schedule 8889 .items 8890 .iter() 8891 .find(|item| item.reminder_id == reminder_id) 8892 .map(|item| item.delivery_state) 8893 .unwrap_or(ReminderDeliveryState::Scheduled); 8894 8895 ReminderDeadlineProjection { 8896 reminder_id, 8897 farm_id, 8898 order_id, 8899 fulfillment_window_id, 8900 kind, 8901 surface, 8902 urgency, 8903 title, 8904 detail, 8905 deadline_at, 8906 action_label, 8907 delivery_state, 8908 } 8909 } 8910 8911 fn stable_reminder_id(identity_key: &str) -> ReminderId { 8912 ReminderId::from(Uuid::new_v5(&Uuid::NAMESPACE_URL, identity_key.as_bytes())) 8913 } 8914 8915 fn reminder_urgency(deadline_at: &str) -> ReminderUrgency { 8916 let Ok(deadline) = chrono::DateTime::parse_from_rfc3339(deadline_at) else { 8917 return ReminderUrgency::Upcoming; 8918 }; 8919 let deadline = deadline.with_timezone(&Utc); 8920 let now = Utc::now(); 8921 8922 if deadline <= now { 8923 ReminderUrgency::Overdue 8924 } else if deadline <= now + Duration::hours(48) { 8925 ReminderUrgency::DueSoon 8926 } else { 8927 ReminderUrgency::Upcoming 8928 } 8929 } 8930 8931 fn filter_reminder_surface( 8932 schedule: &ReminderFeedProjection, 8933 surface: ReminderSurface, 8934 ) -> ReminderFeedProjection { 8935 ReminderFeedProjection { 8936 items: schedule 8937 .items 8938 .iter() 8939 .filter(|item| item.surface == surface) 8940 .cloned() 8941 .collect(), 8942 } 8943 } 8944 8945 fn reconcile_resolved_reminder_log_entries( 8946 existing_schedule: &ReminderFeedProjection, 8947 schedule: &ReminderFeedProjection, 8948 ) -> Vec<ReminderLogEntryProjection> { 8949 existing_schedule 8950 .items 8951 .iter() 8952 .filter(|existing| { 8953 existing.delivery_state != ReminderDeliveryState::Scheduled 8954 && existing.delivery_state != ReminderDeliveryState::Resolved 8955 && !schedule 8956 .items 8957 .iter() 8958 .any(|current| current.reminder_id == existing.reminder_id) 8959 }) 8960 .map(|reminder| build_reminder_log_entry(reminder, ReminderDeliveryState::Resolved)) 8961 .collect() 8962 } 8963 8964 fn promote_desktop_reminder_presentation( 8965 schedule: &mut ReminderFeedProjection, 8966 reminder_log_entries: &mut Vec<ReminderLogEntryProjection>, 8967 allow_auto_present: bool, 8968 ) { 8969 if !allow_auto_present || schedule.items.iter().any(is_desktop_presented_reminder) { 8970 return; 8971 } 8972 8973 let Some(index) = schedule 8974 .items 8975 .iter() 8976 .enumerate() 8977 .filter(|(_, reminder)| { 8978 reminder.delivery_state == ReminderDeliveryState::Scheduled 8979 && is_desktop_presentation_candidate(reminder) 8980 }) 8981 .min_by(|(_, left), (_, right)| desktop_reminder_sort(left, right)) 8982 .map(|(index, _)| index) 8983 else { 8984 return; 8985 }; 8986 8987 schedule.items[index].delivery_state = ReminderDeliveryState::Presented; 8988 reminder_log_entries.push(build_reminder_log_entry( 8989 &schedule.items[index], 8990 ReminderDeliveryState::Presented, 8991 )); 8992 } 8993 8994 fn is_desktop_presented_reminder(reminder: &ReminderDeadlineProjection) -> bool { 8995 reminder.delivery_state == ReminderDeliveryState::Presented 8996 && is_desktop_presentation_candidate(reminder) 8997 } 8998 8999 fn is_desktop_presentation_candidate(reminder: &ReminderDeadlineProjection) -> bool { 9000 matches!( 9001 reminder.urgency, 9002 ReminderUrgency::DueSoon | ReminderUrgency::Overdue | ReminderUrgency::Blocking 9003 ) 9004 } 9005 9006 fn desktop_reminder_sort( 9007 left: &ReminderDeadlineProjection, 9008 right: &ReminderDeadlineProjection, 9009 ) -> std::cmp::Ordering { 9010 desktop_reminder_priority(left.urgency) 9011 .cmp(&desktop_reminder_priority(right.urgency)) 9012 .then_with(|| left.deadline_at.cmp(&right.deadline_at)) 9013 .then_with(|| left.reminder_id.cmp(&right.reminder_id)) 9014 } 9015 9016 fn desktop_reminder_priority(urgency: ReminderUrgency) -> u8 { 9017 match urgency { 9018 ReminderUrgency::Blocking => 0, 9019 ReminderUrgency::Overdue => 1, 9020 ReminderUrgency::DueSoon => 2, 9021 ReminderUrgency::Upcoming => 3, 9022 } 9023 } 9024 9025 fn build_reminder_log_entry( 9026 reminder: &ReminderDeadlineProjection, 9027 delivery_state: ReminderDeliveryState, 9028 ) -> ReminderLogEntryProjection { 9029 ReminderLogEntryProjection { 9030 reminder_id: reminder.reminder_id, 9031 kind: reminder.kind, 9032 title: reminder.title.clone(), 9033 recorded_at: current_utc_timestamp(), 9034 delivery_state, 9035 detail: (!reminder.detail.trim().is_empty()).then_some(reminder.detail.clone()), 9036 } 9037 } 9038 9039 fn load_selected_account_sync_context( 9040 sqlite_store: &AppSqliteStore, 9041 identity_projection: &AppIdentityProjection, 9042 relay_urls: &[String], 9043 ) -> Result<DesktopSelectedAccountSyncContext, AppSqliteError> { 9044 let Some(selected_account) = identity_projection.selected_account.as_ref() else { 9045 return Ok(DesktopSelectedAccountSyncContext::default()); 9046 }; 9047 let account_id = selected_account.account.account_id.as_str(); 9048 let checkpoint = sqlite_store.load_sync_checkpoint(account_id)?; 9049 let stored_conflicts = sqlite_store.load_sync_conflicts(account_id)?; 9050 let conflicts = stored_conflicts 9051 .iter() 9052 .map(|stored| stored.conflict.clone()) 9053 .collect::<Vec<_>>(); 9054 let pending_write_count = sqlite_store.load_pending_sync_operations(account_id)?.len(); 9055 let relay_urls = normalized_app_relay_ingest_urls(relay_urls)?; 9056 let relay_ingest = sqlite_store.load_relay_ingest_freshness( 9057 APP_DIRECT_RELAY_INGEST_SCOPE_KEY, 9058 &relay_urls, 9059 current_runtime_time_seconds()?, 9060 APP_DIRECT_RELAY_INGEST_STALE_AFTER_SECONDS, 9061 )?; 9062 9063 Ok(DesktopSelectedAccountSyncContext { 9064 projection: derive_sync_projection(&checkpoint, &conflicts), 9065 relay_ingest, 9066 pending_write_count, 9067 conflicts: stored_conflicts 9068 .into_iter() 9069 .map(|stored| DesktopAppSyncConflictSummary { 9070 conflict_id: stored.conflict_id, 9071 conflict: stored.conflict, 9072 }) 9073 .collect(), 9074 }) 9075 } 9076 9077 fn personal_detail( 9078 projection: &PersonalWorkspaceProjection, 9079 section: PersonalSection, 9080 ) -> Option<&BuyerProductDetailProjection> { 9081 match section { 9082 PersonalSection::Browse => projection.browse.detail.as_ref(), 9083 PersonalSection::Search => projection.search.detail.as_ref(), 9084 PersonalSection::Cart | PersonalSection::Orders => None, 9085 } 9086 } 9087 9088 fn personal_detail_mut( 9089 projection: &mut PersonalWorkspaceProjection, 9090 section: PersonalSection, 9091 ) -> Option<&mut BuyerProductDetailProjection> { 9092 match section { 9093 PersonalSection::Browse => projection.browse.detail.as_mut(), 9094 PersonalSection::Search => projection.search.detail.as_mut(), 9095 PersonalSection::Cart | PersonalSection::Orders => None, 9096 } 9097 } 9098 9099 fn next_buyer_cart_for_detail( 9100 mut current_cart: BuyerCartProjection, 9101 detail: &BuyerProductDetailProjection, 9102 replace_existing: bool, 9103 ) -> Result<BuyerCartProjection, AppSqliteError> { 9104 let incoming_line = buyer_cart_line_from_detail(detail)?; 9105 let current_farm_id = current_cart.farm_id; 9106 let should_replace_lines = replace_existing 9107 || current_cart.is_empty() 9108 || current_farm_id != Some(detail.listing.farm_id); 9109 9110 if should_replace_lines { 9111 current_cart.lines.clear(); 9112 } 9113 9114 current_cart.farm_id = Some(detail.listing.farm_id); 9115 current_cart.farm_display_name = Some(detail.listing.farm_display_name.clone()); 9116 current_cart.replace_confirmation = None; 9117 9118 if let Some(existing_line) = current_cart 9119 .lines 9120 .iter_mut() 9121 .find(|line| line.product_id == incoming_line.product_id) 9122 { 9123 existing_line.quantity = existing_line 9124 .quantity 9125 .checked_add(incoming_line.quantity) 9126 .ok_or(AppSqliteError::InvalidProjection { 9127 reason: "buyer cart quantity overflow", 9128 })?; 9129 existing_line.line_total_minor_units = existing_line 9130 .unit_price 9131 .amount_minor_units 9132 .checked_mul(existing_line.quantity) 9133 .ok_or(AppSqliteError::InvalidProjection { 9134 reason: "buyer cart line total overflow", 9135 })?; 9136 } else { 9137 current_cart.lines.push(incoming_line); 9138 } 9139 9140 refresh_buyer_cart_totals(&mut current_cart)?; 9141 9142 Ok(current_cart) 9143 } 9144 9145 fn next_buyer_cart_after_removing_line( 9146 mut current_cart: BuyerCartProjection, 9147 product_id: ProductId, 9148 ) -> Result<Option<BuyerCartProjection>, AppSqliteError> { 9149 let previous_line_count = current_cart.lines.len(); 9150 current_cart 9151 .lines 9152 .retain(|line| line.product_id != product_id); 9153 if current_cart.lines.len() == previous_line_count { 9154 return Ok(None); 9155 } 9156 9157 if current_cart.lines.is_empty() { 9158 current_cart.farm_id = None; 9159 current_cart.farm_display_name = None; 9160 current_cart.replace_confirmation = None; 9161 refresh_buyer_cart_totals(&mut current_cart)?; 9162 return Ok(Some(current_cart)); 9163 } 9164 9165 let farm_id = current_cart.lines[0].farm_id; 9166 let farm_display_name = current_cart.lines[0].farm_display_name.clone(); 9167 current_cart.farm_id = Some(farm_id); 9168 current_cart.farm_display_name = Some(farm_display_name); 9169 current_cart.replace_confirmation = None; 9170 refresh_buyer_cart_totals(&mut current_cart)?; 9171 9172 Ok(Some(current_cart)) 9173 } 9174 9175 fn buyer_cart_line_from_detail( 9176 detail: &BuyerProductDetailProjection, 9177 ) -> Result<BuyerCartLineProjection, AppSqliteError> { 9178 Ok(BuyerCartLineProjection { 9179 product_id: detail.listing.product_id, 9180 farm_id: detail.listing.farm_id, 9181 farm_display_name: detail.listing.farm_display_name.clone(), 9182 title: detail.listing.title.clone(), 9183 quantity: detail.selected_quantity, 9184 unit_price: detail.listing.price.clone(), 9185 line_total_minor_units: detail 9186 .listing 9187 .price 9188 .amount_minor_units 9189 .checked_mul(detail.selected_quantity) 9190 .ok_or(AppSqliteError::InvalidProjection { 9191 reason: "buyer cart line total overflow", 9192 })?, 9193 fulfillment_summary: detail 9194 .listing 9195 .next_fulfillment_window_label 9196 .clone() 9197 .unwrap_or_else(|| detail.listing.availability.label.clone()), 9198 }) 9199 } 9200 9201 fn refresh_buyer_cart_totals(cart: &mut BuyerCartProjection) -> Result<(), AppSqliteError> { 9202 if cart.lines.is_empty() { 9203 cart.subtotal_minor_units = None; 9204 cart.currency_code = None; 9205 cart.replace_confirmation = None; 9206 return Ok(()); 9207 } 9208 9209 let currency_code = cart.lines[0].unit_price.currency_code.clone(); 9210 let subtotal_minor_units = cart.lines.iter().try_fold(0u32, |subtotal, line| { 9211 if line.unit_price.currency_code != currency_code { 9212 return Err(AppSqliteError::InvalidProjection { 9213 reason: "buyer cart currency mismatch", 9214 }); 9215 } 9216 9217 subtotal 9218 .checked_add(line.line_total_minor_units) 9219 .ok_or(AppSqliteError::InvalidProjection { 9220 reason: "buyer cart subtotal overflow", 9221 }) 9222 })?; 9223 9224 cart.subtotal_minor_units = Some(subtotal_minor_units); 9225 cart.currency_code = Some(currency_code); 9226 9227 Ok(()) 9228 } 9229 9230 fn selected_farm_id_from_context( 9231 identity_projection: &AppIdentityProjection, 9232 farm_setup_projection: &FarmSetupProjection, 9233 ) -> Option<FarmId> { 9234 farm_setup_projection 9235 .saved_farm 9236 .as_ref() 9237 .map(|farm| farm.farm_id) 9238 .or_else(|| { 9239 identity_projection 9240 .selected_account 9241 .as_ref() 9242 .and_then(|account| account.farmer_activation.farm_id) 9243 }) 9244 } 9245 9246 fn fallback_farm_profile_for_projection( 9247 farm_id: FarmId, 9248 farm_setup_projection: &FarmSetupProjection, 9249 ) -> FarmProfileRecord { 9250 let saved_farm_name = farm_setup_projection 9251 .saved_farm 9252 .as_ref() 9253 .filter(|farm| farm.farm_id == farm_id) 9254 .map(|farm| farm.display_name.clone()); 9255 let drafted_farm_name = farm_setup_projection.draft.farm_name.trim().to_owned(); 9256 let display_name = saved_farm_name 9257 .or_else(|| (!drafted_farm_name.is_empty()).then_some(drafted_farm_name)) 9258 .unwrap_or_default(); 9259 9260 FarmProfileRecord { 9261 farm_id, 9262 display_name, 9263 timezone: "UTC".to_owned(), 9264 currency_code: "USD".to_owned(), 9265 } 9266 } 9267 9268 fn prepare_loaded_farm_rules_projection( 9269 mut projection: FarmRulesProjection, 9270 fallback_profile: &FarmProfileRecord, 9271 ) -> FarmRulesProjection { 9272 if projection.farm_profile.is_none() { 9273 projection.farm_profile = Some(fallback_profile.clone()); 9274 } 9275 9276 normalize_pickup_location_defaults(&mut projection.pickup_locations); 9277 projection.readiness = derive_farm_rules_readiness(&projection); 9278 projection 9279 } 9280 9281 fn normalize_farm_rules_projection( 9282 mut projection: FarmRulesProjection, 9283 fallback_profile: &FarmProfileRecord, 9284 ) -> FarmRulesProjection { 9285 let mut farm_profile = projection 9286 .farm_profile 9287 .take() 9288 .unwrap_or_else(|| fallback_profile.clone()); 9289 farm_profile.farm_id = fallback_profile.farm_id; 9290 farm_profile.display_name = farm_profile.display_name.trim().to_owned(); 9291 farm_profile.timezone = farm_profile.timezone.trim().to_owned(); 9292 farm_profile.currency_code = farm_profile.currency_code.trim().to_uppercase(); 9293 projection.farm_profile = Some(farm_profile); 9294 9295 for pickup_location in &mut projection.pickup_locations { 9296 pickup_location.farm_id = fallback_profile.farm_id; 9297 pickup_location.label = pickup_location.label.trim().to_owned(); 9298 pickup_location.address_line = pickup_location.address_line.trim().to_owned(); 9299 pickup_location.directions = pickup_location 9300 .directions 9301 .take() 9302 .map(|directions| directions.trim().to_owned()) 9303 .filter(|directions| !directions.is_empty()); 9304 } 9305 9306 if let Some(operating_rules) = projection.operating_rules.as_mut() { 9307 operating_rules.farm_id = fallback_profile.farm_id; 9308 operating_rules.substitution_policy = operating_rules.substitution_policy.trim().to_owned(); 9309 } 9310 9311 for fulfillment_window in &mut projection.fulfillment_windows { 9312 fulfillment_window.farm_id = fallback_profile.farm_id; 9313 fulfillment_window.label = fulfillment_window.label.trim().to_owned(); 9314 fulfillment_window.starts_at = fulfillment_window.starts_at.trim().to_owned(); 9315 fulfillment_window.ends_at = fulfillment_window.ends_at.trim().to_owned(); 9316 fulfillment_window.order_cutoff_at = fulfillment_window.order_cutoff_at.trim().to_owned(); 9317 } 9318 9319 for blackout_period in &mut projection.blackout_periods { 9320 blackout_period.farm_id = fallback_profile.farm_id; 9321 blackout_period.label = blackout_period.label.trim().to_owned(); 9322 blackout_period.starts_at = blackout_period.starts_at.trim().to_owned(); 9323 blackout_period.ends_at = blackout_period.ends_at.trim().to_owned(); 9324 } 9325 9326 normalize_pickup_location_defaults(&mut projection.pickup_locations); 9327 projection.readiness = derive_farm_rules_readiness(&projection); 9328 projection 9329 } 9330 9331 fn normalize_pickup_location_defaults(pickup_locations: &mut [PickupLocationRecord]) { 9332 let default_index = pickup_locations 9333 .iter() 9334 .position(|pickup_location| pickup_location.is_default) 9335 .or_else(|| (!pickup_locations.is_empty()).then_some(0)); 9336 9337 for (index, pickup_location) in pickup_locations.iter_mut().enumerate() { 9338 pickup_location.is_default = Some(index) == default_index; 9339 } 9340 } 9341 9342 fn current_utc_timestamp() -> String { 9343 Utc::now().format("%Y-%m-%dT%H:%M:%SZ").to_string() 9344 } 9345 9346 fn signed_event_from_local_record( 9347 record: &LocalEventRecord, 9348 ) -> Result<Option<SdkRadrootsNostrEvent>, AppSqliteError> { 9349 let Some(id) = record.event_id.as_deref().map(str::trim) else { 9350 return Ok(None); 9351 }; 9352 let Some(author) = record.event_pubkey.as_deref().map(str::trim) else { 9353 return Ok(None); 9354 }; 9355 let Some(kind) = record.event_kind else { 9356 return Ok(None); 9357 }; 9358 let Some(content) = record.event_content.as_ref() else { 9359 return Ok(None); 9360 }; 9361 let Some(sig) = record.event_sig.as_deref().map(str::trim) else { 9362 return Ok(None); 9363 }; 9364 let created_at = record.event_created_at.unwrap_or_default(); 9365 let created_at = u32::try_from(created_at).map_err(|_| AppSqliteError::InvalidProjection { 9366 reason: "signed local event created_at must fit u32", 9367 })?; 9368 let kind = u32::try_from(kind).map_err(|_| AppSqliteError::InvalidProjection { 9369 reason: "signed local event kind must fit u32", 9370 })?; 9371 9372 Ok(Some(SdkRadrootsNostrEvent { 9373 id: id.to_owned(), 9374 author: author.to_owned(), 9375 created_at, 9376 kind, 9377 tags: event_tags_from_value(record.event_tags_json.as_ref())?, 9378 content: content.clone(), 9379 sig: sig.to_owned(), 9380 })) 9381 } 9382 9383 fn event_tags_from_value( 9384 value: Option<&serde_json::Value>, 9385 ) -> Result<Vec<Vec<String>>, AppSqliteError> { 9386 let Some(value) = value else { 9387 return Ok(Vec::new()); 9388 }; 9389 let Some(tags) = value.as_array() else { 9390 return Err(AppSqliteError::InvalidProjection { 9391 reason: "signed local event tags must be an array", 9392 }); 9393 }; 9394 9395 tags.iter() 9396 .map(|tag| { 9397 let Some(values) = tag.as_array() else { 9398 return Err(AppSqliteError::InvalidProjection { 9399 reason: "signed local event tag must be an array", 9400 }); 9401 }; 9402 values 9403 .iter() 9404 .map(|value| { 9405 value 9406 .as_str() 9407 .map(str::to_owned) 9408 .ok_or(AppSqliteError::InvalidProjection { 9409 reason: "signed local event tag values must be strings", 9410 }) 9411 }) 9412 .collect() 9413 }) 9414 .collect() 9415 } 9416 9417 fn trade_chain_tag_value(event: &SdkRadrootsNostrEvent, key: &str) -> Option<String> { 9418 event.tags.iter().find_map(|tag| { 9419 if tag.first().map(String::as_str) == Some(key) { 9420 tag.get(1) 9421 .map(String::as_str) 9422 .map(str::trim) 9423 .filter(|value| !value.is_empty()) 9424 .map(str::to_owned) 9425 } else { 9426 None 9427 } 9428 }) 9429 } 9430 9431 fn active_order_event_id( 9432 value: &str, 9433 field: &'static str, 9434 ) -> Result<RadrootsEventId, AppSqliteError> { 9435 value.parse().map_err(|_| AppSqliteError::DecodeId { 9436 field, 9437 value: value.to_owned(), 9438 }) 9439 } 9440 9441 fn active_order_pubkey( 9442 value: &str, 9443 field: &'static str, 9444 ) -> Result<RadrootsPublicKey, AppSqliteError> { 9445 value.parse().map_err(|_| AppSqliteError::DecodeId { 9446 field, 9447 value: value.to_owned(), 9448 }) 9449 } 9450 9451 fn active_order_event_record_context( 9452 event: &SdkRadrootsNostrEvent, 9453 message_type: radroots_sdk::protocol::order::RadrootsOrderEventType, 9454 ) -> Result<(RadrootsPublicKey, RadrootsEventId, RadrootsEventId), AppSqliteError> { 9455 let context = order_event_context_from_tags(message_type, &event.tags).map_err(|_| { 9456 AppSqliteError::InvalidProjection { 9457 reason: "order lifecycle evidence is invalid", 9458 } 9459 })?; 9460 let root_event_id = context 9461 .root_event_id 9462 .ok_or(AppSqliteError::InvalidProjection { 9463 reason: "order lifecycle evidence is invalid", 9464 })?; 9465 let prev_event_id = context 9466 .prev_event_id 9467 .ok_or(AppSqliteError::InvalidProjection { 9468 reason: "order lifecycle evidence is invalid", 9469 })?; 9470 Ok((context.counterparty_pubkey, root_event_id, prev_event_id)) 9471 } 9472 9473 fn active_order_pending_revision_proposal( 9474 lifecycle: &ResolvedAppOrderLifecycleEvidence, 9475 ) -> Option<&ResolvedAppOrderRevisionProposalEvidence> { 9476 let mut parent_event_id = lifecycle.request_event_id.as_str(); 9477 loop { 9478 let proposals = lifecycle 9479 .revision_proposals 9480 .iter() 9481 .filter(|proposal| proposal.payload.prev_event_id == parent_event_id) 9482 .collect::<Vec<_>>(); 9483 let proposal = match proposals.as_slice() { 9484 [] => return None, 9485 [proposal] => *proposal, 9486 _ => return None, 9487 }; 9488 let decisions = lifecycle 9489 .revision_decisions 9490 .iter() 9491 .filter(|decision| { 9492 decision.payload.prev_event_id == proposal.event_id 9493 && decision.payload.revision_id == proposal.payload.revision_id 9494 }) 9495 .collect::<Vec<_>>(); 9496 let decision = match decisions.as_slice() { 9497 [] => return Some(proposal), 9498 [decision] => *decision, 9499 _ => return None, 9500 }; 9501 parent_event_id = decision.event_id.as_str(); 9502 } 9503 } 9504 9505 fn insert_seller_order_request_evidence( 9506 order_id: &OrderId, 9507 event: &SdkRadrootsNostrEvent, 9508 payload: RadrootsOrderRequest, 9509 matched_requests: &mut BTreeMap<String, ResolvedAppSellerOrderRequest>, 9510 ) { 9511 let app_order_id = projected_order_id_from_trade_request( 9512 payload.order_id.as_str(), 9513 payload.buyer_pubkey.as_str(), 9514 ); 9515 if app_order_id != *order_id { 9516 return; 9517 } 9518 matched_requests 9519 .entry(event.id.clone()) 9520 .or_insert_with(|| ResolvedAppSellerOrderRequest { 9521 request_event: event.clone(), 9522 request_event_id: event.id.clone(), 9523 request_author_pubkey: event.author.clone(), 9524 listing_event_id: listing_event_id_from_tags(&event.tags), 9525 payload, 9526 }); 9527 } 9528 9529 fn listing_event_id_from_tags(tags: &[Vec<String>]) -> Option<String> { 9530 tags.iter().find_map(|tag| { 9531 if tag.first().map(String::as_str) == Some("listing_event") { 9532 tag.get(1) 9533 .map(String::as_str) 9534 .map(str::trim) 9535 .filter(|value| !value.is_empty()) 9536 .map(str::to_owned) 9537 } else { 9538 None 9539 } 9540 }) 9541 } 9542 9543 fn seller_order_inventory_commitments( 9544 order: &SellerOrderDecisionExport, 9545 ) -> Result<Vec<AppOrderDecisionInventoryCommitment>, AppSqliteError> { 9546 if order.lines.is_empty() { 9547 return Err(AppSqliteError::InvalidProjection { 9548 reason: "seller order decision requires order lines", 9549 }); 9550 } 9551 9552 order 9553 .lines 9554 .iter() 9555 .map(|line| { 9556 let bin_id = 9557 line.listing_bin_id 9558 .as_deref() 9559 .ok_or(AppSqliteError::InvalidProjection { 9560 reason: "seller order decision requires listing bin evidence", 9561 })?; 9562 let stock_count = line.stock_count.ok_or(AppSqliteError::InvalidProjection { 9563 reason: "seller order decision requires current product stock", 9564 })?; 9565 let available_quantity = stock_count.checked_sub(line.reserved_quantity).ok_or( 9566 AppSqliteError::InvalidProjection { 9567 reason: "seller order decision inventory is over-reserved", 9568 }, 9569 )?; 9570 if line.quantity > available_quantity { 9571 return Err(AppSqliteError::InvalidProjection { 9572 reason: "seller order decision would over-reserve inventory", 9573 }); 9574 } 9575 9576 Ok(AppOrderDecisionInventoryCommitment { 9577 bin_id: bin_id.to_owned(), 9578 bin_count: line.quantity, 9579 }) 9580 }) 9581 .collect() 9582 } 9583 9584 fn publish_order_id(value: &str) -> Result<RadrootsOrderId, AppSyncTransportError> { 9585 RadrootsOrderId::parse(value).map_err(|error| AppSyncTransportError::failed(error.to_string())) 9586 } 9587 9588 fn publish_revision_id(value: &str) -> Result<RadrootsOrderRevisionId, AppSyncTransportError> { 9589 RadrootsOrderRevisionId::parse(value) 9590 .map_err(|error| AppSyncTransportError::failed(error.to_string())) 9591 } 9592 9593 fn publish_event_id(value: &str) -> Result<RadrootsEventId, AppSyncTransportError> { 9594 RadrootsEventId::parse(value).map_err(|error| AppSyncTransportError::failed(error.to_string())) 9595 } 9596 9597 fn publish_pubkey(value: &str) -> Result<RadrootsPublicKey, AppSyncTransportError> { 9598 RadrootsPublicKey::parse(value) 9599 .map_err(|error| AppSyncTransportError::failed(error.to_string())) 9600 } 9601 9602 fn publish_listing_addr(value: &str) -> Result<RadrootsListingAddress, AppSyncTransportError> { 9603 RadrootsListingAddress::parse(value) 9604 .map_err(|error| AppSyncTransportError::failed(error.to_string())) 9605 } 9606 9607 fn publish_bin_id(value: &str) -> Result<RadrootsInventoryBinId, AppSyncTransportError> { 9608 RadrootsInventoryBinId::parse(value) 9609 .map_err(|error| AppSyncTransportError::failed(error.to_string())) 9610 } 9611 9612 fn order_decision_publish_payload_to_sdk_decision( 9613 payload: &AppOrderDecisionPublishPayload, 9614 ) -> Result<RadrootsOrderDecision, AppSyncTransportError> { 9615 Ok(RadrootsOrderDecision { 9616 order_id: publish_order_id(payload.trade_order_id.as_str())?, 9617 listing_addr: publish_listing_addr(payload.listing_addr.as_str())?, 9618 buyer_pubkey: publish_pubkey(payload.buyer_pubkey.as_str())?, 9619 seller_pubkey: publish_pubkey(payload.seller_pubkey.as_str())?, 9620 decision: match &payload.decision { 9621 AppOrderDecisionPayload::Accepted { 9622 inventory_commitments, 9623 } => RadrootsOrderDecisionOutcome::Accepted { 9624 inventory_commitments: inventory_commitments 9625 .iter() 9626 .map(|commitment| { 9627 Ok(RadrootsOrderInventoryCommitment { 9628 bin_id: publish_bin_id(commitment.bin_id.as_str())?, 9629 bin_count: commitment.bin_count, 9630 }) 9631 }) 9632 .collect::<Result<Vec<_>, AppSyncTransportError>>()?, 9633 }, 9634 AppOrderDecisionPayload::Declined { reason } => { 9635 RadrootsOrderDecisionOutcome::Declined { 9636 reason: reason.clone(), 9637 } 9638 } 9639 }, 9640 }) 9641 } 9642 9643 fn order_revision_proposal_publish_payload_to_sdk_revision( 9644 payload: &AppOrderRevisionProposalPublishPayload, 9645 ) -> Result<RadrootsOrderRevisionProposal, AppSyncTransportError> { 9646 Ok(RadrootsOrderRevisionProposal { 9647 revision_id: publish_revision_id(payload.revision_id.as_str())?, 9648 order_id: publish_order_id(payload.trade_order_id.as_str())?, 9649 listing_addr: publish_listing_addr(payload.listing_addr.as_str())?, 9650 buyer_pubkey: publish_pubkey(payload.buyer_pubkey.as_str())?, 9651 seller_pubkey: publish_pubkey(payload.seller_pubkey.as_str())?, 9652 root_event_id: publish_event_id(payload.request_event_id.as_str())?, 9653 prev_event_id: publish_event_id(payload.prev_event_id.as_str())?, 9654 items: payload.items.clone(), 9655 economics: payload.economics.clone(), 9656 reason: payload.reason.clone(), 9657 }) 9658 } 9659 9660 fn order_revision_decision_publish_payload_to_sdk_revision_decision( 9661 payload: &AppOrderRevisionDecisionPublishPayload, 9662 ) -> Result<RadrootsOrderRevisionDecision, AppSyncTransportError> { 9663 Ok(RadrootsOrderRevisionDecision { 9664 revision_id: publish_revision_id(payload.revision_id.as_str())?, 9665 order_id: publish_order_id(payload.trade_order_id.as_str())?, 9666 listing_addr: publish_listing_addr(payload.listing_addr.as_str())?, 9667 buyer_pubkey: publish_pubkey(payload.buyer_pubkey.as_str())?, 9668 seller_pubkey: publish_pubkey(payload.seller_pubkey.as_str())?, 9669 root_event_id: publish_event_id(payload.request_event_id.as_str())?, 9670 prev_event_id: publish_event_id(payload.prev_event_id.as_str())?, 9671 decision: payload.decision.clone(), 9672 }) 9673 } 9674 9675 fn order_cancellation_publish_payload_to_sdk_cancellation( 9676 payload: &AppOrderCancellationPublishPayload, 9677 ) -> Result<RadrootsOrderCancellation, AppSyncTransportError> { 9678 Ok(RadrootsOrderCancellation { 9679 order_id: publish_order_id(payload.trade_order_id.as_str())?, 9680 listing_addr: publish_listing_addr(payload.listing_addr.as_str())?, 9681 buyer_pubkey: publish_pubkey(payload.buyer_pubkey.as_str())?, 9682 seller_pubkey: publish_pubkey(payload.seller_pubkey.as_str())?, 9683 reason: payload.reason.clone(), 9684 }) 9685 } 9686 9687 #[cfg(test)] 9688 fn pending_sync_upsert(aggregate: SyncAggregateRef, payload_json: String) -> PendingSyncOperation { 9689 let created_at = current_utc_timestamp(); 9690 9691 PendingSyncOperation::new( 9692 aggregate, 9693 SyncOperationKind::Upsert, 9694 payload_json, 9695 created_at, 9696 ) 9697 } 9698 9699 #[cfg(test)] 9700 fn farm_sync_payload( 9701 farm_id: FarmId, 9702 display_name: &str, 9703 readiness: Option<FarmReadiness>, 9704 source: &str, 9705 ) -> String { 9706 json!({ 9707 "aggregate_kind": "farm", 9708 "farm_id": farm_id.to_string(), 9709 "display_name": display_name, 9710 "readiness": readiness.map(|value| match value { 9711 FarmReadiness::Incomplete => "incomplete", 9712 FarmReadiness::Ready => "ready", 9713 }), 9714 "source": source, 9715 }) 9716 .to_string() 9717 } 9718 9719 fn signed_order_request_evidence_record_is_usable(record: &LocalEventRecord) -> bool { 9720 if record.status != LocalRecordStatus::Published 9721 || matches!( 9722 record.outbox_status, 9723 PublishOutboxStatus::Pending | PublishOutboxStatus::Failed 9724 ) 9725 { 9726 return false; 9727 } 9728 let Some(relay_delivery_json) = record.relay_delivery_json.as_ref() else { 9729 return false; 9730 }; 9731 let Ok(relay_delivery) = RelayDeliveryEvidence::from_json_value(relay_delivery_json) else { 9732 return false; 9733 }; 9734 matches!(relay_delivery.state.as_str(), "acknowledged" | "observed") 9735 } 9736 9737 #[cfg(test)] 9738 mod tests { 9739 use std::{ 9740 collections::BTreeSet, 9741 fs, 9742 path::PathBuf, 9743 sync::mpsc, 9744 sync::{Arc, Mutex}, 9745 thread, 9746 time::{Duration as StdDuration, SystemTime, UNIX_EPOCH}, 9747 }; 9748 9749 use chrono::{Duration, Utc}; 9750 use futures_util::{SinkExt, StreamExt}; 9751 use radroots_app_core::{ 9752 AppDesktopRuntimePaths, AppRuntimeHostEnvironment, AppRuntimePlatform, 9753 AppSdkLifecycleState, AppSdkProjectionLifecycleState, AppSharedAccountsPaths, 9754 SHARED_ACCOUNTS_STORE_FILE_NAME, SHARED_IDENTITY_FILE_NAME, 9755 }; 9756 use radroots_app_remote_signer::{ 9757 RadrootsAppRemoteSignerPendingSession, RadrootsAppRemoteSignerSessionRecord, 9758 }; 9759 use radroots_app_sqlite::{ 9760 AppSdkMigrationReceiptSourceKind, AppSdkMigrationState, AppSqliteError, AppSqliteStore, 9761 BuyerOrderCoordinationState, DatabaseTarget, latest_schema_version, 9762 projected_order_id_from_trade_request, 9763 }; 9764 use radroots_app_state::{ 9765 APP_STATE_FILE_NAME, AppStateCommand, AppStatePersistenceRepository, AppStateRepository, 9766 AppStateRepositoryError, AppStateStore, AppStateStoreError, FileBackedAppStateRepository, 9767 HomeRoute, 9768 }; 9769 use radroots_app_sync::{ 9770 AppFarmProfilePublishPayload, AppListingPublishPayload, AppOrderCancellationPublishPayload, 9771 AppOrderDecisionInventoryCommitment, AppOrderDecisionPayload, 9772 AppOrderDecisionPublishPayload, AppOrderRequestItemPayload, AppOrderRequestPublishPayload, 9773 AppOrderRevisionDecisionPublishPayload, AppOrderRevisionProposalPublishPayload, 9774 AppPublishContext, AppPublishPayload, AppPublishedOperationReceipt, 9775 AppRelayIngestScopeFreshness, AppRelayIngestScopeStatus, AppSyncRequest, AppSyncResult, 9776 AppSyncRunStatus, AppSyncTransport, AppSyncTransportError, PendingSyncOperation, 9777 PendingSyncOperationState, RecordedAppSyncTransport, SyncAggregateRef, SyncCheckpointState, 9778 SyncCheckpointStatus, SyncConflict, SyncConflictKind, SyncConflictResolutionStatus, 9779 SyncConflictSeverity, SyncOperationKind, SyncTrigger, 9780 }; 9781 use radroots_app_view::{ 9782 AccountCustody, AccountSummary, AccountSurfaceActivationProjection, ActiveSurface, 9783 AppActivityKind, AppIdentityProjection, AppStartupGate, BlackoutPeriodId, 9784 BlackoutPeriodRecord, BuyerOrderReviewDisabledReason, BuyerOrderReviewDraft, 9785 BuyerOrderStatus, FarmId, FarmOperatingRulesRecord, FarmOrderMethod, FarmProfileRecord, 9786 FarmReadiness, FarmReadinessBlocker, FarmRulesProjection, FarmSetupDraft, 9787 FarmSetupProjection, FarmSummary, FarmerActivationProjection, FarmerSection, 9788 FulfillmentWindowId, FulfillmentWindowRecord, LoggedOutStartupProjection, OrderId, 9789 OrderStatus, OrdersFilter, PackDayBatchPrintArtifact, PackDayBatchPrintFailureKind, 9790 PackDayBatchPrintStatus, PackDayExportInstanceId, PackDayExportStatus, 9791 PackDayHostHandoffKind, PackDayHostHandoffStatus, PackDayPackListRow, 9792 PackDayPrintFailureKind, PackDayPrintKind, PackDayPrintStatus, PackDayProductTotalRow, 9793 PackDayProjection, PackDayRosterRow, PersonalSection, PickupLocationId, 9794 PickupLocationRecord, ProductEditorDraft, ProductId, ProductPublishBlocker, ProductStatus, 9795 ProductsFilter, ProductsSort, ReminderDeliveryState, ReminderFeedProjection, ReminderKind, 9796 SelectedAccountProjection, SelectedSurfaceProjection, SettingsPreference, SettingsSection, 9797 ShellSection, TodayAgendaProjection, TodaySetupTask, TodaySetupTaskKind, TodaySummary, 9798 }; 9799 use radroots_core::{ 9800 RadrootsCoreCurrency, RadrootsCoreDecimal, RadrootsCoreMoney, RadrootsCoreUnit, 9801 }; 9802 use radroots_events::ids::{ 9803 RadrootsEventId, RadrootsInventoryBinId, RadrootsListingAddress, RadrootsOrderId, 9804 RadrootsOrderQuoteId, RadrootsOrderRevisionId, RadrootsPublicKey, 9805 }; 9806 use radroots_events_codec::wire::WireEventParts; 9807 use radroots_identity::{RadrootsIdentity, RadrootsIdentityId}; 9808 use radroots_local_events::{ 9809 BUYER_ORDER_REQUEST_LOCAL_WORK_RECORD_KIND, LocalEventRecord, LocalEventRecordInput, 9810 LocalEventsStore, LocalRecordFamily, LocalRecordStatus, PublishOutboxStatus, 9811 RelayDeliveryEvidence, SourceRuntime, 9812 }; 9813 use radroots_nostr::prelude::{ 9814 RadrootsNostrClient, RadrootsNostrEvent, RadrootsNostrKeys, RadrootsNostrSecretKey, 9815 RadrootsNostrTimestamp, radroots_event_from_nostr, radroots_nostr_build_event, 9816 }; 9817 use radroots_nostr_accounts::prelude::{ 9818 RadrootsNostrAccountsManager, RadrootsNostrFileAccountStore, 9819 RadrootsNostrMemoryAccountStore, RadrootsNostrSecretVaultMemory, RadrootsSecretVault, 9820 account_secret_slot, 9821 }; 9822 use radroots_sdk::protocol::events::{ 9823 RadrootsNostrEvent as SdkRadrootsNostrEvent, RadrootsNostrEventPtr, 9824 }; 9825 use radroots_sdk::protocol::order::{ 9826 RadrootsOrderCancellation, RadrootsOrderDecision, RadrootsOrderDecisionOutcome, 9827 RadrootsOrderEconomicItem, RadrootsOrderEconomics, RadrootsOrderInventoryCommitment, 9828 RadrootsOrderItem, RadrootsOrderPricingBasis, RadrootsOrderRequest, 9829 RadrootsOrderRevisionOutcome, RadrootsOrderRevisionProposal, 9830 }; 9831 use radroots_sdk::{ 9832 LISTING_PUBLISH_OPERATION_KIND, ORDER_CANCELLATION_OPERATION_KIND, 9833 ORDER_DECISION_OPERATION_KIND, ORDER_REVISION_DECISION_OPERATION_KIND, 9834 ORDER_SUBMIT_OPERATION_KIND, 9835 }; 9836 use radroots_sql_core::{SqlExecutor, SqliteExecutor}; 9837 use serde_json::json; 9838 use tokio::net::TcpListener; 9839 use tokio::sync::oneshot; 9840 use tokio_tungstenite::tungstenite::Message; 9841 use uuid::Uuid; 9842 9843 use crate::accounts::DesktopLocalIdentityImportRequest; 9844 9845 const SDK_TEST_BUYER_SECRET_KEY_HEX: &str = 9846 "10c5304d6c9ae3a1a16f7860f1cc8f5e3a76225a2663b3a989a0d775919b7df5"; 9847 const SDK_TEST_BUYER_PUBLIC_KEY_HEX: &str = 9848 "585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df"; 9849 const SDK_TEST_SELLER_SECRET_KEY_HEX: &str = 9850 "59392e9068f66431b12f70218fb61281cb6b433d7f27c55d61f1a63fe1a96ff8"; 9851 const SDK_TEST_SELLER_PUBLIC_KEY_HEX: &str = 9852 "e0266e3cfb0d2886f91c73f5f868f3b98273713e5fcd97c081663f5518a4b3af"; 9853 9854 use super::{ 9855 APP_DATABASE_FILE_NAME, APP_SYNC_PUBLISH_USES_SDK_RUNTIME_MESSAGE, 9856 ConfiguredRelayAppSyncTransport, DesktopAppRuntime, DesktopAppRuntimeActivityContextError, 9857 DesktopAppRuntimeCommandError, DesktopAppRuntimeMetadataSummary, DesktopAppRuntimeState, 9858 DesktopAppSdkDiagnosticsState, DesktopAppSyncStatusSummary, DesktopRemoteSignerPaths, 9859 SYNC_TRANSPORT_UNAVAILABLE_MESSAGE, TokioRuntimeBuilder, default_sync_transport, 9860 direct_relay_event_source_runtime, farm_sync_payload, is_hex_64, 9861 order_decision_publish_payload_to_sdk_decision, pending_sync_upsert, 9862 signed_event_from_local_record, 9863 }; 9864 use crate::pack_day_host_handoff::PackDayHostHandoffError; 9865 use crate::pack_day_print::{ 9866 PackDayBatchPrintError, PackDayPrintCommandResult, PackDayPrintError, 9867 execute_pack_day_batch_print_plan_with, prepared_customer_label_asset_root, 9868 }; 9869 9870 const BUYER_VISIBLE_SELLER_PUBKEY: &str = 9871 "2222222222222222222222222222222222222222222222222222222222222222"; 9872 9873 #[derive(Clone)] 9874 struct SharedRecordedSyncTransport(Arc<Mutex<RecordedAppSyncTransport>>); 9875 9876 impl AppSyncTransport for SharedRecordedSyncTransport { 9877 fn sync( 9878 &mut self, 9879 request: AppSyncRequest, 9880 ) -> Result<AppSyncResult, AppSyncTransportError> { 9881 self.0 9882 .lock() 9883 .expect("recorded sync transport lock") 9884 .sync(request) 9885 } 9886 } 9887 9888 #[test] 9889 fn direct_relay_trade_events_use_network_source_runtime_for_app_shaped_d_tags() { 9890 let app_shaped_d_tag = 9891 super::d_tag_from_uuid(Uuid::from_u128(0x12345678123446789123456781234567)); 9892 9893 assert_eq!( 9894 direct_relay_event_source_runtime(30340, Some(app_shaped_d_tag.as_str())), 9895 SourceRuntime::Network 9896 ); 9897 assert_eq!( 9898 direct_relay_event_source_runtime(30402, Some(app_shaped_d_tag.as_str())), 9899 SourceRuntime::Network 9900 ); 9901 assert_eq!( 9902 direct_relay_event_source_runtime(30403, Some(app_shaped_d_tag.as_str())), 9903 SourceRuntime::Network 9904 ); 9905 } 9906 9907 struct ThreadedAckRelay { 9908 url: String, 9909 events: Arc<Mutex<Vec<serde_json::Value>>>, 9910 shutdown_tx: Option<oneshot::Sender<()>>, 9911 join_handle: Option<thread::JoinHandle<()>>, 9912 } 9913 9914 impl ThreadedAckRelay { 9915 fn spawn() -> Self { 9916 let (url_tx, url_rx) = mpsc::channel(); 9917 let (shutdown_tx, shutdown_rx) = oneshot::channel(); 9918 let events: Arc<Mutex<Vec<serde_json::Value>>> = Arc::new(Mutex::new(Vec::new())); 9919 let thread_events = events.clone(); 9920 let join_handle = thread::spawn(move || { 9921 let runtime = TokioRuntimeBuilder::new_current_thread() 9922 .enable_all() 9923 .build() 9924 .expect("relay runtime should build"); 9925 runtime.block_on(async move { 9926 let listener = TcpListener::bind("127.0.0.1:0") 9927 .await 9928 .expect("test relay should bind"); 9929 let url = format!( 9930 "ws://{}", 9931 listener.local_addr().expect("test relay local addr") 9932 ); 9933 url_tx.send(url).expect("relay url should send"); 9934 let mut shutdown_rx = shutdown_rx; 9935 loop { 9936 tokio::select! { 9937 _ = &mut shutdown_rx => break, 9938 accepted = listener.accept() => { 9939 let Ok((stream, _)) = accepted else { 9940 break; 9941 }; 9942 let events = thread_events.clone(); 9943 tokio::spawn(async move { 9944 let Ok(websocket) = tokio_tungstenite::accept_async(stream).await else { 9945 return; 9946 }; 9947 let (mut writer, mut reader) = websocket.split(); 9948 while let Some(message) = reader.next().await { 9949 let Ok(Message::Text(text)) = message else { 9950 continue; 9951 }; 9952 let Ok(value) = serde_json::from_str::<serde_json::Value>(text.as_str()) else { 9953 continue; 9954 }; 9955 let Some(items) = value.as_array() else { 9956 continue; 9957 }; 9958 match items.as_slice() { 9959 [kind, event, ..] if kind.as_str() == Some("EVENT") => { 9960 let Some(event_id) = event.get("id").and_then(|id| id.as_str()) else { 9961 continue; 9962 }; 9963 events.lock().expect("relay events lock").push(event.clone()); 9964 let response = json!(["OK", event_id, true, ""]).to_string(); 9965 if writer.send(Message::Text(response.into())).await.is_err() { 9966 break; 9967 } 9968 } 9969 [kind, subscription_id, filters @ ..] if kind.as_str() == Some("REQ") => { 9970 let Some(subscription_id) = subscription_id.as_str() else { 9971 continue; 9972 }; 9973 let snapshot = events.lock().expect("relay events lock").clone(); 9974 for event in snapshot.iter().filter(|event| relay_event_matches_filters(event, filters)) { 9975 let response = json!(["EVENT", subscription_id, event]).to_string(); 9976 if writer.send(Message::Text(response.into())).await.is_err() { 9977 break; 9978 } 9979 } 9980 let response = json!(["EOSE", subscription_id]).to_string(); 9981 if writer.send(Message::Text(response.into())).await.is_err() { 9982 break; 9983 } 9984 } 9985 [kind, ..] if kind.as_str() == Some("CLOSE") => break, 9986 _ => {} 9987 } 9988 } 9989 }); 9990 } 9991 } 9992 } 9993 }); 9994 }); 9995 let url = url_rx.recv().expect("relay url should be received"); 9996 9997 Self { 9998 url, 9999 events, 10000 shutdown_tx: Some(shutdown_tx), 10001 join_handle: Some(join_handle), 10002 } 10003 } 10004 10005 fn url(&self) -> &str { 10006 self.url.as_str() 10007 } 10008 10009 fn event_count(&self) -> usize { 10010 self.events.lock().expect("relay events lock").len() 10011 } 10012 } 10013 10014 fn relay_event_matches_filters( 10015 event: &serde_json::Value, 10016 filters: &[serde_json::Value], 10017 ) -> bool { 10018 filters.is_empty() 10019 || filters 10020 .iter() 10021 .any(|filter| relay_event_matches_filter(event, filter)) 10022 } 10023 10024 fn relay_event_matches_filter(event: &serde_json::Value, filter: &serde_json::Value) -> bool { 10025 let event_kind = event.get("kind").and_then(serde_json::Value::as_u64); 10026 if let Some(kinds) = filter.get("kinds").and_then(serde_json::Value::as_array) 10027 && !kinds 10028 .iter() 10029 .filter_map(serde_json::Value::as_u64) 10030 .any(|kind| Some(kind) == event_kind) 10031 { 10032 return false; 10033 } 10034 10035 let event_created_at = event.get("created_at").and_then(serde_json::Value::as_u64); 10036 if let Some(until) = filter.get("until").and_then(serde_json::Value::as_u64) 10037 && event_created_at.is_some_and(|created_at| created_at > until) 10038 { 10039 return false; 10040 } 10041 10042 true 10043 } 10044 10045 fn assert_missing_listing_provenance_relay_error( 10046 error: &AppSyncTransportError, 10047 relay_url: &str, 10048 ) { 10049 let AppSyncTransportError::Failed { message } = error else { 10050 panic!("unexpected error: {error}"); 10051 }; 10052 let value = 10053 serde_json::from_str::<serde_json::Value>(message).expect("structured relay error"); 10054 assert_eq!(value["code"], "missing_listing_provenance_relay"); 10055 assert_eq!(value["missing_provenance_relays"], json!([relay_url])); 10056 } 10057 10058 fn assert_migrated_payload_uses_sdk_runtime(error: AppSyncTransportError) { 10059 match error { 10060 AppSyncTransportError::Failed { message } => { 10061 assert_eq!(message, APP_SYNC_PUBLISH_USES_SDK_RUNTIME_MESSAGE) 10062 } 10063 unexpected => panic!("unexpected migrated payload error: {unexpected}"), 10064 } 10065 } 10066 10067 fn direct_relay_listing_payload( 10068 account_id: &str, 10069 farm_pubkey: String, 10070 source: &str, 10071 ) -> AppPublishPayload { 10072 let farm_id = FarmId::new(); 10073 let product_id = ProductId::new(); 10074 AppPublishPayload::Listing(AppListingPublishPayload { 10075 context: AppPublishContext::new(account_id.to_owned(), source), 10076 product_id, 10077 listing_d_tag: Some(super::d_tag_from_uuid(product_id.as_uuid())), 10078 farm_id: Some(farm_id), 10079 farm_pubkey: Some(farm_pubkey), 10080 farm_d_tag: Some(super::d_tag_from_uuid(farm_id.as_uuid())), 10081 title: "North field eggs".to_owned(), 10082 subtitle: Some("Pasture raised".to_owned()), 10083 category: Some("eggs".to_owned()), 10084 unit_label: "each".to_owned(), 10085 price_minor_units: Some(750), 10086 price_currency: "USD".to_owned(), 10087 stock_quantity: Some(12), 10088 availability_window_id: Some(FulfillmentWindowId::new()), 10089 availability_starts_at: Some("2099-05-25T14:00:00Z".to_owned()), 10090 availability_ends_at: Some("2099-05-25T18:00:00Z".to_owned()), 10091 fulfillment_method: Some("pickup".to_owned()), 10092 fulfillment_location: Some("farmstand".to_owned()), 10093 status: ProductStatus::Published, 10094 }) 10095 } 10096 10097 fn publish_signed_test_event_to_relay(relay: &ThreadedAckRelay, event: &RadrootsNostrEvent) { 10098 let runtime = TokioRuntimeBuilder::new_current_thread() 10099 .enable_all() 10100 .build() 10101 .expect("test relay publish runtime should build"); 10102 runtime.block_on(async { 10103 let client = RadrootsNostrClient::new_signerless(); 10104 client 10105 .add_write_relay(relay.url()) 10106 .await 10107 .expect("test relay should accept write relay"); 10108 let connection_output = client.try_connect(StdDuration::from_secs(5)).await; 10109 assert!( 10110 !connection_output.success.is_empty(), 10111 "test relay write connection should succeed" 10112 ); 10113 let output = client 10114 .send_event_to(vec![relay.url().to_owned()], event) 10115 .await 10116 .expect("test event should publish to relay"); 10117 assert!( 10118 !output.success.is_empty(), 10119 "test event publish should be acknowledged" 10120 ); 10121 }); 10122 } 10123 10124 impl Drop for ThreadedAckRelay { 10125 fn drop(&mut self) { 10126 if let Some(shutdown_tx) = self.shutdown_tx.take() { 10127 let _ = shutdown_tx.send(()); 10128 } 10129 if let Some(join_handle) = self.join_handle.take() { 10130 let _ = join_handle.join(); 10131 } 10132 } 10133 } 10134 10135 fn test_event_id_seed(seed: &str) -> String { 10136 if RadrootsEventId::parse(seed).is_ok() { 10137 return seed.to_owned(); 10138 } 10139 let mut bytes = [0u8; 32]; 10140 for (index, byte) in seed.bytes().enumerate() { 10141 let primary = index % bytes.len(); 10142 let secondary = (index * 7 + 13) % bytes.len(); 10143 bytes[primary] = bytes[primary] 10144 .wrapping_add(byte) 10145 .wrapping_add((index as u8).wrapping_mul(31)); 10146 bytes[secondary] ^= byte.rotate_left((index % 8) as u32); 10147 } 10148 let mut hex = String::with_capacity(64); 10149 for byte in bytes { 10150 hex.push_str(format!("{byte:02x}").as_str()); 10151 } 10152 hex 10153 } 10154 10155 fn test_event_id(seed: &str) -> RadrootsEventId { 10156 RadrootsEventId::parse(test_event_id_seed(seed)).expect("event id") 10157 } 10158 10159 fn signed_event_id(record_id: &str) -> String { 10160 test_event_id_seed(record_id) 10161 } 10162 10163 fn test_event_signature_seed(seed: &str) -> String { 10164 let base = test_event_id_seed(seed); 10165 format!("{base}{base}") 10166 } 10167 10168 fn test_event_created_at(seed: &str, base: u32) -> u32 { 10169 let offset = seed 10170 .bytes() 10171 .fold(0u32, |acc, byte| (acc + u32::from(byte)) % 900); 10172 base + offset 10173 } 10174 10175 fn signed_test_event( 10176 secret_key_hex: &str, 10177 created_at: u32, 10178 parts: WireEventParts, 10179 ) -> SdkRadrootsNostrEvent { 10180 let secret_key = RadrootsNostrSecretKey::from_hex(secret_key_hex).expect("secret key"); 10181 let keys = RadrootsNostrKeys::new(secret_key); 10182 let event = radroots_nostr_build_event(parts.kind, parts.content, parts.tags) 10183 .expect("test event builder") 10184 .custom_created_at(RadrootsNostrTimestamp::from_secs(u64::from(created_at))) 10185 .sign_with_keys(&keys) 10186 .expect("test event should sign"); 10187 radroots_event_from_nostr(&event) 10188 } 10189 10190 fn signed_test_event_for_pubkey( 10191 pubkey: &str, 10192 created_at: u32, 10193 parts: WireEventParts, 10194 ) -> Option<SdkRadrootsNostrEvent> { 10195 match pubkey { 10196 SDK_TEST_BUYER_PUBLIC_KEY_HEX => Some(signed_test_event( 10197 SDK_TEST_BUYER_SECRET_KEY_HEX, 10198 created_at, 10199 parts, 10200 )), 10201 SDK_TEST_SELLER_PUBLIC_KEY_HEX => Some(signed_test_event( 10202 SDK_TEST_SELLER_SECRET_KEY_HEX, 10203 created_at, 10204 parts, 10205 )), 10206 _ => None, 10207 } 10208 } 10209 10210 fn test_event_from_parts( 10211 record_id: &str, 10212 event_id: String, 10213 event_pubkey: &str, 10214 created_at: u32, 10215 parts: WireEventParts, 10216 ) -> SdkRadrootsNostrEvent { 10217 signed_test_event_for_pubkey(event_pubkey, created_at, parts.clone()).unwrap_or_else(|| { 10218 SdkRadrootsNostrEvent { 10219 id: event_id, 10220 author: event_pubkey.to_owned(), 10221 created_at, 10222 kind: parts.kind, 10223 tags: parts.tags, 10224 content: parts.content, 10225 sig: test_event_signature_seed(record_id), 10226 } 10227 }) 10228 } 10229 10230 fn signed_listing_event_id(label: &str) -> String { 10231 signed_event_id(format!("app:signed_event:listing:{label}").as_str()) 10232 } 10233 10234 fn test_order_id(value: &str) -> RadrootsOrderId { 10235 RadrootsOrderId::parse(value).expect("order id") 10236 } 10237 10238 fn test_revision_id(value: &str) -> RadrootsOrderRevisionId { 10239 RadrootsOrderRevisionId::parse(value).expect("revision id") 10240 } 10241 10242 fn test_quote_id(value: &str) -> RadrootsOrderQuoteId { 10243 RadrootsOrderQuoteId::parse(value).expect("quote id") 10244 } 10245 10246 fn test_bin_id(value: &str) -> RadrootsInventoryBinId { 10247 RadrootsInventoryBinId::parse(value).expect("bin id") 10248 } 10249 10250 fn test_pubkey(value: &str) -> RadrootsPublicKey { 10251 RadrootsPublicKey::parse(value).expect("pubkey") 10252 } 10253 10254 fn test_listing_addr(value: &str) -> RadrootsListingAddress { 10255 RadrootsListingAddress::parse(value).expect("listing address") 10256 } 10257 10258 fn install_recorded_sync_transport( 10259 runtime: &DesktopAppRuntime, 10260 transport: RecordedAppSyncTransport, 10261 ) -> Arc<Mutex<RecordedAppSyncTransport>> { 10262 let shared = Arc::new(Mutex::new(transport)); 10263 runtime.lock_state_mut().sync_transport = 10264 Box::new(SharedRecordedSyncTransport(shared.clone())); 10265 shared 10266 } 10267 10268 fn install_direct_relay_sync_transport(runtime: &DesktopAppRuntime, relay: &ThreadedAckRelay) { 10269 let accounts_manager = runtime 10270 .lock_state() 10271 .accounts_manager 10272 .as_ref() 10273 .expect("accounts manager") 10274 .clone(); 10275 runtime.lock_state_mut().nostr_relay_urls = vec![relay.url().to_owned()]; 10276 runtime.lock_state_mut().sync_transport = 10277 Box::new(ConfiguredRelayAppSyncTransport::with_relay_urls( 10278 accounts_manager, 10279 vec![relay.url().to_owned()], 10280 )); 10281 } 10282 10283 fn configure_runtime_relay_ingest(runtime: &DesktopAppRuntime, relay: &ThreadedAckRelay) { 10284 runtime.lock_state_mut().nostr_relay_urls = vec![relay.url().to_owned()]; 10285 } 10286 10287 #[test] 10288 fn runtime_direct_relay_transport_rejects_typed_farm_work() { 10289 let relay_a = ThreadedAckRelay::spawn(); 10290 let relay_b = ThreadedAckRelay::spawn(); 10291 let manager = RadrootsNostrAccountsManager::new_in_memory(); 10292 let account_id = manager 10293 .generate_identity(Some("Farmer".to_owned()), true) 10294 .expect("local signing account should generate"); 10295 let farm_id = FarmId::new(); 10296 let payload = AppPublishPayload::FarmProfile(AppFarmProfilePublishPayload { 10297 context: AppPublishContext::new(account_id.to_string(), "farm_setup") 10298 .with_source_local_event_id("app:local_work:farm:direct"), 10299 farm_id, 10300 display_name: "North field farm".to_owned(), 10301 readiness: Some(FarmReadiness::Ready), 10302 }); 10303 let operation = PendingSyncOperation::from_publish_payload(payload, "2026-05-24T12:00:00Z") 10304 .expect("typed farm publish work should serialize"); 10305 let mut transport = ConfiguredRelayAppSyncTransport::with_relay_urls( 10306 manager, 10307 vec![relay_a.url().to_owned(), relay_b.url().to_owned()], 10308 ); 10309 10310 let error = transport 10311 .sync(AppSyncRequest { 10312 trigger: SyncTrigger::ManualRefresh, 10313 checkpoint: SyncCheckpointStatus::never_synced(), 10314 pending_operations: vec![operation], 10315 known_conflicts: Vec::new(), 10316 }) 10317 .expect_err("direct relay farm publish should use AppSdkRuntime"); 10318 10319 assert_migrated_payload_uses_sdk_runtime(error); 10320 assert_eq!(relay_a.event_count(), 0); 10321 assert_eq!(relay_b.event_count(), 0); 10322 } 10323 10324 #[test] 10325 fn runtime_direct_relay_transport_rejects_typed_listing_work() { 10326 let relay = ThreadedAckRelay::spawn(); 10327 let manager = RadrootsNostrAccountsManager::new_in_memory(); 10328 let account_id = manager 10329 .generate_identity(Some("Farmer".to_owned()), true) 10330 .expect("local signing account should generate"); 10331 let identity = manager 10332 .get_signing_identity(&account_id) 10333 .expect("seller signer lookup should succeed") 10334 .expect("seller account should have local signer"); 10335 let payload = direct_relay_listing_payload( 10336 account_id.to_string().as_str(), 10337 identity.public_key_hex(), 10338 "listing_publish", 10339 ); 10340 let operation = PendingSyncOperation::from_publish_payload(payload, "2026-05-24T12:00:00Z") 10341 .expect("typed listing publish work should serialize"); 10342 let mut transport = 10343 ConfiguredRelayAppSyncTransport::with_relay_urls(manager, vec![relay.url().to_owned()]); 10344 10345 let error = transport 10346 .sync(AppSyncRequest { 10347 trigger: SyncTrigger::ManualRefresh, 10348 checkpoint: SyncCheckpointStatus::never_synced(), 10349 pending_operations: vec![operation], 10350 known_conflicts: Vec::new(), 10351 }) 10352 .expect_err("direct relay listing publish should use AppSdkRuntime"); 10353 10354 assert_migrated_payload_uses_sdk_runtime(error); 10355 assert_eq!(relay.event_count(), 0); 10356 } 10357 10358 #[test] 10359 fn runtime_direct_relay_transport_rejects_typed_order_request_work() { 10360 let relay = ThreadedAckRelay::spawn(); 10361 let manager = RadrootsNostrAccountsManager::new_in_memory(); 10362 let account_id = manager 10363 .generate_identity(Some("Buyer".to_owned()), true) 10364 .expect("local signing account should generate"); 10365 let buyer_identity = manager 10366 .get_signing_identity(&account_id) 10367 .expect("buyer signer lookup should succeed") 10368 .expect("buyer account should have local signer"); 10369 let seller_identity = RadrootsIdentity::generate(); 10370 let product_id = ProductId::new(); 10371 let order_id = OrderId::new(); 10372 let listing_event_id = "1".repeat(64); 10373 let listing_addr = format!( 10374 "30402:{}:{}", 10375 seller_identity.public_key_hex(), 10376 super::d_tag_from_uuid(ProductId::new().as_uuid()) 10377 ); 10378 let order_document = RadrootsOrderRequest { 10379 order_id: test_order_id(order_id.to_string().as_str()), 10380 listing_addr: test_listing_addr(listing_addr.as_str()), 10381 buyer_pubkey: test_pubkey(buyer_identity.public_key_hex().as_str()), 10382 seller_pubkey: test_pubkey(seller_identity.public_key_hex().as_str()), 10383 items: vec![RadrootsOrderItem { 10384 bin_id: test_bin_id("bin-1"), 10385 bin_count: 1, 10386 }], 10387 economics: RadrootsOrderEconomics { 10388 quote_id: test_quote_id(format!("quote-{order_id}").as_str()), 10389 quote_version: 1, 10390 pricing_basis: RadrootsOrderPricingBasis::ListingEvent, 10391 currency: RadrootsCoreCurrency::USD, 10392 items: vec![RadrootsOrderEconomicItem { 10393 bin_id: test_bin_id("bin-1"), 10394 bin_count: 1, 10395 quantity_amount: RadrootsCoreDecimal::from(1u32), 10396 quantity_unit: RadrootsCoreUnit::Each, 10397 unit_price_amount: RadrootsCoreDecimal::from(5u32), 10398 unit_price_currency: RadrootsCoreCurrency::USD, 10399 line_subtotal: RadrootsCoreMoney::from_minor_units_u32( 10400 500, 10401 RadrootsCoreCurrency::USD, 10402 ), 10403 }], 10404 discounts: Vec::new(), 10405 adjustments: Vec::new(), 10406 subtotal: RadrootsCoreMoney::from_minor_units_u32(500, RadrootsCoreCurrency::USD), 10407 discount_total: RadrootsCoreMoney::zero(RadrootsCoreCurrency::USD), 10408 adjustment_total: RadrootsCoreMoney::zero(RadrootsCoreCurrency::USD), 10409 total: RadrootsCoreMoney::from_minor_units_u32(500, RadrootsCoreCurrency::USD), 10410 }, 10411 }; 10412 let payload = AppPublishPayload::OrderRequest(AppOrderRequestPublishPayload { 10413 context: AppPublishContext::new(account_id.to_string(), "place_personal_order") 10414 .with_source_local_event_id("app:local_work:order_request:direct"), 10415 order_id, 10416 farm_id: FarmId::new(), 10417 status: Some("needs_action".to_owned()), 10418 order_document_json: Some(json!({"document": {"order": order_document}})), 10419 listing_addr: Some(listing_addr), 10420 listing_event_id: Some(listing_event_id), 10421 listing_relays: vec![relay.url().to_owned()], 10422 buyer_pubkey: Some(buyer_identity.public_key_hex()), 10423 seller_pubkey: Some(seller_identity.public_key_hex()), 10424 items: vec![AppOrderRequestItemPayload { 10425 product_id, 10426 quantity: 1, 10427 }], 10428 currency_code: Some("USD".to_owned()), 10429 total_minor_units: Some(500), 10430 note: Some("coordinate pickup".to_owned()), 10431 }); 10432 let operation = PendingSyncOperation::from_publish_payload(payload, "2026-05-24T12:00:00Z") 10433 .expect("typed order request publish work should serialize"); 10434 let mut transport = 10435 ConfiguredRelayAppSyncTransport::with_relay_urls(manager, vec![relay.url().to_owned()]); 10436 10437 let error = transport 10438 .sync(AppSyncRequest { 10439 trigger: SyncTrigger::ManualRefresh, 10440 checkpoint: SyncCheckpointStatus::never_synced(), 10441 pending_operations: vec![operation], 10442 known_conflicts: Vec::new(), 10443 }) 10444 .expect_err("direct relay order request publish should use AppSdkRuntime"); 10445 10446 assert_migrated_payload_uses_sdk_runtime(error); 10447 assert_eq!(relay.event_count(), 0); 10448 } 10449 10450 #[test] 10451 fn runtime_direct_relay_transport_rejects_typed_order_decision_work() { 10452 let relay = ThreadedAckRelay::spawn(); 10453 let manager = RadrootsNostrAccountsManager::new_in_memory(); 10454 let account_id = manager 10455 .generate_identity(Some("Seller".to_owned()), true) 10456 .expect("local signing account should generate"); 10457 let identity = manager 10458 .get_signing_identity(&account_id) 10459 .expect("seller signer lookup should succeed") 10460 .expect("seller account should have local signer"); 10461 let buyer_pubkey = "1111111111111111111111111111111111111111111111111111111111111111"; 10462 let payload = AppPublishPayload::OrderDecision(AppOrderDecisionPublishPayload { 10463 context: AppPublishContext::new(account_id.to_string(), "seller_order_decision"), 10464 app_order_id: OrderId::new(), 10465 farm_id: FarmId::new(), 10466 trade_order_id: "order-1".to_owned(), 10467 request_event_id: test_event_id_seed("order-request-event-1"), 10468 listing_event_id: Some(test_event_id_seed("listing-event-1")), 10469 listing_addr: format!("30402:{}:listing-key", identity.public_key_hex()), 10470 buyer_pubkey: buyer_pubkey.to_owned(), 10471 seller_pubkey: identity.public_key_hex(), 10472 decision: AppOrderDecisionPayload::Accepted { 10473 inventory_commitments: vec![AppOrderDecisionInventoryCommitment { 10474 bin_id: "bin-1".to_owned(), 10475 bin_count: 2, 10476 }], 10477 }, 10478 }); 10479 let operation = PendingSyncOperation::from_publish_payload(payload, "2026-05-24T12:00:00Z") 10480 .expect("typed order decision publish work should serialize"); 10481 let mut transport = 10482 ConfiguredRelayAppSyncTransport::with_relay_urls(manager, vec![relay.url().to_owned()]); 10483 10484 let error = transport 10485 .sync(AppSyncRequest { 10486 trigger: SyncTrigger::ManualRefresh, 10487 checkpoint: SyncCheckpointStatus::never_synced(), 10488 pending_operations: vec![operation], 10489 known_conflicts: Vec::new(), 10490 }) 10491 .expect_err("direct relay order decision publish should use AppSdkRuntime"); 10492 10493 assert_migrated_payload_uses_sdk_runtime(error); 10494 assert_eq!(relay.event_count(), 0); 10495 } 10496 10497 #[test] 10498 fn runtime_direct_relay_transport_rejects_typed_order_lifecycle_work() { 10499 let relay = ThreadedAckRelay::spawn(); 10500 let manager = RadrootsNostrAccountsManager::new_in_memory(); 10501 let buyer_account_id = manager 10502 .generate_identity(Some("Buyer".to_owned()), true) 10503 .expect("buyer account should generate"); 10504 let seller_account_id = manager 10505 .generate_identity(Some("Seller".to_owned()), true) 10506 .expect("seller account should generate"); 10507 let buyer_identity = manager 10508 .get_signing_identity(&buyer_account_id) 10509 .expect("buyer signer lookup should succeed") 10510 .expect("buyer account should have local signer"); 10511 let seller_identity = manager 10512 .get_signing_identity(&seller_account_id) 10513 .expect("seller signer lookup should succeed") 10514 .expect("seller account should have local signer"); 10515 let app_order_id = OrderId::new(); 10516 let farm_id = FarmId::new(); 10517 let listing_addr = format!( 10518 "30402:{}:AAAAAAAAAAAAAAAAAAAAAg", 10519 seller_identity.public_key_hex() 10520 ); 10521 let common = ( 10522 app_order_id, 10523 farm_id, 10524 "order-1".to_owned(), 10525 test_event_id_seed("order-request-event-1"), 10526 listing_addr, 10527 buyer_identity.public_key_hex(), 10528 seller_identity.public_key_hex(), 10529 ); 10530 let revision_economics = RadrootsOrderEconomics { 10531 quote_id: test_quote_id("quote-revision-1"), 10532 quote_version: 2, 10533 pricing_basis: RadrootsOrderPricingBasis::ListingEvent, 10534 currency: RadrootsCoreCurrency::USD, 10535 items: vec![RadrootsOrderEconomicItem { 10536 bin_id: test_bin_id("bin-1"), 10537 bin_count: 3, 10538 quantity_amount: RadrootsCoreDecimal::from(1u32), 10539 quantity_unit: RadrootsCoreUnit::Each, 10540 unit_price_amount: RadrootsCoreDecimal::from(8u32), 10541 unit_price_currency: RadrootsCoreCurrency::USD, 10542 line_subtotal: RadrootsCoreMoney::from_minor_units_u32( 10543 2400, 10544 RadrootsCoreCurrency::USD, 10545 ), 10546 }], 10547 discounts: Vec::new(), 10548 adjustments: Vec::new(), 10549 subtotal: RadrootsCoreMoney::from_minor_units_u32(2400, RadrootsCoreCurrency::USD), 10550 discount_total: RadrootsCoreMoney::zero(RadrootsCoreCurrency::USD), 10551 adjustment_total: RadrootsCoreMoney::zero(RadrootsCoreCurrency::USD), 10552 total: RadrootsCoreMoney::from_minor_units_u32(2400, RadrootsCoreCurrency::USD), 10553 }; 10554 let revision_proposal = 10555 AppPublishPayload::OrderRevisionProposal(AppOrderRevisionProposalPublishPayload { 10556 context: AppPublishContext::new( 10557 seller_account_id.to_string(), 10558 "seller_order_revision_proposal", 10559 ), 10560 app_order_id: common.0, 10561 farm_id: common.1, 10562 trade_order_id: common.2.clone(), 10563 request_event_id: common.3.clone(), 10564 prev_event_id: test_event_id_seed("order-decision-event-1"), 10565 revision_id: "revision-1".to_owned(), 10566 listing_addr: common.4.clone(), 10567 buyer_pubkey: common.5.clone(), 10568 seller_pubkey: common.6.clone(), 10569 items: vec![RadrootsOrderItem { 10570 bin_id: test_bin_id("bin-1"), 10571 bin_count: 3, 10572 }], 10573 economics: revision_economics, 10574 reason: "harvest count updated".to_owned(), 10575 }); 10576 let revision_decision = 10577 AppPublishPayload::OrderRevisionDecision(AppOrderRevisionDecisionPublishPayload { 10578 context: AppPublishContext::new( 10579 buyer_account_id.to_string(), 10580 "buyer_order_revision_decision", 10581 ), 10582 app_order_id: common.0, 10583 farm_id: common.1, 10584 trade_order_id: common.2.clone(), 10585 request_event_id: common.3.clone(), 10586 prev_event_id: test_event_id_seed("order-revision-proposal-event-1"), 10587 revision_id: "revision-1".to_owned(), 10588 listing_addr: common.4.clone(), 10589 buyer_pubkey: common.5.clone(), 10590 seller_pubkey: common.6.clone(), 10591 decision: RadrootsOrderRevisionOutcome::Accepted, 10592 }); 10593 let cancellation = 10594 AppPublishPayload::OrderCancellation(AppOrderCancellationPublishPayload { 10595 context: AppPublishContext::new( 10596 buyer_account_id.to_string(), 10597 "buyer_order_cancellation", 10598 ), 10599 app_order_id: common.0, 10600 farm_id: common.1, 10601 trade_order_id: common.2.clone(), 10602 request_event_id: common.3.clone(), 10603 prev_event_id: common.3.clone(), 10604 listing_addr: common.4.clone(), 10605 buyer_pubkey: common.5.clone(), 10606 seller_pubkey: common.6.clone(), 10607 reason: "buyer cancelled order".to_owned(), 10608 }); 10609 let operations = [revision_proposal, revision_decision, cancellation] 10610 .into_iter() 10611 .map(|payload| { 10612 PendingSyncOperation::from_publish_payload(payload, "2026-05-24T12:00:00Z") 10613 .expect("typed lifecycle publish work should serialize") 10614 }) 10615 .collect::<Vec<_>>(); 10616 let mut transport = 10617 ConfiguredRelayAppSyncTransport::with_relay_urls(manager, vec![relay.url().to_owned()]); 10618 10619 let error = transport 10620 .sync(AppSyncRequest { 10621 trigger: SyncTrigger::ManualRefresh, 10622 checkpoint: SyncCheckpointStatus::never_synced(), 10623 pending_operations: operations, 10624 known_conflicts: Vec::new(), 10625 }) 10626 .expect_err("direct relay lifecycle publish should use AppSdkRuntime"); 10627 10628 assert_migrated_payload_uses_sdk_runtime(error); 10629 assert_eq!(relay.event_count(), 0); 10630 } 10631 10632 #[test] 10633 fn runtime_configured_relay_sync_triggers_ingest_listing_into_fresh_buyer_projection() { 10634 let relay = ThreadedAckRelay::spawn(); 10635 let projected_product_id = publish_relay_ingest_listing_fixture(&relay); 10636 10637 assert_fresh_buyer_relay_ingest( 10638 relay.url(), 10639 "relay_ingest_manual_refresh", 10640 SyncTrigger::ManualRefresh, 10641 projected_product_id, 10642 ); 10643 assert_fresh_buyer_relay_ingest( 10644 relay.url(), 10645 "relay_ingest_app_launch", 10646 SyncTrigger::AppLaunch, 10647 projected_product_id, 10648 ); 10649 assert_fresh_buyer_relay_ingest( 10650 relay.url(), 10651 "relay_ingest_foreground_resume", 10652 SyncTrigger::ForegroundResume, 10653 projected_product_id, 10654 ); 10655 } 10656 10657 #[test] 10658 fn runtime_relay_ingest_does_not_use_connected_relays_as_listing_provenance() { 10659 let listing_relay = ThreadedAckRelay::spawn(); 10660 let empty_relay = ThreadedAckRelay::spawn(); 10661 let projected_product_id = publish_relay_ingest_listing_fixture(&listing_relay); 10662 let (runtime, paths) = bootstrapped_runtime("relay_ingest_connected_not_provenance"); 10663 assert!( 10664 runtime 10665 .generate_local_account(Some("Buyer".to_owned())) 10666 .expect("buyer account should generate") 10667 ); 10668 runtime.lock_state_mut().nostr_relay_urls = 10669 vec![listing_relay.url().to_owned(), empty_relay.url().to_owned()]; 10670 10671 assert!( 10672 runtime 10673 .sync_on_manual_refresh() 10674 .expect("manual relay ingest should complete") 10675 ); 10676 10677 let summary = runtime.summary(); 10678 let listing = summary 10679 .personal_projection 10680 .browse 10681 .listings 10682 .rows 10683 .iter() 10684 .find(|listing| listing.product_id == projected_product_id) 10685 .expect("fresh buyer app should project relay listing"); 10686 assert_eq!(listing.title, "Relay ingest lettuce"); 10687 assert_eq!(listing.listing_relays, vec![listing_relay.url().to_owned()]); 10688 10689 let product_id_string = projected_product_id.to_string(); 10690 let imports = runtime 10691 .lock_state() 10692 .sqlite_store 10693 .as_ref() 10694 .expect("sqlite store") 10695 .load_local_interop_records() 10696 .expect("local interop records should load"); 10697 let listing_import = imports 10698 .iter() 10699 .find(|record| { 10700 record.projected_kind == "listing" 10701 && record.projected_id.as_deref() == Some(product_id_string.as_str()) 10702 }) 10703 .expect("listing import"); 10704 let delivery = serde_json::from_str::<serde_json::Value>( 10705 listing_import 10706 .relay_delivery_json 10707 .as_deref() 10708 .expect("listing delivery evidence"), 10709 ) 10710 .expect("delivery json"); 10711 10712 assert_eq!( 10713 listing_import.source_runtime, 10714 SourceRuntime::Network.as_str() 10715 ); 10716 assert_eq!(listing_import.outbox_status, "none"); 10717 assert_eq!(delivery["state"], json!("observed")); 10718 assert_eq!(delivery["acknowledged_relays"], json!([])); 10719 assert_eq!(delivery["observed_relays"], json!([listing_relay.url()])); 10720 assert_eq!( 10721 delivery["target_relays"], 10722 json!([listing_relay.url(), empty_relay.url()]) 10723 ); 10724 10725 cleanup_bootstrapped_runtime_paths(&paths); 10726 } 10727 10728 fn publish_relay_ingest_listing_fixture(relay: &ThreadedAckRelay) -> ProductId { 10729 let manager = RadrootsNostrAccountsManager::new_in_memory(); 10730 let account_id = manager 10731 .generate_identity(Some("Farmer".to_owned()), true) 10732 .expect("local signing account should generate"); 10733 let identity = manager 10734 .get_signing_identity(&account_id) 10735 .expect("seller signing lookup should succeed") 10736 .expect("seller account should have local signer"); 10737 let seller_pubkey = identity.public_key_hex(); 10738 let farm_id = FarmId::new(); 10739 let product_id = ProductId::new(); 10740 let listing_d_tag = super::d_tag_from_uuid(product_id.as_uuid()); 10741 let projected_product_id = deterministic_cli_listing_product_id( 10742 Some(seller_pubkey.as_str()), 10743 listing_d_tag.as_str(), 10744 ); 10745 let listing_payload = AppListingPublishPayload { 10746 context: AppPublishContext::new(account_id.to_string(), "relay_ingest_listing"), 10747 product_id, 10748 listing_d_tag: Some(listing_d_tag), 10749 farm_id: Some(farm_id), 10750 farm_pubkey: Some(seller_pubkey), 10751 farm_d_tag: Some(super::d_tag_from_uuid(farm_id.as_uuid())), 10752 title: "Relay ingest lettuce".to_owned(), 10753 subtitle: Some("Pulled into a fresh buyer app".to_owned()), 10754 category: Some("greens".to_owned()), 10755 unit_label: "each".to_owned(), 10756 price_minor_units: Some(450), 10757 price_currency: "USD".to_owned(), 10758 stock_quantity: Some(6), 10759 availability_window_id: Some(FulfillmentWindowId::new()), 10760 availability_starts_at: Some("2099-04-25T14:00:00Z".to_owned()), 10761 availability_ends_at: Some("2099-04-25T18:00:00Z".to_owned()), 10762 fulfillment_method: Some("pickup".to_owned()), 10763 fulfillment_location: Some("Relay barn".to_owned()), 10764 status: ProductStatus::Published, 10765 }; 10766 let listing = super::listing_publish_payload_to_sdk_listing(&listing_payload) 10767 .expect("listing payload should convert to SDK listing"); 10768 let parts = radroots_sdk::protocol::listing::build_draft(&listing) 10769 .expect("listing draft should build") 10770 .into_wire_parts(); 10771 let event = radroots_nostr_build_event(parts.kind, parts.content, parts.tags) 10772 .expect("listing event builder should build") 10773 .sign_with_keys(identity.keys()) 10774 .expect("listing event should sign"); 10775 publish_signed_test_event_to_relay(relay, &event); 10776 assert_eq!(relay.event_count(), 1); 10777 10778 projected_product_id 10779 } 10780 10781 fn assert_fresh_buyer_relay_ingest( 10782 relay_url: &str, 10783 label: &str, 10784 trigger: SyncTrigger, 10785 projected_product_id: ProductId, 10786 ) { 10787 let (runtime, paths) = bootstrapped_runtime(label); 10788 assert!( 10789 runtime 10790 .generate_local_account(Some("Buyer".to_owned())) 10791 .expect("buyer account should generate") 10792 ); 10793 runtime.lock_state_mut().nostr_relay_urls = vec![relay_url.to_owned()]; 10794 10795 let changed = match trigger { 10796 SyncTrigger::ManualRefresh => runtime 10797 .sync_on_manual_refresh() 10798 .expect("manual relay ingest should complete"), 10799 SyncTrigger::AppLaunch => runtime 10800 .sync_on_app_launch() 10801 .expect("launch relay ingest should complete"), 10802 SyncTrigger::ForegroundResume => runtime 10803 .sync_on_foreground_resume() 10804 .expect("foreground relay ingest should complete"), 10805 SyncTrigger::LocalMutation => panic!("local mutation is not a relay ingest trigger"), 10806 }; 10807 assert!(changed); 10808 10809 let summary = runtime.summary(); 10810 let listing = summary 10811 .personal_projection 10812 .browse 10813 .listings 10814 .rows 10815 .iter() 10816 .find(|listing| listing.product_id == projected_product_id) 10817 .expect("fresh buyer app should project relay listing"); 10818 assert_eq!(listing.title, "Relay ingest lettuce"); 10819 assert_eq!(listing.farm_display_name, "Local farm"); 10820 assert_eq!(listing.listing_relays, vec![relay_url.to_owned()]); 10821 let relay_ingest = runtime 10822 .lock_state() 10823 .selected_account_relay_ingest_freshness 10824 .clone(); 10825 assert_eq!(relay_ingest.status, AppRelayIngestScopeStatus::Fresh); 10826 assert_eq!(relay_ingest.relays.len(), 1); 10827 assert_eq!(relay_ingest.relays[0].relay_url, relay_url); 10828 assert!(relay_ingest.relays[0].cursor_since_unix_seconds.is_some()); 10829 10830 let product_id_string = projected_product_id.to_string(); 10831 let imports = runtime 10832 .lock_state() 10833 .sqlite_store 10834 .as_ref() 10835 .expect("sqlite store") 10836 .load_local_interop_records() 10837 .expect("local interop records should load"); 10838 let listing_import = imports 10839 .iter() 10840 .find(|record| { 10841 record.projected_kind == "listing" 10842 && record.projected_id.as_deref() == Some(product_id_string.as_str()) 10843 }) 10844 .expect("listing import"); 10845 let delivery = serde_json::from_str::<serde_json::Value>( 10846 listing_import 10847 .relay_delivery_json 10848 .as_deref() 10849 .expect("listing delivery evidence"), 10850 ) 10851 .expect("delivery json"); 10852 10853 assert_eq!( 10854 listing_import.source_runtime, 10855 SourceRuntime::Network.as_str() 10856 ); 10857 assert_eq!(listing_import.outbox_status, "none"); 10858 assert_eq!(delivery["state"], json!("observed")); 10859 assert_eq!(delivery["acknowledged_relays"], json!([])); 10860 assert_eq!(delivery["observed_relays"], json!([relay_url])); 10861 assert_eq!( 10862 imports 10863 .iter() 10864 .filter(|record| record.projected_kind == "listing" 10865 && record.projected_id.as_deref() == Some(product_id_string.as_str())) 10866 .count(), 10867 1 10868 ); 10869 assert!( 10870 runtime 10871 .sync_on_manual_refresh() 10872 .expect("repeat relay ingest should complete") 10873 ); 10874 let repeated_imports = runtime 10875 .lock_state() 10876 .sqlite_store 10877 .as_ref() 10878 .expect("sqlite store") 10879 .load_local_interop_records() 10880 .expect("repeated local interop records should load"); 10881 assert_eq!( 10882 repeated_imports 10883 .iter() 10884 .filter(|record| record.projected_kind == "listing" 10885 && record.projected_id.as_deref() == Some(product_id_string.as_str())) 10886 .count(), 10887 1 10888 ); 10889 10890 cleanup_bootstrapped_runtime_paths(&paths); 10891 } 10892 10893 #[test] 10894 fn runtime_relay_ingest_runs_after_outbound_sync_failure() { 10895 let relay = ThreadedAckRelay::spawn(); 10896 let projected_product_id = publish_relay_ingest_listing_fixture(&relay); 10897 10898 assert_relay_ingest_after_outbound_failure( 10899 relay.url(), 10900 "relay_ingest_after_manual_failure", 10901 SyncTrigger::ManualRefresh, 10902 projected_product_id, 10903 ); 10904 assert_relay_ingest_after_outbound_failure( 10905 relay.url(), 10906 "relay_ingest_after_launch_failure", 10907 SyncTrigger::AppLaunch, 10908 projected_product_id, 10909 ); 10910 assert_relay_ingest_after_outbound_failure( 10911 relay.url(), 10912 "relay_ingest_after_foreground_failure", 10913 SyncTrigger::ForegroundResume, 10914 projected_product_id, 10915 ); 10916 } 10917 10918 fn assert_relay_ingest_after_outbound_failure( 10919 relay_url: &str, 10920 label: &str, 10921 trigger: SyncTrigger, 10922 projected_product_id: ProductId, 10923 ) { 10924 let (runtime, paths) = bootstrapped_runtime(label); 10925 assert!( 10926 runtime 10927 .generate_local_account(Some("Buyer".to_owned())) 10928 .expect("buyer account should generate") 10929 ); 10930 runtime.lock_state_mut().nostr_relay_urls = vec![relay_url.to_owned()]; 10931 let buyer_account_id = runtime 10932 .summary() 10933 .settings_account_projection 10934 .selected_account 10935 .as_ref() 10936 .expect("selected account") 10937 .account 10938 .account_id 10939 .clone(); 10940 let pending_farm_id = FarmId::new(); 10941 runtime 10942 .lock_state_mut() 10943 .enqueue_selected_account_sync_operations(vec![pending_sync_upsert( 10944 SyncAggregateRef::Farm(pending_farm_id), 10945 farm_sync_payload( 10946 pending_farm_id, 10947 "Pending outbound farm", 10948 Some(FarmReadiness::Ready), 10949 "relay_ingest_after_outbound_failure", 10950 ), 10951 )]) 10952 .expect("pending sync should enqueue"); 10953 let recorded = install_recorded_sync_transport( 10954 &runtime, 10955 RecordedAppSyncTransport::fail(AppSyncTransportError::unavailable( 10956 "test outbound sync unavailable", 10957 )), 10958 ); 10959 10960 let changed = match trigger { 10961 SyncTrigger::ManualRefresh => runtime 10962 .sync_on_manual_refresh() 10963 .expect("manual refresh should complete"), 10964 SyncTrigger::AppLaunch => runtime 10965 .sync_on_app_launch() 10966 .expect("launch sync should complete"), 10967 SyncTrigger::ForegroundResume => runtime 10968 .sync_on_foreground_resume() 10969 .expect("foreground sync should complete"), 10970 SyncTrigger::LocalMutation => panic!("local mutation is not a relay ingest trigger"), 10971 }; 10972 10973 assert!(changed); 10974 assert_eq!(recorded.lock().expect("recorded transport").call_count(), 1); 10975 let summary = runtime.summary(); 10976 assert_eq!( 10977 summary.sync_status.projection.run_status, 10978 AppSyncRunStatus::Failed 10979 ); 10980 assert_eq!( 10981 summary.sync_status.projection.checkpoint.state, 10982 SyncCheckpointState::Failed 10983 ); 10984 assert_eq!( 10985 runtime 10986 .lock_state() 10987 .selected_account_relay_ingest_freshness 10988 .status, 10989 AppRelayIngestScopeStatus::Fresh 10990 ); 10991 assert_eq!(summary.sync_status.pending_write_count, 1); 10992 let listing = summary 10993 .personal_projection 10994 .browse 10995 .listings 10996 .rows 10997 .iter() 10998 .find(|listing| listing.product_id == projected_product_id) 10999 .expect("relay listing should still project after outbound failure"); 11000 assert_eq!(listing.title, "Relay ingest lettuce"); 11001 assert_eq!(listing.listing_relays, vec![relay_url.to_owned()]); 11002 11003 let pending_operations = runtime 11004 .lock_state() 11005 .sqlite_store 11006 .as_ref() 11007 .expect("sqlite store") 11008 .load_pending_sync_operations(buyer_account_id.as_str()) 11009 .expect("pending sync operations should load"); 11010 assert_eq!(pending_operations.len(), 1); 11011 assert_eq!(pending_operations[0].operation.attempt_count, 1); 11012 assert!( 11013 pending_operations[0] 11014 .operation 11015 .last_error_message 11016 .as_deref() 11017 .is_some_and(|message| message.contains("test outbound sync unavailable")) 11018 ); 11019 11020 cleanup_bootstrapped_runtime_paths(&paths); 11021 } 11022 11023 #[test] 11024 fn runtime_direct_relay_transport_rejects_publish_work_before_partial_progress() { 11025 let relay = ThreadedAckRelay::spawn(); 11026 let manager = RadrootsNostrAccountsManager::new_in_memory(); 11027 let account_id = manager 11028 .generate_identity(Some("Farmer".to_owned()), true) 11029 .expect("local signing account should generate"); 11030 let identity = manager 11031 .get_signing_identity(&account_id) 11032 .expect("farmer signer lookup should succeed") 11033 .expect("farmer account should have local signer"); 11034 let payload = direct_relay_listing_payload( 11035 account_id.to_string().as_str(), 11036 identity.public_key_hex(), 11037 "listing_publish", 11038 ); 11039 let successful_operation = 11040 PendingSyncOperation::from_publish_payload(payload, "2026-05-24T12:00:00Z") 11041 .expect("typed listing publish work should serialize"); 11042 let unsupported_operation = PendingSyncOperation::new( 11043 SyncAggregateRef::Product(ProductId::new()), 11044 SyncOperationKind::Delete, 11045 "{}", 11046 "2026-05-24T12:01:00Z", 11047 ); 11048 let mut transport = 11049 ConfiguredRelayAppSyncTransport::with_relay_urls(manager, vec![relay.url().to_owned()]); 11050 11051 let error = transport 11052 .sync(AppSyncRequest { 11053 trigger: SyncTrigger::ManualRefresh, 11054 checkpoint: SyncCheckpointStatus::never_synced(), 11055 pending_operations: vec![successful_operation, unsupported_operation], 11056 known_conflicts: Vec::new(), 11057 }) 11058 .expect_err("publish work should use AppSdkRuntime before partial progress"); 11059 11060 assert_migrated_payload_uses_sdk_runtime(error); 11061 assert_eq!(relay.event_count(), 0); 11062 } 11063 11064 #[test] 11065 fn runtime_direct_relay_transport_normalizes_configured_relay_set() { 11066 let relay_urls = super::normalized_app_sync_relay_urls(&[ 11067 " ws://127.0.0.1:8081 ".to_owned(), 11068 "ws://127.0.0.1:8080".to_owned(), 11069 "ws://127.0.0.1:8081".to_owned(), 11070 ]) 11071 .expect("relay set should normalize"); 11072 11073 assert_eq!( 11074 relay_urls, 11075 vec!["ws://127.0.0.1:8081", "ws://127.0.0.1:8080"] 11076 ); 11077 } 11078 11079 #[test] 11080 fn runtime_direct_relay_transport_rejects_invalid_configured_relay_urls() { 11081 for relay_url in [ 11082 " ", 11083 "https://relay.example", 11084 "wss://", 11085 "wss://user@relay.example", 11086 "wss://relay.example:abc", 11087 ] { 11088 let error = super::normalized_app_sync_relay_urls(&[relay_url.to_owned()]) 11089 .expect_err("invalid app sync relay url"); 11090 assert!( 11091 error.to_string().contains("relay url"), 11092 "unexpected error for {relay_url}: {error}" 11093 ); 11094 } 11095 } 11096 11097 #[test] 11098 fn order_request_listing_pointer_prefers_configured_listing_relay() { 11099 let selected = super::selected_listing_relay( 11100 &[ 11101 "wss://relay-b.example".to_owned(), 11102 "wss://relay-a.example".to_owned(), 11103 ], 11104 &[ 11105 "wss://relay-a.example".to_owned(), 11106 "wss://relay-c.example".to_owned(), 11107 ], 11108 ) 11109 .expect("configured listing relay should be selected"); 11110 11111 assert_eq!(selected.as_str(), "wss://relay-a.example"); 11112 } 11113 11114 #[test] 11115 fn order_request_listing_pointer_rejects_missing_configured_provenance_relay() { 11116 let error = super::selected_listing_relay( 11117 &["wss://listing.example".to_owned()], 11118 &["wss://target.example".to_owned()], 11119 ) 11120 .expect_err("missing listing provenance relay should fail"); 11121 11122 assert_missing_listing_provenance_relay_error(&error, "wss://listing.example"); 11123 } 11124 11125 #[test] 11126 fn runtime_direct_relay_transport_rejects_order_request_missing_listing_provenance_target() { 11127 let relay = ThreadedAckRelay::spawn(); 11128 let manager = RadrootsNostrAccountsManager::new_in_memory(); 11129 let account_id = manager 11130 .generate_identity(Some("Buyer".to_owned()), true) 11131 .expect("buyer account should generate"); 11132 let identity = manager 11133 .get_signing_identity(&account_id) 11134 .expect("buyer signer lookup should succeed") 11135 .expect("buyer account should have local signer"); 11136 let seller_pubkey = "2222222222222222222222222222222222222222222222222222222222222222"; 11137 let payload = AppPublishPayload::OrderRequest(AppOrderRequestPublishPayload { 11138 context: AppPublishContext::new(account_id.to_string(), "order_missing_listing_relay"), 11139 order_id: OrderId::new(), 11140 farm_id: FarmId::new(), 11141 status: Some("needs_action".to_owned()), 11142 order_document_json: Some(json!({"document": {"order": {}}})), 11143 listing_addr: Some(format!("30402:{seller_pubkey}:listing-key")), 11144 listing_event_id: Some("listing-event-id".to_owned()), 11145 listing_relays: vec!["wss://listing.example".to_owned()], 11146 buyer_pubkey: Some(identity.public_key_hex()), 11147 seller_pubkey: Some(seller_pubkey.to_owned()), 11148 items: vec![AppOrderRequestItemPayload { 11149 product_id: ProductId::new(), 11150 quantity: 1, 11151 }], 11152 currency_code: Some("USD".to_owned()), 11153 total_minor_units: Some(450), 11154 note: None, 11155 }); 11156 let operation = PendingSyncOperation::from_publish_payload(payload, "2026-05-25T07:00:00Z") 11157 .expect("order publish payload should serialize"); 11158 let mut transport = 11159 ConfiguredRelayAppSyncTransport::with_relay_urls(manager, vec![relay.url().to_owned()]); 11160 11161 let error = transport 11162 .sync(AppSyncRequest { 11163 trigger: SyncTrigger::ManualRefresh, 11164 checkpoint: SyncCheckpointStatus::never_synced(), 11165 pending_operations: vec![operation], 11166 known_conflicts: Vec::new(), 11167 }) 11168 .expect_err("direct relay order request should use AppSdkRuntime"); 11169 11170 assert_migrated_payload_uses_sdk_runtime(error); 11171 assert_eq!(relay.event_count(), 0); 11172 } 11173 11174 #[test] 11175 fn runtime_direct_relay_transport_rejects_payload_account_context_publish_work() { 11176 let relay = ThreadedAckRelay::spawn(); 11177 let manager = RadrootsNostrAccountsManager::new_in_memory(); 11178 let first_account_id = manager 11179 .generate_identity(Some("First".to_owned()), true) 11180 .expect("first account"); 11181 let first_identity = manager 11182 .get_signing_identity(&first_account_id) 11183 .expect("first signer") 11184 .expect("first local signer"); 11185 let payload = direct_relay_listing_payload( 11186 first_account_id.to_string().as_str(), 11187 first_identity.public_key_hex(), 11188 "listing_publish", 11189 ); 11190 let operation = PendingSyncOperation::from_publish_payload(payload, "2026-05-24T12:00:00Z") 11191 .expect("typed listing publish work should serialize"); 11192 let mut transport = 11193 ConfiguredRelayAppSyncTransport::with_relay_urls(manager, vec![relay.url().to_owned()]); 11194 11195 let error = transport 11196 .sync(AppSyncRequest { 11197 trigger: SyncTrigger::ManualRefresh, 11198 checkpoint: SyncCheckpointStatus::never_synced(), 11199 pending_operations: vec![operation], 11200 known_conflicts: Vec::new(), 11201 }) 11202 .expect_err("payload account publish work should use AppSdkRuntime"); 11203 11204 assert_migrated_payload_uses_sdk_runtime(error); 11205 assert_eq!(relay.event_count(), 0); 11206 } 11207 11208 #[test] 11209 fn runtime_direct_relay_transport_rejects_missing_account_publish_work() { 11210 let relay = ThreadedAckRelay::spawn(); 11211 let manager = RadrootsNostrAccountsManager::new_in_memory(); 11212 let farm_id = FarmId::new(); 11213 let missing_account_id = RadrootsIdentity::generate().id(); 11214 let payload = AppPublishPayload::FarmProfile(AppFarmProfilePublishPayload { 11215 context: AppPublishContext::new(missing_account_id.to_string(), "farm_setup"), 11216 farm_id, 11217 display_name: "North field farm".to_owned(), 11218 readiness: Some(FarmReadiness::Ready), 11219 }); 11220 let operation = PendingSyncOperation::from_publish_payload(payload, "2026-05-24T12:00:00Z") 11221 .expect("typed farm publish work should serialize"); 11222 let mut transport = 11223 ConfiguredRelayAppSyncTransport::with_relay_urls(manager, vec![relay.url().to_owned()]); 11224 11225 let error = transport 11226 .sync(AppSyncRequest { 11227 trigger: SyncTrigger::ManualRefresh, 11228 checkpoint: SyncCheckpointStatus::never_synced(), 11229 pending_operations: vec![operation], 11230 known_conflicts: Vec::new(), 11231 }) 11232 .expect_err("missing account publish work should use AppSdkRuntime"); 11233 11234 assert_migrated_payload_uses_sdk_runtime(error); 11235 assert_eq!(relay.event_count(), 0); 11236 } 11237 11238 #[test] 11239 fn runtime_direct_relay_transport_rejects_watch_only_account_publish_work() { 11240 let relay = ThreadedAckRelay::spawn(); 11241 let manager = RadrootsNostrAccountsManager::new_in_memory(); 11242 let identity = RadrootsIdentity::generate(); 11243 let account_id = manager 11244 .upsert_public_identity(identity.to_public(), Some("Watch".to_owned()), true) 11245 .expect("watch-only account"); 11246 let payload = AppPublishPayload::FarmProfile(AppFarmProfilePublishPayload { 11247 context: AppPublishContext::new(account_id.to_string(), "farm_setup"), 11248 farm_id: FarmId::new(), 11249 display_name: "North field farm".to_owned(), 11250 readiness: Some(FarmReadiness::Ready), 11251 }); 11252 let operation = PendingSyncOperation::from_publish_payload(payload, "2026-05-24T12:00:00Z") 11253 .expect("typed farm publish work should serialize"); 11254 let mut transport = 11255 ConfiguredRelayAppSyncTransport::with_relay_urls(manager, vec![relay.url().to_owned()]); 11256 11257 let error = transport 11258 .sync(AppSyncRequest { 11259 trigger: SyncTrigger::ManualRefresh, 11260 checkpoint: SyncCheckpointStatus::never_synced(), 11261 pending_operations: vec![operation], 11262 known_conflicts: Vec::new(), 11263 }) 11264 .expect_err("watch-only account publish work should use AppSdkRuntime"); 11265 11266 assert_migrated_payload_uses_sdk_runtime(error); 11267 assert_eq!(relay.event_count(), 0); 11268 } 11269 11270 #[test] 11271 fn runtime_direct_relay_transport_rejects_mismatched_local_signing_publish_work() { 11272 let relay = ThreadedAckRelay::spawn(); 11273 let store = Arc::new(RadrootsNostrMemoryAccountStore::new()); 11274 let vault = Arc::new(RadrootsNostrSecretVaultMemory::new()); 11275 let manager = 11276 RadrootsNostrAccountsManager::new(store, vault.clone()).expect("accounts manager"); 11277 let account_identity = RadrootsIdentity::generate(); 11278 let secret_identity = RadrootsIdentity::generate(); 11279 let account_id = manager 11280 .upsert_public_identity( 11281 account_identity.to_public(), 11282 Some("Mismatched".to_owned()), 11283 true, 11284 ) 11285 .expect("public account"); 11286 vault 11287 .store_secret( 11288 account_secret_slot(&account_id).as_str(), 11289 secret_identity.secret_key_hex().as_str(), 11290 ) 11291 .expect("mismatched secret"); 11292 let payload = AppPublishPayload::FarmProfile(AppFarmProfilePublishPayload { 11293 context: AppPublishContext::new(account_id.to_string(), "farm_setup"), 11294 farm_id: FarmId::new(), 11295 display_name: "North field farm".to_owned(), 11296 readiness: Some(FarmReadiness::Ready), 11297 }); 11298 let operation = PendingSyncOperation::from_publish_payload(payload, "2026-05-24T12:00:00Z") 11299 .expect("typed farm publish work should serialize"); 11300 let mut transport = 11301 ConfiguredRelayAppSyncTransport::with_relay_urls(manager, vec![relay.url().to_owned()]); 11302 11303 let error = transport 11304 .sync(AppSyncRequest { 11305 trigger: SyncTrigger::ManualRefresh, 11306 checkpoint: SyncCheckpointStatus::never_synced(), 11307 pending_operations: vec![operation], 11308 known_conflicts: Vec::new(), 11309 }) 11310 .expect_err("mismatched custody publish work should use AppSdkRuntime"); 11311 11312 assert_migrated_payload_uses_sdk_runtime(error); 11313 assert_eq!(relay.event_count(), 0); 11314 } 11315 11316 #[test] 11317 fn desktop_namespace_uses_canonical_app_and_shared_runtime_roots() { 11318 let paths = AppDesktopRuntimePaths::for_desktop( 11319 AppRuntimePlatform::Macos, 11320 AppRuntimeHostEnvironment { 11321 home_dir: Some(PathBuf::from("/Users/treesap")), 11322 ..AppRuntimeHostEnvironment::default() 11323 }, 11324 ) 11325 .expect("interactive user roots should resolve"); 11326 11327 assert_eq!( 11328 paths.app.data, 11329 PathBuf::from("/Users/treesap/.radroots/data/apps/app") 11330 ); 11331 assert_eq!( 11332 paths.app.logs, 11333 PathBuf::from("/Users/treesap/.radroots/logs/apps/app") 11334 ); 11335 assert_eq!( 11336 paths.app.data.join(APP_DATABASE_FILE_NAME), 11337 PathBuf::from("/Users/treesap/.radroots/data/apps/app/app.sqlite3") 11338 ); 11339 assert_eq!( 11340 paths.shared_accounts.data_root, 11341 PathBuf::from("/Users/treesap/.radroots/data/shared/accounts") 11342 ); 11343 assert_eq!( 11344 paths.shared_accounts.secrets_root, 11345 PathBuf::from("/Users/treesap/.radroots/secrets/shared/accounts") 11346 ); 11347 assert_eq!( 11348 paths.shared_accounts.store_path, 11349 PathBuf::from("/Users/treesap/.radroots/data/shared/accounts") 11350 .join(SHARED_ACCOUNTS_STORE_FILE_NAME) 11351 ); 11352 assert_eq!( 11353 paths.shared_identity.default_identity_path, 11354 PathBuf::from("/Users/treesap/.radroots/secrets/shared/identities") 11355 .join(SHARED_IDENTITY_FILE_NAME) 11356 ); 11357 } 11358 11359 #[test] 11360 fn cloned_runtime_handles_shared_settings_state() { 11361 let runtime = DesktopAppRuntime::from_state(DesktopAppRuntimeState { 11362 state_store: AppStateStore::load(AppStatePersistenceRepository::in_memory()) 11363 .expect("in-memory state store should load"), 11364 nostr_relay_urls: vec!["ws://127.0.0.1:8080".to_owned()], 11365 shared_accounts_paths: None, 11366 remote_signer_paths: None, 11367 accounts_manager: None, 11368 sqlite_store: Some( 11369 AppSqliteStore::open(DatabaseTarget::InMemory) 11370 .expect("in-memory sqlite store should open"), 11371 ), 11372 sdk_runtime: None, 11373 sync_transport: default_sync_transport(), 11374 runtime_metadata: DesktopAppRuntimeMetadataSummary::default(), 11375 selected_account_pending_sync_write_count: 0, 11376 selected_account_relay_ingest_freshness: AppRelayIngestScopeFreshness::default(), 11377 selected_account_sync_conflicts: Vec::new(), 11378 startup_issue: None, 11379 }); 11380 let cloned_runtime = runtime.clone(); 11381 11382 assert!(runtime.sync_settings_section(SettingsSection::About)); 11383 assert!(cloned_runtime.set_settings_preference(SettingsPreference::LaunchAtLogin, true)); 11384 11385 let summary = runtime.summary(); 11386 11387 assert_eq!( 11388 summary.shell_projection.selected_section, 11389 ShellSection::Home 11390 ); 11391 assert_eq!( 11392 summary.shell_projection.settings.selected_section, 11393 SettingsSection::About 11394 ); 11395 assert!(summary.shell_projection.settings.general.launch_at_login); 11396 assert_eq!( 11397 cloned_runtime.selected_settings_section(), 11398 SettingsSection::About 11399 ); 11400 assert_eq!(summary.startup_gate, AppStartupGate::SetupRequired); 11401 assert_eq!(summary.home_route, HomeRoute::SetupRequired); 11402 assert!(summary.settings_account_projection.roster.is_empty()); 11403 assert!( 11404 summary 11405 .settings_account_projection 11406 .selected_account 11407 .is_none() 11408 ); 11409 assert_eq!( 11410 summary.logged_out_startup, 11411 LoggedOutStartupProjection::default() 11412 ); 11413 } 11414 11415 #[test] 11416 fn cloned_runtime_handles_shared_startup_identity_choice_state() { 11417 let runtime = DesktopAppRuntime::from_state(DesktopAppRuntimeState { 11418 state_store: AppStateStore::load(AppStatePersistenceRepository::in_memory()) 11419 .expect("in-memory state store should load"), 11420 nostr_relay_urls: vec!["ws://127.0.0.1:8080".to_owned()], 11421 shared_accounts_paths: None, 11422 remote_signer_paths: None, 11423 accounts_manager: None, 11424 sqlite_store: Some( 11425 AppSqliteStore::open(DatabaseTarget::InMemory) 11426 .expect("in-memory sqlite store should open"), 11427 ), 11428 sdk_runtime: None, 11429 sync_transport: default_sync_transport(), 11430 runtime_metadata: DesktopAppRuntimeMetadataSummary::default(), 11431 selected_account_pending_sync_write_count: 0, 11432 selected_account_relay_ingest_freshness: AppRelayIngestScopeFreshness::default(), 11433 selected_account_sync_conflicts: Vec::new(), 11434 startup_issue: None, 11435 }); 11436 let cloned_runtime = runtime.clone(); 11437 11438 assert!(runtime.show_startup_identity_choice()); 11439 assert!(cloned_runtime.show_startup_signer_entry()); 11440 assert!(cloned_runtime.set_startup_signer_source_input( 11441 "bunker://npub1signer?relay=wss%3A%2F%2Frelay.radroots.example" 11442 )); 11443 assert!(runtime.begin_generate_key_startup()); 11444 11445 let summary = runtime.summary(); 11446 11447 assert_eq!( 11448 summary.logged_out_startup.phase, 11449 radroots_app_view::LoggedOutStartupPhase::GenerateKeyStarting 11450 ); 11451 assert_eq!( 11452 summary.logged_out_startup.signer_entry.source_input, 11453 "bunker://npub1signer?relay=wss%3A%2F%2Frelay.radroots.example" 11454 ); 11455 } 11456 11457 #[test] 11458 fn runtime_summary_keeps_sync_disabled_without_a_selected_account() { 11459 let runtime = memory_runtime(); 11460 let summary = runtime.summary(); 11461 11462 assert_eq!(summary.sync_status, DesktopAppSyncStatusSummary::default()); 11463 assert!(!summary.sync_status.is_enabled()); 11464 } 11465 11466 #[test] 11467 fn runtime_summary_refreshes_selected_account_sync_status_from_sqlite() { 11468 let (runtime, paths) = bootstrapped_runtime("selected_account_sync_status"); 11469 let (account_id, farm_id) = provision_ready_farmer_account(&runtime); 11470 11471 { 11472 let state = runtime.lock_state(); 11473 let sqlite_store = state 11474 .sqlite_store 11475 .as_ref() 11476 .expect("sqlite store should exist"); 11477 11478 sqlite_store 11479 .save_sync_checkpoint( 11480 &account_id, 11481 &SyncCheckpointStatus::current( 11482 None, 11483 "2026-04-20T19:00:00Z", 11484 Some("cursor-3".to_owned()), 11485 ), 11486 ) 11487 .expect("sync checkpoint should save"); 11488 sqlite_store 11489 .record_sync_conflict( 11490 &account_id, 11491 &SyncConflict { 11492 aggregate: SyncAggregateRef::Farm(farm_id), 11493 kind: SyncConflictKind::RevisionMismatch, 11494 severity: SyncConflictSeverity::Blocking, 11495 resolution: SyncConflictResolutionStatus::Unresolved, 11496 local_payload_json: "{\"farm\":\"local\"}".to_owned(), 11497 remote_payload_json: Some("{\"farm\":\"remote\"}".to_owned()), 11498 detected_at: "2026-04-20T19:01:00Z".to_owned(), 11499 resolved_at: None, 11500 }, 11501 ) 11502 .expect("sync conflict should save"); 11503 sqlite_store 11504 .enqueue_pending_sync_operation( 11505 &account_id, 11506 &PendingSyncOperation::new( 11507 SyncAggregateRef::Farm(farm_id), 11508 SyncOperationKind::Upsert, 11509 "{\"farm\":\"queued\"}", 11510 "2026-04-20T19:02:00Z", 11511 ), 11512 ) 11513 .expect("pending sync operation should save"); 11514 } 11515 11516 assert!( 11517 runtime 11518 .lock_state_mut() 11519 .refresh_selected_account_sync() 11520 .expect("sync status should refresh") 11521 ); 11522 11523 let summary = runtime.summary(); 11524 11525 assert_eq!( 11526 summary.sync_status.account_id.as_deref(), 11527 Some(account_id.as_str()) 11528 ); 11529 assert!(summary.sync_status.is_enabled()); 11530 assert_eq!(summary.sync_status.pending_write_count, 1); 11531 assert_eq!( 11532 summary.sync_status.projection.run_status, 11533 AppSyncRunStatus::Conflicted 11534 ); 11535 assert_eq!( 11536 summary 11537 .sync_status 11538 .projection 11539 .conflict_status 11540 .unresolved_count, 11541 1 11542 ); 11543 assert_eq!( 11544 summary 11545 .sync_status 11546 .projection 11547 .checkpoint 11548 .last_remote_cursor 11549 .as_deref(), 11550 Some("cursor-3") 11551 ); 11552 11553 cleanup_bootstrapped_runtime_paths(&paths); 11554 } 11555 11556 #[test] 11557 fn runtime_product_incomplete_save_does_not_enqueue_publish_work() { 11558 let runtime = memory_runtime(); 11559 let (account_id, _) = provision_ready_farmer_account(&runtime); 11560 11561 assert!( 11562 runtime 11563 .open_new_product_editor() 11564 .expect("new product editor should open") 11565 ); 11566 let product_id = match runtime.summary().products_projection.editor { 11567 radroots_app_state::ProductEditorState::Open(session) => session 11568 .selected_product_id 11569 .expect("open product editor should select a product"), 11570 radroots_app_state::ProductEditorState::Closed => { 11571 panic!("product editor should be open") 11572 } 11573 }; 11574 let first_draft = ProductEditorDraft { 11575 title: "Salad mix".to_owned(), 11576 subtitle: "Spring blend".to_owned(), 11577 category: String::new(), 11578 unit_label: "bag".to_owned(), 11579 price_minor_units: Some(700), 11580 price_currency: "USD".to_owned(), 11581 stock_quantity: Some(8), 11582 availability_window_id: None, 11583 status: ProductStatus::Draft, 11584 }; 11585 let second_draft = ProductEditorDraft { 11586 title: "Winter greens".to_owned(), 11587 subtitle: "Cut this morning".to_owned(), 11588 category: "greens".to_owned(), 11589 unit_label: "bag".to_owned(), 11590 price_minor_units: Some(900), 11591 price_currency: "USD".to_owned(), 11592 stock_quantity: Some(11), 11593 availability_window_id: None, 11594 status: ProductStatus::Published, 11595 }; 11596 11597 assert!( 11598 runtime 11599 .save_product_editor_draft(first_draft) 11600 .expect("first product editor save should succeed") 11601 ); 11602 assert!( 11603 runtime 11604 .save_product_editor_draft(second_draft.clone()) 11605 .expect("second product editor save should succeed") 11606 ); 11607 11608 let pending_operations = runtime 11609 .lock_state() 11610 .sqlite_store 11611 .as_ref() 11612 .expect("sqlite store") 11613 .load_pending_sync_operations(account_id.as_str()) 11614 .expect("pending sync operations should load"); 11615 11616 assert_eq!(pending_operations.len(), 0); 11617 assert_eq!( 11618 runtime 11619 .lock_state() 11620 .sqlite_store 11621 .as_ref() 11622 .expect("sqlite store") 11623 .load_product_editor_draft(product_id) 11624 .expect("saved product draft should load"), 11625 Some(second_draft) 11626 ); 11627 } 11628 11629 #[test] 11630 fn runtime_product_publishable_save_enqueues_typed_listing_publish_work() { 11631 let (runtime, paths) = bootstrapped_runtime("publishable_product_listing_work"); 11632 let (account_id, farm_id) = provision_ready_farmer_account(&runtime); 11633 let pickup_location_id = PickupLocationId::new(); 11634 let fulfillment_window_id = FulfillmentWindowId::new(); 11635 11636 runtime 11637 .save_farm_rules_projection(FarmRulesProjection { 11638 farm_profile: Some(FarmProfileRecord { 11639 farm_id, 11640 display_name: "North field farm".to_owned(), 11641 timezone: "UTC".to_owned(), 11642 currency_code: "USD".to_owned(), 11643 }), 11644 pickup_locations: vec![PickupLocationRecord { 11645 pickup_location_id, 11646 farm_id, 11647 label: "Barn pickup".to_owned(), 11648 address_line: "14 Orchard Lane".to_owned(), 11649 directions: None, 11650 is_default: true, 11651 }], 11652 operating_rules: Some(FarmOperatingRulesRecord { 11653 farm_id, 11654 promise_lead_hours: 24, 11655 substitution_policy: "ask_customer".to_owned(), 11656 }), 11657 fulfillment_windows: vec![FulfillmentWindowRecord { 11658 fulfillment_window_id, 11659 farm_id, 11660 pickup_location_id, 11661 label: "Friday pickup".to_owned(), 11662 starts_at: "2099-04-25T14:00:00Z".to_owned(), 11663 ends_at: "2099-04-25T18:00:00Z".to_owned(), 11664 order_cutoff_at: "2099-04-24T18:00:00Z".to_owned(), 11665 }], 11666 blackout_periods: Vec::new(), 11667 ..runtime 11668 .load_farm_rules_projection() 11669 .expect("farm rules projection should load") 11670 }) 11671 .expect("farm rules should save"); 11672 11673 assert!( 11674 runtime 11675 .open_new_product_editor() 11676 .expect("new product editor should open") 11677 ); 11678 let product_id = match runtime.summary().products_projection.editor { 11679 radroots_app_state::ProductEditorState::Open(session) => session 11680 .selected_product_id 11681 .expect("open product editor should select a product"), 11682 radroots_app_state::ProductEditorState::Closed => { 11683 panic!("product editor should be open") 11684 } 11685 }; 11686 11687 assert!( 11688 runtime 11689 .save_product_editor_draft(ProductEditorDraft { 11690 title: "Salad mix".to_owned(), 11691 subtitle: "Cut this morning".to_owned(), 11692 category: "greens".to_owned(), 11693 unit_label: "each".to_owned(), 11694 price_minor_units: Some(900), 11695 price_currency: "usd".to_owned(), 11696 stock_quantity: Some(11), 11697 availability_window_id: Some(fulfillment_window_id), 11698 status: ProductStatus::Published, 11699 }) 11700 .expect("publishable product save should succeed") 11701 ); 11702 11703 let pending_operations = runtime 11704 .lock_state() 11705 .sqlite_store 11706 .as_ref() 11707 .expect("sqlite store") 11708 .load_pending_sync_operations(account_id.as_str()) 11709 .expect("pending sync operations should load"); 11710 let product_pending_operations = pending_operations 11711 .iter() 11712 .filter(|pending| pending.operation.aggregate == SyncAggregateRef::Product(product_id)) 11713 .collect::<Vec<_>>(); 11714 assert!(product_pending_operations.is_empty()); 11715 11716 let records = shared_local_event_records(&paths); 11717 let listing_record = records 11718 .iter() 11719 .find(|record| { 11720 record 11721 .local_work_json 11722 .as_ref() 11723 .and_then(|payload| payload["record_kind"].as_str()) 11724 == Some("listing_draft_v1") 11725 }) 11726 .expect("listing local work record"); 11727 let listing_payload = listing_record 11728 .local_work_json 11729 .as_ref() 11730 .expect("listing local work payload"); 11731 assert_eq!(listing_payload["publishability"]["state"], "publishable"); 11732 assert_eq!(listing_payload["document"]["product"]["category"], "greens"); 11733 assert_eq!( 11734 listing_payload["document"]["primary_bin"]["bin_id"] 11735 .as_str() 11736 .expect("primary bin id should be present"), 11737 super::listing_primary_bin_id(super::d_tag_from_uuid(product_id.as_uuid()).as_str()) 11738 ); 11739 assert_eq!(listing_payload["document"]["delivery"]["method"], "pickup"); 11740 assert_eq!( 11741 listing_payload["document"]["location"]["primary"], 11742 "14 Orchard Lane" 11743 ); 11744 let receipt = runtime 11745 .lock_state() 11746 .sqlite_store 11747 .as_ref() 11748 .expect("sqlite store") 11749 .sdk_migration_receipt_repository() 11750 .load_receipt( 11751 AppSdkMigrationReceiptSourceKind::SharedLocalEvent, 11752 listing_record.record_id.as_str(), 11753 ) 11754 .expect("listing SDK migration receipt should load") 11755 .expect("listing SDK migration receipt should exist"); 11756 assert_eq!(receipt.source_record_id, listing_record.record_id); 11757 assert_eq!(receipt.sdk_operation_kind, LISTING_PUBLISH_OPERATION_KIND); 11758 assert_eq!(receipt.migration_state, AppSdkMigrationState::Enqueued); 11759 assert!(receipt.expected_event_id.is_some()); 11760 assert!( 11761 receipt 11762 .actor_pubkey 11763 .as_deref() 11764 .is_some_and(super::is_hex_64) 11765 ); 11766 assert!(!receipt.sdk_outbox_event_ids.is_empty()); 11767 assert!(receipt.idempotency_digest_prefix.is_some()); 11768 assert_eq!( 11769 receipt.detail_json["operation_kind"], 11770 LISTING_PUBLISH_OPERATION_KIND 11771 ); 11772 11773 cleanup_bootstrapped_runtime_paths(&paths); 11774 } 11775 11776 #[test] 11777 fn runtime_product_publishable_save_returns_error_when_sdk_listing_enqueue_fails() { 11778 let (runtime, paths) = bootstrapped_runtime("publishable_product_listing_sdk_failure"); 11779 let (_account_id, farm_id) = provision_ready_farmer_account(&runtime); 11780 let pickup_location_id = PickupLocationId::new(); 11781 let fulfillment_window_id = FulfillmentWindowId::new(); 11782 11783 runtime 11784 .save_farm_rules_projection(FarmRulesProjection { 11785 farm_profile: Some(FarmProfileRecord { 11786 farm_id, 11787 display_name: "North field farm".to_owned(), 11788 timezone: "UTC".to_owned(), 11789 currency_code: "USD".to_owned(), 11790 }), 11791 pickup_locations: vec![PickupLocationRecord { 11792 pickup_location_id, 11793 farm_id, 11794 label: "Barn pickup".to_owned(), 11795 address_line: "14 Orchard Lane".to_owned(), 11796 directions: None, 11797 is_default: true, 11798 }], 11799 operating_rules: Some(FarmOperatingRulesRecord { 11800 farm_id, 11801 promise_lead_hours: 24, 11802 substitution_policy: "ask_customer".to_owned(), 11803 }), 11804 fulfillment_windows: vec![FulfillmentWindowRecord { 11805 fulfillment_window_id, 11806 farm_id, 11807 pickup_location_id, 11808 label: "Friday pickup".to_owned(), 11809 starts_at: "2099-04-25T14:00:00Z".to_owned(), 11810 ends_at: "2099-04-25T18:00:00Z".to_owned(), 11811 order_cutoff_at: "2099-04-24T18:00:00Z".to_owned(), 11812 }], 11813 blackout_periods: Vec::new(), 11814 ..runtime 11815 .load_farm_rules_projection() 11816 .expect("farm rules projection should load") 11817 }) 11818 .expect("farm rules should save"); 11819 11820 assert!( 11821 runtime 11822 .open_new_product_editor() 11823 .expect("new product editor should open") 11824 ); 11825 let product_id = match runtime.summary().products_projection.editor { 11826 radroots_app_state::ProductEditorState::Open(session) => session 11827 .selected_product_id 11828 .expect("open product editor should select a product"), 11829 radroots_app_state::ProductEditorState::Closed => { 11830 panic!("product editor should be open") 11831 } 11832 }; 11833 assert!( 11834 runtime 11835 .shutdown_sdk_runtime() 11836 .expect("sdk runtime should shut down") 11837 ); 11838 let draft = ProductEditorDraft { 11839 title: "Salad mix".to_owned(), 11840 subtitle: "Cut this morning".to_owned(), 11841 category: "greens".to_owned(), 11842 unit_label: "each".to_owned(), 11843 price_minor_units: Some(900), 11844 price_currency: "USD".to_owned(), 11845 stock_quantity: Some(11), 11846 availability_window_id: Some(fulfillment_window_id), 11847 status: ProductStatus::Published, 11848 }; 11849 11850 let error = runtime 11851 .save_product_editor_draft(draft.clone()) 11852 .expect_err("SDK listing enqueue failure should fail the save action"); 11853 assert!(matches!( 11854 error, 11855 super::DesktopAppRuntimeProductEditorSaveError::ListingPublishSdkEnqueueFailed 11856 )); 11857 assert_eq!( 11858 runtime 11859 .lock_state() 11860 .sqlite_store 11861 .as_ref() 11862 .expect("sqlite store") 11863 .load_product_editor_draft(product_id) 11864 .expect("saved product draft should load"), 11865 Some(draft.clone()) 11866 ); 11867 11868 let records = shared_local_event_records(&paths); 11869 let listing_record = records 11870 .iter() 11871 .find(|record| { 11872 record 11873 .local_work_json 11874 .as_ref() 11875 .and_then(|payload| payload["record_kind"].as_str()) 11876 == Some("listing_draft_v1") 11877 }) 11878 .expect("listing local work record"); 11879 let receipt = runtime 11880 .lock_state() 11881 .sqlite_store 11882 .as_ref() 11883 .expect("sqlite store") 11884 .sdk_migration_receipt_repository() 11885 .load_receipt( 11886 AppSdkMigrationReceiptSourceKind::SharedLocalEvent, 11887 listing_record.record_id.as_str(), 11888 ) 11889 .expect("failed listing SDK migration receipt should load") 11890 .expect("failed listing SDK migration receipt should exist"); 11891 assert_eq!(receipt.source_record_id, listing_record.record_id); 11892 assert_eq!(receipt.sdk_operation_kind, LISTING_PUBLISH_OPERATION_KIND); 11893 assert_eq!(receipt.migration_state, AppSdkMigrationState::Failed); 11894 assert!(receipt.sdk_outbox_event_ids.is_empty()); 11895 assert!(receipt.expected_event_id.is_none()); 11896 assert!(receipt.actor_pubkey.is_none()); 11897 assert!(receipt.idempotency_digest_prefix.is_none()); 11898 assert_eq!(receipt.detail_json["code"], "sdk_runtime_not_available"); 11899 assert_eq!(receipt.detail_json["class"], "runtime"); 11900 assert_eq!(receipt.detail_json["retryable"], true); 11901 11902 restore_sdk_runtime(&runtime, &paths); 11903 assert!( 11904 runtime 11905 .save_product_editor_draft(draft.clone()) 11906 .expect("retry should enqueue listing publish through SDK runtime") 11907 ); 11908 let retry_records = shared_local_event_records(&paths); 11909 let enqueued_listing_receipts = { 11910 let state = runtime.lock_state(); 11911 let repository = state 11912 .sqlite_store 11913 .as_ref() 11914 .expect("sqlite store") 11915 .sdk_migration_receipt_repository(); 11916 retry_records 11917 .iter() 11918 .filter(|record| { 11919 record 11920 .local_work_json 11921 .as_ref() 11922 .and_then(|payload| payload["record_kind"].as_str()) 11923 == Some("listing_draft_v1") 11924 }) 11925 .filter_map(|record| { 11926 repository 11927 .load_receipt( 11928 AppSdkMigrationReceiptSourceKind::SharedLocalEvent, 11929 record.record_id.as_str(), 11930 ) 11931 .expect("retry listing SDK migration receipt should load") 11932 }) 11933 .filter(|receipt| receipt.migration_state == AppSdkMigrationState::Enqueued) 11934 .count() 11935 }; 11936 assert!(enqueued_listing_receipts >= 1); 11937 assert!( 11938 runtime 11939 .shutdown_sdk_runtime() 11940 .expect("sdk runtime should shut down after retry") 11941 ); 11942 11943 cleanup_bootstrapped_runtime_paths(&paths); 11944 } 11945 11946 #[test] 11947 fn runtime_product_stock_update_retries_sdk_listing_enqueue_after_local_save() { 11948 let (runtime, paths) = bootstrapped_runtime("stock_listing_sdk_retry"); 11949 let (_account_id, farm_id) = provision_ready_farmer_account(&runtime); 11950 let pickup_location_id = PickupLocationId::new(); 11951 let fulfillment_window_id = FulfillmentWindowId::new(); 11952 11953 runtime 11954 .save_farm_rules_projection(FarmRulesProjection { 11955 farm_profile: Some(FarmProfileRecord { 11956 farm_id, 11957 display_name: "North field farm".to_owned(), 11958 timezone: "UTC".to_owned(), 11959 currency_code: "USD".to_owned(), 11960 }), 11961 pickup_locations: vec![PickupLocationRecord { 11962 pickup_location_id, 11963 farm_id, 11964 label: "Barn pickup".to_owned(), 11965 address_line: "14 Orchard Lane".to_owned(), 11966 directions: None, 11967 is_default: true, 11968 }], 11969 operating_rules: Some(FarmOperatingRulesRecord { 11970 farm_id, 11971 promise_lead_hours: 24, 11972 substitution_policy: "ask_customer".to_owned(), 11973 }), 11974 fulfillment_windows: vec![FulfillmentWindowRecord { 11975 fulfillment_window_id, 11976 farm_id, 11977 pickup_location_id, 11978 label: "Friday pickup".to_owned(), 11979 starts_at: "2099-04-25T14:00:00Z".to_owned(), 11980 ends_at: "2099-04-25T18:00:00Z".to_owned(), 11981 order_cutoff_at: "2099-04-24T18:00:00Z".to_owned(), 11982 }], 11983 blackout_periods: Vec::new(), 11984 ..runtime 11985 .load_farm_rules_projection() 11986 .expect("farm rules projection should load") 11987 }) 11988 .expect("farm rules should save"); 11989 11990 assert!( 11991 runtime 11992 .open_new_product_editor() 11993 .expect("new product editor should open") 11994 ); 11995 let product_id = match runtime.summary().products_projection.editor { 11996 radroots_app_state::ProductEditorState::Open(session) => session 11997 .selected_product_id 11998 .expect("open product editor should select a product"), 11999 radroots_app_state::ProductEditorState::Closed => { 12000 panic!("product editor should be open") 12001 } 12002 }; 12003 let draft = ProductEditorDraft { 12004 title: "Salad mix".to_owned(), 12005 subtitle: "Cut this morning".to_owned(), 12006 category: "greens".to_owned(), 12007 unit_label: "each".to_owned(), 12008 price_minor_units: Some(900), 12009 price_currency: "USD".to_owned(), 12010 stock_quantity: Some(11), 12011 availability_window_id: Some(fulfillment_window_id), 12012 status: ProductStatus::Published, 12013 }; 12014 assert!( 12015 runtime 12016 .save_product_editor_draft(draft) 12017 .expect("initial published product save should enqueue") 12018 ); 12019 assert!( 12020 runtime 12021 .shutdown_sdk_runtime() 12022 .expect("sdk runtime should shut down") 12023 ); 12024 12025 let error = runtime 12026 .update_product_stock(product_id, 13) 12027 .expect_err("SDK listing enqueue failure should fail stock update action"); 12028 assert!(matches!( 12029 error, 12030 super::DesktopAppRuntimeProductStockUpdateError::ListingPublishSdkEnqueueFailed 12031 )); 12032 assert_eq!( 12033 runtime 12034 .lock_state() 12035 .sqlite_store 12036 .as_ref() 12037 .expect("sqlite store") 12038 .load_product_editor_draft(product_id) 12039 .expect("saved product draft should load") 12040 .expect("saved product draft should exist") 12041 .stock_quantity, 12042 Some(13) 12043 ); 12044 let (source_kind, source_record_id) = 12045 super::listing_publish_source_record(product_id, "update_product_stock", None); 12046 let failed_receipt = runtime 12047 .lock_state() 12048 .sqlite_store 12049 .as_ref() 12050 .expect("sqlite store") 12051 .sdk_migration_receipt_repository() 12052 .load_receipt(source_kind, source_record_id.as_str()) 12053 .expect("failed stock listing SDK migration receipt should load") 12054 .expect("failed stock listing SDK migration receipt should exist"); 12055 assert_eq!(failed_receipt.migration_state, AppSdkMigrationState::Failed); 12056 assert_eq!( 12057 failed_receipt.detail_json["code"], 12058 "sdk_runtime_not_available" 12059 ); 12060 12061 restore_sdk_runtime(&runtime, &paths); 12062 assert!( 12063 runtime 12064 .update_product_stock(product_id, 13) 12065 .expect("retry should enqueue stock listing publish through SDK runtime") 12066 ); 12067 let retry_receipt = runtime 12068 .lock_state() 12069 .sqlite_store 12070 .as_ref() 12071 .expect("sqlite store") 12072 .sdk_migration_receipt_repository() 12073 .load_receipt(source_kind, source_record_id.as_str()) 12074 .expect("retry stock listing SDK migration receipt should load") 12075 .expect("retry stock listing SDK migration receipt should exist"); 12076 assert_eq!( 12077 retry_receipt.migration_state, 12078 AppSdkMigrationState::Enqueued 12079 ); 12080 assert!(retry_receipt.expected_event_id.is_some()); 12081 assert!( 12082 runtime 12083 .shutdown_sdk_runtime() 12084 .expect("sdk runtime should shut down after retry") 12085 ); 12086 12087 cleanup_bootstrapped_runtime_paths(&paths); 12088 } 12089 12090 #[test] 12091 fn runtime_product_stale_availability_save_records_blocker_without_publish_work() { 12092 let (runtime, paths) = bootstrapped_runtime("stale_product_listing_work"); 12093 let (account_id, farm_id) = provision_ready_farmer_account(&runtime); 12094 let pickup_location_id = PickupLocationId::new(); 12095 let active_window_id = FulfillmentWindowId::new(); 12096 let stale_window_id = FulfillmentWindowId::new(); 12097 12098 runtime 12099 .save_farm_rules_projection(FarmRulesProjection { 12100 farm_profile: Some(FarmProfileRecord { 12101 farm_id, 12102 display_name: "North field farm".to_owned(), 12103 timezone: "UTC".to_owned(), 12104 currency_code: "USD".to_owned(), 12105 }), 12106 pickup_locations: vec![PickupLocationRecord { 12107 pickup_location_id, 12108 farm_id, 12109 label: "Barn pickup".to_owned(), 12110 address_line: "14 Orchard Lane".to_owned(), 12111 directions: None, 12112 is_default: true, 12113 }], 12114 operating_rules: Some(FarmOperatingRulesRecord { 12115 farm_id, 12116 promise_lead_hours: 24, 12117 substitution_policy: "ask_customer".to_owned(), 12118 }), 12119 fulfillment_windows: vec![FulfillmentWindowRecord { 12120 fulfillment_window_id: active_window_id, 12121 farm_id, 12122 pickup_location_id, 12123 label: "Friday pickup".to_owned(), 12124 starts_at: "2099-04-25T14:00:00Z".to_owned(), 12125 ends_at: "2099-04-25T18:00:00Z".to_owned(), 12126 order_cutoff_at: "2099-04-24T18:00:00Z".to_owned(), 12127 }], 12128 blackout_periods: Vec::new(), 12129 ..runtime 12130 .load_farm_rules_projection() 12131 .expect("farm rules projection should load") 12132 }) 12133 .expect("farm rules should save"); 12134 12135 assert!( 12136 runtime 12137 .open_new_product_editor() 12138 .expect("new product editor should open") 12139 ); 12140 let product_id = match runtime.summary().products_projection.editor { 12141 radroots_app_state::ProductEditorState::Open(session) => session 12142 .selected_product_id 12143 .expect("open product editor should select a product"), 12144 radroots_app_state::ProductEditorState::Closed => { 12145 panic!("product editor should be open") 12146 } 12147 }; 12148 12149 runtime 12150 .lock_state() 12151 .sqlite_store 12152 .as_ref() 12153 .expect("sqlite store") 12154 .connection() 12155 .execute_batch("PRAGMA foreign_keys = OFF;") 12156 .expect("foreign keys should disable for stale fixture"); 12157 let save_result = runtime.save_product_editor_draft(ProductEditorDraft { 12158 title: "Salad mix".to_owned(), 12159 subtitle: "Cut this morning".to_owned(), 12160 category: "greens".to_owned(), 12161 unit_label: "bag".to_owned(), 12162 price_minor_units: Some(900), 12163 price_currency: "usd".to_owned(), 12164 stock_quantity: Some(11), 12165 availability_window_id: Some(stale_window_id), 12166 status: ProductStatus::Published, 12167 }); 12168 runtime 12169 .lock_state() 12170 .sqlite_store 12171 .as_ref() 12172 .expect("sqlite store") 12173 .connection() 12174 .execute_batch("PRAGMA foreign_keys = ON;") 12175 .expect("foreign keys should restore"); 12176 assert!(save_result.expect("stale product editor save should succeed")); 12177 12178 let summary = runtime.summary(); 12179 let radroots_app_state::ProductEditorState::Open(session) = 12180 summary.products_projection.editor 12181 else { 12182 panic!("product editor should stay open") 12183 }; 12184 assert_eq!( 12185 session.publish_blockers, 12186 vec![ProductPublishBlocker::AttachAvailability] 12187 ); 12188 12189 let pending_operations = runtime 12190 .lock_state() 12191 .sqlite_store 12192 .as_ref() 12193 .expect("sqlite store") 12194 .load_pending_sync_operations(account_id.as_str()) 12195 .expect("pending sync operations should load"); 12196 let product_pending_operations = pending_operations 12197 .iter() 12198 .filter(|pending| pending.operation.aggregate == SyncAggregateRef::Product(product_id)) 12199 .collect::<Vec<_>>(); 12200 assert!(product_pending_operations.is_empty()); 12201 12202 let records = shared_local_event_records(&paths); 12203 let listing_record = records 12204 .iter() 12205 .find(|record| { 12206 record 12207 .local_work_json 12208 .as_ref() 12209 .and_then(|payload| payload["record_kind"].as_str()) 12210 == Some("listing_draft_v1") 12211 }) 12212 .expect("listing local work record"); 12213 let listing_payload = listing_record 12214 .local_work_json 12215 .as_ref() 12216 .expect("listing local work payload"); 12217 assert_eq!(listing_payload["publishability"]["state"], "blocked"); 12218 assert_eq!( 12219 listing_payload["publishability"]["blockers"], 12220 json!(["attach_availability"]) 12221 ); 12222 12223 cleanup_bootstrapped_runtime_paths(&paths); 12224 } 12225 12226 #[test] 12227 fn runtime_product_local_drafts_do_not_enqueue_publish_work_without_required_fields() { 12228 let runtime = memory_runtime(); 12229 let (account_id, _) = provision_ready_farmer_account(&runtime); 12230 let recorded = install_recorded_sync_transport( 12231 &runtime, 12232 RecordedAppSyncTransport::succeed(AppSyncResult { 12233 run_status: AppSyncRunStatus::Succeeded, 12234 checkpoint: SyncCheckpointStatus::current( 12235 None, 12236 "2026-04-20T19:30:00Z", 12237 Some("cursor-product".to_owned()), 12238 ), 12239 pushed_operation_count: 1, 12240 pulled_record_count: 0, 12241 conflicts: Vec::new(), 12242 published_receipts: Vec::new(), 12243 }), 12244 ); 12245 12246 assert!( 12247 runtime 12248 .open_new_product_editor() 12249 .expect("new product editor should open") 12250 ); 12251 12252 let summary = runtime.summary(); 12253 let pending_operations = runtime 12254 .lock_state() 12255 .sqlite_store 12256 .as_ref() 12257 .expect("sqlite store") 12258 .load_pending_sync_operations(account_id.as_str()) 12259 .expect("pending sync operations should load"); 12260 12261 assert_eq!(recorded.lock().expect("recorded transport").call_count(), 0); 12262 assert_eq!(summary.sync_status.pending_write_count, 0); 12263 assert_eq!(pending_operations.len(), 0); 12264 } 12265 12266 #[test] 12267 fn runtime_launch_sync_attempt_dequeues_pushed_operations() { 12268 let runtime = memory_runtime(); 12269 let (account_id, farm_id) = provision_ready_farmer_account(&runtime); 12270 runtime 12271 .lock_state_mut() 12272 .enqueue_selected_account_sync_operations(vec![pending_sync_upsert( 12273 SyncAggregateRef::Farm(farm_id), 12274 farm_sync_payload( 12275 farm_id, 12276 "North field farm", 12277 Some(FarmReadiness::Ready), 12278 "launch_sync_attempt_dequeues_pushed_operations", 12279 ), 12280 )]) 12281 .expect("pending farm sync should enqueue"); 12282 12283 let recorded = install_recorded_sync_transport( 12284 &runtime, 12285 RecordedAppSyncTransport::succeed(AppSyncResult { 12286 run_status: AppSyncRunStatus::Succeeded, 12287 checkpoint: SyncCheckpointStatus::current( 12288 Some("2026-04-20T19:40:00Z".to_owned()), 12289 "2026-04-20T19:40:05Z", 12290 Some("cursor-launch".to_owned()), 12291 ), 12292 pushed_operation_count: 1, 12293 pulled_record_count: 0, 12294 conflicts: Vec::new(), 12295 published_receipts: Vec::new(), 12296 }), 12297 ); 12298 12299 assert!( 12300 runtime 12301 .sync_on_app_launch() 12302 .expect("launch sync should succeed") 12303 ); 12304 12305 let summary = runtime.summary(); 12306 let recorded = recorded.lock().expect("recorded transport"); 12307 let request = recorded 12308 .last_request() 12309 .cloned() 12310 .expect("launch sync request should record"); 12311 12312 assert_eq!(recorded.call_count(), 1); 12313 assert_eq!(request.trigger, SyncTrigger::AppLaunch); 12314 assert_eq!(request.pending_operations.len(), 1); 12315 assert_eq!(summary.sync_status.pending_write_count, 0); 12316 assert_eq!( 12317 summary.sync_status.projection.run_status, 12318 AppSyncRunStatus::Succeeded 12319 ); 12320 assert_eq!( 12321 summary.sync_status.projection.checkpoint.state, 12322 SyncCheckpointState::Current 12323 ); 12324 assert_eq!( 12325 runtime 12326 .lock_state() 12327 .sqlite_store 12328 .as_ref() 12329 .expect("sqlite store") 12330 .load_pending_sync_operations(account_id.as_str()) 12331 .expect("pending sync operations should load") 12332 .len(), 12333 0 12334 ); 12335 } 12336 12337 #[test] 12338 fn runtime_sync_result_refreshes_sync_status_after_receipt_import_changes() { 12339 let (runtime, paths) = bootstrapped_runtime("sync_status_after_receipt_import"); 12340 let (account_id, farm_id) = provision_ready_farmer_account(&runtime); 12341 runtime 12342 .lock_state_mut() 12343 .enqueue_selected_account_sync_operations(vec![pending_sync_upsert( 12344 SyncAggregateRef::Farm(farm_id), 12345 farm_sync_payload( 12346 farm_id, 12347 "Receipt import farm", 12348 Some(FarmReadiness::Ready), 12349 "sync_result_refreshes_after_receipt_import", 12350 ), 12351 )]) 12352 .expect("pending farm sync should enqueue"); 12353 12354 install_recorded_sync_transport( 12355 &runtime, 12356 RecordedAppSyncTransport::succeed(AppSyncResult { 12357 run_status: AppSyncRunStatus::Succeeded, 12358 checkpoint: SyncCheckpointStatus::current( 12359 Some("2026-04-20T19:41:00Z".to_owned()), 12360 "2026-04-20T19:41:05Z", 12361 Some("cursor-receipt-import".to_owned()), 12362 ), 12363 pushed_operation_count: 1, 12364 pulled_record_count: 0, 12365 conflicts: Vec::new(), 12366 published_receipts: vec![published_operation_receipt_fixture( 12367 account_id.to_string(), 12368 None, 12369 "1111111111111111111111111111111111111111111111111111111111111111", 12370 )], 12371 }), 12372 ); 12373 12374 assert!( 12375 runtime 12376 .sync_on_app_launch() 12377 .expect("launch sync should import published receipt") 12378 ); 12379 12380 let summary = runtime.summary(); 12381 assert_eq!(summary.sync_status.pending_write_count, 0); 12382 assert_eq!( 12383 summary.sync_status.projection.run_status, 12384 AppSyncRunStatus::Succeeded 12385 ); 12386 assert_eq!( 12387 summary.sync_status.projection.checkpoint.state, 12388 SyncCheckpointState::Current 12389 ); 12390 assert_eq!( 12391 shared_local_event_records(&paths) 12392 .into_iter() 12393 .filter(|record| record.family == LocalRecordFamily::SignedEvent) 12394 .count(), 12395 1 12396 ); 12397 12398 cleanup_bootstrapped_runtime_paths(&paths); 12399 } 12400 12401 #[test] 12402 fn runtime_partial_sync_result_dequeues_successful_prefix_only() { 12403 let runtime = memory_runtime(); 12404 let (account_id, farm_id) = provision_ready_farmer_account(&runtime); 12405 let product_id = ProductId::new(); 12406 runtime 12407 .lock_state_mut() 12408 .enqueue_selected_account_sync_operations(vec![ 12409 pending_sync_upsert( 12410 SyncAggregateRef::Farm(farm_id), 12411 farm_sync_payload( 12412 farm_id, 12413 "North field farm", 12414 Some(FarmReadiness::Ready), 12415 "partial_sync_prefix", 12416 ), 12417 ), 12418 pending_sync_upsert(SyncAggregateRef::Product(product_id), "{}".to_owned()), 12419 ]) 12420 .expect("pending sync should enqueue"); 12421 12422 let recorded = install_recorded_sync_transport( 12423 &runtime, 12424 RecordedAppSyncTransport::succeed(AppSyncResult { 12425 run_status: AppSyncRunStatus::Failed, 12426 checkpoint: SyncCheckpointStatus::failed( 12427 Some("2026-04-20T19:45:00Z".to_owned()), 12428 Some("2026-04-20T19:45:05Z".to_owned()), 12429 Some("cursor-partial".to_owned()), 12430 "relay refused second operation", 12431 ), 12432 pushed_operation_count: 1, 12433 pulled_record_count: 0, 12434 conflicts: Vec::new(), 12435 published_receipts: Vec::new(), 12436 }), 12437 ); 12438 12439 assert!( 12440 runtime 12441 .sync_on_app_launch() 12442 .expect("partial launch sync should apply") 12443 ); 12444 12445 let summary = runtime.summary(); 12446 let pending_operations = runtime 12447 .lock_state() 12448 .sqlite_store 12449 .as_ref() 12450 .expect("sqlite store") 12451 .load_pending_sync_operations(account_id.as_str()) 12452 .expect("pending operations should load"); 12453 12454 assert_eq!(recorded.lock().expect("recorded transport").call_count(), 1); 12455 assert_eq!(summary.sync_status.pending_write_count, 1); 12456 assert_eq!( 12457 summary.sync_status.projection.run_status, 12458 AppSyncRunStatus::Failed 12459 ); 12460 assert_eq!(pending_operations.len(), 1); 12461 assert_eq!( 12462 pending_operations[0].operation.aggregate, 12463 SyncAggregateRef::Product(product_id) 12464 ); 12465 assert_eq!( 12466 pending_operations[0].operation.state, 12467 PendingSyncOperationState::Retryable 12468 ); 12469 assert_eq!(pending_operations[0].operation.attempt_count, 1); 12470 assert_eq!( 12471 pending_operations[0] 12472 .operation 12473 .last_error_message 12474 .as_deref(), 12475 Some("relay refused second operation") 12476 ); 12477 } 12478 12479 #[test] 12480 fn runtime_foreground_resume_sync_uses_the_resume_trigger() { 12481 let runtime = memory_runtime(); 12482 let (_, _) = provision_ready_farmer_account(&runtime); 12483 12484 assert!( 12485 runtime 12486 .open_new_product_editor() 12487 .expect("new product editor should open") 12488 ); 12489 12490 let recorded = install_recorded_sync_transport( 12491 &runtime, 12492 RecordedAppSyncTransport::succeed(AppSyncResult { 12493 run_status: AppSyncRunStatus::Succeeded, 12494 checkpoint: SyncCheckpointStatus::current( 12495 Some("2026-04-20T19:50:00Z".to_owned()), 12496 "2026-04-20T19:50:03Z", 12497 Some("cursor-resume".to_owned()), 12498 ), 12499 pushed_operation_count: 1, 12500 pulled_record_count: 0, 12501 conflicts: Vec::new(), 12502 published_receipts: Vec::new(), 12503 }), 12504 ); 12505 12506 assert!( 12507 runtime 12508 .sync_on_foreground_resume() 12509 .expect("resume sync should succeed") 12510 ); 12511 12512 let request = recorded 12513 .lock() 12514 .expect("recorded transport") 12515 .last_request() 12516 .cloned() 12517 .expect("resume sync request should record"); 12518 12519 assert_eq!(request.trigger, SyncTrigger::ForegroundResume); 12520 } 12521 12522 #[test] 12523 fn runtime_shared_local_events_refresh_reports_and_reloads_products() { 12524 let (runtime, paths) = bootstrapped_runtime("shared_local_events_refresh"); 12525 assert!( 12526 runtime 12527 .generate_local_account(Some("Farmer".to_owned())) 12528 .expect("account should generate") 12529 ); 12530 let account_id = runtime 12531 .summary() 12532 .settings_account_projection 12533 .selected_account 12534 .as_ref() 12535 .expect("selected account") 12536 .account 12537 .account_id 12538 .clone(); 12539 append_cli_local_listing_records(&paths, account_id.as_str()); 12540 12541 let report = runtime 12542 .refresh_shared_local_events() 12543 .expect("shared local events should refresh"); 12544 let summary = runtime.summary(); 12545 12546 assert_eq!(report.scanned_records, 2); 12547 assert_eq!(report.imported_records, 2); 12548 assert_eq!(report.skipped_records, 0); 12549 assert_eq!(summary.farm_setup_projection.draft.farm_name, "Green Farm"); 12550 let saved_farm_id = summary 12551 .farm_setup_projection 12552 .saved_farm 12553 .as_ref() 12554 .expect("saved farm should import") 12555 .farm_id; 12556 let direct_products = runtime 12557 .lock_state() 12558 .sqlite_store 12559 .as_ref() 12560 .expect("sqlite store") 12561 .load_products( 12562 saved_farm_id, 12563 "", 12564 ProductsFilter::Drafts, 12565 ProductsSort::default(), 12566 ) 12567 .expect("imported products should load directly"); 12568 assert_eq!(direct_products.rows.len(), 1); 12569 assert!( 12570 runtime 12571 .select_products_filter(ProductsFilter::Drafts) 12572 .expect("draft products filter should reload") 12573 ); 12574 let summary = runtime.summary(); 12575 assert_eq!(summary.products_projection.list.rows.len(), 1); 12576 assert_eq!(summary.products_projection.list.rows[0].title, "Eggs"); 12577 assert_eq!( 12578 summary.products_projection.list.rows[0].status, 12579 ProductStatus::Draft 12580 ); 12581 12582 cleanup_bootstrapped_runtime_paths(&paths); 12583 } 12584 12585 #[test] 12586 fn runtime_buyer_search_imports_shared_local_events_before_read() { 12587 let (runtime, paths) = bootstrapped_runtime("buyer_search_shared_local_events_refresh"); 12588 assert!( 12589 runtime 12590 .generate_local_account(Some("Buyer".to_owned())) 12591 .expect("account should generate") 12592 ); 12593 assert_eq!( 12594 runtime 12595 .summary() 12596 .personal_projection 12597 .search 12598 .listings 12599 .rows 12600 .len(), 12601 0 12602 ); 12603 12604 append_cli_signed_buyer_listing_record(&paths); 12605 12606 assert!( 12607 runtime 12608 .set_personal_search_query("eggs") 12609 .expect("buyer search query should refresh") 12610 ); 12611 let summary = runtime.summary(); 12612 assert_eq!(summary.personal_projection.search.listings.rows.len(), 1); 12613 assert_eq!( 12614 summary.personal_projection.search.listings.rows[0].title, 12615 "Buyer Visible Eggs" 12616 ); 12617 assert_eq!( 12618 summary.personal_projection.search.listings.rows[0].fulfillment_methods, 12619 BTreeSet::from([FarmOrderMethod::Pickup]) 12620 ); 12621 12622 cleanup_bootstrapped_runtime_paths(&paths); 12623 } 12624 12625 #[test] 12626 fn runtime_buyer_search_repeated_query_refreshes_shared_local_events() { 12627 let (runtime, paths) = 12628 bootstrapped_runtime("buyer_search_same_query_shared_local_events_refresh"); 12629 assert!( 12630 runtime 12631 .generate_local_account(Some("Buyer".to_owned())) 12632 .expect("account should generate") 12633 ); 12634 12635 append_cli_signed_buyer_listing_record_with( 12636 &paths, 12637 "first-buyer-visible-listing", 12638 "DDDDDDDDDDDDDDDDDDDDDD", 12639 "Buyer Visible Eggs", 12640 1100, 12641 ); 12642 12643 assert!( 12644 runtime 12645 .set_personal_search_query("eggs") 12646 .expect("buyer search query should refresh") 12647 ); 12648 let first_summary = runtime.summary(); 12649 assert_eq!( 12650 first_summary.personal_projection.search.listings.rows.len(), 12651 1 12652 ); 12653 12654 append_cli_signed_buyer_listing_record_with( 12655 &paths, 12656 "second-buyer-visible-listing", 12657 "EEEEEEEEEEEEEEEEEEEEEE", 12658 "Buyer Visible Eggs Two", 12659 1200, 12660 ); 12661 12662 assert!( 12663 runtime 12664 .set_personal_search_query("eggs") 12665 .expect("same buyer search query should refresh") 12666 ); 12667 let refreshed_summary = runtime.summary(); 12668 let titles = refreshed_summary 12669 .personal_projection 12670 .search 12671 .listings 12672 .rows 12673 .iter() 12674 .map(|row| row.title.as_str()) 12675 .collect::<BTreeSet<_>>(); 12676 assert_eq!( 12677 titles, 12678 BTreeSet::from(["Buyer Visible Eggs", "Buyer Visible Eggs Two"]) 12679 ); 12680 12681 assert!( 12682 !runtime 12683 .set_personal_search_query("eggs") 12684 .expect("idempotent same buyer search query should refresh") 12685 ); 12686 12687 cleanup_bootstrapped_runtime_paths(&paths); 12688 } 12689 12690 #[test] 12691 fn runtime_shared_local_events_refresh_reloads_buyer_browse_idempotently() { 12692 let (runtime, paths) = bootstrapped_runtime("buyer_browse_shared_local_events_refresh"); 12693 assert!( 12694 runtime 12695 .generate_local_account(Some("Buyer".to_owned())) 12696 .expect("account should generate") 12697 ); 12698 append_cli_signed_buyer_listing_record(&paths); 12699 12700 let report = runtime 12701 .refresh_shared_local_events() 12702 .expect("shared local events should refresh"); 12703 let summary = runtime.summary(); 12704 assert_eq!(report.scanned_records, 1); 12705 assert_eq!(report.imported_records, 1); 12706 assert_eq!(report.skipped_records, 0); 12707 assert_eq!(summary.personal_projection.browse.listings.rows.len(), 1); 12708 assert_eq!( 12709 summary.personal_projection.browse.listings.rows[0].title, 12710 "Buyer Visible Eggs" 12711 ); 12712 12713 let second_report = runtime 12714 .refresh_shared_local_events() 12715 .expect("second shared local events refresh should succeed"); 12716 assert_eq!(second_report.scanned_records, 0); 12717 assert_eq!(second_report.imported_records, 0); 12718 assert_eq!(second_report.skipped_records, 0); 12719 assert_eq!( 12720 runtime 12721 .summary() 12722 .personal_projection 12723 .browse 12724 .listings 12725 .rows 12726 .len(), 12727 1 12728 ); 12729 12730 cleanup_bootstrapped_runtime_paths(&paths); 12731 } 12732 12733 #[test] 12734 fn runtime_buyer_browse_selection_refreshes_shared_local_events() { 12735 let (runtime, paths) = bootstrapped_runtime("buyer_browse_selection_shared_events_refresh"); 12736 assert!( 12737 runtime 12738 .generate_local_account(Some("Buyer".to_owned())) 12739 .expect("account should generate") 12740 ); 12741 assert_eq!( 12742 runtime 12743 .summary() 12744 .personal_projection 12745 .browse 12746 .listings 12747 .rows 12748 .len(), 12749 0 12750 ); 12751 12752 append_cli_signed_buyer_listing_record_with( 12753 &paths, 12754 "browse-selection-first-listing", 12755 "DDDDDDDDDDDDDDDDDDDDDD", 12756 "Buyer Visible Eggs", 12757 1100, 12758 ); 12759 12760 assert!( 12761 runtime 12762 .select_personal_section(PersonalSection::Browse) 12763 .expect("buyer Browse selection should refresh") 12764 ); 12765 let first_summary = runtime.summary(); 12766 assert_eq!( 12767 first_summary.personal_projection.browse.listings.rows.len(), 12768 1 12769 ); 12770 12771 append_cli_signed_buyer_listing_record_with( 12772 &paths, 12773 "browse-selection-second-listing", 12774 "EEEEEEEEEEEEEEEEEEEEEE", 12775 "Buyer Visible Eggs Two", 12776 1200, 12777 ); 12778 12779 assert!( 12780 runtime 12781 .select_personal_section(PersonalSection::Browse) 12782 .expect("same buyer Browse selection should refresh") 12783 ); 12784 let refreshed_summary = runtime.summary(); 12785 let titles = refreshed_summary 12786 .personal_projection 12787 .browse 12788 .listings 12789 .rows 12790 .iter() 12791 .map(|row| row.title.as_str()) 12792 .collect::<BTreeSet<_>>(); 12793 assert_eq!( 12794 titles, 12795 BTreeSet::from(["Buyer Visible Eggs", "Buyer Visible Eggs Two"]) 12796 ); 12797 12798 assert!( 12799 !runtime 12800 .select_personal_section(PersonalSection::Browse) 12801 .expect("idempotent buyer Browse selection should refresh") 12802 ); 12803 12804 cleanup_bootstrapped_runtime_paths(&paths); 12805 } 12806 12807 #[test] 12808 fn runtime_buyer_browse_selection_surfaces_shared_local_events_import_errors() { 12809 let (runtime, paths) = bootstrapped_runtime("buyer_browse_selection_import_error"); 12810 assert!( 12811 runtime 12812 .generate_local_account(Some("Buyer".to_owned())) 12813 .expect("account should generate") 12814 ); 12815 let database_path = paths 12816 .shared_local_events_database_path() 12817 .expect("shared local events path"); 12818 if let Some(parent) = database_path.parent() { 12819 fs::create_dir_all(parent).expect("shared local events parent directory"); 12820 } 12821 if database_path.is_file() { 12822 fs::remove_file(&database_path).expect("shared local events file should be removable"); 12823 } else if database_path.is_dir() { 12824 fs::remove_dir_all(&database_path) 12825 .expect("shared local events directory should be removable"); 12826 } 12827 fs::create_dir(&database_path).expect("directory should block sqlite open"); 12828 12829 let error = runtime 12830 .select_personal_section(PersonalSection::Browse) 12831 .expect_err("buyer Browse selection should surface import errors"); 12832 match error { 12833 AppSqliteError::LocalEventsSql { operation, .. } => { 12834 assert_eq!(operation, "open shared local events database"); 12835 } 12836 unexpected => panic!("unexpected Browse selection error: {unexpected:?}"), 12837 } 12838 12839 cleanup_bootstrapped_runtime_paths(&paths); 12840 } 12841 12842 #[test] 12843 fn runtime_buyer_detail_open_imports_shared_local_events_before_lookup() { 12844 assert_detail_open_imports_shared_local_events_before_lookup( 12845 "buyer_browse_detail_shared_local_events_refresh", 12846 PersonalSection::Browse, 12847 ); 12848 assert_detail_open_imports_shared_local_events_before_lookup( 12849 "buyer_search_detail_shared_local_events_refresh", 12850 PersonalSection::Search, 12851 ); 12852 } 12853 12854 #[test] 12855 fn runtime_app_farm_and_listing_writes_append_shared_local_work_records() { 12856 let (runtime, paths) = bootstrapped_runtime("app_local_work_records"); 12857 assert!( 12858 runtime 12859 .generate_local_account(Some("Farmer".to_owned())) 12860 .expect("account should generate") 12861 ); 12862 let account_id = runtime 12863 .summary() 12864 .settings_account_projection 12865 .selected_account 12866 .as_ref() 12867 .expect("selected account") 12868 .account 12869 .account_id 12870 .clone(); 12871 12872 runtime 12873 .save_farm_setup_draft(FarmSetupDraft::new( 12874 "Green Farm", 12875 "farmstand", 12876 [FarmOrderMethod::Pickup], 12877 )) 12878 .expect("farm setup draft should save"); 12879 runtime 12880 .finish_farm_setup() 12881 .expect("farm setup should finish"); 12882 assert!( 12883 runtime 12884 .open_new_product_editor() 12885 .expect("product editor should open") 12886 ); 12887 assert!( 12888 runtime 12889 .save_product_editor_draft(ProductEditorDraft { 12890 title: "Eggs".to_owned(), 12891 subtitle: "Fresh eggs".to_owned(), 12892 category: "eggs".to_owned(), 12893 unit_label: "dozen".to_owned(), 12894 price_minor_units: Some(750), 12895 price_currency: "USD".to_owned(), 12896 stock_quantity: Some(12), 12897 availability_window_id: None, 12898 status: ProductStatus::Draft, 12899 }) 12900 .expect("product draft should save") 12901 ); 12902 12903 let records = shared_local_event_records(&paths); 12904 let app_records = records 12905 .iter() 12906 .filter(|record| record.source_runtime == SourceRuntime::App) 12907 .collect::<Vec<_>>(); 12908 assert_eq!(app_records.len(), 2); 12909 12910 let farm_record = app_records 12911 .iter() 12912 .find(|record| { 12913 record 12914 .local_work_json 12915 .as_ref() 12916 .and_then(|payload| payload["record_kind"].as_str()) 12917 == Some("farm_config_v1") 12918 }) 12919 .expect("farm local work record"); 12920 assert_eq!(farm_record.family, LocalRecordFamily::LocalWork); 12921 assert_eq!(farm_record.status, LocalRecordStatus::LocalSaved); 12922 assert_eq!(farm_record.outbox_status, PublishOutboxStatus::None); 12923 assert_eq!( 12924 farm_record.owner_account_id.as_deref(), 12925 Some(account_id.as_str()) 12926 ); 12927 let owner_pubkey = farm_record 12928 .owner_pubkey 12929 .as_deref() 12930 .expect("farm owner pubkey"); 12931 assert!(is_hex_64(owner_pubkey)); 12932 assert!( 12933 farm_record 12934 .farm_id 12935 .as_ref() 12936 .is_some_and(|value| value.len() == 22) 12937 ); 12938 assert_eq!(farm_record.listing_addr, None); 12939 let farm_payload = farm_record 12940 .local_work_json 12941 .as_ref() 12942 .expect("farm local work payload"); 12943 assert_eq!(farm_payload["scope"], "app"); 12944 assert_eq!(farm_payload["exportability"]["state"], "exportable"); 12945 assert_eq!(farm_payload["document"]["farm"]["name"], "Green Farm"); 12946 assert_eq!( 12947 farm_payload["document"]["listing_defaults"]["delivery_method"], 12948 "pickup" 12949 ); 12950 assert!(farm_payload.get("draft").is_none()); 12951 assert!(farm_payload.get("editor").is_none()); 12952 12953 let listing_record = app_records 12954 .iter() 12955 .find(|record| { 12956 record 12957 .local_work_json 12958 .as_ref() 12959 .and_then(|payload| payload["record_kind"].as_str()) 12960 == Some("listing_draft_v1") 12961 }) 12962 .expect("listing local work record"); 12963 assert_eq!(listing_record.family, LocalRecordFamily::LocalWork); 12964 assert_eq!(listing_record.status, LocalRecordStatus::LocalSaved); 12965 assert_eq!(listing_record.outbox_status, PublishOutboxStatus::None); 12966 assert_eq!( 12967 listing_record.owner_account_id.as_deref(), 12968 Some(account_id.as_str()) 12969 ); 12970 assert_eq!(listing_record.owner_pubkey.as_deref(), Some(owner_pubkey)); 12971 assert_eq!(listing_record.farm_id, farm_record.farm_id); 12972 let expected_listing_addr_prefix = format!("30402:{owner_pubkey}:"); 12973 assert!( 12974 listing_record 12975 .listing_addr 12976 .as_deref() 12977 .expect("listing address") 12978 .starts_with(expected_listing_addr_prefix.as_str()) 12979 ); 12980 let listing_payload = listing_record 12981 .local_work_json 12982 .as_ref() 12983 .expect("listing local work payload"); 12984 assert_eq!(listing_payload["exportability"]["state"], "exportable"); 12985 assert_eq!(listing_payload["publishability"]["state"], "blocked"); 12986 assert_eq!(listing_payload["document"]["kind"], "listing_draft_v1"); 12987 assert_eq!( 12988 listing_payload["document"]["seller_actor"]["pubkey"], 12989 owner_pubkey 12990 ); 12991 assert_eq!(listing_payload["document"]["product"]["title"], "Eggs"); 12992 assert_eq!(listing_payload["document"]["product"]["category"], "eggs"); 12993 assert!( 12994 listing_payload["document"]["primary_bin"]["bin_id"] 12995 .as_str() 12996 .is_some_and(|value| value.ends_with(":primary")) 12997 ); 12998 assert_eq!( 12999 listing_payload["document"]["primary_bin"]["price_amount"], 13000 "7.50" 13001 ); 13002 assert_eq!(listing_payload["document"]["inventory"]["available"], "12"); 13003 assert_eq!(listing_payload["document"]["delivery"]["method"], "pickup"); 13004 assert_eq!( 13005 listing_payload["document"]["location"]["primary"], 13006 "farmstand" 13007 ); 13008 assert!(listing_payload.get("draft").is_none()); 13009 assert!(listing_payload.get("editor").is_none()); 13010 13011 cleanup_bootstrapped_runtime_paths(&paths); 13012 } 13013 13014 #[test] 13015 fn runtime_published_receipts_record_payload_account_owner() { 13016 let (runtime, paths) = bootstrapped_runtime("published_receipt_payload_owner"); 13017 assert!( 13018 runtime 13019 .generate_local_account(Some("First".to_owned())) 13020 .expect("first account should generate") 13021 ); 13022 let payload_account_id = runtime 13023 .summary() 13024 .settings_account_projection 13025 .selected_account 13026 .as_ref() 13027 .expect("first selected account") 13028 .account 13029 .account_id 13030 .clone(); 13031 assert!( 13032 runtime 13033 .generate_local_account(Some("Second".to_owned())) 13034 .expect("second account should generate") 13035 ); 13036 let selected_account_id = runtime 13037 .summary() 13038 .settings_account_projection 13039 .selected_account 13040 .as_ref() 13041 .expect("second selected account") 13042 .account 13043 .account_id 13044 .clone(); 13045 assert_ne!(payload_account_id, selected_account_id); 13046 13047 let receipt = published_operation_receipt_fixture( 13048 payload_account_id.clone(), 13049 None, 13050 "event-app-owner", 13051 ); 13052 runtime 13053 .lock_state() 13054 .record_published_sync_receipts(&[receipt]) 13055 .expect("published receipt should record"); 13056 13057 let records = shared_local_event_records(&paths); 13058 let signed_record = records 13059 .iter() 13060 .find(|record| record.record_id == "app:signed_event:event-app-owner") 13061 .expect("signed event record"); 13062 assert_eq!( 13063 signed_record.owner_account_id.as_deref(), 13064 Some(payload_account_id.as_str()) 13065 ); 13066 13067 cleanup_bootstrapped_runtime_paths(&paths); 13068 } 13069 13070 #[test] 13071 fn runtime_published_receipts_reject_conflicting_source_owner() { 13072 let (runtime, paths) = bootstrapped_runtime("published_receipt_owner_conflict"); 13073 let database_path = paths 13074 .shared_local_events_database_path() 13075 .expect("shared local events path"); 13076 let executor = 13077 SqliteExecutor::open(database_path.as_path()).expect("open shared local events db"); 13078 let store = LocalEventsStore::new(executor); 13079 store.migrate_up().expect("migrate shared local events"); 13080 store 13081 .append_record(&local_work_record( 13082 "app:local_work:conflict-source", 13083 "other-account", 13084 "farm-key", 13085 None, 13086 json!({"record_kind": "farm_config_v1"}), 13087 )) 13088 .expect("append conflicting source record"); 13089 let receipt = published_operation_receipt_fixture( 13090 "payload-account".to_owned(), 13091 Some("app:local_work:conflict-source".to_owned()), 13092 "event-app-owner-conflict", 13093 ); 13094 13095 let error = runtime 13096 .lock_state() 13097 .record_published_sync_receipts(&[receipt]) 13098 .expect_err("conflicting source owner should fail closed"); 13099 13100 assert!(matches!( 13101 error, 13102 AppSqliteError::InvalidProjection { 13103 reason: "published operation source account does not match local event owner" 13104 } 13105 )); 13106 assert!( 13107 shared_local_event_records(&paths) 13108 .iter() 13109 .all(|record| record.record_id != "app:signed_event:event-app-owner-conflict") 13110 ); 13111 13112 cleanup_bootstrapped_runtime_paths(&paths); 13113 } 13114 13115 #[test] 13116 fn runtime_app_local_work_without_resolved_pubkey_is_non_exportable() { 13117 let (runtime, paths) = bootstrapped_runtime("app_local_work_unresolved_pubkey"); 13118 let farm_id = FarmId::new(); 13119 let account = SelectedAccountProjection::new( 13120 AccountSummary { 13121 account_id: "acct_unresolved".to_owned(), 13122 npub: "npub1unresolved".to_owned(), 13123 label: Some("Unresolved".to_owned()), 13124 custody: AccountCustody::RemoteSigner, 13125 }, 13126 SelectedSurfaceProjection::new(ActiveSurface::Farmer), 13127 FarmerActivationProjection::active(farm_id), 13128 ); 13129 let saved_farm = FarmSummary { 13130 farm_id, 13131 display_name: "Green Farm".to_owned(), 13132 readiness: FarmReadiness::Ready, 13133 }; 13134 let farm_projection = FarmSetupProjection::from_saved_farm(saved_farm.clone()); 13135 13136 { 13137 let mut state = runtime.lock_state_mut(); 13138 let identity = 13139 AppIdentityProjection::ready(vec![account.account.clone()], account.clone()); 13140 let _ = state 13141 .state_store 13142 .apply_in_memory(AppStateCommand::replace_identity_projection(identity)); 13143 state 13144 .append_app_farm_local_work_record(&account, &farm_projection, &saved_farm) 13145 .expect("unresolved farm local work should append"); 13146 state 13147 .append_app_listing_local_work_record( 13148 ProductId::new(), 13149 &ProductEditorDraft { 13150 title: "Eggs".to_owned(), 13151 subtitle: "Fresh eggs".to_owned(), 13152 category: "eggs".to_owned(), 13153 unit_label: "dozen".to_owned(), 13154 price_minor_units: Some(750), 13155 price_currency: "USD".to_owned(), 13156 stock_quantity: Some(12), 13157 availability_window_id: None, 13158 status: ProductStatus::Draft, 13159 }, 13160 ) 13161 .expect("unresolved listing local work should append"); 13162 } 13163 13164 let records = shared_local_event_records(&paths); 13165 let app_records = records 13166 .iter() 13167 .filter(|record| record.source_runtime == SourceRuntime::App) 13168 .collect::<Vec<_>>(); 13169 assert_eq!(app_records.len(), 2); 13170 assert!( 13171 app_records 13172 .iter() 13173 .all(|record| record.owner_account_id.as_deref() == Some("acct_unresolved")) 13174 ); 13175 assert!( 13176 app_records 13177 .iter() 13178 .all(|record| record.owner_pubkey.is_none()) 13179 ); 13180 assert!( 13181 app_records 13182 .iter() 13183 .all(|record| record 13184 .local_work_json 13185 .as_ref() 13186 .is_some_and(|payload| payload["exportability"]["state"] 13187 == "identity_unresolved" 13188 && payload["exportability"]["reason"] == "canonical_hex_pubkey_required")) 13189 ); 13190 let listing_record = app_records 13191 .iter() 13192 .find(|record| { 13193 record 13194 .local_work_json 13195 .as_ref() 13196 .and_then(|payload| payload["record_kind"].as_str()) 13197 == Some("listing_draft_v1") 13198 }) 13199 .expect("listing local work record"); 13200 assert_eq!(listing_record.listing_addr, None); 13201 assert!( 13202 listing_record 13203 .local_work_json 13204 .as_ref() 13205 .expect("listing payload")["document"]["seller_actor"]["pubkey"] 13206 .is_null() 13207 ); 13208 13209 cleanup_bootstrapped_runtime_paths(&paths); 13210 } 13211 13212 #[test] 13213 fn runtime_manual_refresh_marks_failed_checkpoint_when_transport_is_unavailable() { 13214 let runtime = memory_runtime(); 13215 let (account_id, farm_id) = provision_ready_farmer_account(&runtime); 13216 runtime 13217 .lock_state_mut() 13218 .enqueue_selected_account_sync_operations(vec![pending_sync_upsert( 13219 SyncAggregateRef::Farm(farm_id), 13220 farm_sync_payload( 13221 farm_id, 13222 "North field farm", 13223 Some(FarmReadiness::Ready), 13224 "manual_refresh_unavailable_transport", 13225 ), 13226 )]) 13227 .expect("pending farm sync should enqueue"); 13228 13229 assert!( 13230 runtime 13231 .sync_on_manual_refresh() 13232 .expect("manual refresh should complete") 13233 ); 13234 13235 let summary = runtime.summary(); 13236 let pending_operations = runtime 13237 .lock_state() 13238 .sqlite_store 13239 .as_ref() 13240 .expect("sqlite store") 13241 .load_pending_sync_operations(account_id.as_str()) 13242 .expect("pending sync operations should load"); 13243 13244 assert_eq!( 13245 summary.sync_status.projection.run_status, 13246 AppSyncRunStatus::Failed 13247 ); 13248 assert_eq!( 13249 summary.sync_status.projection.checkpoint.state, 13250 SyncCheckpointState::Failed 13251 ); 13252 assert_eq!(summary.sync_status.pending_write_count, 1); 13253 assert!( 13254 summary 13255 .sync_status 13256 .projection 13257 .checkpoint 13258 .last_error_message 13259 .as_deref() 13260 .is_some_and(|message| { message.contains(SYNC_TRANSPORT_UNAVAILABLE_MESSAGE) }) 13261 ); 13262 assert_eq!(pending_operations.len(), 1); 13263 assert_eq!(pending_operations[0].operation.attempt_count, 1); 13264 } 13265 13266 #[test] 13267 fn runtime_sync_attempts_stop_when_blocking_conflicts_are_present() { 13268 let runtime = memory_runtime(); 13269 let (account_id, farm_id) = provision_ready_farmer_account(&runtime); 13270 runtime 13271 .lock_state_mut() 13272 .enqueue_selected_account_sync_operations(vec![pending_sync_upsert( 13273 SyncAggregateRef::Farm(farm_id), 13274 farm_sync_payload( 13275 farm_id, 13276 "North field farm", 13277 Some(FarmReadiness::Ready), 13278 "blocking_conflict_stops_sync", 13279 ), 13280 )]) 13281 .expect("pending farm sync should enqueue"); 13282 13283 runtime 13284 .lock_state() 13285 .sqlite_store 13286 .as_ref() 13287 .expect("sqlite store") 13288 .record_sync_conflict( 13289 account_id.as_str(), 13290 &SyncConflict { 13291 aggregate: SyncAggregateRef::Farm(farm_id), 13292 kind: SyncConflictKind::RevisionMismatch, 13293 severity: SyncConflictSeverity::Blocking, 13294 resolution: SyncConflictResolutionStatus::Unresolved, 13295 local_payload_json: "{\"farm\":\"local\"}".to_owned(), 13296 remote_payload_json: Some("{\"farm\":\"remote\"}".to_owned()), 13297 detected_at: "2026-04-20T20:00:00Z".to_owned(), 13298 resolved_at: None, 13299 }, 13300 ) 13301 .expect("blocking conflict should save"); 13302 assert!( 13303 runtime 13304 .lock_state_mut() 13305 .refresh_selected_account_sync() 13306 .expect("sync status should refresh") 13307 ); 13308 13309 let recorded = install_recorded_sync_transport( 13310 &runtime, 13311 RecordedAppSyncTransport::succeed(AppSyncResult { 13312 run_status: AppSyncRunStatus::Succeeded, 13313 checkpoint: SyncCheckpointStatus::current( 13314 None, 13315 "2026-04-20T20:00:05Z", 13316 Some("cursor-blocked".to_owned()), 13317 ), 13318 pushed_operation_count: 1, 13319 pulled_record_count: 0, 13320 conflicts: Vec::new(), 13321 published_receipts: Vec::new(), 13322 }), 13323 ); 13324 13325 assert!( 13326 !runtime 13327 .sync_on_app_launch() 13328 .expect("blocked launch sync should skip") 13329 ); 13330 13331 let summary = runtime.summary(); 13332 13333 assert_eq!(recorded.lock().expect("recorded transport").call_count(), 0); 13334 assert_eq!(summary.sync_status.pending_write_count, 1); 13335 assert_eq!( 13336 summary.sync_status.projection.run_status, 13337 AppSyncRunStatus::Conflicted 13338 ); 13339 assert_eq!( 13340 summary 13341 .sync_status 13342 .projection 13343 .conflict_status 13344 .blocking_count, 13345 1 13346 ); 13347 } 13348 13349 #[test] 13350 fn runtime_resolving_a_blocking_conflict_refreshes_sync_summary() { 13351 let runtime = memory_runtime(); 13352 let (account_id, farm_id) = provision_ready_farmer_account(&runtime); 13353 13354 let conflict_id = runtime 13355 .lock_state() 13356 .sqlite_store 13357 .as_ref() 13358 .expect("sqlite store") 13359 .record_sync_conflict( 13360 account_id.as_str(), 13361 &SyncConflict { 13362 aggregate: SyncAggregateRef::Farm(farm_id), 13363 kind: SyncConflictKind::RevisionMismatch, 13364 severity: SyncConflictSeverity::Blocking, 13365 resolution: SyncConflictResolutionStatus::Unresolved, 13366 local_payload_json: "{\"farm\":\"local\"}".to_owned(), 13367 remote_payload_json: Some("{\"farm\":\"remote\"}".to_owned()), 13368 detected_at: "2026-04-20T20:05:00Z".to_owned(), 13369 resolved_at: None, 13370 }, 13371 ) 13372 .expect("blocking conflict should save"); 13373 assert!( 13374 runtime 13375 .lock_state_mut() 13376 .refresh_selected_account_sync() 13377 .expect("sync status should refresh") 13378 ); 13379 13380 assert!( 13381 runtime 13382 .resolve_sync_conflict( 13383 conflict_id.as_str(), 13384 SyncConflictResolutionStatus::AcceptedLocal, 13385 ) 13386 .expect("conflict resolution should succeed") 13387 ); 13388 13389 let summary = runtime.summary(); 13390 13391 assert_eq!( 13392 summary 13393 .sync_status 13394 .projection 13395 .conflict_status 13396 .unresolved_count, 13397 0 13398 ); 13399 assert_eq!( 13400 summary 13401 .sync_status 13402 .projection 13403 .conflict_status 13404 .blocking_count, 13405 0 13406 ); 13407 assert_eq!(summary.sync_status.conflicts.len(), 1); 13408 assert_eq!( 13409 summary.sync_status.conflicts[0].conflict.resolution, 13410 SyncConflictResolutionStatus::AcceptedLocal 13411 ); 13412 assert!( 13413 summary.sync_status.conflicts[0] 13414 .conflict 13415 .resolved_at 13416 .as_deref() 13417 .is_some() 13418 ); 13419 } 13420 13421 #[test] 13422 fn runtime_review_required_conflicts_do_not_block_manual_refresh() { 13423 let runtime = memory_runtime(); 13424 let (account_id, farm_id) = provision_ready_farmer_account(&runtime); 13425 13426 assert!( 13427 runtime 13428 .open_new_product_editor() 13429 .expect("new product editor should open") 13430 ); 13431 13432 runtime 13433 .lock_state() 13434 .sqlite_store 13435 .as_ref() 13436 .expect("sqlite store") 13437 .record_sync_conflict( 13438 account_id.as_str(), 13439 &SyncConflict { 13440 aggregate: SyncAggregateRef::Farm(farm_id), 13441 kind: SyncConflictKind::RemoteValidationReject, 13442 severity: SyncConflictSeverity::ReviewRequired, 13443 resolution: SyncConflictResolutionStatus::Unresolved, 13444 local_payload_json: "{\"farm\":\"local\"}".to_owned(), 13445 remote_payload_json: Some("{\"farm\":\"remote\"}".to_owned()), 13446 detected_at: "2026-04-20T20:10:00Z".to_owned(), 13447 resolved_at: None, 13448 }, 13449 ) 13450 .expect("review-required conflict should save"); 13451 assert!( 13452 runtime 13453 .lock_state_mut() 13454 .refresh_selected_account_sync() 13455 .expect("sync status should refresh") 13456 ); 13457 13458 let recorded = install_recorded_sync_transport( 13459 &runtime, 13460 RecordedAppSyncTransport::succeed(AppSyncResult { 13461 run_status: AppSyncRunStatus::Succeeded, 13462 checkpoint: SyncCheckpointStatus::current( 13463 Some("2026-04-20T20:10:05Z".to_owned()), 13464 "2026-04-20T20:10:08Z", 13465 Some("cursor-review-required".to_owned()), 13466 ), 13467 pushed_operation_count: 1, 13468 pulled_record_count: 0, 13469 conflicts: Vec::new(), 13470 published_receipts: Vec::new(), 13471 }), 13472 ); 13473 13474 assert!( 13475 runtime 13476 .sync_on_manual_refresh() 13477 .expect("manual refresh should succeed") 13478 ); 13479 13480 let recorded = recorded.lock().expect("recorded transport"); 13481 let request = recorded 13482 .last_request() 13483 .cloned() 13484 .expect("manual refresh request should record"); 13485 13486 assert_eq!(recorded.call_count(), 1); 13487 assert_eq!(request.trigger, SyncTrigger::ManualRefresh); 13488 } 13489 13490 #[test] 13491 fn runtime_summary_surfaces_runtime_metadata_from_bootstrap() { 13492 let (runtime, paths) = bootstrapped_runtime("runtime_metadata"); 13493 let summary = runtime.summary(); 13494 13495 assert_eq!( 13496 summary.runtime_metadata.snapshot.host.app_name, 13497 radroots_app_core::APP_NAME 13498 ); 13499 assert_eq!( 13500 summary.runtime_metadata.data_root.as_ref(), 13501 Some(&paths.app.data) 13502 ); 13503 assert_eq!( 13504 summary.runtime_metadata.logs_root.as_ref(), 13505 Some(&paths.app.logs) 13506 ); 13507 assert_eq!( 13508 summary.runtime_metadata.database_path.as_ref(), 13509 Some(&paths.app.data.join(APP_DATABASE_FILE_NAME)) 13510 ); 13511 assert_eq!( 13512 summary.runtime_metadata.database_schema_version, 13513 Some(latest_schema_version()) 13514 ); 13515 13516 cleanup_bootstrapped_runtime_paths(&paths); 13517 } 13518 13519 #[test] 13520 fn runtime_bootstrap_starts_sdk_runtime_under_app_data_root() { 13521 let paths = temp_desktop_runtime_paths("sdk_runtime"); 13522 let runtime = DesktopAppRuntime::bootstrap_from_paths_with_snapshot( 13523 paths.clone(), 13524 vec!["ws://127.0.0.1:8080".to_owned()], 13525 super::default_runtime_snapshot(), 13526 ); 13527 let status = runtime 13528 .wait_for_sdk_startup(StdDuration::from_secs(5)) 13529 .expect("sdk runtime should be present"); 13530 13531 assert_eq!(status.state, AppSdkLifecycleState::Ready); 13532 assert_eq!(status.storage_root, paths.app.data.join("sdk")); 13533 assert_eq!( 13534 status 13535 .storage_paths 13536 .as_ref() 13537 .expect("sdk storage paths") 13538 .event_store_path, 13539 paths.app.data.join("sdk").join("event_store.sqlite") 13540 ); 13541 let diagnostics = runtime 13542 .sdk_diagnostics() 13543 .expect("sdk diagnostics should load") 13544 .expect("sdk diagnostics should be present"); 13545 assert_eq!(diagnostics.runtime.state, AppSdkLifecycleState::Ready); 13546 assert_eq!(diagnostics.storage.storage_kind, "directory"); 13547 assert_eq!(diagnostics.sync.relay_targets.configured_count, 1); 13548 assert!( 13549 runtime 13550 .shutdown_sdk_runtime() 13551 .expect("sdk runtime should shut down") 13552 ); 13553 13554 cleanup_bootstrapped_runtime_paths(&paths); 13555 } 13556 13557 #[test] 13558 fn runtime_summary_surfaces_lightweight_sdk_status() { 13559 let paths = temp_desktop_runtime_paths("sdk_summary_status"); 13560 let runtime = DesktopAppRuntime::bootstrap_from_paths_with_snapshot( 13561 paths.clone(), 13562 vec!["ws://127.0.0.1:8080".to_owned()], 13563 super::default_runtime_snapshot(), 13564 ); 13565 runtime 13566 .wait_for_sdk_startup(StdDuration::from_secs(5)) 13567 .expect("sdk runtime should be present"); 13568 13569 let summary = runtime.summary(); 13570 let sdk_status = summary.sdk_status.expect("sdk status summary"); 13571 13572 assert_eq!(sdk_status.lifecycle_state, AppSdkLifecycleState::Ready); 13573 assert_eq!( 13574 sdk_status.projection_lifecycle_state, 13575 AppSdkProjectionLifecycleState::Current 13576 ); 13577 assert_eq!(sdk_status.storage_root, paths.app.data.join("sdk")); 13578 assert_eq!( 13579 sdk_status.event_store_path.as_ref(), 13580 Some(&paths.app.data.join("sdk").join("event_store.sqlite")) 13581 ); 13582 assert_eq!(sdk_status.relay_target_count, 1); 13583 assert!( 13584 runtime 13585 .shutdown_sdk_runtime() 13586 .expect("sdk runtime should shut down") 13587 ); 13588 13589 cleanup_bootstrapped_runtime_paths(&paths); 13590 } 13591 13592 #[test] 13593 fn runtime_sdk_diagnostics_summary_preserves_degraded_issue_metadata() { 13594 let paths = temp_desktop_runtime_paths("sdk_summary_degraded"); 13595 let runtime = DesktopAppRuntime::bootstrap_from_paths_with_snapshot( 13596 paths.clone(), 13597 vec!["ws://relay.example".to_owned()], 13598 super::default_runtime_snapshot(), 13599 ); 13600 let status = runtime 13601 .wait_for_sdk_startup(StdDuration::from_secs(5)) 13602 .expect("sdk runtime should be present"); 13603 assert_eq!(status.state, AppSdkLifecycleState::Degraded); 13604 13605 let diagnostics = runtime 13606 .sdk_diagnostics_summary() 13607 .expect("sdk diagnostics summary"); 13608 13609 assert_eq!( 13610 diagnostics.status.lifecycle_state, 13611 AppSdkLifecycleState::Degraded 13612 ); 13613 match diagnostics.state { 13614 DesktopAppSdkDiagnosticsState::Blocked(issue) => { 13615 assert_eq!(issue.code, "invalid_relay_url"); 13616 assert_eq!(issue.class, "configuration"); 13617 assert!(!issue.retryable); 13618 assert!( 13619 issue 13620 .recovery_actions 13621 .contains(&"configure_relay_targets".to_owned()) 13622 ); 13623 } 13624 unexpected => panic!("unexpected diagnostics state: {unexpected:?}"), 13625 } 13626 assert!( 13627 runtime 13628 .shutdown_sdk_runtime() 13629 .expect("sdk runtime should shut down") 13630 ); 13631 13632 cleanup_bootstrapped_runtime_paths(&paths); 13633 } 13634 13635 #[test] 13636 fn runtime_sdk_diagnostics_summary_keeps_lifecycle_busy_visible() { 13637 let paths = temp_desktop_runtime_paths("sdk_summary_busy"); 13638 let runtime = DesktopAppRuntime::bootstrap_from_paths_with_snapshot( 13639 paths.clone(), 13640 vec!["ws://127.0.0.1:8080".to_owned()], 13641 super::default_runtime_snapshot(), 13642 ); 13643 runtime 13644 .wait_for_sdk_startup(StdDuration::from_secs(5)) 13645 .expect("sdk runtime should be present"); 13646 { 13647 let sdk_runtime = runtime.sdk_runtime.lock().expect("sdk runtime lock"); 13648 sdk_runtime 13649 .as_ref() 13650 .expect("sdk runtime") 13651 .begin_projection_rebuild() 13652 .expect("projection rebuild should begin"); 13653 } 13654 13655 let diagnostics = runtime 13656 .sdk_diagnostics_summary() 13657 .expect("sdk diagnostics summary"); 13658 13659 assert_eq!( 13660 diagnostics.status.lifecycle_state, 13661 AppSdkLifecycleState::RebuildingProjections 13662 ); 13663 assert_eq!( 13664 diagnostics.status.projection_lifecycle_state, 13665 AppSdkProjectionLifecycleState::Rebuilding 13666 ); 13667 match diagnostics.state { 13668 DesktopAppSdkDiagnosticsState::Blocked(issue) => { 13669 assert_eq!(issue.code, "sdk_lifecycle_busy"); 13670 assert!(issue.retryable); 13671 assert!( 13672 issue 13673 .recovery_actions 13674 .contains(&"wait_for_sdk_lifecycle".to_owned()) 13675 ); 13676 } 13677 unexpected => panic!("unexpected diagnostics state: {unexpected:?}"), 13678 } 13679 { 13680 let sdk_runtime = runtime.sdk_runtime.lock().expect("sdk runtime lock"); 13681 sdk_runtime 13682 .as_ref() 13683 .expect("sdk runtime") 13684 .complete_projection_rebuild() 13685 .expect("projection rebuild should complete"); 13686 } 13687 assert!( 13688 runtime 13689 .shutdown_sdk_runtime() 13690 .expect("sdk runtime should shut down") 13691 ); 13692 13693 cleanup_bootstrapped_runtime_paths(&paths); 13694 } 13695 13696 #[test] 13697 fn clearing_startup_pending_remote_signer_session_is_idempotent_without_record() { 13698 let paths = temp_remote_signer_paths("clear_pending_none"); 13699 let runtime = DesktopAppRuntime::from_state(DesktopAppRuntimeState { 13700 state_store: AppStateStore::load(AppStatePersistenceRepository::in_memory()) 13701 .expect("in-memory state store should load"), 13702 nostr_relay_urls: vec!["ws://127.0.0.1:8080".to_owned()], 13703 shared_accounts_paths: None, 13704 remote_signer_paths: Some(paths.clone()), 13705 accounts_manager: None, 13706 sqlite_store: Some( 13707 AppSqliteStore::open(DatabaseTarget::InMemory) 13708 .expect("in-memory sqlite store should open"), 13709 ), 13710 sdk_runtime: None, 13711 sync_transport: default_sync_transport(), 13712 runtime_metadata: DesktopAppRuntimeMetadataSummary::default(), 13713 selected_account_pending_sync_write_count: 0, 13714 selected_account_relay_ingest_freshness: AppRelayIngestScopeFreshness::default(), 13715 selected_account_sync_conflicts: Vec::new(), 13716 startup_issue: None, 13717 }); 13718 13719 assert!( 13720 runtime 13721 .clear_startup_pending_remote_signer_session() 13722 .expect("clear pending should succeed"), 13723 "missing pending startup session should count as a successful cleanup" 13724 ); 13725 13726 cleanup_remote_signer_paths(&paths); 13727 } 13728 13729 #[test] 13730 fn clean_startup_cleanup_allows_generate_key_phase_transition() { 13731 let paths = temp_remote_signer_paths("generate_key_after_clean_cleanup"); 13732 let runtime = DesktopAppRuntime::from_state(DesktopAppRuntimeState { 13733 state_store: AppStateStore::load(AppStatePersistenceRepository::in_memory()) 13734 .expect("in-memory state store should load"), 13735 nostr_relay_urls: vec!["ws://127.0.0.1:8080".to_owned()], 13736 shared_accounts_paths: None, 13737 remote_signer_paths: Some(paths.clone()), 13738 accounts_manager: None, 13739 sqlite_store: Some( 13740 AppSqliteStore::open(DatabaseTarget::InMemory) 13741 .expect("in-memory sqlite store should open"), 13742 ), 13743 sdk_runtime: None, 13744 sync_transport: default_sync_transport(), 13745 runtime_metadata: DesktopAppRuntimeMetadataSummary::default(), 13746 selected_account_pending_sync_write_count: 0, 13747 selected_account_relay_ingest_freshness: AppRelayIngestScopeFreshness::default(), 13748 selected_account_sync_conflicts: Vec::new(), 13749 startup_issue: None, 13750 }); 13751 13752 assert!( 13753 runtime 13754 .clear_startup_pending_remote_signer_session() 13755 .expect("clear pending should succeed") 13756 ); 13757 assert!(runtime.begin_generate_key_startup()); 13758 assert_eq!( 13759 runtime.summary().logged_out_startup.phase, 13760 radroots_app_view::LoggedOutStartupPhase::GenerateKeyStarting 13761 ); 13762 13763 cleanup_remote_signer_paths(&paths); 13764 } 13765 13766 #[test] 13767 fn pending_startup_signer_session_recovers_after_runtime_restart() { 13768 let (runtime, paths) = bootstrapped_runtime("restart_pending_recovery"); 13769 let pending_session = fixture_pending_session(); 13770 13771 assert!( 13772 runtime 13773 .store_startup_pending_remote_signer_session(&pending_session) 13774 .expect("store pending should succeed") 13775 ); 13776 13777 let restarted = restart_runtime(paths.clone()); 13778 let restored = restarted 13779 .load_startup_pending_remote_signer_session() 13780 .expect("load pending should succeed") 13781 .expect("pending session should recover after restart"); 13782 13783 assert_eq!( 13784 restarted.summary().logged_out_startup.phase, 13785 radroots_app_view::LoggedOutStartupPhase::SignerEntry 13786 ); 13787 assert_eq!( 13788 restored.record.client_account_id(), 13789 pending_session.record.client_account_id() 13790 ); 13791 assert_eq!( 13792 restored.record.signer_identity.id, 13793 pending_session.record.signer_identity.id 13794 ); 13795 13796 cleanup_bootstrapped_runtime_paths(&paths); 13797 } 13798 13799 #[test] 13800 fn clearing_pending_startup_signer_session_prevents_restart_recovery() { 13801 let (runtime, paths) = bootstrapped_runtime("restart_after_explicit_cancel"); 13802 let pending_session = fixture_pending_session(); 13803 13804 assert!( 13805 runtime 13806 .store_startup_pending_remote_signer_session(&pending_session) 13807 .expect("store pending should succeed") 13808 ); 13809 assert!( 13810 runtime 13811 .clear_startup_pending_remote_signer_session() 13812 .expect("clear pending should succeed") 13813 ); 13814 13815 let restarted = restart_runtime(paths.clone()); 13816 13817 assert_eq!( 13818 restarted.summary().logged_out_startup.phase, 13819 radroots_app_view::LoggedOutStartupPhase::ContinuePrompt 13820 ); 13821 assert!( 13822 restarted 13823 .load_startup_pending_remote_signer_session() 13824 .expect("load pending should succeed") 13825 .is_none(), 13826 "explicit cancel should leave no pending startup session to recover" 13827 ); 13828 13829 cleanup_bootstrapped_runtime_paths(&paths); 13830 } 13831 13832 #[test] 13833 fn startup_signer_entry_source_input_recovers_after_runtime_restart() { 13834 let (runtime, paths) = bootstrapped_runtime("restart_startup_signer_entry"); 13835 13836 assert!(runtime.show_startup_identity_choice()); 13837 assert!(runtime.show_startup_signer_entry()); 13838 assert!(runtime.set_startup_signer_source_input( 13839 "bunker://npub1signer?relay=wss%3A%2F%2Frelay.radroots.example" 13840 )); 13841 13842 let restarted = restart_runtime(paths.clone()); 13843 let summary = restarted.summary(); 13844 13845 assert_eq!( 13846 summary.logged_out_startup.phase, 13847 radroots_app_view::LoggedOutStartupPhase::SignerEntry 13848 ); 13849 assert_eq!( 13850 summary.logged_out_startup.signer_entry.source_input, 13851 "bunker://npub1signer?relay=wss%3A%2F%2Frelay.radroots.example" 13852 ); 13853 13854 cleanup_bootstrapped_runtime_paths(&paths); 13855 } 13856 13857 #[test] 13858 fn generate_key_startup_phase_fails_closed_to_identity_choice_after_restart() { 13859 let (runtime, paths) = bootstrapped_runtime("restart_generate_key_sanitize"); 13860 13861 assert!(runtime.show_startup_identity_choice()); 13862 assert!(runtime.begin_generate_key_startup()); 13863 13864 let restarted = restart_runtime(paths.clone()); 13865 13866 assert_eq!( 13867 restarted.summary().logged_out_startup.phase, 13868 radroots_app_view::LoggedOutStartupPhase::IdentityChoice 13869 ); 13870 13871 cleanup_bootstrapped_runtime_paths(&paths); 13872 } 13873 13874 #[test] 13875 fn buyer_search_query_and_detail_recover_after_runtime_restart() { 13876 let (runtime, paths) = bootstrapped_runtime("restart_buyer_search_detail"); 13877 let (account_id, farm_id) = provision_ready_farmer_account(&runtime); 13878 13879 assert!( 13880 runtime 13881 .select_active_surface(ActiveSurface::Personal) 13882 .expect("surface should switch into marketplace") 13883 ); 13884 let fulfillment_window_id = seed_buyer_marketplace_support( 13885 &runtime, 13886 account_id.as_str(), 13887 farm_id, 13888 "North field farm", 13889 "Friday pickup", 13890 ); 13891 let product_id = seed_product( 13892 &runtime, 13893 farm_id, 13894 "Salad mix", 13895 "Spring blend", 13896 "published", 13897 Some(8), 13898 "2026-04-20T09:00:00Z", 13899 ); 13900 runtime 13901 .lock_state() 13902 .sqlite_store 13903 .as_ref() 13904 .expect("sqlite store") 13905 .connection() 13906 .execute_batch(&format!( 13907 "update products 13908 set availability_window_id = '{fulfillment_window_id}' 13909 where id = '{product_id}'" 13910 )) 13911 .expect("buyer detail product should attach a fulfillment window"); 13912 13913 assert!( 13914 runtime 13915 .set_personal_search_query("salad") 13916 .expect("buyer search query should update") 13917 ); 13918 assert!( 13919 runtime 13920 .open_personal_product_detail(PersonalSection::Search, product_id) 13921 .expect("buyer search detail should open") 13922 ); 13923 13924 let restarted = restart_runtime(paths.clone()); 13925 let summary = restarted.summary(); 13926 13927 assert_eq!( 13928 summary.shell_projection.selected_section, 13929 ShellSection::Personal(PersonalSection::Search) 13930 ); 13931 assert_eq!( 13932 summary.personal_projection.search.query.search_query, 13933 "salad" 13934 ); 13935 assert_eq!( 13936 summary 13937 .personal_projection 13938 .search 13939 .detail 13940 .as_ref() 13941 .map(|detail| detail.listing.product_id), 13942 Some(product_id) 13943 ); 13944 13945 cleanup_bootstrapped_runtime_paths(&paths); 13946 } 13947 13948 #[test] 13949 fn products_query_and_editor_recover_after_runtime_restart() { 13950 let (runtime, paths) = bootstrapped_runtime("restart_products_editor"); 13951 let (_, farm_id) = provision_ready_farmer_account(&runtime); 13952 let product_id = seed_product( 13953 &runtime, 13954 farm_id, 13955 "Pea shoots", 13956 "Tray", 13957 "draft", 13958 Some(4), 13959 "2026-04-20T09:30:00Z", 13960 ); 13961 13962 assert!( 13963 runtime 13964 .set_products_search_query("pea") 13965 .expect("products query should update") 13966 ); 13967 assert!( 13968 runtime 13969 .select_products_filter(ProductsFilter::Drafts) 13970 .expect("products filter should update") 13971 ); 13972 assert!( 13973 runtime 13974 .select_products_sort(ProductsSort::Name) 13975 .expect("products sort should update") 13976 ); 13977 assert!( 13978 runtime 13979 .open_existing_product_editor(product_id) 13980 .expect("product editor should open") 13981 ); 13982 13983 let restarted = restart_runtime(paths.clone()); 13984 let summary = restarted.summary(); 13985 13986 assert_eq!( 13987 summary.shell_projection.selected_section, 13988 ShellSection::Farmer(FarmerSection::Products) 13989 ); 13990 assert_eq!(summary.products_projection.query.search_query, "pea"); 13991 assert_eq!( 13992 summary.products_projection.query.filter, 13993 ProductsFilter::Drafts 13994 ); 13995 assert_eq!(summary.products_projection.query.sort, ProductsSort::Name); 13996 match &summary.products_projection.editor { 13997 radroots_app_state::ProductEditorState::Open(session) => { 13998 assert_eq!(session.selected_product_id, Some(product_id)); 13999 } 14000 radroots_app_state::ProductEditorState::Closed => { 14001 panic!("product editor should recover after restart") 14002 } 14003 } 14004 14005 cleanup_bootstrapped_runtime_paths(&paths); 14006 } 14007 14008 #[test] 14009 fn orders_query_and_detail_recover_after_runtime_restart() { 14010 let (runtime, paths) = bootstrapped_runtime("restart_orders_detail"); 14011 let (_, farm_id) = provision_ready_farmer_account(&runtime); 14012 let (_, order_id) = seed_order_workspace(&runtime, farm_id); 14013 14014 runtime 14015 .lock_state() 14016 .sqlite_store 14017 .as_ref() 14018 .expect("sqlite store") 14019 .connection() 14020 .execute_batch(&format!( 14021 "update orders 14022 set status = 'packed', updated_at = '2026-04-20T09:45:00Z' 14023 where id = '{order_id}' and farm_id = '{farm_id}'" 14024 )) 14025 .expect("order should update to packed"); 14026 14027 assert!( 14028 runtime 14029 .select_orders_filter(OrdersFilter::Packed) 14030 .expect("orders filter should update") 14031 ); 14032 assert!( 14033 runtime 14034 .open_order_detail(order_id) 14035 .expect("order detail should open") 14036 ); 14037 14038 let restarted = restart_runtime(paths.clone()); 14039 let summary = restarted.summary(); 14040 14041 assert_eq!( 14042 summary.shell_projection.selected_section, 14043 ShellSection::Farmer(FarmerSection::Orders) 14044 ); 14045 assert_eq!(summary.orders_projection.query.filter, OrdersFilter::Packed); 14046 assert_eq!( 14047 summary 14048 .orders_projection 14049 .detail 14050 .as_ref() 14051 .map(|detail| detail.order_id), 14052 Some(order_id) 14053 ); 14054 14055 cleanup_bootstrapped_runtime_paths(&paths); 14056 } 14057 14058 #[test] 14059 fn stale_orders_selection_clears_invalid_window_after_runtime_restart() { 14060 let (runtime, paths) = bootstrapped_runtime("restart_stale_orders"); 14061 let (_, farm_id) = provision_ready_farmer_account(&runtime); 14062 let (fulfillment_window_id, _) = seed_order_workspace(&runtime, farm_id); 14063 14064 assert!( 14065 runtime 14066 .open_orders_fulfillment_window(fulfillment_window_id) 14067 .expect("orders window should open") 14068 ); 14069 let mut persisted_state = runtime.lock_state().state_store.persisted_state().clone(); 14070 persisted_state.seller.orders_query.fulfillment_window_id = 14071 Some(FulfillmentWindowId::new()); 14072 let mut repository = 14073 FileBackedAppStateRepository::new(paths.app.data.join(APP_STATE_FILE_NAME)); 14074 repository 14075 .save_persisted_state(&persisted_state) 14076 .expect("stale orders selection should persist"); 14077 14078 let restarted = restart_runtime(paths.clone()); 14079 let summary = restarted.summary(); 14080 14081 assert_eq!( 14082 summary.shell_projection.selected_section, 14083 ShellSection::Farmer(FarmerSection::Orders) 14084 ); 14085 assert_eq!(summary.orders_projection.query.fulfillment_window_id, None); 14086 assert!( 14087 summary 14088 .orders_projection 14089 .list 14090 .rows 14091 .iter() 14092 .any(|row| { row.fulfillment_window_id == Some(fulfillment_window_id) }) 14093 ); 14094 14095 cleanup_bootstrapped_runtime_paths(&paths); 14096 } 14097 14098 #[test] 14099 fn stale_pack_day_selection_clears_invalid_window_after_runtime_restart() { 14100 let (runtime, paths) = bootstrapped_runtime("restart_stale_pack_day"); 14101 let (_, farm_id) = provision_ready_farmer_account(&runtime); 14102 let (_, _) = seed_order_workspace(&runtime, farm_id); 14103 14104 assert!(runtime.open_pack_day(None).expect("pack day should open")); 14105 let mut persisted_state = runtime.lock_state().state_store.persisted_state().clone(); 14106 let stale_fulfillment_window_id = FulfillmentWindowId::new(); 14107 persisted_state.seller.pack_day_query.fulfillment_window_id = 14108 Some(stale_fulfillment_window_id); 14109 let mut repository = 14110 FileBackedAppStateRepository::new(paths.app.data.join(APP_STATE_FILE_NAME)); 14111 repository 14112 .save_persisted_state(&persisted_state) 14113 .expect("stale pack day selection should persist"); 14114 14115 let restarted = restart_runtime(paths.clone()); 14116 let summary = restarted.summary(); 14117 14118 assert_eq!( 14119 summary.shell_projection.selected_section, 14120 ShellSection::Farmer(FarmerSection::PackDay) 14121 ); 14122 assert_eq!( 14123 summary.pack_day_projection.query.fulfillment_window_id, 14124 None 14125 ); 14126 assert!( 14127 summary 14128 .pack_day_projection 14129 .projection 14130 .fulfillment_window 14131 .is_some() 14132 ); 14133 assert_ne!( 14134 summary.pack_day_projection.query.fulfillment_window_id, 14135 Some(stale_fulfillment_window_id) 14136 ); 14137 14138 cleanup_bootstrapped_runtime_paths(&paths); 14139 } 14140 14141 #[test] 14142 fn replacing_today_agenda_is_shared_without_clobbering_home_shell() { 14143 let runtime = DesktopAppRuntime::from_state(DesktopAppRuntimeState { 14144 state_store: AppStateStore::load(AppStatePersistenceRepository::in_memory()) 14145 .expect("in-memory state store should load"), 14146 nostr_relay_urls: vec!["ws://127.0.0.1:8080".to_owned()], 14147 shared_accounts_paths: None, 14148 remote_signer_paths: None, 14149 accounts_manager: None, 14150 sqlite_store: Some( 14151 AppSqliteStore::open(DatabaseTarget::InMemory) 14152 .expect("in-memory sqlite store should open"), 14153 ), 14154 sdk_runtime: None, 14155 sync_transport: default_sync_transport(), 14156 runtime_metadata: DesktopAppRuntimeMetadataSummary::default(), 14157 selected_account_pending_sync_write_count: 0, 14158 selected_account_relay_ingest_freshness: AppRelayIngestScopeFreshness::default(), 14159 selected_account_sync_conflicts: Vec::new(), 14160 startup_issue: None, 14161 }); 14162 let cloned_runtime = runtime.clone(); 14163 let today_agenda = TodayAgendaProjection { 14164 farm: Some(FarmSummary { 14165 farm_id: radroots_app_view::FarmId::new(), 14166 display_name: "North field farm".to_owned(), 14167 readiness: FarmReadiness::Incomplete, 14168 }), 14169 summary: Some(TodaySummary { 14170 farm_id: radroots_app_view::FarmId::new(), 14171 orders_needing_action: 2, 14172 low_stock_products: 1, 14173 draft_products: 3, 14174 reminders_due_soon: 0, 14175 }), 14176 setup_checklist: vec![TodaySetupTask { 14177 kind: TodaySetupTaskKind::AddFulfillmentWindow, 14178 is_complete: false, 14179 }], 14180 ..TodayAgendaProjection::default() 14181 }; 14182 14183 assert!(runtime.select_settings_section(SettingsSection::About)); 14184 assert!(cloned_runtime.replace_today_agenda(today_agenda.clone())); 14185 14186 let summary = runtime.summary(); 14187 14188 assert_eq!(summary.today_projection.farm, today_agenda.farm); 14189 assert_eq!(summary.today_projection.summary, today_agenda.summary); 14190 assert_eq!(summary.today_projection.setup_checklist.len(), 6); 14191 assert!(summary.today_projection.needs_setup()); 14192 assert_eq!(summary.home_route, HomeRoute::SetupRequired); 14193 assert_eq!( 14194 summary.shell_projection.active_surface, 14195 radroots_app_view::ActiveSurface::Personal 14196 ); 14197 assert_eq!( 14198 summary.shell_projection.selected_section, 14199 ShellSection::Home 14200 ); 14201 assert_eq!( 14202 summary.shell_projection.settings.selected_section, 14203 SettingsSection::About 14204 ); 14205 assert!(summary.today_projection.needs_setup()); 14206 } 14207 14208 #[test] 14209 fn degraded_runtime_surfaces_startup_issue_with_default_today_projection() { 14210 let runtime = DesktopAppRuntime::from_state(DesktopAppRuntimeState::degraded( 14211 super::DesktopAppRuntimeBootstrapError::State(AppStateStoreError::Repository( 14212 AppStateRepositoryError::load("state unavailable"), 14213 )), 14214 )); 14215 14216 let summary = runtime.summary(); 14217 14218 assert_eq!( 14219 summary.shell_projection.active_surface, 14220 radroots_app_view::ActiveSurface::Personal 14221 ); 14222 assert_eq!( 14223 summary.shell_projection.selected_section, 14224 ShellSection::Home 14225 ); 14226 assert_eq!( 14227 summary.shell_projection.settings.selected_section, 14228 SettingsSection::Account 14229 ); 14230 assert_eq!(summary.startup_gate, AppStartupGate::SetupRequired); 14231 assert_eq!( 14232 summary.logged_out_startup, 14233 LoggedOutStartupProjection::default() 14234 ); 14235 assert!(summary.settings_account_projection.roster.is_empty()); 14236 assert_eq!(summary.home_route, HomeRoute::SetupRequired); 14237 assert_eq!(summary.today_projection, TodayAgendaProjection::default()); 14238 assert_eq!( 14239 summary.startup_issue.as_deref(), 14240 Some("app state repository load failed: state unavailable") 14241 ); 14242 } 14243 14244 #[test] 14245 fn runtime_records_activity_context_for_user_visible_actions() { 14246 let runtime = DesktopAppRuntime::from_state(DesktopAppRuntimeState { 14247 state_store: AppStateStore::load(AppStatePersistenceRepository::in_memory()) 14248 .expect("in-memory state store should load"), 14249 nostr_relay_urls: vec!["ws://127.0.0.1:8080".to_owned()], 14250 shared_accounts_paths: None, 14251 remote_signer_paths: None, 14252 accounts_manager: None, 14253 sqlite_store: Some( 14254 AppSqliteStore::open(DatabaseTarget::InMemory) 14255 .expect("in-memory sqlite store should open"), 14256 ), 14257 sdk_runtime: None, 14258 sync_transport: default_sync_transport(), 14259 runtime_metadata: DesktopAppRuntimeMetadataSummary::default(), 14260 selected_account_pending_sync_write_count: 0, 14261 selected_account_relay_ingest_freshness: AppRelayIngestScopeFreshness::default(), 14262 selected_account_sync_conflicts: Vec::new(), 14263 startup_issue: None, 14264 }); 14265 14266 assert!(runtime.record_home_opened()); 14267 assert!(runtime.sync_settings_section(SettingsSection::About)); 14268 assert!(runtime.record_settings_opened(SettingsSection::About)); 14269 assert!(runtime.select_settings_section(SettingsSection::Settings)); 14270 assert!(runtime.set_settings_preference(SettingsPreference::LaunchAtLogin, true)); 14271 14272 let context = runtime 14273 .activity_context(Some(8)) 14274 .expect("activity context should load"); 14275 14276 assert_eq!(context.recent_events.len(), 4); 14277 assert_eq!( 14278 context.recent_events[0].kind, 14279 AppActivityKind::SettingsPreferenceUpdated { 14280 preference: SettingsPreference::LaunchAtLogin, 14281 enabled: true, 14282 } 14283 ); 14284 assert_eq!( 14285 context.recent_events[1].kind, 14286 AppActivityKind::SettingsSectionSelected { 14287 section: SettingsSection::Settings, 14288 } 14289 ); 14290 assert_eq!( 14291 context.recent_events[2].kind, 14292 AppActivityKind::SettingsOpened { 14293 section: SettingsSection::About, 14294 } 14295 ); 14296 assert_eq!(context.recent_events[3].kind, AppActivityKind::HomeOpened); 14297 } 14298 14299 #[test] 14300 fn activity_context_distinguishes_empty_history_from_runtime_unavailable() { 14301 let runtime = DesktopAppRuntime::from_state(DesktopAppRuntimeState { 14302 state_store: AppStateStore::load(AppStatePersistenceRepository::in_memory()) 14303 .expect("in-memory state store should load"), 14304 nostr_relay_urls: vec!["ws://127.0.0.1:8080".to_owned()], 14305 shared_accounts_paths: None, 14306 remote_signer_paths: None, 14307 accounts_manager: None, 14308 sqlite_store: Some( 14309 AppSqliteStore::open(DatabaseTarget::InMemory) 14310 .expect("in-memory sqlite store should open"), 14311 ), 14312 sdk_runtime: None, 14313 sync_transport: default_sync_transport(), 14314 runtime_metadata: DesktopAppRuntimeMetadataSummary::default(), 14315 selected_account_pending_sync_write_count: 0, 14316 selected_account_relay_ingest_freshness: AppRelayIngestScopeFreshness::default(), 14317 selected_account_sync_conflicts: Vec::new(), 14318 startup_issue: None, 14319 }); 14320 14321 let empty_context = runtime 14322 .activity_context(Some(8)) 14323 .expect("empty activity history should still load"); 14324 assert!(empty_context.recent_events.is_empty()); 14325 14326 let degraded = DesktopAppRuntime::from_state(DesktopAppRuntimeState::degraded( 14327 super::DesktopAppRuntimeBootstrapError::State(AppStateStoreError::Repository( 14328 AppStateRepositoryError::load("state unavailable"), 14329 )), 14330 )); 14331 14332 assert!(matches!( 14333 degraded.activity_context(Some(8)), 14334 Err(DesktopAppRuntimeActivityContextError::RuntimeUnavailable) 14335 )); 14336 } 14337 14338 #[test] 14339 fn activity_context_surfaces_store_load_failure() { 14340 let runtime = DesktopAppRuntime::from_state(DesktopAppRuntimeState { 14341 state_store: AppStateStore::load(AppStatePersistenceRepository::in_memory()) 14342 .expect("in-memory state store should load"), 14343 nostr_relay_urls: vec!["ws://127.0.0.1:8080".to_owned()], 14344 shared_accounts_paths: None, 14345 remote_signer_paths: None, 14346 accounts_manager: None, 14347 sqlite_store: Some( 14348 AppSqliteStore::open(DatabaseTarget::InMemory) 14349 .expect("in-memory sqlite store should open"), 14350 ), 14351 sdk_runtime: None, 14352 sync_transport: default_sync_transport(), 14353 runtime_metadata: DesktopAppRuntimeMetadataSummary::default(), 14354 selected_account_pending_sync_write_count: 0, 14355 selected_account_relay_ingest_freshness: AppRelayIngestScopeFreshness::default(), 14356 selected_account_sync_conflicts: Vec::new(), 14357 startup_issue: None, 14358 }); 14359 14360 runtime 14361 .lock_state() 14362 .sqlite_store 14363 .as_ref() 14364 .expect("sqlite store") 14365 .connection() 14366 .execute_batch("DROP TABLE activity_events") 14367 .expect("activity table should drop"); 14368 14369 assert!(matches!( 14370 runtime.activity_context(Some(8)), 14371 Err(DesktopAppRuntimeActivityContextError::Sqlite(_)) 14372 )); 14373 } 14374 14375 #[test] 14376 fn selecting_farmer_section_requires_farmer_identity_gate() { 14377 let runtime = DesktopAppRuntime::from_state(DesktopAppRuntimeState { 14378 state_store: AppStateStore::load(AppStatePersistenceRepository::in_memory()) 14379 .expect("in-memory state store should load"), 14380 nostr_relay_urls: vec!["ws://127.0.0.1:8080".to_owned()], 14381 shared_accounts_paths: None, 14382 remote_signer_paths: None, 14383 accounts_manager: None, 14384 sqlite_store: Some( 14385 AppSqliteStore::open(DatabaseTarget::InMemory) 14386 .expect("in-memory sqlite store should open"), 14387 ), 14388 sdk_runtime: None, 14389 sync_transport: default_sync_transport(), 14390 runtime_metadata: DesktopAppRuntimeMetadataSummary::default(), 14391 selected_account_pending_sync_write_count: 0, 14392 selected_account_relay_ingest_freshness: AppRelayIngestScopeFreshness::default(), 14393 selected_account_sync_conflicts: Vec::new(), 14394 startup_issue: None, 14395 }); 14396 14397 assert!(!runtime.select_farmer_section(FarmerSection::Products)); 14398 assert!(!runtime.select_farmer_section(FarmerSection::Orders)); 14399 assert!(!runtime.select_farmer_section(FarmerSection::PackDay)); 14400 assert_eq!( 14401 runtime.summary().shell_projection.selected_section, 14402 ShellSection::Home 14403 ); 14404 } 14405 14406 #[test] 14407 fn pack_day_stays_blocked_without_a_window_context() { 14408 let runtime = memory_runtime(); 14409 let _ = provision_ready_farmer_account(&runtime); 14410 14411 assert!(!runtime.select_farmer_section(FarmerSection::PackDay)); 14412 assert!( 14413 !runtime 14414 .open_pack_day(None) 14415 .expect("pack day route should stay blocked") 14416 ); 14417 assert_eq!( 14418 runtime.summary().shell_projection.selected_section, 14419 ShellSection::Farmer(FarmerSection::Today) 14420 ); 14421 assert!( 14422 runtime 14423 .summary() 14424 .pack_day_projection 14425 .projection 14426 .fulfillment_window 14427 .is_none() 14428 ); 14429 } 14430 14431 #[test] 14432 fn runtime_routes_between_farmer_home_and_products_through_explicit_methods() { 14433 let runtime = memory_runtime(); 14434 14435 assert!( 14436 runtime 14437 .generate_local_account(Some("Farmer".to_owned())) 14438 .expect("account should generate") 14439 ); 14440 let account_id = runtime 14441 .summary() 14442 .settings_account_projection 14443 .selected_account 14444 .as_ref() 14445 .expect("selected account") 14446 .account 14447 .account_id 14448 .clone(); 14449 let farm_id = 14450 save_farmer_surface_activation(&runtime, account_id.as_str(), ActiveSurface::Farmer); 14451 let farm_setup_projection = FarmSetupProjection::from_saved_farm(FarmSummary { 14452 farm_id, 14453 display_name: "North field farm".to_owned(), 14454 readiness: FarmReadiness::Ready, 14455 }); 14456 runtime 14457 .lock_state() 14458 .sqlite_store 14459 .as_ref() 14460 .expect("sqlite store") 14461 .save_farm_summary( 14462 farm_setup_projection 14463 .saved_farm 14464 .as_ref() 14465 .expect("saved farm should exist"), 14466 ) 14467 .expect("farm summary should save"); 14468 runtime 14469 .lock_state() 14470 .sqlite_store 14471 .as_ref() 14472 .expect("sqlite store") 14473 .save_farm_setup(account_id.as_str(), &farm_setup_projection) 14474 .expect("farm setup should save"); 14475 assert!( 14476 runtime 14477 .select_local_account(account_id.as_str()) 14478 .expect("account should select") 14479 ); 14480 14481 assert!(runtime.select_farmer_section(FarmerSection::Products)); 14482 assert_eq!( 14483 runtime.summary().shell_projection.selected_section, 14484 ShellSection::Farmer(FarmerSection::Products) 14485 ); 14486 14487 assert!(runtime.select_home()); 14488 assert_eq!( 14489 runtime.summary().shell_projection.selected_section, 14490 ShellSection::Farmer(FarmerSection::Today) 14491 ); 14492 } 14493 14494 #[test] 14495 fn guest_marketplace_entry_selects_personal_browse_without_an_account() { 14496 let runtime = memory_runtime(); 14497 14498 assert!( 14499 runtime 14500 .select_personal_section(PersonalSection::Browse) 14501 .expect("guest Browse selection should succeed") 14502 ); 14503 14504 let summary = runtime.summary(); 14505 assert_eq!(summary.startup_gate, AppStartupGate::SetupRequired); 14506 assert_eq!( 14507 summary.shell_projection.selected_section, 14508 ShellSection::Personal(PersonalSection::Browse) 14509 ); 14510 assert_eq!( 14511 summary.personal_projection.entry.state, 14512 radroots_app_view::PersonalEntryState::Guest 14513 ); 14514 } 14515 14516 #[test] 14517 fn runtime_personal_search_queries_refresh_repository_backed_marketplace_projection() { 14518 let runtime = memory_runtime(); 14519 let (account_id, farm_id) = provision_ready_farmer_account(&runtime); 14520 let pickup_location_id = PickupLocationId::new(); 14521 let fulfillment_window_id = FulfillmentWindowId::new(); 14522 let sql = format!( 14523 "insert into pickup_locations ( 14524 id, 14525 farm_id, 14526 label, 14527 address_line, 14528 directions, 14529 is_default, 14530 created_at, 14531 updated_at 14532 ) values ( 14533 '{pickup_location_id}', 14534 '{farm_id}', 14535 'North barn', 14536 '14 County Road', 14537 null, 14538 1, 14539 '2026-04-20T08:00:00Z', 14540 '2026-04-20T08:00:00Z' 14541 ); 14542 insert into fulfillment_windows ( 14543 id, 14544 farm_id, 14545 starts_at, 14546 ends_at, 14547 capacity_limit, 14548 created_at, 14549 updated_at, 14550 pickup_location_id, 14551 label, 14552 order_cutoff_at 14553 ) values ( 14554 '{fulfillment_window_id}', 14555 '{farm_id}', 14556 '2099-04-18T16:00:00Z', 14557 '2099-04-18T18:00:00Z', 14558 null, 14559 '2099-04-18T16:00:00Z', 14560 '2099-04-18T16:00:00Z', 14561 '{pickup_location_id}', 14562 'Friday pickup', 14563 '2099-04-17T18:00:00Z' 14564 ); 14565 update account_farm_setups 14566 set 14567 pickup_enabled = 1, 14568 delivery_enabled = 0, 14569 shipping_enabled = 0, 14570 saved_farm_id = '{farm_id}', 14571 saved_farm_display_name = 'North field farm', 14572 saved_farm_readiness = 'ready', 14573 updated_at = '2026-04-20T08:00:00Z' 14574 where account_id = '{account_id}';" 14575 ); 14576 runtime 14577 .lock_state() 14578 .sqlite_store 14579 .as_ref() 14580 .expect("sqlite store") 14581 .connection() 14582 .execute_batch(&sql) 14583 .expect("buyer search workspace should seed"); 14584 let salad_mix_id = seed_product( 14585 &runtime, 14586 farm_id, 14587 "Salad mix", 14588 "Spring blend", 14589 "published", 14590 Some(8), 14591 "2026-04-20T09:00:00Z", 14592 ); 14593 let pea_shoots_id = seed_product( 14594 &runtime, 14595 farm_id, 14596 "Pea shoots", 14597 "Tray-grown", 14598 "published", 14599 Some(4), 14600 "2026-04-20T09:30:00Z", 14601 ); 14602 runtime 14603 .lock_state() 14604 .sqlite_store 14605 .as_ref() 14606 .expect("sqlite store") 14607 .connection() 14608 .execute_batch(&format!( 14609 "update products 14610 set availability_window_id = '{fulfillment_window_id}' 14611 where id in ('{salad_mix_id}', '{pea_shoots_id}')" 14612 )) 14613 .expect("buyer-visible products should attach a fulfillment window"); 14614 14615 let _ = runtime 14616 .select_local_account(account_id.as_str()) 14617 .expect("account should refresh after buyer workspace seeding"); 14618 let summary = runtime.summary(); 14619 assert_eq!(summary.personal_projection.search.listings.rows.len(), 2); 14620 assert!( 14621 summary 14622 .personal_projection 14623 .search 14624 .query 14625 .fulfillment_methods 14626 .is_empty() 14627 ); 14628 14629 assert!( 14630 runtime 14631 .set_personal_search_query("pea") 14632 .expect("buyer search query should apply") 14633 ); 14634 let searched = runtime.summary(); 14635 assert_eq!(searched.personal_projection.search.listings.rows.len(), 1); 14636 assert_eq!( 14637 searched.personal_projection.search.listings.rows[0].title, 14638 "Pea shoots" 14639 ); 14640 14641 assert!( 14642 runtime 14643 .set_personal_search_fulfillment_method(FarmOrderMethod::Pickup, true) 14644 .expect("buyer fulfillment filter should apply") 14645 ); 14646 let filtered = runtime.summary(); 14647 assert_eq!( 14648 filtered 14649 .personal_projection 14650 .search 14651 .query 14652 .fulfillment_methods, 14653 BTreeSet::from([FarmOrderMethod::Pickup]) 14654 ); 14655 assert_eq!(filtered.personal_projection.search.listings.rows.len(), 1); 14656 assert_eq!( 14657 filtered.personal_projection.search.listings.rows[0] 14658 .next_fulfillment_window_label 14659 .as_deref(), 14660 Some("Friday pickup") 14661 ); 14662 } 14663 14664 #[test] 14665 fn runtime_personal_product_detail_adds_to_cart_and_routes_into_cart() { 14666 let runtime = memory_runtime(); 14667 let (account_id, farm_id) = provision_ready_farmer_account(&runtime); 14668 assert!( 14669 runtime 14670 .select_active_surface(ActiveSurface::Personal) 14671 .expect("surface should switch into marketplace") 14672 ); 14673 let fulfillment_window_id = seed_buyer_marketplace_support( 14674 &runtime, 14675 account_id.as_str(), 14676 farm_id, 14677 "North field farm", 14678 "Friday pickup", 14679 ); 14680 let product_id = seed_product( 14681 &runtime, 14682 farm_id, 14683 "Salad mix", 14684 "Spring blend", 14685 "published", 14686 Some(8), 14687 "2026-04-20T09:00:00Z", 14688 ); 14689 runtime 14690 .lock_state() 14691 .sqlite_store 14692 .as_ref() 14693 .expect("sqlite store") 14694 .connection() 14695 .execute_batch(&format!( 14696 "update products 14697 set availability_window_id = '{fulfillment_window_id}' 14698 where id = '{product_id}'" 14699 )) 14700 .expect("buyer detail product should attach a fulfillment window"); 14701 14702 assert!( 14703 runtime 14704 .open_personal_product_detail(PersonalSection::Browse, product_id) 14705 .expect("buyer detail should open") 14706 ); 14707 assert!(runtime.increase_personal_product_quantity(PersonalSection::Browse)); 14708 assert!( 14709 runtime 14710 .add_personal_product_to_cart(PersonalSection::Browse, false) 14711 .expect("buyer product should add to cart") 14712 ); 14713 14714 let summary = runtime.summary(); 14715 assert_eq!( 14716 summary.shell_projection.selected_section, 14717 ShellSection::Personal(PersonalSection::Cart) 14718 ); 14719 assert_eq!(summary.personal_projection.cart.cart.lines.len(), 1); 14720 assert_eq!( 14721 summary.personal_projection.cart.cart.lines[0].title, 14722 "Salad mix" 14723 ); 14724 assert_eq!(summary.personal_projection.cart.cart.lines[0].quantity, 2); 14725 assert_eq!( 14726 summary.personal_projection.cart.cart.subtotal_minor_units, 14727 Some(1200) 14728 ); 14729 assert_eq!( 14730 summary 14731 .personal_projection 14732 .cart 14733 .cart 14734 .farm_display_name 14735 .as_deref(), 14736 Some("North field farm") 14737 ); 14738 assert!( 14739 summary 14740 .personal_projection 14741 .cart 14742 .cart 14743 .replace_confirmation 14744 .is_none() 14745 ); 14746 assert_eq!( 14747 summary 14748 .personal_projection 14749 .browse 14750 .detail 14751 .as_ref() 14752 .expect("buyer detail should persist on browse") 14753 .selected_quantity, 14754 2 14755 ); 14756 } 14757 14758 #[test] 14759 fn runtime_cross_farm_buyer_add_requires_replace_confirmation() { 14760 let runtime = memory_runtime(); 14761 let (account_id, farm_id) = provision_ready_farmer_account(&runtime); 14762 assert!( 14763 runtime 14764 .select_active_surface(ActiveSurface::Personal) 14765 .expect("surface should switch into marketplace") 14766 ); 14767 let first_window_id = seed_buyer_marketplace_support( 14768 &runtime, 14769 account_id.as_str(), 14770 farm_id, 14771 "North field farm", 14772 "Friday pickup", 14773 ); 14774 let first_product_id = seed_product( 14775 &runtime, 14776 farm_id, 14777 "Salad mix", 14778 "Spring blend", 14779 "published", 14780 Some(8), 14781 "2026-04-20T09:00:00Z", 14782 ); 14783 runtime 14784 .lock_state() 14785 .sqlite_store 14786 .as_ref() 14787 .expect("sqlite store") 14788 .connection() 14789 .execute_batch(&format!( 14790 "update products 14791 set availability_window_id = '{first_window_id}' 14792 where id = '{first_product_id}'" 14793 )) 14794 .expect("first product should attach a fulfillment window"); 14795 assert!( 14796 runtime 14797 .open_personal_product_detail(PersonalSection::Browse, first_product_id) 14798 .expect("first buyer detail should open") 14799 ); 14800 assert!( 14801 runtime 14802 .add_personal_product_to_cart(PersonalSection::Browse, false) 14803 .expect("first buyer product should add to cart") 14804 ); 14805 14806 let other_farm_id = FarmId::new(); 14807 runtime 14808 .lock_state() 14809 .sqlite_store 14810 .as_ref() 14811 .expect("sqlite store") 14812 .save_farm_summary(&FarmSummary { 14813 farm_id: other_farm_id, 14814 display_name: "Willow Farm".to_owned(), 14815 readiness: FarmReadiness::Ready, 14816 }) 14817 .expect("other farm summary should save"); 14818 let second_window_id = seed_buyer_marketplace_support( 14819 &runtime, 14820 "acct_other_farmer", 14821 other_farm_id, 14822 "Willow Farm", 14823 "Saturday pickup", 14824 ); 14825 let second_product_id = seed_product( 14826 &runtime, 14827 other_farm_id, 14828 "Pea shoots", 14829 "Tray-grown", 14830 "published", 14831 Some(5), 14832 "2026-04-20T10:00:00Z", 14833 ); 14834 runtime 14835 .lock_state() 14836 .sqlite_store 14837 .as_ref() 14838 .expect("sqlite store") 14839 .connection() 14840 .execute_batch(&format!( 14841 "update products 14842 set availability_window_id = '{second_window_id}' 14843 where id = '{second_product_id}'" 14844 )) 14845 .expect("second product should attach a fulfillment window"); 14846 14847 assert!( 14848 runtime 14849 .open_personal_product_detail(PersonalSection::Browse, second_product_id) 14850 .expect("second buyer detail should open") 14851 ); 14852 assert!( 14853 runtime 14854 .add_personal_product_to_cart(PersonalSection::Browse, false) 14855 .expect("cross-farm add should require confirmation") 14856 ); 14857 14858 let confirmation_summary = runtime.summary(); 14859 assert_eq!( 14860 confirmation_summary.shell_projection.selected_section, 14861 ShellSection::Personal(PersonalSection::Browse) 14862 ); 14863 assert_eq!( 14864 confirmation_summary 14865 .personal_projection 14866 .cart 14867 .cart 14868 .lines 14869 .len(), 14870 1 14871 ); 14872 assert_eq!( 14873 confirmation_summary.personal_projection.cart.cart.lines[0].title, 14874 "Salad mix" 14875 ); 14876 assert_eq!( 14877 confirmation_summary 14878 .personal_projection 14879 .cart 14880 .cart 14881 .replace_confirmation 14882 .as_ref() 14883 .expect("replace confirmation should exist") 14884 .incoming_farm_display_name, 14885 "Willow Farm" 14886 ); 14887 14888 assert!( 14889 runtime 14890 .add_personal_product_to_cart(PersonalSection::Browse, true) 14891 .expect("confirmed cross-farm add should replace the cart") 14892 ); 14893 let replaced_summary = runtime.summary(); 14894 assert_eq!( 14895 replaced_summary.shell_projection.selected_section, 14896 ShellSection::Personal(PersonalSection::Cart) 14897 ); 14898 assert_eq!( 14899 replaced_summary.personal_projection.cart.cart.lines.len(), 14900 1 14901 ); 14902 assert_eq!( 14903 replaced_summary.personal_projection.cart.cart.lines[0].title, 14904 "Pea shoots" 14905 ); 14906 assert_eq!( 14907 replaced_summary 14908 .personal_projection 14909 .cart 14910 .cart 14911 .farm_display_name 14912 .as_deref(), 14913 Some("Willow Farm") 14914 ); 14915 assert!( 14916 replaced_summary 14917 .personal_projection 14918 .cart 14919 .cart 14920 .replace_confirmation 14921 .is_none() 14922 ); 14923 } 14924 14925 #[test] 14926 fn runtime_removing_buyer_cart_line_clears_cart_and_order_review_readiness() { 14927 let runtime = memory_runtime(); 14928 let (account_id, farm_id) = provision_ready_farmer_account(&runtime); 14929 assert!( 14930 runtime 14931 .select_active_surface(ActiveSurface::Personal) 14932 .expect("surface should switch into marketplace") 14933 ); 14934 let fulfillment_window_id = seed_buyer_marketplace_support( 14935 &runtime, 14936 account_id.as_str(), 14937 farm_id, 14938 "North field farm", 14939 "Friday pickup", 14940 ); 14941 let product_id = seed_product( 14942 &runtime, 14943 farm_id, 14944 "Salad mix", 14945 "Spring blend", 14946 "published", 14947 Some(8), 14948 "2026-04-20T09:00:00Z", 14949 ); 14950 runtime 14951 .lock_state() 14952 .sqlite_store 14953 .as_ref() 14954 .expect("sqlite store") 14955 .connection() 14956 .execute_batch(&format!( 14957 "update products 14958 set availability_window_id = '{fulfillment_window_id}' 14959 where id = '{product_id}'" 14960 )) 14961 .expect("buyer detail product should attach a fulfillment window"); 14962 assert!( 14963 runtime 14964 .open_personal_product_detail(PersonalSection::Browse, product_id) 14965 .expect("buyer detail should open") 14966 ); 14967 assert!( 14968 runtime 14969 .add_personal_product_to_cart(PersonalSection::Browse, false) 14970 .expect("buyer product should add to cart") 14971 ); 14972 14973 assert!( 14974 runtime 14975 .remove_personal_cart_line(product_id) 14976 .expect("buyer cart line should remove") 14977 ); 14978 14979 let summary = runtime.summary(); 14980 assert!(summary.personal_projection.cart.cart.lines.is_empty()); 14981 assert!(summary.personal_projection.cart.cart.farm_id.is_none()); 14982 assert!( 14983 !summary 14984 .personal_projection 14985 .cart 14986 .order_review 14987 .can_place_order 14988 ); 14989 assert_eq!( 14990 summary 14991 .personal_projection 14992 .cart 14993 .order_review 14994 .summary 14995 .line_count, 14996 0 14997 ); 14998 } 14999 15000 #[test] 15001 fn runtime_places_buyer_order_and_routes_into_personal_orders() { 15002 let runtime = memory_runtime(); 15003 let (account_id, farm_id) = provision_ready_farmer_account(&runtime); 15004 assert!( 15005 runtime 15006 .select_active_surface(ActiveSurface::Personal) 15007 .expect("surface should switch into marketplace") 15008 ); 15009 let fulfillment_window_id = seed_buyer_marketplace_support( 15010 &runtime, 15011 account_id.as_str(), 15012 farm_id, 15013 "North field farm", 15014 "Friday pickup", 15015 ); 15016 let product_id = seed_product( 15017 &runtime, 15018 farm_id, 15019 "Salad mix", 15020 "Spring blend", 15021 "published", 15022 Some(8), 15023 "2026-04-20T09:00:00Z", 15024 ); 15025 runtime 15026 .lock_state() 15027 .sqlite_store 15028 .as_ref() 15029 .expect("sqlite store") 15030 .connection() 15031 .execute_batch(&format!( 15032 "update products 15033 set availability_window_id = '{fulfillment_window_id}' 15034 where id = '{product_id}'" 15035 )) 15036 .expect("buyer detail product should attach a fulfillment window"); 15037 assert!( 15038 runtime 15039 .open_personal_product_detail(PersonalSection::Browse, product_id) 15040 .expect("buyer detail should open") 15041 ); 15042 assert!( 15043 runtime 15044 .add_personal_product_to_cart(PersonalSection::Browse, false) 15045 .expect("buyer product should add to cart") 15046 ); 15047 assert!( 15048 runtime 15049 .save_personal_order_review_draft(BuyerOrderReviewDraft { 15050 name: "Casey Buyer".to_owned(), 15051 email: "casey@example.com".to_owned(), 15052 phone: "555-0101".to_owned(), 15053 order_note: "Leave by the cooler".to_owned(), 15054 }) 15055 .expect("buyer order review draft should save") 15056 ); 15057 let order_review = runtime.summary().personal_projection.cart.order_review; 15058 assert!(order_review.can_place_order); 15059 assert_eq!(order_review.place_order_disabled_reason, None); 15060 assert!( 15061 runtime 15062 .place_personal_order() 15063 .expect("buyer order should place") 15064 ); 15065 15066 let summary = runtime.summary(); 15067 assert_eq!( 15068 summary.shell_projection.selected_section, 15069 ShellSection::Personal(PersonalSection::Orders) 15070 ); 15071 assert!(summary.personal_projection.cart.cart.lines.is_empty()); 15072 assert!( 15073 !summary 15074 .personal_projection 15075 .cart 15076 .order_review 15077 .can_place_order 15078 ); 15079 assert_eq!( 15080 summary 15081 .personal_projection 15082 .cart 15083 .order_review 15084 .place_order_disabled_reason, 15085 Some(BuyerOrderReviewDisabledReason::EmptyCart) 15086 ); 15087 assert_eq!(summary.personal_projection.orders.list.rows.len(), 1); 15088 assert_eq!( 15089 summary.personal_projection.orders.list.rows[0].farm_display_name, 15090 "North field farm" 15091 ); 15092 assert_eq!( 15093 summary.personal_projection.orders.list.rows[0] 15094 .status 15095 .storage_key(), 15096 "placed" 15097 ); 15098 assert_eq!( 15099 summary 15100 .personal_projection 15101 .orders 15102 .detail 15103 .as_ref() 15104 .expect("buyer order detail should be selected") 15105 .order_id, 15106 summary.personal_projection.orders.list.rows[0].order_id 15107 ); 15108 assert_eq!( 15109 summary 15110 .personal_projection 15111 .orders 15112 .detail 15113 .as_ref() 15114 .expect("buyer order detail") 15115 .order_note 15116 .as_deref(), 15117 Some("Leave by the cooler") 15118 ); 15119 } 15120 15121 #[test] 15122 fn runtime_guest_order_review_requires_account_before_order_write() { 15123 let runtime = memory_runtime(); 15124 let farm_id = FarmId::new(); 15125 runtime 15126 .lock_state() 15127 .sqlite_store 15128 .as_ref() 15129 .expect("sqlite store") 15130 .save_farm_summary(&FarmSummary { 15131 farm_id, 15132 display_name: "North field farm".to_owned(), 15133 readiness: FarmReadiness::Ready, 15134 }) 15135 .expect("farm summary should save"); 15136 assert!( 15137 runtime 15138 .select_active_surface(ActiveSurface::Personal) 15139 .expect("surface should switch into marketplace") 15140 ); 15141 let fulfillment_window_id = seed_buyer_marketplace_support( 15142 &runtime, 15143 "acct_farmer", 15144 farm_id, 15145 "North field farm", 15146 "Friday pickup", 15147 ); 15148 let product_id = seed_product( 15149 &runtime, 15150 farm_id, 15151 "Salad mix", 15152 "Spring blend", 15153 "published", 15154 Some(8), 15155 "2026-04-20T09:00:00Z", 15156 ); 15157 runtime 15158 .lock_state() 15159 .sqlite_store 15160 .as_ref() 15161 .expect("sqlite store") 15162 .connection() 15163 .execute_batch(&format!( 15164 "update products 15165 set availability_window_id = '{fulfillment_window_id}' 15166 where id = '{product_id}'" 15167 )) 15168 .expect("buyer detail product should attach a fulfillment window"); 15169 assert!( 15170 runtime 15171 .open_personal_product_detail(PersonalSection::Browse, product_id) 15172 .expect("buyer detail should open") 15173 ); 15174 assert!( 15175 runtime 15176 .add_personal_product_to_cart(PersonalSection::Browse, false) 15177 .expect("buyer product should add to cart") 15178 ); 15179 assert!( 15180 runtime 15181 .save_personal_order_review_draft(BuyerOrderReviewDraft { 15182 name: "Casey Buyer".to_owned(), 15183 email: "casey@example.com".to_owned(), 15184 phone: "555-0101".to_owned(), 15185 order_note: "Leave by the cooler".to_owned(), 15186 }) 15187 .expect("buyer order review draft should save") 15188 ); 15189 15190 let ready_summary = runtime.summary(); 15191 assert!( 15192 !ready_summary 15193 .personal_projection 15194 .cart 15195 .order_review 15196 .can_place_order 15197 ); 15198 assert_eq!( 15199 ready_summary 15200 .personal_projection 15201 .cart 15202 .order_review 15203 .place_order_disabled_reason, 15204 Some(BuyerOrderReviewDisabledReason::AccountRequired) 15205 ); 15206 assert_eq!( 15207 ready_summary 15208 .personal_projection 15209 .cart 15210 .order_review 15211 .summary 15212 .line_count, 15213 1 15214 ); 15215 15216 let error = runtime 15217 .place_personal_order() 15218 .expect_err("guest order review should require an account"); 15219 assert!(matches!(error, AppSqliteError::InvalidProjection { .. })); 15220 15221 let summary = runtime.summary(); 15222 assert_eq!( 15223 summary.shell_projection.selected_section, 15224 ShellSection::Personal(PersonalSection::Cart) 15225 ); 15226 assert_eq!(summary.personal_projection.cart.cart.lines.len(), 1); 15227 assert_eq!(summary.personal_projection.orders.list.rows.len(), 0); 15228 let order_count: i64 = runtime 15229 .lock_state() 15230 .sqlite_store 15231 .as_ref() 15232 .expect("sqlite store") 15233 .connection() 15234 .query_row("select count(*) from orders", [], |row| row.get(0)) 15235 .expect("order count should load"); 15236 let coordination_count: i64 = runtime 15237 .lock_state() 15238 .sqlite_store 15239 .as_ref() 15240 .expect("sqlite store") 15241 .connection() 15242 .query_row( 15243 "select count(*) from buyer_order_coordination_records", 15244 [], 15245 |row| row.get(0), 15246 ) 15247 .expect("coordination count should load"); 15248 assert_eq!(order_count, 0); 15249 assert_eq!(coordination_count, 0); 15250 } 15251 15252 #[test] 15253 fn runtime_prepares_seller_order_accept_payload_from_signed_request() { 15254 let relay = ThreadedAckRelay::spawn(); 15255 let (runtime, paths, order_id, _product_id, seller_pubkey, buyer_pubkey) = 15256 seller_order_decision_runtime("seller_order_accept_payload", 6, 2); 15257 configure_runtime_relay_ingest(&runtime, &relay); 15258 15259 let payload = runtime 15260 .prepare_order_accept(order_id) 15261 .expect("seller order accept payload should prepare"); 15262 let decision = order_decision_publish_payload_to_sdk_decision(&payload) 15263 .expect("order accept payload should convert to SDK decision"); 15264 15265 assert_eq!(payload.app_order_id, order_id); 15266 assert_eq!(payload.trade_order_id, "seller-order-decision-1"); 15267 assert_eq!( 15268 payload.request_event_id, 15269 shared_order_request_event_id(&paths, "seller-order-decision-1") 15270 ); 15271 assert_eq!( 15272 payload.listing_event_id, 15273 Some(signed_listing_event_id("seller-order-decision")) 15274 ); 15275 assert_eq!(payload.buyer_pubkey, buyer_pubkey); 15276 assert_eq!(payload.seller_pubkey, seller_pubkey); 15277 assert_eq!(decision.order_id, "seller-order-decision-1"); 15278 let RadrootsOrderDecisionOutcome::Accepted { 15279 inventory_commitments, 15280 } = decision.decision 15281 else { 15282 panic!("expected accepted decision"); 15283 }; 15284 assert_eq!(inventory_commitments.len(), 1); 15285 assert_eq!(inventory_commitments[0].bin_id, "seller-order-primary-bin"); 15286 assert_eq!(inventory_commitments[0].bin_count, 2); 15287 15288 cleanup_bootstrapped_runtime_paths(&paths); 15289 } 15290 15291 #[test] 15292 fn runtime_prepares_seller_order_decline_payload_with_trimmed_reason() { 15293 let relay = ThreadedAckRelay::spawn(); 15294 let (runtime, paths, order_id, _product_id, seller_pubkey, buyer_pubkey) = 15295 seller_order_decision_runtime("seller_order_decline_payload", 6, 2); 15296 configure_runtime_relay_ingest(&runtime, &relay); 15297 15298 let payload = runtime 15299 .prepare_order_decline(order_id, " out of stock ") 15300 .expect("seller order decline payload should prepare"); 15301 let decision = order_decision_publish_payload_to_sdk_decision(&payload) 15302 .expect("order decline payload should convert to SDK decision"); 15303 15304 assert_eq!(payload.buyer_pubkey, buyer_pubkey); 15305 assert_eq!(payload.seller_pubkey, seller_pubkey); 15306 assert_eq!( 15307 payload.decision, 15308 AppOrderDecisionPayload::Declined { 15309 reason: "out of stock".to_owned() 15310 } 15311 ); 15312 let RadrootsOrderDecisionOutcome::Declined { reason } = decision.decision else { 15313 panic!("expected declined decision"); 15314 }; 15315 assert_eq!(reason, "out of stock"); 15316 15317 cleanup_bootstrapped_runtime_paths(&paths); 15318 } 15319 15320 #[test] 15321 fn runtime_finds_seller_order_request_evidence_past_first_local_events_page() { 15322 let relay = ThreadedAckRelay::spawn(); 15323 let (runtime, paths, order_id, _product_id, _seller_pubkey, _buyer_pubkey) = 15324 seller_order_decision_runtime("seller_order_old_request_evidence", 6, 2); 15325 configure_runtime_relay_ingest(&runtime, &relay); 15326 append_unrelated_signed_event_records(&paths, 1_005); 15327 15328 let payload = runtime 15329 .prepare_order_accept(order_id) 15330 .expect("seller order accept payload should prepare from older evidence"); 15331 15332 assert_eq!(payload.trade_order_id, "seller-order-decision-1"); 15333 assert_eq!( 15334 payload.request_event_id, 15335 shared_order_request_event_id(&paths, "seller-order-decision-1") 15336 ); 15337 15338 cleanup_bootstrapped_runtime_paths(&paths); 15339 } 15340 15341 #[test] 15342 fn runtime_rejects_seller_order_decision_with_unusable_request_evidence() { 15343 let relay = ThreadedAckRelay::spawn(); 15344 let (runtime, paths, order_id, _product_id, _seller_pubkey, _buyer_pubkey) = 15345 seller_order_decision_runtime("seller_order_unusable_request_evidence", 6, 2); 15346 configure_runtime_relay_ingest(&runtime, &relay); 15347 mark_shared_seller_order_request_evidence_pending(&paths); 15348 15349 let error = runtime 15350 .prepare_order_accept(order_id) 15351 .expect_err("seller order decision should require usable request evidence"); 15352 15353 assert!(matches!( 15354 error, 15355 AppSqliteError::InvalidProjection { 15356 reason: "seller order decision requires signed order request evidence" 15357 } 15358 )); 15359 15360 cleanup_bootstrapped_runtime_paths(&paths); 15361 } 15362 15363 #[test] 15364 fn runtime_refreshes_configured_relay_before_seller_order_decision_signing() { 15365 let relay = ThreadedAckRelay::spawn(); 15366 let (runtime, paths, order_id, product_id, seller_pubkey, buyer_pubkey) = 15367 seller_order_decision_runtime("seller_order_relay_freshness_pre_sign", 6, 2); 15368 configure_runtime_relay_ingest(&runtime, &relay); 15369 publish_prior_relay_seller_order_accept( 15370 &runtime, 15371 &relay, 15372 order_id, 15373 product_id, 15374 seller_pubkey.as_str(), 15375 buyer_pubkey.as_str(), 15376 ); 15377 15378 let error = runtime 15379 .prepare_order_accept(order_id) 15380 .expect_err("stale seller order decision should fail pre-signing"); 15381 15382 assert!(matches!( 15383 error, 15384 AppSqliteError::InvalidProjection { 15385 reason: "seller order decision requires an undecided order" 15386 } 15387 )); 15388 assert_eq!(persisted_order_status(&runtime, order_id), "scheduled"); 15389 assert_eq!(relay.event_count(), 1); 15390 15391 cleanup_bootstrapped_runtime_paths(&paths); 15392 } 15393 15394 #[test] 15395 fn runtime_rejects_seller_order_decision_when_relay_freshness_fails() { 15396 let (runtime, paths, order_id, _product_id, _seller_pubkey, _buyer_pubkey) = 15397 seller_order_decision_runtime("seller_order_relay_freshness_failure", 6, 2); 15398 runtime.lock_state_mut().nostr_relay_urls = vec!["ws://127.0.0.1:9".to_owned()]; 15399 15400 let error = runtime 15401 .prepare_order_accept(order_id) 15402 .expect_err("seller order decision should require fresh relay state"); 15403 15404 assert!(matches!( 15405 error, 15406 AppSqliteError::InvalidProjection { 15407 reason: "order lifecycle publish requires fresh configured relay state" 15408 } 15409 )); 15410 assert_eq!(persisted_order_status(&runtime, order_id), "needs_action"); 15411 15412 cleanup_bootstrapped_runtime_paths(&paths); 15413 } 15414 15415 #[test] 15416 fn runtime_rejects_seller_order_decision_for_wrong_selected_account() { 15417 let relay = ThreadedAckRelay::spawn(); 15418 let (runtime, paths, order_id, _product_id, _seller_pubkey, _buyer_pubkey) = 15419 seller_order_decision_runtime("seller_order_wrong_account", 6, 2); 15420 configure_runtime_relay_ingest(&runtime, &relay); 15421 assert!( 15422 runtime 15423 .generate_local_account(Some("Other seller".to_owned())) 15424 .expect("other account should generate") 15425 ); 15426 configure_runtime_relay_ingest(&runtime, &relay); 15427 15428 let error = runtime 15429 .prepare_order_accept(order_id) 15430 .expect_err("wrong seller account should fail preflight"); 15431 15432 assert!(matches!(error, AppSqliteError::InvalidProjection { .. })); 15433 15434 cleanup_bootstrapped_runtime_paths(&paths); 15435 } 15436 15437 #[test] 15438 fn runtime_rejects_seller_order_accept_that_would_over_reserve_inventory() { 15439 let relay = ThreadedAckRelay::spawn(); 15440 let (runtime, paths, order_id, _product_id, _seller_pubkey, _buyer_pubkey) = 15441 seller_order_decision_runtime("seller_order_over_reserved", 1, 2); 15442 configure_runtime_relay_ingest(&runtime, &relay); 15443 15444 let error = runtime 15445 .prepare_order_accept(order_id) 15446 .expect_err("over-reserved seller order should fail preflight"); 15447 15448 assert!(matches!(error, AppSqliteError::InvalidProjection { .. })); 15449 15450 cleanup_bootstrapped_runtime_paths(&paths); 15451 } 15452 15453 #[test] 15454 fn runtime_enqueues_seller_order_accept_via_sdk() { 15455 let relay = ThreadedAckRelay::spawn(); 15456 let (runtime, paths, order_id, _product_id, seller_pubkey, _buyer_pubkey) = 15457 seller_order_decision_sdk_runtime("seller_order_accept_publish", 6, 2); 15458 install_direct_relay_sync_transport(&runtime, &relay); 15459 15460 assert!( 15461 runtime 15462 .publish_order_accept(order_id) 15463 .expect("seller order accept should publish") 15464 ); 15465 15466 assert_eq!(persisted_order_status(&runtime, order_id), "needs_action"); 15467 assert_eq!(relay.event_count(), 0); 15468 assert!(!shared_local_event_records(&paths).iter().any(|record| { 15469 record.family == LocalRecordFamily::SignedEvent 15470 && record.event_kind == Some(3423) 15471 && record.event_pubkey.as_deref() == Some(seller_pubkey.as_str()) 15472 })); 15473 assert_order_decision_sdk_migration_receipt( 15474 &runtime, 15475 order_id, 15476 AppSdkMigrationState::Enqueued, 15477 ); 15478 15479 cleanup_bootstrapped_runtime_paths(&paths); 15480 } 15481 15482 #[test] 15483 fn runtime_enqueues_seller_order_decline_via_sdk() { 15484 let relay = ThreadedAckRelay::spawn(); 15485 let (runtime, paths, order_id, _product_id, seller_pubkey, _buyer_pubkey) = 15486 seller_order_decision_sdk_runtime("seller_order_decline_publish", 6, 2); 15487 install_direct_relay_sync_transport(&runtime, &relay); 15488 15489 assert!( 15490 runtime 15491 .publish_order_decline(order_id, "not available") 15492 .expect("seller order decline should publish") 15493 ); 15494 15495 assert_eq!(persisted_order_status(&runtime, order_id), "needs_action"); 15496 assert_eq!(relay.event_count(), 0); 15497 assert!(!shared_local_event_records(&paths).iter().any(|record| { 15498 record.family == LocalRecordFamily::SignedEvent 15499 && record.event_kind == Some(3423) 15500 && record.event_pubkey.as_deref() == Some(seller_pubkey.as_str()) 15501 })); 15502 assert_order_decision_sdk_migration_receipt( 15503 &runtime, 15504 order_id, 15505 AppSdkMigrationState::Enqueued, 15506 ); 15507 15508 cleanup_bootstrapped_runtime_paths(&paths); 15509 } 15510 15511 #[test] 15512 fn runtime_rejects_seller_order_revision_with_reducer_invalid_parent_evidence() { 15513 let relay = ThreadedAckRelay::spawn(); 15514 let (runtime, paths, order_id, product_id, seller_pubkey, buyer_pubkey) = 15515 seller_order_decision_runtime("seller_order_revision_invalid_parent", 6, 2); 15516 install_direct_relay_sync_transport(&runtime, &relay); 15517 let listing_key = super::d_tag_from_uuid(product_id.as_uuid()); 15518 let listing_addr = format!("30402:{seller_pubkey}:{listing_key}"); 15519 let request_event_id = shared_order_request_event_id(&paths, "seller-order-decision-1"); 15520 let request_event_id = request_event_id.as_str(); 15521 append_signed_order_decision_record( 15522 &paths, 15523 "seller-order-decision-1", 15524 request_event_id, 15525 listing_addr.as_str(), 15526 buyer_pubkey.as_str(), 15527 seller_pubkey.as_str(), 15528 2, 15529 ); 15530 append_signed_order_revision_proposal_record_with_prev( 15531 &paths, 15532 "seller-order-decision-1", 15533 "seller-order-decision-1-stale-revision", 15534 request_event_id, 15535 request_event_id, 15536 listing_addr.as_str(), 15537 buyer_pubkey.as_str(), 15538 seller_pubkey.as_str(), 15539 ); 15540 15541 let error = runtime 15542 .publish_order_revision_proposal( 15543 order_id, 15544 revision_test_order_items(), 15545 revision_test_order_economics(), 15546 "harvest count updated", 15547 ) 15548 .expect_err("seller revision proposal should reject reducer-invalid parent evidence"); 15549 15550 assert_order_lifecycle_evidence_invalid(error); 15551 assert_eq!(relay.event_count(), 0); 15552 cleanup_bootstrapped_runtime_paths(&paths); 15553 } 15554 15555 #[test] 15556 fn runtime_places_supported_buyer_order_into_shared_local_events() { 15557 let (runtime, paths) = bootstrapped_runtime("buyer_order_local_event"); 15558 assert!( 15559 runtime 15560 .generate_local_account(Some("Buyer".to_owned())) 15561 .expect("account should generate") 15562 ); 15563 assert!( 15564 runtime 15565 .select_active_surface(ActiveSurface::Personal) 15566 .expect("surface should switch into marketplace") 15567 ); 15568 let buyer_account_id = runtime 15569 .summary() 15570 .settings_account_projection 15571 .selected_account 15572 .as_ref() 15573 .expect("selected account") 15574 .account 15575 .account_id 15576 .clone(); 15577 let listing_key = "DDDDDDDDDDDDDDDDDDDDDD"; 15578 append_cli_signed_buyer_listing_record_with_bin( 15579 &paths, 15580 "buyer-order-supported-listing", 15581 listing_key, 15582 "Buyer Visible Eggs", 15583 1100, 15584 "dozen-eggs", 15585 ); 15586 let product_id = 15587 deterministic_cli_listing_product_id(Some(BUYER_VISIBLE_SELLER_PUBKEY), listing_key); 15588 15589 assert!( 15590 runtime 15591 .open_personal_product_detail(PersonalSection::Browse, product_id) 15592 .expect("buyer detail should import before lookup") 15593 ); 15594 assert!(runtime.increase_personal_product_quantity(PersonalSection::Browse)); 15595 assert!( 15596 runtime 15597 .add_personal_product_to_cart(PersonalSection::Browse, false) 15598 .expect("buyer product should add to cart") 15599 ); 15600 runtime 15601 .lock_state() 15602 .sqlite_store 15603 .as_ref() 15604 .expect("sqlite store") 15605 .connection() 15606 .execute( 15607 "update products set listing_bin_id = 'mutated-bin' where id = ?1", 15608 [product_id.to_string()], 15609 ) 15610 .expect("listing projection should mutate after cart snapshot"); 15611 assert!( 15612 runtime 15613 .save_personal_order_review_draft(BuyerOrderReviewDraft { 15614 name: "Casey Buyer".to_owned(), 15615 email: "casey@example.com".to_owned(), 15616 phone: "555-0101".to_owned(), 15617 order_note: "Leave by the cooler".to_owned(), 15618 }) 15619 .expect("buyer order review draft should save") 15620 ); 15621 assert!( 15622 runtime 15623 .place_personal_order() 15624 .expect("buyer order should place") 15625 ); 15626 let order_id = runtime.summary().personal_projection.orders.list.rows[0].order_id; 15627 assert_no_order_request_pending_sync_payloads( 15628 &runtime, 15629 buyer_account_id.as_str(), 15630 order_id, 15631 ); 15632 assert_order_request_sdk_migration_receipt( 15633 &runtime, 15634 order_id, 15635 AppSdkMigrationState::Enqueued, 15636 ); 15637 15638 { 15639 let state = runtime.lock_state_mut(); 15640 let buyer_context = state.state_store.identity_projection().buyer_context(); 15641 let sqlite_store = state.sqlite_store.as_ref().expect("sqlite store"); 15642 let order_export = state 15643 .sqlite_store 15644 .as_ref() 15645 .expect("sqlite store") 15646 .load_buyer_order_local_event_export(&buyer_context, order_id) 15647 .expect("order export should load") 15648 .expect("order export should exist"); 15649 let coordination = sqlite_store 15650 .load_buyer_order_coordination_record(&buyer_context, order_id) 15651 .expect("order coordination should load") 15652 .expect("order coordination should exist"); 15653 assert_eq!(coordination.state, BuyerOrderCoordinationState::Synced); 15654 assert_eq!( 15655 coordination.record_id.as_deref(), 15656 Some(format!("app:local_work:order_request:{order_id}").as_str()) 15657 ); 15658 assert!(coordination.payload_json.is_some()); 15659 assert_eq!(coordination.attempt_count, 1); 15660 assert_eq!(coordination.last_error_message, None); 15661 assert!( 15662 state 15663 .append_app_buyer_order_request_local_work_record( 15664 sqlite_store, 15665 &buyer_context, 15666 &order_export, 15667 ) 15668 .expect("order local event reappend should be idempotent") 15669 .is_some() 15670 ); 15671 let coordination_after = sqlite_store 15672 .load_buyer_order_coordination_record(&buyer_context, order_id) 15673 .expect("order coordination should reload") 15674 .expect("order coordination should still exist"); 15675 assert_eq!(coordination_after.attempt_count, 1); 15676 } 15677 15678 let records = shared_local_event_records(&paths); 15679 let order_records = records 15680 .iter() 15681 .filter(|record| { 15682 record.source_runtime == SourceRuntime::App 15683 && record 15684 .local_work_json 15685 .as_ref() 15686 .and_then(|payload| payload["record_kind"].as_str()) 15687 == Some(BUYER_ORDER_REQUEST_LOCAL_WORK_RECORD_KIND) 15688 }) 15689 .collect::<Vec<_>>(); 15690 assert_eq!(order_records.len(), 1); 15691 let order_record = order_records[0]; 15692 assert_eq!(order_record.family, LocalRecordFamily::LocalWork); 15693 assert_eq!(order_record.status, LocalRecordStatus::LocalSaved); 15694 assert_eq!(order_record.outbox_status, PublishOutboxStatus::None); 15695 assert_eq!( 15696 order_record.record_id, 15697 format!("app:local_work:order_request:{order_id}") 15698 ); 15699 assert_eq!( 15700 order_record.owner_account_id.as_deref(), 15701 Some(buyer_account_id.as_str()) 15702 ); 15703 assert!(order_record.owner_pubkey.as_deref().is_some_and(is_hex_64)); 15704 assert_eq!( 15705 order_record.listing_addr.as_deref(), 15706 Some(format!("30402:{BUYER_VISIBLE_SELLER_PUBKEY}:{listing_key}").as_str()) 15707 ); 15708 let payload = order_record 15709 .local_work_json 15710 .as_ref() 15711 .expect("order local work payload"); 15712 assert_eq!(payload["support_status"]["state"], "supported"); 15713 assert_eq!(payload["currentness"]["current"], true); 15714 assert_eq!(payload["document"]["kind"], "order_draft_v1"); 15715 assert_eq!( 15716 payload["document"]["order"]["order_id"], 15717 order_id.to_string() 15718 ); 15719 assert_eq!( 15720 payload["document"]["order"]["listing_event_id"], 15721 signed_event_id("cli:signed_event:buyer-order-supported-listing") 15722 ); 15723 assert_eq!( 15724 payload["document"]["order"]["listing_relays"], 15725 json!(["ws://127.0.0.1:1234/"]) 15726 ); 15727 assert_eq!( 15728 payload["document"]["order"]["seller_pubkey"], 15729 BUYER_VISIBLE_SELLER_PUBKEY 15730 ); 15731 assert_eq!( 15732 payload["document"]["order"]["items"][0]["bin_id"], 15733 "dozen-eggs" 15734 ); 15735 assert_eq!(payload["document"]["order"]["items"][0]["bin_count"], 2); 15736 assert_eq!( 15737 payload["document"]["order"]["economics"]["items"][0]["quantity_amount"], 15738 "1" 15739 ); 15740 assert_eq!( 15741 payload["document"]["order"]["economics"]["items"][0]["bin_id"], 15742 "dozen-eggs" 15743 ); 15744 assert_eq!( 15745 payload["document"]["order"]["economics"]["pricing_basis"], 15746 "listing_event" 15747 ); 15748 assert_eq!( 15749 payload["document"]["order"]["economics"]["total"]["amount"], 15750 "16.00" 15751 ); 15752 assert_eq!( 15753 payload["app_order"]["buyer_order_note"], 15754 "Leave by the cooler" 15755 ); 15756 assert_eq!( 15757 payload["app_order"]["lines"][0]["listing_bin_id"], 15758 "dozen-eggs" 15759 ); 15760 15761 cleanup_bootstrapped_runtime_paths(&paths); 15762 } 15763 15764 #[test] 15765 fn runtime_buyer_order_shared_append_failure_is_recoverable_in_same_session() { 15766 let (runtime, paths, buyer_account_id, order_id) = 15767 blocked_buyer_order_runtime("buyer_order_append_failure_same_session"); 15768 { 15769 let state = runtime.lock_state_mut(); 15770 let sqlite_store = state.sqlite_store.as_ref().expect("sqlite store"); 15771 sqlite_store 15772 .connection() 15773 .execute( 15774 "update orders set status = 'scheduled' where id = ?1", 15775 [order_id.to_string()], 15776 ) 15777 .expect("buyer order status should mutate before retry refresh"); 15778 } 15779 unblock_shared_local_events_database(&paths); 15780 assert!( 15781 runtime 15782 .retry_pending_personal_order_coordination() 15783 .expect("same-session buyer order coordination retry should sync") 15784 ); 15785 let summary_after_retry = runtime.summary(); 15786 assert!( 15787 !summary_after_retry 15788 .personal_projection 15789 .orders 15790 .has_recoverable_coordination 15791 ); 15792 assert_no_order_request_pending_sync_payloads( 15793 &runtime, 15794 buyer_account_id.as_str(), 15795 order_id, 15796 ); 15797 assert_order_request_sdk_migration_receipt( 15798 &runtime, 15799 order_id, 15800 AppSdkMigrationState::Enqueued, 15801 ); 15802 assert_eq!( 15803 summary_after_retry 15804 .personal_projection 15805 .orders 15806 .list 15807 .rows 15808 .len(), 15809 1 15810 ); 15811 assert_eq!( 15812 summary_after_retry.personal_projection.orders.list.rows[0].order_id, 15813 order_id 15814 ); 15815 assert_eq!( 15816 summary_after_retry.personal_projection.orders.list.rows[0].status, 15817 BuyerOrderStatus::Scheduled 15818 ); 15819 assert_eq!( 15820 summary_after_retry 15821 .personal_projection 15822 .orders 15823 .detail 15824 .as_ref() 15825 .expect("buyer order detail should refresh after same-session retry") 15826 .status, 15827 BuyerOrderStatus::Scheduled 15828 ); 15829 assert_eq!( 15830 buyer_order_local_work_record_ids(&paths), 15831 vec![format!("app:local_work:order_request:{order_id}")] 15832 ); 15833 { 15834 let state = runtime.lock_state_mut(); 15835 let buyer_context = state.state_store.identity_projection().buyer_context(); 15836 let sqlite_store = state.sqlite_store.as_ref().expect("sqlite store"); 15837 let buyer_orders = sqlite_store 15838 .load_buyer_orders(&buyer_context) 15839 .expect("buyer orders should reload"); 15840 assert_eq!(buyer_orders.rows.len(), 1); 15841 let coordination = sqlite_store 15842 .load_buyer_order_coordination_record(&buyer_context, order_id) 15843 .expect("buyer order coordination should reload") 15844 .expect("buyer order coordination should still exist"); 15845 assert_eq!(coordination.state, BuyerOrderCoordinationState::Synced); 15846 assert_eq!(coordination.attempt_count, 2); 15847 assert_eq!(coordination.last_error_message, None); 15848 } 15849 assert!( 15850 !runtime 15851 .retry_pending_personal_order_coordination() 15852 .expect("same-session synced buyer order coordination retry should be idempotent") 15853 ); 15854 assert_no_order_request_pending_sync_payloads( 15855 &runtime, 15856 buyer_account_id.as_str(), 15857 order_id, 15858 ); 15859 assert_order_request_sdk_migration_receipt( 15860 &runtime, 15861 order_id, 15862 AppSdkMigrationState::Enqueued, 15863 ); 15864 15865 cleanup_bootstrapped_runtime_paths(&paths); 15866 } 15867 15868 #[test] 15869 fn runtime_buyer_order_shared_append_failure_is_recoverable_after_restart() { 15870 let (runtime, paths, buyer_account_id, order_id) = 15871 blocked_buyer_order_runtime("buyer_order_append_failure_restart"); 15872 unblock_shared_local_events_database(&paths); 15873 drop(runtime); 15874 15875 let restarted_runtime = restart_runtime(paths.clone()); 15876 assert_eq!( 15877 buyer_order_local_work_record_ids(&paths), 15878 vec![format!("app:local_work:order_request:{order_id}")] 15879 ); 15880 let summary = restarted_runtime.summary(); 15881 assert!( 15882 !summary 15883 .personal_projection 15884 .orders 15885 .has_recoverable_coordination 15886 ); 15887 assert_eq!(summary.personal_projection.orders.list.rows.len(), 1); 15888 assert_eq!( 15889 summary.personal_projection.orders.list.rows[0].order_id, 15890 order_id 15891 ); 15892 assert_eq!( 15893 summary 15894 .personal_projection 15895 .orders 15896 .detail 15897 .as_ref() 15898 .expect("buyer order detail should reload after restart") 15899 .order_id, 15900 order_id 15901 ); 15902 { 15903 let state = restarted_runtime.lock_state_mut(); 15904 let buyer_context = state.state_store.identity_projection().buyer_context(); 15905 let sqlite_store = state.sqlite_store.as_ref().expect("sqlite store"); 15906 let buyer_orders = sqlite_store 15907 .load_buyer_orders(&buyer_context) 15908 .expect("buyer orders should reload"); 15909 assert_eq!(buyer_orders.rows.len(), 1); 15910 let coordination = sqlite_store 15911 .load_buyer_order_coordination_record(&buyer_context, order_id) 15912 .expect("buyer order coordination should reload") 15913 .expect("buyer order coordination should still exist"); 15914 assert_eq!(coordination.state, BuyerOrderCoordinationState::Synced); 15915 assert_eq!(coordination.attempt_count, 2); 15916 assert_eq!(coordination.last_error_message, None); 15917 } 15918 assert!( 15919 !restarted_runtime 15920 .retry_pending_personal_order_coordination() 15921 .expect("synced buyer order coordination retry should be idempotent") 15922 ); 15923 assert_no_order_request_pending_sync_payloads( 15924 &restarted_runtime, 15925 buyer_account_id.as_str(), 15926 order_id, 15927 ); 15928 assert_order_request_sdk_migration_receipt( 15929 &restarted_runtime, 15930 order_id, 15931 AppSdkMigrationState::Enqueued, 15932 ); 15933 15934 cleanup_bootstrapped_runtime_paths(&paths); 15935 } 15936 15937 #[test] 15938 fn runtime_outbox_recovery_buyer_order_shared_append_failure_is_recoverable_on_foreground_resume() 15939 { 15940 let (runtime, paths, buyer_account_id, order_id) = 15941 blocked_buyer_order_runtime("buyer_order_append_failure_foreground_resume"); 15942 unblock_shared_local_events_database(&paths); 15943 assert!( 15944 runtime 15945 .sync_on_foreground_resume() 15946 .expect("foreground resume should repair buyer order coordination") 15947 ); 15948 let summary = runtime.summary(); 15949 assert!( 15950 !summary 15951 .personal_projection 15952 .orders 15953 .has_recoverable_coordination 15954 ); 15955 assert_eq!( 15956 buyer_order_local_work_record_ids(&paths), 15957 vec![format!("app:local_work:order_request:{order_id}")] 15958 ); 15959 assert_no_order_request_pending_sync_payloads( 15960 &runtime, 15961 buyer_account_id.as_str(), 15962 order_id, 15963 ); 15964 assert_order_request_sdk_migration_receipt( 15965 &runtime, 15966 order_id, 15967 AppSdkMigrationState::Enqueued, 15968 ); 15969 { 15970 let state = runtime.lock_state_mut(); 15971 let buyer_context = state.state_store.identity_projection().buyer_context(); 15972 let sqlite_store = state.sqlite_store.as_ref().expect("sqlite store"); 15973 let coordination = sqlite_store 15974 .load_buyer_order_coordination_record(&buyer_context, order_id) 15975 .expect("buyer order coordination should reload") 15976 .expect("buyer order coordination should still exist"); 15977 assert_eq!(coordination.state, BuyerOrderCoordinationState::Synced); 15978 assert_eq!(coordination.attempt_count, 2); 15979 assert_eq!(coordination.last_error_message, None); 15980 } 15981 assert!( 15982 !runtime 15983 .retry_pending_personal_order_coordination() 15984 .expect("foreground-resumed buyer order retry should be idempotent") 15985 ); 15986 15987 cleanup_bootstrapped_runtime_paths(&paths); 15988 } 15989 15990 #[test] 15991 fn runtime_opens_buyer_order_detail_from_personal_orders() { 15992 let runtime = memory_runtime(); 15993 let (account_id, farm_id) = provision_ready_farmer_account(&runtime); 15994 assert!( 15995 runtime 15996 .select_active_surface(ActiveSurface::Personal) 15997 .expect("surface should switch into marketplace") 15998 ); 15999 let fulfillment_window_id = seed_buyer_marketplace_support( 16000 &runtime, 16001 account_id.as_str(), 16002 farm_id, 16003 "North field farm", 16004 "Friday pickup", 16005 ); 16006 let product_id = seed_product( 16007 &runtime, 16008 farm_id, 16009 "Salad mix", 16010 "Spring blend", 16011 "published", 16012 Some(8), 16013 "2026-04-20T09:00:00Z", 16014 ); 16015 runtime 16016 .lock_state() 16017 .sqlite_store 16018 .as_ref() 16019 .expect("sqlite store") 16020 .connection() 16021 .execute_batch(&format!( 16022 "update products 16023 set availability_window_id = '{fulfillment_window_id}' 16024 where id = '{product_id}'" 16025 )) 16026 .expect("buyer detail product should attach a fulfillment window"); 16027 assert!( 16028 runtime 16029 .open_personal_product_detail(PersonalSection::Browse, product_id) 16030 .expect("buyer detail should open") 16031 ); 16032 assert!( 16033 runtime 16034 .add_personal_product_to_cart(PersonalSection::Browse, false) 16035 .expect("buyer product should add to cart") 16036 ); 16037 assert!( 16038 runtime 16039 .save_personal_order_review_draft(BuyerOrderReviewDraft { 16040 name: "Casey Buyer".to_owned(), 16041 email: "casey@example.com".to_owned(), 16042 phone: String::new(), 16043 order_note: String::new(), 16044 }) 16045 .expect("buyer order review draft should save") 16046 ); 16047 assert!( 16048 runtime 16049 .place_personal_order() 16050 .expect("buyer order should place") 16051 ); 16052 let order_id = runtime.summary().personal_projection.orders.list.rows[0].order_id; 16053 assert!( 16054 runtime 16055 .select_personal_section(PersonalSection::Browse) 16056 .expect("buyer Browse selection should succeed") 16057 ); 16058 assert!(runtime.lock_state_mut().set_personal_order_detail(None)); 16059 16060 assert!( 16061 runtime 16062 .open_personal_order_detail(order_id) 16063 .expect("buyer order detail should open") 16064 ); 16065 16066 let summary = runtime.summary(); 16067 assert_eq!( 16068 summary.shell_projection.selected_section, 16069 ShellSection::Personal(PersonalSection::Orders) 16070 ); 16071 assert_eq!( 16072 summary 16073 .personal_projection 16074 .orders 16075 .detail 16076 .as_ref() 16077 .expect("buyer order detail") 16078 .order_id, 16079 order_id 16080 ); 16081 } 16082 16083 #[test] 16084 fn runtime_opens_linked_buyer_order_detail_from_selected_account_nostr_scope() { 16085 let fixture = linked_buyer_lifecycle_runtime("linked_buyer_order_open"); 16086 let report = fixture 16087 .runtime 16088 .refresh_shared_local_events() 16089 .expect("linked buyer local events should import"); 16090 assert!(report.imported_records > 0); 16091 16092 assert!( 16093 fixture 16094 .runtime 16095 .open_personal_order_detail(fixture.order_id) 16096 .expect("linked buyer order detail should open") 16097 ); 16098 16099 let summary = fixture.runtime.summary(); 16100 let row = summary 16101 .personal_projection 16102 .orders 16103 .list 16104 .rows 16105 .iter() 16106 .find(|row| row.order_id == fixture.order_id) 16107 .expect("linked buyer order row should exist"); 16108 let detail = summary 16109 .personal_projection 16110 .orders 16111 .detail 16112 .as_ref() 16113 .expect("linked buyer order detail should exist"); 16114 assert_eq!(row.status, BuyerOrderStatus::Scheduled); 16115 assert_eq!(detail.order_id, fixture.order_id); 16116 assert_eq!(detail.status, BuyerOrderStatus::Scheduled); 16117 assert_eq!( 16118 detail.workflow.provenance.last_event_id.as_deref(), 16119 Some(fixture.decision_event_id.as_str()) 16120 ); 16121 16122 cleanup_bootstrapped_runtime_paths(&fixture.paths); 16123 } 16124 16125 #[test] 16126 fn runtime_publishes_linked_buyer_cancellation_from_selected_account_nostr_scope() { 16127 let relay = ThreadedAckRelay::spawn(); 16128 let fixture = linked_buyer_request_runtime("linked_buyer_order_cancel"); 16129 install_direct_relay_sync_transport(&fixture.runtime, &relay); 16130 fixture 16131 .runtime 16132 .refresh_shared_local_events() 16133 .expect("linked buyer local events should import"); 16134 assert!( 16135 fixture 16136 .runtime 16137 .open_personal_order_detail(fixture.order_id) 16138 .expect("linked buyer order detail should open") 16139 ); 16140 16141 assert!( 16142 fixture 16143 .runtime 16144 .publish_buyer_order_cancel(fixture.order_id) 16145 .expect("linked buyer cancellation should publish") 16146 ); 16147 16148 assert_eq!( 16149 persisted_order_status(&fixture.runtime, fixture.order_id), 16150 "needs_action" 16151 ); 16152 assert_eq!(relay.event_count(), 0); 16153 let cancellation_events = 16154 shared_order_events_by_kind(&fixture.paths, 3432, fixture.buyer_pubkey.as_str()); 16155 assert!(cancellation_events.is_empty()); 16156 assert_order_cancellation_sdk_migration_receipt( 16157 &fixture.runtime, 16158 fixture.order_id, 16159 AppSdkMigrationState::Enqueued, 16160 ); 16161 16162 cleanup_bootstrapped_runtime_paths(&fixture.paths); 16163 } 16164 16165 #[test] 16166 fn runtime_rejects_linked_buyer_cancellation_from_pending_revision_proposal() { 16167 let relay = ThreadedAckRelay::spawn(); 16168 let fixture = linked_buyer_request_runtime("linked_buyer_order_cancel_revision"); 16169 let proposal_key = "linked-buyer-order-cancel-revision-proposal"; 16170 append_signed_order_revision_proposal_record_with_prev( 16171 &fixture.paths, 16172 fixture.trade_order_id.as_str(), 16173 proposal_key, 16174 fixture.request_event_id.as_str(), 16175 fixture.request_event_id.as_str(), 16176 fixture.listing_addr.as_str(), 16177 fixture.buyer_pubkey.as_str(), 16178 fixture.seller_pubkey.as_str(), 16179 ); 16180 install_direct_relay_sync_transport(&fixture.runtime, &relay); 16181 fixture 16182 .runtime 16183 .refresh_shared_local_events() 16184 .expect("linked buyer revision proposal should import"); 16185 assert!( 16186 fixture 16187 .runtime 16188 .open_personal_order_detail(fixture.order_id) 16189 .expect("linked buyer order detail should open") 16190 ); 16191 16192 let error = fixture 16193 .runtime 16194 .publish_buyer_order_cancel(fixture.order_id) 16195 .expect_err("linked buyer cancellation should reject from pending proposal"); 16196 16197 assert_invalid_projection_reason( 16198 error, 16199 "buyer order cancellation requires no pending seller proposal", 16200 ); 16201 assert_eq!(relay.event_count(), 0); 16202 let cancellation_events = 16203 shared_order_events_by_kind(&fixture.paths, 3432, fixture.buyer_pubkey.as_str()); 16204 assert!(cancellation_events.is_empty()); 16205 16206 cleanup_bootstrapped_runtime_paths(&fixture.paths); 16207 } 16208 16209 #[test] 16210 fn runtime_rejects_linked_buyer_cancellation_after_agreement() { 16211 let relay = ThreadedAckRelay::spawn(); 16212 let fixture = linked_buyer_lifecycle_runtime("linked_buyer_order_cancel_after_agreement"); 16213 install_direct_relay_sync_transport(&fixture.runtime, &relay); 16214 fixture 16215 .runtime 16216 .refresh_shared_local_events() 16217 .expect("linked buyer local events should import"); 16218 assert!( 16219 fixture 16220 .runtime 16221 .open_personal_order_detail(fixture.order_id) 16222 .expect("linked buyer order detail should open") 16223 ); 16224 16225 let error = fixture 16226 .runtime 16227 .publish_buyer_order_cancel(fixture.order_id) 16228 .expect_err("post-agreement buyer cancellation should reject"); 16229 16230 assert_invalid_projection_reason( 16231 error, 16232 "buyer order cancellation requires an open pre-agreement order", 16233 ); 16234 assert_eq!(relay.event_count(), 0); 16235 let cancellation_events = 16236 shared_order_events_by_kind(&fixture.paths, 3432, fixture.buyer_pubkey.as_str()); 16237 assert!(cancellation_events.is_empty()); 16238 16239 cleanup_bootstrapped_runtime_paths(&fixture.paths); 16240 } 16241 16242 #[test] 16243 fn runtime_rejects_linked_buyer_cancellation_with_reducer_invalid_evidence() { 16244 let relay = ThreadedAckRelay::spawn(); 16245 let fixture = linked_buyer_lifecycle_runtime("linked_buyer_order_cancel_invalid"); 16246 install_direct_relay_sync_transport(&fixture.runtime, &relay); 16247 fixture 16248 .runtime 16249 .refresh_shared_local_events() 16250 .expect("linked buyer local events should import"); 16251 assert!( 16252 fixture 16253 .runtime 16254 .open_personal_order_detail(fixture.order_id) 16255 .expect("linked buyer order detail should open") 16256 ); 16257 append_signed_order_cancellation_record_with_prev( 16258 &fixture.paths, 16259 fixture.trade_order_id.as_str(), 16260 "linked-buyer-order-cancel-invalid-a", 16261 fixture.request_event_id.as_str(), 16262 fixture.decision_event_id.as_str(), 16263 fixture.listing_addr.as_str(), 16264 fixture.buyer_pubkey.as_str(), 16265 fixture.seller_pubkey.as_str(), 16266 ); 16267 append_signed_order_cancellation_record_with_prev( 16268 &fixture.paths, 16269 fixture.trade_order_id.as_str(), 16270 "linked-buyer-order-cancel-invalid-b", 16271 fixture.request_event_id.as_str(), 16272 fixture.decision_event_id.as_str(), 16273 fixture.listing_addr.as_str(), 16274 fixture.buyer_pubkey.as_str(), 16275 fixture.seller_pubkey.as_str(), 16276 ); 16277 16278 let error = fixture 16279 .runtime 16280 .publish_buyer_order_cancel(fixture.order_id) 16281 .expect_err("linked buyer cancellation should reject reducer-invalid evidence"); 16282 16283 assert_order_lifecycle_evidence_invalid(error); 16284 assert_eq!(relay.event_count(), 0); 16285 cleanup_bootstrapped_runtime_paths(&fixture.paths); 16286 } 16287 16288 #[test] 16289 fn runtime_publishes_linked_buyer_revision_decision_from_reducer_valid_parent() { 16290 let relay = ThreadedAckRelay::spawn(); 16291 let fixture = linked_buyer_request_runtime("linked_buyer_order_revision"); 16292 let proposal_key = "linked-buyer-order-revision-proposal"; 16293 let _proposal_event_id = append_signed_order_revision_proposal_record_with_prev( 16294 &fixture.paths, 16295 fixture.trade_order_id.as_str(), 16296 proposal_key, 16297 fixture.request_event_id.as_str(), 16298 fixture.request_event_id.as_str(), 16299 fixture.listing_addr.as_str(), 16300 fixture.buyer_pubkey.as_str(), 16301 fixture.seller_pubkey.as_str(), 16302 ); 16303 let revision_id = format!("revision-{proposal_key}"); 16304 install_direct_relay_sync_transport(&fixture.runtime, &relay); 16305 fixture 16306 .runtime 16307 .refresh_shared_local_events() 16308 .expect("linked buyer local events should import"); 16309 assert!( 16310 fixture 16311 .runtime 16312 .open_personal_order_detail(fixture.order_id) 16313 .expect("linked buyer order detail should open") 16314 ); 16315 16316 assert!( 16317 fixture 16318 .runtime 16319 .publish_buyer_order_revision_accept(fixture.order_id) 16320 .expect("linked buyer revision decision should publish") 16321 ); 16322 16323 assert_eq!(relay.event_count(), 0); 16324 let revision_decision_events = 16325 shared_order_events_by_kind(&fixture.paths, 3425, fixture.buyer_pubkey.as_str()); 16326 assert!(revision_decision_events.is_empty()); 16327 assert_order_revision_decision_sdk_migration_receipt( 16328 &fixture.runtime, 16329 fixture.order_id, 16330 revision_id.as_str(), 16331 AppSdkMigrationState::Enqueued, 16332 ); 16333 16334 cleanup_bootstrapped_runtime_paths(&fixture.paths); 16335 } 16336 16337 #[test] 16338 fn runtime_repeat_personal_order_readds_only_currently_eligible_items() { 16339 let runtime = memory_runtime(); 16340 let (account_id, farm_id) = provision_ready_farmer_account(&runtime); 16341 assert!( 16342 runtime 16343 .select_active_surface(ActiveSurface::Personal) 16344 .expect("surface should switch into marketplace") 16345 ); 16346 let fulfillment_window_id = seed_buyer_marketplace_support( 16347 &runtime, 16348 account_id.as_str(), 16349 farm_id, 16350 "North field farm", 16351 "Friday pickup", 16352 ); 16353 let available_product_id = seed_product( 16354 &runtime, 16355 farm_id, 16356 "Salad mix", 16357 "Spring blend", 16358 "published", 16359 Some(8), 16360 "2026-04-20T09:00:00Z", 16361 ); 16362 let unavailable_product_id = seed_product( 16363 &runtime, 16364 farm_id, 16365 "Pea shoots", 16366 "Tray-grown", 16367 "published", 16368 Some(6), 16369 "2026-04-20T10:00:00Z", 16370 ); 16371 runtime 16372 .lock_state() 16373 .sqlite_store 16374 .as_ref() 16375 .expect("sqlite store") 16376 .connection() 16377 .execute_batch(&format!( 16378 "update products 16379 set availability_window_id = '{fulfillment_window_id}' 16380 where id in ('{available_product_id}', '{unavailable_product_id}')" 16381 )) 16382 .expect("buyer detail products should attach a fulfillment window"); 16383 assert!( 16384 runtime 16385 .open_personal_product_detail(PersonalSection::Browse, available_product_id) 16386 .expect("available buyer detail should open") 16387 ); 16388 assert!( 16389 runtime 16390 .add_personal_product_to_cart(PersonalSection::Browse, false) 16391 .expect("available buyer product should add to cart") 16392 ); 16393 assert!( 16394 runtime 16395 .open_personal_product_detail(PersonalSection::Browse, unavailable_product_id) 16396 .expect("unavailable buyer detail should open") 16397 ); 16398 assert!( 16399 runtime 16400 .add_personal_product_to_cart(PersonalSection::Browse, false) 16401 .expect("second buyer product should add to cart") 16402 ); 16403 assert!( 16404 runtime 16405 .save_personal_order_review_draft(BuyerOrderReviewDraft { 16406 name: "Casey Buyer".to_owned(), 16407 email: "casey@example.com".to_owned(), 16408 phone: String::new(), 16409 order_note: String::new(), 16410 }) 16411 .expect("buyer order review draft should save") 16412 ); 16413 assert!( 16414 runtime 16415 .place_personal_order() 16416 .expect("buyer order should place") 16417 ); 16418 let order_id = runtime.summary().personal_projection.orders.list.rows[0].order_id; 16419 16420 runtime 16421 .lock_state() 16422 .sqlite_store 16423 .as_ref() 16424 .expect("sqlite store") 16425 .connection() 16426 .execute( 16427 "update products set status = 'archived' where id = ?1", 16428 [unavailable_product_id.to_string()], 16429 ) 16430 .expect("product should archive"); 16431 16432 assert!( 16433 runtime 16434 .open_personal_order_detail(order_id) 16435 .expect("buyer order detail should reopen") 16436 ); 16437 let detail_summary = runtime.summary(); 16438 let repeat_demand = detail_summary 16439 .personal_projection 16440 .orders 16441 .detail 16442 .as_ref() 16443 .and_then(|detail| detail.repeat_demand.as_ref()) 16444 .expect("repeat demand should derive from buyer order detail"); 16445 assert_eq!(repeat_demand.eligibility.storage_key(), "partial"); 16446 assert_eq!(repeat_demand.available_item_count, 1); 16447 assert_eq!(repeat_demand.unavailable_item_count, 1); 16448 16449 assert!( 16450 runtime 16451 .repeat_personal_order(order_id, false) 16452 .expect("repeat demand should add available items to cart") 16453 ); 16454 16455 let summary = runtime.summary(); 16456 assert_eq!( 16457 summary.shell_projection.selected_section, 16458 ShellSection::Personal(PersonalSection::Cart) 16459 ); 16460 assert_eq!(summary.personal_projection.cart.cart.lines.len(), 1); 16461 assert_eq!( 16462 summary.personal_projection.cart.cart.lines[0].product_id, 16463 available_product_id 16464 ); 16465 assert_eq!(summary.personal_projection.cart.cart.lines[0].quantity, 1); 16466 assert!( 16467 summary 16468 .personal_projection 16469 .cart 16470 .cart 16471 .replace_confirmation 16472 .is_none() 16473 ); 16474 } 16475 16476 #[test] 16477 fn runtime_products_queries_refresh_the_repository_backed_projection() { 16478 let runtime = memory_runtime(); 16479 16480 assert!( 16481 runtime 16482 .generate_local_account(Some("Farmer".to_owned())) 16483 .expect("account should generate") 16484 ); 16485 let account_id = runtime 16486 .summary() 16487 .settings_account_projection 16488 .selected_account 16489 .as_ref() 16490 .expect("selected account") 16491 .account 16492 .account_id 16493 .clone(); 16494 let farm_id = 16495 save_farmer_surface_activation(&runtime, account_id.as_str(), ActiveSurface::Farmer); 16496 let farm_setup_projection = FarmSetupProjection::from_saved_farm(FarmSummary { 16497 farm_id, 16498 display_name: "North field farm".to_owned(), 16499 readiness: FarmReadiness::Ready, 16500 }); 16501 runtime 16502 .lock_state() 16503 .sqlite_store 16504 .as_ref() 16505 .expect("sqlite store") 16506 .save_farm_summary( 16507 farm_setup_projection 16508 .saved_farm 16509 .as_ref() 16510 .expect("saved farm should exist"), 16511 ) 16512 .expect("farm summary should save"); 16513 runtime 16514 .lock_state() 16515 .sqlite_store 16516 .as_ref() 16517 .expect("sqlite store") 16518 .save_farm_setup(account_id.as_str(), &farm_setup_projection) 16519 .expect("farm setup should save"); 16520 seed_product( 16521 &runtime, 16522 farm_id, 16523 "Salad mix", 16524 "Spring blend", 16525 "published", 16526 Some(2), 16527 "2026-04-18T10:00:00Z", 16528 ); 16529 seed_product( 16530 &runtime, 16531 farm_id, 16532 "Pea shoots", 16533 "Tray-grown", 16534 "draft", 16535 None, 16536 "2026-04-18T09:00:00Z", 16537 ); 16538 16539 assert!( 16540 runtime 16541 .select_local_account(account_id.as_str()) 16542 .expect("account should select") 16543 ); 16544 16545 let summary = runtime.summary(); 16546 assert_eq!(summary.products_projection.list.summary.total_products, 2); 16547 assert_eq!(summary.products_projection.list.rows[0].title, "Salad mix"); 16548 assert_eq!( 16549 summary.products_projection.query.filter, 16550 ProductsFilter::default() 16551 ); 16552 assert_eq!( 16553 summary.products_projection.query.sort, 16554 ProductsSort::default() 16555 ); 16556 16557 assert!( 16558 runtime 16559 .select_products_filter(ProductsFilter::NeedAttention) 16560 .expect("filter should apply") 16561 ); 16562 assert_eq!(runtime.summary().products_projection.list.rows.len(), 2); 16563 16564 assert!( 16565 runtime 16566 .set_products_search_query("pea") 16567 .expect("search should apply") 16568 ); 16569 let searched = runtime.summary(); 16570 assert_eq!(searched.products_projection.list.rows.len(), 1); 16571 assert_eq!( 16572 searched.products_projection.list.rows[0].title, 16573 "Pea shoots" 16574 ); 16575 16576 assert!( 16577 runtime 16578 .select_products_sort(ProductsSort::Name) 16579 .expect("sort should apply") 16580 ); 16581 assert_eq!( 16582 runtime.summary().products_projection.query.sort, 16583 ProductsSort::Name 16584 ); 16585 } 16586 16587 #[test] 16588 fn runtime_open_products_filter_routes_today_follow_ons_into_products() { 16589 let runtime = memory_runtime(); 16590 16591 assert!( 16592 runtime 16593 .generate_local_account(Some("Farmer".to_owned())) 16594 .expect("account should generate") 16595 ); 16596 let account_id = runtime 16597 .summary() 16598 .settings_account_projection 16599 .selected_account 16600 .as_ref() 16601 .expect("selected account") 16602 .account 16603 .account_id 16604 .clone(); 16605 let farm_id = 16606 save_farmer_surface_activation(&runtime, account_id.as_str(), ActiveSurface::Farmer); 16607 let farm_setup_projection = FarmSetupProjection::from_saved_farm(FarmSummary { 16608 farm_id, 16609 display_name: "North field farm".to_owned(), 16610 readiness: FarmReadiness::Ready, 16611 }); 16612 runtime 16613 .lock_state() 16614 .sqlite_store 16615 .as_ref() 16616 .expect("sqlite store") 16617 .save_farm_summary( 16618 farm_setup_projection 16619 .saved_farm 16620 .as_ref() 16621 .expect("saved farm should exist"), 16622 ) 16623 .expect("farm summary should save"); 16624 runtime 16625 .lock_state() 16626 .sqlite_store 16627 .as_ref() 16628 .expect("sqlite store") 16629 .save_farm_setup(account_id.as_str(), &farm_setup_projection) 16630 .expect("farm setup should save"); 16631 16632 assert!( 16633 runtime 16634 .select_local_account(account_id.as_str()) 16635 .expect("account should select") 16636 ); 16637 assert_eq!( 16638 runtime.summary().shell_projection.selected_section, 16639 ShellSection::Farmer(FarmerSection::Today) 16640 ); 16641 16642 assert!( 16643 runtime 16644 .open_products_filter(ProductsFilter::Drafts) 16645 .expect("products follow-on should route") 16646 ); 16647 let summary = runtime.summary(); 16648 16649 assert_eq!( 16650 summary.shell_projection.selected_section, 16651 ShellSection::Farmer(FarmerSection::Products) 16652 ); 16653 assert_eq!( 16654 summary.products_projection.query.filter, 16655 ProductsFilter::Drafts 16656 ); 16657 } 16658 16659 #[test] 16660 fn runtime_opens_orders_detail_and_pack_day_through_shared_farmer_routing() { 16661 let runtime = memory_runtime(); 16662 let (_, farm_id) = provision_ready_farmer_account(&runtime); 16663 let (fulfillment_window_id, order_id) = seed_order_workspace(&runtime, farm_id); 16664 16665 assert_eq!( 16666 runtime.summary().orders_projection.query.filter, 16667 OrdersFilter::NeedsAction 16668 ); 16669 16670 assert!(runtime.open_orders().expect("orders should open")); 16671 let orders_summary = runtime.summary(); 16672 assert_eq!( 16673 orders_summary.shell_projection.selected_section, 16674 ShellSection::Farmer(FarmerSection::Orders) 16675 ); 16676 assert_eq!(orders_summary.orders_projection.list.rows.len(), 1); 16677 assert_eq!( 16678 orders_summary.orders_projection.list.rows[0].order_id, 16679 order_id 16680 ); 16681 assert!(orders_summary.orders_projection.detail.is_none()); 16682 16683 assert!( 16684 runtime 16685 .open_order_detail(order_id) 16686 .expect("order detail should open") 16687 ); 16688 let detail_summary = runtime.summary(); 16689 assert_eq!( 16690 detail_summary.shell_projection.selected_section, 16691 ShellSection::Farmer(FarmerSection::Orders) 16692 ); 16693 assert_eq!( 16694 detail_summary 16695 .orders_projection 16696 .detail 16697 .as_ref() 16698 .expect("order detail") 16699 .order_id, 16700 order_id 16701 ); 16702 16703 assert!(runtime.open_pack_day(None).expect("pack day should open")); 16704 let pack_day_summary = runtime.summary(); 16705 assert_eq!( 16706 pack_day_summary.shell_projection.selected_section, 16707 ShellSection::Farmer(FarmerSection::PackDay) 16708 ); 16709 assert_eq!( 16710 pack_day_summary 16711 .pack_day_projection 16712 .query 16713 .fulfillment_window_id, 16714 None 16715 ); 16716 assert_eq!( 16717 pack_day_summary 16718 .pack_day_projection 16719 .projection 16720 .fulfillment_window 16721 .as_ref() 16722 .expect("pack day fulfillment window") 16723 .fulfillment_window_id, 16724 fulfillment_window_id 16725 ); 16726 } 16727 16728 #[test] 16729 fn runtime_export_pack_day_requires_a_current_window_context() { 16730 let (runtime, paths) = bootstrapped_runtime("pack_day_export_requires_context"); 16731 let (_, _farm_id) = provision_ready_farmer_account(&runtime); 16732 16733 assert!( 16734 !runtime 16735 .export_pack_day() 16736 .expect("missing pack day context should no-op") 16737 ); 16738 assert_eq!( 16739 runtime.summary().pack_day_projection.export.status, 16740 PackDayExportStatus::Idle 16741 ); 16742 16743 cleanup_bootstrapped_runtime_paths(&paths); 16744 } 16745 16746 #[test] 16747 fn runtime_export_pack_day_uses_repository_source_truth_and_writes_bundle() { 16748 let (runtime, paths) = bootstrapped_runtime("pack_day_export_bundle"); 16749 let (_, farm_id) = provision_ready_farmer_account(&runtime); 16750 let (fulfillment_window_id, order_id) = seed_order_workspace(&runtime, farm_id); 16751 16752 assert!(runtime.open_pack_day(None).expect("pack day should open")); 16753 let fulfillment_window = runtime 16754 .summary() 16755 .pack_day_projection 16756 .projection 16757 .fulfillment_window 16758 .clone() 16759 .expect("pack day fulfillment window"); 16760 let _ = runtime.lock_state_mut().state_store.apply_in_memory( 16761 AppStateCommand::replace_pack_day_projection(PackDayProjection { 16762 fulfillment_window: Some(fulfillment_window.clone()), 16763 reminders: ReminderFeedProjection::default(), 16764 totals_by_product: vec![PackDayProductTotalRow { 16765 title: "Bogus totals".to_owned(), 16766 quantity_display: "999 crates".to_owned(), 16767 }], 16768 pack_list: vec![PackDayPackListRow { 16769 title: "Bogus pack list".to_owned(), 16770 quantity_display: "Do not trust screen strings".to_owned(), 16771 }], 16772 pickup_roster: vec![PackDayRosterRow { 16773 order_id: OrderId::new(), 16774 order_number: "R-999".to_owned(), 16775 customer_display_name: "Bogus".to_owned(), 16776 }], 16777 }), 16778 ); 16779 16780 assert!( 16781 runtime 16782 .export_pack_day() 16783 .expect("pack day export should succeed") 16784 ); 16785 16786 let summary = runtime.summary(); 16787 let export = &summary.pack_day_projection.export; 16788 assert_eq!(export.status, PackDayExportStatus::Succeeded); 16789 assert_eq!( 16790 export 16791 .request 16792 .as_ref() 16793 .expect("export request") 16794 .fulfillment_window_id, 16795 fulfillment_window_id 16796 ); 16797 let bundle = export.bundle.as_ref().expect("export bundle"); 16798 assert_eq!(bundle.fulfillment_window_id, fulfillment_window_id); 16799 assert_eq!(bundle.artifact_count(), 3); 16800 16801 let pack_sheet_path = PathBuf::from(&bundle.bundle_directory).join("pack_sheet.txt"); 16802 let pickup_roster_path = PathBuf::from(&bundle.bundle_directory).join("pickup_roster.txt"); 16803 let customer_labels_path = 16804 PathBuf::from(&bundle.bundle_directory).join("customer_labels.txt"); 16805 16806 let pack_sheet = fs::read_to_string(&pack_sheet_path).expect("pack sheet should exist"); 16807 let pickup_roster = 16808 fs::read_to_string(&pickup_roster_path).expect("pickup roster should exist"); 16809 let customer_labels = 16810 fs::read_to_string(&customer_labels_path).expect("customer labels should exist"); 16811 16812 assert!(pack_sheet.contains("Farm: North field farm")); 16813 assert!(pack_sheet.contains("Casey | R-100 | needs_action | Salad mix | 2 bags")); 16814 assert!(!pack_sheet.contains("Bogus")); 16815 assert!(pickup_roster.contains("Casey | R-100 | needs_action")); 16816 assert!(customer_labels.contains("North field farm")); 16817 assert!(customer_labels.contains("Casey")); 16818 assert!(customer_labels.contains("Order: R-100")); 16819 assert!(!customer_labels.contains("Bogus")); 16820 assert!(!pickup_roster.contains(&order_id.to_string())); 16821 16822 cleanup_bootstrapped_runtime_paths(&paths); 16823 } 16824 16825 #[test] 16826 fn runtime_bootstrap_sweeps_prepared_pack_day_print_assets() { 16827 let paths = temp_desktop_runtime_paths("pack_day_print_bootstrap_sweep"); 16828 let stale_root = prepared_customer_label_asset_root(); 16829 let stale_directory = stale_root.join(PackDayExportInstanceId::new().to_string()); 16830 let _ = fs::remove_file(&stale_root); 16831 let _ = fs::remove_dir_all(&stale_root); 16832 fs::create_dir_all(&stale_directory).expect("stale prepared directory should create"); 16833 fs::write(stale_directory.join("stale.ps"), "stale").expect("stale asset should write"); 16834 16835 let _ = restart_runtime(paths.clone()); 16836 16837 assert!(!stale_root.exists()); 16838 16839 cleanup_bootstrapped_runtime_paths(&paths); 16840 } 16841 16842 #[test] 16843 fn runtime_bootstrap_keeps_running_when_prepared_pack_day_print_root_sweep_fails() { 16844 let paths = temp_desktop_runtime_paths("pack_day_print_bootstrap_best_effort"); 16845 let stale_root = prepared_customer_label_asset_root(); 16846 let _ = fs::remove_file(&stale_root); 16847 let _ = fs::remove_dir_all(&stale_root); 16848 if let Some(parent) = stale_root.parent() { 16849 fs::create_dir_all(parent).expect("prepared asset root parent should create"); 16850 } 16851 fs::write(&stale_root, "blocked").expect("prepared asset root blocker should write"); 16852 16853 let _ = restart_runtime(paths.clone()); 16854 16855 assert!(stale_root.is_file()); 16856 16857 let _ = fs::remove_file(&stale_root); 16858 cleanup_bootstrapped_runtime_paths(&paths); 16859 } 16860 16861 #[test] 16862 fn runtime_prepare_pack_day_host_handoff_uses_the_current_export_bundle_for_file_actions() { 16863 let (runtime, paths) = bootstrapped_runtime("pack_day_host_handoff_prepare"); 16864 let (_, farm_id) = provision_ready_farmer_account(&runtime); 16865 16866 seed_order_workspace(&runtime, farm_id); 16867 assert!(runtime.open_pack_day(None).expect("pack day should open")); 16868 assert!( 16869 runtime 16870 .export_pack_day() 16871 .expect("pack day export should succeed") 16872 ); 16873 16874 for (kind, suffix) in [ 16875 (PackDayHostHandoffKind::OpenPackSheet, "pack_sheet.txt"), 16876 ( 16877 PackDayHostHandoffKind::OpenPickupRoster, 16878 "pickup_roster.txt", 16879 ), 16880 ( 16881 PackDayHostHandoffKind::OpenCustomerLabels, 16882 "customer_labels.txt", 16883 ), 16884 ] { 16885 let prepared = runtime 16886 .prepare_pack_day_host_handoff(kind) 16887 .expect("host handoff should prepare") 16888 .expect("host handoff should produce a plan"); 16889 16890 let summary = runtime.summary(); 16891 assert_eq!( 16892 summary.pack_day_projection.host_handoff.status, 16893 PackDayHostHandoffStatus::Running 16894 ); 16895 assert_eq!( 16896 summary.pack_day_projection.host_handoff.request, 16897 Some(prepared.0.clone()) 16898 ); 16899 assert_eq!(prepared.0.kind, kind); 16900 assert_eq!( 16901 prepared.0.bundle_directory, 16902 summary 16903 .pack_day_projection 16904 .export 16905 .bundle 16906 .as_ref() 16907 .expect("pack day export bundle") 16908 .bundle_directory 16909 ); 16910 assert_eq!(prepared.1.kind, kind); 16911 assert!(prepared.1.target_path.ends_with(suffix)); 16912 16913 assert!( 16914 runtime 16915 .finish_pack_day_host_handoff(prepared.0, Ok(())) 16916 .expect("host handoff success should apply") 16917 ); 16918 } 16919 16920 cleanup_bootstrapped_runtime_paths(&paths); 16921 } 16922 16923 #[test] 16924 fn runtime_finish_pack_day_host_handoff_records_failures_in_state() { 16925 let (runtime, paths) = bootstrapped_runtime("pack_day_host_handoff_failure"); 16926 let (_, farm_id) = provision_ready_farmer_account(&runtime); 16927 16928 seed_order_workspace(&runtime, farm_id); 16929 assert!(runtime.open_pack_day(None).expect("pack day should open")); 16930 assert!( 16931 runtime 16932 .export_pack_day() 16933 .expect("pack day export should succeed") 16934 ); 16935 16936 let (request, _) = runtime 16937 .prepare_pack_day_host_handoff(PackDayHostHandoffKind::RevealBundle) 16938 .expect("host handoff should prepare") 16939 .expect("host handoff should produce a plan"); 16940 16941 let error = runtime 16942 .finish_pack_day_host_handoff( 16943 request.clone(), 16944 Err(PackDayHostHandoffError::UnsupportedPlatform), 16945 ) 16946 .expect_err("host handoff failure should surface"); 16947 assert!(matches!( 16948 error, 16949 DesktopAppRuntimeCommandError::PackDayHostHandoff( 16950 PackDayHostHandoffError::UnsupportedPlatform 16951 ) 16952 )); 16953 16954 let summary = runtime.summary(); 16955 assert_eq!( 16956 summary.pack_day_projection.host_handoff.status, 16957 PackDayHostHandoffStatus::Failed 16958 ); 16959 assert_eq!( 16960 summary.pack_day_projection.host_handoff.request, 16961 Some(request) 16962 ); 16963 assert_eq!( 16964 summary 16965 .pack_day_projection 16966 .host_handoff 16967 .error_message 16968 .as_deref(), 16969 Some("pack day host handoff is only supported on macos") 16970 ); 16971 16972 cleanup_bootstrapped_runtime_paths(&paths); 16973 } 16974 16975 #[test] 16976 fn runtime_finish_pack_day_host_handoff_ignores_stale_background_completion() { 16977 let (runtime, paths) = bootstrapped_runtime("pack_day_host_handoff_stale"); 16978 let (_, farm_id) = provision_ready_farmer_account(&runtime); 16979 16980 seed_order_workspace(&runtime, farm_id); 16981 assert!(runtime.open_pack_day(None).expect("pack day should open")); 16982 assert!( 16983 runtime 16984 .export_pack_day() 16985 .expect("pack day export should succeed") 16986 ); 16987 16988 let (request, _) = runtime 16989 .prepare_pack_day_host_handoff(PackDayHostHandoffKind::RevealBundle) 16990 .expect("host handoff should prepare") 16991 .expect("host handoff should produce a plan"); 16992 16993 let _ = runtime 16994 .lock_state_mut() 16995 .state_store 16996 .apply_in_memory(AppStateCommand::reset_pack_day_host_handoff()); 16997 16998 assert!( 16999 !runtime 17000 .finish_pack_day_host_handoff(request, Ok(())) 17001 .expect("stale completion should no-op") 17002 ); 17003 assert_eq!( 17004 runtime.summary().pack_day_projection.host_handoff.status, 17005 PackDayHostHandoffStatus::Idle 17006 ); 17007 17008 cleanup_bootstrapped_runtime_paths(&paths); 17009 } 17010 17011 #[test] 17012 fn runtime_prepare_pack_day_print_uses_the_current_export_bundle_for_all_v1_documents() { 17013 let (runtime, paths) = bootstrapped_runtime("pack_day_print_prepare"); 17014 let (_, farm_id) = provision_ready_farmer_account(&runtime); 17015 17016 seed_order_workspace(&runtime, farm_id); 17017 assert!(runtime.open_pack_day(None).expect("pack day should open")); 17018 assert!( 17019 runtime 17020 .export_pack_day() 17021 .expect("pack day export should succeed") 17022 ); 17023 17024 for (kind, expected_exported_suffix) in [ 17025 (PackDayPrintKind::PrintPackSheet, Some("pack_sheet.txt")), 17026 ( 17027 PackDayPrintKind::PrintPickupRoster, 17028 Some("pickup_roster.txt"), 17029 ), 17030 (PackDayPrintKind::PrintCustomerLabels, None), 17031 ] { 17032 let prepared = runtime 17033 .prepare_pack_day_print(kind) 17034 .expect("print should prepare") 17035 .expect("print should produce a plan"); 17036 17037 let summary = runtime.summary(); 17038 assert_eq!( 17039 summary.pack_day_projection.print.status, 17040 PackDayPrintStatus::Running 17041 ); 17042 assert_eq!( 17043 summary.pack_day_projection.print.request, 17044 Some(prepared.0.clone()) 17045 ); 17046 assert_eq!(prepared.0.kind, kind); 17047 assert_eq!( 17048 prepared.0.export_instance_id, 17049 summary 17050 .pack_day_projection 17051 .export 17052 .bundle 17053 .as_ref() 17054 .expect("pack day export bundle") 17055 .export_instance_id 17056 ); 17057 assert_eq!(prepared.0.label_stock, kind.label_stock()); 17058 assert_eq!(prepared.1.kind, kind); 17059 assert_eq!(prepared.1.command_program, "lp"); 17060 match expected_exported_suffix { 17061 Some(suffix) => { 17062 assert!(prepared.1.target_path.ends_with(suffix)); 17063 assert_eq!( 17064 prepared.1.command_args, 17065 vec![prepared.1.target_path.to_string_lossy().into_owned()] 17066 ); 17067 } 17068 None => { 17069 let export_bundle = summary 17070 .pack_day_projection 17071 .export 17072 .bundle 17073 .as_ref() 17074 .expect("pack day export bundle"); 17075 assert!( 17076 prepared 17077 .1 17078 .target_path 17079 .ends_with("customer_labels_avery_5160_letter_30_up.ps") 17080 ); 17081 assert!( 17082 !prepared 17083 .1 17084 .target_path 17085 .starts_with(PathBuf::from(&export_bundle.bundle_directory)) 17086 ); 17087 assert!( 17088 prepared 17089 .1 17090 .target_path 17091 .to_string_lossy() 17092 .contains(export_bundle.export_instance_id.to_string().as_str()) 17093 ); 17094 assert_eq!( 17095 prepared.1.command_args, 17096 vec![ 17097 "-o".to_owned(), 17098 "media=Letter".to_owned(), 17099 prepared.1.target_path.to_string_lossy().into_owned() 17100 ] 17101 ); 17102 } 17103 } 17104 17105 assert!( 17106 runtime 17107 .finish_pack_day_print(prepared.0, Ok(())) 17108 .expect("print success should apply") 17109 ); 17110 17111 if let PackDayPrintKind::PrintCustomerLabels = kind { 17112 if let Some(parent) = prepared.1.target_path.parent() { 17113 let _ = fs::remove_dir_all(parent); 17114 } 17115 } 17116 } 17117 17118 cleanup_bootstrapped_runtime_paths(&paths); 17119 } 17120 17121 #[test] 17122 fn runtime_prepare_pack_day_batch_print_uses_the_current_export_bundle_for_all_v1_documents() { 17123 let (runtime, paths) = bootstrapped_runtime("pack_day_batch_print_prepare"); 17124 let (_, farm_id) = provision_ready_farmer_account(&runtime); 17125 17126 seed_order_workspace(&runtime, farm_id); 17127 assert!(runtime.open_pack_day(None).expect("pack day should open")); 17128 assert!( 17129 runtime 17130 .export_pack_day() 17131 .expect("pack day export should succeed") 17132 ); 17133 17134 let (request, plan) = runtime 17135 .prepare_pack_day_batch_print() 17136 .expect("batch print should prepare") 17137 .expect("batch print should produce a plan"); 17138 17139 let summary = runtime.summary(); 17140 let bundle = summary 17141 .pack_day_projection 17142 .export 17143 .bundle 17144 .as_ref() 17145 .expect("pack day export bundle"); 17146 assert_eq!( 17147 summary.pack_day_projection.batch_print.status, 17148 PackDayBatchPrintStatus::Running 17149 ); 17150 assert_eq!( 17151 summary.pack_day_projection.batch_print.request, 17152 Some(request.clone()) 17153 ); 17154 assert_eq!(request.export_instance_id, bundle.export_instance_id); 17155 assert_eq!( 17156 request.artifacts, 17157 Vec::from(PackDayBatchPrintArtifact::all_v1()) 17158 ); 17159 assert_eq!(plan.export_instance_id, bundle.export_instance_id); 17160 assert_eq!( 17161 plan.plans 17162 .iter() 17163 .map(|plan| PackDayBatchPrintArtifact::from_print_kind(plan.kind)) 17164 .collect::<Vec<_>>(), 17165 request.artifacts.clone() 17166 ); 17167 assert!(plan.plans.iter().all(|plan| plan.command_program == "lp")); 17168 17169 assert!( 17170 runtime 17171 .finish_pack_day_batch_print(request, Ok(())) 17172 .expect("batch print success should apply") 17173 ); 17174 assert_eq!( 17175 runtime.summary().pack_day_projection.batch_print.status, 17176 PackDayBatchPrintStatus::Succeeded 17177 ); 17178 17179 cleanup_bootstrapped_runtime_paths(&paths); 17180 } 17181 17182 #[test] 17183 fn runtime_pack_day_batch_print_blocks_conflicting_pack_day_actions() { 17184 let (runtime, paths) = bootstrapped_runtime("pack_day_batch_print_conflicts"); 17185 let (_, farm_id) = provision_ready_farmer_account(&runtime); 17186 17187 seed_order_workspace(&runtime, farm_id); 17188 assert!(runtime.open_pack_day(None).expect("pack day should open")); 17189 assert!( 17190 runtime 17191 .export_pack_day() 17192 .expect("pack day export should succeed") 17193 ); 17194 17195 let (_, _) = runtime 17196 .prepare_pack_day_batch_print() 17197 .expect("batch print should prepare") 17198 .expect("batch print should produce a plan"); 17199 assert!( 17200 runtime 17201 .prepare_pack_day_print(PackDayPrintKind::PrintPackSheet) 17202 .expect("print prepare should not fail") 17203 .is_none() 17204 ); 17205 assert!( 17206 runtime 17207 .prepare_pack_day_host_handoff(PackDayHostHandoffKind::RevealBundle) 17208 .expect("host handoff prepare should not fail") 17209 .is_none() 17210 ); 17211 17212 let _ = runtime 17213 .lock_state_mut() 17214 .state_store 17215 .apply_in_memory(AppStateCommand::reset_pack_day_batch_print()); 17216 17217 let (print_request, _) = runtime 17218 .prepare_pack_day_print(PackDayPrintKind::PrintPackSheet) 17219 .expect("print should prepare") 17220 .expect("print should produce a plan"); 17221 assert!( 17222 runtime 17223 .prepare_pack_day_batch_print() 17224 .expect("batch print prepare should not fail") 17225 .is_none() 17226 ); 17227 assert!( 17228 runtime 17229 .finish_pack_day_print(print_request, Ok(())) 17230 .expect("print success should apply") 17231 ); 17232 let _ = runtime 17233 .lock_state_mut() 17234 .state_store 17235 .apply_in_memory(AppStateCommand::reset_pack_day_print()); 17236 17237 let (_, _) = runtime 17238 .prepare_pack_day_host_handoff(PackDayHostHandoffKind::RevealBundle) 17239 .expect("host handoff should prepare") 17240 .expect("host handoff should produce a plan"); 17241 assert!( 17242 runtime 17243 .prepare_pack_day_batch_print() 17244 .expect("batch print prepare should not fail") 17245 .is_none() 17246 ); 17247 17248 cleanup_bootstrapped_runtime_paths(&paths); 17249 } 17250 17251 #[test] 17252 fn runtime_finish_pack_day_batch_print_records_failures_and_cleans_prepared_assets() { 17253 let (runtime, paths) = bootstrapped_runtime("pack_day_batch_print_failure_cleanup"); 17254 let (_, farm_id) = provision_ready_farmer_account(&runtime); 17255 17256 seed_order_workspace(&runtime, farm_id); 17257 assert!(runtime.open_pack_day(None).expect("pack day should open")); 17258 assert!( 17259 runtime 17260 .export_pack_day() 17261 .expect("pack day export should succeed") 17262 ); 17263 17264 let (request, plan) = runtime 17265 .prepare_pack_day_batch_print() 17266 .expect("batch print should prepare") 17267 .expect("batch print should produce a plan"); 17268 let prepared_directory = plan 17269 .plans 17270 .iter() 17271 .find(|plan| plan.kind == PackDayPrintKind::PrintCustomerLabels) 17272 .and_then(|plan| plan.target_path.parent()) 17273 .expect("prepared customer labels parent") 17274 .to_path_buf(); 17275 assert!(prepared_directory.is_dir()); 17276 17277 let failed_artifact = 17278 PackDayBatchPrintArtifact::from_print_kind(PackDayPrintKind::PrintPickupRoster); 17279 let error = runtime 17280 .finish_pack_day_batch_print( 17281 request.clone(), 17282 Err(PackDayBatchPrintError::QueueExit { 17283 submitted_artifacts: vec![PackDayBatchPrintArtifact::from_print_kind( 17284 PackDayPrintKind::PrintPackSheet, 17285 )], 17286 failed_artifact, 17287 source: PackDayPrintError::UnsupportedPlatform, 17288 }), 17289 ) 17290 .expect_err("batch print failure should surface"); 17291 assert!(matches!( 17292 error, 17293 DesktopAppRuntimeCommandError::PackDayBatchPrint( 17294 PackDayBatchPrintError::QueueExit { .. } 17295 ) 17296 )); 17297 assert!(!prepared_directory.exists()); 17298 17299 let summary = runtime.summary(); 17300 let batch_print = &summary.pack_day_projection.batch_print; 17301 assert_eq!(batch_print.status, PackDayBatchPrintStatus::Failed); 17302 assert_eq!(batch_print.request, Some(request)); 17303 assert_eq!(batch_print.failed_artifact, Some(failed_artifact)); 17304 assert_eq!( 17305 batch_print.failure, 17306 Some(PackDayBatchPrintFailureKind::QueueExit) 17307 ); 17308 17309 cleanup_bootstrapped_runtime_paths(&paths); 17310 } 17311 17312 #[test] 17313 fn runtime_finish_pack_day_batch_print_ignores_stale_background_completion() { 17314 let (runtime, paths) = bootstrapped_runtime("pack_day_batch_print_stale"); 17315 let (_, farm_id) = provision_ready_farmer_account(&runtime); 17316 17317 seed_order_workspace(&runtime, farm_id); 17318 assert!(runtime.open_pack_day(None).expect("pack day should open")); 17319 assert!( 17320 runtime 17321 .export_pack_day() 17322 .expect("pack day export should succeed") 17323 ); 17324 17325 let (request, _) = runtime 17326 .prepare_pack_day_batch_print() 17327 .expect("batch print should prepare") 17328 .expect("batch print should produce a plan"); 17329 17330 let _ = runtime 17331 .lock_state_mut() 17332 .state_store 17333 .apply_in_memory(AppStateCommand::reset_pack_day_batch_print()); 17334 17335 assert!( 17336 !runtime 17337 .finish_pack_day_batch_print(request, Ok(())) 17338 .expect("stale completion should no-op") 17339 ); 17340 assert_eq!( 17341 runtime.summary().pack_day_projection.batch_print.status, 17342 PackDayBatchPrintStatus::Idle 17343 ); 17344 17345 cleanup_bootstrapped_runtime_paths(&paths); 17346 } 17347 17348 #[test] 17349 fn pack_day_batch_workflow_success_submits_frozen_v1_and_records_success() { 17350 let (runtime, paths) = bootstrapped_runtime("pack_day_batch_workflow_success"); 17351 let (_, farm_id) = provision_ready_farmer_account(&runtime); 17352 17353 seed_order_workspace(&runtime, farm_id); 17354 assert!(runtime.open_pack_day(None).expect("pack day should open")); 17355 assert!( 17356 runtime 17357 .export_pack_day() 17358 .expect("pack day export should succeed") 17359 ); 17360 17361 let (request, plan) = runtime 17362 .prepare_pack_day_batch_print() 17363 .expect("batch print should prepare") 17364 .expect("batch print should produce a plan"); 17365 let mut submitted = Vec::new(); 17366 17367 execute_pack_day_batch_print_plan_with(&plan, |print_plan| { 17368 submitted.push(PackDayBatchPrintArtifact::from_print_kind(print_plan.kind)); 17369 Ok(PackDayPrintCommandResult::succeeded()) 17370 }) 17371 .expect("batch print execution should succeed"); 17372 17373 assert_eq!(submitted, Vec::from(PackDayBatchPrintArtifact::all_v1())); 17374 assert!( 17375 runtime 17376 .finish_pack_day_batch_print(request.clone(), Ok(())) 17377 .expect("batch print success should apply") 17378 ); 17379 17380 let summary = runtime.summary(); 17381 let batch_print = &summary.pack_day_projection.batch_print; 17382 assert_eq!(batch_print.status, PackDayBatchPrintStatus::Succeeded); 17383 assert_eq!(batch_print.request, Some(request)); 17384 assert_eq!(batch_print.failed_artifact, None); 17385 assert_eq!(batch_print.failure, None); 17386 17387 cleanup_bootstrapped_runtime_paths(&paths); 17388 } 17389 17390 #[test] 17391 fn pack_day_batch_workflow_queue_failure_records_failed_artifact_state() { 17392 let (runtime, paths) = bootstrapped_runtime("pack_day_batch_workflow_queue_failure"); 17393 let (_, farm_id) = provision_ready_farmer_account(&runtime); 17394 17395 seed_order_workspace(&runtime, farm_id); 17396 assert!(runtime.open_pack_day(None).expect("pack day should open")); 17397 assert!( 17398 runtime 17399 .export_pack_day() 17400 .expect("pack day export should succeed") 17401 ); 17402 17403 let (request, plan) = runtime 17404 .prepare_pack_day_batch_print() 17405 .expect("batch print should prepare") 17406 .expect("batch print should produce a plan"); 17407 let mut submitted = Vec::new(); 17408 17409 let execution_error = execute_pack_day_batch_print_plan_with(&plan, |print_plan| { 17410 submitted.push(PackDayBatchPrintArtifact::from_print_kind(print_plan.kind)); 17411 match print_plan.kind { 17412 PackDayPrintKind::PrintPackSheet => Ok(PackDayPrintCommandResult::succeeded()), 17413 PackDayPrintKind::PrintPickupRoster => Ok(PackDayPrintCommandResult::failed( 17414 Some(2), 17415 "lp stopped before submit", 17416 )), 17417 PackDayPrintKind::PrintCustomerLabels => { 17418 panic!("batch should stop before customer labels") 17419 } 17420 } 17421 }) 17422 .expect_err("batch print execution should fail"); 17423 17424 assert_eq!( 17425 submitted, 17426 vec![ 17427 PackDayBatchPrintArtifact::from_print_kind(PackDayPrintKind::PrintPackSheet), 17428 PackDayBatchPrintArtifact::from_print_kind(PackDayPrintKind::PrintPickupRoster), 17429 ] 17430 ); 17431 let failed_artifact = 17432 PackDayBatchPrintArtifact::from_print_kind(PackDayPrintKind::PrintPickupRoster); 17433 let runtime_error = runtime 17434 .finish_pack_day_batch_print(request.clone(), Err(execution_error)) 17435 .expect_err("batch print failure should surface"); 17436 assert!(matches!( 17437 runtime_error, 17438 DesktopAppRuntimeCommandError::PackDayBatchPrint( 17439 PackDayBatchPrintError::QueueExit { .. } 17440 ) 17441 )); 17442 17443 let summary = runtime.summary(); 17444 let batch_print = &summary.pack_day_projection.batch_print; 17445 assert_eq!(batch_print.status, PackDayBatchPrintStatus::Failed); 17446 assert_eq!(batch_print.request, Some(request)); 17447 assert_eq!(batch_print.failed_artifact, Some(failed_artifact)); 17448 assert_eq!( 17449 batch_print.failure, 17450 Some(PackDayBatchPrintFailureKind::QueueExit) 17451 ); 17452 17453 cleanup_bootstrapped_runtime_paths(&paths); 17454 } 17455 17456 #[test] 17457 fn runtime_finish_pack_day_print_records_failures_in_state() { 17458 let (runtime, paths) = bootstrapped_runtime("pack_day_print_failure"); 17459 let (_, farm_id) = provision_ready_farmer_account(&runtime); 17460 17461 seed_order_workspace(&runtime, farm_id); 17462 assert!(runtime.open_pack_day(None).expect("pack day should open")); 17463 assert!( 17464 runtime 17465 .export_pack_day() 17466 .expect("pack day export should succeed") 17467 ); 17468 17469 let (request, _) = runtime 17470 .prepare_pack_day_print(PackDayPrintKind::PrintPackSheet) 17471 .expect("print should prepare") 17472 .expect("print should produce a plan"); 17473 17474 let error = runtime 17475 .finish_pack_day_print(request.clone(), Err(PackDayPrintError::UnsupportedPlatform)) 17476 .expect_err("print failure should surface"); 17477 assert!(matches!( 17478 error, 17479 DesktopAppRuntimeCommandError::PackDayPrint(PackDayPrintError::UnsupportedPlatform) 17480 )); 17481 17482 let summary = runtime.summary(); 17483 assert_eq!( 17484 summary.pack_day_projection.print.status, 17485 PackDayPrintStatus::Failed 17486 ); 17487 assert_eq!(summary.pack_day_projection.print.request, Some(request)); 17488 assert_eq!(summary.pack_day_projection.print.failure, None); 17489 17490 cleanup_bootstrapped_runtime_paths(&paths); 17491 } 17492 17493 #[test] 17494 fn runtime_prepare_pack_day_print_surfaces_customer_label_overflow_as_a_typed_failure() { 17495 let (runtime, paths) = bootstrapped_runtime("pack_day_print_overflow_failure"); 17496 let (_, farm_id) = provision_ready_farmer_account(&runtime); 17497 17498 seed_order_workspace(&runtime, farm_id); 17499 assert!(runtime.open_pack_day(None).expect("pack day should open")); 17500 assert!( 17501 runtime 17502 .export_pack_day() 17503 .expect("pack day export should succeed") 17504 ); 17505 17506 let bundle = runtime 17507 .summary() 17508 .pack_day_projection 17509 .export 17510 .bundle 17511 .clone() 17512 .expect("pack day export bundle"); 17513 let customer_labels_path = 17514 PathBuf::from(&bundle.bundle_directory).join("customer_labels.txt"); 17515 fs::write( 17516 &customer_labels_path, 17517 "Willow farm\nCasey\nOrder R-1001\nPickup barn\nThursday\nKeep cold\nOverflow note\n", 17518 ) 17519 .expect("overflowing customer labels should write"); 17520 17521 let error = runtime 17522 .prepare_pack_day_print(PackDayPrintKind::PrintCustomerLabels) 17523 .expect_err("overflowing customer labels should fail"); 17524 assert!(matches!( 17525 error, 17526 DesktopAppRuntimeCommandError::PackDayPrint( 17527 PackDayPrintError::CustomerLabelsAvery5160Overflow 17528 ) 17529 )); 17530 17531 let summary = runtime.summary(); 17532 let print = &summary.pack_day_projection.print; 17533 assert_eq!(print.status, PackDayPrintStatus::Failed); 17534 assert_eq!( 17535 print.request.as_ref().map(|request| request.kind), 17536 Some(PackDayPrintKind::PrintCustomerLabels) 17537 ); 17538 assert_eq!( 17539 print 17540 .request 17541 .as_ref() 17542 .map(|request| request.export_instance_id), 17543 Some(bundle.export_instance_id) 17544 ); 17545 assert_eq!( 17546 print.failure, 17547 Some(PackDayPrintFailureKind::CustomerLabelsAvery5160Overflow) 17548 ); 17549 17550 cleanup_bootstrapped_runtime_paths(&paths); 17551 } 17552 17553 #[test] 17554 fn runtime_finish_pack_day_print_cleans_customer_label_assets_and_keeps_cleanup_failures_best_effort() 17555 { 17556 let (runtime, paths) = bootstrapped_runtime("pack_day_print_cleanup"); 17557 let (_, farm_id) = provision_ready_farmer_account(&runtime); 17558 17559 seed_order_workspace(&runtime, farm_id); 17560 assert!(runtime.open_pack_day(None).expect("pack day should open")); 17561 assert!( 17562 runtime 17563 .export_pack_day() 17564 .expect("pack day export should succeed") 17565 ); 17566 17567 let (success_request, success_plan) = runtime 17568 .prepare_pack_day_print(PackDayPrintKind::PrintCustomerLabels) 17569 .expect("customer labels should prepare") 17570 .expect("customer labels plan should exist"); 17571 let success_directory = success_plan 17572 .target_path 17573 .parent() 17574 .expect("prepared asset parent") 17575 .to_path_buf(); 17576 assert!(success_directory.is_dir()); 17577 17578 assert!( 17579 runtime 17580 .finish_pack_day_print(success_request, Ok(())) 17581 .expect("print success should apply") 17582 ); 17583 assert!(!success_directory.exists()); 17584 17585 let (failure_request, failure_plan) = runtime 17586 .prepare_pack_day_print(PackDayPrintKind::PrintCustomerLabels) 17587 .expect("customer labels should prepare again") 17588 .expect("customer labels plan should exist again"); 17589 let failure_directory = failure_plan 17590 .target_path 17591 .parent() 17592 .expect("prepared asset parent") 17593 .to_path_buf(); 17594 fs::remove_file(&failure_plan.target_path).expect("prepared asset should remove"); 17595 fs::remove_dir_all(&failure_directory).expect("prepared asset directory should remove"); 17596 fs::write(&failure_directory, "blocked").expect("cleanup blocker should write"); 17597 17598 let error = runtime 17599 .finish_pack_day_print( 17600 failure_request.clone(), 17601 Err(PackDayPrintError::UnsupportedPlatform), 17602 ) 17603 .expect_err("print failure should surface"); 17604 assert!(matches!( 17605 error, 17606 DesktopAppRuntimeCommandError::PackDayPrint(PackDayPrintError::UnsupportedPlatform) 17607 )); 17608 assert!(failure_directory.is_file()); 17609 17610 let summary = runtime.summary(); 17611 assert_eq!( 17612 summary.pack_day_projection.print.status, 17613 PackDayPrintStatus::Failed 17614 ); 17615 assert_eq!( 17616 summary.pack_day_projection.print.request, 17617 Some(failure_request) 17618 ); 17619 17620 let _ = fs::remove_file(&failure_directory); 17621 cleanup_bootstrapped_runtime_paths(&paths); 17622 } 17623 17624 #[test] 17625 fn runtime_reexport_pack_day_cleans_previous_customer_label_prepared_assets() { 17626 let (runtime, paths) = bootstrapped_runtime("pack_day_print_reexport_cleanup"); 17627 let (_, farm_id) = provision_ready_farmer_account(&runtime); 17628 17629 seed_order_workspace(&runtime, farm_id); 17630 assert!(runtime.open_pack_day(None).expect("pack day should open")); 17631 assert!( 17632 runtime 17633 .export_pack_day() 17634 .expect("initial pack day export should succeed") 17635 ); 17636 let first_bundle = runtime 17637 .summary() 17638 .pack_day_projection 17639 .export 17640 .bundle 17641 .clone() 17642 .expect("initial export bundle"); 17643 17644 let (_request, plan) = runtime 17645 .prepare_pack_day_print(PackDayPrintKind::PrintCustomerLabels) 17646 .expect("customer labels should prepare") 17647 .expect("customer labels plan should exist"); 17648 let prepared_directory = plan 17649 .target_path 17650 .parent() 17651 .expect("prepared asset parent") 17652 .to_path_buf(); 17653 assert!(prepared_directory.is_dir()); 17654 let _ = runtime 17655 .lock_state_mut() 17656 .state_store 17657 .apply_in_memory(AppStateCommand::reset_pack_day_print()); 17658 17659 assert!( 17660 runtime 17661 .export_pack_day() 17662 .expect("replacement pack day export should succeed") 17663 ); 17664 17665 let summary = runtime.summary(); 17666 let replacement_bundle = summary 17667 .pack_day_projection 17668 .export 17669 .bundle 17670 .as_ref() 17671 .expect("replacement export bundle"); 17672 assert_ne!( 17673 replacement_bundle.export_instance_id, 17674 first_bundle.export_instance_id 17675 ); 17676 assert!(!prepared_directory.exists()); 17677 17678 cleanup_bootstrapped_runtime_paths(&paths); 17679 } 17680 17681 #[test] 17682 fn runtime_pack_day_window_change_cleans_previous_customer_label_prepared_assets() { 17683 let (runtime, paths) = bootstrapped_runtime("pack_day_print_window_cleanup"); 17684 let (_, farm_id) = provision_ready_farmer_account(&runtime); 17685 let (fulfillment_window_id, _) = seed_order_workspace(&runtime, farm_id); 17686 let (other_fulfillment_window_id, _) = 17687 seed_second_order_workspace(&runtime, farm_id, fulfillment_window_id); 17688 17689 assert!( 17690 runtime 17691 .open_pack_day(Some(fulfillment_window_id)) 17692 .expect("first pack day window should open") 17693 ); 17694 assert!( 17695 runtime 17696 .export_pack_day() 17697 .expect("initial pack day export should succeed") 17698 ); 17699 17700 let (_request, plan) = runtime 17701 .prepare_pack_day_print(PackDayPrintKind::PrintCustomerLabels) 17702 .expect("customer labels should prepare") 17703 .expect("customer labels plan should exist"); 17704 let prepared_directory = plan 17705 .target_path 17706 .parent() 17707 .expect("prepared asset parent") 17708 .to_path_buf(); 17709 assert!(prepared_directory.is_dir()); 17710 let _ = runtime 17711 .lock_state_mut() 17712 .state_store 17713 .apply_in_memory(AppStateCommand::reset_pack_day_print()); 17714 17715 assert!( 17716 runtime 17717 .open_pack_day(Some(other_fulfillment_window_id)) 17718 .expect("second pack day window should open") 17719 ); 17720 17721 let summary = runtime.summary(); 17722 assert_eq!( 17723 summary.pack_day_projection.query.fulfillment_window_id, 17724 Some(other_fulfillment_window_id) 17725 ); 17726 assert_eq!( 17727 summary.pack_day_projection.export.status, 17728 PackDayExportStatus::Idle 17729 ); 17730 assert!(!prepared_directory.exists()); 17731 17732 cleanup_bootstrapped_runtime_paths(&paths); 17733 } 17734 17735 #[test] 17736 fn runtime_finish_pack_day_print_ignores_stale_background_completion() { 17737 let (runtime, paths) = bootstrapped_runtime("pack_day_print_stale"); 17738 let (_, farm_id) = provision_ready_farmer_account(&runtime); 17739 17740 seed_order_workspace(&runtime, farm_id); 17741 assert!(runtime.open_pack_day(None).expect("pack day should open")); 17742 assert!( 17743 runtime 17744 .export_pack_day() 17745 .expect("pack day export should succeed") 17746 ); 17747 17748 let (request, _) = runtime 17749 .prepare_pack_day_print(PackDayPrintKind::PrintPickupRoster) 17750 .expect("print should prepare") 17751 .expect("print should produce a plan"); 17752 17753 let _ = runtime 17754 .lock_state_mut() 17755 .state_store 17756 .apply_in_memory(AppStateCommand::reset_pack_day_print()); 17757 17758 assert!( 17759 !runtime 17760 .finish_pack_day_print(request, Ok(())) 17761 .expect("stale completion should no-op") 17762 ); 17763 assert_eq!( 17764 runtime.summary().pack_day_projection.print.status, 17765 PackDayPrintStatus::Idle 17766 ); 17767 17768 cleanup_bootstrapped_runtime_paths(&paths); 17769 } 17770 17771 #[test] 17772 fn runtime_threads_canonical_seller_reminders_across_today_orders_and_pack_day() { 17773 let runtime = memory_runtime(); 17774 let (_, farm_id) = provision_ready_farmer_account(&runtime); 17775 seed_order_workspace(&runtime, farm_id); 17776 17777 assert!(runtime.open_orders().expect("orders should open")); 17778 let summary = runtime.summary(); 17779 17780 assert_eq!(summary.today_projection.reminders.items.len(), 1); 17781 assert_eq!( 17782 summary.today_projection.reminders.items[0].kind, 17783 ReminderKind::FulfillmentWindow 17784 ); 17785 assert_eq!(summary.orders_projection.reminders.items.len(), 1); 17786 assert_eq!( 17787 summary.orders_projection.reminders.items[0].kind, 17788 ReminderKind::OrderAction 17789 ); 17790 assert_eq!( 17791 summary.pack_day_projection.projection.reminders.items.len(), 17792 1 17793 ); 17794 assert_eq!( 17795 summary.pack_day_projection.projection.reminders.items[0].kind, 17796 ReminderKind::FulfillmentWindow 17797 ); 17798 } 17799 17800 #[test] 17801 fn runtime_sync_refresh_threads_sync_reminders_into_orders_projection() { 17802 let runtime = memory_runtime(); 17803 let (_, farm_id) = provision_ready_farmer_account(&runtime); 17804 let (_, order_id) = seed_order_workspace(&runtime, farm_id); 17805 17806 assert!(runtime.open_orders().expect("orders should open")); 17807 assert!( 17808 runtime 17809 .lock_state_mut() 17810 .enqueue_selected_account_sync_operations(vec![pending_sync_upsert( 17811 SyncAggregateRef::Order(order_id), 17812 json!({ 17813 "aggregate_kind": "order", 17814 "order_id": order_id.to_string(), 17815 "source": "test_pending_order_sync", 17816 }) 17817 .to_string(), 17818 )]) 17819 .expect("pending order sync should enqueue") 17820 ); 17821 let summary = runtime.summary(); 17822 17823 assert_eq!(summary.sync_status.pending_write_count, 1); 17824 assert!( 17825 summary 17826 .orders_projection 17827 .reminders 17828 .items 17829 .iter() 17830 .any(|item| item.kind == ReminderKind::SyncImpact 17831 && item.title == "Pending local changes") 17832 ); 17833 } 17834 17835 #[test] 17836 fn runtime_refresh_promotes_blocking_sync_reminders_into_presented_log_entries() { 17837 let runtime = memory_runtime(); 17838 let (account_id, farm_id) = provision_ready_farmer_account(&runtime); 17839 17840 runtime 17841 .lock_state() 17842 .sqlite_store 17843 .as_ref() 17844 .expect("sqlite store") 17845 .record_sync_conflict( 17846 account_id.as_str(), 17847 &SyncConflict { 17848 aggregate: SyncAggregateRef::Farm(farm_id), 17849 kind: SyncConflictKind::RevisionMismatch, 17850 severity: SyncConflictSeverity::Blocking, 17851 resolution: SyncConflictResolutionStatus::Unresolved, 17852 local_payload_json: "{\"farm\":\"local\"}".to_owned(), 17853 remote_payload_json: Some("{\"farm\":\"remote\"}".to_owned()), 17854 detected_at: "2026-04-20T20:10:00Z".to_owned(), 17855 resolved_at: None, 17856 }, 17857 ) 17858 .expect("blocking conflict should save"); 17859 17860 assert!( 17861 runtime 17862 .lock_state_mut() 17863 .refresh_selected_account_sync() 17864 .expect("sync status should refresh") 17865 ); 17866 17867 let summary = runtime.summary(); 17868 let reminder = summary 17869 .orders_projection 17870 .reminders 17871 .items 17872 .iter() 17873 .find(|item| item.kind == ReminderKind::SyncImpact) 17874 .expect("sync reminder"); 17875 17876 assert_eq!(reminder.delivery_state, ReminderDeliveryState::Presented); 17877 assert!(summary.reminder_log.entries.iter().any(|entry| { 17878 entry.reminder_id == reminder.reminder_id 17879 && entry.delivery_state == ReminderDeliveryState::Presented 17880 })); 17881 } 17882 17883 #[test] 17884 fn runtime_resolving_an_acknowledged_reminder_records_the_resolved_log_entry() { 17885 let runtime = memory_runtime(); 17886 let (account_id, farm_id) = provision_ready_farmer_account(&runtime); 17887 17888 let conflict_id = runtime 17889 .lock_state() 17890 .sqlite_store 17891 .as_ref() 17892 .expect("sqlite store") 17893 .record_sync_conflict( 17894 account_id.as_str(), 17895 &SyncConflict { 17896 aggregate: SyncAggregateRef::Farm(farm_id), 17897 kind: SyncConflictKind::RevisionMismatch, 17898 severity: SyncConflictSeverity::Blocking, 17899 resolution: SyncConflictResolutionStatus::Unresolved, 17900 local_payload_json: "{\"farm\":\"local\"}".to_owned(), 17901 remote_payload_json: Some("{\"farm\":\"remote\"}".to_owned()), 17902 detected_at: "2026-04-20T20:15:00Z".to_owned(), 17903 resolved_at: None, 17904 }, 17905 ) 17906 .expect("blocking conflict should save"); 17907 assert!( 17908 runtime 17909 .lock_state_mut() 17910 .refresh_selected_account_sync() 17911 .expect("sync status should refresh") 17912 ); 17913 17914 let reminder_id = runtime 17915 .summary() 17916 .orders_projection 17917 .reminders 17918 .items 17919 .iter() 17920 .find(|item| item.kind == ReminderKind::SyncImpact) 17921 .expect("sync reminder") 17922 .reminder_id; 17923 assert!( 17924 runtime 17925 .acknowledge_reminder(reminder_id) 17926 .expect("reminder should acknowledge") 17927 ); 17928 17929 let acknowledged_summary = runtime.summary(); 17930 assert!( 17931 acknowledged_summary 17932 .orders_projection 17933 .reminders 17934 .items 17935 .iter() 17936 .any(|item| { 17937 item.reminder_id == reminder_id 17938 && item.delivery_state == ReminderDeliveryState::Acknowledged 17939 }) 17940 ); 17941 assert!( 17942 acknowledged_summary 17943 .reminder_log 17944 .entries 17945 .iter() 17946 .any(|entry| { 17947 entry.reminder_id == reminder_id 17948 && entry.delivery_state == ReminderDeliveryState::Acknowledged 17949 }) 17950 ); 17951 17952 assert!( 17953 runtime 17954 .resolve_sync_conflict( 17955 conflict_id.as_str(), 17956 SyncConflictResolutionStatus::AcceptedLocal, 17957 ) 17958 .expect("conflict resolution should succeed") 17959 ); 17960 17961 let resolved_summary = runtime.summary(); 17962 assert!( 17963 resolved_summary 17964 .orders_projection 17965 .reminders 17966 .items 17967 .iter() 17968 .all(|item| { item.reminder_id != reminder_id }) 17969 ); 17970 assert!(resolved_summary.reminder_log.entries.iter().any(|entry| { 17971 entry.reminder_id == reminder_id 17972 && entry.delivery_state == ReminderDeliveryState::Resolved 17973 })); 17974 } 17975 17976 #[test] 17977 fn reminder_urgency_marks_due_soon_and_overdue_deadlines() { 17978 let due_soon = (Utc::now() + Duration::hours(24)) 17979 .format("%Y-%m-%dT%H:%M:%SZ") 17980 .to_string(); 17981 let overdue = (Utc::now() - Duration::hours(2)) 17982 .format("%Y-%m-%dT%H:%M:%SZ") 17983 .to_string(); 17984 17985 assert_eq!( 17986 super::reminder_urgency(due_soon.as_str()), 17987 super::ReminderUrgency::DueSoon 17988 ); 17989 assert_eq!( 17990 super::reminder_urgency(overdue.as_str()), 17991 super::ReminderUrgency::Overdue 17992 ); 17993 } 17994 17995 #[test] 17996 fn runtime_open_orders_resets_to_default_queue_and_clears_detail() { 17997 let runtime = memory_runtime(); 17998 let (_, farm_id) = provision_ready_farmer_account(&runtime); 17999 let (_, order_id) = seed_order_workspace(&runtime, farm_id); 18000 18001 assert!( 18002 runtime 18003 .select_orders_filter(OrdersFilter::Packed) 18004 .expect("orders filter should update") 18005 ); 18006 assert!( 18007 runtime 18008 .open_order_detail(order_id) 18009 .expect("order detail should open") 18010 ); 18011 18012 assert!(runtime.open_orders().expect("orders should reopen")); 18013 let summary = runtime.summary(); 18014 18015 assert_eq!( 18016 summary.shell_projection.selected_section, 18017 ShellSection::Farmer(FarmerSection::Orders) 18018 ); 18019 assert_eq!( 18020 summary.orders_projection.query.filter, 18021 OrdersFilter::NeedsAction 18022 ); 18023 assert_eq!(summary.orders_projection.list.rows.len(), 1); 18024 assert!(summary.orders_projection.detail.is_none()); 18025 } 18026 18027 #[test] 18028 fn runtime_open_orders_fulfillment_window_filters_the_queue_to_one_window() { 18029 let runtime = memory_runtime(); 18030 let (_, farm_id) = provision_ready_farmer_account(&runtime); 18031 let (fulfillment_window_id, order_id) = seed_order_workspace(&runtime, farm_id); 18032 let other_fulfillment_window_id = FulfillmentWindowId::new(); 18033 let other_order_id = OrderId::new(); 18034 let sql = format!( 18035 "insert into fulfillment_windows ( 18036 id, 18037 farm_id, 18038 starts_at, 18039 ends_at, 18040 capacity_limit, 18041 created_at, 18042 updated_at, 18043 pickup_location_id, 18044 label, 18045 order_cutoff_at 18046 ) 18047 select 18048 '{other_fulfillment_window_id}', 18049 farm_id, 18050 '2099-04-19T16:00:00Z', 18051 '2099-04-19T18:00:00Z', 18052 capacity_limit, 18053 '2099-04-19T16:00:00Z', 18054 '2099-04-19T16:00:00Z', 18055 pickup_location_id, 18056 'Saturday pickup', 18057 '2099-04-18T18:00:00Z' 18058 from fulfillment_windows 18059 where id = '{fulfillment_window_id}' and farm_id = '{farm_id}'; 18060 insert into orders ( 18061 id, 18062 farm_id, 18063 fulfillment_window_id, 18064 order_number, 18065 customer_display_name, 18066 status, 18067 updated_at 18068 ) values ( 18069 '{other_order_id}', 18070 '{farm_id}', 18071 '{other_fulfillment_window_id}', 18072 'R-101', 18073 'Robin', 18074 'scheduled', 18075 '2026-04-17T11:00:00Z' 18076 )" 18077 ); 18078 runtime 18079 .lock_state() 18080 .sqlite_store 18081 .as_ref() 18082 .expect("sqlite store") 18083 .connection() 18084 .execute_batch(&sql) 18085 .expect("second orders workspace should seed"); 18086 18087 assert!( 18088 runtime 18089 .open_orders_fulfillment_window(fulfillment_window_id) 18090 .expect("orders window follow-on should route") 18091 ); 18092 let summary = runtime.summary(); 18093 18094 assert_eq!( 18095 summary.shell_projection.selected_section, 18096 ShellSection::Farmer(FarmerSection::Orders) 18097 ); 18098 assert_eq!(summary.orders_projection.query.filter, OrdersFilter::All); 18099 assert_eq!( 18100 summary.orders_projection.query.fulfillment_window_id, 18101 Some(fulfillment_window_id) 18102 ); 18103 assert_eq!(summary.orders_projection.list.rows.len(), 1); 18104 assert_eq!(summary.orders_projection.list.rows[0].order_id, order_id); 18105 assert!(summary.orders_projection.detail.is_none()); 18106 } 18107 18108 #[test] 18109 fn runtime_order_filters_refresh_repository_backed_orders_projection() { 18110 let runtime = memory_runtime(); 18111 let (_, farm_id) = provision_ready_farmer_account(&runtime); 18112 let (fulfillment_window_id, scheduled_order_id) = seed_order_workspace(&runtime, farm_id); 18113 let packed_order_id = OrderId::new(); 18114 let completed_order_id = OrderId::new(); 18115 18116 let sql = format!( 18117 "update orders 18118 set status = 'scheduled', updated_at = '2026-04-17T12:00:00Z' 18119 where id = '{scheduled_order_id}' and farm_id = '{farm_id}'; 18120 insert into orders ( 18121 id, 18122 farm_id, 18123 fulfillment_window_id, 18124 order_number, 18125 customer_display_name, 18126 status, 18127 updated_at 18128 ) values ( 18129 '{packed_order_id}', 18130 '{farm_id}', 18131 '{fulfillment_window_id}', 18132 'R-101', 18133 'Taylor', 18134 'packed', 18135 '2026-04-17T12:30:00Z' 18136 ); 18137 insert into orders ( 18138 id, 18139 farm_id, 18140 fulfillment_window_id, 18141 order_number, 18142 customer_display_name, 18143 status, 18144 updated_at 18145 ) values ( 18146 '{completed_order_id}', 18147 '{farm_id}', 18148 '{fulfillment_window_id}', 18149 'R-102', 18150 'Morgan', 18151 'completed', 18152 '2026-04-17T13:00:00Z' 18153 )" 18154 ); 18155 runtime 18156 .lock_state() 18157 .sqlite_store 18158 .as_ref() 18159 .expect("sqlite store") 18160 .connection() 18161 .execute_batch(&sql) 18162 .expect("order should update to scheduled"); 18163 18164 assert!( 18165 runtime 18166 .select_orders_filter(OrdersFilter::Scheduled) 18167 .expect("scheduled filter should apply") 18168 ); 18169 assert_eq!(runtime.summary().orders_projection.list.rows.len(), 1); 18170 assert_eq!( 18171 runtime.summary().orders_projection.list.rows[0].status, 18172 OrderStatus::Scheduled 18173 ); 18174 18175 assert!( 18176 runtime 18177 .open_order_detail(scheduled_order_id) 18178 .expect("order detail should open") 18179 ); 18180 let scheduled_detail_summary = runtime.summary(); 18181 assert_eq!( 18182 scheduled_detail_summary 18183 .orders_projection 18184 .detail 18185 .as_ref() 18186 .expect("scheduled detail") 18187 .status, 18188 OrderStatus::Scheduled 18189 ); 18190 assert_eq!( 18191 scheduled_detail_summary 18192 .orders_projection 18193 .list 18194 .summary 18195 .scheduled_orders, 18196 1 18197 ); 18198 assert_eq!( 18199 scheduled_detail_summary 18200 .orders_projection 18201 .list 18202 .summary 18203 .packed_orders, 18204 1 18205 ); 18206 18207 assert!( 18208 runtime 18209 .select_orders_filter(OrdersFilter::Packed) 18210 .expect("packed filter should apply") 18211 ); 18212 assert_eq!(runtime.summary().orders_projection.list.rows.len(), 1); 18213 assert_eq!( 18214 runtime.summary().orders_projection.list.rows[0].status, 18215 OrderStatus::Packed 18216 ); 18217 18218 assert!( 18219 runtime 18220 .open_order_detail(packed_order_id) 18221 .expect("packed detail should open") 18222 ); 18223 let packed_detail_summary = runtime.summary(); 18224 assert_eq!( 18225 packed_detail_summary 18226 .orders_projection 18227 .detail 18228 .as_ref() 18229 .expect("packed detail") 18230 .status, 18231 OrderStatus::Packed 18232 ); 18233 18234 assert!( 18235 runtime 18236 .select_orders_filter(OrdersFilter::Completed) 18237 .expect("completed filter should apply") 18238 ); 18239 assert_eq!(runtime.summary().orders_projection.list.rows.len(), 1); 18240 assert_eq!( 18241 runtime.summary().orders_projection.list.rows[0].status, 18242 OrderStatus::Completed 18243 ); 18244 18245 assert!( 18246 runtime 18247 .open_order_detail(completed_order_id) 18248 .expect("completed detail should open") 18249 ); 18250 assert_eq!( 18251 runtime 18252 .summary() 18253 .orders_projection 18254 .detail 18255 .as_ref() 18256 .expect("completed detail") 18257 .status, 18258 OrderStatus::Completed 18259 ); 18260 } 18261 18262 #[test] 18263 fn runtime_stock_updates_refresh_today_and_products_projections() { 18264 let runtime = memory_runtime(); 18265 18266 assert!( 18267 runtime 18268 .generate_local_account(Some("Farmer".to_owned())) 18269 .expect("account should generate") 18270 ); 18271 let account_id = runtime 18272 .summary() 18273 .settings_account_projection 18274 .selected_account 18275 .as_ref() 18276 .expect("selected account") 18277 .account 18278 .account_id 18279 .clone(); 18280 let farm_id = 18281 save_farmer_surface_activation(&runtime, account_id.as_str(), ActiveSurface::Farmer); 18282 let farm_setup_projection = FarmSetupProjection::from_saved_farm(FarmSummary { 18283 farm_id, 18284 display_name: "North field farm".to_owned(), 18285 readiness: FarmReadiness::Ready, 18286 }); 18287 runtime 18288 .lock_state() 18289 .sqlite_store 18290 .as_ref() 18291 .expect("sqlite store") 18292 .save_farm_summary( 18293 farm_setup_projection 18294 .saved_farm 18295 .as_ref() 18296 .expect("saved farm should exist"), 18297 ) 18298 .expect("farm summary should save"); 18299 runtime 18300 .lock_state() 18301 .sqlite_store 18302 .as_ref() 18303 .expect("sqlite store") 18304 .save_farm_setup(account_id.as_str(), &farm_setup_projection) 18305 .expect("farm setup should save"); 18306 seed_product( 18307 &runtime, 18308 farm_id, 18309 "Salad mix", 18310 "Spring blend", 18311 "published", 18312 Some(2), 18313 "2026-04-18T10:00:00Z", 18314 ); 18315 18316 assert!( 18317 runtime 18318 .select_local_account(account_id.as_str()) 18319 .expect("account should select") 18320 ); 18321 let product_id = runtime.summary().products_projection.list.rows[0].product_id; 18322 18323 assert_eq!( 18324 runtime.summary().today_projection.low_stock_products.len(), 18325 1 18326 ); 18327 assert!( 18328 runtime 18329 .update_product_stock(product_id, 12) 18330 .expect("stock update should succeed") 18331 ); 18332 18333 let summary = runtime.summary(); 18334 assert_eq!( 18335 summary.products_projection.list.rows[0].stock.quantity, 18336 Some(12) 18337 ); 18338 assert!(summary.today_projection.low_stock_products.is_empty()); 18339 } 18340 18341 #[test] 18342 fn runtime_open_new_product_editor_creates_a_local_draft_and_opens_it() { 18343 let runtime = memory_runtime(); 18344 18345 assert!( 18346 runtime 18347 .generate_local_account(Some("Farmer".to_owned())) 18348 .expect("account should generate") 18349 ); 18350 let account_id = runtime 18351 .summary() 18352 .settings_account_projection 18353 .selected_account 18354 .as_ref() 18355 .expect("selected account") 18356 .account 18357 .account_id 18358 .clone(); 18359 let farm_id = 18360 save_farmer_surface_activation(&runtime, account_id.as_str(), ActiveSurface::Farmer); 18361 let farm_setup_projection = FarmSetupProjection::from_saved_farm(FarmSummary { 18362 farm_id, 18363 display_name: "North field farm".to_owned(), 18364 readiness: FarmReadiness::Ready, 18365 }); 18366 runtime 18367 .lock_state() 18368 .sqlite_store 18369 .as_ref() 18370 .expect("sqlite store") 18371 .save_farm_summary( 18372 farm_setup_projection 18373 .saved_farm 18374 .as_ref() 18375 .expect("saved farm should exist"), 18376 ) 18377 .expect("farm summary should save"); 18378 runtime 18379 .lock_state() 18380 .sqlite_store 18381 .as_ref() 18382 .expect("sqlite store") 18383 .save_farm_setup(account_id.as_str(), &farm_setup_projection) 18384 .expect("farm setup should save"); 18385 18386 assert!( 18387 runtime 18388 .select_local_account(account_id.as_str()) 18389 .expect("account should select") 18390 ); 18391 assert_eq!( 18392 runtime 18393 .summary() 18394 .products_projection 18395 .list 18396 .summary 18397 .total_products, 18398 0 18399 ); 18400 18401 assert!( 18402 runtime 18403 .open_new_product_editor() 18404 .expect("new product editor should open") 18405 ); 18406 18407 let summary = runtime.summary(); 18408 assert_eq!(summary.products_projection.list.summary.total_products, 1); 18409 assert!(matches!( 18410 summary.products_projection.editor, 18411 radroots_app_state::ProductEditorState::Open(_) 18412 )); 18413 assert_eq!( 18414 summary.products_projection.list.rows[0].status, 18415 ProductStatus::Draft 18416 ); 18417 } 18418 18419 #[test] 18420 fn runtime_open_existing_and_save_product_editor_refreshes_products_projection() { 18421 let runtime = memory_runtime(); 18422 18423 assert!( 18424 runtime 18425 .generate_local_account(Some("Farmer".to_owned())) 18426 .expect("account should generate") 18427 ); 18428 let account_id = runtime 18429 .summary() 18430 .settings_account_projection 18431 .selected_account 18432 .as_ref() 18433 .expect("selected account") 18434 .account 18435 .account_id 18436 .clone(); 18437 let farm_id = 18438 save_farmer_surface_activation(&runtime, account_id.as_str(), ActiveSurface::Farmer); 18439 let farm_setup_projection = FarmSetupProjection::from_saved_farm(FarmSummary { 18440 farm_id, 18441 display_name: "North field farm".to_owned(), 18442 readiness: FarmReadiness::Ready, 18443 }); 18444 runtime 18445 .lock_state() 18446 .sqlite_store 18447 .as_ref() 18448 .expect("sqlite store") 18449 .save_farm_summary( 18450 farm_setup_projection 18451 .saved_farm 18452 .as_ref() 18453 .expect("saved farm should exist"), 18454 ) 18455 .expect("farm summary should save"); 18456 runtime 18457 .lock_state() 18458 .sqlite_store 18459 .as_ref() 18460 .expect("sqlite store") 18461 .save_farm_setup(account_id.as_str(), &farm_setup_projection) 18462 .expect("farm setup should save"); 18463 let product_id = seed_product( 18464 &runtime, 18465 farm_id, 18466 "Salad mix", 18467 "Spring blend", 18468 "draft", 18469 Some(2), 18470 "2026-04-18T10:00:00Z", 18471 ); 18472 18473 assert!( 18474 runtime 18475 .select_local_account(account_id.as_str()) 18476 .expect("account should select") 18477 ); 18478 assert!( 18479 runtime 18480 .open_existing_product_editor(product_id) 18481 .expect("existing product editor should open") 18482 ); 18483 18484 let saved_draft = ProductEditorDraft { 18485 title: "Salad mix".to_owned(), 18486 subtitle: "Washed and boxed".to_owned(), 18487 category: "greens".to_owned(), 18488 unit_label: "box".to_owned(), 18489 price_minor_units: Some(900), 18490 price_currency: "usd".to_owned(), 18491 stock_quantity: Some(14), 18492 availability_window_id: None, 18493 status: radroots_app_view::ProductStatus::Published, 18494 }; 18495 18496 assert!( 18497 runtime 18498 .save_product_editor_draft(saved_draft.clone()) 18499 .expect("product editor draft should save") 18500 ); 18501 18502 let summary = runtime.summary(); 18503 assert_eq!( 18504 summary.products_projection.list.rows[0].subtitle.as_deref(), 18505 Some("Washed and boxed") 18506 ); 18507 assert_eq!( 18508 summary.products_projection.list.rows[0] 18509 .price 18510 .as_ref() 18511 .map(|price| price.amount_minor_units), 18512 Some(900) 18513 ); 18514 assert_eq!( 18515 summary.products_projection.list.rows[0].stock.quantity, 18516 Some(14) 18517 ); 18518 assert_eq!( 18519 runtime 18520 .lock_state() 18521 .sqlite_store 18522 .as_ref() 18523 .expect("sqlite store") 18524 .load_product_editor_draft(product_id) 18525 .expect("saved draft should load"), 18526 Some(ProductEditorDraft { 18527 price_currency: "USD".to_owned(), 18528 ..saved_draft 18529 }) 18530 ); 18531 } 18532 18533 #[test] 18534 fn runtime_account_commands_refresh_identity_projection() { 18535 let runtime = memory_runtime(); 18536 18537 assert!( 18538 runtime 18539 .generate_local_account(Some("First".to_owned())) 18540 .expect("first account should generate") 18541 ); 18542 let first_summary = runtime.summary(); 18543 let first_account_id = first_summary 18544 .settings_account_projection 18545 .selected_account 18546 .as_ref() 18547 .expect("first selected account") 18548 .account 18549 .account_id 18550 .clone(); 18551 18552 assert!( 18553 runtime 18554 .generate_local_account(Some("Second".to_owned())) 18555 .expect("second account should generate") 18556 ); 18557 let second_summary = runtime.summary(); 18558 let second_account_id = second_summary 18559 .settings_account_projection 18560 .selected_account 18561 .as_ref() 18562 .expect("second selected account") 18563 .account 18564 .account_id 18565 .clone(); 18566 assert_eq!(second_summary.settings_account_projection.roster.len(), 2); 18567 assert_eq!( 18568 second_summary 18569 .settings_account_projection 18570 .selected_account 18571 .as_ref() 18572 .and_then(|account| account.account.label.as_deref()), 18573 Some("Second") 18574 ); 18575 18576 save_surface_activation( 18577 &runtime, 18578 second_account_id.as_str(), 18579 ActiveSurface::Farmer, 18580 true, 18581 ); 18582 assert!( 18583 runtime 18584 .select_local_account(second_account_id.as_str()) 18585 .expect("selection should succeed") 18586 ); 18587 let selected_summary = runtime.summary(); 18588 assert_eq!(selected_summary.startup_gate, AppStartupGate::Farmer); 18589 assert_eq!(selected_summary.home_route, HomeRoute::FarmSetupOnboarding); 18590 assert_eq!( 18591 selected_summary 18592 .settings_account_projection 18593 .selected_account 18594 .as_ref() 18595 .map(|account| account.active_surface()), 18596 Some(ActiveSurface::Farmer) 18597 ); 18598 18599 assert!( 18600 runtime 18601 .remove_selected_local_key() 18602 .expect("selected local key should remove") 18603 ); 18604 let removed_summary = runtime.summary(); 18605 assert_eq!(removed_summary.settings_account_projection.roster.len(), 1); 18606 assert_eq!( 18607 removed_summary 18608 .settings_account_projection 18609 .selected_account 18610 .as_ref() 18611 .map(|account| account.account.account_id.as_str()), 18612 Some(first_account_id.as_str()) 18613 ); 18614 assert_eq!( 18615 runtime 18616 .lock_state() 18617 .sqlite_store 18618 .as_ref() 18619 .expect("sqlite store") 18620 .load_surface_activation(second_account_id.as_str()) 18621 .expect("removed activation should load"), 18622 None 18623 ); 18624 18625 let imported_identity = RadrootsIdentity::generate(); 18626 assert!( 18627 runtime 18628 .import_local_account(DesktopLocalIdentityImportRequest::raw_secret_key( 18629 imported_identity.nsec(), 18630 )) 18631 .expect("raw import should succeed") 18632 ); 18633 let imported_summary = runtime.summary(); 18634 assert_eq!(imported_summary.settings_account_projection.roster.len(), 2); 18635 assert_eq!( 18636 imported_summary 18637 .settings_account_projection 18638 .selected_account 18639 .as_ref() 18640 .map(|account| account.account.account_id.as_str()), 18641 Some(imported_identity.id().as_str()) 18642 ); 18643 } 18644 18645 #[test] 18646 fn runtime_select_active_surface_persists_selected_surface() { 18647 let runtime = memory_runtime(); 18648 18649 assert!( 18650 runtime 18651 .generate_local_account(Some("Farmer".to_owned())) 18652 .expect("account should generate") 18653 ); 18654 let account_id = runtime 18655 .summary() 18656 .settings_account_projection 18657 .selected_account 18658 .as_ref() 18659 .expect("selected account") 18660 .account 18661 .account_id 18662 .clone(); 18663 save_surface_activation(&runtime, account_id.as_str(), ActiveSurface::Farmer, true); 18664 assert!( 18665 runtime 18666 .select_local_account(account_id.as_str()) 18667 .expect("account should select") 18668 ); 18669 assert_eq!(runtime.summary().startup_gate, AppStartupGate::Farmer); 18670 assert!(runtime.select_account()); 18671 assert_eq!( 18672 runtime.summary().shell_projection.selected_section, 18673 ShellSection::Account 18674 ); 18675 18676 assert!( 18677 runtime 18678 .select_active_surface(ActiveSurface::Personal) 18679 .expect("surface should select") 18680 ); 18681 let personal_summary = runtime.summary(); 18682 assert_eq!(personal_summary.startup_gate, AppStartupGate::Personal); 18683 assert_eq!( 18684 personal_summary.shell_projection.active_surface, 18685 ActiveSurface::Personal 18686 ); 18687 assert_eq!( 18688 personal_summary.shell_projection.selected_section, 18689 ShellSection::Personal(PersonalSection::Browse) 18690 ); 18691 assert_eq!( 18692 personal_summary 18693 .settings_account_projection 18694 .selected_account 18695 .as_ref() 18696 .map(|account| account.active_surface()), 18697 Some(ActiveSurface::Personal) 18698 ); 18699 assert_eq!( 18700 runtime 18701 .lock_state() 18702 .sqlite_store 18703 .as_ref() 18704 .expect("sqlite store") 18705 .load_surface_activation(account_id.as_str()) 18706 .expect("surface activation should load") 18707 .expect("surface activation should exist") 18708 .active_surface(), 18709 ActiveSurface::Personal 18710 ); 18711 18712 assert!(runtime.select_account()); 18713 assert_eq!( 18714 runtime.summary().shell_projection.selected_section, 18715 ShellSection::Account 18716 ); 18717 assert!( 18718 runtime 18719 .select_active_surface(ActiveSurface::Farmer) 18720 .expect("surface should reselect") 18721 ); 18722 let farmer_summary = runtime.summary(); 18723 assert_eq!(farmer_summary.startup_gate, AppStartupGate::Farmer); 18724 assert_eq!( 18725 farmer_summary.shell_projection.active_surface, 18726 ActiveSurface::Farmer 18727 ); 18728 assert_eq!( 18729 farmer_summary.shell_projection.selected_section, 18730 ShellSection::Farmer(FarmerSection::Today) 18731 ); 18732 assert_eq!( 18733 farmer_summary 18734 .settings_account_projection 18735 .selected_account 18736 .as_ref() 18737 .map(|account| account.active_surface()), 18738 Some(ActiveSurface::Farmer) 18739 ); 18740 assert_eq!( 18741 runtime 18742 .lock_state() 18743 .sqlite_store 18744 .as_ref() 18745 .expect("sqlite store") 18746 .load_surface_activation(account_id.as_str()) 18747 .expect("surface activation should load") 18748 .expect("surface activation should exist") 18749 .active_surface(), 18750 ActiveSurface::Farmer 18751 ); 18752 } 18753 18754 #[test] 18755 fn selecting_farmer_account_loads_persisted_farm_setup_draft() { 18756 let runtime = memory_runtime(); 18757 18758 assert!( 18759 runtime 18760 .generate_local_account(Some("Farmer".to_owned())) 18761 .expect("account should generate") 18762 ); 18763 let account_id = runtime 18764 .summary() 18765 .settings_account_projection 18766 .selected_account 18767 .as_ref() 18768 .expect("selected account") 18769 .account 18770 .account_id 18771 .clone(); 18772 let projection = FarmSetupProjection::from_draft(FarmSetupDraft::new( 18773 "North field farm", 18774 "Stockholm County", 18775 [FarmOrderMethod::Pickup], 18776 )); 18777 runtime 18778 .lock_state() 18779 .sqlite_store 18780 .as_ref() 18781 .expect("sqlite store") 18782 .save_farm_setup(account_id.as_str(), &projection) 18783 .expect("farm setup should save"); 18784 save_surface_activation(&runtime, account_id.as_str(), ActiveSurface::Farmer, true); 18785 18786 assert!( 18787 runtime 18788 .select_local_account(account_id.as_str()) 18789 .expect("account should select") 18790 ); 18791 let summary = runtime.summary(); 18792 18793 assert_eq!(summary.startup_gate, AppStartupGate::Farmer); 18794 assert_eq!(summary.home_route, HomeRoute::FarmSetupForm); 18795 assert_eq!(summary.farm_setup_projection, projection); 18796 } 18797 18798 #[test] 18799 fn finishing_farm_setup_persists_saved_farm_and_today_projection() { 18800 let runtime = memory_runtime(); 18801 18802 assert!( 18803 runtime 18804 .generate_local_account(Some("Farmer".to_owned())) 18805 .expect("account should generate") 18806 ); 18807 let account_id = runtime 18808 .summary() 18809 .settings_account_projection 18810 .selected_account 18811 .as_ref() 18812 .expect("selected account") 18813 .account 18814 .account_id 18815 .clone(); 18816 let farm_id = 18817 save_farmer_surface_activation(&runtime, account_id.as_str(), ActiveSurface::Farmer); 18818 assert!( 18819 runtime 18820 .select_local_account(account_id.as_str()) 18821 .expect("account should select") 18822 ); 18823 assert_eq!(runtime.summary().home_route, HomeRoute::FarmSetupOnboarding); 18824 18825 let draft = FarmSetupDraft::new( 18826 "North field farm", 18827 "Stockholm County", 18828 [FarmOrderMethod::Pickup, FarmOrderMethod::Delivery], 18829 ); 18830 assert_eq!( 18831 runtime 18832 .save_farm_setup_draft(draft.clone()) 18833 .expect("draft should save") 18834 .draft, 18835 draft 18836 ); 18837 assert_eq!(runtime.summary().home_route, HomeRoute::FarmSetupForm); 18838 18839 let finished_projection = runtime 18840 .finish_farm_setup() 18841 .expect("farm setup should finish"); 18842 let summary = runtime.summary(); 18843 18844 assert_eq!(summary.home_route, HomeRoute::Today); 18845 assert_eq!( 18846 finished_projection.saved_farm, 18847 Some(FarmSummary { 18848 farm_id, 18849 display_name: "North field farm".to_owned(), 18850 readiness: FarmReadiness::Incomplete, 18851 }) 18852 ); 18853 assert_eq!( 18854 summary.today_projection.farm, 18855 finished_projection.saved_farm.clone() 18856 ); 18857 assert_eq!(summary.today_projection.setup_checklist.len(), 6); 18858 assert_eq!( 18859 runtime 18860 .lock_state() 18861 .sqlite_store 18862 .as_ref() 18863 .expect("sqlite store") 18864 .load_farm_setup(account_id.as_str()) 18865 .expect("farm setup should load"), 18866 finished_projection 18867 ); 18868 assert_eq!( 18869 runtime 18870 .lock_state() 18871 .sqlite_store 18872 .as_ref() 18873 .expect("sqlite store") 18874 .load_today_agenda(Some(farm_id)) 18875 .expect("today agenda should load") 18876 .farm, 18877 finished_projection.saved_farm 18878 ); 18879 } 18880 18881 #[test] 18882 fn loading_farm_rules_projection_seeds_profile_from_saved_farm() { 18883 let runtime = memory_runtime(); 18884 18885 assert!( 18886 runtime 18887 .generate_local_account(Some("Farmer".to_owned())) 18888 .expect("account should generate") 18889 ); 18890 let account_id = runtime 18891 .summary() 18892 .settings_account_projection 18893 .selected_account 18894 .as_ref() 18895 .expect("selected account") 18896 .account 18897 .account_id 18898 .clone(); 18899 let farm_id = 18900 save_farmer_surface_activation(&runtime, account_id.as_str(), ActiveSurface::Farmer); 18901 let farm_setup_projection = FarmSetupProjection::from_saved_farm(FarmSummary { 18902 farm_id, 18903 display_name: "North field farm".to_owned(), 18904 readiness: FarmReadiness::Incomplete, 18905 }); 18906 runtime 18907 .lock_state() 18908 .sqlite_store 18909 .as_ref() 18910 .expect("sqlite store") 18911 .save_farm_summary( 18912 farm_setup_projection 18913 .saved_farm 18914 .as_ref() 18915 .expect("saved farm should exist"), 18916 ) 18917 .expect("farm summary should save"); 18918 runtime 18919 .lock_state() 18920 .sqlite_store 18921 .as_ref() 18922 .expect("sqlite store") 18923 .save_farm_setup(account_id.as_str(), &farm_setup_projection) 18924 .expect("farm setup should save"); 18925 18926 assert!( 18927 runtime 18928 .select_local_account(account_id.as_str()) 18929 .expect("account should select") 18930 ); 18931 18932 let projection = runtime 18933 .load_farm_rules_projection() 18934 .expect("farm rules projection should load"); 18935 18936 assert_eq!( 18937 projection.farm_profile, 18938 Some(FarmProfileRecord { 18939 farm_id, 18940 display_name: "North field farm".to_owned(), 18941 timezone: "UTC".to_owned(), 18942 currency_code: "USD".to_owned(), 18943 }) 18944 ); 18945 assert_eq!( 18946 projection.readiness.blockers, 18947 vec![ 18948 FarmReadinessBlocker::MissingPickupLocation, 18949 FarmReadinessBlocker::MissingOperatingRules, 18950 FarmReadinessBlocker::MissingFulfillmentWindow, 18951 ] 18952 ); 18953 } 18954 18955 #[test] 18956 fn saving_farm_rules_projection_refreshes_saved_farm_summary_and_pickup_defaults() { 18957 let runtime = memory_runtime(); 18958 18959 assert!( 18960 runtime 18961 .generate_local_account(Some("Farmer".to_owned())) 18962 .expect("account should generate") 18963 ); 18964 let account_id = runtime 18965 .summary() 18966 .settings_account_projection 18967 .selected_account 18968 .as_ref() 18969 .expect("selected account") 18970 .account 18971 .account_id 18972 .clone(); 18973 let farm_id = 18974 save_farmer_surface_activation(&runtime, account_id.as_str(), ActiveSurface::Farmer); 18975 let farm_setup_projection = FarmSetupProjection::from_saved_farm(FarmSummary { 18976 farm_id, 18977 display_name: "North field farm".to_owned(), 18978 readiness: FarmReadiness::Incomplete, 18979 }); 18980 runtime 18981 .lock_state() 18982 .sqlite_store 18983 .as_ref() 18984 .expect("sqlite store") 18985 .save_farm_summary( 18986 farm_setup_projection 18987 .saved_farm 18988 .as_ref() 18989 .expect("saved farm should exist"), 18990 ) 18991 .expect("farm summary should save"); 18992 runtime 18993 .lock_state() 18994 .sqlite_store 18995 .as_ref() 18996 .expect("sqlite store") 18997 .save_farm_setup(account_id.as_str(), &farm_setup_projection) 18998 .expect("farm setup should save"); 18999 19000 assert!( 19001 runtime 19002 .select_local_account(account_id.as_str()) 19003 .expect("account should select") 19004 ); 19005 19006 let default_pickup_location_id = PickupLocationId::new(); 19007 let market_pickup_location_id = PickupLocationId::new(); 19008 let fulfillment_window_id = FulfillmentWindowId::new(); 19009 let blackout_period_id = BlackoutPeriodId::new(); 19010 19011 let saved_projection = runtime 19012 .save_farm_rules_projection(radroots_app_view::FarmRulesProjection { 19013 farm_profile: Some(FarmProfileRecord { 19014 farm_id, 19015 display_name: "Harbor farm".to_owned(), 19016 timezone: "Europe/Stockholm".to_owned(), 19017 currency_code: "sek".to_owned(), 19018 }), 19019 pickup_locations: vec![ 19020 PickupLocationRecord { 19021 pickup_location_id: default_pickup_location_id, 19022 farm_id, 19023 label: " Barn pickup ".to_owned(), 19024 address_line: " 14 Orchard Lane ".to_owned(), 19025 directions: Some(" Drive to the red barn. ".to_owned()), 19026 is_default: false, 19027 }, 19028 PickupLocationRecord { 19029 pickup_location_id: market_pickup_location_id, 19030 farm_id, 19031 label: "Market stall".to_owned(), 19032 address_line: "2 Harbor Road".to_owned(), 19033 directions: None, 19034 is_default: false, 19035 }, 19036 ], 19037 operating_rules: Some(FarmOperatingRulesRecord { 19038 farm_id, 19039 promise_lead_hours: 24, 19040 substitution_policy: " ask_customer ".to_owned(), 19041 }), 19042 fulfillment_windows: vec![FulfillmentWindowRecord { 19043 fulfillment_window_id, 19044 farm_id, 19045 pickup_location_id: default_pickup_location_id, 19046 label: " Friday pickup ".to_owned(), 19047 starts_at: " 2026-04-25T14:00:00Z ".to_owned(), 19048 ends_at: " 2026-04-25T18:00:00Z ".to_owned(), 19049 order_cutoff_at: " 2026-04-24T18:00:00Z ".to_owned(), 19050 }], 19051 blackout_periods: vec![BlackoutPeriodRecord { 19052 blackout_period_id, 19053 farm_id, 19054 label: " Spring break ".to_owned(), 19055 starts_at: " 2026-05-01T00:00:00Z ".to_owned(), 19056 ends_at: " 2026-05-03T23:59:59Z ".to_owned(), 19057 }], 19058 ..runtime 19059 .load_farm_rules_projection() 19060 .expect("farm rules projection should load") 19061 }) 19062 .expect("farm rules projection should save"); 19063 19064 assert_eq!( 19065 saved_projection.farm_profile, 19066 Some(FarmProfileRecord { 19067 farm_id, 19068 display_name: "Harbor farm".to_owned(), 19069 timezone: "Europe/Stockholm".to_owned(), 19070 currency_code: "SEK".to_owned(), 19071 }) 19072 ); 19073 assert_eq!(saved_projection.pickup_locations.len(), 2); 19074 assert!(saved_projection.pickup_locations[0].is_default); 19075 assert_eq!(saved_projection.pickup_locations[0].label, "Barn pickup"); 19076 assert_eq!( 19077 saved_projection.pickup_locations[0].address_line, 19078 "14 Orchard Lane" 19079 ); 19080 assert_eq!( 19081 saved_projection.pickup_locations[0].directions.as_deref(), 19082 Some("Drive to the red barn.") 19083 ); 19084 assert_eq!( 19085 saved_projection.operating_rules, 19086 Some(FarmOperatingRulesRecord { 19087 farm_id, 19088 promise_lead_hours: 24, 19089 substitution_policy: "ask_customer".to_owned(), 19090 }) 19091 ); 19092 assert_eq!( 19093 saved_projection.fulfillment_windows, 19094 vec![FulfillmentWindowRecord { 19095 fulfillment_window_id, 19096 farm_id, 19097 pickup_location_id: default_pickup_location_id, 19098 label: "Friday pickup".to_owned(), 19099 starts_at: "2026-04-25T14:00:00Z".to_owned(), 19100 ends_at: "2026-04-25T18:00:00Z".to_owned(), 19101 order_cutoff_at: "2026-04-24T18:00:00Z".to_owned(), 19102 }] 19103 ); 19104 assert_eq!( 19105 saved_projection.blackout_periods, 19106 vec![BlackoutPeriodRecord { 19107 blackout_period_id, 19108 farm_id, 19109 label: "Spring break".to_owned(), 19110 starts_at: "2026-05-01T00:00:00Z".to_owned(), 19111 ends_at: "2026-05-03T23:59:59Z".to_owned(), 19112 }] 19113 ); 19114 19115 let summary = runtime.summary(); 19116 assert_eq!( 19117 summary.farm_setup_projection.saved_farm, 19118 Some(FarmSummary { 19119 farm_id, 19120 display_name: "Harbor farm".to_owned(), 19121 readiness: FarmReadiness::Ready, 19122 }) 19123 ); 19124 assert_eq!(summary.farm_setup_projection.draft.farm_name, "Harbor farm"); 19125 assert_eq!( 19126 summary.today_projection.farm, 19127 summary.farm_setup_projection.saved_farm 19128 ); 19129 } 19130 19131 #[test] 19132 fn runtime_reset_local_device_state_clears_store_file_and_projection() { 19133 let (runtime, paths) = file_backed_runtime("reset"); 19134 19135 assert!( 19136 runtime 19137 .generate_local_account(Some("First".to_owned())) 19138 .expect("first account should generate") 19139 ); 19140 let first_account_id = runtime 19141 .summary() 19142 .settings_account_projection 19143 .selected_account 19144 .as_ref() 19145 .expect("first selected account") 19146 .account 19147 .account_id 19148 .clone(); 19149 assert!( 19150 runtime 19151 .generate_local_account(Some("Second".to_owned())) 19152 .expect("second account should generate") 19153 ); 19154 let second_account_id = runtime 19155 .summary() 19156 .settings_account_projection 19157 .selected_account 19158 .as_ref() 19159 .expect("second selected account") 19160 .account 19161 .account_id 19162 .clone(); 19163 save_surface_activation( 19164 &runtime, 19165 first_account_id.as_str(), 19166 ActiveSurface::Farmer, 19167 true, 19168 ); 19169 save_surface_activation( 19170 &runtime, 19171 second_account_id.as_str(), 19172 ActiveSurface::Farmer, 19173 true, 19174 ); 19175 assert!(paths.store_path.exists()); 19176 19177 assert!( 19178 runtime 19179 .reset_local_device_state() 19180 .expect("device state should reset") 19181 ); 19182 let summary = runtime.summary(); 19183 19184 assert_eq!(summary.startup_gate, AppStartupGate::SetupRequired); 19185 assert!(summary.settings_account_projection.roster.is_empty()); 19186 assert!( 19187 summary 19188 .settings_account_projection 19189 .selected_account 19190 .is_none() 19191 ); 19192 assert!(!paths.store_path.exists()); 19193 assert_eq!( 19194 runtime 19195 .lock_state() 19196 .sqlite_store 19197 .as_ref() 19198 .expect("sqlite store") 19199 .load_surface_activation(first_account_id.as_str()) 19200 .expect("first activation should load"), 19201 None 19202 ); 19203 assert_eq!( 19204 runtime 19205 .lock_state() 19206 .sqlite_store 19207 .as_ref() 19208 .expect("sqlite store") 19209 .load_surface_activation(second_account_id.as_str()) 19210 .expect("second activation should load"), 19211 None 19212 ); 19213 19214 cleanup_paths(&paths); 19215 } 19216 19217 #[test] 19218 fn runtime_account_commands_fail_closed_without_accounts_manager() { 19219 let paths = temp_shared_accounts_paths("blocked"); 19220 let runtime = DesktopAppRuntime::from_state(DesktopAppRuntimeState { 19221 state_store: AppStateStore::load(AppStatePersistenceRepository::in_memory()) 19222 .expect("in-memory state store should load"), 19223 nostr_relay_urls: vec!["ws://127.0.0.1:8080".to_owned()], 19224 shared_accounts_paths: Some(paths), 19225 remote_signer_paths: None, 19226 accounts_manager: None, 19227 sqlite_store: Some( 19228 AppSqliteStore::open(DatabaseTarget::InMemory) 19229 .expect("in-memory sqlite store should open"), 19230 ), 19231 sdk_runtime: None, 19232 sync_transport: default_sync_transport(), 19233 runtime_metadata: DesktopAppRuntimeMetadataSummary::default(), 19234 selected_account_pending_sync_write_count: 0, 19235 selected_account_relay_ingest_freshness: AppRelayIngestScopeFreshness::default(), 19236 selected_account_sync_conflicts: Vec::new(), 19237 startup_issue: None, 19238 }); 19239 19240 let error = runtime 19241 .generate_local_account(Some("Blocked".to_owned())) 19242 .expect_err("blocked runtime should fail closed"); 19243 19244 assert!(matches!( 19245 error, 19246 DesktopAppRuntimeCommandError::RuntimeUnavailable 19247 )); 19248 } 19249 19250 fn memory_runtime() -> DesktopAppRuntime { 19251 DesktopAppRuntime::from_state(DesktopAppRuntimeState { 19252 state_store: AppStateStore::load(AppStatePersistenceRepository::in_memory()) 19253 .expect("in-memory state store should load"), 19254 nostr_relay_urls: vec!["ws://127.0.0.1:8080".to_owned()], 19255 shared_accounts_paths: None, 19256 remote_signer_paths: None, 19257 accounts_manager: Some( 19258 RadrootsNostrAccountsManager::new( 19259 Arc::new(RadrootsNostrMemoryAccountStore::new()), 19260 Arc::new(RadrootsNostrSecretVaultMemory::new()), 19261 ) 19262 .expect("memory manager should build"), 19263 ), 19264 sqlite_store: Some( 19265 AppSqliteStore::open(DatabaseTarget::InMemory) 19266 .expect("in-memory sqlite store should open"), 19267 ), 19268 sdk_runtime: None, 19269 sync_transport: default_sync_transport(), 19270 runtime_metadata: DesktopAppRuntimeMetadataSummary::default(), 19271 selected_account_pending_sync_write_count: 0, 19272 selected_account_relay_ingest_freshness: AppRelayIngestScopeFreshness::default(), 19273 selected_account_sync_conflicts: Vec::new(), 19274 startup_issue: None, 19275 }) 19276 } 19277 19278 fn file_backed_runtime(label: &str) -> (DesktopAppRuntime, AppSharedAccountsPaths) { 19279 let paths = temp_shared_accounts_paths(label); 19280 fs::create_dir_all(paths.data_root.as_path()).expect("data root should create"); 19281 fs::create_dir_all(paths.secrets_root.as_path()).expect("secrets root should create"); 19282 19283 ( 19284 DesktopAppRuntime::from_state(DesktopAppRuntimeState { 19285 state_store: AppStateStore::load(AppStatePersistenceRepository::in_memory()) 19286 .expect("in-memory state store should load"), 19287 nostr_relay_urls: vec!["ws://127.0.0.1:8080".to_owned()], 19288 shared_accounts_paths: Some(paths.clone()), 19289 remote_signer_paths: None, 19290 accounts_manager: Some( 19291 RadrootsNostrAccountsManager::new( 19292 Arc::new(RadrootsNostrFileAccountStore::new( 19293 paths.store_path.as_path(), 19294 )), 19295 Arc::new(RadrootsNostrSecretVaultMemory::new()), 19296 ) 19297 .expect("file-backed manager should build"), 19298 ), 19299 sqlite_store: Some( 19300 AppSqliteStore::open(DatabaseTarget::InMemory) 19301 .expect("in-memory sqlite store should open"), 19302 ), 19303 sdk_runtime: None, 19304 sync_transport: default_sync_transport(), 19305 runtime_metadata: DesktopAppRuntimeMetadataSummary::default(), 19306 selected_account_pending_sync_write_count: 0, 19307 selected_account_relay_ingest_freshness: AppRelayIngestScopeFreshness::default(), 19308 selected_account_sync_conflicts: Vec::new(), 19309 startup_issue: None, 19310 }), 19311 paths, 19312 ) 19313 } 19314 19315 fn bootstrapped_runtime(label: &str) -> (DesktopAppRuntime, AppDesktopRuntimePaths) { 19316 let paths = temp_desktop_runtime_paths(label); 19317 let runtime = restart_runtime(paths.clone()); 19318 (runtime, paths) 19319 } 19320 19321 fn restart_runtime(paths: AppDesktopRuntimePaths) -> DesktopAppRuntime { 19322 DesktopAppRuntime::bootstrap_from_paths_with_snapshot( 19323 paths.clone(), 19324 vec!["ws://127.0.0.1:8080".to_owned()], 19325 super::default_runtime_snapshot(), 19326 ) 19327 } 19328 19329 fn restore_sdk_runtime(runtime: &DesktopAppRuntime, paths: &AppDesktopRuntimePaths) { 19330 let sdk_runtime = 19331 super::start_desktop_sdk_runtime(paths, vec!["ws://127.0.0.1:8080".to_owned()]) 19332 .expect("sdk runtime should restart"); 19333 { 19334 let mut handle = runtime.sdk_runtime.lock().expect("sdk runtime lock"); 19335 *handle = Some(sdk_runtime); 19336 } 19337 let status = runtime 19338 .wait_for_sdk_startup(StdDuration::from_secs(5)) 19339 .expect("sdk runtime should be present after restart"); 19340 assert_eq!(status.state, AppSdkLifecycleState::Ready); 19341 } 19342 19343 fn temp_shared_accounts_paths(label: &str) -> AppSharedAccountsPaths { 19344 let suffix = SystemTime::now() 19345 .duration_since(UNIX_EPOCH) 19346 .expect("clock") 19347 .as_nanos(); 19348 let base = std::env::temp_dir().join(format!("radroots_runtime_accounts_{label}_{suffix}")); 19349 19350 AppSharedAccountsPaths { 19351 data_root: base.join("data/shared/accounts"), 19352 secrets_root: base.join("secrets/shared/accounts"), 19353 store_path: base.join("data/shared/accounts/store.json"), 19354 } 19355 } 19356 19357 fn temp_desktop_runtime_paths(label: &str) -> AppDesktopRuntimePaths { 19358 let suffix = SystemTime::now() 19359 .duration_since(UNIX_EPOCH) 19360 .expect("clock") 19361 .as_nanos(); 19362 let home_dir = std::env::temp_dir().join(format!("radroots_runtime_home_{label}_{suffix}")); 19363 AppDesktopRuntimePaths::for_desktop( 19364 AppRuntimePlatform::Macos, 19365 AppRuntimeHostEnvironment { 19366 home_dir: Some(home_dir), 19367 ..AppRuntimeHostEnvironment::default() 19368 }, 19369 ) 19370 .expect("desktop runtime paths should resolve") 19371 } 19372 19373 fn temp_remote_signer_paths(label: &str) -> DesktopRemoteSignerPaths { 19374 let suffix = SystemTime::now() 19375 .duration_since(UNIX_EPOCH) 19376 .expect("clock") 19377 .as_nanos(); 19378 let base = 19379 std::env::temp_dir().join(format!("radroots_runtime_remote_signer_{label}_{suffix}")); 19380 DesktopRemoteSignerPaths { 19381 sessions_path: base.join("data/apps/app/nostr/remote-signer-sessions.json"), 19382 client_secret_root: base.join("secrets/shared/accounts/remote_signer"), 19383 } 19384 } 19385 19386 fn cleanup_remote_signer_paths(paths: &DesktopRemoteSignerPaths) { 19387 if let Some(base) = paths.sessions_path.ancestors().nth(5) { 19388 let _ = fs::remove_dir_all(base); 19389 } 19390 } 19391 19392 fn cleanup_bootstrapped_runtime_paths(paths: &AppDesktopRuntimePaths) { 19393 if let Some(home_dir) = paths.app.data.ancestors().nth(4) { 19394 let _ = fs::remove_dir_all(home_dir); 19395 } 19396 } 19397 19398 fn append_cli_local_listing_records(paths: &AppDesktopRuntimePaths, account_id: &str) { 19399 let database_path = paths 19400 .shared_local_events_database_path() 19401 .expect("shared local events path"); 19402 if let Some(parent) = database_path.parent() { 19403 fs::create_dir_all(parent).expect("shared local events directory should create"); 19404 } 19405 let executor = 19406 SqliteExecutor::open(database_path.as_path()).expect("open shared local events db"); 19407 let store = LocalEventsStore::new(executor); 19408 store.migrate_up().expect("migrate shared local events"); 19409 let farm_key = "AAAAAAAAAAAAAAAAAAAAAA"; 19410 let listing_key = "BBBBBBBBBBBBBBBBBBBBBB"; 19411 store 19412 .append_record(&local_work_record( 19413 "cli:local_work:farm", 19414 account_id, 19415 farm_key, 19416 None, 19417 json!({ 19418 "record_kind": "farm_config_v1", 19419 "document": { 19420 "selection": { 19421 "account": account_id, 19422 "farm_d_tag": farm_key 19423 }, 19424 "profile": { 19425 "name": "Green Farm", 19426 "display_name": "Green Farm" 19427 }, 19428 "farm": { 19429 "d_tag": farm_key, 19430 "name": "Green Farm", 19431 "location": { 19432 "primary": "farmstand" 19433 } 19434 }, 19435 "listing_defaults": { 19436 "delivery_method": "pickup", 19437 "location": { 19438 "primary": "farmstand" 19439 } 19440 } 19441 } 19442 }), 19443 )) 19444 .expect("append farm local work"); 19445 store 19446 .append_record(&local_work_record( 19447 "cli:local_work:listing", 19448 account_id, 19449 farm_key, 19450 Some(format!("30402:seller-pubkey:{listing_key}")), 19451 json!({ 19452 "record_kind": "listing_draft_v1", 19453 "document": { 19454 "listing": { 19455 "d_tag": listing_key, 19456 "farm_d_tag": farm_key 19457 }, 19458 "seller_actor": { 19459 "account_id": account_id, 19460 "pubkey": "seller-pubkey" 19461 }, 19462 "product": { 19463 "key": "eggs", 19464 "title": "Eggs", 19465 "summary": "Fresh eggs" 19466 }, 19467 "primary_bin": { 19468 "quantity_unit": "each", 19469 "price_amount": "6", 19470 "price_currency": "USD" 19471 }, 19472 "inventory": { 19473 "available": "10" 19474 } 19475 } 19476 }), 19477 )) 19478 .expect("append listing local work"); 19479 } 19480 19481 fn append_cli_signed_buyer_listing_record(paths: &AppDesktopRuntimePaths) { 19482 append_cli_signed_buyer_listing_record_with( 19483 paths, 19484 "buyer-visible-listing", 19485 "DDDDDDDDDDDDDDDDDDDDDD", 19486 "Buyer Visible Eggs", 19487 1100, 19488 ); 19489 } 19490 19491 fn append_cli_signed_buyer_listing_record_with( 19492 paths: &AppDesktopRuntimePaths, 19493 record_suffix: &str, 19494 listing_key: &str, 19495 title: &str, 19496 created_at_ms: i64, 19497 ) { 19498 append_cli_signed_buyer_listing_record_with_bin( 19499 paths, 19500 record_suffix, 19501 listing_key, 19502 title, 19503 created_at_ms, 19504 "bin-1", 19505 ); 19506 } 19507 19508 fn append_cli_signed_buyer_listing_record_with_bin( 19509 paths: &AppDesktopRuntimePaths, 19510 record_suffix: &str, 19511 listing_key: &str, 19512 title: &str, 19513 created_at_ms: i64, 19514 bin_id: &str, 19515 ) { 19516 let database_path = paths 19517 .shared_local_events_database_path() 19518 .expect("shared local events path"); 19519 if let Some(parent) = database_path.parent() { 19520 fs::create_dir_all(parent).expect("shared local events directory should create"); 19521 } 19522 let executor = 19523 SqliteExecutor::open(database_path.as_path()).expect("open shared local events db"); 19524 let store = LocalEventsStore::new(executor); 19525 store.migrate_up().expect("migrate shared local events"); 19526 let farm_key = "CCCCCCCCCCCCCCCCCCCCCC"; 19527 let owner_pubkey = BUYER_VISIBLE_SELLER_PUBKEY; 19528 let record_id = format!("cli:signed_event:{record_suffix}"); 19529 let content = json!({ 19530 "d_tag": listing_key, 19531 "status": "active", 19532 "farm": { 19533 "pubkey": owner_pubkey, 19534 "d_tag": farm_key 19535 }, 19536 "product": { 19537 "key": listing_key, 19538 "title": title, 19539 "summary": "Published local eggs" 19540 }, 19541 "availability": { 19542 "kind": "window", 19543 "amount": { 19544 "start": 4_102_444_800u64, 19545 "end": 4_102_531_200u64 19546 } 19547 }, 19548 "delivery_method": { 19549 "kind": "pickup" 19550 }, 19551 "location": { 19552 "primary": "North barn pickup" 19553 } 19554 }); 19555 let event_id = signed_event_id(record_id.as_str()); 19556 store 19557 .append_record(&LocalEventRecordInput { 19558 record_id: record_id.to_owned(), 19559 family: LocalRecordFamily::SignedEvent, 19560 status: LocalRecordStatus::Published, 19561 source_runtime: SourceRuntime::Cli, 19562 created_at_ms, 19563 inserted_at_ms: created_at_ms + 1, 19564 owner_account_id: Some("seller-account".to_owned()), 19565 owner_pubkey: Some(owner_pubkey.to_owned()), 19566 farm_id: Some(farm_key.to_owned()), 19567 listing_addr: Some(format!("30402:{owner_pubkey}:{listing_key}")), 19568 local_work_json: None, 19569 event_id: Some(event_id.clone()), 19570 event_kind: Some(30402), 19571 event_pubkey: Some(owner_pubkey.to_owned()), 19572 event_created_at: Some(created_at_ms), 19573 event_tags_json: Some(json!([ 19574 ["d", listing_key], 19575 ["a", format!("30340:{owner_pubkey}:{farm_key}")], 19576 ["key", listing_key], 19577 ["title", title], 19578 ["summary", "Published local eggs"], 19579 ["radroots:bin", bin_id, "1", "each"], 19580 ["radroots:price", bin_id, "8", "USD", "1", "each"], 19581 ["inventory", "9"], 19582 ["status", "active"], 19583 ["radroots:availability_start", "4102444800"], 19584 ["expires_at", "4102531200"], 19585 ["delivery", "pickup"], 19586 ["location", "North barn pickup"] 19587 ])), 19588 event_content: Some(content.to_string()), 19589 event_sig: Some("signature".to_owned()), 19590 raw_event_json: Some(json!({ 19591 "id": event_id, 19592 "kind": 30402, 19593 "pubkey": owner_pubkey, 19594 "content": content.to_string() 19595 })), 19596 outbox_status: PublishOutboxStatus::Acknowledged, 19597 relay_set_fingerprint: Some("relay-set".to_owned()), 19598 relay_delivery_json: Some(json!({ 19599 "state": "acknowledged", 19600 "acknowledged_relays": ["ws://127.0.0.1:1234/"] 19601 })), 19602 }) 19603 .expect("append signed buyer listing"); 19604 } 19605 19606 struct LinkedBuyerLifecycleFixture { 19607 runtime: DesktopAppRuntime, 19608 paths: AppDesktopRuntimePaths, 19609 order_id: OrderId, 19610 trade_order_id: String, 19611 request_event_id: String, 19612 decision_event_id: String, 19613 listing_addr: String, 19614 buyer_pubkey: String, 19615 seller_pubkey: String, 19616 } 19617 19618 fn linked_buyer_lifecycle_runtime(label: &str) -> LinkedBuyerLifecycleFixture { 19619 linked_buyer_lifecycle_runtime_with_seller_pubkey(label, SDK_TEST_SELLER_PUBLIC_KEY_HEX) 19620 } 19621 19622 fn linked_buyer_request_runtime(label: &str) -> LinkedBuyerLifecycleFixture { 19623 linked_buyer_runtime_with_seller_pubkey(label, SDK_TEST_SELLER_PUBLIC_KEY_HEX, false) 19624 } 19625 19626 fn linked_buyer_lifecycle_runtime_with_seller_pubkey( 19627 label: &str, 19628 seller_pubkey: &str, 19629 ) -> LinkedBuyerLifecycleFixture { 19630 linked_buyer_runtime_with_seller_pubkey(label, seller_pubkey, true) 19631 } 19632 19633 fn linked_buyer_runtime_with_seller_pubkey( 19634 label: &str, 19635 seller_pubkey: &str, 19636 append_decision: bool, 19637 ) -> LinkedBuyerLifecycleFixture { 19638 let (runtime, paths) = bootstrapped_runtime(label); 19639 assert!( 19640 runtime 19641 .import_local_account(DesktopLocalIdentityImportRequest::raw_secret_key( 19642 SDK_TEST_BUYER_SECRET_KEY_HEX, 19643 )) 19644 .expect("buyer account should import") 19645 ); 19646 assert!( 19647 runtime 19648 .select_active_surface(ActiveSurface::Personal) 19649 .expect("buyer surface should select") 19650 ); 19651 let buyer_account_id = runtime 19652 .summary() 19653 .settings_account_projection 19654 .selected_account 19655 .as_ref() 19656 .expect("selected buyer account") 19657 .account 19658 .account_id 19659 .clone(); 19660 let buyer_pubkey = runtime 19661 .lock_state() 19662 .accounts_manager 19663 .as_ref() 19664 .expect("accounts manager") 19665 .resolve_account_selector(buyer_account_id.as_str()) 19666 .expect("selected buyer account should resolve") 19667 .public_identity 19668 .public_key_hex; 19669 let farm_key = super::d_tag_from_uuid(FarmId::new().as_uuid()); 19670 let listing_key = super::d_tag_from_uuid(ProductId::new().as_uuid()); 19671 let listing_addr = format!("30402:{seller_pubkey}:{listing_key}"); 19672 let listing_event_id = signed_listing_event_id(label); 19673 let trade_order_id = format!("{label}-trade-order"); 19674 let order_id = 19675 projected_order_id_from_trade_request(trade_order_id.as_str(), buyer_pubkey.as_str()); 19676 append_app_signed_listing_record( 19677 &paths, 19678 "linked-seller-account", 19679 seller_pubkey, 19680 farm_key.as_str(), 19681 listing_key.as_str(), 19682 listing_event_id.as_str(), 19683 6, 19684 ); 19685 let request_event_id = append_signed_order_request_record( 19686 &paths, 19687 trade_order_id.as_str(), 19688 listing_addr.as_str(), 19689 listing_event_id.as_str(), 19690 buyer_pubkey.as_str(), 19691 seller_pubkey, 19692 2, 19693 ); 19694 let decision_event_id = if append_decision { 19695 append_signed_order_decision_record( 19696 &paths, 19697 trade_order_id.as_str(), 19698 request_event_id.as_str(), 19699 listing_addr.as_str(), 19700 buyer_pubkey.as_str(), 19701 seller_pubkey, 19702 2, 19703 ) 19704 } else { 19705 String::new() 19706 }; 19707 LinkedBuyerLifecycleFixture { 19708 runtime, 19709 paths, 19710 order_id, 19711 trade_order_id, 19712 request_event_id, 19713 decision_event_id, 19714 listing_addr, 19715 buyer_pubkey, 19716 seller_pubkey: seller_pubkey.to_owned(), 19717 } 19718 } 19719 19720 fn seller_order_decision_runtime( 19721 label: &str, 19722 stock_count: u32, 19723 order_quantity: u32, 19724 ) -> ( 19725 DesktopAppRuntime, 19726 AppDesktopRuntimePaths, 19727 OrderId, 19728 ProductId, 19729 String, 19730 String, 19731 ) { 19732 let (runtime, paths) = bootstrapped_runtime(label); 19733 let (account_id, farm_id) = 19734 provision_ready_farmer_account_from_secret(&runtime, SDK_TEST_SELLER_SECRET_KEY_HEX); 19735 runtime.lock_state_mut().nostr_relay_urls = vec!["wss://relay.example".to_owned()]; 19736 let seller_pubkey = runtime 19737 .lock_state() 19738 .accounts_manager 19739 .as_ref() 19740 .expect("accounts manager") 19741 .resolve_account_selector(account_id.as_str()) 19742 .expect("selected seller account should resolve") 19743 .public_identity 19744 .public_key_hex; 19745 let buyer_pubkey = SDK_TEST_BUYER_PUBLIC_KEY_HEX.to_owned(); 19746 let product_id = ProductId::new(); 19747 let trade_order_id = "seller-order-decision-1"; 19748 let order_id = projected_order_id_from_trade_request(trade_order_id, buyer_pubkey.as_str()); 19749 let farm_key = super::d_tag_from_uuid(farm_id.as_uuid()); 19750 let listing_key = super::d_tag_from_uuid(product_id.as_uuid()); 19751 let listing_addr = format!("30402:{seller_pubkey}:{listing_key}"); 19752 let listing_event_id = signed_listing_event_id("seller-order-decision"); 19753 append_app_signed_listing_record( 19754 &paths, 19755 account_id.as_str(), 19756 seller_pubkey.as_str(), 19757 farm_key.as_str(), 19758 listing_key.as_str(), 19759 listing_event_id.as_str(), 19760 stock_count, 19761 ); 19762 append_signed_order_request_record( 19763 &paths, 19764 trade_order_id, 19765 listing_addr.as_str(), 19766 listing_event_id.as_str(), 19767 buyer_pubkey.as_str(), 19768 seller_pubkey.as_str(), 19769 order_quantity, 19770 ); 19771 19772 ( 19773 runtime, 19774 paths, 19775 order_id, 19776 product_id, 19777 seller_pubkey, 19778 buyer_pubkey, 19779 ) 19780 } 19781 19782 fn seller_order_decision_sdk_runtime( 19783 label: &str, 19784 stock_count: u32, 19785 order_quantity: u32, 19786 ) -> ( 19787 DesktopAppRuntime, 19788 AppDesktopRuntimePaths, 19789 OrderId, 19790 ProductId, 19791 String, 19792 String, 19793 ) { 19794 let (runtime, paths) = bootstrapped_runtime(label); 19795 let (account_id, farm_id) = 19796 provision_ready_farmer_account_from_secret(&runtime, SDK_TEST_SELLER_SECRET_KEY_HEX); 19797 runtime.lock_state_mut().nostr_relay_urls = vec!["wss://relay.example".to_owned()]; 19798 let seller_pubkey = runtime 19799 .lock_state() 19800 .accounts_manager 19801 .as_ref() 19802 .expect("accounts manager") 19803 .resolve_account_selector(account_id.as_str()) 19804 .expect("selected seller account should resolve") 19805 .public_identity 19806 .public_key_hex; 19807 let buyer_pubkey = SDK_TEST_BUYER_PUBLIC_KEY_HEX.to_owned(); 19808 let product_id = ProductId::new(); 19809 let trade_order_id = "seller-order-decision-1"; 19810 let order_id = projected_order_id_from_trade_request(trade_order_id, buyer_pubkey.as_str()); 19811 let farm_key = super::d_tag_from_uuid(farm_id.as_uuid()); 19812 let listing_key = super::d_tag_from_uuid(product_id.as_uuid()); 19813 let listing_addr = format!("30402:{seller_pubkey}:{listing_key}"); 19814 let listing_event_id = signed_listing_event_id("seller-order-decision"); 19815 append_app_signed_listing_record( 19816 &paths, 19817 account_id.as_str(), 19818 seller_pubkey.as_str(), 19819 farm_key.as_str(), 19820 listing_key.as_str(), 19821 listing_event_id.as_str(), 19822 stock_count, 19823 ); 19824 append_verified_signed_order_request_record( 19825 &paths, 19826 trade_order_id, 19827 listing_addr.as_str(), 19828 listing_event_id.as_str(), 19829 buyer_pubkey.as_str(), 19830 seller_pubkey.as_str(), 19831 order_quantity, 19832 ); 19833 19834 ( 19835 runtime, 19836 paths, 19837 order_id, 19838 product_id, 19839 seller_pubkey, 19840 buyer_pubkey, 19841 ) 19842 } 19843 19844 fn publish_prior_relay_seller_order_accept( 19845 runtime: &DesktopAppRuntime, 19846 relay: &ThreadedAckRelay, 19847 order_id: OrderId, 19848 product_id: ProductId, 19849 seller_pubkey: &str, 19850 buyer_pubkey: &str, 19851 ) { 19852 let (account_id, farm_id, accounts_manager) = { 19853 let state = runtime.lock_state(); 19854 let selected_account = state 19855 .state_store 19856 .identity_projection() 19857 .selected_account 19858 .as_ref() 19859 .expect("selected seller account"); 19860 ( 19861 selected_account.account.account_id.clone(), 19862 state.selected_farm_id().expect("selected farm"), 19863 state 19864 .accounts_manager 19865 .as_ref() 19866 .expect("accounts manager") 19867 .clone(), 19868 ) 19869 }; 19870 let listing_key = super::d_tag_from_uuid(product_id.as_uuid()); 19871 let request_event_id = runtime 19872 .lock_state() 19873 .resolve_seller_order_request_evidence(order_id) 19874 .expect("seller request evidence should resolve") 19875 .request_event 19876 .id; 19877 let payload = AppPublishPayload::OrderDecision(AppOrderDecisionPublishPayload { 19878 context: AppPublishContext::new(account_id.clone(), "seller_order_decision"), 19879 app_order_id: order_id, 19880 farm_id, 19881 trade_order_id: "seller-order-decision-1".to_owned(), 19882 request_event_id, 19883 listing_event_id: Some(signed_listing_event_id("seller-order-decision")), 19884 listing_addr: format!("30402:{seller_pubkey}:{listing_key}"), 19885 buyer_pubkey: buyer_pubkey.to_owned(), 19886 seller_pubkey: seller_pubkey.to_owned(), 19887 decision: AppOrderDecisionPayload::Accepted { 19888 inventory_commitments: vec![AppOrderDecisionInventoryCommitment { 19889 bin_id: "seller-order-primary-bin".to_owned(), 19890 bin_count: 2, 19891 }], 19892 }, 19893 }); 19894 let operation = PendingSyncOperation::from_publish_payload(payload, "2026-05-24T12:00:00Z") 19895 .expect("prior order decision publish work should serialize"); 19896 let payload = operation 19897 .publish_payload() 19898 .expect("prior order decision operation should carry payload"); 19899 let AppPublishPayload::OrderDecision(payload) = payload else { 19900 panic!("prior order decision operation should carry order decision payload") 19901 }; 19902 let account_id = 19903 RadrootsIdentityId::parse(account_id.as_str()).expect("selected account id"); 19904 let identity = accounts_manager 19905 .get_signing_identity(&account_id) 19906 .expect("seller signer lookup should succeed") 19907 .expect("seller account should have local signer"); 19908 let request_event_id = test_event_id(payload.request_event_id.as_str()); 19909 let decision = order_decision_publish_payload_to_sdk_decision(&payload) 19910 .expect("order decision payload should convert to SDK decision"); 19911 let parts = radroots_sdk::protocol::order::build_order_decision_draft( 19912 &request_event_id, 19913 &request_event_id, 19914 &decision, 19915 ) 19916 .expect("order decision draft should build") 19917 .into_wire_parts(); 19918 let event = radroots_nostr_build_event(parts.kind, parts.content, parts.tags) 19919 .expect("order decision event builder should build") 19920 .sign_with_keys(identity.keys()) 19921 .expect("order decision event should sign"); 19922 publish_signed_test_event_to_relay(relay, &event); 19923 assert_eq!(relay.event_count(), 1); 19924 } 19925 19926 fn append_app_signed_listing_record( 19927 paths: &AppDesktopRuntimePaths, 19928 account_id: &str, 19929 seller_pubkey: &str, 19930 farm_key: &str, 19931 listing_key: &str, 19932 listing_event_id: &str, 19933 stock_count: u32, 19934 ) { 19935 let database_path = paths 19936 .shared_local_events_database_path() 19937 .expect("shared local events path"); 19938 if let Some(parent) = database_path.parent() { 19939 fs::create_dir_all(parent).expect("shared local events directory should create"); 19940 } 19941 let executor = 19942 SqliteExecutor::open(database_path.as_path()).expect("open shared local events db"); 19943 let store = LocalEventsStore::new(executor); 19944 store.migrate_up().expect("migrate shared local events"); 19945 let listing_addr = format!("30402:{seller_pubkey}:{listing_key}"); 19946 let listing_event_id = test_event_id_seed(listing_event_id); 19947 let content = json!({ 19948 "d_tag": listing_key, 19949 "status": "active", 19950 "farm": { 19951 "pubkey": seller_pubkey, 19952 "d_tag": farm_key 19953 }, 19954 "product": { 19955 "key": listing_key, 19956 "title": "Seller decision lettuce", 19957 "summary": "Signed listing for seller decision tests" 19958 }, 19959 "availability": { 19960 "kind": "window", 19961 "amount": { 19962 "start": 4_102_444_800u64, 19963 "end": 4_102_531_200u64 19964 } 19965 }, 19966 "delivery_method": { 19967 "kind": "pickup" 19968 }, 19969 "location": { 19970 "primary": "North barn pickup" 19971 } 19972 }); 19973 store 19974 .append_record(&LocalEventRecordInput { 19975 record_id: "app:signed_event:listing:seller-order-decision".to_owned(), 19976 family: LocalRecordFamily::SignedEvent, 19977 status: LocalRecordStatus::Published, 19978 source_runtime: SourceRuntime::App, 19979 created_at_ms: 1_774_000_000_000, 19980 inserted_at_ms: 1_774_000_000_001, 19981 owner_account_id: Some(account_id.to_owned()), 19982 owner_pubkey: Some(seller_pubkey.to_owned()), 19983 farm_id: Some(farm_key.to_owned()), 19984 listing_addr: Some(listing_addr), 19985 local_work_json: None, 19986 event_id: Some(listing_event_id.clone()), 19987 event_kind: Some(30402), 19988 event_pubkey: Some(seller_pubkey.to_owned()), 19989 event_created_at: Some(1_774_000_000), 19990 event_tags_json: Some(json!([ 19991 ["d", listing_key], 19992 ["a", format!("30340:{seller_pubkey}:{farm_key}")], 19993 ["key", listing_key], 19994 ["title", "Seller decision lettuce"], 19995 ["summary", "Signed listing for seller decision tests"], 19996 ["radroots:bin", "seller-order-primary-bin", "1", "each"], 19997 [ 19998 "radroots:price", 19999 "seller-order-primary-bin", 20000 "8", 20001 "USD", 20002 "1", 20003 "each" 20004 ], 20005 ["inventory", stock_count.to_string()], 20006 ["status", "active"], 20007 ["radroots:availability_start", "4102444800"], 20008 ["expires_at", "4102531200"], 20009 ["delivery", "pickup"], 20010 ["location", "North barn pickup"] 20011 ])), 20012 event_content: Some(content.to_string()), 20013 event_sig: Some("signature".to_owned()), 20014 raw_event_json: Some(json!({ 20015 "id": listing_event_id, 20016 "kind": 30402, 20017 "pubkey": seller_pubkey, 20018 "content": content.to_string() 20019 })), 20020 outbox_status: PublishOutboxStatus::Acknowledged, 20021 relay_set_fingerprint: Some("relay-set".to_owned()), 20022 relay_delivery_json: Some(json!({ 20023 "state": "acknowledged", 20024 "target_relays": ["wss://relay.example"], 20025 "connected_relays": ["wss://relay.example"], 20026 "acknowledged_relays": ["wss://relay.example"] 20027 })), 20028 }) 20029 .expect("append app signed listing"); 20030 } 20031 20032 fn append_signed_order_request_record( 20033 paths: &AppDesktopRuntimePaths, 20034 trade_order_id: &str, 20035 listing_addr: &str, 20036 listing_event_id: &str, 20037 buyer_pubkey: &str, 20038 seller_pubkey: &str, 20039 order_quantity: u32, 20040 ) -> String { 20041 let database_path = paths 20042 .shared_local_events_database_path() 20043 .expect("shared local events path"); 20044 if let Some(parent) = database_path.parent() { 20045 fs::create_dir_all(parent).expect("shared local events directory should create"); 20046 } 20047 let executor = 20048 SqliteExecutor::open(database_path.as_path()).expect("open shared local events db"); 20049 let store = LocalEventsStore::new(executor); 20050 store.migrate_up().expect("migrate shared local events"); 20051 let order = RadrootsOrderRequest { 20052 order_id: test_order_id(trade_order_id), 20053 listing_addr: test_listing_addr(listing_addr), 20054 buyer_pubkey: test_pubkey(buyer_pubkey), 20055 seller_pubkey: test_pubkey(seller_pubkey), 20056 items: vec![RadrootsOrderItem { 20057 bin_id: test_bin_id("seller-order-primary-bin"), 20058 bin_count: order_quantity, 20059 }], 20060 economics: signed_order_request_economics(trade_order_id, order_quantity), 20061 }; 20062 let parts = radroots_sdk::protocol::order::build_order_request_draft( 20063 &RadrootsNostrEventPtr { 20064 id: test_event_id_seed(listing_event_id), 20065 relays: Some("wss://relay.example".to_owned()), 20066 }, 20067 &order, 20068 ) 20069 .expect("order request draft should build") 20070 .into_wire_parts(); 20071 let record_id = format!("app:signed_event:order-request:{trade_order_id}"); 20072 let event_id = signed_event_id(record_id.as_str()); 20073 let event = test_event_from_parts( 20074 record_id.as_str(), 20075 event_id, 20076 buyer_pubkey, 20077 1_774_000_010, 20078 parts, 20079 ); 20080 let stored_event_id = event.id.clone(); 20081 let relay_delivery_json = RelayDeliveryEvidence::acknowledged( 20082 ["wss://relay.example"], 20083 ["wss://relay.example"], 20084 ["wss://relay.example"], 20085 Vec::new(), 20086 ) 20087 .expect("acknowledged relay delivery evidence") 20088 .to_json_value() 20089 .expect("acknowledged relay delivery json"); 20090 store 20091 .append_record(&LocalEventRecordInput { 20092 record_id, 20093 family: LocalRecordFamily::SignedEvent, 20094 status: LocalRecordStatus::Published, 20095 source_runtime: SourceRuntime::Test, 20096 created_at_ms: 1_774_000_010_000, 20097 inserted_at_ms: 1_774_000_010_001, 20098 owner_account_id: None, 20099 owner_pubkey: Some(event.author.clone()), 20100 farm_id: None, 20101 listing_addr: Some(listing_addr.to_owned()), 20102 local_work_json: None, 20103 event_id: Some(event.id.clone()), 20104 event_kind: Some(i64::from(event.kind)), 20105 event_pubkey: Some(event.author.clone()), 20106 event_created_at: Some(i64::from(event.created_at)), 20107 event_tags_json: Some(json!(event.tags.clone())), 20108 event_content: Some(event.content.clone()), 20109 event_sig: Some(event.sig.clone()), 20110 raw_event_json: Some(json!({ 20111 "id": event.id.clone(), 20112 "kind": event.kind, 20113 "pubkey": event.author.clone(), 20114 "tags": event.tags.clone(), 20115 "content": event.content.clone(), 20116 "sig": event.sig.clone() 20117 })), 20118 outbox_status: PublishOutboxStatus::Acknowledged, 20119 relay_set_fingerprint: Some("relay-set".to_owned()), 20120 relay_delivery_json: Some(relay_delivery_json), 20121 }) 20122 .expect("append signed order request"); 20123 stored_event_id 20124 } 20125 20126 fn append_verified_signed_order_request_record( 20127 paths: &AppDesktopRuntimePaths, 20128 trade_order_id: &str, 20129 listing_addr: &str, 20130 listing_event_id: &str, 20131 buyer_pubkey: &str, 20132 seller_pubkey: &str, 20133 order_quantity: u32, 20134 ) { 20135 assert_eq!(buyer_pubkey, SDK_TEST_BUYER_PUBLIC_KEY_HEX); 20136 let database_path = paths 20137 .shared_local_events_database_path() 20138 .expect("shared local events path"); 20139 if let Some(parent) = database_path.parent() { 20140 fs::create_dir_all(parent).expect("shared local events directory should create"); 20141 } 20142 let executor = 20143 SqliteExecutor::open(database_path.as_path()).expect("open shared local events db"); 20144 let store = LocalEventsStore::new(executor); 20145 store.migrate_up().expect("migrate shared local events"); 20146 let order = RadrootsOrderRequest { 20147 order_id: test_order_id(trade_order_id), 20148 listing_addr: test_listing_addr(listing_addr), 20149 buyer_pubkey: test_pubkey(buyer_pubkey), 20150 seller_pubkey: test_pubkey(seller_pubkey), 20151 items: vec![RadrootsOrderItem { 20152 bin_id: test_bin_id("seller-order-primary-bin"), 20153 bin_count: order_quantity, 20154 }], 20155 economics: signed_order_request_economics(trade_order_id, order_quantity), 20156 }; 20157 let parts = radroots_sdk::protocol::order::build_order_request_draft( 20158 &RadrootsNostrEventPtr { 20159 id: test_event_id_seed(listing_event_id), 20160 relays: Some("wss://relay.example".to_owned()), 20161 }, 20162 &order, 20163 ) 20164 .expect("order request draft should build") 20165 .into_wire_parts(); 20166 let secret_key = RadrootsNostrSecretKey::from_hex(SDK_TEST_BUYER_SECRET_KEY_HEX) 20167 .expect("SDK test buyer secret key should parse"); 20168 let keys = RadrootsNostrKeys::new(secret_key); 20169 let signed_event = radroots_nostr_build_event(parts.kind, parts.content, parts.tags) 20170 .expect("order request event should build") 20171 .custom_created_at(RadrootsNostrTimestamp::from_secs(1_774_000_010)) 20172 .sign_with_keys(&keys) 20173 .expect("order request event should sign"); 20174 let event = radroots_event_from_nostr(&signed_event); 20175 let record_id = format!("app:signed_event:order-request:{trade_order_id}"); 20176 let relay_delivery_json = RelayDeliveryEvidence::acknowledged( 20177 ["wss://relay.example"], 20178 ["wss://relay.example"], 20179 ["wss://relay.example"], 20180 Vec::new(), 20181 ) 20182 .expect("acknowledged relay delivery evidence") 20183 .to_json_value() 20184 .expect("acknowledged relay delivery json"); 20185 store 20186 .append_record(&LocalEventRecordInput { 20187 record_id, 20188 family: LocalRecordFamily::SignedEvent, 20189 status: LocalRecordStatus::Published, 20190 source_runtime: SourceRuntime::Test, 20191 created_at_ms: 1_774_000_010_000, 20192 inserted_at_ms: 1_774_000_010_001, 20193 owner_account_id: None, 20194 owner_pubkey: Some(event.author.clone()), 20195 farm_id: None, 20196 listing_addr: Some(listing_addr.to_owned()), 20197 local_work_json: None, 20198 event_id: Some(event.id.clone()), 20199 event_kind: Some(i64::from(event.kind)), 20200 event_pubkey: Some(event.author.clone()), 20201 event_created_at: Some(i64::from(event.created_at)), 20202 event_tags_json: Some(json!(event.tags.clone())), 20203 event_content: Some(event.content.clone()), 20204 event_sig: Some(event.sig.clone()), 20205 raw_event_json: Some( 20206 serde_json::to_value(&event).expect("SDK test event should serialize"), 20207 ), 20208 outbox_status: PublishOutboxStatus::Acknowledged, 20209 relay_set_fingerprint: Some("relay-set".to_owned()), 20210 relay_delivery_json: Some(relay_delivery_json), 20211 }) 20212 .expect("append verified signed order request"); 20213 } 20214 20215 fn signed_order_request_economics( 20216 trade_order_id: &str, 20217 order_quantity: u32, 20218 ) -> RadrootsOrderEconomics { 20219 let currency = RadrootsCoreCurrency::USD; 20220 let unit_price_minor_units = 800_u32; 20221 let total_minor_units = unit_price_minor_units 20222 .checked_mul(order_quantity) 20223 .expect("order total should fit"); 20224 20225 RadrootsOrderEconomics { 20226 quote_id: test_quote_id(format!("{trade_order_id}-quote").as_str()), 20227 quote_version: 1, 20228 pricing_basis: RadrootsOrderPricingBasis::ListingEvent, 20229 currency, 20230 items: vec![RadrootsOrderEconomicItem { 20231 bin_id: test_bin_id("seller-order-primary-bin"), 20232 bin_count: order_quantity, 20233 quantity_amount: RadrootsCoreDecimal::from(1u32), 20234 quantity_unit: RadrootsCoreUnit::Each, 20235 unit_price_amount: RadrootsCoreDecimal::from(8u32), 20236 unit_price_currency: currency, 20237 line_subtotal: RadrootsCoreMoney::from_minor_units_u32(total_minor_units, currency), 20238 }], 20239 discounts: Vec::new(), 20240 adjustments: Vec::new(), 20241 subtotal: RadrootsCoreMoney::from_minor_units_u32(total_minor_units, currency), 20242 discount_total: RadrootsCoreMoney::zero(currency), 20243 adjustment_total: RadrootsCoreMoney::zero(currency), 20244 total: RadrootsCoreMoney::from_minor_units_u32(total_minor_units, currency), 20245 } 20246 } 20247 20248 fn append_signed_order_decision_record( 20249 paths: &AppDesktopRuntimePaths, 20250 trade_order_id: &str, 20251 request_event_id: &str, 20252 listing_addr: &str, 20253 buyer_pubkey: &str, 20254 seller_pubkey: &str, 20255 order_quantity: u32, 20256 ) -> String { 20257 let payload = RadrootsOrderDecision { 20258 order_id: test_order_id(trade_order_id), 20259 listing_addr: test_listing_addr(listing_addr), 20260 buyer_pubkey: test_pubkey(buyer_pubkey), 20261 seller_pubkey: test_pubkey(seller_pubkey), 20262 decision: RadrootsOrderDecisionOutcome::Accepted { 20263 inventory_commitments: vec![RadrootsOrderInventoryCommitment { 20264 bin_id: test_bin_id("seller-order-primary-bin"), 20265 bin_count: order_quantity, 20266 }], 20267 }, 20268 }; 20269 let request_event_id = test_event_id(request_event_id); 20270 let parts = radroots_sdk::protocol::order::build_order_decision_draft( 20271 &request_event_id, 20272 &request_event_id, 20273 &payload, 20274 ) 20275 .expect("order decision draft should build") 20276 .into_wire_parts(); 20277 let record_id = format!("app:signed_event:order-decision:{trade_order_id}"); 20278 append_trade_signed_event_record( 20279 paths, 20280 record_id.as_str(), 20281 seller_pubkey, 20282 listing_addr, 20283 parts, 20284 ) 20285 } 20286 20287 fn append_signed_order_cancellation_record_with_prev( 20288 paths: &AppDesktopRuntimePaths, 20289 trade_order_id: &str, 20290 event_key: &str, 20291 request_event_id: &str, 20292 prev_event_id: &str, 20293 listing_addr: &str, 20294 buyer_pubkey: &str, 20295 seller_pubkey: &str, 20296 ) -> String { 20297 let request_event_id = test_event_id(request_event_id); 20298 let prev_event_id = test_event_id(prev_event_id); 20299 let payload = RadrootsOrderCancellation { 20300 order_id: test_order_id(trade_order_id), 20301 listing_addr: test_listing_addr(listing_addr), 20302 buyer_pubkey: test_pubkey(buyer_pubkey), 20303 seller_pubkey: test_pubkey(seller_pubkey), 20304 reason: "buyer cancelled order".to_owned(), 20305 }; 20306 let parts = radroots_sdk::protocol::order::build_order_cancellation_draft( 20307 &request_event_id, 20308 &prev_event_id, 20309 &payload, 20310 ) 20311 .expect("order cancellation draft should build") 20312 .into_wire_parts(); 20313 let record_id = format!("app:signed_event:cancellation:{event_key}"); 20314 append_trade_signed_event_record( 20315 paths, 20316 record_id.as_str(), 20317 buyer_pubkey, 20318 listing_addr, 20319 parts, 20320 ) 20321 } 20322 20323 fn append_signed_order_revision_proposal_record_with_prev( 20324 paths: &AppDesktopRuntimePaths, 20325 trade_order_id: &str, 20326 event_key: &str, 20327 request_event_id: &str, 20328 prev_event_id: &str, 20329 listing_addr: &str, 20330 buyer_pubkey: &str, 20331 seller_pubkey: &str, 20332 ) -> String { 20333 let request_event_id = test_event_id(request_event_id); 20334 let prev_event_id = test_event_id(prev_event_id); 20335 let payload = RadrootsOrderRevisionProposal { 20336 revision_id: test_revision_id(format!("revision-{event_key}").as_str()), 20337 order_id: test_order_id(trade_order_id), 20338 listing_addr: test_listing_addr(listing_addr), 20339 buyer_pubkey: test_pubkey(buyer_pubkey), 20340 seller_pubkey: test_pubkey(seller_pubkey), 20341 root_event_id: request_event_id.clone(), 20342 prev_event_id: prev_event_id.clone(), 20343 items: revision_test_order_items(), 20344 economics: revision_test_order_economics(), 20345 reason: "harvest count updated".to_owned(), 20346 }; 20347 let parts = radroots_sdk::protocol::order::build_order_revision_proposal_draft( 20348 &request_event_id, 20349 &prev_event_id, 20350 &payload, 20351 ) 20352 .expect("order revision proposal draft should build") 20353 .into_wire_parts(); 20354 let record_id = format!("app:signed_event:revision-proposal:{event_key}"); 20355 append_trade_signed_event_record( 20356 paths, 20357 record_id.as_str(), 20358 seller_pubkey, 20359 listing_addr, 20360 parts, 20361 ) 20362 } 20363 20364 fn revision_test_order_items() -> Vec<RadrootsOrderItem> { 20365 vec![RadrootsOrderItem { 20366 bin_id: test_bin_id("seller-order-primary-bin"), 20367 bin_count: 3, 20368 }] 20369 } 20370 20371 fn revision_test_order_economics() -> RadrootsOrderEconomics { 20372 RadrootsOrderEconomics { 20373 quote_id: test_quote_id("quote-revision-test"), 20374 quote_version: 2, 20375 pricing_basis: RadrootsOrderPricingBasis::ListingEvent, 20376 currency: RadrootsCoreCurrency::USD, 20377 items: vec![RadrootsOrderEconomicItem { 20378 bin_id: test_bin_id("seller-order-primary-bin"), 20379 bin_count: 3, 20380 quantity_amount: RadrootsCoreDecimal::from(1u32), 20381 quantity_unit: RadrootsCoreUnit::Each, 20382 unit_price_amount: RadrootsCoreDecimal::from(8u32), 20383 unit_price_currency: RadrootsCoreCurrency::USD, 20384 line_subtotal: RadrootsCoreMoney::from_minor_units_u32( 20385 2400, 20386 RadrootsCoreCurrency::USD, 20387 ), 20388 }], 20389 discounts: Vec::new(), 20390 adjustments: Vec::new(), 20391 subtotal: RadrootsCoreMoney::from_minor_units_u32(2400, RadrootsCoreCurrency::USD), 20392 discount_total: RadrootsCoreMoney::zero(RadrootsCoreCurrency::USD), 20393 adjustment_total: RadrootsCoreMoney::zero(RadrootsCoreCurrency::USD), 20394 total: RadrootsCoreMoney::from_minor_units_u32(2400, RadrootsCoreCurrency::USD), 20395 } 20396 } 20397 20398 fn assert_order_lifecycle_evidence_invalid(error: AppSqliteError) { 20399 assert!( 20400 matches!( 20401 error, 20402 AppSqliteError::InvalidProjection { 20403 reason: "order lifecycle evidence is invalid" 20404 } 20405 ), 20406 "{error:?}" 20407 ); 20408 } 20409 20410 fn assert_invalid_projection_reason(error: AppSqliteError, expected_reason: &'static str) { 20411 assert!( 20412 matches!( 20413 error, 20414 AppSqliteError::InvalidProjection { reason } if reason == expected_reason 20415 ), 20416 "{error:?}" 20417 ); 20418 } 20419 20420 fn append_trade_signed_event_record( 20421 paths: &AppDesktopRuntimePaths, 20422 record_id: &str, 20423 event_pubkey: &str, 20424 listing_addr: &str, 20425 parts: WireEventParts, 20426 ) -> String { 20427 let database_path = paths 20428 .shared_local_events_database_path() 20429 .expect("shared local events path"); 20430 if let Some(parent) = database_path.parent() { 20431 fs::create_dir_all(parent).expect("shared local events directory should create"); 20432 } 20433 let executor = 20434 SqliteExecutor::open(database_path.as_path()).expect("open shared local events db"); 20435 let store = LocalEventsStore::new(executor); 20436 store.migrate_up().expect("migrate shared local events"); 20437 let created_at = test_event_created_at(record_id, 1_774_000_020); 20438 let event = test_event_from_parts( 20439 record_id, 20440 signed_event_id(record_id), 20441 event_pubkey, 20442 created_at, 20443 parts, 20444 ); 20445 let stored_event_id = event.id.clone(); 20446 let relay_delivery_json = RelayDeliveryEvidence::acknowledged( 20447 ["wss://relay.example"], 20448 ["wss://relay.example"], 20449 ["wss://relay.example"], 20450 Vec::new(), 20451 ) 20452 .expect("acknowledged relay delivery evidence") 20453 .to_json_value() 20454 .expect("acknowledged relay delivery json"); 20455 store 20456 .append_record(&LocalEventRecordInput { 20457 record_id: record_id.to_owned(), 20458 family: LocalRecordFamily::SignedEvent, 20459 status: LocalRecordStatus::Published, 20460 source_runtime: SourceRuntime::Test, 20461 created_at_ms: i64::from(created_at) * 1000, 20462 inserted_at_ms: i64::from(created_at) * 1000 + 1, 20463 owner_account_id: None, 20464 owner_pubkey: Some(event.author.clone()), 20465 farm_id: None, 20466 listing_addr: Some(listing_addr.to_owned()), 20467 local_work_json: None, 20468 event_id: Some(event.id.clone()), 20469 event_kind: Some(i64::from(event.kind)), 20470 event_pubkey: Some(event.author.clone()), 20471 event_created_at: Some(i64::from(event.created_at)), 20472 event_tags_json: Some(json!(event.tags.clone())), 20473 event_content: Some(event.content.clone()), 20474 event_sig: Some(event.sig.clone()), 20475 raw_event_json: Some(json!({ 20476 "id": event.id.clone(), 20477 "kind": event.kind, 20478 "pubkey": event.author.clone(), 20479 "tags": event.tags.clone(), 20480 "content": event.content.clone(), 20481 "sig": event.sig.clone() 20482 })), 20483 outbox_status: PublishOutboxStatus::Acknowledged, 20484 relay_set_fingerprint: Some("relay-set".to_owned()), 20485 relay_delivery_json: Some(relay_delivery_json), 20486 }) 20487 .expect("append signed trade event"); 20488 stored_event_id 20489 } 20490 20491 fn mark_shared_seller_order_request_evidence_pending(paths: &AppDesktopRuntimePaths) { 20492 let database_path = paths 20493 .shared_local_events_database_path() 20494 .expect("shared local events path"); 20495 let executor = 20496 SqliteExecutor::open(database_path.as_path()).expect("open shared local events db"); 20497 executor 20498 .exec( 20499 "UPDATE local_event_record 20500 SET status = 'pending_publish', 20501 outbox_status = 'pending', 20502 relay_set_fingerprint = NULL, 20503 relay_delivery_json = NULL 20504 WHERE record_id = 'app:signed_event:order-request:seller-order-decision-1'", 20505 "[]", 20506 ) 20507 .expect("mark shared order request evidence pending"); 20508 } 20509 20510 fn append_unrelated_signed_event_records(paths: &AppDesktopRuntimePaths, count: usize) { 20511 let database_path = paths 20512 .shared_local_events_database_path() 20513 .expect("shared local events path"); 20514 let executor = 20515 SqliteExecutor::open(database_path.as_path()).expect("open shared local events db"); 20516 let store = LocalEventsStore::new(executor); 20517 store.migrate_up().expect("migrate shared local events"); 20518 let pubkey = "2222222222222222222222222222222222222222222222222222222222222222"; 20519 20520 for index in 0..count { 20521 let record_id = format!("app:signed_event:unrelated:{index}"); 20522 let event_id = signed_event_id(record_id.as_str()); 20523 store 20524 .append_record(&LocalEventRecordInput { 20525 record_id, 20526 family: LocalRecordFamily::SignedEvent, 20527 status: LocalRecordStatus::Published, 20528 source_runtime: SourceRuntime::Test, 20529 created_at_ms: 1_774_000_100_000 + i64::try_from(index).unwrap_or_default(), 20530 inserted_at_ms: 1_774_000_100_001 + i64::try_from(index).unwrap_or_default(), 20531 owner_account_id: None, 20532 owner_pubkey: Some(pubkey.to_owned()), 20533 farm_id: None, 20534 listing_addr: None, 20535 local_work_json: None, 20536 event_id: Some(event_id.clone()), 20537 event_kind: Some(1), 20538 event_pubkey: Some(pubkey.to_owned()), 20539 event_created_at: Some( 20540 1_774_000_100 + i64::try_from(index).unwrap_or_default(), 20541 ), 20542 event_tags_json: Some(json!([])), 20543 event_content: Some("{}".to_owned()), 20544 event_sig: Some("signature".to_owned()), 20545 raw_event_json: Some(json!({ 20546 "id": event_id, 20547 "kind": 1, 20548 "pubkey": pubkey, 20549 "content": "{}" 20550 })), 20551 outbox_status: PublishOutboxStatus::Acknowledged, 20552 relay_set_fingerprint: Some("relay-set".to_owned()), 20553 relay_delivery_json: Some(json!({ 20554 "state": "acknowledged", 20555 "acknowledged_relays": ["wss://relay.example"] 20556 })), 20557 }) 20558 .expect("append unrelated signed event"); 20559 } 20560 } 20561 20562 fn deterministic_cli_listing_product_id( 20563 owner_pubkey: Option<&str>, 20564 listing_key: &str, 20565 ) -> ProductId { 20566 let seed = format!( 20567 "radroots-cli-listing:{}:{}", 20568 owner_pubkey.unwrap_or("unknown-owner"), 20569 listing_key.trim() 20570 ); 20571 20572 ProductId::from(uuid::Uuid::new_v5( 20573 &uuid::Uuid::NAMESPACE_URL, 20574 seed.as_bytes(), 20575 )) 20576 } 20577 20578 fn assert_detail_open_imports_shared_local_events_before_lookup( 20579 label: &str, 20580 section: PersonalSection, 20581 ) { 20582 let (runtime, paths) = bootstrapped_runtime(label); 20583 assert!( 20584 runtime 20585 .generate_local_account(Some("Buyer".to_owned())) 20586 .expect("account should generate") 20587 ); 20588 assert_eq!( 20589 runtime 20590 .summary() 20591 .personal_projection 20592 .browse 20593 .listings 20594 .rows 20595 .len(), 20596 0 20597 ); 20598 20599 let listing_key = "DDDDDDDDDDDDDDDDDDDDDD"; 20600 append_cli_signed_buyer_listing_record_with( 20601 &paths, 20602 "detail-open-pending-listing", 20603 listing_key, 20604 "Buyer Visible Eggs", 20605 1100, 20606 ); 20607 let product_id = 20608 deterministic_cli_listing_product_id(Some(BUYER_VISIBLE_SELLER_PUBKEY), listing_key); 20609 20610 assert!( 20611 runtime 20612 .open_personal_product_detail(section, product_id) 20613 .expect("buyer detail should import before lookup") 20614 ); 20615 let summary = runtime.summary(); 20616 let detail = match section { 20617 PersonalSection::Browse => summary.personal_projection.browse.detail, 20618 PersonalSection::Search => summary.personal_projection.search.detail, 20619 _ => None, 20620 } 20621 .expect("buyer detail should open from imported shared local events"); 20622 20623 assert_eq!(detail.listing.product_id, product_id); 20624 assert_eq!(detail.listing.title, "Buyer Visible Eggs"); 20625 20626 cleanup_bootstrapped_runtime_paths(&paths); 20627 } 20628 20629 fn local_work_record( 20630 record_id: &str, 20631 account_id: &str, 20632 farm_key: &str, 20633 listing_addr: Option<String>, 20634 payload: serde_json::Value, 20635 ) -> LocalEventRecordInput { 20636 LocalEventRecordInput { 20637 record_id: record_id.to_owned(), 20638 family: LocalRecordFamily::LocalWork, 20639 status: LocalRecordStatus::LocalSaved, 20640 source_runtime: SourceRuntime::Cli, 20641 created_at_ms: 1000, 20642 inserted_at_ms: 1001, 20643 owner_account_id: Some(account_id.to_owned()), 20644 owner_pubkey: Some("seller-pubkey".to_owned()), 20645 farm_id: Some(farm_key.to_owned()), 20646 listing_addr, 20647 local_work_json: Some(payload), 20648 event_id: None, 20649 event_kind: None, 20650 event_pubkey: None, 20651 event_created_at: None, 20652 event_tags_json: None, 20653 event_content: None, 20654 event_sig: None, 20655 raw_event_json: None, 20656 outbox_status: PublishOutboxStatus::None, 20657 relay_set_fingerprint: None, 20658 relay_delivery_json: None, 20659 } 20660 } 20661 20662 fn published_operation_receipt_fixture( 20663 source_account_id: String, 20664 source_local_event_id: Option<String>, 20665 event_id: &str, 20666 ) -> AppPublishedOperationReceipt { 20667 let event_pubkey = "1111111111111111111111111111111111111111111111111111111111111111"; 20668 AppPublishedOperationReceipt { 20669 operation_key: "farm:upsert".to_owned(), 20670 source_account_id, 20671 source_local_event_id, 20672 listing_addr: None, 20673 event_id: event_id.to_owned(), 20674 event_kind: 30340, 20675 event_pubkey: event_pubkey.to_owned(), 20676 event_created_at: 1_774_000_000, 20677 event_tags_json: json!([["d", "farm-key"]]), 20678 event_content: "{}".to_owned(), 20679 event_sig: "signature".to_owned(), 20680 raw_event_json: json!({ 20681 "id": event_id, 20682 "kind": 30340, 20683 "pubkey": event_pubkey, 20684 "content": "{}" 20685 }), 20686 relay_set_fingerprint: "relay-set".to_owned(), 20687 relay_delivery_json: json!({ 20688 "state": "acknowledged", 20689 "acknowledged_relays": ["ws://127.0.0.1:1234/"] 20690 }), 20691 } 20692 } 20693 20694 fn shared_local_event_records(paths: &AppDesktopRuntimePaths) -> Vec<LocalEventRecord> { 20695 let database_path = paths 20696 .shared_local_events_database_path() 20697 .expect("shared local events path"); 20698 let executor = 20699 SqliteExecutor::open(database_path.as_path()).expect("open shared local events db"); 20700 let store = LocalEventsStore::new(executor); 20701 store 20702 .list_records_after_seq(0, 100) 20703 .expect("shared local records should list") 20704 } 20705 20706 fn shared_order_request_event_id( 20707 paths: &AppDesktopRuntimePaths, 20708 trade_order_id: &str, 20709 ) -> String { 20710 let record_id = format!("app:signed_event:order-request:{trade_order_id}"); 20711 shared_local_event_records(paths) 20712 .into_iter() 20713 .find(|record| record.record_id == record_id) 20714 .and_then(|record| record.event_id) 20715 .expect("signed order request event id should exist") 20716 } 20717 20718 fn shared_order_events_by_kind( 20719 paths: &AppDesktopRuntimePaths, 20720 kind: i64, 20721 pubkey: &str, 20722 ) -> Vec<SdkRadrootsNostrEvent> { 20723 shared_local_event_records(paths) 20724 .into_iter() 20725 .filter(|record| { 20726 record.family == LocalRecordFamily::SignedEvent 20727 && record.event_kind == Some(kind) 20728 && record.event_pubkey.as_deref() == Some(pubkey) 20729 }) 20730 .filter_map(|record| { 20731 signed_event_from_local_record(&record) 20732 .expect("shared signed event record should decode") 20733 }) 20734 .collect() 20735 } 20736 20737 fn persisted_order_status(runtime: &DesktopAppRuntime, order_id: OrderId) -> String { 20738 runtime 20739 .lock_state() 20740 .sqlite_store 20741 .as_ref() 20742 .expect("sqlite store") 20743 .connection() 20744 .query_row( 20745 "select status from orders where id = ?1 limit 1", 20746 [order_id.to_string()], 20747 |row| row.get::<_, String>(0), 20748 ) 20749 .expect("order status should load") 20750 } 20751 20752 fn pending_order_sync_payloads( 20753 runtime: &DesktopAppRuntime, 20754 account_id: &str, 20755 order_id: OrderId, 20756 ) -> Vec<String> { 20757 runtime 20758 .lock_state() 20759 .sqlite_store 20760 .as_ref() 20761 .expect("sqlite store") 20762 .load_pending_sync_operations(account_id) 20763 .expect("pending sync operations should load") 20764 .into_iter() 20765 .filter(|pending| { 20766 pending.operation.operation == SyncOperationKind::Upsert 20767 && matches!(pending.operation.aggregate, SyncAggregateRef::Order(id) if id == order_id) 20768 }) 20769 .map(|pending| pending.operation.payload_json) 20770 .collect() 20771 } 20772 20773 fn assert_no_order_request_pending_sync_payloads( 20774 runtime: &DesktopAppRuntime, 20775 account_id: &str, 20776 order_id: OrderId, 20777 ) { 20778 assert!(pending_order_sync_payloads(runtime, account_id, order_id).is_empty()); 20779 } 20780 20781 fn assert_order_request_sdk_migration_receipt( 20782 runtime: &DesktopAppRuntime, 20783 order_id: OrderId, 20784 expected_state: AppSdkMigrationState, 20785 ) { 20786 let source_record_id = format!("app:local_work:order_request:{order_id}"); 20787 let receipt = runtime 20788 .lock_state() 20789 .sqlite_store 20790 .as_ref() 20791 .expect("sqlite store") 20792 .sdk_migration_receipt_repository() 20793 .load_receipt( 20794 AppSdkMigrationReceiptSourceKind::SharedLocalEvent, 20795 source_record_id.as_str(), 20796 ) 20797 .expect("SDK migration receipt should load") 20798 .expect("SDK migration receipt should exist"); 20799 assert_eq!(receipt.source_record_id, source_record_id); 20800 assert_eq!(receipt.sdk_operation_kind, ORDER_SUBMIT_OPERATION_KIND); 20801 assert_eq!( 20802 receipt.migration_state, expected_state, 20803 "receipt detail: {}", 20804 receipt.detail_json 20805 ); 20806 if expected_state == AppSdkMigrationState::Enqueued { 20807 assert!(receipt.expected_event_id.is_some()); 20808 assert!(receipt.actor_pubkey.as_deref().is_some_and(is_hex_64)); 20809 assert!(!receipt.sdk_outbox_event_ids.is_empty()); 20810 } 20811 } 20812 20813 fn assert_order_decision_sdk_migration_receipt( 20814 runtime: &DesktopAppRuntime, 20815 order_id: OrderId, 20816 expected_state: AppSdkMigrationState, 20817 ) { 20818 let source_record_id = format!("app:order_decision:{order_id}"); 20819 let receipt = runtime 20820 .lock_state() 20821 .sqlite_store 20822 .as_ref() 20823 .expect("sqlite store") 20824 .sdk_migration_receipt_repository() 20825 .load_receipt( 20826 AppSdkMigrationReceiptSourceKind::LocalOutbox, 20827 source_record_id.as_str(), 20828 ) 20829 .expect("SDK migration receipt should load") 20830 .expect("SDK migration receipt should exist"); 20831 assert_eq!(receipt.source_record_id, source_record_id); 20832 assert_eq!(receipt.sdk_operation_kind, ORDER_DECISION_OPERATION_KIND); 20833 assert_eq!( 20834 receipt.migration_state, expected_state, 20835 "receipt detail: {}", 20836 receipt.detail_json 20837 ); 20838 if expected_state == AppSdkMigrationState::Enqueued { 20839 assert!(receipt.expected_event_id.is_some()); 20840 assert!(receipt.actor_pubkey.as_deref().is_some_and(is_hex_64)); 20841 assert!(!receipt.sdk_outbox_event_ids.is_empty()); 20842 } 20843 } 20844 20845 fn assert_order_revision_decision_sdk_migration_receipt( 20846 runtime: &DesktopAppRuntime, 20847 order_id: OrderId, 20848 revision_id: &str, 20849 expected_state: AppSdkMigrationState, 20850 ) { 20851 assert_order_sdk_migration_receipt( 20852 runtime, 20853 format!("app:order_revision_decision:{order_id}:{revision_id}").as_str(), 20854 ORDER_REVISION_DECISION_OPERATION_KIND, 20855 expected_state, 20856 ); 20857 } 20858 20859 fn assert_order_cancellation_sdk_migration_receipt( 20860 runtime: &DesktopAppRuntime, 20861 order_id: OrderId, 20862 expected_state: AppSdkMigrationState, 20863 ) { 20864 assert_order_sdk_migration_receipt( 20865 runtime, 20866 format!("app:order_cancellation:{order_id}").as_str(), 20867 ORDER_CANCELLATION_OPERATION_KIND, 20868 expected_state, 20869 ); 20870 } 20871 20872 fn assert_order_sdk_migration_receipt( 20873 runtime: &DesktopAppRuntime, 20874 source_record_id: &str, 20875 operation_kind: &str, 20876 expected_state: AppSdkMigrationState, 20877 ) { 20878 let receipt = runtime 20879 .lock_state() 20880 .sqlite_store 20881 .as_ref() 20882 .expect("sqlite store") 20883 .sdk_migration_receipt_repository() 20884 .load_receipt( 20885 AppSdkMigrationReceiptSourceKind::LocalOutbox, 20886 source_record_id, 20887 ) 20888 .expect("SDK migration receipt should load") 20889 .expect("SDK migration receipt should exist"); 20890 assert_eq!(receipt.source_record_id, source_record_id); 20891 assert_eq!(receipt.sdk_operation_kind, operation_kind); 20892 assert_eq!( 20893 receipt.migration_state, expected_state, 20894 "receipt detail: {}", 20895 receipt.detail_json 20896 ); 20897 if expected_state == AppSdkMigrationState::Enqueued { 20898 assert!(receipt.expected_event_id.is_some()); 20899 assert!(receipt.actor_pubkey.as_deref().is_some_and(is_hex_64)); 20900 assert!(!receipt.sdk_outbox_event_ids.is_empty()); 20901 } 20902 } 20903 20904 fn buyer_order_local_work_record_ids(paths: &AppDesktopRuntimePaths) -> Vec<String> { 20905 shared_local_event_records(paths) 20906 .into_iter() 20907 .filter(|record| { 20908 record.source_runtime == SourceRuntime::App 20909 && record 20910 .local_work_json 20911 .as_ref() 20912 .and_then(|payload| payload["record_kind"].as_str()) 20913 == Some(BUYER_ORDER_REQUEST_LOCAL_WORK_RECORD_KIND) 20914 }) 20915 .map(|record| record.record_id) 20916 .collect() 20917 } 20918 20919 fn blocked_buyer_order_runtime( 20920 label: &str, 20921 ) -> (DesktopAppRuntime, AppDesktopRuntimePaths, String, OrderId) { 20922 let (runtime, paths) = bootstrapped_runtime(label); 20923 let _ = install_recorded_sync_transport( 20924 &runtime, 20925 RecordedAppSyncTransport::fail(AppSyncTransportError::unavailable( 20926 "test sync unavailable", 20927 )), 20928 ); 20929 assert!( 20930 runtime 20931 .generate_local_account(Some("Buyer".to_owned())) 20932 .expect("account should generate") 20933 ); 20934 let buyer_account_id = runtime 20935 .summary() 20936 .settings_account_projection 20937 .selected_account 20938 .as_ref() 20939 .expect("selected account") 20940 .account 20941 .account_id 20942 .clone(); 20943 assert!( 20944 runtime 20945 .select_active_surface(ActiveSurface::Personal) 20946 .expect("surface should switch into marketplace") 20947 ); 20948 let listing_key = "DDDDDDDDDDDDDDDDDDDDDD"; 20949 append_cli_signed_buyer_listing_record_with( 20950 &paths, 20951 "buyer-order-append-failure-listing", 20952 listing_key, 20953 "Buyer Visible Eggs", 20954 1100, 20955 ); 20956 let product_id = 20957 deterministic_cli_listing_product_id(Some(BUYER_VISIBLE_SELLER_PUBKEY), listing_key); 20958 assert!( 20959 runtime 20960 .open_personal_product_detail(PersonalSection::Browse, product_id) 20961 .expect("buyer detail should import before lookup") 20962 ); 20963 assert!( 20964 runtime 20965 .add_personal_product_to_cart(PersonalSection::Browse, false) 20966 .expect("buyer product should add to cart") 20967 ); 20968 assert!( 20969 runtime 20970 .save_personal_order_review_draft(BuyerOrderReviewDraft { 20971 name: "Casey Buyer".to_owned(), 20972 email: "casey@example.com".to_owned(), 20973 phone: String::new(), 20974 order_note: String::new(), 20975 }) 20976 .expect("buyer order review draft should save") 20977 ); 20978 block_shared_local_events_database(&paths); 20979 20980 let error = runtime 20981 .place_personal_order() 20982 .expect_err("blocked local events should fail order completion"); 20983 20984 assert!(matches!(error, AppSqliteError::LocalEventsSql { .. })); 20985 let summary = runtime.summary(); 20986 assert_eq!( 20987 summary.shell_projection.selected_section, 20988 ShellSection::Personal(PersonalSection::Orders) 20989 ); 20990 assert!( 20991 summary 20992 .personal_projection 20993 .orders 20994 .has_recoverable_coordination 20995 ); 20996 assert!(summary.personal_projection.cart.cart.lines.is_empty()); 20997 assert!( 20998 !summary 20999 .personal_projection 21000 .cart 21001 .order_review 21002 .can_place_order 21003 ); 21004 assert_eq!(summary.personal_projection.orders.list.rows.len(), 1); 21005 let visible_order_id = summary.personal_projection.orders.list.rows[0].order_id; 21006 let order_detail = summary 21007 .personal_projection 21008 .orders 21009 .detail 21010 .as_ref() 21011 .expect("buyer order detail should remain visible after coordination failure"); 21012 assert_eq!(order_detail.order_id, visible_order_id); 21013 { 21014 let state = runtime.lock_state_mut(); 21015 let buyer_context = state.state_store.identity_projection().buyer_context(); 21016 let sqlite_store = state.sqlite_store.as_ref().expect("sqlite store"); 21017 let buyer_orders = sqlite_store 21018 .load_buyer_orders(&buyer_context) 21019 .expect("buyer order should persist after coordination failure"); 21020 assert_eq!(buyer_orders.rows.len(), 1); 21021 let order_id = buyer_orders.rows[0].order_id; 21022 assert_eq!(order_id, visible_order_id); 21023 let coordination = sqlite_store 21024 .load_buyer_order_coordination_record(&buyer_context, order_id) 21025 .expect("buyer order coordination should load") 21026 .expect("buyer order coordination should exist"); 21027 assert_eq!(coordination.state, BuyerOrderCoordinationState::Failed); 21028 assert_eq!(coordination.attempt_count, 1); 21029 assert!(coordination.record_id.is_some()); 21030 assert!(coordination.payload_json.is_some()); 21031 assert!(coordination.last_error_message.is_some()); 21032 } 21033 assert!( 21034 pending_order_sync_payloads(&runtime, buyer_account_id.as_str(), visible_order_id) 21035 .is_empty() 21036 ); 21037 21038 (runtime, paths, buyer_account_id, visible_order_id) 21039 } 21040 21041 fn block_shared_local_events_database(paths: &AppDesktopRuntimePaths) { 21042 let database_path = paths 21043 .shared_local_events_database_path() 21044 .expect("shared local events path"); 21045 if let Some(parent) = database_path.parent() { 21046 fs::create_dir_all(parent).expect("shared local events directory should create"); 21047 } 21048 if database_path.is_file() { 21049 fs::remove_file(&database_path).expect("shared local events file should remove"); 21050 } else if database_path.is_dir() { 21051 fs::remove_dir_all(&database_path) 21052 .expect("shared local events directory should remove"); 21053 } 21054 fs::create_dir(&database_path).expect("blocking directory should create"); 21055 } 21056 21057 fn unblock_shared_local_events_database(paths: &AppDesktopRuntimePaths) { 21058 let database_path = paths 21059 .shared_local_events_database_path() 21060 .expect("shared local events path"); 21061 if database_path.is_dir() { 21062 fs::remove_dir_all(&database_path).expect("blocking directory should remove"); 21063 } 21064 } 21065 21066 fn fixture_pending_session() -> RadrootsAppRemoteSignerPendingSession { 21067 let signer_identity = RadrootsIdentity::from_secret_key_str( 21068 "1111111111111111111111111111111111111111111111111111111111111111", 21069 ) 21070 .expect("signer identity"); 21071 let client_identity = RadrootsIdentity::from_secret_key_str( 21072 "3333333333333333333333333333333333333333333333333333333333333333", 21073 ) 21074 .expect("client identity"); 21075 21076 RadrootsAppRemoteSignerPendingSession { 21077 record: RadrootsAppRemoteSignerSessionRecord::pending( 21078 client_identity.to_public(), 21079 signer_identity.to_public(), 21080 vec!["ws://127.0.0.1:8080".to_owned()], 21081 ), 21082 client_secret_key_hex: client_identity.secret_key_hex(), 21083 } 21084 } 21085 21086 fn save_surface_activation( 21087 runtime: &DesktopAppRuntime, 21088 account_id: &str, 21089 active_surface: ActiveSurface, 21090 farmer_active: bool, 21091 ) { 21092 let activation = AccountSurfaceActivationProjection::new( 21093 account_id, 21094 SelectedSurfaceProjection::new(active_surface), 21095 if farmer_active { 21096 FarmerActivationProjection::active(FarmId::new()) 21097 } else { 21098 FarmerActivationProjection::inactive() 21099 }, 21100 ); 21101 runtime 21102 .lock_state() 21103 .sqlite_store 21104 .as_ref() 21105 .expect("sqlite store") 21106 .save_surface_activation(&activation) 21107 .expect("surface activation should save"); 21108 } 21109 21110 fn save_farmer_surface_activation( 21111 runtime: &DesktopAppRuntime, 21112 account_id: &str, 21113 active_surface: ActiveSurface, 21114 ) -> FarmId { 21115 let farm_id = FarmId::new(); 21116 let activation = AccountSurfaceActivationProjection::new( 21117 account_id, 21118 SelectedSurfaceProjection::new(active_surface), 21119 FarmerActivationProjection::active(farm_id), 21120 ); 21121 runtime 21122 .lock_state() 21123 .sqlite_store 21124 .as_ref() 21125 .expect("sqlite store") 21126 .save_surface_activation(&activation) 21127 .expect("surface activation should save"); 21128 farm_id 21129 } 21130 21131 fn seed_product( 21132 runtime: &DesktopAppRuntime, 21133 farm_id: FarmId, 21134 title: &str, 21135 subtitle: &str, 21136 status: &str, 21137 stock_count: Option<u32>, 21138 updated_at: &str, 21139 ) -> radroots_app_view::ProductId { 21140 let product_id = radroots_app_view::ProductId::new(); 21141 let stock_count = stock_count 21142 .map(|value| value.to_string()) 21143 .unwrap_or_else(|| "null".to_owned()); 21144 let sql = format!( 21145 "insert into products ( 21146 id, 21147 farm_id, 21148 title, 21149 subtitle, 21150 status, 21151 unit_label, 21152 price_minor_units, 21153 price_currency, 21154 stock_count, 21155 availability_window_id, 21156 updated_at 21157 ) values ( 21158 '{product_id}', 21159 '{farm_id}', 21160 '{title}', 21161 '{subtitle}', 21162 '{status}', 21163 'box', 21164 600, 21165 'USD', 21166 {stock_count}, 21167 null, 21168 '{updated_at}' 21169 )", 21170 product_id = product_id, 21171 farm_id = farm_id, 21172 title = title, 21173 subtitle = subtitle, 21174 status = status, 21175 stock_count = stock_count, 21176 updated_at = updated_at, 21177 ); 21178 runtime 21179 .lock_state() 21180 .sqlite_store 21181 .as_ref() 21182 .expect("sqlite store") 21183 .connection() 21184 .execute_batch(&sql) 21185 .expect("product should seed"); 21186 21187 product_id 21188 } 21189 21190 fn seed_buyer_marketplace_support( 21191 runtime: &DesktopAppRuntime, 21192 account_id: &str, 21193 farm_id: FarmId, 21194 farm_display_name: &str, 21195 fulfillment_label: &str, 21196 ) -> FulfillmentWindowId { 21197 let pickup_location_id = PickupLocationId::new(); 21198 let fulfillment_window_id = FulfillmentWindowId::new(); 21199 let sql = format!( 21200 "insert into pickup_locations ( 21201 id, 21202 farm_id, 21203 label, 21204 address_line, 21205 directions, 21206 is_default, 21207 created_at, 21208 updated_at 21209 ) values ( 21210 '{pickup_location_id}', 21211 '{farm_id}', 21212 'North barn', 21213 '14 County Road', 21214 null, 21215 1, 21216 '2026-04-20T08:00:00Z', 21217 '2026-04-20T08:00:00Z' 21218 ); 21219 insert into fulfillment_windows ( 21220 id, 21221 farm_id, 21222 starts_at, 21223 ends_at, 21224 capacity_limit, 21225 created_at, 21226 updated_at, 21227 pickup_location_id, 21228 label, 21229 order_cutoff_at 21230 ) values ( 21231 '{fulfillment_window_id}', 21232 '{farm_id}', 21233 '2099-04-18T16:00:00Z', 21234 '2099-04-18T18:00:00Z', 21235 null, 21236 '2099-04-18T16:00:00Z', 21237 '2099-04-18T16:00:00Z', 21238 '{pickup_location_id}', 21239 '{fulfillment_label}', 21240 '2099-04-17T18:00:00Z' 21241 ); 21242 insert into account_farm_setups ( 21243 account_id, 21244 farm_name, 21245 location_or_service_area, 21246 pickup_enabled, 21247 delivery_enabled, 21248 shipping_enabled, 21249 saved_farm_id, 21250 saved_farm_display_name, 21251 saved_farm_readiness, 21252 updated_at 21253 ) values ( 21254 '{account_id}', 21255 '{farm_display_name}', 21256 'County Road', 21257 1, 21258 0, 21259 0, 21260 '{farm_id}', 21261 '{farm_display_name}', 21262 'ready', 21263 '2026-04-20T08:00:00Z' 21264 ) 21265 on conflict(account_id) do update set 21266 farm_name = excluded.farm_name, 21267 location_or_service_area = excluded.location_or_service_area, 21268 pickup_enabled = excluded.pickup_enabled, 21269 delivery_enabled = excluded.delivery_enabled, 21270 shipping_enabled = excluded.shipping_enabled, 21271 saved_farm_id = excluded.saved_farm_id, 21272 saved_farm_display_name = excluded.saved_farm_display_name, 21273 saved_farm_readiness = excluded.saved_farm_readiness, 21274 updated_at = excluded.updated_at;" 21275 ); 21276 runtime 21277 .lock_state() 21278 .sqlite_store 21279 .as_ref() 21280 .expect("sqlite store") 21281 .connection() 21282 .execute_batch(&sql) 21283 .expect("buyer marketplace support should seed"); 21284 21285 fulfillment_window_id 21286 } 21287 21288 fn provision_ready_farmer_account(runtime: &DesktopAppRuntime) -> (String, FarmId) { 21289 assert!( 21290 runtime 21291 .generate_local_account(Some("Farmer".to_owned())) 21292 .expect("account should generate") 21293 ); 21294 provision_selected_farmer_account(runtime) 21295 } 21296 21297 fn provision_ready_farmer_account_from_secret( 21298 runtime: &DesktopAppRuntime, 21299 secret_key_hex: &str, 21300 ) -> (String, FarmId) { 21301 assert!( 21302 runtime 21303 .import_local_account(DesktopLocalIdentityImportRequest::raw_secret_key( 21304 secret_key_hex, 21305 )) 21306 .expect("account should import") 21307 ); 21308 provision_selected_farmer_account(runtime) 21309 } 21310 21311 fn provision_selected_farmer_account(runtime: &DesktopAppRuntime) -> (String, FarmId) { 21312 let account_id = runtime 21313 .summary() 21314 .settings_account_projection 21315 .selected_account 21316 .as_ref() 21317 .expect("selected account") 21318 .account 21319 .account_id 21320 .clone(); 21321 let farm_id = 21322 save_farmer_surface_activation(runtime, account_id.as_str(), ActiveSurface::Farmer); 21323 let farm_setup_projection = FarmSetupProjection::from_saved_farm(FarmSummary { 21324 farm_id, 21325 display_name: "North field farm".to_owned(), 21326 readiness: FarmReadiness::Ready, 21327 }); 21328 runtime 21329 .lock_state() 21330 .sqlite_store 21331 .as_ref() 21332 .expect("sqlite store") 21333 .save_farm_summary( 21334 farm_setup_projection 21335 .saved_farm 21336 .as_ref() 21337 .expect("saved farm should exist"), 21338 ) 21339 .expect("farm summary should save"); 21340 runtime 21341 .lock_state() 21342 .sqlite_store 21343 .as_ref() 21344 .expect("sqlite store") 21345 .save_farm_setup(account_id.as_str(), &farm_setup_projection) 21346 .expect("farm setup should save"); 21347 assert!( 21348 runtime 21349 .select_local_account(account_id.as_str()) 21350 .expect("account should select") 21351 ); 21352 21353 (account_id, farm_id) 21354 } 21355 21356 fn seed_order_workspace( 21357 runtime: &DesktopAppRuntime, 21358 farm_id: FarmId, 21359 ) -> (FulfillmentWindowId, OrderId) { 21360 let pickup_location_id = PickupLocationId::new(); 21361 let fulfillment_window_id = FulfillmentWindowId::new(); 21362 let order_id = OrderId::new(); 21363 let sql = format!( 21364 "insert into pickup_locations ( 21365 id, 21366 farm_id, 21367 label, 21368 address_line, 21369 directions, 21370 is_default, 21371 created_at, 21372 updated_at 21373 ) values ( 21374 '{pickup_location_id}', 21375 '{farm_id}', 21376 'North barn', 21377 '14 County Road', 21378 null, 21379 1, 21380 '2026-04-17T08:00:00Z', 21381 '2026-04-17T08:00:00Z' 21382 ); 21383 insert into fulfillment_windows ( 21384 id, 21385 farm_id, 21386 starts_at, 21387 ends_at, 21388 capacity_limit, 21389 created_at, 21390 updated_at, 21391 pickup_location_id, 21392 label, 21393 order_cutoff_at 21394 ) values ( 21395 '{fulfillment_window_id}', 21396 '{farm_id}', 21397 '2099-04-18T16:00:00Z', 21398 '2099-04-18T18:00:00Z', 21399 null, 21400 '2099-04-18T16:00:00Z', 21401 '2099-04-18T16:00:00Z', 21402 '{pickup_location_id}', 21403 'Friday pickup', 21404 '2099-04-17T18:00:00Z' 21405 ); 21406 insert into orders ( 21407 id, 21408 farm_id, 21409 fulfillment_window_id, 21410 order_number, 21411 customer_display_name, 21412 status, 21413 updated_at 21414 ) values ( 21415 '{order_id}', 21416 '{farm_id}', 21417 '{fulfillment_window_id}', 21418 'R-100', 21419 'Casey', 21420 'needs_action', 21421 '2026-04-17T10:00:00Z' 21422 ); 21423 insert into order_lines ( 21424 id, 21425 order_id, 21426 title, 21427 quantity_value, 21428 quantity_unit_label, 21429 quantity_display, 21430 sort_index 21431 ) values ( 21432 'line-1', 21433 '{order_id}', 21434 'Salad mix', 21435 2, 21436 'bags', 21437 '2 bags', 21438 0 21439 )", 21440 ); 21441 runtime 21442 .lock_state() 21443 .sqlite_store 21444 .as_ref() 21445 .expect("sqlite store") 21446 .connection() 21447 .execute_batch(&sql) 21448 .expect("orders workspace should seed"); 21449 21450 (fulfillment_window_id, order_id) 21451 } 21452 21453 fn seed_second_order_workspace( 21454 runtime: &DesktopAppRuntime, 21455 farm_id: FarmId, 21456 source_fulfillment_window_id: FulfillmentWindowId, 21457 ) -> (FulfillmentWindowId, OrderId) { 21458 let fulfillment_window_id = FulfillmentWindowId::new(); 21459 let order_id = OrderId::new(); 21460 let sql = format!( 21461 "insert into fulfillment_windows ( 21462 id, 21463 farm_id, 21464 starts_at, 21465 ends_at, 21466 capacity_limit, 21467 created_at, 21468 updated_at, 21469 pickup_location_id, 21470 label, 21471 order_cutoff_at 21472 ) 21473 select 21474 '{fulfillment_window_id}', 21475 farm_id, 21476 '2099-04-19T16:00:00Z', 21477 '2099-04-19T18:00:00Z', 21478 capacity_limit, 21479 '2099-04-19T16:00:00Z', 21480 '2099-04-19T16:00:00Z', 21481 pickup_location_id, 21482 'Saturday pickup', 21483 '2099-04-18T18:00:00Z' 21484 from fulfillment_windows 21485 where id = '{source_fulfillment_window_id}' and farm_id = '{farm_id}'; 21486 insert into orders ( 21487 id, 21488 farm_id, 21489 fulfillment_window_id, 21490 order_number, 21491 customer_display_name, 21492 status, 21493 updated_at 21494 ) values ( 21495 '{order_id}', 21496 '{farm_id}', 21497 '{fulfillment_window_id}', 21498 'R-101', 21499 'Robin', 21500 'scheduled', 21501 '2026-04-17T11:00:00Z' 21502 )" 21503 ); 21504 runtime 21505 .lock_state() 21506 .sqlite_store 21507 .as_ref() 21508 .expect("sqlite store") 21509 .connection() 21510 .execute_batch(&sql) 21511 .expect("second orders workspace should seed"); 21512 21513 (fulfillment_window_id, order_id) 21514 } 21515 21516 fn cleanup_paths(paths: &AppSharedAccountsPaths) { 21517 let Some(base) = paths.data_root.ancestors().nth(3).map(PathBuf::from) else { 21518 return; 21519 }; 21520 let _ = fs::remove_dir_all(base); 21521 } 21522 }