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:
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");