Test persister load and close in FFI bindings - #1835
Conversation
Coverage Report for CI Build 33742775414Coverage remained the same at 86.64%Details
Uncovered ChangesNo uncovered changes found. Coverage RegressionsNo coverage regressions found. Coverage Stats
💛 - Coveralls |
caarloshenriq
left a comment
There was a problem hiding this comment.
Concept nACK
This is a good start toward #1325, but the issue asks for coverage of .load() and .close() more broadly, not only the error paths during replay. Happy-path tests (load returns correct events, close marks the persister as closed) are still missing. I'd reword the PR body to describe this as a partial step rather than Closes #1325.
| #[cfg(test)] | ||
| #[derive(Debug, Clone, Copy)] | ||
| pub(crate) enum PersisterFailure { | ||
| Load, | ||
| Close, | ||
| } | ||
|
|
||
| #[cfg(test)] | ||
| #[derive(Debug)] | ||
| pub(crate) struct TestStorageError(PersisterFailure); | ||
|
|
||
| #[cfg(test)] | ||
| impl fmt::Display for TestStorageError { | ||
| fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { | ||
| match self.0 { | ||
| PersisterFailure::Load => write!(f, "load failed"), | ||
| PersisterFailure::Close => write!(f, "close failed"), | ||
| } | ||
| } | ||
| } | ||
|
|
||
| #[cfg(test)] | ||
| impl std::error::Error for TestStorageError {} | ||
|
|
||
| #[cfg(test)] | ||
| pub(crate) struct FailingPersister<V> { | ||
| events: Vec<V>, | ||
| failure: PersisterFailure, | ||
| } | ||
|
|
||
| #[cfg(test)] | ||
| impl<V> FailingPersister<V> { | ||
| pub(crate) fn on_load() -> Self { Self { events: vec![], failure: PersisterFailure::Load } } | ||
|
|
||
| pub(crate) fn on_close(events: Vec<V>) -> Self { | ||
| Self { events, failure: PersisterFailure::Close } | ||
| } | ||
| } | ||
|
|
||
| #[cfg(test)] | ||
| impl<V> SessionPersister for FailingPersister<V> | ||
| where | ||
| V: Clone + 'static, | ||
| { | ||
| type InternalStorageError = TestStorageError; | ||
| type SessionEvent = V; | ||
|
|
||
| fn save_event(&self, _event: Self::SessionEvent) -> Result<(), Self::InternalStorageError> { | ||
| Ok(()) | ||
| } | ||
|
|
||
| fn load( | ||
| &self, | ||
| ) -> Result<Box<dyn Iterator<Item = Self::SessionEvent>>, Self::InternalStorageError> { | ||
| match self.failure { | ||
| PersisterFailure::Load => Err(TestStorageError(PersisterFailure::Load)), | ||
| PersisterFailure::Close => Ok(Box::new(self.events.clone().into_iter())), | ||
| } | ||
| } | ||
|
|
||
| fn close(&self) -> Result<(), Self::InternalStorageError> { | ||
| match self.failure { | ||
| PersisterFailure::Load => Ok(()), | ||
| PersisterFailure::Close => Err(TestStorageError(PersisterFailure::Close)), | ||
| } | ||
| } | ||
| } | ||
|
|
||
| #[cfg(test)] | ||
| pub(crate) struct FailingAsyncPersister<V>(FailingPersister<V>); | ||
|
|
||
| #[cfg(test)] | ||
| impl<V> FailingAsyncPersister<V> { | ||
| pub(crate) fn on_load() -> Self { Self(FailingPersister::on_load()) } | ||
|
|
||
| pub(crate) fn on_close(events: Vec<V>) -> Self { Self(FailingPersister::on_close(events)) } | ||
| } | ||
|
|
||
| #[cfg(test)] | ||
| impl<V> AsyncSessionPersister for FailingAsyncPersister<V> | ||
| where | ||
| V: Clone + Send + Sync + 'static, | ||
| { | ||
| type InternalStorageError = TestStorageError; | ||
| type SessionEvent = V; | ||
|
|
||
| async fn save_event( | ||
| &self, | ||
| _event: Self::SessionEvent, | ||
| ) -> Result<(), Self::InternalStorageError> { | ||
| Ok(()) | ||
| } | ||
|
|
||
| async fn load( | ||
| &self, | ||
| ) -> Result<Box<dyn Iterator<Item = Self::SessionEvent> + Send>, Self::InternalStorageError> | ||
| { | ||
| match self.0.failure { | ||
| PersisterFailure::Load => Err(TestStorageError(PersisterFailure::Load)), | ||
| PersisterFailure::Close => Ok(Box::new(self.0.events.clone().into_iter())), | ||
| } | ||
| } | ||
|
|
||
| async fn close(&self) -> Result<(), Self::InternalStorageError> { | ||
| match self.0.failure { | ||
| PersisterFailure::Load => Ok(()), | ||
| PersisterFailure::Close => Err(TestStorageError(PersisterFailure::Close)), | ||
| } | ||
| } | ||
| } | ||
|
|
There was a problem hiding this comment.
Nine #[cfg(test)] annotations on individual items in the module's top-level scope. Since these need pub(crate) visibility for cross-module test access, grouping them in a single gated submodule would be cleaner (following 1213 line):
#[cfg(test)]
pub(crate) mod test_support {
use super::*;
// PersisterFailure, TestStorageError, FailingPersister, FailingAsyncPersister
}One gate, one module, no test-only items floating in production scope.
There was a problem hiding this comment.
Done — all four items now live in #[cfg(test)] pub(crate) mod test_support. One gate, and callers import from crate::persist::test_support.
| async fn replay_surfaces_persistence_failures() { | ||
| let err = replay_event_log(&FailingPersister::<SessionEvent>::on_load()) | ||
| .expect_err("load failure should fail replay"); | ||
| assert_eq!(err.to_string(), "Persistence failure: load failed"); |
There was a problem hiding this comment.
String-based error assertions couple the test to the exact Display formatting of ReplayError. If someone rewords the message without changing behavior, these break. Matching on the variant (matches!(err, ReplayError::Persistence(_))) or destructuring would be more robust.
There was a problem hiding this comment.
Agreed. ReplayError wraps a private InternalReplayError, so the session test modules can't match the variant directly — I added a #[cfg(test)] pub(crate) fn persistence_failure() accessor on ReplayError and an
assert_persistence_failure helper that takes it, then downcasts the ImplementationError source to TestStorageError. That pins both the variant and which fault was injected, and no longer depends on Display.
| let err = replay_event_log(&FailingPersister::on_close(vec![ | ||
| SessionEvent::CheckedBroadcastSuitability(), | ||
| ])) | ||
| .expect_err("close failure should replace the invalid-event error"); |
There was a problem hiding this comment.
This test relies on replay_event_log calling close() even on the error path, so the close error replaces the replay error. That's a subtle contract worth a comment explaining the error chain. If a future refactor of replay_event_log stops calling close() on failure, this test breaks for non-obvious reasons.
There was a problem hiding this comment.
Split into replay_surfaces_load_failure and replay_surfaces_close_failure, and the latter now carries a doc comment explaining that replay closes the session on an invalid log, that the close failure therefore replaces the invalid-event error, and that the invalid first event is what drives replay into that close() call at all.
1c3af10 to
042c2f2
Compare
Fair — added direct happy-path coverage in persist.rs: in_memory_persister_load_and_close and its async twin assert that a fresh persister loads nothing, that load() replays events in save order without consuming the log, and |
|
As far as I can tell, #1325 relates to the FFI tests and their lack of coverage for the persister |
payjoin#1287 added save() and save_async() coverage to the bindings but left load() and close() untested. Nothing pinned the binding layer's contract with a foreign persister: that a terminal save reaches through to close(), and that load() is a read the runtime can repeat rather than one that consumes the log. Cover the receiver and the sender in dart, javascript, python and csharp, sync and async. The two reach their terminal state by different routes: cancelling an initialized receiver is itself terminal, while cancelling a sender only moves it to a pending fallback and the session closes a step later, when the broadcast transition is saved. Assert the persister is open beforehand, closed afterwards, and that load() still returns every event.
042c2f2 to
c785c8b
Compare
|
Retargeted to the FFI bindings — @xstoicunicornx is right that #1325 is about the binding suites, not the payjoin crate. @caarloshenriq your nACK asked for happy-path load()/close() coverage, and that's here now, just in the bindings rather than persist.rs: each test asserts load() returns the saved events and that a terminal save flips the The Rust replay error-path commit is dropped from this PR. I'll open it separately with your three points already applied — test_support module, variant matching instead of Display strings, and a doc comment on the close-on-error chain. |
Closes #1325.
What
Add
load()andclose()coverage to the FFI binding test suites — dart, javascript, python and csharp — for the receiver and the sender, sync and async.Each test asserts
load()returns what was saved and the persister is still open, drives a terminal transition, then asserts the persister was closed andload()still returns every event. The receiver's terminal step iscancel(); the sender's ispendingFallback.close(), since cancelling a sender only reaches a pending fallback.Why
#1287 added
save()/save_async()coverage but leftload()andclose()untested. What these pin down is not the test-utils persister but the binding layer's contract with it: that a terminal save actually calls the foreignclose()across the FFI boundary, and thatload()is a non-consuming read.Tests
bash payjoin-ffi/dart/contrib/test.sh— 28 passedbash payjoin-ffi/python/contrib/test.sh— 21 passedbash payjoin-ffi/javascript/contrib/test.sh— 39 passed (nodejs and web)bash payjoin-ffi/csharp/contrib/test.sh— not run locally, no dotnet available in my environment; relying on CIDisclosure: co-authored by Claude