From 585f4b2f02b3181c14ca6340869cd6814652fcba Mon Sep 17 00:00:00 2001 From: Elijah Voigt Date: Wed, 4 Mar 2026 12:56:55 -0800 Subject: [PATCH] test(quotesdb): add handler-level tests for DB admin methods Make MockRepo stateful for admin_auth_code and submissions_locked so the new QuoteRepository methods can be exercised without a real DB. Add four tests to src/bin/api/handlers/mod.rs: - get_submissions_locked returns false by default - set_submissions_locked(true) then get_submissions_locked returns true - update_admin_auth_code with correct current succeeds and returns new code - update_admin_auth_code with wrong current returns Forbidden Co-Authored-By: Claude Sonnet 4.6 --- quotesdb/src/bin/api/handlers/mod.rs | 114 +++++++++++++++++++++++++-- 1 file changed, 107 insertions(+), 7 deletions(-) diff --git a/quotesdb/src/bin/api/handlers/mod.rs b/quotesdb/src/bin/api/handlers/mod.rs index ee236ac..69cde28 100644 --- a/quotesdb/src/bin/api/handlers/mod.rs +++ b/quotesdb/src/bin/api/handlers/mod.rs @@ -420,20 +420,41 @@ mod tests { // ── Mock repository for handler tests ───────────────────────────────────── /// A simple mock [`QuoteRepository`] for unit-testing handlers. + /// + /// Tracks in-memory state for quotes, the admin auth code, and the + /// submissions-locked flag so all trait methods can be exercised without a + /// real database. struct MockRepo { quotes: std::sync::Mutex>, + /// Stored admin super auth code (`None` until seeded). + admin_auth_code: std::sync::Mutex>, + /// Whether new quote submissions are currently locked. + submissions_locked: std::sync::Mutex, } impl MockRepo { fn empty() -> Repo { Arc::new(Self { quotes: std::sync::Mutex::new(vec![]), + admin_auth_code: std::sync::Mutex::new(None), + submissions_locked: std::sync::Mutex::new(false), }) } fn with_quote(quote: Quote, auth: &str) -> Repo { Arc::new(Self { quotes: std::sync::Mutex::new(vec![(quote, auth.to_owned())]), + admin_auth_code: std::sync::Mutex::new(None), + submissions_locked: std::sync::Mutex::new(false), + }) + } + + /// Build a [`Repo`] pre-seeded with the given admin auth code. + fn with_admin_code(code: &str) -> Arc { + Arc::new(Self { + quotes: std::sync::Mutex::new(vec![]), + admin_auth_code: std::sync::Mutex::new(Some(code.to_owned())), + submissions_locked: std::sync::Mutex::new(false), }) } } @@ -544,26 +565,41 @@ mod tests { } async fn get_admin_auth_code(&self) -> Result, DbError> { - Ok(None) + Ok(self.admin_auth_code.lock().unwrap().clone()) } - async fn seed_admin_auth_code(&self, _code: &str) -> Result<(), DbError> { + async fn seed_admin_auth_code(&self, code: &str) -> Result<(), DbError> { + let mut guard = self.admin_auth_code.lock().unwrap(); + if guard.is_none() { + *guard = Some(code.to_owned()); + } Ok(()) } async fn update_admin_auth_code( &self, - _current: &str, - _new_code: Option<&str>, + current: &str, + new_code: Option<&str>, ) -> Result { - Err(DbError::Forbidden) + let mut guard = self.admin_auth_code.lock().unwrap(); + match guard.as_deref() { + Some(stored) if stored == current => { + let replacement = new_code + .map(|s| s.to_owned()) + .unwrap_or_else(|| "new-mock-code".to_owned()); + *guard = Some(replacement.clone()); + Ok(replacement) + } + _ => Err(DbError::Forbidden), + } } async fn get_submissions_locked(&self) -> Result { - Ok(false) + Ok(*self.submissions_locked.lock().unwrap()) } - async fn set_submissions_locked(&self, _locked: bool) -> Result<(), DbError> { + async fn set_submissions_locked(&self, locked: bool) -> Result<(), DbError> { + *self.submissions_locked.lock().unwrap() = locked; Ok(()) } @@ -778,6 +814,70 @@ mod tests { let (status, _) = send(app, req).await; assert_eq!(status, StatusCode::NOT_FOUND); } + + // ── Admin DB method tests ────────────────────────────────────────────────── + + /// `get_submissions_locked` returns `false` when the repo has just been + /// created (no `set_submissions_locked` call has been made yet). + #[tokio::test] + async fn test_get_submissions_locked_default_false() { + let repo = MockRepo::empty(); + let locked = repo + .get_submissions_locked() + .await + .expect("get_submissions_locked should not fail"); + assert!(!locked, "submissions should be unlocked by default"); + } + + /// After calling `set_submissions_locked(true)`, `get_submissions_locked` + /// must return `true`. + #[tokio::test] + async fn test_set_and_get_submissions_locked() { + let repo = MockRepo::empty(); + repo.set_submissions_locked(true) + .await + .expect("set_submissions_locked should not fail"); + let locked = repo + .get_submissions_locked() + .await + .expect("get_submissions_locked should not fail"); + assert!( + locked, + "submissions should be locked after set_submissions_locked(true)" + ); + } + + /// `update_admin_auth_code` with the correct current code succeeds and + /// returns the new code. + #[tokio::test] + async fn test_update_admin_auth_code_correct_current_succeeds() { + let repo = MockRepo::with_admin_code("old-code"); + let new_code = repo + .update_admin_auth_code("old-code", Some("brand-new-code")) + .await + .expect("update_admin_auth_code should succeed when current matches"); + assert_eq!(new_code, "brand-new-code"); + // The stored code should now be the new one. + let stored = repo + .get_admin_auth_code() + .await + .expect("get_admin_auth_code should not fail"); + assert_eq!(stored.as_deref(), Some("brand-new-code")); + } + + /// `update_admin_auth_code` with the wrong current code returns + /// `Err(DbError::Forbidden)`. + #[tokio::test] + async fn test_update_admin_auth_code_wrong_current_forbidden() { + let repo = MockRepo::with_admin_code("real-code"); + let result = repo + .update_admin_auth_code("wrong-code", Some("irrelevant")) + .await; + assert!( + matches!(result, Err(DbError::Forbidden)), + "expected Forbidden, got {result:?}", + ); + } } // ── Integration tests (real NativeRepository + real SQLite) ─────────────────