app

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

commit db26c45ceb130e5fd337707c0252896ca5a3f07e
parent 93ef5e2db2f3fdb13d34195cd87941aa6098c029
Author: triesap <tyson@radroots.org>
Date:   Mon, 25 May 2026 03:04:02 +0000

products: block stale availability publish work

- validate product publish blockers against current farm fulfillment windows
- keep local listing work blocked when selected availability is stale
- prevent direct relay listing work from queuing without a usable window
- expose farm rules in app summaries so editor readiness matches runtime state

Diffstat:
Mcrates/launchers/desktop/src/app.rs | 1+
Mcrates/launchers/desktop/src/runtime.rs | 170+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++------
Mcrates/launchers/desktop/src/window.rs | 9++++++++-
Mcrates/shared/sqlite/src/products.rs | 50++++++++++++++++++++++++++++++++++++++++++++------
Mcrates/shared/state/src/lib.rs | 232+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++------------
5 files changed, 408 insertions(+), 54 deletions(-)

diff --git a/crates/launchers/desktop/src/app.rs b/crates/launchers/desktop/src/app.rs @@ -256,6 +256,7 @@ mod tests { home_route, personal_projection: Default::default(), farm_setup_projection: Default::default(), + farm_rules_projection: Default::default(), farm_readiness_projection: FarmWorkspaceReadinessProjection::default(), today_projection: TodayAgendaProjection::default(), products_projection: Default::default(), diff --git a/crates/launchers/desktop/src/runtime.rs b/crates/launchers/desktop/src/runtime.rs @@ -361,6 +361,7 @@ impl DesktopAppRuntime { home_route: state.state_store.home_route(), personal_projection: state.state_store.personal_projection().clone(), farm_setup_projection: state.state_store.farm_setup_projection().clone(), + farm_rules_projection: state.state_store.farm_rules_projection().clone(), farm_readiness_projection: state.state_store.farm_readiness_projection().clone(), today_projection: state.state_store.today_projection().clone(), products_projection: state.state_store.products_projection().clone(), @@ -1012,6 +1013,7 @@ pub struct DesktopAppRuntimeSummary { pub home_route: HomeRoute, pub personal_projection: PersonalWorkspaceProjection, pub farm_setup_projection: FarmSetupProjection, + pub farm_rules_projection: FarmRulesProjection, pub farm_readiness_projection: FarmWorkspaceReadinessProjection, pub today_projection: TodayAgendaProjection, pub products_projection: ProductsScreenProjection, @@ -3631,8 +3633,13 @@ impl DesktopAppRuntimeState { if !product_status_needs_relay_publish(draft.status) { return Ok(None); } - if !derive_product_publish_blockers(&draft, self.state_store.farm_readiness_projection()) - .is_empty() + let farm_rules = self.state_store.farm_rules_projection(); + if !derive_product_publish_blockers( + &draft, + self.state_store.farm_readiness_projection(), + farm_rules, + ) + .is_empty() { return Ok(None); } @@ -3643,7 +3650,6 @@ impl DesktopAppRuntimeState { return Ok(None); }; let farm_setup = self.state_store.farm_setup_projection(); - let farm_rules = self.state_store.farm_rules_projection(); let (availability_starts_at, availability_ends_at) = listing_availability_window_times(&draft, farm_rules); let listing_d_tag = d_tag_from_uuid(product_id.as_uuid()); @@ -4144,11 +4150,14 @@ impl DesktopAppRuntimeState { let unit_label = non_empty_string(draft.unit_label.as_str()); let price_amount = draft.price_minor_units.map(decimal_from_minor_units); let available = draft.stock_quantity.map(|value| value.to_string()); - let publish_blockers = - derive_product_publish_blockers(draft, self.state_store.farm_readiness_projection()) - .into_iter() - .map(|blocker| blocker.storage_key()) - .collect::<Vec<_>>(); + let publish_blockers = derive_product_publish_blockers( + draft, + self.state_store.farm_readiness_projection(), + farm_rules, + ) + .into_iter() + .map(|blocker| blocker.storage_key()) + .collect::<Vec<_>>(); let payload = json!({ "record_kind": "listing_draft_v1", "exportability": exportability, @@ -7028,10 +7037,10 @@ mod tests { PackDayHostHandoffStatus, PackDayPackListRow, PackDayPrintFailureKind, PackDayPrintKind, PackDayPrintStatus, PackDayProductTotalRow, PackDayProjection, PackDayRosterRow, PersonalSection, PickupLocationId, PickupLocationRecord, ProductEditorDraft, ProductId, - ProductStatus, ProductsFilter, ProductsSort, RecoveryKind, RecoveryRecordId, - ReminderDeliveryState, ReminderFeedProjection, ReminderKind, SelectedAccountProjection, - SelectedSurfaceProjection, SettingsPreference, SettingsSection, ShellSection, - TodayAgendaProjection, TodaySetupTask, TodaySetupTaskKind, TodaySummary, + ProductPublishBlocker, ProductStatus, ProductsFilter, ProductsSort, RecoveryKind, + RecoveryRecordId, ReminderDeliveryState, ReminderFeedProjection, ReminderKind, + SelectedAccountProjection, SelectedSurfaceProjection, SettingsPreference, SettingsSection, + ShellSection, TodayAgendaProjection, TodaySetupTask, TodaySetupTaskKind, TodaySummary, }; use radroots_app_remote_signer::{ RadrootsAppRemoteSignerPendingSession, RadrootsAppRemoteSignerSessionRecord, @@ -7975,6 +7984,143 @@ mod tests { } #[test] + fn runtime_product_stale_availability_save_records_blocker_without_publish_work() { + let (runtime, paths) = bootstrapped_runtime("stale_product_listing_work"); + let (account_id, farm_id) = provision_ready_farmer_account(&runtime); + let pickup_location_id = PickupLocationId::new(); + let active_window_id = FulfillmentWindowId::new(); + let stale_window_id = FulfillmentWindowId::new(); + + runtime + .save_farm_rules_projection(FarmRulesProjection { + farm_profile: Some(FarmProfileRecord { + farm_id, + display_name: "North field farm".to_owned(), + timezone: "UTC".to_owned(), + currency_code: "USD".to_owned(), + }), + pickup_locations: vec![PickupLocationRecord { + pickup_location_id, + farm_id, + label: "Barn pickup".to_owned(), + address_line: "14 Orchard Lane".to_owned(), + directions: None, + is_default: true, + }], + operating_rules: Some(FarmOperatingRulesRecord { + farm_id, + promise_lead_hours: 24, + substitution_policy: "ask_customer".to_owned(), + missed_pickup_policy: "hold_next_window".to_owned(), + }), + fulfillment_windows: vec![FulfillmentWindowRecord { + fulfillment_window_id: active_window_id, + farm_id, + pickup_location_id, + label: "Friday pickup".to_owned(), + starts_at: "2099-04-25T14:00:00Z".to_owned(), + ends_at: "2099-04-25T18:00:00Z".to_owned(), + order_cutoff_at: "2099-04-24T18:00:00Z".to_owned(), + }], + blackout_periods: Vec::new(), + ..runtime + .load_farm_rules_projection() + .expect("farm rules projection should load") + }) + .expect("farm rules should save"); + + assert!( + runtime + .open_new_product_editor() + .expect("new product editor should open") + ); + let product_id = match runtime.summary().products_projection.editor { + radroots_app_state::ProductEditorState::Open(session) => session + .selected_product_id + .expect("open product editor should select a product"), + radroots_app_state::ProductEditorState::Closed => { + panic!("product editor should be open") + } + }; + + runtime + .lock_state() + .sqlite_store + .as_ref() + .expect("sqlite store") + .connection() + .execute_batch("PRAGMA foreign_keys = OFF;") + .expect("foreign keys should disable for stale fixture"); + let save_result = runtime.save_product_editor_draft(ProductEditorDraft { + title: "Salad mix".to_owned(), + subtitle: "Cut this morning".to_owned(), + category: "greens".to_owned(), + unit_label: "bag".to_owned(), + price_minor_units: Some(900), + price_currency: "usd".to_owned(), + stock_quantity: Some(11), + availability_window_id: Some(stale_window_id), + status: ProductStatus::Published, + }); + runtime + .lock_state() + .sqlite_store + .as_ref() + .expect("sqlite store") + .connection() + .execute_batch("PRAGMA foreign_keys = ON;") + .expect("foreign keys should restore"); + assert!(save_result.expect("stale product editor save should succeed")); + + let summary = runtime.summary(); + let radroots_app_state::ProductEditorState::Open(session) = + summary.products_projection.editor + else { + panic!("product editor should stay open") + }; + assert_eq!( + session.publish_blockers, + vec![ProductPublishBlocker::AttachAvailability] + ); + + let pending_operations = runtime + .lock_state() + .sqlite_store + .as_ref() + .expect("sqlite store") + .load_pending_sync_operations(account_id.as_str()) + .expect("pending sync operations should load"); + let product_pending_operations = pending_operations + .iter() + .filter(|pending| pending.operation.aggregate == SyncAggregateRef::Product(product_id)) + .collect::<Vec<_>>(); + assert!(product_pending_operations.is_empty()); + + let records = shared_local_event_records(&paths); + let listing_record = records + .iter() + .find(|record| { + record + .local_work_json + .as_ref() + .and_then(|payload| payload["record_kind"].as_str()) + == Some("listing_draft_v1") + }) + .expect("listing local work record"); + let listing_payload = listing_record + .local_work_json + .as_ref() + .expect("listing local work payload"); + assert_eq!(listing_payload["publishability"]["state"], "blocked"); + assert_eq!( + listing_payload["publishability"]["blockers"], + json!(["attach_availability"]) + ); + + cleanup_bootstrapped_runtime_paths(&paths); + } + + #[test] fn runtime_product_local_drafts_do_not_enqueue_publish_work_without_required_fields() { let runtime = memory_runtime(); let (account_id, _) = provision_ready_farmer_account(&runtime); diff --git a/crates/launchers/desktop/src/window.rs b/crates/launchers/desktop/src/window.rs @@ -11787,7 +11787,13 @@ fn products_editor_publish_readiness_section( ) -> impl IntoElement { let blockers = form .current_draft(cx) - .map(|draft| derive_product_publish_blockers(&draft, &runtime.farm_readiness_projection)) + .map(|draft| { + derive_product_publish_blockers( + &draft, + &runtime.farm_readiness_projection, + &runtime.farm_rules_projection, + ) + }) .unwrap_or_default(); div() @@ -15099,6 +15105,7 @@ mod tests { logged_out_startup: LoggedOutStartupProjection::default(), home_route, personal_projection: Default::default(), + farm_rules_projection: Default::default(), farm_readiness_projection, farm_setup_projection, today_projection, diff --git a/crates/shared/sqlite/src/products.rs b/crates/shared/sqlite/src/products.rs @@ -168,8 +168,8 @@ impl<'a> AppProductsRepository<'a> { &self, product_id: ProductId, ) -> Result<Option<Vec<ProductPublishBlocker>>, AppSqliteError> { - self.load_product_editor_draft(product_id)? - .map(|draft| Ok(draft.publish_blockers())) + self.load_product_record_by_id(product_id)? + .map(|record| Ok(record.editor_draft_publish_blockers())) .transpose() } @@ -479,7 +479,7 @@ impl ProductRecord { fn attention_state( &self, - now_utc: &str, + _now_utc: &str, availability: &ProductAvailabilitySummary, ) -> ProductAttentionState { if matches!(self.status, ProductStatus::Paused | ProductStatus::Archived) { @@ -508,7 +508,7 @@ impl ProductRecord { } if self - .editor_draft_publish_blockers(now_utc) + .editor_draft_publish_blockers() .into_iter() .any(|blocker| blocker != ProductPublishBlocker::AttachAvailability) { @@ -548,8 +548,15 @@ impl ProductRecord { ) } - fn editor_draft_publish_blockers(&self, _now_utc: &str) -> Vec<ProductPublishBlocker> { - self.clone().into_editor_draft().publish_blockers() + fn editor_draft_publish_blockers(&self) -> Vec<ProductPublishBlocker> { + let mut blockers = self.clone().into_editor_draft().publish_blockers(); + if self.availability_window_id.is_some() + && (self.availability_starts_at.is_none() || self.availability_ends_at.is_none()) + && !blockers.contains(&ProductPublishBlocker::AttachAvailability) + { + blockers.push(ProductPublishBlocker::AttachAvailability); + } + blockers } } @@ -977,6 +984,37 @@ mod tests { .expect("ready blockers should load"), Some(Vec::new()) ); + let stale_window_id = FulfillmentWindowId::new(); + connection + .execute_batch("PRAGMA foreign_keys = OFF;") + .expect("foreign keys should disable for stale fixture"); + connection + .execute( + "update products set availability_window_id = ?2 where id = ?1", + params![product_id.to_string(), stale_window_id.to_string()], + ) + .expect("stale availability id should write"); + connection + .execute_batch("PRAGMA foreign_keys = ON;") + .expect("foreign keys should restore"); + assert_eq!( + repository + .evaluate_product_publish_blockers(product_id) + .expect("stale blockers should load"), + Some(vec![ProductPublishBlocker::AttachAvailability]) + ); + connection + .execute_batch("PRAGMA foreign_keys = OFF;") + .expect("foreign keys should disable for fixture restore"); + connection + .execute( + "update products set availability_window_id = ?2 where id = ?1", + params![product_id.to_string(), window_id.to_string()], + ) + .expect("ready availability id should restore"); + connection + .execute_batch("PRAGMA foreign_keys = ON;") + .expect("foreign keys should restore"); assert!( repository diff --git a/crates/shared/state/src/lib.rs b/crates/shared/state/src/lib.rs @@ -195,24 +195,34 @@ pub struct ProductEditorSession { } impl ProductEditorSession { - fn new_draft(farm_readiness: &FarmWorkspaceReadinessProjection) -> Self { - Self::from_selection(None, ProductEditorDraft::default(), farm_readiness) + fn new_draft( + farm_readiness: &FarmWorkspaceReadinessProjection, + farm_rules: &FarmRulesProjection, + ) -> Self { + Self::from_selection( + None, + ProductEditorDraft::default(), + farm_readiness, + farm_rules, + ) } fn existing( product_id: ProductId, draft: ProductEditorDraft, farm_readiness: &FarmWorkspaceReadinessProjection, + farm_rules: &FarmRulesProjection, ) -> Self { - Self::from_selection(Some(product_id), draft, farm_readiness) + Self::from_selection(Some(product_id), draft, farm_readiness, farm_rules) } fn from_selection( selected_product_id: Option<ProductId>, draft: ProductEditorDraft, farm_readiness: &FarmWorkspaceReadinessProjection, + farm_rules: &FarmRulesProjection, ) -> Self { - let publish_blockers = derive_product_publish_blockers(&draft, farm_readiness); + let publish_blockers = derive_product_publish_blockers(&draft, farm_readiness, farm_rules); Self { selected_product_id, @@ -225,8 +235,9 @@ impl ProductEditorSession { &mut self, draft: ProductEditorDraft, farm_readiness: &FarmWorkspaceReadinessProjection, + farm_rules: &FarmRulesProjection, ) { - self.publish_blockers = derive_product_publish_blockers(&draft, farm_readiness); + self.publish_blockers = derive_product_publish_blockers(&draft, farm_readiness, farm_rules); self.draft = draft; } } @@ -244,8 +255,12 @@ impl Default for ProductEditorState { } impl ProductEditorState { - fn open_new_draft(&mut self, farm_readiness: &FarmWorkspaceReadinessProjection) { - *self = Self::Open(ProductEditorSession::new_draft(farm_readiness)); + fn open_new_draft( + &mut self, + farm_readiness: &FarmWorkspaceReadinessProjection, + farm_rules: &FarmRulesProjection, + ) { + *self = Self::Open(ProductEditorSession::new_draft(farm_readiness, farm_rules)); } fn open_existing( @@ -253,11 +268,13 @@ impl ProductEditorState { product_id: ProductId, draft: ProductEditorDraft, farm_readiness: &FarmWorkspaceReadinessProjection, + farm_rules: &FarmRulesProjection, ) { *self = Self::Open(ProductEditorSession::existing( product_id, draft, farm_readiness, + farm_rules, )); } @@ -265,9 +282,10 @@ impl ProductEditorState { &mut self, draft: ProductEditorDraft, farm_readiness: &FarmWorkspaceReadinessProjection, + farm_rules: &FarmRulesProjection, ) { if let Self::Open(session) = self { - session.replace_draft(draft, farm_readiness); + session.replace_draft(draft, farm_readiness, farm_rules); } } @@ -976,6 +994,7 @@ impl PersistedAppState { sync_product_editor_publish_blockers( &mut projection.products.editor, &projection.farm_readiness, + &projection.farm_rules, ); projection.startup_gate = projection.identity.startup_gate(); projection.personal.entry = projection.identity.personal_entry(); @@ -1871,19 +1890,22 @@ fn apply_command(projection: &mut AppProjection, command: AppStateCommand) -> Ap projection .products .editor - .open_new_draft(&projection.farm_readiness); + .open_new_draft(&projection.farm_readiness, &projection.farm_rules); } AppStateCommand::OpenExistingProductEditor { product_id, draft } => { - projection - .products - .editor - .open_existing(product_id, draft, &projection.farm_readiness); + projection.products.editor.open_existing( + product_id, + draft, + &projection.farm_readiness, + &projection.farm_rules, + ); } AppStateCommand::ReplaceProductEditorDraft(draft) => { - projection - .products - .editor - .replace_draft(draft, &projection.farm_readiness); + projection.products.editor.replace_draft( + draft, + &projection.farm_readiness, + &projection.farm_rules, + ); } AppStateCommand::CloseProductEditor => { projection.products.editor.close(); @@ -1936,6 +1958,7 @@ fn sync_projection(projection: &mut AppProjection) { sync_product_editor_publish_blockers( &mut projection.products.editor, &projection.farm_readiness, + &projection.farm_rules, ); projection.startup_gate = projection.identity.startup_gate(); projection.personal.entry = projection.identity.personal_entry(); @@ -2076,9 +2099,16 @@ pub fn derive_today_setup_checklist( pub fn derive_product_publish_blockers( draft: &ProductEditorDraft, farm_readiness: &FarmWorkspaceReadinessProjection, + farm_rules: &FarmRulesProjection, ) -> Vec<ProductPublishBlocker> { let mut blockers = draft.publish_blockers(); + if draft.availability_window_id.is_some() + && !product_availability_window_exists(draft, farm_rules) + { + push_unique_product_blocker(&mut blockers, ProductPublishBlocker::AttachAvailability); + } + if farm_readiness.has_saved_farm { replace_availability_blocker(&mut blockers, farm_readiness); @@ -2109,6 +2139,18 @@ pub fn derive_product_publish_blockers( blockers } +fn product_availability_window_exists( + draft: &ProductEditorDraft, + farm_rules: &FarmRulesProjection, +) -> bool { + draft.availability_window_id.is_some_and(|window_id| { + farm_rules + .fulfillment_windows + .iter() + .any(|window| window.fulfillment_window_id == window_id) + }) +} + fn sync_coarse_farm_readiness( farm_setup: &mut FarmSetupProjection, today: &mut TodayAgendaProjection, @@ -2130,9 +2172,11 @@ fn sync_coarse_farm_readiness( fn sync_product_editor_publish_blockers( editor: &mut ProductEditorState, farm_readiness: &FarmWorkspaceReadinessProjection, + farm_rules: &FarmRulesProjection, ) { if let ProductEditorState::Open(session) = editor { - session.publish_blockers = derive_product_publish_blockers(&session.draft, farm_readiness); + session.publish_blockers = + derive_product_publish_blockers(&session.draft, farm_readiness, farm_rules); } } @@ -2208,21 +2252,23 @@ mod tests { }; use radroots_app_models::{ AccountCustody, AccountSummary, ActiveSurface, AppIdentityProjection, AppStartupGate, - FarmId, FarmOrderMethod, FarmReadiness, FarmSetupDraft, FarmSetupProjection, - FarmerActivationProjection, FarmerSection, FulfillmentWindowId, LoggedOutStartupPhase, - LoggedOutStartupProjection, OrderDetailItemRow, OrderDetailProjection, OrderId, - OrderPrimaryAction, OrderStatus, OrdersFilter, OrdersListProjection, OrdersListRow, - OrdersListSummary, OrdersScreenQueryState, PackDayBatchPrintArtifact, - PackDayBatchPrintFailureKind, PackDayBatchPrintStatus, PackDayExportArtifact, - PackDayExportArtifactKind, PackDayExportBundle, PackDayExportInstanceId, - PackDayExportStatus, PackDayHostHandoffKind, PackDayHostHandoffStatus, PackDayPackListRow, - PackDayPrintFailureKind, PackDayPrintKind, PackDayPrintLabelStock, PackDayPrintStatus, - PackDayProductTotalRow, PackDayProjection, PackDayRosterRow, PackDayScreenQueryState, - PersonalEntryState, PersonalSection, ProductEditorDraft, ProductId, ProductPublishBlocker, - ProductsFilter, ProductsListProjection, ProductsSort, ReminderDeliveryState, - ReminderFeedProjection, ReminderKind, ReminderLogEntryProjection, ReminderLogProjection, - SelectedAccountProjection, SelectedSurfaceProjection, SettingsSection, ShellSection, - TodayAgendaProjection, TodaySetupTask, TodaySetupTaskKind, + FarmId, FarmOperatingRulesRecord, FarmOrderMethod, FarmProfileRecord, FarmReadiness, + FarmRulesProjection, FarmRulesReadiness, FarmSetupDraft, FarmSetupProjection, FarmSummary, + FarmerActivationProjection, FarmerSection, FulfillmentWindowId, FulfillmentWindowRecord, + LoggedOutStartupPhase, LoggedOutStartupProjection, OrderDetailItemRow, + OrderDetailProjection, OrderId, OrderPrimaryAction, OrderStatus, OrdersFilter, + OrdersListProjection, OrdersListRow, OrdersListSummary, OrdersScreenQueryState, + PackDayBatchPrintArtifact, PackDayBatchPrintFailureKind, PackDayBatchPrintStatus, + PackDayExportArtifact, PackDayExportArtifactKind, PackDayExportBundle, + PackDayExportInstanceId, PackDayExportStatus, PackDayHostHandoffKind, + PackDayHostHandoffStatus, PackDayPackListRow, PackDayPrintFailureKind, PackDayPrintKind, + PackDayPrintLabelStock, PackDayPrintStatus, PackDayProductTotalRow, PackDayProjection, + PackDayRosterRow, PackDayScreenQueryState, PersonalEntryState, PersonalSection, + PickupLocationId, PickupLocationRecord, ProductEditorDraft, ProductId, + ProductPublishBlocker, ProductsFilter, ProductsListProjection, ProductsSort, + ReminderDeliveryState, ReminderFeedProjection, ReminderKind, ReminderLogEntryProjection, + ReminderLogProjection, SelectedAccountProjection, SelectedSurfaceProjection, + SettingsSection, ShellSection, TodayAgendaProjection, TodaySetupTask, TodaySetupTaskKind, }; use radroots_app_sync::{ AppSyncProjection, AppSyncRunStatus, SyncCheckpointState, SyncCheckpointStatus, @@ -3477,7 +3523,7 @@ mod tests { ProductEditorState::Open(super::ProductEditorSession { selected_product_id: None, draft: ready_draft.clone(), - publish_blockers: Vec::new(), + publish_blockers: vec![ProductPublishBlocker::AttachAvailability], }) ); @@ -3493,7 +3539,7 @@ mod tests { ProductEditorState::Open(super::ProductEditorSession { selected_product_id: Some(product_id), draft: ready_draft, - publish_blockers: Vec::new(), + publish_blockers: vec![ProductPublishBlocker::AttachAvailability], }) ); @@ -3514,6 +3560,122 @@ mod tests { } #[test] + fn product_editor_publish_blockers_require_current_fulfillment_window() { + let mut store = AppStateStore::load(InMemoryAppStateRepository::default()) + .expect("in-memory repository should load"); + let farm_id = FarmId::new(); + let pickup_location_id = PickupLocationId::new(); + let active_window_id = FulfillmentWindowId::new(); + let stale_window_id = FulfillmentWindowId::new(); + let product_id = ProductId::new(); + let publishable_draft = ProductEditorDraft { + title: "Salad mix".to_owned(), + subtitle: "Spring blend".to_owned(), + category: "greens".to_owned(), + unit_label: "bag".to_owned(), + price_minor_units: Some(900), + price_currency: "USD".to_owned(), + stock_quantity: Some(12), + availability_window_id: Some(active_window_id), + status: radroots_app_models::ProductStatus::Published, + }; + let stale_draft = ProductEditorDraft { + availability_window_id: Some(stale_window_id), + ..publishable_draft.clone() + }; + + assert_eq!( + store.apply(AppStateCommand::replace_farm_setup_projection( + FarmSetupProjection::from_saved_farm(FarmSummary { + farm_id, + display_name: "North field farm".to_owned(), + readiness: FarmReadiness::Ready, + }), + )), + Ok(true) + ); + assert_eq!( + store.apply(AppStateCommand::replace_farm_rules_projection( + FarmRulesProjection { + farm_profile: Some(FarmProfileRecord { + farm_id, + display_name: "North field farm".to_owned(), + timezone: "UTC".to_owned(), + currency_code: "USD".to_owned(), + }), + pickup_locations: vec![PickupLocationRecord { + pickup_location_id, + farm_id, + label: "Barn pickup".to_owned(), + address_line: "14 Orchard Lane".to_owned(), + directions: None, + is_default: true, + }], + operating_rules: Some(FarmOperatingRulesRecord { + farm_id, + promise_lead_hours: 24, + substitution_policy: "ask_customer".to_owned(), + missed_pickup_policy: "hold_next_window".to_owned(), + }), + fulfillment_windows: vec![FulfillmentWindowRecord { + fulfillment_window_id: active_window_id, + farm_id, + pickup_location_id, + label: "Friday pickup".to_owned(), + starts_at: "2099-04-25T14:00:00Z".to_owned(), + ends_at: "2099-04-25T18:00:00Z".to_owned(), + order_cutoff_at: "2099-04-24T18:00:00Z".to_owned(), + }], + blackout_periods: Vec::new(), + readiness: FarmRulesReadiness::ready(), + }, + )), + Ok(true) + ); + + assert_eq!( + store.apply(AppStateCommand::open_existing_product_editor( + product_id, + publishable_draft, + )), + Ok(true) + ); + assert_eq!( + store.projection().products.editor, + ProductEditorState::Open(super::ProductEditorSession { + selected_product_id: Some(product_id), + draft: ProductEditorDraft { + title: "Salad mix".to_owned(), + subtitle: "Spring blend".to_owned(), + category: "greens".to_owned(), + unit_label: "bag".to_owned(), + price_minor_units: Some(900), + price_currency: "USD".to_owned(), + stock_quantity: Some(12), + availability_window_id: Some(active_window_id), + status: radroots_app_models::ProductStatus::Published, + }, + publish_blockers: Vec::new(), + }) + ); + + assert_eq!( + store.apply(AppStateCommand::replace_product_editor_draft( + stale_draft.clone(), + )), + Ok(true) + ); + assert_eq!( + store.projection().products.editor, + ProductEditorState::Open(super::ProductEditorSession { + selected_product_id: Some(product_id), + draft: stale_draft, + publish_blockers: vec![ProductPublishBlocker::AttachAvailability], + }) + ); + } + + #[test] fn select_settings_section_updates_shared_settings_without_clobbering_home() { let mut store = AppStateStore::load(InMemoryAppStateRepository::default()) .expect("in-memory repository should load");