feat(wfe-core): add test support with in-memory providers and test suites
InMemoryPersistenceProvider, InMemoryLockProvider, InMemoryQueueProvider, InMemoryLifecyclePublisher behind test-support feature flag. Shared test suite macros: persistence_suite!, lock_suite!, queue_suite! that run the same tests against any provider implementation.
This commit is contained in:
62
wfe-core/src/test_support/fixtures.rs
Normal file
62
wfe-core/src/test_support/fixtures.rs
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
use chrono::Utc;
|
||||||
|
|
||||||
|
use crate::models::{Event, EventSubscription, ExecutionError, WorkflowInstance};
|
||||||
|
|
||||||
|
/// Create a sample `WorkflowInstance` for testing.
|
||||||
|
pub fn sample_workflow_instance() -> WorkflowInstance {
|
||||||
|
WorkflowInstance::new("test-workflow", 1, serde_json::json!({"key": "value"}))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Create a sample `Event` for testing.
|
||||||
|
pub fn sample_event() -> Event {
|
||||||
|
Event::new(
|
||||||
|
"order.created",
|
||||||
|
"order-123",
|
||||||
|
serde_json::json!({"amount": 42}),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Create a sample `EventSubscription` for testing.
|
||||||
|
pub fn sample_subscription() -> EventSubscription {
|
||||||
|
EventSubscription::new("wf-1", 0, "ptr-1", "order.created", "order-123", Utc::now())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Create a sample `ExecutionError` for testing.
|
||||||
|
pub fn sample_execution_error() -> ExecutionError {
|
||||||
|
ExecutionError::new("wf-1", "ptr-1", "something went wrong")
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn sample_workflow_instance_has_expected_fields() {
|
||||||
|
let instance = sample_workflow_instance();
|
||||||
|
assert_eq!(instance.workflow_definition_id, "test-workflow");
|
||||||
|
assert_eq!(instance.version, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn sample_event_has_expected_fields() {
|
||||||
|
let event = sample_event();
|
||||||
|
assert_eq!(event.event_name, "order.created");
|
||||||
|
assert_eq!(event.event_key, "order-123");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn sample_subscription_has_expected_fields() {
|
||||||
|
let sub = sample_subscription();
|
||||||
|
assert_eq!(sub.workflow_id, "wf-1");
|
||||||
|
assert_eq!(sub.event_name, "order.created");
|
||||||
|
assert_eq!(sub.event_key, "order-123");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn sample_execution_error_has_expected_fields() {
|
||||||
|
let err = sample_execution_error();
|
||||||
|
assert_eq!(err.workflow_id, "wf-1");
|
||||||
|
assert_eq!(err.execution_pointer_id, "ptr-1");
|
||||||
|
assert_eq!(err.message, "something went wrong");
|
||||||
|
}
|
||||||
|
}
|
||||||
65
wfe-core/src/test_support/in_memory_lifecycle.rs
Normal file
65
wfe-core/src/test_support/in_memory_lifecycle.rs
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
use async_trait::async_trait;
|
||||||
|
use tokio::sync::Mutex;
|
||||||
|
|
||||||
|
use crate::models::LifecycleEvent;
|
||||||
|
use crate::traits::LifecyclePublisher;
|
||||||
|
use crate::Result;
|
||||||
|
|
||||||
|
/// An in-memory implementation of `LifecyclePublisher` for testing.
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct InMemoryLifecyclePublisher {
|
||||||
|
events: Arc<Mutex<Vec<LifecycleEvent>>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl InMemoryLifecyclePublisher {
|
||||||
|
pub fn new() -> Self {
|
||||||
|
Self {
|
||||||
|
events: Arc::new(Mutex::new(Vec::new())),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Retrieve all published lifecycle events for assertions.
|
||||||
|
pub async fn events(&self) -> Vec<LifecycleEvent> {
|
||||||
|
self.events.lock().await.clone()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for InMemoryLifecyclePublisher {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self::new()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl LifecyclePublisher for InMemoryLifecyclePublisher {
|
||||||
|
async fn publish(&self, event: LifecycleEvent) -> Result<()> {
|
||||||
|
self.events.lock().await.push(event);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use crate::models::LifecycleEventType;
|
||||||
|
use crate::traits::LifecyclePublisher;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn default_impl() {
|
||||||
|
let lc = InMemoryLifecyclePublisher::default();
|
||||||
|
drop(lc);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn publish_and_retrieve_events() {
|
||||||
|
let lc = InMemoryLifecyclePublisher::new();
|
||||||
|
let event = LifecycleEvent::new("wf-1", "def-1", 1, LifecycleEventType::Started);
|
||||||
|
lc.publish(event).await.unwrap();
|
||||||
|
|
||||||
|
let events = lc.events().await;
|
||||||
|
assert_eq!(events.len(), 1);
|
||||||
|
assert_eq!(events[0].event_type, LifecycleEventType::Started);
|
||||||
|
}
|
||||||
|
}
|
||||||
50
wfe-core/src/test_support/in_memory_lock.rs
Normal file
50
wfe-core/src/test_support/in_memory_lock.rs
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
use std::collections::HashSet;
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
use async_trait::async_trait;
|
||||||
|
use tokio::sync::Mutex;
|
||||||
|
|
||||||
|
use crate::traits::DistributedLockProvider;
|
||||||
|
use crate::Result;
|
||||||
|
|
||||||
|
/// An in-memory implementation of `DistributedLockProvider` for testing.
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct InMemoryLockProvider {
|
||||||
|
locks: Arc<Mutex<HashSet<String>>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl InMemoryLockProvider {
|
||||||
|
pub fn new() -> Self {
|
||||||
|
Self {
|
||||||
|
locks: Arc::new(Mutex::new(HashSet::new())),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for InMemoryLockProvider {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self::new()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl DistributedLockProvider for InMemoryLockProvider {
|
||||||
|
async fn acquire_lock(&self, resource: &str) -> Result<bool> {
|
||||||
|
let mut locks = self.locks.lock().await;
|
||||||
|
Ok(locks.insert(resource.to_string()))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn release_lock(&self, resource: &str) -> Result<()> {
|
||||||
|
let mut locks = self.locks.lock().await;
|
||||||
|
locks.remove(resource);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn start(&self) -> Result<()> {
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn stop(&self) -> Result<()> {
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
585
wfe-core/src/test_support/in_memory_persistence.rs
Normal file
585
wfe-core/src/test_support/in_memory_persistence.rs
Normal file
@@ -0,0 +1,585 @@
|
|||||||
|
use std::collections::HashMap;
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
use async_trait::async_trait;
|
||||||
|
use chrono::{DateTime, Utc};
|
||||||
|
use tokio::sync::RwLock;
|
||||||
|
|
||||||
|
use crate::models::{
|
||||||
|
Event, EventSubscription, ExecutionError, ScheduledCommand, WorkflowInstance,
|
||||||
|
};
|
||||||
|
use crate::traits::{
|
||||||
|
EventRepository, PersistenceProvider, ScheduledCommandRepository, SubscriptionRepository,
|
||||||
|
WorkflowRepository,
|
||||||
|
};
|
||||||
|
use crate::{Result, WfeError};
|
||||||
|
|
||||||
|
/// An in-memory implementation of `PersistenceProvider` for testing.
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct InMemoryPersistenceProvider {
|
||||||
|
workflows: Arc<RwLock<HashMap<String, WorkflowInstance>>>,
|
||||||
|
events: Arc<RwLock<HashMap<String, Event>>>,
|
||||||
|
subscriptions: Arc<RwLock<HashMap<String, EventSubscription>>>,
|
||||||
|
errors: Arc<RwLock<Vec<ExecutionError>>>,
|
||||||
|
scheduled_commands: Arc<RwLock<Vec<ScheduledCommand>>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl InMemoryPersistenceProvider {
|
||||||
|
pub fn new() -> Self {
|
||||||
|
Self {
|
||||||
|
workflows: Arc::new(RwLock::new(HashMap::new())),
|
||||||
|
events: Arc::new(RwLock::new(HashMap::new())),
|
||||||
|
subscriptions: Arc::new(RwLock::new(HashMap::new())),
|
||||||
|
errors: Arc::new(RwLock::new(Vec::new())),
|
||||||
|
scheduled_commands: Arc::new(RwLock::new(Vec::new())),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Retrieve all stored errors (for test assertions).
|
||||||
|
pub async fn get_errors(&self) -> Vec<ExecutionError> {
|
||||||
|
self.errors.read().await.clone()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for InMemoryPersistenceProvider {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self::new()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl WorkflowRepository for InMemoryPersistenceProvider {
|
||||||
|
async fn create_new_workflow(&self, instance: &WorkflowInstance) -> Result<String> {
|
||||||
|
let id = if instance.id.is_empty() {
|
||||||
|
uuid::Uuid::new_v4().to_string()
|
||||||
|
} else {
|
||||||
|
instance.id.clone()
|
||||||
|
};
|
||||||
|
let mut stored = instance.clone();
|
||||||
|
stored.id = id.clone();
|
||||||
|
self.workflows.write().await.insert(id.clone(), stored);
|
||||||
|
Ok(id)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn persist_workflow(&self, instance: &WorkflowInstance) -> Result<()> {
|
||||||
|
self.workflows
|
||||||
|
.write()
|
||||||
|
.await
|
||||||
|
.insert(instance.id.clone(), instance.clone());
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn persist_workflow_with_subscriptions(
|
||||||
|
&self,
|
||||||
|
instance: &WorkflowInstance,
|
||||||
|
subscriptions: &[EventSubscription],
|
||||||
|
) -> Result<()> {
|
||||||
|
self.persist_workflow(instance).await?;
|
||||||
|
let mut subs = self.subscriptions.write().await;
|
||||||
|
for sub in subscriptions {
|
||||||
|
subs.insert(sub.id.clone(), sub.clone());
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn get_runnable_instances(&self, as_at: DateTime<Utc>) -> Result<Vec<String>> {
|
||||||
|
let workflows = self.workflows.read().await;
|
||||||
|
let as_at_millis = as_at.timestamp_millis();
|
||||||
|
let ids = workflows
|
||||||
|
.values()
|
||||||
|
.filter(|w| {
|
||||||
|
w.status == crate::models::WorkflowStatus::Runnable
|
||||||
|
&& w.next_execution
|
||||||
|
.map(|ne| ne <= as_at_millis)
|
||||||
|
.unwrap_or(false)
|
||||||
|
})
|
||||||
|
.map(|w| w.id.clone())
|
||||||
|
.collect();
|
||||||
|
Ok(ids)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn get_workflow_instance(&self, id: &str) -> Result<WorkflowInstance> {
|
||||||
|
self.workflows
|
||||||
|
.read()
|
||||||
|
.await
|
||||||
|
.get(id)
|
||||||
|
.cloned()
|
||||||
|
.ok_or_else(|| WfeError::WorkflowNotFound(id.to_string()))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn get_workflow_instances(&self, ids: &[String]) -> Result<Vec<WorkflowInstance>> {
|
||||||
|
let workflows = self.workflows.read().await;
|
||||||
|
let mut result = Vec::new();
|
||||||
|
for id in ids {
|
||||||
|
if let Some(w) = workflows.get(id) {
|
||||||
|
result.push(w.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(result)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl SubscriptionRepository for InMemoryPersistenceProvider {
|
||||||
|
async fn create_event_subscription(
|
||||||
|
&self,
|
||||||
|
subscription: &EventSubscription,
|
||||||
|
) -> Result<String> {
|
||||||
|
let id = if subscription.id.is_empty() {
|
||||||
|
uuid::Uuid::new_v4().to_string()
|
||||||
|
} else {
|
||||||
|
subscription.id.clone()
|
||||||
|
};
|
||||||
|
let mut stored = subscription.clone();
|
||||||
|
stored.id = id.clone();
|
||||||
|
self.subscriptions.write().await.insert(id.clone(), stored);
|
||||||
|
Ok(id)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn get_subscriptions(
|
||||||
|
&self,
|
||||||
|
event_name: &str,
|
||||||
|
event_key: &str,
|
||||||
|
as_of: DateTime<Utc>,
|
||||||
|
) -> Result<Vec<EventSubscription>> {
|
||||||
|
let subs = self.subscriptions.read().await;
|
||||||
|
let result = subs
|
||||||
|
.values()
|
||||||
|
.filter(|s| {
|
||||||
|
s.event_name == event_name
|
||||||
|
&& s.event_key == event_key
|
||||||
|
&& s.subscribe_as_of <= as_of
|
||||||
|
&& s.external_token.is_none() // not terminated
|
||||||
|
})
|
||||||
|
.cloned()
|
||||||
|
.collect();
|
||||||
|
Ok(result)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn terminate_subscription(&self, subscription_id: &str) -> Result<()> {
|
||||||
|
let mut subs = self.subscriptions.write().await;
|
||||||
|
match subs.get_mut(subscription_id) {
|
||||||
|
Some(sub) => {
|
||||||
|
sub.external_token = Some("__terminated__".to_string());
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
None => Err(WfeError::SubscriptionNotFound(subscription_id.to_string())),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn get_subscription(&self, subscription_id: &str) -> Result<EventSubscription> {
|
||||||
|
self.subscriptions
|
||||||
|
.read()
|
||||||
|
.await
|
||||||
|
.get(subscription_id)
|
||||||
|
.cloned()
|
||||||
|
.ok_or_else(|| WfeError::SubscriptionNotFound(subscription_id.to_string()))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn get_first_open_subscription(
|
||||||
|
&self,
|
||||||
|
event_name: &str,
|
||||||
|
event_key: &str,
|
||||||
|
as_of: DateTime<Utc>,
|
||||||
|
) -> Result<Option<EventSubscription>> {
|
||||||
|
let subs = self.subscriptions.read().await;
|
||||||
|
let result = subs
|
||||||
|
.values()
|
||||||
|
.find(|s| {
|
||||||
|
s.event_name == event_name
|
||||||
|
&& s.event_key == event_key
|
||||||
|
&& s.subscribe_as_of <= as_of
|
||||||
|
&& s.external_token.is_none()
|
||||||
|
})
|
||||||
|
.cloned();
|
||||||
|
Ok(result)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn set_subscription_token(
|
||||||
|
&self,
|
||||||
|
subscription_id: &str,
|
||||||
|
token: &str,
|
||||||
|
worker_id: &str,
|
||||||
|
expiry: DateTime<Utc>,
|
||||||
|
) -> Result<bool> {
|
||||||
|
let mut subs = self.subscriptions.write().await;
|
||||||
|
match subs.get_mut(subscription_id) {
|
||||||
|
Some(sub) => {
|
||||||
|
if sub.external_token.is_some() {
|
||||||
|
return Ok(false);
|
||||||
|
}
|
||||||
|
sub.external_token = Some(token.to_string());
|
||||||
|
sub.external_worker_id = Some(worker_id.to_string());
|
||||||
|
sub.external_token_expiry = Some(expiry);
|
||||||
|
Ok(true)
|
||||||
|
}
|
||||||
|
None => Err(WfeError::SubscriptionNotFound(subscription_id.to_string())),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn clear_subscription_token(
|
||||||
|
&self,
|
||||||
|
subscription_id: &str,
|
||||||
|
token: &str,
|
||||||
|
) -> Result<()> {
|
||||||
|
let mut subs = self.subscriptions.write().await;
|
||||||
|
match subs.get_mut(subscription_id) {
|
||||||
|
Some(sub) => {
|
||||||
|
if sub.external_token.as_deref() != Some(token) {
|
||||||
|
return Err(WfeError::Persistence(format!(
|
||||||
|
"Token mismatch for subscription {subscription_id}"
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
sub.external_token = None;
|
||||||
|
sub.external_worker_id = None;
|
||||||
|
sub.external_token_expiry = None;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
None => Err(WfeError::SubscriptionNotFound(subscription_id.to_string())),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl EventRepository for InMemoryPersistenceProvider {
|
||||||
|
async fn create_event(&self, event: &Event) -> Result<String> {
|
||||||
|
let id = if event.id.is_empty() {
|
||||||
|
uuid::Uuid::new_v4().to_string()
|
||||||
|
} else {
|
||||||
|
event.id.clone()
|
||||||
|
};
|
||||||
|
let mut stored = event.clone();
|
||||||
|
stored.id = id.clone();
|
||||||
|
self.events.write().await.insert(id.clone(), stored);
|
||||||
|
Ok(id)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn get_event(&self, id: &str) -> Result<Event> {
|
||||||
|
self.events
|
||||||
|
.read()
|
||||||
|
.await
|
||||||
|
.get(id)
|
||||||
|
.cloned()
|
||||||
|
.ok_or_else(|| WfeError::EventNotFound(id.to_string()))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn get_runnable_events(&self, as_at: DateTime<Utc>) -> Result<Vec<String>> {
|
||||||
|
let events = self.events.read().await;
|
||||||
|
let ids = events
|
||||||
|
.values()
|
||||||
|
.filter(|e| !e.is_processed && e.event_time <= as_at)
|
||||||
|
.map(|e| e.id.clone())
|
||||||
|
.collect();
|
||||||
|
Ok(ids)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn get_events(
|
||||||
|
&self,
|
||||||
|
event_name: &str,
|
||||||
|
event_key: &str,
|
||||||
|
as_of: DateTime<Utc>,
|
||||||
|
) -> Result<Vec<String>> {
|
||||||
|
let events = self.events.read().await;
|
||||||
|
let ids = events
|
||||||
|
.values()
|
||||||
|
.filter(|e| e.event_name == event_name && e.event_key == event_key && e.event_time <= as_of)
|
||||||
|
.map(|e| e.id.clone())
|
||||||
|
.collect();
|
||||||
|
Ok(ids)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn mark_event_processed(&self, id: &str) -> Result<()> {
|
||||||
|
let mut events = self.events.write().await;
|
||||||
|
match events.get_mut(id) {
|
||||||
|
Some(event) => {
|
||||||
|
event.is_processed = true;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
None => Err(WfeError::EventNotFound(id.to_string())),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn mark_event_unprocessed(&self, id: &str) -> Result<()> {
|
||||||
|
let mut events = self.events.write().await;
|
||||||
|
match events.get_mut(id) {
|
||||||
|
Some(event) => {
|
||||||
|
event.is_processed = false;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
None => Err(WfeError::EventNotFound(id.to_string())),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl ScheduledCommandRepository for InMemoryPersistenceProvider {
|
||||||
|
fn supports_scheduled_commands(&self) -> bool {
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn schedule_command(&self, command: &ScheduledCommand) -> Result<()> {
|
||||||
|
self.scheduled_commands.write().await.push(command.clone());
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn process_commands(
|
||||||
|
&self,
|
||||||
|
as_of: DateTime<Utc>,
|
||||||
|
handler: &(dyn Fn(ScheduledCommand) -> std::pin::Pin<Box<dyn std::future::Future<Output = Result<()>> + Send>>
|
||||||
|
+ Send
|
||||||
|
+ Sync),
|
||||||
|
) -> Result<()> {
|
||||||
|
let as_of_millis = as_of.timestamp_millis();
|
||||||
|
let due: Vec<ScheduledCommand> = {
|
||||||
|
let mut cmds = self.scheduled_commands.write().await;
|
||||||
|
let (due, remaining): (Vec<_>, Vec<_>) =
|
||||||
|
cmds.drain(..).partition(|c| c.execute_time <= as_of_millis);
|
||||||
|
*cmds = remaining;
|
||||||
|
due
|
||||||
|
};
|
||||||
|
for cmd in due {
|
||||||
|
handler(cmd).await?;
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl PersistenceProvider for InMemoryPersistenceProvider {
|
||||||
|
async fn persist_errors(&self, errors: &[ExecutionError]) -> Result<()> {
|
||||||
|
let mut stored = self.errors.write().await;
|
||||||
|
stored.extend(errors.iter().cloned());
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn ensure_store_exists(&self) -> Result<()> {
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use crate::models::{Event, EventSubscription, ExecutionError, ScheduledCommand, CommandName};
|
||||||
|
use crate::traits::{
|
||||||
|
EventRepository, PersistenceProvider, ScheduledCommandRepository, SubscriptionRepository,
|
||||||
|
WorkflowRepository,
|
||||||
|
};
|
||||||
|
use chrono::{Duration, Utc};
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn default_impl() {
|
||||||
|
let p = InMemoryPersistenceProvider::default();
|
||||||
|
p.ensure_store_exists().await.unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn get_workflow_instances_batch() {
|
||||||
|
let p = InMemoryPersistenceProvider::new();
|
||||||
|
let w1 = WorkflowInstance::new("wf", 1, serde_json::json!({}));
|
||||||
|
let id1 = p.create_new_workflow(&w1).await.unwrap();
|
||||||
|
let w2 = WorkflowInstance::new("wf", 1, serde_json::json!({}));
|
||||||
|
let id2 = p.create_new_workflow(&w2).await.unwrap();
|
||||||
|
|
||||||
|
let ids = vec![id1.clone(), id2.clone(), "nonexistent".to_string()];
|
||||||
|
let result = p.get_workflow_instances(&ids).await.unwrap();
|
||||||
|
// Only existing workflows are returned.
|
||||||
|
assert_eq!(result.len(), 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn get_first_open_subscription_returns_match() {
|
||||||
|
let p = InMemoryPersistenceProvider::new();
|
||||||
|
let now = Utc::now();
|
||||||
|
let sub = EventSubscription::new("wf-1", 0, "ptr-1", "evt", "key", now);
|
||||||
|
let id = p.create_event_subscription(&sub).await.unwrap();
|
||||||
|
|
||||||
|
let found = p
|
||||||
|
.get_first_open_subscription("evt", "key", now + Duration::seconds(1))
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert!(found.is_some());
|
||||||
|
assert_eq!(found.unwrap().id, id);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn get_first_open_subscription_returns_none() {
|
||||||
|
let p = InMemoryPersistenceProvider::new();
|
||||||
|
let now = Utc::now();
|
||||||
|
let found = p
|
||||||
|
.get_first_open_subscription("evt", "key", now)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert!(found.is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn set_subscription_token_success() {
|
||||||
|
let p = InMemoryPersistenceProvider::new();
|
||||||
|
let now = Utc::now();
|
||||||
|
let sub = EventSubscription::new("wf-1", 0, "ptr-1", "evt", "key", now);
|
||||||
|
let id = p.create_event_subscription(&sub).await.unwrap();
|
||||||
|
|
||||||
|
let expiry = now + Duration::hours(1);
|
||||||
|
let result = p
|
||||||
|
.set_subscription_token(&id, "token-1", "worker-1", expiry)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert!(result);
|
||||||
|
|
||||||
|
// Setting token again on already-tokened subscription returns false.
|
||||||
|
let result2 = p
|
||||||
|
.set_subscription_token(&id, "token-2", "worker-2", expiry)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert!(!result2);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn set_subscription_token_not_found() {
|
||||||
|
let p = InMemoryPersistenceProvider::new();
|
||||||
|
let result = p
|
||||||
|
.set_subscription_token("nonexistent", "t", "w", Utc::now())
|
||||||
|
.await;
|
||||||
|
assert!(result.is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn clear_subscription_token_success() {
|
||||||
|
let p = InMemoryPersistenceProvider::new();
|
||||||
|
let now = Utc::now();
|
||||||
|
let sub = EventSubscription::new("wf-1", 0, "ptr-1", "evt", "key", now);
|
||||||
|
let id = p.create_event_subscription(&sub).await.unwrap();
|
||||||
|
|
||||||
|
let expiry = now + Duration::hours(1);
|
||||||
|
p.set_subscription_token(&id, "token-1", "worker-1", expiry)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
p.clear_subscription_token(&id, "token-1").await.unwrap();
|
||||||
|
|
||||||
|
// After clearing, the subscription should be open again.
|
||||||
|
let retrieved = p.get_subscription(&id).await.unwrap();
|
||||||
|
assert!(retrieved.external_token.is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn clear_subscription_token_not_found() {
|
||||||
|
let p = InMemoryPersistenceProvider::new();
|
||||||
|
let result = p.clear_subscription_token("nonexistent", "t").await;
|
||||||
|
assert!(result.is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn terminate_subscription_not_found() {
|
||||||
|
let p = InMemoryPersistenceProvider::new();
|
||||||
|
let result = p.terminate_subscription("nonexistent").await;
|
||||||
|
assert!(result.is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn get_events_by_name_and_key() {
|
||||||
|
let p = InMemoryPersistenceProvider::new();
|
||||||
|
let now = Utc::now();
|
||||||
|
let e1 = Event::new("evt-a", "key-1", serde_json::json!({}));
|
||||||
|
let id1 = p.create_event(&e1).await.unwrap();
|
||||||
|
let e2 = Event::new("evt-a", "key-2", serde_json::json!({}));
|
||||||
|
let _id2 = p.create_event(&e2).await.unwrap();
|
||||||
|
let e3 = Event::new("evt-b", "key-1", serde_json::json!({}));
|
||||||
|
let _id3 = p.create_event(&e3).await.unwrap();
|
||||||
|
|
||||||
|
let ids = p
|
||||||
|
.get_events("evt-a", "key-1", now + Duration::seconds(1))
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(ids.len(), 1);
|
||||||
|
assert_eq!(ids[0], id1);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn mark_event_unprocessed() {
|
||||||
|
let p = InMemoryPersistenceProvider::new();
|
||||||
|
let e = Event::new("evt", "key", serde_json::json!({}));
|
||||||
|
let id = p.create_event(&e).await.unwrap();
|
||||||
|
|
||||||
|
p.mark_event_processed(&id).await.unwrap();
|
||||||
|
let event = p.get_event(&id).await.unwrap();
|
||||||
|
assert!(event.is_processed);
|
||||||
|
|
||||||
|
p.mark_event_unprocessed(&id).await.unwrap();
|
||||||
|
let event = p.get_event(&id).await.unwrap();
|
||||||
|
assert!(!event.is_processed);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn mark_event_unprocessed_not_found() {
|
||||||
|
let p = InMemoryPersistenceProvider::new();
|
||||||
|
let result = p.mark_event_unprocessed("nonexistent").await;
|
||||||
|
assert!(result.is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn mark_event_processed_not_found() {
|
||||||
|
let p = InMemoryPersistenceProvider::new();
|
||||||
|
let result = p.mark_event_processed("nonexistent").await;
|
||||||
|
assert!(result.is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn get_errors_returns_persisted_errors() {
|
||||||
|
let p = InMemoryPersistenceProvider::new();
|
||||||
|
let errors = vec![
|
||||||
|
ExecutionError::new("wf-1", "ptr-1", "err1"),
|
||||||
|
ExecutionError::new("wf-2", "ptr-2", "err2"),
|
||||||
|
];
|
||||||
|
p.persist_errors(&errors).await.unwrap();
|
||||||
|
let stored = p.get_errors().await;
|
||||||
|
assert_eq!(stored.len(), 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn supports_scheduled_commands_returns_true() {
|
||||||
|
let p = InMemoryPersistenceProvider::new();
|
||||||
|
assert!(p.supports_scheduled_commands());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn schedule_and_process_commands() {
|
||||||
|
let p = InMemoryPersistenceProvider::new();
|
||||||
|
let now = Utc::now();
|
||||||
|
let cmd = ScheduledCommand {
|
||||||
|
command_name: CommandName::ProcessEvent,
|
||||||
|
data: "test-event-id".to_string(),
|
||||||
|
execute_time: now.timestamp_millis() - 1000,
|
||||||
|
};
|
||||||
|
p.schedule_command(&cmd).await.unwrap();
|
||||||
|
|
||||||
|
p.process_commands(now, &|c: ScheduledCommand| {
|
||||||
|
let _data = c.data;
|
||||||
|
Box::pin(async move { Ok(()) })
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
// After processing, no more due commands remain.
|
||||||
|
let remaining_count = p.scheduled_commands.read().await.len();
|
||||||
|
assert_eq!(remaining_count, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn persist_workflow_with_subscriptions() {
|
||||||
|
let p = InMemoryPersistenceProvider::new();
|
||||||
|
let w = WorkflowInstance::new("wf", 1, serde_json::json!({}));
|
||||||
|
let id = p.create_new_workflow(&w).await.unwrap();
|
||||||
|
let mut updated = p.get_workflow_instance(&id).await.unwrap();
|
||||||
|
updated.description = Some("updated".to_string());
|
||||||
|
|
||||||
|
let sub = EventSubscription::new("wf-1", 0, "ptr-1", "evt", "key", Utc::now());
|
||||||
|
p.persist_workflow_with_subscriptions(&updated, &[sub])
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let retrieved = p.get_workflow_instance(&id).await.unwrap();
|
||||||
|
assert_eq!(retrieved.description.as_deref(), Some("updated"));
|
||||||
|
}
|
||||||
|
}
|
||||||
58
wfe-core/src/test_support/in_memory_queue.rs
Normal file
58
wfe-core/src/test_support/in_memory_queue.rs
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
use std::collections::{HashMap, VecDeque};
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
use async_trait::async_trait;
|
||||||
|
use tokio::sync::Mutex;
|
||||||
|
|
||||||
|
use crate::models::QueueType;
|
||||||
|
use crate::traits::QueueProvider;
|
||||||
|
use crate::Result;
|
||||||
|
|
||||||
|
/// An in-memory implementation of `QueueProvider` for testing.
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct InMemoryQueueProvider {
|
||||||
|
queues: Arc<Mutex<HashMap<QueueType, VecDeque<String>>>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl InMemoryQueueProvider {
|
||||||
|
pub fn new() -> Self {
|
||||||
|
Self {
|
||||||
|
queues: Arc::new(Mutex::new(HashMap::new())),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for InMemoryQueueProvider {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self::new()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl QueueProvider for InMemoryQueueProvider {
|
||||||
|
async fn queue_work(&self, id: &str, queue: QueueType) -> Result<()> {
|
||||||
|
let mut queues = self.queues.lock().await;
|
||||||
|
queues
|
||||||
|
.entry(queue)
|
||||||
|
.or_insert_with(VecDeque::new)
|
||||||
|
.push_back(id.to_string());
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn dequeue_work(&self, queue: QueueType) -> Result<Option<String>> {
|
||||||
|
let mut queues = self.queues.lock().await;
|
||||||
|
Ok(queues.get_mut(&queue).and_then(|q| q.pop_front()))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn is_dequeue_blocking(&self) -> bool {
|
||||||
|
false
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn start(&self) -> Result<()> {
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn stop(&self) -> Result<()> {
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
49
wfe-core/src/test_support/lock_suite.rs
Normal file
49
wfe-core/src/test_support/lock_suite.rs
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
/// Generates a test suite for any `DistributedLockProvider` implementation.
|
||||||
|
///
|
||||||
|
/// The macro takes a factory expression that returns an `impl DistributedLockProvider`.
|
||||||
|
///
|
||||||
|
/// # Example
|
||||||
|
/// ```ignore
|
||||||
|
/// lock_suite!(|| async { InMemoryLockProvider::new() });
|
||||||
|
/// ```
|
||||||
|
#[macro_export]
|
||||||
|
macro_rules! lock_suite {
|
||||||
|
($factory:expr) => {
|
||||||
|
mod lock_suite {
|
||||||
|
use super::*;
|
||||||
|
use $crate::traits::DistributedLockProvider;
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn acquire_lock_succeeds() {
|
||||||
|
let provider = ($factory)().await;
|
||||||
|
let acquired = provider.acquire_lock("resource-1").await.unwrap();
|
||||||
|
assert!(acquired);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn double_acquire_fails() {
|
||||||
|
let provider = ($factory)().await;
|
||||||
|
let first = provider.acquire_lock("resource-1").await.unwrap();
|
||||||
|
assert!(first);
|
||||||
|
let second = provider.acquire_lock("resource-1").await.unwrap();
|
||||||
|
assert!(!second);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn release_then_reacquire() {
|
||||||
|
let provider = ($factory)().await;
|
||||||
|
provider.acquire_lock("resource-1").await.unwrap();
|
||||||
|
provider.release_lock("resource-1").await.unwrap();
|
||||||
|
let reacquired = provider.acquire_lock("resource-1").await.unwrap();
|
||||||
|
assert!(reacquired);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn release_nonexistent_ok() {
|
||||||
|
let provider = ($factory)().await;
|
||||||
|
// Should not error even if lock was never acquired
|
||||||
|
provider.release_lock("nonexistent").await.unwrap();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
26
wfe-core/src/test_support/mod.rs
Normal file
26
wfe-core/src/test_support/mod.rs
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
pub mod fixtures;
|
||||||
|
pub mod in_memory_lifecycle;
|
||||||
|
pub mod in_memory_lock;
|
||||||
|
pub mod in_memory_persistence;
|
||||||
|
pub mod in_memory_queue;
|
||||||
|
|
||||||
|
// Test suite macros (exported via #[macro_export] at crate level)
|
||||||
|
mod lock_suite;
|
||||||
|
mod persistence_suite;
|
||||||
|
mod queue_suite;
|
||||||
|
|
||||||
|
pub use fixtures::*;
|
||||||
|
pub use in_memory_lifecycle::InMemoryLifecyclePublisher;
|
||||||
|
pub use in_memory_lock::InMemoryLockProvider;
|
||||||
|
pub use in_memory_persistence::InMemoryPersistenceProvider;
|
||||||
|
pub use in_memory_queue::InMemoryQueueProvider;
|
||||||
|
|
||||||
|
// Run the test suites against the in-memory providers.
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
crate::persistence_suite!(|| async { InMemoryPersistenceProvider::new() });
|
||||||
|
crate::lock_suite!(|| async { InMemoryLockProvider::new() });
|
||||||
|
crate::queue_suite!(|| async { InMemoryQueueProvider::new() });
|
||||||
|
}
|
||||||
243
wfe-core/src/test_support/persistence_suite.rs
Normal file
243
wfe-core/src/test_support/persistence_suite.rs
Normal file
@@ -0,0 +1,243 @@
|
|||||||
|
/// Generates a test suite for any `PersistenceProvider` implementation.
|
||||||
|
///
|
||||||
|
/// The macro takes a factory expression that returns an `impl PersistenceProvider`.
|
||||||
|
/// The factory expression must be async-compatible (it will be `.await`ed).
|
||||||
|
///
|
||||||
|
/// # Example
|
||||||
|
/// ```ignore
|
||||||
|
/// persistence_suite!(|| async { InMemoryPersistenceProvider::new() });
|
||||||
|
/// ```
|
||||||
|
#[macro_export]
|
||||||
|
macro_rules! persistence_suite {
|
||||||
|
($factory:expr) => {
|
||||||
|
mod persistence_suite {
|
||||||
|
use super::*;
|
||||||
|
use chrono::{Duration, Utc};
|
||||||
|
use $crate::models::{
|
||||||
|
Event, EventSubscription, ExecutionError, WorkflowInstance, WorkflowStatus,
|
||||||
|
};
|
||||||
|
use $crate::traits::{
|
||||||
|
EventRepository, PersistenceProvider, SubscriptionRepository, WorkflowRepository,
|
||||||
|
};
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn create_new_workflow_generates_id() {
|
||||||
|
let provider = ($factory)().await;
|
||||||
|
let instance = WorkflowInstance::new("test-wf", 1, serde_json::json!({}));
|
||||||
|
let id = provider.create_new_workflow(&instance).await.unwrap();
|
||||||
|
assert!(!id.is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn get_workflow_instance_retrieves_workflow() {
|
||||||
|
let provider = ($factory)().await;
|
||||||
|
let instance = WorkflowInstance::new("test-wf", 1, serde_json::json!({"x": 1}));
|
||||||
|
let id = provider.create_new_workflow(&instance).await.unwrap();
|
||||||
|
let retrieved = provider.get_workflow_instance(&id).await.unwrap();
|
||||||
|
assert_eq!(retrieved.id, id);
|
||||||
|
assert_eq!(retrieved.workflow_definition_id, "test-wf");
|
||||||
|
assert_eq!(retrieved.data, serde_json::json!({"x": 1}));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn persist_workflow_updates_fields() {
|
||||||
|
let provider = ($factory)().await;
|
||||||
|
let mut instance =
|
||||||
|
WorkflowInstance::new("test-wf", 1, serde_json::json!({}));
|
||||||
|
let id = provider.create_new_workflow(&instance).await.unwrap();
|
||||||
|
instance.id = id.clone();
|
||||||
|
instance.description = Some("updated".to_string());
|
||||||
|
instance.status = WorkflowStatus::Suspended;
|
||||||
|
provider.persist_workflow(&instance).await.unwrap();
|
||||||
|
|
||||||
|
let retrieved = provider.get_workflow_instance(&id).await.unwrap();
|
||||||
|
assert_eq!(retrieved.description.as_deref(), Some("updated"));
|
||||||
|
assert_eq!(retrieved.status, WorkflowStatus::Suspended);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn get_runnable_instances_filters_by_time() {
|
||||||
|
let provider = ($factory)().await;
|
||||||
|
|
||||||
|
// Runnable with next_execution in the past
|
||||||
|
let mut w1 = WorkflowInstance::new("wf", 1, serde_json::json!({}));
|
||||||
|
w1.next_execution = Some(0);
|
||||||
|
let id1 = provider.create_new_workflow(&w1).await.unwrap();
|
||||||
|
|
||||||
|
// Runnable with next_execution far in the future
|
||||||
|
let mut w2 = WorkflowInstance::new("wf", 1, serde_json::json!({}));
|
||||||
|
w2.next_execution = Some(i64::MAX);
|
||||||
|
let _id2 = provider.create_new_workflow(&w2).await.unwrap();
|
||||||
|
|
||||||
|
// Suspended workflow
|
||||||
|
let mut w3 = WorkflowInstance::new("wf", 1, serde_json::json!({}));
|
||||||
|
w3.next_execution = Some(0);
|
||||||
|
w3.status = WorkflowStatus::Suspended;
|
||||||
|
let id3 = provider.create_new_workflow(&w3).await.unwrap();
|
||||||
|
// Need to persist updated status
|
||||||
|
w3.id = id3;
|
||||||
|
provider.persist_workflow(&w3).await.unwrap();
|
||||||
|
|
||||||
|
let runnable = provider.get_runnable_instances(Utc::now()).await.unwrap();
|
||||||
|
assert!(runnable.contains(&id1));
|
||||||
|
assert_eq!(runnable.len(), 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn persist_errors_stores_errors() {
|
||||||
|
let provider = ($factory)().await;
|
||||||
|
let errors = vec![
|
||||||
|
ExecutionError::new("wf-1", "ptr-1", "error one"),
|
||||||
|
ExecutionError::new("wf-1", "ptr-2", "error two"),
|
||||||
|
];
|
||||||
|
provider.persist_errors(&errors).await.unwrap();
|
||||||
|
|
||||||
|
// Persist more errors
|
||||||
|
let more = vec![ExecutionError::new("wf-2", "ptr-3", "error three")];
|
||||||
|
provider.persist_errors(&more).await.unwrap();
|
||||||
|
|
||||||
|
// We can't directly read errors from the trait, but persist_errors should not fail
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn create_and_get_subscription() {
|
||||||
|
let provider = ($factory)().await;
|
||||||
|
let sub = EventSubscription::new(
|
||||||
|
"wf-1",
|
||||||
|
0,
|
||||||
|
"ptr-1",
|
||||||
|
"order.created",
|
||||||
|
"order-123",
|
||||||
|
Utc::now(),
|
||||||
|
);
|
||||||
|
let id = provider.create_event_subscription(&sub).await.unwrap();
|
||||||
|
let retrieved = provider.get_subscription(&id).await.unwrap();
|
||||||
|
assert_eq!(retrieved.id, id);
|
||||||
|
assert_eq!(retrieved.event_name, "order.created");
|
||||||
|
assert_eq!(retrieved.event_key, "order-123");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn get_subscriptions_by_event() {
|
||||||
|
let provider = ($factory)().await;
|
||||||
|
let now = Utc::now();
|
||||||
|
|
||||||
|
let sub1 = EventSubscription::new(
|
||||||
|
"wf-1", 0, "ptr-1", "order.created", "key-A", now,
|
||||||
|
);
|
||||||
|
provider.create_event_subscription(&sub1).await.unwrap();
|
||||||
|
|
||||||
|
let sub2 = EventSubscription::new(
|
||||||
|
"wf-2", 1, "ptr-2", "order.created", "key-A", now,
|
||||||
|
);
|
||||||
|
provider.create_event_subscription(&sub2).await.unwrap();
|
||||||
|
|
||||||
|
let sub3 = EventSubscription::new(
|
||||||
|
"wf-3", 0, "ptr-3", "order.created", "key-B", now,
|
||||||
|
);
|
||||||
|
provider.create_event_subscription(&sub3).await.unwrap();
|
||||||
|
|
||||||
|
let result = provider
|
||||||
|
.get_subscriptions("order.created", "key-A", now + Duration::seconds(1))
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(result.len(), 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn terminate_subscription() {
|
||||||
|
let provider = ($factory)().await;
|
||||||
|
let now = Utc::now();
|
||||||
|
let sub = EventSubscription::new(
|
||||||
|
"wf-1", 0, "ptr-1", "evt", "key", now,
|
||||||
|
);
|
||||||
|
let id = provider.create_event_subscription(&sub).await.unwrap();
|
||||||
|
|
||||||
|
provider.terminate_subscription(&id).await.unwrap();
|
||||||
|
|
||||||
|
// Terminated subscriptions should not appear in get_subscriptions
|
||||||
|
let subs = provider
|
||||||
|
.get_subscriptions("evt", "key", now + Duration::seconds(1))
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert!(subs.is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn create_and_get_event() {
|
||||||
|
let provider = ($factory)().await;
|
||||||
|
let event =
|
||||||
|
Event::new("order.created", "order-123", serde_json::json!({"x": 1}));
|
||||||
|
let id = provider.create_event(&event).await.unwrap();
|
||||||
|
let retrieved = provider.get_event(&id).await.unwrap();
|
||||||
|
assert_eq!(retrieved.id, id);
|
||||||
|
assert_eq!(retrieved.event_name, "order.created");
|
||||||
|
assert_eq!(retrieved.event_data, serde_json::json!({"x": 1}));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn get_runnable_events() {
|
||||||
|
let provider = ($factory)().await;
|
||||||
|
let event = Event::new("evt", "key", serde_json::json!(null));
|
||||||
|
let id = provider.create_event(&event).await.unwrap();
|
||||||
|
|
||||||
|
let runnable = provider
|
||||||
|
.get_runnable_events(Utc::now() + Duration::seconds(1))
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert!(runnable.contains(&id));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn mark_event_processed() {
|
||||||
|
let provider = ($factory)().await;
|
||||||
|
let event = Event::new("evt", "key", serde_json::json!(null));
|
||||||
|
let id = provider.create_event(&event).await.unwrap();
|
||||||
|
|
||||||
|
provider.mark_event_processed(&id).await.unwrap();
|
||||||
|
|
||||||
|
let runnable = provider
|
||||||
|
.get_runnable_events(Utc::now() + Duration::seconds(1))
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert!(!runnable.contains(&id));
|
||||||
|
|
||||||
|
let retrieved = provider.get_event(&id).await.unwrap();
|
||||||
|
assert!(retrieved.is_processed);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn concurrent_persist_workflow_no_data_race() {
|
||||||
|
let provider = std::sync::Arc::new(($factory)().await);
|
||||||
|
let mut handles = Vec::new();
|
||||||
|
|
||||||
|
for i in 0..30 {
|
||||||
|
let p = provider.clone();
|
||||||
|
handles.push(tokio::spawn(async move {
|
||||||
|
let instance = WorkflowInstance::new(
|
||||||
|
format!("wf-{i}"),
|
||||||
|
1,
|
||||||
|
serde_json::json!({"i": i}),
|
||||||
|
);
|
||||||
|
p.create_new_workflow(&instance).await.unwrap()
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut ids = Vec::new();
|
||||||
|
for handle in handles {
|
||||||
|
ids.push(handle.await.unwrap());
|
||||||
|
}
|
||||||
|
|
||||||
|
// All 30 should be unique
|
||||||
|
let unique: std::collections::HashSet<_> = ids.iter().collect();
|
||||||
|
assert_eq!(unique.len(), 30);
|
||||||
|
|
||||||
|
// All should be retrievable
|
||||||
|
for id in &ids {
|
||||||
|
let w = provider.get_workflow_instance(id).await.unwrap();
|
||||||
|
assert_eq!(w.id, *id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
110
wfe-core/src/test_support/queue_suite.rs
Normal file
110
wfe-core/src/test_support/queue_suite.rs
Normal file
@@ -0,0 +1,110 @@
|
|||||||
|
/// Generates a test suite for any `QueueProvider` implementation.
|
||||||
|
///
|
||||||
|
/// The macro takes a factory expression that returns an `impl QueueProvider`.
|
||||||
|
///
|
||||||
|
/// # Example
|
||||||
|
/// ```ignore
|
||||||
|
/// queue_suite!(|| async { InMemoryQueueProvider::new() });
|
||||||
|
/// ```
|
||||||
|
#[macro_export]
|
||||||
|
macro_rules! queue_suite {
|
||||||
|
($factory:expr) => {
|
||||||
|
mod queue_suite {
|
||||||
|
use super::*;
|
||||||
|
use $crate::models::QueueType;
|
||||||
|
use $crate::traits::QueueProvider;
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn enqueue_dequeue_fifo() {
|
||||||
|
let provider = ($factory)().await;
|
||||||
|
provider
|
||||||
|
.queue_work("a", QueueType::Workflow)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
provider
|
||||||
|
.queue_work("b", QueueType::Workflow)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
provider
|
||||||
|
.queue_work("c", QueueType::Workflow)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
provider
|
||||||
|
.dequeue_work(QueueType::Workflow)
|
||||||
|
.await
|
||||||
|
.unwrap()
|
||||||
|
.as_deref(),
|
||||||
|
Some("a")
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
provider
|
||||||
|
.dequeue_work(QueueType::Workflow)
|
||||||
|
.await
|
||||||
|
.unwrap()
|
||||||
|
.as_deref(),
|
||||||
|
Some("b")
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
provider
|
||||||
|
.dequeue_work(QueueType::Workflow)
|
||||||
|
.await
|
||||||
|
.unwrap()
|
||||||
|
.as_deref(),
|
||||||
|
Some("c")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn dequeue_empty_returns_none() {
|
||||||
|
let provider = ($factory)().await;
|
||||||
|
let result = provider.dequeue_work(QueueType::Workflow).await.unwrap();
|
||||||
|
assert!(result.is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn multiple_queue_types_independent() {
|
||||||
|
let provider = ($factory)().await;
|
||||||
|
provider
|
||||||
|
.queue_work("wf-1", QueueType::Workflow)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
provider
|
||||||
|
.queue_work("evt-1", QueueType::Event)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
// Dequeue from Event queue should get evt-1, not wf-1
|
||||||
|
assert_eq!(
|
||||||
|
provider
|
||||||
|
.dequeue_work(QueueType::Event)
|
||||||
|
.await
|
||||||
|
.unwrap()
|
||||||
|
.as_deref(),
|
||||||
|
Some("evt-1")
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
provider
|
||||||
|
.dequeue_work(QueueType::Workflow)
|
||||||
|
.await
|
||||||
|
.unwrap()
|
||||||
|
.as_deref(),
|
||||||
|
Some("wf-1")
|
||||||
|
);
|
||||||
|
|
||||||
|
// Both should now be empty
|
||||||
|
assert!(provider
|
||||||
|
.dequeue_work(QueueType::Event)
|
||||||
|
.await
|
||||||
|
.unwrap()
|
||||||
|
.is_none());
|
||||||
|
assert!(provider
|
||||||
|
.dequeue_work(QueueType::Workflow)
|
||||||
|
.await
|
||||||
|
.unwrap()
|
||||||
|
.is_none());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user