diff --git a/CHANGELOG.md b/CHANGELOG.md index 7a68f372..d830f85b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Fixed + +- S3 storage errors now propagate instead of silently returning empty site — misconfigured or unreachable S3 returns proper error messages to the Backstage plugin and 503 responses from the HTTP server + ## [0.1.18] - 2026-03-23 ### Added diff --git a/crates/rw-napi/src/lib.rs b/crates/rw-napi/src/lib.rs index ce8a06f6..67b4e548 100644 --- a/crates/rw-napi/src/lib.rs +++ b/crates/rw-napi/src/lib.rs @@ -158,13 +158,16 @@ pub fn create_site(config: SiteConfig) -> Result { #[allow(clippy::needless_pass_by_value)] impl RwSite { #[napi(js_name = "getNavigation")] - pub fn get_navigation(&self, section_ref: Option) -> NavigationResponse { - let nav = self.site.navigation(section_ref.as_deref()); - NavigationResponse { + pub fn get_navigation(&self, section_ref: Option) -> Result { + let nav = self + .site + .navigation(section_ref.as_deref()) + .map_err(|e| napi::Error::from_reason(e.to_string()))?; + Ok(NavigationResponse { items: nav.items.into_iter().map(convert_nav_item).collect(), scope: nav.scope.map(convert_scope_info), parent_scope: nav.parent_scope.map(convert_scope_info), - } + }) } #[napi] @@ -189,7 +192,9 @@ fn build_page_response(site: &Site, path: &str) -> Result { let source_mtime = UNIX_EPOCH + Duration::from_secs_f64(result.source_mtime); let last_modified: DateTime = source_mtime.into(); let last_modified = last_modified.to_rfc3339(); - let section_ref = site.get_section_ref(path); + let section_ref = site + .get_section_ref(path) + .map_err(|e| napi::Error::from_reason(e.to_string()))?; let (description, page_kind, vars) = if let Some(ref meta) = result.metadata { ( diff --git a/crates/rw-server/src/error.rs b/crates/rw-server/src/error.rs index f57b5dba..e5be14cf 100644 --- a/crates/rw-server/src/error.rs +++ b/crates/rw-server/src/error.rs @@ -37,6 +37,10 @@ pub(crate) enum HandlerError { /// I/O error. #[error("I/O error: {0}")] Io(#[from] std::io::Error), + + /// Storage backend unavailable. + #[error("Storage error: {0}")] + Storage(#[from] rw_storage::StorageError), } impl IntoResponse for HandlerError { @@ -54,8 +58,28 @@ impl IntoResponse for HandlerError { StatusCode::INTERNAL_SERVER_ERROR, json!({"error": e.to_string()}), ), + Self::Storage(e) => ( + StatusCode::SERVICE_UNAVAILABLE, + json!({"error": "Storage unavailable", "detail": e.to_string()}), + ), }; (status, axum::Json(body)).into_response() } } + +#[cfg(test)] +mod tests { + use super::*; + use axum::response::IntoResponse; + + #[test] + fn test_storage_error_returns_503() { + let err = HandlerError::Storage( + rw_storage::StorageError::new(rw_storage::StorageErrorKind::Unavailable) + .with_backend("S3"), + ); + let response = err.into_response(); + assert_eq!(response.status(), StatusCode::SERVICE_UNAVAILABLE); + } +} diff --git a/crates/rw-server/src/handlers/navigation.rs b/crates/rw-server/src/handlers/navigation.rs index 0454f89e..ebf00c40 100644 --- a/crates/rw-server/src/handlers/navigation.rs +++ b/crates/rw-server/src/handlers/navigation.rs @@ -9,6 +9,7 @@ use axum::extract::{Query, State}; use rw_site::{NavItem, ScopeInfo, Section}; use serde::{Deserialize, Serialize}; +use crate::error::HandlerError; use crate::handlers::to_url_path; use crate::state::AppState; @@ -89,10 +90,10 @@ impl From for NavItemResponse { pub(crate) async fn get_navigation( Query(query): Query, State(state): State>, -) -> Json { - let scoped_nav = state.site.navigation(query.section_ref.as_deref()); +) -> Result, HandlerError> { + let scoped_nav = state.site.navigation(query.section_ref.as_deref())?; - Json(NavigationResponse { + Ok(Json(NavigationResponse { items: scoped_nav .items .into_iter() @@ -100,7 +101,7 @@ pub(crate) async fn get_navigation( .collect(), scope: scoped_nav.scope.map(ScopeInfoResponse::from), parent_scope: scoped_nav.parent_scope.map(ScopeInfoResponse::from), - }) + })) } #[cfg(test)] diff --git a/crates/rw-server/src/handlers/pages.rs b/crates/rw-server/src/handlers/pages.rs index cb78f769..1f054b36 100644 --- a/crates/rw-server/src/handlers/pages.rs +++ b/crates/rw-server/src/handlers/pages.rs @@ -126,10 +126,11 @@ fn get_page_impl( headers: HeaderMap, ) -> Result { // Render the page using unified Site API (path is already without leading slash) - let result = state - .site - .render(&path) - .map_err(|_| HandlerError::PageNotFound(path.clone()))?; + let result = state.site.render(&path).map_err(|e| match e { + rw_site::RenderError::PageNotFound(p) => HandlerError::PageNotFound(p), + rw_site::RenderError::Storage(se) => HandlerError::Storage(se), + other => HandlerError::Render(other), + })?; // Log warnings in verbose mode if state.verbose && !result.warnings.is_empty() { @@ -169,7 +170,7 @@ fn get_page_impl( }; // Get section ref for this page's section - let section_ref = state.site.get_section_ref(&path); + let section_ref = state.site.get_section_ref(&path)?; let response = PageResponse { meta: PageMeta { diff --git a/crates/rw-server/src/live_reload/manager.rs b/crates/rw-server/src/live_reload/manager.rs index 67c209bb..ccee34c2 100644 --- a/crates/rw-server/src/live_reload/manager.rs +++ b/crates/rw-server/src/live_reload/manager.rs @@ -116,7 +116,7 @@ impl LiveReloadManager { } StorageEventKind::Created => { site.invalidate(); - if site.has_page(&event.path) { + if site.has_page(&event.path).unwrap_or(false) { let _ = broadcaster.send(ReloadEvent { event_type: ReloadEventType::Structure, path: url_path, @@ -124,7 +124,7 @@ impl LiveReloadManager { } } StorageEventKind::Removed => { - let known = site.has_page(&event.path); + let known = site.has_page(&event.path).unwrap_or(false); site.invalidate(); if known { let _ = broadcaster.send(ReloadEvent { diff --git a/crates/rw-site/src/lib.rs b/crates/rw-site/src/lib.rs index 8c384e57..989c9338 100644 --- a/crates/rw-site/src/lib.rs +++ b/crates/rw-site/src/lib.rs @@ -20,7 +20,7 @@ //! let site = Arc::new(Site::new(storage, cache, config)); //! //! // Get navigation (root) -//! let nav = site.navigation(None); +//! let nav = site.navigation(None)?; //! //! // Render a page //! let result = site.render("guide")?; diff --git a/crates/rw-site/src/page.rs b/crates/rw-site/src/page.rs index 33b66d81..9d08e36f 100644 --- a/crates/rw-site/src/page.rs +++ b/crates/rw-site/src/page.rs @@ -98,6 +98,9 @@ pub enum RenderError { /// I/O error reading source file. #[error("I/O error: {0}")] Io(#[source] std::io::Error), + /// Storage backend error (e.g., S3 unavailable). + #[error("Storage error: {0}")] + Storage(#[source] StorageError), } impl From for RenderError { @@ -109,7 +112,7 @@ impl From for RenderError { .map(|p| p.to_string_lossy().to_string()) .unwrap_or_default(), ), - _ => Self::Io(std::io::Error::other(e.to_string())), + _ => Self::Storage(e), } } } @@ -189,10 +192,7 @@ impl PageRenderer { meta_include_source: Option>, sections: &Arc, ) -> Result { - let source_mtime = self - .storage - .mtime(path) - .map_err(|_| RenderError::FileNotFound(path.to_owned()))?; + let source_mtime = self.storage.mtime(path).map_err(RenderError::from)?; let metadata = self.load_metadata(path); diff --git a/crates/rw-site/src/site.rs b/crates/rw-site/src/site.rs index 43153c49..11c6753e 100644 --- a/crates/rw-site/src/site.rs +++ b/crates/rw-site/src/site.rs @@ -33,7 +33,7 @@ //! let site = Arc::new(Site::new(storage, cache, config)); //! //! // Load site structure -//! let nav = site.navigation(None); +//! let nav = site.navigation(None)?; //! //! // Render a page //! let result = site.render("guide")?; @@ -52,7 +52,7 @@ use crate::site_state::{Navigation, SiteState, SiteStateBuilder}; use rw_cache::{Cache, CacheBucket}; use rw_diagrams::{EntityInfo, MetaIncludeSource}; use rw_sections::Sections; -use rw_storage::Storage; +use rw_storage::{Storage, StorageError}; /// Get the depth of a URL path. /// @@ -128,6 +128,8 @@ pub struct Site { current_snapshot: RwLock>, /// Cache validity flag. cache_valid: AtomicBool, + /// Whether the site has successfully loaded at least once. + has_loaded: AtomicBool, /// Page rendering pipeline. renderer: PageRenderer, } @@ -161,6 +163,7 @@ impl Site { reload_lock: Mutex::new(()), current_snapshot: RwLock::new(initial_snapshot), cache_valid: AtomicBool::new(false), + has_loaded: AtomicBool::new(false), renderer, } } @@ -175,25 +178,27 @@ impl Site { /// Pass `None` for root navigation, or a section ref string /// (e.g., `"domain:default/billing"`) to scope to that section. /// - /// # Panics + /// # Errors /// - /// Panics if internal locks are poisoned. - #[must_use] - pub fn navigation(&self, section_ref: Option<&str>) -> Navigation { - let snapshot = self.reload_if_needed(); + /// Returns `StorageError` if initial site load fails. + pub fn navigation(&self, section_ref: Option<&str>) -> Result { + let snapshot = self.reload_if_needed()?; let scope_path = section_ref .and_then(|r| snapshot.sections.find_by_ref(r).map(str::to_owned)) .unwrap_or_default(); let mut nav = snapshot.state.navigation(&scope_path); nav.apply_sections(&snapshot.sections); - nav + Ok(nav) } /// Get the current sections map. - #[must_use] - pub fn sections(&self) -> Arc { - let snapshot = self.reload_if_needed(); - Arc::clone(&snapshot.sections) + /// + /// # Errors + /// + /// Returns `StorageError` if initial site load fails. + pub fn sections(&self) -> Result, StorageError> { + let snapshot = self.reload_if_needed()?; + Ok(Arc::clone(&snapshot.sections)) } /// Get the section ref string for the section a page belongs to. @@ -206,12 +211,11 @@ impl Site { /// /// * `page_path` - URL path without leading slash. /// - /// # Panics + /// # Errors /// - /// Panics if internal locks are poisoned. - #[must_use] - pub fn get_section_ref(&self, page_path: &str) -> String { - self.reload_if_needed().state.get_section_ref(page_path) + /// Returns `StorageError` if initial site load fails. + pub fn get_section_ref(&self, page_path: &str) -> Result { + Ok(self.reload_if_needed()?.state.get_section_ref(page_path)) } /// Check if a page exists at the given URL path. @@ -222,12 +226,11 @@ impl Site { /// /// * `path` - URL path without leading slash (e.g., "guide", "domain/page", "" for root) /// - /// # Panics + /// # Errors /// - /// Panics if internal locks are poisoned. - #[must_use] - pub fn has_page(&self, path: &str) -> bool { - self.reload_if_needed().state.get_page(path).is_some() + /// Returns `StorageError` if initial site load fails. + pub fn has_page(&self, path: &str) -> Result { + Ok(self.reload_if_needed()?.state.get_page(path).is_some()) } /// Get the title of a page from the current cached snapshot. @@ -255,12 +258,11 @@ impl Site { /// /// * `path` - URL path without leading slash (e.g., "guide/setup", "" for root) /// - /// # Panics + /// # Errors /// - /// Panics if internal locks are poisoned. - #[must_use] - pub fn get_breadcrumbs(&self, path: &str) -> Vec { - self.reload_if_needed().state.get_breadcrumbs(path) + /// Returns `StorageError` if initial site load fails. + pub fn get_breadcrumbs(&self, path: &str) -> Result, StorageError> { + Ok(self.reload_if_needed()?.state.get_breadcrumbs(path)) } /// Reload site from storage if cache is invalid. @@ -269,17 +271,24 @@ impl Site { /// 1. Fast path: return current snapshot if cache valid /// 2. Slow path: acquire `reload_lock`, recheck, then reload /// + /// On initial load, storage errors are propagated to the caller. + /// On subsequent reloads, storage errors are logged and stale data is kept. + /// /// # Returns /// /// `Arc` containing the current site snapshot. /// + /// # Errors + /// + /// Returns `StorageError` if the initial site load fails. + /// /// # Panics /// /// Panics if internal locks are poisoned. - pub(crate) fn reload_if_needed(&self) -> Arc { + pub(crate) fn reload_if_needed(&self) -> Result, StorageError> { // Fast path: cache valid if self.cache_valid.load(Ordering::Acquire) { - return self.snapshot(); + return Ok(self.snapshot()); } // Slow path: acquire reload lock @@ -287,16 +296,30 @@ impl Site { // Double-check after acquiring lock if self.cache_valid.load(Ordering::Acquire) { - return self.snapshot(); + return Ok(self.snapshot()); } + let has_loaded = self.has_loaded.load(Ordering::Acquire); let etag = self.generation.load(Ordering::Acquire).to_string(); - // Load state from bucket cache or storage - let state = if let Some(cached) = SiteState::from_cache(self.site_bucket.as_ref(), &etag) { - cached + // Load state: skip bucket cache on initial load to verify storage connectivity + let state = if has_loaded { + if let Some(cached) = SiteState::from_cache(self.site_bucket.as_ref(), &etag) { + cached + } else { + match self.load_from_storage() { + Ok(state) => { + state.to_cache(self.site_bucket.as_ref(), &etag); + state + } + Err(e) => { + tracing::warn!(error = %e, "Failed to reload site from storage, keeping stale data"); + return Ok(self.snapshot()); + } + } + } } else { - let state = self.load_from_storage(); + let state = self.load_from_storage()?; state.to_cache(self.site_bucket.as_ref(), &etag); state }; @@ -306,8 +329,9 @@ impl Site { *self.current_snapshot.write().unwrap() = Arc::clone(&snapshot); self.cache_valid.store(true, Ordering::Release); + self.has_loaded.store(true, Ordering::Release); - snapshot + Ok(snapshot) } /// Invalidate cached site state. @@ -338,7 +362,7 @@ impl Site { /// Returns `RenderError::FileNotFound` if source file doesn't exist. /// Returns `RenderError::Io` if file cannot be read. pub fn render(&self, path: &str) -> Result { - let snapshot = self.reload_if_needed(); + let snapshot = self.reload_if_needed().map_err(RenderError::Storage)?; let page = snapshot .state .get_page(path) @@ -358,17 +382,9 @@ impl Site { /// Page titles are determined by: /// 1. Metadata title from storage (if page has `page_kind`) /// 2. Document title from storage (extracted from H1 or filename) - fn load_from_storage(&self) -> SiteState { + fn load_from_storage(&self) -> Result { let mut builder = SiteStateBuilder::new(); - - // Scan storage for documents (including virtual pages) - let mut documents = match self.storage.scan() { - Ok(docs) => docs, - Err(e) => { - tracing::warn!(error = %e, "Failed to scan storage"); - return builder.build(); - } - }; + let mut documents = self.storage.scan()?; // Sort documents: parents before children, real pages before virtual, by path documents.sort_by(|a, b| { @@ -379,7 +395,7 @@ impl Site { }); if documents.is_empty() { - return builder.build(); + return Ok(builder.build()); } // Track URL paths to page indices for parent lookup @@ -400,7 +416,7 @@ impl Site { url_to_idx.insert(doc.path.clone(), idx); } - builder.build() + Ok(builder.build()) } /// Find parent page index from URL path. @@ -426,9 +442,10 @@ mod tests { use std::sync::Arc; - use rw_storage::MockStorage; + use rw_storage::{MockStorage, StorageErrorKind}; use super::*; + use crate::page::RenderError; fn create_site_with_storage(storage: MockStorage) -> Site { let config = PageRendererConfig::default(); @@ -444,7 +461,7 @@ mod tests { let storage = MockStorage::new(); let site = create_site_with_storage(storage); - let snapshot = site.reload_if_needed(); + let snapshot = site.reload_if_needed().unwrap(); assert!(snapshot.state.get_root_pages().is_empty()); } @@ -457,7 +474,7 @@ mod tests { let site = create_site_with_storage(storage); - let snapshot = site.reload_if_needed(); + let snapshot = site.reload_if_needed().unwrap(); assert_eq!(snapshot.state.get_root_pages().len(), 2); assert!(snapshot.state.get_page("guide").is_some()); @@ -471,7 +488,7 @@ mod tests { let site = create_site_with_storage(storage); - let snapshot = site.reload_if_needed(); + let snapshot = site.reload_if_needed().unwrap(); let page = snapshot.state.get_page(""); assert!(page.is_some()); @@ -489,7 +506,7 @@ mod tests { let site = create_site_with_storage(storage); - let snapshot = site.reload_if_needed(); + let snapshot = site.reload_if_needed().unwrap(); let domain = snapshot.state.get_page("domain-a"); assert!(domain.is_some()); @@ -515,7 +532,7 @@ mod tests { let site = create_site_with_storage(storage); - let snapshot = site.reload_if_needed(); + let snapshot = site.reload_if_needed().unwrap(); let page = snapshot.state.get_page("guide"); assert!(page.is_some()); @@ -528,7 +545,7 @@ mod tests { let site = create_site_with_storage(storage); - let snapshot = site.reload_if_needed(); + let snapshot = site.reload_if_needed().unwrap(); let page = snapshot.state.get_page("руководство"); assert!(page.is_some()); @@ -545,7 +562,7 @@ mod tests { let site = create_site_with_storage(storage); - let snapshot = site.reload_if_needed(); + let snapshot = site.reload_if_needed().unwrap(); // Child should be at root level (promoted) let roots = snapshot.state.get_root_pages(); @@ -561,7 +578,7 @@ mod tests { let site = create_site_with_storage(storage); // First reload to populate - let _ = site.reload_if_needed(); + let _ = site.reload_if_needed().unwrap(); // snapshot() should return the same Arc let snapshot1 = site.snapshot(); @@ -576,8 +593,8 @@ mod tests { let site = create_site_with_storage(storage); - let state1 = site.reload_if_needed(); - let state2 = site.reload_if_needed(); + let state1 = site.reload_if_needed().unwrap(); + let state2 = site.reload_if_needed().unwrap(); // Should return the same Arc (cached) assert!(Arc::ptr_eq(&state1, &state2)); @@ -590,14 +607,14 @@ mod tests { let site = create_site_with_storage(storage); // First reload - let snapshot1 = site.reload_if_needed(); + let snapshot1 = site.reload_if_needed().unwrap(); assert!(snapshot1.state.get_page("guide").is_some()); // Invalidate cache site.invalidate(); // Second reload - should be a different Arc - let snapshot2 = site.reload_if_needed(); + let snapshot2 = site.reload_if_needed().unwrap(); assert!(!Arc::ptr_eq(&snapshot1, &snapshot2)); } @@ -614,7 +631,7 @@ mod tests { .map(|_| { let site = Arc::clone(&site); thread::spawn(move || { - let snapshot = site.reload_if_needed(); + let snapshot = site.reload_if_needed().unwrap(); assert!(snapshot.state.get_page("guide").is_some()); }) }) @@ -634,7 +651,7 @@ mod tests { let site = Arc::new(create_site_with_storage(storage)); // Initial load - let _ = site.reload_if_needed(); + let _ = site.reload_if_needed().unwrap(); // Spawn threads that invalidate and reload concurrently let handles: Vec<_> = (0..10) @@ -644,7 +661,7 @@ mod tests { if i % 2 == 0 { site.invalidate(); } else { - let snapshot = site.reload_if_needed(); + let snapshot = site.reload_if_needed().unwrap(); // Site should always be valid assert!(snapshot.state.get_page("guide").is_some()); } @@ -657,7 +674,7 @@ mod tests { } // Final state should be valid - let snapshot = site.reload_if_needed(); + let snapshot = site.reload_if_needed().unwrap(); assert!(snapshot.state.get_page("guide").is_some()); } @@ -671,7 +688,7 @@ mod tests { let site = create_site_with_storage(storage); - let snapshot = site.reload_if_needed(); + let snapshot = site.reload_if_needed().unwrap(); // Check root let root = snapshot.state.get_page("").unwrap(); @@ -811,7 +828,7 @@ mod tests { let site = create_site_with_storage(storage); - let snapshot = site.reload_if_needed(); + let snapshot = site.reload_if_needed().unwrap(); let page = snapshot.state.get_page("my-domain"); assert!(page.is_some()); @@ -833,7 +850,7 @@ mod tests { let site = create_site_with_storage(storage); - let snapshot = site.reload_if_needed(); + let snapshot = site.reload_if_needed().unwrap(); let page = snapshot.state.get_page("real-domain"); assert!(page.is_some()); @@ -871,7 +888,7 @@ mod tests { let site = create_site_with_storage(storage); - let nav = site.navigation(None); + let nav = site.navigation(None).unwrap(); assert_eq!(nav.items.len(), 1); assert_eq!(nav.items[0].title, "My Domain"); @@ -893,7 +910,7 @@ mod tests { let site = create_site_with_storage(storage); - let snapshot = site.reload_if_needed(); + let snapshot = site.reload_if_needed().unwrap(); // Check parent virtual let domains = snapshot.state.get_page("domains"); @@ -912,21 +929,21 @@ mod tests { // Check navigation structure via scoped navigation // Domains section in root scope - let root_nav = site.navigation(None); + let root_nav = site.navigation(None).unwrap(); assert_eq!(root_nav.items.len(), 1); assert_eq!(root_nav.items[0].title, "Domains"); // Sections are leaves in root scope assert!(root_nav.items[0].children.is_empty()); // Navigate into Domains section - let domains_nav = site.navigation(Some("section:default/domains")); + let domains_nav = site.navigation(Some("section:default/domains")).unwrap(); assert_eq!(domains_nav.items.len(), 1); assert_eq!(domains_nav.items[0].title, "Billing"); // Billing is also a section, so it's a leaf in domains scope assert!(domains_nav.items[0].children.is_empty()); // Navigate into Billing section - let billing_nav = site.navigation(Some("domain:default/billing")); + let billing_nav = site.navigation(Some("domain:default/billing")).unwrap(); assert_eq!(billing_nav.items.len(), 1); assert_eq!(billing_nav.items[0].title, "Overview"); } @@ -997,7 +1014,7 @@ mod tests { let site = create_site_with_storage(storage); // Trigger initial load - let _ = site.reload_if_needed(); + let _ = site.reload_if_needed().unwrap(); assert_eq!(site.page_title("guide"), Some("User Guide".to_owned())); } @@ -1007,7 +1024,7 @@ mod tests { let storage = MockStorage::new().with_document("guide", "Guide"); let site = create_site_with_storage(storage); - let _ = site.reload_if_needed(); + let _ = site.reload_if_needed().unwrap(); assert_eq!(site.page_title("nonexistent"), None); } @@ -1018,9 +1035,76 @@ mod tests { let site = create_site_with_storage(storage); // Load initial state - let _ = site.reload_if_needed(); + let _ = site.reload_if_needed().unwrap(); // page_title reads cached snapshot, not triggering reload assert_eq!(site.page_title("guide"), Some("Old Title".to_owned())); } + + // ======================================================================== + // Storage error propagation tests + // ======================================================================== + + #[test] + fn test_reload_if_needed_scan_error_on_initial_load() { + let storage = MockStorage::new().with_scan_error(StorageErrorKind::Unavailable); + let site = create_site_with_storage(storage); + + let result = site.reload_if_needed(); + + assert!(result.is_err()); + assert_eq!(result.err().unwrap().kind, StorageErrorKind::Unavailable); + } + + #[test] + fn test_reload_keeps_stale_data_on_subsequent_scan_error() { + let storage = Arc::new(MockStorage::new().with_document("guide", "Guide")); + let config = PageRendererConfig::default(); + let site = Site::new( + Arc::clone(&storage) as Arc, + Arc::new(rw_cache::NullCache), + config, + ); + + // Initial load succeeds + let snapshot = site.reload_if_needed().unwrap(); + assert!(snapshot.state.get_page("guide").is_some()); + + // Make storage fail + storage.set_scan_error(Some(StorageErrorKind::Unavailable)); + site.invalidate(); + + // Reload should succeed with stale data + let snapshot2 = site.reload_if_needed().unwrap(); + assert!(snapshot2.state.get_page("guide").is_some()); + } + + #[test] + fn test_navigation_propagates_storage_error_on_initial_failure() { + let storage = MockStorage::new().with_scan_error(StorageErrorKind::Unavailable); + let site = create_site_with_storage(storage); + + let result = site.navigation(None); + assert!(result.is_err()); + assert_eq!(result.unwrap_err().kind, StorageErrorKind::Unavailable); + } + + #[test] + fn test_render_propagates_storage_error_on_initial_failure() { + let storage = MockStorage::new().with_scan_error(StorageErrorKind::Unavailable); + let site = create_site_with_storage(storage); + + let result = site.render("test"); + assert!(matches!(result, Err(RenderError::Storage(_)))); + } + + #[test] + fn test_has_page_propagates_storage_error_on_initial_failure() { + let storage = MockStorage::new().with_scan_error(StorageErrorKind::Unavailable); + let site = create_site_with_storage(storage); + + let result = site.has_page("test"); + assert!(result.is_err()); + assert_eq!(result.unwrap_err().kind, StorageErrorKind::Unavailable); + } } diff --git a/crates/rw-storage/src/mock.rs b/crates/rw-storage/src/mock.rs index 6031c391..2c79c336 100644 --- a/crates/rw-storage/src/mock.rs +++ b/crates/rw-storage/src/mock.rs @@ -39,6 +39,8 @@ pub struct MockStorage { mtimes: RwLock>, /// Metadata keyed by URL path. metadata: RwLock>, + /// If set, `scan()` returns this error kind. + scan_error: RwLock>, event_sender: RwLock>>, } @@ -49,6 +51,7 @@ impl Default for MockStorage { contents: RwLock::new(HashMap::new()), mtimes: RwLock::new(HashMap::new()), metadata: RwLock::new(HashMap::new()), + scan_error: RwLock::new(None), event_sender: RwLock::new(None), } } @@ -215,6 +218,26 @@ impl MockStorage { self } + /// Configure `scan()` to return an error with the given kind. + /// + /// # Panics + /// + /// Panics if the internal lock is poisoned. + #[must_use] + pub fn with_scan_error(self, kind: StorageErrorKind) -> Self { + *self.scan_error.write().unwrap() = Some(kind); + self + } + + /// Set or clear the scan error at runtime (for testing reload-with-error scenarios). + /// + /// # Panics + /// + /// Panics if the internal lock is poisoned. + pub fn set_scan_error(&self, kind: Option) { + *self.scan_error.write().unwrap() = kind; + } + /// Emit a storage event. /// /// Only works if `watch()` has been called first. @@ -269,6 +292,9 @@ impl MockStorage { impl Storage for MockStorage { fn scan(&self) -> Result, StorageError> { + if let Some(kind) = self.scan_error.read().unwrap().as_ref() { + return Err(StorageError::new(*kind).with_backend("Mock")); + } let guard = self.documents.read().unwrap(); Ok(guard .iter() @@ -599,4 +625,16 @@ mod tests { // Emit before watch() is called should not panic storage.emit_created("test"); } + + #[test] + fn test_scan_error() { + let storage = MockStorage::new().with_scan_error(StorageErrorKind::Unavailable); + + let result = storage.scan(); + + assert!(result.is_err()); + let err = result.unwrap_err(); + assert_eq!(err.kind, StorageErrorKind::Unavailable); + assert_eq!(err.backend, Some("Mock")); + } } diff --git a/crates/rw-storage/src/storage.rs b/crates/rw-storage/src/storage.rs index 6f38e6fc..94ba0b2f 100644 --- a/crates/rw-storage/src/storage.rs +++ b/crates/rw-storage/src/storage.rs @@ -49,7 +49,7 @@ pub struct Document { } /// Semantic error categories (inspired by Object Store + `OpenDAL`). -#[derive(Debug, PartialEq, Eq)] +#[derive(Debug, Clone, Copy, PartialEq, Eq)] #[non_exhaustive] pub enum StorageErrorKind { /// Resource does not exist.