Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Move mock to it's own module #21

Merged
merged 3 commits into from
Mar 9, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,15 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [Unreleased]

### Changed
- Mock creation now goes via a builder type to prevent some correctness issues involving cloning and
attaching multiple modified copies of a mock

### Fixed
- Fixed some intra-doc links which were broken

## [0.1.0] - 2025-03-03

### Added
Expand Down
276 changes: 24 additions & 252 deletions src/lib.rs
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
#![doc = include_str!("../README.md")]
use crate::match_state::*;
use crate::responder::{pending, MapResponder, ResponseStream, StreamResponse};
pub use crate::utils::*;
use crate::responder::ResponseStream;
use axum::{
extract::{
ws::{CloseFrame as AxumCloseFrame, Message as AxumMessage, WebSocket, WebSocketUpgrade},
Expand All @@ -12,25 +11,22 @@ use axum::{
routing::any,
Extension, Router,
};
use futures::{
sink::SinkExt,
stream::{Stream, StreamExt},
};
use futures::{sink::SinkExt, stream::StreamExt};
use std::collections::HashMap;
use std::future::IntoFuture;
use std::sync::{
atomic::{AtomicU64, Ordering},
Arc,
};
use tokio::sync::{broadcast, oneshot, watch, Mutex, RwLock};
use tokio::sync::{broadcast, oneshot, watch, Mutex};
use tracing::{debug, error, Instrument};
use tungstenite::{
protocol::{frame::Utf8Bytes, CloseFrame},
Message,
};

pub use crate::mock::*;
pub use crate::utils::*;

pub mod match_state;
pub mod matchers;
pub mod mock;
pub mod responder;
pub mod utils;

Expand All @@ -42,241 +38,15 @@ pub mod prelude {
pub use crate::responder::*;
}

type MockList = Arc<RwLock<Vec<Mock>>>;

/// Server here we'd apply our mock servers and ability to verify requests. Based off of
/// https://docs.rs/wiremock/latest/wiremock/struct.MockServer.html
/// <https://docs.rs/wiremock/latest/wiremock/struct.MockServer.html>
pub struct MockServer {
addr: String,
shutdown: Option<oneshot::Sender<()>>,
mocks: MockList,
active_requests: Mutex<watch::Receiver<usize>>,
}

#[derive(Copy, Clone, Debug, Eq, PartialEq, PartialOrd, Ord, Hash)]
enum MatchStatus {
/// One or more matchers return Some(false)
Mismatch,
/// All matchers return None
Potential,
/// Some matchers return Some(true) some None
Partial,
/// Every matcher returns Some(rue)
Full,
}

/// Specify things like the routes this responds to i.e. `/api/ws-stream` query parameters, and
/// behaviour it should exhibit in terms of source/sink messages. Also has matchers to allow
/// you to do things like "make sure all messages are valid json"
///
/// # Mock precedence.
///
/// Given a mock can generate a sequence of responses we have to adequately handle instances where
/// multiple mocks match. Despite a temptation to allow multiple mocks to be active and merge
/// multiple response streams this is complicated and will make it harder to specify tests that
/// act deterministically where failures can be debugged.
///
/// With that in mind how do we select which mock is active when we have matchers that act on the
/// initial request parameters and ones which act on the received messages? Our matching parameter
/// is already more complicated to handle the case where there's no path/header matching and only
/// body matching.
///
/// The way this is accomplished is to move matching from a simple "yes/no/undetermined" to a 4 state
/// system:
///
/// 1. Mismatch: One or more matchers is false
/// 2. Potential: No matchers match but none have rejected the request
/// 3. Partial: Some matchers match the request
/// 4. Full: All matchers match the request - this is unambiguous
///
/// If we're in state 3 or 4 at the request point we'll pick the combination of most complete
/// status and highest priority. Otherwise, the list of potential matchers is used for the request
/// checking and each message we check and if we find a partial or higher we'll select the one with
/// the highest priority.
///
/// Once a mock has been selected as the active mock, we'll then start passing the messages into
/// the responder and the mock server will start sending messages back (if it's not a silent
/// responder).
///
/// For the `Mock` to pass then all of the matchers added will have to have evaluated as
/// `Some(true)` at least once. If any return `Some(false)` after the `Mock` has been selected then
/// the call won't be registered but no other failure will occur.
#[derive(Clone)]
pub struct Mock {
matcher: Vec<Arc<dyn Match + Send + Sync + 'static>>,
responder: Arc<dyn ResponseStream + Send + Sync + 'static>,
expected_calls: Arc<Times>,
calls: Arc<AtomicU64>,
name: Option<String>,
priority: u8,
}

impl Mock {
/// Start building a [`Mock`] specifying the first matcher.
///
/// TODO this should return a builder actually.
pub fn given(matcher: impl Match + Send + Sync + 'static) -> Self {
Self {
matcher: vec![Arc::new(matcher)],
responder: Arc::new(pending()),
name: None,
priority: 5,
expected_calls: Default::default(),
calls: Default::default(),
}
}

/// Assign a name to your mock.
///
/// The mock name will be used in error messages (e.g. if the mock expectation
/// is not satisfied) and debug logs to help you identify what failed.
pub fn named<T: Into<String>>(mut self, mock_name: T) -> Self {
self.name = Some(mock_name.into());
self
}

/// Set an expectation on the number of times this [`Mock`] should match in the current
/// test case.
///
/// Unlike wiremock no expectations are checked when the server is shutting down. Although this
/// may change in the future.
///
/// By default, no expectation is set for [`Mock`]s.
///
/// ### When is this useful?
///
/// `expect` can turn out handy when you'd like to verify that a certain side-effect has
/// (or has not!) taken place.
///
/// For example:
/// - check that a 3rd party notification API (e.g. email service) is called when an event
/// in your application is supposed to trigger a notification;
/// - check that a 3rd party API is NOT called when the response of a call is expected
/// to be retrieved from a cache (`.expect(0)`).
///
/// This technique is also called [spying](https://martinfowler.com/bliki/TestDouble.html).
pub fn expect(mut self, times: impl Into<Times>) -> Self {
self.expected_calls = Arc::new(times.into());
self
}

/// Add another request matcher to the `Mock` you are building.
///
/// **All** specified [`matchers`] must match for the overall [`Mock`] you are building.
///
/// [`matchers`]: crate::matchers
pub fn add_matcher(mut self, matcher: impl Match + Send + Sync + 'static) -> Self {
assert!(self.matcher.len() < 65, "Cannot have more than 65 matchers");
self.matcher.push(Arc::new(matcher));
self
}

/// Specify a priority for this [`Mock`].
/// Use this when you mount many [`Mock`] in a [`MockServer`]
/// and those mocks have interlaced request matching conditions
/// e.g. `mock A` accepts path `/abcd` and `mock B` a path regex `[a-z]{4}`
/// It is recommended to set the highest priority (1) for mocks with exact conditions (`mock A` in this case)
/// `1` is the highest priority, `255` the lowest, default to `5`
/// If two mocks have the same priority, priority is defined by insertion order (first one mounted has precedence over the others).
pub fn with_priority(mut self, priority: u8) -> Self {
assert!(priority > 0, "priority must be strictly greater than 0!");
self.priority = priority;
self
}

/// Sets a responder for the `Mock`. If you have a simpler function you can use
/// `Mock::one_to_one_response` or `Mock::response_stream` to determine how the `Mock` responds.
pub fn set_responder(mut self, responder: impl ResponseStream + Send + Sync + 'static) -> Self {
self.responder = Arc::new(responder);
self
}

/// This `Mock` will respond with a stream of `Messages` independent of the inputs received.
pub fn response_stream<F, S>(mut self, ctor: F) -> Self
where
F: Fn() -> S + Send + Sync + 'static,
S: Stream<Item = Message> + Send + Sync + 'static,
{
self.responder = Arc::new(StreamResponse::new(ctor));
self
}

/// For each `Message` from the client respond with a `Message`.
pub fn one_to_one_response<F>(mut self, map_fn: F) -> Self
where
F: Fn(Message) -> Message + Send + Sync + 'static,
{
self.responder = Arc::new(MapResponder::new(map_fn));
self
}

/// You can use this to verify the mock separately to the one you put into the server (if
/// you've cloned it).
pub fn verify(&self) -> bool {
let calls = self.calls.load(Ordering::SeqCst);
debug!("mock hit over {} calls", calls);
// If this mock doesn't need calling we don't need to check the hit matches
self.expected_calls.contains(calls)
}

fn check_request(
&self,
path: &str,
headers: &HeaderMap,
params: &HashMap<String, String>,
) -> (MatchStatus, u64) {
let values = self
.matcher
.iter()
.map(|x| x.request_match(path, headers, params))
.collect::<Vec<Option<bool>>>();

self.check_matcher_responses(&values)
}

fn check_message(&self, state: &mut MatchState) -> (MatchStatus, u64) {
let values = self
.matcher
.iter()
.map(|x| x.temporal_match(state))
.collect::<Vec<Option<bool>>>();

self.check_matcher_responses(&values)
}

fn check_matcher_responses(&self, values: &[Option<bool>]) -> (MatchStatus, u64) {
if values.iter().copied().all(can_consider) {
let contains_true = values.contains(&Some(true));
let contains_none = values.contains(&None);

if contains_true {
let mut current_mask = 0u64;
for (i, _val) in values.iter().enumerate().filter(|(_, i)| **i == Some(true)) {
current_mask |= 1 << i as u64;
}

if !contains_none {
(MatchStatus::Full, current_mask)
} else {
(MatchStatus::Partial, current_mask)
}
} else {
(MatchStatus::Potential, 0)
}
} else {
(MatchStatus::Mismatch, 0)
}
}

fn expected_mask(&self) -> u64 {
u64::MAX >> (64 - self.matcher.len() as u64)
}

fn register_hit(&self) {
self.calls.fetch_add(1, Ordering::Acquire);
}
}

async fn ws_handler_pathless(
ws: WebSocketUpgrade,
headers: HeaderMap,
Expand Down Expand Up @@ -648,31 +418,33 @@ impl Drop for MockServer {
///
/// Point 2. is actually a subset of 3. but it is much simpler hence it's own method. Because a
/// `Match` may not be applied in all 3 domains there is the ability for it to return `None`. For
/// example, if we have a [`CloseFrameReceivedMatcher`] this only checks if a close frame is
/// received, every other component of the request is irrelevant and when checking them will return
/// a `None`. Likewise the `PatchExactMatcher` can only return `Some(true)` when we look at the
/// initial request parameters. Once the body is being received it's irrelevant.
/// example, if we have a [`CloseFrameReceivedMatcher`](crate::matchers::CloseFrameReceivedMatcher)
/// this only checks if a close frame is received, every other component of the request is irrelevant
/// and when checking them will return a `None`. Likewise the `PatchExactMatcher` can only return
/// `Some(true)` when we look at the initial request parameters. Once the body is being received
/// it's irrelevant.
///
/// # Temporal Matching
///
/// Here we care about the state of the stream of messages. When `Match::temporal_match` is called
/// it will be after a message is received. [`MatchState::last`] will return the most recent
/// message.
/// it will be after a message is received. [`MatchState::last`](crate::match_state::MatchState::last)
/// will return the most recent message.
///
/// To avoid storing all messages if your `Match` implementation will require access to a message
/// in future passes use [`MatchState::keep_message`] to retain the message in the buffer. Likewise
/// when your `Match` doesn't want the message anymore call [`MatchStatus::forget_message`].
/// in future passes use [`MatchState::keep_message`](crate::match_state::MatchState::keep_message)
/// to retain the message in the buffer. Likewise when your `Match` doesn't want the message anymore call
/// [`MatchState::forget_message`](crate::match_state::MatchState::forget_message).
///
/// One thing to note is because unary message matching is a special case of temporal message
/// handling the default temporal matcher calls the unary method with [`MatchState::last`] as the
/// argument.
/// handling the default temporal matcher calls the unary method with
/// [`MatchState::last`](crate::match_state::MatchState::last) as the argument.
///
/// ## Note
///
/// If you call [`MatchStatus::forget_message`] twice for the same index in the same `Match`
/// instance during a connection you may evict a message which another `Match` required for
/// temporal checking. Take care you don't over-forget messages to avoid tests failing erroneous
/// (or worse passing eroneously).
/// If you call [`MatchState::forget_message`](crate::match_state::MatchState::forget_message)
/// twice for the same index in the same `Match` instance during a connection you may evict a
/// message which another `Match` required for temporal checking. Take care you don't over-forget
/// messages to avoid tests failing erroneous (or worse passing eroneously).
///
/// This is done in part to allow for reduced resource usage (and ease of implementation).
///
Expand Down
4 changes: 2 additions & 2 deletions src/match_state.rs
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ struct StoredMessage {
count: usize,
}

/// Mutable state shared for every matcher. For more thorough documentation [`Match`] can be
/// Mutable state shared for every matcher. For more thorough documentation [`Match`](crate::Match) can be
/// consulted.
#[derive(Debug)]
pub struct MatchState {
Expand Down Expand Up @@ -104,7 +104,7 @@ impl MatchState {
.rev()
.find(|x| x.index == index)
{
stored.count.saturating_sub(1);
stored.count = stored.count.saturating_sub(1);
}
}

Expand Down
Loading